@marver-design/marver 0.11.1 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/CHANGELOG.md +111 -0
  2. package/README.md +1 -1
  3. package/dist/auth-DdUKeVKt.mjs +1150 -0
  4. package/dist/{build-DI31MFTB.mjs → build-BxGrHFT2.mjs} +188 -33
  5. package/dist/cli.mjs +16 -7
  6. package/dist/collab-mAiNZlDj.mjs +970 -0
  7. package/dist/{comments-CRqaO2MH.mjs → comments-DHB_8BRa.mjs} +3 -2
  8. package/dist/{daemon-DSmaS453.mjs → daemon-BChkzDqQ.mjs} +3 -3
  9. package/dist/{dev-BWIL2ewj.mjs → dev-DLwt3Brb.mjs} +5 -5
  10. package/dist/events-B3LBn74P.mjs +103 -0
  11. package/dist/{init-BSclDg4I.mjs → init-BpitOqRQ.mjs} +2 -3
  12. package/dist/{config-t9coJ-Pq.mjs → manifest-CS6krOTe.mjs} +236 -4
  13. package/dist/{marver-id-B8-3WiHk.mjs → marver-id-CVCoaMGI.mjs} +106 -1
  14. package/dist/{marver-id-gate-BDzW6ahN.mjs → marver-id-gate-B2uraTHS.mjs} +100 -9
  15. package/dist/{plugin-DjBFxEQ7.mjs → plugin-DNc4Jpae.mjs} +82 -9
  16. package/dist/{profile-DcsJyppw.mjs → profile-BjAPAJSb.mjs} +1 -1
  17. package/dist/{serve-3APqaNSB.mjs → serve-EjEqsiYa.mjs} +70 -17
  18. package/dist/share-Gqo_Ygqw.mjs +158 -0
  19. package/dist/summary-C7CAypbB.mjs +211 -0
  20. package/dist/{sync-DELGomPk.mjs → sync-BZaCWqK-.mjs} +3 -102
  21. package/package.json +1 -1
  22. package/src/client/shell/App.tsx +62 -11
  23. package/src/client/shell/Comments.tsx +103 -22
  24. package/src/client/shell/LockedApp.tsx +113 -0
  25. package/src/client/shell/Play.tsx +97 -18
  26. package/src/client/shell/Toolbar.tsx +26 -2
  27. package/src/client/shell/canvas/Canvas.tsx +5 -15
  28. package/src/client/shell/canvas/ctl.ts +26 -0
  29. package/src/client/shell/comments-store.ts +96 -11
  30. package/src/client/shell/hash.ts +19 -0
  31. package/src/client/shell/icons.tsx +1 -0
  32. package/src/client/shell/mentions.ts +108 -1
  33. package/src/client/shell/sound.ts +32 -0
  34. package/src/client/shell/store.ts +66 -7
  35. package/src/client/shell/styles.css +68 -0
  36. package/src/shared/events.ts +20 -6
  37. package/dist/auth-B5yuwnOq.mjs +0 -494
  38. package/dist/collab-CWaG3Q4w.mjs +0 -340
  39. package/dist/manifest-C2tzkNaC.mjs +0 -233
  40. package/dist/update-DuWDj5nR.mjs +0 -77
  41. package/dist/utm-CxC3QN5X.mjs +0 -20
@@ -0,0 +1,26 @@
1
+ /**
2
+ * The canvas control channel, in its own dependency-free module.
3
+ *
4
+ * Play, Comments and App steer the camera through this object; Canvas assigns
5
+ * the real implementations when it mounts. Living here rather than inside
6
+ * Canvas.tsx keeps the play/focus surfaces importable WITHOUT the canvas - a
7
+ * locked-shell bundle mounts the stage chrome and the canvas code never enters
8
+ * it (01-sharing §5.1's all-boards rule). Before Canvas mounts (or when it
9
+ * never does), every call is a harmless no-op.
10
+ */
11
+
12
+ /** B0.2: one wheel event, whatever its origin - a shell-document wheel over the canvas, or
13
+ * a wheel forwarded from a passive frame's iframe. clientX/Y are shell-viewport pixels. */
14
+ export interface CanvasWheelInput {
15
+ deltaX: number; deltaY: number; deltaMode: number
16
+ ctrlKey: boolean; metaKey: boolean; clientX: number; clientY: number
17
+ }
18
+
19
+ export const canvasCtl = {
20
+ fitNode(_key: string) {},
21
+ fitNodes(_keys: string[]) {},
22
+ fitAll() {},
23
+ zoomTo(_scale: number) {},
24
+ zoom100() { canvasCtl.zoomTo(1) },
25
+ wheel(_input: CanvasWheelInput) {},
26
+ }
@@ -6,21 +6,41 @@
6
6
  */
7
7
  import { create } from 'zustand'
8
8
  import { replay, type CommentEvent, type Thread } from '../../shared/events.ts'
9
+ import { mentionPeople, mentionsIn } from './mentions.ts'
10
+ import { playPing } from './sound.ts'
9
11
  import { ROUTE } from '../const.ts'
10
12
 
11
- export interface Me { email: string; name: string; avatar?: string }
13
+ export interface Me {
14
+ email: string
15
+ name: string
16
+ avatar?: string
17
+ /** The viewer's own opaque author id on a published canvas - what projected
18
+ * events carry instead of an email. "Is this mine" compares ids first. */
19
+ id?: string
20
+ }
21
+
22
+ /** The author snapshot a POST carries: the canonical profile, never the opaque
23
+ * id - that field is server-assigned, and the server refuses it in writes. */
24
+ const postAuthor = (me: Me | null): { email: string; name?: string; avatar?: string } | undefined =>
25
+ me ? { email: me.email, name: me.name, ...(me.avatar ? { avatar: me.avatar } : {}) } : undefined
12
26
 
