@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.
Files changed (54) hide show
  1. package/CHANGELOG.md +141 -3
  2. package/README.md +72 -16
  3. package/dist/{auth-B36fMCM3.mjs → auth-KQ9Aj-nB.mjs} +1 -1
  4. package/dist/{build-BZaPa2DS.mjs → build-BBVQRetk.mjs} +5 -5
  5. package/dist/cli.mjs +16 -7
  6. package/dist/{collab-CXy8gqoz.mjs → collab-s3k5byM1.mjs} +18 -7
  7. package/dist/{comments-Ba8mU600.mjs → comments-BZBKhKRO.mjs} +4 -10
  8. package/dist/{comments-odHzYdO3.mjs → comments-J06jqCVV.mjs} +10 -16
  9. package/dist/daemon-DkyNOwIt.mjs +878 -0
  10. package/dist/{dev-DaPQ9xA5.mjs → dev-BTAhTie-.mjs} +28 -3
  11. package/dist/events-BMtBvvgU.mjs +101 -0
  12. package/dist/{init-DsCUmlCW.mjs → init-DWdhjJD5.mjs} +28 -3
  13. package/dist/ledger-CbzTJrV2.mjs +64 -0
  14. package/dist/{manifest-C8FODq2S.mjs → manifest-B4zcDGBf.mjs} +19 -4
  15. package/dist/{plugin-wMY9lNf3.mjs → plugin-BdQEeTLg.mjs} +89 -41
  16. package/dist/profile-BkiWglVE.mjs +39 -0
  17. package/dist/{serve-D_KBK7Oy.mjs → serve-CZqPnj19.mjs} +32 -8
  18. package/dist/{sync-CkBk-tUk.mjs → sync-BJKKmy1n.mjs} +5 -103
  19. package/dist/work-CLrmY-vQ.mjs +97 -0
  20. package/dist/work-lzC-lPY0.mjs +76 -0
  21. package/package.json +16 -1
  22. package/src/client/const.ts +1 -1
  23. package/src/client/content/diagram.tsx +1 -1
  24. package/src/client/content/index.tsx +2 -2
  25. package/src/client/content/md.ts +1 -1
  26. package/src/client/content/palette.ts +2 -2
  27. package/src/client/frame-host/bridge.js +1 -1
  28. package/src/client/frame-host/inspect.js +16 -3
  29. package/src/client/frame-host/serialize.ts +2 -2
  30. package/src/client/shell/App.tsx +165 -26
  31. package/src/client/shell/Comments.tsx +434 -78
  32. package/src/client/shell/Play.tsx +12 -8
  33. package/src/client/shell/Toolbar.tsx +14 -2
  34. package/src/client/shell/canvas/Canvas.tsx +1 -1
  35. package/src/client/shell/canvas/FrameNode.tsx +47 -15
  36. package/src/client/shell/canvas/snapshots.ts +3 -3
  37. package/src/client/shell/comments-store.ts +147 -33
  38. package/src/client/shell/hash.ts +2 -2
  39. package/src/client/shell/icons.tsx +5 -1
  40. package/src/client/shell/keys.ts +39 -0
  41. package/src/client/shell/mentions.ts +18 -0
  42. package/src/client/shell/perf.ts +1 -1
  43. package/src/client/shell/store.ts +127 -39
  44. package/src/client/shell/styles.css +303 -30
  45. package/src/client/shell/tidy.ts +5 -5
  46. package/src/client/stage/main.tsx +2 -2
  47. package/src/shared/events.ts +23 -6
  48. package/src/shared/utm.ts +22 -0
  49. package/templates/AGENTS-embedded.md +49 -1
  50. package/templates/AGENTS-studio.md +49 -1
  51. package/templates/instructions/configure.md +15 -0
  52. package/templates/instructions/jam.md +85 -0
  53. package/templates/instructions/publish.md +1 -1
  54. package/dist/rolldown-runtime-D7D4PA-g.mjs +0 -13
@@ -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 (codex final review P2).
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
- // SPEC-M5: laser/comment paint live outlines + do element-picking INSIDE the live frame; a scriptless
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 (SPEC-M2 §3): board before load, play mode after it.
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, spec §8).
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 (spec §6 protocol); source validated against known iframes
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
- // SPEC-026 generation guard: the sender echoes ITS document's URL rev; a
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
- const node = existing ?? spawn(target)
525
- if (!node) return toast(`unknown goto target "${target}"`)
526
- select(node.key)
527
- // a goto from inside an interacting frame CARRIES interact mode to the target -
528
- // walking a flow must not eject you to design mode at every hop
529
- if (s.interact === nodeKey) setInteract(node.key)
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 (SPEC-M3 §6, the Figma/Miro convention) · Shift+C = hide/show
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="https://marver.design" target="_blank" rel="noreferrer" aria-label="marver.design" tabIndex={panelOpen ? 0 : -1}>
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 (SPEC-023 §5)
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, prototype */}
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: UI management only - hide, collapse */}
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
- <div className="sh-toasts">
834
- {toasts.map((t) => <div key={t.id} className="sh-toast"><CheckIcon size={12} /> {t.text}</div>)}
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
  }