baldart 4.76.1 → 4.78.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/CHANGELOG.md +42 -0
- package/README.md +14 -13
- package/VERSION +1 -1
- package/framework/.claude/agents/REGISTRY.md +2 -1
- package/framework/.claude/agents/markup-fidelity-verifier.md +239 -0
- package/framework/.claude/agents/prd-card-writer.md +6 -1
- package/framework/.claude/agents/visual-fidelity-verifier.md +22 -0
- package/framework/.claude/skills/design-system-init/SKILL.md +16 -6
- package/framework/.claude/skills/design-system-init/scripts/compile-ds-cards.mjs +2 -1
- package/framework/.claude/skills/design-system-init/scripts/extract-manifest.mjs +17 -5
- package/framework/.claude/skills/design-system-init/scripts/extract-ts.mjs +302 -0
- package/framework/.claude/skills/design-system-init/scripts/render-manifest.mjs +26 -9
- package/framework/.claude/skills/design-system-init/scripts/serialize-spec.mjs +4 -2
- package/framework/.claude/skills/ds-edit/SKILL.md +2 -1
- package/framework/.claude/skills/ds-new/SKILL.md +2 -2
- package/framework/.claude/skills/e2e-review/SKILL.md +196 -27
- package/framework/.claude/skills/new/references/codex-gate.md +15 -1
- package/framework/.claude/skills/new/references/completeness.md +2 -0
- package/framework/.claude/skills/new/references/review-cycle.md +14 -1
- package/framework/agents/component-manifest-schema.md +24 -3
- package/framework/agents/design-system-protocol.md +79 -1
- package/framework/docs/COMPONENT-MANIFEST-LAYER.md +6 -3
- package/framework/templates/component-spec.template.md +3 -2
- package/framework/templates/overlays/e2e-review.fidelity-example.md +74 -0
- package/package.json +1 -1
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// TS-aware component-manifest extraction (since v4.77.0).
|
|
3
|
+
//
|
|
4
|
+
// OPTIONAL companion to the zero-dep regex extractor (extract-manifest.mjs). It
|
|
5
|
+
// resolves TypeScript type aliases / unions / intersections / Omit / Pick in a
|
|
6
|
+
// component's prop type — the cases the pure-regex path cannot see
|
|
7
|
+
// (`type Variant = 'a'|'b'; variant: Variant` captures the alias NAME, not its
|
|
8
|
+
// members). The fix is SYNTACTIC: it parses the file with `ts.createSourceFile`
|
|
9
|
+
// (no Program / TypeChecker / tsconfig — fast, in-file, deterministic) and walks
|
|
10
|
+
// the AST. Opaque / imported types (`ComponentPropsWithoutRef<'button'>`,
|
|
11
|
+
// `HTMLAttributes`) stay EMPTY — we never dump DOM attributes.
|
|
12
|
+
//
|
|
13
|
+
// Reliability contract (the whole reason this is a separate, probed module):
|
|
14
|
+
// - `typescript` is the CONSUMER's dependency, not BALDART's. It is resolved
|
|
15
|
+
// from `process.cwd()` (the repo root the extractor runs against), NOT from
|
|
16
|
+
// this script's location inside `.framework/` where it is absent.
|
|
17
|
+
// - When typescript is unavailable (Codex / greenfield / non-TS stack) OR the
|
|
18
|
+
// file fails to parse/traverse, `extractPropsTs` returns `null` and the
|
|
19
|
+
// caller falls back to the regex path for THAT file — zero regression.
|
|
20
|
+
// - `computeVariantProp` / `pureStringLiteralUnion` / `literalMembers` carry NO
|
|
21
|
+
// typescript dependency and are SHARED with the regex path, so the
|
|
22
|
+
// variant-axis ranking rule is byte-identical on both paths (determinism).
|
|
23
|
+
//
|
|
24
|
+
// This module imports NOTHING from extract-manifest.mjs (one-way, no cycle).
|
|
25
|
+
// Schema: framework/agents/component-manifest-schema.md.
|
|
26
|
+
|
|
27
|
+
import { createRequire } from 'node:module';
|
|
28
|
+
|
|
29
|
+
const require = createRequire(import.meta.url);
|
|
30
|
+
|
|
31
|
+
// --- typescript probe: resolve from the CONSUMER root (process.cwd()), not from
|
|
32
|
+
// this script's location inside .framework/ (where typescript is not installed). ---
|
|
33
|
+
let ts = null;
|
|
34
|
+
try {
|
|
35
|
+
ts = require(require.resolve('typescript', { paths: [process.cwd()] }));
|
|
36
|
+
} catch (_) {
|
|
37
|
+
ts = null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** True when the consumer has `typescript` resolvable; else the TS path no-ops. */
|
|
41
|
+
export const TS_AVAILABLE = !!ts;
|
|
42
|
+
|
|
43
|
+
// === Shared, typescript-FREE helpers (used by BOTH the TS and regex paths) ===
|
|
44
|
+
|
|
45
|
+
/** Variant-axis ranking: which prop name wins when several are literal unions. */
|
|
46
|
+
const RANK = ['variant', 'tone', 'intent', 'kind', 'appearance', 'state', 'status', 'color', 'severity'];
|
|
47
|
+
|
|
48
|
+
/** A type text that is a pure union (or single) of string literals: `'a' | 'b'`. */
|
|
49
|
+
export function pureStringLiteralUnion(typeText) {
|
|
50
|
+
if (!typeText) return false;
|
|
51
|
+
const parts = String(typeText).split('|').map((s) => s.trim()).filter(Boolean);
|
|
52
|
+
if (!parts.length) return false;
|
|
53
|
+
return parts.every((p) => /^'[^']*'$/.test(p) || /^"[^"]*"$/.test(p));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The string-literal members of a union type text, in source order. */
|
|
57
|
+
export function literalMembers(typeText) {
|
|
58
|
+
if (!typeText) return [];
|
|
59
|
+
return [...String(typeText).matchAll(/'([^']*)'|"([^"]*)"/g)].map((m) => (m[1] !== undefined ? m[1] : m[2]));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The variant-axis prop name for a props map. A candidate is any prop whose
|
|
64
|
+
* resolved type is a pure string-literal union; the winner is the first by RANK,
|
|
65
|
+
* else the sole candidate, else '' (no resolvable axis). This is the single rule
|
|
66
|
+
* shared by the TS and regex paths — never fork it.
|
|
67
|
+
*/
|
|
68
|
+
export function computeVariantProp(props) {
|
|
69
|
+
if (!props || typeof props !== 'object') return '';
|
|
70
|
+
const candidates = Object.keys(props).filter((k) => pureStringLiteralUnion(props[k] && props[k].type));
|
|
71
|
+
if (!candidates.length) return '';
|
|
72
|
+
for (const r of RANK) if (candidates.includes(r)) return r;
|
|
73
|
+
return candidates.length === 1 ? candidates[0] : '';
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// === TS path (no-ops entirely when typescript is unavailable) ===
|
|
77
|
+
|
|
78
|
+
const MAX_DEPTH = 8; // alias-recursion cap (with `seen`, prevents pathological/cyclic blowups)
|
|
79
|
+
|
|
80
|
+
/** Map of top-level type aliases + interfaces declared IN this file (by name). */
|
|
81
|
+
function buildDeclMap(sf) {
|
|
82
|
+
const map = new Map();
|
|
83
|
+
for (const st of sf.statements) {
|
|
84
|
+
if ((ts.isTypeAliasDeclaration(st) || ts.isInterfaceDeclaration(st)) && st.name) {
|
|
85
|
+
map.set(st.name.text, st);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return map;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function paramTypeOfFunction(fn) {
|
|
92
|
+
return (fn.parameters && fn.parameters[0] && fn.parameters[0].type) || null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** React.FC<P> / FC<P> / FunctionComponent<P> → the first type argument (P). */
|
|
96
|
+
function propsFromComponentTypeAnnotation(typeNode) {
|
|
97
|
+
if (!ts.isTypeReferenceNode(typeNode) || !typeNode.typeArguments || !typeNode.typeArguments.length) return null;
|
|
98
|
+
const tn = typeNode.typeName;
|
|
99
|
+
const simple = ts.isQualifiedName(tn) ? tn.right.text : tn.text;
|
|
100
|
+
if (simple === 'FC' || simple === 'FunctionComponent') return typeNode.typeArguments[0];
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Locate the exported component `name` and return the TypeNode of its props. */
|
|
105
|
+
function findComponentParamType(sf, name) {
|
|
106
|
+
let fallback = null;
|
|
107
|
+
for (const st of sf.statements) {
|
|
108
|
+
// function Name(props: T) / export [default] function Name(props: T)
|
|
109
|
+
if (ts.isFunctionDeclaration(st)) {
|
|
110
|
+
const isDefault = st.modifiers && st.modifiers.some((m) => m.kind === ts.SyntaxKind.DefaultKeyword);
|
|
111
|
+
if ((st.name && st.name.text === name) || (!st.name && isDefault)) {
|
|
112
|
+
const t = paramTypeOfFunction(st);
|
|
113
|
+
if (t) return t;
|
|
114
|
+
}
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
// export default Name; (anonymous arrow assigned to const handled below)
|
|
118
|
+
if (ts.isVariableStatement(st)) {
|
|
119
|
+
for (const decl of st.declarationList.declarations) {
|
|
120
|
+
if (!decl.name || !ts.isIdentifier(decl.name) || decl.name.text !== name) continue;
|
|
121
|
+
// const Name: React.FC<P> = ...
|
|
122
|
+
if (decl.type) {
|
|
123
|
+
const t = propsFromComponentTypeAnnotation(decl.type);
|
|
124
|
+
if (t) return t;
|
|
125
|
+
}
|
|
126
|
+
const init = decl.initializer;
|
|
127
|
+
if (!init) continue;
|
|
128
|
+
// forwardRef<Ref, Props>(...) / memo<Props>(...)
|
|
129
|
+
if (ts.isCallExpression(init)) {
|
|
130
|
+
const callee = init.expression;
|
|
131
|
+
const calleeName = ts.isPropertyAccessExpression(callee)
|
|
132
|
+
? callee.name.text
|
|
133
|
+
: (ts.isIdentifier(callee) ? callee.text : '');
|
|
134
|
+
if (init.typeArguments && init.typeArguments.length) {
|
|
135
|
+
if (calleeName === 'forwardRef' && init.typeArguments[1]) return init.typeArguments[1];
|
|
136
|
+
if (calleeName === 'memo' && init.typeArguments[0]) return init.typeArguments[0];
|
|
137
|
+
}
|
|
138
|
+
// unwrap the first function argument (forwardRef/memo wrapping a component fn)
|
|
139
|
+
const fn = init.arguments && init.arguments[0];
|
|
140
|
+
if (fn && (ts.isArrowFunction(fn) || ts.isFunctionExpression(fn))) {
|
|
141
|
+
const t = paramTypeOfFunction(fn);
|
|
142
|
+
if (t) return t;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (ts.isArrowFunction(init) || ts.isFunctionExpression(init)) {
|
|
146
|
+
const t = paramTypeOfFunction(init);
|
|
147
|
+
if (t) return t;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return fallback;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* If a prop's type is (via single-file type aliases) a PURE string-literal union,
|
|
157
|
+
* return its expanded text (`'a' | 'b'`); else null. This is what makes
|
|
158
|
+
* `variant: Variant` (where `type Variant = 'a'|'b'`) resolve to the members — the
|
|
159
|
+
* core alias-resolution the regex path cannot do. Interfaces can't be a literal
|
|
160
|
+
* union, so only type-alias references are followed.
|
|
161
|
+
*/
|
|
162
|
+
function unionLiteralText(node, ctx, seen) {
|
|
163
|
+
const { declMap } = ctx;
|
|
164
|
+
if (!node) return null;
|
|
165
|
+
if (ts.isParenthesizedTypeNode(node)) return unionLiteralText(node.type, ctx, seen);
|
|
166
|
+
if (ts.isLiteralTypeNode(node) && node.literal && ts.isStringLiteral(node.literal)) return `'${node.literal.text}'`;
|
|
167
|
+
if (ts.isUnionTypeNode(node)) {
|
|
168
|
+
const parts = node.types.map((t) => unionLiteralText(t, ctx, seen));
|
|
169
|
+
return parts.every((p) => p !== null) ? parts.join(' | ') : null;
|
|
170
|
+
}
|
|
171
|
+
if (ts.isTypeReferenceNode(node)) {
|
|
172
|
+
const tn = node.typeName;
|
|
173
|
+
const simple = ts.isQualifiedName(tn) ? tn.right.text : tn.text;
|
|
174
|
+
if (declMap.has(simple) && !seen.has(simple)) {
|
|
175
|
+
const decl = declMap.get(simple);
|
|
176
|
+
if (ts.isTypeAliasDeclaration(decl)) {
|
|
177
|
+
const next = new Set(seen); next.add(simple);
|
|
178
|
+
return unionLiteralText(decl.type, ctx, next);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** PropertySignature members of a TypeLiteral / InterfaceDeclaration body. */
|
|
186
|
+
function membersOf(node, ctx) {
|
|
187
|
+
const { sf } = ctx;
|
|
188
|
+
const out = {};
|
|
189
|
+
const members = node.members || [];
|
|
190
|
+
for (const m of members) {
|
|
191
|
+
if (!ts.isPropertySignature(m) || !m.name) continue;
|
|
192
|
+
const pname = (ts.isIdentifier(m.name) || ts.isStringLiteral(m.name)) ? m.name.text : m.name.getText(sf);
|
|
193
|
+
// Resolve aliased literal unions to their members; else keep the raw type text.
|
|
194
|
+
const resolved = m.type ? unionLiteralText(m.type, ctx, new Set()) : null;
|
|
195
|
+
const type = resolved || (m.type ? m.type.getText(sf).replace(/\s+/g, ' ').trim() : 'any');
|
|
196
|
+
out[pname] = { type, required: !m.questionToken };
|
|
197
|
+
}
|
|
198
|
+
return out;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** String-literal keys named by an Omit/Pick second argument (`'a'` or `'a'|'b'`). */
|
|
202
|
+
function extractKeyLiterals(node) {
|
|
203
|
+
if (!node) return [];
|
|
204
|
+
if (ts.isLiteralTypeNode(node) && node.literal && ts.isStringLiteral(node.literal)) return [node.literal.text];
|
|
205
|
+
if (ts.isUnionTypeNode(node)) return node.types.flatMap((t) => extractKeyLiterals(t));
|
|
206
|
+
return [];
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Resolve a TypeNode to `{ [prop]: { type, required } }` — syntactic, in-file only. */
|
|
210
|
+
function resolveType(node, ctx, depth, seen) {
|
|
211
|
+
const { declMap } = ctx;
|
|
212
|
+
if (!node || depth > MAX_DEPTH) return {};
|
|
213
|
+
if (ts.isParenthesizedTypeNode(node)) return resolveType(node.type, ctx, depth + 1, seen);
|
|
214
|
+
|
|
215
|
+
if (ts.isInterfaceDeclaration(node)) {
|
|
216
|
+
const out = {};
|
|
217
|
+
// `extends` heritage first (own members override below).
|
|
218
|
+
for (const hc of node.heritageClauses || []) {
|
|
219
|
+
for (const et of hc.types || []) {
|
|
220
|
+
const exprName = et.expression && et.expression.text;
|
|
221
|
+
if (exprName && declMap.has(exprName) && !seen.has(exprName)) {
|
|
222
|
+
const next = new Set(seen); next.add(exprName);
|
|
223
|
+
Object.assign(out, resolveType(declMap.get(exprName), ctx, depth + 1, next));
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
Object.assign(out, membersOf(node, ctx));
|
|
228
|
+
return out;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (ts.isTypeLiteralNode(node)) return membersOf(node, ctx);
|
|
232
|
+
|
|
233
|
+
if (ts.isIntersectionTypeNode(node)) {
|
|
234
|
+
const out = {};
|
|
235
|
+
for (const t of node.types) {
|
|
236
|
+
const m = resolveType(t, ctx, depth + 1, seen);
|
|
237
|
+
for (const k of Object.keys(m)) {
|
|
238
|
+
out[k] = out[k] ? { type: out[k].type, required: out[k].required || m[k].required } : m[k];
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return out;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (ts.isUnionTypeNode(node)) {
|
|
245
|
+
const arms = node.types.map((t) => resolveType(t, ctx, depth + 1, seen)).filter((m) => Object.keys(m).length);
|
|
246
|
+
if (!arms.length) return {};
|
|
247
|
+
const out = {};
|
|
248
|
+
for (const k of new Set(arms.flatMap((m) => Object.keys(m)))) {
|
|
249
|
+
const present = arms.filter((m) => m[k]);
|
|
250
|
+
const inAll = present.length === arms.length;
|
|
251
|
+
const requiredInAll = inAll && present.every((m) => m[k].required);
|
|
252
|
+
out[k] = { type: present[0][k].type, required: requiredInAll };
|
|
253
|
+
}
|
|
254
|
+
return out;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (ts.isTypeReferenceNode(node)) {
|
|
258
|
+
const tn = node.typeName;
|
|
259
|
+
const simple = ts.isQualifiedName(tn) ? tn.right.text : tn.text;
|
|
260
|
+
if ((simple === 'Omit' || simple === 'Pick') && node.typeArguments && node.typeArguments.length >= 2) {
|
|
261
|
+
const base = resolveType(node.typeArguments[0], ctx, depth + 1, seen);
|
|
262
|
+
const keys = new Set(extractKeyLiterals(node.typeArguments[1]));
|
|
263
|
+
const out = {};
|
|
264
|
+
for (const k of Object.keys(base)) {
|
|
265
|
+
const inSet = keys.has(k);
|
|
266
|
+
if (simple === 'Omit' ? !inSet : inSet) out[k] = base[k];
|
|
267
|
+
}
|
|
268
|
+
return out;
|
|
269
|
+
}
|
|
270
|
+
if (declMap.has(simple) && !seen.has(simple)) {
|
|
271
|
+
const next = new Set(seen); next.add(simple);
|
|
272
|
+
const decl = declMap.get(simple);
|
|
273
|
+
const target = ts.isInterfaceDeclaration(decl) ? decl : decl.type;
|
|
274
|
+
return resolveType(target, ctx, depth + 1, next);
|
|
275
|
+
}
|
|
276
|
+
return {}; // opaque / imported — never enumerate (no DOM-attr dump)
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return {};
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* TS-aware extraction for ONE component. Returns `{ props, variantProp, variants }`
|
|
284
|
+
* on success, or `null` to signal the caller to use the regex path for this file
|
|
285
|
+
* (typescript unavailable, no resolvable param type, or any parse/traversal error).
|
|
286
|
+
*/
|
|
287
|
+
export function extractPropsTs(src, name, fileName) {
|
|
288
|
+
if (!ts) return null;
|
|
289
|
+
try {
|
|
290
|
+
const kind = /\.tsx$/.test(fileName || '') ? ts.ScriptKind.TSX : ts.ScriptKind.TS;
|
|
291
|
+
const sf = ts.createSourceFile(fileName || 'component.tsx', src, ts.ScriptTarget.Latest, true, kind);
|
|
292
|
+
const typeNode = findComponentParamType(sf, name);
|
|
293
|
+
if (!typeNode) return null; // untyped param → let regex try the `${name}Props` convention
|
|
294
|
+
const props = resolveType(typeNode, { sf, declMap: buildDeclMap(sf) }, 0, new Set());
|
|
295
|
+
if (!props || typeof props !== 'object') return null;
|
|
296
|
+
const variantProp = computeVariantProp(props);
|
|
297
|
+
const variants = variantProp ? literalMembers(props[variantProp].type) : [];
|
|
298
|
+
return { props, variantProp, variants };
|
|
299
|
+
} catch (_) {
|
|
300
|
+
return null;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
@@ -45,13 +45,27 @@ function parseHead(md) {
|
|
|
45
45
|
if (!name) return null;
|
|
46
46
|
const source = scalar('source');
|
|
47
47
|
|
|
48
|
-
// variants
|
|
48
|
+
// variants — the enumerated literal union of the variant-axis prop. The
|
|
49
|
+
// serializer emits a BLOCK list for non-empty arrays (`variants:\n - a`) and
|
|
50
|
+
// flow `[]` when empty, so accept BOTH block and flow `[a, b]` here.
|
|
49
51
|
let variants = [];
|
|
50
|
-
const
|
|
51
|
-
if (
|
|
52
|
-
variants =
|
|
52
|
+
const vFlow = head.match(/^variants:\s*\[(.*?)\]/m);
|
|
53
|
+
if (vFlow && vFlow[1].trim()) {
|
|
54
|
+
variants = vFlow[1].split(',').map((s) => s.trim().replace(/^["']|["']$/g, '')).filter(Boolean);
|
|
55
|
+
} else {
|
|
56
|
+
const vBlock = head.match(/^variants:\s*\n((?:[ \t]+-[ \t]*.+\n?)+)/m);
|
|
57
|
+
if (vBlock) {
|
|
58
|
+
variants = vBlock[1].split('\n')
|
|
59
|
+
.map((l) => { const m2 = l.match(/^\s*-\s*(.+?)\s*$/); return m2 ? m2[1].replace(/^["']|["']$/g, '') : null; })
|
|
60
|
+
.filter(Boolean);
|
|
61
|
+
}
|
|
53
62
|
}
|
|
54
63
|
|
|
64
|
+
// variant_prop: which prop is the variant axis (since v4.77.0). Legacy HEADs
|
|
65
|
+
// (pre-v4.77.0) lack it → default to 'variant' when variants are present, so
|
|
66
|
+
// existing registries keep the same axis/rendering.
|
|
67
|
+
const variant_prop = scalar('variant_prop') || (variants.length ? 'variant' : '');
|
|
68
|
+
|
|
55
69
|
// props block: each line ` <name>: { type: "...", required: ..., default: ... }`
|
|
56
70
|
const props = {};
|
|
57
71
|
const pm = head.match(/^props:\s*\n([\s\S]*?)(?=^\S|\n# ===|\Z)/m);
|
|
@@ -68,7 +82,7 @@ function parseHead(md) {
|
|
|
68
82
|
props[pname] = { type: strip(typeM && typeM[1]) || '', default: strip(defM && defM[1]) };
|
|
69
83
|
}
|
|
70
84
|
}
|
|
71
|
-
return { name, source, variants, props };
|
|
85
|
+
return { name, source, variant_prop, variants, props };
|
|
72
86
|
}
|
|
73
87
|
|
|
74
88
|
// Enumerable iff the prop type is a literal union of string/number/boolean
|
|
@@ -89,7 +103,10 @@ function enumMembers(type) {
|
|
|
89
103
|
|
|
90
104
|
function entriesFor(spec, full) {
|
|
91
105
|
const { name, variants, props } = spec;
|
|
92
|
-
|
|
106
|
+
// The variant axis is the prop named by `variant_prop` (default 'variant' for
|
|
107
|
+
// legacy HEADs) — NOT hardcoded, so a Chip whose axis is `tone` mounts `tone`.
|
|
108
|
+
const axis = spec.variant_prop || (variants.length ? 'variant' : '');
|
|
109
|
+
const others = Object.keys(props).filter((p) => p !== axis);
|
|
93
110
|
const baseFixture = {};
|
|
94
111
|
for (const p of others) if (props[p].default !== undefined && props[p].default !== null) baseFixture[p] = props[p].default;
|
|
95
112
|
|
|
@@ -97,8 +114,8 @@ function entriesFor(spec, full) {
|
|
|
97
114
|
const variantValues = variants.length ? variants : ['default'];
|
|
98
115
|
let entries = variantValues.map((v) => ({
|
|
99
116
|
id: `${name}--${v}`,
|
|
100
|
-
props: { ...(variants.length ? {
|
|
101
|
-
axis: 'variant',
|
|
117
|
+
props: { ...(variants.length && axis ? { [axis]: v } : {}), ...baseFixture },
|
|
118
|
+
axis: axis || 'variant',
|
|
102
119
|
}));
|
|
103
120
|
|
|
104
121
|
if (full) {
|
|
@@ -150,7 +167,7 @@ function main() {
|
|
|
150
167
|
spec = parseHead(fs.readFileSync(path.join(dir, file), 'utf8'));
|
|
151
168
|
} catch (_) { spec = null; }
|
|
152
169
|
if (!spec) { skipped.push(file); continue; }
|
|
153
|
-
components.push({ name: spec.name, source: spec.source || null, variants: spec.variants, entries: entriesFor(spec, full) });
|
|
170
|
+
components.push({ name: spec.name, source: spec.source || null, variant_prop: spec.variant_prop || null, variants: spec.variants, entries: entriesFor(spec, full) });
|
|
154
171
|
}
|
|
155
172
|
|
|
156
173
|
emit({
|
|
@@ -16,8 +16,9 @@
|
|
|
16
16
|
// Usage:
|
|
17
17
|
// node serialize-spec.mjs --bundle bundle.json --out-dir <dir> [--index <path>] [--brand <name>]
|
|
18
18
|
// bundle.json = { brand?, components: [ { name, source, source_sha, status,
|
|
19
|
-
// category, props:{}, variants:[], composes:[], purpose,
|
|
20
|
-
// a11y, related:[], must_rules:[], last_verified, owner,
|
|
19
|
+
// category, props:{}, variant_prop, variants:[], composes:[], purpose,
|
|
20
|
+
// token_bindings:[], a11y, related:[], must_rules:[], last_verified, owner,
|
|
21
|
+
// prose } ] }
|
|
21
22
|
// Exits non-zero if any emitted HEAD fails to re-parse (when js-yaml is present).
|
|
22
23
|
|
|
23
24
|
import fs from 'node:fs';
|
|
@@ -72,6 +73,7 @@ export function emitHead(c) {
|
|
|
72
73
|
L.push(`status: ${scalar(c.status || 'stable')}`);
|
|
73
74
|
L.push(`category: ${scalar(c.category || 'primitive')}`);
|
|
74
75
|
L.push(propsBlock(c.props));
|
|
76
|
+
L.push(`variant_prop: ${scalar(c.variant_prop ?? '')}`);
|
|
75
77
|
L.push(list('variants', c.variants));
|
|
76
78
|
L.push(list('composes', c.composes));
|
|
77
79
|
L.push('# AGENTIC (curated by doc-reviewer — preserved across regeneration):');
|
|
@@ -110,7 +110,8 @@ Each step is a delegation. Read the cited SSOT; do not paraphrase it.
|
|
|
110
110
|
describe the required source change as a TODO and require the user to apply it.
|
|
111
111
|
3. **Re-extract the deterministic HEAD.**
|
|
112
112
|
`node .framework/framework/.claude/skills/design-system-init/scripts/extract-one.mjs --source <path.tsx> --json`
|
|
113
|
-
→ the updated `props` / `variants` / `composes` / `source_sha`
|
|
113
|
+
→ the updated `props` / `variant_prop` / `variants` / `composes` / `source_sha`
|
|
114
|
+
from the new source (TS-aware: resolves type aliases / unions / `Omit` / `Pick`).
|
|
114
115
|
4. **Re-verify the agentic fields.** DELEGATE `doc-reviewer` to update ONLY what the
|
|
115
116
|
change affects (`must_rules` / `a11y` / `token_bindings` / `purpose`) and keep the
|
|
116
117
|
rest. Existing values are preserved by the serializer; never blank a field you
|
|
@@ -102,8 +102,8 @@ Each step is a delegation. Read the cited SSOT; do not paraphrase it.
|
|
|
102
102
|
path. The skill itself NEVER writes component source.
|
|
103
103
|
3. **Extract the deterministic HEAD.**
|
|
104
104
|
`node .framework/framework/.claude/skills/design-system-init/scripts/extract-one.mjs --source <path.tsx> --json`
|
|
105
|
-
→ `name`/`source`/`source_sha`/`props`/`variants`/`composes`
|
|
106
|
-
Codex-portable, no subagent).
|
|
105
|
+
→ `name`/`source`/`source_sha`/`props`/`variant_prop`/`variants`/`composes`
|
|
106
|
+
(TS-aware when typescript resolves, else zero-dep regex; Codex-portable, no subagent).
|
|
107
107
|
4. **Enrich the agentic fields AS DATA.** DELEGATE `doc-reviewer` (Task tool) to
|
|
108
108
|
produce `purpose` / `token_bindings` (picked from the REAL ids in
|
|
109
109
|
`${paths.design_tokens}`) / `a11y` / `related` / `must_rules` as JSON values —
|