@marver-design/marver 0.15.0 → 0.16.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 (33) hide show
  1. package/CHANGELOG.md +75 -0
  2. package/README.md +2 -1
  3. package/dist/boards-6hKVW42a.mjs +57 -0
  4. package/dist/boards-BdG1TJwU.mjs +267 -0
  5. package/dist/{build-DfuTQZlY.mjs → build-Ct0iMoSi.mjs} +68 -29
  6. package/dist/cli.mjs +13 -4
  7. package/dist/{daemon-DalgvoA9.mjs → daemon-Cb4JjpgL.mjs} +1 -1
  8. package/dist/{dev-BxCmeU_H.mjs → dev-CdcIuhZJ.mjs} +4 -4
  9. package/dist/{init-QKNi9gvF.mjs → init-BQIpIpIv.mjs} +6 -3
  10. package/dist/{manifest-BzxSMoDB.mjs → manifest-CpbsqQ_v.mjs} +77 -11
  11. package/dist/{plugin-DJyjmQeh.mjs → plugin-BXyezwfN.mjs} +164 -69
  12. package/dist/{poster-CbpzSzJu.mjs → poster-DOY7pax8.mjs} +1 -1
  13. package/dist/{shot-BWhoz6cU.mjs → shot-CwmHO5T4.mjs} +2 -2
  14. package/docs/publish.md +4 -1
  15. package/package.json +1 -1
  16. package/src/client/shell/App.tsx +4 -246
  17. package/src/client/shell/BoardList.tsx +408 -0
  18. package/src/client/shell/ContextMenu.tsx +59 -0
  19. package/src/client/shell/icons.tsx +6 -0
  20. package/src/client/shell/store.ts +105 -47
  21. package/src/client/shell/styles.css +36 -4
  22. package/src/shared/board-tree.ts +285 -0
  23. package/templates/AGENTS-embedded.md +18 -5
  24. package/templates/AGENTS-studio.md +18 -5
  25. package/templates/instructions/boards.md +73 -2
  26. package/templates/instructions/craft.md +4 -0
  27. package/templates/instructions/discover.md +7 -2
  28. package/templates/instructions/iterate.md +5 -4
  29. package/templates/instructions/review.md +4 -0
  30. package/templates/instructions/shape.md +1 -1
  31. package/templates/instructions/welcome.md +4 -1
  32. package/templates/instructions/wireframe.md +3 -0
  33. package/dist/{comments-DHB_8BRa.mjs → comments-oYcZ3cE-.mjs} +1 -1
