@marver-design/marver 0.7.0 → 0.8.1
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 +141 -3
- package/README.md +72 -16
- package/dist/{auth-B36fMCM3.mjs → auth-KQ9Aj-nB.mjs} +1 -1
- package/dist/{build-BZaPa2DS.mjs → build-BBVQRetk.mjs} +5 -5
- package/dist/cli.mjs +16 -7
- package/dist/{collab-CXy8gqoz.mjs → collab-s3k5byM1.mjs} +18 -7
- package/dist/{comments-Ba8mU600.mjs → comments-BZBKhKRO.mjs} +4 -10
- package/dist/{comments-odHzYdO3.mjs → comments-J06jqCVV.mjs} +10 -16
- package/dist/daemon-DkyNOwIt.mjs +878 -0
- package/dist/{dev-DaPQ9xA5.mjs → dev-BTAhTie-.mjs} +28 -3
- package/dist/events-BMtBvvgU.mjs +101 -0
- package/dist/{init-DsCUmlCW.mjs → init-DWdhjJD5.mjs} +28 -3
- package/dist/ledger-CbzTJrV2.mjs +64 -0
- package/dist/{manifest-C8FODq2S.mjs → manifest-B4zcDGBf.mjs} +19 -4
- package/dist/{plugin-wMY9lNf3.mjs → plugin-BdQEeTLg.mjs} +89 -41
- package/dist/profile-BkiWglVE.mjs +39 -0
- package/dist/{serve-D_KBK7Oy.mjs → serve-CZqPnj19.mjs} +32 -8
- package/dist/{sync-CkBk-tUk.mjs → sync-BJKKmy1n.mjs} +5 -103
- package/dist/work-CLrmY-vQ.mjs +97 -0
- package/dist/work-lzC-lPY0.mjs +76 -0
- package/package.json +16 -1
- package/src/client/const.ts +1 -1
- package/src/client/content/diagram.tsx +1 -1
- package/src/client/content/index.tsx +2 -2
- package/src/client/content/md.ts +1 -1
- package/src/client/content/palette.ts +2 -2
- package/src/client/frame-host/bridge.js +1 -1
- package/src/client/frame-host/inspect.js +16 -3
- package/src/client/frame-host/serialize.ts +2 -2
- package/src/client/shell/App.tsx +165 -26
- package/src/client/shell/Comments.tsx +434 -78
- package/src/client/shell/Play.tsx +12 -8
- package/src/client/shell/Toolbar.tsx +14 -2
- package/src/client/shell/canvas/Canvas.tsx +1 -1
- package/src/client/shell/canvas/FrameNode.tsx +47 -15
- package/src/client/shell/canvas/snapshots.ts +3 -3
- package/src/client/shell/comments-store.ts +147 -33
- package/src/client/shell/hash.ts +2 -2
- package/src/client/shell/icons.tsx +5 -1
- package/src/client/shell/keys.ts +39 -0
- package/src/client/shell/mentions.ts +18 -0
- package/src/client/shell/perf.ts +1 -1
- package/src/client/shell/store.ts +127 -39
- package/src/client/shell/styles.css +303 -30
- package/src/client/shell/tidy.ts +5 -5
- package/src/client/stage/main.tsx +2 -2
- package/src/shared/events.ts +23 -6
- package/src/shared/utm.ts +22 -0
- package/templates/AGENTS-embedded.md +49 -1
- package/templates/AGENTS-studio.md +49 -1
- package/templates/instructions/configure.md +15 -0
- package/templates/instructions/jam.md +85 -0
- package/templates/instructions/publish.md +1 -1
- package/dist/rolldown-runtime-D7D4PA-g.mjs +0 -13
package/src/client/shell/App.tsx
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import { Component, useEffect, useRef, useState, type ReactNode } from 'react'
|
|
2
|
-
import { useStore, CONFIG, PUBLISHED, boardLabel, cap, humanize, fetchBoardNames, type FrameEntry } from './store.ts'
|
|
2
|
+
import { useStore, CONFIG, PUBLISHED, boardLabel, boardFrames, cap, humanize, fetchBoardNames, type FrameEntry } from './store.ts'
|
|
3
3
|
import { Tip } from './Tip.tsx'
|
|
4
4
|
import { PKG, ROUTE } from '../const.ts'
|
|
5
5
|
import { animateLayout, Canvas, canvasCtl } from './canvas/Canvas.tsx'
|
|
6
6
|
import { frameByWindow } from './canvas/frame-registry.ts'
|
|
7
7
|
import { enterPlay, playCtl, PlayOverlay } from './Play.tsx'
|
|
8
8
|
import { bootHash, parseHash, writeHash } from './hash.ts'
|
|
9
|
-
import { CardsIcon, CardsThreeIcon, CaretIcon, CheckIcon, ColumnsIcon, FrameRectIcon, IntentGlyph, MoonIcon, PanelFilledIcon, PanelHollowIcon, ParallelogramDuoIcon, PlayIcon, PlusIcon, SignpostIcon, SunIcon, VariantsIcon, XIcon, deviceIcon } from './icons.tsx'
|
|
10
|
-
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'
|
|
11
|
+
import { poweredByUrl } from '../../shared/utm.ts'
|
|
11
12
|
import { useComments } from './comments-store.ts'
|
|
12
13
|
import { CommentButton, DevicePicker, HideUIButton, LaserButton, Popover, ThemePicker, toggleHideUI, usePopover } from './Toolbar.tsx'
|
|
13
14
|
|
|
@@ -126,7 +127,7 @@ function SelectionBar() {
|
|
|
126
127
|
// frame top - the caption offset (8px screen, world-capped) - the caption's height
|
|
127
128
|
// (screen-clamped 12..18px font) - the bar. Gate on the TOP edge of the selection,
|
|
128
129
|
// not the last-selected frame: a mixed selection whose topmost frames are variants
|
|
129
|
-
// still has a caption to clear
|
|
130
|
+
// still has a caption to clear.
|
|
130
131
|
const capAtTop = selNodes.some((n) => n.y === by0 && useStore.getState().frameFor(n)?.variantGroup)
|
|
131
132
|
const rawTop = capAtTop
|
|
132
133
|
? `calc(var(--sh-ty, 0px) + var(--sh-s, 1) * ${by0}px - clamp(4px * var(--sh-s, 1), 8px, 40px * var(--sh-s, 1)) - (clamp(12px, 17px * var(--sh-s, 1), 18px) * 1.4) - 44px)`
|
|
@@ -309,14 +310,14 @@ export function App() {
|
|
|
309
310
|
const selection = useStore((s) => s.selection)
|
|
310
311
|
const laser = useStore((s) => s.laser)
|
|
311
312
|
const commentMode = useComments((s) => s.commentMode)
|
|
312
|
-
//
|
|
313
|
+
// Laser/comment paint live outlines + do element-picking INSIDE the live frame; a scriptless
|
|
313
314
|
// lean cover would hide them, so a body class suppresses the cover while either mode is active.
|
|
314
315
|
useEffect(() => { document.body.classList.toggle('sh-laser', laser) }, [laser])
|
|
315
316
|
useEffect(() => { document.body.classList.toggle('sh-commenting', commentMode) }, [commentMode])
|
|
316
317
|
const { boot, applyManifest, togglePanel, select, setInteract, runTidy, toast, spawn } = useStore.getState()
|
|
317
318
|
const [pillOpen, setPillOpen] = useState(true)
|
|
318
319
|
|
|
319
|
-
// boot honors the deep link
|
|
320
|
+
// boot honors the deep link: board before load, play mode after it.
|
|
320
321
|
// Selection + camera intent are restored by the Canvas boot effect. The module-level
|
|
321
322
|
// guard makes boot single-shot: Fast Refresh re-runs mount effects on every App edit,
|
|
322
323
|
// and a re-boot would revert live state to the long-consumed deep link.
|
|
@@ -442,6 +443,12 @@ export function App() {
|
|
|
442
443
|
useEffect(() => {
|
|
443
444
|
if (!import.meta.hot) return
|
|
444
445
|
import.meta.hot.on('sh:manifest', (m: any) => applyManifest(m))
|
|
446
|
+
// Live Jam presence: the daemon broadcasts the set of frames Marver is editing.
|
|
447
|
+
// Camera-safe by construction - this only toggles a glow class, never moves the view.
|
|
448
|
+
import.meta.hot.on('sh:jam-activity', (m: any) => useStore.getState().setWorking(Array.isArray(m?.frames) ? m.frames.filter((x: unknown) => typeof x === 'string') : []))
|
|
449
|
+
// Live Jam reply delivery: the daemon just wrote to a board's log - fetch it NOW instead of
|
|
450
|
+
// waiting out the 30s comment poll, so the reply + notification land within a second.
|
|
451
|
+
import.meta.hot.on('sh:jam-comment', (m: any) => { if (typeof m?.board === 'string') useComments.getState().poke(m.board) })
|
|
445
452
|
// A7 controlled HMR: a frame file changed. Reload exactly the affected frames through the
|
|
446
453
|
// lease-aware path (idle frames now, leased ones deferred to a safe point) - never a shell
|
|
447
454
|
// reload, never a React Fast Refresh yanking a frame the user is in.
|
|
@@ -451,7 +458,7 @@ export function App() {
|
|
|
451
458
|
})
|
|
452
459
|
// multi-viewer sync: another viewer (or an agent) saved this board. A clean canvas
|
|
453
460
|
// re-boots silently, keeping whatever selection survives; a dirty one keeps its
|
|
454
|
-
// edits and converges through the 409 path on its next save (disk wins
|
|
461
|
+
// edits and converges through the 409 path on its next save (disk wins).
|
|
455
462
|
import.meta.hot.on('sh:board', (m: any) => {
|
|
456
463
|
const s = useStore.getState()
|
|
457
464
|
if (m?.name !== s.board || m?.sha256 === s.boardHash || s.dirty) return
|
|
@@ -464,7 +471,7 @@ export function App() {
|
|
|
464
471
|
})
|
|
465
472
|
}, [])
|
|
466
473
|
|
|
467
|
-
// frame -> shell messages
|
|
474
|
+
// frame -> shell messages; source validated against known iframes
|
|
468
475
|
useEffect(() => {
|
|
469
476
|
const onMsg = (e: MessageEvent) => {
|
|
470
477
|
const data = e.data
|
|
@@ -484,7 +491,7 @@ export function App() {
|
|
|
484
491
|
} else if (data.type === 'sh:exit-interact') {
|
|
485
492
|
if (s.interact === nodeKey) setInteract(null)
|
|
486
493
|
} else if (data.type === 'sh:measure') {
|
|
487
|
-
//
|
|
494
|
+
// Generation guard: the sender echoes ITS document's URL rev; a
|
|
488
495
|
// WindowProxy survives navigation, so a stale pre-navigation message would
|
|
489
496
|
// otherwise route as if it came from the current document. Compare against
|
|
490
497
|
// the iframe's CURRENT src - mismatched generations are dropped.
|
|
@@ -520,14 +527,16 @@ export function App() {
|
|
|
520
527
|
if (c.commentMode) c.setDraft({ nodeKey, frame: String(data.id ?? ''), anchor: data.anchor })
|
|
521
528
|
} else if (data.type === 'sh:go') {
|
|
522
529
|
const target = String(data.target ?? '')
|
|
530
|
+
const carry = s.interact === nodeKey // a goto from inside an interacting frame
|
|
531
|
+
// CARRIES interact mode to the target - walking a flow must not eject you to
|
|
532
|
+
// design mode at every hop (and must survive a board switch)
|
|
523
533
|
const existing = s.nodes.find((n) => n.frame === target && !n.missing)
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
setTimeout(() => canvasCtl.fitNode(node.key), 50)
|
|
534
|
+
if (existing) {
|
|
535
|
+
gotoSeq++ // a local goto supersedes any cross-board one in flight
|
|
536
|
+
select(existing.key)
|
|
537
|
+
if (carry) setInteract(existing.key)
|
|
538
|
+
setTimeout(() => canvasCtl.fitNode(existing.key), 50)
|
|
539
|
+
} else void gotoAcrossBoards(target, carry)
|
|
531
540
|
} else if (data.type === 'sh:wheel') {
|
|
532
541
|
// B0.2: a passive frame forwarded a wheel event; the canvas owns it. Never for the
|
|
533
542
|
// interact target or play (the app scrolls itself - the parent stays authoritative).
|
|
@@ -584,7 +593,7 @@ export function App() {
|
|
|
584
593
|
if (!s.laser) commentsStore().setMode(false)
|
|
585
594
|
s.setLaser(!s.laser)
|
|
586
595
|
}
|
|
587
|
-
// C = comment mode (
|
|
596
|
+
// C = comment mode (the Figma/Miro convention) · Shift+C = hide/show
|
|
588
597
|
// pins · Shift+P = copy file path(s) (P alone is play; D6, changelog 0.4.0)
|
|
589
598
|
if (e.key === 'c' && !e.shiftKey) {
|
|
590
599
|
const c = commentsStore()
|
|
@@ -593,6 +602,14 @@ export function App() {
|
|
|
593
602
|
toast(c.commentMode ? 'comment mode off' : 'comment mode - click an element in a frame')
|
|
594
603
|
}
|
|
595
604
|
if (e.key === 'C' && e.shiftKey) { const c = commentsStore(); c.setShow(!c.show) }
|
|
605
|
+
// Shift+L = laser comment: the laser-sharp lighting on the element a comment tags
|
|
606
|
+
// (pick hover, compose lock, open-thread highlight). Pins and cards stay - this
|
|
607
|
+
// only dims the lighting inside the artwork.
|
|
608
|
+
if (e.key === 'L' && e.shiftKey) {
|
|
609
|
+
const c = commentsStore()
|
|
610
|
+
c.setShowAnchor(!c.showAnchor)
|
|
611
|
+
toast(c.showAnchor ? 'laser comment off' : 'laser comment on')
|
|
612
|
+
}
|
|
596
613
|
if (e.key === 'P' && e.shiftKey && s.selection.length) {
|
|
597
614
|
const files = s.selection
|
|
598
615
|
.map((k) => { const n = s.nodes.find((x) => x.key === k); return n ? s.frameFor(n)?.file : undefined })
|
|
@@ -681,7 +698,7 @@ export function App() {
|
|
|
681
698
|
{/* the mark links to the marver site; the title is the humanized repo name (C2: names
|
|
682
699
|
the project so two concurrent canvases are never confused), ellipsed if long */}
|
|
683
700
|
<Tip side="bottom" label="marver.design">
|
|
684
|
-
<a className="mark-link" href=
|
|
701
|
+
<a className="mark-link" href={poweredByUrl(CONFIG.projectName, PUBLISHED ? 'published-canvas' : 'dev-canvas', 'shell')} target="_blank" rel="noreferrer" aria-label="marver.design" tabIndex={panelOpen ? 0 : -1}>
|
|
685
702
|
<ParallelogramDuoIcon size={21} className="mark" />
|
|
686
703
|
</a>
|
|
687
704
|
</Tip>
|
|
@@ -702,7 +719,7 @@ export function App() {
|
|
|
702
719
|
canvasCtl.fitNodes(keys)
|
|
703
720
|
}}>
|
|
704
721
|
{(() => {
|
|
705
|
-
// variant groups render as ONE surface row with A/B/C chips
|
|
722
|
+
// variant groups render as ONE surface row with A/B/C chips
|
|
706
723
|
const nodeFor = (id: string) => nodes.find((x) => x.frame === id && !x.missing) ?? nodes.find((x) => x.frame === id)
|
|
707
724
|
// sidebar order follows the CANVAS (reading order: rows top-to-bottom,
|
|
708
725
|
// left-to-right) - a story board's list must tell the same story as its
|
|
@@ -794,22 +811,22 @@ export function App() {
|
|
|
794
811
|
|
|
795
812
|
{/* floating pill nav, top right; collapses to a chip (same ladder as panel/fab) */}
|
|
796
813
|
<nav className={`sh-pill${pillOpen ? '' : ' closed'}`} aria-hidden={!pillOpen}>
|
|
797
|
-
{/* far-left section: actions - comment mode, laser, tidy
|
|
814
|
+
{/* far-left section: actions - comment mode, laser, tidy */}
|
|
798
815
|
<CommentButton />
|
|
799
816
|
<LaserButton />
|
|
800
817
|
<Tip side="bottom" label={<><b>Tidy layout</b><span>T</span></>}>
|
|
801
818
|
<button className="sh-pill-btn" onClick={() => { animateLayout(); runTidy() }}><ColumnsIcon size={16} /></button>
|
|
802
819
|
</Tip>
|
|
803
|
-
<Tip side="bottom" label={<><b>Prototype view</b><span>P</span></>}>
|
|
804
|
-
<button className="sh-pill-btn" onClick={() => enterPlay()}><PlayIcon size={15} /></button>
|
|
805
|
-
</Tip>
|
|
806
820
|
<i className="sep" />
|
|
807
821
|
<DeviceMenu />
|
|
808
822
|
<ThemeMenu />
|
|
809
823
|
<i className="sep" />
|
|
810
824
|
<ZoomMenu />
|
|
811
825
|
<i className="sep" />
|
|
812
|
-
{/* far right:
|
|
826
|
+
{/* far right: view management - prototype, hide, collapse */}
|
|
827
|
+
<Tip side="bottom" label={<><b>Prototype view</b><span>P</span></>}>
|
|
828
|
+
<button className="sh-pill-btn" onClick={() => enterPlay()}><PlayIcon size={15} /></button>
|
|
829
|
+
</Tip>
|
|
813
830
|
<HideUIButton />
|
|
814
831
|
<Tip side="bottom" label={<><b>Collapse toolbar</b><span>⌘/</span></>}>
|
|
815
832
|
<button className="sh-pill-btn" onClick={() => setPillOpen(false)} tabIndex={pillOpen ? 0 : -1}>
|
|
@@ -830,9 +847,131 @@ export function App() {
|
|
|
830
847
|
: 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>}
|
|
831
848
|
<UpdatePill />
|
|
832
849
|
|
|
833
|
-
<
|
|
834
|
-
|
|
850
|
+
<JamToasts toasts={toasts} />
|
|
851
|
+
</div>
|
|
852
|
+
)
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
/** The bottom-right notification corner. Plain toasts render as before.
|
|
856
|
+
* Jam pills are FRAME-FIRST (icon + frame title, then Marver · preview) and stack as a DECK:
|
|
857
|
+
* 1-2 show in full; 3+ collapse to the newest pill with two card edges peeking beneath and a
|
|
858
|
+
* +N badge - click to expand the full list (newest first, timestamps, Clear all). */
|
|
859
|
+
function JamToasts({ toasts }: { toasts: import('./store.ts').Toast[] }) {
|
|
860
|
+
const [expanded, setExpanded] = useState(false)
|
|
861
|
+
const plain = toasts.filter((t) => !t.jam)
|
|
862
|
+
const jams = toasts.filter((t) => t.jam)
|
|
863
|
+
useEffect(() => { if (jams.length <= 2) setExpanded(false) }, [jams.length])
|
|
864
|
+
const deck = jams.length > 2 && !expanded
|
|
865
|
+
const newest = jams[jams.length - 1]
|
|
866
|
+
return (
|
|
867
|
+
<div className="sh-toasts">
|
|
868
|
+
{plain.slice(-2).map((t) => <div key={t.id} className="sh-toast"><CheckIcon size={12} /> {t.text}</div>)}
|
|
869
|
+
{expanded && jams.length > 2 && (
|
|
870
|
+
<div className="sh-jam-list">
|
|
871
|
+
<div className="sh-jam-listhead">
|
|
872
|
+
<span>{jams.length} NOTIFICATIONS</span>
|
|
873
|
+
<button onClick={() => { useStore.getState().clearJamToasts(); setExpanded(false) }}>Clear all</button>
|
|
874
|
+
<button aria-label="Collapse" onClick={() => setExpanded(false)}><XIcon size={12} /></button>
|
|
875
|
+
</div>
|
|
876
|
+
{[...jams].reverse().map((t) => <JamToast key={t.id} id={t.id} note={t.jam!} />)}
|
|
877
|
+
</div>
|
|
878
|
+
)}
|
|
879
|
+
{!expanded && (deck
|
|
880
|
+
? (
|
|
881
|
+
<div className="sh-jam-deck" onClick={() => setExpanded(true)} role="button" aria-label={`${jams.length} notifications - expand`}>
|
|
882
|
+
<div className="sh-jam-ghost g2" />
|
|
883
|
+
<div className="sh-jam-ghost g1" />
|
|
884
|
+
{newest?.jam && <JamToast id={newest.id} note={newest.jam} badge={jams.length - 1} inert />}
|
|
885
|
+
</div>
|
|
886
|
+
)
|
|
887
|
+
: jams.map((t) => <JamToast key={t.id} id={t.id} note={t.jam!} />))}
|
|
888
|
+
</div>
|
|
889
|
+
)
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
/** A data-goto whose target frame is not on the current board follows the frame HOME:
|
|
893
|
+
* the first curated board (switcher rank) that pins it is switched to and the frame
|
|
894
|
+
* focused there - a link is navigation, and navigation never edits a board. Only a
|
|
895
|
+
* frame NO board pins spawns onto the current board (the original prototype behavior
|
|
896
|
+
* for unpinned targets); an id the manifest doesn't know stays a toast. Every goto
|
|
897
|
+
* bumps `gotoSeq` so a slow older resolution can never override newer navigation. */
|
|
898
|
+
let gotoSeq = 0
|
|
899
|
+
async function gotoAcrossBoards(target: string, carry: boolean) {
|
|
900
|
+
const s = useStore.getState()
|
|
901
|
+
// an id the manifest doesn't know resolves NOWHERE - a tombstone pin on some board
|
|
902
|
+
// must not send us on a trip that ends in a silent timeout
|
|
903
|
+
if (!s.manifest?.frames.some((f) => f.id === target)) return s.toast(`unknown goto target "${target}"`)
|
|
904
|
+
const seq = ++gotoSeq
|
|
905
|
+
let home: string | null = null
|
|
906
|
+
try {
|
|
907
|
+
const names = (await fetchBoardNames()).filter((n) => n !== s.board && n !== 'all-scenes')
|
|
908
|
+
for (const name of names) {
|
|
909
|
+
if ((await boardFrames(name)).includes(target)) { home = name; break }
|
|
910
|
+
}
|
|
911
|
+
} catch {
|
|
912
|
+
// a transport failure is NOT proof the frame is unpinned - spawning here would
|
|
913
|
+
// recreate the board mutation this function exists to prevent
|
|
914
|
+
return useStore.getState().toast(`goto: could not read the boards - try again`)
|
|
915
|
+
}
|
|
916
|
+
if (seq !== gotoSeq) return // superseded by newer navigation
|
|
917
|
+
if (!home) {
|
|
918
|
+
// no curated board pins it - the original prototype behavior: spawn beside you
|
|
919
|
+
const st = useStore.getState()
|
|
920
|
+
const node = st.spawn(target)
|
|
921
|
+
if (!node) return st.toast(`unknown goto target "${target}"`)
|
|
922
|
+
st.select(node.key)
|
|
923
|
+
if (carry) st.setInteract(node.key)
|
|
924
|
+
setTimeout(() => canvasCtl.fitNode(node.key), 50)
|
|
925
|
+
return
|
|
926
|
+
}
|
|
927
|
+
await s.switchBoard(home)
|
|
928
|
+
for (let i = 0; i < 12; i++) { // the board commits async - retry like viewNote
|
|
929
|
+
if (seq !== gotoSeq) return
|
|
930
|
+
const st = useStore.getState()
|
|
931
|
+
if (st.board === home) { // a cancelled/failed switch must not select here
|
|
932
|
+
const node = st.nodes.find((n) => n.frame === target && !n.missing)
|
|
933
|
+
if (node) {
|
|
934
|
+
st.select(node.key)
|
|
935
|
+
if (carry) st.setInteract(node.key)
|
|
936
|
+
setTimeout(() => canvasCtl.fitNode(node.key), 50)
|
|
937
|
+
return
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
await new Promise((r) => setTimeout(r, 250))
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
/** View from a notification: threads are frame-scoped, so first try to reveal RIGHT HERE
|
|
945
|
+
* (the current board may show the thread's frame); only when it doesn't, switch to the
|
|
946
|
+
* note's origin board and retry while its comments load. Dismiss only on success: a
|
|
947
|
+
* cross-board note must never be a destructive no-op. */
|
|
948
|
+
async function viewNote(note: import('./store.ts').JamNote, dismiss: () => void) {
|
|
949
|
+
if (revealThread(note.threadId)) { dismiss(); return }
|
|
950
|
+
const s = useStore.getState()
|
|
951
|
+
if (note.board && s.board !== note.board) await s.switchBoard(note.board)
|
|
952
|
+
for (let i = 0; i < 12; i++) {
|
|
953
|
+
if (revealThread(note.threadId)) { dismiss(); return }
|
|
954
|
+
await new Promise((r) => setTimeout(r, 250))
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
/** One jam pill, frame-first: row 1 = intent icon + FRAME TITLE (blue) · age; row 2 = Marver · preview.
|
|
959
|
+
* `badge` shows the +N deck count (replacing View); `inert` disables inner clicks (the deck handles it). */
|
|
960
|
+
function JamToast({ id, note, badge, inert }: { id: number; note: import('./store.ts').JamNote; badge?: number; inert?: boolean }) {
|
|
961
|
+
const dismiss = () => useStore.getState().dismissToast(id)
|
|
962
|
+
return (
|
|
963
|
+
<div className="sh-toast jam">
|
|
964
|
+
<span className="sh-jam-mark"><ParallelogramFillIcon size={17} /></span>
|
|
965
|
+
<div className="sh-jam-txt">
|
|
966
|
+
<b className="sh-jam-frame"><span className="t">{note.frameTitle ?? note.board}</span></b>
|
|
967
|
+
<span className="sh-jam-prev">{note.preview}</span>
|
|
835
968
|
</div>
|
|
969
|
+
{badge != null
|
|
970
|
+
? <span className="sh-jam-badge">+{badge}</span>
|
|
971
|
+
: !inert && <>
|
|
972
|
+
<button className="sh-jam-view" onClick={() => void viewNote(note, dismiss)}>View</button>
|
|
973
|
+
<button className="sh-jam-x" aria-label="Dismiss" onClick={dismiss}><XIcon size={13} /></button>
|
|
974
|
+
</>}
|
|
836
975
|
</div>
|
|
837
976
|
)
|
|
838
977
|
}
|