@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.
- package/CHANGELOG.md +46 -0
- package/dist/{build-BZaPa2DS.mjs → build-B8z902TW.mjs} +2 -2
- package/dist/cli.mjs +5 -5
- package/dist/{collab-CXy8gqoz.mjs → collab-pLGzSvm5.mjs} +16 -5
- package/dist/{comments-odHzYdO3.mjs → comments-BrpC86Kf.mjs} +9 -15
- package/dist/{comments-Ba8mU600.mjs → comments-DSwMhdbZ.mjs} +2 -8
- package/dist/daemon-C-huGHAM.mjs +899 -0
- package/dist/{dev-DaPQ9xA5.mjs → dev-BYm9ebfN.mjs} +14 -3
- package/dist/events-BMtBvvgU.mjs +101 -0
- package/dist/{init-DsCUmlCW.mjs → init-DrLomSWq.mjs} +13 -1
- package/dist/ledger-wFvEIEGi.mjs +64 -0
- package/dist/{manifest-C8FODq2S.mjs → manifest-D3eaARf4.mjs} +16 -1
- package/dist/{plugin-wMY9lNf3.mjs → plugin-BVFuRfEo.mjs} +56 -40
- package/dist/profile-BkiWglVE.mjs +39 -0
- package/dist/{serve-D_KBK7Oy.mjs → serve-CwAfayJk.mjs} +3 -3
- package/dist/{sync-CkBk-tUk.mjs → sync-Ch4Bymb1.mjs} +4 -102
- package/package.json +1 -1
- package/src/client/frame-host/inspect.js +15 -2
- package/src/client/shell/App.tsx +91 -9
- package/src/client/shell/Comments.tsx +418 -68
- package/src/client/shell/Play.tsx +10 -6
- package/src/client/shell/Toolbar.tsx +14 -2
- package/src/client/shell/canvas/FrameNode.tsx +33 -6
- package/src/client/shell/comments-store.ts +47 -6
- package/src/client/shell/icons.tsx +4 -0
- package/src/client/shell/mentions.ts +18 -0
- package/src/client/shell/store.ts +30 -2
- package/src/client/shell/styles.css +294 -21
- package/src/shared/events.ts +22 -5
- package/templates/AGENTS-embedded.md +1 -0
- package/templates/AGENTS-studio.md +1 -0
- package/templates/instructions/configure.md +15 -0
- package/templates/instructions/jam.md +85 -0
- 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
|
|
169
|
-
|
|
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={
|
|
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={
|
|
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<
|
|
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)
|
|
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
|
-
|
|
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)
|