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