@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
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Shared top-right toolbar pieces (prototype-review Phase 1). The canvas pill and the
3
+ * prototype pill render the SAME controls so they never drift: laser / comment buttons
4
+ * (store-level, zero coupling), a Hide-UI toggle (one shared body class), and controlled
5
+ * Device / Theme dropdowns whose wiring stays local to each surface (canvas mutates
6
+ * selection/board; play mutates play.device/theme). usePopover + Popover live here too so
7
+ * both App and Play share the popover chrome.
8
+ */
9
+ import { createPortal } from 'react-dom'
10
+ import { useEffect, useRef, useState, type ReactNode } from 'react'
11
+ import { useStore, CONFIG, cap } from './store.ts'
12
+ import { useComments } from './comments-store.ts'
13
+ import { Tip } from './Tip.tsx'
14
+ import { CaretIcon, CheckIcon, CommentIcon, DevicesIcon, EyeSlashIcon, FrameCornersIcon, LaserIcon, MoonIcon, SunIcon, deviceIcon } from './icons.tsx'
15
+
16
+ const commentsStore = () => useComments.getState()
17
+
18
+ /** Shared popover machinery: trigger position, outside-click close, portal to the app
19
+ * root (glass never nests - a nested backdrop-filter cannot sample the page). */
20
+ export function usePopover() {
21
+ const [open, setOpen] = useState(false)
22
+ const [pos, setPos] = useState({ left: 0, top: 0 })
23
+ const boxRef = useRef<HTMLDivElement>(null)
24
+ const menuRef = useRef<HTMLDivElement>(null)
25
+ const toggle = () => {
26
+ if (!open && boxRef.current) {
27
+ const r = boxRef.current.getBoundingClientRect()
28
+ setPos({ left: r.left, top: r.bottom + 10 })
29
+ }
30
+ setOpen(!open)
31
+ }
32
+ useEffect(() => {
33
+ if (!open) return
34
+ const close = (e: PointerEvent) => {
35
+ const t = e.target as globalThis.Node
36
+ if (!boxRef.current?.contains(t) && !menuRef.current?.contains(t)) setOpen(false)
37
+ }
38
+ window.addEventListener('pointerdown', close)
39
+ return () => window.removeEventListener('pointerdown', close)
40
+ }, [open])
41
+ return { open, setOpen, pos, boxRef, menuRef, toggle }
42
+ }
43
+
44
+ export function Popover({ pop, children, dark }: { pop: ReturnType<typeof usePopover>; children: ReactNode; dark?: boolean }) {
45
+ const app = document.querySelector('.sh-app')
46
+ if (!pop.open || !app) return null
47
+ return createPortal(
48
+ <div className={`sh-menu${dark ? ' sh-menu-dark' : ''}`} ref={pop.menuRef} style={{ left: pop.pos.left, top: pop.pos.top }}>{children}</div>,
49
+ app,
50
+ )
51
+ }
52
+
53
+ // ---- Hide UI: one shared, binary body class (canvas + prototype) ---------------------
54
+ // The state IS the body class - one source of truth for both pills, so they can never
55
+ // desync. Not persisted, so a page refresh always restores the chrome (the safety net
56
+ // for a forgotten shortcut). toggleHideUI is the only writer; useHideUI subscribes.
57
+
58
+ const hideListeners = new Set<() => void>()
59
+ export const isHideUI = () => document.body.classList.contains('sh-hide-ui')
60
+ export function toggleHideUI() {
61
+ document.body.classList.toggle('sh-hide-ui', !isHideUI())
62
+ hideListeners.forEach((f) => f())
63
+ }
64
+ export function useHideUI() {
65
+ const [on, setOn] = useState(isHideUI)
66
+ useEffect(() => {
67
+ const f = () => setOn(isHideUI())
68
+ hideListeners.add(f)
69
+ return () => { hideListeners.delete(f) }
70
+ }, [])
71
+ return on
72
+ }
73
+
74
+ // ---- shared controls ----------------------------------------------------------------
75
+
76
+ /** Two-row shortcut tooltip: one action per row, key right-aligned - the provenance
77
+ * tooltip's bold-value/muted-key language, shared by every two-shortcut button. */
78
+ function TipRows({ rows }: { rows: [string, string][] }) {
79
+ return (
80
+ <span className="sh-tip-rows">
81
+ {rows.map(([label, key]) => <span className="r" key={key}><b>{label}</b><span>{key}</span></span>)}
82
+ </span>
83
+ )
84
+ }
85
+
86
+ /** Laser toggle - store-level, identical on canvas and prototype. The per-surface code
87
+ * broadcasts sh:laser to its frame(s); this only flips the flag (and drops comment mode,
88
+ * since the two are one-at-a-time). */
89
+ export function LaserButton() {
90
+ const laser = useStore((s) => s.laser)
91
+ const showAnchor = useComments((s) => s.showAnchor)
92
+ return (
93
+ <Tip side="bottom" label={<TipRows rows={[['Laser mode', 'L'], [showAnchor ? 'Hide laser comment' : 'Show laser comment', '⇧L']]} />}>
94
+ <button className={`sh-pill-btn${laser ? ' on' : ''}`} onClick={() => {
95
+ if (!laser) commentsStore().setMode(false)
96
+ useStore.getState().setLaser(!laser)
97
+ }}><LaserIcon size={16} /></button>
98
+ </Tip>
99
+ )
100
+ }
101
+
102
+ /** Comment toggle - store-level, identical on canvas and prototype. */
103
+ export function CommentButton() {
104
+ const commentMode = useComments((s) => s.commentMode)
105
+ const show = useComments((s) => s.show)
106
+ return (
107
+ <Tip side="bottom" label={<TipRows rows={[['Comment', 'C'], [show ? 'Hide pins' : 'Show pins', '⇧C']]} />}>
108
+ <button className={`sh-pill-btn${commentMode ? ' on' : ''}`} onClick={() => {
109
+ const c = commentsStore()
110
+ if (!c.commentMode) useStore.getState().setLaser(false)
111
+ c.setMode(!c.commentMode)
112
+ useStore.getState().toast(c.commentMode ? 'comment mode off' : 'comment mode - click an element in a frame')
113
+ }}><CommentIcon size={16} /></button>
114
+ </Tip>
115
+ )
116
+ }
117
+
118
+ /** Hide-UI toggle - the dedicated binary control (no auto-fade, no hover-reveal). Its
119
+ * tooltip carries the way back; H is the shortcut, wired per-surface. */
120
+ export function HideUIButton() {
121
+ const on = useHideUI()
122
+ return (
123
+ <Tip side="bottom" label={<><b>Hide all UI</b><span>press H to reveal</span></>}>
124
+ <button className={`sh-pill-btn${on ? ' on' : ''}`} onClick={toggleHideUI}><EyeSlashIcon size={16} /></button>
125
+ </Tip>
126
+ )
127
+ }
128
+
129
+ /** Controlled device dropdown. `value` is the active device name ('fill' allowed when
130
+ * includeFill), null = default/mixed. The trigger + menu chrome is shared; the adapter
131
+ * owns what select does. */
132
+ export function DevicePicker({ value, onSelect, includeDefault, includeFill, hint, dark }: {
133
+ value: string | null
134
+ onSelect: (name: string | null) => void
135
+ includeDefault?: boolean
136
+ includeFill?: boolean
137
+ hint?: ReactNode
138
+ dark?: boolean
139
+ }) {
140
+ const pop = usePopover()
141
+ const entries = Object.entries(CONFIG.viewports)
142
+ const pick = (name: string | null) => { onSelect(name); pop.setOpen(false) }
143
+ const triggerIcon = value === 'fill' ? <FrameCornersIcon size={16} /> : deviceIcon(value, 16)
144
+ return (
145
+ <div className="sh-theme" ref={pop.boxRef}>
146
+ <Tip side="bottom" label={<><b>Device view</b>{hint && <span>{hint}</span>}</>}>
147
+ <button className="sh-pill-btn" onClick={pop.toggle}>
148
+ {triggerIcon}
149
+ <CaretIcon size={11} style={{ transform: pop.open ? 'rotate(180deg)' : undefined }} />
150
+ </button>
151
+ </Tip>
152
+ <Popover pop={pop} dark={dark}>
153
+ {includeDefault && <>
154
+ <button onClick={() => pick(null)}>
155
+ <DevicesIcon size={15} /><span>Default</span><kbd>0</kbd>
156
+ {value === null && <CheckIcon size={13} className="chk" />}
157
+ </button>
158
+ <i className="div" />
159
+ </>}
160
+ {entries.map(([name, vp], i) => (
161
+ <button key={name} onClick={() => pick(name)} title={`${vp.width} × ${vp.height}`}>
162
+ {deviceIcon(name)}<span>{cap(name)}</span><kbd>{i < 9 ? i + 1 : ''}</kbd>
163
+ {value === name && <CheckIcon size={13} className="chk" />}
164
+ </button>
165
+ ))}
166
+ {includeFill && <button onClick={() => pick('fill')}>
167
+ <FrameCornersIcon size={15} /><span>Fill window</span><kbd>{entries.length + 1}</kbd>
168
+ {value === 'fill' && <CheckIcon size={13} className="chk" />}
169
+ </button>}
170
+ </Popover>
171
+ </div>
172
+ )
173
+ }
174
+
175
+ /** Controlled theme dropdown. `value` drives the trigger (the level the picker acts on -
176
+ * the scope MAJORITY on the canvas); `checked` is the menu tick and can differ (undefined
177
+ * = tick `value`; null = tick nothing, e.g. a mixed selection). onSelect owns the write. */
178
+ export function ThemePicker({ value, checked, onSelect, hint, dark }: { value: string; checked?: string | null; onSelect: (t: string) => void; hint?: ReactNode; dark?: boolean }) {
179
+ const pop = usePopover()
180
+ const pick = (t: string) => { onSelect(t); pop.setOpen(false) }
181
+ const tick = checked === undefined ? value : checked
182
+ return (
183
+ <div className="sh-theme" ref={pop.boxRef}>
184
+ <Tip side="bottom" label={<><b>Theme</b>{hint && <span>{hint}</span>}</>}>
185
+ <button className="sh-pill-btn" onClick={pop.toggle}>
186
+ {value === 'dark' ? <MoonIcon size={16} /> : <SunIcon size={16} />}
187
+ <CaretIcon size={11} style={{ transform: pop.open ? 'rotate(180deg)' : undefined }} />
188
+ </button>
189
+ </Tip>
190
+ <Popover pop={pop} dark={dark}>
191
+ {CONFIG.themes.map((t) => (
192
+ <button key={t} onClick={() => pick(t)}>
193
+ {t === 'dark' ? <MoonIcon size={15} /> : <SunIcon size={15} />}
194
+ <span>{t}</span>
195
+ {tick === t && <CheckIcon size={13} className="chk" />}
196
+ </button>
197
+ ))}
198
+ </Popover>
199
+ </div>
200
+ )
201
+ }
@@ -1,6 +1,6 @@
1
1
  import { memo, useCallback, useEffect, useRef } from 'react'
