@marver-design/marver 0.16.0 → 0.16.1

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.
@@ -1,8 +1,8 @@
1
1
  import { t as __exportAll } from "./rolldown-runtime-D7D4PA-g.mjs";
2
2
  import { i as ROUTE, n as NAME, r as PKG } from "./cli.mjs";
3
- import { a as nodeExists, c as FOLDERS_FILE, f as readDescription, i as listBoardFiles, m as hash, n as checkBoardsDir, o as readRegistry, p as validateWire, r as isRegularFile, s as BOARD_NAME, t as boardFields } from "./boards-BdG1TJwU.mjs";
3
+ import { a as listBoardFiles, c as BOARD_NAME, g as hash, h as validateWire, i as isRegularFile, l as FOLDERS_FILE, m as readTitle, n as checkBoardsDir, o as nodeExists, p as readDescription, s as readRegistry, t as boardFields } from "./boards-BmxcT3Lc.mjs";
4
4
  import { n as localProfile, t as isConnected } from "./profile-BjAPAJSb.mjs";
5
- import { i as writeManifest, n as scanFrames, o as loadConfig, t as affectedFrameIds } from "./manifest-CpbsqQ_v.mjs";
5
+ import { a as writeManifest, n as scanFrames, r as setSceneTitle, s as loadConfig, t as affectedFrameIds } from "./manifest-CaslQIAO.mjs";
6
6
  import { copyFileSync, existsSync, linkSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, watch, writeFileSync } from "node:fs";
7
7
  import { basename, dirname, join, resolve, sep } from "node:path";
8
8
  import { fileURLToPath } from "node:url";
@@ -155,9 +155,13 @@ function apiMiddleware(root, opts = {}) {
155
155
  return json(res, 400, { error: "malformed JSON" });
156
156
  }
157
157
  if (!parsed || typeof parsed !== "object") return json(res, 400, { error: "expected an object" });
158
- const { from, to } = parsed;
158
+ const { from, to: toRaw, title: titleRaw, baseHash } = parsed;
159
+ const to = toRaw === void 0 ? from : toRaw;
159
160
  if (!validName(from) || !validName(to)) return json(res, 400, { error: "invalid board name" });
