@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.
- package/CHANGELOG.md +124 -0
- package/README.md +41 -19
- package/dist/{build-DmfxJc6L.mjs → build-ByYafIhj.mjs} +37 -5
- package/dist/cli.mjs +21 -7
- package/dist/{collab-BmaOjwP1.mjs → collab-mAiNZlDj.mjs} +232 -43
- package/dist/{comments-pNxjTrGN.mjs → comments-DHB_8BRa.mjs} +3 -3
- package/dist/{daemon-WjonKlPz.mjs → daemon-Bfucyf1o.mjs} +3 -3
- package/dist/{dev-BXq7yE1p.mjs → dev-yZMyQeUj.mjs} +5 -5
- package/dist/{events-C9k-ceOj.mjs → events-B3LBn74P.mjs} +3 -1
- package/dist/{init-BpitOqRQ.mjs → init-Dvaso7YO.mjs} +66 -2
- package/dist/{manifest-CS6krOTe.mjs → manifest-DvOmglFp.mjs} +7 -0
- package/dist/{marver-id-gate-DOjnZakC.mjs → marver-id-gate-D6By7XHj.mjs} +8 -5
- package/dist/{plugin-Boy7I_JH.mjs → plugin-BsmG5i2X.mjs} +25 -7
- package/dist/{profile-ChpTPd-X.mjs → profile-BjAPAJSb.mjs} +1 -1
- package/dist/{serve-BeLLizNg.mjs → serve-Bcwfpvhl.mjs} +4 -3
- package/dist/{share-C64b1tdN.mjs → share-Gqo_Ygqw.mjs} +14 -15
- package/dist/{shot-Cyv3GN79.mjs → shot-kbR_xzJH.mjs} +7 -1
- package/dist/{summary-Bf9tiGrE.mjs → summary-C7CAypbB.mjs} +1 -1
- package/dist/{sync-WYxDN9IT.mjs → sync-BZaCWqK-.mjs} +1 -1
- package/docs/live-jam.md +173 -0
- package/docs/publish.md +270 -0
- package/docs/sharing.md +333 -0
- package/docs/slides.md +134 -0
- package/package.json +3 -1
- package/src/client/const.ts +13 -0
- package/src/client/content/chart-engine.ts +33 -0
- package/src/client/content/chart.tsx +100 -0
- package/src/client/content/index.tsx +25 -4
- package/src/client/content/slide.tsx +238 -0
- package/src/client/content/video.tsx +126 -0
- package/src/client/shell/App.tsx +49 -15
- package/src/client/shell/Comments.tsx +94 -17
- package/src/client/shell/LockedApp.tsx +7 -2
- package/src/client/shell/Play.tsx +138 -24
- package/src/client/shell/Toolbar.tsx +12 -3
- package/src/client/shell/canvas/FrameNode.tsx +5 -3
- package/src/client/shell/comments-store.ts +59 -4
- package/src/client/shell/hash.ts +3 -1
- package/src/client/shell/icons.tsx +2 -0
- package/src/client/shell/mentions.ts +108 -1
- package/src/client/shell/play-order.ts +22 -0
- package/src/client/shell/sound.ts +32 -0
- package/src/client/shell/store.ts +41 -12
- package/src/client/shell/styles.css +58 -27
- package/src/client/stage/main.tsx +54 -3
- package/src/shared/events.ts +12 -3
- package/src/shared/utm.ts +3 -2
- package/templates/AGENTS-embedded.md +1 -0
- package/templates/AGENTS-studio.md +1 -0
- package/templates/instructions/publish.md +7 -0
- package/templates/instructions/reference/deck-layouts.md +230 -0
- package/templates/instructions/reference/deck-story.md +110 -0
- 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
|
-
|
|
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) {
|
|
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
|
|
package/src/client/shell/hash.ts
CHANGED
|
@@ -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
|
-
*
|
|
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
|
|
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
|
|
64
|
-
*
|
|
65
|
-
|
|
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
|
-
|
|
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 '
|
|
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
|
-
|
|
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
|
-
|
|
863
|
-
|
|
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 } }
|