@marver-design/marver 0.7.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 (34) hide show
  1. package/CHANGELOG.md +46 -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/inspect.js +15 -2
  19. package/src/client/shell/App.tsx +91 -9
  20. package/src/client/shell/Comments.tsx +418 -68
  21. package/src/client/shell/Play.tsx +10 -6
  22. package/src/client/shell/Toolbar.tsx +14 -2
  23. package/src/client/shell/canvas/FrameNode.tsx +33 -6
  24. package/src/client/shell/comments-store.ts +47 -6
  25. package/src/client/shell/icons.tsx +4 -0
  26. package/src/client/shell/mentions.ts +18 -0
  27. package/src/client/shell/store.ts +30 -2
  28. package/src/client/shell/styles.css +294 -21
  29. package/src/shared/events.ts +22 -5
  30. package/templates/AGENTS-embedded.md +1 -0
  31. package/templates/AGENTS-studio.md +1 -0
  32. package/templates/instructions/configure.md +15 -0
  33. package/templates/instructions/jam.md +85 -0
  34. package/dist/rolldown-runtime-D7D4PA-g.mjs +0 -13
@@ -125,6 +125,7 @@ function PlayComments({ iframe, frameId, vp, dw, dh, ready }: {
125
125
  dw: number; dh: number; ready: number
126
126
  }) {
127
127
  const show = useComments((s) => s.show)
128
+ const showAnchor = useComments((s) => s.showAnchor)
128
129
  const active = useComments((s) => s.active)
129
130
  const draft = useComments((s) => s.draft)
130
131
  const allThreads = useComments((s) => s.threads)
@@ -165,11 +166,12 @@ function PlayComments({ iframe, frameId, vp, dw, dh, ready }: {
165
166
  useEffect(() => {
166
167
  const win = iframe.current?.contentWindow
167
168
  if (!win) return
168
- // only while the thread is active (or composing) AND pins are shown - closing or ⇧C clears it
169
- const at = !show ? null : (draft?.frame === frameId ? draft.anchor
169
+ // only while the thread is active (or composing) AND pins are shown AND element focus is
170
+ // on (⇧L) - closing, ⇧C, or dimming focus clears it
171
+ const at = !show || !showAnchor ? null : (draft?.frame === frameId ? draft.anchor
170
172
  : (active ? (threads.find((t) => t.id === active && (t.anchor as any)?.el)?.anchor ?? null) : null))
171
173
  win.postMessage({ type: 'sh:highlight-anchor', frame: frameId, anchor: at ?? null }, location.origin)
172
- }, [active, draft, show, frameId, iframe, rects, ready])
174
+ }, [active, draft, show, showAnchor, frameId, iframe, rects, ready])
173
175
 
174
176
  if (!show) return null
175
177
 
@@ -201,7 +203,7 @@ function PlayComments({ iframe, frameId, vp, dw, dh, ready }: {
201
203
  </div>
202
204
  )
203
205
  })}
204
- {activeThread && <ThreadCard key={active!} thread={activeThread} at={pinAt(activeThread)} bounds={{ w: dw, h: dh }} />}
206
+ {activeThread && <ThreadCard key={active!} thread={activeThread} at={pinAt(activeThread)} bounds={{ w: dw, h: dh }} stage />}
205
207
  {draftAt && <DraftComposer at={draftAt} bounds={{ w: dw, h: dh }} hue={(draft?.anchor as any)?.el?.hue} />}
206
208
  </div>
207
209
  )
