@marver-design/marver 0.2.2 → 0.2.4
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/dist/{build-CNoXE13J.mjs → build-Bqd6OEsQ.mjs} +2 -2
- package/dist/cli.mjs +3 -3
- package/dist/{dev-Blyy4jOL.mjs → dev-9-80L5i5.mjs} +2 -2
- package/dist/{init-3h9pXEzp.mjs → init-Di4geblA.mjs} +171 -24
- package/dist/{manifest-CHmKAAtG.mjs → manifest-mYlO_1Pj.mjs} +78 -2
- package/dist/{plugin-DiDJA9n-.mjs → plugin-YVpBNTB3.mjs} +1 -1
- package/package.json +1 -1
- package/src/client/shell/App.tsx +78 -16
- package/src/client/shell/Play.tsx +36 -0
- package/src/client/shell/canvas/Canvas.tsx +54 -7
- package/src/client/shell/canvas/FrameNode.tsx +14 -1
- package/src/client/shell/icons.tsx +2 -0
- package/src/client/shell/store.ts +115 -14
- package/src/client/shell/styles.css +86 -0
- package/src/client/shell/tidy.ts +347 -17
- package/src/client/stage/main.tsx +11 -3
- package/templates/AGENTS-embedded.md +25 -11
- package/templates/AGENTS-studio.md +25 -11
- package/templates/design-tsconfig.json +7 -2
- package/templates/instructions/boards.md +47 -1
- package/templates/instructions/configure.md +5 -2
- package/templates/instructions/discover.md +8 -1
- package/templates/instructions/welcome.md +122 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { r as ROUTE, t as NAME } from "./cli.mjs";
|
|
2
|
-
import { a as loadConfig, n as scanFrames, o as detectHost } from "./manifest-
|
|
3
|
-
import { n as tailwind3Css, r as tailwind4Plugin, t as marverPlugin } from "./plugin-
|
|
2
|
+
import { a as loadConfig, n as scanFrames, o as detectHost } from "./manifest-mYlO_1Pj.mjs";
|
|
3
|
+
import { n as tailwind3Css, r as tailwind4Plugin, t as marverPlugin } from "./plugin-YVpBNTB3.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-Di4geblA.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-9-80L5i5.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-Bqd6OEsQ.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
|
-
import { a as loadConfig, o as detectHost } from "./manifest-
|
|
3
|
-
import { n as tailwind3Css, r as tailwind4Plugin, t as marverPlugin } from "./plugin-
|
|
2
|
+
import { a as loadConfig, o as detectHost } from "./manifest-mYlO_1Pj.mjs";
|
|
3
|
+
import { n as tailwind3Css, r as tailwind4Plugin, t as marverPlugin } from "./plugin-YVpBNTB3.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,5 +1,5 @@
|
|
|
1
1
|
import { t as NAME } from "./cli.mjs";
|
|
2
|
-
import { i as DEFAULTS, n as scanFrames, o as detectHost, r as writeManifest } from "./manifest-
|
|
2
|
+
import { i as DEFAULTS, n as scanFrames, o as detectHost, r as writeManifest, s as readJson } from "./manifest-mYlO_1Pj.mjs";
|
|
3
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";
|
|
@@ -98,21 +98,40 @@ function init(root, opts) {
|
|
|
98
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
99
|
} else if (e.name.endsWith(".md")) writeManaged(`instructions/${e.name}`, readFileSync(join(instrRoot, e.name), "utf8"));
|
|
100
100
|
const setupPath = join(design, "instructions", "setup.md");
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
101
|
+
const setupState = () => {
|
|
102
|
+
if (!existsSync(setupPath)) return "absent";
|
|
103
|
+
const s = readFileSync(setupPath, "utf8");
|
|
104
|
+
if (s.startsWith(MANAGED_PREFIX)) {
|
|
105
|
+
const recorded = s.slice(MANAGED_PREFIX.length).split(" ")[0];
|
|
106
|
+
const nl = s.indexOf("\n");
|
|
107
|
+
return nl >= 0 && hashBody(s.slice(nl + 1)) === recorded ? "ours-pristine" : "ours-edited";
|
|
107
108
|
}
|
|
109
|
+
return s.startsWith("# Setup required") && s.includes("marver init") ? "ours-pristine" : "foreign";
|
|
108
110
|
};
|
|
111
|
+
const setupWas = setupState();
|
|
112
|
+
const appJustAppeared = !noApp(host) && (setupWas === "ours-pristine" || setupWas === "ours-edited");
|
|
109
113
|
if (noApp(host)) {
|
|
110
|
-
if (!
|
|
111
|
-
|
|
114
|
+
if (setupWas === "ours-pristine" && !readFileSync(setupPath, "utf8").startsWith(MANAGED_PREFIX)) rmSync(setupPath);
|
|
115
|
+
if (setupWas !== "foreign") writeManaged("instructions/setup.md", SETUP_MD);
|
|
116
|
+
} else if (setupWas === "ours-pristine") {
|
|
112
117
|
rmSync(setupPath);
|
|
113
118
|
console.log(` - design/instructions/setup.md removed (app detected - setup complete)`);
|
|
119
|
+
} else if (setupWas === "ours-edited") console.log(` - design/instructions/setup.md: app detected, but you customized the file - delete it yourself when setup is done`);
|
|
120
|
+
const rootTsconfig = existsSync(join(root, "tsconfig.json"));
|
|
121
|
+
const tsconfigNow = () => rootTsconfig ? readFileSync(join(templates, "design-tsconfig.json"), "utf8").replace("{{PATHS}}", designPaths(root)) : STANDALONE_TSCONFIG;
|
|
122
|
+
write("tsconfig.json", tsconfigNow());
|
|
123
|
+
if (appJustAppeared) {
|
|
124
|
+
const refresh = (rel, noAppPristine, next) => {
|
|
125
|
+
try {
|
|
126
|
+
if (!noAppPristine.includes(next) && noAppPristine.includes(readFileSync(join(design, rel), "utf8"))) {
|
|
127
|
+
writeFileSync(join(design, rel), next);
|
|
128
|
+
created.push(`design/${rel} (updated for the detected app)`);
|
|
129
|
+
}
|
|
130
|
+
} catch {}
|
|
131
|
+
};
|
|
132
|
+
refresh("providers.tsx", [providersTemplate(null, null), providersTemplate(null, host.toaster, host.routerPkg)], providersTemplate(host.router, host.toaster, host.routerPkg));
|
|
133
|
+
refresh("tsconfig.json", [STANDALONE_TSCONFIG], tsconfigNow());
|
|
114
134
|
}
|
|
115
|
-
write("tsconfig.json", existsSync(join(root, "tsconfig.json")) ? readFileSync(join(templates, "design-tsconfig.json"), "utf8") : STANDALONE_TSCONFIG);
|
|
116
135
|
write(".gitignore", ".local/\n.dist/\n");
|
|
117
136
|
write("scenes/_layout.tsx", readFileSync(join(templates, "root-layout.tsx"), "utf8"));
|
|
118
137
|
if (!existsSync(join(design, "boards"))) {
|
|
@@ -134,14 +153,16 @@ function init(root, opts) {
|
|
|
134
153
|
│ No framework, no theme CSS, no component library. ${NAME} builds │
|
|
135
154
|
│ frames from YOUR components - with none, designs get thrown away. │
|
|
136
155
|
│ │
|
|
137
|
-
│ Setup instructions: design/instructions/setup.md.
|
|
138
|
-
│
|
|
139
|
-
│
|
|
156
|
+
│ Setup instructions: design/instructions/setup.md. Your agent will │
|
|
157
|
+
│ ask what you are building, propose a stack, set it up with you, and │
|
|
158
|
+
│ re-run init - that file then removes itself. AGENTS.md points there │
|
|
159
|
+
│ so nothing gets designed against components that do not exist. │
|
|
140
160
|
└───────────────────────────────────────────────────────────────────────┘`);
|
|
141
161
|
console.log(`\n commit design/ - only .local/ is ignored`);
|
|
142
162
|
console.log(` uninstall = delete design/, remove the ${NAME} dependency${host.tsconfigSweepsDesign ? ", revert the \"design\" line in tsconfig exclude" : ""}`);
|
|
163
|
+
if (!noApp(host) && !existsSync(join(design, "DESIGN.md"))) console.log(`\n note: design/DESIGN.md (the brand doc) does not exist yet - have your agent create it from the app's tokens (instructions/brand.md, Path A) to reach the idle state.`);
|
|
143
164
|
console.log(`\n next: npx ${NAME} dev (canvas on http://localhost:${DEFAULTS.port} by default)\n`);
|
|
144
|
-
if (!noApp(host)) console.log(` then, to your agent: "Read design/AGENTS.md.
|
|
165
|
+
if (!noApp(host)) console.log(` then, to your agent: "Read design/AGENTS.md. This is our first session - follow design/instructions/welcome.md."\n`);
|
|
145
166
|
}
|
|
146
167
|
const MANAGED_PREFIX = "<!-- marver:managed ";
|
|
147
168
|
const LEGACY_PREFIX = "<!-- generated by marver init";
|
|
@@ -150,13 +171,26 @@ const hashBody = (s) => createHash("sha256").update(s).digest("hex");
|
|
|
150
171
|
* Edit freely - init preserves edits and stages upstream updates for merging.
|
|
151
172
|
* Deleting the marker line detaches the file from updates entirely. */
|
|
152
173
|
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}`;
|
|
174
|
+
/** The host's path aliases, re-rooted one level down for design/tsconfig.json.
|
|
175
|
+
* "./src/*" becomes "../src/*" so `@/` imports resolve from inside design/. */
|
|
176
|
+
function designPaths(root) {
|
|
177
|
+
const hostPaths = readJson(join(root, "tsconfig.json"))?.compilerOptions?.paths;
|
|
178
|
+
if (!hostPaths || typeof hostPaths !== "object") return "";
|
|
179
|
+
const rerooted = {};
|
|
180
|
+
for (const [alias, targets] of Object.entries(hostPaths)) {
|
|
181
|
+
if (!Array.isArray(targets)) continue;
|
|
182
|
+
rerooted[alias] = targets.map((t) => typeof t === "string" ? t.startsWith("./") ? `../${t.slice(2)}` : `../${t}` : t);
|
|
183
|
+
}
|
|
184
|
+
if (!Object.keys(rerooted).length) return "";
|
|
185
|
+
return `,\n // the host's aliases, re-rooted (inherited paths resolve against the WRONG dir)\n "paths": ${JSON.stringify(rerooted)}`;
|
|
186
|
+
}
|
|
153
187
|
/** No framework, no theme, no component alias = nothing to build frames FROM. */
|
|
154
188
|
const noApp = (host) => !host.router && !host.tailwind && !host.shadcn && !host.themeCss;
|
|
155
189
|
/** The UI line of AGENTS.md, matched to what detection actually found (friction log #1).
|
|
156
190
|
* The STOP branch fires only on the same condition that creates SETUP.md - an app
|
|
157
191
|
* without Tailwind (plain React + CSS) gets guidance, never a dead pointer. */
|
|
158
192
|
function uiGuidance(host, isNoApp) {
|
|
159
|
-
if (isNoApp) return `
|
|
193
|
+
if (isNoApp) return `Setup required - this repo has no app yet: read design/instructions/setup.md and follow it before designing anything.`;
|
|
160
194
|
if (host.shadcn) return `Use the app's UI: import from ${host.shadcn.uiAlias}; style with the app's Tailwind classes.`;
|
|
161
195
|
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/.`;
|
|
162
196
|
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/.`;
|
|
@@ -169,28 +203,141 @@ const SETUP_MD = `# Setup required - this repo has no app yet
|
|
|
169
203
|
> the real stack. While this file exists, DO NOT design.
|
|
170
204
|
|
|
171
205
|
${NAME} builds frames from YOUR components and YOUR theme. With none, frames become
|
|
172
|
-
hand-rolled CSS that shares nothing with the future app
|
|
173
|
-
|
|
206
|
+
hand-rolled CSS that shares nothing with the future app - throwaway work. So the
|
|
207
|
+
first session sets up the stack - TOGETHER with the human. The stack is their
|
|
208
|
+
decision; your job is a good recommendation and a smooth setup. Narrate every step
|
|
209
|
+
in one plain line as you go - and tell the story, not the machinery: this file is
|
|
210
|
+
stage directions, never read it aloud to the human ("setup.md says...", "step 2
|
|
211
|
+
requires..."). Voice rules and the structured-question guidance live in
|
|
212
|
+
instructions/welcome.md - read that section before you say anything.
|
|
213
|
+
|
|
214
|
+
## 1. Greet and explain
|
|
215
|
+
|
|
216
|
+
Tell the human the repo is empty and that this is a perfect starting point. Then
|
|
217
|
+
the pitch, ~4 sentences in your own words (source: instructions/welcome.md): we design
|
|
218
|
+
in real code; the theme, components, and screens made while designing ARE the
|
|
219
|
+
app's building blocks; by the time the design is agreed most of the UI work
|
|
220
|
+
exists, and building the product means plugging functionality in; the goal is
|
|
221
|
+
alignment on look and feel across themes and devices first.
|
|
222
|
+
|
|
223
|
+
## 2. Ask what they are building - STOP
|
|
224
|
+
|
|
225
|
+
Two questions, one message (use the harness's structured question tool if it
|
|
226
|
+
has one):
|
|
227
|
+
|
|
228
|
+
1. "In a sentence or two - what are we building?"
|
|
229
|
+
2. "Any intuition for the look? Colors, mood, UI style - a sentence like
|
|
230
|
+
'minimalist, glass UI, witty copy' steers everything. 'Surprise me' is a
|
|
231
|
+
fine answer."
|
|
232
|
+
|
|
233
|
+
Then STOP: no further tool calls, end your turn, resume only after the human
|
|
234
|
+
replies. (The one exception: the human explicitly asked for unattended
|
|
235
|
+
execution - then assume something reasonable, mark it UNCONFIRMED, surface it
|
|
236
|
+
first.)
|
|
174
237
|
|
|
175
|
-
##
|
|
238
|
+
## 3. Propose the stack - STOP
|
|
176
239
|
|
|
177
|
-
|
|
240
|
+
From their answer, recommend a framework with one line of reasoning each:
|
|
241
|
+
|
|
242
|
+
- Marketing site, content, SEO -> latest Next.js.
|
|
243
|
+
- App-like, interactive, client-heavy -> latest React Router.
|
|
244
|
+
- Their answer points somewhere else? Recommend that instead, and say why.
|
|
245
|
+
|
|
246
|
+
If you can search the web, verify current major versions first - one or two
|
|
247
|
+
searches, then propose. Recommend shadcn/ui + latest Tailwind as the default
|
|
248
|
+
component layer (take their current defaults) - but they are a recommendation,
|
|
249
|
+
not a requirement: if the human prefers another component library, plain CSS,
|
|
250
|
+
or their own design system, that wins. Any React + a real stylesheet works;
|
|
251
|
+
${NAME} adapts to what detection finds. Present the proposal with the
|
|
252
|
+
harness's structured question tool when it has one - short labels, one-line
|
|
253
|
+
trade-offs - closing with "aligned, or tell me what you'd rather use". Then
|
|
254
|
+
STOP again: no further tool calls, end your turn, wait for the nod.
|
|
255
|
+
|
|
256
|
+
## 4. Hand them the tour, then scaffold
|
|
257
|
+
|
|
258
|
+
The setup and first draft take real minutes; the human should spend them
|
|
259
|
+
learning the canvas, not watching a terminal. The moment the stack is agreed,
|
|
260
|
+
send them to the ${NAME} tour - a published canvas we host, built to be
|
|
261
|
+
explored: https://tour.marver.design - password \`welcome\`. Tell them it teaches
|
|
262
|
+
selection, devices, themes, variants, and play mode from inside the canvas,
|
|
263
|
+
and that it ends by sending them back to check on you. (Unreachable from this
|
|
264
|
+
machine? Say so and skip it - never stall on it.) Then get to work.
|
|
265
|
+
|
|
266
|
+
NOTE: scaffolders refuse non-empty directories (this repo already holds design/
|
|
267
|
+
and a package.json - the one you likely created with \`npm init -y\` to install
|
|
268
|
+
${NAME} into), so scaffold into a temp dir and merge. The Next.js lane - adapt
|
|
269
|
+
the same shape to whatever stack was agreed:
|
|
178
270
|
|
|
179
271
|
\`\`\`bash
|
|
180
|
-
npx create-next-app@latest
|
|
272
|
+
npx create-next-app@latest app-scaffold --ts --tailwind --app --src-dir --yes
|
|
273
|
+
# move everything from app-scaffold/ up to the repo root EXCEPT its package.json;
|
|
274
|
+
# merge app-scaffold/package.json's dependencies+scripts into the existing one,
|
|
275
|
+
# then: rm -rf app-scaffold && npm install
|
|
181
276
|
npx shadcn@latest init
|
|
182
277
|
\`\`\`
|
|
183
278
|
|
|
184
|
-
|
|
279
|
+
Scaffolder flags drift between versions (create-next-app and shadcn both) - if
|
|
280
|
+
a flag errors or a prompt appears despite --yes, accept the tool's defaults.
|
|
281
|
+
|
|
282
|
+
Known scaffold bug if shadcn was agreed (hit on every create-next-app + shadcn
|
|
283
|
+
run so far): shadcn's
|
|
284
|
+
init rewrites the theme CSS's \`@theme inline\` block and leaves
|
|
285
|
+
\`--font-sans: var(--font-sans)\` - self-referential, resolves to nothing, and
|
|
286
|
+
the app silently renders in the browser's default font. After shadcn init,
|
|
287
|
+
open the theme CSS and bind every font token to a variable that actually
|
|
288
|
+
exists (e.g. \`--font-sans: var(--font-geist-sans)\` under Next); check
|
|
289
|
+
--font-heading and friends for the same circularity.
|
|
185
290
|
|
|
186
|
-
|
|
291
|
+
Then START the dev server and
|
|
292
|
+
confirm the starter page renders before moving on. Unsure about the stack's
|
|
293
|
+
conventions? Fetch its docs.
|
|
294
|
+
|
|
295
|
+
## 5. Re-run init
|
|
187
296
|
|
|
188
297
|
\`\`\`bash
|
|
189
298
|
npx ${NAME} init
|
|
190
299
|
\`\`\`
|
|
191
300
|
|
|
192
|
-
init is idempotent: it
|
|
193
|
-
|
|
301
|
+
init is idempotent: it detects the real stack, deletes this file, and
|
|
302
|
+
regenerates AGENTS.md against reality. Verify the wiring (instructions/
|
|
303
|
+
configure.md): frames render styled, one app component imports cleanly.
|
|
304
|
+
DESIGN.md comes next, as part of the first draft.
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
## 6. The first draft - make it impressive, then tour
|
|
308
|
+
|
|
309
|
+
This is the human's first impression of the canvas AND the first draft of their
|
|
310
|
+
product - it sets the direction. Take the time to do it well:
|
|
311
|
+
|
|
312
|
+
- If you can search the web, spend a few minutes understanding the domain from
|
|
313
|
+
step 2's answer; write design/DESIGN.md. The human's look intuition from
|
|
314
|
+
step 2 is the north star - honor it literally. If they said "surprise me",
|
|
315
|
+
commit to a direction and name it in one sentence at the reveal.
|
|
316
|
+
- Build ~4 frames of THEIR product - not lorem, not filler. Hold them to the
|
|
317
|
+
craft bar: instructions/craft.md and instructions/reference/slop.md are
|
|
318
|
+
binding here. Responsive, working in BOTH themes, linked with data-goto so
|
|
319
|
+
play mode flows.
|
|
320
|
+
- UNDERWHELMING IS THE FAILURE MODE. A restrained concept executed thinly
|
|
321
|
+
reads as a wireframe, however careful the type. Every frame needs presence -
|
|
322
|
+
scale, contrast, color, one real visual moment - and the human should feel
|
|
323
|
+
the direction before they read a word. Write the copy like it ships:
|
|
324
|
+
specific, confident, witty where the brand allows, never placeholder. This
|
|
325
|
+
first draft is the product's first impression AND ${NAME}'s - go above and
|
|
326
|
+
beyond.
|
|
327
|
+
- Delete the generic demo scene (design/scenes/demo/) once your frames are in -
|
|
328
|
+
it exists to show YOU the file shapes, not to impress anyone.
|
|
329
|
+
- Create a curated board for them (instructions/boards.md) containing every
|
|
330
|
+
frame the flow visits.
|
|
331
|
+
- Offer a divergence: "want a variant of <frame> exploring a different
|
|
332
|
+
direction?" - one a-/b- pair teaches the variant workflow better than any
|
|
333
|
+
explanation.
|
|
334
|
+
|
|
335
|
+
This first draft skips the written-brief ceremony (the human just told you what
|
|
336
|
+
they are building) but never the quality bar. Then THE REVEAL: start
|
|
337
|
+
\`npx ${NAME} dev\` and give the guided tour from instructions/welcome.md -
|
|
338
|
+
by now the human has played with the hosted tour, so keep it short and let
|
|
339
|
+
their own product carry it. End with the deep link using the PRINTED port -
|
|
340
|
+
\`http://localhost:<port>/#/b/<board>\`, never the bare root URL.
|
|
194
341
|
`;
|
|
195
342
|
/** Next.js frames render OUTSIDE Next - say concretely what that means (friction log #10/#11). */
|
|
196
343
|
const NEXT_NOTES = `- Next.js caveats (frames render in Vite, outside Next):
|
|
@@ -204,7 +204,7 @@ function extractMeta(src) {
|
|
|
204
204
|
if (!m) return {};
|
|
205
205
|
const body = m[1];
|
|
206
206
|
const pick = (key) => {
|
|
207
|
-
const r = new RegExp(
|
|
207
|
+
const r = new RegExp(`(?:^|[{,])\\s*${key}\\s*:\\s*(['"\`])([^'"\`]*)\\1`).exec(body);
|
|
208
208
|
return r ? r[2] : void 0;
|
|
209
209
|
};
|
|
210
210
|
const out = {};
|
|
@@ -214,6 +214,10 @@ function extractMeta(src) {
|
|
|
214
214
|
if (viewport) out.viewport = viewport;
|
|
215
215
|
const theme = pick("theme");
|
|
216
216
|
if (theme) out.theme = theme;
|
|
217
|
+
const of = pick("of");
|
|
218
|
+
if (of) out.of = of;
|
|
219
|
+
const variant = pick("variant");
|
|
220
|
+
if (variant) out.variant = variant;
|
|
217
221
|
return out;
|
|
218
222
|
}
|
|
219
223
|
/** id = path relative to design/, extension dropped, `scenes/` prefix dropped. Always `/`-separated. */
|
|
@@ -256,6 +260,8 @@ function scanFrames(root) {
|
|
|
256
260
|
if (meta.title) entry.title = meta.title;
|
|
257
261
|
if (meta.viewport) entry.viewport = meta.viewport;
|
|
258
262
|
if (meta.theme) entry.theme = meta.theme;
|
|
263
|
+
if (meta.of) entry.variantGroup = meta.of;
|
|
264
|
+
if (meta.variant) entry.variant = meta.variant;
|
|
259
265
|
}
|
|
260
266
|
frames.push(entry);
|
|
261
267
|
}
|
|
@@ -273,6 +279,7 @@ function scanFrames(root) {
|
|
|
273
279
|
});
|
|
274
280
|
frames.length = 0;
|
|
275
281
|
frames.push(...deduped);
|
|
282
|
+
inferVariantGroups(frames);
|
|
276
283
|
const sceneCounts = /* @__PURE__ */ new Map();
|
|
277
284
|
for (const f of frames) sceneCounts.set(f.scene, (sceneCounts.get(f.scene) ?? 0) + 1);
|
|
278
285
|
return {
|
|
@@ -283,6 +290,75 @@ function scanFrames(root) {
|
|
|
283
290
|
})).sort((a, b) => a.name.localeCompare(b.name))
|
|
284
291
|
};
|
|
285
292
|
}
|
|
293
|
+
/** Variant groups (SPEC-023 §1). A group = 2+ frames in one DIRECTORY whose basenames
|
|
294
|
+
* are letter-prefixed (`a-terminal`), or frames declaring `meta.of`. Group id = the
|
|
295
|
+
* directory's id prefix (or meta.of); variant key = the letter (or meta.variant).
|
|
296
|
+
* Nested directories scope alternatives inside a busy scene (checkout/payment/a-card).
|
|
297
|
+
* States (empty.tsx, error.tsx) never letter-prefix, so they never misgroup.
|
|
298
|
+
* Mutates entries in place: only frames whose group materializes keep the fields. */
|
|
299
|
+
function inferVariantGroups(frames) {
|
|
300
|
+
const dirOf = (id) => {
|
|
301
|
+
const i = id.lastIndexOf("/");
|
|
302
|
+
return i >= 0 ? id.slice(0, i) : "";
|
|
303
|
+
};
|
|
304
|
+
const candidates = /* @__PURE__ */ new Map();
|
|
305
|
+
for (const f of frames) {
|
|
306
|
+
if (f.kind !== "tsx") {
|
|
307
|
+
delete f.variantGroup;
|
|
308
|
+
delete f.variant;
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
let group = f.variantGroup;
|
|
312
|
+
let key = f.variant;
|
|
313
|
+
if (!group) {
|
|
314
|
+
const base = f.id.slice(f.id.lastIndexOf("/") + 1);
|
|
315
|
+
const m = /^([a-z])-.+$/.exec(base);
|
|
316
|
+
if (!m) continue;
|
|
317
|
+
group = dirOf(f.id);
|
|
318
|
+
if (!group) continue;
|
|
319
|
+
key = key ?? m[1];
|
|
320
|
+
} else if (!key) {
|
|
321
|
+
const m = /^([a-z])-.+$/.exec(f.id.slice(f.id.lastIndexOf("/") + 1));
|
|
322
|
+
if (!m) {
|
|
323
|
+
console.warn(`[marver] ${f.file} declares of:"${group}" but no variant key (add meta.variant or a letter prefix) - not grouped.`);
|
|
324
|
+
delete f.variantGroup;
|
|
325
|
+
delete f.variant;
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
key = m[1];
|
|
329
|
+
}
|
|
330
|
+
f.variantGroup = group;
|
|
331
|
+
f.variant = key;
|
|
332
|
+
candidates.set(group, [...candidates.get(group) ?? [], f]);
|
|
333
|
+
}
|
|
334
|
+
for (const [group, members] of candidates) {
|
|
335
|
+
if (new Set(members.map((m) => dirOf(m.id))).size > 1) {
|
|
336
|
+
console.warn(`[marver] group "${group}" spans directories - variants must be siblings; not grouped.`);
|
|
337
|
+
for (const m of members) {
|
|
338
|
+
delete m.variantGroup;
|
|
339
|
+
delete m.variant;
|
|
340
|
+
}
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
const seen = /* @__PURE__ */ new Set();
|
|
344
|
+
const kept = [];
|
|
345
|
+
for (const m of members.sort((a, b) => (a.variant ?? "").localeCompare(b.variant ?? ""))) {
|
|
346
|
+
if (seen.has(m.variant)) {
|
|
347
|
+
console.warn(`[marver] duplicate variant "${m.variant}" in group "${group}" (${m.file}) - not grouped.`);
|
|
348
|
+
delete m.variantGroup;
|
|
349
|
+
delete m.variant;
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
seen.add(m.variant);
|
|
353
|
+
kept.push(m);
|
|
354
|
+
}
|
|
355
|
+
if (kept.length < 2) for (const m of kept) {
|
|
356
|
+
delete m.variantGroup;
|
|
357
|
+
delete m.variant;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
for (const f of frames) if (f.variant && !f.variantGroup) delete f.variant;
|
|
361
|
+
}
|
|
286
362
|
/** Write design/manifest.json only when content changed. Returns the manifest either way. */
|
|
287
363
|
function writeManifest(root, manifest) {
|
|
288
364
|
const file = join(root, "design", "manifest.json");
|
|
@@ -295,4 +371,4 @@ function writeManifest(root, manifest) {
|
|
|
295
371
|
}
|
|
296
372
|
const hash = (s) => createHash("sha256").update(s).digest("hex");
|
|
297
373
|
//#endregion
|
|
298
|
-
export { loadConfig as a, DEFAULTS as i, scanFrames as n, detectHost as o, writeManifest as r, hash as t };
|
|
374
|
+
export { loadConfig as a, DEFAULTS as i, scanFrames as n, detectHost as o, writeManifest as r, readJson as s, hash as t };
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { n as PKG, r as ROUTE, t as NAME } from "./cli.mjs";
|
|
2
|
-
import { n as scanFrames, r as writeManifest, t as hash } from "./manifest-
|
|
2
|
+
import { n as scanFrames, r as writeManifest, t as hash } from "./manifest-mYlO_1Pj.mjs";
|
|
3
3
|
import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, watch, writeFileSync } from "node:fs";
|
|
4
4
|
import { dirname, join, resolve, sep } from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@marver-design/marver",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.4",
|
|
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
|
@@ -6,19 +6,20 @@ 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, XIcon, deviceIcon } from './icons.tsx'
|
|
9
|
+
import { CardsIcon, CardsThreeIcon, CaretIcon, CheckIcon, DevicesIcon, GridIcon, MoonIcon, PanelFilledIcon, PanelHollowIcon, ParallelogramDuoIcon, PlayIcon, PlusIcon, SignpostIcon, SunIcon, VariantsIcon, XIcon, deviceIcon } from './icons.tsx'
|
|
10
10
|
|
|
11
11
|
let booted = false // survives Fast Refresh; see the boot effect
|
|
12
12
|
|
|
13
13
|
/** One collapsible scene group in the sidebar. `held` marks a scene that contains a
|
|
14
14
|
* selected frame - a quiet secondary wash so ancestry survives collapsing the group. */
|
|
15
|
-
function SceneGroup({ name, count, held, children }: { name: string; count: number; held: boolean; children: ReactNode }) {
|
|
15
|
+
function SceneGroup({ name, count, held, onPick, children }: { name: string; count: number; held: boolean; onPick?: () => void; children: ReactNode }) {
|
|
16
16
|
const [open, setOpen] = useState(true)
|
|
17
17
|
return (
|
|
18
18
|
<div>
|
|
19
19
|
<button className={`it${held ? ' held' : ''}`} onClick={() => setOpen(!open)}>
|
|
20
20
|
<CaretIcon size={11} className="tw" style={{ transform: open ? undefined : 'rotate(-90deg)' }} />
|
|
21
|
-
|
|
21
|
+
{/* the NAME selects every frame in the scene; the caret/row still collapses */}
|
|
22
|
+
<span onClick={(e) => { if (!onPick) return; e.stopPropagation(); onPick() }}>{cap(name) || '(root)'}</span>
|
|
22
23
|
<small>{count}</small>
|
|
23
24
|
</button>
|
|
24
25
|
{open && children}
|
|
@@ -151,7 +152,15 @@ function SelectionBar() {
|
|
|
151
152
|
// controls for a selected frame must stay reachable when its top edge is panned
|
|
152
153
|
// off-screen, and must never drift off the sides (friction log #23)
|
|
153
154
|
const centerX = `calc(var(--sh-tx, 0px) + var(--sh-s, 1) * ${(bx0 + bx1) / 2}px)`
|
|
154
|
-
|
|
155
|
+
// a grouped frame carries a caption above it - clear it EXACTLY, in screen terms:
|
|
156
|
+
// frame top - the caption offset (8px screen, world-capped) - the caption's height
|
|
157
|
+
// (screen-clamped 12..18px font) - the bar. Gate on the TOP edge of the selection,
|
|
158
|
+
// not the last-selected frame: a mixed selection whose topmost frames are variants
|
|
159
|
+
// still has a caption to clear (codex final review P2).
|
|
160
|
+
const capAtTop = selNodes.some((n) => n.y === by0 && useStore.getState().frameFor(n)?.variantGroup)
|
|
161
|
+
const rawTop = capAtTop
|
|
162
|
+
? `calc(var(--sh-ty, 0px) + var(--sh-s, 1) * ${by0}px - clamp(4px * var(--sh-s, 1), 8px, 40px * var(--sh-s, 1)) - (clamp(12px, 17px * var(--sh-s, 1), 18px) * 1.4) - 44px)`
|
|
163
|
+
: `calc(var(--sh-ty, 0px) + var(--sh-s, 1) * ${by0}px - 52px)`
|
|
155
164
|
return (
|
|
156
165
|
<div
|
|
157
166
|
className="sh-ctx"
|
|
@@ -633,18 +642,71 @@ export function App() {
|
|
|
633
642
|
<div className="hd" style={{ marginTop: 10 }}>Scenes</div>
|
|
634
643
|
{scenes.map((sc) => (
|
|
635
644
|
<SceneGroup key={sc.name} name={sc.name} count={sc.frames}
|
|
636
|
-
held={frames.some((f) => f.scene === sc.name && selFrames.has(f.id))}
|
|
637
|
-
{
|
|
638
|
-
const
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
)
|
|
647
|
-
|
|
645
|
+
held={frames.some((f) => f.scene === sc.name && selFrames.has(f.id))}
|
|
646
|
+
onPick={() => {
|
|
647
|
+
const keys = nodes.filter((n) => frames.some((f) => f.scene === sc.name && f.id === n.frame) && !n.missing).map((n) => n.key)
|
|
648
|
+
if (!keys.length) return
|
|
649
|
+
useStore.getState().selectMany(keys)
|
|
650
|
+
canvasCtl.fitNodes(keys)
|
|
651
|
+
}}>
|
|
652
|
+
{(() => {
|
|
653
|
+
// variant groups render as ONE surface row with A/B/C chips (SPEC-023 §5)
|
|
654
|
+
const sceneFrames = frames.filter((f) => f.scene === sc.name)
|
|
655
|
+
const nodeFor = (id: string) => nodes.find((x) => x.frame === id && !x.missing) ?? nodes.find((x) => x.frame === id)
|
|
656
|
+
const go = (id: string, shift: boolean) => {
|
|
657
|
+
const n = nodeFor(id)
|
|
658
|
+
if (!n) return
|
|
659
|
+
select(n.key, shift)
|
|
660
|
+
if (!shift) canvasCtl.fitNode(n.key)
|
|
661
|
+
}
|
|
662
|
+
const seen = new Set<string>()
|
|
663
|
+
const rows: ReactNode[] = []
|
|
664
|
+
for (const f of sceneFrames) {
|
|
665
|
+
if (f.variantGroup && !seen.has(f.variantGroup)) {
|
|
666
|
+
seen.add(f.variantGroup)
|
|
667
|
+
const members = sceneFrames.filter((m) => m.variantGroup === f.variantGroup)
|
|
668
|
+
.sort((a, b) => (a.variant ?? '').localeCompare(b.variant ?? ''))
|
|
669
|
+
if (members.length > 1) {
|
|
670
|
+
const rel = f.variantGroup === sc.name ? 'Variants'
|
|
671
|
+
: cap(f.variantGroup.slice(sc.name.length + 1).replace(/-/g, ' '))
|
|
672
|
+
const memberKeys = members.map((m) => nodeFor(m.id)?.key).filter((k): k is string => !!k)
|
|
673
|
+
const allOn = memberKeys.length > 0 && memberKeys.every((k) => selection.includes(k))
|
|
674
|
+
// held = SOME member active (same quiet wash as scene headers) - the
|
|
675
|
+
// group participates without claiming full selection
|
|
676
|
+
const held = !allOn && memberKeys.some((k) => selection.includes(k) || useStore.getState().interact === k)
|
|
677
|
+
// group header: click selects EVERY variant (the quick compare-and-test grab)
|
|
678
|
+
rows.push(
|
|
679
|
+
<div key={`g:${f.variantGroup}`} className={`sub vgroup${allOn ? ' on' : ''}${held ? ' held' : ''}`}
|
|
680
|
+
title="Select all variants"
|
|
681
|
+
onClick={() => { useStore.getState().selectMany(memberKeys); canvasCtl.fitNodes(memberKeys) }}>
|
|
682
|
+
<span className="glabel">{rel}</span>
|
|
683
|
+
<VariantsIcon size={14} className="gicon" />
|
|
684
|
+
</div>,
|
|
685
|
+
)
|
|
686
|
+
// one row per variant: [letter chip] + name, individually selectable
|
|
687
|
+
for (const m of members) {
|
|
688
|
+
const n = nodeFor(m.id)
|
|
689
|
+
const on = !!n && selection.includes(n.key)
|
|
690
|
+
const nm = m.title ?? cap((m.id.split('/').pop() ?? '').replace(/^[a-z]-/, '').replace(/-/g, ' '))
|
|
691
|
+
rows.push(
|
|
692
|
+
<div key={m.id} className={`sub vrow${on ? ' on' : ''}`} onClick={(e) => go(m.id, e.shiftKey)}>
|
|
693
|
+
<span className={`chip${on ? ' on' : ''}`}>{(m.variant ?? '?').toUpperCase()}</span><span className="nm">{nm}</span>
|
|
694
|
+
</div>,
|
|
695
|
+
)
|
|
696
|
+
}
|
|
697
|
+
continue
|
|
698
|
+
}
|
|
699
|
+
} else if (f.variantGroup) continue
|
|
700
|
+
const n = nodeFor(f.id)
|
|
701
|
+
const on = !!n && selection.includes(n.key)
|
|
702
|
+
rows.push(
|
|
703
|
+
<div key={f.id} className={`sub${on ? ' on' : ''}`} onClick={(e) => go(f.id, e.shiftKey)}>
|
|
704
|
+
{cap(f.id.split('/').slice(1).join('/') || f.id)}
|
|
705
|
+
</div>,
|
|
706
|
+
)
|
|
707
|
+
}
|
|
708
|
+
return rows
|
|
709
|
+
})()}
|
|
648
710
|
</SceneGroup>
|
|
649
711
|
))}
|
|
650
712
|
{frames.length === 0 && <div className="sub dim">no frames yet - ask your agent<br />(design/AGENTS.md)</div>}
|
|
@@ -174,6 +174,28 @@ function PlayInner() {
|
|
|
174
174
|
}
|
|
175
175
|
const restart = () => { const list = playList(); if (list.length) goTo(list[0]) }
|
|
176
176
|
|
|
177
|
+
/** Variant siblings of the CURRENT frame present on this board (SPEC-023 §6): the
|
|
178
|
+
* review question is "which direction is better on THIS screen" - switch in place,
|
|
179
|
+
* device and theme preserved, each variant's own data-goto links drive after. */
|
|
180
|
+
const variantList = () => {
|
|
181
|
+
const s = useStore.getState()
|
|
182
|
+
const cur = s.manifest?.frames.find((f) => f.id === s.play?.at)
|
|
183
|
+
if (!cur?.variantGroup) return []
|
|
184
|
+
const onBoard = new Set(s.nodes.filter((n) => !n.missing).map((n) => n.frame))
|
|
185
|
+
if (!onBoard.has(cur.id)) return [] // off-board frame: no coherent control
|
|
186
|
+
return (s.manifest?.frames ?? [])
|
|
187
|
+
.filter((f) => f.variantGroup === cur.variantGroup && f.kind === 'tsx' && onBoard.has(f.id))
|
|
188
|
+
.sort((a, b) => (a.variant ?? '').localeCompare(b.variant ?? ''))
|
|
189
|
+
}
|
|
190
|
+
const switchVariant = (dir: 1 | -1) => {
|
|
191
|
+
const p = useStore.getState().play
|
|
192
|
+
if (!p) return
|
|
193
|
+
const vs = variantList()
|
|
194
|
+
if (vs.length < 2) return
|
|
195
|
+
const i = vs.findIndex((f) => f.id === p.at)
|
|
196
|
+
goTo(vs[(i + dir + vs.length) % vs.length].id)
|
|
197
|
+
}
|
|
198
|
+
|
|
177
199
|
// history restores + walk: swap the stage silently (no sh:stage-at back) and track here
|
|
178
200
|
useEffect(() => {
|
|
179
201
|
playCtl.setAt = (at: string) => {
|
|
@@ -251,6 +273,8 @@ function PlayInner() {
|
|
|
251
273
|
if (key === 'Escape') { exit(); return }
|
|
252
274
|
if (key === 'ArrowRight') { step(1); return }
|
|
253
275
|
if (key === 'ArrowLeft') { step(-1); return }
|
|
276
|
+
if (key === '[') { switchVariant(-1); return }
|
|
277
|
+
if (key === ']') { switchVariant(1); return }
|
|
254
278
|
if (key === 'r') { restart(); return }
|
|
255
279
|
if (key === 'h') { chromeRef.current === 'hidden' ? setChrome('open') : hideAll(); return }
|
|
256
280
|
if (/^Digit[1-9]$/.test(code)) {
|
|
@@ -313,6 +337,7 @@ function PlayInner() {
|
|
|
313
337
|
const names = Object.keys(CONFIG.viewports)
|
|
314
338
|
const list = playList()
|
|
315
339
|
const pos = list.indexOf(play.at)
|
|
340
|
+
const variants = variantList()
|
|
316
341
|
|
|
317
342
|
// whole-pixel wrapper + per-axis scale so the iframe lands exactly on its edges -
|
|
318
343
|
// fractional sizes left subpixel seams glowing at the corners on dark frames
|
|
@@ -393,6 +418,17 @@ function PlayInner() {
|
|
|
393
418
|
<Tip inv label={<><b>Next frame</b><span className="k">→</span></>}>
|
|
394
419
|
<button onClick={() => step(1)}><ArrowRightIcon size={14} /></button>
|
|
395
420
|
</Tip>
|
|
421
|
+
{variants.length > 1 && <>
|
|
422
|
+
<i className="sep" />
|
|
423
|
+
<span className="vname">{(() => { const c = variants.find((v) => v.id === play.at); return c ? (c.title ?? (c.id.split('/').pop() ?? '').replace(/^[a-z]-/, '').replace(/-/g, ' ')) : '' })()}</span>
|
|
424
|
+
{variants.map((v) => (
|
|
425
|
+
<Tip inv key={v.id} label={<><b>{v.title ?? v.id}</b><span className="k">[ ]</span></>}>
|
|
426
|
+
<button className={`vchip${v.id === play.at ? ' on' : ''}`} onClick={() => goTo(v.id)}>
|
|
427
|
+
{(v.variant ?? '?').toUpperCase()}
|
|
428
|
+
</button>
|
|
429
|
+
</Tip>
|
|
430
|
+
))}
|
|
431
|
+
</>}
|
|
396
432
|
</div>
|
|
397
433
|
</div>
|
|
398
434
|
)
|