@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.
- package/CHANGELOG.md +151 -0
- package/README.md +2 -0
- package/dist/auth-B36fMCM3.mjs +245 -0
- package/dist/{build-Ckmyci3O.mjs → build-BrCl9hJS.mjs} +56 -14
- package/dist/cli.mjs +15 -6
- package/dist/collab-CXy8gqoz.mjs +297 -0
- package/dist/comments-Ba8mU600.mjs +90 -0
- package/dist/comments-odHzYdO3.mjs +179 -0
- package/dist/dev-DdeU-Jst.mjs +162 -0
- package/dist/{init-DolP_Ld4.mjs → init-DsCUmlCW.mjs} +2 -1
- package/dist/{manifest-DW-T52MM.mjs → manifest-C8FODq2S.mjs} +26 -1
- package/dist/{plugin-Crp4CAma.mjs → plugin-BtSGAm2h.mjs} +169 -19
- package/dist/rolldown-runtime-D7D4PA-g.mjs +13 -0
- package/dist/serve-D_KBK7Oy.mjs +431 -0
- package/dist/sync-CkBk-tUk.mjs +248 -0
- package/package.json +3 -1
- package/src/client/content/diagram.tsx +46 -2
- package/src/client/content/index.tsx +6 -2
- package/src/client/content/md.ts +29 -0
- package/src/client/content/palette.ts +6 -0
- package/src/client/frame-host/bridge.js +269 -3
- package/src/client/frame-host/serialize.ts +195 -0
- package/src/client/shell/App.tsx +131 -17
- package/src/client/shell/Comments.tsx +464 -0
- package/src/client/shell/Play.tsx +24 -0
- package/src/client/shell/canvas/Canvas.tsx +66 -29
- package/src/client/shell/canvas/FrameNode.tsx +130 -10
- package/src/client/shell/canvas/frame-registry.ts +18 -0
- package/src/client/shell/canvas/snapshots.ts +233 -0
- package/src/client/shell/comments-store.ts +180 -0
- package/src/client/shell/cursor-arrow-dark.svg +1 -0
- package/src/client/shell/cursor-arrow.svg +1 -0
- package/src/client/shell/hash.ts +14 -3
- package/src/client/shell/icons.tsx +6 -0
- package/src/client/shell/labels.ts +10 -0
- package/src/client/shell/perf.ts +92 -0
- package/src/client/shell/store.ts +101 -6
- package/src/client/shell/styles.css +196 -7
- package/src/client/stage/main.tsx +2 -0
- package/src/shared/events.ts +103 -0
- package/templates/AGENTS-embedded.md +13 -1
- package/templates/AGENTS-studio.md +13 -1
- package/templates/instructions/boards.md +6 -3
- package/templates/instructions/configure.md +28 -0
- package/templates/instructions/iterate.md +38 -0
- package/templates/instructions/publish.md +129 -0
- package/templates/instructions/reference/color.md +22 -1
- package/templates/instructions/shape.md +23 -16
- package/dist/dev-C2oTKuXe.mjs +0 -97
- package/dist/serve-OtA9Nlow.mjs +0 -207
package/src/client/shell/App.tsx
CHANGED
|
@@ -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,
|
|
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() }}>{
|
|
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"
|
|
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
|
-
|
|
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
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
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')
|
|
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
|
-
|
|
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
|
-
|
|
657
|
-
|
|
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>
|