@marver-design/marver 0.2.2 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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) {
@@ -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
@@ -14,7 +14,7 @@ const DATA: { manifest: Manifest; boards: Record<string, unknown>; names: string
14
14
  /** True on a published static canvas - no dev server, no API, no update checks. */
15
15
  export const PUBLISHED = DATA !== null
16
16
 
17
- export interface FrameEntry { id: string; file: string; kind: 'tsx' | 'html'; scene: string; title?: string; viewport?: string; theme?: string }
17
+ export interface FrameEntry { id: string; file: string; kind: 'tsx' | 'html'; scene: string; title?: string; viewport?: string; theme?: string; variantGroup?: string; variant?: string }
18
18
  export interface Manifest { frames: FrameEntry[]; scenes: { name: string; frames: number }[] }
19
19
  export interface Node {
20
20
  key: string; frame: string; x: number; y: number; w: number; h: number
@@ -76,6 +76,18 @@ function defaultSize(frame: FrameEntry) {
76
76
  return { w: vp.width, h: vp.height }
77
77
  }
78
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
+
79
91
  interface State {
80
92
  manifest: Manifest | null
81
93
  nodes: Node[] // append-only order; NEVER sorted or reordered (iframe law G-1)
@@ -87,6 +99,9 @@ interface State {
87
99
  board: string // active board name; 'all-scenes' is the auto board
88
100
  boardAuto: boolean // auto boards gain new frames on arrival; curated boards never do
89
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
90
105
  baseLayout: Record<string, { x: number; y: number; w: number; h: number }> | null // snapshot taken on entering a device view; Default restores it exactly
91
106
  panelOpen: boolean
92
107
  scale: number
@@ -102,6 +117,7 @@ interface State {
102
117
  setStatus(key: string, status: Node['status'], error?: string): void
103
118
  removeNode(key: string): void
104
119
  select(key: string | null, additive?: boolean): void
120
+ selectMany(keys: string[]): void
105
121
  selectAll(): void
106
122
  setInteract(key: string | null): void
107
123
  setPlay(p: State['play']): void
@@ -170,6 +186,9 @@ export const useStore = create<State>((set, get) => {
170
186
  let boardHash: string | null = null
171
187
  let boardAuto = boardName === 'all-scenes'
172
188
  let deviceView: string | null = null
189
+ let sceneRows: string[][] | null = null
190
+ let layout: BoardLayout | null = null
191
+ let layoutRaw: unknown = undefined
173
192
  let baseLayout: State['baseLayout'] = null
174
193
  let needTidy = false
175
194
  // published build: boards come from the inlined data; absent = fresh (the 404 path)
@@ -224,6 +243,15 @@ export const useStore = create<State>((set, get) => {
224
243
  // device view and the free-form snapshot survive reloads - independently:
225
244
  // a scoped "exception" clears deviceView but the snapshot must keep restoring
226
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')
227
255
  if (board?.baseLayout && typeof board.baseLayout === 'object') baseLayout = board.baseLayout
228
256
  }
229
257
  // auto-managed goes both ways (friction log #15): an auto board gains new frames
@@ -236,15 +264,30 @@ export const useStore = create<State>((set, get) => {
236
264
  nodes = nodes.filter((n) => !n.missing)
237
265
  prunedAtLoad = !DATA && nodes.length !== before
238
266
  }
239
- // 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)
240
272
  if (boardAuto) {
241
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)
242
275
  for (const f of manifest.frames) {
243
276
  if (placed.has(f.id)) continue
244
277
  placed.add(f.id)
245
278
  const d = defaultSize(f)
246
279
  const vp = deviceView ? CONFIG.viewports[deviceView] : null
247
- 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)
248
291
  }
249
292
  }
250
293
  // published: re-apply this visit's pins over the inlined data
@@ -256,19 +299,31 @@ export const useStore = create<State>((set, get) => {
256
299
  }
257
300
  }
258
301
  if ((!boardHash || needTidy) && nodes.length) {
259
- const sceneOf = (id: string) => manifest.frames.find((f) => f.id === id)?.scene ?? ''
260
- 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)
261
307
  for (const pl of placedAll) { const n = nodes.find((x) => x.key === pl.key)!; n.x = pl.x; n.y = pl.y }
262
308
  }
263
309
  // dirty matches disk by construction - except when load-time pruning changed the
264
310
  // node set; callers see dirty:true and schedule the save that persists the prune
265
- 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 }
266
321
  } catch { return null }
267
322
  }
268
323
 
269
324
  return {
270
325
  manifest: null, nodes: [], selection: [], interact: null, viewTheme: initialViewTheme(), play: null, gesture: false,
271
- 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,
272
327
  panelOpen: true, scale: 1, toasts: [], boardHash: null, dirty: false,
273
328
 
274
329
  async boot() {
@@ -290,6 +345,12 @@ export const useStore = create<State>((set, get) => {
290
345
  async switchBoard(name) {
291
346
  const mySwitch = ++switchSeq // also cancels any pending switch (incl. re-clicking the current board)
292
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
+ }
293
354
  // flush loop: an edit landing mid-save keeps dirty set and gets its own pass
294
355
  let ok = true
295
356
  for (let i = 0; i < 5 && get().dirty && ok; i++) { clearTimeout(saveTimer); ok = await get().save() }
@@ -326,7 +387,15 @@ export const useStore = create<State>((set, get) => {
326
387
  if (known.has(f.id)) continue
327
388
  const d = defaultSize(f)
328
389
  const maxY = next.reduce((a, n) => Math.max(a, n.y + n.h), 0)
329
- 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 }
330
399
  next.push(node)
331
400
  // in a device view, the snapshot learns the newcomer's DEFAULT size so 0 restores it sanely
332
401
  if (vp && nextBase) nextBase = { ...nextBase, [node.key]: { x: node.x, y: node.y, w: d.w, h: d.h } }
@@ -402,6 +471,8 @@ export const useStore = create<State>((set, get) => {
402
471
  scheduleSave()
403
472
  },
404
473
  resizeNode(key, w, h) {
474
+ resizedInGesture = true
475
+ const inGesture = get().gesture
405
476
  // a manual resize means the board no longer uniformly shows one device; the resized
406
477
  // frame's snapshot entry follows the user's new size so Default will not undo it
407
478
  set((s) => {
@@ -416,7 +487,9 @@ export const useStore = create<State>((set, get) => {
416
487
  : s.baseLayout,
417
488
  }
418
489
  })
419
- 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()
420
493
  },
421
494
  setDeviceView(name) {
422
495
  const vp = name ? CONFIG.viewports[name] : null
@@ -484,12 +557,28 @@ export const useStore = create<State>((set, get) => {
484
557
  selectAll() {
485
558
  set((s) => ({ selection: s.nodes.map((n) => n.key) }))
486
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
+ },
487
567
  // interact is a ONE-frame mode: entering it collapses any multi-selection to the
488
568
  // interacted frame (a 4-frame selection double-clicked otherwise leaves all four
489
569
  // painted as "interactive"). Exiting keeps the frame selected for continuity.
490
570
  setInteract(key) { set((s) => ({ interact: key, selection: key ? [key] : s.selection })) },
491
571
  setPlay(play) { set({ play }) },
492
- 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
+ },
493
582
  setScale(scale) { set({ scale }) },
494
583
  togglePanel() { set((s) => ({ panelOpen: !s.panelOpen })) },
495
584
  // global theme = the VIEW preference: persists across boards + reloads, clears
@@ -507,9 +596,12 @@ export const useStore = create<State>((set, get) => {
507
596
  scheduleSave()
508
597
  },
509
598
  runTidy() {
510
- const { nodes, manifest } = get()
511
- const sceneOf = (id: string) => manifest?.frames.find((f) => f.id === id)?.scene ?? ''
512
- 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)
513
605
  set((s) => ({
514
606
  nodes: s.nodes.map((n) => {
515
607
  const p = placed.find((x) => x.key === n.key)
@@ -543,6 +635,9 @@ export const useStore = create<State>((set, get) => {
543
635
  },
544
636
 
545
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) }
546
641
  // a published canvas is read-only ON DISK (spec §12): theme pins persist to the
547
642
  // session instead, and dirty must clear or switchBoard's save-flush loop wedges
548
643
  if (DATA) {
@@ -551,6 +646,10 @@ export const useStore = create<State>((set, get) => {
551
646
  return Promise.resolve(true)
552
647
  }
553
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 }
554
653
  const rev = editRev
555
654
  const { nodes, boardHash, deviceView, baseLayout, board: boardName, boardAuto } = get()
556
655
  // Missing nodes persist too - only the explicit remove button drops them (spec §7).
@@ -559,6 +658,8 @@ export const useStore = create<State>((set, get) => {
559
658
  // deviceView and the free-form snapshot persist independently: a scoped
560
659
  // exception clears the view but 0 must still restore the snapshot after reload
561
660
  ...(deviceView ? { deviceView } : {}),
661
+ ...(get().sceneRows?.length ? { sceneRows: get().sceneRows } : {}),
662
+ ...(get().layoutRaw !== undefined ? { layout: get().layoutRaw } : {}),
562
663
  ...(baseLayout ? { baseLayout } : {}),
563
664
  // only PINNED themes persist - inherited values follow viewTheme at load time
564
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 } : {}) })),
@@ -525,3 +525,89 @@ body.sh-space .sh-node { pointer-events: none } /* space-drag
525
525
  Inset so scroll containers can never clip it; outline follows the border radius. */
526
526
  .sh-app :focus { outline: none }
527
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) }