@marver-design/marver 0.2.2 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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)
@@ -1,7 +1,9 @@
1
1
  # Design canvas - agent contract (embedded mode)
2
2
 
3
3
  You design by writing files. The canvas at the printed localhost URL reflects them live.
4
- Never run or talk to the canvas tool; read and write files only.
4
+ Never drive or automate the canvas UI; read and write files only. (Starting
5
+ `npx marver dev` so the human has a live canvas - first session, or on request -
6
+ is the one allowed touch.)
5
7
 
6
8
  ## The method (binding)
7
9
 
@@ -10,6 +12,7 @@ file in design/instructions/ - they are short, strict, and part of this contract
10
12
 
11
13
  | Phase | When | Read |
12
14
  |---|---|---|
15
+ | Welcome | the human's FIRST session, or "what is this?" | instructions/welcome.md |
13
16
  | Configure | first session in a repo, or frames render unstyled | instructions/configure.md |
14
17
  | Discover | any new surface, feature, or flow | instructions/discover.md |
15
18
  | Wireframe | new work: nail structure + copy in throwaway lo-fi | instructions/wireframe.md |
@@ -22,6 +25,12 @@ Refining an existing screen: Configure must hold, then Build + Review. New work
22
25
  the full ladder. Unsure which phase you are in? Ask the human - one question beats a
23
26
  phase of wrong work.
24
27
 
28
+ First sessions are teaching sessions: narrate what you do and why in short plain
29
+ sentences - story, not machinery; never read these files aloud to the human
30
+ (voice rules in welcome.md).
31
+ The first-session draft is the ladder's one exception: it skips the written
32
+ brief (the human just said what they are building) but never the craft bar.
33
+
25
34
  Stuck, or the human is unhappy with a result? instructions/reference/ holds the deep
26
35
  guides (layout, typography, color, motion, copy, states, tuning, critique, concepts) -
27
36
  the routing index is at the top of instructions/craft.md. Pull ONE file, apply, return.
@@ -32,14 +41,16 @@ the routing index is at the top of instructions/craft.md. Pull ONE file, apply,
32
41
  export const meta = { title: "...", viewport: "mobile" } // literal values only
33
42
  // viewport names come from design/config.ts (default: mobile, tablet, laptop, monitor;
34
43
  // tv available commented-out). Pick the one the screen is designed for - the human can
35
- // flip the whole board to any device (Devices menu, hotkeys 0-5) to check responsiveness.
44
+ // flip the whole board to any device (Devices menu; digit keys - 0 restores each
45
+ // frame's own size, 1..n per configured device) to check responsiveness.
36
46
  - States are sibling frames: empty.tsx, filled.tsx, error.tsx, success.tsx.
37
- - VERSIONS are sibling frames too - the scene is the surface, each frame one direction:
38
- design/scenes/landing/a-terminal.tsx, landing/b-editorial.tsx, landing/c-product.tsx.
39
- Layout and the sidebar follow frame-id order, so variants named under one scene with
40
- a-/b-/c- prefixes stay adjacent and ordered through tidy and every device view.
41
- Never spread versions across scenes (terminal/landing, editorial/landing) - they
42
- interleave with everything else and the comparison falls apart.
47
+ - VERSIONS are sibling frames with letter prefixes, and the canvas understands them:
48
+ design/scenes/landing/a-terminal.tsx + b-editorial.tsx form a VARIANT GROUP - kept
49
+ contiguous through tidy and device views, badged A/B on the canvas, one row with
50
+ chips in the sidebar, and switchable in place in play mode ([ and ]). Scope
51
+ alternatives inside a busy scene with a nested dir: checkout/payment/a-card.tsx vs
52
+ b-wallet.tsx groups beside checkout/cart.tsx. meta `of`/`variant` (literal strings)
53
+ override when filenames can't carry it. Never spread versions across scenes.
43
54
  - {{UI_GUIDANCE}}
44
55
  {{NEXT_NOTES}}
45
56
  - Navigation: put data-goto="scene/frame" on any element. That is the whole prototype system.
@@ -83,6 +94,9 @@ the routing index is at the top of instructions/craft.md. Pull ONE file, apply,
83
94
  ## Boards (curated canvases)
84
95
 
85
96
  A board is a saved canvas: `design/boards/<name>.json` - you create and manage them
