@marver-design/marver 0.12.0 → 0.14.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 (53) hide show
  1. package/CHANGELOG.md +124 -0
  2. package/README.md +41 -19
  3. package/dist/{build-DmfxJc6L.mjs → build-ByYafIhj.mjs} +37 -5
  4. package/dist/cli.mjs +21 -7
  5. package/dist/{collab-BmaOjwP1.mjs → collab-mAiNZlDj.mjs} +232 -43
  6. package/dist/{comments-pNxjTrGN.mjs → comments-DHB_8BRa.mjs} +3 -3
  7. package/dist/{daemon-WjonKlPz.mjs → daemon-Bfucyf1o.mjs} +3 -3
  8. package/dist/{dev-BXq7yE1p.mjs → dev-yZMyQeUj.mjs} +5 -5
  9. package/dist/{events-C9k-ceOj.mjs → events-B3LBn74P.mjs} +3 -1
  10. package/dist/{init-BpitOqRQ.mjs → init-Dvaso7YO.mjs} +66 -2
  11. package/dist/{manifest-CS6krOTe.mjs → manifest-DvOmglFp.mjs} +7 -0
  12. package/dist/{marver-id-gate-DOjnZakC.mjs → marver-id-gate-D6By7XHj.mjs} +8 -5
  13. package/dist/{plugin-Boy7I_JH.mjs → plugin-BsmG5i2X.mjs} +25 -7
  14. package/dist/{profile-ChpTPd-X.mjs → profile-BjAPAJSb.mjs} +1 -1
  15. package/dist/{serve-BeLLizNg.mjs → serve-Bcwfpvhl.mjs} +4 -3
  16. package/dist/{share-C64b1tdN.mjs → share-Gqo_Ygqw.mjs} +14 -15
  17. package/dist/{shot-Cyv3GN79.mjs → shot-kbR_xzJH.mjs} +7 -1
  18. package/dist/{summary-Bf9tiGrE.mjs → summary-C7CAypbB.mjs} +1 -1
  19. package/dist/{sync-WYxDN9IT.mjs → sync-BZaCWqK-.mjs} +1 -1
  20. package/docs/live-jam.md +173 -0
  21. package/docs/publish.md +270 -0
  22. package/docs/sharing.md +333 -0
  23. package/docs/slides.md +134 -0
  24. package/package.json +3 -1
  25. package/src/client/const.ts +13 -0
  26. package/src/client/content/chart-engine.ts +33 -0
  27. package/src/client/content/chart.tsx +100 -0
  28. package/src/client/content/index.tsx +25 -4
  29. package/src/client/content/slide.tsx +238 -0
  30. package/src/client/content/video.tsx +126 -0
  31. package/src/client/shell/App.tsx +49 -15
  32. package/src/client/shell/Comments.tsx +94 -17
  33. package/src/client/shell/LockedApp.tsx +7 -2
  34. package/src/client/shell/Play.tsx +138 -24
  35. package/src/client/shell/Toolbar.tsx +12 -3
  36. package/src/client/shell/canvas/FrameNode.tsx +5 -3
  37. package/src/client/shell/comments-store.ts +59 -4
  38. package/src/client/shell/hash.ts +3 -1
  39. package/src/client/shell/icons.tsx +2 -0
  40. package/src/client/shell/mentions.ts +108 -1
  41. package/src/client/shell/play-order.ts +22 -0
  42. package/src/client/shell/sound.ts +32 -0
  43. package/src/client/shell/store.ts +41 -12
  44. package/src/client/shell/styles.css +58 -27
  45. package/src/client/stage/main.tsx +54 -3
  46. package/src/shared/events.ts +12 -3
  47. package/src/shared/utm.ts +3 -2
  48. package/templates/AGENTS-embedded.md +1 -0
  49. package/templates/AGENTS-studio.md +1 -0
  50. package/templates/instructions/publish.md +7 -0
  51. package/templates/instructions/reference/deck-layouts.md +230 -0
  52. package/templates/instructions/reference/deck-story.md +110 -0
  53. package/templates/instructions/slides.md +398 -0