2
2
  import { cap, frameUrl, useStore, CONFIG, type Node } from '../store.ts'
3
- import { CopyIcon, IntentGlyph, ReloadIcon, XIcon } from '../icons.tsx'
3
+ import { CopyIcon, IntentGlyph, ParallelogramFillIcon, ReloadIcon, XIcon } from '../icons.tsx'
4
4
  import { CommentLayer } from '../Comments.tsx'
5
5
  import { useComments } from '../comments-store.ts'
6
6
  import { registerFrame, unregisterFrame } from './frame-registry.ts'
@@ -10,6 +10,24 @@ import { registerLeanFrame, dropSnapshot, scheduleCapture, invalidateLean } from
10
10
  export const HEADER = 28
11
11
  const SNAP = 12
12
12
 
13
+ /** Live Jam working shimmer (SPEC §10): a slim 2x6 strip of tiny marver marks on the frame's left
14
+ * flank, top-aligned - each mark twinkles on its own scattered beat, phased per frame by
15
+ * --mv-w0 (set on the node) so parallel frames never pulse in sync. */
16
+ const SHIM_DELAYS = Array.from({ length: 12 }, (_, i) => {
17
+ const r = Math.floor(i / 2), c = i % 2
18
+ return (((r * 7 + c * 13) % 9) / 9) * 0.95
19
+ })
20
+ function WorkShimmer({ belowBadge }: { belowBadge: boolean }) {
21
+ // a variant badge owns the top of the left flank - the shimmer yields and sits below it
22
+ return (
23
+ <div className={`sh-work-ind${belowBadge ? ' below-vbadge' : ''}`} aria-hidden>
24
+ {SHIM_DELAYS.map((d, i) => (
25
+ <ParallelogramFillIcon key={i} size={5} style={{ animationDelay: `calc(var(--mv-w0, 0s) + ${d.toFixed(2)}s)` }} />
26
+ ))}
27
+ </div>
28
+ )
29
+ }
30
+
13
31
  /**
14
32
  * One frame on the canvas. Iframe laws (spec §7): the iframe element is created once per node key
15
33
  * and never remounted - theme changes go through sh:set-theme, size changes are CSS only.
@@ -23,6 +41,8 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
23
41
  const frame = useStore((s) => s.frameFor(node))
24
42
  const selected = useStore((s) => s.selection.includes(node.key))
25
43
  const interact = useStore((s) => s.interact === node.key)
44
+ const working = useStore((s) => s.working.includes(node.frame)) // Live Jam: Marver is editing this frame
45
+ const workingSince = useStore((s) => s.workingSince[node.frame])
26
46
  // B0.1: no reactive scale subscription - it re-rendered every FrameNode on every
27
47
  // pan/zoom tick. gestureScale below measures the world rect (the canonical source,
28
48
  // Law G-5); the stored scale is only a never-hit fallback, read lazily at drag time.
@@ -81,7 +101,16 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
81
101
  if (node.status !== 'ready' || node.missing) return
82
102
  const iframe = iframeRef.current
83
103
  if (!iframe) return
84
- const t = setTimeout(() => scheduleCapture(node.key, iframe, { sourceRevision: String(node.nav ?? 0), theme: node.theme }), 450)
104
+ const t = setTimeout(() => {
105
+ // never re-admit a cover while this frame hosts an open thread / draft: the live app
106
+ // must stay visible (its highlight updates in real time). A status/theme change would
107
+ // otherwise capture the live DOM WITH the highlight baked in and re-cover it. The
108
+ // hostsCard rail recaptures a clean lean once the card closes.
109
+ const c = useComments.getState()
110
+ const hosting = (!!c.active && c.threads.some((th) => th.id === c.active && th.nodeKey === node.key && !th.resolved)) || c.draft?.nodeKey === node.key
111
+ if (hosting) return
112
+ scheduleCapture(node.key, iframe, { sourceRevision: String(node.nav ?? 0), theme: node.theme })
113
+ }, 450)
85
114
  return () => clearTimeout(t)
86
115
  // node.theme IS a dep: baked content (mermaid SVG) can't be re-themed by the cover's attribute
87
116
  // flip, so a theme change re-captures after the live frame re-renders (key includes theme).
@@ -111,16 +140,33 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
111
140
  // neighbors - each node is a stacking context, so an overflowing card would
112
141
  // otherwise paint under the next frame
113
142
  const hostsCard = useComments((s) =>
114
- (!!s.active && s.threads.some((t) => t.id === s.active && t.nodeKey === node.key)) || s.draft?.nodeKey === node.key)
143
+ (!!s.active && s.threads.some((t) => t.id === s.active && t.nodeKey === node.key && !t.resolved)) || s.draft?.nodeKey === node.key)
144
+ // a frame hosting an OPEN thread or a draft must show its LIVE app, not the frozen lean
145
+ // cover: the active-element highlight lives in the live DOM and updates in real time
146
+ // (open -> lit, close -> cleared). Without this the cover re-freezes the moment comment
147
+ // mode ends and either bakes a stale highlight or hides the live one. Mirror the interact
148
+ // rail: drop the cover while hosting, rebuild a fresh lean once the card closes (the
149
+ // highlight is cleared by then, so the recapture is clean).
150
+ const prevHostsCard = useRef(hostsCard)
151
+ useEffect(() => {
152
+ if (prevHostsCard.current === hostsCard) return
153
+ const wasHosting = prevHostsCard.current
154
+ prevHostsCard.current = hostsCard
155
+ if (hostsCard) invalidateLean(node.key)
156
+ else if (wasHosting && node.status === 'ready' && iframeRef.current)
157
+ scheduleCapture(node.key, iframeRef.current, { sourceRevision: String(node.nav ?? 0), theme: node.theme }, true)
158
+ }, [hostsCard, node.key, node.nav, node.theme, node.status])
115
159
  useEffect(() => {
116
160
  if (node.status === 'ready' || !laser)
117
161
  iframeRef.current?.contentWindow?.postMessage({ type: 'sh:laser', on: laser }, location.origin)
118
162
  }, [laser, node.status])
119
- // comment mode = pick mode in the frame (late loaders join like laser does)
163
+ // comment mode = pick mode in the frame (late loaders join like laser does); quiet when
164
+ // laser comment (⇧L) is off - clicks still anchor, no lighting in the artwork
165
+ const showAnchor = useComments((s) => s.showAnchor)
120
166
  useEffect(() => {
121
167
  if (node.status === 'ready' || !commentMode)
122
- iframeRef.current?.contentWindow?.postMessage({ type: 'sh:pick', on: commentMode }, location.origin)
123
- }, [commentMode, node.status])
168
+ iframeRef.current?.contentWindow?.postMessage({ type: 'sh:pick', on: commentMode, quiet: !showAnchor }, location.origin)
169
+ }, [commentMode, showAnchor, node.status])
124
170
  // B0.2: the interact target owns its own wheel (app scrolls); passive frames forward
125
171
  // wheel to the canvas. Replayed on ready like laser/pick so a reload restores truth.
126
172
  useEffect(() => {
@@ -263,11 +309,15 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
263
309
 
264
310
  return (
265
311
  <div
266
- className={`sh-node${selected ? ' sel' : ''}${interact ? ' interact' : ''}`}
312
+ className={`sh-node${selected ? ' sel' : ''}${interact ? ' interact' : ''}${working ? ' working' : ''}`}
267
313
  data-theme={node.theme}
268
- style={{ transform: `translate(${node.x}px, ${node.y}px)`, width: node.w, height: node.h + HEADER, zIndex: hostsCard ? 30 : undefined }}
314
+ style={{ transform: `translate(${node.x}px, ${node.y}px)`, width: node.w, height: node.h + HEADER, zIndex: hostsCard ? 30 : undefined,
315
+ // phase every working animation by when THIS frame's job started (SPEC §10) - parallel
316
+ // frames pulsing in sync would read as one fake choreography
317
+ ...(working ? { ['--mv-w0' as string]: `${-(Date.now() - (workingSince ?? Date.now()))}ms` } : {}) }}
269
318
  data-node={node.key}
270
319
  >
320
+ {working && <WorkShimmer belowBadge={!!frame.variantGroup} />}
271
321
  {frame.variantGroup && (
272
322
  <div className="sh-vbadge sh-no-pan" title={`${frame.variantGroup} · variant ${frame.variant?.toUpperCase()} - click to select`}
273
323
  onPointerDown={(e) => e.stopPropagation()}
@@ -324,6 +374,7 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
324
374
  onDoubleClick={(e) => { e.stopPropagation(); setInteract(node.key) }}
325
375
  />
326
376
  )}
377
+ {working && <div className="sh-work-wave" />}
327
378
  </div>
328
379
 
329
380
  {/* comments live OUTSIDE the clipped body: a card or pin near the frame edge
@@ -16,8 +16,10 @@ interface CommentsState {
16
16
  board: string | null
17
17
  me: Me | null // null on published until signed in
18
18
  local: boolean // dev mirror (identity is the local profile)
19
+ connected: boolean // dev only: a connect account provides name/email (read-only here)
19
20
  commentMode: boolean // C - picking + composing
20
21
  show: boolean // Shift+C - pins visible at all
22
+ showAnchor: boolean // Shift+L - light the tagged ELEMENT while its thread is open
21
23
  active: string | null // open thread id
22
24
  draft: { nodeKey: string; frame: string; anchor: unknown } | null // picked, composing
23
25
  needsIdentity: boolean // published viewer tried to comment while signed out
@@ -25,6 +27,9 @@ interface CommentsState {
25
27
 
26
28
  load(board: string): Promise<void>
27
29
  live(board: string): () => void
30
+ /** Fetch the active board's log NOW (Live Jam: the daemon just wrote a reply) - same union path
31
+ * as the poll, so dedup + the reply notification behave identically. */
32
+ poke(board?: string): void
28
33
  send(events: CommentEvent[]): Promise<boolean>