@@ -218,6 +220,7 @@ function PlayInner() {
218
220
  const playNav = useStore((s) => s.playNav)
219
221
  const laser = useStore((s) => s.laser)
220
222
  const commentMode = useComments((s) => s.commentMode)
223
+ const playShowAnchor = useComments((s) => s.showAnchor)
221
224
  const iframeRef = useRef<HTMLIFrameElement>(null)
222
225
  // the src is frozen at mount - navigation happens INSIDE the stage; device and theme
223
226
  // changes must never reload it (a phone does not remount when you flip dark mode)
@@ -244,7 +247,7 @@ function PlayInner() {
244
247
  // laser / comment ride the same rail as canvas frames, broadcast to the stage iframe.
245
248
  // Re-sent on stage-ready (below) so a stage reload restores the mode.
246
249
  useEffect(() => { postStage({ type: 'sh:laser', on: laser }) }, [laser])
247
- useEffect(() => { postStage({ type: 'sh:pick', on: commentMode }) }, [commentMode])
250
+ useEffect(() => { postStage({ type: 'sh:pick', on: commentMode, quiet: !useComments.getState().showAnchor }) }, [commentMode, playShowAnchor])
248
251
 
249
252
  const exit = () => {
250
253
  const { at } = useStore.getState().play ?? {}
@@ -333,7 +336,7 @@ function PlayInner() {
333
336
  postStage({ type: 'sh:set-theme', theme: p.theme })
334
337
  }
335
338
  postStage({ type: 'sh:laser', on: s.laser })
336
- postStage({ type: 'sh:pick', on: commentsStore().commentMode })
339
+ postStage({ type: 'sh:pick', on: commentsStore().commentMode, quiet: !commentsStore().showAnchor })
337
340
  setStageReady((n) => n + 1) // a reload wiped the frame's lock; let PlayComments re-drive it
338
341
  } else if (data.type === 'sh:stage-at') {
339
342
  const p = s.play
@@ -397,6 +400,7 @@ function PlayInner() {
397
400
  if (key === 'l') { const s = useStore.getState(); if (!s.laser) commentsStore().setMode(false); s.setLaser(!s.laser); return }
398
401
  if (key === 'c') { const c = commentsStore(); if (!c.commentMode) useStore.getState().setLaser(false); c.setMode(!c.commentMode); return }
399
402
  if (key === 'C') { const c = commentsStore(); c.setShow(!c.show); return } // ⇧C hides/shows pins
403
+ if (key === 'L') { const c = commentsStore(); c.setShowAnchor(!c.showAnchor); return } // ⇧L element focus
400
404
 
401
405
  if (/^Digit[1-9]$/.test(code)) {
402
406
  const names = Object.keys(CONFIG.viewports)
@@ -73,13 +73,24 @@ export function useHideUI() {
73
73
 
74
74
  // ---- shared controls ----------------------------------------------------------------
75
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
+
76
86
  /** Laser toggle - store-level, identical on canvas and prototype. The per-surface code
77
87
  * broadcasts sh:laser to its frame(s); this only flips the flag (and drops comment mode,
78
88
  * since the two are one-at-a-time). */
79
89
  export function LaserButton() {
80
90
  const laser = useStore((s) => s.laser)
91
+ const showAnchor = useComments((s) => s.showAnchor)
81
92
  return (
82
- <Tip side="bottom" label={<><b>Laser mode</b><span>L</span></>}>
93
+ <Tip side="bottom" label={<TipRows rows={[['Laser mode', 'L'], [showAnchor ? 'Hide laser comment' : 'Show laser comment', '⇧L']]} />}>
83
94
  <button className={`sh-pill-btn${laser ? ' on' : ''}`} onClick={() => {
84
95
  if (!laser) commentsStore().setMode(false)
85
96
  useStore.getState().setLaser(!laser)
@@ -91,8 +102,9 @@ export function LaserButton() {
91
102
  /** Comment toggle - store-level, identical on canvas and prototype. */
92
103
  export function CommentButton() {
93
104
  const commentMode = useComments((s) => s.commentMode)
105
+ const show = useComments((s) => s.show)
94
106
  return (
95
- <Tip side="bottom" label={<><b>Comment</b><span>C · ⇧C hides pins</span></>}>
107
+ <Tip side="bottom" label={<TipRows rows={[['Comment', 'C'], [show ? 'Hide pins' : 'Show pins', '⇧C']]} />}>
96
108
  <button className={`sh-pill-btn${commentMode ? ' on' : ''}`} onClick={() => {
97
109
  const c = commentsStore()
98
110
  if (!c.commentMode) useStore.getState().setLaser(false)
@@ -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.
@@ -140,11 +160,13 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
140
160
  if (node.status === 'ready' || !laser)
141
161
  iframeRef.current?.contentWindow?.postMessage({ type: 'sh:laser', on: laser }, location.origin)
142
162
  }, [laser, node.status])
143
- // 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)
144
166
  useEffect(() => {
145
167
  if (node.status === 'ready' || !commentMode)
146
- iframeRef.current?.contentWindow?.postMessage({ type: 'sh:pick', on: commentMode }, location.origin)
147
- }, [commentMode, node.status])
168
+ iframeRef.current?.contentWindow?.postMessage({ type: 'sh:pick', on: commentMode, quiet: !showAnchor }, location.origin)
169
+ }, [commentMode, showAnchor, node.status])
148
170
  // B0.2: the interact target owns its own wheel (app scrolls); passive frames forward
149
171
  // wheel to the canvas. Replayed on ready like laser/pick so a reload restores truth.
150
172
  useEffect(() => {
@@ -287,11 +309,15 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
287
309
 
288
310
  return (
289
311
  <div
290
- className={`sh-node${selected ? ' sel' : ''}${interact ? ' interact' : ''}`}
312
+ className={`sh-node${selected ? ' sel' : ''}${interact ? ' interact' : ''}${working ? ' working' : ''}`}
291
313
  data-theme={node.theme}
292
- 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` } : {}) }}
293
318
  data-node={node.key}
294
319
  >
320
+ {working && <WorkShimmer belowBadge={!!frame.variantGroup} />}
295
321
  {frame.variantGroup && (
296
322
  <div className="sh-vbadge sh-no-pan" title={`${frame.variantGroup} · variant ${frame.variant?.toUpperCase()} - click to select`}
297
323
  onPointerDown={(e) => e.stopPropagation()}
@@ -348,6 +374,7 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
348
374
  onDoubleClick={(e) => { e.stopPropagation(); setInteract(node.key) }}
349
375
  />
350
376
  )}
377
+ {working && <div className="sh-work-wave" />}
351
378
  </div>
352
379
 
353
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
@@ -40,12 +40,16 @@ export const MonitorIcon = icon(<path d="M208,40H48A24,24,0,0,0,24,64V176a24,24,
40
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" />)
41
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" />)
42
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" />)
43
45
  /** Sidebar trigger, custom in Phosphor 256-space: outer frame + panel pill -
44
46
  * pill filled while the sidebar is open, hollow while collapsed. */
45
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" /></>)
46
48
 
47
49
  /** Plain UI frame - the rectangle every non-content sidebar row leads with. */
48
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" />)
49
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" />)
50
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" /></>)
51
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)