@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.
- package/CHANGELOG.md +111 -0
- package/README.md +1 -1
- package/dist/auth-DdUKeVKt.mjs +1150 -0
- package/dist/{build-DI31MFTB.mjs → build-BxGrHFT2.mjs} +188 -33
- package/dist/cli.mjs +16 -7
- package/dist/collab-mAiNZlDj.mjs +970 -0
- package/dist/{comments-CRqaO2MH.mjs → comments-DHB_8BRa.mjs} +3 -2
- package/dist/{daemon-DSmaS453.mjs → daemon-BChkzDqQ.mjs} +3 -3
- package/dist/{dev-BWIL2ewj.mjs → dev-DLwt3Brb.mjs} +5 -5
- package/dist/events-B3LBn74P.mjs +103 -0
- package/dist/{init-BSclDg4I.mjs → init-BpitOqRQ.mjs} +2 -3
- package/dist/{config-t9coJ-Pq.mjs → manifest-CS6krOTe.mjs} +236 -4
- package/dist/{marver-id-B8-3WiHk.mjs → marver-id-CVCoaMGI.mjs} +106 -1
- package/dist/{marver-id-gate-BDzW6ahN.mjs → marver-id-gate-B2uraTHS.mjs} +100 -9
- package/dist/{plugin-DjBFxEQ7.mjs → plugin-DNc4Jpae.mjs} +82 -9
- package/dist/{profile-DcsJyppw.mjs → profile-BjAPAJSb.mjs} +1 -1
- package/dist/{serve-3APqaNSB.mjs → serve-EjEqsiYa.mjs} +70 -17
- package/dist/share-Gqo_Ygqw.mjs +158 -0
- package/dist/summary-C7CAypbB.mjs +211 -0
- package/dist/{sync-DELGomPk.mjs → sync-BZaCWqK-.mjs} +3 -102
- package/package.json +1 -1
- package/src/client/shell/App.tsx +62 -11
- package/src/client/shell/Comments.tsx +103 -22
- package/src/client/shell/LockedApp.tsx +113 -0
- package/src/client/shell/Play.tsx +97 -18
- package/src/client/shell/Toolbar.tsx +26 -2
- package/src/client/shell/canvas/Canvas.tsx +5 -15
- package/src/client/shell/canvas/ctl.ts +26 -0
- package/src/client/shell/comments-store.ts +96 -11
- package/src/client/shell/hash.ts +19 -0
- package/src/client/shell/icons.tsx +1 -0
- package/src/client/shell/mentions.ts +108 -1
- package/src/client/shell/sound.ts +32 -0
- package/src/client/shell/store.ts +66 -7
- package/src/client/shell/styles.css +68 -0
- package/src/shared/events.ts +20 -6
- package/dist/auth-B5yuwnOq.mjs +0 -494
- package/dist/collab-CWaG3Q4w.mjs +0 -340
- package/dist/manifest-C2tzkNaC.mjs +0 -233
- package/dist/update-DuWDj5nR.mjs +0 -77
- 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 {
|
|
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
|
-
|
|
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({
|
|
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
|
|
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
|
|
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) {
|
|
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
|
-
|
|
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
|
}
|
package/src/client/shell/hash.ts
CHANGED
|
@@ -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
|
-
*
|
|
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: {
|
|
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
|
|
47
|
-
*
|
|
48
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 }
|