@marver-design/marver 0.6.0 → 0.8.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 +95 -0
- package/dist/{build-BZaPa2DS.mjs → build-B8z902TW.mjs} +2 -2
- package/dist/cli.mjs +5 -5
- package/dist/{collab-CXy8gqoz.mjs → collab-pLGzSvm5.mjs} +16 -5
- package/dist/{comments-odHzYdO3.mjs → comments-BrpC86Kf.mjs} +9 -15
- package/dist/{comments-Ba8mU600.mjs → comments-DSwMhdbZ.mjs} +2 -8
- package/dist/daemon-C-huGHAM.mjs +899 -0
- package/dist/{dev-DaPQ9xA5.mjs → dev-BYm9ebfN.mjs} +14 -3
- package/dist/events-BMtBvvgU.mjs +101 -0
- package/dist/{init-DsCUmlCW.mjs → init-DrLomSWq.mjs} +13 -1
- package/dist/ledger-wFvEIEGi.mjs +64 -0
- package/dist/{manifest-C8FODq2S.mjs → manifest-D3eaARf4.mjs} +16 -1
- package/dist/{plugin-wMY9lNf3.mjs → plugin-BVFuRfEo.mjs} +56 -40
- package/dist/profile-BkiWglVE.mjs +39 -0
- package/dist/{serve-D_KBK7Oy.mjs → serve-CwAfayJk.mjs} +3 -3
- package/dist/{sync-CkBk-tUk.mjs → sync-Ch4Bymb1.mjs} +4 -102
- package/package.json +1 -1
- package/src/client/frame-host/bridge.js +28 -242
- package/src/client/frame-host/inspect.d.ts +6 -0
- package/src/client/frame-host/inspect.js +359 -0
- package/src/client/shell/App.tsx +107 -110
- package/src/client/shell/Comments.tsx +465 -70
- package/src/client/shell/Play.tsx +218 -155
- package/src/client/shell/Toolbar.tsx +201 -0
- package/src/client/shell/canvas/FrameNode.tsx +59 -8
- package/src/client/shell/comments-store.ts +47 -6
- package/src/client/shell/icons.tsx +5 -0
- package/src/client/shell/mentions.ts +18 -0
- package/src/client/shell/store.ts +30 -2
- package/src/client/shell/styles.css +356 -30
- package/src/client/stage/main.tsx +35 -11
- package/src/shared/events.ts +22 -5
- package/templates/AGENTS-embedded.md +1 -0
- package/templates/AGENTS-studio.md +1 -0
- package/templates/instructions/configure.md +15 -0
- package/templates/instructions/jam.md +85 -0
- package/dist/rolldown-runtime-D7D4PA-g.mjs +0 -13
package/src/client/shell/App.tsx
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { Component, useEffect, useRef, useState, type ReactNode } from 'react'
|
|
2
|
-
import { createPortal } from 'react-dom'
|
|
3
2
|
import { useStore, CONFIG, PUBLISHED, boardLabel, cap, humanize, fetchBoardNames, type FrameEntry } from './store.ts'
|
|
4
3
|
import { Tip } from './Tip.tsx'
|
|
5
4
|
import { PKG, ROUTE } from '../const.ts'
|
|
@@ -7,9 +6,10 @@ import { animateLayout, Canvas, canvasCtl } from './canvas/Canvas.tsx'
|
|
|
7
6
|
import { frameByWindow } from './canvas/frame-registry.ts'
|
|
8
7
|
import { enterPlay, playCtl, PlayOverlay } from './Play.tsx'
|
|
9
8
|
import { bootHash, parseHash, writeHash } from './hash.ts'
|
|
10
|
-
import { CardsIcon, CardsThreeIcon, CaretIcon, CheckIcon, ColumnsIcon,
|
|
11
|
-
import { CommentsController } from './Comments.tsx'
|
|
9
|
+
import { CardsIcon, CardsThreeIcon, CaretIcon, CheckIcon, ColumnsIcon, FrameRectIcon, IntentGlyph, MoonIcon, PanelFilledIcon, PanelHollowIcon, ParallelogramDuoIcon, ParallelogramFillIcon, PlayIcon, PlusIcon, SignpostIcon, SunIcon, VariantsIcon, XIcon, deviceIcon } from './icons.tsx'
|
|
10
|
+
import { CommentsController, revealThread } from './Comments.tsx'
|
|
12
11
|
import { useComments } from './comments-store.ts'
|
|
12
|
+
import { CommentButton, DevicePicker, HideUIButton, LaserButton, Popover, ThemePicker, toggleHideUI, usePopover } from './Toolbar.tsx'
|
|
13
13
|
|
|
14
14
|
const commentsStore = () => useComments.getState()
|
|
15
15
|
|
|
@@ -48,41 +48,6 @@ export class ShellBoundary extends Component<{ children: ReactNode }, { err: Err
|
|
|
48
48
|
}
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
/** Shared popover machinery: trigger position, outside-click close, portal to the app
|
|
52
|
-
* root (glass never nests - a nested backdrop-filter cannot sample the page). */
|
|
53
|
-
function usePopover() {
|
|
54
|
-
const [open, setOpen] = useState(false)
|
|
55
|
-
const [pos, setPos] = useState({ left: 0, top: 0 })
|
|
56
|
-
const boxRef = useRef<HTMLDivElement>(null)
|
|
57
|
-
const menuRef = useRef<HTMLDivElement>(null)
|
|
58
|
-
const toggle = () => {
|
|
59
|
-
if (!open && boxRef.current) {
|
|
60
|
-
const r = boxRef.current.getBoundingClientRect()
|
|
61
|
-
setPos({ left: r.left, top: r.bottom + 10 })
|
|
62
|
-
}
|
|
63
|
-
setOpen(!open)
|
|
64
|
-
}
|
|
65
|
-
useEffect(() => {
|
|
66
|
-
if (!open) return
|
|
67
|
-
const close = (e: PointerEvent) => {
|
|
68
|
-
const t = e.target as globalThis.Node
|
|
69
|
-
if (!boxRef.current?.contains(t) && !menuRef.current?.contains(t)) setOpen(false)
|
|
70
|
-
}
|
|
71
|
-
window.addEventListener('pointerdown', close)
|
|
72
|
-
return () => window.removeEventListener('pointerdown', close)
|
|
73
|
-
}, [open])
|
|
74
|
-
return { open, setOpen, pos, boxRef, menuRef, toggle }
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
function Popover({ pop, children }: { pop: ReturnType<typeof usePopover>; children: ReactNode }) {
|
|
78
|
-
const app = document.querySelector('.sh-app')
|
|
79
|
-
if (!pop.open || !app) return null
|
|
80
|
-
return createPortal(
|
|
81
|
-
<div className="sh-menu" ref={pop.menuRef} style={{ left: pop.pos.left, top: pop.pos.top }}>{children}</div>,
|
|
82
|
-
app,
|
|
83
|
-
)
|
|
84
|
-
}
|
|
85
|
-
|
|
86
51
|
/** Boards live flat in the sidebar - always visible, one click to switch. The list
|
|
87
52
|
* refreshes on mount, window focus, and a slow poll so agent-created board files
|
|
88
53
|
* appear without a reload. Active board = accent icon + wash, same language as scenes. */
|
|
@@ -213,12 +178,12 @@ function SelectionBar() {
|
|
|
213
178
|
}
|
|
214
179
|
|
|
215
180
|
/** Devices view: one click sizes frames to a device width, tidies, and fits. Scoped
|
|
216
|
-
* like the digit keys: the selection when one exists, the whole board otherwise.
|
|
181
|
+
* like the digit keys: the selection when one exists, the whole board otherwise. The
|
|
182
|
+
* dropdown chrome is the shared DevicePicker; this adapter owns the canvas wiring. */
|
|
217
183
|
function DeviceMenu() {
|
|
218
184
|
const deviceView = useStore((s) => s.deviceView)
|
|
219
185
|
const selection = useStore((s) => s.selection)
|
|
220
186
|
const nodes = useStore((s) => s.nodes)
|
|
221
|
-
const pop = usePopover()
|
|
222
187
|
const scoped = selection.length > 0
|
|
223
188
|
const pick = (name: string | null) => {
|
|
224
189
|
animateLayout()
|
|
@@ -230,7 +195,6 @@ function DeviceMenu() {
|
|
|
230
195
|
st.setDeviceView(name)
|
|
231
196
|
setTimeout(() => canvasCtl.fitAll(), 30)
|
|
232
197
|
}
|
|
233
|
-
pop.setOpen(false)
|
|
234
198
|
}
|
|
235
199
|
const entries = Object.entries(CONFIG.viewports)
|
|
236
200
|
// active check: board-wide it is the device view; scoped, the device every selected frame wears
|
|
@@ -238,31 +202,8 @@ function DeviceMenu() {
|
|
|
238
202
|
const active = scoped
|
|
239
203
|
? entries.find(([, vp]) => selNodes.length > 0 && selNodes.every((n) => n.w === vp.width))?.[0] ?? null
|
|
240
204
|
: deviceView
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
<Tip side="bottom" label={<><b>Device view</b><span>{scoped ? `${selection.length} selected` : deviceView ? `${cap(deviceView)} · 0 resets` : `keys 1-${entries.length}`}</span></>}>
|
|
244
|
-
<button className="sh-pill-btn" onClick={pop.toggle}>
|
|
245
|
-
{deviceIcon(active, 16)}
|
|
246
|
-
<CaretIcon size={11} style={{ transform: pop.open ? 'rotate(180deg)' : undefined }} />
|
|
247
|
-
</button>
|
|
248
|
-
</Tip>
|
|
249
|
-
<Popover pop={pop}>
|
|
250
|
-
<Tip side="bottom" label={scoped ? 'Selected frames at their default sizes' : 'Every frame at its own default size'}>
|
|
251
|
-
<button onClick={() => pick(null)}>
|
|
252
|
-
<DevicesIcon size={15} /><span>Default</span><kbd>0</kbd>
|
|
253
|
-
{!scoped && deviceView === null && <CheckIcon size={13} className="chk" />}
|
|
254
|
-
</button>
|
|
255
|
-
</Tip>
|
|
256
|
-
<i className="div" />
|
|
257
|
-
{entries.map(([name, vp], i) => (
|
|
258
|
-
<button key={name} onClick={() => pick(name)} title={`${vp.width} × ${vp.height}`}>
|
|
259
|
-
{deviceIcon(name)}<span>{cap(name)}</span><kbd>{i < 9 ? i + 1 : ''}</kbd>
|
|
260
|
-
{active === name && <CheckIcon size={13} className="chk" />}
|
|
261
|
-
</button>
|
|
262
|
-
))}
|
|
263
|
-
</Popover>
|
|
264
|
-
</div>
|
|
265
|
-
)
|
|
205
|
+
const hint = scoped ? `${selection.length} selected` : deviceView ? cap(deviceView) : `keys 1-${entries.length}`
|
|
206
|
+
return <DevicePicker value={active} onSelect={pick} includeDefault hint={hint} />
|
|
266
207
|
}
|
|
267
208
|
|
|
268
209
|
/** Update pill (dev only): the daily registry check surfaces here - same glass, same
|
|
@@ -337,46 +278,25 @@ function ZoomMenu() {
|
|
|
337
278
|
/** Theme dropdown, scoped like the device digits: the selection when one exists, every
|
|
338
279
|
* frame otherwise. The trigger reflects the scope's MAJORITY (per-frame overrides never
|
|
339
280
|
* flip a board-level trigger - it reports the level it acts on, same rule as the shell).
|
|
340
|
-
* The
|
|
341
|
-
* so a nested backdrop-filter samples the pill's surface instead of the page - flat grey. */
|
|
281
|
+
* The dropdown chrome is the shared ThemePicker; this adapter owns the canvas wiring. */
|
|
342
282
|
function ThemeMenu() {
|
|
343
283
|
const nodes = useStore((s) => s.nodes)
|
|
344
284
|
const selection = useStore((s) => s.selection)
|
|
345
285
|
const viewTheme = useStore((s) => s.viewTheme)
|
|
346
|
-
const pop = usePopover()
|
|
347
286
|
const scoped = selection.length > 0
|
|
348
|
-
// scoped: the trigger + check reflect the selection; global: the VIEW preference
|
|
349
287
|
const scope = scoped ? nodes.filter((n) => selection.includes(n.key)) : []
|
|
350
|
-
const uniform = scoped
|
|
351
|
-
? (scope.length && scope.every((n) => n.theme === scope[0].theme) ? scope[0].theme : null)
|
|
352
|
-
: viewTheme
|
|
353
288
|
const majority = scoped
|
|
354
289
|
? (scope.length ? [...scope.reduce((m, n) => m.set(n.theme, (m.get(n.theme) ?? 0) + 1), new Map<string, number>()).entries()].sort((a, b) => b[1] - a[1])[0][0] : viewTheme)
|
|
355
290
|
: viewTheme
|
|
291
|
+
// the tick shows only a UNIFORM scope (mixed selection = no theme is "the" one), while
|
|
292
|
+
// the trigger reports the majority - the level the control acts on
|
|
293
|
+
const uniform = scoped ? (scope.length && scope.every((n) => n.theme === scope[0].theme) ? scope[0].theme : null) : viewTheme
|
|
356
294
|
const pick = (t: string) => {
|
|
357
295
|
const st = useStore.getState()
|
|
358
296
|
scoped ? st.setSelectedTheme(t) : st.setTheme(t)
|
|
359
|
-
pop.setOpen(false)
|
|
360
297
|
}
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
<Tip side="bottom" label={<><b>Theme</b><span>{scoped ? `${selection.length} selected · D` : 'all frames · D'}</span></>}>
|
|
364
|
-
<button className="sh-pill-btn" onClick={pop.toggle}>
|
|
365
|
-
{majority === 'dark' ? <MoonIcon size={16} /> : <SunIcon size={16} />}
|
|
366
|
-
<CaretIcon size={11} style={{ transform: pop.open ? 'rotate(180deg)' : undefined }} />
|
|
367
|
-
</button>
|
|
368
|
-
</Tip>
|
|
369
|
-
<Popover pop={pop}>
|
|
370
|
-
{CONFIG.themes.map((t) => (
|
|
371
|
-
<button key={t} onClick={() => pick(t)}>
|
|
372
|
-
{t === 'dark' ? <MoonIcon size={15} /> : <SunIcon size={15} />}
|
|
373
|
-
<span>{t}</span>
|
|
374
|
-
{uniform === t && <CheckIcon size={13} className="chk" />}
|
|
375
|
-
</button>
|
|
376
|
-
))}
|
|
377
|
-
</Popover>
|
|
378
|
-
</div>
|
|
379
|
-
)
|
|
298
|
+
const hint = scoped ? `${selection.length} selected · D` : 'all frames · D'
|
|
299
|
+
return <ThemePicker value={majority} checked={uniform} onSelect={pick} hint={hint} />
|
|
380
300
|
}
|
|
381
301
|
|
|
382
302
|
export function App() {
|
|
@@ -522,6 +442,12 @@ export function App() {
|
|
|
522
442
|
useEffect(() => {
|
|
523
443
|
if (!import.meta.hot) return
|
|
524
444
|
import.meta.hot.on('sh:manifest', (m: any) => applyManifest(m))
|
|
445
|
+
// Live Jam presence (SPEC §10): the daemon broadcasts the set of frames Marver is editing.
|
|
446
|
+
// Camera-safe by construction - this only toggles a glow class, never moves the view.
|
|
447
|
+
import.meta.hot.on('sh:jam-activity', (m: any) => useStore.getState().setWorking(Array.isArray(m?.frames) ? m.frames.filter((x: unknown) => typeof x === 'string') : []))
|
|
448
|
+
// Live Jam reply delivery: the daemon just wrote to a board's log - fetch it NOW instead of
|
|
449
|
+
// waiting out the 30s comment poll, so the reply + notification land within a second.
|
|
450
|
+
import.meta.hot.on('sh:jam-comment', (m: any) => { if (typeof m?.board === 'string') useComments.getState().poke(m.board) })
|
|
525
451
|
// A7 controlled HMR: a frame file changed. Reload exactly the affected frames through the
|
|
526
452
|
// lease-aware path (idle frames now, leased ones deferred to a safe point) - never a shell
|
|
527
453
|
// reload, never a React Fast Refresh yanking a frame the user is in.
|
|
@@ -549,6 +475,7 @@ export function App() {
|
|
|
549
475
|
const onMsg = (e: MessageEvent) => {
|
|
550
476
|
const data = e.data
|
|
551
477
|
if (!data || typeof data.type !== 'string' || !data.type.startsWith('sh:')) return
|
|
478
|
+
if (e.origin && e.origin !== location.origin) return // a cross-origin-navigated frame keeps its WindowProxy; reject it
|
|
552
479
|
// B0.3: O(1) registry lookup - also the security gate (unknown source = not a
|
|
553
480
|
// registered frame = dropped), replacing a per-message iframe scan + DOM walk.
|
|
554
481
|
const reg = frameByWindow(e.source)
|
|
@@ -649,9 +576,13 @@ export function App() {
|
|
|
649
576
|
if (e.key === 'Escape') {
|
|
650
577
|
const c = commentsStore()
|
|
651
578
|
if (c.commentMode || c.active || c.draft) { c.setMode(false); c.setActive(null); c.setDraft(null) }
|
|
579
|
+
else if (s.laser) s.setLaser(false) // Escape also exits laser mode (parity with comment)
|
|
652
580
|
else s.interact ? setInteract(null) : select(null)
|
|
653
581
|
}
|
|
654
582
|
if (e.key === 'p') enterPlay()
|
|
583
|
+
// H hides all chrome (shared binary Hide-UI); press again to reveal. Not persisted,
|
|
584
|
+
// so a refresh always restores it (the safety net for a forgotten toggle).
|
|
585
|
+
if (e.key === 'h') { toggleHideUI(); return }
|
|
655
586
|
if (e.key === 't') { animateLayout(); runTidy() }
|
|
656
587
|
// laser and comment mode are one-at-a-time: comment mode already highlights
|
|
657
588
|
// what you'd click, so stacking the full rainbow on top only adds noise
|
|
@@ -668,6 +599,14 @@ export function App() {
|
|
|
668
599
|
toast(c.commentMode ? 'comment mode off' : 'comment mode - click an element in a frame')
|
|
669
600
|
}
|
|
670
601
|
if (e.key === 'C' && e.shiftKey) { const c = commentsStore(); c.setShow(!c.show) }
|
|
602
|
+
// Shift+L = laser comment: the laser-sharp lighting on the element a comment tags
|
|
603
|
+
// (pick hover, compose lock, open-thread highlight). Pins and cards stay - this
|
|
604
|
+
// only dims the lighting inside the artwork.
|
|
605
|
+
if (e.key === 'L' && e.shiftKey) {
|
|
606
|
+
const c = commentsStore()
|
|
607
|
+
c.setShowAnchor(!c.showAnchor)
|
|
608
|
+
toast(c.showAnchor ? 'laser comment off' : 'laser comment on')
|
|
609
|
+
}
|
|
671
610
|
if (e.key === 'P' && e.shiftKey && s.selection.length) {
|
|
672
611
|
const files = s.selection
|
|
673
612
|
.map((k) => { const n = s.nodes.find((x) => x.key === k); return n ? s.frameFor(n)?.file : undefined })
|
|
@@ -869,21 +808,9 @@ export function App() {
|
|
|
869
808
|
|
|
870
809
|
{/* floating pill nav, top right; collapses to a chip (same ladder as panel/fab) */}
|
|
871
810
|
<nav className={`sh-pill${pillOpen ? '' : ' closed'}`} aria-hidden={!pillOpen}>
|
|
872
|
-
{/* far-left section:
|
|
873
|
-
<
|
|
874
|
-
|
|
875
|
-
const c = commentsStore()
|
|
876
|
-
if (!c.commentMode) useStore.getState().setLaser(false)
|
|
877
|
-
c.setMode(!c.commentMode)
|
|
878
|
-
toast(c.commentMode ? 'comment mode off' : 'comment mode - click an element in a frame')
|
|
879
|
-
}}><CommentIcon size={16} /></button>
|
|
880
|
-
</Tip>
|
|
881
|
-
<Tip side="bottom" label={<><b>Laser mode</b><span>L</span></>}>
|
|
882
|
-
<button className={`sh-pill-btn${laser ? ' on' : ''}`} onClick={() => {
|
|
883
|
-
if (!laser) commentsStore().setMode(false)
|
|
884
|
-
useStore.getState().setLaser(!laser)
|
|
885
|
-
}}><LaserIcon size={16} /></button>
|
|
886
|
-
</Tip>
|
|
811
|
+
{/* far-left section: actions - comment mode, laser, tidy */}
|
|
812
|
+
<CommentButton />
|
|
813
|
+
<LaserButton />
|
|
887
814
|
<Tip side="bottom" label={<><b>Tidy layout</b><span>T</span></>}>
|
|
888
815
|
<button className="sh-pill-btn" onClick={() => { animateLayout(); runTidy() }}><ColumnsIcon size={16} /></button>
|
|
889
816
|
</Tip>
|
|
@@ -893,9 +820,11 @@ export function App() {
|
|
|
893
820
|
<i className="sep" />
|
|
894
821
|
<ZoomMenu />
|
|
895
822
|
<i className="sep" />
|
|
823
|
+
{/* far right: view management - prototype, hide, collapse */}
|
|
896
824
|
<Tip side="bottom" label={<><b>Prototype view</b><span>P</span></>}>
|
|
897
825
|
<button className="sh-pill-btn" onClick={() => enterPlay()}><PlayIcon size={15} /></button>
|
|
898
826
|
</Tip>
|
|
827
|
+
<HideUIButton />
|
|
899
828
|
<Tip side="bottom" label={<><b>Collapse toolbar</b><span>⌘/</span></>}>
|
|
900
829
|
<button className="sh-pill-btn" onClick={() => setPillOpen(false)} tabIndex={pillOpen ? 0 : -1}>
|
|
901
830
|
<PanelFilledIcon size={17} style={{ transform: 'rotate(90deg)' }} />
|
|
@@ -915,9 +844,77 @@ export function App() {
|
|
|
915
844
|
: CONFIG.noTheme && <div className="sh-banner">no theme configured - frames render unstyled. Create design/theme.css importing your app's stylesheet (or set theme in design/config.ts)</div>}
|
|
916
845
|
<UpdatePill />
|
|
917
846
|
|
|
918
|
-
<
|
|
919
|
-
|
|
847
|
+
<JamToasts toasts={toasts} />
|
|
848
|
+
</div>
|
|
849
|
+
)
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
/** The bottom-right notification corner (SPEC-live-jam §9). Plain toasts render as before.
|
|
853
|
+
* Jam pills are FRAME-FIRST (icon + frame title, then Marver · preview) and stack as a DECK:
|
|
854
|
+
* 1-2 show in full; 3+ collapse to the newest pill with two card edges peeking beneath and a
|
|
855
|
+
* +N badge - click to expand the full list (newest first, timestamps, Clear all). */
|
|
856
|
+
function JamToasts({ toasts }: { toasts: import('./store.ts').Toast[] }) {
|
|
857
|
+
const [expanded, setExpanded] = useState(false)
|
|
858
|
+
const plain = toasts.filter((t) => !t.jam)
|
|
859
|
+
const jams = toasts.filter((t) => t.jam)
|
|
860
|
+
useEffect(() => { if (jams.length <= 2) setExpanded(false) }, [jams.length])
|
|
861
|
+
const deck = jams.length > 2 && !expanded
|
|
862
|
+
const newest = jams[jams.length - 1]
|
|
863
|
+
return (
|
|
864
|
+
<div className="sh-toasts">
|
|
865
|
+
{plain.slice(-2).map((t) => <div key={t.id} className="sh-toast"><CheckIcon size={12} /> {t.text}</div>)}
|
|
866
|
+
{expanded && jams.length > 2 && (
|
|
867
|
+
<div className="sh-jam-list">
|
|
868
|
+
<div className="sh-jam-listhead">
|
|
869
|
+
<span>{jams.length} NOTIFICATIONS</span>
|
|
870
|
+
<button onClick={() => { useStore.getState().clearJamToasts(); setExpanded(false) }}>Clear all</button>
|
|
871
|
+
<button aria-label="Collapse" onClick={() => setExpanded(false)}><XIcon size={12} /></button>
|
|
872
|
+
</div>
|
|
873
|
+
{[...jams].reverse().map((t) => <JamToast key={t.id} id={t.id} note={t.jam!} />)}
|
|
874
|
+
</div>
|
|
875
|
+
)}
|
|
876
|
+
{!expanded && (deck
|
|
877
|
+
? (
|
|
878
|
+
<div className="sh-jam-deck" onClick={() => setExpanded(true)} role="button" aria-label={`${jams.length} notifications - expand`}>
|
|
879
|
+
<div className="sh-jam-ghost g2" />
|
|
880
|
+
<div className="sh-jam-ghost g1" />
|
|
881
|
+
{newest?.jam && <JamToast id={newest.id} note={newest.jam} badge={jams.length - 1} inert />}
|
|
882
|
+
</div>
|
|
883
|
+
)
|
|
884
|
+
: jams.map((t) => <JamToast key={t.id} id={t.id} note={t.jam!} />))}
|
|
885
|
+
</div>
|
|
886
|
+
)
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
/** View from a notification: switch to the note's board first when needed (the thread only exists
|
|
890
|
+
* in that board's state), then reveal - retrying briefly while the board's comments load. Dismiss
|
|
891
|
+
* only on success: a cross-board note must never be a destructive no-op. */
|
|
892
|
+
async function viewNote(note: import('./store.ts').JamNote, dismiss: () => void) {
|
|
893
|
+
const s = useStore.getState()
|
|
894
|
+
if (note.board && s.board !== note.board) await s.switchBoard(note.board)
|
|
895
|
+
for (let i = 0; i < 12; i++) {
|
|
896
|
+
if (revealThread(note.threadId)) { dismiss(); return }
|
|
897
|
+
await new Promise((r) => setTimeout(r, 250))
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
/** One jam pill, frame-first: row 1 = intent icon + FRAME TITLE (blue) · age; row 2 = Marver · preview.
|
|
902
|
+
* `badge` shows the +N deck count (replacing View); `inert` disables inner clicks (the deck handles it). */
|
|
903
|
+
function JamToast({ id, note, badge, inert }: { id: number; note: import('./store.ts').JamNote; badge?: number; inert?: boolean }) {
|
|
904
|
+
const dismiss = () => useStore.getState().dismissToast(id)
|
|
905
|
+
return (
|
|
906
|
+
<div className="sh-toast jam">
|
|
907
|
+
<span className="sh-jam-mark"><ParallelogramFillIcon size={17} /></span>
|
|
908
|
+
<div className="sh-jam-txt">
|
|
909
|
+
<b className="sh-jam-frame"><span className="t">{note.frameTitle ?? note.board}</span></b>
|
|
910
|
+
<span className="sh-jam-prev">{note.preview}</span>
|
|
920
911
|
</div>
|
|
912
|
+
{badge != null
|
|
913
|
+
? <span className="sh-jam-badge">+{badge}</span>
|
|
914
|
+
: !inert && <>
|
|
915
|
+
<button className="sh-jam-view" onClick={() => void viewNote(note, dismiss)}>View</button>
|
|
916
|
+
<button className="sh-jam-x" aria-label="Dismiss" onClick={dismiss}><XIcon size={13} /></button>
|
|
917
|
+
</>}
|
|
921
918
|
</div>
|
|
922
919
|
)
|
|
923
920
|
}
|