@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.
- package/dist/{build-CNoXE13J.mjs → build-Bqd6OEsQ.mjs} +2 -2
- package/dist/cli.mjs +3 -3
- package/dist/{dev-Blyy4jOL.mjs → dev-9-80L5i5.mjs} +2 -2
- package/dist/{init-3h9pXEzp.mjs → init-Di4geblA.mjs} +171 -24
- package/dist/{manifest-CHmKAAtG.mjs → manifest-mYlO_1Pj.mjs} +78 -2
- package/dist/{plugin-DiDJA9n-.mjs → plugin-YVpBNTB3.mjs} +1 -1
- package/package.json +1 -1
- package/src/client/shell/App.tsx +78 -16
- package/src/client/shell/Play.tsx +36 -0
- package/src/client/shell/canvas/Canvas.tsx +54 -7
- package/src/client/shell/canvas/FrameNode.tsx +14 -1
- package/src/client/shell/icons.tsx +2 -0
- package/src/client/shell/store.ts +115 -14
- package/src/client/shell/styles.css +86 -0
- package/src/client/shell/tidy.ts +347 -17
- package/src/client/stage/main.tsx +11 -3
- package/templates/AGENTS-embedded.md +25 -11
- package/templates/AGENTS-studio.md +25 -11
- package/templates/design-tsconfig.json +7 -2
- package/templates/instructions/boards.md +47 -1
- package/templates/instructions/configure.md +5 -2
- package/templates/instructions/discover.md +8 -1
- package/templates/instructions/welcome.md +122 -0
package/src/client/shell/tidy.ts
CHANGED
|
@@ -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
|
-
|
|
5
|
-
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
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')
|
|
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
|
|
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
|
|
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
|
|
38
|
-
design/scenes/landing/a-terminal.tsx
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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.
|
|
87
|
-
|
|
88
|
-
|
|
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
|
|
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
|
|
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
|
|
38
|
-
design/scenes/landing/a-terminal.tsx
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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.
|
|
87
|
-
|
|
88
|
-
|
|
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 -
|
|
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
|