@marver-design/marver 0.2.4 → 0.3.1
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.
- package/CHANGELOG.md +52 -0
- package/README.md +1 -0
- package/dist/{build-Bqd6OEsQ.mjs → build-Ckmyci3O.mjs} +55 -4
- package/dist/cli.mjs +12 -5
- package/dist/{dev-9-80L5i5.mjs → dev-C2oTKuXe.mjs} +6 -4
- package/dist/{init-Di4geblA.mjs → init-DolP_Ld4.mjs} +36 -7
- package/dist/{manifest-mYlO_1Pj.mjs → manifest-DW-T52MM.mjs} +29 -1
- package/dist/{plugin-YVpBNTB3.mjs → plugin-Crp4CAma.mjs} +2 -2
- package/dist/{serve-BvbAbWeK.mjs → serve-OtA9Nlow.mjs} +1 -1
- package/package.json +6 -2
- package/src/client/const.ts +5 -0
- package/src/client/content/diagram.tsx +96 -0
- package/src/client/content/index.tsx +196 -0
- package/src/client/content/md.ts +50 -0
- package/src/client/content/palette.ts +93 -0
- package/src/client/shell/App.tsx +50 -6
- package/src/client/shell/Play.tsx +7 -1
- package/src/client/shell/canvas/FrameNode.tsx +3 -1
- package/src/client/shell/icons.tsx +22 -0
- package/src/client/shell/store.ts +150 -17
- package/src/client/shell/styles.css +29 -7
- package/templates/AGENTS-embedded.md +5 -0
- package/templates/AGENTS-studio.md +5 -0
- package/templates/instructions/boards.md +6 -0
- package/templates/instructions/brand.md +3 -1
- package/templates/instructions/craft.md +49 -0
- package/templates/instructions/discover.md +2 -2
- package/templates/instructions/iterate.md +50 -0
- package/templates/instructions/shape.md +171 -0
- package/templates/instructions/welcome.md +27 -12
- package/templates/instructions/wireframe.md +8 -1
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
664
|
-
//
|
|
665
|
-
|
|
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
|
-
|
|
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) }
|
|
@@ -579,12 +587,26 @@ body.sh-kbd .sh-app :focus-visible { outline: 2px solid var(--accent); outline-o
|
|
|
579
587
|
.sub .chip:hover { color: var(--glass-ink); background: var(--glass-hover) }
|
|
580
588
|
.sub .chip.on { color: #fff; background: var(--accent); border-color: var(--accent) }
|
|
581
589
|
|
|
582
|
-
/* play-mode variant chips (SPEC-023 §6)
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
.sh-play-nav .
|
|
587
|
-
|
|
590
|
+
/* play-mode variant chips (SPEC-023 §6): the SAME badge language as the sidebar's
|
|
591
|
+
variant rows (20px, 7px-radius square, one consistent shape in every state) so the
|
|
592
|
+
two surfaces read as one concept - but in the pill's own dark-glass inks, never
|
|
593
|
+
theme tokens (the nav is always dark; theme ink goes invisible in light mode). */
|
|
594
|
+
.sh-play-nav .vchip { width: 20px; height: 20px; min-width: 0; flex: none; margin: 0 1px;
|
|
595
|
+
border: 1px solid rgba(255, 255, 255, .14); border-radius: 7px;
|
|
596
|
+
font: 700 10px -apple-system, system-ui, sans-serif; color: rgba(245, 245, 247, .6);
|
|
597
|
+
line-height: 1; letter-spacing: 0; text-box: trim-both cap alphabetic }
|
|
598
|
+
.sh-play-nav .vchip:hover { color: #f5f5f7; background: rgba(255, 255, 255, .1) }
|
|
599
|
+
.sh-play-nav .vchip.on { color: #fff; background: var(--accent); border-color: var(--accent) }
|
|
600
|
+
/* bordered chips read tighter than borderless icons at the same distance - the
|
|
601
|
+
divider needs a touch more air before the first chip */
|
|
602
|
+
.sh-play-nav .sep + .vchip { margin-left: 5px }
|
|
603
|
+
|
|
604
|
+
/* the current variant's name trails the chips at natural width - the chips anchor,
|
|
605
|
+
so a name change grows the pill rightward without shifting any control underfoot.
|
|
606
|
+
A max caps runaway titles; the Tip carries the full name. */
|
|
607
|
+
.sh-play-nav .vname { font-size: 11px; font-weight: 600; color: rgba(245, 245, 247, .78);
|
|
608
|
+
max-width: 180px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
609
|
+
text-transform: capitalize; padding: 0 11px 0 5px }
|
|
588
610
|
|
|
589
611
|
/* variant chrome goes LIVE with selection (drive feedback 2026-08-12): accent when the
|
|
590
612
|
frame is selected, the interact purple when it is being interacted with - the badge,
|
|
@@ -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`)
|
|
55
|
-
|
|
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.
|