@marver-design/marver 0.3.1 → 0.4.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 +89 -0
- package/README.md +2 -0
- package/dist/auth-B36fMCM3.mjs +245 -0
- package/dist/{build-Ckmyci3O.mjs → build-p3xmXU3b.mjs} +52 -12
- package/dist/cli.mjs +15 -6
- package/dist/collab-CXy8gqoz.mjs +297 -0
- package/dist/comments-Ba8mU600.mjs +90 -0
- package/dist/comments-odHzYdO3.mjs +179 -0
- package/dist/{dev-C2oTKuXe.mjs → dev-DZi1yRhn.mjs} +17 -1
- package/dist/{init-DolP_Ld4.mjs → init-Ck8z-HiD.mjs} +1 -0
- package/dist/{plugin-Crp4CAma.mjs → plugin-CtQqO5ZZ.mjs} +114 -0
- package/dist/rolldown-runtime-D7D4PA-g.mjs +13 -0
- package/dist/serve-D_KBK7Oy.mjs +431 -0
- package/dist/sync-CkBk-tUk.mjs +248 -0
- package/package.json +2 -1
- package/src/client/frame-host/bridge.js +231 -0
- package/src/client/shell/App.tsx +73 -7
- package/src/client/shell/Comments.tsx +463 -0
- package/src/client/shell/canvas/FrameNode.tsx +32 -2
- package/src/client/shell/comments-store.ts +180 -0
- package/src/client/shell/hash.ts +14 -3
- package/src/client/shell/icons.tsx +6 -0
- package/src/client/shell/store.ts +4 -1
- package/src/client/shell/styles.css +159 -0
- package/src/shared/events.ts +103 -0
- package/templates/AGENTS-embedded.md +13 -1
- package/templates/AGENTS-studio.md +13 -1
- package/templates/instructions/boards.md +6 -3
- package/templates/instructions/configure.md +28 -0
- package/templates/instructions/iterate.md +38 -0
- package/templates/instructions/publish.md +129 -0
- package/dist/serve-OtA9Nlow.mjs +0 -207
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Comment state for the shell (SPEC-M3 §6) - its own small store beside the canvas
|
|
3
|
+
* store. One API client for both worlds: `marver dev` (api.ts mirror, local profile,
|
|
4
|
+
* poll) and the published serve (collab.ts, sessions, SSE). The shell never branches
|
|
5
|
+
* on which one it is beyond capability flags the endpoints themselves report.
|
|
6
|
+
*/
|
|
7
|
+
import { create } from 'zustand'
|
|
8
|
+
import { replay, type CommentEvent, type Thread } from '../../shared/events.ts'
|
|
9
|
+
import { ROUTE } from '../const.ts'
|
|
10
|
+
|
|
11
|
+
export interface Me { email: string; name: string; avatar?: string }
|
|
12
|
+
|
|
13
|
+
interface CommentsState {
|
|
14
|
+
events: CommentEvent[] // the active board's log
|
|
15
|
+
threads: Thread[] // derived on every change
|
|
16
|
+
board: string | null
|
|
17
|
+
me: Me | null // null on published until signed in
|
|
18
|
+
local: boolean // dev mirror (identity is the local profile)
|
|
19
|
+
commentMode: boolean // C - picking + composing
|
|
20
|
+
show: boolean // Shift+C - pins visible at all
|
|
21
|
+
active: string | null // open thread id
|
|
22
|
+
draft: { nodeKey: string; frame: string; anchor: unknown } | null // picked, composing
|
|
23
|
+
needsIdentity: boolean // published viewer tried to comment while signed out
|
|
24
|
+
inviteToken: string | null // arrived via an invite link - claim flow, token known
|
|
25
|
+
|
|
26
|
+
load(board: string): Promise<void>
|
|
27
|
+
live(board: string): () => void
|
|
28
|
+
send(events: CommentEvent[]): Promise<boolean>
|
|
29
|
+
create(body: string): Promise<void>
|
|
30
|
+
reply(threadId: string, body: string): Promise<void>
|
|
31
|
+
replyOk(threadId: string, body: string): Promise<boolean>
|
|
32
|
+
resolve(threadId: string, reopen?: boolean): Promise<void>
|
|
33
|
+
setMode(on: boolean): void
|
|
34
|
+
setShow(show: boolean): void
|
|
35
|
+
setActive(id: string | null): void
|
|
36
|
+
setDraft(d: CommentsState['draft']): void
|
|
37
|
+
signIn(email: string, password: string): Promise<string | null>
|
|
38
|
+
claim(token: string, password: string, name: string, avatar?: string): Promise<string | null>
|
|
39
|
+
saveProfile(patch: Partial<Me>): Promise<void>
|
|
40
|
+
dismissIdentity(): void
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const uuid = () => crypto.randomUUID()
|
|
44
|
+
const csrf = () => /(?:^|;\s*)mv_c=([\w-]+)/.exec(document.cookie)?.[1] ?? ''
|
|
45
|
+
const api = async (path: string, body?: unknown) => {
|
|
46
|
+
const res = await fetch(`${ROUTE}/api/${path}`, body === undefined ? undefined : {
|
|
47
|
+
method: 'POST', headers: { 'content-type': 'application/json', 'x-mv-c': csrf() },
|
|
48
|
+
body: JSON.stringify(body),
|
|
49
|
+
})
|
|
50
|
+
return { ok: res.ok, status: res.status, data: await res.json().catch(() => ({})) }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export const useComments = create<CommentsState>((set, get) => {
|
|
54
|
+
const derive = (events: CommentEvent[]) => ({ events, threads: replay(events) })
|
|
55
|
+
const union = (events: CommentEvent[]) => {
|
|
56
|
+
const have = new Set(get().events.map((e) => e.id))
|
|
57
|
+
const fresh = events.filter((e) => !have.has(e.id))
|
|
58
|
+
if (fresh.length) set(derive([...get().events, ...fresh]))
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
events: [], threads: [], board: null, me: null, local: false,
|
|
63
|
+
commentMode: false, show: true, active: null, draft: null, needsIdentity: false, inviteToken: null,
|
|
64
|
+
|
|
65
|
+
async load(board) {
|
|
66
|
+
set({ board })
|
|
67
|
+
const me = await api('me')
|
|
68
|
+
if (me.ok) set({ me: me.data.user ?? null, local: !!me.data.local })
|
|
69
|
+
const res = await api(`comments/${board}`)
|
|
70
|
+
if (res.ok && get().board === board) set(derive(res.data.events ?? []))
|
|
71
|
+
},
|
|
72
|
+
|
|
73
|
+
/** Liveness: SSE on the published serve; dev has no event rail (its sync loop
|
|
74
|
+
* writes files) so it polls only - an EventSource there would 404-retry forever. */
|
|
75
|
+
live(board) {
|
|
76
|
+
const stops: (() => void)[] = []
|
|
77
|
+
let es: EventSource | null = null
|
|
78
|
+
const wantSSE = () => {
|
|
79
|
+
if (es || get().local) return
|
|
80
|
+
es = new EventSource(`${ROUTE}/api/events`)
|
|
81
|
+
es.addEventListener('comment', (e) => {
|
|
82
|
+
try {
|
|
83
|
+
const { board: b, ev } = JSON.parse((e as MessageEvent).data)
|
|
84
|
+
if (b === get().board) union([ev])
|
|
85
|
+
} catch { /* ignore */ }
|
|
86
|
+
})
|
|
87
|
+
es.addEventListener('resync', () => { void get().load(board) })
|
|
88
|
+
es.onerror = () => { /* EventSource retries itself; the poll covers the gap */ }
|
|
89
|
+
}
|
|
90
|
+
// `local` is only known once load() has answered - defer the SSE decision past it
|
|
91
|
+
const t = setTimeout(wantSSE, 1500)
|
|
92
|
+
stops.push(() => { clearTimeout(t); es?.close() })
|
|
93
|
+
const poll = () => { if (get().board === board) void api(`comments/${board}`).then((r) => r.ok && union(r.data.events ?? [])) }
|
|
94
|
+
const iv = setInterval(poll, 30_000)
|
|
95
|
+
const onFocus = () => poll()
|
|
96
|
+
window.addEventListener('focus', onFocus)
|
|
97
|
+
stops.push(() => { clearInterval(iv); window.removeEventListener('focus', onFocus) })
|
|
98
|
+
return () => stops.forEach((f) => f())
|
|
99
|
+
},
|
|
100
|
+
|
|
101
|
+
async send(events) {
|
|
102
|
+
const { board } = get()
|
|
103
|
+
if (!board) return false
|
|
104
|
+
const res = await api(`comments/${board}`, { events })
|
|
105
|
+
if (res.status === 401) { set({ needsIdentity: true }); return false }
|
|
106
|
+
// any other refusal gets said out loud - a silent dead Enter key reads as a bug
|
|
107
|
+
// (the canonical case: a signed-in viewer on a read-only board)
|
|
108
|
+
if (!res.ok) {
|
|
109
|
+
const { useStore } = await import('./store.ts')
|
|
110
|
+
useStore.getState().toast(String((res.data as any)?.error ?? 'comment rejected'))
|
|
111
|
+
}
|
|
112
|
+
// union only what the server took - a rejected send must not leave phantoms -
|
|
113
|
+
// and only if the user is still LOOKING at that board (a slow response after a
|
|
114
|
+
// board switch must not leak events into the wrong client state); client ids
|
|
115
|
+
// keep the eventual SSE/poll echo idempotent either way
|
|
116
|
+
if (res.ok && get().board === board) union(events)
|
|
117
|
+
return res.ok
|
|
118
|
+
},
|
|
119
|
+
|
|
120
|
+
async create(body) {
|
|
121
|
+
const { draft, me } = get()
|
|
122
|
+
if (!draft || !body.trim()) return
|
|
123
|
+
const id = uuid()
|
|
124
|
+
const ok = await get().send([{
|
|
125
|
+
id: uuid(), ts: Date.now(), type: 'create', commentId: id,
|
|
126
|
+
nodeKey: draft.nodeKey, frame: draft.frame, anchor: draft.anchor,
|
|
127
|
+
author: me ?? undefined, body: body.trim(),
|
|
128
|
+
}])
|
|
129
|
+
if (ok) set({ draft: null, active: id, commentMode: false })
|
|
130
|
+
},
|
|
131
|
+
|
|
132
|
+
async reply(threadId, body) { await get().replyOk(threadId, body) },
|
|
133
|
+
async replyOk(threadId, body) {
|
|
134
|
+
if (!body.trim()) return false
|
|
135
|
+
return get().send([{
|
|
136
|
+
id: uuid(), ts: Date.now(), type: 'reply', commentId: uuid(), parentId: threadId,
|
|
137
|
+
author: get().me ?? undefined, body: body.trim(),
|
|
138
|
+
}])
|
|
139
|
+
},
|
|
140
|
+
|
|
141
|
+
async resolve(threadId, reopen = false) {
|
|
142
|
+
await get().send([{ id: uuid(), ts: Date.now(), type: reopen ? 'reopen' : 'resolve', commentId: threadId }])
|
|
143
|
+
},
|
|
144
|
+
|
|
145
|
+
setMode(on) { set({ commentMode: on, ...(on ? { show: true } : { draft: null }) }) },
|
|
146
|
+
setShow(show) { set({ show }) },
|
|
147
|
+
setActive(active) { set({ active }) },
|
|
148
|
+
setDraft(draft) { set({ draft }) },
|
|
149
|
+
dismissIdentity() { set({ needsIdentity: false }) }, // inviteToken survives dismissal - commenting later reopens the claim
|
|
150
|
+
|
|
151
|
+
async signIn(email, password) {
|
|
152
|
+
const res = await api('auth/signin', { email, password })
|
|
153
|
+
if (!res.ok) return res.data?.error ?? 'sign-in failed'
|
|
154
|
+
set({ me: res.data.user, needsIdentity: false })
|
|
155
|
+
return null
|
|
156
|
+
},
|
|
157
|
+
async claim(token, password, name, avatar) {
|
|
158
|
+
const res = await api('auth/claim', { token, password, name, avatar })
|
|
159
|
+
if (!res.ok) return res.data?.error ?? 'claim failed'
|
|
160
|
+
set({ me: res.data.user, needsIdentity: false, inviteToken: null })
|
|
161
|
+
return null
|
|
162
|
+
},
|
|
163
|
+
async saveProfile(patch) {
|
|
164
|
+
const res = await api('profile', patch)
|
|
165
|
+
if (res.ok) set({ me: res.data.user })
|
|
166
|
+
},
|
|
167
|
+
}
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
// dev-only debug handle - the canvas store exposes the same
|
|
171
|
+
if (typeof window !== 'undefined' && (import.meta as any).env?.DEV) (window as any).__mvComments = useComments
|
|
172
|
+
|
|
173
|
+
/** Initials + deterministic hue for avatarless authors - the whole fallback ladder. */
|
|
174
|
+
export const avatarFallback = (author?: { email?: string; name?: string }) => {
|
|
175
|
+
const name = author?.name || author?.email || '?'
|
|
176
|
+
const initials = name.split(/\s+/).map((w) => w[0]).slice(0, 2).join('').toUpperCase()
|
|
177
|
+
let h = 0
|
|
178
|
+
for (const c of (author?.email || name)) h = (h * 31 + c.charCodeAt(0)) % 360
|
|
179
|
+
return { initials, hue: h }
|
|
180
|
+
}
|
package/src/client/shell/hash.ts
CHANGED
|
@@ -7,12 +7,16 @@
|
|
|
7
7
|
* #/ default board, fit all
|
|
8
8
|
* #/b/<board> board, fit all
|
|
9
9
|
* #/b/<board>?n=k1,k2 board with nodes selected, camera fit to selection
|
|
10
|
+
* #/b/<board>?c=<id> board with a comment thread open (SPEC-M3 §6)
|
|
11
|
+
* #/i/<token> invite link - opens the claim dialog with the token
|
|
10
12
|
* #/p/<board>?at=<frame-id>&device=<viewport>&theme=<theme> play mode
|
|
11
13
|
*/
|
|
12
14
|
|
|
13
15
|
export interface HashState {
|
|
14
16
|
board?: string
|
|
15
17
|
n?: string[]
|
|
18
|
+
c?: string
|
|
19
|
+
invite?: string
|
|
16
20
|
play?: { at?: string; device?: string; theme?: string }
|
|
17
21
|
}
|
|
18
22
|
|
|
@@ -25,13 +29,16 @@ export function parseHash(hash: string = location.hash): HashState {
|
|
|
25
29
|
const q = raw.indexOf('?')
|
|
26
30
|
const path = q === -1 ? raw : raw.slice(0, q)
|
|
27
31
|
const params = new URLSearchParams(q === -1 ? '' : raw.slice(q + 1))
|
|
32
|
+
const mi = path.match(/^\/i\/([\w-]{8,128})$/)
|
|
33
|
+
if (mi) return { invite: mi[1] }
|
|
28
34
|
const m = path.match(/^\/(b|p)\/([^/?]+)$/)
|
|
29
35
|
if (!m) return {}
|
|
30
36
|
const board = decodeURIComponent(m[2])
|
|
31
37
|
if (!BOARD_RE.test(board)) return {}
|
|
32
38
|
if (m[1] === 'b') {
|
|
33
39
|
const n = (params.get('n') ?? '').split(',').map((s) => s.trim()).filter(Boolean)
|
|
34
|
-
|
|
40
|
+
const c = params.get('c') ?? undefined
|
|
41
|
+
return { board, ...(n.length ? { n } : {}), ...(c && /^[\w-]+$/.test(c) ? { c } : {}) }
|
|
35
42
|
}
|
|
36
43
|
return {
|
|
37
44
|
board,
|
|
@@ -51,8 +58,12 @@ export function buildHash(s: HashState): string {
|
|
|
51
58
|
if (s.play.theme) p.set('theme', s.play.theme)
|
|
52
59
|
return `#/p/${s.board}?${p}`
|
|
53
60
|
}
|
|
54
|
-
if (!s.board || (s.board === 'all-scenes' && !s.n?.length)) return '#/'
|
|
55
|
-
|
|
61
|
+
if (!s.board || (s.board === 'all-scenes' && !s.n?.length && !s.c)) return '#/'
|
|
62
|
+
const p = new URLSearchParams()
|
|
63
|
+
if (s.n?.length) p.set('n', s.n.join(','))
|
|
64
|
+
if (s.c) p.set('c', s.c)
|
|
65
|
+
const q = p.toString()
|
|
66
|
+
return `#/b/${s.board}${q ? `?${decodeURIComponent(q)}` : ''}`
|
|
56
67
|
}
|
|
57
68
|
|
|
58
69
|
/** Write the hash; identical URLs are skipped so restore paths never loop. */
|
|
@@ -27,6 +27,10 @@ export const PlusIcon = icon(<path d="M224,128a8,8,0,0,1-8,8H136v80a8,8,0,0,1-16
|
|
|
27
27
|
export const XIcon = icon(<path d="M205.66,194.34a8,8,0,0,1-11.32,11.32L128,139.31,61.66,205.66a8,8,0,0,1-11.32-11.32L116.69,128,50.34,61.66A8,8,0,0,1,61.66,50.34L128,116.69l66.34-66.35a8,8,0,0,1,11.32,11.32L139.31,128Z" />)
|
|
28
28
|
export const ReloadIcon = icon(<path d="M240,56v48a8,8,0,0,1-8,8H184a8,8,0,0,1,0-16H211.4L184.81,71.64l-.25-.24a80,80,0,1,0-1.67,114.78,8,8,0,0,1,11,11.63A95.44,95.44,0,0,1,128,224h-1.32A96,96,0,1,1,195.75,60L224,85.8V56a8,8,0,1,1,16,0Z" />)
|
|
29
29
|
export const CheckIcon = icon(<path d="M229.66,77.66l-128,128a8,8,0,0,1-11.32,0l-56-56a8,8,0,0,1,11.32-11.32L96,188.69,218.34,66.34a8,8,0,0,1,11.32,11.32Z" />)
|
|
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
|
+
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
|
+
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 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" />)
|
|
30
34
|
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" />)
|
|
31
35
|
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" />)
|
|
32
36
|
export const DeviceTabletIcon = icon(<path d="M192,24H64A24,24,0,0,0,40,48V208a24,24,0,0,0,24,24H192a24,24,0,0,0,24-24V48A24,24,0,0,0,192,24ZM56,72H200V184H56Zm8-32H192a8,8,0,0,1,8,8v8H56V48A8,8,0,0,1,64,40ZM192,216H64a8,8,0,0,1-8-8v-8H200v8A8,8,0,0,1,192,216Z" />)
|
|
@@ -41,6 +45,8 @@ export const PanelFilledIcon = icon(<><rect x="32" y="48" width="192" height="16
|
|
|
41
45
|
|
|
42
46
|
/** Plain UI frame - the rectangle every non-content sidebar row leads with. */
|
|
43
47
|
export const FrameRectIcon = icon(<rect x="40" y="52" width="176" height="152" rx="20" fill="none" stroke="currentColor" strokeWidth="16" />)
|
|
48
|
+
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
|
+
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" /></>)
|
|
44
50
|
|
|
45
51
|
/* Content-frame intent glyphs (SPEC-026), custom in the same 256-space. */
|
|
46
52
|
export const DiagramShapeIcon = icon(<><rect x="28" y="36" width="88" height="64" rx="14" fill="none" stroke="currentColor" strokeWidth="16" /><rect x="140" y="156" width="88" height="64" rx="14" fill="none" stroke="currentColor" strokeWidth="16" /><path d="M116 68 H184 V156" fill="none" stroke="currentColor" strokeWidth="16" strokeLinecap="round" strokeLinejoin="round" /></>)
|
|
@@ -112,6 +112,7 @@ interface State {
|
|
|
112
112
|
viewTheme: string // the global theme preference; sticky across boards and reloads
|
|
113
113
|
play: { at: string; device: string; theme: string } | null // play mode (SPEC-M2 §1); at = current frame id
|
|
114
114
|
gesture: boolean // a frame drag/resize is in progress - canvas panning is disabled
|
|
115
|
+
laser: boolean // laser/inspect mode (SPEC-M3 §7): frames outline their structure
|
|
115
116
|
board: string // active board name; 'all-scenes' is the auto board
|
|
116
117
|
boardAuto: boolean // auto boards gain new frames on arrival; curated boards never do
|
|
117
118
|
deviceView: string | null // board-wide device preview (viewport name), null = free-form layout
|
|
@@ -139,6 +140,7 @@ interface State {
|
|
|
139
140
|
setInteract(key: string | null): void
|
|
140
141
|
setPlay(p: State['play']): void
|
|
141
142
|
setGesture(g: boolean): void
|
|
143
|
+
setLaser(on: boolean): void
|
|
142
144
|
moveSelectedBy(dx: number, dy: number, starts: Record<string, { x: number; y: number }>): void
|
|
143
145
|
setSelectedTheme(theme: string): void
|
|
144
146
|
setDeviceView(name: string | null): void
|
|
@@ -388,7 +390,7 @@ export const useStore = create<State>((set, get) => {
|
|
|
388
390
|
}
|
|
389
391
|
|
|
390
392
|
return {
|
|
391
|
-
manifest: null, nodes: [], selection: [], interact: null, viewTheme: initialViewTheme(), play: null, gesture: false,
|
|
393
|
+
manifest: null, nodes: [], selection: [], interact: null, viewTheme: initialViewTheme(), play: null, gesture: false, laser: false,
|
|
392
394
|
board: DATA?.default ?? 'all-scenes', boardAuto: (DATA?.default ?? 'all-scenes') === 'all-scenes', deviceView: null, sceneRows: null, layout: null, layoutRaw: undefined, baseLayout: null,
|
|
393
395
|
panelOpen: true, scale: 1, toasts: [], boardHash: null, dirty: false,
|
|
394
396
|
|
|
@@ -687,6 +689,7 @@ export const useStore = create<State>((set, get) => {
|
|
|
687
689
|
// painted as "interactive"). Exiting keeps the frame selected for continuity.
|
|
688
690
|
setInteract(key) { set((s) => ({ interact: key, selection: key ? [key] : s.selection })) },
|
|
689
691
|
setPlay(play) { set({ play }) },
|
|
692
|
+
setLaser(laser) { set({ laser }) },
|
|
690
693
|
setGesture(gesture) {
|
|
691
694
|
set({ gesture })
|
|
692
695
|
// SPEC-024 §4: a board WITH a layout recipe re-applies it when a resize
|
|
@@ -28,6 +28,13 @@
|
|
|
28
28
|
--interact: #db35f2; --interact-ring: rgba(219, 53, 242, .16);
|
|
29
29
|
--interact-strong: rgba(219, 53, 242, 1); --interact-soft: rgba(234, 141, 255, .95);
|
|
30
30
|
--interact-deep: rgba(176, 47, 194, .85); --interact-spark: rgba(255, 255, 255, .95);
|
|
31
|
+
/* comments own a third mode color (Apple systemGreen): selection = blue,
|
|
32
|
+
interact = purple, comments = green - same geometry, unique hue per mode */
|
|
33
|
+
--comment: #34c759; --comment-ring: rgba(52, 199, 89, .22);
|
|
34
|
+
/* comment-card field surface: APP-scoped on purpose - the card keys to the board
|
|
35
|
+
theme, and node-scoped --node-bg would flip with the frame underneath it */
|
|
36
|
+
--cm-field: #fff;
|
|
37
|
+
--cm-modal-bg: rgba(255, 255, 255, .95);
|
|
31
38
|
|
|
32
39
|
--r-node: 10px; --r-panel: 24px; --edge: 10px;
|
|
33
40
|
--blur: blur(20px) saturate(150%);
|
|
@@ -60,6 +67,9 @@
|
|
|
60
67
|
--interact: #ea8dff; --interact-ring: rgba(219, 53, 242, .3);
|
|
61
68
|
--interact-strong: rgba(219, 53, 242, 1); --interact-soft: rgba(234, 141, 255, .95);
|
|
62
69
|
--interact-deep: rgba(203, 48, 224, .8); --interact-spark: rgba(255, 255, 255, .9);
|
|
70
|
+
--comment: #30d158; --comment-ring: rgba(48, 209, 88, .34);
|
|
71
|
+
--cm-field: #0f1015;
|
|
72
|
+
--cm-modal-bg: rgba(22, 22, 27, .96);
|
|
63
73
|
|
|
64
74
|
--shadow-node: 0 1px 2px rgba(0, 0, 0, .35); /* shadow-sm, alpha raised to read on dark */
|
|
65
75
|
}
|
|
@@ -373,6 +383,8 @@ body.sh-space .sh-node { pointer-events: none } /* space-drag
|
|
|
373
383
|
.sh-pill-btn.pct { font-variant-numeric: tabular-nums; min-width: 46px }
|
|
374
384
|
.sh-pill-btn.off { color: var(--glass-ink-3); cursor: default }
|
|
375
385
|
.sh-pill-btn.off:hover { background: none; color: var(--glass-ink-3) }
|
|
386
|
+
.sh-pill-btn.on { color: var(--accent); background: color-mix(in srgb, var(--accent) 12%, transparent) }
|
|
387
|
+
.sh-pill-btn.on:hover { color: var(--accent); background: color-mix(in srgb, var(--accent) 18%, transparent) }
|
|
376
388
|
.sh-pill .sep { width: 1px; height: 16px; background: var(--glass-brd); margin: 0 3px }
|
|
377
389
|
|
|
378
390
|
/* dropdown menu (theme) - portaled to .sh-app (its own backdrop root), left-aligned to its trigger */
|
|
@@ -633,3 +645,150 @@ body.sh-kbd .sh-app :focus-visible { outline: 2px solid var(--accent); outline-o
|
|
|
633
645
|
.sub.vgroup { cursor: pointer }
|
|
634
646
|
.sub.vgroup.held { background: color-mix(in srgb, var(--glass-hover) 55%, transparent) }
|
|
635
647
|
.sub.vgroup.held .gicon { color: var(--glass-ink-2) }
|
|
648
|
+
|
|
649
|
+
/* ---- comments (SPEC-M3 §6) ----------------------------------------------------------
|
|
650
|
+
The marker family (pins, stacks) is SOLID with the Figma teardrop silhouette - a
|
|
651
|
+
marker must hold contrast on any artwork. The surfaces (card, composer, dialog)
|
|
652
|
+
are OUR glass - the shell's own token language, theme-aware where Figma is flat. */
|
|
653
|
+
.cm-avatar { display: inline-flex; align-items: center; justify-content: center; border-radius: 50%;
|
|
654
|
+
color: #fff; font-weight: 700; font-family: -apple-system, system-ui, sans-serif;
|
|
655
|
+
object-fit: cover; flex: none; user-select: none }
|
|
656
|
+
|
|
657
|
+
/* markers - pins and stacks share one face: overlapping avatars + a total count.
|
|
658
|
+
A pin shows one thread's participants (tail points at its div); a stack shows
|
|
659
|
+
everyone across the frame's threads (tail points up-right at the frame corner). */
|
|
660
|
+
.cm-pin, .cm-stack { display: inline-flex; align-items: center; padding: 4px;
|
|
661
|
+
background: #fff; border: 1px solid rgba(24,24,27,.1); cursor: pointer;
|
|
662
|
+
font: 700 11px -apple-system, system-ui, sans-serif; color: #18181b }
|
|
663
|
+
.cm-pin .cm-avatar, .cm-stack .cm-avatar { border: 2px solid #fff }
|
|
664
|
+
.cm-pin .cm-avatar + .cm-avatar, .cm-stack .cm-avatar + .cm-avatar { margin-left: -8px }
|
|
665
|
+
.cm-pin b, .cm-stack b { padding: 0 8px 0 6px }
|
|
666
|
+
|
|
667
|
+
/* pin: the teardrop. Bottom-left tail points at the anchored element. */
|
|
668
|
+
.cm-pin { position: absolute; z-index: 30; transform: translate(-2px, -100%) scale(var(--sh-inv, 1));
|
|
669
|
+
transform-origin: bottom left; border-radius: 18px 18px 18px 4px;
|
|
670
|
+
box-shadow: 0 1px 2px rgba(24,24,27,.18), 0 6px 16px -6px rgba(24,24,27,.35);
|
|
671
|
+
transition: box-shadow .15s, transform .15s }
|
|
672
|
+
.cm-pin:hover { box-shadow: 0 1px 3px rgba(24,24,27,.22), 0 10px 24px -6px rgba(24,24,27,.45) }
|
|
673
|
+
/* selected pin wears the selected frame's exact geometry - border, 2px
|
|
674
|
+
outline hugging the edge, 4px ring halo - in the comment green (the pin
|
|
675
|
+
is already screen-space via scale(--sh-inv), so plain px match the
|
|
676
|
+
frame's calc'd screen px) */
|
|
677
|
+
.cm-pin.on { border-color: var(--comment);
|
|
678
|
+
outline: 2px solid var(--comment); outline-offset: -1px;
|
|
679
|
+
box-shadow: 0 0 0 4px var(--comment-ring), 0 1px 2px rgba(24,24,27,.18), 0 6px 16px -6px rgba(24,24,27,.35) }
|
|
680
|
+
.cm-pin.orphan { border-style: dashed; opacity: .75 }
|
|
681
|
+
|
|
682
|
+
/* stack: the frame-corner cluster - everyone across the frame's threads */
|
|
683
|
+
.cm-stack { position: absolute; top: 10px; right: 10px; z-index: 30;
|
|
684
|
+
transform: scale(var(--sh-inv, 1)); transform-origin: top right; border-radius: 18px 18px 4px 18px;
|
|
685
|
+
box-shadow: 0 1px 2px rgba(24,24,27,.15), 0 6px 16px -6px rgba(24,24,27,.3);
|
|
686
|
+
transition: box-shadow .15s }
|
|
687
|
+
.cm-stack:hover { box-shadow: 0 1px 3px rgba(24,24,27,.2), 0 10px 24px -6px rgba(24,24,27,.4) }
|
|
688
|
+
|
|
689
|
+
/* card: OUR glass - the floating-menu language, theme-aware */
|
|
690
|
+
.cm-card { position: absolute; z-index: 40; width: 300px; padding: 16px;
|
|
691
|
+
transform: scale(var(--sh-inv, 1)); transform-origin: top left;
|
|
692
|
+
background: var(--glass); backdrop-filter: var(--blur); -webkit-backdrop-filter: var(--blur);
|
|
693
|
+
/* an open card is the active selection - the frame's exact geometry, comment green */
|
|
694
|
+
color: var(--glass-ink); border-radius: 20px; border: 1px solid var(--comment);
|
|
695
|
+
outline: 2px solid var(--comment); outline-offset: -1px;
|
|
696
|
+
box-shadow: 0 0 0 4px var(--comment-ring), var(--shadow-glass), 0 16px 40px -12px rgba(24,24,27,.35);
|
|
697
|
+
display: flex; flex-direction: column; gap: 12px;
|
|
698
|
+
font: 400 13px -apple-system, system-ui, sans-serif; letter-spacing: -.005em; text-align: left }
|
|
699
|
+
.cm-card.flip { transform: translateX(-100%) scale(var(--sh-inv, 1)); transform-origin: top right }
|
|
700
|
+
.cm-card header { display: flex; align-items: center; gap: 9px }
|
|
701
|
+
.cm-card header b { font-size: 14px; font-weight: 600; letter-spacing: -.01em }
|
|
702
|
+
.cm-card .dim { color: var(--glass-ink-3); font-size: 11.5px; font-variant-numeric: tabular-nums }
|
|
703
|
+
.cm-actions { position: absolute; top: 13px; right: 12px; display: flex; gap: 2px }
|
|
704
|
+
/* the root header shares its line with the pinned actions - reserve their width
|
|
705
|
+
so a long display name ellipsizes instead of running under the icons */
|
|
706
|
+
.cm-card > header { padding-right: 84px }
|
|
707
|
+
.cm-card > header b { overflow: hidden; text-overflow: ellipsis; white-space: nowrap }
|
|
708
|
+
/* every message - root and replies - is the same unit: avatar + name + time,
|
|
709
|
+
body indented to the name column (24px avatar + 9px gap) */
|
|
710
|
+
.cm-card .cm-body { margin: 0; padding-left: 33px; font-size: 13px; line-height: 1.55;
|
|
711
|
+
color: var(--glass-ink); white-space: pre-wrap; overflow-wrap: break-word }
|
|
712
|
+
.cm-card > .cm-body { margin-top: -10px } /* pulls against the card's 12px gap -> 2px under the header */
|
|
713
|
+
.cm-msg { display: flex; flex-direction: column; gap: 2px }
|
|
714
|
+
.cm-icon { display: inline-flex; align-items: center; justify-content: center; width: 26px; height: 26px;
|
|
715
|
+
border: 0; border-radius: 8px; background: none; color: var(--glass-ink-3); cursor: pointer;
|
|
716
|
+
transition: background .12s, color .12s }
|
|
717
|
+
.cm-icon:hover { background: var(--glass-hover); color: var(--glass-ink) }
|
|
718
|
+
/* copy-link morphs into a check on the shell's morph curve, then reverts */
|
|
719
|
+
.cm-copy { position: relative }
|
|
720
|
+
.cm-copy .a, .cm-copy .b { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
|
|
721
|
+
transition: transform var(--morph), opacity .2s ease }
|
|
722
|
+
.cm-copy .b { transform: scale(.4); opacity: 0; color: var(--comment) }
|
|
723
|
+
.cm-copy.ok .a { transform: scale(.4); opacity: 0 }
|
|
724
|
+
.cm-copy.ok .b { transform: scale(1); opacity: 1 }
|
|
725
|
+
/* composer row: my avatar (list size) + a pill input with a round send button inside */
|
|
726
|
+
.cm-compose { display: flex; align-items: center; gap: 8px }
|
|
727
|
+
.cm-inputwrap { position: relative; flex: 1; min-width: 0 }
|
|
728
|
+
.cm-compose input { width: 100%; height: 36px; padding: 0 42px 0 15px; border-radius: 999px;
|
|
729
|
+
border: 1px solid var(--glass-brd); background: var(--cm-field); color: var(--glass-ink);
|
|
730
|
+
font: 400 13px -apple-system, system-ui, sans-serif; outline: none;
|
|
731
|
+
transition: border-color .15s, box-shadow .15s }
|
|
732
|
+
.cm-compose input::placeholder { color: var(--glass-ink-3) }
|
|
733
|
+
.cm-compose input:focus { border-color: var(--comment); box-shadow: 0 0 0 3px var(--comment-ring) }
|
|
734
|
+
.cm-send { position: absolute; right: 4px; top: 50%; transform: translateY(-50%);
|
|
735
|
+
width: 28px; height: 28px; border-radius: 50%; border: 0; display: flex; align-items: center; justify-content: center;
|
|
736
|
+
background: var(--comment); color: #fff; cursor: pointer; transition: background .15s, opacity .15s }
|
|
737
|
+
.cm-send:hover:not(:disabled) { filter: brightness(1.08) }
|
|
738
|
+
.cm-send:disabled { background: var(--glass-ink-3); opacity: .5; cursor: default }
|
|
739
|
+
/* signed-out viewer: one clear button in place of the composer */
|
|
740
|
+
.cm-signin-cta { width: 100%; height: 40px; border: 0; border-radius: 999px;
|
|
741
|
+
background: var(--comment); color: #fff; cursor: pointer;
|
|
742
|
+
font: 600 13px -apple-system, system-ui, sans-serif; transition: filter .15s }
|
|
743
|
+
.cm-signin-cta:hover { filter: brightness(1.06) }
|
|
744
|
+
/* the draft composer is JUST the pill - no card chrome around a single input */
|
|
745
|
+
.cm-draft { padding: 0; width: 272px; background: none; border: none; box-shadow: none; outline: none;
|
|
746
|
+
backdrop-filter: none; -webkit-backdrop-filter: none }
|
|
747
|
+
.cm-draft input { height: 40px; box-shadow: 0 2px 6px rgba(24,24,27,.15), 0 12px 32px -8px rgba(24,24,27,.35) }
|
|
748
|
+
.cm-draft .cm-send { right: 6px } /* nudge in for the taller draft pill */
|
|
749
|
+
/* identity modal - same glass family */
|
|
750
|
+
.cm-modal-wrap { position: fixed; inset: 0; z-index: 300; display: flex; align-items: center; justify-content: center;
|
|
751
|
+
background: rgba(24,24,27,.45); backdrop-filter: blur(5px) }
|
|
752
|
+
/* the auth modal is a focused surface, not a floating glass panel - near-solid so
|
|
753
|
+
the canvas behind never bleeds through the fields */
|
|
754
|
+
.cm-modal { width: 344px; padding: 26px; border-radius: 24px;
|
|
755
|
+
background: var(--cm-modal-bg); backdrop-filter: var(--blur); -webkit-backdrop-filter: var(--blur);
|
|
756
|
+
color: var(--glass-ink); border: 1px solid var(--glass-brd);
|
|
757
|
+
display: flex; flex-direction: column; gap: 8px; font: 400 13px -apple-system, system-ui, sans-serif;
|
|
758
|
+
box-shadow: 0 1px 2px rgba(24,24,27,.1), 0 20px 60px -12px rgba(24,24,27,.5) }
|
|
759
|
+
.cm-modal h2 { margin: 0; font-size: 16px; letter-spacing: -.01em }
|
|
760
|
+
.cm-modal .dim { margin: 0 0 8px; color: var(--glass-ink-2); font-size: 12.5px; line-height: 1.5 }
|
|
761
|
+
.cm-fields { display: flex; flex-direction: column; gap: 9px }
|
|
762
|
+
.cm-modal input { height: 38px; padding: 0 14px; border-radius: 999px; border: 1px solid var(--glass-brd);
|
|
763
|
+
background: var(--cm-field); color: var(--glass-ink); font: inherit; outline: none }
|
|
764
|
+
.cm-modal input:focus { border-color: var(--comment); box-shadow: 0 0 0 3px var(--comment-ring) }
|
|
765
|
+
.cm-err { color: #b42318; font-size: 12px }
|
|
766
|
+
.cm-chip { height: 38px; box-sizing: border-box; padding: 0 14px; border-radius: 999px;
|
|
767
|
+
border: 1px solid transparent; background: var(--glass-hover);
|
|
768
|
+
display: flex; align-items: center; justify-content: space-between; gap: 10px; overflow: hidden }
|
|
769
|
+
.cm-div { border: 0; height: 1px; background: var(--glass-brd); margin: 1px 2px; align-self: stretch }
|
|
770
|
+
.cm-chip b { font-weight: 500; font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap }
|
|
771
|
+
.cm-chip span { font-size: 10.5px; font-weight: 600; letter-spacing: .04em; color: var(--glass-ink-3); flex: none }
|
|
772
|
+
.cm-idrow { display: flex; align-items: center; gap: 10px }
|
|
773
|
+
.cm-pfp { width: 44px; height: 44px; border-radius: 50%; border: 1.5px dashed var(--glass-ink-3);
|
|
774
|
+
background: var(--cm-field) center/cover no-repeat; color: var(--glass-ink-3); font-size: 18px; flex: none;
|
|
775
|
+
display: flex; align-items: center; justify-content: center; cursor: pointer; padding: 0;
|
|
776
|
+
transition: border-color .15s, color .15s }
|
|
777
|
+
.cm-pfp:hover { border-color: var(--comment); color: var(--comment) }
|
|
778
|
+
.cm-pfp.set { border-style: solid; border-color: var(--glass-brd); color: transparent }
|
|
779
|
+
.cm-row { display: flex; gap: 8px; margin-top: 12px }
|
|
780
|
+
.cm-row button:disabled, .cm-row button.cm-primary:disabled {
|
|
781
|
+
background: var(--glass-ink-3); color: var(--glass); cursor: default; opacity: 1 }
|
|
782
|
+
.cm-switch { margin: 2px auto 0; border: 0; background: none; padding: 4px 8px; cursor: pointer;
|
|
783
|
+
font: 500 12px -apple-system, system-ui, sans-serif; color: var(--glass-ink-3); border-radius: 999px }
|
|
784
|
+
.cm-switch:hover { color: var(--glass-ink); background: var(--glass-hover) }
|
|
785
|
+
.cm-row button { height: 36px; padding: 0 16px; border-radius: 999px; border: 1px solid var(--glass-brd);
|
|
786
|
+
background: none; color: var(--glass-ink); font: 600 12.5px -apple-system, system-ui, sans-serif; cursor: pointer }
|
|
787
|
+
.cm-row button:hover { background: var(--glass-hover) }
|
|
788
|
+
.cm-row button.cm-primary { background: var(--glass-ink); color: var(--glass); border-color: transparent; flex: 1 }
|
|
789
|
+
.cm-row button.cm-primary:hover { opacity: .88 }
|
|
790
|
+
|
|
791
|
+
/* the unclipped comment plane over each frame: itself transparent to the pointer,
|
|
792
|
+
its children (pins, cards, stacks) fully interactive */
|
|
793
|
+
.cm-layer { position: absolute; left: 0; width: 100%; pointer-events: none; z-index: 25 }
|
|
794
|
+
.cm-layer > * { pointer-events: auto }
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Comment events - the PURE half of the store (SPEC-M3 §1), shared by the node side
|
|
3
|
+
* (JSONL persistence in server/comments.ts) and the browser shell (thread state from
|
|
4
|
+
* fetched events). No node imports here, ever.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export type EventType = 'create' | 'reply' | 'edit' | 'resolve' | 'reopen' | 'react' | 'profile'
|
|
8
|
+
|
|
9
|
+
export interface CommentEvent {
|
|
10
|
+
id: string // client-generated UUID - the idempotency key
|
|
11
|
+
ts: number // ms epoch at creation
|
|
12
|
+
type: EventType
|
|
13
|
+
commentId?: string // the comment this event belongs to (root id = thread id)
|
|
14
|
+
parentId?: string // replies: the ROOT comment id (threads are flat)
|
|
15
|
+
board?: string
|
|
16
|
+
nodeKey?: string // a frame can sit on a board twice - comments are node-scoped
|
|
17
|
+
frame?: string
|
|
18
|
+
anchor?: unknown // SPEC-M3 §5 bundle; absent = frame-level comment
|
|
19
|
+
author?: { email: string; name?: string; avatar?: string }
|
|
20
|
+
body?: string // plain text in v1
|
|
21
|
+
emoji?: string // react events
|
|
22
|
+
addressedIn?: string // resolve events: the variant frame that answered
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface Thread {
|
|
26
|
+
id: string
|
|
27
|
+
board?: string; nodeKey?: string; frame?: string
|
|
28
|
+
anchor?: unknown
|
|
29
|
+
author?: CommentEvent['author']
|
|
30
|
+
body?: string
|
|
31
|
+
ts: number
|
|
32
|
+
resolved: boolean
|
|
33
|
+
addressedIn?: string
|
|
34
|
+
replies: { id: string; author?: CommentEvent['author']; body?: string; ts: number }[]
|
|
35
|
+
reactions: Record<string, string[]> // emoji -> author emails (toggle semantics)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Events the other side lacks, by id - the sync payload in either direction. */
|
|
39
|
+
export function diffEvents(mine: CommentEvent[], theirIds: Iterable<string>): CommentEvent[] {
|
|
40
|
+
const have = new Set(theirIds)
|
|
41
|
+
return mine.filter((e) => !have.has(e.id))
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Replay a board's events into current thread state. Deterministic: replay order is
|
|
45
|
+
* (ts, id) so two stores holding the same event SET always derive the same state.
|
|
46
|
+
* TWO passes - creates first, then everything else - so a reply whose author's clock
|
|
47
|
+
* ran ahead of the creator's still lands instead of being dropped forever. */
|
|
48
|
+
export function replay(events: CommentEvent[]): Thread[] {
|
|
49
|
+
const ordered = [...events].sort((a, b) => a.ts - b.ts || (a.id < b.id ? -1 : 1))
|
|
50
|
+
const threads = new Map<string, Thread>()
|
|
51
|
+
for (const ev of ordered) {
|
|
52
|
+
if (ev.type !== 'create') continue
|
|
53
|
+
if (!ev.commentId || threads.has(ev.commentId)) continue
|
|
54
|
+
threads.set(ev.commentId, {
|
|
55
|
+
id: ev.commentId, board: ev.board, nodeKey: ev.nodeKey, frame: ev.frame,
|
|
56
|
+
anchor: ev.anchor, author: ev.author, body: ev.body, ts: ev.ts,
|
|
57
|
+
resolved: false, replies: [], reactions: {},
|
|
58
|
+
})
|
|
59
|
+
}
|
|
60
|
+
for (const ev of ordered) {
|
|
61
|
+
switch (ev.type) {
|
|
62
|
+
case 'reply': {
|
|
63
|
+
const t = ev.parentId ? threads.get(ev.parentId) : undefined
|
|
64
|
+
if (!t || !ev.commentId || t.replies.some((r) => r.id === ev.commentId)) break
|
|
65
|
+
t.replies.push({ id: ev.commentId, author: ev.author, body: ev.body, ts: ev.ts })
|
|
66
|
+
break
|
|
67
|
+
}
|
|
68
|
+
case 'edit': {
|
|
69
|
+
if (!ev.commentId || ev.body === undefined) break
|
|
70
|
+
const t = threads.get(ev.commentId)
|
|
71
|
+
if (t) { t.body = ev.body; break }
|
|
72
|
+
for (const th of threads.values()) {
|
|
73
|
+
const r = th.replies.find((r) => r.id === ev.commentId)
|
|
74
|
+
if (r) { r.body = ev.body; break }
|
|
75
|
+
}
|
|
76
|
+
break
|
|
77
|
+
}
|
|
78
|
+
case 'resolve': {
|
|
79
|
+
const t = ev.commentId ? threads.get(ev.commentId) : undefined
|
|
80
|
+
if (t) { t.resolved = true; if (ev.addressedIn) t.addressedIn = ev.addressedIn }
|
|
81
|
+
break
|
|
82
|
+
}
|
|
83
|
+
case 'reopen': {
|
|
84
|
+
const t = ev.commentId ? threads.get(ev.commentId) : undefined
|
|
85
|
+
if (t) { t.resolved = false; t.addressedIn = undefined }
|
|
86
|
+
break
|
|
87
|
+
}
|
|
88
|
+
case 'react': {
|
|
89
|
+
// toggle keyed on comment+author+emoji: present removes, absent adds
|
|
90
|
+
const t = ev.commentId ? threads.get(ev.commentId) : undefined
|
|
91
|
+
const who = ev.author?.email
|
|
92
|
+
if (!t || !who || !ev.emoji) break
|
|
93
|
+
const users = (t.reactions[ev.emoji] ??= [])
|
|
94
|
+
const at = users.indexOf(who)
|
|
95
|
+
if (at === -1) users.push(who)
|
|
96
|
+
else { users.splice(at, 1); if (!users.length) delete t.reactions[ev.emoji] }
|
|
97
|
+
break
|
|
98
|
+
}
|
|
99
|
+
// 'profile' events update author snapshots at write time on the client - replay ignores them
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return [...threads.values()]
|
|
103
|
+
}
|
|
@@ -21,7 +21,8 @@ file in design/instructions/ - they are short, strict, and part of this contract
|
|
|
21
21
|
| Build | hi-fi frames from real components | instructions/craft.md + components.md |
|
|
22
22
|
| Iterate | changing a frame the human has seen, or retiring explorations | instructions/iterate.md |
|
|
23
23
|
| Review | before presenting anything | instructions/review.md |
|
|
24
|
-
| Boards | creating a board
|
|
24
|
+
| Boards | creating a board, choosing what ships | instructions/boards.md |
|
|
25
|
+
| Publish | deploying the canvas: gate, volume, accounts, invites | instructions/publish.md |
|
|
25
26
|
|
|
26
27
|
Refining an existing screen: Configure must hold, then Build + Review. New work runs
|
|
27
28
|
the full ladder. Unsure which phase you are in? Ask the human - one question beats a
|
|
@@ -37,6 +38,17 @@ Stuck, or the human is unhappy with a result? instructions/reference/ holds the
|
|
|
37
38
|
guides (layout, typography, color, motion, copy, states, tuning, critique, concepts) -
|
|
38
39
|
the routing index is at the top of instructions/craft.md. Pull ONE file, apply, return.
|
|
39
40
|
|
|
41
|
+
## When the human points at a specific element
|
|
42
|
+
|
|
43
|
+
Two channels carry element-precise feedback - honor both:
|
|
44
|
+
- **A pasted address** like `design/scenes/hero/a.tsx · #root > div > h1 (a.tsx:12)` is
|
|
45
|
+
a LASER-COPIED pointer: the human pressed L (laser mode), hovered to see the element,
|
|
46
|
+
clicked it, and its exact address landed on their clipboard. Open that frame file and
|
|
47
|
+
go straight to that element - the css path (and source location, when present) are exact.
|
|
48
|
+
- **A pinned comment** on an element: run `npx marver comments list --open --json` - each
|
|
49
|
+
thread carries the anchored element (tag, quoted text, css path, frame). Work that queue
|
|
50
|
+
per instructions/iterate.md; the comment names the div, so read the anchor before the words.
|
|
51
|
+
|
|
40
52
|
## Frames
|
|
41
53
|
- A frame = one file: design/scenes/<scene>/<name>.tsx or .html. One frame, one surface.
|
|
42
54
|
- It default-exports a React component. No imports from the tool are needed. Optional:
|
|
@@ -21,7 +21,8 @@ file in design/instructions/ - they are short, strict, and part of this contract
|
|
|
21
21
|
| Build | hi-fi frames from real components | instructions/craft.md + components.md |
|
|
22
22
|
| Iterate | changing a frame the human has seen, or retiring explorations | instructions/iterate.md |
|
|
23
23
|
| Review | before presenting anything | instructions/review.md |
|
|
24
|
-
| Boards | creating a board
|
|
24
|
+
| Boards | creating a board, choosing what ships | instructions/boards.md |
|
|
25
|
+
| Publish | deploying the canvas: gate, volume, accounts, invites | instructions/publish.md |
|
|
25
26
|
|
|
26
27
|
Refining an existing screen: Configure must hold, then Build + Review. New work runs
|
|
27
28
|
the full ladder. Unsure which phase you are in? Ask the human - one question beats a
|
|
@@ -37,6 +38,17 @@ Stuck, or the human is unhappy with a result? instructions/reference/ holds the
|
|
|
37
38
|
guides (layout, typography, color, motion, copy, states, tuning, critique, concepts) -
|
|
38
39
|
the routing index is at the top of instructions/craft.md. Pull ONE file, apply, return.
|
|
39
40
|
|
|
41
|
+
## When the human points at a specific element
|
|
42
|
+
|
|
43
|
+
Two channels carry element-precise feedback - honor both:
|
|
44
|
+
- **A pasted address** like `design/scenes/hero/a.tsx · #root > div > h1 (a.tsx:12)` is
|
|
45
|
+
a LASER-COPIED pointer: the human pressed L (laser mode), hovered to see the element,
|
|
46
|
+
clicked it, and its exact address landed on their clipboard. Open that frame file and
|
|
47
|
+
go straight to that element - the css path (and source location, when present) are exact.
|
|
48
|
+
- **A pinned comment** on an element: run `npx marver comments list --open --json` - each
|
|
49
|
+
thread carries the anchored element (tag, quoted text, css path, frame). Work that queue
|
|
50
|
+
per instructions/iterate.md; the comment names the div, so read the anchor before the words.
|
|
51
|
+
|
|
40
52
|
## Frames
|
|
41
53
|
- A frame = one file: design/scenes/<scene>/<name>.tsx or .html. One frame, one surface.
|
|
42
54
|
- It default-exports a React component. No imports from the tool are needed. Optional:
|
|
@@ -78,9 +78,12 @@ ordered list of atoms and `{ "space": n }` tokens.
|
|
|
78
78
|
|
|
79
79
|
## Publishing
|
|
80
80
|
|
|
81
|
-
Boards are the unit of publishing
|
|
82
|
-
|
|
83
|
-
|
|
81
|
+
Boards are the unit of publishing: `design/publish.json` names each shipped board
|
|
82
|
+
with `read` or `comment` rights (`marver build` fails without it - default-closed;
|
|
83
|
+
`--boards <name>` overrides ad hoc). Every frame a published flow data-gotos must
|
|
84
|
+
be ON a published board - unlisted frames are excluded from the bundle at build
|
|
85
|
+
time. Deploying the built canvas - gate password, the collaboration volume,
|
|
86
|
+
accounts and invites - is its own phase: **instructions/publish.md**.
|
|
84
87
|
|
|
85
88
|
The published gate page shows the app's identity: `design/logo.svg` + the host
|
|
86
89
|
package name (overridable via config `share`). If the app has no logo asset yet,
|