@marver-design/marver 0.3.1 → 0.5.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.
Files changed (50) hide show
  1. package/CHANGELOG.md +151 -0
  2. package/README.md +2 -0
  3. package/dist/auth-B36fMCM3.mjs +245 -0
  4. package/dist/{build-Ckmyci3O.mjs → build-BrCl9hJS.mjs} +56 -14
  5. package/dist/cli.mjs +15 -6
  6. package/dist/collab-CXy8gqoz.mjs +297 -0
  7. package/dist/comments-Ba8mU600.mjs +90 -0
  8. package/dist/comments-odHzYdO3.mjs +179 -0
  9. package/dist/dev-DdeU-Jst.mjs +162 -0
  10. package/dist/{init-DolP_Ld4.mjs → init-DsCUmlCW.mjs} +2 -1
  11. package/dist/{manifest-DW-T52MM.mjs → manifest-C8FODq2S.mjs} +26 -1
  12. package/dist/{plugin-Crp4CAma.mjs → plugin-BtSGAm2h.mjs} +169 -19
  13. package/dist/rolldown-runtime-D7D4PA-g.mjs +13 -0
  14. package/dist/serve-D_KBK7Oy.mjs +431 -0
  15. package/dist/sync-CkBk-tUk.mjs +248 -0
  16. package/package.json +3 -1
  17. package/src/client/content/diagram.tsx +46 -2
  18. package/src/client/content/index.tsx +6 -2
  19. package/src/client/content/md.ts +29 -0
  20. package/src/client/content/palette.ts +6 -0
  21. package/src/client/frame-host/bridge.js +269 -3
  22. package/src/client/frame-host/serialize.ts +195 -0
  23. package/src/client/shell/App.tsx +131 -17
  24. package/src/client/shell/Comments.tsx +464 -0
  25. package/src/client/shell/Play.tsx +24 -0
  26. package/src/client/shell/canvas/Canvas.tsx +66 -29
  27. package/src/client/shell/canvas/FrameNode.tsx +130 -10
  28. package/src/client/shell/canvas/frame-registry.ts +18 -0
  29. package/src/client/shell/canvas/snapshots.ts +233 -0
  30. package/src/client/shell/comments-store.ts +180 -0
  31. package/src/client/shell/cursor-arrow-dark.svg +1 -0
  32. package/src/client/shell/cursor-arrow.svg +1 -0
  33. package/src/client/shell/hash.ts +14 -3
  34. package/src/client/shell/icons.tsx +6 -0
  35. package/src/client/shell/labels.ts +10 -0
  36. package/src/client/shell/perf.ts +92 -0
  37. package/src/client/shell/store.ts +101 -6
  38. package/src/client/shell/styles.css +196 -7
  39. package/src/client/stage/main.tsx +2 -0
  40. package/src/shared/events.ts +103 -0
  41. package/templates/AGENTS-embedded.md +13 -1
  42. package/templates/AGENTS-studio.md +13 -1
  43. package/templates/instructions/boards.md +6 -3
  44. package/templates/instructions/configure.md +28 -0
  45. package/templates/instructions/iterate.md +38 -0
  46. package/templates/instructions/publish.md +129 -0
  47. package/templates/instructions/reference/color.md +22 -1
  48. package/templates/instructions/shape.md +23 -16
  49. package/dist/dev-C2oTKuXe.mjs +0 -97
  50. package/dist/serve-OtA9Nlow.mjs +0 -207
@@ -1,12 +1,17 @@
1
1
  import { Component, useEffect, useRef, useState, type ReactNode } from 'react'
2
2
  import { createPortal } from 'react-dom'
3
- import { useStore, CONFIG, PUBLISHED, boardLabel, cap, fetchBoardNames, type FrameEntry } from './store.ts'
3
+ import { useStore, CONFIG, PUBLISHED, boardLabel, cap, humanize, fetchBoardNames, type FrameEntry } from './store.ts'
4
4
  import { Tip } from './Tip.tsx'
5
5
  import { PKG, ROUTE } from '../const.ts'
6
6
  import { animateLayout, Canvas, canvasCtl } from './canvas/Canvas.tsx'
