@marver-design/marver 0.2.1 → 0.2.2
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/README.md +1 -0
- package/dist/{build-BYZDMiIS.mjs → build-CNoXE13J.mjs} +1 -1
- package/dist/cli.mjs +3 -3
- package/dist/{dev-Dt9D4O7Z.mjs → dev-Blyy4jOL.mjs} +1 -1
- package/dist/{init-CHUZTYG7.mjs → init-3h9pXEzp.mjs} +137 -24
- package/dist/{plugin-lVQoABEx.mjs → plugin-DiDJA9n-.mjs} +102 -8
- package/package.json +1 -1
- package/src/client/shell/App.tsx +46 -4
- package/src/client/shell/store.ts +4 -1
- package/src/client/shell/styles.css +21 -2
- package/templates/AGENTS-embedded.md +28 -27
- package/templates/AGENTS-studio.md +28 -27
- package/templates/instructions/boards.md +38 -0
- package/templates/instructions/brand.md +60 -0
- package/templates/instructions/components.md +52 -0
- package/templates/instructions/configure.md +44 -0
- package/templates/instructions/craft.md +90 -0
- package/templates/instructions/discover.md +52 -0
- package/templates/instructions/reference/color.md +53 -0
- package/templates/instructions/reference/concepts.md +68 -0
- package/templates/instructions/reference/copy.md +57 -0
- package/templates/instructions/reference/critique.md +53 -0
- package/templates/instructions/reference/delight.md +35 -0
- package/templates/instructions/reference/layout.md +51 -0
- package/templates/instructions/reference/motion.md +66 -0
- package/templates/instructions/reference/operate.md +38 -0
- package/templates/instructions/reference/slop.md +76 -0
- package/templates/instructions/reference/states.md +48 -0
- package/templates/instructions/reference/tune.md +61 -0
- package/templates/instructions/reference/typography.md +45 -0
- package/templates/instructions/review.md +51 -0
- package/templates/instructions/wireframe.md +49 -0
package/README.md
CHANGED
|
@@ -18,6 +18,7 @@ Then, to your agent:
|
|
|
18
18
|
- **Boards**: one canvas on screen at a time. Agents write `design/boards/<name>.json` (a frame list is enough); switch boards at the top of the sidebar. `all-scenes` is auto-managed.
|
|
19
19
|
- **Devices view**: the Devices menu (or hotkeys `1`-`5`) sizes every frame to mobile / tablet / laptop / monitor / tv to sweep your breakpoints; `0` restores your own layout exactly. Widths live in `design/config.ts`.
|
|
20
20
|
- `data-goto="scene/frame"` on any element links frames into a walkable prototype.
|
|
21
|
+
- **Upgrade**: `npm i -D @marver-design/marver@latest && npx marver init`. The canvas tells you when a new version is out (one anonymous registry check per day, cached in `design/.local/`; `MARVER_NO_UPDATE_CHECK=1` disables). Re-running init refreshes the managed files (AGENTS.md, `design/instructions/`) - your edits to them are detected and preserved; when both you and a release changed a file, the fresh version is staged at `design/.local/latest/` for you (or your agent) to merge. Everything else in `design/` is yours and never touched.
|
|
21
22
|
- Uninstall: delete `design/`, remove the dependency. (If `init` patched your tsconfig `exclude`, revert that one line.)
|
|
22
23
|
|
|
23
24
|
**Next.js**: supported with one caveat - frames render in Vite, outside Next. `next/font` CSS variables are undefined inside frames (give font tokens a fallback chain), `next/image`/`next/link` should be plain `img`/`data-goto` in frames, and Server Components cannot run there. `init` writes the specifics into `design/AGENTS.md` when it detects Next.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { r as ROUTE, t as NAME } from "./cli.mjs";
|
|
2
2
|
import { a as loadConfig, n as scanFrames, o as detectHost } from "./manifest-CHmKAAtG.mjs";
|
|
3
|
-
import { n as tailwind3Css, r as tailwind4Plugin, t as marverPlugin } from "./plugin-
|
|
3
|
+
import { n as tailwind3Css, r as tailwind4Plugin, t as marverPlugin } from "./plugin-DiDJA9n-.mjs";
|
|
4
4
|
import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
5
5
|
import { basename, dirname, join, sep } from "node:path";
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
package/dist/cli.mjs
CHANGED
|
@@ -32,14 +32,14 @@ function version() {
|
|
|
32
32
|
}
|
|
33
33
|
const cli = cac(NAME);
|
|
34
34
|
cli.command("init", "Scaffold design/ in this repo").option("--mode <mode>", "studio | embedded", { default: "studio" }).option("--no-demo", "Skip the demo scene (the demo ships unless this flag is passed)").option("--root <dir>", "Host repo root", { default: "." }).action(async (opts) => {
|
|
35
|
-
const { init } = await import("./init-
|
|
35
|
+
const { init } = await import("./init-3h9pXEzp.mjs");
|
|
36
36
|
init(resolve(opts.root), {
|
|
37
37
|
mode: opts.mode === "embedded" ? "embedded" : "studio",
|
|
38
38
|
demo: opts.demo !== false
|
|
39
39
|
});
|
|
40
40
|
});
|
|
41
41
|
cli.command("dev", "Start the canvas").option("--root <dir>", "Host repo root", { default: "." }).option("--port <port>", "Port (default 5199)").action(async (opts) => {
|
|
42
|
-
const { dev } = await import("./dev-
|
|
42
|
+
const { dev } = await import("./dev-Blyy4jOL.mjs");
|
|
43
43
|
let port;
|
|
44
44
|
if (opts.port !== void 0) {
|
|
45
45
|
const n = Number(opts.port);
|
|
@@ -49,7 +49,7 @@ cli.command("dev", "Start the canvas").option("--root <dir>", "Host repo root",
|
|
|
49
49
|
await dev(resolve(opts.root), port);
|
|
50
50
|
});
|
|
51
51
|
cli.command("build", "Static export → design/.dist").option("--boards <names>", "Publish only these boards (comma-separated); the frame filter is applied at build time").option("--root <dir>", "Host repo root", { default: "." }).action(async (opts) => {
|
|
52
|
-
const { buildSite } = await import("./build-
|
|
52
|
+
const { buildSite } = await import("./build-CNoXE13J.mjs");
|
|
53
53
|
try {
|
|
54
54
|
const boards = opts.boards === void 0 ? void 0 : typeof opts.boards === "string" ? opts.boards : "";
|
|
55
55
|
await buildSite(resolve(opts.root), boards);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { n as PKG, t as NAME } from "./cli.mjs";
|
|
2
2
|
import { a as loadConfig, o as detectHost } from "./manifest-CHmKAAtG.mjs";
|
|
3
|
-
import { n as tailwind3Css, r as tailwind4Plugin, t as marverPlugin } from "./plugin-
|
|
3
|
+
import { n as tailwind3Css, r as tailwind4Plugin, t as marverPlugin } from "./plugin-DiDJA9n-.mjs";
|
|
4
4
|
import { dirname, join } from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
import { createLogger, createServer, searchForWorkspaceRoot } from "vite";
|
|
@@ -1,16 +1,45 @@
|
|
|
1
1
|
import { t as NAME } from "./cli.mjs";
|
|
2
2
|
import { i as DEFAULTS, n as scanFrames, o as detectHost, r as writeManifest } from "./manifest-CHmKAAtG.mjs";
|
|
3
|
-
import { cpSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { dirname, join, relative } from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { createHash } from "node:crypto";
|
|
6
7
|
//#region src/cli/init.ts
|
|
7
|
-
|
|
8
|
+
/** Package root = the nearest ancestor holding templates/ (one hop from dist/, two
|
|
9
|
+
* from src/cli/ - the walk serves both, and tests run init from source). */
|
|
10
|
+
const pkgDir = () => {
|
|
11
|
+
let dir = dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
for (let i = 0; i < 4; i++) {
|
|
13
|
+
if (existsSync(join(dir, "templates"))) return dir;
|
|
14
|
+
dir = dirname(dir);
|
|
15
|
+
}
|
|
16
|
+
return join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
17
|
+
};
|
|
8
18
|
/** Idempotent scaffolder: never overwrites existing files; every host-repo touch prints a diff. */
|
|
9
19
|
function init(root, opts) {
|
|
10
20
|
const host = detectHost(root);
|
|
11
21
|
const design = join(root, "design");
|
|
12
22
|
const templates = join(pkgDir(), "templates");
|
|
13
23
|
const created = [];
|
|
24
|
+
const fileHas = (rel, needle) => {
|
|
25
|
+
try {
|
|
26
|
+
return readFileSync(join(design, rel), "utf8").includes(needle);
|
|
27
|
+
} catch {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
const marverShaped = fileHas("config.ts", "marver") || fileHas("AGENTS.md", "agent contract");
|
|
32
|
+
if (existsSync(design) && !marverShaped && readdirSync(design).some((f) => !f.startsWith("."))) {
|
|
33
|
+
console.error(`
|
|
34
|
+
[${NAME}] design/ already exists in this repo and does not look like a ${NAME} workspace.
|
|
35
|
+
Refusing to merge into it - your files and ${NAME}'s would interleave, and
|
|
36
|
+
"uninstall = delete design/" would stop being safe.
|
|
37
|
+
|
|
38
|
+
Move or rename the existing design/ folder, then re-run \`npx ${NAME} init\`.
|
|
39
|
+
(If you need marver to live in a differently-named folder, say so at
|
|
40
|
+
github.com/TNEP4/marver - a --dir flag is planned.)`);
|
|
41
|
+
process.exit(1);
|
|
42
|
+
}
|
|
14
43
|
const write = (rel, content) => {
|
|
15
44
|
const file = join(design, rel);
|
|
16
45
|
if (existsSync(file)) return;
|
|
@@ -18,21 +47,70 @@ function init(root, opts) {
|
|
|
18
47
|
writeFileSync(file, content);
|
|
19
48
|
created.push(`design/${rel}`);
|
|
20
49
|
};
|
|
50
|
+
const writeManaged = (rel, body) => {
|
|
51
|
+
const file = join(design, rel);
|
|
52
|
+
const next = managedFile(body);
|
|
53
|
+
const latest = join(design, ".local", "latest", rel);
|
|
54
|
+
if (!existsSync(file)) return write(rel, next);
|
|
55
|
+
const current = readFileSync(file, "utf8");
|
|
56
|
+
if (current === next) {
|
|
57
|
+
rmSync(latest, { force: true });
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
if (current.startsWith(MANAGED_PREFIX)) {
|
|
61
|
+
const recorded = current.slice(MANAGED_PREFIX.length).split(" ")[0];
|
|
62
|
+
const nl = current.indexOf("\n");
|
|
63
|
+
const currentBody = nl >= 0 ? current.slice(nl + 1) : "";
|
|
64
|
+
if (nl >= 0 && hashBody(currentBody) === recorded) {
|
|
65
|
+
writeFileSync(file, next);
|
|
66
|
+
rmSync(latest, { force: true });
|
|
67
|
+
created.push(`design/${rel} (updated)`);
|
|
68
|
+
} else if (recorded !== hashBody(body)) {
|
|
69
|
+
mkdirSync(dirname(latest), { recursive: true });
|
|
70
|
+
writeFileSync(latest, body);
|
|
71
|
+
if (nl >= 0) {
|
|
72
|
+
const tmp = file + ".tmp";
|
|
73
|
+
writeFileSync(tmp, managedFile(body).split("\n")[0] + "\n" + currentBody);
|
|
74
|
+
renameSync(tmp, file);
|
|
75
|
+
}
|
|
76
|
+
console.warn(` ~ design/${rel}: you customized it and a newer version exists - your edits are untouched. Merge what you want from design/.local/latest/${rel}`);
|
|
77
|
+
}
|
|
78
|
+
} else if (current.startsWith(LEGACY_PREFIX)) {
|
|
79
|
+
writeFileSync(file, next);
|
|
80
|
+
rmSync(latest, { force: true });
|
|
81
|
+
created.push(`design/${rel} (updated)`);
|
|
82
|
+
} else if (current !== body) {
|
|
83
|
+
rmSync(latest, { force: true });
|
|
84
|
+
console.warn(` note: design/${rel} exists without a marver marker - left untouched. If you did not author it, delete it and re-run init to restore the managed version.`);
|
|
85
|
+
}
|
|
86
|
+
};
|
|
21
87
|
write("config.ts", configTemplate(opts.mode));
|
|
22
88
|
if (host.themeCss) {
|
|
23
89
|
const relCss = relative(design, join(root, host.themeCss)).split("\\").join("/");
|
|
24
90
|
write("theme.css", themeWrapper(relCss, host.tailwind === 4));
|
|
25
91
|
} else console.warn(`[${NAME}] no theme CSS detected - create design/theme.css importing your app's stylesheet when you have one (or set \`theme\` in design/config.ts).`);
|
|
26
92
|
write("providers.tsx", providersTemplate(host.router, host.toaster, host.routerPkg));
|
|
27
|
-
|
|
28
|
-
const
|
|
29
|
-
if (!
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
if (
|
|
33
|
-
|
|
34
|
-
|
|
93
|
+
writeManaged("AGENTS.md", readFileSync(join(templates, `AGENTS-${opts.mode}.md`), "utf8").replaceAll("{{UI_GUIDANCE}}", uiGuidance(host, noApp(host))).replace(/\{\{NEXT_NOTES\}\}\n?/, host.router === "next" ? NEXT_NOTES : ""));
|
|
94
|
+
const agentsNow = readFileSync(join(design, "AGENTS.md"), "utf8");
|
|
95
|
+
if (!agentsNow.startsWith(MANAGED_PREFIX) && !agentsNow.startsWith(LEGACY_PREFIX) && agentsNow.includes("# Design canvas - agent contract") && !agentsNow.includes("## The method (binding)")) console.warn(` note: design/AGENTS.md predates managed regeneration - if you never edited it, delete it and re-run init to get the current contract (incl. the design/instructions routing).`);
|
|
96
|
+
const instrRoot = join(templates, "instructions");
|
|
97
|
+
for (const e of readdirSync(instrRoot, { withFileTypes: true })) if (e.isDirectory()) {
|
|
98
|
+
for (const f of readdirSync(join(instrRoot, e.name))) if (f.endsWith(".md")) writeManaged(`instructions/${e.name}/${f}`, readFileSync(join(instrRoot, e.name, f), "utf8"));
|
|
99
|
+
} else if (e.name.endsWith(".md")) writeManaged(`instructions/${e.name}`, readFileSync(join(instrRoot, e.name), "utf8"));
|
|
100
|
+
const setupPath = join(design, "instructions", "setup.md");
|
|
101
|
+
const ourSetup = () => {
|
|
102
|
+
try {
|
|
103
|
+
const s = readFileSync(setupPath, "utf8");
|
|
104
|
+
return s.startsWith("# Setup required") && s.includes("marver init");
|
|
105
|
+
} catch {
|
|
106
|
+
return false;
|
|
35
107
|
}
|
|
108
|
+
};
|
|
109
|
+
if (noApp(host)) {
|
|
110
|
+
if (!existsSync(setupPath)) write("instructions/setup.md", SETUP_MD);
|
|
111
|
+
} else if (existsSync(setupPath) && ourSetup()) {
|
|
112
|
+
rmSync(setupPath);
|
|
113
|
+
console.log(` - design/instructions/setup.md removed (app detected - setup complete)`);
|
|
36
114
|
}
|
|
37
115
|
write("tsconfig.json", existsSync(join(root, "tsconfig.json")) ? readFileSync(join(templates, "design-tsconfig.json"), "utf8") : STANDALONE_TSCONFIG);
|
|
38
116
|
write(".gitignore", ".local/\n.dist/\n");
|
|
@@ -53,32 +131,67 @@ function init(root, opts) {
|
|
|
53
131
|
if (host.router === "next") console.log(`\n note: Next.js support is partial - frames render outside Next, so next/font, next/image and Server Components do not exist inside them (details in design/AGENTS.md).`);
|
|
54
132
|
if (noApp(host)) console.warn(`
|
|
55
133
|
┌─ NO APP DETECTED ─────────────────────────────────────────────────────┐
|
|
56
|
-
│
|
|
57
|
-
│
|
|
58
|
-
│ hand-rolled CSS that cannot be promoted into an app later. │
|
|
134
|
+
│ No framework, no theme CSS, no component library. ${NAME} builds │
|
|
135
|
+
│ frames from YOUR components - with none, designs get thrown away. │
|
|
59
136
|
│ │
|
|
60
|
-
│ Set up the app
|
|
61
|
-
│
|
|
62
|
-
│
|
|
63
|
-
│ npx shadcn@latest init │
|
|
64
|
-
│ │
|
|
65
|
-
│ design/AGENTS.md was generated with a STOP instruction so your agent │
|
|
66
|
-
│ does not design against a component library that does not exist. │
|
|
137
|
+
│ Setup instructions: design/instructions/setup.md. Set up the app, │
|
|
138
|
+
│ re-run init, and that file removes itself. AGENTS.md carries a STOP │
|
|
139
|
+
│ so your agent does not design against components that do not exist. │
|
|
67
140
|
└───────────────────────────────────────────────────────────────────────┘`);
|
|
68
141
|
console.log(`\n commit design/ - only .local/ is ignored`);
|
|
69
142
|
console.log(` uninstall = delete design/, remove the ${NAME} dependency${host.tsconfigSweepsDesign ? ", revert the \"design\" line in tsconfig exclude" : ""}`);
|
|
70
143
|
console.log(`\n next: npx ${NAME} dev (canvas on http://localhost:${DEFAULTS.port} by default)\n`);
|
|
71
144
|
if (!noApp(host)) console.log(` then, to your agent: "Read design/AGENTS.md. Build an onboarding scene - welcome, form, done - mobile-first, using our components."\n`);
|
|
72
145
|
}
|
|
73
|
-
const
|
|
146
|
+
const MANAGED_PREFIX = "<!-- marver:managed ";
|
|
147
|
+
const LEGACY_PREFIX = "<!-- generated by marver init";
|
|
148
|
+
const hashBody = (s) => createHash("sha256").update(s).digest("hex");
|
|
149
|
+
/** The marker carries a hash of the generated body: edits are DETECTED, not assumed.
|
|
150
|
+
* Edit freely - init preserves edits and stages upstream updates for merging.
|
|
151
|
+
* Deleting the marker line detaches the file from updates entirely. */
|
|
152
|
+
const managedFile = (body) => `${MANAGED_PREFIX}${hashBody(body)} - edit freely: init preserves your edits and stages upstream updates at design/.local/latest/ for you to merge. Delete this line to detach this file from updates entirely. -->\n${body}`;
|
|
74
153
|
/** No framework, no theme, no component alias = nothing to build frames FROM. */
|
|
75
154
|
const noApp = (host) => !host.router && !host.tailwind && !host.shadcn && !host.themeCss;
|
|
76
|
-
/** The UI line of AGENTS.md, matched to what detection actually found (friction log #1).
|
|
77
|
-
|
|
155
|
+
/** The UI line of AGENTS.md, matched to what detection actually found (friction log #1).
|
|
156
|
+
* The STOP branch fires only on the same condition that creates SETUP.md - an app
|
|
157
|
+
* without Tailwind (plain React + CSS) gets guidance, never a dead pointer. */
|
|
158
|
+
function uiGuidance(host, isNoApp) {
|
|
159
|
+
if (isNoApp) return `STOP - this repo has no app yet. Read design/instructions/setup.md before designing anything.`;
|
|
78
160
|
if (host.shadcn) return `Use the app's UI: import from ${host.shadcn.uiAlias}; style with the app's Tailwind classes.`;
|
|
79
161
|
if (host.tailwind) return `Style with the app's Tailwind classes and design tokens; there is no detected component library - extract shared pieces into design/components/.`;
|
|
80
|
-
return `
|
|
162
|
+
return `Use the app's existing components and stylesheets (import them directly); there is no Tailwind or component library detected - extract shared pieces into design/components/.`;
|
|
81
163
|
}
|
|
164
|
+
const SETUP_MD = `# Setup required - this repo has no app yet
|
|
165
|
+
|
|
166
|
+
> This file exists because \`${NAME} init\` ran in a repo with no framework, no theme
|
|
167
|
+
> CSS, and no component library. It disappears automatically: set up the app, re-run
|
|
168
|
+
> \`npx ${NAME} init\`, and init deletes this file and regenerates AGENTS.md against
|
|
169
|
+
> the real stack. While this file exists, DO NOT design.
|
|
170
|
+
|
|
171
|
+
${NAME} builds frames from YOUR components and YOUR theme. With none, frames become
|
|
172
|
+
hand-rolled CSS that shares nothing with the future app and cannot be promoted into
|
|
173
|
+
it later - work that gets thrown away.
|
|
174
|
+
|
|
175
|
+
## Do this first
|
|
176
|
+
|
|
177
|
+
For a web app or marketing site, the blessed stack:
|
|
178
|
+
|
|
179
|
+
\`\`\`bash
|
|
180
|
+
npx create-next-app@latest . --ts --tailwind --app --src-dir
|
|
181
|
+
npx shadcn@latest init
|
|
182
|
+
\`\`\`
|
|
183
|
+
|
|
184
|
+
Any React + CSS setup works; the point is that components and a theme EXIST.
|
|
185
|
+
|
|
186
|
+
## Then
|
|
187
|
+
|
|
188
|
+
\`\`\`bash
|
|
189
|
+
npx ${NAME} init
|
|
190
|
+
\`\`\`
|
|
191
|
+
|
|
192
|
+
init is idempotent: it fills in what it now detects (theme wrapper, providers, a
|
|
193
|
+
shadcn-aware AGENTS.md), deletes this file, and you design from real parts.
|
|
194
|
+
`;
|
|
82
195
|
/** Next.js frames render OUTSIDE Next - say concretely what that means (friction log #10/#11). */
|
|
83
196
|
const NEXT_NOTES = `- Next.js caveats (frames render in Vite, outside Next):
|
|
84
197
|
next/font does not exist here - CSS variables it injects (e.g. --font-geist-sans) are
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { r as ROUTE } from "./cli.mjs";
|
|
1
|
+
import { n as PKG, r as ROUTE, t as NAME } from "./cli.mjs";
|
|
2
2
|
import { n as scanFrames, r as writeManifest, t as hash } from "./manifest-CHmKAAtG.mjs";
|
|
3
3
|
import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, watch, writeFileSync } from "node:fs";
|
|
4
|
-
import { join, resolve, sep } from "node:path";
|
|
4
|
+
import { dirname, join, resolve, sep } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
5
6
|
import { randomBytes } from "node:crypto";
|
|
6
7
|
//#region src/server/api.ts
|
|
7
8
|
const BOARD_NAME = /^[a-z0-9][a-z0-9-]*$/;
|
|
@@ -185,6 +186,73 @@ function routesMiddleware(server, clientDir) {
|
|
|
185
186
|
};
|
|
186
187
|
}
|
|
187
188
|
//#endregion
|
|
189
|
+
//#region src/server/update.ts
|
|
190
|
+
/**
|
|
191
|
+
* Update discovery - dev only, and deliberately boring about privacy: one anonymous
|
|
192
|
+
* registry metadata GET per day (the same request `npm view` makes), cached in
|
|
193
|
+
* design/.local/, nothing sent beyond the request itself. Offline, slow, or
|
|
194
|
+
* firewalled registries degrade to silence. MARVER_NO_UPDATE_CHECK=1 disables it.
|
|
195
|
+
* Published bundles never check anything - viewers are not the owner.
|
|
196
|
+
*/
|
|
197
|
+
const TTL = 864e5;
|
|
198
|
+
/** Installed version: walk up from this module to the package's own package.json
|
|
199
|
+
* (one level from dist/, two from src/server/ - the walk covers both). */
|
|
200
|
+
function installedVersion() {
|
|
201
|
+
let dir = dirname(fileURLToPath(import.meta.url));
|
|
202
|
+
for (let i = 0; i < 4; i++) {
|
|
203
|
+
try {
|
|
204
|
+
const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
|
|
205
|
+
if (pkg.name === "@marver-design/marver") return pkg.version;
|
|
206
|
+
} catch {}
|
|
207
|
+
dir = dirname(dir);
|
|
208
|
+
}
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
/** Strictly-newer numeric compare; anything non-numeric (prerelease tags) compares false. */
|
|
212
|
+
const newer = (a, b) => {
|
|
213
|
+
const pa = a.split(".").map((n) => parseInt(n, 10));
|
|
214
|
+
const pb = b.split(".").map((n) => parseInt(n, 10));
|
|
215
|
+
for (let i = 0; i < 3; i++) {
|
|
216
|
+
const x = pa[i] ?? 0, y = pb[i] ?? 0;
|
|
217
|
+
if (!Number.isFinite(x) || !Number.isFinite(y)) return false;
|
|
218
|
+
if (x !== y) return x > y;
|
|
219
|
+
}
|
|
220
|
+
return false;
|
|
221
|
+
};
|
|
222
|
+
/** The latest published version when strictly newer than installed, else null. Never throws. */
|
|
223
|
+
async function checkUpdate(root) {
|
|
224
|
+
if (process.env.MARVER_NO_UPDATE_CHECK) return null;
|
|
225
|
+
const current = installedVersion();
|
|
226
|
+
if (!current) return null;
|
|
227
|
+
const latest = await latestVersion(root);
|
|
228
|
+
return latest && newer(latest, current) ? latest : null;
|
|
229
|
+
}
|
|
230
|
+
async function latestVersion(root) {
|
|
231
|
+
const cacheFile = join(root, "design", ".local", "update-check.json");
|
|
232
|
+
const wellFormed = (v) => typeof v === "string" && /^\d+\.\d+\.\d+$/.test(v);
|
|
233
|
+
try {
|
|
234
|
+
const c = JSON.parse(readFileSync(cacheFile, "utf8"));
|
|
235
|
+
const age = Date.now() - c.checkedAt;
|
|
236
|
+
if (typeof c.checkedAt === "number" && age >= 0 && age < TTL) return wellFormed(c.latest) ? c.latest : null;
|
|
237
|
+
} catch {}
|
|
238
|
+
let latest = null;
|
|
239
|
+
try {
|
|
240
|
+
const res = await fetch(`https://registry.npmjs.org/${encodeURIComponent(PKG)}/latest`, { signal: AbortSignal.timeout(3e3) });
|
|
241
|
+
if (res.ok) {
|
|
242
|
+
const v = (await res.json())?.version;
|
|
243
|
+
if (wellFormed(v)) latest = v;
|
|
244
|
+
}
|
|
245
|
+
} catch {}
|
|
246
|
+
try {
|
|
247
|
+
mkdirSync(join(root, "design", ".local"), { recursive: true });
|
|
248
|
+
writeFileSync(cacheFile, JSON.stringify({
|
|
249
|
+
checkedAt: Date.now(),
|
|
250
|
+
latest
|
|
251
|
+
}) + "\n");
|
|
252
|
+
} catch {}
|
|
253
|
+
return latest;
|
|
254
|
+
}
|
|
255
|
+
//#endregion
|
|
188
256
|
//#region src/server/plugin.ts
|
|
189
257
|
const VIRTUAL_THEME = "virtual:sh-theme";
|
|
190
258
|
const VIRTUAL_CONFIG = "virtual:sh-config";
|
|
@@ -211,12 +279,23 @@ function marverPlugin(ctx) {
|
|
|
211
279
|
console.warn("[marver] no theme detected - frames render unstyled. Create design/theme.css importing your app's stylesheet (or set `theme` in design/config.ts).");
|
|
212
280
|
return "/* marver: no theme configured */";
|
|
213
281
|
}
|
|
214
|
-
if (id === "\0virtual:sh-config")
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
282
|
+
if (id === "\0virtual:sh-config") {
|
|
283
|
+
const setupPending = (() => {
|
|
284
|
+
try {
|
|
285
|
+
const s = readFileSync(join(root, "design", "instructions", "setup.md"), "utf8");
|
|
286
|
+
return s.startsWith("# Setup required") && s.includes("marver init");
|
|
287
|
+
} catch {
|
|
288
|
+
return false;
|
|
289
|
+
}
|
|
290
|
+
})();
|
|
291
|
+
return `export default ${JSON.stringify({
|
|
292
|
+
viewports: config.viewports,
|
|
293
|
+
themes: config.themes,
|
|
294
|
+
zoomSpeed: config.zoomSpeed,
|
|
295
|
+
noTheme: themeFile() == null,
|
|
296
|
+
setup: setupPending
|
|
297
|
+
})}`;
|
|
298
|
+
}
|
|
220
299
|
if (id === "\0virtual:sh-data") return "export default null";
|
|
221
300
|
},
|
|
222
301
|
/** HTML frames: inject theme + bridge into any design/**.html Vite serves.
|
|
@@ -263,6 +342,21 @@ function marverPlugin(ctx) {
|
|
|
263
342
|
}
|
|
264
343
|
next();
|
|
265
344
|
});
|
|
345
|
+
const update = checkUpdate(root).catch(() => null);
|
|
346
|
+
update.then((latest) => {
|
|
347
|
+
if (latest) console.log(`\n update: ${PKG} ${latest} is out (installed ${installedVersion() ?? "?"}) → npm i -D ${PKG}@latest && npx ${NAME} init\n`);
|
|
348
|
+
});
|
|
349
|
+
server.middlewares.use((req, res, next) => {
|
|
350
|
+
if (new URL(req.url ?? "/", "http://x").pathname !== `/__mv/api/update`) return next();
|
|
351
|
+
update.then((latest) => {
|
|
352
|
+
res.setHeader("content-type", "application/json");
|
|
353
|
+
res.setHeader("cache-control", "no-store");
|
|
354
|
+
res.end(JSON.stringify({
|
|
355
|
+
latest,
|
|
356
|
+
current: installedVersion()
|
|
357
|
+
}));
|
|
358
|
+
});
|
|
359
|
+
});
|
|
266
360
|
server.middlewares.use(apiMiddleware(root));
|
|
267
361
|
server.middlewares.use(routesMiddleware(server, clientDir));
|
|
268
362
|
const regen = debounce(() => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@marver-design/marver",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
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. The tool ships no AI - your coding agent is the designer.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"private": false,
|
package/src/client/shell/App.tsx
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { Component, useEffect, useRef, useState, type ReactNode } from 'react'
|
|
2
2
|
import { createPortal } from 'react-dom'
|
|
3
|
-
import { useStore, CONFIG, boardLabel, cap, fetchBoardNames } from './store.ts'
|
|
3
|
+
import { useStore, CONFIG, PUBLISHED, boardLabel, cap, fetchBoardNames } from './store.ts'
|
|
4
4
|
import { Tip } from './Tip.tsx'
|
|
5
|
-
import { ROUTE } from '../const.ts'
|
|
5
|
+
import { PKG, ROUTE } from '../const.ts'
|
|
6
6
|
import { animateLayout, Canvas, canvasCtl } from './canvas/Canvas.tsx'
|
|
7
7
|
import { enterPlay, playCtl, PlayOverlay } from './Play.tsx'
|
|
8
8
|
import { bootHash, parseHash, writeHash } from './hash.ts'
|
|
9
|
-
import { CardsIcon, CardsThreeIcon, CaretIcon, CheckIcon, DevicesIcon, GridIcon, MoonIcon, PanelFilledIcon, PanelHollowIcon, ParallelogramDuoIcon, PlayIcon, PlusIcon, SignpostIcon, SunIcon, deviceIcon } from './icons.tsx'
|
|
9
|
+
import { CardsIcon, CardsThreeIcon, CaretIcon, CheckIcon, DevicesIcon, GridIcon, MoonIcon, PanelFilledIcon, PanelHollowIcon, ParallelogramDuoIcon, PlayIcon, PlusIcon, SignpostIcon, SunIcon, XIcon, deviceIcon } from './icons.tsx'
|
|
10
10
|
|
|
11
11
|
let booted = false // survives Fast Refresh; see the boot effect
|
|
12
12
|
|
|
@@ -251,6 +251,45 @@ function DeviceMenu() {
|
|
|
251
251
|
)
|
|
252
252
|
}
|
|
253
253
|
|
|
254
|
+
/** Update pill (dev only): the daily registry check surfaces here - same glass, same
|
|
255
|
+
* pill, bottom-center. Click the command to copy it; × dismisses THIS version for
|
|
256
|
+
* good (localStorage), so the pill returns only when the next release lands. */
|
|
257
|
+
function UpdatePill() {
|
|
258
|
+
const [latest, setLatest] = useState<string | null>(null)
|
|
259
|
+
const play = useStore((s) => s.play)
|
|
260
|
+
useEffect(() => {
|
|
261
|
+
if (PUBLISHED) return // a shared canvas never nags its viewers
|
|
262
|
+
fetch(`${ROUTE}/api/update`)
|
|
263
|
+
.then((r) => (r.ok ? r.json() : null))
|
|
264
|
+
.then((u) => {
|
|
265
|
+
if (u?.latest && localStorage.getItem('mv-update-seen') !== u.latest) setLatest(u.latest)
|
|
266
|
+
})
|
|
267
|
+
.catch(() => { /* dev server gone or endpoint absent - stay quiet */ })
|
|
268
|
+
}, [])
|
|
269
|
+
if (!latest || play) return null
|
|
270
|
+
// init rides along so managed files (AGENTS.md, instructions/) refresh with the code
|
|
271
|
+
const cmd = `npm i -D ${PKG}@latest && npx marver init`
|
|
272
|
+
const dismiss = () => {
|
|
273
|
+
try { localStorage.setItem('mv-update-seen', latest) } catch { /* storage unavailable */ }
|
|
274
|
+
setLatest(null)
|
|
275
|
+
}
|
|
276
|
+
return (
|
|
277
|
+
<div className="sh-update">
|
|
278
|
+
<span><b>{latest}</b> is out</span>
|
|
279
|
+
<Tip side="top" label="Copy, then paste to your terminal or your agent">
|
|
280
|
+
<button className="cmd" onClick={() => {
|
|
281
|
+
const t = useStore.getState().toast
|
|
282
|
+
navigator.clipboard?.writeText(cmd).then(() => t('update command copied'), () => t('copy blocked - select it manually'))
|
|
283
|
+
?? t('copy unavailable - select it manually')
|
|
284
|
+
}}><code>{cmd}</code></button>
|
|
285
|
+
</Tip>
|
|
286
|
+
<Tip side="top" label="Dismiss this version">
|
|
287
|
+
<button className="x" onClick={dismiss}><XIcon size={13} /></button>
|
|
288
|
+
</Tip>
|
|
289
|
+
</div>
|
|
290
|
+
)
|
|
291
|
+
}
|
|
292
|
+
|
|
254
293
|
const ZOOMS = [2, 1.5, 1, 0.5, 0.25, 0.1]
|
|
255
294
|
|
|
256
295
|
/** Zoom preset dropdown on the percentage readout. */
|
|
@@ -642,7 +681,10 @@ export function App() {
|
|
|
642
681
|
|
|
643
682
|
<PlayOverlay />
|
|
644
683
|
|
|
645
|
-
{CONFIG.
|
|
684
|
+
{CONFIG.setup
|
|
685
|
+
? <div className="sh-banner">no app detected - designs would be built from nothing. See design/instructions/setup.md, then restart</div>
|
|
686
|
+
: CONFIG.noTheme && <div className="sh-banner">no theme configured - frames render unstyled. Create design/theme.css importing your app's stylesheet (or set theme in design/config.ts)</div>}
|
|
687
|
+
<UpdatePill />
|
|
646
688
|
|
|
647
689
|
<div className="sh-toasts">
|
|
648
690
|
{toasts.map((t) => <div key={t.id} className="sh-toast"><CheckIcon size={12} /> {t.text}</div>)}
|
|
@@ -11,6 +11,9 @@ import shData from 'virtual:sh-data'
|
|
|
11
11
|
* is where `/` opens - never a synthesized aggregate of a filtered build. */
|
|
12
12
|
const DATA: { manifest: Manifest; boards: Record<string, unknown>; names: string[]; default: string } | null = shData
|
|
13
13
|
|
|
14
|
+
/** True on a published static canvas - no dev server, no API, no update checks. */
|
|
15
|
+
export const PUBLISHED = DATA !== null
|
|
16
|
+
|
|
14
17
|
export interface FrameEntry { id: string; file: string; kind: 'tsx' | 'html'; scene: string; title?: string; viewport?: string; theme?: string }
|
|
15
18
|
export interface Manifest { frames: FrameEntry[]; scenes: { name: string; frames: number }[] }
|
|
16
19
|
export interface Node {
|
|
@@ -27,7 +30,7 @@ export interface Node {
|
|
|
27
30
|
}
|
|
28
31
|
export interface Toast { id: number; text: string }
|
|
29
32
|
|
|
30
|
-
export const CONFIG: { viewports: Record<string, { width: number; height: number }>; themes: string[]; zoomSpeed?: number; noTheme: boolean } = shConfig
|
|
33
|
+
export const CONFIG: { viewports: Record<string, { width: number; height: number }>; themes: string[]; zoomSpeed?: number; noTheme: boolean; setup?: boolean } = shConfig
|
|
31
34
|
|
|
32
35
|
export const cap = (s: string) => (s ? s[0].toUpperCase() + s.slice(1) : s)
|
|
33
36
|
|
|
@@ -194,7 +194,7 @@ body.sh-space .sh-node { pointer-events: none } /* space-drag
|
|
|
194
194
|
.sh-handle.se { right: -5px; bottom: -5px; width: 11px; height: 11px; cursor: nwse-resize }
|
|
195
195
|
|
|
196
196
|
/* glass surfaces share one recipe; chrome text is never selectable (shift-click = multi-select) */
|
|
197
|
-
.sh-ctx, .sh-panel, .sh-fab, .sh-pill, .sh-pill-fab, .sh-menu, .sh-banner, .sh-toast {
|
|
197
|
+
.sh-ctx, .sh-panel, .sh-fab, .sh-pill, .sh-pill-fab, .sh-menu, .sh-banner, .sh-toast, .sh-update {
|
|
198
198
|
background: var(--glass); backdrop-filter: var(--blur); -webkit-backdrop-filter: var(--blur);
|
|
199
199
|
border: 1px solid var(--glass-brd); box-shadow: var(--shadow-glass); position: relative;
|
|
200
200
|
user-select: none; -webkit-user-select: none }
|
|
@@ -202,7 +202,7 @@ body.sh-space .sh-node { pointer-events: none } /* space-drag
|
|
|
202
202
|
/* edge light: a 1px gradient ring over the border, so glass reads as glass even
|
|
203
203
|
with nothing behind it to blur (masked-border technique) */
|
|
204
204
|
.sh-ctx::after, .sh-panel::after, .sh-fab::after, .sh-pill::after, .sh-pill-fab::after,
|
|
205
|
-
.sh-menu::after, .sh-banner::after, .sh-toast::after, .sh-node::after {
|
|
205
|
+
.sh-menu::after, .sh-banner::after, .sh-toast::after, .sh-update::after, .sh-node::after {
|
|
206
206
|
content: ''; position: absolute; inset: -1px; padding: 1px; border-radius: inherit;
|
|
207
207
|
background: var(--edge-light); pointer-events: none;
|
|
208
208
|
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
|
|
@@ -404,6 +404,25 @@ body.sh-space .sh-node { pointer-events: none } /* space-drag
|
|
|
404
404
|
.sh-banner { position: absolute; bottom: var(--edge); right: var(--edge); z-index: 10; font: 500 12px -apple-system, system-ui, sans-serif;
|
|
405
405
|
color: var(--glass-warn); border-radius: 999px; padding: 8px 14px }
|
|
406
406
|
|
|
407
|
+
/* update pill: bottom-center, the quietest possible member of the pill family.
|
|
408
|
+
The command IS the button - one click copies it for the terminal or the agent. */
|
|
409
|
+
.sh-update { position: absolute; left: 50%; bottom: var(--edge); transform: translateX(-50%); z-index: 10;
|
|
410
|
+
display: flex; align-items: center; gap: 10px; height: 40px; padding: 0 7px 0 16px; border-radius: 999px;
|
|
411
|
+
max-width: min(92vw, 620px); white-space: nowrap;
|
|
412
|
+
font: 500 12.5px -apple-system, system-ui, sans-serif; color: var(--glass-ink-2);
|
|
413
|
+
animation: sh-update-in .35s cubic-bezier(.2, .9, .3, 1.2) }
|
|
414
|
+
@keyframes sh-update-in { from { opacity: 0; transform: translate(-50%, 8px) scale(.96) } }
|
|
415
|
+
.sh-update b { font-weight: 650; color: var(--glass-ink) }
|
|
416
|
+
.sh-update .cmd { border: 1px solid var(--glass-brd); background: var(--glass-hover); border-radius: 999px;
|
|
417
|
+
padding: 4px 11px; cursor: pointer; min-width: 0; overflow: hidden; transition: color .15s }
|
|
418
|
+
.sh-update .cmd code { font: 500 11.5px ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--glass-ink-2);
|
|
419
|
+
display: block; overflow: hidden; text-overflow: ellipsis }
|
|
420
|
+
.sh-update .cmd:hover code { color: var(--glass-ink) }
|
|
421
|
+
.sh-update .x { display: inline-flex; align-items: center; justify-content: center; width: 26px; height: 26px;
|
|
422
|
+
border: 0; background: none; border-radius: 999px; color: var(--glass-ink-3); cursor: pointer;
|
|
423
|
+
transition: color .15s, background .15s }
|
|
424
|
+
.sh-update .x:hover { background: var(--glass-hover); color: var(--glass-ink) }
|
|
425
|
+
|
|
407
426
|
.sh-toasts { position: absolute; left: var(--edge); bottom: var(--edge); z-index: 12; display: flex; flex-direction: column; gap: 6px }
|
|
408
427
|
.sh-toast { display: flex; align-items: center; gap: 6px; font: 500 12px -apple-system, system-ui, sans-serif;
|
|
409
428
|
color: var(--glass-ink-2); border-radius: 999px; padding: 7px 13px }
|
|
@@ -3,6 +3,29 @@
|
|
|
3
3
|
You design by writing files. The canvas at the printed localhost URL reflects them live.
|
|
4
4
|
Never run or talk to the canvas tool; read and write files only.
|
|
5
5
|
|
|
6
|
+
## The method (binding)
|
|
7
|
+
|
|
8
|
+
Design work moves through phases. BEFORE working in a phase, read its instruction
|
|
9
|
+
file in design/instructions/ - they are short, strict, and part of this contract:
|
|
10
|
+
|
|
11
|
+
| Phase | When | Read |
|
|
12
|
+
|---|---|---|
|
|
13
|
+
| Configure | first session in a repo, or frames render unstyled | instructions/configure.md |
|
|
14
|
+
| Discover | any new surface, feature, or flow | instructions/discover.md |
|
|
15
|
+
| Wireframe | new work: nail structure + copy in throwaway lo-fi | instructions/wireframe.md |
|
|
16
|
+
| Brand | before the first hi-fi work: extract or create the world | instructions/brand.md |
|
|
17
|
+
| Build | hi-fi frames from real components | instructions/craft.md + components.md |
|
|
18
|
+
| Review | before presenting anything | instructions/review.md |
|
|
19
|
+
| Boards | creating a board or publishing | instructions/boards.md |
|
|
20
|
+
|
|
21
|
+
Refining an existing screen: Configure must hold, then Build + Review. New work runs
|
|
22
|
+
the full ladder. Unsure which phase you are in? Ask the human - one question beats a
|
|
23
|
+
phase of wrong work.
|
|
24
|
+
|
|
25
|
+
Stuck, or the human is unhappy with a result? instructions/reference/ holds the deep
|
|
26
|
+
guides (layout, typography, color, motion, copy, states, tuning, critique, concepts) -
|
|
27
|
+
the routing index is at the top of instructions/craft.md. Pull ONE file, apply, return.
|
|
28
|
+
|
|
6
29
|
## Frames
|
|
7
30
|
- A frame = one file: design/scenes/<scene>/<name>.tsx or .html. One frame, one surface.
|
|
8
31
|
- It default-exports a React component. No imports from the tool are needed. Optional:
|
|
@@ -35,7 +58,7 @@ Never run or talk to the canvas tool; read and write files only.
|
|
|
35
58
|
convention). The root design/scenes/_layout.tsx mounts the app's real shell component.
|
|
36
59
|
|
|
37
60
|
## Fixtures
|
|
38
|
-
- design/scenes/<scene>/_fixtures.ts - typed plain objects shaped like the
|
|
61
|
+
- design/scenes/<scene>/_fixtures.ts - typed plain objects shaped like the component's PROPS (containers map real APIs into them at promotion; see instructions/components.md).
|
|
39
62
|
- Fixture shapes should match the component's props so tsc catches drift.
|
|
40
63
|
- Loading states are fixtures too: export const slowOrders = () => new Promise(r =>
|
|
41
64
|
setTimeout(() => r(orders), 800)) and let the frame render its skeleton while awaiting.
|
|
@@ -59,29 +82,7 @@ Never run or talk to the canvas tool; read and write files only.
|
|
|
59
82
|
|
|
60
83
|
## Boards (curated canvases)
|
|
61
84
|
|
|
62
|
-
A board is a saved canvas: `design/boards/<name>.json`
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
```json
|
|
68
|
-
{ "version": 1, "name": "checkout-compare", "auto": false,
|
|
69
|
-
"nodes": [ { "frame": "checkout-a/cart" }, { "frame": "checkout-b/cart" } ] }
|
|
70
|
-
```
|
|
71
|
-
|
|
72
|
-
- The same frame may appear on many boards, or twice on one board (e.g. two widths:
|
|
73
|
-
add `"w"`/`"h"` on a node to pin a size, `"x"`/`"y"` to place it - e.g. a comparison
|
|
74
|
-
row: same `y`, increasing `x`). Know that the human's tidy (`t`) and device views
|
|
75
|
-
re-layout in frame-id order, so id ordering is the durable arrangement; explicit
|
|
76
|
-
coordinates are for one-off setups.
|
|
77
|
-
- `auto: false` boards show exactly their list. The `all-scenes` board is auto-managed -
|
|
78
|
-
never write it.
|
|
79
|
-
- Use boards for comparisons: version A vs B vs C of a flow, side by side.
|
|
80
|
-
- Boards are also the unit of PUBLISHING (`marver build --boards <name>`): a flow being
|
|
81
|
-
published must have every frame it data-gotos on that board - unlisted frames are
|
|
82
|
-
excluded from the bundle at build time. The published gate page shows the app's
|
|
83
|
-
identity: design/logo.svg + the host package name (overridable via config `share`).
|
|
84
|
-
If the app has no logo asset yet, create a simple design/logo.svg for it.
|
|
85
|
-
Leave `share.branding` ON unless the human explicitly asks to remove it: Marver is
|
|
86
|
-
free, the gate is already fully personalized to the app, and the small "Powered by"
|
|
87
|
-
line is how the tool spreads. Do not remove it as part of "branding polish".
|
|
85
|
+
A board is a saved canvas: `design/boards/<name>.json` - you create and manage them
|
|
86
|
+
by writing files; `all-scenes` is auto-managed, never write it. BEFORE creating a
|
|
87
|
+
board or publishing anything, read instructions/boards.md (file format, layout
|
|
88
|
+
durability, publishing rules).
|