@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
@@ -1,6 +1,6 @@
1
1
  import { create } from 'zustand'
2
2
  import { ROUTE } from '../const.ts'
3
- import { tidy } from './tidy.ts'
3
+ import { tidy, parseLayout, type BoardLayout } from './tidy.ts'
4
4
  // @ts-expect-error virtual module provided by the plugin
5
5
  import shConfig from 'virtual:sh-config'
6
6
  // @ts-expect-error virtual module: null in dev; a published build inlines manifest+boards
@@ -11,7 +11,10 @@ import shData from 'virtual:sh-data'
11
11
  * is where `/` opens - never a synthesized aggregate of a filtered build. */
12
12
  const DATA: { manifest: Manifest; boards: Record<string, unknown>; names: string[]; default: string } | null = shData
13
13
 
14
- export interface FrameEntry { id: string; file: string; kind: 'tsx' | 'html'; scene: string; title?: string; viewport?: string; theme?: string }
14
+ /** True on a published static canvas - no dev server, no API, no update checks. */
15
+ export const PUBLISHED = DATA !== null
16
+
17
+ export interface FrameEntry { id: string; file: string; kind: 'tsx' | 'html'; scene: string; title?: string; viewport?: string; theme?: string; variantGroup?: string; variant?: string }
15
18
  export interface Manifest { frames: FrameEntry[]; scenes: { name: string; frames: number }[] }