29
34
  create(body: string): Promise<void>
30
35
  reply(threadId: string, body: string): Promise<void>
@@ -32,11 +37,12 @@ interface CommentsState {
32
37
  resolve(threadId: string, reopen?: boolean): Promise<void>
33
38
  setMode(on: boolean): void
34
39
  setShow(show: boolean): void
40
+ setShowAnchor(on: boolean): void
35
41
  setActive(id: string | null): void
36
42
  setDraft(d: CommentsState['draft']): void
37
43
  signIn(email: string, password: string): Promise<string | null>
38
44
  claim(token: string, password: string, name: string, avatar?: string): Promise<string | null>
39
- saveProfile(patch: Partial<Me>): Promise<void>
45
+ saveProfile(patch: Partial<Me>): Promise<string | null>
40
46
  dismissIdentity(): void
41
47
  }
42
48
 
@@ -52,20 +58,49 @@ const api = async (path: string, body?: unknown) => {
52
58
 
53
59
  export const useComments = create<CommentsState>((set, get) => {
54
60
  const derive = (events: CommentEvent[]) => ({ events, threads: replay(events) })
61
+ // Live Jam (SPEC §9): a Marver reply that arrives AFTER the initial load (load() baselines via
62
+ // derive, so pre-existing replies never notify) raises a persistent bottom-right pill. Keyed on
63
+ // the event id via `union`'s fresh filter, so it fires exactly once; active-board only by
64
+ // construction (union runs against the active board's poll).
65
+ const notifyAgent = (fresh: CommentEvent[]) => {
66
+ const replies = fresh.filter((e) => e.agent && e.type === 'reply')
67
+ if (!replies.length) return
68
+ void import('./store.ts').then(({ useStore }) => {
69
+ const board = get().board ?? ''
70
+ const s = useStore.getState()
71
+ for (const e of replies) {
72
+ const threadId = e.parentId ?? e.commentId ?? ''
73
+ // the FRAME is the news: resolve the reply's thread -> frame -> manifest title + intent
74
+ const frame = get().threads.find((t) => t.id === threadId)?.frame
75
+ const entry = frame ? s.manifest?.frames.find((f) => f.id === frame) : undefined
76
+ s.jamToast({
77
+ threadId, board, ts: e.ts,
78
+ preview: (e.body ?? '').replace(/\s+/g, ' ').slice(0, 90),
79
+ frame, frameTitle: entry?.title ?? frame, intent: entry?.intent,
80
+ })
81
+ }
82
+ })
83
+ }
55
84
  const union = (events: CommentEvent[]) => {
56
85
  const have = new Set(get().events.map((e) => e.id))
57
86
  const fresh = events.filter((e) => !have.has(e.id))
58
- if (fresh.length) set(derive([...get().events, ...fresh]))
87
+ if (fresh.length) { set(derive([...get().events, ...fresh])); notifyAgent(fresh) }
59
88
  }
60
89
 
61
90
  return {
62
- events: [], threads: [], board: null, me: null, local: false,
63
- commentMode: false, show: true, active: null, draft: null, needsIdentity: false, inviteToken: null,
91
+ events: [], threads: [], board: null, me: null, local: false, connected: false,
92
+ commentMode: false, show: true, showAnchor: true, active: null, draft: null, needsIdentity: false, inviteToken: null,
93
+
94
+ poke(board) {
95
+ const b = get().board
96
+ if (!b || (board && board !== b)) return // only the active board renders; others load on switch
97
+ void api(`comments/${b}`).then((r) => { if (r.ok && get().board === b) union(r.data.events ?? []) })
98
+ },
64
99
 
65
100
  async load(board) {
66
101
  set({ board })
67
102
  const me = await api('me')
68
- if (me.ok) set({ me: me.data.user ?? null, local: !!me.data.local })
103
+ if (me.ok) set({ me: me.data.user ?? null, local: !!me.data.local, connected: !!me.data.connected })
69
104
  const res = await api(`comments/${board}`)
70
105
  if (res.ok && get().board === board) set(derive(res.data.events ?? []))
71
106
  },
@@ -144,6 +179,7 @@ export const useComments = create<CommentsState>((set, get) => {
144
179
 
145
180
  setMode(on) { set({ commentMode: on, ...(on ? { show: true } : { draft: null }) }) },
146
181
  setShow(show) { set({ show }) },
182
+ setShowAnchor(showAnchor) { set({ showAnchor }) },
147
183
  setActive(active) { set({ active }) },
148
184
  setDraft(draft) { set({ draft }) },
149
185
  dismissIdentity() { set({ needsIdentity: false }) }, // inviteToken survives dismissal - commenting later reopens the claim
@@ -162,7 +198,9 @@ export const useComments = create<CommentsState>((set, get) => {
162
198
  },
163
199
  async saveProfile(patch) {
164
200
  const res = await api('profile', patch)
165
- if (res.ok) set({ me: res.data.user })
201
+ if (!res.ok) return res.data?.error ?? 'could not save - try again'
202
+ set({ me: res.data.user })
203
+ return null
166
204
  },
167
205
  }
168
206
  })
@@ -173,6 +211,9 @@ if (typeof window !== 'undefined' && (import.meta as any).env?.DEV) (window as a
173
211
  /** Initials + deterministic hue for avatarless authors - the whole fallback ladder. */
174
212
  export const avatarFallback = (author?: { email?: string; name?: string }) => {
175
213
  const name = author?.name || author?.email || '?'
214
+ // the unset dev default ("You", no account) renders in the COMMENT green - it's the mode's
215
+ // own hue, not a fake identity color, until a real profile is set
216
+ if (name === 'You' && !author?.email) return { initials: 'Y', hue: 131 }
176
217
  const initials = name.split(/\s+/).map((w) => w[0]).slice(0, 2).join('').toUpperCase()
177
218
  let h = 0
178
219
  for (const c of (author?.email || name)) h = (h * 31 + c.charCodeAt(0)) % 360
@@ -30,6 +30,7 @@ export const CheckIcon = icon(<path d="M229.66,77.66l-128,128a8,8,0,0,1-11.32,0l
30
30
  export const ColumnsIcon = icon(<path d="M104,32H64A16,16,0,0,0,48,48V208a16,16,0,0,0,16,16h40a16,16,0,0,0,16-16V48A16,16,0,0,0,104,32Zm0,176H64V48h40ZM192,32H152a16,16,0,0,0-16,16V208a16,16,0,0,0,16,16h40a16,16,0,0,0,16-16V48A16,16,0,0,0,192,32Zm0,176H152V48h40Z" />)
31
31
  export const ArrowUpIcon = icon(<path d="M205.66,117.66a8,8,0,0,1-11.32,0L136,59.31V216a8,8,0,0,1-16,0V59.31L61.66,117.66a8,8,0,0,1-11.32-11.32l72-72a8,8,0,0,1,11.32,0l72,72A8,8,0,0,1,205.66,117.66Z" />)
32
32
  export const CommentIcon = icon(<path d="M132,24A100.11,100.11,0,0,0,32,124v84a16,16,0,0,0,16,16h84a100,100,0,0,0,0-200Zm0,184H48V124a84,84,0,1,1,84,84Z" />)
33
+ export const EyeSlashIcon = icon(<path d="M53.92,34.62A8,8,0,1,0,42.08,45.38L61.32,66.55C25,88.84,9.38,123.2,8.69,124.76a8,8,0,0,0,0,6.5c.35.79,8.82,19.57,27.65,38.4C61.43,194.74,93.12,208,128,208a127.11,127.11,0,0,0,52.07-11.07l22,24.21a8,8,0,1,0,11.84-10.76Zm47.33,75.84,41.67,45.85a32,32,0,0,1-41.67-45.85ZM128,192c-30.78,0-57.67-11.19-79.93-33.25A133.47,133.47,0,0,1,25,128c4.69-8.79,19.66-33.39,47.35-49.38l18,19.75a48,48,0,0,0,63.66,70l14.73,16.2A112,112,0,0,1,128,192Zm6-95.43a8,8,0,0,1,3-15.72,48.16,48.16,0,0,1,38.77,42.64,8,8,0,0,1-7.22,8.71,6.39,6.39,0,0,1-.75,0,8,8,0,0,1-8-7.26A32.09,32.09,0,0,0,134,96.57Zm113.28,34.69c-.42.94-10.55,23.37-33.36,43.8a8,8,0,1,1-10.67-11.92A132.77,132.77,0,0,0,231,128a133.15,133.15,0,0,0-23.12-30.77C185.67,75.19,158.78,64,128,64a118.37,118.37,0,0,0-19.36,1.57A8,8,0,1,1,106,49.79,134,134,0,0,1,128,48c34.88,0,66.57,13.26,91.66,38.35,18.83,18.83,27.3,37.62,27.65,38.41A8,8,0,0,1,247.31,131.26Z" />)
33
34
  export const CheckSquareOffsetIcon = icon(<path d="M224,48V208a16,16,0,0,1-16,16H136a8,8,0,0,1,0-16h72V48H48v96a8,8,0,0,1-16,0V48A16,16,0,0,1,48,32H208A16,16,0,0,1,224,48ZM125.66,154.34a8,8,0,0,0-11.32,0L64,204.69,45.66,186.34a8,8,0,0,0-11.32,11.32l24,24a8,8,0,0,0,11.32,0l56-56A8,8,0,0,0,125.66,154.34Z" />)
34
35
  export const DevicesIcon = icon(<path d="M224,72H208V64a24,24,0,0,0-24-24H40A24,24,0,0,0,16,64v96a24,24,0,0,0,24,24H152v8a24,24,0,0,0,24,24h48a24,24,0,0,0,24-24V96A24,24,0,0,0,224,72ZM40,168a8,8,0,0,1-8-8V64a8,8,0,0,1,8-8H184a8,8,0,0,1,8,8v8H176a24,24,0,0,0-24,24v72Zm192,24a8,8,0,0,1-8,8H176a8,8,0,0,1-8-8V96a8,8,0,0,1,8-8h48a8,8,0,0,1,8,8Zm-96,16a8,8,0,0,1-8,8H88a8,8,0,0,1,0-16h40A8,8,0,0,1,136,208Zm80-96a8,8,0,0,1-8,8H192a8,8,0,0,1,0-16h16A8,8,0,0,1,216,112Z" />)
35
36
  export const DeviceMobileIcon = icon(<path d="M176,16H80A24,24,0,0,0,56,40V216a24,24,0,0,0,24,24h96a24,24,0,0,0,24-24V40A24,24,0,0,0,176,16ZM72,64H184V192H72Zm8-32h96a8,8,0,0,1,8,8v8H72V40A8,8,0,0,1,80,32Zm96,192H80a8,8,0,0,1-8-8v-8H184v8A8,8,0,0,1,176,224Z" />)
@@ -39,12 +40,16 @@ export const MonitorIcon = icon(<path d="M208,40H48A24,24,0,0,0,24,64V176a24,24,
39
40
  export const TvIcon = icon(<path d="M216,64H147.31l34.35-34.34a8,8,0,1,0-11.32-11.32L128,60.69,85.66,18.34A8,8,0,0,0,74.34,29.66L108.69,64H40A16,16,0,0,0,24,80V200a16,16,0,0,0,16,16H216a16,16,0,0,0,16-16V80A16,16,0,0,0,216,64Zm0,136H40V80H216V200Z" />)
40
41
  export const SignpostIcon = icon(<path d="M246,106.65,212.33,69.3A16,16,0,0,0,200.44,64H136V32a8,8,0,0,0-16,0V64H40A16,16,0,0,0,24,80v64a16,16,0,0,0,16,16h80v64a8,8,0,0,0,16,0V160h64.44a16,16,0,0,0,11.89-5.3L246,117.35A8,8,0,0,0,246,106.65ZM200.44,144H40V80H200.44l28.8,32Z" />)
41
42
  export const ParallelogramDuoIcon = icon(<><path d="M239.29,59.28l-64.8,144a8,8,0,0,1-7.3,4.72H24a8,8,0,0,1-7.3-11.28l64.8-144A8,8,0,0,1,88.81,48H232A8,8,0,0,1,239.29,59.28Z" opacity=".1" /><path d="M245.43,47.31A15.94,15.94,0,0,0,232,40H88.81a16,16,0,0,0-14.59,9.43l-64.8,144A16,16,0,0,0,24,216H167.19a16,16,0,0,0,14.59-9.43l64.8-144A16,16,0,0,0,245.43,47.31ZM167.19,200H24L88.81,56H232Z" /></>)
43
+ /** The marver mark, SOLID fill (for the Marver avatar - a filled white parallelogram on accent). */
44
+ export const ParallelogramFillIcon = icon(<path d="M245.43,47.31A15.94,15.94,0,0,0,232,40H88.81a16,16,0,0,0-14.59,9.43l-64.8,144A16,16,0,0,0,24,216H167.19a16,16,0,0,0,14.59-9.43l64.8-144A16,16,0,0,0,245.43,47.31Z" />)
42
45
  /** Sidebar trigger, custom in Phosphor 256-space: outer frame + panel pill -
43
46
  * pill filled while the sidebar is open, hollow while collapsed. */
44
47
  export const PanelFilledIcon = icon(<><rect x="32" y="48" width="192" height="160" rx="32" fill="none" stroke="currentColor" strokeWidth="16" /><rect x="72" y="88" width="48" height="80" rx="24" /></>)
45
48
 
46
49
  /** Plain UI frame - the rectangle every non-content sidebar row leads with. */
47
50
  export const FrameRectIcon = icon(<rect x="40" y="52" width="176" height="152" rx="20" fill="none" stroke="currentColor" strokeWidth="16" />)
51
+ export const UserIcon = icon(<path d="M230.92,212c-15.23-26.33-38.7-45.21-66.09-54.16a72,72,0,1,0-73.66,0C63.78,166.78,40.31,185.66,25.08,212a8,8,0,1,0,13.85,8c18.84-32.56,52.14-52,89.07-52s70.23,19.44,89.07,52a8,8,0,1,0,13.85-8ZM72,96a56,56,0,1,1,56,56A56.06,56.06,0,0,1,72,96Z" />)
52
+ export const PencilSimpleIcon = icon(<path d="M227.31,73.37,182.63,28.68a16,16,0,0,0-22.63,0L36.69,152A15.86,15.86,0,0,0,32,163.31V208a16,16,0,0,0,16,16H92.69A15.86,15.86,0,0,0,104,219.31L227.31,96a16,16,0,0,0,0-22.63ZM92.69,208H48V163.31l88-88L180.69,120ZM192,108.68,147.31,64l24-24L216,84.68Z" />)
48
53
  export const LinkIcon = icon(<path d="M137.54,186.36a8,8,0,0,1,0,11.31l-9.94,10A56,56,0,0,1,48.38,128.4L72.5,104.28A56,56,0,0,1,149.31,102a8,8,0,1,1-10.64,12,40,40,0,0,0-54.85,1.63L59.7,139.72a40,40,0,0,0,56.58,56.58l9.94-9.94A8,8,0,0,1,137.54,186.36Zm70.08-138a56.08,56.08,0,0,0-79.22,0l-9.94,9.95a8,8,0,0,0,11.32,11.31l9.94-9.94a40,40,0,0,1,56.58,56.58L172.18,140.4a40,40,0,0,1-54.85,1.6,8,8,0,1,0-10.64,12,56,56,0,0,0,76.81-2.26l24.12-24.12A56.08,56.08,0,0,0,207.62,48.38Z" />)
49
54
  export const LaserIcon = icon(<><circle cx="128" cy="128" r="56" fill="none" stroke="currentColor" strokeWidth="16" /><path d="M128 24 V56 M128 200 V232 M24 128 H56 M200 128 H232" fill="none" stroke="currentColor" strokeWidth="16" strokeLinecap="round" /><circle cx="128" cy="128" r="12" /></>)
50
55
 
@@ -0,0 +1,18 @@
1
+ /**
2
+ * @marver mention parsing (SPEC-live-jam §1) - pure, so the rendering is unit-testable without
3
+ * pulling the whole comment UI (and its virtual:sh-config) into the test.
4
+ */
5
+
6
+ /** Split a comment body into plain text and @marver mention segments (case-insensitive, word-bounded). */
7
+ export function parseMentions(body: string): { text: string; mention: boolean }[] {
8
+ const out: { text: string; mention: boolean }[] = []
9
+ let last = 0
10
+ for (const m of body.matchAll(/@marver\b/gi)) {
11
+ const i = m.index ?? 0
12
+ if (i > last) out.push({ text: body.slice(last, i), mention: false })
13
+ out.push({ text: m[0], mention: true })
14
+ last = i + m[0].length
15
+ }
16
+ if (last < body.length) out.push({ text: body.slice(last), mention: false })
17
+ return out.length ? out : [{ text: body, mention: false }]
18
+ }
@@ -33,7 +33,10 @@ export interface Node {
33
33
  sizeMode?: 'auto' | 'manual' | 'device'
34
34
  status: 'loading' | 'ready' | 'error'; error?: string; missing?: boolean
35
35
  }
36
- export interface Toast { id: number; text: string }
36
+ /** A Live Jam notification (SPEC-live-jam §9): a persistent bottom-right glass pill for a Marver
37
+ * reply. Frame-first: the FRAME is the news (icon + title, blue), Marver + preview below. */
38
+ export interface JamNote { threadId: string; board: string; preview: string; ts: number; frame?: string; frameTitle?: string; intent?: string }
39
+ export interface Toast { id: number; text: string; jam?: JamNote }
37
40
 
38
41
  export const CONFIG: { viewports: Record<string, { width: number; height: number }>; themes: string[]; zoomSpeed?: number; noTheme: boolean; setup?: boolean; projectName?: string } = shConfig
39
42
 
@@ -169,6 +172,15 @@ interface State {
169
172
  setTheme(theme: string): void
170
173
  runTidy(): void
171
174
  toast(text: string): void
175
+ jamToast(note: JamNote): void
176
+ dismissToast(id: number): void
177
+ clearJamToasts(): void
178
+ /** Frames Marver is editing right now (Live Jam presence, SPEC §10) - drives the working glow. */
179
+ working: string[]
180
+ /** When each working frame's job started (ms epoch) - phases the animations so parallel
181
+ * frames never pulse in sync. */
182
+ workingSince: Record<string, number>
183
+ setWorking(frames: string[]): void
172
184
  spawn(frameId: string): Node | null
173
185
  save(): Promise<boolean>
174
186
  }
@@ -420,7 +432,7 @@ export const useStore = create<State>((set, get) => {
420
432
  return {
421
433
  manifest: null, nodes: [], selection: [], interact: null, viewTheme: initialViewTheme(), play: null, gesture: false, laser: false,
422
434
  board: DATA?.default ?? 'all-scenes', boardAuto: (DATA?.default ?? 'all-scenes') === 'all-scenes', deviceView: null, sceneRows: null, layout: null, layoutRaw: undefined, baseLayout: null,
423
- panelOpen: true, scale: 1, toasts: [], boardHash: null, dirty: false,
435
+ panelOpen: true, scale: 1, toasts: [], working: [], workingSince: {}, boardHash: null, dirty: false,
424
436
  pendingFrameRevisions: {}, externalLeases: {}, playUpdateRevision: null, playNav: 0,
425
437
 
426
438
  async boot() {
@@ -833,6 +845,22 @@ export const useStore = create<State>((set, get) => {
833
845
  set((s) => ({ toasts: [...s.toasts, { id, text }] }))
834
846
  setTimeout(() => set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) })), 4000)
835
847
  },
848
+ /** A Live Jam reply notification: persistent (no auto-dismiss), one per thread (a newer reply
849
+ * supersedes), capped so the stack stays small. */
850
+ jamToast(note) {
851
+ const id = ++toastSeq
852
+ set((s) => ({ toasts: [...s.toasts.filter((t) => t.jam?.threadId !== note.threadId), { id, text: 'Marver replied', jam: note }].slice(-8) }))
853
+ },
854
+ dismissToast(id) { set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) })) },
855
+ clearJamToasts() { set((s) => ({ toasts: s.toasts.filter((t) => !t.jam) })) },
856
+ setWorking(frames) {
857
+ // preserve each frame's original start time; stamp now() only for newly-working frames -
858
+ // the start phases the working animations so parallel frames never pulse in sync
859
+ const prev = get().workingSince
860
+ const workingSince: Record<string, number> = {}
861
+ for (const f of frames) workingSince[f] = prev[f] ?? Date.now()
862
+ set({ working: frames, workingSince })
863
+ },
836
864
  spawn(frameId) {
837
865
  const { manifest, nodes, deviceView, baseLayout } = get()
838
866
  const f = manifest?.frames.find((x) => x.id === frameId)