@marver-design/marver 0.2.1 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +1 -0
  2. package/dist/{build-BYZDMiIS.mjs → build-Bqd6OEsQ.mjs} +2 -2
  3. package/dist/cli.mjs +3 -3
  4. package/dist/{dev-Dt9D4O7Z.mjs → dev-9-80L5i5.mjs} +2 -2
  5. package/dist/init-ClhCgn4v.mjs +358 -0
  6. package/dist/{manifest-CHmKAAtG.mjs → manifest-mYlO_1Pj.mjs} +78 -2
  7. package/dist/{plugin-lVQoABEx.mjs → plugin-YVpBNTB3.mjs} +103 -9
  8. package/package.json +1 -1
  9. package/src/client/shell/App.tsx +123 -19
  10. package/src/client/shell/Play.tsx +36 -0
  11. package/src/client/shell/canvas/Canvas.tsx +54 -7
  12. package/src/client/shell/canvas/FrameNode.tsx +14 -1
  13. package/src/client/shell/icons.tsx +2 -0
  14. package/src/client/shell/store.ts +119 -15
  15. package/src/client/shell/styles.css +107 -2
  16. package/src/client/shell/tidy.ts +347 -17
  17. package/src/client/stage/main.tsx +11 -3
  18. package/templates/AGENTS-embedded.md +38 -33
  19. package/templates/AGENTS-studio.md +38 -33
  20. package/templates/design-tsconfig.json +7 -2
  21. package/templates/instructions/boards.md +84 -0
  22. package/templates/instructions/brand.md +60 -0
  23. package/templates/instructions/components.md +52 -0
  24. package/templates/instructions/configure.md +44 -0
  25. package/templates/instructions/craft.md +90 -0
  26. package/templates/instructions/discover.md +58 -0
  27. package/templates/instructions/reference/color.md +53 -0
  28. package/templates/instructions/reference/concepts.md +68 -0
  29. package/templates/instructions/reference/copy.md +57 -0
  30. package/templates/instructions/reference/critique.md +53 -0
  31. package/templates/instructions/reference/delight.md +35 -0
  32. package/templates/instructions/reference/layout.md +51 -0
  33. package/templates/instructions/reference/motion.md +66 -0
  34. package/templates/instructions/reference/operate.md +38 -0
  35. package/templates/instructions/reference/slop.md +76 -0
  36. package/templates/instructions/reference/states.md +48 -0
  37. package/templates/instructions/reference/tune.md +61 -0
  38. package/templates/instructions/reference/typography.md +45 -0
  39. package/templates/instructions/review.md +51 -0
  40. package/templates/instructions/wireframe.md +49 -0
  41. package/dist/init-CHUZTYG7.mjs +0 -224
@@ -1,24 +1,354 @@
1
- export interface TidyNode { key: string; scene: string; w: number; h: number }
1
+ export interface TidyNode { key: string; frame: string; scene: string; group?: string; variant?: string; w: number; h: number }
2
2
  export interface Placed { key: string; x: number; y: number }
3
3
 
4
- const GUTTER = 72
5
- const SCENE_GAP = 96
4
+ // SPEC-024 lane-flow grammar: one shape at both scopes. A scope is rows XOR columns
5
+ // of lanes; a lane is atoms + {space:n} tokens. Board atoms are scene names; a scene
6
+ // recipe's atoms are frame basenames or a variant-group name (one indivisible run).
7
+ export interface Space { space: number }
8
+ export type Lane = Array<string | Space>
9
+ export interface Flow { rows?: Array<Lane | Space>; columns?: Array<Lane | Space> }
10
+ export interface BoardLayout extends Flow { scenes?: Record<string, Flow> }
11
+ export type Warn = (msg: string) => void
6
12
 
