@marver-design/marver 0.16.0 → 0.17.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 +51 -0
- package/README.md +4 -4
- package/dist/{boards-BdG1TJwU.mjs → boards-BmxcT3Lc.mjs} +32 -9
- package/dist/{boards-6hKVW42a.mjs → boards-PuVzw5Wp.mjs} +9 -4
- package/dist/{build-Ct0iMoSi.mjs → build-D_g53Bp2.mjs} +12 -8
- package/dist/cli.mjs +27 -16
- package/dist/{daemon-Cb4JjpgL.mjs → daemon-Bbh_jmui.mjs} +1 -1
- package/dist/{dev-CdcIuhZJ.mjs → dev-LnIISva5.mjs} +9 -4
- package/dist/{init-BQIpIpIv.mjs → init-B7YhcN2o.mjs} +1 -1
- package/dist/{manifest-CpbsqQ_v.mjs → manifest-CaslQIAO.mjs} +112 -15
- package/dist/{plugin-BXyezwfN.mjs → plugin-D2msH1cj.mjs} +223 -43
- package/dist/{poster-DOY7pax8.mjs → poster-BEjUcQP3.mjs} +13 -5
- package/dist/shot-BzQ0PXKH.mjs +86 -0
- package/dist/shot-DlmTO8AF.mjs +926 -0
- package/docs/live-jam.md +20 -8
- package/docs/publish.md +2 -2
- package/package.json +1 -1
- package/src/client/shell/App.tsx +42 -14
- package/src/client/shell/BoardList.tsx +88 -58
- package/src/client/shell/LockedApp.tsx +2 -1
- package/src/client/shell/Play.tsx +1 -0
- package/src/client/shell/labels.ts +4 -4
- package/src/client/shell/store.ts +76 -29
- package/src/client/shell/styles.css +5 -1
- package/src/shared/board-tree.ts +104 -46
- package/templates/AGENTS-embedded.md +13 -3
- package/templates/AGENTS-studio.md +13 -3
- package/templates/instructions/boards.md +27 -9
- package/templates/instructions/discover.md +13 -1
- package/templates/instructions/jam.md +15 -3
- package/templates/instructions/shape.md +2 -1
- package/dist/shot-By1AItpD.mjs +0 -30
- package/dist/shot-CwmHO5T4.mjs +0 -528
package/docs/live-jam.md
CHANGED
|
@@ -89,19 +89,31 @@ resolves a thread; you do that after reviewing.
|
|
|
89
89
|
|
|
90
90
|
The missing sense that no-shell used to cost - "does my frame actually RENDER?" - is a
|
|
91
91
|
server capability instead, rendered in the machine's own headless Chrome (no bundled
|
|
92
|
-
browser
|
|
92
|
+
browser; CDP over Chrome's debugging pipe, so the browser lives exactly as long as the
|
|
93
|
+
shot) and written as a PNG under
|
|
93
94
|
`design/.local/shots/`. Two transports reach it, because the no-shell jail rules out the
|
|
94
95
|
obvious one:
|
|
95
96
|
|
|
96
97
|
- **The file-drop inbox** (works for every agent, including Claude Code, which has no shell
|
|
97
98
|
and whose WebFetch refuses localhost). The agent writes
|
|
98
|
-
`design/.local/shots/<slug>.request.json` with `{"frame":"<id>","theme":"<t>"}
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
99
|
+
`design/.local/shots/<slug>.request.json` with `{"frame":"<id>","theme":"<t>"}` - or
|
|
100
|
+
`{"scene":"<name>"}`, `{"frames":[...]}`, `{"all":true}` for a batch; the dev server renders
|
|
101
|
+
and writes `<slug>.result.json` with the PNG path or an error (a batch: `results`, one entry
|
|
102
|
+
per frame), which the agent Reads.
|
|
103
|
+
- **`npx marver shot <frame ...> | --scene <name> | --all [--scale 1-4] [--json]`** /
|
|
104
|
+
`GET /api/shot?frame=<id>&theme=<t>&scale=<n>` / `POST /api/shots {frames|scene|all, theme,
|
|
105
|
+
scale}` for humans and shell-ful agents - the same renderer, one line. A batch is ONE
|
|
106
|
+
operation: one headless browser, `MARVER_SHOT_CONCURRENCY` frames at a time inside it
|
|
107
|
+
(default up to 6, sized to the machine), so a scene costs about what a frame does. Default
|
|
108
|
+
2x; `--scale 4` for a print-quality still (a slide comes back 5120×2880). A frame too tall
|
|
109
|
+
for the asked scale steps down and says so in `note`; the file name carries the scale
|
|
110
|
+
actually used (`…@4x.png`). A frame that ran out of settle budget still ships, marked
|
|
111
|
+
`unsettled` with a note.
|
|
112
|
+
- **The browser's life.** The headless Chrome exists only while an operation runs - it is
|
|
113
|
+
driven over Chrome's own debugging pipe, so it dies with the dev server however the server
|
|
114
|
+
dies (Ctrl-C, a closed terminal, `kill -9`), and none is kept between shots. `MARVER_CHROME`
|
|
115
|
+
picks the binary; pointing it at a Chrome for Testing or Chromium build makes the shot
|
|
116
|
+
browser a different app from your own, which some people prefer on macOS.
|
|
105
117
|
The canvas's **copy as image** (`i` / `⇧i`, the images-square toolbar button) is this same
|
|
106
118
|
renderer with `format=png`, so what a designer pastes and what an agent shoots is one picture.
|
|
107
119
|
|
package/docs/publish.md
CHANGED
|
@@ -33,8 +33,8 @@ opens the same view on the published site, as long as its board and frame shippe
|
|
|
33
33
|
directory in full (the `--boards` filter covers frames, not public assets). A flow you
|
|
34
34
|
publish must have all its `data-goto` targets on a published board. Sidebar folders ship
|
|
35
35
|
only around published boards (a folder with nothing published never reaches the bundle),
|
|
36
|
-
and `description`s ship only for published things - the project's, the
|
|
37
|
-
their folders', scenes' and frames'.
|
|
36
|
+
and `title`s and `description`s ship only for published things - the project's, the
|
|
37
|
+
published boards', their folders', scenes' and frames'.
|
|
38
38
|
|
|
39
39
|
## Who can open your canvas
|
|
40
40
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@marver-design/marver",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.0",
|
|
4
4
|
"description": "The agent-native design canvas. A design/ folder, one command, a canvas of live frames built from your repo's real components - comment @marver and your own coding agent does the work. The tool ships no AI.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"private": false,
|
package/src/client/shell/App.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Component, useEffect, useRef, useState, type MouseEvent as ReactMouseEvent, type ReactNode } from 'react'
|
|
2
|
-
import { useStore, CONFIG, LOCKED_SHELL, PUBLISHED, SOURCE_REVEALED, boardLabel, boardFrames, cap, humanize, fetchBoardNames, hydrateBoardPolicy, landingMode, playsAsSlides, type FrameEntry } from './store.ts'
|
|
2
|
+
import { useStore, CONFIG, LOCKED_SHELL, PUBLISHED, SOURCE_REVEALED, boardLabel, boardFrames, cap, humanize, fetchBoardNames, hydrateBoardPolicy, landingMode, playsAsSlides, sceneLabel, type FrameEntry } from './store.ts'
|
|
3
3
|
import { BoardList } from './BoardList.tsx'
|
|
4
4
|
import { ContextMenu, copyToClipboard, framePath, useContextMenu, type MenuItem } from './ContextMenu.tsx'
|
|
5
5
|
import { Tip } from './Tip.tsx'
|
|
@@ -8,7 +8,8 @@ import { animateLayout, Canvas, canvasCtl } from './canvas/Canvas.tsx'
|
|
|
8
8
|
import { frameByWindow } from './canvas/frame-registry.ts'
|
|
9
9
|
import { enterFocus, enterPlay, enterSlides, playCtl, PlayOverlay } from './Play.tsx'
|
|
10
10
|
import { bootHash, parseHash, writeHash } from './hash.ts'
|
|
11
|
-
import { CaretIcon, CheckIcon, ColumnsIcon, FrameRectIcon, ImagesSquareIcon, IntentGlyph, MoonIcon, PanelFilledIcon, PanelHollowIcon, ParallelogramDuoIcon, ParallelogramFillIcon, PlayIcon, SignpostIcon, SlideFrameIcon, SunIcon, VariantsIcon, XIcon, deviceIcon } from './icons.tsx'
|
|
11
|
+
import { CaretIcon, CheckIcon, ColumnsIcon, FrameRectIcon, ImagesSquareIcon, IntentGlyph, MoonIcon, PanelFilledIcon, PanelHollowIcon, ParallelogramDuoIcon, ParallelogramFillIcon, PencilSimpleIcon, PlayIcon, SignpostIcon, SlideFrameIcon, SunIcon, VariantsIcon, XIcon, deviceIcon } from './icons.tsx'
|
|
12
|
+
import { humanize as slugLabel, readTitle } from '../../shared/board-tree.ts'
|
|
12
13
|
import { CommentsController, revealThread } from './Comments.tsx'
|
|
13
14
|
import { poweredByUrl } from '../../shared/utm.ts'
|
|
14
15
|
import { avatarFallback, useComments } from './comments-store.ts'
|
|
@@ -19,17 +20,31 @@ const commentsStore = () => useComments.getState()
|
|
|
19
20
|
let booted = false // survives Fast Refresh; see the boot effect
|
|
20
21
|
|
|
21
22
|
/** One collapsible scene group in the sidebar. `held` marks a scene that contains a
|
|
22
|
-
* selected frame - a quiet secondary wash so ancestry survives collapsing the group.
|
|
23
|
-
|
|
23
|
+
* selected frame - a quiet secondary wash so ancestry survives collapsing the group.
|
|
24
|
+
* `renaming` swaps the label for an input: the scene's TITLE (its brief's front matter) -
|
|
25
|
+
* the directory never moves from here. */
|
|
26
|
+
function SceneGroup({ name, label, count, held, renaming, onRename, onPick, onContextMenu, children }: { name: string; label: string; count: number; held: boolean; renaming?: boolean; onRename?: (title: string | null) => void; onPick?: () => void; onContextMenu?: (e: ReactMouseEvent) => void; children: ReactNode }) {
|
|
24
27
|
const [open, setOpen] = useState(true)
|
|
28
|
+
const done = useRef(false) // Enter then blur: one commit
|
|
29
|
+
if (renaming) { done.current = false }
|
|
30
|
+
const finish = (v: string | null) => { if (done.current) return; done.current = true; onRename?.(v) }
|
|
25
31
|
return (
|
|
26
32
|
<div>
|
|
27
|
-
|
|
28
|
-
<
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
+
{renaming ? (
|
|
34
|
+
<div className="it editing">
|
|
35
|
+
<CaretIcon size={11} className="tw" style={{ transform: open ? undefined : 'rotate(-90deg)' }} />
|
|
36
|
+
<input autoFocus defaultValue={label} spellCheck={false} onFocus={(e) => e.currentTarget.select()}
|
|
37
|
+
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); finish(e.currentTarget.value) } else if (e.key === 'Escape') { e.preventDefault(); finish(null) } }}
|
|
38
|
+
onBlur={(e) => finish(e.currentTarget.value)} />
|
|
39
|
+
</div>
|
|
40
|
+
) : (
|
|
41
|
+
<button className={`it${held ? ' held' : ''}`} onClick={() => setOpen(!open)} onContextMenu={onContextMenu}>
|
|
42
|
+
<CaretIcon size={11} className="tw" style={{ transform: open ? undefined : 'rotate(-90deg)' }} />
|
|
43
|
+
{/* the NAME selects every frame in the scene; the caret/row still collapses */}
|
|
44
|
+
<span onClick={(e) => { if (!onPick) return; e.stopPropagation(); onPick() }}>{label || '(root)'}</span>
|
|
45
|
+
<small>{count}</small>
|
|
46
|
+
</button>
|
|
47
|
+
)}
|
|
33
48
|
{open && children}
|
|
34
49
|
</div>
|
|
35
50
|
)
|
|
@@ -325,6 +340,7 @@ export function App() {
|
|
|
325
340
|
const { boot, applyManifest, togglePanel, select, setInteract, runTidy, toast, spawn } = useStore.getState()
|
|
326
341
|
const [pillOpen, setPillOpen] = useState(true)
|
|
327
342
|
const cm = useContextMenu() // shared sidebar right-click menu (boards + scenes)
|
|
343
|
+
const [sceneNaming, setSceneNaming] = useState<string | null>(null) // the scene whose title is being edited inline
|
|
328
344
|
|
|
329
345
|
// boot honors the deep link: board before load, play mode after it.
|
|
330
346
|
// Selection + camera intent are restored by the Canvas boot effect. The module-level
|
|
@@ -431,9 +447,10 @@ export function App() {
|
|
|
431
447
|
return () => { window.removeEventListener('popstate', onPop); window.removeEventListener('hashchange', onPop) }
|
|
432
448
|
}, [])
|
|
433
449
|
|
|
434
|
-
// page title follows the open board
|
|
450
|
+
// page title follows the open board (and its title, when one is set)
|
|
435
451
|
const board = useStore((s) => s.board)
|
|
436
|
-
|
|
452
|
+
const titles = useStore((s) => s.boardTitles)
|
|
453
|
+
useEffect(() => { document.title = board ? `${boardLabel(board)} - Marver` : 'Marver' }, [board, titles])
|
|
437
454
|
|
|
438
455
|
// favicon follows the mode: blue pack in design mode, purple pack in interact.
|
|
439
456
|
// The links are rebuilt (not toggled) so the set stays deterministic; the .ico is
|
|
@@ -484,6 +501,8 @@ export function App() {
|
|
|
484
501
|
useEffect(() => {
|
|
485
502
|
if (!import.meta.hot) return
|
|
486
503
|
import.meta.hot.on('sh:manifest', (m: any) => applyManifest(m))
|
|
504
|
+
// a brief's title or description changed with the frames intact: relabel, never re-key
|
|
505
|
+
import.meta.hot.on('sh:scenes', (m: any) => useStore.getState().setScenes(m?.scenes))
|
|
487
506
|
// Live Jam presence: the daemon broadcasts the set of frames Marver is editing.
|
|
488
507
|
// Camera-safe by construction - this only toggles a glow class, never moves the view.
|
|
489
508
|
import.meta.hot.on('sh:jam-activity', (m: any) => useStore.getState().setWorking(Array.isArray(m?.frames) ? m.frames.filter((x: unknown) => typeof x === 'string') : []))
|
|
@@ -779,15 +798,24 @@ export function App() {
|
|
|
779
798
|
<BoardList onMenu={cm.open} />
|
|
780
799
|
<div className="hd" style={{ marginTop: 10 }}>Scenes</div>
|
|
781
800
|
{scenes.map((sc) => (
|
|
782
|
-
<SceneGroup key={sc.name} name={sc.name} count={sc.frames}
|
|
801
|
+
<SceneGroup key={sc.name} name={sc.name} label={sceneLabel(sc.name)} count={sc.frames}
|
|
783
802
|
held={frames.some((f) => f.scene === sc.name && selFrames.has(f.id))}
|
|
803
|
+
renaming={sceneNaming === sc.name}
|
|
804
|
+
onRename={(raw) => {
|
|
805
|
+
setSceneNaming(null)
|
|
806
|
+
const title = raw === null ? undefined : readTitle(raw)
|
|
807
|
+
if (!title || title === sceneLabel(sc.name)) return // Escape, empty, or unchanged = never mind
|
|
808
|
+
if (scenes.some((o) => o.name !== sc.name && sceneLabel(o.name) === title)) { toast(`a scene called "${title}" already exists`); return }
|
|
809
|
+
// typing what the directory reads as anyway clears the title - the brief stays clean
|
|
810
|
+
useStore.getState().renameScene(sc.name, title === slugLabel(sc.name) ? '' : title).then((r) => { if (!r.ok) toast(r.error ?? 'rename failed') })
|
|
811
|
+
}}
|
|
784
812
|
onContextMenu={(e) => cm.open(e, [{
|
|
785
813
|
label: 'Copy path',
|
|
786
814
|
icon: <SignpostIcon size={15} />,
|
|
787
815
|
onClick: () => copyToClipboard(sc.name
|
|
788
816
|
? `board: ${useStore.getState().board} · scene: ${sc.name} (design/scenes/${sc.name}/)`
|
|
789
817
|
: `board: ${useStore.getState().board} · scene: (root)`, 'path copied'),
|
|
790
|
-
}])}
|
|
818
|
+
}, ...(!PUBLISHED && sc.name ? [{ label: 'Rename', icon: <PencilSimpleIcon size={15} />, onClick: () => setSceneNaming(sc.name) }] : [])])}
|
|
791
819
|
onPick={() => {
|
|
792
820
|
const keys = nodes.filter((n) => frames.some((f) => f.scene === sc.name && f.id === n.frame) && !n.missing).map((n) => n.key)
|
|
793
821
|
if (!keys.length) return
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { useEffect, useRef, useState, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from 'react'
|
|
2
|
-
import { useStore, HAS_ALL_SCENES, PUBLISHED,
|
|
2
|
+
import { useStore, HAS_ALL_SCENES, PUBLISHED, fetchBoardTree, rememberTitles, type TreeBase } from './store.ts'
|
|
3
3
|
import { canvasCtl } from './canvas/Canvas.tsx'
|
|
4
4
|
import { Tip } from './Tip.tsx'
|
|
5
5
|
import { copyToClipboard, type MenuItem, type MenuOpener } from './ContextMenu.tsx'
|
|
6
6
|
import { ArrowLineUpIcon, CardsIcon, CardsThreeIcon, FolderIcon, FolderMinusIcon, FolderOpenIcon, FolderPlusIcon, PencilSimpleIcon, SignpostIcon } from './icons.tsx'
|
|
7
7
|
import {
|
|
8
|
-
applyDrop, boardsIn, createFolder, deleteFolder, folderOf, foldersIn,
|
|
9
|
-
type Drag, type Drop, type TreeItem,
|
|
8
|
+
applyDrop, boardsIn, createFolder, deleteFolder, folderIn, folderOf, foldersIn, humanize, isOwnSlot, labelOf, moveBoard, newFolderSlot, readTitle, resolveDrop, retitleFolder, rootIndex, slugFor,
|
|
9
|
+
type Drag, type Drop, type Folder, type Row, type TreeItem,
|
|
10
10
|
} from '../../shared/board-tree.ts'
|
|
11
11
|
|
|
12
12
|
/**
|
|
@@ -21,7 +21,6 @@ import {
|
|
|
21
21
|
|
|
22
22
|
const CLOSED_KEY = 'mv-folders-closed' // collapsed folders are a viewer preference, never data
|
|
23
23
|
const readClosed = (): Record<string, true> => { try { return JSON.parse(localStorage.getItem(CLOSED_KEY) ?? '{}') } catch { return {} } }
|
|
24
|
-
const INDENT = 28 // px a board row indents inside a folder; left of it is the root gutter
|
|
25
24
|
|
|
26
25
|
/** The inline input: renaming a board or a folder, or naming a NEW folder that does not exist
|
|
27
26
|
* yet - drawn at root `index`, optionally with `board` already inside it. */
|
|
@@ -31,6 +30,8 @@ type Intent = (tree: TreeItem[]) => TreeItem[] | null
|
|
|
31
30
|
|
|
32
31
|
export function BoardList({ onMenu }: { onMenu: MenuOpener }) {
|
|
33
32
|
const board = useStore((s) => s.board)
|
|
33
|
+
const titles = useStore((s) => s.boardTitles) // board slug → title, off the last tree read
|
|
34
|
+
const label = (n: string) => labelOf(n, titles[n]) // a board's label; a folder's is labelOf(name, item.title)
|
|
34
35
|
const [tree, setTree] = useState<TreeItem[]>([])
|
|
35
36
|
const [naming, setNaming] = useState<Naming | null>(null)
|
|
36
37
|
const [closed, setClosed] = useState<Record<string, true>>(readClosed)
|
|
@@ -50,7 +51,7 @@ export function BoardList({ onMenu }: { onMenu: MenuOpener }) {
|
|
|
50
51
|
// drag runs on pointer events, NOT native drag-and-drop: native DnD hands the cursor to
|
|
51
52
|
// the OS (arrow/move), so a grabbing hand can't persist. Owning the gesture lets us hold
|
|
52
53
|
// the grabbing cursor for the whole drag via a body class.
|
|
53
|
-
const gestureRef = useRef<{ pointerId: number; startX: number; startY: number; item: Drag; dragging: boolean; el: HTMLElement } | null>(null)
|
|
54
|
+
const gestureRef = useRef<{ pointerId: number; startX: number; startY: number; item: Drag; dragging: boolean; el: HTMLElement; x: number; y: number } | null>(null)
|
|
54
55
|
const treeRef = useRef(tree)
|
|
55
56
|
treeRef.current = tree
|
|
56
57
|
const namingRef = useRef(naming) // commit reads the LIVE naming state, never a stale closure (Escape, then a late blur)
|
|
@@ -70,6 +71,7 @@ export function BoardList({ onMenu }: { onMenu: MenuOpener }) {
|
|
|
70
71
|
if (seq !== loadSeq.current) return false // an older read landing late never overwrites a newer one
|
|
71
72
|
confirmedRef.current = snap.tree
|
|
72
73
|
baseRef.current = snap.base
|
|
74
|
+
rememberTitles(snap.titles) // the labels follow the same latest-wins rule
|
|
73
75
|
lastErr.current = ''
|
|
74
76
|
show()
|
|
75
77
|
return true
|
|
@@ -156,92 +158,117 @@ export function BoardList({ onMenu }: { onMenu: MenuOpener }) {
|
|
|
156
158
|
}
|
|
157
159
|
}
|
|
158
160
|
|
|
159
|
-
// ---- inline naming (
|
|
161
|
+
// ---- inline naming (retitle a board, retitle a folder, name a new folder) ----
|
|
162
|
+
// What the human types is the TITLE - any casing, punctuation, emoji - what the row shows.
|
|
163
|
+
// The slug (the board's file name, the folder's key on its boards and in the registry) is
|
|
164
|
+
// the object's identity: agents, publish.json, URLs and comment threads hold it, so a rename
|
|
165
|
+
// never moves it. A new folder mints its slug from the title once. Typing exactly what the
|
|
166
|
+
// slug reads as anyway ("Research" for `research`) clears the title - the file stays clean.
|
|
167
|
+
// An empty entry, or the label unchanged, means never mind.
|
|
160
168
|
const commit = async (raw: string) => {
|
|
161
169
|
const n = namingRef.current
|
|
162
170
|
if (!n || commitBusy.current) return // Enter already fired this (or Escape cancelled); ignore the follow-up blur
|
|
163
171
|
commitBusy.current = true
|
|
164
172
|
try {
|
|
173
|
+
const title = readTitle(raw)
|
|
174
|
+
if (!title) { setNaming(null); return }
|
|
175
|
+
// two rows reading alike would be a trap: a name another board (folder) already shows stays editing
|
|
176
|
+
const taken = (kind: 'board' | 'folder') => useStore.getState().toast(`a ${kind} called "${title}" already exists`)
|
|
177
|
+
const stored = (slug: string) => (title === humanize(slug) ? '' : title)
|
|
165
178
|
if (n.kind === 'board') {
|
|
166
|
-
|
|
167
|
-
if (
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
else useStore.getState().toast(r.error ?? 'rename failed') // stay editing
|
|
179
|
+
if (title === label(n.name)) { setNaming(null); return }
|
|
180
|
+
if (boardsIn(tree).some((b) => b !== n.name && label(b) === title) || (HAS_ALL_SCENES && label('all-scenes') === title)) { taken('board'); return }
|
|
181
|
+
let r = await useStore.getState().renameBoard(n.name, stored(n.name), baseRef.current.boards[n.name])
|
|
182
|
+
if (!r.ok && r.stale && await load()) r = await useStore.getState().renameBoard(n.name, stored(n.name), baseRef.current.boards[n.name]) // the file moved on (a drag just before, an agent): re-read, once more
|
|
183
|
+
if (!r.ok) { useStore.getState().toast(r.error ?? 'rename failed'); return } // stay editing
|
|
184
|
+
setNaming(null)
|
|
185
|
+
refresh()
|
|
174
186
|
return
|
|
175
187
|
}
|
|
176
|
-
|
|
177
|
-
// an empty entry means never mind
|
|
178
|
-
if (!raw.trim() || (n.kind === 'folder' && slugify(raw) === n.name)) { setNaming(null); return }
|
|
179
|
-
const slug = slugify(raw)
|
|
180
|
-
if (!slug) { useStore.getState().toast('use letters, numbers and dashes'); return }
|
|
181
|
-
if (foldersIn(tree).includes(slug)) { useStore.getState().toast(`a folder named "${slug}" already exists`); return }
|
|
182
|
-
setNaming(null)
|
|
188
|
+
const folderLabels = tree.filter((it): it is Folder => it.kind === 'folder' && (n.kind !== 'folder' || it.name !== n.name)).map((it) => labelOf(it.name, it.title))
|
|
183
189
|
if (n.kind === 'folder') {
|
|
184
|
-
const
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
+
const current = folderIn(tree, n.name)
|
|
191
|
+
if (!current || title === labelOf(n.name, current.title)) { setNaming(null); return }
|
|
192
|
+
if (folderLabels.includes(title)) { taken('folder'); return }
|
|
193
|
+
setNaming(null)
|
|
194
|
+
mutate((t) => retitleFolder(t, n.name, stored(n.name)))
|
|
195
|
+
return
|
|
190
196
|
}
|
|
197
|
+
if (n.kind !== 'new') return
|
|
198
|
+
if (folderLabels.includes(title)) { taken('folder'); return }
|
|
199
|
+
const { index, board: withBoard } = n
|
|
200
|
+
const slug = slugFor(title, foldersIn(tree)) // "Old stuff" → old-stuff (-2 past a namesake); "🚀" alone → folder
|
|
201
|
+
setNaming(null)
|
|
202
|
+
setOpen(slug, true) // a new folder opens, whatever an old namesake left behind
|
|
203
|
+
if (!mutate((t) => createFolder(t, slug, index, withBoard, stored(slug) || undefined))) useStore.getState().toast('that board is gone - nothing changed')
|
|
191
204
|
} finally { commitBusy.current = false }
|
|
192
205
|
}
|
|
193
206
|
|
|
194
207
|
// ---- drag and drop: one pointer gesture for board rows and folder rows ----
|
|
195
|
-
/** The
|
|
196
|
-
|
|
197
|
-
const
|
|
198
|
-
const el = (document.elementFromPoint(x, y) as HTMLElement | null)?.closest('[data-board-row],[data-folder-row]') as HTMLElement | null
|
|
199
|
-
if (!el) return null
|
|
208
|
+
/** The rows as rendered, measured - what the pure resolver reads the pointer against. */
|
|
209
|
+
const rootRef = useRef<HTMLDivElement>(null)
|
|
210
|
+
const measure = (): Row[] => Array.from(rootRef.current?.querySelectorAll<HTMLElement>('[data-board-row],[data-folder-row]') ?? []).map((el) => {
|
|
200
211
|
const r = el.getBoundingClientRect()
|
|
201
|
-
const
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
212
|
+
const folder = el.hasAttribute('data-folder-row')
|
|
213
|
+
return { kind: folder ? 'folder' : 'board', name: el.dataset.folderRow ?? el.dataset.board ?? '', parent: folder ? null : (el.dataset.folder ?? null), open: el.dataset.open === '1', top: r.top, bottom: r.bottom, left: r.left }
|
|
214
|
+
})
|
|
215
|
+
/** The drop target for the pointer at (x, y), or null: outside the panel (a release there
|
|
216
|
+
* cancels), or a slot that would change nothing. Inside the panel there is always one - the
|
|
217
|
+
* ends clamp - so the seam on screen is exactly where a release lands. */
|
|
218
|
+
const dropAt = (x: number, y: number, d: Drag): Drop | null => {
|
|
219
|
+
const panel = rootRef.current?.closest('.sh-panel')?.getBoundingClientRect()
|
|
220
|
+
if (!panel || x < panel.left || x >= panel.right || y < panel.top || y >= panel.bottom) return null
|
|
221
|
+
const target = resolveDrop(treeRef.current, d, measure(), x, y)
|
|
210
222
|
return target && !isOwnSlot(treeRef.current, d, target) ? target : null
|
|
211
223
|
}
|
|
224
|
+
const dropRef = useRef<Drop | null>(null) // the target on screen; the release applies THIS, never a recomputation
|
|
225
|
+
const showDrop = (t: Drop | null) => { dropRef.current = t; setDrop(t) }
|
|
212
226
|
const resetPointer = () => {
|
|
213
227
|
const g = gestureRef.current
|
|
214
228
|
gestureRef.current = null
|
|
215
229
|
if (g) { try { g.el.releasePointerCapture(g.pointerId) } catch { /* already released */ } }
|
|
216
230
|
document.body.classList.remove('sh-board-dragging')
|
|
217
231
|
setDrag(null)
|
|
218
|
-
|
|
232
|
+
showDrop(null)
|
|
219
233
|
}
|
|
220
234
|
const onPointerDown = (e: ReactPointerEvent<HTMLButtonElement>, item: Drag) => {
|
|
221
235
|
if (e.button !== 0) return // left button only; right-click opens the menu
|
|
222
236
|
const el = e.currentTarget
|
|
223
237
|
try { el.setPointerCapture(e.pointerId) } catch { /* capture can fail on rapid input */ }
|
|
224
|
-
gestureRef.current = { pointerId: e.pointerId, startX: e.clientX, startY: e.clientY, item, dragging: false, el }
|
|
238
|
+
gestureRef.current = { pointerId: e.pointerId, startX: e.clientX, startY: e.clientY, item, dragging: false, el, x: e.clientX, y: e.clientY }
|
|
225
239
|
}
|
|
226
240
|
const onPointerMove = (e: ReactPointerEvent<HTMLButtonElement>) => {
|
|
227
241
|
const g = gestureRef.current
|
|
228
242
|
if (!g || g.pointerId !== e.pointerId) return
|
|
243
|
+
g.x = e.clientX; g.y = e.clientY
|
|
229
244
|
if (!g.dragging) {
|
|
230
245
|
if (Math.hypot(e.clientX - g.startX, e.clientY - g.startY) < 5) return // click vs drag threshold
|
|
231
246
|
g.dragging = true
|
|
232
247
|
setDrag(g.item)
|
|
233
248
|
document.body.classList.add('sh-board-dragging') // holds the grabbing cursor for the whole drag
|
|
234
249
|
}
|
|
235
|
-
|
|
250
|
+
showDrop(dropAt(e.clientX, e.clientY, g.item))
|
|
236
251
|
}
|
|
252
|
+
// the tree changed under a drag in progress (a poll, an agent's write): the seam is re-read
|
|
253
|
+
// against the rows as they are NOW, from where the pointer is - never a slot that no longer means it
|
|
254
|
+
useEffect(() => {
|
|
255
|
+
const g = gestureRef.current
|
|
256
|
+
if (g?.dragging) showDrop(dropAt(g.x, g.y, g.item))
|
|
257
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
258
|
+
}, [tree])
|
|
237
259
|
const onPointerUp = (e: ReactPointerEvent<HTMLButtonElement>) => {
|
|
238
260
|
const g = gestureRef.current
|
|
239
261
|
if (!g || g.pointerId !== e.pointerId) return
|
|
240
262
|
// the item is the one the gesture STARTED on - never the row that happens to receive the
|
|
241
|
-
// up (capture can be lost, a refresh can re-key the dragged row)
|
|
263
|
+
// up (capture can be lost, a refresh can re-key the dragged row); the target is the seam
|
|
264
|
+
// the human saw
|
|
242
265
|
const item = g.item
|
|
243
266
|
const dragged = g.dragging
|
|
244
|
-
|
|
267
|
+
// a release outside the panel cancels, even after a seam showed (the pointer can leave
|
|
268
|
+
// between the last move and the up); inside, the seam is the contract
|
|
269
|
+
const panel = rootRef.current?.closest('.sh-panel')?.getBoundingClientRect()
|
|
270
|
+
const inside = !!panel && e.clientX >= panel.left && e.clientX < panel.right && e.clientY >= panel.top && e.clientY < panel.bottom
|
|
271
|
+
const target = dragged && inside ? dropRef.current : null
|
|
245
272
|
resetPointer()
|
|
246
273
|
if (dragged) {
|
|
247
274
|
if (!target) return
|
|
@@ -290,7 +317,6 @@ export function BoardList({ onMenu }: { onMenu: MenuOpener }) {
|
|
|
290
317
|
}
|
|
291
318
|
const onMenuRef = useRef(onMenu)
|
|
292
319
|
onMenuRef.current = onMenu
|
|
293
|
-
const rootRef = useRef<HTMLDivElement>(null)
|
|
294
320
|
useEffect(() => {
|
|
295
321
|
const panel = rootRef.current?.closest('.sh-panel')
|
|
296
322
|
const scroll = rootRef.current?.closest('.sh-panel-scroll')
|
|
@@ -320,13 +346,14 @@ export function BoardList({ onMenu }: { onMenu: MenuOpener }) {
|
|
|
320
346
|
return drop.index === index ? ' drop-before' : last && drop.index === index + 1 ? ' drop-after' : ''
|
|
321
347
|
}
|
|
322
348
|
const boardRow = (n: string, parent: string | null, index: number, last: boolean) => {
|
|
349
|
+
const dragging = drag?.kind === 'board' && drag.name === n
|
|
350
|
+
const dropCls = dragging ? '' : n === 'all-scenes' ? (drop && !('into' in drop) && drop.list === null && drop.index === tree.length ? ' drop-before' : '') : seam(parent, index, last)
|
|
351
|
+
// the row being renamed is still a row: measured by a drag (its slot exists) and it draws its seam
|
|
323
352
|
if (naming?.kind === 'board' && naming.name === n) return (
|
|
324
|
-
<div key={n} className={`it board editing${parent ? ' in-folder' : ''}`}><CardsIcon size={14} />{input(n)}</div>
|
|
353
|
+
<div key={n} data-board-row data-board={n} data-folder={parent ?? undefined} className={`it board editing${parent ? ' in-folder' : ''}${dropCls}`}><CardsIcon size={14} />{input(label(n))}</div>
|
|
325
354
|
)
|
|
326
355
|
const canDrag = !PUBLISHED && n !== 'all-scenes'
|
|
327
356
|
const item: Drag = { kind: 'board', name: n }
|
|
328
|
-
const dragging = drag?.kind === 'board' && drag.name === n
|
|
329
|
-
const dropCls = dragging ? '' : n === 'all-scenes' ? (drop && !('into' in drop) && drop.list === null && drop.index === tree.length ? ' drop-before' : '') : seam(parent, index, last)
|
|
330
357
|
return (
|
|
331
358
|
<button key={`${parent ?? ''}/${n}`} data-board-row data-board={n} data-folder={parent ?? undefined} data-reorderable={canDrag || undefined}
|
|
332
359
|
className={`it board${parent ? ' in-folder' : ''}${n === board ? ' cur' : ''}${dragging ? ' dragging' : ''}${dropCls}`}
|
|
@@ -341,22 +368,25 @@ export function BoardList({ onMenu }: { onMenu: MenuOpener }) {
|
|
|
341
368
|
onPointerCancel={canDrag ? () => resetPointer() : undefined}
|
|
342
369
|
onLostPointerCapture={canDrag ? (e) => { if (gestureRef.current?.pointerId === e.pointerId) resetPointer() } : undefined}>
|
|
343
370
|
{n === 'all-scenes' ? <CardsThreeIcon size={14} /> : <CardsIcon size={14} />}
|
|
344
|
-
<span>{
|
|
371
|
+
<span>{label(n)}</span>
|
|
345
372
|
</button>
|
|
346
373
|
)
|
|
347
374
|
}
|
|
348
|
-
const folderRow = (
|
|
375
|
+
const folderRow = (it: Folder, boards: string[], index: number): ReactNode[] => {
|
|
376
|
+
const f = it.name
|
|
349
377
|
const open = !closed[f]
|
|
350
378
|
const rows: ReactNode[] = []
|
|
351
379
|
if (naming?.kind === 'folder' && naming.name === f) {
|
|
352
|
-
rows.push(<div key={`f:${f}`} className=
|
|
380
|
+
rows.push(<div key={`f:${f}`} data-folder-row={f} data-open={open ? '1' : '0'} className={`it folder editing${seam(null, index, false)}`}>{open ? <FolderOpenIcon size={14} /> : <FolderIcon size={14} />}{input(labelOf(f, it.title))}</div>)
|
|
353
381
|
} else {
|
|
354
382
|
const item: Drag = { kind: 'folder', name: f }
|
|
355
383
|
const dragging = drag?.kind === 'folder' && drag.name === f
|
|
356
384
|
const into = !!drag && !!drop && 'into' in drop && drop.into === f
|
|
385
|
+
// a slot inside an open EMPTY folder has no board row to draw on: the indented seam sits under the header
|
|
386
|
+
const inSeam = !!drag && !!drop && !('into' in drop) && drop.list === f && open && boards.length === 0
|
|
357
387
|
rows.push(
|
|
358
|
-
<button key={`f:${f}`} data-folder-row={f} data-reorderable={!PUBLISHED || undefined}
|
|
359
|
-
className={`it folder${boards.includes(board) ? ' held' : ''}${dragging ? ' dragging' : ''}${into ? ' drop-into' : ''}${dragging ? '' : seam(null, index, false)}`}
|
|
388
|
+
<button key={`f:${f}`} data-folder-row={f} data-open={open ? '1' : '0'} data-reorderable={!PUBLISHED || undefined}
|
|
389
|
+
className={`it folder${boards.includes(board) ? ' held' : ''}${dragging ? ' dragging' : ''}${into ? ' drop-into' : ''}${inSeam ? ' drop-in' : ''}${dragging ? '' : seam(null, index, false)}`}
|
|
360
390
|
onClick={(e) => { if (PUBLISHED || e.detail === 0) setOpen(f, !open) }}
|
|
361
391
|
onContextMenu={(e) => onMenu(e, folderMenu(f, boards))}
|
|
362
392
|
onPointerDown={!PUBLISHED ? (e) => onPointerDown(e, item) : undefined}
|
|
@@ -365,7 +395,7 @@ export function BoardList({ onMenu }: { onMenu: MenuOpener }) {
|
|
|
365
395
|
onPointerCancel={!PUBLISHED ? () => resetPointer() : undefined}
|
|
366
396
|
onLostPointerCapture={!PUBLISHED ? (e) => { if (gestureRef.current?.pointerId === e.pointerId) resetPointer() } : undefined}>
|
|
367
397
|
{open ? <FolderOpenIcon size={14} /> : <FolderIcon size={14} />}
|
|
368
|
-
<span>{
|
|
398
|
+
<span>{labelOf(f, it.title)}</span>
|
|
369
399
|
<small>{boards.length}</small>
|
|
370
400
|
</button>,
|
|
371
401
|
)
|
|
@@ -380,12 +410,12 @@ export function BoardList({ onMenu }: { onMenu: MenuOpener }) {
|
|
|
380
410
|
const rows: ReactNode[] = []
|
|
381
411
|
const draftRows = draft ? [
|
|
382
412
|
<div key="f:new" className="it folder editing"><FolderOpenIcon size={14} />{input('', 'Folder name')}</div>,
|
|
383
|
-
...(draft.board ? [<div key="new/board" className={`it board in-folder draft${draft.board === board ? ' cur' : ''}`}><CardsIcon size={14} /><span>{
|
|
413
|
+
...(draft.board ? [<div key="new/board" className={`it board in-folder draft${draft.board === board ? ' cur' : ''}`}><CardsIcon size={14} /><span>{label(draft.board)}</span></div>] : []),
|
|
384
414
|
] : []
|
|
385
415
|
tree.forEach((it, i) => {
|
|
386
416
|
if (draft && draft.index === i) rows.push(...draftRows)
|
|
387
417
|
if (it.kind === 'board') { if (draft?.board !== it.name) rows.push(boardRow(it.name, null, i, false)) }
|
|
388
|
-
else rows.push(...folderRow(it
|
|
418
|
+
else rows.push(...folderRow(it, draft?.board ? it.boards.filter((b) => b !== draft.board) : it.boards, i))
|
|
389
419
|
})
|
|
390
420
|
if (draft && draft.index >= tree.length) rows.push(...draftRows)
|
|
391
421
|
if (HAS_ALL_SCENES) rows.push(boardRow('all-scenes', null, -1, false)) // a published bundle without it shows none
|
|
@@ -107,7 +107,8 @@ export function App() {
|
|
|
107
107
|
return c.live(board)
|
|
108
108
|
}, [board])
|
|
109
109
|
|
|
110
|
-
|
|
110
|
+
const titles = useStore((s) => s.boardTitles)
|
|
111
|
+
useEffect(() => { document.title = board ? `${boardLabel(board)} - Marver` : 'Marver' }, [board, titles])
|
|
111
112
|
|
|
112
113
|
return (
|
|
113
114
|
<div tabIndex={-1} className="sh-app dark">
|
|
@@ -168,6 +168,7 @@ export function PlayOverlay() {
|
|
|
168
168
|
function BoardMenu({ current }: { current: string }) {
|
|
169
169
|
const pop = usePopover()
|
|
170
170
|
const [names, setNames] = useState<string[]>([current])
|
|
171
|
+
useStore((s) => s.boardTitles) // labels follow titles (the fetch below refreshes them)
|
|
171
172
|
// refreshed on every open - agents create boards while you present
|
|
172
173
|
useEffect(() => { if (pop.open) fetchBoardNames().then(setNames).catch(() => {}) }, [pop.open])
|
|
173
174
|
return (
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
export const cap = (s: string): string => (s ? s[0].toUpperCase() + s.slice(1) : s)
|
|
5
5
|
|
|
6
6
|
/** D5: derive a display label from a kebab slug - drop the dashes, Title Case each word.
|
|
7
|
-
* For board/scene NAMES only (device/theme names are single words, cap is right
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
export
|
|
7
|
+
* For board/folder/scene NAMES only (device/theme names are single words, cap is right
|
|
8
|
+
* there). An explicit `title` (boards, folders, scenes - v1.6.1) is shown verbatim and never
|
|
9
|
+
* passes through here; `labelOf` in shared/board-tree.ts picks. */
|
|
10
|
+
export { humanize } from '../../shared/board-tree.ts'
|