@marver-design/marver 0.2.2 → 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.
- 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-ClhCgn4v.mjs} +26 -5
- 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 +13 -9
- package/templates/AGENTS-studio.md +13 -9
- package/templates/design-tsconfig.json +7 -2
- package/templates/instructions/boards.md +47 -1
- package/templates/instructions/discover.md +6 -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)
|
|
@@ -34,12 +34,13 @@ the routing index is at the top of instructions/craft.md. Pull ONE file, apply,
|
|
|
34
34
|
// tv available commented-out). Pick the one the screen is designed for - the human can
|
|
35
35
|
// flip the whole board to any device (Devices menu, hotkeys 0-5) to check responsiveness.
|
|
36
36
|
- 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
|
-
|
|
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.
|
|
43
44
|
- {{UI_GUIDANCE}}
|
|
44
45
|
{{NEXT_NOTES}}
|
|
45
46
|
- Navigation: put data-goto="scene/frame" on any element. That is the whole prototype system.
|
|
@@ -83,6 +84,9 @@ the routing index is at the top of instructions/craft.md. Pull ONE file, apply,
|
|
|
83
84
|
## Boards (curated canvases)
|
|
84
85
|
|
|
85
86
|
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
|
-
|
|
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).
|
|
@@ -34,12 +34,13 @@ the routing index is at the top of instructions/craft.md. Pull ONE file, apply,
|
|
|
34
34
|
// tv available commented-out). Pick the one the screen is designed for - the human can
|
|
35
35
|
// flip the whole board to any device (Devices menu, hotkeys 0-5) to check responsiveness.
|
|
36
36
|
- 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
|
-
|
|
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.
|
|
43
44
|
- {{UI_GUIDANCE}}
|
|
44
45
|
{{NEXT_NOTES}}
|
|
45
46
|
- Navigation: put data-goto="scene/frame" on any element. That is the whole prototype system.
|
|
@@ -83,6 +84,9 @@ the routing index is at the top of instructions/craft.md. Pull ONE file, apply,
|
|
|
83
84
|
## Boards (curated canvases)
|
|
84
85
|
|
|
85
86
|
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
|
-
|
|
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
|
}
|
|
@@ -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
|
|
|
@@ -41,6 +41,12 @@ Create `design/scenes/<scene>/_brief.md`: audience + scene, the one job, mode, t
|
|
|
41
41
|
flow as a numbered list, content sources, out-of-scope. Ten lines, not a document.
|
|
42
42
|
Show it. Get the nod.
|
|
43
43
|
|
|
44
|
+
**Unattended?** When the human is away or has said "don't ask", the interview and
|
|
45
|
+
the nod convert to obligations, not blockers: answer the five questions yourself
|
|
46
|
+
from the repo and reasonable product judgment, mark the brief `UNCONFIRMED` at the
|
|
47
|
+
top, proceed - and surface the brief FIRST when the human returns. Never stall on
|
|
48
|
+
an absent human; never hide that the brief was self-answered.
|
|
49
|
+
|
|
44
50
|
## 4. Align on flow with a diagram frame
|
|
45
51
|
|
|
46
52
|
When the flow has branches or more than four screens, draw it before wireframing:
|