@marver-design/marver 0.3.1 → 0.4.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.
@@ -0,0 +1,463 @@
1
+ /**
2
+ * Comment surfaces on the canvas (SPEC-M3 §6). Canvas-first: pins live ON the frame
3
+ * at their anchored elements; an inactive frame collapses its open threads into a
4
+ * top-right stack; the thread card opens beside its pin. Pins keep a screen-space
5
+ * size via --sh-inv (the vbadge pattern) so zoom never shrinks them away.
6
+ */
7
+ import { useEffect, useRef, useState } from 'react'
8
+ import { avatarFallback, useComments } from './comments-store.ts'
9
+ import { useStore, type Node } from './store.ts'
10
+ import { canvasCtl } from './canvas/Canvas.tsx'
11
+ import { bootHash, buildHash, parseHash, writeHash } from './hash.ts'
12
+ import { ArrowUpIcon, CheckIcon, CheckSquareOffsetIcon, LinkIcon, XIcon } from './icons.tsx'
13
+ import { Tip } from './Tip.tsx'
14
+ import { ROUTE } from '../const.ts'
15
+ import type { Thread } from '../../shared/events.ts'
16
+
17
+ const rel = (ts: number) => {
18
+ const m = Math.round((Date.now() - ts) / 60_000)
19
+ if (m < 1) return 'now'
20
+ if (m < 60) return `${m}m`
21
+ if (m < 24 * 60) return `${Math.round(m / 60)}h`
22
+ return `${Math.round(m / 1440)}d`
23
+ }
24
+
25
+ export function Avatar({ author, size = 22 }: { author?: { email?: string; name?: string; avatar?: string }; size?: number }) {
26
+ if (author?.avatar) return <img className="cm-avatar" src={author.avatar} width={size} height={size} alt="" />
27
+ const { initials, hue } = avatarFallback(author)
28
+ return (
29
+ <span className="cm-avatar" style={{ width: size, height: size, fontSize: size * 0.42, background: `hsl(${hue} 55% 45%)` }}>
30
+ {initials}
31
+ </span>
32
+ )
33
+ }
34
+
35
+ type Face = { email?: string; name?: string; avatar?: string }
36
+
37
+ /** The signal a marker (pin or stack) carries: WHO took part and HOW MANY comments
38
+ * in total - root + every reply, across all the given threads. Unique participants
39
+ * in first-seen order; a single reply-less comment counts as 1. */
40
+ function markerDigest(threads: Thread[]): { faces: Face[]; count: number } {
41
+ const seen = new Map<string, Face>()
42
+ let count = 0
43
+ // namespace the identity key so an email can never collide with someone's name,
44
+ // and normalize email case; authorless comments collapse to one 'anon' face
45
+ const add = (a?: Face) => { if (a) { const k = a.email ? `e:${a.email.toLowerCase()}` : a.name ? `n:${a.name}` : 'anon'; if (!seen.has(k)) seen.set(k, a) } }
46
+ for (const t of threads) {
47
+ count += 1 + t.replies.length
48
+ add(t.author)
49
+ for (const r of t.replies) add(r.author)
50
+ }
51
+ return { faces: [...seen.values()], count }
52
+ }
53
+
54
+ /** The face of a marker: up to `max` overlapping avatars + the total count when >1.
55
+ * One shape for pins (one thread, anchored) and stacks (all threads on a frame). */
56
+ function MarkerFace({ threads, max = 3 }: { threads: Thread[]; max?: number }) {
57
+ const { faces, count } = markerDigest(threads)
58
+ return (
59
+ <>
60
+ {faces.slice(0, max).map((a, i) => <Avatar key={i} author={a} size={24} />)}
61
+ {count > 1 && <b>{count}</b>}
62
+ </>
63
+ )
64
+ }
65
+
66
+ /** Lives inside FrameNode's body, over the iframe. Owns anchor resolution for its frame. */
67
+ export function CommentLayer({ node, frameId, iframe }: { node: Node; frameId: string; iframe: React.RefObject<HTMLIFrameElement | null> }) {
68
+ const show = useComments((s) => s.show)
69
+ const active = useComments((s) => s.active)
70
+ const draft = useComments((s) => s.draft)
71
+ // select the stable array, filter in render - a selector returning a fresh
72
+ // .filter() array re-renders forever (Object.is never matches)
73
+ const allThreads = useComments((s) => s.threads)
74
+ const threads = allThreads.filter((t) => t.frame === frameId && (!t.nodeKey || t.nodeKey === node.key))
75
+ const selected = useStore((s) => s.selection.includes(node.key))
76
+ const { setActive } = useComments.getState()
77
+ const [rects, setRects] = useState<Record<string, { x: number; y: number; w: number; h: number } | null>>({})
78
+
79
+ const open = threads.filter((t) => !t.resolved)
80
+ const anchored = open.filter((t) => (t.anchor as any)?.el)
81
+
82
+ // resolve anchors against the live frame whenever threads or the document change
83
+ useEffect(() => {
84
+ const win = iframe.current?.contentWindow
85
+ if (!win || node.status !== 'ready' || !anchored.length) return
86
+ const ask = () => win.postMessage({ type: 'sh:resolve-anchors', anchors: anchored.map((t) => ({ key: t.id, anchor: t.anchor })) }, location.origin)
87
+ const onMsg = (e: MessageEvent) => {
88
+ if (e.source !== win || e.data?.type !== 'sh:anchor-rects') return
89
+ const next: typeof rects = {}
90
+ for (const r of e.data.rects ?? []) next[r.key] = r.orphan ? null : r.rect
91
+ setRects(next)
92
+ }
93
+ window.addEventListener('message', onMsg)
94
+ ask()
95
+ const iv = setInterval(ask, 4000) // re-renders, scroll, hot reloads - cheap to re-ask
96
+ return () => { clearInterval(iv); window.removeEventListener('message', onMsg) }
97
+ }, [node.status, anchored.map((t) => t.id).join(','), iframe])
98
+
99
+ const drafting = draft?.nodeKey === node.key
100
+ if (!show || (!open.length && !drafting)) return null
101
+
102
+ // inactive frame: the stack - count + avatars, top-right (SPEC-M3 §6)
103
+ const engaged = selected || open.some((t) => t.id === active) || drafting
104
+ if (!engaged && open.length) {
105
+ return (
106
+ <button className="cm-stack sh-no-pan" onClick={(e) => { e.stopPropagation(); useStore.getState().select(node.key); if (open[0]) setActive(open[0].id) }}>
107
+ <MarkerFace threads={open} />
108
+ </button>
109
+ )
110
+ }
111
+
112
+ const pinPos = (t: Thread) => {
113
+ const a = t.anchor as any
114
+ const r = rects[t.id]
115
+ if (a?.el && r) return { x: r.x + (a.pos?.fx ?? 0.5) * r.w, y: r.y + (a.pos?.fy ?? 0.5) * r.h, orphan: false }
116
+ if (a?.el && r === null) return { x: node.w - 16, y: 16, orphan: true } // orphan parks top-right
117
+ const p = a?.pos // frame-level: stored fraction of the frame
118
+ return { x: (p?.fx ?? 0.94) * node.w, y: (p?.fy ?? 0.06) * node.h, orphan: false }
119
+ }
120
+
121
+ return (
122
+ <>
123
+ {open.map((t) => {
124
+ const { x, y, orphan } = pinPos(t)
125
+ return (
126
+ <div key={t.id} className={`cm-pin sh-no-pan${t.id === active ? ' on' : ''}${orphan ? ' orphan' : ''}`}
127
+ style={{ left: x, top: y }}
128
+ onPointerDown={(e) => e.stopPropagation()}
129
+ onClick={(e) => { e.stopPropagation(); setActive(t.id === active ? null : t.id) }}
130
+ title={orphan ? 'the anchored element is gone - comment parked' : undefined}>
131
+ <MarkerFace threads={[t]} />
132
+ </div>
133
+ )
134
+ })}
135
+ {active && open.some((t) => t.id === active) && (
136
+ <ThreadCard key={active} thread={open.find((t) => t.id === active)!} at={pinPos(open.find((t) => t.id === active)!)} node={node} />
137
+ )}
138
+ {draft?.nodeKey === node.key && <DraftComposer at={{ x: ((draft.anchor as any)?.rect?.x ?? 0) + ((draft.anchor as any)?.pos?.fx ?? 0.5) * ((draft.anchor as any)?.rect?.w ?? 0), y: ((draft.anchor as any)?.rect?.y ?? 0) + ((draft.anchor as any)?.pos?.fy ?? 0.5) * ((draft.anchor as any)?.rect?.h ?? 0) }} node={node} />}
139
+ </>
140
+ )
141
+ }
142
+
143
+ function ThreadCard({ thread, at, node }: { thread: Thread; at: { x: number; y: number }; node: Node }) {
144
+ const { resolve, setActive } = useComments.getState()
145
+ const me = useComments((s) => s.me)
146
+ const local = useComments((s) => s.local)
147
+ const canComment = local || !!me // dev is always "me"; published needs a session
148
+ const [text, setText] = useState('')
149
+ const [copied, setCopied] = useState(false)
150
+ const copyTimer = useRef<ReturnType<typeof setTimeout>>(undefined)
151
+ useEffect(() => () => clearTimeout(copyTimer.current), [])
152
+ const flip = at.x > node.w * 0.55
153
+ // clear only after the server took it - a failed send must not eat the words,
154
+ // and text typed WHILE the request was in flight must survive the clear
155
+ const submit = async () => {
156
+ const sent = text
157
+ if (sent.trim() && await useComments.getState().replyOk(thread.id, sent)) setText((cur) => (cur === sent ? '' : cur))
158
+ }
159
+ return (
160
+ <div className={`cm-card sh-no-pan${flip ? ' flip' : ''}`} style={{ left: at.x, top: Math.min(at.y + 14, node.h - 40) }}
161
+ onPointerDown={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()} onWheel={(e) => e.stopPropagation()}>
162
+ {/* thread-level actions pin to the card corner, out of the header's flow -
163
+ the name row never has to share its line with them */}
164
+ <div className="cm-actions">
165
+ <Tip side="bottom" label={<b>{copied ? 'Copied' : 'Copy link'}</b>}>
166
+ <button className={`cm-icon cm-copy${copied ? ' ok' : ''}`} onClick={() => {
167
+ const url = `${location.origin}${location.pathname}${buildHash({ board: useStore.getState().board, c: thread.id })}`
168
+ // the check means "it's on your clipboard" - only show it when that's true
169
+ navigator.clipboard.writeText(url).then(() => {
170
+ setCopied(true)
171
+ clearTimeout(copyTimer.current)
172
+ copyTimer.current = setTimeout(() => setCopied(false), 1600)
173
+ }, () => useStore.getState().toast('copy blocked - try again'))
174
+ }}>
175
+ <span className="a"><LinkIcon size={15} /></span>
176
+ <span className="b"><CheckIcon size={16} /></span>
177
+ </button>
178
+ </Tip>
179
+ <Tip side="bottom" label={<b>Resolve</b>}>
180
+ <button className="cm-icon" onClick={() => { void resolve(thread.id); setActive(null) }}><CheckSquareOffsetIcon size={16} /></button>
181
+ </Tip>
182
+ <Tip side="bottom" label={<b>Close</b>}>
183
+ <button className="cm-icon" onClick={() => setActive(null)}><XIcon size={15} /></button>
184
+ </Tip>
185
+ </div>
186
+ <header>
187
+ <Avatar author={thread.author} size={24} />
188
+ <b>{thread.author?.name ?? 'Someone'}</b>
189
+ <span className="dim">{rel(thread.ts)}</span>
190
+ </header>
191
+ <p className="cm-body">{thread.body}</p>
192
+ {/* replies repeat the root's exact message shape (Figma's pattern) - only the icons differ */}
193
+ {thread.replies.map((r) => (
194
+ <div key={r.id} className="cm-msg">
195
+ <header>
196
+ <Avatar author={r.author} size={24} />
197
+ <b>{r.author?.name ?? 'Someone'}</b>
198
+ <span className="dim">{rel(r.ts)}</span>
199
+ </header>
200
+ <p className="cm-body">{r.body}</p>
201
+ </div>
202
+ ))}
203
+ {canComment ? (
204
+ <div className="cm-compose">
205
+ <Avatar author={me ?? undefined} size={24} />
206
+ <div className="cm-inputwrap">
207
+ <input value={text} placeholder="Reply…" onChange={(e) => setText(e.target.value)}
208
+ onKeyDown={(e) => { e.stopPropagation(); if (e.key === 'Enter') void submit(); if (e.key === 'Escape') setActive(null) }} />
209
+ <button className="cm-send" disabled={!text.trim()} aria-label="Send" onClick={() => void submit()}>
210
+ <ArrowUpIcon size={15} />
211
+ </button>
212
+ </div>
213
+ </div>
214
+ ) : (
215
+ <button className="cm-signin-cta" onClick={() => useComments.setState({ needsIdentity: true })}>
216
+ Sign in to comment
217
+ </button>
218
+ )}
219
+ </div>
220
+ )
221
+ }
222
+
223
+ function DraftComposer({ at, node }: { at: { x: number; y: number }; node: Node }) {
224
+ const { create, setDraft } = useComments.getState()
225
+ const [text, setText] = useState('')
226
+ const ref = useRef<HTMLInputElement>(null)
227
+ useEffect(() => { ref.current?.focus() }, [])
228
+ const flip = at.x > node.w * 0.55
229
+ return (
230
+ <div className={`cm-card cm-draft sh-no-pan${flip ? ' flip' : ''}`} style={{ left: at.x, top: Math.min(at.y + 14, node.h - 40) }}
231
+ onPointerDown={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}>
232
+ <div className="cm-compose">
233
+ <div className="cm-inputwrap">
234
+ <input ref={ref} value={text} placeholder="Comment on this element…" onChange={(e) => setText(e.target.value)}
235
+ onKeyDown={(e) => {
236
+ e.stopPropagation()
237
+ if (e.key === 'Enter') void create(text)
238
+ if (e.key === 'Escape') setDraft(null)
239
+ }} />
240
+ <button className="cm-send" disabled={!text.trim()} aria-label="Comment" onClick={() => void create(text)}>
241
+ <ArrowUpIcon size={15} />
242
+ </button>
243
+ </div>
244
+ </div>
245
+ </div>
246
+ )
247
+ }
248
+
249
+ /** Avatar picker: dashed circle → file input → client-side 128px downscale →
250
+ * data-URI preview. Optional everywhere it appears; never gates a CTA. */
251
+ function AvatarPick({ value, onPick }: { value: string; onPick: (dataUri: string) => void }) {
252
+ const fileRef = useRef<HTMLInputElement>(null)
253
+ const pick = (file: File | undefined) => {
254
+ if (!file) return
255
+ const img = new Image()
256
+ img.onload = () => {
257
+ const s = Math.min(img.width, img.height)
258
+ const c = document.createElement('canvas')
259
+ c.width = c.height = 128
260
+ c.getContext('2d')!.drawImage(img, (img.width - s) / 2, (img.height - s) / 2, s, s, 0, 0, 128, 128)
261
+ onPick(c.toDataURL('image/jpeg', 0.85))
262
+ URL.revokeObjectURL(img.src)
263
+ }
264
+ img.src = URL.createObjectURL(file)
265
+ }
266
+ return (
267
+ <>
268
+ <Tip side="top" label={<b>{value ? 'Change your photo' : 'Select your profile picture'}</b>}>
269
+ <button className={`cm-pfp${value ? ' set' : ''}`} aria-label="Select your profile picture"
270
+ style={value ? { backgroundImage: `url(${value})` } : undefined}
271
+ onClick={() => fileRef.current?.click()}>{value ? '' : '+'}</button>
272
+ </Tip>
273
+ <input ref={fileRef} type="file" accept="image/*" hidden onChange={(e) => pick(e.target.files?.[0])} />
274
+ </>
275
+ )
276
+ }
277
+
278
+ /** Signed-out published viewer tried to comment (or arrived on an invite link).
279
+ * Invite link: a focused claim - the token in hand, email shown as a chip.
280
+ * Fallback: sign in (returning device), with a quiet switch to pasting a token.
281
+ * Primary CTAs stay disabled until their mandatory fields are filled; hovering
282
+ * the disabled button says what's missing (avatar never gates). */
283
+ export function IdentityDialog() {
284
+ const needs = useComments((s) => s.needsIdentity)
285
+ const invite = useComments((s) => s.inviteToken)
286
+ const { signIn, claim, dismissIdentity } = useComments.getState()
287
+ const [mode, setMode] = useState<'signin' | 'claim'>('signin')
288
+ const [email, setEmail] = useState(''); const [password, setPassword] = useState('')
289
+ const [token, setToken] = useState(''); const [name, setName] = useState('')
290
+ const [avatar, setAvatar] = useState('')
291
+ const [invitedAs, setInvitedAs] = useState('')
292
+ const [err, setErr] = useState<string | null>(null)
293
+ // an invite link knows WHO it's for - show it (the token is the proof of holding)
294
+ useEffect(() => {
295
+ if (!needs || !invite) return setInvitedAs('')
296
+ fetch(`${ROUTE}/api/invite-info?token=${encodeURIComponent(invite)}`)
297
+ .then((r) => (r.ok ? r.json() : null))
298
+ .then((d) => d?.email && setInvitedAs(d.email))
299
+ .catch(() => { /* chip just stays hidden */ })
300
+ }, [needs, invite])
301
+ if (!needs) return null
302
+ const claiming = !!invite || mode === 'claim'
303
+ const ready = claiming
304
+ ? !!(password.trim() && name.trim() && (invite || token.trim()))
305
+ : !!(email.trim() && password.trim())
306
+ const missing = claiming
307
+ ? (invite ? 'Password and display name still needed' : 'Token, password, and display name still needed')
308
+ : 'Fill in email and password'
309
+ const go = async () => {
310
+ if (!ready) return
311
+ setErr(null)
312
+ const e = claiming ? await claim(invite ?? token, password, name, avatar || undefined) : await signIn(email, password)
313
+ if (e) return setErr(e)
314
+ // a consumed invite link leaves the URL - the hash becomes the plain board view
315
+ if (invite) writeHash({ board: useStore.getState().board })
316
+ }
317
+ const onEnter = (e: React.KeyboardEvent) => e.key === 'Enter' && void go()
318
+ return (
319
+ <div className="cm-modal-wrap" onClick={dismissIdentity}>
320
+ <div className="cm-modal" onClick={(e) => e.stopPropagation()}>
321
+ <h2>{invite ? 'You’re invited to comment' : claiming ? 'Sign up to comment' : 'Sign in to comment'}</h2>
322
+ <p className="dim">
323
+ {invite
324
+ ? <>Pick how you’ll appear - comments carry your name.</>
325
+ : claiming
326
+ ? <>Create your account with the invite token your admin sent you.</>
327
+ : <>You’re in read-only. Sign in to your account to comment on this canvas.</>}
328
+ </p>
329
+ <div className="cm-fields">
330
+ {claiming ? (
331
+ <>
332
+ {invite
333
+ ? invitedAs && <div className="cm-chip"><b>{invitedAs}</b><span>INVITED</span></div>
334
+ : <input placeholder="Invite token" value={token} onChange={(e) => setToken(e.target.value)} />}
335
+ <input placeholder="Choose a password" type="password" autoComplete="new-password"
336
+ value={password} onChange={(e) => setPassword(e.target.value)} />
337
+ <hr className="cm-div" />
338
+ <div className="cm-idrow">
339
+ <AvatarPick value={avatar} onPick={setAvatar} />
340
+ <input placeholder="Set a display name" style={{ flex: 1 }}
341
+ value={name} onChange={(e) => setName(e.target.value)} onKeyDown={onEnter} />
342
+ </div>
343
+ </>
344
+ ) : (
345
+ <>
346
+ <input placeholder="Email" type="email" autoComplete="email"
347
+ value={email} onChange={(e) => setEmail(e.target.value)} />
348
+ <input placeholder="Password" type="password" autoComplete="current-password"
349
+ value={password} onChange={(e) => setPassword(e.target.value)} onKeyDown={onEnter} />
350
+ </>
351
+ )}
352
+ </div>
353
+ {err && <span className="cm-err">{err}</span>}
354
+ <div className="cm-row">
355
+ {ready ? (
356
+ <button className="cm-primary" onClick={() => void go()}>
357
+ {claiming ? (invite ? 'Join the canvas' : 'Create account') : 'Sign in'}
358
+ </button>
359
+ ) : (
360
+ <Tip side="top" label={<b>{missing}</b>}>
361
+ <button className="cm-primary" disabled>
362
+ {claiming ? (invite ? 'Join the canvas' : 'Create account') : 'Sign in'}
363
+ </button>
364
+ </Tip>
365
+ )}
366
+ <button onClick={dismissIdentity}>Not now</button>
367
+ </div>
368
+ {!invite && (
369
+ <button className="cm-switch" onClick={() => { setErr(null); setMode(mode === 'signin' ? 'claim' : 'signin') }}>
370
+ {mode === 'signin' ? 'Have an invite token instead?' : 'Already have an account? Sign in'}
371
+ </button>
372
+ )}
373
+ </div>
374
+ </div>
375
+ )
376
+ }
377
+
378
+ /** Open a thread by id: activate, select its node, fit the camera. True on success. */
379
+ function revealThread(c: string): boolean {
380
+ const t = useComments.getState().threads.find((t) => t.id === c)
381
+ if (!t) return false
382
+ useComments.getState().setActive(c)
383
+ const node = t.nodeKey && useStore.getState().nodes.find((n) => n.key === t.nodeKey)
384
+ if (node) {
385
+ useStore.getState().select(node.key)
386
+ setTimeout(() => canvasCtl.fitNode(node.key), 80)
387
+ }
388
+ return true
389
+ }
390
+
391
+ /** Board-level comment wiring: load + liveness + mode broadcast, one instance in App. */
392
+ let bootThreadConsumed = false
393
+ let pendingThread: string | null = null // cross-board deep link awaiting its board's comments
394
+ export function CommentsController() {
395
+ const board = useStore((s) => s.board)
396
+ const commentMode = useComments((s) => s.commentMode)
397
+ const active = useComments((s) => s.active)
398
+
399
+ // modal-style dismiss: any press outside the card (and outside the surfaces that
400
+ // legitimately manage it - pins, stacks, the identity dialog) closes the thread
401
+ useEffect(() => {
402
+ if (!active) return
403
+ const onDown = (e: PointerEvent) => {
404
+ const t = e.target instanceof Element ? e.target : null
405
+ if (t?.closest('.cm-card, .cm-pin, .cm-stack, .cm-modal')) return
406
+ useComments.getState().setActive(null)
407
+ }
408
+ document.addEventListener('pointerdown', onDown, true)
409
+ return () => document.removeEventListener('pointerdown', onDown, true)
410
+ }, [active])
411
+
412
+ useEffect(() => {
413
+ const { load, live } = useComments.getState()
414
+ void load(board).then(() => {
415
+ // invite link (#/i/<token>): open the claim dialog with the token in hand -
416
+ // unless this browser is already someone (their invite is not for this session)
417
+ const s = useComments.getState()
418
+ if (bootHash.invite && !s.local && !s.me && !s.inviteToken)
419
+ useComments.setState({ inviteToken: bootHash.invite, needsIdentity: true })
420
+ // a cross-board deep link parks its thread id here until the target board's
421
+ // comments have actually loaded - a timer can't know how long that takes
422
+ if (pendingThread && revealThread(pendingThread)) { pendingThread = null; return }
423
+ // deep link ?c=<thread> (SPEC-M3 §6): open the thread, select its node, fit it.
424
+ // Consumed only on a successful find: the controller's first effect fires with
425
+ // the pre-boot board (child effects run before the parent's boot effect), and
426
+ // burning the flag on that empty pass would eat the link.
427
+ if (bootThreadConsumed || !bootHash.c) return
428
+ if (revealThread(bootHash.c)) bootThreadConsumed = true
429
+ })
430
+ return live(board)
431
+ }, [board])
432
+
433
+ // a comment link pasted into an ALREADY-OPEN canvas is a hash-only navigation -
434
+ // no reload, no boot path. Same board: the threads are in memory, reveal now.
435
+ // Different board: park the id; the load effect above consumes it once the
436
+ // board switch has fetched that board's comments.
437
+ useEffect(() => {
438
+ const onHash = (e: HashChangeEvent) => {
439
+ // read the URL the EVENT carries, not location.hash: the shell's own
440
+ // hashchange handler resets selection, whose projection synchronously
441
+ // rewrites the hash - listener order decides which URL survives, and
442
+ // e.newURL is immune to that race
443
+ const h = parseHash(e.newURL ? new URL(e.newURL).hash : location.hash)
444
+ if (h.invite) {
445
+ const s = useComments.getState()
446
+ if (!s.local && !s.me) useComments.setState({ inviteToken: h.invite, needsIdentity: true })
447
+ return
448
+ }
449
+ if (!h.c) return
450
+ if (!revealThread(h.c)) pendingThread = h.c
451
+ }
452
+ window.addEventListener('hashchange', onHash)
453
+ return () => window.removeEventListener('hashchange', onHash)
454
+ }, [])
455
+
456
+ // broadcast pick mode to every frame (laser rides along inside the bridge)
457
+ useEffect(() => {
458
+ for (const f of document.querySelectorAll('iframe'))
459
+ (f as HTMLIFrameElement).contentWindow?.postMessage({ type: 'sh:pick', on: commentMode }, location.origin)
460
+ }, [commentMode])
461
+
462
+ return <IdentityDialog />
463
+ }
@@ -1,6 +1,8 @@
1
1
  import { memo, useEffect, useRef } from 'react'
