@marver-design/marver 0.12.0 → 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.
@@ -1,6 +1,6 @@
1
1
  import { n as NAME } from "./cli.mjs";
2
2
  import { M as resolveAccess } from "./auth-DdUKeVKt.mjs";
3
- import { i as loadCollab } from "./sync-WYxDN9IT.mjs";
3
+ import { i as loadCollab } from "./sync-BZaCWqK-.mjs";
4
4
  //#region src/cli/share.ts
5
5
  /**
6
6
  * `marver share <action>` - the roster from the terminal (01-sharing §9).
@@ -9,9 +9,12 @@ import { i as loadCollab } from "./sync-WYxDN9IT.mjs";
9
9
  * IS the whole interface, and on every canvas it calls the same owner routes
10
10
  * the app does, over the device credential `comments connect` stored. The
11
11
  * resolver ships with an explainer because a permission system nobody can
12
- * predict is not safe - `explain` and `who` run the SAME pure function the
13
- * server enforces with, on the same roster bytes, so the terminal and the
14
- * dialog can never disagree (acceptance 9).
12
+ * predict is not safe - `explain` asks the canvas's OWN `share/explain` route,
13
+ * so its trace is the resolver the doors enforce with (owner role filled,
14
+ * `@domain` explained through a synthetic member, the winning step marked) and
15
+ * cannot drift from the dialog's Who-sees-what (acceptance 9). `who` runs that
16
+ * same pure function locally over the fetched roster for the granted principals
17
+ * it lists - none of them the owner, so no server-only role handling applies.
15
18
  *
16
19
  * add <who> grant view (default) or --role comment; --expires <iso>
17
20
  * remove <who> remove the grant
@@ -48,19 +51,15 @@ const asStore = (roster) => ({
48
51
  blocked: roster.blocked,
49
52
  grants: roster.grants
50
53
  });
51
- /** The trace, rendered - what `explain` prints and what the dialog shows. */
52
- function renderExplain(who, store, ceilings) {
53
- const r = resolveAccess({
54
- email: who.startsWith("@") ? null : who,
55
- store,
56
- ceilings
57
- });
54
+ /** The resolver's trace as the server returned it - the winning step marked with
55
+ * an arrow, the same verdict the dialog's Who-sees-what draws. */
56
+ function renderExplain(r) {
58
57
  const lines = [];
59
- for (const step of r.trace) lines.push(` ${step.where.padEnd(14)} ${step.role.padEnd(9)} ${step.why}`);
58
+ for (const step of r.trace) lines.push(` ${step.win ? "→" : " "} ${step.where.padEnd(14)} ${step.role.padEnd(9)} ${step.why}`);
60
59
  const boards = Object.entries(r.boards);
61
60
  const readable = boards.filter(([, role]) => role !== "none").length;
62
61
  const writable = boards.filter(([, role]) => role === "comment").length;
63
- for (const [b, role] of boards) lines.push(` board ${b.padEnd(14)} ${role}`);
62
+ for (const [b, role] of boards) lines.push(` board ${b.padEnd(14)} ${role}`);
64
63
  lines.push(` → ${readable} of ${boards.length} board${boards.length === 1 ? "" : "s"}, may comment on ${writable}`);
65
64
  return lines.join("\n");
66
65
  }
@@ -128,8 +127,8 @@ async function shareCommand(root, action, value, opts) {
128
127
  }
129
128
  case "explain": {
130
129
  if (!value) throw new Error("explain needs a principal to explain");
131
- const [roster, ceilings] = await Promise.all([api(root, "GET", "share/roster"), fetchCeilings(root)]);
132
- console.log(`\n${renderExplain(value, asStore(roster), ceilings)}\n`);
130
+ const r = await api(root, "GET", `share/explain?who=${encodeURIComponent(value)}`);
131
+ console.log(`\n${renderExplain(r)}\n`);
133
132
  return;
134
133
  }
135
134
  case "who": {
@@ -1,4 +1,4 @@
1
- import { n as replay } from "./events-C9k-ceOj.mjs";
1
+ import { n as replay } from "./events-B3LBn74P.mjs";
2
2
  import { x as withLock } from "./auth-DdUKeVKt.mjs";
3
3
  import { closeSync, fsyncSync, openSync, readFileSync, renameSync, writeSync } from "node:fs";
4
4
  import { join } from "node:path";
@@ -1,5 +1,5 @@
1
1
  import { t as __exportAll } from "./rolldown-runtime-D7D4PA-g.mjs";
2
- import { t as diffEvents } from "./events-C9k-ceOj.mjs";
2
+ import { t as diffEvents } from "./events-B3LBn74P.mjs";
3
3
  import { i as readLog, r as listBoards, t as appendEvents } from "./comments-DZyobpxG.mjs";
4
4
  import { t as CLI_TOKEN_CHARS } from "./auth-DdUKeVKt.mjs";
5
5
  import { chmodSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, unlinkSync, writeSync } from "node:fs";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marver-design/marver",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "The agent-native design canvas. A design/ folder, one command, a canvas of live frames built from your repo's real components - comment @marver and your own coding agent does the work. The tool ships no AI.",
5
5
  "type": "module",
6
6
  "private": false,
@@ -10,7 +10,7 @@ import { bootHash, parseHash, writeHash } from './hash.ts'
10
10
  import { CardsIcon, CardsThreeIcon, CaretIcon, CheckIcon, ColumnsIcon, FrameRectIcon, IntentGlyph, MoonIcon, PanelFilledIcon, PanelHollowIcon, ParallelogramDuoIcon, ParallelogramFillIcon, PencilSimpleIcon, PlayIcon, SignpostIcon, SunIcon, VariantsIcon, XIcon, deviceIcon } from './icons.tsx'
11
11
  import { CommentsController, revealThread } from './Comments.tsx'
12
12
  import { poweredByUrl } from '../../shared/utm.ts'
13
- import { useComments } from './comments-store.ts'
13
+ import { avatarFallback, useComments } from './comments-store.ts'
14
14
  import { CommentButton, DevicePicker, HideUIButton, LaserButton, Popover, ThemePicker, toggleHideUI, usePopover } from './Toolbar.tsx'
15
15
 
16
16
  const commentsStore = () => useComments.getState()
@@ -1169,6 +1169,15 @@ function JamToasts({ toasts }: { toasts: import('./store.ts').Toast[] }) {
1169
1169
  )
1170
1170
  }
1171
1171
 
1172
+ /** The pill's face for a human notification - the author's picture, or the
1173
+ * house initials fallback (same ladder the thread avatars use). */
1174
+ function JamAvatar({ author }: { author: { name?: string; avatar?: string; id?: string; email?: string } }) {
1175
+ const { initials, hue } = avatarFallback(author)
1176
+ return author.avatar
1177
+ ? <img className="sh-jam-ava" src={author.avatar} alt="" referrerPolicy="no-referrer" />
1178
+ : <span className="sh-jam-ava init" style={{ background: `hsl(${hue} 55% 45%)` }}>{initials}</span>
1179
+ }
1180
+
1172
1181
  /** A data-goto whose target frame is not on the current board follows the frame HOME:
1173
1182
  * the first curated board (switcher rank) that pins it is switched to and the frame
1174
1183
  * focused there - a link is navigation, and navigation never edits a board. Only a
@@ -1239,11 +1248,18 @@ async function viewNote(note: import('./store.ts').JamNote, dismiss: () => void)
1239
1248
  * `badge` shows the +N deck count (replacing View); `inert` disables inner clicks (the deck handles it). */
1240
1249
  function JamToast({ id, note, badge, inert }: { id: number; note: import('./store.ts').JamNote; badge?: number; inert?: boolean }) {
1241
1250
  const dismiss = () => useStore.getState().dismissToast(id)
1251
+ // human pills are person-first: their avatar, "name mentioned you"; the
1252
+ // agent pill keeps its frame-first shape with the mark
1253
+ const human = note.kind && note.author
1242
1254
  return (
1243
1255
  <div className="sh-toast jam">
1244
- <span className="sh-jam-mark"><ParallelogramFillIcon size={17} /></span>
1256
+ {human
1257
+ ? <JamAvatar author={note.author!} />
1258
+ : <span className="sh-jam-mark"><ParallelogramFillIcon size={17} /></span>}
1245
1259
  <div className="sh-jam-txt">
1246
- <b className="sh-jam-frame"><span className="t">{note.frameTitle ?? note.board}</span></b>
1260
+ {human
1261
+ ? <b className="sh-jam-frame"><span className="t">{note.author!.name ?? 'Someone'}</span> <span className="v">{note.kind === 'mention' ? 'mentioned you' : 'replied'}</span></b>
1262
+ : <b className="sh-jam-frame"><span className="t">{note.frameTitle ?? note.board}</span></b>}
1247
1263
  <span className="sh-jam-prev">{note.preview}</span>
1248
1264
  </div>
1249
1265
  {badge != null
@@ -4,7 +4,7 @@
4
4
  * top-right stack; the thread card opens beside its pin. Pins keep a screen-space
5
5
  * size via --sh-inv (the vbadge pattern) so zoom never shrinks them away.
6
6
  */
7
- import { useEffect, useRef, useState } from 'react'
7
+ import { useEffect, useMemo, useRef, useState } from 'react'
8
8
  import { createPortal } from 'react-dom'
9
9
  import { avatarFallback, useComments } from './comments-store.ts'
10
10
  import { useStore, type Node } from './store.ts'
@@ -13,7 +13,7 @@ import { canvasCtl } from './canvas/ctl.ts'
13
13
  import { bootHash, buildHash, parseHash, writeHash } from './hash.ts'
14
14
  import { ArrowUpIcon, CheckIcon, CheckSquareOffsetIcon, LinkIcon, ParallelogramFillIcon, PencilSimpleIcon, PlusIcon, XIcon } from './icons.tsx'
15
15
  import { Tip } from './Tip.tsx'
16
- import { parseMentions } from './mentions.ts'
16
+ import { mentionAlerts, mentionPeople, mentionQueryAt, parseBody, type MentionPerson } from './mentions.ts'
17
17
  import { ROUTE } from '../const.ts'
18
18
  import type { AgentMeta, Thread } from '../../shared/events.ts'
19
19
 
@@ -182,13 +182,15 @@ export function CommentLayer({ node, frameId, iframe }: { node: Node; frameId: s
182
182
  const flankBadge = useStore((s) => !!s.frameFor(node)?.variantGroup)
183
183
  const flankShim = useStore((s) => s.working.includes(frameId))
184
184
  const drafting = draft?.nodeKey === node.key
185
+ const alertIds = useMentionAlerts()
185
186
  if (!show || (!open.length && !drafting)) return null
186
187
 
187
188
  // inactive frame: the stack - count + avatars, top-right
188
189
  const engaged = selected || open.some((t) => t.id === active) || drafting
189
190
  if (!engaged && open.length) {
190
191
  return (
191
- <button className="cm-stack sh-no-pan" onClick={(e) => { e.stopPropagation(); useStore.getState().select(node.key); if (open[0]) setActive(open[0].id) }}>
192
+ <button className={`cm-stack sh-no-pan${open.some((t) => alertIds.has(t.id)) ? ' mention' : ''}`}
193
+ onClick={(e) => { e.stopPropagation(); useStore.getState().select(node.key); const hit = open.find((t) => alertIds.has(t.id)) ?? open[0]; if (hit) setActive(hit.id) }}>
192
194
  <MarkerFace threads={open} />
193
195
  </button>
194
196
  )
@@ -234,7 +236,7 @@ export function CommentLayer({ node, frameId, iframe }: { node: Node; frameId: s
234
236
  const { x, y, orphan } = pinPos(t)
235
237
  const isActive = t.id === active
236
238
  return (
237
- <div key={t.id} className={`cm-pin sh-no-pan${isActive ? ' on' : ''}${isActive && cardSide === 'r' ? ' tail-r' : ''}${orphan ? ' orphan' : ''}`}
239
+ <div key={t.id} className={`cm-pin sh-no-pan${isActive ? ' on' : ''}${isActive && cardSide === 'r' ? ' tail-r' : ''}${orphan ? ' orphan' : ''}${alertIds.has(t.id) ? ' mention' : ''}`}
238
240
  style={{ left: x, top: y, ...hueVars(anchorHue(t.anchor)) }}
239
241
  onPointerDown={(e) => e.stopPropagation()}
240
242
  onClick={(e) => { e.stopPropagation(); setActive(t.id === active ? null : t.id) }}
@@ -258,17 +260,51 @@ export function CommentLayer({ node, frameId, iframe }: { node: Node; frameId: s
258
260
 
259
261
  const AT_TIP = "Read like any other comment. Marver won't act on this unless the owner promotes it."
260
262
 
261
- /** A comment body with @marver mentions styled: owner-authored → bold accent (a live trigger);
262
- * anyone else's → plain + a teaching tooltip (context, not a command). */
263
+ /** The mentionable set, derived from everything the client has seen (published:
264
+ * projected ids + names; dev: canonical emails). The viewer themself is out -
265
+ * this is the COMPOSER's set (you cannot mention yourself). */
266
+ function usePeople(): MentionPerson[] {
267
+ const events = useComments((s) => s.events)
268
+ const me = useComments((s) => s.me)
269
+ return mentionPeople(events, me ?? undefined)
270
+ }
271
+
272
+ /** Everyone, self included - the RENDERING set: a mention of you must light up
273
+ * hardest of all, not disappear (the bug that shipped: the excluded self made
274
+ * your own mentions render as plain text exactly for the person they were for). */
275
+ function useRenderPeople(): { people: MentionPerson[]; isMe: (p: MentionPerson) => boolean } {
276
+ const events = useComments((s) => s.events)
277
+ const me = useComments((s) => s.me)
278
+ const people = useMemo(() => mentionPeople(events), [events])
279
+ const isMe = (p: MentionPerson) =>
280
+ (!!p.id && p.id === me?.id) || (!!p.email && !!me?.email && p.email.toLowerCase() === me.email.toLowerCase())
281
+ return { people, isMe }
282
+ }
283
+
284
+ /** Threads holding an unseen mention of me - drives the pin/stack pulse. */
285
+ function useMentionAlerts(): Set<string> {
286
+ const events = useComments((s) => s.events)
287
+ const me = useComments((s) => s.me)
288
+ const seen = useComments((s) => s.mentionSeen)
289
+ return useMemo(() => new Set(mentionAlerts(events, me, seen)), [events, me, seen])
290
+ }
291
+
292
+ /** A comment body with mentions styled. `@marver`: owner-authored → bold accent (a live
293
+ * trigger); anyone else's → plain + a teaching tooltip (context, not a command).
294
+ * `@Person` (v1.1) → the person chip; the same segmentation that derived the
295
+ * event's mentions, so what lit up is what notified. */
263
296
  function CommentBody({ body, owner }: { body?: string; owner: boolean }) {
297
+ const { people, isMe } = useRenderPeople()
264
298
  if (!body) return null
265
299
  return (
266
300
  <p className="cm-body">
267
- {parseMentions(body).map((s, i) => !s.mention
268
- ? <span key={i}>{s.text}</span>
269
- : owner
270
- ? <span key={i} className="cm-at owner">{s.text}</span>
271
- : <Tip key={i} side="top" label={<span className="cm-at-tip">{AT_TIP}</span>}><span className="cm-at">{s.text}</span></Tip>)}
301
+ {parseBody(body, people).map((s, i) => s.person
302
+ ? <span key={i} className={isMe(s.person) ? 'cm-at person me' : 'cm-at person'}>{s.text}</span>
303
+ : !s.marver
304
+ ? <span key={i}>{s.text}</span>
305
+ : owner
306
+ ? <span key={i} className="cm-at owner">{s.text}</span>
307
+ : <Tip key={i} side="top" label={<span className="cm-at-tip">{AT_TIP}</span>}><span className="cm-at">{s.text}</span></Tip>)}
272
308
  </p>
273
309
  )
274
310
  }
@@ -339,6 +375,27 @@ function CommentInput({ value, onChange, onSubmit, onCancel, placeholder, autoFo
339
375
  const ref = useRef<HTMLTextAreaElement>(null)
340
376
  const hlRef = useRef<HTMLDivElement>(null)
341
377
  const [busy, setBusy] = useState(false)
378
+ // ---- @-mention typeahead (v1.1): the token under the caret filters the people the
379
+ // client has seen; picking completes `@Label ` in place. Escape dismisses THIS token
380
+ // only (the composer keeps its own Escape = cancel semantics when no list is open).
381
+ const people = usePeople()
382
+ const [caret, setCaret] = useState(0)
383
+ const [pick, setPick] = useState(0)
384
+ const [dismissed, setDismissed] = useState<number | null>(null)
385
+ const q = mentionQueryAt(value, caret)
386
+ const matches = q && dismissed !== q.start
387
+ ? people.filter((p) => p.label.toLowerCase().startsWith(q.query.toLowerCase())).slice(0, 5)
388
+ : []
389
+ const listOpen = matches.length > 0
390
+ useEffect(() => { setPick(0) }, [q?.start, q?.query])
391
+ const track = () => setCaret(ref.current?.selectionStart ?? 0)
392
+ const choose = (p: MentionPerson) => {
393
+ if (!q) return
394
+ const pos = q.start + p.label.length + 2 // '@' + label + ' '
395
+ onChange(value.slice(0, q.start) + '@' + p.label + ' ' + value.slice(caret))
396
+ setCaret(pos)
397
+ requestAnimationFrame(() => { const el = ref.current; if (el) { el.focus(); el.setSelectionRange(pos, pos) } })
398
+ }
342
399
  // +2: scrollHeight excludes the 1px borders, but border-box height includes them - without
343
400
  // it every line is squeezed 2px and the single-line pill renders off-center
344
401
  const grow = () => { const el = ref.current; if (el) { el.style.height = 'auto'; el.style.height = Math.min(el.scrollHeight + 2, 132) + 'px' } }
@@ -352,18 +409,38 @@ function CommentInput({ value, onChange, onSubmit, onCancel, placeholder, autoFo
352
409
  }
353
410
  return (
354
411
  <div className="cm-inputwrap">
355
- {/* Live mention highlight: a mirror layer behind the transparent-text textarea, so @marver shows
356
- its trigger colour AS YOU TYPE (owner = accent-blue "this will run"; else muted). */}
412
+ {/* Live mention highlight: a mirror layer behind the transparent-text textarea, so @marver
413
+ shows its trigger colour AS YOU TYPE (owner = accent-blue "this will run"; else muted)
414
+ and a completed @Person wears the chip it will render with. */}
357
415
  <div className="cm-hl" ref={hlRef} aria-hidden>
358
- {parseMentions(value).map((s, i) => s.mention
359
- ? <span key={i} className={owner ? 'cm-at owner' : 'cm-at'}>{s.text}</span>
360
- : <span key={i}>{s.text}</span>)}
416
+ {parseBody(value, people).map((s, i) => s.person
417
+ ? <span key={i} className="cm-at person">{s.text}</span>
418
+ : s.marver
419
+ ? <span key={i} className={owner ? 'cm-at owner' : 'cm-at'}>{s.text}</span>
420
+ : <span key={i}>{s.text}</span>)}
361
421
  {value.endsWith('\n') ? '​' : ''}
362
422
  </div>
423
+ {listOpen && (
424
+ <div className="cm-mentionlist" role="listbox">
425
+ {matches.map((p, i) => (
426
+ <button key={p.id ?? p.email} type="button" className={i === pick ? 'on' : ''} role="option" aria-selected={i === pick}
427
+ onMouseDown={(e) => { e.preventDefault(); choose(p) }} onMouseEnter={() => setPick(i)}>
428
+ <Avatar author={{ ...p, name: p.label }} size={18} /><span>{p.label}</span>
429
+ </button>
430
+ ))}
431
+ </div>
432
+ )}
363
433
  <textarea ref={ref} rows={1} value={value} placeholder={placeholder} disabled={busy} spellCheck={false}
364
- onChange={(e) => onChange(e.target.value)} onInput={grow} onScroll={syncScroll}
434
+ onChange={(e) => { onChange(e.target.value); setDismissed(null); setCaret(e.target.selectionStart ?? 0) }}
435
+ onInput={grow} onScroll={syncScroll} onClick={track} onKeyUp={track}
365
436
  onKeyDown={(e) => {
366
437
  e.stopPropagation()
438
+ if (listOpen) {
439
+ if (e.key === 'ArrowDown') { e.preventDefault(); setPick((v) => (v + 1) % matches.length); return }
440
+ if (e.key === 'ArrowUp') { e.preventDefault(); setPick((v) => (v + matches.length - 1) % matches.length); return }
441
+ if (e.key === 'Enter' || e.key === 'Tab') { e.preventDefault(); choose(matches[pick] ?? matches[0]); return }
442
+ if (e.key === 'Escape') { setDismissed(q!.start); return }
443
+ }
367
444
  if (e.key === 'Escape') { onCancel?.(); return }
368
445
  if (e.key === 'Enter') {
369
446
  if ((e.nativeEvent as unknown as { isComposing?: boolean }).isComposing) return // IME: mid-composition Enter must not send
@@ -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
 
@@ -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
+ }
@@ -60,9 +60,16 @@ export interface Node {
60
60
  * on every fresh navigation. */
61
61
  readyRetried?: boolean
62
62
  }
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 }
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
+ }
66
73
  export interface Toast { id: number; text: string; jam?: JamNote }
67
74
 
68
75
  export const CONFIG: { viewports: Record<string, { width: number; height: number }>; themes: string[]; zoomSpeed?: number; noTheme: boolean; setup?: boolean; projectName?: string; branding?: boolean } = shConfig
@@ -1029,7 +1036,8 @@ export const useStore = create<State>((set, get) => {
1029
1036
  * supersedes), capped so the stack stays small. */
1030
1037
  jamToast(note) {
1031
1038
  const id = ++toastSeq
1032
- 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) }))
1033
1041
  },
1034
1042
  dismissToast(id) { set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) })) },
1035
1043
  clearJamToasts() { set((s) => ({ toasts: s.toasts.filter((t) => !t.jam) })) },