86
- by writing files; `all-scenes` is auto-managed, never write it. BEFORE creating a
87
- board or publishing anything, read instructions/boards.md (file format, layout
88
- durability, publishing rules).
97
+ by writing files; `all-scenes` is auto-managed, never write it. Compose a board
98
+ deliberately with `"layout"`: rows/columns lanes of scenes plus `{ "space": n }`
99
+ whitespace tokens, and the same grammar per scene for frames (columns align left
100
+ edges; a variant-group name is one indivisible atom). BEFORE creating a board or
101
+ publishing anything, read instructions/boards.md (the layout grammar, file format,
102
+ publishing rules).
@@ -1,7 +1,9 @@
1
1
  # Design canvas - agent contract
2
2
 
3
3
  You design by writing files. The canvas at the printed localhost URL reflects them live.
4
- Never run or talk to the canvas tool; read and write files only.
4
+ Never drive or automate the canvas UI; read and write files only. (Starting
5
+ `npx marver dev` so the human has a live canvas - first session, or on request -
6
+ is the one allowed touch.)
5
7
 
6
8
  ## The method (binding)
7
9
 
@@ -10,6 +12,7 @@ file in design/instructions/ - they are short, strict, and part of this contract
10
12
 
11
13
  | Phase | When | Read |
12
14
  |---|---|---|
15
+ | Welcome | the human's FIRST session, or "what is this?" | instructions/welcome.md |
13
16
  | Configure | first session in a repo, or frames render unstyled | instructions/configure.md |
14
17
  | Discover | any new surface, feature, or flow | instructions/discover.md |
15
18
  | Wireframe | new work: nail structure + copy in throwaway lo-fi | instructions/wireframe.md |
@@ -22,6 +25,12 @@ Refining an existing screen: Configure must hold, then Build + Review. New work
22
25
  the full ladder. Unsure which phase you are in? Ask the human - one question beats a
23
26
  phase of wrong work.
24
27
 
28
+ First sessions are teaching sessions: narrate what you do and why in short plain
29
+ sentences - story, not machinery; never read these files aloud to the human
30
+ (voice rules in welcome.md).
31
+ The first-session draft is the ladder's one exception: it skips the written
32
+ brief (the human just said what they are building) but never the craft bar.
33
+
25
34
  Stuck, or the human is unhappy with a result? instructions/reference/ holds the deep
26
35
  guides (layout, typography, color, motion, copy, states, tuning, critique, concepts) -
27
36
  the routing index is at the top of instructions/craft.md. Pull ONE file, apply, return.
@@ -32,14 +41,16 @@ the routing index is at the top of instructions/craft.md. Pull ONE file, apply,
32
41
  export const meta = { title: "...", viewport: "mobile" } // literal values only
33
42
  // viewport names come from design/config.ts (default: mobile, tablet, laptop, monitor;
34
43
  // tv available commented-out). Pick the one the screen is designed for - the human can
35
- // flip the whole board to any device (Devices menu, hotkeys 0-5) to check responsiveness.
44
+ // flip the whole board to any device (Devices menu; digit keys - 0 restores each
45
+ // frame's own size, 1..n per configured device) to check responsiveness.
36
46
  - States are sibling frames: empty.tsx, filled.tsx, error.tsx, success.tsx.
37
- - VERSIONS are sibling frames too - the scene is the surface, each frame one direction:
38
- design/scenes/landing/a-terminal.tsx, landing/b-editorial.tsx, landing/c-product.tsx.
39
- Layout and the sidebar follow frame-id order, so variants named under one scene with
40
- a-/b-/c- prefixes stay adjacent and ordered through tidy and every device view.
41
- Never spread versions across scenes (terminal/landing, editorial/landing) - they
42
- interleave with everything else and the comparison falls apart.
47
+ - VERSIONS are sibling frames with letter prefixes, and the canvas understands them:
48
+ design/scenes/landing/a-terminal.tsx + b-editorial.tsx form a VARIANT GROUP - kept
49
+ contiguous through tidy and device views, badged A/B on the canvas, one row with
50
+ chips in the sidebar, and switchable in place in play mode ([ and ]). Scope
51
+ alternatives inside a busy scene with a nested dir: checkout/payment/a-card.tsx vs
52
+ b-wallet.tsx groups beside checkout/cart.tsx. meta `of`/`variant` (literal strings)
53
+ override when filenames can't carry it. Never spread versions across scenes.
43
54
  - {{UI_GUIDANCE}}
44
55
  {{NEXT_NOTES}}
45
56
  - Navigation: put data-goto="scene/frame" on any element. That is the whole prototype system.
@@ -83,6 +94,9 @@ the routing index is at the top of instructions/craft.md. Pull ONE file, apply,
83
94
  ## Boards (curated canvases)
84
95
 
85
96
  A board is a saved canvas: `design/boards/<name>.json` - you create and manage them
