@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.
@@ -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-CHmKAAtG.mjs";
3
- import { n as tailwind3Css, r as tailwind4Plugin, t as marverPlugin } from "./plugin-DiDJA9n-.mjs";
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-3h9pXEzp.mjs");
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-Blyy4jOL.mjs");
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-CNoXE13J.mjs");
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-CHmKAAtG.mjs";
3
- import { n as tailwind3Css, r as tailwind4Plugin, t as marverPlugin } from "./plugin-DiDJA9n-.mjs";
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-CHmKAAtG.mjs";
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 . --ts --tailwind --app --src-dir
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
- Any React + CSS setup works; the point is that components and a theme EXIST.
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(`${key}\\s*:\\s*(['"\`])([^'"\`]*)\\1`).exec(body);
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-CHmKAAtG.mjs";
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.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,
@@ -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
- <span>{cap(name) || '(root)'}</span>
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
- const rawTop = `calc(var(--sh-ty, 0px) + var(--sh-s, 1) * ${by0}px - 52px)`
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
- {frames.filter((f) => f.scene === sc.name).map((f) => {
638
- const n = nodes.find((x) => x.frame === f.id && !x.missing) ?? nodes.find((x) => x.frame === f.id)
639
- const on = !!n && selection.includes(n.key)
640
- return (
641
- <div key={f.id} className={`sub${on ? ' on' : ''}`} onClick={(e) => {
642
- if (!n) return
643
- select(n.key, e.shiftKey)
644
- if (!e.shiftKey) canvasCtl.fitNode(n.key)
645
- }}>{cap(f.id.split('/').slice(1).join('/') || f.id)}</div>
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 = useStore.getState().nodes.filter((n) => sel.has(n.key))
161
+ const ns = st.nodes.filter((n) => sel.has(n.key))
129
162
  if (!ns.length) return
130
- const x0 = Math.min(...ns.map((n) => n.x)), y0 = Math.min(...ns.map((n) => n.y))
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) {