@marver-design/marver 0.5.0 → 0.7.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.
@@ -1,5 +1,4 @@
1
1
  import { Component, useEffect, useRef, useState, type ReactNode } from 'react'
2
- import { createPortal } from 'react-dom'
3
2
  import { useStore, CONFIG, PUBLISHED, boardLabel, cap, humanize, fetchBoardNames, type FrameEntry } from './store.ts'
4
3
  import { Tip } from './Tip.tsx'
5
4
  import { PKG, ROUTE } from '../const.ts'
@@ -7,9 +6,10 @@ import { animateLayout, Canvas, canvasCtl } from './canvas/Canvas.tsx'
7
6
  import { frameByWindow } from './canvas/frame-registry.ts'
8
7
  import { enterPlay, playCtl, PlayOverlay } from './Play.tsx'
9
8
  import { bootHash, parseHash, writeHash } from './hash.ts'
10
- import { CardsIcon, CardsThreeIcon, CaretIcon, CheckIcon, ColumnsIcon, CommentIcon, DevicesIcon, FrameRectIcon, IntentGlyph, LaserIcon, MoonIcon, PanelFilledIcon, PanelHollowIcon, ParallelogramDuoIcon, PlayIcon, PlusIcon, SignpostIcon, SunIcon, VariantsIcon, XIcon, deviceIcon } from './icons.tsx'
9
+ import { CardsIcon, CardsThreeIcon, CaretIcon, CheckIcon, ColumnsIcon, FrameRectIcon, IntentGlyph, MoonIcon, PanelFilledIcon, PanelHollowIcon, ParallelogramDuoIcon, PlayIcon, PlusIcon, SignpostIcon, SunIcon, VariantsIcon, XIcon, deviceIcon } from './icons.tsx'
11
10
  import { CommentsController } from './Comments.tsx'
12
11
  import { useComments } from './comments-store.ts'
12
+ import { CommentButton, DevicePicker, HideUIButton, LaserButton, Popover, ThemePicker, toggleHideUI, usePopover } from './Toolbar.tsx'
13
13
 
14
14
  const commentsStore = () => useComments.getState()
15
15
 
@@ -48,41 +48,6 @@ export class ShellBoundary extends Component<{ children: ReactNode }, { err: Err
48
48
  }
49
49
  }
50
50
 
51
- /** Shared popover machinery: trigger position, outside-click close, portal to the app
52
- * root (glass never nests - a nested backdrop-filter cannot sample the page). */
53
- function usePopover() {
54
- const [open, setOpen] = useState(false)
55
- const [pos, setPos] = useState({ left: 0, top: 0 })
56
- const boxRef = useRef<HTMLDivElement>(null)
57
- const menuRef = useRef<HTMLDivElement>(null)
58
- const toggle = () => {
59
- if (!open && boxRef.current) {
60
- const r = boxRef.current.getBoundingClientRect()
61
- setPos({ left: r.left, top: r.bottom + 10 })
62
- }
63
- setOpen(!open)
64
- }
65
- useEffect(() => {
66
- if (!open) return
67
- const close = (e: PointerEvent) => {
68
- const t = e.target as globalThis.Node
69
- if (!boxRef.current?.contains(t) && !menuRef.current?.contains(t)) setOpen(false)
70
- }
71
- window.addEventListener('pointerdown', close)
72
- return () => window.removeEventListener('pointerdown', close)
73
- }, [open])
74
- return { open, setOpen, pos, boxRef, menuRef, toggle }
75
- }
76
-
77
- function Popover({ pop, children }: { pop: ReturnType<typeof usePopover>; children: ReactNode }) {
78
- const app = document.querySelector('.sh-app')
79
- if (!pop.open || !app) return null
80
- return createPortal(
81
- <div className="sh-menu" ref={pop.menuRef} style={{ left: pop.pos.left, top: pop.pos.top }}>{children}</div>,
82
- app,
83
- )
84
- }
85
-
86
51
  /** Boards live flat in the sidebar - always visible, one click to switch. The list
87
52
  * refreshes on mount, window focus, and a slow poll so agent-created board files
88
53
  * appear without a reload. Active board = accent icon + wash, same language as scenes. */
@@ -213,12 +178,12 @@ function SelectionBar() {
213
178
  }
214
179
 