2
2
  import { cap, frameUrl, useStore, CONFIG, type Node } from '../store.ts'
3
3
  import { CopyIcon, IntentGlyph, ReloadIcon, XIcon } from '../icons.tsx'
4
+ import { CommentLayer } from '../Comments.tsx'
5
+ import { useComments } from '../comments-store.ts'
4
6
 
5
7
  export const HEADER = 28
6
8
  const SNAP = 12
@@ -36,6 +38,25 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
36
38
  }
37
39
  }, [node.theme])
38
40
 
41
+ // laser mode (SPEC-M3 §7) rides the same rail; re-sent when a frame becomes ready
42
+ // so late loaders join an already-lasered board
43
+ const laser = useStore((s) => s.laser)
44
+ const commentMode = useComments((s) => s.commentMode)
45
+ // a node hosting the OPEN thread card (or a draft composer) rises above its
46
+ // neighbors - each node is a stacking context, so an overflowing card would
47
+ // otherwise paint under the next frame
48
+ const hostsCard = useComments((s) =>
49
+ (!!s.active && s.threads.some((t) => t.id === s.active && t.nodeKey === node.key)) || s.draft?.nodeKey === node.key)
50
+ useEffect(() => {
51
+ if (node.status === 'ready' || !laser)
52
+ iframeRef.current?.contentWindow?.postMessage({ type: 'sh:laser', on: laser }, location.origin)
53
+ }, [laser, node.status])
54
+ // comment mode = pick mode in the frame (late loaders join like laser does)
55
+ useEffect(() => {
56
+ if (node.status === 'ready' || !commentMode)
57
+ iframeRef.current?.contentWindow?.postMessage({ type: 'sh:pick', on: commentMode }, location.origin)
58
+ }, [commentMode, node.status])
59
+
39
60
  // a frame whose FILE actually changed (e.g. tsx -> html swap, same id) must renavigate