86
- by writing files; `all-scenes` is auto-managed, never write it. BEFORE creating a
87
- board or publishing anything, read instructions/boards.md (file format, layout
88
- durability, publishing rules).
97
+ by writing files; `all-scenes` is auto-managed, never write it. Compose a board
98
+ deliberately with `"layout"`: rows/columns lanes of scenes plus `{ "space": n }`
99
+ whitespace tokens, and the same grammar per scene for frames (columns align left
100
+ edges; a variant-group name is one indivisible atom). BEFORE creating a board or
101
+ publishing anything, read instructions/boards.md (the layout grammar, file format,
102
+ 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
  }
@@ -22,7 +22,53 @@ viewport and lays it out:
22
22
  never write it.
23
23
  - Do not edit board files while the canvas is open unless asked; the shell owns
24
24
  their layout fields.
25
- - Use boards for comparisons: version A vs B vs C of a flow, side by side.
25
+ - Use boards for comparisons: version A vs B vs C of a flow, side by side. Variant
26
+ groups (letter-prefixed siblings) stay contiguous through every relayout
27
+ automatically.
28
+
29
+ ## Composing the canvas: `layout`
30
+
31
+ Compose a board deliberately - whitespace, lanes, alignment - with a `layout`
32
+ recipe. One grammar: a scope is `"rows"` OR `"columns"` of lanes; a lane is an
33
+ ordered list of atoms and `{ "space": n }` tokens.
34
+
35
+ ```json
36
+ { "version": 1, "name": "showcase", "auto": false,
37
+ "layout": {
38
+ "columns": [
39
+ ["hero", { "space": 2 }, "archive"],
40
+ { "space": 4 },
41
+ ["variants"]
42
+ ],
43
+ "scenes": {
44
+ "hero": { "rows": [["overview", "detail", "proof", { "space": 3 }, "directions"]] }
45
+ }
46
+ },
47
+ "nodes": [ { "frame": "hero/overview" }, { "frame": "hero/detail" } ] }
48
+ ```
49
+
50
+ - **Board scope** (`layout.rows` / `layout.columns`): atoms are scene names.
51
+ `rows` lanes stack top-to-bottom, scenes in a lane flow left-to-right.
52
+ `columns` lanes sit left-to-right, scenes in a lane stack top-to-bottom and
53
+ share a left edge - use columns when things must align vertically (a parked
54
+ archive under a hero, a variants cluster off to the right).
55
+ - **Scene scope** (`layout.scenes.<scene>`): the same grammar, atoms are frame
56
+ basenames within that scene; a variant-group name (its directory name) is ONE
57
+ atom - the run stays together. Example above: three frames, a 3-unit gap, then
58
+ the variant run.
59
+ - `{ "space": n }` = n gap units at that boundary; a unit is the adaptive gutter
60
+ (proportional to the touching frames), so spacing holds across phone and
61
+ monitor frames and through resizes. Plain adjacency = 1 unit.
62
+ - **Isolate variant runs.** When a variant group shares a scene with regular
63
+ flow frames, put `{ "space": 2 }` or `{ "space": 3 }` before (and after, if
64
+ frames follow) the group's atom in that scene's recipe - explorations should
65
+ read as their own cluster, not blend into the flow:
66
+ `"scenes": { "checkout": { "rows": [["cart", "payment", { "space": 3 }, "directions"]] } }`
67
+ - Tidy, device switches, and frame resizes re-apply the recipe; dragging stays
68
+ free until sizes change. Scenes/frames not listed append after, in default
69
+ order. Unknown names warn and skip - check the name against the sidebar.
70
+ - Legacy `"sceneRows": [["landing","docs"]]` still works (= a plain `rows`
71
+ layout); prefer `layout` for anything new.
26
72
 
27
73
  ## Publishing
28
74
 
@@ -20,9 +20,12 @@ All four true → idle state. Go design.
20
20
 
21
21
  ## By repo maturity
22
22
 
23
+ The human's first session layers on top of this: philosophy, their product on the
24
+ canvas, the tour - that flow lives in instructions/welcome.md, run it alongside.
25
+
23
26
  - **Brand-new repo (no app)**: `design/instructions/setup.md` exists and is the
24
- authority - STOP, follow it (set up the stack, re-run init). Do not design against
25
- a repo that has nothing to build from.
27
+ authority - follow it (set up the stack WITH the human, re-run init). Do not
28
+ design against a repo that has nothing to build from.
26
29
  - **Fresh repo (app scaffolded, little product code)**: init's detection is usually
27
30
  right. Verify the checklist, create DESIGN.md from the starter tokens (Path A -
28
31
  even a default shadcn theme is a documentable brand), and note in it which parts