@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.
- package/CHANGELOG.md +95 -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/bridge.js +28 -242
- package/src/client/frame-host/inspect.d.ts +6 -0
- package/src/client/frame-host/inspect.js +359 -0
- package/src/client/shell/App.tsx +107 -110
- package/src/client/shell/Comments.tsx +465 -70
- package/src/client/shell/Play.tsx +218 -155
- package/src/client/shell/Toolbar.tsx +201 -0
- package/src/client/shell/canvas/FrameNode.tsx +59 -8
- package/src/client/shell/comments-store.ts +47 -6
- package/src/client/shell/icons.tsx +5 -0
- package/src/client/shell/mentions.ts +18 -0
- package/src/client/shell/store.ts +30 -2
- package/src/client/shell/styles.css +356 -30
- package/src/client/stage/main.tsx +35 -11
- 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
|
@@ -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(() =>
|
|
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<
|
|
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
|
|
@@ -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
|
-
|
|
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)
|