160
- if (to === "all-scenes" || from === "all-scenes" || to === from) return json(res, 400, { error: "invalid rename" });
161
+ if (titleRaw !== void 0 && (typeof titleRaw !== "string" || Array.from(titleRaw).length > 120)) return json(res, 400, { error: "invalid title" });
162
+ if (baseHash !== void 0 && typeof baseHash !== "string") return json(res, 400, { error: "invalid baseHash" });
163
+ if (to === "all-scenes" || from === "all-scenes") return json(res, 400, { error: "invalid rename" });
164
+ if (to === from && titleRaw === void 0) return json(res, 400, { error: "invalid rename" });
161
165
  {
162
166
  const de = dirError();
163
167
  if (de) return json(res, 400, { error: de });
@@ -166,21 +170,74 @@ function apiMiddleware(root, opts = {}) {
166
170
  if (!fromPath || !toPath) return json(res, 400, { error: "invalid board name" });
167
171
  if (!existsSync(fromPath)) return json(res, 404, { error: `board "${from}" does not exist` });
168
172
  if (!notSymlink(fromPath)) return json(res, 400, { error: "refusing to rename a symlinked board file" });
169
- if (isConnected(root)) return json(res, 409, { error: "disconnect before renaming boards - comment sync is board-keyed" });
170
- if (existsSync(join(root, "design", "comments", `${from}.jsonl`))) return json(res, 409, { error: "rename a board before commenting on it - this board has threads" });
171
- if (nodeExists$1(toPath)) return json(res, 409, { error: `a board named "${to}" already exists` });
172
- try {
173
- linkSync(fromPath, toPath);
174
- } catch (err) {
175
- const code = err.code;
176
- if (code === "EEXIST") return json(res, 409, { error: `a board named "${to}" already exists` });
177
- if (code !== "EPERM" && code !== "ENOSYS" && code !== "ENOTSUP" && code !== "EOPNOTSUPP") throw err;
173
+ const current = readFileSync(fromPath, "utf8");
174
+ if (baseHash !== void 0 && baseHash !== hash(current)) return json(res, 409, {
175
+ error: "board changed on disk",
176
+ sha256: hash(current)
177
+ });
178
+ let obj = null;
179
+ if (titleRaw !== void 0) {
180
+ let parsedBoard;
181
+ try {
182
+ parsedBoard = JSON.parse(current);
183
+ } catch {
184
+ return json(res, 422, { error: `board "${from}" is not valid JSON - fix the file` });
185
+ }
186
+ if (!parsedBoard || typeof parsedBoard !== "object" || Array.isArray(parsedBoard)) return json(res, 422, { error: `board "${from}" is not a JSON object - fix the file` });
187
+ obj = parsedBoard;
188
+ }
189
+ let next;
190
+ if (obj) {
191
+ const title = readTitle(titleRaw);
192
+ if (title) obj.title = title;
193
+ else delete obj.title;
194
+ next = JSON.stringify(obj, null, 2) + "\n";
195
+ }
196
+ if (to !== from) {
197
+ if (isConnected(root)) return json(res, 409, { error: "disconnect before renaming boards - comment sync is board-keyed" });
198
+ if (existsSync(join(root, "design", "comments", `${from}.jsonl`))) return json(res, 409, { error: "rename a board before commenting on it - this board has threads" });
178
199
  if (nodeExists$1(toPath)) return json(res, 409, { error: `a board named "${to}" already exists` });
179
- renameSync(fromPath, toPath);
180
- return json(res, 200, { name: to });
200
+ try {
201
+ if (next !== void 0) writeFileSync(toPath, next, { flag: "wx" });
202
+ else linkSync(fromPath, toPath);
203
+ } catch (err) {
204
+ const code = err.code;
205
+ if (code === "EEXIST") return json(res, 409, { error: `a board named "${to}" already exists` });
206
+ if (next !== void 0 || code !== "EPERM" && code !== "ENOSYS" && code !== "ENOTSUP" && code !== "EOPNOTSUPP") throw err;
207
+ if (nodeExists$1(toPath)) return json(res, 409, { error: `a board named "${to}" already exists` });
208
+ renameSync(fromPath, toPath);
209
+ return json(res, 200, { name: to });
210
+ }
211
+ rmSync(fromPath);
212
+ return json(res, 200, {
213
+ name: to,
214
+ ...next !== void 0 ? { sha256: hash(next) } : {}
215
+ });
181
216
  }
182
- rmSync(fromPath);
183
- return json(res, 200, { name: to });
217
+ atomicWrite(toPath, next);
218
+ return json(res, 200, {
219
+ name: to,
220
+ sha256: hash(next)
221
+ });
222
+ }
223
+ if (path === "scenes/rename" && req.method === "POST") {
224
+ if (!ownerGated(req)) return json(res, 403, { error: "forbidden" });
225
+ const raw = await readBody(req);
226
+ if (raw == null) return json(res, 400, { error: "body too large or unreadable" });
227
+ let parsed;
228
+ try {
229
+ parsed = JSON.parse(raw);
230
+ } catch {
231
+ return json(res, 400, { error: "malformed JSON" });
232
+ }
233
+ if (!parsed || typeof parsed !== "object") return json(res, 400, { error: "expected an object" });
234
+ const { scene, title } = parsed;
235
+ if (typeof scene !== "string" || !scene || scene.length > 128 || /[\\/]/.test(scene) || scene === "." || scene === "..") return json(res, 400, { error: "invalid scene name" });
236
+ if (typeof title !== "string" || Array.from(title).length > 120) return json(res, 400, { error: "invalid title" });
237
+ if (!scanFrames(root).scenes.some((sc) => sc.name === scene)) return json(res, 400, { error: `scene "${scene}" does not exist (no frames under design/scenes/${scene}/)` });
238
+ const e = setSceneTitle(root, scene, readTitle(title) ?? "");
239
+ if (e) return json(res, 400, { error: e });
240
+ return json(res, 200, { ok: true });
184
241
  }
185
242
  if (path === "boards/reorder" && req.method === "POST") {
186
243
  if (!ownerGated(req)) return json(res, 403, { error: "forbidden" });
@@ -249,10 +306,11 @@ function apiMiddleware(root, opts = {}) {
249
306
  if (e) return json(res, 422, { error: e });
250
307
  continue;
251
308
  }
252
- const description = readDescription(it.description);
309
+ const title = readTitle(it.title), description = readDescription(it.description);
253
310
  folders.push({
254
311
  name: it.folder,
255
312
  order: i,
313
+ ...title ? { title } : {},
256
314
  ...description ? { description } : {}
257
315
  });
258
316
  for (const [j, kid] of it.boards.entries()) {
@@ -339,6 +397,7 @@ function apiMiddleware(root, opts = {}) {
339
397
  const disk = JSON.parse(current);
340
398
  if (incoming.order === void 0 && typeof disk.order === "number" && Number.isFinite(disk.order)) incoming.order = disk.order;
341
399
  if (incoming.folder === void 0 && validName(disk.folder)) incoming.folder = disk.folder;
400
+ if (incoming.title === void 0 && readTitle(disk.title)) incoming.title = disk.title;
342
401
  if (incoming.description === void 0 && readDescription(disk.description)) incoming.description = disk.description;
343
402
  } catch {}
344
403
  const next2 = JSON.stringify(body.board, null, 2) + "\n";
@@ -363,7 +422,7 @@ function apiMiddleware(root, opts = {}) {
363
422
  };
364
423
  const asPng = url.searchParams.get("format") === "png";
365
424
  const origin = opts.origin?.() ?? `http://${req.headers.host ?? "localhost"}`;
366
- const { shootFrame } = await import("./shot-CwmHO5T4.mjs");
425
+ const { shootFrame } = await import("./shot-z-d-zMzf.mjs");
367
426
  const r = await shootFrame({
368
427
  root,
369
428
  viewports: opts.viewports ?? {},
@@ -397,8 +456,8 @@ function apiMiddleware(root, opts = {}) {
397
456
  if (path === "poster" && req.method === "GET") {
398
457
  if (!ownerGated(req)) return json(res, 403, { error: "forbidden" });
399
458
  const src = url.searchParams.get("src") ?? "";
400
- const { ensurePoster, isLocalClip } = await import("./poster-DOY7pax8.mjs");
401
- const { isLocalAssetRef } = await import("./build-Ct0iMoSi.mjs");
459
+ const { ensurePoster, isLocalClip } = await import("./poster-CoyobbGW.mjs");
460
+ const { isLocalAssetRef } = await import("./build-B4yPgFNF.mjs");
402
461
  if (!isLocalAssetRef(src) || !isLocalClip(src)) return json(res, 400, { error: "src must be a clip under design/assets/" });
403
462
  const r = await ensurePoster(join(root, "design", "assets"), src);
404
463
  if (!r.ok) return json(res, r.error.includes("does not exist") ? 404 : 503, { error: r.error });
@@ -827,12 +886,15 @@ function marverPlugin(ctx) {
827
886
  server.middlewares.use(routesMiddleware(server, clientDir));
828
887
  devServer = server;
829
888
  let framesKey = JSON.stringify(manifest.frames);
889
+ let scenesKey = JSON.stringify(manifest.scenes);
830
890
  const regen = debounce(() => {
831
891
  manifest = scanFrames(root, projectInfo());
832
892
  const changed = writeManifest(root, manifest);
833
- const key = JSON.stringify(manifest.frames);
893
+ const key = JSON.stringify(manifest.frames), sKey = JSON.stringify(manifest.scenes);
834
894
  if (changed && key !== framesKey) server.ws.send("sh:manifest", manifest);
895
+ else if (changed && sKey !== scenesKey) server.ws.send("sh:scenes", { scenes: manifest.scenes });
835
896
  framesKey = key;
897
+ scenesKey = sKey;
836
898
  }, 150);
837
899
  const watched = [join(root, "design", "scenes"), join(root, "design", "components")];
838
900
  const inScope = (file) => watched.some((w) => file.startsWith(w));
@@ -944,7 +1006,7 @@ function marverPlugin(ctx) {
944
1006
  });
945
1007
  return;
946
1008
  }
947
- const { shootFrame } = await import("./shot-CwmHO5T4.mjs");
1009
+ const { shootFrame } = await import("./shot-z-d-zMzf.mjs");
948
1010
  write(await shootFrame({
949
1011
  root,
950
1012
  viewports: config.viewports,
@@ -1,4 +1,4 @@
1
- import { capture, findChrome } from "./shot-CwmHO5T4.mjs";
1
+ import { capture, findChrome } from "./shot-z-d-zMzf.mjs";
2
2
  import { constants, copyFileSync, existsSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { basename, dirname, join, sep } from "node:path";
4
4
  import { pathToFileURL } from "node:url";
@@ -95,8 +95,8 @@ async function shootFrame(opts) {
95
95
  };
96
96
  const plan = planShot(frame, viewports, size);
97
97
  if (frame.kind !== "html") try {
98
- const { scanAssetRefs } = await import("./build-Ct0iMoSi.mjs");
99
- const { ensurePoster } = await import("./poster-DOY7pax8.mjs");
98
+ const { scanAssetRefs } = await import("./build-B4yPgFNF.mjs");
99
+ const { ensurePoster } = await import("./poster-CoyobbGW.mjs");
100
100
  const refs = scanAssetRefs(readFileSync(join(root, frame.file), "utf8"), frame.file);
101
101
  for (const r of refs) if (r.endsWith(".poster.png")) await ensurePoster(join(root, "design", "assets"), r.slice(0, -11));
102
102
  } catch {}
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 published boards',
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.16.0",
3
+ "version": "0.16.1",
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,
@@ -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
- function SceneGroup({ name, count, held, onPick, onContextMenu, children }: { name: string; count: number; held: boolean; onPick?: () => void; onContextMenu?: (e: ReactMouseEvent) => void; children: ReactNode }) {
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
- <button className={`it${held ? ' held' : ''}`} onClick={() => setOpen(!open)} onContextMenu={onContextMenu}>
28
- <CaretIcon size={11} className="tw" style={{ transform: open ? undefined : 'rotate(-90deg)' }} />
29
- {/* the NAME selects every frame in the scene; the caret/row still collapses */}
30
- <span onClick={(e) => { if (!onPick) return; e.stopPropagation(); onPick() }}>{humanize(name) || '(root)'}</span>
31
- <small>{count}</small>
32
- </button>
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
- useEffect(() => { document.title = board ? `${boardLabel(board)} - Marver` : 'Marver' }, [board])
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