@marver-design/marver 0.2.1 → 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.
Files changed (41) hide show
  1. package/README.md +1 -0
  2. package/dist/{build-BYZDMiIS.mjs → build-Bqd6OEsQ.mjs} +2 -2
  3. package/dist/cli.mjs +3 -3
  4. package/dist/{dev-Dt9D4O7Z.mjs → dev-9-80L5i5.mjs} +2 -2
  5. package/dist/init-ClhCgn4v.mjs +358 -0
  6. package/dist/{manifest-CHmKAAtG.mjs → manifest-mYlO_1Pj.mjs} +78 -2
  7. package/dist/{plugin-lVQoABEx.mjs → plugin-YVpBNTB3.mjs} +103 -9
  8. package/package.json +1 -1
  9. package/src/client/shell/App.tsx +123 -19
  10. package/src/client/shell/Play.tsx +36 -0
  11. package/src/client/shell/canvas/Canvas.tsx +54 -7
  12. package/src/client/shell/canvas/FrameNode.tsx +14 -1
  13. package/src/client/shell/icons.tsx +2 -0
  14. package/src/client/shell/store.ts +119 -15
  15. package/src/client/shell/styles.css +107 -2
  16. package/src/client/shell/tidy.ts +347 -17
  17. package/src/client/stage/main.tsx +11 -3
  18. package/templates/AGENTS-embedded.md +38 -33
  19. package/templates/AGENTS-studio.md +38 -33
  20. package/templates/design-tsconfig.json +7 -2
  21. package/templates/instructions/boards.md +84 -0
  22. package/templates/instructions/brand.md +60 -0
  23. package/templates/instructions/components.md +52 -0
  24. package/templates/instructions/configure.md +44 -0
  25. package/templates/instructions/craft.md +90 -0
  26. package/templates/instructions/discover.md +58 -0
  27. package/templates/instructions/reference/color.md +53 -0
  28. package/templates/instructions/reference/concepts.md +68 -0
  29. package/templates/instructions/reference/copy.md +57 -0
  30. package/templates/instructions/reference/critique.md +53 -0
  31. package/templates/instructions/reference/delight.md +35 -0
  32. package/templates/instructions/reference/layout.md +51 -0
  33. package/templates/instructions/reference/motion.md +66 -0
  34. package/templates/instructions/reference/operate.md +38 -0
  35. package/templates/instructions/reference/slop.md +76 -0
  36. package/templates/instructions/reference/states.md +48 -0
  37. package/templates/instructions/reference/tune.md +61 -0
  38. package/templates/instructions/reference/typography.md +45 -0
  39. package/templates/instructions/review.md +51 -0
  40. package/templates/instructions/wireframe.md +49 -0
  41. package/dist/init-CHUZTYG7.mjs +0 -224
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marver-design/marver",
3
- "version": "0.2.1",
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,
@@ -1,24 +1,25 @@
1
1
  import { Component, useEffect, useRef, useState, type ReactNode } from 'react'
2
2
  import { createPortal } from 'react-dom'
3
- import { useStore, CONFIG, boardLabel, cap, fetchBoardNames } from './store.ts'
3
+ import { useStore, CONFIG, PUBLISHED, boardLabel, cap, fetchBoardNames } from './store.ts'
4
4
  import { Tip } from './Tip.tsx'
5
- import { ROUTE } from '../const.ts'
5
+ import { PKG, ROUTE } from '../const.ts'
6
6
  import { animateLayout, Canvas, canvasCtl } from './canvas/Canvas.tsx'
7
7
  import { enterPlay, playCtl, PlayOverlay } from './Play.tsx'
8
8
  import { bootHash, parseHash, writeHash } from './hash.ts'
9
- import { CardsIcon, CardsThreeIcon, CaretIcon, CheckIcon, DevicesIcon, GridIcon, MoonIcon, PanelFilledIcon, PanelHollowIcon, ParallelogramDuoIcon, PlayIcon, PlusIcon, SignpostIcon, SunIcon, deviceIcon } from './icons.tsx'
9
+ import { CardsIcon, CardsThreeIcon, CaretIcon, CheckIcon, DevicesIcon, GridIcon, MoonIcon, PanelFilledIcon, PanelHollowIcon, ParallelogramDuoIcon, PlayIcon, PlusIcon, SignpostIcon, SunIcon, 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"
@@ -251,6 +260,45 @@ function DeviceMenu() {
251
260
  )
252
261
  }
