@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.
- package/CHANGELOG.md +26 -0
- package/README.md +2 -2
- 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-B4yPgFNF.mjs} +12 -8
- package/dist/cli.mjs +25 -14
- package/dist/{daemon-Cb4JjpgL.mjs → daemon-Bbh_jmui.mjs} +1 -1
- package/dist/{dev-CdcIuhZJ.mjs → dev-DH2W7Ffw.mjs} +4 -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-BeBGu3gH.mjs} +85 -23
- package/dist/{poster-DOY7pax8.mjs → poster-CoyobbGW.mjs} +1 -1
- package/dist/{shot-CwmHO5T4.mjs → shot-z-d-zMzf.mjs} +2 -2
- 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 +9 -2
- package/templates/AGENTS-studio.md +9 -2
- package/templates/instructions/boards.md +27 -9
- package/templates/instructions/discover.md +13 -1
|
@@ -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
|
|
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 {
|
|
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 (
|
|
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
|
-
|
|
170
|
-
if (
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
180
|
-
|
|
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
|
-
|
|
183
|
-
return json(res, 200, {
|
|
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-
|
|
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-
|
|
401
|
-
const { isLocalAssetRef } = await import("./build-
|
|
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-
|
|
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-
|
|
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-
|
|
99
|
-
const { ensurePoster } = await import("./poster-
|
|
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
|
|
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.
|
|
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,
|
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
|