@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.
@@ -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
+ }
@@ -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
- return { board, ...(n.length ? { n } : {}) }
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
- return `#/b/${s.board}${s.n?.length ? `?n=${s.n.join(',')}` : ''}`
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 or publishing | instructions/boards.md |
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 or publishing | instructions/boards.md |
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 (`marver build --boards <name>`): every frame a
82
- published flow data-gotos must be ON that board - unlisted frames are excluded from
83
- the bundle at build time.
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,