253
262
 
263
+ /** Update pill (dev only): the daily registry check surfaces here - same glass, same
264
+ * pill, bottom-center. Click the command to copy it; × dismisses THIS version for
265
+ * good (localStorage), so the pill returns only when the next release lands. */
266
+ function UpdatePill() {
267
+ const [latest, setLatest] = useState<string | null>(null)
268
+ const play = useStore((s) => s.play)
269
+ useEffect(() => {
270
+ if (PUBLISHED) return // a shared canvas never nags its viewers
271
+ fetch(`${ROUTE}/api/update`)
272
+ .then((r) => (r.ok ? r.json() : null))
273
+ .then((u) => {
274
+ if (u?.latest && localStorage.getItem('mv-update-seen') !== u.latest) setLatest(u.latest)
275
+ })
276
+ .catch(() => { /* dev server gone or endpoint absent - stay quiet */ })
277
+ }, [])
278
+ if (!latest || play) return null
279
+ // init rides along so managed files (AGENTS.md, instructions/) refresh with the code
280
+ const cmd = `npm i -D ${PKG}@latest && npx marver init`
281
+ const dismiss = () => {
282
+ try { localStorage.setItem('mv-update-seen', latest) } catch { /* storage unavailable */ }
283
+ setLatest(null)
284
+ }
285
+ return (
286
+ <div className="sh-update">
287
+ <span><b>{latest}</b> is out</span>
288
+ <Tip side="top" label="Copy, then paste to your terminal or your agent">
289
+ <button className="cmd" onClick={() => {
290
+ const t = useStore.getState().toast
291
+ navigator.clipboard?.writeText(cmd).then(() => t('update command copied'), () => t('copy blocked - select it manually'))
292
+ ?? t('copy unavailable - select it manually')
293
+ }}><code>{cmd}</code></button>
294
+ </Tip>
295
+ <Tip side="top" label="Dismiss this version">
296
+ <button className="x" onClick={dismiss}><XIcon size={13} /></button>
297
+ </Tip>
298
+ </div>
299
+ )
300
+ }
301
+
254
302
  const ZOOMS = [2, 1.5, 1, 0.5, 0.25, 0.1]
255
303
 
256
304
  /** Zoom preset dropdown on the percentage readout. */
@@ -594,18 +642,71 @@ export function App() {
594
642
  <div className="hd" style={{ marginTop: 10 }}>Scenes</div>
595
643
  {scenes.map((sc) => (
596
644
  <SceneGroup key={sc.name} name={sc.name} count={sc.frames}
597
- held={frames.some((f) => f.scene === sc.name && selFrames.has(f.id))}>
598
- {frames.filter((f) => f.scene === sc.name).map((f) => {
599
- const n = nodes.find((x) => x.frame === f.id && !x.missing) ?? nodes.find((x) => x.frame === f.id)
600
- const on = !!n && selection.includes(n.key)
601
- return (
602
- <div key={f.id} className={`sub${on ? ' on' : ''}`} onClick={(e) => {
603
- if (!n) return
604
- select(n.key, e.shiftKey)
605
- if (!e.shiftKey) canvasCtl.fitNode(n.key)
606
- }}>{cap(f.id.split('/').slice(1).join('/') || f.id)}</div>
607
- )
608
- })}
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
+ })()}
609
710
  </SceneGroup>
610
711
  ))}
611
712
  {frames.length === 0 && <div className="sub dim">no frames yet - ask your agent<br />(design/AGENTS.md)</div>}
@@ -642,7 +743,10 @@ export function App() {
642
743
 
643
744
  <PlayOverlay />
644
745
 
645
- {CONFIG.noTheme && <div className="sh-banner">no theme configured - frames render unstyled. Create design/theme.css importing your app's stylesheet (or set theme in design/config.ts)</div>}
746
+ {CONFIG.setup
747
+ ? <div className="sh-banner">no app detected - designs would be built from nothing. See design/instructions/setup.md, then restart</div>
748
+ : CONFIG.noTheme && <div className="sh-banner">no theme configured - frames render unstyled. Create design/theme.css importing your app's stylesheet (or set theme in design/config.ts)</div>}
749
+ <UpdatePill />
646
750
 
647
751
  <div className="sh-toasts">
648
752
  {toasts.map((t) => <div key={t.id} className="sh-toast"><CheckIcon size={12} /> {t.text}</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) {