215
180
  /** Devices view: one click sizes frames to a device width, tidies, and fits. Scoped
216
- * like the digit keys: the selection when one exists, the whole board otherwise. */
181
+ * like the digit keys: the selection when one exists, the whole board otherwise. The
182
+ * dropdown chrome is the shared DevicePicker; this adapter owns the canvas wiring. */
217
183
  function DeviceMenu() {
218
184
  const deviceView = useStore((s) => s.deviceView)
219
185
  const selection = useStore((s) => s.selection)
220
186
  const nodes = useStore((s) => s.nodes)
221
- const pop = usePopover()
222
187
  const scoped = selection.length > 0
223
188
  const pick = (name: string | null) => {
224
189
  animateLayout()
@@ -230,7 +195,6 @@ function DeviceMenu() {
230
195
  st.setDeviceView(name)
231
196
  setTimeout(() => canvasCtl.fitAll(), 30)
232
197
  }
233
- pop.setOpen(false)
234
198
  }
235
199
  const entries = Object.entries(CONFIG.viewports)
236
200
  // active check: board-wide it is the device view; scoped, the device every selected frame wears
@@ -238,31 +202,8 @@ function DeviceMenu() {
238
202
  const active = scoped
239
203
  ? entries.find(([, vp]) => selNodes.length > 0 && selNodes.every((n) => n.w === vp.width))?.[0] ?? null
240
204
  : deviceView
241
- return (
242
- <div className="sh-theme" ref={pop.boxRef}>
243
- <Tip side="bottom" label={<><b>Device view</b><span>{scoped ? `${selection.length} selected` : deviceView ? `${cap(deviceView)} · 0 resets` : `keys 1-${entries.length}`}</span></>}>
244
- <button className="sh-pill-btn" onClick={pop.toggle}>
245
- {deviceIcon(active, 16)}
246
- <CaretIcon size={11} style={{ transform: pop.open ? 'rotate(180deg)' : undefined }} />
247
- </button>
248
- </Tip>
249
- <Popover pop={pop}>
250
- <Tip side="bottom" label={scoped ? 'Selected frames at their default sizes' : 'Every frame at its own default size'}>
251
- <button onClick={() => pick(null)}>
252
- <DevicesIcon size={15} /><span>Default</span><kbd>0</kbd>
253
- {!scoped && deviceView === null && <CheckIcon size={13} className="chk" />}
254
- </button>
255
- </Tip>
256
- <i className="div" />
257
- {entries.map(([name, vp], i) => (
258
- <button key={name} onClick={() => pick(name)} title={`${vp.width} × ${vp.height}`}>
259
- {deviceIcon(name)}<span>{cap(name)}</span><kbd>{i < 9 ? i + 1 : ''}</kbd>
260
- {active === name && <CheckIcon size={13} className="chk" />}
261
- </button>
262
- ))}
263
- </Popover>
264
- </div>
265
- )
205
+ const hint = scoped ? `${selection.length} selected` : deviceView ? cap(deviceView) : `keys 1-${entries.length}`
206
+ return <DevicePicker value={active} onSelect={pick} includeDefault hint={hint} />
266
207
  }
267
208
 
268
209
  /** Update pill (dev only): the daily registry check surfaces here - same glass, same
@@ -337,46 +278,25 @@ function ZoomMenu() {
337
278
  /** Theme dropdown, scoped like the device digits: the selection when one exists, every
338
279
  * frame otherwise. The trigger reflects the scope's MAJORITY (per-frame overrides never
339
280
  * flip a board-level trigger - it reports the level it acts on, same rule as the shell).
340
- * The menu is PORTALED out of the pill: an element with backdrop-filter is a backdrop root,
341
- * so a nested backdrop-filter samples the pill's surface instead of the page - flat grey. */
281
+ * The dropdown chrome is the shared ThemePicker; this adapter owns the canvas wiring. */
342
282
  function ThemeMenu() {
343
283
  const nodes = useStore((s) => s.nodes)
344
284
  const selection = useStore((s) => s.selection)
345
285
  const viewTheme = useStore((s) => s.viewTheme)
346
- const pop = usePopover()
347
286
  const scoped = selection.length > 0
348
- // scoped: the trigger + check reflect the selection; global: the VIEW preference
349
287
  const scope = scoped ? nodes.filter((n) => selection.includes(n.key)) : []
350
- const uniform = scoped
351
- ? (scope.length && scope.every((n) => n.theme === scope[0].theme) ? scope[0].theme : null)
352
- : viewTheme
353
288
  const majority = scoped
354
289
  ? (scope.length ? [...scope.reduce((m, n) => m.set(n.theme, (m.get(n.theme) ?? 0) + 1), new Map<string, number>()).entries()].sort((a, b) => b[1] - a[1])[0][0] : viewTheme)
355
290
  : viewTheme
291
+ // the tick shows only a UNIFORM scope (mixed selection = no theme is "the" one), while
292
+ // the trigger reports the majority - the level the control acts on
293
+ const uniform = scoped ? (scope.length && scope.every((n) => n.theme === scope[0].theme) ? scope[0].theme : null) : viewTheme
356
294
  const pick = (t: string) => {
357
295
  const st = useStore.getState()
358
296
  scoped ? st.setSelectedTheme(t) : st.setTheme(t)
359
- pop.setOpen(false)
360
297
  }
361
- return (
362
- <div className="sh-theme" ref={pop.boxRef}>
363
- <Tip side="bottom" label={<><b>Theme</b><span>{scoped ? `${selection.length} selected · D` : 'all frames · D'}</span></>}>
364
- <button className="sh-pill-btn" onClick={pop.toggle}>
365
- {majority === 'dark' ? <MoonIcon size={16} /> : <SunIcon size={16} />}
366
- <CaretIcon size={11} style={{ transform: pop.open ? 'rotate(180deg)' : undefined }} />
367
- </button>
368
- </Tip>
369
- <Popover pop={pop}>
370
- {CONFIG.themes.map((t) => (
371
- <button key={t} onClick={() => pick(t)}>
372
- {t === 'dark' ? <MoonIcon size={15} /> : <SunIcon size={15} />}
373
- <span>{t}</span>
374
- {uniform === t && <CheckIcon size={13} className="chk" />}
375
- </button>
376
- ))}
377
- </Popover>
378
- </div>
379
- )
298
+ const hint = scoped ? `${selection.length} selected · D` : 'all frames · D'
299
+ return <ThemePicker value={majority} checked={uniform} onSelect={pick} hint={hint} />
380
300
  }
381
301
 
382
302
  export function App() {
@@ -403,12 +323,21 @@ export function App() {
403
323
  useEffect(() => {
404
324
  if (booted) return
405
325
  booted = true
406
- if (bootHash.board && bootHash.board !== useStore.getState().board)
407
- useStore.setState({ board: bootHash.board, boardAuto: bootHash.board === 'all-scenes' })
408
- boot().then((ok) => {
326
+ const start = async () => {
327
+ if (bootHash.board) {
328
+ if (bootHash.board !== useStore.getState().board) // a deep link wins
329
+ useStore.setState({ board: bootHash.board, boardAuto: bootHash.board === 'all-scenes' })
330
+ } else if (!PUBLISHED) {
331
+ // fresh open, no deep link: LAND on the first curated board (a tight, fast board = a good first
332
+ // impression) instead of the auto all-scenes everything-board, which renders every frame at once.
333
+ const first = (await fetchBoardNames().catch(() => [] as string[])).find((n) => n !== 'all-scenes')
334
+ if (first && first !== useStore.getState().board) useStore.setState({ board: first, boardAuto: false })
335
+ }
336
+ const ok = await boot()
409
337
  urlReady.current = true
410
- if (ok && bootHash.play) enterPlay(bootHash.play) // #/p/<board> alone = board start
411
- })
338
+ if (ok && bootHash.play) enterPlay(bootHash.play) // #/p/<board> alone = board start
339
+ }
340
+ void start()
412
341
  }, [])
413
342
 
414
343
  // the URL is a projection of state: design views replace in place; entering play and
@@ -540,6 +469,7 @@ export function App() {
540
469
  const onMsg = (e: MessageEvent) => {
541
470
  const data = e.data
542
471
  if (!data || typeof data.type !== 'string' || !data.type.startsWith('sh:')) return
472
+ if (e.origin && e.origin !== location.origin) return // a cross-origin-navigated frame keeps its WindowProxy; reject it
543
473
  // B0.3: O(1) registry lookup - also the security gate (unknown source = not a
544
474
  // registered frame = dropped), replacing a per-message iframe scan + DOM walk.
545
475
  const reg = frameByWindow(e.source)
@@ -640,9 +570,13 @@ export function App() {
640
570
  if (e.key === 'Escape') {
641
571
  const c = commentsStore()
642
572
  if (c.commentMode || c.active || c.draft) { c.setMode(false); c.setActive(null); c.setDraft(null) }
573
+ else if (s.laser) s.setLaser(false) // Escape also exits laser mode (parity with comment)
643
574
  else s.interact ? setInteract(null) : select(null)
644
575
  }
645
576
  if (e.key === 'p') enterPlay()
577
+ // H hides all chrome (shared binary Hide-UI); press again to reveal. Not persisted,
578
+ // so a refresh always restores it (the safety net for a forgotten toggle).
579
+ if (e.key === 'h') { toggleHideUI(); return }
646
580
  if (e.key === 't') { animateLayout(); runTidy() }
647
581
  // laser and comment mode are one-at-a-time: comment mode already highlights
648
582
  // what you'd click, so stacking the full rainbow on top only adds noise
@@ -860,33 +794,23 @@ export function App() {
860
794
 
861
795
  {/* floating pill nav, top right; collapses to a chip (same ladder as panel/fab) */}
862
796
  <nav className={`sh-pill${pillOpen ? '' : ' closed'}`} aria-hidden={!pillOpen}>
863
- {/* far-left section: single actions - comment mode, laser, tidy */}
864
- <Tip side="bottom" label={<><b>Comment</b><span>C · ⇧C hides pins</span></>}>
865
- <button className={`sh-pill-btn${commentMode ? ' on' : ''}`} onClick={() => {
866
- const c = commentsStore()
867
- if (!c.commentMode) useStore.getState().setLaser(false)
868
- c.setMode(!c.commentMode)
869
- toast(c.commentMode ? 'comment mode off' : 'comment mode - click an element in a frame')
870
- }}><CommentIcon size={16} /></button>
871
- </Tip>
872
- <Tip side="bottom" label={<><b>Laser mode</b><span>L</span></>}>
873
- <button className={`sh-pill-btn${laser ? ' on' : ''}`} onClick={() => {
874
- if (!laser) commentsStore().setMode(false)
875
- useStore.getState().setLaser(!laser)
876
- }}><LaserIcon size={16} /></button>
877
- </Tip>
797
+ {/* far-left section: actions - comment mode, laser, tidy, prototype */}
798
+ <CommentButton />
799
+ <LaserButton />
878
800
  <Tip side="bottom" label={<><b>Tidy layout</b><span>T</span></>}>
879
801
  <button className="sh-pill-btn" onClick={() => { animateLayout(); runTidy() }}><ColumnsIcon size={16} /></button>
880
802
  </Tip>
803
+ <Tip side="bottom" label={<><b>Prototype view</b><span>P</span></>}>
804
+ <button className="sh-pill-btn" onClick={() => enterPlay()}><PlayIcon size={15} /></button>
805
+ </Tip>
881
806
  <i className="sep" />
882
807
  <DeviceMenu />
883
808
  <ThemeMenu />
884
809
  <i className="sep" />
885
810
  <ZoomMenu />
886
811
  <i className="sep" />
887
- <Tip side="bottom" label={<><b>Prototype view</b><span>P</span></>}>
888
- <button className="sh-pill-btn" onClick={() => enterPlay()}><PlayIcon size={15} /></button>
889
- </Tip>
812
+ {/* far right: UI management only - hide, collapse */}
813
+ <HideUIButton />
890
814
  <Tip side="bottom" label={<><b>Collapse toolbar</b><span>⌘/</span></>}>
891
815
  <button className="sh-pill-btn" onClick={() => setPillOpen(false)} tabIndex={pillOpen ? 0 : -1}>
892
816
  <PanelFilledIcon size={17} style={{ transform: 'rotate(90deg)' }} />
@@ -14,6 +14,27 @@ import { Tip } from './Tip.tsx'
14
14
  import { ROUTE } from '../const.ts'
15
15
  import type { Thread } from '../../shared/events.ts'
16
16
 
17
+ /** Tint the pin / composer / thread card EDGES (border, outline, focus ring) in the anchored
18
+ * element's own laser hue (captured at pick as anchor.el.hue) via --cm-line/--cm-line-ring.
19
+ * Deliberately NOT the button fills (--comment stays green: white text on a saturated hue
20
+ * fails at yellow/green/cyan) and NOT the avatar (keeps its per-user colour). No hue -> the
21
+ * CSS falls back to the green token. */
22
+ export function hueVars(hue?: number): React.CSSProperties {
23
+ // guard NaN / Infinity from a bad persisted anchor - an invalid custom property would
24
+ // suppress the CSS fallback and leave the edge uncoloured
25
+ if (!Number.isFinite(hue)) return {}
26
+ const h = hue as number
27
+ // --cm-solid is the FILLED-button colour (send / sign-in): a hue-aware lightness so the
28
+ // white glyph keeps contrast - yellow/green/cyan read light, so they go darker.
29
+ const l = (h >= 40 && h < 75) ? 34 : (h >= 75 && h < 165) ? 36 : (h >= 165 && h < 200) ? 38 : 45
30
+ return {
31
+ '--cm-line': `hsl(${h} 95% 45%)`,
32
+ '--cm-line-ring': `hsl(${h} 95% 50% / .30)`,
33
+ '--cm-solid': `hsl(${h} 85% ${l}%)`,
34
+ } as React.CSSProperties
35
+ }
36
+ const anchorHue = (a: unknown): number | undefined => (a as any)?.el?.hue
37
+
17
38
  const rel = (ts: number) => {
18
39
  const m = Math.round((Date.now() - ts) / 60_000)
19
40
  if (m < 1) return 'now'
@@ -53,7 +74,7 @@ function markerDigest(threads: Thread[]): { faces: Face[]; count: number } {
53
74
 
54
75
  /** The face of a marker: up to `max` overlapping avatars + the total count when >1.
55
76
  * One shape for pins (one thread, anchored) and stacks (all threads on a frame). */
56
- function MarkerFace({ threads, max = 3 }: { threads: Thread[]; max?: number }) {
77
+ export function MarkerFace({ threads, max = 3 }: { threads: Thread[]; max?: number }) {
57
78
  const { faces, count } = markerDigest(threads)
58
79
  return (
59
80
  <>
@@ -78,6 +99,9 @@ export function CommentLayer({ node, frameId, iframe }: { node: Node; frameId: s
78
99
 
79
100
  const open = threads.filter((t) => !t.resolved)
80
101
  const anchored = open.filter((t) => (t.anchor as any)?.el)
102
+ // whether the ACTIVE thread is still open on this frame - a dep for the highlight effect so
103
+ // a remote resolve (thread leaves `open` while `active` is unchanged) still clears the lock
104
+ const activeShown = !!active && open.some((t) => t.id === active)
81
105
 
82
106
  // resolve anchors against the live frame whenever threads or the document change
83
107
  useEffect(() => {
@@ -85,7 +109,7 @@ export function CommentLayer({ node, frameId, iframe }: { node: Node; frameId: s
85
109
  if (!win || node.status !== 'ready' || !anchored.length) return
86
110
  const ask = () => win.postMessage({ type: 'sh:resolve-anchors', anchors: anchored.map((t) => ({ key: t.id, anchor: t.anchor })) }, location.origin)
87
111
  const onMsg = (e: MessageEvent) => {
88
- if (e.source !== win || e.data?.type !== 'sh:anchor-rects') return
112
+ if (e.source !== win || e.origin !== location.origin || e.data?.type !== 'sh:anchor-rects') return
89
113
  const next: typeof rects = {}
90
114
  for (const r of e.data.rects ?? []) next[r.key] = r.orphan ? null : r.rect
91
115
  setRects(next)
@@ -96,6 +120,23 @@ export function CommentLayer({ node, frameId, iframe }: { node: Node; frameId: s
96
120
  return () => { clearInterval(iv); window.removeEventListener('message', onMsg) }
97
121
  }, [node.status, anchored.map((t) => t.id).join(','), iframe])
98
122
 
123
+ // #4/#5: drive the persistent element highlight into this frame - the composing draft on
124
+ // this node (lock on pick), or the open thread anchored here (highlight on open). null
125
+ // clears it, so the shell owns the release; on a failed/late resolve the frame re-applies
126
+ // on its next anchor-resolve tick. The self-lock on pick handles the instant case.
127
+ useEffect(() => {
128
+ const win = iframe.current?.contentWindow
129
+ if (!win || node.status !== 'ready') return
130
+ // 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.
133
+ const draftHere = draft?.nodeKey === node.key ? (draft.anchor as any) : null
134
+ 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
136
+ 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])
139
+
99
140
  const drafting = draft?.nodeKey === node.key
100
141
  if (!show || (!open.length && !drafting)) return null
101
142
 
@@ -124,7 +165,7 @@ export function CommentLayer({ node, frameId, iframe }: { node: Node; frameId: s
124
165
  const { x, y, orphan } = pinPos(t)
125
166
  return (
126
167
  <div key={t.id} className={`cm-pin sh-no-pan${t.id === active ? ' on' : ''}${orphan ? ' orphan' : ''}`}
127
- style={{ left: x, top: y }}
168
+ style={{ left: x, top: y, ...hueVars(anchorHue(t.anchor)) }}
128
169
  onPointerDown={(e) => e.stopPropagation()}
129
170
  onClick={(e) => { e.stopPropagation(); setActive(t.id === active ? null : t.id) }}
130
171
  title={orphan ? 'the anchored element is gone - comment parked' : undefined}>
@@ -133,14 +174,16 @@ export function CommentLayer({ node, frameId, iframe }: { node: Node; frameId: s
133
174
  )
134
175
  })}
135
176
  {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} />
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 }} />
137
178
  )}
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} />}
179
+ {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
180
  </>
140
181
  )
141
182
  }
142
183
 
143
- function ThreadCard({ thread, at, node }: { thread: Thread; at: { x: number; y: number }; node: Node }) {
184
+ /** The open thread card - shared by canvas (bounds = node size, `at` in frame coords) and
185
+ * 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 } }) {
144
187
  const { resolve, setActive } = useComments.getState()
145
188
  const me = useComments((s) => s.me)
146
189
  const local = useComments((s) => s.local)
@@ -149,7 +192,7 @@ function ThreadCard({ thread, at, node }: { thread: Thread; at: { x: number; y:
149
192
  const [copied, setCopied] = useState(false)
150
193
  const copyTimer = useRef<ReturnType<typeof setTimeout>>(undefined)
151
194
  useEffect(() => () => clearTimeout(copyTimer.current), [])
152
- const flip = at.x > node.w * 0.55
195
+ const flip = at.x > bounds.w * 0.55
153
196
  // clear only after the server took it - a failed send must not eat the words,
154
197
  // and text typed WHILE the request was in flight must survive the clear
155
198
  const submit = async () => {
@@ -157,7 +200,7 @@ function ThreadCard({ thread, at, node }: { thread: Thread; at: { x: number; y:
157
200
  if (sent.trim() && await useComments.getState().replyOk(thread.id, sent)) setText((cur) => (cur === sent ? '' : cur))
158
201
  }
159
202
  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) }}
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)) }}
161
204
  onPointerDown={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()} onWheel={(e) => e.stopPropagation()}>
162
205
  {/* thread-level actions pin to the card corner, out of the header's flow -
163
206
  the name row never has to share its line with them */}
@@ -220,14 +263,16 @@ function ThreadCard({ thread, at, node }: { thread: Thread; at: { x: number; y:
220
263
  )
221
264
  }
222
265
 
223
- function DraftComposer({ at, node }: { at: { x: number; y: number }; node: Node }) {
266
+ /** The draft composer - shared by canvas and prototype, geometry via `bounds`, tinted with
267
+ * the picked element's hue. */
268
+ export function DraftComposer({ at, bounds, hue }: { at: { x: number; y: number }; bounds: { w: number; h: number }; hue?: number }) {
224
269
  const { create, setDraft } = useComments.getState()
225
270
  const [text, setText] = useState('')
226
271
  const ref = useRef<HTMLInputElement>(null)
227
272
  useEffect(() => { ref.current?.focus() }, [])
228
- const flip = at.x > node.w * 0.55
273
+ const flip = at.x > bounds.w * 0.55
229
274
  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) }}
275
+ <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
276
  onPointerDown={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}>
232
277
  <div className="cm-compose">
233
278
  <div className="cm-inputwrap">