@@ -6,6 +6,8 @@
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
13
  export interface Me {
@@ -36,6 +38,9 @@ interface CommentsState {
36
38
  show: boolean // Shift+C - pins visible at all
37
39
  showAnchor: boolean // Shift+L - light the tagged ELEMENT while its thread is open
38
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>
39
44
  // picked, composing. `board` is captured at pick time - the write must land in the
40
45
  // log of the board the user was LOOKING at, however long the composer stays open
41
46
  draft: { nodeKey: string; frame: string; anchor: unknown; board?: string } | null
@@ -68,6 +73,9 @@ interface CommentsState {
68
73
  }
69
74
 
70
75
  const uuid = () => crypto.randomUUID()
76
+ const loadMentionSeen = (): Record<string, number> => {
77
+ try { return JSON.parse(localStorage.getItem('mv-mention-seen') ?? '{}') } catch { return {} }
78
+ }
71
79
  const csrf = () => /(?:^|;\s*)mv_c=([\w-]+)/.exec(document.cookie)?.[1] ?? ''
72
80
  const api = async (path: string, body?: unknown) => {
73
81
  const res = await fetch(`${ROUTE}/api/${path}`, body === undefined ? undefined : {
@@ -118,6 +126,41 @@ export const useComments = create<CommentsState>((set, get) => {
118
126
  }
119
127
  })
120
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
+ }
121
164
  // events are append-only everywhere, so union is ALWAYS safe - the one rule is the
122
165
  // baseline, PER BOARD: a board's first successful read must never replay its history
123
166
  // as notifications (the switcher list can arrive after the first sweep, so a global
@@ -131,7 +174,9 @@ export const useComments = create<CommentsState>((set, get) => {
131
174
  const fresh = events.filter((e) => !have.has(key(e)) && (have.add(key(e)), true))
132
175
  if (!fresh.length) return
133
176
  set(derive([...get().events, ...fresh]))
134
- 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)
135
180
  }
136
181
  /** Legacy logs (0.8.0 clients never sent `board`) get it from the endpoint they came
137
182
  * from - without it, replies to their threads would route to whatever board is open. */
@@ -163,6 +208,7 @@ export const useComments = create<CommentsState>((set, get) => {
163
208
 
164
209
  return {
165
210
  events: [], threads: [], board: null, me: null, myBoards: null, local: false, connected: false,
211
+ mentionSeen: loadMentionSeen(),
166
212
  commentMode: false, show: true, showAnchor: true, active: null, draft: null, needsIdentity: false, inviteToken: null,
167
213
 
168
214
  poke(board) {
@@ -250,10 +296,11 @@ export const useComments = create<CommentsState>((set, get) => {
250
296
  const { draft, me } = get()
251
297
  if (!draft || !body.trim()) return
252
298
  const id = uuid()
299
+ const mentions = mentionsIn(body.trim(), mentionPeople(get().events, me ?? undefined))
253
300
  const ok = await get().send([{
254
301
  id: uuid(), ts: Date.now(), type: 'create', commentId: id,
255
302
  nodeKey: draft.nodeKey, frame: draft.frame, anchor: draft.anchor,
256
- author: postAuthor(me), body: body.trim(),
303
+ author: postAuthor(me), body: body.trim(), ...(mentions ? { mentions } : {}),
257
304
  }], draft.board)
258
305
  if (ok) set({ draft: null, active: id, commentMode: false })
259
306
  },
@@ -264,9 +311,10 @@ export const useComments = create<CommentsState>((set, get) => {
264
311
  // route to the thread's ORIGIN log - the viewer may be reading it from another
265
312
  // board (frame-scoped display); a reply landing in the wrong log would fork it
266
313
  const origin = get().threads.find((t) => t.id === threadId)?.board
314
+ const mentions = mentionsIn(body.trim(), mentionPeople(get().events, get().me ?? undefined))
267
315
  return get().send([{
268
316
  id: uuid(), ts: Date.now(), type: 'reply', commentId: uuid(), parentId: threadId,
269
- author: postAuthor(get().me), body: body.trim(),
317
+ author: postAuthor(get().me), body: body.trim(), ...(mentions ? { mentions } : {}),
270
318
  }], origin)
271
319
  },
272
320
 
@@ -278,7 +326,14 @@ export const useComments = create<CommentsState>((set, get) => {
278
326
  setMode(on) { set({ commentMode: on, ...(on ? { show: true } : { draft: null }) }) },
279
327
  setShow(show) { set({ show }) },
280
328
  setShowAnchor(showAnchor) { set({ showAnchor }) },
281
- 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
+ },
282
337
  setDraft(draft) { set({ draft: draft ? { ...draft, board: draft.board ?? get().board ?? undefined } : null }) },
283
338
  dismissIdentity() { set({ needsIdentity: false }) }, // inviteToken survives dismissal - commenting later reopens the claim
284
339
 
@@ -20,7 +20,7 @@ export interface HashState {
20
20
  n?: string[]
21
21
  c?: string
22
22
  invite?: string
23
- play?: { at?: string; device?: string; theme?: string }
23
+ play?: { at?: string; device?: string; theme?: string; slides?: boolean }
24
24
  /** A frame deep link: lands that visit in focus, over every other rule. */
25
25
  focus?: { at: string; device?: string; theme?: string }
26
26
  }
@@ -58,6 +58,7 @@ export function parseHash(hash: string = location.hash): HashState {
58
58
  at: params.get('at') ?? undefined,
59
59
  device: params.get('device') ?? undefined,
60
60
  theme: params.get('theme') ?? undefined,
61
+ ...(params.get('slides') === '1' ? { slides: true } : {}),
61
62
  },
62
63
  }
63
64
  } catch { return {} } // a malformed hash (#/b/%) is a default view, never a crash
@@ -75,6 +76,7 @@ export function buildHash(s: HashState): string {
75
76
  const p = new URLSearchParams({ at: s.play.at })
76
77
  if (s.play.device) p.set('device', s.play.device)
77
78
  if (s.play.theme) p.set('theme', s.play.theme)
79
+ if (s.play.slides) p.set('slides', '1')
78
80
  return `#/p/${s.board}?${p}`
79
81
  }
80
82
  if (!s.board || (s.board === 'all-scenes' && !s.n?.length && !s.c)) return '#/'
@@ -71,6 +71,8 @@ export function IntentGlyph({ intent, size = 14, ...rest }: IconProps & { intent
71
71
  : ContentBlocksIcon
72
72
  return <I size={size} role="img" aria-hidden={false} aria-label={intent} {...({ title: intent } as object)} {...rest} />
73
73
  }
74
+ /** The slide badge (v1.5): a 16:9 stage with the play wedge - a deck frame. */
75
+ export const SlideFrameIcon = icon(<path d="M192,48H64A16,16,0,0,0,48,64V192a16,16,0,0,0,16,16H192a16,16,0,0,0,16-16V64A16,16,0,0,0,192,48Zm0,144H64V64H192V192ZM240,56V200a8,8,0,0,1-16,0V56a8,8,0,0,1,16,0ZM32,56V200a8,8,0,0,1-16,0V56a8,8,0,0,1,16,0Z" />)
74
76
  export const PanelHollowIcon = icon(<><rect x="32" y="48" width="192" height="160" rx="32" fill="none" stroke="currentColor" strokeWidth="16" /><rect x="72" y="88" width="48" height="80" rx="24" fill="none" stroke="currentColor" strokeWidth="14" /></>)
75
77
  /** Board glyphs: CardsThree = the built-in `all-scenes` board; Cards = curated boards. */
76
78
  export const CardsThreeIcon = icon(<path d="M208,88H48a16,16,0,0,0-16,16v96a16,16,0,0,0,16,16H208a16,16,0,0,0,16-16V104A16,16,0,0,0,208,88Zm0,112H48V104H208v96ZM48,64a8,8,0,0,1,8-8H200a8,8,0,0,1,0,16H56A8,8,0,0,1,48,64ZM64,32a8,8,0,0,1,8-8H184a8,8,0,0,1,0,16H72A8,8,0,0,1,64,32Z" />)
@@ -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,22 @@
1
+ /** The deck order (v1.5), pure: slide tsx frames in the board's reading
2
+ * order - (y, then x, then original index), deduped by frame id. Excluded
3
+ * frames come back for a ONE-TIME warning at entry; this file stays
4
+ * dependency-free so unit tests import it without React or virtual modules. */
5
+ export interface OrderNode { frame: string; x: number; y: number; missing?: boolean }
6
+ export interface OrderFrame { id: string; kind: 'tsx' | 'html'; slide?: boolean }
7
+
8
+ export function deckOrder(
9
+ nodes: OrderNode[], frames: Map<string, OrderFrame>,
10
+ ): { deck: string[]; excluded: string[] } {
11
+ const rows = nodes
12
+ .map((n, i) => ({ n, i, f: frames.get(n.frame) }))
13
+ .filter(({ n, f }) => !n.missing && !!f)
14
+ const excluded = rows.filter(({ f }) => f!.kind !== 'tsx' || !f!.slide).map(({ f }) => f!.id)
15
+ const seen = new Set<string>()
16
+ const deck = rows
17
+ .filter(({ f }) => f!.kind === 'tsx' && !!f!.slide)
18
+ .sort((a, b) => a.n.y - b.n.y || a.n.x - b.n.x || a.i - b.i)
19
+ .filter(({ n }) => !seen.has(n.frame) && (seen.add(n.frame), true))
20
+ .map(({ n }) => n.frame)
21
+ return { deck, excluded: [...new Set(excluded)] }
22
+ }
@@ -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
+ }
@@ -1,5 +1,5 @@
1
1
  import { create } from 'zustand'
2
- import { ROUTE } from '../const.ts'
2
+ import { ROUTE, slideSize } from '../const.ts'
3
3
  import { tidy, parseLayout, type BoardLayout } from './tidy.ts'
4
4
  import { stableNodeKey } from './keys.ts'
5
5
  // @ts-expect-error virtual module provided by the plugin
@@ -30,9 +30,18 @@ export const PUBLISHED = DATA !== null
30
30
  export const SOURCE_REVEALED = DATA ? DATA.policy?.reveal?.source === true : true
31
31
 
32
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 ?? {}
33
+ export const BOARD_POLICY: Record<string, { type?: string; open?: string; lock?: boolean; transition?: string; chrome?: string }> = DATA?.policy?.boards ?? {}
34
34
 
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 }
35
+ export { SLIDE_INTRINSIC } from '../const.ts'
36
+
37
+ /** Dev-only hydration: the shell loads the repo's publish.json boards so an
38
+ * author previews slides mode exactly as viewers will get it. Published
39
+ * builds carry the policy inline and never call this. */
40
+ export function hydrateBoardPolicy(boards: Record<string, { type?: string; open?: string; lock?: boolean; transition?: string; chrome?: string }>) {
41
+ for (const [k, v] of Object.entries(boards)) if (!BOARD_POLICY[k]) BOARD_POLICY[k] = v
42
+ }
43
+
44
+ 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; slide?: boolean }
36
45
  export interface Manifest { frames: FrameEntry[]; scenes: { name: string; frames: number }[] }
37
46
  export interface Node {
38
47
  key: string; frame: string; x: number; y: number; w: number; h: number
@@ -60,9 +69,16 @@ export interface Node {
60
69
  * on every fresh navigation. */
61
70
  readyRetried?: boolean
62
71
  }
63
- /** A Live Jam notification: a persistent bottom-right glass pill for a Marver
64
- * reply. Frame-first: the FRAME is the news (icon + title, blue), Marver + preview below. */
65
- export interface JamNote { threadId: string; board: string; preview: string; ts: number; frame?: string; frameTitle?: string; intent?: string }
72
+ /** A comment notification: a persistent bottom-right glass pill. Three kinds
73
+ * share it - a Marver (agent) reply renders frame-first with the mark; a
74
+ * human `mention`/`reply` renders person-first: their avatar, "«name»
75
+ * mentioned you" / "«name» replied", the message underneath. */
76
+ export interface JamNote {
77
+ threadId: string; board: string; preview: string; ts: number
78
+ frame?: string; frameTitle?: string; intent?: string
79
+ kind?: 'mention' | 'reply' // absent = the agent pill
80
+ author?: { name?: string; avatar?: string; id?: string; email?: string }
81
+ }
66
82
  export interface Toast { id: number; text: string; jam?: JamNote }
67
83
 
68
84
  export const CONFIG: { viewports: Record<string, { width: number; height: number }>; themes: string[]; zoomSpeed?: number; noTheme: boolean; setup?: boolean; projectName?: string; branding?: boolean } = shConfig
@@ -76,17 +92,24 @@ export function landingMode(board: string): 'canvas' | 'board' | 'present' | 'fo
76
92
  const p = BOARD_POLICY[board]
77
93
  if (!p) return 'canvas'
78
94
  if (p.open && ['canvas', 'board', 'present', 'focus', 'slides'].includes(p.open)) {
79
- // slides is v1.5 - until the mode exists it lands in present, its nearest kin
80
- return p.open === 'slides' ? 'present' : p.open as any
95
+ return p.open as any
81
96
  }
82
97
  switch (p.type) {
83
98
  case 'doc': return 'focus'
84
- case 'slides': return 'present' // slides mode is v1.5; present until then
99
+ case 'slides': return 'slides'
85
100
  case 'design': case 'sketch': case 'refs': return 'board'
86
101
  default: return 'canvas'
87
102
  }
88
103
  }
89
104
 
105
+ /** Does this board PLAY as a deck? A `type: slides` board is a deck whatever
106
+ * its landing (`open: canvas` only says where viewers arrive) - the P key and
107
+ * the play button route here, never through landingMode. */
108
+ export function playsAsSlides(board: string): boolean {
109
+ const p = BOARD_POLICY[board]
110
+ return p?.type === 'slides' || p?.open === 'slides'
111
+ }
112
+
90
113
  /** Is this board frozen to its landing mode? The lock caps disclosure - no way
91
114
  * out into other modes, and (published) no sidebar ever renders for it. */
92
115
  export const boardLocked = (board: string): boolean => BOARD_POLICY[board]?.lock === true
@@ -94,7 +117,7 @@ export const boardLocked = (board: string): boolean => BOARD_POLICY[board]?.lock
94
117
  /** May this board enter `mode`? A lock freezes what open pinned - a board
95
118
  * locked to canvas must refuse present just as one locked to present refuses
96
119
  * canvas. Unlocked boards enter anything. */
97
- export const modeAllowed = (board: string, mode: 'present' | 'focus'): boolean =>
120
+ export const modeAllowed = (board: string, mode: 'present' | 'focus' | 'slides'): boolean =>
98
121
  !boardLocked(board) || landingMode(board) === mode
99
122
 
100
123
  export { cap, humanize } from './labels.ts'
@@ -166,6 +189,11 @@ const manifestKey = (m: Manifest) => JSON.stringify(m.frames) // any change co
166
189
  const measuredHeights = new Map<string, number>()
167
190
 
168
191
  function defaultSize(frame: FrameEntry) {
192
+ // the precedence chain (spec 09 slice 1): slide intrinsic → authored
193
+ // viewport → content sizing → default (one helper, shared with shot) -
194
+ // the Slide root renders fixed 1280×720, so nothing may size it smaller
195
+ const sl = slideSize(frame)
196
+ if (sl) return { w: sl.width, h: sl.height }
169
197
  // content frames: own width from Doc layout; height from the latest
170
198
  // measurement at that width, or a placeholder until sh:measure lands.
171
199
  // meta.viewport, when declared, wins - the existing precedence.
@@ -199,7 +227,7 @@ interface State {
199
227
  * fourth mode): no walking, doc reading preset when the board type is doc.
200
228
  * `deep` = entered by a frame deep link - the chrome then carries no board
201
229
  * name and no canvas door (presentation default of the link, 04 §2.35). */
202
- play: { at: string; device: string; theme: string; focus?: boolean; deep?: boolean } | null
230
+ play: { at: string; device: string; theme: string; focus?: boolean; deep?: boolean; slides?: boolean } | null
203
231
  gesture: boolean // a frame drag/resize is in progress - canvas panning is disabled
204
232
  laser: boolean // laser/inspect mode: frames outline their structure
205
233
  board: string // active board name; 'all-scenes' is the auto board
@@ -1029,7 +1057,8 @@ export const useStore = create<State>((set, get) => {
1029
1057
  * supersedes), capped so the stack stays small. */
1030
1058
  jamToast(note) {
1031
1059
  const id = ++toastSeq
1032
- set((s) => ({ toasts: [...s.toasts.filter((t) => t.jam?.threadId !== note.threadId), { id, text: 'Marver replied', jam: note }].slice(-8) }))
1060
+ const text = note.kind === 'mention' ? 'You were mentioned' : note.kind === 'reply' ? 'New reply' : 'Marver replied'
1061
+ set((s) => ({ toasts: [...s.toasts.filter((t) => t.jam?.threadId !== note.threadId), { id, text, jam: note }].slice(-8) }))
1033
1062
  },
1034
1063
  dismissToast(id) { set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) })) },
1035
1064
  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);
@@ -734,22 +736,6 @@ body.sh-commenting .sh-lean { opacity: 0 }
734
736
  .sh-play-chip:hover { color: #f5f5f7 }
735
737
  .sh-play-chip.idle { opacity: 0; pointer-events: none; transform: scale(.6) }
736
738
 
737
- /* the H coach bubble: hairline, translucent, teaches the way back from immersive mode.
738
- Same pill geometry and separators as the bar it stands in for. */
739
- .sh-play-hint { position: absolute; top: 14px; right: 16px; display: flex; align-items: center; gap: 3px;
740
- padding: 4px 4px 4px 14px; border-radius: 999px;
741
- background: rgba(18, 18, 24, .6); border: 1px solid rgba(255, 255, 255, .14);
742
- backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px);
743
- font: 500 12px -apple-system, system-ui, sans-serif; color: rgba(245, 245, 247, .85);
744
- animation: sh-play-in .3s ease-out }
745
- .sh-play-hint .sep { width: 1px; height: 16px; background: rgba(255, 255, 255, .14); margin: 0 8px }
746
- .sh-play-hint kbd { font: 600 11px -apple-system, system-ui, sans-serif; padding: 1px 6px; border-radius: 5px;
747
- background: rgba(255, 255, 255, .12); border: 1px solid rgba(255, 255, 255, .16) }
748
- .sh-play-hint button { height: 30px; padding: 0 12px; border: 0; border-radius: 999px; cursor: pointer;
749
- font: 600 11px -apple-system, system-ui, sans-serif; background: rgba(255, 255, 255, .14); color: #f5f5f7 }
750
- .sh-play-hint button:hover { background: rgba(255, 255, 255, .22) }
751
- .sh-play-hint button.dim { background: transparent; color: rgba(245, 245, 247, .5); font-weight: 500 }
752
- .sh-play-hint button.dim:hover { color: rgba(245, 245, 247, .8); background: rgba(255, 255, 255, .08) }
753
739
 
754
740
  /* ---- Focus: silent for pointer + shortcut use; ONE branded inset ring, and only while
755
741
  the user is actually Tab-navigating (body.sh-kbd, armed by Tab, disarmed by pointer).
@@ -859,22 +845,19 @@ body.sh-hide-ui .sh-play-nav { display: none !important }
859
845
 
860
846
  /* ---- present/focus chrome (sharing v1): brand pill top-left, counter in the
861
847
  toolbar, doc reading preset. Same glass skin as the play pill. */
862
- .sh-play-brand.sh-pill { left: var(--edge); right: auto; transform-origin: 22px 22px; padding: 5px 14px 5px 10px }
863
- .sh-play-mark { display: inline-flex; align-items: center; gap: 7px; padding: 0 2px 0 6px; color: rgba(245, 245, 247, .88) }
848
+ /* the brand pill matches the toolbar pill to the pixel: the SAME .sh-pill
849
+ padding (5px) and the SAME 34px inner height as .sh-pill-btn - its two
850
+ children are sized like buttons, so text metrics never set the height */
851
+ .sh-play-brand.sh-pill { left: var(--edge); right: auto; transform-origin: 22px 22px; padding: 5px 9px 5px 5px }
852
+ .sh-play-mark { display: inline-flex; align-items: center; gap: 7px; height: 34px; padding: 0 8px 0 10px; border-radius: 999px;
853
+ color: rgba(245, 245, 247, .88); text-decoration: none; cursor: pointer }
854
+ .sh-play-mark:hover { background: rgba(255, 255, 255, .1); color: #f5f5f7 }
864
855
  .sh-play-mark svg { color: #6db3ff }
865
856
  .sh-play-mark .wd { font: 600 13px -apple-system, system-ui, sans-serif; letter-spacing: -.01em }
866
- .sh-play-title { font: 500 13px -apple-system, system-ui, sans-serif; color: rgba(245, 245, 247, .82);
857
+ .sh-play-title { display: inline-block; height: 34px; line-height: 34px; font: 500 13px/34px -apple-system, system-ui, sans-serif; color: rgba(245, 245, 247, .82);
867
858
  max-width: 143px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 0 6px }
868
859
  .sh-play-pos { font: 500 12.5px -apple-system, system-ui, sans-serif; color: rgba(245, 245, 247, .64);
869
860
  font-variant-numeric: tabular-nums; padding: 0 8px; white-space: nowrap }
870
- /* the hint pill: bottom center, the design's quiet keyboard cue */
871
- .sh-play-hint { position: absolute; bottom: 12px; left: 50%; transform: translateX(-50%); z-index: 31;
872
- border-radius: 999px; padding: 7px 16px; white-space: nowrap;
873
- background: rgba(18, 18, 24, .8); border: 1px solid rgba(255, 255, 255, .1);
874
- backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px);
875
- font: 500 12px -apple-system, system-ui, sans-serif; color: rgba(245, 245, 247, .64) }
876
- .sh-play-hint kbd { font: 500 11.5px ui-monospace, monospace }
877
- body.sh-hide-ui .sh-play-hint { display: none !important }
878
861
  /* doc preset: the page IS the surface - reading column on a calm ground, no
879
862
  device shell, natural document scrolling inside the frame */
880
863
  .sh-play.doc { background: #f4f4f6 }
@@ -1121,6 +1104,21 @@ body.sh-hide-ui .sh-play-hint { display: none !important }
1121
1104
  .cm-hl .cm-at { font-weight: inherit; -webkit-text-stroke: .4px currentColor }
1122
1105
  .cm-at-tip { display: block; max-width: 220px; line-height: 1.4 }
1123
1106
 
1107
+ /* v1.1: @Person mentions - the comment accent, medium weight (the mirror rule above
1108
+ already keeps composer metrics honest: color + stroke only, never font-weight). */
1109
+ .cm-at.person { color: var(--cm-line, var(--comment)); font-weight: 600 }
1110
+ .cm-hl .cm-at.person { font-weight: inherit }
1111
+ /* The typeahead: a small glass list ABOVE the composer; rows follow the pick. */
1112
+ /* SOLID, never glass: the picker floats over thread text, and translucency
1113
+ made the names unreadable */
1114
+ .cm-mentionlist { position: absolute; bottom: calc(100% + 6px); left: 0; z-index: 5; min-width: 180px; max-width: 260px;
1115
+ display: flex; flex-direction: column; padding: 4px; border-radius: 10px;
1116
+ background: var(--solid); border: 1px solid var(--glass-brd); box-shadow: 0 10px 28px rgba(0,0,0,.22) }
1117
+ .cm-mentionlist button { display: flex; align-items: center; gap: 8px; padding: 6px 8px; border: 0; border-radius: 7px;
1118
+ background: none; color: var(--glass-ink); font: 500 12.5px/1.2 inherit; text-align: left; cursor: pointer; white-space: nowrap }
1119
+ .cm-mentionlist button span { overflow: hidden; text-overflow: ellipsis }
1120
+ .cm-mentionlist button.on { background: var(--glass-hover) }
1121
+
1124
1122
  /* Marver identity: the agent's avatar mark + provenance tooltip (§7). Always accent, never a hue. */
1125
1123
  .cm-avatar.cm-marver { background: var(--mv-blue); color: rgba(255,255,255,.9); cursor: default } /* white/90 mark, embedded in the accent */
1126
1124
  .cm-marver-wrap { display: inline-flex; border-radius: 50% }
@@ -1208,3 +1206,36 @@ body.sh-hide-ui .sh-play-hint { display: none !important }
1208
1206
  its children (pins, cards, stacks) fully interactive */
1209
1207
  .cm-layer { position: absolute; left: 0; width: 100%; pointer-events: none; z-index: 25 }
1210
1208
  .cm-layer > * { pointer-events: auto }
1209
+
1210
+ /* v1.1: an unseen mention of YOU pulses the pin - accent lights until the
1211
+ thread is opened (opening marks it seen). Motion-reduced readers keep a
1212
+ steady accent ring instead of the pulse. */
1213
+ .cm-pin.mention, .cm-stack.mention { outline: 2px solid var(--accent); outline-offset: 1px;
1214
+ animation: cm-mention-pulse 1.7s ease-out infinite }
1215
+ @keyframes cm-mention-pulse {
1216
+ 0% { box-shadow: 0 0 0 0 var(--accent-ring), 0 0 0 0 rgba(0, 136, 255, .45) }
1217
+ 70% { box-shadow: 0 0 0 6px rgba(0, 136, 255, 0), 0 0 0 12px rgba(0, 136, 255, 0) }
1218
+ 100% { box-shadow: 0 0 0 0 rgba(0, 136, 255, 0), 0 0 0 0 rgba(0, 136, 255, 0) }
1219
+ }
1220
+ @media (prefers-reduced-motion: reduce) {
1221
+ .cm-pin.mention, .cm-stack.mention { animation: none }
1222
+ }
1223
+ /* a mention of YOU inside a thread: the loudest chip - accent, heavier */
1224
+ .cm-at.person.me { color: var(--accent); font-weight: 700 }
1225
+
1226
+ /* human notification pill: the author's face, then "name mentioned you".
1227
+ The face outsizes the agent mark (34px) on purpose - a person is the news. */
1228
+ .sh-jam-ava { width: 36px; height: 36px; border-radius: 999px; object-fit: cover; flex: none }
1229
+ .sh-jam-ava.init { display: inline-flex; align-items: center; justify-content: center;
1230
+ color: rgba(255,255,255,.92); font: 700 13px/1 -apple-system, system-ui, sans-serif; letter-spacing: .02em }
1231
+ /* the title row is flex, which swallows the JSX whitespace - the verb carries its own gap */
1232
+ .sh-jam-frame .v { color: var(--glass-ink-2); font-weight: 500; margin-left: 4px; flex: none }
1233
+
1234
+ /* v1.5 slides mode: the slim progress strip + the zero-chrome escape hatch */
1235
+ .sh-slides-strip { position: fixed; left: 50%; bottom: 14px; transform: translateX(-50%); z-index: 30;
1236
+ display: flex; align-items: center; gap: 10px; padding: 8px 14px; border-radius: 999px;
1237
+ background: var(--glass); border: 1px solid var(--glass-brd); backdrop-filter: var(--blur); -webkit-backdrop-filter: var(--blur) }
1238
+ .sh-slides-strip .n { font: 600 12px/1 -apple-system, system-ui, sans-serif; color: var(--glass-ink-2); white-space: nowrap }
1239
+ .sh-slides-strip .bar { width: 140px; height: 3px; border-radius: 999px; background: var(--glass-hover); overflow: hidden }
1240
+ .sh-slides-strip .bar i { display: block; height: 100%; border-radius: inherit; background: var(--accent); transition: width .25s ease }
1241
+ @media (max-width: 480px) { .sh-slides-strip .bar { width: 80px } }