7
- /** Pure: rows per scene (scenes alphabetical, node order preserved within a scene). Spec §7. */
8
- export function tidy(nodes: TidyNode[]): Placed[] {
9
- const scenes = [...new Set(nodes.map((n) => n.scene))].sort()
10
- const out: Placed[] = []
11
- let y = 0
12
- for (const scene of scenes) {
13
- const row = nodes.filter((n) => n.scene === scene)
14
- let x = 0
15
- let rowH = 0
16
- for (const n of row) {
17
- out.push({ key: n.key, x, y })
18
- x += n.w + GUTTER
19
- rowH = Math.max(rowH, n.h)
13
+ const isSpace = (x: unknown): x is Space => !!x && typeof x === 'object' && !Array.isArray(x)
14
+ const units = (s: Space, warn: Warn): number =>
15
+ Number.isInteger(s.space) && s.space > 0 ? s.space : (warn(`invalid space ${JSON.stringify(s.space)} - using 1`), 1)
16
+
17
+ // Adaptive units (SPEC-024 §2): a "block" is a multiple of the proportional gutter,
18
+ // measured from the touching content's characteristic FRAME size (its largest single
19
+ // frame) - a wide multi-frame box must not inflate its neighbors' gutters.
20
+ const frameGapX = (w: number) => Math.max(140, w * 0.12)
21
+ const frameGapY = (h: number) => Math.max(96, h * 0.16)
22
+ const sceneGapX = (w: number) => Math.max(280, w * 0.2)
23
+ const sceneGapY = (h: number) => Math.max(96, h * 0.16)
24
+
25
+ /** An atom resolved to concrete content: member nodes at relative offsets + extents. */
26
+ interface Box {
27
+ id: string
28
+ parts: Array<{ key: string; dx: number; dy: number }>
29
+ w: number; h: number
30
+ charW: number; charH: number
31
+ }
32
+
33
+ const box = (id: string, parts: Array<{ key: string; dx: number; dy: number; w: number; h: number }>): Box => ({
34
+ id,
35
+ parts: parts.map(({ key, dx, dy }) => ({ key, dx, dy })),
36
+ w: Math.max(0, ...parts.map((p) => p.dx + p.w)),
37
+ h: Math.max(0, ...parts.map((p) => p.dy + p.h)),
38
+ charW: Math.max(0, ...parts.map((p) => p.w)),
39
+ charH: Math.max(0, ...parts.map((p) => p.h)),
40
+ })
41
+
42
+ /** A run of nodes laid side by side (a frame's instances, or a variant run). */
43
+ const runBox = (id: string, run: TidyNode[]): Box => {
44
+ const parts: Array<{ key: string; dx: number; dy: number; w: number; h: number }> = []
45
+ let dx = 0
46
+ for (const n of run) { parts.push({ key: n.key, dx, dy: 0, w: n.w, h: n.h }); dx += n.w + frameGapX(n.w) }
47
+ return box(id, parts)
48
+ }
49
+
50
+ /**
51
+ * The one flow engine (both scopes, both axes). Places boxes; returns absolute box
52
+ * origins. Lanes share an origin on the cross axis - that IS the alignment.
53
+ * Two phases: COLLECT lanes (resolving atoms, so skipped content never strands a
54
+ * track), then PLACE knowing both neighbors of every boundary.
55
+ */
56
+ function layoutFlow(
57
+ flow: Flow,
58
+ resolve: (atom: string) => Box | null,
59
+ trailing: () => Box[], // unlisted content, appended after the recipe (evaluated post-collection)
60
+ trailingMode: 'lanes' | 'append', // board scope: own trailing lanes · scene scope: tail of the final lane
61
+ gapX: (c: number) => number,
62
+ gapY: (c: number) => number,
63
+ warn: Warn,
64
+ ): Map<string, { x: number; y: number }> {
65
+ const vertical = !!flow.columns // columns: lanes advance in X, atoms flow in Y
66
+ // the gap belongs to the AXIS, not the argument slot: a columns lane flows in Y,
67
+ // so its in-lane gaps are vertical units and its lane boundaries horizontal ones
68
+ const gapMain = vertical ? gapY : gapX
69
+ const gapCross = vertical ? gapX : gapY
70
+ const entries = (vertical ? flow.columns : flow.rows) ?? []
71
+ const out = new Map<string, { x: number; y: number }>()
72
+ const seen = new Set<string>()
73
+
74
+ // ---- phase 1: collect ----
75
+ interface CollectedLane { beforeUnits: number; items: Array<Box | number> } // number = spacer units
76
+ const lanes: CollectedLane[] = []
77
+ let pendingLane = 0 // 0 = ordinary boundary · -1 = degraded (consecutive/invalid)
78
+ for (const entry of entries) {
79
+ if (isSpace(entry)) {
80
+ if (pendingLane) { warn('consecutive lane spacers - degrading to one ordinary gap'); pendingLane = -1 }
81
+ else pendingLane = units(entry, warn)
82
+ continue
83
+ }
84
+ if (!Array.isArray(entry)) continue
85
+ const items: Array<Box | number> = []
86
+ let pending = 0
87
+ let hasBox = false
88
+ for (const a of entry) {
89
+ if (isSpace(a)) {
90
+ if (!hasBox) { warn('leading spacer in lane ignored'); continue }
91
+ if (pending) { warn('consecutive spacers - degrading to one ordinary gap'); pending = -1; items[items.length - 1] = -1 }
92
+ else { pending = units(a, warn); items.push(pending) }
93
+ continue
94
+ }
95
+ if (typeof a !== 'string') { warn(`ignoring non-string atom ${JSON.stringify(a)}`); continue }
96
+ if (seen.has(a)) { warn(`"${a}" listed twice - first occurrence wins`); continue }
97
+ const b = resolve(a)
98
+ if (!b) continue // resolve() warned with specifics
99
+ seen.add(a)
100
+ items.push(b)
101
+ hasBox = true
102
+ pending = 0
103
+ }
104
+ if (pending) { warn('trailing spacer in lane ignored'); items.pop() }
105
+ if (!hasBox) {
106
+ // a lane whose every atom was skipped (or an empty array) must not consume a
107
+ // track - P1: `[["ghost"],["a"]]` places a at the origin, not one gap down
108
+ if (entry.length) warn('lane with no placeable content dropped')
109
+ continue // pendingLane stays armed for the next real lane
110
+ }
111
+ if (lanes.length === 0 && pendingLane) warn('leading lane spacer ignored')
112
+ lanes.push({ beforeUnits: lanes.length === 0 ? 0 : (pendingLane === -1 ? 1 : Math.max(1, pendingLane || 1)), items })
113
+ pendingLane = 0
114
+ }
115
+ if (pendingLane) warn('trailing lane spacer ignored')
116
+ const extra = trailing()
117
+ if (extra.length) {
118
+ if (trailingMode === 'append') {
119
+ // scene scope: leftovers extend the FINAL lane (one shared row even when
120
+ // every authored lane was dropped - they must not scatter into lanes)
121
+ if (lanes.length) lanes[lanes.length - 1].items.push(...extra)
122
+ else lanes.push({ beforeUnits: 0, items: [...extra] })
123
+ }
124
+ else for (const b of extra) lanes.push({ beforeUnits: lanes.length === 0 ? 0 : 1, items: [b] })
125
+ }
126
+
127
+ // ---- phase 2: place ----
128
+ const laneChar = (l: CollectedLane) =>
129
+ Math.max(0, ...l.items.filter((i): i is Box => typeof i !== 'number').map((b) => (vertical ? b.charW : b.charH)))
130
+ const laneExtent = (l: CollectedLane) =>
131
+ Math.max(0, ...l.items.filter((i): i is Box => typeof i !== 'number').map((b) => (vertical ? b.w : b.h)))
132
+
133
+ let cross = 0
134
+ for (let i = 0; i < lanes.length; i++) {
135
+ const lane = lanes[i]
136
+ if (i > 0) {
137
+ // the boundary sees BOTH lanes (P1: a small lane before a monitor lane must
138
+ // not produce a phone-sized gap)
139
+ const c = Math.max(laneChar(lanes[i - 1]), laneChar(lane), 1)
140
+ cross += laneExtent(lanes[i - 1]) + gapCross(c) * lane.beforeUnits
141
+ }
142
+ let main = 0
143
+ let pendingUnits = 0
144
+ let prevChar = 0
145
+ let placedAny = false
146
+ for (const item of lane.items) {
147
+ if (typeof item === 'number') { pendingUnits = item; continue }
148
+ if (placedAny) {
149
+ const n = pendingUnits === -1 ? 1 : Math.max(1, pendingUnits || 1)
150
+ main += gapMain(Math.max(prevChar, vertical ? item.charH : item.charW)) * n
151
+ }
152
+ pendingUnits = 0
153
+ out.set(item.id, { x: vertical ? cross : main, y: vertical ? main : cross })
154
+ main += vertical ? item.h : item.w
155
+ prevChar = vertical ? item.charH : item.charW
156
+ placedAny = true
20
157
  }
21
- y += rowH + SCENE_GAP
22
158
  }
23
159
  return out
24
160
  }
161
+
162
+ /** Scene placement order for the DEFAULT (recipe-less) flow: appearance order, but a
163
+ * variant group is one contiguous run (sorted by variant key) at its first member's slot. */
164
+ function orderWithinScene(members: TidyNode[]): TidyNode[] {
165
+ const consumed = new Set<string>()
166
+ const ordered: TidyNode[] = []
167
+ for (const n of members) {
168
+ if (consumed.has(n.key)) continue
169
+ if (!n.group) { ordered.push(n); continue }
170
+ const run = members.filter((m) => m.group === n.group)
171
+ .sort((a, b) => (a.variant ?? '').localeCompare(b.variant ?? ''))
172
+ for (const m of run) { ordered.push(m); consumed.add(m.key) }
173
+ }
174
+ return ordered
175
+ }
176
+
177
+ /** Group leftover nodes into placement boxes: node order, variant runs contiguous
178
+ * and indivisible (P1: leftovers must honor the same contract as listed content). */
179
+ function leftoverBoxes(remaining: TidyNode[]): Box[] {
180
+ const out: Box[] = []
181
+ const consumed = new Set<string>()
182
+ for (const n of remaining) {
183
+ if (consumed.has(n.key)) continue
184
+ if (!n.group) { consumed.add(n.key); out.push(runBox(`${n.frame}#${n.key}`, [n])); continue }
185
+ const run = remaining.filter((m) => m.group === n.group)
186
+ .sort((a, b) => (a.variant ?? '').localeCompare(b.variant ?? ''))
187
+ for (const m of run) consumed.add(m.key)
188
+ out.push(runBox(`${n.group}#run`, run))
189
+ }
190
+ return out
191
+ }
192
+
193
+ const rel = (id: string, scene: string) => (id.startsWith(scene + '/') ? id.slice(scene.length + 1) : id)
194
+
195
+ /** Lay out ONE scene: recipe if present, else a single default lane. Returns member
196
+ * positions relative to the scene origin. */
197
+ function layoutScene(scene: string, members: TidyNode[], flow: Flow | undefined, warn: Warn): Map<string, { x: number; y: number }> {
198
+ // atoms CONSUME nodes: a later atom that names already-placed content is a
199
+ // duplicate reference, not a second placement (P1: ["pay", "pay/a"] must not
200
+ // tear member a out of the run)
201
+ const consumed = new Set<string>()
202
+ const boxIndex = new Map<string, Box>()
203
+ const resolve = (atom: string): Box | null => {
204
+ const frames = members.filter((n) => rel(n.frame, scene) === atom)
205
+ const run = members.filter((n) => n.group && rel(n.group, scene) === atom)
206
+ if (frames.length && run.length) warn(`scene "${scene}": "${atom}" names a frame AND a variant group - the frame wins`)
207
+ const chosen = frames.length ? frames : run.sort((a, b) => (a.variant ?? '').localeCompare(b.variant ?? ''))
208
+ if (!chosen.length) { warn(`unknown "${atom}" in scene "${scene}" layout - skipped`); return null }
209
+ const fresh = chosen.filter((n) => !consumed.has(n.key))
210
+ if (!fresh.length) { warn(`"${atom}" repeats already-placed content - skipped`); return null }
211
+ if (!frames.length && fresh.length !== chosen.length) {
212
+ // some members were placed individually; the run can no longer be indivisible
213
+ warn(`group "${atom}" already partially placed - skipped (remaining members append as unlisted)`)
214
+ return null
215
+ }
216
+ for (const n of fresh) consumed.add(n.key)
217
+ const b = runBox(atom, fresh)
218
+ boxIndex.set(atom, b)
219
+ return b
220
+ }
221
+
222
+ let flows: Flow
223
+ if (flow && flow.rows && flow.columns) {
224
+ warn(`scene "${scene}": layout has rows AND columns - using the default lane`)
225
+ flows = { rows: [[...new Set(orderWithinScene(members).map((n) => rel(n.frame, scene)))]] }
226
+ } else if (flow && (flow.rows || flow.columns)) {
227
+ flows = flow
228
+ } else {
229
+ // default: one lane, node order, group runs contiguous (dedupe: duplicate node
230
+ // instances share one atom - resolve() expands every instance)
231
+ flows = { rows: [[...new Set(orderWithinScene(members).map((n) => rel(n.frame, scene)))]] }
232
+ }
233
+
234
+ // unlisted frames append AFTER the recipe as their own lane content, node order,
235
+ // variant runs intact; evaluated post-collection so `consumed` is final
236
+ const trailing = () => leftoverBoxes(members.filter((n) => !consumed.has(n.key)))
237
+
238
+ const placedBoxes = layoutFlow(flows, resolve, () => {
239
+ const boxes = trailing()
240
+ for (const b of boxes) boxIndex.set(b.id, b)
241
+ return boxes
242
+ }, 'append', frameGapX, frameGapY, warn)
243
+
244
+ const out = new Map<string, { x: number; y: number }>()
245
+ for (const [id, pos] of placedBoxes) {
246
+ const b = boxIndex.get(id)
247
+ if (!b) continue
248
+ for (const p of b.parts) out.set(p.key, { x: pos.x + p.dx, y: pos.y + p.dy })
249
+ }
250
+ return out
251
+ }
252
+
253
+ /**
254
+ * Pure layout (spec §7 + SPEC-023 + SPEC-024). Returns positions only - the nodes
255
+ * array is never reordered (iframe law G-1). Two passes: each scene lays out its
256
+ * frames (recipe or default lane), then the board flow places the scene boxes.
257
+ */
258
+ export function tidy(nodes: TidyNode[], layout?: BoardLayout, warn: Warn = () => {}): Placed[] {
259
+ const scenes = [...new Set(nodes.map((n) => n.scene))]
260
+
261
+ // pass 1: per-scene relative layouts + bounding boxes
262
+ const sceneMaps = new Map<string, Map<string, { x: number; y: number }>>()
263
+ const sceneBoxes = new Map<string, Box>()
264
+ for (const scene of scenes) {
265
+ const members = nodes.filter((n) => n.scene === scene)
266
+ const m = layoutScene(scene, members, layout?.scenes?.[scene], warn)
267
+ sceneMaps.set(scene, m)
268
+ const parts = members
269
+ .filter((n) => m.has(n.key))
270
+ .map((n) => ({ key: n.key, dx: m.get(n.key)!.x, dy: m.get(n.key)!.y, w: n.w, h: n.h }))
271
+ sceneBoxes.set(scene, box(scene, parts))
272
+ }
273
+
274
+ // pass 2: board flow over scene boxes. rows AND columns is invalid: fall back to
275
+ // PLAIN tidy (default trailing lanes), never a silent pick (SPEC-024 §5)
276
+ let boardFlow: Flow
277
+ if (layout && layout.rows && layout.columns) { warn('layout has rows AND columns - ignoring it (plain tidy)'); boardFlow = { rows: [] } }
278
+ else if (layout && (layout.rows || layout.columns)) boardFlow = { rows: layout.rows, columns: layout.columns }
279
+ else boardFlow = { rows: [] } // default: every scene its own trailing lane (alphabetical)
280
+
281
+ const listed = new Set<string>()
282
+ for (const entry of [...(boardFlow.rows ?? []), ...(boardFlow.columns ?? [])]) {
283
+ if (Array.isArray(entry)) for (const a of entry) if (typeof a === 'string') listed.add(a)
284
+ }
285
+ // board scope: unlisted scenes become their OWN trailing lanes (below in rows mode,
286
+ // right in columns mode), alphabetical
287
+ const trailing = () => scenes.filter((s) => !listed.has(s)).sort().map((s) => sceneBoxes.get(s)!)
288
+
289
+ const placedScenes = layoutFlow(
290
+ boardFlow,
291
+ (atom) => {
292
+ const b = sceneBoxes.get(atom)
293
+ if (!b) warn(`unknown scene "${atom}" in layout - skipped`)
294
+ return b ?? null
295
+ },
296
+ trailing,
297
+ 'lanes',
298
+ sceneGapX, sceneGapY, warn,
299
+ )
300
+
301
+ const out: Placed[] = []
302
+ for (const [scene, pos] of placedScenes) {
303
+ const m = sceneMaps.get(scene)
304
+ if (!m) continue
305
+ for (const [key, p] of m) out.push({ key, x: pos.x + p.x, y: pos.y + p.y })
306
+ }
307
+ return out
308
+ }
309
+
310
+ /** Guarded parse of agent-authored board.layout (SPEC-024 §1). Malformed pieces warn
311
+ * and degrade - they never blank the board, and never vanish silently. */
312
+ export function parseLayout(raw: unknown, warn: Warn): BoardLayout | null {
313
+ if (raw === undefined || raw === null) return null
314
+ if (typeof raw !== 'object' || Array.isArray(raw)) { warn('layout must be an object - ignored'); return null }
315
+ const o = raw as Record<string, unknown>
316
+ const flow = parseFlow(o, warn)
317
+ const scenes: Record<string, Flow> = {}
318
+ if (o.scenes !== undefined) {
319
+ if (!o.scenes || typeof o.scenes !== 'object' || Array.isArray(o.scenes)) warn('layout.scenes must be an object map - ignored')
320
+ else {
321
+ for (const [k, v] of Object.entries(o.scenes as Record<string, unknown>)) {
322
+ const f = v && typeof v === 'object' && !Array.isArray(v) ? parseFlow(v as Record<string, unknown>, warn) : null
323
+ if (f) scenes[k] = f
324
+ else warn(`layout.scenes["${k}"] is not a rows/columns flow - ignored`)
325
+ }
326
+ }
327
+ }
328
+ if (!flow && !Object.keys(scenes).length) return null
329
+ return { ...(flow ?? {}), ...(Object.keys(scenes).length ? { scenes } : {}) }
330
+ }
331
+
332
+ function parseFlow(o: Record<string, unknown>, warn: Warn): Flow | null {
333
+ const parseEntries = (v: unknown): Array<Lane | Space> | null => {
334
+ if (!Array.isArray(v)) { if (v !== undefined) warn('layout rows/columns must be an array - ignored'); return null }
335
+ const out: Array<Lane | Space> = []
336
+ for (const e of v) {
337
+ if (Array.isArray(e)) {
338
+ // pass atoms through loosely - the ENGINE warns with placement specifics -
339
+ // but never SILENTLY strip junk (a dropped atom must be visible)
340
+ out.push(e.filter((a: unknown): a is string | Space => {
341
+ const ok = typeof a === 'string' || (!!a && typeof a === 'object' && !Array.isArray(a))
342
+ if (!ok) warn(`ignoring invalid layout atom ${JSON.stringify(a)}`)
343
+ return ok
344
+ }))
345
+ } else if (e && typeof e === 'object' && !Array.isArray(e)) out.push(e as Space)
346
+ else warn(`ignoring invalid layout entry ${JSON.stringify(e)}`)
347
+ }
348
+ return out // an EMPTY array is still a layout
349
+ }
350
+ const rows = parseEntries(o.rows)
351
+ const columns = parseEntries(o.columns)
352
+ if (!rows && !columns) return null
353
+ return { ...(rows ? { rows } : {}), ...(columns ? { columns } : {}) }
354
+ }
@@ -16,7 +16,12 @@ import { createRoot } from 'react-dom/client'
16
16
  import { frameFile, frames, layoutChain, layouts, providers } from '../frame-host/registry.ts'
17
17
 
18
18
  const params = new URLSearchParams(location.search)
19
- document.documentElement.dataset.theme = params.get('theme') ?? 'light'
19
+ // Both signals, always - same pair as the frame host: [data-theme] for attribute-keyed
20
+ // token systems, .dark for class-keyed ones (Tailwind/shadcn). Missing the class made
21
+ // play render class-keyed apps light while the canvas showed them dark.
22
+ const bootTheme = params.get('theme') ?? 'light'
23
+ document.documentElement.dataset.theme = bootTheme
24
+ document.documentElement.classList.toggle('dark', bootTheme === 'dark')
20
25
  const startId = params.get('at') ?? ''
21
26
 
22
27
  const post = (msg: Record<string, unknown>) => { if (window.parent !== window) window.parent.postMessage(msg, '*') }
@@ -117,7 +122,7 @@ function Stage() {
117
122
  if (e.metaKey || e.ctrlKey) return // ⌘D is the browser's bookmark, not our theme
118
123
  if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return
119
124
  // every play shortcut belongs to the shell (it owns walk order + chrome) - forward
120
- if (/^Digit[0-9]$/.test(e.code) || ['d', 'h', 'r', 'ArrowRight', 'ArrowLeft'].includes(e.key))
125
+ if (/^Digit[0-9]$/.test(e.code) || ['d', 'h', 'r', '[', ']', 'ArrowRight', 'ArrowLeft'].includes(e.key))
121
126
  post({ type: 'sh:stage-key', key: e.key, code: e.code })
122
127
  }
123
128
  window.addEventListener('keydown', onKey)
@@ -125,7 +130,10 @@ function Stage() {
125
130
  const onMsg = (e: MessageEvent) => {
126
131
  if (e.source !== window.parent) return
127
132
  const data = e.data
128
- if (data?.type === 'sh:set-theme') document.documentElement.dataset.theme = data.theme
133
+ if (data?.type === 'sh:set-theme') {
134
+ document.documentElement.dataset.theme = data.theme
135
+ document.documentElement.classList.toggle('dark', data.theme === 'dark')
136
+ }
129
137
  else if (data?.type === 'sh:stage-set' && typeof data.at === 'string') goto(data.at, false)
130
138
  }
131
139
  window.addEventListener('message', onMsg)
@@ -3,6 +3,29 @@
3
3
  You design by writing files. The canvas at the printed localhost URL reflects them live.
4
4
  Never run or talk to the canvas tool; read and write files only.
5
5
 
6
+ ## The method (binding)
7
+
8
+ Design work moves through phases. BEFORE working in a phase, read its instruction
9
+ file in design/instructions/ - they are short, strict, and part of this contract:
10
+
11
+ | Phase | When | Read |
12
+ |---|---|---|
13
+ | Configure | first session in a repo, or frames render unstyled | instructions/configure.md |
14
+ | Discover | any new surface, feature, or flow | instructions/discover.md |
15
+ | Wireframe | new work: nail structure + copy in throwaway lo-fi | instructions/wireframe.md |
16
+ | Brand | before the first hi-fi work: extract or create the world | instructions/brand.md |
17
+ | Build | hi-fi frames from real components | instructions/craft.md + components.md |
18
+ | Review | before presenting anything | instructions/review.md |
19
+ | Boards | creating a board or publishing | instructions/boards.md |
20
+
21
+ Refining an existing screen: Configure must hold, then Build + Review. New work runs
22
+ the full ladder. Unsure which phase you are in? Ask the human - one question beats a
23
+ phase of wrong work.
24
+
25
+ Stuck, or the human is unhappy with a result? instructions/reference/ holds the deep
26
+ guides (layout, typography, color, motion, copy, states, tuning, critique, concepts) -
27
+ the routing index is at the top of instructions/craft.md. Pull ONE file, apply, return.
28
+
6
29
  ## Frames
7
30
  - A frame = one file: design/scenes/<scene>/<name>.tsx or .html. One frame, one surface.
8
31
  - It default-exports a React component. No imports from the tool are needed. Optional:
@@ -11,12 +34,13 @@ Never run or talk to the canvas tool; read and write files only.
11
34
  // tv available commented-out). Pick the one the screen is designed for - the human can
12
35
  // flip the whole board to any device (Devices menu, hotkeys 0-5) to check responsiveness.
13
36
  - States are sibling frames: empty.tsx, filled.tsx, error.tsx, success.tsx.
14
- - VERSIONS are sibling frames too - the scene is the surface, each frame one direction:
15
- design/scenes/landing/a-terminal.tsx, landing/b-editorial.tsx, landing/c-product.tsx.
16
- Layout and the sidebar follow frame-id order, so variants named under one scene with
17
- a-/b-/c- prefixes stay adjacent and ordered through tidy and every device view.
18
- Never spread versions across scenes (terminal/landing, editorial/landing) - they
19
- interleave with everything else and the comparison falls apart.
37
+ - VERSIONS are sibling frames with letter prefixes, and the canvas understands them:
38
+ design/scenes/landing/a-terminal.tsx + b-editorial.tsx form a VARIANT GROUP - kept
39
+ contiguous through tidy and device views, badged A/B on the canvas, one row with
40
+ chips in the sidebar, and switchable in place in play mode ([ and ]). Scope
41
+ alternatives inside a busy scene with a nested dir: checkout/payment/a-card.tsx vs
42
+ b-wallet.tsx groups beside checkout/cart.tsx. meta `of`/`variant` (literal strings)
43
+ override when filenames can't carry it. Never spread versions across scenes.
20
44
  - {{UI_GUIDANCE}}
21
45
  {{NEXT_NOTES}}
22
46
  - Navigation: put data-goto="scene/frame" on any element. That is the whole prototype system.
@@ -35,7 +59,7 @@ Never run or talk to the canvas tool; read and write files only.
35
59
  convention). The root design/scenes/_layout.tsx mounts the app's real shell component.
36
60
 
37
61
  ## Fixtures
38
- - design/scenes/<scene>/_fixtures.ts - typed plain objects shaped like the future API.
62
+ - design/scenes/<scene>/_fixtures.ts - typed plain objects shaped like the component's PROPS (containers map real APIs into them at promotion; see instructions/components.md).
39
63
  - Fixture shapes should match the component's props so tsc catches drift.
40
64
  - Loading states are fixtures too: export const slowOrders = () => new Promise(r =>
41
65
  setTimeout(() => r(orders), 800)) and let the frame render its skeleton while awaiting.
@@ -59,29 +83,10 @@ Never run or talk to the canvas tool; read and write files only.
59
83
 
60
84
  ## Boards (curated canvases)
61
85
 
62
- A board is a saved canvas: `design/boards/<name>.json` (name: `^[a-z0-9][a-z0-9-]*$`).
63
- The human switches boards in the sidebar; YOU create and manage them by writing files.
64
- Minimal file - just list the frames; the shell fills sizes from each frame's viewport,
65
- lays it out, and keeps it tidy:
66
-
67
- ```json
68
- { "version": 1, "name": "checkout-compare", "auto": false,
69
- "nodes": [ { "frame": "checkout-a/cart" }, { "frame": "checkout-b/cart" } ] }
70
- ```
71
-
72
- - The same frame may appear on many boards, or twice on one board (e.g. two widths:
73
- add `"w"`/`"h"` on a node to pin a size, `"x"`/`"y"` to place it - e.g. a comparison
74
- row: same `y`, increasing `x`). Know that the human's tidy (`t`) and device views
75
- re-layout in frame-id order, so id ordering is the durable arrangement; explicit
76
- coordinates are for one-off setups.
77
- - `auto: false` boards show exactly their list. The `all-scenes` board is auto-managed -
78
- never write it.
79
- - Use boards for comparisons: version A vs B vs C of a flow, side by side.
80
- - Boards are also the unit of PUBLISHING (`marver build --boards <name>`): a flow being
81
- published must have every frame it data-gotos on that board - unlisted frames are
82
- excluded from the bundle at build time. The published gate page shows the app's
83
- identity: design/logo.svg + the host package name (overridable via config `share`).
84
- If the app has no logo asset yet, create a simple design/logo.svg for it.
85
- Leave `share.branding` ON unless the human explicitly asks to remove it: Marver is
86
- free, the gate is already fully personalized to the app, and the small "Powered by"
87
- line is how the tool spreads. Do not remove it as part of "branding polish".
86
+ A board is a saved canvas: `design/boards/<name>.json` - you create and manage them
87
+ by writing files; `all-scenes` is auto-managed, never write it. Compose a board
88
+ deliberately with `"layout"`: rows/columns lanes of scenes plus `{ "space": n }`
89
+ whitespace tokens, and the same grammar per scene for frames (columns align left
90
+ edges; a variant-group name is one indivisible atom). BEFORE creating a board or
91
+ publishing anything, read instructions/boards.md (the layout grammar, file format,
92
+ publishing rules).
@@ -3,6 +3,29 @@
3
3
  You design by writing files. The canvas at the printed localhost URL reflects them live.
4
4
  Never run or talk to the canvas tool; read and write files only.
5
5
 
6
+ ## The method (binding)
7
+
8
+ Design work moves through phases. BEFORE working in a phase, read its instruction
9
+ file in design/instructions/ - they are short, strict, and part of this contract:
10
+
11
+ | Phase | When | Read |
12
+ |---|---|---|
13
+ | Configure | first session in a repo, or frames render unstyled | instructions/configure.md |
14
+ | Discover | any new surface, feature, or flow | instructions/discover.md |
15
+ | Wireframe | new work: nail structure + copy in throwaway lo-fi | instructions/wireframe.md |
16
+ | Brand | before the first hi-fi work: extract or create the world | instructions/brand.md |
17
+ | Build | hi-fi frames from real components | instructions/craft.md + components.md |
18
+ | Review | before presenting anything | instructions/review.md |
19
+ | Boards | creating a board or publishing | instructions/boards.md |
20
+
21
+ Refining an existing screen: Configure must hold, then Build + Review. New work runs
22
+ the full ladder. Unsure which phase you are in? Ask the human - one question beats a
23
+ phase of wrong work.
24
+
25
+ Stuck, or the human is unhappy with a result? instructions/reference/ holds the deep
26
+ guides (layout, typography, color, motion, copy, states, tuning, critique, concepts) -
27
+ the routing index is at the top of instructions/craft.md. Pull ONE file, apply, return.
28
+
6
29
  ## Frames
7
30
  - A frame = one file: design/scenes/<scene>/<name>.tsx or .html. One frame, one surface.
8
31
  - It default-exports a React component. No imports from the tool are needed. Optional:
@@ -11,12 +34,13 @@ Never run or talk to the canvas tool; read and write files only.
11
34
  // tv available commented-out). Pick the one the screen is designed for - the human can
12
35
  // flip the whole board to any device (Devices menu, hotkeys 0-5) to check responsiveness.
13
36
  - States are sibling frames: empty.tsx, filled.tsx, error.tsx, success.tsx.
14
- - VERSIONS are sibling frames too - the scene is the surface, each frame one direction:
15
- design/scenes/landing/a-terminal.tsx, landing/b-editorial.tsx, landing/c-product.tsx.
16
- Layout and the sidebar follow frame-id order, so variants named under one scene with
17
- a-/b-/c- prefixes stay adjacent and ordered through tidy and every device view.
18
- Never spread versions across scenes (terminal/landing, editorial/landing) - they
19
- interleave with everything else and the comparison falls apart.
37
+ - VERSIONS are sibling frames with letter prefixes, and the canvas understands them:
38
+ design/scenes/landing/a-terminal.tsx + b-editorial.tsx form a VARIANT GROUP - kept
39
+ contiguous through tidy and device views, badged A/B on the canvas, one row with
40
+ chips in the sidebar, and switchable in place in play mode ([ and ]). Scope
41
+ alternatives inside a busy scene with a nested dir: checkout/payment/a-card.tsx vs
42
+ b-wallet.tsx groups beside checkout/cart.tsx. meta `of`/`variant` (literal strings)
43
+ override when filenames can't carry it. Never spread versions across scenes.
20
44
  - {{UI_GUIDANCE}}
21
45
  {{NEXT_NOTES}}
22
46
  - Navigation: put data-goto="scene/frame" on any element. That is the whole prototype system.
@@ -34,7 +58,7 @@ Never run or talk to the canvas tool; read and write files only.
34
58
  convention). The root design/scenes/_layout.tsx is the app shell.
35
59
 
36
60
  ## Fixtures
37
- - design/scenes/<scene>/_fixtures.ts - typed plain objects shaped like the future API.
61
+ - design/scenes/<scene>/_fixtures.ts - typed plain objects shaped like the component's PROPS (containers map real APIs into them at promotion; see instructions/components.md).
38
62
  - Frames import fixtures, never stores, never the network, never auth.
39
63
  - Loading states are fixtures too: export const slowOrders = () => new Promise(r =>
40
64
  setTimeout(() => r(orders), 800)) and let the frame render its skeleton while awaiting.
@@ -59,29 +83,10 @@ Never run or talk to the canvas tool; read and write files only.
59
83
 
60
84
  ## Boards (curated canvases)
61
85
 
62
- A board is a saved canvas: `design/boards/<name>.json` (name: `^[a-z0-9][a-z0-9-]*$`).
63
- The human switches boards in the sidebar; YOU create and manage them by writing files.
64
- Minimal file - just list the frames; the shell fills sizes from each frame's viewport,
65
- lays it out, and keeps it tidy:
66
-
67
- ```json
68
- { "version": 1, "name": "checkout-compare", "auto": false,
69
- "nodes": [ { "frame": "checkout-a/cart" }, { "frame": "checkout-b/cart" } ] }
70
- ```
71
-
72
- - The same frame may appear on many boards, or twice on one board (e.g. two widths:
73
- add `"w"`/`"h"` on a node to pin a size, `"x"`/`"y"` to place it - e.g. a comparison
74
- row: same `y`, increasing `x`). Know that the human's tidy (`t`) and device views
75
- re-layout in frame-id order, so id ordering is the durable arrangement; explicit
76
- coordinates are for one-off setups.
77
- - `auto: false` boards show exactly their list. The `all-scenes` board is auto-managed -
78
- never write it.
79
- - Use boards for comparisons: version A vs B vs C of a flow, side by side.
80
- - Boards are also the unit of PUBLISHING (`marver build --boards <name>`): a flow being
81
- published must have every frame it data-gotos on that board - unlisted frames are
82
- excluded from the bundle at build time. The published gate page shows the app's
83
- identity: design/logo.svg + the host package name (overridable via config `share`).
84
- If the app has no logo asset yet, create a simple design/logo.svg for it.
85
- Leave `share.branding` ON unless the human explicitly asks to remove it: Marver is
86
- free, the gate is already fully personalized to the app, and the small "Powered by"
87
- line is how the tool spreads. Do not remove it as part of "branding polish".
86
+ A board is a saved canvas: `design/boards/<name>.json` - you create and manage them
87
+ by writing files; `all-scenes` is auto-managed, never write it. Compose a board
88
+ deliberately with `"layout"`: rows/columns lanes of scenes plus `{ "space": n }`
89
+ whitespace tokens, and the same grammar per scene for frames (columns align left
90
+ edges; a variant-group name is one indivisible atom). BEFORE creating a board or
91
+ publishing anything, read instructions/boards.md (the layout grammar, file format,
92
+ publishing rules).
@@ -2,7 +2,12 @@
2
2
  "extends": "../tsconfig.json",
3
3
  "compilerOptions": {
4
4
  "jsx": "react-jsx",
5
- "noEmit": true
5
+ "noEmit": true,
6
+ // frames import _fixtures.ts with the extension (marver's native-TS convention)
7
+ "allowImportingTsExtensions": true{{PATHS}}
6
8
  },
7
- "include": ["."]
9
+ "include": ["."],
10
+ // the host tsconfig excludes "design" (init adds it so the APP's typecheck skips
11
+ // frames); inherited here it would make this project exclude itself (TS18003)
12
+ "exclude": ["node_modules"]
8
13
  }