40
61
  useEffect(() => {
41
62
  if (!frame) return
@@ -157,7 +178,7 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
157
178
  <div
158
179
  className={`sh-node${selected ? ' sel' : ''}${interact ? ' interact' : ''}`}
159
180
  data-theme={node.theme}
160
- style={{ transform: `translate(${node.x}px, ${node.y}px)`, width: node.w, height: node.h + HEADER }}
181
+ style={{ transform: `translate(${node.x}px, ${node.y}px)`, width: node.w, height: node.h + HEADER, zIndex: hostsCard ? 30 : undefined }}
161
182
  data-node={node.key}
162
183
  >
163
184
  {frame.variantGroup && (
@@ -197,7 +218,10 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
197
218
  title={frame.id}
198
219
  style={{ width: node.w, height: node.h, display: node.missing || node.status === 'error' ? 'none' : 'block' }}
199
220
  />
200
- {!interact && (
221
+ {/* the overlay eats mouse events for drag-by-body; laser and comment mode both
222
+ need the mouse INSIDE the frame for hover highlights, so it steps aside
223
+ (drag still works via the header) */}
224
+ {!interact && !commentMode && !laser && (
201
225
  <div
202
226
  className="sh-overlay sh-no-pan"
203
227
  onPointerDown={(e) => drag(e, 'move')}
@@ -206,6 +230,12 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
206
230
  )}
207
231
  </div>
208
232
 
233
+ {/* comments live OUTSIDE the clipped body: a card or pin near the frame edge
234
+ hangs over it (the vbadge precedent) instead of being cut off */}
235
+ <div className="cm-layer" style={{ top: HEADER, height: node.h }}>
236
+ <CommentLayer node={node} frameId={frame.id} iframe={iframeRef} />
237
+ </div>
238
+
209
239
  {selected && (
210
240
  <>
211
241
  <div className="sh-handle e sh-no-pan" onPointerDown={(e) => drag(e, 'e')} />