@marver-design/marver 0.7.0 → 0.8.1

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 (54) hide show
  1. package/CHANGELOG.md +141 -3
  2. package/README.md +72 -16
  3. package/dist/{auth-B36fMCM3.mjs → auth-KQ9Aj-nB.mjs} +1 -1
  4. package/dist/{build-BZaPa2DS.mjs → build-BBVQRetk.mjs} +5 -5
  5. package/dist/cli.mjs +16 -7
  6. package/dist/{collab-CXy8gqoz.mjs → collab-s3k5byM1.mjs} +18 -7
  7. package/dist/{comments-Ba8mU600.mjs → comments-BZBKhKRO.mjs} +4 -10
  8. package/dist/{comments-odHzYdO3.mjs → comments-J06jqCVV.mjs} +10 -16
  9. package/dist/daemon-DkyNOwIt.mjs +878 -0
  10. package/dist/{dev-DaPQ9xA5.mjs → dev-BTAhTie-.mjs} +28 -3
  11. package/dist/events-BMtBvvgU.mjs +101 -0
  12. package/dist/{init-DsCUmlCW.mjs → init-DWdhjJD5.mjs} +28 -3
  13. package/dist/ledger-CbzTJrV2.mjs +64 -0
  14. package/dist/{manifest-C8FODq2S.mjs → manifest-B4zcDGBf.mjs} +19 -4
  15. package/dist/{plugin-wMY9lNf3.mjs → plugin-BdQEeTLg.mjs} +89 -41
  16. package/dist/profile-BkiWglVE.mjs +39 -0
  17. package/dist/{serve-D_KBK7Oy.mjs → serve-CZqPnj19.mjs} +32 -8
  18. package/dist/{sync-CkBk-tUk.mjs → sync-BJKKmy1n.mjs} +5 -103
  19. package/dist/work-CLrmY-vQ.mjs +97 -0
  20. package/dist/work-lzC-lPY0.mjs +76 -0
  21. package/package.json +16 -1
  22. package/src/client/const.ts +1 -1
  23. package/src/client/content/diagram.tsx +1 -1
  24. package/src/client/content/index.tsx +2 -2
  25. package/src/client/content/md.ts +1 -1
  26. package/src/client/content/palette.ts +2 -2
  27. package/src/client/frame-host/bridge.js +1 -1
  28. package/src/client/frame-host/inspect.js +16 -3
  29. package/src/client/frame-host/serialize.ts +2 -2
  30. package/src/client/shell/App.tsx +165 -26
  31. package/src/client/shell/Comments.tsx +434 -78
  32. package/src/client/shell/Play.tsx +12 -8
  33. package/src/client/shell/Toolbar.tsx +14 -2
  34. package/src/client/shell/canvas/Canvas.tsx +1 -1
  35. package/src/client/shell/canvas/FrameNode.tsx +47 -15
  36. package/src/client/shell/canvas/snapshots.ts +3 -3
  37. package/src/client/shell/comments-store.ts +147 -33
  38. package/src/client/shell/hash.ts +2 -2
  39. package/src/client/shell/icons.tsx +5 -1
  40. package/src/client/shell/keys.ts +39 -0
  41. package/src/client/shell/mentions.ts +18 -0
  42. package/src/client/shell/perf.ts +1 -1
  43. package/src/client/shell/store.ts +127 -39
  44. package/src/client/shell/styles.css +303 -30
  45. package/src/client/shell/tidy.ts +5 -5
  46. package/src/client/stage/main.tsx +2 -2
  47. package/src/shared/events.ts +23 -6
  48. package/src/shared/utm.ts +22 -0
  49. package/templates/AGENTS-embedded.md +49 -1
  50. package/templates/AGENTS-studio.md +49 -1
  51. package/templates/instructions/configure.md +15 -0
  52. package/templates/instructions/jam.md +85 -0
  53. package/templates/instructions/publish.md +1 -1
  54. package/dist/rolldown-runtime-D7D4PA-g.mjs +0 -13
@@ -1,18 +1,21 @@
1
1
  /**
2
- * Comment surfaces on the canvas (SPEC-M3 §6). Canvas-first: pins live ON the frame
2
+ * Comment surfaces on the canvas. Canvas-first: pins live ON the frame
3
3
  * at their anchored elements; an inactive frame collapses its open threads into a
4
4
  * top-right stack; the thread card opens beside its pin. Pins keep a screen-space
5
5
  * size via --sh-inv (the vbadge pattern) so zoom never shrinks them away.
6
6
  */
7
7
  import { useEffect, useRef, useState } from 'react'
8
+ import { createPortal } from 'react-dom'
8
9
  import { avatarFallback, useComments } from './comments-store.ts'
9
10
  import { useStore, type Node } from './store.ts'
11
+ import { threadHostKey } from './keys.ts'
10
12
  import { canvasCtl } from './canvas/Canvas.tsx'
11
13
  import { bootHash, buildHash, parseHash, writeHash } from './hash.ts'
12
- import { ArrowUpIcon, CheckIcon, CheckSquareOffsetIcon, LinkIcon, XIcon } from './icons.tsx'
14
+ import { ArrowUpIcon, CheckIcon, CheckSquareOffsetIcon, LinkIcon, ParallelogramFillIcon, PencilSimpleIcon, PlusIcon, XIcon } from './icons.tsx'
13
15
  import { Tip } from './Tip.tsx'
16
+ import { parseMentions } from './mentions.ts'
14
17
  import { ROUTE } from '../const.ts'
15
- import type { Thread } from '../../shared/events.ts'
18
+ import type { AgentMeta, Thread } from '../../shared/events.ts'
16
19
 
