@marver-design/marver 0.2.2 → 0.2.3
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-ClhCgn4v.mjs} +26 -5
- 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 +13 -9
- package/templates/AGENTS-studio.md +13 -9
- package/templates/design-tsconfig.json +7 -2
- package/templates/instructions/boards.md +47 -1
- package/templates/instructions/discover.md +6 -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-ClhCgn4v.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";
|
|
@@ -112,7 +112,7 @@ function init(root, opts) {
|
|
|
112
112
|
rmSync(setupPath);
|
|
113
113
|
console.log(` - design/instructions/setup.md removed (app detected - setup complete)`);
|
|
114
114
|
}
|
|
115
|
-
write("tsconfig.json", existsSync(join(root, "tsconfig.json")) ? readFileSync(join(templates, "design-tsconfig.json"), "utf8") : STANDALONE_TSCONFIG);
|
|
115
|
+
write("tsconfig.json", existsSync(join(root, "tsconfig.json")) ? readFileSync(join(templates, "design-tsconfig.json"), "utf8").replace("{{PATHS}}", designPaths(root)) : STANDALONE_TSCONFIG);
|
|
116
116
|
write(".gitignore", ".local/\n.dist/\n");
|
|
117
117
|
write("scenes/_layout.tsx", readFileSync(join(templates, "root-layout.tsx"), "utf8"));
|
|
118
118
|
if (!existsSync(join(design, "boards"))) {
|
|
@@ -140,6 +140,7 @@ function init(root, opts) {
|
|
|
140
140
|
└───────────────────────────────────────────────────────────────────────┘`);
|
|
141
141
|
console.log(`\n commit design/ - only .local/ is ignored`);
|
|
142
142
|
console.log(` uninstall = delete design/, remove the ${NAME} dependency${host.tsconfigSweepsDesign ? ", revert the \"design\" line in tsconfig exclude" : ""}`);
|
|
143
|
+
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
144
|
console.log(`\n next: npx ${NAME} dev (canvas on http://localhost:${DEFAULTS.port} by default)\n`);
|
|
144
145
|
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`);
|
|
145
146
|
}
|
|
@@ -150,6 +151,19 @@ const hashBody = (s) => createHash("sha256").update(s).digest("hex");
|
|
|
150
151
|
* Edit freely - init preserves edits and stages upstream updates for merging.
|
|
151
152
|
* Deleting the marker line detaches the file from updates entirely. */
|
|
152
153
|
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}`;
|
|
154
|
+
/** The host's path aliases, re-rooted one level down for design/tsconfig.json.
|
|
155
|
+
* "./src/*" becomes "../src/*" so `@/` imports resolve from inside design/. */
|
|
156
|
+
function designPaths(root) {
|
|
157
|
+
const hostPaths = readJson(join(root, "tsconfig.json"))?.compilerOptions?.paths;
|
|
158
|
+
if (!hostPaths || typeof hostPaths !== "object") return "";
|
|
159
|
+
const rerooted = {};
|
|
160
|
+
for (const [alias, targets] of Object.entries(hostPaths)) {
|
|
161
|
+
if (!Array.isArray(targets)) continue;
|
|
162
|
+
rerooted[alias] = targets.map((t) => typeof t === "string" ? t.startsWith("./") ? `../${t.slice(2)}` : `../${t}` : t);
|
|
163
|
+
}
|
|
164
|
+
if (!Object.keys(rerooted).length) return "";
|
|
165
|
+
return `,\n // the host's aliases, re-rooted (inherited paths resolve against the WRONG dir)\n "paths": ${JSON.stringify(rerooted)}`;
|
|
166
|
+
}
|
|
153
167
|
/** No framework, no theme, no component alias = nothing to build frames FROM. */
|
|
154
168
|
const noApp = (host) => !host.router && !host.tailwind && !host.shadcn && !host.themeCss;
|
|
155
169
|
/** The UI line of AGENTS.md, matched to what detection actually found (friction log #1).
|
|
@@ -174,14 +188,21 @@ it later - work that gets thrown away.
|
|
|
174
188
|
|
|
175
189
|
## Do this first
|
|
176
190
|
|
|
177
|
-
For a web app or marketing site, the blessed stack:
|
|
191
|
+
For a web app or marketing site, the blessed stack. NOTE: create-next-app refuses a
|
|
192
|
+
non-empty directory (this repo already holds design/ and a package.json), so
|
|
193
|
+
scaffold into a temp dir and merge:
|
|
178
194
|
|
|
179
195
|
\`\`\`bash
|
|
180
|
-
npx create-next-app@latest
|
|
196
|
+
npx create-next-app@latest app-scaffold --ts --tailwind --app --src-dir --yes
|
|
197
|
+
# move everything from app-scaffold/ up to the repo root EXCEPT its package.json;
|
|
198
|
+
# merge app-scaffold/package.json's dependencies+scripts into the existing one,
|
|
199
|
+
# then: rm -rf app-scaffold && npm install
|
|
181
200
|
npx shadcn@latest init
|
|
182
201
|
\`\`\`
|
|
183
202
|
|
|
184
|
-
|
|
203
|
+
shadcn's flags change between versions - if a flag errors or it prompts despite
|
|
204
|
+
--yes, answer the prompts with its defaults. Any React + CSS setup works; the
|
|
205
|
+
point is that components and a theme EXIST.
|
|
185
206
|
|
|
186
207
|
## Then
|
|
187
208
|
|
|
@@ -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.3",
|
|
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
|
)
|
|
@@ -90,6 +90,42 @@ export const canvasCtl = {
|
|
|
90
90
|
zoom100() { canvasCtl.zoomTo(1) },
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
+
/** Variant-group captions (SPEC-023 §4): "Landing · 3 variants" above each group with
|
|
94
|
+
* 2+ members on this board. World-space (scales with the canvas); min screen size via
|
|
95
|
+
* --sh-inv. Groups with one lone member on a curated board keep the badge, no caption. */
|
|
96
|
+
function GroupCaptions() {
|
|
97
|
+
const nodes = useStore((s) => s.nodes)
|
|
98
|
+
const manifest = useStore((s) => s.manifest)
|
|
99
|
+
const selection = useStore((s) => s.selection)
|
|
100
|
+
if (!manifest) return null
|
|
101
|
+
const byId = new Map(manifest.frames.map((f) => [f.id, f])) // O(F+N), not O(F*N) per drag
|
|
102
|
+
const groups = new Map<string, { x: number; y: number; ids: Set<string>; keys: string[] }>()
|
|
103
|
+
for (const n of nodes) {
|
|
104
|
+
if (n.missing) continue
|
|
105
|
+
const f = byId.get(n.frame)
|
|
106
|
+
if (!f?.variantGroup) continue
|
|
107
|
+
const g = groups.get(f.variantGroup)
|
|
108
|
+
if (!g) groups.set(f.variantGroup, { x: n.x, y: n.y, ids: new Set([n.frame]), keys: [n.key] })
|
|
109
|
+
else { g.x = Math.min(g.x, n.x); g.y = Math.min(g.y, n.y); g.ids.add(n.frame); g.keys.push(n.key) }
|
|
110
|
+
}
|
|
111
|
+
return (
|
|
112
|
+
<>
|
|
113
|
+
{[...groups.entries()].filter(([, g]) => g.ids.size > 1).map(([id, g]) => {
|
|
114
|
+
const allOn = g.keys.every((k) => selection.includes(k))
|
|
115
|
+
return (
|
|
116
|
+
<div key={id} className={`sh-gcaption sh-no-pan${allOn ? ' on' : ''}`}
|
|
117
|
+
style={{ transform: `translate(${g.x}px, ${g.y}px) translateY(calc(-100% - clamp(4px, 8px * var(--sh-inv, 1), 40px)))` }}
|
|
118
|
+
title="Select all variants"
|
|
119
|
+
onPointerDown={(e) => e.stopPropagation()}
|
|
120
|
+
onClick={() => { useStore.getState().selectMany(g.keys); canvasCtl.fitNodes(g.keys) }}>
|
|
121
|
+
{id.split('/').map((s) => s[0].toUpperCase() + s.slice(1)).join(' / ')} · {g.ids.size} variants
|
|
122
|
+
</div>
|
|
123
|
+
)
|
|
124
|
+
})}
|
|
125
|
+
</>
|
|
126
|
+
)
|
|
127
|
+
}
|
|
128
|
+
|
|
93
129
|
export function Canvas() {
|
|
94
130
|
const nodes = useStore((s) => s.nodes)
|
|
95
131
|
const gesture = useStore((s) => s.gesture)
|
|
@@ -119,18 +155,28 @@ export function Canvas() {
|
|
|
119
155
|
}
|
|
120
156
|
ref.current.setTransform(px - x * scale, py - y * scale, scale, 320, 'easeOut')
|
|
121
157
|
}
|
|
122
|
-
canvasCtl.fitNode = (key: string) => {
|
|
123
|
-
const n = useStore.getState().nodes.find((x) => x.key === key)
|
|
124
|
-
if (n) fitRect(n.x, n.y, n.w, n.h + HEADER, true)
|
|
125
|
-
}
|
|
126
158
|
const fitKeys = (keys: string[], focus: boolean) => {
|
|
159
|
+
const st = useStore.getState()
|
|
127
160
|
const sel = new Set(keys)
|
|
128
|
-
const ns =
|
|
161
|
+
const ns = st.nodes.filter((n) => sel.has(n.key))
|
|
129
162
|
if (!ns.length) return
|
|
130
|
-
|
|
163
|
+
let x0 = Math.min(...ns.map((n) => n.x)), y0 = Math.min(...ns.map((n) => n.y))
|
|
131
164
|
const x1 = Math.max(...ns.map((n) => n.x + n.w)), y1 = Math.max(...ns.map((n) => n.y + n.h + HEADER))
|
|
165
|
+
// grouped frames carry chrome OUTSIDE their box (badge left, caption above) -
|
|
166
|
+
// fit must include that envelope or the variant text lands under the sidebar.
|
|
167
|
+
// Screen-clamped sizes need a scale estimate first: one pre-pass, then pad.
|
|
168
|
+
if (ns.some((n) => st.frameFor(n)?.variantGroup)) {
|
|
169
|
+
const el = wrap()
|
|
170
|
+
if (el) {
|
|
171
|
+
const p = insets(el)
|
|
172
|
+
const s1 = Math.max(0.05, Math.min(1, (el.clientWidth - p.left - p.right) / (x1 - x0), (el.clientHeight - p.top - p.bottom) / (y1 - y0)))
|
|
173
|
+
x0 -= Math.max(140, 44 / s1) // badge letter+name column (min 44 screen px)
|
|
174
|
+
y0 -= Math.max(64, 36 / s1) // caption line above the group
|
|
175
|
+
}
|
|
176
|
+
}
|
|
132
177
|
fitRect(x0, y0, x1 - x0, y1 - y0, focus)
|
|
133
178
|
}
|
|
179
|
+
canvasCtl.fitNode = (key: string) => fitKeys([key], true)
|
|
134
180
|
canvasCtl.fitNodes = (keys: string[]) => fitKeys(keys, true)
|
|
135
181
|
canvasCtl.fitAll = () => fitKeys(useStore.getState().nodes.map((n) => n.key), false)
|
|
136
182
|
canvasCtl.zoomTo = (target: number) => {
|
|
@@ -177,7 +223,7 @@ export function Canvas() {
|
|
|
177
223
|
if (!el) return
|
|
178
224
|
const down = (e: PointerEvent) => {
|
|
179
225
|
const t = e.target as HTMLElement | null
|
|
180
|
-
if (t?.closest('.sh-node')) return
|
|
226
|
+
if (t?.closest('.sh-node') || t?.closest('.sh-gcaption')) return
|
|
181
227
|
const s = useStore.getState()
|
|
182
228
|
if (s.selection.length || s.interact) s.select(null)
|
|
183
229
|
}
|
|
@@ -277,6 +323,7 @@ export function Canvas() {
|
|
|
277
323
|
>
|
|
278
324
|
<TransformComponent wrapperClass="sh-canvas" contentClass="sh-content">
|
|
279
325
|
<div id="sh-world">
|
|
326
|
+
<GroupCaptions />
|
|
280
327
|
{nodes.map((n) => <FrameNode key={n.key} node={n} />)}
|
|
281
328
|
</div>
|
|
282
329
|
</TransformComponent>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { memo, useEffect, useRef } from 'react'
|
|
2
|
-
import { frameUrl, useStore, CONFIG, type Node } from '../store.ts'
|
|
2
|
+
import { cap, frameUrl, useStore, CONFIG, type Node } from '../store.ts'
|
|
3
3
|
import { CopyIcon, ReloadIcon, XIcon } from '../icons.tsx'
|
|
4
4
|
|
|
5
5
|
export const HEADER = 28
|
|
@@ -148,6 +148,11 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
|
|
|
148
148
|
)
|
|
149
149
|
}
|
|
150
150
|
|
|
151
|
+
// variant badge (SPEC-023 §4): letter + name floating LEFT of the frame, outside the
|
|
152
|
+
// artwork, world-anchored (scales with zoom) with a screen-space minimum via --sh-inv
|
|
153
|
+
const variantName = frame.title
|
|
154
|
+
?? cap((frame.id.split('/').pop() ?? '').replace(/^[a-z]-/, '').replace(/-/g, ' '))
|
|
155
|
+
|
|
151
156
|
return (
|
|
152
157
|
<div
|
|
153
158
|
className={`sh-node${selected ? ' sel' : ''}${interact ? ' interact' : ''}`}
|
|
@@ -155,6 +160,14 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
|
|
|
155
160
|
style={{ transform: `translate(${node.x}px, ${node.y}px)`, width: node.w, height: node.h + HEADER }}
|
|
156
161
|
data-node={node.key}
|
|
157
162
|
>
|
|
163
|
+
{frame.variantGroup && (
|
|
164
|
+
<div className="sh-vbadge sh-no-pan" title={`${frame.variantGroup} · variant ${frame.variant?.toUpperCase()} - click to select`}
|
|
165
|
+
onPointerDown={(e) => e.stopPropagation()}
|
|
166
|
+
onClick={(e) => { select(node.key, e.shiftKey) }}>
|
|
167
|
+
<b>{frame.variant?.toUpperCase()}</b>
|
|
168
|
+
<span>{variantName}</span>
|
|
169
|
+
</div>
|
|
170
|
+
)}
|
|
158
171
|
<div className="sh-node-head sh-no-pan" onPointerDown={(e) => drag(e, 'move')} title={frame.file}>
|
|
159
172
|
<span className="id sh-no-pan">{frame.title ?? frame.id}</span>
|
|
160
173
|
<span className="dim sh-no-pan">{Math.round(node.w)} · {node.theme}</span>
|
|
@@ -44,6 +44,8 @@ export const CardsThreeIcon = icon(<path d="M208,88H48a16,16,0,0,0-16,16v96a16,1
|
|
|
44
44
|
export const FrameCornersIcon = icon(<path d="M160 48h40a8 8 0 0 1 8 8v40M208 160v40a8 8 0 0 1-8 8h-40M96 208H56a8 8 0 0 1-8-8v-40M48 96V56a8 8 0 0 1 8-8h40" fill="none" stroke="currentColor" strokeWidth="16" strokeLinecap="round" strokeLinejoin="round" />)
|
|
45
45
|
export const ArrowLeftIcon = icon(<path d="M224,128a8,8,0,0,1-8,8H59.31l58.35,58.34a8,8,0,0,1-11.32,11.32l-72-72a8,8,0,0,1,0-11.32l72-72a8,8,0,0,1,11.32,11.32L59.31,120H216A8,8,0,0,1,224,128Z" />)
|
|
46
46
|
export const ArrowRightIcon = icon(<path d="M221.66,133.66l-72,72a8,8,0,0,1-11.32-11.32L196.69,136H40a8,8,0,0,1,0-16H196.69L138.34,61.66a8,8,0,0,1,11.32-11.32l72,72A8,8,0,0,1,221.66,133.66Z" />)
|
|
47
|
+
/** Variants/experimentation mark: the flask (Phosphor). */
|
|
48
|
+
export const VariantsIcon = icon(<path d="M223.45,208.19,168,101.11V40h8a8,8,0,0,0,0-16H80a8,8,0,0,0,0,16h8v61.11L32.55,208.19A16,16,0,0,0,46.86,232H209.14a16,16,0,0,0,14.31-23.81ZM102.13,108.5A8,8,0,0,0,104,103.35V40h48v63.35a8,8,0,0,0,1.87,5.15L179.36,157c-11.71,2.87-26.5.66-44.16-6.64-14.08-5.82-27.34-8.53-39.66-8.16ZM46.86,216,84.36,143.6c11.68-2.9,26.47-.68,44.13,6.61,13.09,5.42,25.36,8.11,36.65,8.11a67.86,67.86,0,0,0,22.53-3.72L209.14,216Z" />)
|
|
47
49
|
export const CardsIcon = icon(<path d="M184,72H40A16,16,0,0,0,24,88V200a16,16,0,0,0,16,16H184a16,16,0,0,0,16-16V88A16,16,0,0,0,184,72Zm0,128H40V88H184V200ZM232,56V176a8,8,0,0,1-16,0V56H64a8,8,0,0,1,0-16H216A16,16,0,0,1,232,56Z" />)
|
|
48
50
|
/** Viewport-name -> device glyph; unknown custom names fall back to the generic Devices. */
|
|
49
51
|
export function deviceIcon(name: string | null, size = 14) {
|