7
+ import { frameByWindow } from './canvas/frame-registry.ts'
7
8
  import { enterPlay, playCtl, PlayOverlay } from './Play.tsx'
8
9
  import { bootHash, parseHash, writeHash } from './hash.ts'
9
- import { CardsIcon, CardsThreeIcon, CaretIcon, CheckIcon, DevicesIcon, FrameRectIcon, GridIcon, IntentGlyph, MoonIcon, PanelFilledIcon, PanelHollowIcon, ParallelogramDuoIcon, PlayIcon, PlusIcon, SignpostIcon, SunIcon, VariantsIcon, XIcon, deviceIcon } from './icons.tsx'
10
+ import { CardsIcon, CardsThreeIcon, CaretIcon, CheckIcon, ColumnsIcon, CommentIcon, DevicesIcon, FrameRectIcon, IntentGlyph, LaserIcon, MoonIcon, PanelFilledIcon, PanelHollowIcon, ParallelogramDuoIcon, PlayIcon, PlusIcon, SignpostIcon, SunIcon, VariantsIcon, XIcon, deviceIcon } from './icons.tsx'
11
+ import { CommentsController } from './Comments.tsx'
12
+ import { useComments } from './comments-store.ts'
13
+
14
+ const commentsStore = () => useComments.getState()
10
15
 
11
16
  let booted = false // survives Fast Refresh; see the boot effect
12
17
 
@@ -19,7 +24,7 @@ function SceneGroup({ name, count, held, onPick, children }: { name: string; cou
19
24
  <button className={`it${held ? ' held' : ''}`} onClick={() => setOpen(!open)}>
20
25
  <CaretIcon size={11} className="tw" style={{ transform: open ? undefined : 'rotate(-90deg)' }} />
21
26
  {/* the NAME selects every frame in the scene; the caret/row still collapses */}
22
- <span onClick={(e) => { if (!onPick) return; e.stopPropagation(); onPick() }}>{cap(name) || '(root)'}</span>
27
+ <span onClick={(e) => { if (!onPick) return; e.stopPropagation(); onPick() }}>{humanize(name) || '(root)'}</span>
23
28
  <small>{count}</small>
24
29
  </button>
25
30
  {open && children}
@@ -196,7 +201,7 @@ function SelectionBar() {
196
201
  </Tip>
197
202
  ))}
198
203
  <i className="sep" />
199
- <Tip label={<><b>{multi ? `Copy ${selection.length} file paths` : 'Copy file path'}</b><span className="k">C</span></>}>
204
+ <Tip label={<><b>{multi ? `Copy ${selection.length} file paths` : 'Copy file path'}</b><span className="k">⇧P</span></>}>
200
205
  <button className="icon"
201
206
  onClick={() => { navigator.clipboard.writeText(selectedFrames().map((f) => f.file).join('\n')); toast(multi ? `${selection.length} file paths copied` : 'file path copied') }}><SignpostIcon size={15} /></button>
202
207
  </Tip>
@@ -375,7 +380,19 @@ function ThemeMenu() {
375
380
  }
376
381
 
