@marver-design/marver 0.2.4 → 0.3.0

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.
@@ -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; variantGroup?: string; variant?: string }
17
+ export interface FrameEntry { id: string; file: string; kind: 'tsx' | 'html'; scene: string; title?: string; viewport?: string; theme?: string; variantGroup?: string; variant?: string; intent?: string; contentWidth?: number }
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
@@ -26,6 +26,11 @@ export interface Node {
26
26
  /** Explicit per-frame override, set by scoped theme actions; cleared by a global set.
27
27
  * The only theme value that persists into the board file. */
28
28
  themeUser?: string
29
+ /** Size provenance for CONTENT frames (SPEC-026), explicit: 'auto' = measured
30
+ * (transient - never serialized); 'manual' = the human's - measurements never
31
+ * overwrite; 'device' = a preset's. UI frames never carry it. Only manual/device
32
+ * persist, so a reload can tell an authored size from a measured one. */
33
+ sizeMode?: 'auto' | 'manual' | 'device'
29
34
  status: 'loading' | 'ready' | 'error'; error?: string; missing?: boolean
30
35
  }
31
36
  export interface Toast { id: number; text: string }
@@ -71,7 +76,18 @@ const initialViewTheme = () => {
71
76
  }
72
77
  const manifestKey = (m: Manifest) => JSON.stringify(m.frames) // any change counts, not just added/removed ids
73
78
 
79
+ /** Latest measured content heights, keyed frameId@width. TRANSIENT by design
80
+ * (SPEC-026): auto sizes are never serialized - a reload remeasures. */
81
+ const measuredHeights = new Map<string, number>()
82
+
74
83
  function defaultSize(frame: FrameEntry) {
84
+ // content frames (SPEC-026): own width from Doc layout; height from the latest
85
+ // measurement at that width, or a placeholder until sh:measure lands.
86
+ // meta.viewport, when declared, wins - the existing precedence.
87
+ if (frame.contentWidth && !frame.viewport) {
88
+ const w = frame.contentWidth
89
+ return { w, h: measuredHeights.get(`${frame.id}@${w}`) ?? Math.round(w * 0.75) }
90
+ }
75
91
  const vp = CONFIG.viewports[frame.viewport ?? ''] ?? CONFIG.viewports.mobile ?? { width: 390, height: 844 }
76
92
  return { w: vp.width, h: vp.height }
77
93
  }