@@ -1,13 +1,14 @@
1
- import { Component, useEffect, useRef, useState, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from 'react'
2
- import { createPortal } from 'react-dom'
1
+ import { Component, useEffect, useRef, useState, type MouseEvent as ReactMouseEvent, type ReactNode } from 'react'
3
2
  import { useStore, CONFIG, LOCKED_SHELL, PUBLISHED, SOURCE_REVEALED, boardLabel, boardFrames, cap, humanize, fetchBoardNames, hydrateBoardPolicy, landingMode, playsAsSlides, type FrameEntry } from './store.ts'
3
+ import { BoardList } from './BoardList.tsx'
4
+ import { ContextMenu, copyToClipboard, framePath, useContextMenu, type MenuItem } from './ContextMenu.tsx'
4
5
  import { Tip } from './Tip.tsx'
5
6
  import { PKG, ROUTE } from '../const.ts'
6
7
  import { animateLayout, Canvas, canvasCtl } from './canvas/Canvas.tsx'
7
8
  import { frameByWindow } from './canvas/frame-registry.ts'
8
9
  import { enterFocus, enterPlay, enterSlides, playCtl, PlayOverlay } from './Play.tsx'
9
10
  import { bootHash, parseHash, writeHash } from './hash.ts'
10
- import { CardsIcon, CardsThreeIcon, CaretIcon, CheckIcon, ColumnsIcon, FrameRectIcon, ImagesSquareIcon, IntentGlyph, MoonIcon, PanelFilledIcon, PanelHollowIcon, ParallelogramDuoIcon, ParallelogramFillIcon, PencilSimpleIcon, PlayIcon, SignpostIcon, SlideFrameIcon, SunIcon, VariantsIcon, XIcon, deviceIcon } from './icons.tsx'
11
+ import { CaretIcon, CheckIcon, ColumnsIcon, FrameRectIcon, ImagesSquareIcon, IntentGlyph, MoonIcon, PanelFilledIcon, PanelHollowIcon, ParallelogramDuoIcon, ParallelogramFillIcon, PlayIcon, SignpostIcon, SlideFrameIcon, SunIcon, VariantsIcon, XIcon, deviceIcon } from './icons.tsx'
11
12
  import { CommentsController, revealThread } from './Comments.tsx'
12
13
  import { poweredByUrl } from '../../shared/utm.ts'
13
14
  import { avatarFallback, useComments } from './comments-store.ts'
@@ -17,60 +18,6 @@ const commentsStore = () => useComments.getState()
17
18
 
18
19
  let booted = false // survives Fast Refresh; see the boot effect
19
20
 
20
- /** Copy text to the clipboard, toasting the outcome. Success is confirmed out loud; a
21
- * blocked clipboard (no user gesture / permission) says so instead of failing silently. */
22
- function copyToClipboard(text: string, okMsg: string) {
23
- const { toast } = useStore.getState()
24
- navigator.clipboard.writeText(text).then(() => toast(okMsg), () => toast('copy blocked - click the canvas first'))
25
- }
26
-
27
- /** The address a frame copies - the same string from the sidebar right-click, the floating
28
- * toolbar, and ⇧P: the board it sits on, the frame id, and its file. */
29
- // stripped build: the file column would only show the opaque token, so the
30
- // copy keeps what is still honest - the board and the frame id
31
- const framePath = (board: string, f: { id: string; file: string }) =>
32
- SOURCE_REVEALED ? `board: ${board} · frame: ${f.id} (${f.file})` : `board: ${board} · frame: ${f.id}`
33
-
34
- type MenuItem = { label: string; icon: ReactNode; onClick: () => void }
35
- type MenuState = { x: number; y: number; items: MenuItem[] }
36
-
37
- /** A right-click menu for the sidebar. One instance lives in App; `open(e, items)` positions
38
- * it at the cursor, CLAMPED into the viewport. It closes on outside pointerdown, on pick, and
39
- * on Escape - the Escape listener runs in CAPTURE phase so it does not also trip App's global
40
- * keydown (which would clear the selection/laser underneath the menu). */
41
- function useContextMenu() {
42
- const [menu, setMenu] = useState<MenuState | null>(null)
43
- const open = (e: { preventDefault(): void; clientX: number; clientY: number }, items: MenuItem[]) => {
44
- e.preventDefault()
45
- const MENU_W = 184
46
- const h = items.length * 32 + 12
47
- setMenu({ x: Math.min(e.clientX, window.innerWidth - MENU_W - 8), y: Math.min(e.clientY, window.innerHeight - h - 8), items })
48
- }
49
- return { menu, open, close: () => setMenu(null) }
50
- }
51
-
52
- function ContextMenu({ menu, close }: { menu: MenuState | null; close: () => void }) {
53
- const ref = useRef<HTMLDivElement>(null)
54
- useEffect(() => {
55
- if (!menu) return
56
- const onDown = (e: PointerEvent) => { if (!ref.current?.contains(e.target as globalThis.Node)) close() }
57
- const onEsc = (e: KeyboardEvent) => { if (e.key === 'Escape') { e.stopPropagation(); e.preventDefault(); close() } }
58
- window.addEventListener('pointerdown', onDown)
59
- window.addEventListener('keydown', onEsc, true) // capture: beat App's bubble-phase Escape
60
- return () => { window.removeEventListener('pointerdown', onDown); window.removeEventListener('keydown', onEsc, true) }
61
- }, [menu])
62
- const app = document.querySelector('.sh-app')
63
- if (!menu || !app) return null
64
- return createPortal(
65
- <div className="sh-menu sh-ctxmenu" ref={ref} style={{ left: menu.x, top: menu.y }}>
66
- {menu.items.map((it) => (
67
- <button key={it.label} onClick={() => { it.onClick(); close() }}>{it.icon}<span>{it.label}</span></button>
68
- ))}
69
- </div>,
70
- app,
71
- )
72
- }
73
-
74
21
  /** One collapsible scene group in the sidebar. `held` marks a scene that contains a
75
22
  * selected frame - a quiet secondary wash so ancestry survives collapsing the group. */
76
23
  function SceneGroup({ name, count, held, onPick, onContextMenu, children }: { name: string; count: number; held: boolean; onPick?: () => void; onContextMenu?: (e: ReactMouseEvent) => void; children: ReactNode }) {
@@ -104,194 +51,6 @@ export class ShellBoundary extends Component<{ children: ReactNode }, { err: Err
104
51
  }
105
52
  }
106
53
 
107
- /** Boards live flat in the sidebar - always visible, one click to switch. The list
108
- * refreshes on mount, window focus, and a slow poll so agent-created board files
109
- * appear without a reload. Active board = accent icon + wash, same language as scenes. */
110
- const BOARD_NAME_RE = /^[a-z0-9][a-z0-9-]*$/
111
-
112
- function BoardList({ onMenu }: { onMenu: (e: { preventDefault(): void; clientX: number; clientY: number }, items: MenuItem[]) => void }) {
113
- const board = useStore((s) => s.board)
114
- const [names, setNames] = useState<string[]>(['all-scenes'])
115
- const [editing, setEditing] = useState<string | null>(null)
116
- const [drag, setDrag] = useState<string | null>(null) // board being dragged
117
- // insertion index in the CURATED list (0..len): one value per gap, so the divider lands
118
- // once between two boards - not twice (A's bottom-half and B's top-half were separate spots)
119
- const [dropIndex, setDropIndex] = useState<number | null>(null)
120
- // genRef invalidates every async setNames: a mutation bumps it, so a poll/fetch that
121
- // STARTED earlier can never clobber a fresh optimistic reorder or a just-renamed list.
122
- const genRef = useRef(0)
123
- const busyRef = useRef(0) // reorders in flight; while >0 polls hold off
124
- const commitBusy = useRef(false) // guards Enter+blur firing two renames
125
- const chainRef = useRef<Promise<unknown>>(Promise.resolve()) // serializes reorder POSTs
126
- // reorder runs on pointer events, NOT native drag-and-drop: native DnD hands the cursor to
127
- // the OS (arrow/move), so a grabbing hand can't persist. Owning the gesture lets us hold
128
- // the grabbing cursor for the whole drag via a body class.
129
- const gestureRef = useRef<{ pointerId: number; startX: number; startY: number; name: string; dragging: boolean; el: HTMLElement } | null>(null)
130
- const refresh = () => {
131
- if (busyRef.current > 0) return // a reorder is mid-flight; its optimistic order stands until it settles
132
- const gen = genRef.current
133
- fetchBoardNames().then((list) => { if (gen === genRef.current) setNames(list) }).catch(() => { /* keep last known */ })
134
- }
135
- useEffect(() => {
136
- refresh()
137
- const t = setInterval(refresh, 8000)
138
- window.addEventListener('focus', refresh)
139
- return () => { clearInterval(t); window.removeEventListener('focus', refresh) }
140
- // eslint-disable-next-line react-hooks/exhaustive-deps
141
- }, [])
142
- const pick = async (name: string) => {
143
- if (name === useStore.getState().board) return
144
- await useStore.getState().switchBoard(name)
145
- setTimeout(() => canvasCtl.fitAll(), 60)
146
- }
147
- const commit = async (old: string, raw: string) => {
148
- if (commitBusy.current) return // Enter already fired this; ignore the follow-up blur
149
- const next = raw.trim()
150
- if (!next || next === old) { setEditing(null); return }
151
- if (!BOARD_NAME_RE.test(next) || next.length > 64 || next === 'all-scenes' || names.includes(next)) {
152
- useStore.getState().toast('use a free name - lowercase letters, numbers and dashes'); return // stay editing
153
- }
154
- commitBusy.current = true
155
- try {
156
- const r = await useStore.getState().renameBoard(old, next)
157
- if (r.ok) { setEditing(null); genRef.current++; refresh() }
158
- else useStore.getState().toast(r.error ?? 'rename failed') // stay editing
159
- } finally { commitBusy.current = false }
160
- }
161
- // the INSERTION INDEX (in the curated list, 0..len) nearest the pointer - one value per
162
- // gap, so a single divider lands between two boards instead of one per row-half
163
- const indexAt = (x: number, y: number): number | null => {
164
- const el = (document.elementFromPoint(x, y) as HTMLElement | null)?.closest('[data-board-row]') as HTMLElement | null
165
- const name = el?.dataset.board
166
- if (!name) return null
167
- const curated = names.filter((n) => n !== 'all-scenes')
168
- if (name === 'all-scenes') return curated.length // over the pinned last row = end slot
169
- const ci = curated.indexOf(name)
170
- if (ci < 0) return null
171
- const r = el!.getBoundingClientRect()
172
- return y > r.top + r.height / 2 ? ci + 1 : ci
173
- }
174
- const resetPointer = () => {
175
- const g = gestureRef.current
176
- gestureRef.current = null
177
- if (g) { try { g.el.releasePointerCapture(g.pointerId) } catch { /* already released */ } }
178
- document.body.classList.remove('sh-board-dragging')
179
- setDrag(null)
180
- setDropIndex(null)
181
- }
182
- // move the dragged board to the insertion index and persist the new order optimistically
183
- const applyReorder = (dragName: string, dropIndex: number) => {
184
- if (dragName === 'all-scenes') return
185
- const curated = names.filter((n) => n !== 'all-scenes')
186
- const from = curated.indexOf(dragName)
187
- if (from < 0) return
188
- const to = dropIndex > from ? dropIndex - 1 : dropIndex // removing `from` shifts later indices left
189
- if (to === from) return // dropped in its own slot - no move
190
- curated.splice(from, 1)
191
- curated.splice(to, 0, dragName)
192
- const prev = names
193
- genRef.current++
194
- const gen = genRef.current
195
- busyRef.current++ // hold polls off until this settles
196
- setNames(names.includes('all-scenes') ? [...curated, 'all-scenes'] : curated)
197
- chainRef.current = chainRef.current.then(async () => {
198
- try {
199
- const ok = await useStore.getState().reorderBoards(curated)
200
- if (gen !== genRef.current) return // a newer drop superseded this one
201
- if (!ok) { setNames(prev); useStore.getState().toast('could not save order') }
202
- } catch { if (gen === genRef.current) { setNames(prev); useStore.getState().toast('could not save order') } }
203
- finally { busyRef.current-- }
204
- })
205
- }
206
- const onBoardPointerDown = (e: ReactPointerEvent<HTMLButtonElement>, n: string) => {
207
- if (e.button !== 0) return // left button only; right-click opens the menu
208
- const el = e.currentTarget
209
- try { el.setPointerCapture(e.pointerId) } catch { /* capture can fail on rapid input */ }
210
- gestureRef.current = { pointerId: e.pointerId, startX: e.clientX, startY: e.clientY, name: n, dragging: false, el }
211
- }
212
- const onBoardPointerMove = (e: ReactPointerEvent<HTMLButtonElement>) => {
213
- const g = gestureRef.current
214
- if (!g || g.pointerId !== e.pointerId) return
215
- if (!g.dragging) {
216
- if (Math.hypot(e.clientX - g.startX, e.clientY - g.startY) < 5) return // click vs drag threshold
217
- g.dragging = true
218
- setDrag(g.name)
219
- document.body.classList.add('sh-board-dragging') // holds the grabbing cursor for the whole drag
220
- }
221
- const idx = indexAt(e.clientX, e.clientY)
222
- // hide the seam at the dragged board's OWN slot (index from or from+1 = no move)
223
- const from = names.filter((nm) => nm !== 'all-scenes').indexOf(g.name)
224
- setDropIndex(idx === null || idx === from || idx === from + 1 ? null : idx)
225
- }
226
- const onBoardPointerUp = (e: ReactPointerEvent<HTMLButtonElement>, n: string) => {
227
- const g = gestureRef.current
228
- if (!g || g.pointerId !== e.pointerId) return
229
- const dragged = g.dragging
230
- const idx = dragged ? indexAt(e.clientX, e.clientY) : null
231
- resetPointer()
232
- if (dragged) { if (idx !== null) applyReorder(n, idx) }
233
- else void pick(n) // a tap switches boards (the trailing mouse click is ignored)
234
- }
235
- // cancel a drag on Escape (capture phase, so the app's global Escape never sees it) or focus loss
236
- useEffect(() => {
237
- const onEsc = (e: KeyboardEvent) => { if (e.key === 'Escape' && gestureRef.current?.dragging) { e.preventDefault(); e.stopPropagation(); resetPointer() } }
238
- const onBlur = () => { if (gestureRef.current) resetPointer() }
239
- window.addEventListener('keydown', onEsc, true)
240
- window.addEventListener('blur', onBlur)
241
- return () => { window.removeEventListener('keydown', onEsc, true); window.removeEventListener('blur', onBlur); resetPointer() }
242
- // eslint-disable-next-line react-hooks/exhaustive-deps
243
- }, [])
244
- const menuFor = (n: string): MenuItem[] => [
245
- { label: 'Copy path', icon: <SignpostIcon size={15} />, onClick: () => copyToClipboard(`board: ${n}`, 'path copied') },
246
- ...(!PUBLISHED && n !== 'all-scenes' ? [{ label: 'Rename', icon: <PencilSimpleIcon size={15} />, onClick: () => setEditing(n) }] : []),
247
- ]
248
- const curated = names.filter((nm) => nm !== 'all-scenes')
249
- return (
250
- <>
251
- {names.map((n) => {
252
- if (editing === n) return (
253
- <div key={n} className="it board editing">
254
- {n === 'all-scenes' ? <CardsThreeIcon size={14} /> : <CardsIcon size={14} />}
255
- <input autoFocus defaultValue={n} spellCheck={false}
256
- onFocus={(e) => e.currentTarget.select()}
257
- onKeyDown={(e) => {
258
- if (e.key === 'Enter') { e.preventDefault(); void commit(n, e.currentTarget.value) }
259
- else if (e.key === 'Escape') { e.preventDefault(); setEditing(null) }
260
- }}
261
- onBlur={(e) => { if (editing === n) void commit(n, e.currentTarget.value) }} />
262
- </div>
263
- )
264
- const canDrag = !PUBLISHED && n !== 'all-scenes'
265
- // ONE seam per gap, from the insertion index: drop-before the row AT that index, or
266
- // drop-after the last curated row when the index is the end. Overlay (::after), so no
267
- // layout shift. Never on the row being dragged.
268
- const ci = curated.indexOf(n)
269
- const dropCls = drag == null || dropIndex == null || n === drag ? ''
270
- : ci === dropIndex ? ' drop-before'
271
- : dropIndex === curated.length && ci === curated.length - 1 ? ' drop-after'
272
- : ''
273
- return (
274
- <button key={n} data-board-row data-board={n} data-reorderable={canDrag || undefined}
275
- className={`it board${n === board ? ' cur' : ''}${drag === n ? ' dragging' : ''}${dropCls}`}
276
- // draggable rows switch on the pointer tap (onPointerUp), so their trailing mouse
277
- // click (detail >= 1) must be ignored to avoid a double switch; keyboard clicks
278
- // (detail === 0) and non-draggable rows (all-scenes) switch here as normal
279
- onClick={(e) => { if (!canDrag || e.detail === 0) void pick(n) }}
280
- onContextMenu={(e) => onMenu(e, menuFor(n))}
281
- onPointerDown={canDrag ? (e) => onBoardPointerDown(e, n) : undefined}
282
- onPointerMove={canDrag ? onBoardPointerMove : undefined}
283
- onPointerUp={canDrag ? (e) => onBoardPointerUp(e, n) : undefined}
284
- onPointerCancel={canDrag ? () => resetPointer() : undefined}
285
- onLostPointerCapture={canDrag ? (e) => { if (gestureRef.current?.pointerId === e.pointerId) resetPointer() } : undefined}>
286
- {n === 'all-scenes' ? <CardsThreeIcon size={14} /> : <CardsIcon size={14} />}
287
- <span>{boardLabel(n)}</span>
288
- </button>
289
- )
290
- })}
291
- </>
292
- )
293
- }
294
-
295
54
  /** Selection toolbar: screen-space overlay above the selected frame - constant size at any
296
55
  * zoom. Position derives from --sh-s/tx/ty (written per transform frame in Canvas), so
297
56
  * pan/zoom tracking is pure CSS with zero React re-renders. */
@@ -1017,7 +776,6 @@ export function App() {
1017
776
  <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>
1018
777
  </div>
1019
778
  <div className="sh-panel-scroll">
1020
- <div className="hd">Boards</div>
1021
779
  <BoardList onMenu={cm.open} />
1022
780
  <div className="hd" style={{ marginTop: 10 }}>Scenes</div>
1023
781
  {scenes.map((sc) => (