377
382
  export function App() {
378
- const { manifest, nodes, panelOpen, toasts, selection } = useStore()
383
+ // B0.1: per-field selectors, NOT `useStore()` (which subscribes to the whole store and
384
+ // re-rendered the entire shell on every setScale tick during a pan/zoom).
385
+ const manifest = useStore((s) => s.manifest)
386
+ const nodes = useStore((s) => s.nodes)
387
+ const panelOpen = useStore((s) => s.panelOpen)
388
+ const toasts = useStore((s) => s.toasts)
389
+ const selection = useStore((s) => s.selection)
390
+ const laser = useStore((s) => s.laser)
391
+ const commentMode = useComments((s) => s.commentMode)
392
+ // SPEC-M5: laser/comment paint live outlines + do element-picking INSIDE the live frame; a scriptless
393
+ // lean cover would hide them, so a body class suppresses the cover while either mode is active.
394
+ useEffect(() => { document.body.classList.toggle('sh-laser', laser) }, [laser])
395
+ useEffect(() => { document.body.classList.toggle('sh-commenting', commentMode) }, [commentMode])
379
396
  const { boot, applyManifest, togglePanel, select, setInteract, runTidy, toast, spawn } = useStore.getState()
380
397
  const [pillOpen, setPillOpen] = useState(true)
381
398
 
@@ -496,6 +513,13 @@ export function App() {
496
513
  useEffect(() => {
497
514
  if (!import.meta.hot) return
498
515
  import.meta.hot.on('sh:manifest', (m: any) => applyManifest(m))
516
+ // A7 controlled HMR: a frame file changed. Reload exactly the affected frames through the
517
+ // lease-aware path (idle frames now, leased ones deferred to a safe point) - never a shell
518
+ // reload, never a React Fast Refresh yanking a frame the user is in.
519
+ import.meta.hot.on('sh:frame-invalidated', (m: any) => {
520
+ const frameIds = Array.isArray(m?.frameIds) ? m.frameIds.filter((x: unknown) => typeof x === 'string') : []
521
+ if (frameIds.length && typeof m?.revision === 'string') useStore.getState().invalidateFrames(frameIds, m.revision)
522
+ })
499
523
  // multi-viewer sync: another viewer (or an agent) saved this board. A clean canvas
500
524
  // re-boots silently, keeping whatever selection survives; a dirty one keeps its
501
525
  // edits and converges through the 409 path on its next save (disk wins, spec §8).
@@ -516,11 +540,11 @@ export function App() {
516
540
  const onMsg = (e: MessageEvent) => {
517
541
  const data = e.data
518
542
  if (!data || typeof data.type !== 'string' || !data.type.startsWith('sh:')) return
519
- const iframes = [...document.querySelectorAll('iframe')]
520
- const el = iframes.find((f) => f.contentWindow === e.source) as HTMLIFrameElement | undefined
521
- if (!el) return
522
- const nodeKey = el.closest('[data-node]')?.getAttribute('data-node')
523
- if (!nodeKey) return
543
+ // B0.3: O(1) registry lookup - also the security gate (unknown source = not a
544
+ // registered frame = dropped), replacing a per-message iframe scan + DOM walk.
545
+ const reg = frameByWindow(e.source)
546
+ if (!reg) return
547
+ const { key: nodeKey, iframe: el } = reg
524
548
  const s = useStore.getState()
525
549
 
526
550
  if (data.type === 'sh:ready') {
@@ -539,6 +563,31 @@ export function App() {
539
563
  // measureNode does the rest of the admission (content frames only,
540
564
  // frame-id match, finite positive, clamped)
541
565
  s.measureNode(nodeKey, String(data.frame ?? ''), Number(data.ownWidth), Number(data.measuredWidth), Number(data.height))
566
+ } else if (data.type === 'sh:laser-copy') {
567
+ // laser click = copy the element's full address for the agent:
568
+ // frame source file + css path inside it (+ jsx source loc when stamped)
569
+ const n = s.nodes.find((x) => x.key === nodeKey)
570
+ const f = n && s.frameFor(n)
571
+ if (f) {
572
+ const addr = `${f.file} · ${String(data.path ?? '')}${data.source ? ` (${String(data.source)})` : ''}`
573
+ // success confirms IN the frame's hover label (right where the eyes are);
574
+ // only failure needs the toast
575
+ navigator.clipboard.writeText(addr).then(
576
+ () => el.contentWindow?.postMessage({ type: 'sh:copy-ok', seq: data.seq }, location.origin),
577
+ () => toast('copy blocked - click the canvas first'))
578
+ }
579
+ } else if (data.type === 'sh:frame-down') {
580
+ // clicks INSIDE a frame never reach the shell document - the frame reports
581
+ // them so an open thread card dismisses modal-style from anywhere
582
+ const c = commentsStore()
583
+ if (c.active) c.setActive(null)
584
+ } else if (data.type === 'sh:picked') {
585
+ // comment mode: the frame reports the picked element - stage the draft on
586
+ // that node (picking while a thread is open replaces it, modal-style);
587
+ // the CommentLayer opens the composer at the pin
588
+ const c = commentsStore()
589
+ if (c.active) c.setActive(null)
590
+ if (c.commentMode) c.setDraft({ nodeKey, frame: String(data.id ?? ''), anchor: data.anchor })
542
591
  } else if (data.type === 'sh:go') {
543
592
  const target = String(data.target ?? '')
544
593
  const existing = s.nodes.find((n) => n.frame === target && !n.missing)
@@ -549,6 +598,29 @@ export function App() {
549
598
  // walking a flow must not eject you to design mode at every hop
550
599
  if (s.interact === nodeKey) setInteract(node.key)
551
600
  setTimeout(() => canvasCtl.fitNode(node.key), 50)
601
+ } else if (data.type === 'sh:wheel') {
602
+ // B0.2: a passive frame forwarded a wheel event; the canvas owns it. Never for the
603
+ // interact target or play (the app scrolls itself - the parent stays authoritative).
604
+ if (s.play || s.interact === nodeKey) return
605
+ const nums = [data.deltaX, data.deltaY, data.clientX, data.clientY].map(Number)
606
+ if (!nums.every(Number.isFinite)) return
607
+ const [deltaX, deltaY, localX, localY] = nums
608
+ const rect = el.getBoundingClientRect()
609
+ // iframe-local (untransformed CSS px) -> shell-screen px: rect is the frame's
610
+ // transformed size, so rect.width/clientWidth is its effective on-screen scale.
611
+ // Plain rect.left+localX would put the zoom origin in the wrong place when zoomed.
612
+ const sx = el.clientWidth ? rect.width / el.clientWidth : 1
613
+ const sy = el.clientHeight ? rect.height / el.clientHeight : 1
614
+ canvasCtl.wheel({
615
+ deltaX, deltaY, deltaMode: Number(data.deltaMode) || 0,
616
+ ctrlKey: !!data.ctrlKey, metaKey: !!data.metaKey,
617
+ clientX: rect.left + localX * sx, clientY: rect.top + localY * sy,
618
+ })
619
+ } else if (data.type === 'sh:interaction') {
620
+ // A6: the frame reports transient laser/comment engagement (pointer inside + mode on).
621
+ // While engaged the frame is leased, so a hot update to it defers until disengage.
622
+ s.setExternalLease(nodeKey, 'laser', !!data.laser)
623
+ s.setExternalLease(nodeKey, 'comment', !!data.comment)
552
624
  }
553
625
  }
554
626
  window.addEventListener('message', onMsg)
@@ -565,10 +637,29 @@ export function App() {
565
637
  if ((e.metaKey || e.ctrlKey) && e.key === '/') { e.preventDefault(); setPillOpen((o) => !o); return }
566
638
  if ((e.metaKey || e.ctrlKey) && e.key === 'a') { e.preventDefault(); s.selectAll(); return }
567
639
  if (e.metaKey || e.ctrlKey) return
568
- if (e.key === 'Escape') s.interact ? setInteract(null) : select(null)
640
+ if (e.key === 'Escape') {
641
+ const c = commentsStore()
642
+ if (c.commentMode || c.active || c.draft) { c.setMode(false); c.setActive(null); c.setDraft(null) }
643
+ else s.interact ? setInteract(null) : select(null)
644
+ }
569
645
  if (e.key === 'p') enterPlay()
570
646
  if (e.key === 't') { animateLayout(); runTidy() }
571
- if (e.key === 'c' && s.selection.length) {
647
+ // laser and comment mode are one-at-a-time: comment mode already highlights
648
+ // what you'd click, so stacking the full rainbow on top only adds noise
649
+ if (e.key === 'l') {
650
+ if (!s.laser) commentsStore().setMode(false)
651
+ s.setLaser(!s.laser)
652
+ }
653
+ // C = comment mode (SPEC-M3 §6, the Figma/Miro convention) · Shift+C = hide/show
654
+ // pins · Shift+P = copy file path(s) (P alone is play; D6, changelog 0.4.0)
655
+ if (e.key === 'c' && !e.shiftKey) {
656
+ const c = commentsStore()
657
+ if (!c.commentMode) s.setLaser(false)
658
+ c.setMode(!c.commentMode)
659
+ toast(c.commentMode ? 'comment mode off' : 'comment mode - click an element in a frame')
660
+ }
661
+ if (e.key === 'C' && e.shiftKey) { const c = commentsStore(); c.setShow(!c.show) }
662
+ if (e.key === 'P' && e.shiftKey && s.selection.length) {
572
663
  const files = s.selection
573
664
  .map((k) => { const n = s.nodes.find((x) => x.key === k); return n ? s.frameFor(n)?.file : undefined })
574
665
  .filter((f): f is string => !!f)
@@ -653,8 +744,14 @@ export function App() {
653
744
  mounted so collapse/expand can crossfade-morph between them */}
654
745
  <aside className={`sh-panel${panelOpen ? '' : ' closed'}`} aria-hidden={!panelOpen}>
655
746
  <div className="sh-panel-top">
656
- <ParallelogramDuoIcon size={21} className="mark" />
657
- <span className="name">Marver</span>
747
+ {/* the mark links to the marver site; the title is the humanized repo name (C2: names
748
+ the project so two concurrent canvases are never confused), ellipsed if long */}
749
+ <Tip side="bottom" label="marver.design">
750
+ <a className="mark-link" href="https://marver.design" target="_blank" rel="noreferrer" aria-label="marver.design" tabIndex={panelOpen ? 0 : -1}>
751
+ <ParallelogramDuoIcon size={21} className="mark" />
752
+ </a>
753
+ </Tip>
754
+ <span className="name" title={CONFIG.projectName || 'Marver'}>{CONFIG.projectName ? humanize(CONFIG.projectName) : 'Marver'}</span>
658
755
  <Tip side="bottom" label={<><b>Collapse panel</b><span>⌘\</span></>}><button className="sh-ibtn" onClick={togglePanel} tabIndex={panelOpen ? 0 : -1}><PanelFilledIcon size={17} /></button></Tip>
659
756
  </div>
660
757
  <div className="sh-panel-scroll">
@@ -763,13 +860,29 @@ export function App() {
763
860
 
764
861
  {/* floating pill nav, top right; collapses to a chip (same ladder as panel/fab) */}
765
862
  <nav className={`sh-pill${pillOpen ? '' : ' closed'}`} aria-hidden={!pillOpen}>
863
+ {/* far-left section: single actions - comment mode, laser, tidy */}
864
+ <Tip side="bottom" label={<><b>Comment</b><span>C · ⇧C hides pins</span></>}>
865
+ <button className={`sh-pill-btn${commentMode ? ' on' : ''}`} onClick={() => {
866
+ const c = commentsStore()
867
+ if (!c.commentMode) useStore.getState().setLaser(false)
868
+ c.setMode(!c.commentMode)
869
+ toast(c.commentMode ? 'comment mode off' : 'comment mode - click an element in a frame')
870
+ }}><CommentIcon size={16} /></button>
871
+ </Tip>
872
+ <Tip side="bottom" label={<><b>Laser mode</b><span>L</span></>}>
873
+ <button className={`sh-pill-btn${laser ? ' on' : ''}`} onClick={() => {
874
+ if (!laser) commentsStore().setMode(false)
875
+ useStore.getState().setLaser(!laser)
876
+ }}><LaserIcon size={16} /></button>
877
+ </Tip>
878
+ <Tip side="bottom" label={<><b>Tidy layout</b><span>T</span></>}>
879
+ <button className="sh-pill-btn" onClick={() => { animateLayout(); runTidy() }}><ColumnsIcon size={16} /></button>
880
+ </Tip>
881
+ <i className="sep" />
766
882
  <DeviceMenu />
767
883
  <ThemeMenu />
768
884
  <i className="sep" />
769
885
  <ZoomMenu />
770
- <Tip side="bottom" label={<><b>Tidy layout</b><span>T</span></>}>
771
- <button className="sh-pill-btn" onClick={() => { animateLayout(); runTidy() }}><GridIcon size={16} /></button>
772
- </Tip>
773
886
  <i className="sep" />
774
887
  <Tip side="bottom" label={<><b>Prototype view</b><span>P</span></>}>
775
888
  <button className="sh-pill-btn" onClick={() => enterPlay()}><PlayIcon size={15} /></button>
@@ -786,6 +899,7 @@ export function App() {
786
899
  </Tip>
787
900
 
788
901
  <PlayOverlay />
902
+ <CommentsController />
789
903
 
790
904
  {CONFIG.setup
791
905
  ? <div className="sh-banner">no app detected - designs would be built from nothing. See design/instructions/setup.md, then restart</div>