13
27
  interface CommentsState {
14
28
  events: CommentEvent[] // EVERY board's log, unioned - comments are frame-scoped
15
29
  threads: Thread[] // derived on every change; each carries its origin board
16
30
  board: string | null
17
31
  me: Me | null // null on published until signed in
32
+ /** The viewer's effective role per board (published) - what makes a locked
33
+ * capability honest: `view` on a comment board = "not available for you YET". */
34
+ myBoards: Record<string, string> | null
18
35
  local: boolean // dev mirror (identity is the local profile)
19
36
  connected: boolean // dev only: a connect account provides name/email (read-only here)
20
37
  commentMode: boolean // C - picking + composing
21
38
  show: boolean // Shift+C - pins visible at all
22
39
  showAnchor: boolean // Shift+L - light the tagged ELEMENT while its thread is open
23
40
  active: string | null // open thread id
41
+ /** threadId -> when I last opened it; feeds the mention-alert pulse. This
42
+ * browser only (localStorage) - a per-person server mark is v1.2 territory. */
43
+ mentionSeen: Record<string, number>
24
44
  // picked, composing. `board` is captured at pick time - the write must land in the
25
45
  // log of the board the user was LOOKING at, however long the composer stays open
26
46
  draft: { nodeKey: string; frame: string; anchor: unknown; board?: string } | null
@@ -48,9 +68,14 @@ interface CommentsState {
48
68
  claim(token: string, password: string, name: string, avatar?: string): Promise<string | null>
49
69
  saveProfile(patch: Partial<Me>): Promise<string | null>
50
70
  dismissIdentity(): void
71
+ /** The admitted viewer's "Ask to comment" - one request into the owner's queue. */
72
+ askToComment(): Promise<boolean>
51
73
  }
52
74
 
53
75
  const uuid = () => crypto.randomUUID()
76
+ const loadMentionSeen = (): Record<string, number> => {
77
+ try { return JSON.parse(localStorage.getItem('mv-mention-seen') ?? '{}') } catch { return {} }
78
+ }
54
79
  const csrf = () => /(?:^|;\s*)mv_c=([\w-]+)/.exec(document.cookie)?.[1] ?? ''
55
80
  const api = async (path: string, body?: unknown) => {
56
81
  const res = await fetch(`${ROUTE}/api/${path}`, body === undefined ? undefined : {
@@ -101,6 +126,41 @@ export const useComments = create<CommentsState>((set, get) => {
101
126
  }
102
127
  })
103
128
  }
129
+ // A HUMAN's fresh comment that concerns me raises the same pill the agent
130
+ // gets, person-first: a mention of me anywhere, or a reply in a thread I am
131
+ // part of. Same freshness rules as notifyAgent; one ping per batch, not per
132
+ // event - a burst must not become a drum roll.
133
+ const notifyHuman = (fresh: CommentEvent[]) => {
134
+ const me = get().me
135
+ if (!me) return
136
+ const isMe = (a?: { id?: string; email?: string }) =>
137
+ !!a && ((!!a.id && a.id === me.id) || (!!a.email && !!me.email && a.email.toLowerCase() === me.email.toLowerCase()))
138
+ const news = fresh.filter((e) => !e.agent && (e.type === 'create' || e.type === 'reply') && e.author && !isMe(e.author))
139
+ if (!news.length) return
140
+ const notes: { e: CommentEvent; kind: 'mention' | 'reply' }[] = []
141
+ for (const e of news) {
142
+ if ((e.mentions ?? []).some((m) => isMe(m))) { notes.push({ e, kind: 'mention' }); continue }
143
+ if (e.type !== 'reply' || !e.parentId) continue
144
+ const t = get().threads.find((t) => t.id === e.parentId)
145
+ const mine = !!t && (isMe(t.author) || t.replies.some((r) => r.id !== e.commentId && isMe(r.author)))
146
+ if (mine) notes.push({ e, kind: 'reply' })
147
+ }
148
+ if (!notes.length) return
149
+ playPing()
150
+ void import('./store.ts').then(({ useStore }) => {
151
+ const s = useStore.getState()
152
+ for (const { e, kind } of notes) {
153
+ const threadId = e.type === 'reply' ? e.parentId! : e.commentId!
154
+ const t = get().threads.find((t) => t.id === threadId)
155
+ const entry = t?.frame ? s.manifest?.frames.find((f) => f.id === t.frame) : undefined
156
+ s.jamToast({
157
+ threadId, board: t?.board ?? e.board ?? get().board ?? '', ts: e.ts, kind, author: e.author,
158
+ preview: (e.body ?? '').replace(/\s+/g, ' ').slice(0, 90),
159
+ frame: t?.frame, frameTitle: entry?.title ?? t?.frame, intent: entry?.intent,
160
+ })
161
+ }
162
+ })
163
+ }
104
164
  // events are append-only everywhere, so union is ALWAYS safe - the one rule is the
105
165
  // baseline, PER BOARD: a board's first successful read must never replay its history
106
166
  // as notifications (the switcher list can arrive after the first sweep, so a global
@@ -114,7 +174,9 @@ export const useComments = create<CommentsState>((set, get) => {
114
174
  const fresh = events.filter((e) => !have.has(key(e)) && (have.add(key(e)), true))
115
175
  if (!fresh.length) return
116
176
  set(derive([...get().events, ...fresh]))
117
- notifyAgent(fresh.filter((e) => (e.board && baselinedBoards.has(e.board)) || e.ts > bootTs))
177
+ const news = fresh.filter((e) => (e.board && baselinedBoards.has(e.board)) || e.ts > bootTs)
178
+ notifyAgent(news)
179
+ notifyHuman(news)
118
180
  }
119
181
  /** Legacy logs (0.8.0 clients never sent `board`) get it from the endpoint they came
120
182
  * from - without it, replies to their threads would route to whatever board is open. */
@@ -145,7 +207,8 @@ export const useComments = create<CommentsState>((set, get) => {
145
207
  }
146
208
 
147
209
  return {
148
- events: [], threads: [], board: null, me: null, local: false, connected: false,
210
+ events: [], threads: [], board: null, me: null, myBoards: null, local: false, connected: false,
211
+ mentionSeen: loadMentionSeen(),
149
212
  commentMode: false, show: true, showAnchor: true, active: null, draft: null, needsIdentity: false, inviteToken: null,
150
213
 
151
214
  poke(board) {
@@ -165,8 +228,16 @@ export const useComments = create<CommentsState>((set, get) => {
165
228
  else set({ board })
166
229
  const me = await api('me')
167
230
  if (me.status === 404) { apiOff = true; return } // static serve: no comments API at all
168
- if (me.ok) set({ me: me.data.user ?? null, local: !!me.data.local, connected: !!me.data.connected })
231
+ if (me.ok) set({
232
+ me: me.data.user ? { ...me.data.user, ...(me.data.id ? { id: me.data.id } : {}) } : null,
233
+ myBoards: me.data.boards && typeof me.data.boards === 'object' ? me.data.boards : null,
234
+ local: !!me.data.local, connected: !!me.data.connected,
235
+ })
169
236
  await fetchAll()
237
+ // mark-seen: the board was actually PRESENTED (load runs on open, never
238
+ // from the background poll), so the viewer's unread mark advances - the
239
+ // front door's counter moves on this and nothing else (02-home §4)
240
+ if (!get().local && get().me) void api('seen', { board }).catch(() => {})
170
241
  },
171
242
 
172
243
  /** Liveness: SSE on the published serve; dev has no event rail (its sync loop
@@ -225,10 +296,11 @@ export const useComments = create<CommentsState>((set, get) => {
225
296
  const { draft, me } = get()
226
297
  if (!draft || !body.trim()) return
227
298
  const id = uuid()
299
+ const mentions = mentionsIn(body.trim(), mentionPeople(get().events, me ?? undefined))
228
300
  const ok = await get().send([{
229
301
  id: uuid(), ts: Date.now(), type: 'create', commentId: id,
230
302
  nodeKey: draft.nodeKey, frame: draft.frame, anchor: draft.anchor,
231
- author: me ?? undefined, body: body.trim(),
303
+ author: postAuthor(me), body: body.trim(), ...(mentions ? { mentions } : {}),
232
304
  }], draft.board)
233
305
  if (ok) set({ draft: null, active: id, commentMode: false })
234
306
  },
@@ -239,9 +311,10 @@ export const useComments = create<CommentsState>((set, get) => {
239
311
  // route to the thread's ORIGIN log - the viewer may be reading it from another
240
312
  // board (frame-scoped display); a reply landing in the wrong log would fork it
241
313
  const origin = get().threads.find((t) => t.id === threadId)?.board
314
+ const mentions = mentionsIn(body.trim(), mentionPeople(get().events, get().me ?? undefined))
242
315
  return get().send([{
243
316
  id: uuid(), ts: Date.now(), type: 'reply', commentId: uuid(), parentId: threadId,
244
- author: get().me ?? undefined, body: body.trim(),
317
+ author: postAuthor(get().me), body: body.trim(), ...(mentions ? { mentions } : {}),
245
318
  }], origin)
246
319
  },
247
320
 
@@ -253,7 +326,14 @@ export const useComments = create<CommentsState>((set, get) => {
253
326
  setMode(on) { set({ commentMode: on, ...(on ? { show: true } : { draft: null }) }) },
254
327
  setShow(show) { set({ show }) },
255
328
  setShowAnchor(showAnchor) { set({ showAnchor }) },
256
- setActive(active) { set({ active }) },
329
+ setActive(active) {
330
+ // opening a thread is SEEING it - the mention pulse for it stops here
331
+ if (active) {
332
+ const mentionSeen = { ...get().mentionSeen, [active]: Date.now() }
333
+ try { localStorage.setItem('mv-mention-seen', JSON.stringify(mentionSeen)) } catch { /* private mode */ }
334
+ set({ active, mentionSeen })
335
+ } else set({ active })
336
+ },
257
337
  setDraft(draft) { set({ draft: draft ? { ...draft, board: draft.board ?? get().board ?? undefined } : null }) },
258
338
  dismissIdentity() { set({ needsIdentity: false }) }, // inviteToken survives dismissal - commenting later reopens the claim
259
339
 
@@ -275,20 +355,25 @@ export const useComments = create<CommentsState>((set, get) => {
275
355
  set({ me: res.data.user })
276
356
  return null
277
357
  },
358
+ async askToComment() {
359
+ const res = await api('request-access', { requestedRole: 'comment' })
360
+ return res.ok
361
+ },
278
362
  }
279
363
  })
280
364
 
281
365
  // dev-only debug handle - the canvas store exposes the same
282
366
  if (typeof window !== 'undefined' && (import.meta as any).env?.DEV) (window as any).__mvComments = useComments
283
367
 
284
- /** Initials + deterministic hue for avatarless authors - the whole fallback ladder. */
285
- export const avatarFallback = (author?: { email?: string; name?: string }) => {
368
+ /** Initials + deterministic hue for avatarless authors - the whole fallback ladder.
369
+ * Hue keys on the stable identity: email locally, the opaque id when projected. */
370
+ export const avatarFallback = (author?: { email?: string; id?: string; name?: string }) => {
286
371
  const name = author?.name || author?.email || '?'
287
372
  // the unset dev default ("You", no account) renders in the COMMENT green - it's the mode's
288
373
  // own hue, not a fake identity color, until a real profile is set
289
- if (name === 'You' && !author?.email) return { initials: 'Y', hue: 131 }
374
+ if (name === 'You' && !author?.email && !author?.id) return { initials: 'Y', hue: 131 }
290
375
  const initials = name.split(/\s+/).map((w) => w[0]).slice(0, 2).join('').toUpperCase()
291
376
  let h = 0
292
- for (const c of (author?.email || name)) h = (h * 31 + c.charCodeAt(0)) % 360
377
+ for (const c of (author?.email || author?.id || name)) h = (h * 31 + c.charCodeAt(0)) % 360
293
378
  return { initials, hue: h }
294
379
  }
@@ -10,6 +10,9 @@
10
10
  * #/b/<board>?c=<id> board with a comment thread open
11
11
  * #/i/<token> invite link - opens the claim dialog with the token
12
12
  * #/p/<board>?at=<frame-id>&device=<viewport>&theme=<theme> play mode
13
+ * #/f/<frame-id>?device=&theme= focus - one frame, full screen, no board in
14
+ * the chrome (a frame deep link is presentation,
15
+ * not access: 01-sharing §3.5)
13
16
  */
14
17
 
15
18
  export interface HashState {
@@ -18,6 +21,8 @@ export interface HashState {
18
21
  c?: string
19
22
  invite?: string
20
23
  play?: { at?: string; device?: string; theme?: string }
24
+ /** A frame deep link: lands that visit in focus, over every other rule. */
25
+ focus?: { at: string; device?: string; theme?: string }
21
26
  }
22
27
 
23
28
  const BOARD_RE = /^[a-z0-9][a-z0-9-]*$/
@@ -31,6 +36,13 @@ export function parseHash(hash: string = location.hash): HashState {
31
36
  const params = new URLSearchParams(q === -1 ? '' : raw.slice(q + 1))
32
37
  const mi = path.match(/^\/i\/([\w-]{8,128})$/)
33
38
  if (mi) return { invite: mi[1] }
39
+ // frame ids are <scene>/<name> (or bare <name>), so the f route takes the rest of the path
40
+ const mf = path.match(/^\/f\/(.+)$/)
41
+ if (mf) {
42
+ const at = decodeURIComponent(mf[1])
43
+ if (!/^[\w./-]{1,200}$/.test(at) || at.includes('..')) return {}
44
+ return { focus: { at, device: params.get('device') ?? undefined, theme: params.get('theme') ?? undefined } }
45
+ }
34
46
  const m = path.match(/^\/(b|p)\/([^/?]+)$/)
35
47
  if (!m) return {}
36
48
  const board = decodeURIComponent(m[2])
@@ -52,6 +64,13 @@ export function parseHash(hash: string = location.hash): HashState {
52
64
  }
53
65
 
54
66
  export function buildHash(s: HashState): string {
67
+ if (s.focus?.at) {
68
+ const p = new URLSearchParams()
69
+ if (s.focus.device) p.set('device', s.focus.device)
70
+ if (s.focus.theme) p.set('theme', s.focus.theme)
71
+ const q = p.toString()
72
+ return `#/f/${s.focus.at}${q ? `?${q}` : ''}`
73
+ }
55
74
  if (s.play?.at && s.board) {
56
75
  const p = new URLSearchParams({ at: s.play.at })
57
76
  if (s.play.device) p.set('device', s.play.device)
@@ -86,3 +86,4 @@ export function deviceIcon(name: string | null, size = 14) {
86
86
  return <Icon size={size} />
87
87
  }
88
88
 
89
+ export const BrowsersIcon = icon(<path d="M216,40H72A16,16,0,0,0,56,56V72H40A16,16,0,0,0,24,88V200a16,16,0,0,0,16,16H184a16,16,0,0,0,16-16V184h16a16,16,0,0,0,16-16V56A16,16,0,0,0,216,40ZM184,88v16H40V88Zm0,112H40V120H184v80Zm32-32H200V88a16,16,0,0,0-16-16H72V56H216Z" />)
@@ -1,7 +1,13 @@
1
1
  /**
2
- * @marver mention parsing - pure, so the rendering is unit-testable without
2
+ * Mention parsing - pure, so the rendering is unit-testable without
3
3
  * pulling the whole comment UI (and its virtual:sh-config) into the test.
4
+ *
5
+ * Two kinds live in a body: `@marver` (the Live Jam trigger, reserved) and
6
+ * person mentions (`@Display Name`, sharing v1.1). ONE segmentation drives
7
+ * highlighting AND the mentions array a send derives - what lights up is
8
+ * exactly what notifies.
4
9
  */
10
+ import type { CommentEvent, Mention } from '../../shared/events.ts'
5
11
 
6
12
  /** Split a comment body into plain text and @marver mention segments (case-insensitive, word-bounded). */
7
13
  export function parseMentions(body: string): { text: string; mention: boolean }[] {
@@ -16,3 +22,104 @@ export function parseMentions(body: string): { text: string; mention: boolean }[
16
22
  if (last < body.length) out.push({ text: body.slice(last), mention: false })
17
23
  return out.length ? out : [{ text: body, mention: false }]
18
24
  }
25
+
26
+ /** Someone the composer may name: a distinct author (or past mention target)
27
+ * seen in this canvas's events. `id` on a published canvas (the projected
28
+ * transport), `email` in dev (canonical) - exactly what the event will carry. */
29
+ export interface MentionPerson { label: string; id?: string; email?: string; avatar?: string }
30
+
31
+ const personKey = (p: { id?: string; email?: string }) => p.id ? `i:${p.id}` : `e:${p.email?.toLowerCase()}`
32
+
33
+ /** The mentionable set: named humans from the event log, first-seen order,
34
+ * minus the viewer themself and the reserved agent NAMESPACE - any label
35
+ * STARTING with the word "marver" is out, because the Live Jam daemon's own
36
+ * watcher sees `@marver` inside `@Marver Team` and would run the agent. */
37
+ export function mentionPeople(events: CommentEvent[], self?: { id?: string; email?: string }): MentionPerson[] {
38
+ const seen = new Map<string, MentionPerson>()
39
+ const selfKey = self && (self.id || self.email) ? personKey(self) : null
40
+ const add = (a?: { id?: string; email?: string; name?: string; avatar?: string }, label?: string) => {
41
+ const name = (label ?? a?.name)?.trim()
42
+ if (!a || (!a.id && !a.email) || !name || /^marver\b/i.test(name)) return
43
+ const k = personKey(a)
44
+ if (k !== selfKey && !seen.has(k)) seen.set(k, { label: name, id: a.id, email: a.email, avatar: a.avatar })
45
+ }
46
+ for (const ev of events) {
47
+ if (!ev.agent && (ev.type === 'create' || ev.type === 'reply')) add(ev.author)
48
+ for (const m of ev.mentions ?? []) add(m, m.label)
49
+ }
50
+ return [...seen.values()]
51
+ }
52
+
53
+ export type BodySegment = { text: string; marver?: boolean; person?: MentionPerson }
54
+
55
+ const escapeRe = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
56
+
57
+ /** Segment a body against the known people: `@Label` runs (longest label wins
58
+ * at each position, case-insensitive, trailing-boundary-guarded, and only at
59
+ * a mention START - after whitespace, an opener, or the beginning; `foo@Sam`
60
+ * is an address shape, not a mention, exactly the rule the typeahead uses)
61
+ * plus `@marver` (whose namespace mentionPeople already reserves). */
62
+ export function parseBody(body: string, people: MentionPerson[]): BodySegment[] {
63
+ if (!people.length) return parseMentions(body).map((s) => (s.mention ? { text: s.text, marver: true } : { text: s.text }))
64
+ const byLength = [...people].sort((a, b) => b.label.length - a.label.length)
65
+ const alts = byLength.map((p) => `${escapeRe(p.label)}(?![\\w])`)
66
+ alts.push('marver\\b')
67
+ const pattern = new RegExp(`(?<=^|[\\s(])@(?:${alts.join('|')})`, 'gim')
68
+ const out: BodySegment[] = []
69
+ let last = 0
70
+ for (const m of body.matchAll(pattern)) {
71
+ const i = m.index ?? 0
72
+ if (i > last) out.push({ text: body.slice(last, i) })
73
+ const hit = m[0].slice(1)
74
+ if (/^marver$/i.test(hit)) out.push({ text: m[0], marver: true })
75
+ else out.push({ text: m[0], person: byLength.find((p) => p.label.toLowerCase() === hit.toLowerCase()) })
76
+ last = i + m[0].length
77
+ }
78
+ if (last < body.length) out.push({ text: body.slice(last) })
79
+ return out.length ? out : [{ text: body }]
80
+ }
81
+
82
+ /** The mentions a body carries, in event form - derived from the SAME
83
+ * segmentation the highlight uses: what lights up is what notifies. ≤ 8,
84
+ * the validator's cap. An ambiguous label (two people wearing the same
85
+ * display name) mentions EVERY bearer - deterministic and stateless, where
86
+ * remembering which twin was picked would depend on hidden composer state. */
87
+ export function mentionsIn(body: string, people: MentionPerson[]): Mention[] | undefined {
88
+ const out = new Map<string, Mention>()
89
+ const hit = new Set([...parseBody(body, people)].flatMap((s) => (s.person ? [s.person.label.toLowerCase()] : [])))
90
+ for (const p of people) {
91
+ if (out.size >= 8) break
92
+ if (hit.has(p.label.toLowerCase()))
93
+ out.set(personKey(p), p.id ? { id: p.id, label: p.label } : { email: p.email, label: p.label })
94
+ }
95
+ return out.size ? [...out.values()] : undefined
96
+ }
97
+
98
+ /** The @-token the caret sits in, for the typeahead: `@` + a partial name
99
+ * (letters, spaces) immediately before `caret`, not already a completed
100
+ * mention. Null when the caret is not in one. */
101
+ export function mentionQueryAt(text: string, caret: number): { start: number; query: string } | null {
102
+ const upto = text.slice(0, caret)
103
+ const m = /(^|[\s(])@([^@\n]{0,40})$/.exec(upto)
104
+ if (!m) return null
105
+ return { start: (m.index ?? 0) + m[1].length, query: m[2] }
106
+ }
107
+
108
+ /** Thread ids holding a mention of ME newer than my seen mark - what the pin
109
+ * pulse and unread ring key on. Pure; the store persists `seen` per thread. */
110
+ export function mentionAlerts(
111
+ events: CommentEvent[], me: { id?: string; email?: string } | null | undefined,
112
+ seen: Record<string, number>,
113
+ ): string[] {
114
+ if (!me || (!me.id && !me.email)) return []
115
+ const mine = (m: Mention) =>
116
+ (!!m.id && m.id === me.id) || (!!m.email && !!me.email && m.email.toLowerCase() === me.email.toLowerCase())
117
+ const out = new Set<string>()
118
+ for (const ev of events) {
119
+ if (ev.type !== 'create' && ev.type !== 'reply') continue
120
+ if (!(ev.mentions ?? []).some(mine)) continue
121
+ const thread = ev.type === 'reply' ? ev.parentId : ev.commentId
122
+ if (thread && ev.ts > (seen[thread] ?? 0)) out.add(thread)
123
+ }
124
+ return [...out]
125
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * The notification ping - two soft sine tones, generated, no asset to load.
3
+ * Autoplay policy makes this best-effort by nature: the context only runs
4
+ * after the person has interacted with the page (they are ON the canvas, so
5
+ * in practice it plays). Every failure path is silence, never an error.
6
+ */
7
+ let ctx: AudioContext | null = null
8
+
9
+ export function playPing(): void {
10
+ try {
11
+ ctx ??= new AudioContext()
12
+ if (ctx.state === 'suspended') {
13
+ // resume() only sticks inside a user gesture; fire-and-forget - if the
14
+ // browser refuses, this ping is silent and the NEXT one plays
15
+ void ctx.resume().catch(() => {})
16
+ if (ctx.state === 'suspended') return
17
+ }
18
+ const t0 = ctx.currentTime
19
+ for (const [freq, at, dur] of [[880, 0, .09], [1174.7, .09, .16]] as const) {
20
+ const osc = ctx.createOscillator()
21
+ const gain = ctx.createGain()
22
+ osc.type = 'sine'
23
+ osc.frequency.value = freq
24
+ gain.gain.setValueAtTime(0, t0 + at)
25
+ gain.gain.linearRampToValueAtTime(.14, t0 + at + .015)
26
+ gain.gain.exponentialRampToValueAtTime(.0001, t0 + at + dur)
27
+ osc.connect(gain).connect(ctx.destination)
28
+ osc.start(t0 + at)
29
+ osc.stop(t0 + at + dur + .02)
30
+ }
31
+ } catch { /* no audio here - fine */ }
32
+ }
@@ -10,11 +10,28 @@ import shData from 'virtual:sh-data'
10
10
  /** Published-build data. Non-null = static site: no API, no saves, no live events.
11
11
  * `names` is the switcher list (all-scenes only when actually published); `default`
12
12
  * is where `/` opens - never a synthesized aggregate of a filtered build. */
13
- const DATA: { manifest: Manifest; boards: Record<string, unknown>; names: string[]; default: string } | null = shData
13
+ const DATA: {
14
+ manifest: Manifest; boards: Record<string, unknown>; names: string[]; default: string
15
+ /** publish.json v2: per-board artifact type + open/lock, and the reveal flags. */
16
+ policy?: { boards: Record<string, { type?: string; open?: string; lock?: boolean }>; reveal?: { structure?: boolean; source?: boolean }; lockedShell?: boolean }
17
+ } | null = shData
18
+
19
+ /** Every published board is locked to a stage mode - the canvas shell is never
20
+ * offered on this bundle (01-sharing §5.1's all-boards rule). */
21
+ export const LOCKED_SHELL = DATA?.policy?.lockedShell === true
14
22
 
15
23
  /** True on a published static canvas - no dev server, no API, no update checks. */
16
24
  export const PUBLISHED = DATA !== null
17
25
 
26
+ /** May this bundle show source paths (laser copy)? Dev always; published only
27
+ * when publish.json says `reveal.source: true`. The strip already removed the
28
+ * paths from the bundle - this flag removes the affordance that would show
29
+ * their opaque replacements (01-sharing §6.2). */
30
+ export const SOURCE_REVEALED = DATA ? DATA.policy?.reveal?.source === true : true
31
+
32
+ /** The published per-board artifact metadata (type, open, lock). Empty in dev. */
33
+ export const BOARD_POLICY: Record<string, { type?: string; open?: string; lock?: boolean }> = DATA?.policy?.boards ?? {}
34
+
18
35
  export interface FrameEntry { id: string; file: string; kind: 'tsx' | 'html'; scene: string; title?: string; viewport?: string; theme?: string; variantGroup?: string; variant?: string; intent?: string; contentWidth?: number }
19
36
  export interface Manifest { frames: FrameEntry[]; scenes: { name: string; frames: number }[] }
20
37
  export interface Node {
@@ -43,12 +60,49 @@ export interface Node {
43
60
  * on every fresh navigation. */
44
61
  readyRetried?: boolean
45
62
  }
46
- /** A Live Jam notification: a persistent bottom-right glass pill for a Marver
47
- * reply. Frame-first: the FRAME is the news (icon + title, blue), Marver + preview below. */
48
- export interface JamNote { threadId: string; board: string; preview: string; ts: number; frame?: string; frameTitle?: string; intent?: string }
63
+ /** A comment notification: a persistent bottom-right glass pill. Three kinds
64
+ * share it - a Marver (agent) reply renders frame-first with the mark; a
65
+ * human `mention`/`reply` renders person-first: their avatar, "«name»
66
+ * mentioned you" / "«name» replied", the message underneath. */
67
+ export interface JamNote {
68
+ threadId: string; board: string; preview: string; ts: number
69
+ frame?: string; frameTitle?: string; intent?: string
70
+ kind?: 'mention' | 'reply' // absent = the agent pill
71
+ author?: { name?: string; avatar?: string; id?: string; email?: string }
72
+ }
49
73
  export interface Toast { id: number; text: string; jam?: JamNote }
50
74
 
51
- export const CONFIG: { viewports: Record<string, { width: number; height: number }>; themes: string[]; zoomSpeed?: number; noTheme: boolean; setup?: boolean; projectName?: string } = shConfig
75
+ export const CONFIG: { viewports: Record<string, { width: number; height: number }>; themes: string[]; zoomSpeed?: number; noTheme: boolean; setup?: boolean; projectName?: string; branding?: boolean } = shConfig
76
+
77
+ /** Invariant: `branding: false` strips every Marver mention - new chrome included. */
78
+ export const BRANDING = CONFIG.branding !== false
79
+
80
+ /** The five view modes' landing map (04-solution §2.35): the artifact type picks
81
+ * the default view; an explicit `open` beats it; a frame deep link beats both. */
82
+ export function landingMode(board: string): 'canvas' | 'board' | 'present' | 'focus' | 'slides' {
83
+ const p = BOARD_POLICY[board]
84
+ if (!p) return 'canvas'
85
+ if (p.open && ['canvas', 'board', 'present', 'focus', 'slides'].includes(p.open)) {
86
+ // slides is v1.5 - until the mode exists it lands in present, its nearest kin
87
+ return p.open === 'slides' ? 'present' : p.open as any
88
+ }
89
+ switch (p.type) {
90
+ case 'doc': return 'focus'
91
+ case 'slides': return 'present' // slides mode is v1.5; present until then
92
+ case 'design': case 'sketch': case 'refs': return 'board'
93
+ default: return 'canvas'
94
+ }
95
+ }
96
+
97
+ /** Is this board frozen to its landing mode? The lock caps disclosure - no way
98
+ * out into other modes, and (published) no sidebar ever renders for it. */
99
+ export const boardLocked = (board: string): boolean => BOARD_POLICY[board]?.lock === true
100
+
101
+ /** May this board enter `mode`? A lock freezes what open pinned - a board
102
+ * locked to canvas must refuse present just as one locked to present refuses
103
+ * canvas. Unlocked boards enter anything. */
104
+ export const modeAllowed = (board: string, mode: 'present' | 'focus'): boolean =>
105
+ !boardLocked(board) || landingMode(board) === mode
52
106
 
53
107
  export { cap, humanize } from './labels.ts'
54
108
  import { cap, humanize } from './labels.ts'
@@ -148,7 +202,11 @@ interface State {
148
202
  selection: string[] // ordered; last entry is the primary (bar anchor)
149
203
  interact: string | null
150
204
  viewTheme: string // the global theme preference; sticky across boards and reloads
151
- play: { at: string; device: string; theme: string } | null // play mode; at = current frame id
205
+ /** Play mode; at = current frame id. `focus` = the single-frame view (the
206
+ * fourth mode): no walking, doc reading preset when the board type is doc.
207
+ * `deep` = entered by a frame deep link - the chrome then carries no board
208
+ * name and no canvas door (presentation default of the link, 04 §2.35). */
209
+ play: { at: string; device: string; theme: string; focus?: boolean; deep?: boolean } | null
152
210
  gesture: boolean // a frame drag/resize is in progress - canvas panning is disabled
153
211
  laser: boolean // laser/inspect mode: frames outline their structure
154
212
  board: string // active board name; 'all-scenes' is the auto board
@@ -978,7 +1036,8 @@ export const useStore = create<State>((set, get) => {
978
1036
  * supersedes), capped so the stack stays small. */
979
1037
  jamToast(note) {
980
1038
  const id = ++toastSeq
981
- set((s) => ({ toasts: [...s.toasts.filter((t) => t.jam?.threadId !== note.threadId), { id, text: 'Marver replied', jam: note }].slice(-8) }))
1039
+ const text = note.kind === 'mention' ? 'You were mentioned' : note.kind === 'reply' ? 'New reply' : 'Marver replied'
1040
+ set((s) => ({ toasts: [...s.toasts.filter((t) => t.jam?.threadId !== note.threadId), { id, text, jam: note }].slice(-8) }))
982
1041
  },
983
1042
  dismissToast(id) { set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) })) },
984
1043
  clearJamToasts() { set((s) => ({ toasts: s.toasts.filter((t) => !t.jam) })) },
@@ -7,6 +7,7 @@
7
7
  --accent: #0088ff; --accent-ring: rgba(0, 136, 255, .2); --beam: rgba(0, 136, 255, .42); --beam-blur: 6px; --beam-bottom: -13px; --beam-h: 12px; --beam-inset: 12%; --edge-accent: rgba(0, 136, 255, .8); --inner-tint: rgba(0, 136, 255, .13); --tip-bg: #18181b; --tip-ink: #fafafa; --accent-wash: rgba(0, 136, 255, .07);
8
8
 
9
9
  --glass: rgba(255, 255, 255, .64);
10
+ --solid: #ffffff;
10
11
  --glass-brd: rgba(24, 24, 27, .1);
11
12
  --glass-ink: #18181b;
12
13
  --glass-ink-2: rgba(24, 24, 27, .66);
@@ -46,6 +47,7 @@
46
47
  --accent: #0088ff; --accent-ring: rgba(0, 136, 255, .32); --beam: rgba(0, 136, 255, .5); --beam-blur: 3px; --beam-bottom: -13px; --beam-h: 16px; --beam-inset: 20%; --edge-accent: rgba(92, 179, 255, .85); --inner-tint: rgba(92, 179, 255, .16); --tip-bg: #f4f4f5; --tip-ink: #18181b; --accent-wash: rgba(0, 136, 255, .14);
47
48
 
48
49
  --glass: rgba(12, 12, 17, .72);
50
+ --solid: #17171b;
49
51
  --glass-brd: rgba(255, 255, 255, .16);
50
52
  --glass-ink: #f5f5f7;
51
53
  --glass-ink-2: rgba(245, 245, 247, .64);
@@ -856,6 +858,33 @@ body.sh-hide-ui .sh-play-nav { display: none !important }
856
858
  .sh-play-pill .sep { background: rgba(255, 255, 255, .12) }
857
859
  .sh-play-pill .bd { color: rgba(245, 245, 247, .82) }
858
860
  .sh-pill-btn.bd { width: auto; gap: 6px; padding: 0 8px 0 11px; white-space: nowrap; font-weight: 600 }
861
+
862
+ /* ---- present/focus chrome (sharing v1): brand pill top-left, counter in the
863
+ toolbar, doc reading preset. Same glass skin as the play pill. */
864
+ .sh-play-brand.sh-pill { left: var(--edge); right: auto; transform-origin: 22px 22px; padding: 5px 14px 5px 10px }
865
+ .sh-play-mark { display: inline-flex; align-items: center; gap: 7px; padding: 0 2px 0 6px; color: rgba(245, 245, 247, .88) }
866
+ .sh-play-mark svg { color: #6db3ff }
867
+ .sh-play-mark .wd { font: 600 13px -apple-system, system-ui, sans-serif; letter-spacing: -.01em }
868
+ .sh-play-title { font: 500 13px -apple-system, system-ui, sans-serif; color: rgba(245, 245, 247, .82);
869
+ max-width: 143px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 0 6px }
870
+ .sh-play-pos { font: 500 12.5px -apple-system, system-ui, sans-serif; color: rgba(245, 245, 247, .64);
871
+ font-variant-numeric: tabular-nums; padding: 0 8px; white-space: nowrap }
872
+ /* the hint pill: bottom center, the design's quiet keyboard cue */
873
+ .sh-play-hint { position: absolute; bottom: 12px; left: 50%; transform: translateX(-50%); z-index: 31;
874
+ border-radius: 999px; padding: 7px 16px; white-space: nowrap;
875
+ background: rgba(18, 18, 24, .8); border: 1px solid rgba(255, 255, 255, .1);
876
+ backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px);
877
+ font: 500 12px -apple-system, system-ui, sans-serif; color: rgba(245, 245, 247, .64) }
878
+ .sh-play-hint kbd { font: 500 11.5px ui-monospace, monospace }
879
+ body.sh-hide-ui .sh-play-hint { display: none !important }
880
+ /* doc preset: the page IS the surface - reading column on a calm ground, no
881
+ device shell, natural document scrolling inside the frame */
882
+ .sh-play.doc { background: #f4f4f6 }
883
+ .sh-play.doc.t-dark { background: #101116 }
884
+ .sh-play.doc .dev { --pdev-bg: #fff; box-shadow: none }
885
+ .sh-play.doc.t-dark .dev { --pdev-bg: #0f1015 }
886
+ /* the doc chrome floats over a LIGHT ground in light theme - the dark pill skin
887
+ still reads (it is glass over anything), matching the design's fixed pills */
859
888
  .sh-play-update { width: auto !important; gap: 5px; padding: 0 11px !important; font-size: 12px; font-weight: 600 }
860
889
  /* the picker dropdowns portal to .sh-app; in play they carry .sh-menu-dark for the dark skin */
861
890
  .sh-menu-dark { background: rgba(20, 20, 26, .97); border-color: rgba(255, 255, 255, .12); box-shadow: 0 12px 40px rgba(0, 0, 0, .5) }
@@ -1094,6 +1123,21 @@ body.sh-hide-ui .sh-play-nav { display: none !important }
1094
1123
  .cm-hl .cm-at { font-weight: inherit; -webkit-text-stroke: .4px currentColor }
1095
1124
  .cm-at-tip { display: block; max-width: 220px; line-height: 1.4 }
1096
1125
 
1126
+ /* v1.1: @Person mentions - the comment accent, medium weight (the mirror rule above
1127
+ already keeps composer metrics honest: color + stroke only, never font-weight). */
1128
+ .cm-at.person { color: var(--cm-line, var(--comment)); font-weight: 600 }
1129
+ .cm-hl .cm-at.person { font-weight: inherit }
1130
+ /* The typeahead: a small glass list ABOVE the composer; rows follow the pick. */
1131
+ /* SOLID, never glass: the picker floats over thread text, and translucency
1132
+ made the names unreadable */
1133
+ .cm-mentionlist { position: absolute; bottom: calc(100% + 6px); left: 0; z-index: 5; min-width: 180px; max-width: 260px;
1134
+ display: flex; flex-direction: column; padding: 4px; border-radius: 10px;
1135
+ background: var(--solid); border: 1px solid var(--glass-brd); box-shadow: 0 10px 28px rgba(0,0,0,.22) }
1136
+ .cm-mentionlist button { display: flex; align-items: center; gap: 8px; padding: 6px 8px; border: 0; border-radius: 7px;
1137
+ background: none; color: var(--glass-ink); font: 500 12.5px/1.2 inherit; text-align: left; cursor: pointer; white-space: nowrap }
1138
+ .cm-mentionlist button span { overflow: hidden; text-overflow: ellipsis }
1139
+ .cm-mentionlist button.on { background: var(--glass-hover) }
1140
+
1097
1141
  /* Marver identity: the agent's avatar mark + provenance tooltip (§7). Always accent, never a hue. */
1098
1142
  .cm-avatar.cm-marver { background: var(--mv-blue); color: rgba(255,255,255,.9); cursor: default } /* white/90 mark, embedded in the accent */
1099
1143
  .cm-marver-wrap { display: inline-flex; border-radius: 50% }
@@ -1181,3 +1225,27 @@ body.sh-hide-ui .sh-play-nav { display: none !important }
1181
1225
  its children (pins, cards, stacks) fully interactive */
1182
1226
  .cm-layer { position: absolute; left: 0; width: 100%; pointer-events: none; z-index: 25 }
1183
1227
  .cm-layer > * { pointer-events: auto }
1228
+
1229
+ /* v1.1: an unseen mention of YOU pulses the pin - accent lights until the
1230
+ thread is opened (opening marks it seen). Motion-reduced readers keep a
1231
+ steady accent ring instead of the pulse. */
1232
+ .cm-pin.mention, .cm-stack.mention { outline: 2px solid var(--accent); outline-offset: 1px;
1233
+ animation: cm-mention-pulse 1.7s ease-out infinite }
1234
+ @keyframes cm-mention-pulse {
1235
+ 0% { box-shadow: 0 0 0 0 var(--accent-ring), 0 0 0 0 rgba(0, 136, 255, .45) }
1236
+ 70% { box-shadow: 0 0 0 6px rgba(0, 136, 255, 0), 0 0 0 12px rgba(0, 136, 255, 0) }
1237
+ 100% { box-shadow: 0 0 0 0 rgba(0, 136, 255, 0), 0 0 0 0 rgba(0, 136, 255, 0) }
1238
+ }
1239
+ @media (prefers-reduced-motion: reduce) {
1240
+ .cm-pin.mention, .cm-stack.mention { animation: none }
1241
+ }
1242
+ /* a mention of YOU inside a thread: the loudest chip - accent, heavier */
1243
+ .cm-at.person.me { color: var(--accent); font-weight: 700 }
1244
+
1245
+ /* human notification pill: the author's face, then "name mentioned you".
1246
+ The face outsizes the agent mark (34px) on purpose - a person is the news. */
1247
+ .sh-jam-ava { width: 36px; height: 36px; border-radius: 999px; object-fit: cover; flex: none }
1248
+ .sh-jam-ava.init { display: inline-flex; align-items: center; justify-content: center;
1249
+ color: rgba(255,255,255,.92); font: 700 13px/1 -apple-system, system-ui, sans-serif; letter-spacing: .02em }
1250
+ /* the title row is flex, which swallows the JSX whitespace - the verb carries its own gap */
1251
+ .sh-jam-frame .v { color: var(--glass-ink-2); font-weight: 500; margin-left: 4px; flex: none }