@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.
Files changed (37) hide show
  1. package/CHANGELOG.md +95 -0
  2. package/dist/{build-BZaPa2DS.mjs → build-B8z902TW.mjs} +2 -2
  3. package/dist/cli.mjs +5 -5
  4. package/dist/{collab-CXy8gqoz.mjs → collab-pLGzSvm5.mjs} +16 -5
  5. package/dist/{comments-odHzYdO3.mjs → comments-BrpC86Kf.mjs} +9 -15
  6. package/dist/{comments-Ba8mU600.mjs → comments-DSwMhdbZ.mjs} +2 -8
  7. package/dist/daemon-C-huGHAM.mjs +899 -0
  8. package/dist/{dev-DaPQ9xA5.mjs → dev-BYm9ebfN.mjs} +14 -3
  9. package/dist/events-BMtBvvgU.mjs +101 -0
  10. package/dist/{init-DsCUmlCW.mjs → init-DrLomSWq.mjs} +13 -1
  11. package/dist/ledger-wFvEIEGi.mjs +64 -0
  12. package/dist/{manifest-C8FODq2S.mjs → manifest-D3eaARf4.mjs} +16 -1
  13. package/dist/{plugin-wMY9lNf3.mjs → plugin-BVFuRfEo.mjs} +56 -40
  14. package/dist/profile-BkiWglVE.mjs +39 -0
  15. package/dist/{serve-D_KBK7Oy.mjs → serve-CwAfayJk.mjs} +3 -3
  16. package/dist/{sync-CkBk-tUk.mjs → sync-Ch4Bymb1.mjs} +4 -102
  17. package/package.json +1 -1
  18. package/src/client/frame-host/bridge.js +28 -242
  19. package/src/client/frame-host/inspect.d.ts +6 -0
  20. package/src/client/frame-host/inspect.js +359 -0
  21. package/src/client/shell/App.tsx +107 -110
  22. package/src/client/shell/Comments.tsx +465 -70
  23. package/src/client/shell/Play.tsx +218 -155
  24. package/src/client/shell/Toolbar.tsx +201 -0
  25. package/src/client/shell/canvas/FrameNode.tsx +59 -8
  26. package/src/client/shell/comments-store.ts +47 -6
  27. package/src/client/shell/icons.tsx +5 -0
  28. package/src/client/shell/mentions.ts +18 -0
  29. package/src/client/shell/store.ts +30 -2
  30. package/src/client/shell/styles.css +356 -30
  31. package/src/client/stage/main.tsx +35 -11
  32. package/src/shared/events.ts +22 -5
  33. package/templates/AGENTS-embedded.md +1 -0
  34. package/templates/AGENTS-studio.md +1 -0
  35. package/templates/instructions/configure.md +15 -0
  36. package/templates/instructions/jam.md +85 -0
  37. package/dist/rolldown-runtime-D7D4PA-g.mjs +0 -13
@@ -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, 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'
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
- return (
242
- <div className="sh-theme" ref={pop.boxRef}>
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 menu is PORTALED out of the pill: an element with backdrop-filter is a backdrop root,
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
- return (
362
- <div className="sh-theme" ref={pop.boxRef}>
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: single actions - comment mode, laser, tidy */}
873
- <Tip side="bottom" label={<><b>Comment</b><span>C · ⇧C hides pins</span></>}>
874
- <button className={`sh-pill-btn${commentMode ? ' on' : ''}`} onClick={() => {
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
- <div className="sh-toasts">
919
- {toasts.map((t) => <div key={t.id} className="sh-toast"><CheckIcon size={12} /> {t.text}</div>)}
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
  }