17
20
  /** Tint the pin / composer / thread card EDGES (border, outline, focus ring) in the anchored
18
21
  * element's own laser hue (captured at pick as anchor.el.hue) via --cm-line/--cm-line-ring.
@@ -43,9 +46,27 @@ const rel = (ts: number) => {
43
46
  return `${Math.round(m / 1440)}d`
44
47
  }
45
48
 
46
- export function Avatar({ author, size = 22 }: { author?: { email?: string; name?: string; avatar?: string }; size?: number }) {
47
- if (author?.avatar) return <img className="cm-avatar" src={author.avatar} width={size} height={size} alt="" />
48
- const { initials, hue } = avatarFallback(author)
49
+ /** The Marver mark avatar: accent-blue disc + the solid white parallelogram. One place, reused
50
+ * by the thread header, the pin/stack marker, and the notification pill. */
51
+ export function MarverAvatar({ size = 24 }: { size?: number }) {
52
+ // 0.5 keeps clear blue padding around the mark even in the marker, where a 2px white border
53
+ // shrinks the visible disc (a bigger mark looked nearly cut there).
54
+ return (
55
+ <span className="cm-avatar cm-marver" style={{ width: size, height: size }} aria-label="Marver">
56
+ <ParallelogramFillIcon size={Math.round(size * 0.5)} />
57
+ </span>
58
+ )
59
+ }
60
+
61
+ export function Avatar({ author, size = 22 }: { author?: { email?: string; name?: string; avatar?: string; agent?: boolean }; size?: number }) {
62
+ const me = useComments((s) => s.me)
63
+ const local = useComments((s) => s.local)
64
+ if (author?.agent) return <MarverAvatar size={size} /> // Marver shows as its own participant
65
+ // dev: an account-less author IS the person at this keyboard - render the CURRENT identity,
66
+ // not the snapshot the event happened to store (old "Designer" events become you, live)
67
+ const a = local && me && !author?.email ? { ...author, ...me } : author
68
+ if (a?.avatar) return <img className="cm-avatar" src={a.avatar} width={size} height={size} alt="" />
69
+ const { initials, hue } = avatarFallback(a)
49
70
  return (
50
71
  <span className="cm-avatar" style={{ width: size, height: size, fontSize: size * 0.42, background: `hsl(${hue} 55% 45%)` }}>
51
72
  {initials}
@@ -53,7 +74,7 @@ export function Avatar({ author, size = 22 }: { author?: { email?: string; name?
53
74
  )
54
75
  }
55
76
 
56
- type Face = { email?: string; name?: string; avatar?: string }
77
+ type Face = { email?: string; name?: string; avatar?: string; agent?: boolean }
57
78
 
58
79
  /** The signal a marker (pin or stack) carries: WHO took part and HOW MANY comments
59
80
  * in total - root + every reply, across all the given threads. Unique participants
@@ -61,13 +82,20 @@ type Face = { email?: string; name?: string; avatar?: string }
61
82
  function markerDigest(threads: Thread[]): { faces: Face[]; count: number } {
62
83
  const seen = new Map<string, Face>()
63
84
  let count = 0
64
- // namespace the identity key so an email can never collide with someone's name,
65
- // and normalize email case; authorless comments collapse to one 'anon' face
66
- 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) } }
85
+ // namespace the identity key so an email can never collide with someone's name, and normalize
86
+ // email case. An AGENT message is its own participant ("Marver"), NOT the owner who authored
87
+ // it - so the marker shows N + Marver. Every email-less human face collapses to ONE key: in
88
+ // dev those snapshots ("Designer", "You", nothing) are all the person at this keyboard, and
89
+ // Avatar renders them all as the live identity - two keys would show identical twin faces.
90
+ const add = (a: Face | undefined, agent?: boolean) => {
91
+ const face: Face | undefined = agent ? { agent: true } : a
92
+ const k = agent ? 'marver' : a?.email ? `e:${a.email.toLowerCase()}` : 'local'
93
+ if (face && !seen.has(k)) seen.set(k, face)
94
+ }
67
95
  for (const t of threads) {
68
96
  count += 1 + t.replies.length
69
- add(t.author)
70
- for (const r of t.replies) add(r.author)
97
+ add(t.author, t.agent)
98
+ for (const r of t.replies) add(r.author, r.agent)
71
99
  }
72
100
  return { faces: [...seen.values()], count }
73
101
  }
@@ -87,12 +115,17 @@ export function MarkerFace({ threads, max = 3 }: { threads: Thread[]; max?: numb
87
115
  /** Lives inside FrameNode's body, over the iframe. Owns anchor resolution for its frame. */
88
116
  export function CommentLayer({ node, frameId, iframe }: { node: Node; frameId: string; iframe: React.RefObject<HTMLIFrameElement | null> }) {
89
117
  const show = useComments((s) => s.show)
118
+ const showAnchor = useComments((s) => s.showAnchor)
90
119
  const active = useComments((s) => s.active)
91
120
  const draft = useComments((s) => s.draft)
92
121
  // select the stable array, filter in render - a selector returning a fresh
93
122
  // .filter() array re-renders forever (Object.is never matches)
94
123
  const allThreads = useComments((s) => s.threads)
95
- const threads = allThreads.filter((t) => t.frame === frameId && (!t.nodeKey || t.nodeKey === node.key))
124
+ const allNodes = useStore((s) => s.nodes)
125
+ // one owner per thread (keys.ts): the stored anchor when it still holds, else the
126
+ // first node showing the frame - a stale key (pre-0.8.1 random keys, board rewrites,
127
+ // deleted copies) degrades to the frame, never to invisible, and never to two pins
128
+ const threads = allThreads.filter((t) => t.frame === frameId && threadHostKey(t, allNodes) === node.key)
96
129
  const selected = useStore((s) => s.selection.includes(node.key))
97
130
  const { setActive } = useComments.getState()
98
131
  const [rects, setRects] = useState<Record<string, { x: number; y: number; w: number; h: number } | null>>({})
@@ -128,19 +161,28 @@ export function CommentLayer({ node, frameId, iframe }: { node: Node; frameId: s
128
161
  const win = iframe.current?.contentWindow
129
162
  if (!win || node.status !== 'ready') return
130
163
  // ONLY light an element while its thread is the active one (or a draft is composing on
131
- // it) AND pins are shown - closing the thread or hiding comments (⇧C) clears it, so a
132
- // stray outline never lingers on the frame.
164
+ // it) AND pins are shown AND element focus is on (⇧L) - closing the thread, hiding
165
+ // comments (⇧C), or dimming focus clears it, so a stray outline never lingers.
133
166
  const draftHere = draft?.nodeKey === node.key ? (draft.anchor as any) : null
134
167
  const activeHere = active ? open.find((t) => t.id === active && (t.anchor as any)?.el) : undefined
135
- const anchor = show ? (draftHere ?? (activeHere ? (activeHere.anchor as any) : null)) : null
168
+ const anchor = show && showAnchor ? (draftHere ?? (activeHere ? (activeHere.anchor as any) : null)) : null
136
169
  win.postMessage({ type: 'sh:highlight-anchor', frame: frameId, anchor: anchor ?? null }, location.origin)
137
- // active/draft/show/activeShown are the triggers; `open` is read fresh from the closure
138
- }, [active, draft, show, activeShown, node.status, node.key, frameId, iframe])
170
+ // active/draft/show/showAnchor/activeShown are the triggers; `open` is read fresh from the closure
171
+ }, [active, draft, show, showAnchor, activeShown, node.status, node.key, frameId, iframe])
139
172
 
173
+ // the open card's frozen side (D3) - a HOOK, so it must live above the early returns; and the
174
+ // reset must too: a card can close via the inactive-frame early return (clicking another frame),
175
+ // which never reaches the render tail - reopen must still RE-PICK the side
176
+ const sideRef = useRef<{ id: string; side: 'l' | 'r' } | null>(null)
177
+ if (sideRef.current && sideRef.current.id !== active) sideRef.current = null
178
+ // what occupies this frame's LEFT flank (drives the docked card's left gutter): a variant
179
+ // badge is widest, the working shimmer next. Reactive, so a job starting mid-read adjusts.
180
+ const flankBadge = useStore((s) => !!s.frameFor(node)?.variantGroup)
181
+ const flankShim = useStore((s) => s.working.includes(frameId))
140
182
  const drafting = draft?.nodeKey === node.key
141
183
  if (!show || (!open.length && !drafting)) return null
142
184
 
143
- // inactive frame: the stack - count + avatars, top-right (SPEC-M3 §6)
185
+ // inactive frame: the stack - count + avatars, top-right
144
186
  const engaged = selected || open.some((t) => t.id === active) || drafting
145
187
  if (!engaged && open.length) {
146
188
  return (
@@ -159,12 +201,38 @@ export function CommentLayer({ node, frameId, iframe }: { node: Node; frameId: s
159
201
  return { x: (p?.fx ?? 0.94) * node.w, y: (p?.fy ?? 0.06) * node.h, orphan: false }
160
202
  }
161
203
 
204
+ // The card's SIDE, chosen ONCE per open and frozen (D3 - never switches mid-read): the pin's
205
+ // side of the frame first, skipping a side another frame's screen rect occupies, then wherever
206
+ // the viewport has room. The active pin's teardrop tail flips toward this side.
207
+ const activeThread2 = active ? open.find((t) => t.id === active) : undefined
208
+ if (activeThread2 && sideRef.current?.id !== activeThread2.id) {
209
+ const compute = (): 'l' | 'r' => {
210
+ const W = 320, GAP = 30
211
+ const rect = document.querySelector(`[data-node="${CSS.escape(node.key)}"]`)?.getBoundingClientRect()
212
+ if (!rect) return 'r'
213
+ const occupied = (s: 'l' | 'r') => {
214
+ const rx = s === 'r' ? rect.right + 10 : rect.left - 10 - W
215
+ return [...document.querySelectorAll('.sh-node')].some((n) => {
216
+ if (n.getAttribute('data-node') === node.key) return false
217
+ const r = n.getBoundingClientRect()
218
+ return r.left < rx + W && r.right > rx && r.top < rect.bottom && r.bottom > rect.top
219
+ })
220
+ }
221
+ const room = (s: 'l' | 'r') => (s === 'r' ? window.innerWidth - rect.right : rect.left) >= W + GAP
222
+ const prefer: ('l' | 'r')[] = pinPos(activeThread2).x < node.w / 2 ? ['l', 'r'] : ['r', 'l']
223
+ return prefer.find((s) => room(s) && !occupied(s)) ?? prefer.find(room) ?? prefer[0]
224
+ }
225
+ sideRef.current = { id: activeThread2.id, side: compute() }
226
+ }
227
+ const cardSide: 'l' | 'r' = sideRef.current?.side ?? 'r'
228
+
162
229
  return (
163
230
  <>
164
231
  {open.map((t) => {
165
232
  const { x, y, orphan } = pinPos(t)
233
+ const isActive = t.id === active
166
234
  return (
167
- <div key={t.id} className={`cm-pin sh-no-pan${t.id === active ? ' on' : ''}${orphan ? ' orphan' : ''}`}
235
+ <div key={t.id} className={`cm-pin sh-no-pan${isActive ? ' on' : ''}${isActive && cardSide === 'r' ? ' tail-r' : ''}${orphan ? ' orphan' : ''}`}
168
236
  style={{ left: x, top: y, ...hueVars(anchorHue(t.anchor)) }}
169
237
  onPointerDown={(e) => e.stopPropagation()}
170
238
  onClick={(e) => { e.stopPropagation(); setActive(t.id === active ? null : t.id) }}
@@ -173,8 +241,9 @@ export function CommentLayer({ node, frameId, iframe }: { node: Node; frameId: s
173
241
  </div>
174
242
  )
175
243
  })}
176
- {active && open.some((t) => t.id === active) && (
177
- <ThreadCard key={active} thread={open.find((t) => t.id === active)!} at={pinPos(open.find((t) => t.id === active)!)} bounds={{ w: node.w, h: node.h }} />
244
+ {activeThread2 && (
245
+ <ThreadCard key={active} thread={activeThread2} at={pinPos(activeThread2)} bounds={{ w: node.w, h: node.h }} nodeKey={node.key} side={cardSide}
246
+ flank={cardSide === 'l' ? (flankBadge ? 'badge' : flankShim ? 'shim' : null) : null} />
178
247
  )}
179
248
  {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) }} bounds={{ w: node.w, h: node.h }} hue={anchorHue(draft.anchor)} />}
180
249
  </>
@@ -183,24 +252,327 @@ export function CommentLayer({ node, frameId, iframe }: { node: Node; frameId: s
183
252
 
184
253
  /** The open thread card - shared by canvas (bounds = node size, `at` in frame coords) and
185
254
  * prototype (bounds = the on-screen stage size, `at` in screen coords). Geometry only. */
186
- export function ThreadCard({ thread, at, bounds }: { thread: Thread; at: { x: number; y: number }; bounds: { w: number; h: number } }) {
255
+ // ---- Live Jam: @marver rendering + Marver identity ----------------------
256
+
257
+ const AT_TIP = "Read like any other comment. Marver won't act on this unless the owner promotes it."
258
+
259
+ /** A comment body with @marver mentions styled: owner-authored → bold accent (a live trigger);
260
+ * anyone else's → plain + a teaching tooltip (context, not a command). */
261
+ function CommentBody({ body, owner }: { body?: string; owner: boolean }) {
262
+ if (!body) return null
263
+ return (
264
+ <p className="cm-body">
265
+ {parseMentions(body).map((s, i) => !s.mention
266
+ ? <span key={i}>{s.text}</span>
267
+ : owner
268
+ ? <span key={i} className="cm-at owner">{s.text}</span>
269
+ : <Tip key={i} side="top" label={<span className="cm-at-tip">{AT_TIP}</span>}><span className="cm-at">{s.text}</span></Tip>)}
270
+ </p>
271
+ )
272
+ }
273
+
274
+ const HARNESS: Record<string, string> = { claude: 'Claude Code', codex: 'Codex', cursor: 'Cursor', opencode: 'OpenCode', droid: 'Factory Droid' }
275
+ /** claude-opus-5 → Opus 5; gpt-5.1-codex → Gpt 5.1 Codex; unknown → as-is. */
276
+ function prettyModel(m: string): string {
277
+ const cleaned = m.replace(/^claude-/, '').replace(/-/g, ' ')
278
+ return cleaned.replace(/\b\w/g, (c) => c.toUpperCase())
279
+ }
280
+
281
+ /** The provenance tooltip on the Marver avatar: who orchestrated the change.
282
+ * One row per fact, left-aligned, bold label + regular value - the house tooltip treatment. */
283
+ function AgentMetaTip({ meta }: { meta?: AgentMeta }) {
284
+ const rows = [
285
+ meta?.devUser && ['Dev user', meta.devUser],
286
+ meta?.harness && ['Harness', HARNESS[meta.harness] ?? meta.harness],
287
+ meta?.model && ['Model', prettyModel(meta.model)],
288
+ meta?.effort && ['Effort', meta.effort],
289
+ ].filter(Boolean) as [string, string][]
290
+ if (!rows.length) return <b>Marver</b>
291
+ // Toolbar-tooltip treatment: the VALUE is bold + bright (what you scan), the label is a muted
292
+ // qualifier after it - like "Hide all UI press H to reveal".
293
+ return (
294
+ <span className="cm-meta-tip">
295
+ {rows.map(([label, value]) => <span key={label} className="cm-meta-row"><b>{value}</b><span className="k">{label}</span></span>)}
296
+ </span>
297
+ )
298
+ }
299
+
300
+ /** A message header. Agent messages render as "Marver" with the mark + provenance tooltip;
301
+ * human messages keep their avatar + name. While the dev profile is still UNSET, every own
302
+ * message avatar invites editing on hover (discoverability); once set, only the composer's
303
+ * avatar is the door. */
304
+ function MessageHead({ author, agent, agentMeta, ts }: { author?: Thread['author']; agent?: boolean; agentMeta?: AgentMeta; ts: number }) {
305
+ const me = useComments((s) => s.me)
306
+ const local = useComments((s) => s.local)
307
+ const connected = useComments((s) => s.connected)
308
+ if (agent) return (
309
+ <header>
310
+ <Tip side="top" label={<AgentMetaTip meta={agentMeta} />}>
311
+ <span className="cm-marver-wrap"><MarverAvatar size={24} /></span>
312
+ </Tip>
313
+ <b className="cm-marver-name">Marver</b>
314
+ <span className="dim">{rel(ts)}</span>
315
+ </header>
316
+ )
317
+ // own = born on this machine without an account (no email); unset = identity still the default.
318
+ // Own messages render the CURRENT identity, not the stored snapshot - same rule as Avatar.
319
+ const unset = local && !connected && (!me?.name || me.name === 'You')
320
+ const own = local && !author?.email
321
+ const shown = own && me ? { ...author, ...me } : author
322
+ return (
323
+ <header>
324
+ {unset && own ? <EditableAvatar author={author} size={24} /> : <Avatar author={author} size={24} />}
325
+ <b>{shown?.name ?? 'Someone'}</b>
326
+ <span className="dim">{rel(ts)}</span>
327
+ </header>
328
+ )
329
+ }
330
+
331
+ /** An auto-growing composer with the full keybindings: Enter send, Shift+Enter newline,
332
+ * Cmd/Ctrl+Enter send, Escape cancel; IME-guarded, disabled while a send is in flight. */
333
+ function CommentInput({ value, onChange, onSubmit, onCancel, placeholder, autoFocus, sendLabel, owner }: {
334
+ value: string; onChange: (v: string) => void; onSubmit: () => void | Promise<unknown>
335
+ onCancel?: () => void; placeholder: string; autoFocus?: boolean; sendLabel: string; owner: boolean
336
+ }) {
337
+ const ref = useRef<HTMLTextAreaElement>(null)
338
+ const hlRef = useRef<HTMLDivElement>(null)
339
+ const [busy, setBusy] = useState(false)
340
+ // +2: scrollHeight excludes the 1px borders, but border-box height includes them - without
341
+ // it every line is squeezed 2px and the single-line pill renders off-center
342
+ const grow = () => { const el = ref.current; if (el) { el.style.height = 'auto'; el.style.height = Math.min(el.scrollHeight + 2, 132) + 'px' } }
343
+ const syncScroll = () => { if (hlRef.current && ref.current) hlRef.current.scrollTop = ref.current.scrollTop }
344
+ useEffect(() => { if (autoFocus) ref.current?.focus() }, [autoFocus])
345
+ useEffect(grow, [value])
346
+ const submit = async () => {
347
+ if (busy || !value.trim()) return
348
+ setBusy(true)
349
+ try { await onSubmit() } finally { setBusy(false) }
350
+ }
351
+ return (
352
+ <div className="cm-inputwrap">
353
+ {/* Live mention highlight: a mirror layer behind the transparent-text textarea, so @marver shows
354
+ its trigger colour AS YOU TYPE (owner = accent-blue "this will run"; else muted). */}
355
+ <div className="cm-hl" ref={hlRef} aria-hidden>
356
+ {parseMentions(value).map((s, i) => s.mention
357
+ ? <span key={i} className={owner ? 'cm-at owner' : 'cm-at'}>{s.text}</span>
358
+ : <span key={i}>{s.text}</span>)}
359
+ {value.endsWith('\n') ? '​' : ''}
360
+ </div>
361
+ <textarea ref={ref} rows={1} value={value} placeholder={placeholder} disabled={busy} spellCheck={false}
362
+ onChange={(e) => onChange(e.target.value)} onInput={grow} onScroll={syncScroll}
363
+ onKeyDown={(e) => {
364
+ e.stopPropagation()
365
+ if (e.key === 'Escape') { onCancel?.(); return }
366
+ if (e.key === 'Enter') {
367
+ if ((e.nativeEvent as unknown as { isComposing?: boolean }).isComposing) return // IME: mid-composition Enter must not send
368
+ if (e.shiftKey) return // newline
369
+ e.preventDefault(); void submit() // Enter / Cmd·Ctrl+Enter send
370
+ }
371
+ }} />
372
+ <Tip side="top" label={<b>⏎ send · ⇧⏎ new line</b>}>
373
+ <button className="cm-send" disabled={busy || !value.trim()} aria-label={sendLabel} onClick={() => void submit()}>
374
+ <ArrowUpIcon size={15} />
375
+ </button>
376
+ </Tip>
377
+ </div>
378
+ )
379
+ }
380
+
381
+ /** The dev identity: a full-screen modal (the IdentityDialog treatment - same classes, same
382
+ * weight) with display name + photo, saved to design/.local/profile.json (this machine only).
383
+ * When a connect account provides name/email (the published server validates authors), the name
384
+ * is read-only here and only the photo is editable. Portaled to body: the thread card is
385
+ * transformed + overflow-hidden, so anything anchored inside it clips. */
386
+ function ProfileDialog({ onClose }: { onClose: () => void }) {
387
+ const me = useComments((s) => s.me)
388
+ const connected = useComments((s) => s.connected)
389
+ const unset = !connected && (!me?.name || me.name === 'You')
390
+ const [name, setName] = useState(unset ? '' : me?.name ?? '')
391
+ const [avatar, setAvatar] = useState(me?.avatar ?? '')
392
+ const [busy, setBusy] = useState(false)
393
+ const [err, setErr] = useState<string | null>(null)
394
+ const ready = connected || !!name.trim()
395
+ const save = async () => {
396
+ if (busy || !ready) return
397
+ setBusy(true)
398
+ try {
399
+ const e = await useComments.getState().saveProfile({ ...(connected ? {} : { name: name.trim() }), avatar })
400
+ if (e) return setErr(e) // a failed save must not close as if it worked
401
+ onClose()
402
+ } finally { setBusy(false) }
403
+ }
404
+ return createPortal(
405
+ <div className="cm-modal-wrap" onClick={onClose} onPointerDown={(e) => e.stopPropagation()}>
406
+ <div className="cm-modal" onClick={(e) => e.stopPropagation()}
407
+ onKeyDown={(e) => { e.stopPropagation(); if (e.key === 'Escape') onClose() }}>
408
+ <h2>{unset ? 'Set up your profile' : 'Edit your profile'}</h2>
409
+ <p className="dim">
410
+ {connected
411
+ ? <>Your name comes from your connect account. The photo stays on this machine.</>
412
+ : <>Comments carry your name and photo. Saved to design/.local on this machine - yours, nowhere else.</>}
413
+ </p>
414
+ <div className="cm-fields">
415
+ <div className="cm-idrow">
416
+ <AvatarPick value={avatar} onPick={setAvatar} />
417
+ {connected
418
+ ? <div className="cm-chip" style={{ flex: 1 }}><b>{me?.name}</b><span>CONNECT</span></div>
419
+ : <input placeholder="Set a display name" style={{ flex: 1 }} value={name}
420
+ onChange={(e) => setName(e.target.value)}
421
+ onKeyDown={(e) => e.key === 'Enter' && void save()} />}
422
+ </div>
423
+ </div>
424
+ {err && <span className="cm-err">{err}</span>}
425
+ <div className="cm-row">
426
+ <button className="cm-primary" disabled={busy || !ready} onClick={() => void save()}>Save</button>
427
+ <button onClick={onClose}>Not now</button>
428
+ </div>
429
+ </div>
430
+ </div>,
431
+ // into .sh-app, NOT body: every theme token (--cm-modal-bg, inks) lives on .sh-app,
432
+ // and a body-portaled modal renders transparent outside that scope
433
+ document.querySelector('.sh-app') ?? document.body)
434
+ }
435
+
436
+ /** An avatar that IS the door to your profile: hover morphs it into a pen on gray, click opens
437
+ * the modal. The composer's own avatar is always this door (dev only); while the profile is
438
+ * still unset, every own message avatar is too - discoverability until identity is real. */
439
+ function EditableAvatar({ author, size = 24 }: { author?: Thread['author']; size?: number }) {
440
+ const [open, setOpen] = useState(false)
441
+ return (
442
+ <span className="cm-me">
443
+ <Tip side="top" label={<b>Edit your profile</b>}>
444
+ <button className="cm-mebtn" aria-label="Edit your profile" onClick={() => setOpen(true)}>
445
+ <Avatar author={author} size={size} />
446
+ <span className="cm-pen-face"><PencilSimpleIcon size={Math.round(size * 0.55)} /></span>
447
+ </button>
448
+ </Tip>
449
+ {open && <ProfileDialog onClose={() => setOpen(false)} />}
450
+ </span>
451
+ )
452
+ }
453
+
454
+ /** The composer's own avatar (dev only - published viewers already have accounts). */
455
+ function ComposeAvatar() {
456
+ const me = useComments((s) => s.me)
457
+ const local = useComments((s) => s.local)
458
+ if (!local) return <Avatar author={me ?? undefined} size={24} />
459
+ return <EditableAvatar author={me ?? undefined} size={24} />
460
+ }
461
+
462
+ export function ThreadCard({ thread, at, bounds, nodeKey, side = 'r', flank, stage }: { thread: Thread; at: { x: number; y: number }; bounds: { w: number; h: number }; nodeKey?: string; side?: 'l' | 'r'; flank?: 'badge' | 'shim' | null; stage?: boolean }) {
187
463
  const { resolve, setActive } = useComments.getState()
188
464
  const me = useComments((s) => s.me)
189
465
  const local = useComments((s) => s.local)
190
466
  const canComment = local || !!me // dev is always "me"; published needs a session
467
+ // Owner-authored @marver is a live trigger (bold accent); anyone else's is context (plain + tooltip).
468
+ // Match by email when we know the owner's; in dev without a profile, local writes are the owner.
469
+ const isOwner = (a?: { email?: string }) => (me?.email ? a?.email === me.email : local)
191
470
  const [text, setText] = useState('')
192
471
  const [copied, setCopied] = useState(false)
193
472
  const copyTimer = useRef<ReturnType<typeof setTimeout>>(undefined)
194
473
  useEffect(() => () => clearTimeout(copyTimer.current), [])
195
- const flip = at.x > bounds.w * 0.55
474
+ // Canvas: the card is a WORLD OBJECT parked beside its frame - it stays put (panning moves
475
+ // frame + card together, never following the viewport). Zoom scales it with a CLAMP
476
+ // (0.9..1.35 of screen-constant), so it reads steady at working zooms yet never dwarfs the
477
+ // frame far out or shrinks away deep in. Height rules: capped at the frame's height; grows
478
+ // DOWNWARD from the pin until it reaches the frame's bottom, then grows UP; past the cap the
479
+ // messages scroll inside. The SIDE is chosen once at open (D3). Play keeps in-stage placement.
480
+ const scrollRef = useRef<HTMLDivElement>(null)
481
+ const cardRef = useRef<HTMLDivElement>(null)
482
+ const float = !!nodeKey
483
+ const [cardH, setCardH] = useState(320) // measured layout px (pre-transform)
484
+ useEffect(() => {
485
+ if (float && cardRef.current) {
486
+ const h = cardRef.current.offsetHeight
487
+ setCardH((c) => (Math.abs(c - h) > 2 ? h : c))
488
+ }
489
+ })
490
+ // scroll shadows: "there is more" above/below - recomputed on scroll + content growth
491
+ const [shadows, setShadows] = useState({ top: false, bot: false, can: false })
492
+ const syncShadows = () => {
493
+ const el = scrollRef.current
494
+ if (!el) return
495
+ // gate on MEANINGFUL overflow - a thread that overflows by a few px must not smear a
496
+ // 30px fade over its last line ("looks scrollable" when it isn't, really)
497
+ const can = el.scrollHeight - el.clientHeight > 12
498
+ const top = can && el.scrollTop > 4
499
+ const bot = can && el.scrollTop + el.clientHeight < el.scrollHeight - 4
500
+ setShadows((s) => (s.top === top && s.bot === bot && s.can === can ? s : { top, bot, can }))
501
+ }
502
+ // open at the LATEST message (what you came to read); follow new replies while open
503
+ useEffect(() => {
504
+ const el = scrollRef.current
505
+ if (el) el.scrollTop = el.scrollHeight
506
+ syncShadows()
507
+ }, [thread.replies.length])
508
+ // ...and when the scroller RESIZES without a scroll event (zoom changing the height cap, the
509
+ // composer growing) - a stale fade would otherwise claim "more below" at the bottom of a
510
+ // thread that no longer overflows
511
+ useEffect(() => {
512
+ const el = scrollRef.current
513
+ if (!el) return
514
+ const ro = new ResizeObserver(syncShadows)
515
+ ro.observe(el)
516
+ return () => ro.disconnect()
517
+ }, [])
518
+ // ...and after EVERY render: an edited message changes scrollHeight without resizing the
519
+ // height-capped box or the reply count. setShadows dedups, so this is free.
520
+ useEffect(syncShadows)
521
+ // PLAY: one static centered device, no zoom - the card docks FIXED beside it on the pin's side
522
+ // (falling back to the roomier side), fully viewport-clamped so the composer is ALWAYS reachable
523
+ // whatever the device layout or theme. Recomputed when the stage moves under it:
524
+ // a device/theme switch resizes .sh-play-stage and a window resize shifts it.
525
+ const computeStagePos = (): React.CSSProperties | null => {
526
+ if (!stage) return null
527
+ const W = 320, M = 12, maxH = Math.min(660, window.innerHeight - 2 * M)
528
+ const rect = document.querySelector('.sh-play-stage')?.getBoundingClientRect()
529
+ if (!rect) return { position: 'fixed', left: M, top: 72, maxHeight: maxH }
530
+ const roomR = window.innerWidth - rect.right >= W + 30
531
+ const roomL = rect.left >= W + 30
532
+ const s: 'l' | 'r' = at.x < bounds.w / 2 ? (roomL ? 'l' : 'r') : (roomR ? 'r' : 'l')
533
+ const x = Math.min(Math.max(s === 'r' ? rect.right + 18 : rect.left - 18 - W, M), window.innerWidth - W - M)
534
+ // worst-case clamp (content = maxH): even a full-height thread keeps its composer on screen
535
+ const y = Math.min(Math.max(rect.top + at.y - 36, M), Math.max(M, window.innerHeight - maxH - M))
536
+ return { position: 'fixed', left: x, top: y, maxHeight: maxH }
537
+ }
538
+ const [stagePos, setStagePos] = useState<React.CSSProperties | null>(computeStagePos)
539
+ useEffect(() => {
540
+ if (!stage) return
541
+ const re = () => setStagePos(computeStagePos())
542
+ re() // bounds changed (device/theme switch) -> re-dock
543
+ window.addEventListener('resize', re)
544
+ const el = document.querySelector('.sh-play-stage')
545
+ const ro = el ? new ResizeObserver(re) : null
546
+ if (el && ro) ro.observe(el)
547
+ return () => { window.removeEventListener('resize', re); ro?.disconnect() }
548
+ }, [stage, bounds.w, bounds.h])
549
+ const flip = !float && !stage && at.x > bounds.w * 0.55
550
+ // The card scales EXACTLY like the pins - screen-constant via --sh-inv, pure
551
+ // CSS, smooth per zoom tick with zero re-renders. Geometry in CSS math over the live vars:
552
+ // top: grows DOWN from the pin, then UP once its bottom reaches the frame's bottom; floor
553
+ // at -28 (the layer starts below the header - -28 = the frame's TOP border).
554
+ // maxHeight: the visual height never exceeds the WHOLE frame (body + 28px header).
555
+ // Gutters live in CSS (dock margins, screen-constant; wider on the left for the shimmer).
556
+ const pos: React.CSSProperties = stage && stagePos
557
+ ? stagePos
558
+ : float
559
+ ? {
560
+ left: side === 'r' ? bounds.w : 0,
561
+ top: `max(-28px, min(${Math.round(at.y)}px - 36px * var(--sh-inv, 1), ${bounds.h}px - ${cardH}px * var(--sh-inv, 1)))`,
562
+ // capped at the whole frame's on-screen height, with a REAL readability floor: zoomed way
563
+ // out the card still shows a meaningful slice of conversation (~3 messages), even if that
564
+ // makes it taller than the tiny frame
565
+ maxHeight: `max(320px, calc(${bounds.h + 28}px * var(--sh-s, 1)))`,
566
+ }
567
+ : { left: at.x, top: Math.min(at.y + 14, bounds.h - 40) }
196
568
  // clear only after the server took it - a failed send must not eat the words,
197
569
  // and text typed WHILE the request was in flight must survive the clear
198
570
  const submit = async () => {
199
571
  const sent = text
200
572
  if (sent.trim() && await useComments.getState().replyOk(thread.id, sent)) setText((cur) => (cur === sent ? '' : cur))
201
573
  }
202
- return (
203
- <div data-sh-wheel-local className={`cm-card sh-no-pan${flip ? ' flip' : ''}`} style={{ left: at.x, top: Math.min(at.y + 14, bounds.h - 40), ...hueVars(anchorHue(thread.anchor)) }}
574
+ const card = (
575
+ <div ref={cardRef} data-sh-wheel-local className={`cm-card sh-no-pan${flip ? ' flip' : ''}${float ? ` parked dock-${side}` : ''}${float && flank ? ` flank-${flank}` : ''}${stage ? ' stage-dock' : ''}`} style={{ ...pos, ...hueVars(anchorHue(thread.anchor)) }}
204
576
  onPointerDown={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()} onWheel={(e) => e.stopPropagation()}>
205
577
  {/* thread-level actions pin to the card corner, out of the header's flow -
206
578
  the name row never has to share its line with them */}
@@ -226,33 +598,23 @@ export function ThreadCard({ thread, at, bounds }: { thread: Thread; at: { x: nu
226
598
  <button className="cm-icon" onClick={() => setActive(null)}><XIcon size={15} /></button>
227
599
  </Tip>
228
600
  </div>
229
- <header>
230
- <Avatar author={thread.author} size={24} />
231
- <b>{thread.author?.name ?? 'Someone'}</b>
232
- <span className="dim">{rel(thread.ts)}</span>
233
- </header>
234
- <p className="cm-body">{thread.body}</p>
235
- {/* replies repeat the root's exact message shape (Figma's pattern) - only the icons differ */}
236
- {thread.replies.map((r) => (
237
- <div key={r.id} className="cm-msg">
238
- <header>
239
- <Avatar author={r.author} size={24} />
240
- <b>{r.author?.name ?? 'Someone'}</b>
241
- <span className="dim">{rel(r.ts)}</span>
242
- </header>
243
- <p className="cm-body">{r.body}</p>
244
- </div>
245
- ))}
601
+ {/* "there is more": the scroller MASKS its own content at the overflowing edge - a soft fade
602
+ of the text itself, not a paint layer over the glass */}
603
+ <div className={`cm-scroll${shadows.top ? ' fade-top' : ''}${shadows.bot ? ' fade-bot' : ''}`} ref={scrollRef} onScroll={syncShadows}>
604
+ <MessageHead author={thread.author} agent={thread.agent} agentMeta={thread.agentMeta} ts={thread.ts} />
605
+ <CommentBody body={thread.body} owner={isOwner(thread.author)} />
606
+ {/* replies repeat the root's exact message shape (Figma's pattern) - only the icons differ */}
607
+ {thread.replies.map((r) => (
608
+ <div key={r.id} className="cm-msg">
609
+ <MessageHead author={r.author} agent={r.agent} agentMeta={r.agentMeta} ts={r.ts} />
610
+ <CommentBody body={r.body} owner={isOwner(r.author)} />
611
+ </div>
612
+ ))}
613
+ </div>
246
614
  {canComment ? (
247
615
  <div className="cm-compose">
248
- <Avatar author={me ?? undefined} size={24} />
249
- <div className="cm-inputwrap">
250
- <input value={text} placeholder="Reply…" onChange={(e) => setText(e.target.value)}
251
- onKeyDown={(e) => { e.stopPropagation(); if (e.key === 'Enter') void submit(); if (e.key === 'Escape') setActive(null) }} />
252
- <button className="cm-send" disabled={!text.trim()} aria-label="Send" onClick={() => void submit()}>
253
- <ArrowUpIcon size={15} />
254
- </button>
255
- </div>
616
+ <ComposeAvatar />
617
+ <CommentInput value={text} onChange={setText} onSubmit={submit} onCancel={() => setActive(null)} placeholder="Reply…" sendLabel="Send" owner={local} />
256
618
  </div>
257
619
  ) : (
258
620
  <button className="cm-signin-cta" onClick={() => useComments.setState({ needsIdentity: true })}>
@@ -261,31 +623,22 @@ export function ThreadCard({ thread, at, bounds }: { thread: Thread; at: { x: nu
261
623
  )}
262
624
  </div>
263
625
  )
626
+ return card
264
627
  }
265
628
 
266
629
  /** The draft composer - shared by canvas and prototype, geometry via `bounds`, tinted with
267
630
  * the picked element's hue. */
268
631
  export function DraftComposer({ at, bounds, hue }: { at: { x: number; y: number }; bounds: { w: number; h: number }; hue?: number }) {
269
632
  const { create, setDraft } = useComments.getState()
633
+ const local = useComments((s) => s.local)
270
634
  const [text, setText] = useState('')
271
- const ref = useRef<HTMLInputElement>(null)
272
- useEffect(() => { ref.current?.focus() }, [])
273
635
  const flip = at.x > bounds.w * 0.55
274
636
  return (
275
637
  <div data-sh-wheel-local className={`cm-card cm-draft sh-no-pan${flip ? ' flip' : ''}`} style={{ left: at.x, top: Math.min(at.y + 14, bounds.h - 40), ...hueVars(hue) }}
276
638
  onPointerDown={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}>
639
+ {/* the draft is JUST the tight pill - no avatar chrome on a first comment */}
277
640
  <div className="cm-compose">
278
- <div className="cm-inputwrap">
279
- <input ref={ref} value={text} placeholder="Comment on this element…" onChange={(e) => setText(e.target.value)}
280
- onKeyDown={(e) => {
281
- e.stopPropagation()
282
- if (e.key === 'Enter') void create(text)
283
- if (e.key === 'Escape') setDraft(null)
284
- }} />
285
- <button className="cm-send" disabled={!text.trim()} aria-label="Comment" onClick={() => void create(text)}>
286
- <ArrowUpIcon size={15} />
287
- </button>
288
- </div>
641
+ <CommentInput value={text} onChange={setText} onSubmit={() => create(text)} onCancel={() => setDraft(null)} placeholder="Comment on this element…" autoFocus sendLabel="Comment" owner={local} />
289
642
  </div>
290
643
  </div>
291
644
  )
@@ -310,10 +663,11 @@ function AvatarPick({ value, onPick }: { value: string; onPick: (dataUri: string
310
663
  }
311
664
  return (
312
665
  <>
313
- <Tip side="top" label={<b>{value ? 'Change your photo' : 'Select your profile picture'}</b>}>
314
- <button className={`cm-pfp${value ? ' set' : ''}`} aria-label="Select your profile picture"
315
- style={value ? { backgroundImage: `url(${value})` } : undefined}
316
- onClick={() => fileRef.current?.click()}>{value ? '' : '+'}</button>
666
+ <Tip side="top" label={<b>{value ? 'Change your photo' : 'Select or upload a profile picture'}</b>}>
667
+ <button className={`cm-pfp${value ? ' set' : ''}`} aria-label="Select or upload a profile picture"
668
+ onClick={() => fileRef.current?.click()}>
669
+ {value ? <img src={value} alt="" /> : <PlusIcon size={16} />}
670
+ </button>
317
671
  </Tip>
318
672
  <input ref={fileRef} type="file" accept="image/*" hidden onChange={(e) => pick(e.target.files?.[0])} />
319
673
  </>
@@ -421,15 +775,16 @@ export function IdentityDialog() {
421
775
  }
422
776
 
423
777
  /** Open a thread by id: activate, select its node, fit the camera. True on success. */
424
- function revealThread(c: string): boolean {
778
+ export function revealThread(c: string): boolean {
425
779
  const t = useComments.getState().threads.find((t) => t.id === c)
426
780
  if (!t) return false
781
+ // resolve the host BEFORE activating: a thread whose frame is not on this board
782
+ // must not consume the deep link (the board switch that CAN show it comes later)
783
+ const key = threadHostKey(t, useStore.getState().nodes)
784
+ if (!key) return false
427
785
  useComments.getState().setActive(c)
428
- const node = t.nodeKey && useStore.getState().nodes.find((n) => n.key === t.nodeKey)
429
- if (node) {
430
- useStore.getState().select(node.key)
431
- setTimeout(() => canvasCtl.fitNode(node.key), 80)
432
- }
786
+ useStore.getState().select(key)
787
+ setTimeout(() => canvasCtl.fitNode(key), 80)
433
788
  return true
434
789
  }
435
790
 
@@ -437,6 +792,7 @@ function revealThread(c: string): boolean {
437
792
  let bootThreadConsumed = false
438
793
  let pendingThread: string | null = null // cross-board deep link awaiting its board's comments
439
794
  export function CommentsController() {
795
+ const ctlShowAnchor = useComments((s) => s.showAnchor)
440
796
  const board = useStore((s) => s.board)
441
797
  const commentMode = useComments((s) => s.commentMode)
442
798
  const active = useComments((s) => s.active)
@@ -465,7 +821,7 @@ export function CommentsController() {
465
821
  // a cross-board deep link parks its thread id here until the target board's
466
822
  // comments have actually loaded - a timer can't know how long that takes
467
823
  if (pendingThread && revealThread(pendingThread)) { pendingThread = null; return }
468
- // deep link ?c=<thread> (SPEC-M3 §6): open the thread, select its node, fit it.
824
+ // deep link ?c=<thread>: open the thread, select its node, fit it.
469
825
  // Consumed only on a successful find: the controller's first effect fires with
470
826
  // the pre-boot board (child effects run before the parent's boot effect), and
471
827
  // burning the flag on that empty pass would eat the link.
@@ -499,11 +855,11 @@ export function CommentsController() {
499
855
  }, [])
500
856
 
501
857
  // broadcast pick mode to every LIVE frame (laser rides along inside the bridge). Scoped to
502
- // .sh-live so the SPEC-M5 lean cover (.sh-lean, a scriptless snapshot) is never messaged.
858
+ // .sh-live so the lean cover (.sh-lean, a scriptless snapshot) is never messaged.
503
859
  useEffect(() => {
504
860
  for (const f of document.querySelectorAll('iframe.sh-live'))
505
- (f as HTMLIFrameElement).contentWindow?.postMessage({ type: 'sh:pick', on: commentMode }, location.origin)
506
- }, [commentMode])
861
+ (f as HTMLIFrameElement).contentWindow?.postMessage({ type: 'sh:pick', on: commentMode, quiet: !ctlShowAnchor }, location.origin)
862
+ }, [commentMode, ctlShowAnchor])
507
863
 
508
864
  return <IdentityDialog />
509
865
  }