@@ -102,7 +118,7 @@ interface State {
102
118
  sceneRows: string[][] | null // LEGACY scene arrangement (SPEC-023 §2); still round-trips through save
103
119
  layout: BoardLayout | null // lane-flow recipe (SPEC-024), parsed; wins over sceneRows when both exist
104
120
  layoutRaw: unknown // the author's layout VERBATIM - save round-trips this, never the parse
105
- baseLayout: Record<string, { x: number; y: number; w: number; h: number }> | null // snapshot taken on entering a device view; Default restores it exactly
121
+ baseLayout: Record<string, { x: number; y: number; w?: number; h?: number }> | null // snapshot taken on entering a device view; Default restores it exactly (auto content entries carry positions only - their sizes are measured)
106
122
  panelOpen: boolean
107
123
  scale: number
108
124
  toasts: Toast[]
@@ -114,6 +130,7 @@ interface State {
114
130
  frameFor(node: Node): FrameEntry | undefined
115
131
  moveNode(key: string, x: number, y: number): void
116
132
  resizeNode(key: string, w: number, h: number): void
133
+ measureNode(key: string, frameId: string, ownWidth: number, measuredWidth: number, height: number): void
117
134
  setStatus(key: string, status: Node['status'], error?: string): void
118
135
  removeNode(key: string): void
119
136
  select(key: string | null, additive?: boolean): void
@@ -144,6 +161,21 @@ export const useStore = create<State>((set, get) => {
144
161
  let switchSeq = 0 // last click wins when board switches race
145
162
  const scheduleSave = () => { editRev++; clearTimeout(saveTimer); saveTimer = setTimeout(() => get().save(), 500) }
146
163
 
164
+ // SPEC-026: one cancelable, BOARD-SCOPED reflow after content measurements settle.
165
+ // The captured board name is the generation guard - a debounce surviving a board
166
+ // switch fires into a name check and dies, never touching the new board.
167
+ let reflowTimer: ReturnType<typeof setTimeout> | undefined
168
+ const scheduleReflow = () => {
169
+ const boardAt = get().board
170
+ clearTimeout(reflowTimer)
171
+ reflowTimer = setTimeout(() => {
172
+ const s = get()
173
+ if (s.board !== boardAt) return
174
+ if (s.gesture) { scheduleReflow(); return } // defer, never drop - retries after the drag
175
+ if (s.layout || s.sceneRows?.length) s.runTidy()
176
+ }, 400)
177
+ }
178
+
147
179
  /** Theme resolution ladder: user pin > the frame's declared meta.theme > viewTheme. */
148
180
  const resolveTheme = (frame?: FrameEntry, user?: string) => user ?? frame?.theme ?? get().viewTheme
149
181
 
@@ -163,6 +195,28 @@ export const useStore = create<State>((set, get) => {
163
195
  },
164
196
  }
165
197
 
198
+ /** Published parity for content-frame sizes (SPEC-026): manual/device sizes persist
199
+ * to the session the same way theme pins do - a board switch or reload on a
200
+ * published canvas must not silently drop a hand-resized spec frame. Keys are
201
+ * frame#occurrence: node keys are minted per load on virtual boards, but the node
202
+ * ORDER round-trips (board file order / manifest order), so the ordinal is the
203
+ * stable identity - duplicates of one frame keep their individual sizes. */
204
+ const sizeKey = (nodes: Node[], node: Node) =>
205
+ `${node.frame}#${nodes.filter((n) => n.frame === node.frame).indexOf(node)}`
206
+ const sessionSizes = {
207
+ read(board: string): Record<string, { w: number; h: number; sizeMode: 'manual' | 'device' }> {
208
+ try { return JSON.parse(sessionStorage.getItem(`mv-sizes-${board}`) ?? '{}') } catch { return {} }
209
+ },
210
+ write(board: string, nodes: Node[]) {
211
+ try {
212
+ const sizes: Record<string, { w: number; h: number; sizeMode: string }> = {}
213
+ for (const n of nodes) if (n.sizeMode === 'manual' || n.sizeMode === 'device')
214
+ sizes[sizeKey(nodes, n)] = { w: Math.round(n.w), h: Math.round(n.h), sizeMode: n.sizeMode }
215
+ sessionStorage.setItem(`mv-sizes-${board}`, JSON.stringify(sizes))
216
+ } catch { /* storage unavailable */ }
217
+ },
218
+ }
219
+
166
220
  /** Fetch + normalize a board into ready-to-commit state, WITHOUT touching the store.
167
221
  * null = failure (transport, malformed manifest, non-404 board error) - the caller
168
222
  * keeps whatever board is currently mounted. */
@@ -221,11 +275,17 @@ export const useStore = create<State>((set, get) => {
221
275
  let key = typeof n.key === 'string' && n.key ? n.key : nodeKey()
222
276
  if (seenKeys.has(key)) key = nodeKey()
223
277
  seenKeys.add(key)
278
+ // content-frame size provenance round-trips (SPEC-026); auto nodes saved no
279
+ // w/h, so they fall to defaultSize (placeholder) and remeasure on mount
280
+ const sizeMode = f?.contentWidth
281
+ ? { sizeMode: (n.sizeMode === 'manual' || n.sizeMode === 'device' ? n.sizeMode : 'auto') as Node['sizeMode'] }
282
+ : {}
224
283
  return {
225
284
  key,
226
285
  frame: n.frame,
227
286
  x: typeof n.x === 'number' ? n.x : 0, y: typeof n.y === 'number' ? n.y : 0,
228
287
  w: typeof n.w === 'number' ? n.w : d.w, h: typeof n.h === 'number' ? n.h : d.h,
288
+ ...sizeMode,
229
289
  // pins persist as their own field (exact round-trip). Legacy boards stored a
230
290
  // theme on EVERY node: only values differing from the frame's static default
231
291
  // were deliberate - the rest follow viewTheme
@@ -285,17 +345,23 @@ export const useStore = create<State>((set, get) => {
285
345
  : null
286
346
  if (sibling) { x = sibling.x + sibling.w + Math.max(140, sibling.w * 0.12); y = sibling.y }
287
347
  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' }
348
+ const node: Node = { key: nodeKey(), frame: f.id, x, y, w, h, theme: resolveTheme(f), status: 'loading', ...(f.contentWidth ? { sizeMode: (vp ? 'device' : 'auto') as Node['sizeMode'] } : {}) }
289
349
  nodes.push(node)
290
350
  maxX = Math.max(maxX, x + w)
291
351
  }
292
352
  }
293
- // published: re-apply this visit's pins over the inlined data
353
+ // published: re-apply this visit's pins and content sizes over the inlined data
294
354
  if (DATA && nodes.length) {
295
355
  const pins = sessionPins.read(boardName)
356
+ const sizes = sessionSizes.read(boardName)
296
357
  for (const n of nodes) {
297
358
  const pin = pins[n.frame]
298
359
  if (pin && CONFIG.themes.includes(pin)) { n.themeUser = pin; n.theme = pin }
360
+ const sz = sizes[sizeKey(nodes, n)]
361
+ if (sz && manifest.frames.find((f) => f.id === n.frame)?.contentWidth
362
+ && Number.isFinite(sz.w) && Number.isFinite(sz.h) && (sz.sizeMode === 'manual' || sz.sizeMode === 'device')) {
363
+ n.w = sz.w; n.h = sz.h; n.sizeMode = sz.sizeMode
364
+ }
299
365
  }
300
366
  }
301
367
  if ((!boardHash || needTidy) && nodes.length) {
@@ -393,9 +459,10 @@ export const useStore = create<State>((set, get) => {
393
459
  ? next.filter((n) => { const g = m.frames.find((x) => x.id === n.frame)?.variantGroup; return g === f.variantGroup && !n.missing })
394
460
  : []
395
461
  const right = sibs.length ? sibs.reduce((a, n) => (n.x > a.x ? n : a)) : null
462
+ const mode = f.contentWidth ? { sizeMode: (vp ? 'device' : 'auto') as Node['sizeMode'] } : {}
396
463
  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 }
464
+ ? { 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, ...mode }
465
+ : { 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, ...mode }
399
466
  next.push(node)
400
467
  // in a device view, the snapshot learns the newcomer's DEFAULT size so 0 restores it sanely
401
468
  if (vp && nextBase) nextBase = { ...nextBase, [node.key]: { x: node.x, y: node.y, w: d.w, h: d.h } }
@@ -412,6 +479,12 @@ export const useStore = create<State>((set, get) => {
412
479
  const f = m.frames.find((x) => x.id === n.frame)
413
480
  const want = n.themeUser ?? f?.theme ?? get().viewTheme
414
481
  if (n.theme !== want) { n.theme = want; retinted = true }
482
+ // content-ness can change live (agent adds/removes the primitives): reconcile
483
+ // provenance or measurements are rejected / UI dims silently omitted from saves.
484
+ // Under an active device view the newcomer joins it as 'device' - a measurement
485
+ // must not override the preset the whole board is showing
486
+ if (f?.contentWidth && !n.sizeMode) { n.sizeMode = deviceView ? 'device' : 'auto'; retinted = true }
487
+ if (!f?.contentWidth && n.sizeMode) { delete n.sizeMode; retinted = true }
415
488
  // an errored frame whose file IS in the fresh manifest gets one automatic retry
416
489
  // on a rev-stamped URL - the "unknown frame id" dead end must self-heal (#20)
417
490
  if (!missing && n.status === 'error') { n.status = 'loading'; n.nav = (n.nav ?? 0) + 1; retinted = true }
@@ -478,8 +551,10 @@ export const useStore = create<State>((set, get) => {
478
551
  set((s) => {
479
552
  const W = Math.max(120, w), H = Math.max(80, h)
480
553
  const cur = s.nodes.find((n) => n.key === key)
554
+ // a human-resized CONTENT frame goes 'manual': measurements never overwrite it (SPEC-026)
555
+ const content = !!s.manifest?.frames.find((x) => x.id === cur?.frame)?.contentWidth
481
556
  return {
482
- nodes: s.nodes.map((n) => (n.key === key ? { ...n, w: W, h: H } : n)),
557
+ nodes: s.nodes.map((n) => (n.key === key ? { ...n, w: W, h: H, ...(content ? { sizeMode: 'manual' as const } : {}) } : n)),
483
558
  dirty: true,
484
559
  deviceView: null,
485
560
  baseLayout: s.baseLayout && cur
@@ -491,21 +566,62 @@ export const useStore = create<State>((set, get) => {
491
566
  // the gesture-end hook saves once, after any retidy
492
567
  if (!inGesture) scheduleSave()
493
568
  },
569
+
570
+ /** SPEC-026 sh:measure. Admission: content frames only, finite positive numbers,
571
+ * clamped. A height only commits when it was measured at the width being applied;
572
+ * auto sizes are transient - applying one never dirties the board (positions from
573
+ * the follow-up reflow do, exactly like a human resize). */
574
+ measureNode(key, frameId, ownWidth, measuredWidth, height) {
575
+ const s = get()
576
+ const node = s.nodes.find((n) => n.key === key)
577
+ if (!node || node.sizeMode !== 'auto') return // manual/device always win
578
+ if (node.frame !== frameId) return // generation guard: a reused node key
579
+ // across a board switch never mis-attributes
580
+ const f = s.manifest?.frames.find((x) => x.id === node.frame)
581
+ if (!f?.contentWidth) return // not a content frame - spoof-proofing
582
+ if (![ownWidth, measuredWidth, height].every((v) => Number.isFinite(v) && v > 0)) return
583
+ const maxH = Math.round(2.5 * Math.max(844, ...Object.values(CONFIG.viewports).map((v) => v.height)))
584
+ // declared meta.viewport WINS over the Doc layout width - the existing precedence
585
+ const vpw = CONFIG.viewports[f.viewport ?? '']?.width
586
+ const W = vpw ?? Math.min(1600, Math.max(320, Math.round(ownWidth)))
587
+ const H = Math.min(maxH, Math.max(80, Math.round(height)))
588
+ const curW = Math.round(node.w)
589
+ measuredHeights.set(`${node.frame}@${Math.round(measuredWidth)}`, H)
590
+ if (Math.round(measuredWidth) !== curW) return // height not true at the applied width
591
+ if (W !== curW) {
592
+ // Doc layout changed (document<->wide): adopt the new own width first; the
593
+ // iframe resizes, remeasures, and the height commits on the next message
594
+ set((st) => ({ nodes: st.nodes.map((n) => (n.key === key ? { ...n, w: W } : n)) }))
595
+ scheduleReflow()
596
+ return
597
+ }
598
+ if (Math.round(node.h) === H) return
599
+ set((st) => ({ nodes: st.nodes.map((n) => (n.key === key ? { ...n, h: H } : n)) }))
600
+ scheduleReflow()
601
+ },
494
602
  setDeviceView(name) {
495
603
  const vp = name ? CONFIG.viewports[name] : null
496
604
  if (name && !vp) return
497
605
  set((s) => {
498
- // entering a device view from free-form: snapshot the layout so Default restores it
606
+ // entering a device view from free-form: snapshot the layout so Default restores it.
607
+ // Auto content nodes snapshot POSITIONS only - their w/h are measured (transient by
608
+ // contract) and restore comes from the measurement cache, never from the snapshot
499
609
  const baseLayout = name
500
610
  ? (s.deviceView === null
501
- ? Object.fromEntries(s.nodes.map((n) => [n.key, { x: n.x, y: n.y, w: n.w, h: n.h }]))
611
+ ? Object.fromEntries(s.nodes.map((n) => [n.key, n.sizeMode === 'auto' ? { x: n.x, y: n.y } : { x: n.x, y: n.y, w: n.w, h: n.h }]))
502
612
  : s.baseLayout)
503
613
  : null
504
614
  const nodes = s.nodes.map((n) => {
505
- if (vp) return { ...n, w: vp.width, h: vp.height }
615
+ const f = s.manifest?.frames.find((x) => x.id === n.frame)
616
+ const content = !!f?.contentWidth
617
+ if (vp) return { ...n, w: vp.width, h: vp.height, ...(content ? { sizeMode: 'device' as const } : {}) }
618
+ // digit 0 on a CONTENT frame = back to auto: measured size, remeasure ahead (SPEC-026)
506
619
  const b = s.baseLayout?.[n.key]
620
+ if (content && f) {
621
+ const d = defaultSize(f)
622
+ return { ...n, ...(b ? { x: b.x, y: b.y } : {}), w: d.w, h: d.h, sizeMode: 'auto' as const }
623
+ }
507
624
  if (b) return { ...n, ...b } // exact free-form layout, positions included
508
- const f = s.manifest?.frames.find((x) => x.id === n.frame)
509
625
  if (!f) return n
510
626
  const d = defaultSize(f) // frames added mid-device-view get their default
511
627
  return { ...n, w: d.w, h: d.h }
@@ -539,11 +655,13 @@ export const useStore = create<State>((set, get) => {
539
655
  const sel = new Set(s.selection)
540
656
  const nodes = s.nodes.map((n) => {
541
657
  if (!sel.has(n.key)) return n
542
- if (vp) return { ...n, w: vp.width, h: vp.height }
543
658
  const f = s.manifest?.frames.find((x) => x.id === n.frame)
659
+ const content = !!f?.contentWidth
660
+ if (vp) return { ...n, w: vp.width, h: vp.height, ...(content ? { sizeMode: 'device' as const } : {}) }
544
661
  if (!f) return n
545
662
  const d = defaultSize(f)
546
- return { ...n, w: d.w, h: d.h }
663
+ // digit 0 on a content frame clears provenance back to auto (SPEC-026)
664
+ return { ...n, w: d.w, h: d.h, ...(content ? { sizeMode: 'auto' as const } : {}) }
547
665
  })
548
666
  const baseLayout = s.baseLayout ? { ...s.baseLayout } : null
549
667
  if (baseLayout) for (const k of s.selection) {
@@ -623,7 +741,7 @@ export const useStore = create<State>((set, get) => {
623
741
  const d = defaultSize(f)
624
742
  const vp = deviceView ? CONFIG.viewports[deviceView] : null
625
743
  const maxX = nodes.reduce((a, n) => Math.max(a, n.x + n.w), 0)
626
- const node: Node = { key: nodeKey(), frame: f.id, x: maxX + 96, y: 0, w: vp?.width ?? d.w, h: vp?.height ?? d.h, theme: resolveTheme(f), status: 'loading' }
744
+ const node: Node = { key: nodeKey(), frame: f.id, x: maxX + 96, y: 0, w: vp?.width ?? d.w, h: vp?.height ?? d.h, theme: resolveTheme(f), status: 'loading', ...(f.contentWidth ? { sizeMode: (vp ? 'device' : 'auto') as Node['sizeMode'] } : {}) }
627
745
  set((s) => ({
628
746
  nodes: [...s.nodes, node],
629
747
  dirty: true,
@@ -642,6 +760,7 @@ export const useStore = create<State>((set, get) => {
642
760
  // session instead, and dirty must clear or switchBoard's save-flush loop wedges
643
761
  if (DATA) {
644
762
  sessionPins.write(get().board, get().nodes)
763
+ sessionSizes.write(get().board, get().nodes)
645
764
  set({ dirty: false })
646
765
  return Promise.resolve(true)
647
766
  }
@@ -660,9 +779,23 @@ export const useStore = create<State>((set, get) => {
660
779
  ...(deviceView ? { deviceView } : {}),
661
780
  ...(get().sceneRows?.length ? { sceneRows: get().sceneRows } : {}),
662
781
  ...(get().layoutRaw !== undefined ? { layout: get().layoutRaw } : {}),
663
- ...(baseLayout ? { baseLayout } : {}),
664
- // only PINNED themes persist - inherited values follow viewTheme at load time
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 } : {}) })),
782
+ // baseLayout entries for auto content nodes keep POSITIONS only - their
783
+ // measured dimensions are transient and never reach the file (SPEC-026)
784
+ ...(baseLayout ? {
785
+ baseLayout: Object.fromEntries(Object.entries(baseLayout).map(([k, b]) => {
786
+ const n = nodes.find((x) => x.key === k)
787
+ return n?.sizeMode === 'auto' ? [k, { x: b.x, y: b.y }] : [k, b]
788
+ })),
789
+ } : {}),
790
+ // only PINNED themes persist - inherited values follow viewTheme at load time.
791
+ // Content frames in AUTO save no dimensions (SPEC-026): measured sizes are
792
+ // transient - a reflow-triggered save can never leak an auto height to disk.
793
+ nodes: nodes.map(({ key, frame, x, y, w, h, themeUser, sizeMode }) => ({
794
+ key, frame, x: Math.round(x), y: Math.round(y),
795
+ ...(sizeMode === 'auto' ? {} : { w: Math.round(w), h: Math.round(h) }),
796
+ ...(sizeMode === 'manual' || sizeMode === 'device' ? { sizeMode } : {}),
797
+ ...(themeUser ? { themeUser } : {}),
798
+ })),
666
799
  }
667
800
  try {
668
801
  const res = await fetch(`${ROUTE}/api/boards/${boardName}`, {
@@ -178,6 +178,12 @@ body.sh-space .sh-node { pointer-events: none } /* space-drag
178
178
  background-color: var(--head-bg); background-image: var(--head-sheen);
179
179
  backdrop-filter: var(--blur); -webkit-backdrop-filter: var(--blur) }
180
180
  .sh-node-head .id { font-weight: 600; color: var(--head-ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap }
181
+ /* frame icons (SPEC-026 + feedback): every sidebar row leads with one */
182
+ .sh-node-head .iicon { flex: none; color: var(--head-dim) }
183
+ .sh-panel .sub .iicon { flex: none; margin-right: 7px; opacity: .72 }
184
+ /* variant members sit visibly INSIDE their group: the letter chip starts
185
+ exactly on the parent rows' 51px text column (31 pad + 13 icon + 7 gap) */
186
+ .sh-panel .sub.vrow { padding-left: 51px }
181
187
  .sh-node-head .dim { margin-left: auto; color: var(--head-dim); flex: none; font-variant-numeric: tabular-nums }
182
188
  .sh-node-body { position: relative; background: var(--node-bg); border-radius: 0 0 var(--r-node) var(--r-node); overflow: hidden }
183
189
  .sh-node iframe { border: 0; display: block }
@@ -564,7 +570,9 @@ body.sh-kbd .sh-app :focus-visible { outline: 2px solid var(--accent); outline-o
564
570
 
565
571
  /* sidebar variant rows: group header carries the experimentation mark; each variant
566
572
  row carries its letter CHIP (accent-filled when selected) + name (SPEC-023 §5) */
567
- .sub.vgroup { display: flex; align-items: center; gap: 8px }
573
+ /* gap 0: the leading .iicon's margin-right carries the spacing, so the group
574
+ label lands on the SAME 51px text column as every sibling row */
575
+ .sub.vgroup { display: flex; align-items: center; gap: 0 }
568
576
  .sub.vgroup .glabel { overflow: hidden; text-overflow: ellipsis; white-space: nowrap }
569
577
  .sub.vgroup .gicon { margin-left: auto; flex: none; color: var(--glass-ink-3); transition: color .15s }
570
578
  .sub.vgroup:hover .gicon { color: var(--glass-ink) }
@@ -15,9 +15,11 @@ file in design/instructions/ - they are short, strict, and part of this contract
15
15
  | Welcome | the human's FIRST session, or "what is this?" | instructions/welcome.md |
16
16
  | Configure | first session in a repo, or frames render unstyled | instructions/configure.md |
17
17
  | Discover | any new surface, feature, or flow | instructions/discover.md |
18
+ | Shape | thinking a feature through on canvas (specs, diagrams, mood boards) - never the first session | instructions/shape.md |
18
19
  | Wireframe | new work: nail structure + copy in throwaway lo-fi | instructions/wireframe.md |
19
20
  | Brand | before the first hi-fi work: extract or create the world | instructions/brand.md |
20
21
  | Build | hi-fi frames from real components | instructions/craft.md + components.md |
22
+ | Iterate | changing a frame the human has seen, or retiring explorations | instructions/iterate.md |
21
23
  | Review | before presenting anything | instructions/review.md |
22
24
  | Boards | creating a board or publishing | instructions/boards.md |
23
25
 
@@ -59,6 +61,9 @@ the routing index is at the top of instructions/craft.md. Pull ONE file, apply,
59
61
  a terminal state; play mode makes dead ends visible. Give an element the same
60
62
  view-transition-name CSS in two frames and play mode morphs it between screens.
61
63
  - Files starting with _ are infrastructure (never frames): _layout.tsx, _fixtures.ts.
64
+ - CONTENT frames (specs, mermaid diagrams, mood boards) are ordinary tsx frames built
65
+ from the block primitives in '@marver-design/marver/content' - import them directly
66
+ in the frame file and declare meta.intent. Full guide: instructions/shape.md.
62
67
 
63
68
  ## Structure ladder (embedded mode: screens live in src/)
64
69
  1. First pass: write the whole page inline in the frame file. Diverge fast.
@@ -15,9 +15,11 @@ file in design/instructions/ - they are short, strict, and part of this contract
15
15
  | Welcome | the human's FIRST session, or "what is this?" | instructions/welcome.md |
16
16
  | Configure | first session in a repo, or frames render unstyled | instructions/configure.md |
17
17
  | Discover | any new surface, feature, or flow | instructions/discover.md |
18
+ | Shape | thinking a feature through on canvas (specs, diagrams, mood boards) - never the first session | instructions/shape.md |
18
19
  | Wireframe | new work: nail structure + copy in throwaway lo-fi | instructions/wireframe.md |
19
20
  | Brand | before the first hi-fi work: extract or create the world | instructions/brand.md |
20
21
  | Build | hi-fi frames from real components | instructions/craft.md + components.md |
22
+ | Iterate | changing a frame the human has seen, or retiring explorations | instructions/iterate.md |
21
23
  | Review | before presenting anything | instructions/review.md |
22
24
  | Boards | creating a board or publishing | instructions/boards.md |
23
25
 
@@ -59,6 +61,9 @@ the routing index is at the top of instructions/craft.md. Pull ONE file, apply,
59
61
  a terminal state; play mode makes dead ends visible. Give an element the same
60
62
  view-transition-name CSS in two frames and play mode morphs it between screens.
61
63
  - Files starting with _ are infrastructure (never frames): _layout.tsx, _fixtures.ts.
64
+ - CONTENT frames (specs, mermaid diagrams, mood boards) are ordinary tsx frames built
65
+ from the block primitives in '@marver-design/marver/content' - import them directly
66
+ in the frame file and declare meta.intent. Full guide: instructions/shape.md.
62
67
 
63
68
  ## Structure ladder
64
69
  1. First pass: write the whole page inline in the frame file. Diverge fast.
@@ -25,6 +25,12 @@ viewport and lays it out:
25
25
  - Use boards for comparisons: version A vs B vs C of a flow, side by side. Variant
26
26
  groups (letter-prefixed siblings) stay contiguous through every relayout
27
27
  automatically.
28
+ - Content frames (specs, diagrams, mood boards - instructions/shape.md) are ordinary
29
+ atoms in every layout scope: a feature-story board mixes them freely with UI frames.
30
+ - The `archive` board (instructions/iterate.md) is the one board of retired
31
+ explorations: curated over design/scenes/archive/, tidied with a recipe,
32
+ every frame relabeled with what it was and why it retired. Winners live on
33
+ the feature boards; the archive answers "what did we try?".
28
34
 
29
35
  ## Composing the canvas: `layout`
30
36
 
@@ -39,7 +39,9 @@ token-level work once a direction exists.
39
39
  - glassmorphism as decoration, gradient text, Inter/Geist/Space Grotesk as the
40
40
  "safe" pick
41
41
  - emoji as icons, `rounded-lg` on everything, everything centered
42
- (the complete tell catalog: reference/slop.md)
42
+ (the complete tell catalog: reference/slop.md; icons and real-asset rules:
43
+ craft.md "Real assets" - Phosphor is the default icon system when the repo
44
+ has none)
43
45
  4. **Settle it into tokens.** The winning direction becomes CSS custom properties in
44
46
  the theme (grounds, text tiers, accent + meaning, radius scale, spacing scale, two
45
47
  type roles minimum). Then write DESIGN.md as in Path A. Components consume tokens;
@@ -76,6 +76,55 @@ it on review passes.
76
76
  - Choosing light or dark by product category reflex - the use scene decides, or
77
77
  DESIGN.md already did.
78
78
 
79
+ ## Real assets - fetched, not faked
80
+
81
+ The difference between a frame that feels alive and one that feels generated is
82
+ usually the assets. Doing the work - finding, downloading, and wiring the real
83
+ thing - changes everything. This section is binding, not aspiration:
84
+
85
+ - **Icons come from a real icon system, Phosphor by default.** The app's existing
86
+ icon library always wins (consistency beats preference); when the repo has none,
87
+ install Phosphor (`@phosphor-icons/react` or the framework's equivalent) - that
88
+ is the house default and good taste. One weight throughout a design. Never emoji,
89
+ never unicode glyphs, never hand-drawn approximations of icons that exist.
90
+ - **Real brands get their real logos.** An integrations row, a payment-methods
91
+ strip, a press bar, a testimonial card - fetch the ACTUAL marks (official brand
92
+ or press pages first; Simple Icons for product marks), download them into the
93
+ repo (the host's `public/` for app frames, `design/assets/` for content frames),
94
+ SVG preferred, respectful of clear space, checked in both themes. A gray box
95
+ labeled "Logo" is a defect, not a placeholder.
96
+ - **Imagery is real imagery.** When the design calls for photos or screenshots,
97
+ fetch and commit them locally with names that say what they are - never
98
+ hotlink (published canvases make zero external requests, and remote URLs rot).
99
+ - **Licensing sanity, briefly:** brand marks from official sources shown to
100
+ identify the brand are fine; photos come from sources that permit the use.
101
+ Unsure about one? Use it, and flag it to the human in the same message.
102
+
103
+ ## Interactive means visibly interactive - true to life
104
+
105
+ The prototype is only believable if everything that would respond in the shipped
106
+ product responds here. This is binding at EVERY fidelity (the lo-fi version is in
107
+ instructions/wireframe.md); in play mode a hover-dead control reads as a broken
108
+ app, and the human attributes the fault to your frame, not to a library.
109
+
110
+ - **Every clickable target shows `cursor: pointer` and a visible hover state** -
111
+ buttons, dropdown triggers AND the options inside them, tabs, toggles, rows and
112
+ cards that navigate, icon buttons. If it responds to a click, it must respond to
113
+ a hover first.
114
+ - **Component libraries do not guarantee this - audit them.** shadcn/ui on
115
+ Tailwind v4 notably ships buttons with the browser's `cursor: default`, and
116
+ menu/select items can lack a hover treatment depending on version. These are
117
+ design-system deficiencies, not reasons the rule bends.
118
+ - **Fix gaps at the design-system level, never per-instance.** One base-layer rule
119
+ (e.g. `@layer base { button:not(:disabled), [role="button"]:not(:disabled)
120
+ { cursor: pointer } }` plus the library's own hover token on option items) beats
121
+ a hundred scattered `cursor-pointer` classes - and fixes the app, not just the
122
+ frame. When you find such a gap, patch the theme/base layer and tell the human
123
+ what the library got wrong.
124
+ - **Sweep by hand once per design system:** render a frame, hover every KIND of
125
+ control it uses, and watch for the dead ones. The check is against the rendered
126
+ frame - a class in the source proves nothing about what the cascade delivered.
127
+
79
128
  ## Frame law
80
129
 
81
130
  - Frames are made of the app's real components and tokens. Rebuilding a lookalike of
@@ -51,8 +51,8 @@ an absent human; never hide that the brief was self-answered.
51
51
  ## 4. Align on flow with a diagram frame
52
52
 
53
53
  When the flow has branches or more than four screens, draw it before wireframing:
54
- one frame (`<scene>/flow.tsx`) of labeled boxes and arrows - plain divs and SVG lines,
55
- grayscale, no dependency. Each box names a future frame. The human can look at one
54
+ one content frame (`<scene>/flow.tsx`) with a mermaid `Diagram` block - the how lives
55
+ in instructions/shape.md. Each node names a future frame. The human can look at one
56
56
  picture and say "step 3 is wrong" before step 3 costs anything.
57
57
 
58
58
  Then move to Wireframe. Do not brand, do not pick type, do not open the craft rules
@@ -0,0 +1,50 @@
1
+ # Iterate - versions are nearly free, so keep them
2
+
3
+ Read this when you are about to CHANGE a frame the human has already seen, or
4
+ when a direction has won and it is time to clean up. The rule underneath
5
+ everything: exploration is cheap here - never make the human lose a version
6
+ they might want back.
7
+
8
+ ## Fork, don't overwrite
9
+
10
+ - **Meaningful direction change on a seen frame → fork a variant.** Rename the
11
+ current file to `a-<direction>.tsx`, write the new take as `b-<direction>.tsx`
12
+ (nested dir when the scene is busy: `checkout/payment/a-card.tsx`). The
13
+ canvas badges them, keeps them together, and `[` `]` swaps them in place in
14
+ play mode - the human compares in seconds and nothing is lost.
15
+ - **Successive iterations are variants too.** "v2 of the hero" is just another
16
+ letter: `c-tighter.tsx`. Simultaneous alternatives and successive versions
17
+ use the same mechanism - letters carry the order, names carry the idea.
18
+ - **Polish in place** for small refinements (spacing, copy, states): git holds
19
+ the fine-grained history; letters are for DIRECTIONS, not typo fixes.
20
+ - Name variants for the idea, never the sequence alone: `b-editorial.tsx`
21
+ beats `b-v2.tsx` - three weeks later, "editorial" still means something.
22
+
23
+ ## Keep the working set live
24
+
25
+ Divergences stay on the board while a decision is open - visible, comparable,
26
+ playable. Never silently delete an exploration the human has seen; the human
27
+ decides what dies, you decide when to ask.
28
+
29
+ ## The cleanup ritual - when a path wins
30
+
31
+ The human picks a direction; then, in one pass:
32
+
33
+ 1. **The winner takes the clean name.** Drop its letter prefix (or promote it
34
+ over the original file); update goto targets pointing at old ids.
35
+ 2. **The losers move to `design/scenes/archive/`** - never deleted, RELABELED:
36
+ filename `<feature>-<direction>.tsx`, meta.title saying exactly what it was
37
+ and why it retired, e.g.
38
+ `{ title: "Routines editor - guided steps (retired: sentence canvas won, sharper mental model)" }`.
39
+ A one-line comment at the top of the file carries any longer why. That
40
+ sentence is the learning - write it while the reason is fresh.
41
+ 3. **The `archive` board stays clean and organized:** a curated board over the
42
+ archive scene, tidied with a layout recipe, grouped by feature. Anyone
43
+ opening it should know what every frame was without asking.
44
+ 4. **The main boards show winners only.** A feature board after cleanup holds
45
+ the kept path; the archive holds the rest. In-between states are fine WHILE
46
+ deciding - the ritual is what ends them.
47
+
48
+ Archived frames are history, not options: never link them from live flows,
49
+ never count them as current design. They exist so "what did we try for the
50
+ editor?" has a visual answer.