16
19
  export interface Node {
17
20
  key: string; frame: string; x: number; y: number; w: number; h: number
@@ -27,7 +30,7 @@ export interface Node {
27
30
  }
28
31
  export interface Toast { id: number; text: string }
29
32
 
30
- export const CONFIG: { viewports: Record<string, { width: number; height: number }>; themes: string[]; zoomSpeed?: number; noTheme: boolean } = shConfig
33
+ export const CONFIG: { viewports: Record<string, { width: number; height: number }>; themes: string[]; zoomSpeed?: number; noTheme: boolean; setup?: boolean } = shConfig
31
34
 
32
35
  export const cap = (s: string) => (s ? s[0].toUpperCase() + s.slice(1) : s)
33
36
 
@@ -73,6 +76,18 @@ function defaultSize(frame: FrameEntry) {
73
76
  return { w: vp.width, h: vp.height }
74
77
  }
75
78
 
79
+ let resizedInGesture = false
80
+
81
+ /** sceneRows is the legacy shorthand for a plain rows layout (SPEC-024 §5). */
82
+ const effectiveLayout = (layout: BoardLayout | null, sceneRows: string[][] | null): BoardLayout | undefined =>
83
+ layout ?? (sceneRows?.length ? { rows: sceneRows.map((r) => [...r]) } : undefined)
84
+
85
+ const layoutWarn = (msg: string) => {
86
+ console.warn('[marver layout]', msg)
87
+ try { useStore.getState().toast(`layout: ${msg}`) } catch { /* store not ready during boot */ }
88
+ }
89
+
90
+
76
91
  interface State {
77
92
  manifest: Manifest | null
78
93
  nodes: Node[] // append-only order; NEVER sorted or reordered (iframe law G-1)
@@ -84,6 +99,9 @@ interface State {
84
99
  board: string // active board name; 'all-scenes' is the auto board
85
100
  boardAuto: boolean // auto boards gain new frames on arrival; curated boards never do
86
101
  deviceView: string | null // board-wide device preview (viewport name), null = free-form layout
102
+ sceneRows: string[][] | null // LEGACY scene arrangement (SPEC-023 §2); still round-trips through save
103
+ layout: BoardLayout | null // lane-flow recipe (SPEC-024), parsed; wins over sceneRows when both exist
104
+ layoutRaw: unknown // the author's layout VERBATIM - save round-trips this, never the parse
87
105
  baseLayout: Record<string, { x: number; y: number; w: number; h: number }> | null // snapshot taken on entering a device view; Default restores it exactly
88
106
  panelOpen: boolean
89
107
  scale: number
@@ -99,6 +117,7 @@ interface State {
99
117
  setStatus(key: string, status: Node['status'], error?: string): void
100
118
  removeNode(key: string): void
101
119
  select(key: string | null, additive?: boolean): void
120
+ selectMany(keys: string[]): void
102
121
  selectAll(): void
103
122
  setInteract(key: string | null): void
104
123
  setPlay(p: State['play']): void
@@ -167,6 +186,9 @@ export const useStore = create<State>((set, get) => {
167
186
  let boardHash: string | null = null
168
187
  let boardAuto = boardName === 'all-scenes'
169
188
  let deviceView: string | null = null
189
+ let sceneRows: string[][] | null = null
190
+ let layout: BoardLayout | null = null
191
+ let layoutRaw: unknown = undefined
170
192
  let baseLayout: State['baseLayout'] = null
171
193
  let needTidy = false
172
194
  // published build: boards come from the inlined data; absent = fresh (the 404 path)
@@ -221,6 +243,15 @@ export const useStore = create<State>((set, get) => {
221
243
  // device view and the free-form snapshot survive reloads - independently:
222
244
  // a scoped "exception" clears deviceView but the snapshot must keep restoring
223
245
  if (typeof board?.deviceView === 'string' && CONFIG.viewports[board.deviceView]) deviceView = board.deviceView
246
+ if (Array.isArray(board?.sceneRows)) {
247
+ const rows = board.sceneRows
248
+ .map((r: unknown) => (Array.isArray(r) ? r.filter((x: unknown) => typeof x === 'string') : []))
249
+ .filter((r: string[]) => r.length)
250
+ if (rows.length) sceneRows = rows
251
+ }
252
+ layoutRaw = board?.layout
253
+ layout = parseLayout(board?.layout, layoutWarn)
254
+ if (layout && sceneRows) layoutWarn('board has layout AND sceneRows - layout wins')
224
255
  if (board?.baseLayout && typeof board.baseLayout === 'object') baseLayout = board.baseLayout
225
256
  }
226
257
  // auto-managed goes both ways (friction log #15): an auto board gains new frames
@@ -233,15 +264,30 @@ export const useStore = create<State>((set, get) => {
233
264
  nodes = nodes.filter((n) => !n.missing)
234
265
  prunedAtLoad = !DATA && nodes.length !== before
235
266
  }
236
- // frames not on the board yet → auto boards only (a curated board shows exactly its list)
267
+ // frames not on the board yet → auto boards only (a curated board shows exactly its list).
268
+ // Grouped newcomers land beside their siblings (like the live JOIN); ungrouped ones
269
+ // go past the board's right edge (the live JOIN flows them below) - NEVER at (0,0)
270
+ // (the published all-scenes bug: frames created after the board file was last
271
+ // saved piled up at the origin)
237
272
  if (boardAuto) {
238
273
  const placed = new Set(nodes.map((n) => n.frame))
274
+ let maxX = nodes.reduce((a, n) => Math.max(a, n.x + n.w), 0)
239
275
  for (const f of manifest.frames) {
240
276
  if (placed.has(f.id)) continue
241
277
  placed.add(f.id)
242
278
  const d = defaultSize(f)
243
279
  const vp = deviceView ? CONFIG.viewports[deviceView] : null
244
- nodes.push({ key: nodeKey(), frame: f.id, x: 0, y: 0, w: vp?.width ?? d.w, h: vp?.height ?? d.h, theme: resolveTheme(f), status: 'loading' })
280
+ const w = vp?.width ?? d.w, h = vp?.height ?? d.h
281
+ let x: number, y: number
282
+ const sibling = f.variantGroup
283
+ ? nodes.filter((n) => manifest.frames.find((m) => m.id === n.frame)?.variantGroup === f.variantGroup)
284
+ .reduce<Node | null>((a, n) => (!a || n.x + n.w > a.x + a.w ? n : a), null)
285
+ : null
286
+ if (sibling) { x = sibling.x + sibling.w + Math.max(140, sibling.w * 0.12); y = sibling.y }
287
+ else { x = nodes.length ? maxX + 96 : 0; y = 0 }
288
+ const node: Node = { key: nodeKey(), frame: f.id, x, y, w, h, theme: resolveTheme(f), status: 'loading' }
289
+ nodes.push(node)
290
+ maxX = Math.max(maxX, x + w)
245
291
  }
246
292
  }
247
293
  // published: re-apply this visit's pins over the inlined data
@@ -253,19 +299,31 @@ export const useStore = create<State>((set, get) => {
253
299
  }
254
300
  }
255
301
  if ((!boardHash || needTidy) && nodes.length) {
256
- const sceneOf = (id: string) => manifest.frames.find((f) => f.id === id)?.scene ?? ''
257
- const placedAll = tidy(nodes.map((n) => ({ key: n.key, scene: sceneOf(n.frame), w: n.w, h: n.h + HEADER })))
302
+ const entryOf = (id: string) => manifest.frames.find((f) => f.id === id)
303
+ const placedAll = tidy(nodes.map((n) => {
304
+ const f = entryOf(n.frame)
305
+ return { key: n.key, frame: n.frame, scene: f?.scene ?? '', group: f?.variantGroup, variant: f?.variant, w: n.w, h: n.h + HEADER }
306
+ }), effectiveLayout(layout, sceneRows), layoutWarn)
258
307
  for (const pl of placedAll) { const n = nodes.find((x) => x.key === pl.key)!; n.x = pl.x; n.y = pl.y }
259
308
  }
260
309
  // dirty matches disk by construction - except when load-time pruning changed the
261
310
  // node set; callers see dirty:true and schedule the save that persists the prune
262
- return { manifest, nodes, boardHash, boardAuto, deviceView, baseLayout, selection: [], dirty: prunedAtLoad }
311
+ // surface recipe problems at load (dry-run): materialized boards otherwise
312
+ // never run tidy, so a broken agent-authored layout would fail silently
313
+ if (layout && boardHash && !needTidy && nodes.length) {
314
+ const entryOf2 = (id: string) => manifest.frames.find((f) => f.id === id)
315
+ tidy(nodes.map((n) => {
316
+ const f = entryOf2(n.frame)
317
+ return { key: n.key, frame: n.frame, scene: f?.scene ?? '', group: f?.variantGroup, variant: f?.variant, w: n.w, h: n.h + HEADER }
318
+ }), layout, layoutWarn)
319
+ }
320
+ return { manifest, nodes, boardHash, boardAuto, deviceView, sceneRows, layout, layoutRaw, baseLayout, selection: [], dirty: prunedAtLoad }
263
321
  } catch { return null }
264
322
  }
265
323
 
266
324
  return {
267
325
  manifest: null, nodes: [], selection: [], interact: null, viewTheme: initialViewTheme(), play: null, gesture: false,
268
- board: DATA?.default ?? 'all-scenes', boardAuto: (DATA?.default ?? 'all-scenes') === 'all-scenes', deviceView: null, baseLayout: null,
326
+ board: DATA?.default ?? 'all-scenes', boardAuto: (DATA?.default ?? 'all-scenes') === 'all-scenes', deviceView: null, sceneRows: null, layout: null, layoutRaw: undefined, baseLayout: null,
269
327
  panelOpen: true, scale: 1, toasts: [], boardHash: null, dirty: false,
270
328
 
271
329
  async boot() {
@@ -287,6 +345,12 @@ export const useStore = create<State>((set, get) => {
287
345
  async switchBoard(name) {
288
346
  const mySwitch = ++switchSeq // also cancels any pending switch (incl. re-clicking the current board)
289
347
  if (name === get().board) return
348
+ // a resize held across a switch: settle THIS board (retidy if it has a recipe)
349
+ // before the flush, so the torn mid-gesture state is never what reaches disk
350
+ if (resizedInGesture) {
351
+ resizedInGesture = false
352
+ if (get().layout || get().sceneRows?.length) get().runTidy()
353
+ }
290
354
  // flush loop: an edit landing mid-save keeps dirty set and gets its own pass
291
355
  let ok = true
292
356
  for (let i = 0; i < 5 && get().dirty && ok; i++) { clearTimeout(saveTimer); ok = await get().save() }
@@ -323,7 +387,15 @@ export const useStore = create<State>((set, get) => {
323
387
  if (known.has(f.id)) continue
324
388
  const d = defaultSize(f)
325
389
  const maxY = next.reduce((a, n) => Math.max(a, n.y + n.h), 0)
326
- const node = { key: nodeKey(), frame: f.id, x: 0, y: maxY + 96, w: vp?.width ?? d.w, h: vp?.height ?? d.h, theme: resolveTheme(f), status: 'loading' as const }
390
+ // a grouped newcomer lands BESIDE its siblings, not at the canvas bottom - the
391
+ // badge/caption say "these belong together"; the position must agree (SPEC-023 §3)
392
+ const sibs = f.variantGroup
393
+ ? next.filter((n) => { const g = m.frames.find((x) => x.id === n.frame)?.variantGroup; return g === f.variantGroup && !n.missing })
394
+ : []
395
+ const right = sibs.length ? sibs.reduce((a, n) => (n.x > a.x ? n : a)) : null
396
+ const node = right
397
+ ? { key: nodeKey(), frame: f.id, x: right.x + right.w + Math.max(140, right.w * 0.12), y: right.y, w: vp?.width ?? d.w, h: vp?.height ?? d.h, theme: resolveTheme(f), status: 'loading' as const }
398
+ : { key: nodeKey(), frame: f.id, x: 0, y: maxY + 96, w: vp?.width ?? d.w, h: vp?.height ?? d.h, theme: resolveTheme(f), status: 'loading' as const }
327
399
  next.push(node)
328
400
  // in a device view, the snapshot learns the newcomer's DEFAULT size so 0 restores it sanely
329
401
  if (vp && nextBase) nextBase = { ...nextBase, [node.key]: { x: node.x, y: node.y, w: d.w, h: d.h } }
@@ -399,6 +471,8 @@ export const useStore = create<State>((set, get) => {
399
471
  scheduleSave()
400
472
  },
401
473
  resizeNode(key, w, h) {
474
+ resizedInGesture = true
475
+ const inGesture = get().gesture
402
476
  // a manual resize means the board no longer uniformly shows one device; the resized
403
477
  // frame's snapshot entry follows the user's new size so Default will not undo it
404
478
  set((s) => {
@@ -413,7 +487,9 @@ export const useStore = create<State>((set, get) => {
413
487
  : s.baseLayout,
414
488
  }
415
489
  })
416
- scheduleSave()
490
+ // mid-gesture saves would persist torn state (new sizes, pre-recipe positions);
491
+ // the gesture-end hook saves once, after any retidy
492
+ if (!inGesture) scheduleSave()
417
493
  },
418
494
  setDeviceView(name) {
419
495
  const vp = name ? CONFIG.viewports[name] : null
@@ -481,12 +557,28 @@ export const useStore = create<State>((set, get) => {
481
557
  selectAll() {
482
558
  set((s) => ({ selection: s.nodes.map((n) => n.key) }))
483
559
  },
560
+ // group/scene selection from sidebar rows and canvas captions (SPEC-023 feedback)
561
+ selectMany(keys) {
562
+ set((s) => {
563
+ const valid = keys.filter((k) => s.nodes.some((n) => n.key === k))
564
+ return { selection: valid, interact: s.interact && valid.includes(s.interact) ? s.interact : null }
565
+ })
566
+ },
484
567
  // interact is a ONE-frame mode: entering it collapses any multi-selection to the
485
568
  // interacted frame (a 4-frame selection double-clicked otherwise leaves all four
486
569
  // painted as "interactive"). Exiting keeps the frame selected for continuity.
487
570
  setInteract(key) { set((s) => ({ interact: key, selection: key ? [key] : s.selection })) },
488
571
  setPlay(play) { set({ play }) },
489
- setGesture(gesture) { set({ gesture }) },
572
+ setGesture(gesture) {
573
+ set({ gesture })
574
+ // SPEC-024 §4: a board WITH a layout recipe re-applies it when a resize
575
+ // gesture ends - sizes changed, so lanes and gaps must recompute
576
+ if (!gesture && resizedInGesture) {
577
+ resizedInGesture = false
578
+ if (get().layout || get().sceneRows?.length) get().runTidy() // schedules the save
579
+ else scheduleSave()
580
+ }
581
+ },
490
582
  setScale(scale) { set({ scale }) },
491
583
  togglePanel() { set((s) => ({ panelOpen: !s.panelOpen })) },
492
584
  // global theme = the VIEW preference: persists across boards + reloads, clears
@@ -504,9 +596,12 @@ export const useStore = create<State>((set, get) => {
504
596
  scheduleSave()
505
597
  },
506
598
  runTidy() {
507
- const { nodes, manifest } = get()
508
- const sceneOf = (id: string) => manifest?.frames.find((f) => f.id === id)?.scene ?? ''
509
- const placed = tidy(nodes.map((n) => ({ key: n.key, scene: sceneOf(n.frame), w: n.w, h: n.h + HEADER })))
599
+ const { nodes, manifest, sceneRows, layout } = get()
600
+ const entryOf = (id: string) => manifest?.frames.find((f) => f.id === id)
601
+ const placed = tidy(nodes.map((n) => {
602
+ const f = entryOf(n.frame)
603
+ return { key: n.key, frame: n.frame, scene: f?.scene ?? '', group: f?.variantGroup, variant: f?.variant, w: n.w, h: n.h + HEADER }
604
+ }), effectiveLayout(layout, sceneRows), layoutWarn)
510
605
  set((s) => ({
511
606
  nodes: s.nodes.map((n) => {
512
607
  const p = placed.find((x) => x.key === n.key)
@@ -540,6 +635,9 @@ export const useStore = create<State>((set, get) => {
540
635
  },
541
636
 
542
637
  save() {
638
+ // a resize gesture in flight = torn state (new sizes, pre-recipe positions):
639
+ // even a PREVIOUSLY scheduled timer must defer to the gesture-end save
640
+ if (get().gesture && resizedInGesture) { scheduleSave(); return Promise.resolve(false) }
543
641
  // a published canvas is read-only ON DISK (spec §12): theme pins persist to the
544
642
  // session instead, and dirty must clear or switchBoard's save-flush loop wedges
545
643
  if (DATA) {
@@ -548,6 +646,10 @@ export const useStore = create<State>((set, get) => {
548
646
  return Promise.resolve(true)
549
647
  }
550
648
  const p = saveChain.then(async () => {
649
+ // recheck INSIDE the serialized chain: a save queued behind an in-flight PUT
650
+ // may only execute after a resize gesture began - the entry guard above ran
651
+ // too early to see it (codex probe: torn PUT #2 behind blocked PUT #1)
652
+ if (get().gesture && resizedInGesture) { scheduleSave(); return false }
551
653
  const rev = editRev
552
654
  const { nodes, boardHash, deviceView, baseLayout, board: boardName, boardAuto } = get()
553
655
  // Missing nodes persist too - only the explicit remove button drops them (spec §7).
@@ -556,6 +658,8 @@ export const useStore = create<State>((set, get) => {
556
658
  // deviceView and the free-form snapshot persist independently: a scoped
557
659
  // exception clears the view but 0 must still restore the snapshot after reload
558
660
  ...(deviceView ? { deviceView } : {}),
661
+ ...(get().sceneRows?.length ? { sceneRows: get().sceneRows } : {}),
662
+ ...(get().layoutRaw !== undefined ? { layout: get().layoutRaw } : {}),
559
663
  ...(baseLayout ? { baseLayout } : {}),
560
664
  // only PINNED themes persist - inherited values follow viewTheme at load time
561
665
  nodes: nodes.map(({ key, frame, x, y, w, h, themeUser }) => ({ key, frame, x: Math.round(x), y: Math.round(y), w: Math.round(w), h: Math.round(h), ...(themeUser ? { themeUser } : {}) })),
@@ -194,7 +194,7 @@ body.sh-space .sh-node { pointer-events: none } /* space-drag
194
194
  .sh-handle.se { right: -5px; bottom: -5px; width: 11px; height: 11px; cursor: nwse-resize }
195
195
 
196
196
  /* glass surfaces share one recipe; chrome text is never selectable (shift-click = multi-select) */
197
- .sh-ctx, .sh-panel, .sh-fab, .sh-pill, .sh-pill-fab, .sh-menu, .sh-banner, .sh-toast {
197
+ .sh-ctx, .sh-panel, .sh-fab, .sh-pill, .sh-pill-fab, .sh-menu, .sh-banner, .sh-toast, .sh-update {
198
198
  background: var(--glass); backdrop-filter: var(--blur); -webkit-backdrop-filter: var(--blur);
199
199
  border: 1px solid var(--glass-brd); box-shadow: var(--shadow-glass); position: relative;
200
200
  user-select: none; -webkit-user-select: none }
@@ -202,7 +202,7 @@ body.sh-space .sh-node { pointer-events: none } /* space-drag
202
202
  /* edge light: a 1px gradient ring over the border, so glass reads as glass even
203
203
  with nothing behind it to blur (masked-border technique) */
204
204
  .sh-ctx::after, .sh-panel::after, .sh-fab::after, .sh-pill::after, .sh-pill-fab::after,
205
- .sh-menu::after, .sh-banner::after, .sh-toast::after, .sh-node::after {
205
+ .sh-menu::after, .sh-banner::after, .sh-toast::after, .sh-update::after, .sh-node::after {
206
206
  content: ''; position: absolute; inset: -1px; padding: 1px; border-radius: inherit;
207
207
  background: var(--edge-light); pointer-events: none;
208
208
  -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
@@ -404,6 +404,25 @@ body.sh-space .sh-node { pointer-events: none } /* space-drag
404
404
  .sh-banner { position: absolute; bottom: var(--edge); right: var(--edge); z-index: 10; font: 500 12px -apple-system, system-ui, sans-serif;
405
405
  color: var(--glass-warn); border-radius: 999px; padding: 8px 14px }
406
406
 
407
+ /* update pill: bottom-center, the quietest possible member of the pill family.
408
+ The command IS the button - one click copies it for the terminal or the agent. */
409
+ .sh-update { position: absolute; left: 50%; bottom: var(--edge); transform: translateX(-50%); z-index: 10;
410
+ display: flex; align-items: center; gap: 10px; height: 40px; padding: 0 7px 0 16px; border-radius: 999px;
411
+ max-width: min(92vw, 620px); white-space: nowrap;
412
+ font: 500 12.5px -apple-system, system-ui, sans-serif; color: var(--glass-ink-2);
413
+ animation: sh-update-in .35s cubic-bezier(.2, .9, .3, 1.2) }
414
+ @keyframes sh-update-in { from { opacity: 0; transform: translate(-50%, 8px) scale(.96) } }
415
+ .sh-update b { font-weight: 650; color: var(--glass-ink) }
416
+ .sh-update .cmd { border: 1px solid var(--glass-brd); background: var(--glass-hover); border-radius: 999px;
417
+ padding: 4px 11px; cursor: pointer; min-width: 0; overflow: hidden; transition: color .15s }
418
+ .sh-update .cmd code { font: 500 11.5px ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--glass-ink-2);
419
+ display: block; overflow: hidden; text-overflow: ellipsis }
420
+ .sh-update .cmd:hover code { color: var(--glass-ink) }
421
+ .sh-update .x { display: inline-flex; align-items: center; justify-content: center; width: 26px; height: 26px;
422
+ border: 0; background: none; border-radius: 999px; color: var(--glass-ink-3); cursor: pointer;
423
+ transition: color .15s, background .15s }
424
+ .sh-update .x:hover { background: var(--glass-hover); color: var(--glass-ink) }
425
+
407
426
  .sh-toasts { position: absolute; left: var(--edge); bottom: var(--edge); z-index: 12; display: flex; flex-direction: column; gap: 6px }
408
427
  .sh-toast { display: flex; align-items: center; gap: 6px; font: 500 12px -apple-system, system-ui, sans-serif;
409
428
  color: var(--glass-ink-2); border-radius: 999px; padding: 7px 13px }
@@ -506,3 +525,89 @@ body.sh-space .sh-node { pointer-events: none } /* space-drag
506
525
  Inset so scroll containers can never clip it; outline follows the border radius. */
507
526
  .sh-app :focus { outline: none }
508
527
  body.sh-kbd .sh-app :focus-visible { outline: 2px solid var(--accent); outline-offset: -2px }
528
+
529
+ /* ---- Variant groups (SPEC-023 §4): badge left of grouped frames + group caption.
530
+ Both live in WORLD space (scale with the canvas, per Nic's ask) with a screen-space
531
+ minimum via --sh-inv, the resize-handle trick - legible at overview zoom, proportionate
532
+ up close. Colors are on-canvas ink (the dot-grid's world), not chrome glass. */
533
+ /* gap to the frame is ~12px screen at working zooms, capped at 40 world px far out:
534
+ a pure world gap reads ever wider zoomed in, an uncapped screen gap outgrows the
535
+ gutter zoomed out */
536
+ .sh-vbadge { position: absolute; right: 100%; top: 0;
537
+ /* margin caps at 40 WORLD px: below ~30% zoom an uncapped screen-constant margin
538
+ alone would outgrow the gutter and push the badge onto the left neighbor */
539
+ margin-right: clamp(6px, calc(12px * var(--sh-inv, 1)), 40px);
540
+ display: flex; flex-direction: column; align-items: flex-end; gap: 2px;
541
+ text-align: right; user-select: none; -webkit-user-select: none;
542
+ font-family: -apple-system, system-ui, sans-serif }
543
+ .sh-vbadge b { font-size: min(clamp(40px, calc(20px * var(--sh-inv, 1)), 120px), calc(40px * var(--sh-inv, 1))); line-height: 1; font-weight: 750;
544
+ /* world cap 120px: past ~17% zoom the letter stops growing (in world terms) and
545
+ shrinks with the canvas instead of bleeding across the gutter onto the neighbor */
546
+ letter-spacing: -0.02em; color: rgba(24, 24, 27, .55);
547
+ /* the letter OUTLIVES the name (drive feedback 2026-08-12): the name leaves at
548
+ ~30%, the letter stays useful until ~10-16% - the world cap keeps it inside the
549
+ gutter down there - and then hands identity to the group caption */
550
+ opacity: clamp(0, calc((var(--sh-s, 1) - 0.1) * 15), 1) }
551
+ .sh-vbadge span { font-size: min(max(15px, calc(10px * var(--sh-inv, 1))), calc(15px * var(--sh-inv, 1))); font-weight: 550;
552
+ color: rgba(24, 24, 27, .38); max-width: 96px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
553
+ /* the letter is the identity at overview zoom; the name earns pixels only close up */
554
+ opacity: clamp(0, calc((var(--sh-s, 1) - 0.3) * 6), 1) }
555
+ .sh-app.dark .sh-vbadge b { color: rgba(245, 245, 247, .6) }
556
+ .sh-app.dark .sh-vbadge span { color: rgba(245, 245, 247, .4) }
557
+
558
+ .sh-gcaption { position: absolute; top: 0; left: 0; user-select: none; -webkit-user-select: none;
559
+ /* screen-clamped both ways: 12px floor at overview, 18px ceiling zoomed in - world
560
+ text that keeps growing past 100% zoom dwarfs the fixed-size floating menu above */
561
+ font: 600 clamp(calc(12px * var(--sh-inv, 1)), 17px, calc(18px * var(--sh-inv, 1)))/1.2 -apple-system, system-ui, sans-serif;
562
+ letter-spacing: .01em; color: rgba(24, 24, 27, .45); white-space: nowrap }
563
+ .sh-app.dark .sh-gcaption { color: rgba(245, 245, 247, .45) }
564
+
565
+ /* sidebar variant rows: group header carries the experimentation mark; each variant
566
+ row carries its letter CHIP (accent-filled when selected) + name (SPEC-023 §5) */
567
+ .sub.vgroup { display: flex; align-items: center; gap: 8px }
568
+ .sub.vgroup .glabel { overflow: hidden; text-overflow: ellipsis; white-space: nowrap }
569
+ .sub.vgroup .gicon { margin-left: auto; flex: none; color: var(--glass-ink-3); transition: color .15s }
570
+ .sub.vgroup:hover .gicon { color: var(--glass-ink) }
571
+ .sub.vgroup.on .gicon { color: var(--accent) }
572
+ .sub .chip { width: 20px; height: 20px; flex: none; display: inline-flex; align-items: center; justify-content: center;
573
+ border: 1px solid var(--glass-brd); border-radius: 7px; background: none; cursor: pointer;
574
+ font: 700 10px -apple-system, system-ui, sans-serif; color: var(--glass-ink-3);
575
+ /* optical centering: trim the text box to CAP height so flex centers the glyph
576
+ itself, not the em box (whose descender space floats caps high) */
577
+ line-height: 1; letter-spacing: 0; text-box: trim-both cap alphabetic;
578
+ transition: color .15s, background .15s, border-color .15s }
579
+ .sub .chip:hover { color: var(--glass-ink); background: var(--glass-hover) }
580
+ .sub .chip.on { color: #fff; background: var(--accent); border-color: var(--accent) }
581
+
582
+ /* play-mode variant chips (SPEC-023 §6) */
583
+ .sh-play-nav .vchip { min-width: 24px; font-weight: 700; font-size: 11px }
584
+ .sh-play-nav .vchip.on { color: #fff; background: var(--accent); border-radius: 7px }
585
+
586
+ .sh-play-nav .vname { font-size: 11px; font-weight: 600; color: var(--glass-ink-2); max-width: 120px;
587
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-transform: capitalize; padding: 0 2px }
588
+
589
+ /* variant chrome goes LIVE with selection (drive feedback 2026-08-12): accent when the
590
+ frame is selected, the interact purple when it is being interacted with - the badge,
591
+ name, caption, and frame title all agree with the frame's state */
592
+ .sh-vbadge { cursor: pointer }
593
+ /* children stay click-transparent: rzpp's pan exclusion and the select handler both
594
+ need the BADGE DIV to be the event target (same pattern as button svg) */
595
+ .sh-vbadge b, .sh-vbadge span { pointer-events: none }
596
+ .sh-node.sel .sh-vbadge b, .sh-node.sel .sh-vbadge span { color: var(--accent) }
597
+ .sh-node.interact .sh-vbadge b, .sh-node.interact .sh-vbadge span { color: var(--interact) }
598
+ /* the frame header sits INSIDE the selection border - tinting its title too would
599
+ double-signal (drive feedback 2026-08-12); the badge/caption/sidebar carry state */
600
+ .sh-gcaption { cursor: pointer }
601
+ .sh-gcaption.on { color: var(--accent) }
602
+ .sh-gcaption:hover { color: var(--glass-ink-2) }
603
+ .sh-gcaption.on:hover { color: var(--accent) }
604
+
605
+ /* per-variant sidebar rows under the group header */
606
+ /* variant rows read one level QUIETER than frame rows: shorter, smaller, lighter -
607
+ the scannable hierarchy is scene > frame/surface > variants */
608
+ .sub.vrow { display: flex; align-items: center; gap: 7px; padding-left: 31px; height: 24px; font-size: 12px }
609
+ .sub.vrow .chip { width: 16px; height: 16px; border-radius: 5px; font-size: 8.5px }
610
+ .sub.vrow .nm { overflow: hidden; text-overflow: ellipsis; white-space: nowrap }
611
+ .sub.vgroup { cursor: pointer }
612
+ .sub.vgroup.held { background: color-mix(in srgb, var(--glass-hover) 55%, transparent) }
613
+ .sub.vgroup.held .gicon { color: var(--glass-ink-2) }