@radicool/throughline 0.25.0 → 0.27.0
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 +8 -4
- package/adapters/codex/AGENTS.md +2 -0
- package/adapters/codex/prompts/component-builder.md +5 -3
- package/adapters/codex/prompts/compose-code.md +78 -0
- package/adapters/codex/prompts/compose-figma.md +59 -0
- package/adapters/codex/prompts/document-component.md +3 -2
- package/adapters/cursor/.cursor/commands/compose-code.md +78 -0
- package/adapters/cursor/.cursor/commands/compose-figma.md +59 -0
- package/adapters/cursor/.cursor/commands/document-component.md +3 -2
- package/adapters/cursor/.cursor/rules/component-builder.mdc +5 -3
- package/adapters/generic/AGENTS.md +2 -0
- package/adapters/generic/commands/compose-code.md +78 -0
- package/adapters/generic/commands/compose-figma.md +59 -0
- package/adapters/generic/commands/document-component.md +3 -2
- package/adapters/generic/skills/component-builder/SKILL.md +5 -3
- package/package.json +1 -1
- package/references/doc-card-builder.md +62 -15
- package/references/figma-adherence.md +21 -0
- package/references/figma-compose.md +896 -0
- package/scripts/README.md +8 -4
- package/scripts/build-doc-card-builder.mjs +16 -0
- package/scripts/build-figma-checks.mjs +89 -2
- package/scripts/compose-code.mjs +271 -0
- package/scripts/doc-card-payload.mjs +115 -0
- package/scripts/install.mjs +5 -3
- package/scripts/lib/doc-card-plan.mjs +37 -0
- package/scripts/lib/doc-card-render.figma.js +9 -15
- package/scripts/lib/figma-adherence-read.figma.js +21 -0
- package/scripts/lib/figma-compose.figma.js +326 -0
- package/scripts/lib/figma-compose.mjs +317 -0
- package/scripts/lib/fixtures/figma-compose/casing-and-suffix.json +8 -0
- package/scripts/lib/fixtures/figma-compose/fill-in-unlaid-slot.json +7 -0
- package/scripts/lib/fixtures/figma-compose/hug-on-fixed-component.json +34 -0
- package/scripts/lib/fixtures/figma-compose/inventory.json +295 -0
- package/scripts/lib/fixtures/figma-compose/many-errors.json +8 -0
- package/scripts/lib/fixtures/figma-compose/missing-component.json +7 -0
- package/scripts/lib/fixtures/figma-compose/name-taken.json +6 -0
- package/scripts/lib/fixtures/figma-compose/padding-sides.json +23 -0
- package/scripts/lib/fixtures/figma-compose/radius-kind.json +27 -0
- package/scripts/lib/fixtures/figma-compose/raw-number.json +5 -0
- package/scripts/lib/fixtures/figma-compose/sign-in-ok.json +16 -0
- package/scripts/lib/fixtures/figma-compose/slot-ok.json +9 -0
- package/scripts/lib/fixtures/figma-compose/unknown-prop-and-option.json +7 -0
- package/scripts/lib/fixtures/figma-compose/wrong-kind.json +5 -0
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
// Screen composition for Figma as a pure resolver: checks a screen description
|
|
2
|
+
// against what the file has, and turns its names into what the writer sets.
|
|
3
|
+
// ZERO imports, `export const`/`export function` only. It is inlined, next to
|
|
4
|
+
// figma-adherence.mjs and the shared variable reader, into the generated Figma
|
|
5
|
+
// script (references/figma-compose.md) by build-figma-checks.mjs, so it must run
|
|
6
|
+
// in both Node and the Figma plugin sandbox, and reuse none of their top-level
|
|
7
|
+
// names.
|
|
8
|
+
//
|
|
9
|
+
// Spec: docs/specs/2026-10-13-compose-figma-screen.md
|
|
10
|
+
//
|
|
11
|
+
// DESCRIPTION (what the planner writes):
|
|
12
|
+
// {
|
|
13
|
+
// screen, page, width, height?, // width/height: the only raw numbers
|
|
14
|
+
// modes?: { [collectionName]: modeName },
|
|
15
|
+
// root: <frame>, // no sizing on the root
|
|
16
|
+
// }
|
|
17
|
+
// frame: { layout: 'vertical'|'horizontal', gap?, padding?, fill?, stroke?, radius?,
|
|
18
|
+
// align?: { main?: 'start'|'center'|'end'|'space-between', cross?: 'start'|'center'|'end' },
|
|
19
|
+
// children?: [node], sizing?, name? }
|
|
20
|
+
// padding: a variable name, or { top?, right?, bottom?, left? }
|
|
21
|
+
// component: { component, props?: { [propName]: value }, slots?: { [slotName]: [node] }, sizing?, name? }
|
|
22
|
+
// text: { text, style, color, sizing?, name? }
|
|
23
|
+
// sizing: { horizontal?: 'fill'|'hug', vertical?: 'fill'|'hug' }
|
|
24
|
+
// An axis left out stays as Figma makes it: a frame hugs, text sizes to
|
|
25
|
+
// its content, an instance keeps its component's size. 'hug' on an
|
|
26
|
+
// instance needs a component with auto layout (Figma throws otherwise).
|
|
27
|
+
// Every visual value is a variable or text-style name.
|
|
28
|
+
//
|
|
29
|
+
// INVENTORY (what the reader returns):
|
|
30
|
+
// {
|
|
31
|
+
// sets: [{ name, id, page, autoLayout, props: [{ key, name, type, options?, slotLayout? }] }],
|
|
32
|
+
// // key: 'label#10:273'; type: TEXT | BOOLEAN | VARIANT
|
|
33
|
+
// // | INSTANCE_SWAP | SLOT; slotLayout: NONE | VERTICAL | HORIZONTAL,
|
|
34
|
+
// // NONE if any variant's slot has no auto layout;
|
|
35
|
+
// // autoLayout: every variant has auto layout
|
|
36
|
+
// components: [{ name, id, page }], // standalone COMPONENTs, the swap targets
|
|
37
|
+
// variables: [{ name, id, kinds: ['colour'|'gap'|'radius'] }],
|
|
38
|
+
// textStyles: [{ name, id }],
|
|
39
|
+
// collections: [{ name, id, modes: [{ name, id }] }],
|
|
40
|
+
// target: { screen, wip } | null, // build only: 'marked' | 'unmarked' | null for
|
|
41
|
+
// // the frames named <screen> and 'WIP: <screen>'
|
|
42
|
+
// }
|
|
43
|
+
//
|
|
44
|
+
// resolveScreen(description, inventory) → { ok, errors, plan }
|
|
45
|
+
// errors: [{ kind, path, asked, nearest? }]; path is the node's place in the
|
|
46
|
+
// description ('root.children[2].slots.Body[0]').
|
|
47
|
+
// plan (only when ok): the same tree with every name replaced by ids, real
|
|
48
|
+
// property keys and values, and Figma's enum spellings.
|
|
49
|
+
|
|
50
|
+
export const COMPOSE_ERROR_KINDS = [
|
|
51
|
+
'invalid', 'name-taken', 'missing-component', 'unknown-prop', 'unknown-option',
|
|
52
|
+
'prop-type', 'missing-swap-target', 'unknown-variable', 'variable-kind',
|
|
53
|
+
'unknown-text-style', 'unknown-mode',
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
const composeFold = (s) => String(s).toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
57
|
+
|
|
58
|
+
// 'label#10:273' → 'label'. Variant property keys carry no suffix.
|
|
59
|
+
const propBaseName = (key) => String(key).replace(/#[^#]*$/, '');
|
|
60
|
+
|
|
61
|
+
const LAYOUT = { vertical: 'VERTICAL', horizontal: 'HORIZONTAL' };
|
|
62
|
+
const ALIGN_MAIN = { start: 'MIN', center: 'CENTER', end: 'MAX', 'space-between': 'SPACE_BETWEEN' };
|
|
63
|
+
const ALIGN_CROSS = { start: 'MIN', center: 'CENTER', end: 'MAX' };
|
|
64
|
+
const SIZING = { fill: 'FILL', hug: 'HUG' };
|
|
65
|
+
const SIDES = ['top', 'right', 'bottom', 'left'];
|
|
66
|
+
|
|
67
|
+
const NODE_FIELDS = {
|
|
68
|
+
frame: ['layout', 'gap', 'padding', 'fill', 'stroke', 'radius', 'align', 'children', 'sizing', 'name'],
|
|
69
|
+
component: ['component', 'props', 'slots', 'sizing', 'name'],
|
|
70
|
+
text: ['text', 'style', 'color', 'sizing', 'name'],
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
function editDistance(a, b) {
|
|
74
|
+
const row = Array.from({ length: b.length + 1 }, (_, j) => j);
|
|
75
|
+
for (let i = 1; i <= a.length; i++) {
|
|
76
|
+
let diag = row[0];
|
|
77
|
+
row[0] = i;
|
|
78
|
+
for (let j = 1; j <= b.length; j++) {
|
|
79
|
+
const above = row[j];
|
|
80
|
+
row[j] = Math.min(row[j] + 1, row[j - 1] + 1, diag + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
81
|
+
diag = above;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return row[b.length];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Up to three names, by longest shared folded prefix, then edit distance.
|
|
88
|
+
export function nearestNames(asked, names) {
|
|
89
|
+
const want = composeFold(asked);
|
|
90
|
+
const shared = (f) => { let n = 0; while (n < f.length && n < want.length && f[n] === want[n]) n++; return n; };
|
|
91
|
+
return [...new Set(names)]
|
|
92
|
+
.map((name) => ({ name, f: composeFold(name) }))
|
|
93
|
+
.map((x) => ({ ...x, prefix: shared(x.f), dist: editDistance(want, x.f) }))
|
|
94
|
+
.sort((a, b) => b.prefix - a.prefix || a.dist - b.dist || a.name.localeCompare(b.name))
|
|
95
|
+
.slice(0, 3)
|
|
96
|
+
.map((x) => x.name);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const byFold = (list, asked, nameOf = (x) => x.name) =>
|
|
100
|
+
list.find((x) => nameOf(x) === asked) || list.find((x) => composeFold(nameOf(x)) === composeFold(asked));
|
|
101
|
+
|
|
102
|
+
export function resolveScreen(description, inventory) {
|
|
103
|
+
const errors = [];
|
|
104
|
+
const fail = (kind, path, asked, nearest) =>
|
|
105
|
+
errors.push(nearest ? { kind, path, asked, nearest } : { kind, path, asked });
|
|
106
|
+
const inv = {
|
|
107
|
+
sets: [], components: [], variables: [], textStyles: [], collections: [], target: null,
|
|
108
|
+
...inventory,
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const d = description && typeof description === 'object' ? description : {};
|
|
112
|
+
if (typeof d.screen !== 'string' || !d.screen.trim()) fail('invalid', 'screen', 'a screen name');
|
|
113
|
+
if (typeof d.page !== 'string' || !d.page.trim()) fail('invalid', 'page', 'a page name');
|
|
114
|
+
if (!(typeof d.width === 'number' && d.width > 0)) fail('invalid', 'width', 'a positive number');
|
|
115
|
+
if (d.height !== undefined && !(typeof d.height === 'number' && d.height > 0)) fail('invalid', 'height', 'a positive number');
|
|
116
|
+
if (!d.root || typeof d.root !== 'object') fail('invalid', 'root', 'a frame');
|
|
117
|
+
|
|
118
|
+
if (inv.target && typeof d.screen === 'string') {
|
|
119
|
+
if (inv.target.screen === 'unmarked') fail('name-taken', 'screen', d.screen);
|
|
120
|
+
if (inv.target.wip === 'unmarked') fail('name-taken', 'screen', `WIP: ${d.screen}`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const modes = [];
|
|
124
|
+
for (const [collectionName, modeName] of Object.entries(d.modes || {})) {
|
|
125
|
+
const collection = byFold(inv.collections, collectionName);
|
|
126
|
+
const mode = collection && byFold(collection.modes, String(modeName));
|
|
127
|
+
if (!collection) fail('unknown-mode', `modes.${collectionName}`, collectionName, nearestNames(collectionName, inv.collections.map((c) => c.name)));
|
|
128
|
+
else if (!mode) fail('unknown-mode', `modes.${collectionName}`, String(modeName), nearestNames(String(modeName), collection.modes.map((m) => m.name)));
|
|
129
|
+
else modes.push({ collectionId: collection.id, modeId: mode.id });
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function variable(name, kind, path) {
|
|
133
|
+
if (name === undefined || name === null) return null;
|
|
134
|
+
if (typeof name !== 'string') { fail('invalid', path, `a variable name, not ${JSON.stringify(name)}`); return null; }
|
|
135
|
+
const found = byFold(inv.variables, name);
|
|
136
|
+
if (!found) {
|
|
137
|
+
fail('unknown-variable', path, name, nearestNames(name, inv.variables.filter((v) => v.kinds.includes(kind)).map((v) => v.name)));
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
if (!found.kinds.includes(kind)) { fail('variable-kind', path, `${name} is not a ${kind} variable`); return null; }
|
|
141
|
+
return found.id;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// `fallback` fills an axis the description leaves out: 'hug' for frames (a new
|
|
145
|
+
// frame is a fixed 100×100 otherwise), null (leave alone) for text and instances.
|
|
146
|
+
function sizing(node, path, parent, fallback, hugAllowed = true) {
|
|
147
|
+
const s = node.sizing === undefined ? {} : node.sizing;
|
|
148
|
+
if (!s || typeof s !== 'object') { fail('invalid', `${path}.sizing`, 'an object { horizontal, vertical }'); return null; }
|
|
149
|
+
const out = {};
|
|
150
|
+
for (const axis of ['horizontal', 'vertical']) {
|
|
151
|
+
const v = s[axis] === undefined ? fallback : s[axis];
|
|
152
|
+
if (v === null) continue;
|
|
153
|
+
if (!SIZING[v]) { fail('invalid', `${path}.sizing.${axis}`, `fill or hug, not ${JSON.stringify(v)}`); continue; }
|
|
154
|
+
if (v === 'hug' && !hugAllowed) {
|
|
155
|
+
fail('invalid', `${path}.sizing.${axis}`, 'hug on a component without auto layout');
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
if (v === 'fill' && parent.slotLayout === 'NONE') {
|
|
159
|
+
fail('invalid', `${path}.sizing.${axis}`, 'fill inside a slot without auto layout');
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
out[axis] = SIZING[v];
|
|
163
|
+
}
|
|
164
|
+
for (const key of Object.keys(s)) if (key !== 'horizontal' && key !== 'vertical') fail('invalid', `${path}.sizing.${key}`, 'horizontal or vertical');
|
|
165
|
+
return out;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function kindOf(node, path) {
|
|
169
|
+
if (!node || typeof node !== 'object' || Array.isArray(node)) { fail('invalid', path, 'a frame, component or text node'); return null; }
|
|
170
|
+
const isComponent = 'component' in node;
|
|
171
|
+
const isText = 'text' in node;
|
|
172
|
+
if (isComponent && isText) { fail('invalid', path, 'one of component or text, not both'); return null; }
|
|
173
|
+
const kind = isComponent ? 'component' : isText ? 'text' : 'frame';
|
|
174
|
+
for (const key of Object.keys(node)) if (!NODE_FIELDS[kind].includes(key)) fail('invalid', `${path}.${key}`, `a ${kind} field`);
|
|
175
|
+
return kind;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function frame(node, path, parent, isRoot) {
|
|
179
|
+
const layoutMode = LAYOUT[node.layout];
|
|
180
|
+
if (!layoutMode) fail('invalid', `${path}.layout`, `vertical or horizontal, not ${JSON.stringify(node.layout)}`);
|
|
181
|
+
let padding = null;
|
|
182
|
+
if (node.padding !== undefined) {
|
|
183
|
+
if (typeof node.padding === 'object' && node.padding !== null) {
|
|
184
|
+
padding = {};
|
|
185
|
+
for (const [side, name] of Object.entries(node.padding)) {
|
|
186
|
+
if (!SIDES.includes(side)) { fail('invalid', `${path}.padding.${side}`, 'top, right, bottom or left'); continue; }
|
|
187
|
+
padding[side] = variable(name, 'gap', `${path}.padding.${side}`);
|
|
188
|
+
}
|
|
189
|
+
} else {
|
|
190
|
+
const id = variable(node.padding, 'gap', `${path}.padding`);
|
|
191
|
+
padding = Object.fromEntries(SIDES.map((side) => [side, id]));
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
const align = node.align || {};
|
|
195
|
+
if (node.align !== undefined && (typeof node.align !== 'object' || node.align === null)) fail('invalid', `${path}.align`, 'an object { main, cross }');
|
|
196
|
+
if (align.main !== undefined && !ALIGN_MAIN[align.main]) fail('invalid', `${path}.align.main`, `start, center, end or space-between, not ${JSON.stringify(align.main)}`);
|
|
197
|
+
if (align.cross !== undefined && !ALIGN_CROSS[align.cross]) fail('invalid', `${path}.align.cross`, `start, center or end, not ${JSON.stringify(align.cross)}`);
|
|
198
|
+
if (isRoot && node.sizing !== undefined) fail('invalid', `${path}.sizing`, 'no sizing on the root; it takes width and height');
|
|
199
|
+
const children = node.children === undefined ? [] : node.children;
|
|
200
|
+
if (!Array.isArray(children)) fail('invalid', `${path}.children`, 'a list of nodes');
|
|
201
|
+
return {
|
|
202
|
+
type: 'frame',
|
|
203
|
+
name: typeof node.name === 'string' ? node.name : 'Stack',
|
|
204
|
+
layoutMode,
|
|
205
|
+
itemSpacing: variable(node.gap, 'gap', `${path}.gap`),
|
|
206
|
+
padding,
|
|
207
|
+
fill: variable(node.fill, 'colour', `${path}.fill`),
|
|
208
|
+
stroke: variable(node.stroke, 'colour', `${path}.stroke`),
|
|
209
|
+
radius: variable(node.radius, 'radius', `${path}.radius`),
|
|
210
|
+
primaryAxisAlignItems: ALIGN_MAIN[align.main] || 'MIN',
|
|
211
|
+
counterAxisAlignItems: ALIGN_CROSS[align.cross] || 'MIN',
|
|
212
|
+
sizing: isRoot ? null : sizing(node, path, parent, 'hug'),
|
|
213
|
+
children: Array.isArray(children)
|
|
214
|
+
? children.map((child, i) => resolveNode(child, `${path}.children[${i}]`, { slotLayout: null }))
|
|
215
|
+
: [],
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function component(node, path, parent) {
|
|
220
|
+
const set = byFold(inv.sets, String(node.component));
|
|
221
|
+
const sizingPlan = sizing(node, path, parent, null, !set || set.autoLayout !== false);
|
|
222
|
+
if (!set) {
|
|
223
|
+
fail('missing-component', `${path}.component`, String(node.component), nearestNames(String(node.component), inv.sets.map((s) => s.name)));
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
const findProp = (name) => set.props.find((p) => composeFold(propBaseName(p.key)) === composeFold(name));
|
|
227
|
+
const propNames = set.props.map((p) => propBaseName(p.key));
|
|
228
|
+
const variantProps = {};
|
|
229
|
+
const otherProps = {};
|
|
230
|
+
for (const [name, value] of Object.entries(node.props || {})) {
|
|
231
|
+
const at = `${path}.props.${name}`;
|
|
232
|
+
const prop = findProp(name);
|
|
233
|
+
if (!prop) { fail('unknown-prop', at, name, nearestNames(name, propNames)); continue; }
|
|
234
|
+
if (prop.type === 'SLOT') { fail('invalid', at, 'slot content goes under slots, not props'); continue; }
|
|
235
|
+
if (prop.type === 'VARIANT') {
|
|
236
|
+
const option = (prop.options || []).find((o) => composeFold(o) === composeFold(value));
|
|
237
|
+
if (option === undefined) fail('unknown-option', at, String(value), nearestNames(String(value), prop.options || []));
|
|
238
|
+
else variantProps[prop.key] = option;
|
|
239
|
+
} else if (prop.type === 'TEXT') {
|
|
240
|
+
if (typeof value !== 'string') fail('prop-type', at, `text, not ${JSON.stringify(value)}`);
|
|
241
|
+
else otherProps[prop.key] = value;
|
|
242
|
+
} else if (prop.type === 'BOOLEAN') {
|
|
243
|
+
if (typeof value !== 'boolean') fail('prop-type', at, `true or false, not ${JSON.stringify(value)}`);
|
|
244
|
+
else otherProps[prop.key] = value;
|
|
245
|
+
} else if (prop.type === 'INSTANCE_SWAP') {
|
|
246
|
+
const target = typeof value === 'string' && byFold(inv.components, value);
|
|
247
|
+
if (!target) fail('missing-swap-target', at, String(value), nearestNames(String(value), inv.components.map((c) => c.name)));
|
|
248
|
+
else otherProps[prop.key] = target.id;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
const slots = [];
|
|
252
|
+
for (const [name, nodes] of Object.entries(node.slots || {})) {
|
|
253
|
+
const at = `${path}.slots.${name}`;
|
|
254
|
+
const prop = findProp(name);
|
|
255
|
+
if (!prop || prop.type !== 'SLOT') {
|
|
256
|
+
fail('unknown-prop', at, name, nearestNames(name, set.props.filter((p) => p.type === 'SLOT').map((p) => propBaseName(p.key))));
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
if (!Array.isArray(nodes)) { fail('invalid', at, 'a list of nodes'); continue; }
|
|
260
|
+
slots.push({
|
|
261
|
+
key: prop.key,
|
|
262
|
+
nodes: nodes.map((child, i) => resolveNode(child, `${at}[${i}]`, { slotLayout: prop.slotLayout || 'NONE' })),
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
return {
|
|
266
|
+
type: 'component',
|
|
267
|
+
name: typeof node.name === 'string' ? node.name : set.name,
|
|
268
|
+
setId: set.id,
|
|
269
|
+
variantProps,
|
|
270
|
+
otherProps,
|
|
271
|
+
slots,
|
|
272
|
+
sizing: sizingPlan,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function text(node, path, parent) {
|
|
277
|
+
if (typeof node.text !== 'string') fail('invalid', `${path}.text`, 'the text to show');
|
|
278
|
+
let styleId = null;
|
|
279
|
+
if (typeof node.style !== 'string') fail('invalid', `${path}.style`, 'a text style name');
|
|
280
|
+
else {
|
|
281
|
+
const style = byFold(inv.textStyles, node.style);
|
|
282
|
+
if (!style) fail('unknown-text-style', `${path}.style`, node.style, nearestNames(node.style, inv.textStyles.map((s) => s.name)));
|
|
283
|
+
else styleId = style.id;
|
|
284
|
+
}
|
|
285
|
+
if (node.color === undefined) fail('invalid', `${path}.color`, 'a colour variable name');
|
|
286
|
+
return {
|
|
287
|
+
type: 'text',
|
|
288
|
+
name: typeof node.name === 'string' ? node.name : String(node.text).slice(0, 40),
|
|
289
|
+
characters: node.text,
|
|
290
|
+
styleId,
|
|
291
|
+
color: variable(node.color, 'colour', `${path}.color`),
|
|
292
|
+
sizing: sizing(node, path, parent, null),
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function resolveNode(node, path, parent) {
|
|
297
|
+
const kind = kindOf(node, path);
|
|
298
|
+
if (kind === 'component') return component(node, path, parent);
|
|
299
|
+
if (kind === 'text') return text(node, path, parent);
|
|
300
|
+
if (kind === 'frame') return frame(node, path, parent, false);
|
|
301
|
+
return null;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
let root = null;
|
|
305
|
+
if (d.root && typeof d.root === 'object') {
|
|
306
|
+
const kind = kindOf(d.root, 'root');
|
|
307
|
+
if (kind === 'frame') root = frame(d.root, 'root', { slotLayout: null }, true);
|
|
308
|
+
else if (kind) fail('invalid', 'root', 'a frame');
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const ok = errors.length === 0;
|
|
312
|
+
return {
|
|
313
|
+
ok,
|
|
314
|
+
errors,
|
|
315
|
+
plan: ok ? { screen: d.screen, page: d.page, width: d.width, height: d.height ?? null, modes, root } : null,
|
|
316
|
+
};
|
|
317
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
{
|
|
2
|
+
"covers": "set, prop and option names match case-folded and without the #id suffix",
|
|
3
|
+
"description": { "screen": "Menu", "page": "Screens", "width": 390, "root": { "layout": "vertical", "children": [
|
|
4
|
+
{ "component": "select menu item", "props": { "state": "SELECTED", "label": "One" } },
|
|
5
|
+
{ "component": "BUTTON", "props": { "State": "default", "LABEL": "Go" } }
|
|
6
|
+
] } },
|
|
7
|
+
"expected": { "ok": true, "errors": [] }
|
|
8
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"covers": "fill inside a slot with no auto layout would throw in Figma, so it is rejected first",
|
|
3
|
+
"description": { "screen": "X", "page": "Screens", "width": 390, "root": { "layout": "vertical", "children": [
|
|
4
|
+
{ "component": "Sheet", "slots": { "Content": [ { "layout": "vertical", "sizing": { "horizontal": "fill" } } ] } }
|
|
5
|
+
] } },
|
|
6
|
+
"expected": { "ok": false, "errors": ["invalid"] }
|
|
7
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"covers": "hug on a component without auto layout would throw in Figma, so it is rejected; fill is fine",
|
|
3
|
+
"description": {
|
|
4
|
+
"screen": "X",
|
|
5
|
+
"page": "Screens",
|
|
6
|
+
"width": 390,
|
|
7
|
+
"root": {
|
|
8
|
+
"layout": "vertical",
|
|
9
|
+
"children": [
|
|
10
|
+
{
|
|
11
|
+
"component": "Spinner",
|
|
12
|
+
"sizing": {
|
|
13
|
+
"horizontal": "hug"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"component": "Spinner",
|
|
18
|
+
"sizing": {
|
|
19
|
+
"horizontal": "fill"
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"component": "Spinner"
|
|
24
|
+
}
|
|
25
|
+
]
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
"expected": {
|
|
29
|
+
"ok": false,
|
|
30
|
+
"errors": [
|
|
31
|
+
"invalid"
|
|
32
|
+
]
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
{
|
|
2
|
+
"covers": "the shared inventory every case resolves against, modelled on the sample file (2026-10-13)",
|
|
3
|
+
"sets": [
|
|
4
|
+
{
|
|
5
|
+
"name": "Button",
|
|
6
|
+
"id": "10:1",
|
|
7
|
+
"page": "Components",
|
|
8
|
+
"props": [
|
|
9
|
+
{
|
|
10
|
+
"key": "label#10:273",
|
|
11
|
+
"name": "label",
|
|
12
|
+
"type": "TEXT"
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"key": "hasTrailing#10:455",
|
|
16
|
+
"name": "hasTrailing",
|
|
17
|
+
"type": "BOOLEAN"
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"key": "trailingIcon#11:91",
|
|
21
|
+
"name": "trailingIcon",
|
|
22
|
+
"type": "INSTANCE_SWAP"
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"key": "variant",
|
|
26
|
+
"name": "variant",
|
|
27
|
+
"type": "VARIANT",
|
|
28
|
+
"options": [
|
|
29
|
+
"default",
|
|
30
|
+
"secondary",
|
|
31
|
+
"outline",
|
|
32
|
+
"ghost",
|
|
33
|
+
"link",
|
|
34
|
+
"destructive"
|
|
35
|
+
]
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
"key": "size",
|
|
39
|
+
"name": "size",
|
|
40
|
+
"type": "VARIANT",
|
|
41
|
+
"options": [
|
|
42
|
+
"sm",
|
|
43
|
+
"md",
|
|
44
|
+
"lg"
|
|
45
|
+
]
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
"key": "state",
|
|
49
|
+
"name": "state",
|
|
50
|
+
"type": "VARIANT",
|
|
51
|
+
"options": [
|
|
52
|
+
"Default",
|
|
53
|
+
"Hover",
|
|
54
|
+
"Focus",
|
|
55
|
+
"Disabled",
|
|
56
|
+
"Loading",
|
|
57
|
+
"Active"
|
|
58
|
+
]
|
|
59
|
+
}
|
|
60
|
+
]
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
"name": "Input",
|
|
64
|
+
"id": "12:1",
|
|
65
|
+
"page": "Components",
|
|
66
|
+
"props": [
|
|
67
|
+
{
|
|
68
|
+
"key": "placeholder#12:200",
|
|
69
|
+
"name": "placeholder",
|
|
70
|
+
"type": "TEXT"
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
"key": "size",
|
|
74
|
+
"name": "size",
|
|
75
|
+
"type": "VARIANT",
|
|
76
|
+
"options": [
|
|
77
|
+
"sm",
|
|
78
|
+
"md",
|
|
79
|
+
"lg"
|
|
80
|
+
]
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
"key": "state",
|
|
84
|
+
"name": "state",
|
|
85
|
+
"type": "VARIANT",
|
|
86
|
+
"options": [
|
|
87
|
+
"Default",
|
|
88
|
+
"Focus",
|
|
89
|
+
"Disabled",
|
|
90
|
+
"Error",
|
|
91
|
+
"Hover",
|
|
92
|
+
"Active"
|
|
93
|
+
]
|
|
94
|
+
}
|
|
95
|
+
]
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
"name": "Checkbox",
|
|
99
|
+
"id": "13:1",
|
|
100
|
+
"page": "Components",
|
|
101
|
+
"props": [
|
|
102
|
+
{
|
|
103
|
+
"key": "state",
|
|
104
|
+
"name": "state",
|
|
105
|
+
"type": "VARIANT",
|
|
106
|
+
"options": [
|
|
107
|
+
"Disabled",
|
|
108
|
+
"Default",
|
|
109
|
+
"Hover",
|
|
110
|
+
"Focus",
|
|
111
|
+
"Active"
|
|
112
|
+
]
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
"key": "checked",
|
|
116
|
+
"name": "checked",
|
|
117
|
+
"type": "VARIANT",
|
|
118
|
+
"options": [
|
|
119
|
+
"false",
|
|
120
|
+
"true",
|
|
121
|
+
"indeterminate"
|
|
122
|
+
]
|
|
123
|
+
}
|
|
124
|
+
]
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
"name": "Select Menu Item",
|
|
128
|
+
"id": "23:1",
|
|
129
|
+
"page": "Components",
|
|
130
|
+
"props": [
|
|
131
|
+
{
|
|
132
|
+
"key": "Label#23:242",
|
|
133
|
+
"name": "Label",
|
|
134
|
+
"type": "TEXT"
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
"key": "State",
|
|
138
|
+
"name": "State",
|
|
139
|
+
"type": "VARIANT",
|
|
140
|
+
"options": [
|
|
141
|
+
"Default",
|
|
142
|
+
"Hover",
|
|
143
|
+
"Focused",
|
|
144
|
+
"Selected",
|
|
145
|
+
"Disabled"
|
|
146
|
+
]
|
|
147
|
+
}
|
|
148
|
+
]
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
"name": "Panel",
|
|
152
|
+
"id": "40:1",
|
|
153
|
+
"page": "Components",
|
|
154
|
+
"props": [
|
|
155
|
+
{
|
|
156
|
+
"key": "Body#40:2",
|
|
157
|
+
"name": "Body",
|
|
158
|
+
"type": "SLOT",
|
|
159
|
+
"slotLayout": "VERTICAL"
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
"key": "title#40:3",
|
|
163
|
+
"name": "title",
|
|
164
|
+
"type": "TEXT"
|
|
165
|
+
}
|
|
166
|
+
]
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
"name": "Sheet",
|
|
170
|
+
"id": "41:1",
|
|
171
|
+
"page": "Components",
|
|
172
|
+
"props": [
|
|
173
|
+
{
|
|
174
|
+
"key": "Content#41:2",
|
|
175
|
+
"name": "Content",
|
|
176
|
+
"type": "SLOT",
|
|
177
|
+
"slotLayout": "NONE"
|
|
178
|
+
}
|
|
179
|
+
]
|
|
180
|
+
},
|
|
181
|
+
{
|
|
182
|
+
"name": "Spinner",
|
|
183
|
+
"id": "30:1",
|
|
184
|
+
"page": "Components",
|
|
185
|
+
"autoLayout": false,
|
|
186
|
+
"props": [
|
|
187
|
+
{
|
|
188
|
+
"key": "size",
|
|
189
|
+
"name": "size",
|
|
190
|
+
"type": "VARIANT",
|
|
191
|
+
"options": [
|
|
192
|
+
"sm",
|
|
193
|
+
"md",
|
|
194
|
+
"lg"
|
|
195
|
+
]
|
|
196
|
+
}
|
|
197
|
+
]
|
|
198
|
+
}
|
|
199
|
+
],
|
|
200
|
+
"components": [
|
|
201
|
+
{
|
|
202
|
+
"name": "icon/arrow-right",
|
|
203
|
+
"id": "6:553",
|
|
204
|
+
"page": "Icons"
|
|
205
|
+
},
|
|
206
|
+
{
|
|
207
|
+
"name": "icon/check",
|
|
208
|
+
"id": "6:560",
|
|
209
|
+
"page": "Icons"
|
|
210
|
+
}
|
|
211
|
+
],
|
|
212
|
+
"variables": [
|
|
213
|
+
{
|
|
214
|
+
"name": "color/bg/canvas",
|
|
215
|
+
"id": "V:1",
|
|
216
|
+
"kinds": [
|
|
217
|
+
"colour"
|
|
218
|
+
]
|
|
219
|
+
},
|
|
220
|
+
{
|
|
221
|
+
"name": "color/text/primary",
|
|
222
|
+
"id": "V:2",
|
|
223
|
+
"kinds": [
|
|
224
|
+
"colour"
|
|
225
|
+
]
|
|
226
|
+
},
|
|
227
|
+
{
|
|
228
|
+
"name": "color/border/default",
|
|
229
|
+
"id": "V:3",
|
|
230
|
+
"kinds": [
|
|
231
|
+
"colour"
|
|
232
|
+
]
|
|
233
|
+
},
|
|
234
|
+
{
|
|
235
|
+
"name": "space/gap/md",
|
|
236
|
+
"id": "V:4",
|
|
237
|
+
"kinds": [
|
|
238
|
+
"gap"
|
|
239
|
+
]
|
|
240
|
+
},
|
|
241
|
+
{
|
|
242
|
+
"name": "space/inset/lg",
|
|
243
|
+
"id": "V:5",
|
|
244
|
+
"kinds": [
|
|
245
|
+
"gap"
|
|
246
|
+
]
|
|
247
|
+
},
|
|
248
|
+
{
|
|
249
|
+
"name": "space/4",
|
|
250
|
+
"id": "V:6",
|
|
251
|
+
"kinds": [
|
|
252
|
+
"gap"
|
|
253
|
+
]
|
|
254
|
+
},
|
|
255
|
+
{
|
|
256
|
+
"name": "radius/md",
|
|
257
|
+
"id": "V:7",
|
|
258
|
+
"kinds": [
|
|
259
|
+
"radius"
|
|
260
|
+
]
|
|
261
|
+
},
|
|
262
|
+
{
|
|
263
|
+
"name": "font/size/400",
|
|
264
|
+
"id": "V:8",
|
|
265
|
+
"kinds": []
|
|
266
|
+
}
|
|
267
|
+
],
|
|
268
|
+
"textStyles": [
|
|
269
|
+
{
|
|
270
|
+
"name": "Heading/L",
|
|
271
|
+
"id": "S:1"
|
|
272
|
+
},
|
|
273
|
+
{
|
|
274
|
+
"name": "Body/Default",
|
|
275
|
+
"id": "S:2"
|
|
276
|
+
}
|
|
277
|
+
],
|
|
278
|
+
"collections": [
|
|
279
|
+
{
|
|
280
|
+
"name": "Color / Semantic",
|
|
281
|
+
"id": "C:1",
|
|
282
|
+
"modes": [
|
|
283
|
+
{
|
|
284
|
+
"name": "Dark",
|
|
285
|
+
"id": "M:1"
|
|
286
|
+
},
|
|
287
|
+
{
|
|
288
|
+
"name": "Light",
|
|
289
|
+
"id": "M:2"
|
|
290
|
+
}
|
|
291
|
+
]
|
|
292
|
+
}
|
|
293
|
+
],
|
|
294
|
+
"target": null
|
|
295
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
{
|
|
2
|
+
"covers": "every problem is reported in one pass",
|
|
3
|
+
"description": { "screen": "X", "page": "Screens", "width": 390, "modes": { "Color / Semantic": "Sepia" }, "root": { "layout": "diagonal", "children": [
|
|
4
|
+
{ "text": "Hi", "style": "Heading/XXL", "color": "color/text/primary" },
|
|
5
|
+
{ "component": "Carousel" }
|
|
6
|
+
] } },
|
|
7
|
+
"expected": { "ok": false, "errors": ["unknown-mode", "invalid", "unknown-text-style", "missing-component"] }
|
|
8
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"covers": "a component the file lacks is a gap, with the nearest names",
|
|
3
|
+
"description": { "screen": "Booking", "page": "Screens", "width": 390, "root": { "layout": "vertical", "children": [
|
|
4
|
+
{ "component": "DatePicker" }
|
|
5
|
+
] } },
|
|
6
|
+
"expected": { "ok": false, "errors": ["missing-component"] }
|
|
7
|
+
}
|