@marver-design/marver 0.2.1 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/dist/{build-BYZDMiIS.mjs → build-Bqd6OEsQ.mjs} +2 -2
- package/dist/cli.mjs +3 -3
- package/dist/{dev-Dt9D4O7Z.mjs → dev-9-80L5i5.mjs} +2 -2
- package/dist/init-ClhCgn4v.mjs +358 -0
- package/dist/{manifest-CHmKAAtG.mjs → manifest-mYlO_1Pj.mjs} +78 -2
- package/dist/{plugin-lVQoABEx.mjs → plugin-YVpBNTB3.mjs} +103 -9
- package/package.json +1 -1
- package/src/client/shell/App.tsx +123 -19
- 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 +119 -15
- package/src/client/shell/styles.css +107 -2
- package/src/client/shell/tidy.ts +347 -17
- package/src/client/stage/main.tsx +11 -3
- package/templates/AGENTS-embedded.md +38 -33
- package/templates/AGENTS-studio.md +38 -33
- package/templates/design-tsconfig.json +7 -2
- package/templates/instructions/boards.md +84 -0
- package/templates/instructions/brand.md +60 -0
- package/templates/instructions/components.md +52 -0
- package/templates/instructions/configure.md +44 -0
- package/templates/instructions/craft.md +90 -0
- package/templates/instructions/discover.md +58 -0
- package/templates/instructions/reference/color.md +53 -0
- package/templates/instructions/reference/concepts.md +68 -0
- package/templates/instructions/reference/copy.md +57 -0
- package/templates/instructions/reference/critique.md +53 -0
- package/templates/instructions/reference/delight.md +35 -0
- package/templates/instructions/reference/layout.md +51 -0
- package/templates/instructions/reference/motion.md +66 -0
- package/templates/instructions/reference/operate.md +38 -0
- package/templates/instructions/reference/slop.md +76 -0
- package/templates/instructions/reference/states.md +48 -0
- package/templates/instructions/reference/tune.md +61 -0
- package/templates/instructions/reference/typography.md +45 -0
- package/templates/instructions/review.md +51 -0
- package/templates/instructions/wireframe.md +49 -0
- package/dist/init-CHUZTYG7.mjs +0 -224
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)
|
|
@@ -3,6 +3,29 @@
|
|
|
3
3
|
You design by writing files. The canvas at the printed localhost URL reflects them live.
|
|
4
4
|
Never run or talk to the canvas tool; read and write files only.
|
|
5
5
|
|
|
6
|
+
## The method (binding)
|
|
7
|
+
|
|
8
|
+
Design work moves through phases. BEFORE working in a phase, read its instruction
|
|
9
|
+
file in design/instructions/ - they are short, strict, and part of this contract:
|
|
10
|
+
|
|
11
|
+
| Phase | When | Read |
|
|
12
|
+
|---|---|---|
|
|
13
|
+
| Configure | first session in a repo, or frames render unstyled | instructions/configure.md |
|
|
14
|
+
| Discover | any new surface, feature, or flow | instructions/discover.md |
|
|
15
|
+
| Wireframe | new work: nail structure + copy in throwaway lo-fi | instructions/wireframe.md |
|
|
16
|
+
| Brand | before the first hi-fi work: extract or create the world | instructions/brand.md |
|
|
17
|
+
| Build | hi-fi frames from real components | instructions/craft.md + components.md |
|
|
18
|
+
| Review | before presenting anything | instructions/review.md |
|
|
19
|
+
| Boards | creating a board or publishing | instructions/boards.md |
|
|
20
|
+
|
|
21
|
+
Refining an existing screen: Configure must hold, then Build + Review. New work runs
|
|
22
|
+
the full ladder. Unsure which phase you are in? Ask the human - one question beats a
|
|
23
|
+
phase of wrong work.
|
|
24
|
+
|
|
25
|
+
Stuck, or the human is unhappy with a result? instructions/reference/ holds the deep
|
|
26
|
+
guides (layout, typography, color, motion, copy, states, tuning, critique, concepts) -
|
|
27
|
+
the routing index is at the top of instructions/craft.md. Pull ONE file, apply, return.
|
|
28
|
+
|
|
6
29
|
## Frames
|
|
7
30
|
- A frame = one file: design/scenes/<scene>/<name>.tsx or .html. One frame, one surface.
|
|
8
31
|
- It default-exports a React component. No imports from the tool are needed. Optional:
|
|
@@ -11,12 +34,13 @@ Never run or talk to the canvas tool; read and write files only.
|
|
|
11
34
|
// tv available commented-out). Pick the one the screen is designed for - the human can
|
|
12
35
|
// flip the whole board to any device (Devices menu, hotkeys 0-5) to check responsiveness.
|
|
13
36
|
- States are sibling frames: empty.tsx, filled.tsx, error.tsx, success.tsx.
|
|
14
|
-
- VERSIONS are sibling frames
|
|
15
|
-
design/scenes/landing/a-terminal.tsx
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
37
|
+
- VERSIONS are sibling frames with letter prefixes, and the canvas understands them:
|
|
38
|
+
design/scenes/landing/a-terminal.tsx + b-editorial.tsx form a VARIANT GROUP - kept
|
|
39
|
+
contiguous through tidy and device views, badged A/B on the canvas, one row with
|
|
40
|
+
chips in the sidebar, and switchable in place in play mode ([ and ]). Scope
|
|
41
|
+
alternatives inside a busy scene with a nested dir: checkout/payment/a-card.tsx vs
|
|
42
|
+
b-wallet.tsx groups beside checkout/cart.tsx. meta `of`/`variant` (literal strings)
|
|
43
|
+
override when filenames can't carry it. Never spread versions across scenes.
|
|
20
44
|
- {{UI_GUIDANCE}}
|
|
21
45
|
{{NEXT_NOTES}}
|
|
22
46
|
- Navigation: put data-goto="scene/frame" on any element. That is the whole prototype system.
|
|
@@ -35,7 +59,7 @@ Never run or talk to the canvas tool; read and write files only.
|
|
|
35
59
|
convention). The root design/scenes/_layout.tsx mounts the app's real shell component.
|
|
36
60
|
|
|
37
61
|
## Fixtures
|
|
38
|
-
- design/scenes/<scene>/_fixtures.ts - typed plain objects shaped like the
|
|
62
|
+
- design/scenes/<scene>/_fixtures.ts - typed plain objects shaped like the component's PROPS (containers map real APIs into them at promotion; see instructions/components.md).
|
|
39
63
|
- Fixture shapes should match the component's props so tsc catches drift.
|
|
40
64
|
- Loading states are fixtures too: export const slowOrders = () => new Promise(r =>
|
|
41
65
|
setTimeout(() => r(orders), 800)) and let the frame render its skeleton while awaiting.
|
|
@@ -59,29 +83,10 @@ Never run or talk to the canvas tool; read and write files only.
|
|
|
59
83
|
|
|
60
84
|
## Boards (curated canvases)
|
|
61
85
|
|
|
62
|
-
A board is a saved canvas: `design/boards/<name>.json`
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
"nodes": [ { "frame": "checkout-a/cart" }, { "frame": "checkout-b/cart" } ] }
|
|
70
|
-
```
|
|
71
|
-
|
|
72
|
-
- The same frame may appear on many boards, or twice on one board (e.g. two widths:
|
|
73
|
-
add `"w"`/`"h"` on a node to pin a size, `"x"`/`"y"` to place it - e.g. a comparison
|
|
74
|
-
row: same `y`, increasing `x`). Know that the human's tidy (`t`) and device views
|
|
75
|
-
re-layout in frame-id order, so id ordering is the durable arrangement; explicit
|
|
76
|
-
coordinates are for one-off setups.
|
|
77
|
-
- `auto: false` boards show exactly their list. The `all-scenes` board is auto-managed -
|
|
78
|
-
never write it.
|
|
79
|
-
- Use boards for comparisons: version A vs B vs C of a flow, side by side.
|
|
80
|
-
- Boards are also the unit of PUBLISHING (`marver build --boards <name>`): a flow being
|
|
81
|
-
published must have every frame it data-gotos on that board - unlisted frames are
|
|
82
|
-
excluded from the bundle at build time. The published gate page shows the app's
|
|
83
|
-
identity: design/logo.svg + the host package name (overridable via config `share`).
|
|
84
|
-
If the app has no logo asset yet, create a simple design/logo.svg for it.
|
|
85
|
-
Leave `share.branding` ON unless the human explicitly asks to remove it: Marver is
|
|
86
|
-
free, the gate is already fully personalized to the app, and the small "Powered by"
|
|
87
|
-
line is how the tool spreads. Do not remove it as part of "branding polish".
|
|
86
|
+
A board is a saved canvas: `design/boards/<name>.json` - you create and manage them
|
|
87
|
+
by writing files; `all-scenes` is auto-managed, never write it. Compose a board
|
|
88
|
+
deliberately with `"layout"`: rows/columns lanes of scenes plus `{ "space": n }`
|
|
89
|
+
whitespace tokens, and the same grammar per scene for frames (columns align left
|
|
90
|
+
edges; a variant-group name is one indivisible atom). BEFORE creating a board or
|
|
91
|
+
publishing anything, read instructions/boards.md (the layout grammar, file format,
|
|
92
|
+
publishing rules).
|
|
@@ -3,6 +3,29 @@
|
|
|
3
3
|
You design by writing files. The canvas at the printed localhost URL reflects them live.
|
|
4
4
|
Never run or talk to the canvas tool; read and write files only.
|
|
5
5
|
|
|
6
|
+
## The method (binding)
|
|
7
|
+
|
|
8
|
+
Design work moves through phases. BEFORE working in a phase, read its instruction
|
|
9
|
+
file in design/instructions/ - they are short, strict, and part of this contract:
|
|
10
|
+
|
|
11
|
+
| Phase | When | Read |
|
|
12
|
+
|---|---|---|
|
|
13
|
+
| Configure | first session in a repo, or frames render unstyled | instructions/configure.md |
|
|
14
|
+
| Discover | any new surface, feature, or flow | instructions/discover.md |
|
|
15
|
+
| Wireframe | new work: nail structure + copy in throwaway lo-fi | instructions/wireframe.md |
|
|
16
|
+
| Brand | before the first hi-fi work: extract or create the world | instructions/brand.md |
|
|
17
|
+
| Build | hi-fi frames from real components | instructions/craft.md + components.md |
|
|
18
|
+
| Review | before presenting anything | instructions/review.md |
|
|
19
|
+
| Boards | creating a board or publishing | instructions/boards.md |
|
|
20
|
+
|
|
21
|
+
Refining an existing screen: Configure must hold, then Build + Review. New work runs
|
|
22
|
+
the full ladder. Unsure which phase you are in? Ask the human - one question beats a
|
|
23
|
+
phase of wrong work.
|
|
24
|
+
|
|
25
|
+
Stuck, or the human is unhappy with a result? instructions/reference/ holds the deep
|
|
26
|
+
guides (layout, typography, color, motion, copy, states, tuning, critique, concepts) -
|
|
27
|
+
the routing index is at the top of instructions/craft.md. Pull ONE file, apply, return.
|
|
28
|
+
|
|
6
29
|
## Frames
|
|
7
30
|
- A frame = one file: design/scenes/<scene>/<name>.tsx or .html. One frame, one surface.
|
|
8
31
|
- It default-exports a React component. No imports from the tool are needed. Optional:
|
|
@@ -11,12 +34,13 @@ Never run or talk to the canvas tool; read and write files only.
|
|
|
11
34
|
// tv available commented-out). Pick the one the screen is designed for - the human can
|
|
12
35
|
// flip the whole board to any device (Devices menu, hotkeys 0-5) to check responsiveness.
|
|
13
36
|
- States are sibling frames: empty.tsx, filled.tsx, error.tsx, success.tsx.
|
|
14
|
-
- VERSIONS are sibling frames
|
|
15
|
-
design/scenes/landing/a-terminal.tsx
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
37
|
+
- VERSIONS are sibling frames with letter prefixes, and the canvas understands them:
|
|
38
|
+
design/scenes/landing/a-terminal.tsx + b-editorial.tsx form a VARIANT GROUP - kept
|
|
39
|
+
contiguous through tidy and device views, badged A/B on the canvas, one row with
|
|
40
|
+
chips in the sidebar, and switchable in place in play mode ([ and ]). Scope
|
|
41
|
+
alternatives inside a busy scene with a nested dir: checkout/payment/a-card.tsx vs
|
|
42
|
+
b-wallet.tsx groups beside checkout/cart.tsx. meta `of`/`variant` (literal strings)
|
|
43
|
+
override when filenames can't carry it. Never spread versions across scenes.
|
|
20
44
|
- {{UI_GUIDANCE}}
|
|
21
45
|
{{NEXT_NOTES}}
|
|
22
46
|
- Navigation: put data-goto="scene/frame" on any element. That is the whole prototype system.
|
|
@@ -34,7 +58,7 @@ Never run or talk to the canvas tool; read and write files only.
|
|
|
34
58
|
convention). The root design/scenes/_layout.tsx is the app shell.
|
|
35
59
|
|
|
36
60
|
## Fixtures
|
|
37
|
-
- design/scenes/<scene>/_fixtures.ts - typed plain objects shaped like the
|
|
61
|
+
- design/scenes/<scene>/_fixtures.ts - typed plain objects shaped like the component's PROPS (containers map real APIs into them at promotion; see instructions/components.md).
|
|
38
62
|
- Frames import fixtures, never stores, never the network, never auth.
|
|
39
63
|
- Loading states are fixtures too: export const slowOrders = () => new Promise(r =>
|
|
40
64
|
setTimeout(() => r(orders), 800)) and let the frame render its skeleton while awaiting.
|
|
@@ -59,29 +83,10 @@ Never run or talk to the canvas tool; read and write files only.
|
|
|
59
83
|
|
|
60
84
|
## Boards (curated canvases)
|
|
61
85
|
|
|
62
|
-
A board is a saved canvas: `design/boards/<name>.json`
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
"nodes": [ { "frame": "checkout-a/cart" }, { "frame": "checkout-b/cart" } ] }
|
|
70
|
-
```
|
|
71
|
-
|
|
72
|
-
- The same frame may appear on many boards, or twice on one board (e.g. two widths:
|
|
73
|
-
add `"w"`/`"h"` on a node to pin a size, `"x"`/`"y"` to place it - e.g. a comparison
|
|
74
|
-
row: same `y`, increasing `x`). Know that the human's tidy (`t`) and device views
|
|
75
|
-
re-layout in frame-id order, so id ordering is the durable arrangement; explicit
|
|
76
|
-
coordinates are for one-off setups.
|
|
77
|
-
- `auto: false` boards show exactly their list. The `all-scenes` board is auto-managed -
|
|
78
|
-
never write it.
|
|
79
|
-
- Use boards for comparisons: version A vs B vs C of a flow, side by side.
|
|
80
|
-
- Boards are also the unit of PUBLISHING (`marver build --boards <name>`): a flow being
|
|
81
|
-
published must have every frame it data-gotos on that board - unlisted frames are
|
|
82
|
-
excluded from the bundle at build time. The published gate page shows the app's
|
|
83
|
-
identity: design/logo.svg + the host package name (overridable via config `share`).
|
|
84
|
-
If the app has no logo asset yet, create a simple design/logo.svg for it.
|
|
85
|
-
Leave `share.branding` ON unless the human explicitly asks to remove it: Marver is
|
|
86
|
-
free, the gate is already fully personalized to the app, and the small "Powered by"
|
|
87
|
-
line is how the tool spreads. Do not remove it as part of "branding polish".
|
|
86
|
+
A board is a saved canvas: `design/boards/<name>.json` - you create and manage them
|
|
87
|
+
by writing files; `all-scenes` is auto-managed, never write it. Compose a board
|
|
88
|
+
deliberately with `"layout"`: rows/columns lanes of scenes plus `{ "space": n }`
|
|
89
|
+
whitespace tokens, and the same grammar per scene for frames (columns align left
|
|
90
|
+
edges; a variant-group name is one indivisible atom). BEFORE creating a board or
|
|
91
|
+
publishing anything, read instructions/boards.md (the layout grammar, file format,
|
|
92
|
+
publishing rules).
|
|
@@ -2,7 +2,12 @@
|
|
|
2
2
|
"extends": "../tsconfig.json",
|
|
3
3
|
"compilerOptions": {
|
|
4
4
|
"jsx": "react-jsx",
|
|
5
|
-
"noEmit": true
|
|
5
|
+
"noEmit": true,
|
|
6
|
+
// frames import _fixtures.ts with the extension (marver's native-TS convention)
|
|
7
|
+
"allowImportingTsExtensions": true{{PATHS}}
|
|
6
8
|
},
|
|
7
|
-
"include": ["."]
|
|
9
|
+
"include": ["."],
|
|
10
|
+
// the host tsconfig excludes "design" (init adds it so the APP's typecheck skips
|
|
11
|
+
// frames); inherited here it would make this project exclude itself (TS18003)
|
|
12
|
+
"exclude": ["node_modules"]
|
|
8
13
|
}
|