baldart 4.76.0 → 4.77.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 CHANGED
@@ -5,6 +5,36 @@ All notable changes to BALDART will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [4.77.0] - 2026-06-29
9
+
10
+ **TS-aware design-system extractor + the `variant_prop` HEAD field.** The component-manifest extractor was pure-regex (zero-dep) and could not resolve TypeScript: a `type Variant = 'a'|'b'; variant: Variant` captured the alias *name* `"Variant"`, not its members, so on a TS-strict consumer (mayo) every HEAD came out with `variants: []` and ~25 with `props: {}`. Since the HEAD is the SSOT every UI agent reads for discovery, `variants: []` on a 6-variant Button induces wrong code. Separately, the variant axis was **hardcoded** to the prop name `variant` in three places — wrong for a `Chip`/`Pill` whose axis is `tone`. This release makes the extractor **optionally TS-aware** (it borrows the consumer's own `typescript`, resolved from the repo root) and adds a deterministic **`variant_prop`** field naming the real axis.
11
+
12
+ **MINOR** — adds capability + a new deterministic HEAD field. **No new `baldart.config.yml` key** (rides on `features.has_design_system`), so the schema-change propagation rule does NOT apply. **Zero regression**: when typescript is absent (Codex / greenfield / non-TS stack) or a file fails to parse, the extractor falls back to the existing zero-dep regex path per file; legacy HEADs (pre-v4.77.0, no `variant_prop`) default the render axis to `'variant'` so existing registries render identically until a one-time re-extract backfills the field.
13
+
14
+ ### Added
15
+
16
+ - **`extract-ts.mjs`** (`framework/.claude/skills/design-system-init/scripts/extract-ts.mjs`) — optional TS-aware resolver. Probes `typescript` via `require.resolve('typescript', { paths: [process.cwd()] })` (the **consumer's** dependency, not BALDART's), parses syntactically with `ts.createSourceFile` (no tsconfig / Program / TypeChecker — fast, in-file, deterministic), and resolves type **aliases**, **unions**, **intersections**, **`Omit`/`Pick`**, interface `extends`, and `forwardRef`/`memo`/`React.FC` wrappers. Opaque / imported types (`ComponentPropsWithoutRef<'button'>`, `HTMLAttributes`) stay **empty** — never a DOM-attribute dump. Per-file try/catch → regex fallback. The typescript-free `computeVariantProp` / `pureStringLiteralUnion` / `literalMembers` are **shared** with the regex path so the axis ranking is byte-identical on both.
17
+ - **`variant_prop` deterministic HEAD field** — which prop IS the variant axis, ranked `variant > tone > intent > kind > appearance > state > status > color > severity` (else the sole literal-union prop, else `''`). `variants` is the literal members of `variant_prop`'s type. Emitted by `serialize-spec.mjs`, consumed as the axis key by `render-manifest.mjs` (`entriesFor`) and `compile-ds-cards.mjs` (row label) instead of the hardcoded `'variant'`. Documented in `component-manifest-schema.md` (SSOT), the canonical `component-spec.template.md`, the `/design-system-init` `/ds-new` `/ds-edit` skills, `COMPONENT-MANIFEST-LAYER.md`, and `design-system-protocol.md`.
18
+
19
+ ### Fixed
20
+
21
+ - **`render-manifest.mjs parseHead` now reads block-style `variants`** — the serializer emits non-empty `variants` as a YAML **block list** (`variants:\n - a`), but `parseHead` only matched flow `variants: [a, b]`, so on any real serialized spec the variant matrix read `[]` (every component collapsed to a single `--default` entry). It now accepts both block and flow forms; with `variant_prop` this makes the render/publish axis work end-to-end (a `Chip` mounts `{ tone }` per variant).
22
+
23
+ ### Changed
24
+
25
+ - **`extract-manifest.mjs extractFile`** — TS-path-first with per-file regex fallback; emits `variant_prop`; `extractVariants(props)` generalized to `extractVariantsFor(props, variantProp)` (the axis is no longer hardcoded to `variant`).
26
+ - **`/design-system-init` UPGRADE mode** (`SKILL.md`) — a one-line **pre-v4.77.0 backfill** note: re-extract all components ignoring `source_sha` once to populate `variant_prop` + resolve aliases, agentic fields preserved by the existing non-clobber rule.
27
+
28
+ ## [4.76.1] - 2026-06-26
29
+
30
+ **`/design-system-init` offers a render surface (opt-in Storybook scaffold).** The render harness (Stage C) needs a Storybook, and a registry-only project has none — so the harness was a silent no-op with nowhere to go. Now `/design-system-init`, after scaffolding the registry, OFFERS (default-NO, JS/TS stacks only) to run the **official `npx storybook init`** so the harness has a surface. It **never hand-writes a per-stack render surface and never auto-wires the project's providers** — that "auto-provider-shim" is the refuted false-fidelity trap; instead it leaves a `.storybook/preview` decorator TODO so **the user owns the context-shimming** (theme/i18n/router), which is how Storybook does it correctly. `/ds-render`'s no-op message now points here. Declined / Codex / non-JS → skipped (publish still works via static-spec cards).
31
+
32
+ **PATCH** — opt-in, no behaviour change for projects that decline or already have Storybook; no new config key.
33
+
34
+ ### Changed
35
+ - **`/design-system-init` step 7b** (`framework/.claude/skills/design-system-init/SKILL.md`) — opt-in `npx storybook init` offer + the user-owned-decorator TODO; cross-ref `/ds-render`.
36
+ - **`/ds-render` no-Storybook message** (`framework/.claude/skills/ds-render/SKILL.md`) — points to the init offer instead of a dead no-op.
37
+
8
38
  ## [4.76.0] - 2026-06-26
9
39
 
10
40
  **Closed-loop design system: BALDART ⇄ Claude Design.** BALDART owns the design system; Claude Design is where designers draw mockups. The two **diverge** over time — v4.75 made every new mockup born on the project's tokens, but that alignment decays as the in-repo registry evolves and the satellite stays frozen. This release adds the machinery to keep them in sync with **ONE authority** (the in-repo registry — `.tokens.json` + `components/<Name>.md` HEAD + INDEX/Selection-Policy + `ds-gate`); Claude Design is a **satellite mirror**, never a 2nd SSOT. Planned via a multi-agent workflow (map → per-axis design → adversarial refutation → completeness/SSOT critic → synthesis) which **refuted** the obvious-but-wrong pieces before any code.
package/VERSION CHANGED
@@ -1 +1 @@
1
- 4.76.0
1
+ 4.77.0
@@ -104,10 +104,19 @@ precedence caveats: `framework/agents/effort-protocol.md`.
104
104
  extractor (`scripts/extract-manifest.mjs` via Bash) over
105
105
  `${paths.components_primitives}` (+ `${paths.components_root}/shared`) to read
106
106
  from TS types: exported name, `source`, `source_sha`, `props`
107
- ({type,required,default}), `variants` (literal union), `composes` (in-registry
108
- imports), `category` (import-fan-in heuristic). **This half runs with NO
109
- subagent** it is the Codex-portable path. Empty results on a non-TS stack are
110
- acceptable (advisory), never a failure.
107
+ ({type,required,default}), `variant_prop` (which prop is the variant axis,
108
+ ranked), `variants` (literal members of the axis), `composes` (in-registry
109
+ imports), `category` (import-fan-in heuristic). When `typescript` is resolvable
110
+ from the consumer root the extractor is **TS-aware** (resolves aliases / unions /
111
+ intersections / `Omit` / `Pick`); else it falls back to regex per file. **This
112
+ half runs with NO subagent** — it is the Codex-portable path. Empty results on a
113
+ non-TS stack are acceptable (advisory), never a failure.
114
+ - **Pre-v4.77.0 backfill (UPGRADE):** a registry whose HEADs were generated
115
+ before v4.77.0 lacks `variant_prop` and may have unresolved aliases. Re-extract
116
+ ALL components **ignoring `source_sha`** once (it is cheap + deterministic),
117
+ then re-serialize — non-empty agentic fields are preserved by the existing
118
+ non-clobber rule. The render harness defaults the axis to `'variant'` until
119
+ then, so an un-backfilled registry still renders correctly.
111
120
  4. **Enrich — AGENTIC, AS DATA (never hand-write YAML).** Launch `doc-reviewer`
112
121
  to produce the agentic fields (`purpose`, `a11y`, `token_bindings`, `related`,
113
122
  `must_rules`, **plus the selection-policy fields `canonical_for` / `use_when` /
@@ -234,9 +243,28 @@ precedence caveats: `framework/agents/effort-protocol.md`.
234
243
  `paths.design_system: <target>`, and (when tokens bootstrapped)
235
244
  `paths.design_tokens` + `design_tokens.outputs`. Use the same YAML write path
236
245
  as `configure` (preserve user values, never clobber unrelated keys).
246
+ 7b. **Offer a render surface — OPT-IN, never forced (closed-loop Stage C enabler).**
247
+ A registry documents components but cannot RENDER them in isolation — the render
248
+ harness (`baldart render` / `/ds-render`) needs a Storybook. If **no `.storybook/`
249
+ exists** AND the stack is JS/TS (`stack.framework` is react/vue/svelte/etc.),
250
+ ASK the user (one consolidated question, default-NO): *"Vuoi che inizializzi
251
+ Storybook così il render harness può montare i primitivi in isolamento? (serve
252
+ per la verifica di qualità isolata e per il render dei componenti)"*. On YES:
253
+ - **Delegate to the official scaffolder** — run `npx storybook init` (stack-aware;
254
+ installs the right `@storybook/<framework>` devDeps + writes `.storybook/main`
255
+ + `preview`). NEVER hand-write a per-stack render surface and NEVER auto-wire
256
+ the project's providers — that "auto-provider-shim" is the REFUTED false-fidelity
257
+ trap. Instead leave a **TODO in `.storybook/preview`**: *"add your ThemeProvider
258
+ / i18n provider / router here as a decorator so isolated primitives render with
259
+ real context"* — **the user owns the decorators** (that is how Storybook does the
260
+ context-shimming correctly).
261
+ - On Codex / non-JS stack / user declines → skip silently; the harness simply
262
+ stays a no-op (publish still works via static-spec cards). This is an offer, not
263
+ a requirement.
237
264
  8. **Print next-steps.** Specs needing agentic review (empty fields), the
238
265
  round-trip token diff if non-empty, suggested follow-up (`/design-review` on a
239
- key route).
266
+ key route); if Storybook was initialized, the `.storybook/preview` decorator TODO
267
+ + `/ds-render` to render the primitives.
240
268
 
241
269
  ## Workflow
242
270
 
@@ -249,8 +277,9 @@ precedence caveats: `framework/agents/effort-protocol.md`.
249
277
  node .framework/framework/.claude/skills/design-system-init/scripts/extract-manifest.mjs \
250
278
  --roots <primitives>,<shared> --json
251
279
  ```
252
- It emits, per component: `name`, `source`, `source_sha`, `props`, `variants`,
253
- `composes`, `category`. Empty fields on a non-TS stack are advisory, not errors.
280
+ It emits, per component: `name`, `source`, `source_sha`, `props`, `variant_prop`,
281
+ `variants`, `composes`, `category` (TS-aware when typescript is resolvable; regex
282
+ fallback otherwise). Empty fields on a non-TS stack are advisory, not errors.
254
283
 
255
284
  3. **Agentic enrichment (`doc-reviewer`).** Fill `purpose`, `a11y`,
256
285
  `token_bindings`, `related`, `must_rules`, and the selection-policy fields
@@ -64,7 +64,8 @@ function loadTokens(file) {
64
64
  function isColor(v) { return /^#([0-9a-f]{3,8})$|^(rgb|hsl|oklch|color)\(/i.test(String(v).trim()); }
65
65
 
66
66
  function card({ spec, group, tokens }) {
67
- const variantRows = (spec.variants.length ? spec.variants : ['—']).map((v) => `<tr><td>variant</td><td><code>${esc(v)}</code></td></tr>`).join('');
67
+ const axisLabel = spec.variant_prop || 'variant';
68
+ const variantRows = (spec.variants.length ? spec.variants : ['—']).map((v) => `<tr><td>${esc(axisLabel)}</td><td><code>${esc(v)}</code></td></tr>`).join('');
68
69
  const propRows = Object.entries(spec.props || {}).map(([p, d]) => `<tr><td><code>${esc(p)}</code></td><td><code>${esc(d.type)}</code></td><td>${esc(d.default)}</td></tr>`).join('');
69
70
  // Token swatches the component declares (token_bindings would be richer; v1 shows the project palette subset by colour).
70
71
  const swatches = Object.entries(tokens).filter(([, v]) => isColor(v)).slice(0, 24)
@@ -19,6 +19,7 @@
19
19
  import fs from 'node:fs';
20
20
  import path from 'node:path';
21
21
  import { execFileSync } from 'node:child_process';
22
+ import { TS_AVAILABLE, extractPropsTs, computeVariantProp } from './extract-ts.mjs';
22
23
 
23
24
  function arg(name, def = '') {
24
25
  const i = process.argv.indexOf(name);
@@ -97,9 +98,9 @@ function extractProps(src, name) {
97
98
  return props;
98
99
  }
99
100
 
100
- /** Variants = literal union of the `variant` prop. */
101
- function extractVariants(props) {
102
- const v = props.variant && props.variant.type;
101
+ /** Variants = literal union of the variant-axis prop (`variant_prop`). */
102
+ function extractVariantsFor(props, variantProp) {
103
+ const v = variantProp && props[variantProp] && props[variantProp].type;
103
104
  if (!v) return [];
104
105
  const lits = [...v.matchAll(/'([^']+)'|"([^"]+)"/g)].map((m) => m[1] || m[2]);
105
106
  return lits;
@@ -129,7 +130,17 @@ export function extractFile(file) {
129
130
  try { src = fs.readFileSync(file, 'utf8'); } catch (_) { return null; }
130
131
  const name = exportedName(src, file);
131
132
  if (!name) return null;
132
- const props = extractProps(src, name);
133
+ // TS-aware path first (resolves aliases/unions/intersections/Omit/Pick); on any
134
+ // miss it returns null and we fall back to the zero-dep regex path for THIS file.
135
+ let props, variantProp, variants;
136
+ const tsOut = TS_AVAILABLE ? extractPropsTs(src, name, file) : null;
137
+ if (tsOut) {
138
+ ({ props, variantProp, variants } = tsOut);
139
+ } else {
140
+ props = extractProps(src, name);
141
+ variantProp = computeVariantProp(props); // shared rule — identical on both paths
142
+ variants = extractVariantsFor(props, variantProp);
143
+ }
133
144
  return {
134
145
  name,
135
146
  source: path.relative(process.cwd(), file),
@@ -137,7 +148,8 @@ export function extractFile(file) {
137
148
  status: 'stable',
138
149
  category: 'primitive',
139
150
  props,
140
- variants: extractVariants(props),
151
+ variant_prop: variantProp,
152
+ variants,
141
153
  composes: extractComposes(src).filter((c) => c !== name),
142
154
  };
143
155
  }
@@ -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: [a, b, c] (the enumerated literal union of the `variant` prop)
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 vr = head.match(/^variants:\s*\[(.*?)\]/m);
51
- if (vr && vr[1].trim()) {
52
- variants = vr[1].split(',').map((s) => s.trim().replace(/^["']|["']$/g, '')).filter(Boolean);
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
- const others = Object.keys(props).filter((p) => p !== 'variant');
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 ? { variant: v } : {}), ...baseFixture },
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, token_bindings:[],
20
- // a11y, related:[], must_rules:[], last_verified, owner, prose } ] }
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` from the new source.
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` (zero-dep,
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 —
@@ -43,7 +43,11 @@ Baseline `medium`. Honor an inline `effort=<level>` override.
43
43
 
44
44
  1. **Build the surface.** `baldart render build [--full]` → emits
45
45
  `render-manifest.json` (from the component HEADs) + generates the adapter render
46
- surface (ephemeral `.baldart.stories.tsx`). No Storybook → STOP (clean no-op).
46
+ surface (ephemeral `.baldart.stories.tsx`). **No Storybook → STOP (clean no-op)**
47
+ and tell the user the harness needs a render surface: offer to run
48
+ `/design-system-init` (which proposes `npx storybook init`, opt-in — § step 7b)
49
+ or to `npx storybook init` directly, then re-run `/ds-render`. Never auto-install
50
+ Storybook from this skill.
47
51
  2. **Run the screenshot loop** (this skill owns the browser via `playwright-skill`):
48
52
  `npx storybook build -o .harness/storybook-static`, then for each story
49
53
  `iframe.html?id=<storyId>&viewMode=story` capture a PNG into `.harness/shots/`.
@@ -51,11 +51,12 @@ source: components/ui/Button.tsx # path relative to repo root
51
51
  source_sha: a1b2c3d # git blob sha of source at last generation (drift anchor)
52
52
  status: stable # stable | experimental | deprecated (default stable; `deprecated` is a hand override)
53
53
  category: primitive # primitive | composite | feature (heuristic on import fan-in; hand-overridable)
54
- props: # from the TS prop type / interface
54
+ props: # from the TS prop type / interface (TS-aware: resolves aliases / unions / intersections / Omit / Pick)
55
55
  variant: { type: "'primary'|'ghost'|'destructive'", required: false, default: "'primary'" }
56
56
  size: { type: "'sm'|'md'|'lg'", required: false, default: "'md'" }
57
57
  disabled: { type: boolean, required: false, default: false }
58
- variants: [primary, ghost, destructive] # enumerated literal union of the `variant` prop
58
+ variant_prop: variant # which prop IS the variant axis (ranked; '' if none). NOT hardcoded to `variant` — a Chip whose axis is `tone` reports `tone`.
59
+ variants: [primary, ghost, destructive] # enumerated literal union of the `variant_prop` type
59
60
  composes: [Slot, Spinner] # in-registry primitives imported by this component
60
61
  # === AGENTIC — curated by doc-reviewer; survives regeneration, never clobbered when non-empty ===
61
62
  purpose: "Primary action trigger; one primary per surface."
@@ -76,7 +77,27 @@ owner: doc-reviewer # curation owner (constant; documents
76
77
 
77
78
  | Origin | Fields | Rule |
78
79
  |---|---|---|
79
- | **Deterministic** (TS/git) | `name`, `source`, `source_sha`, `status` (default), `category` (heuristic), `props`, `variants`, `composes` | Always safe to **overwrite** on regeneration — they are derived. Extracted by a pure Node script (no agent), so the path is **portable to Codex**. |
80
+ | **Deterministic** (TS/git) | `name`, `source`, `source_sha`, `status` (default), `category` (heuristic), `props`, `variant_prop`, `variants`, `composes` | Always safe to **overwrite** on regeneration — they are derived. Extracted by a pure Node script (no agent), so the path is **portable to Codex**. |
81
+
82
+ **TS-aware extraction (since v4.77.0).** When `typescript` is resolvable from the
83
+ consumer root, the extractor parses the source syntactically (`ts.createSourceFile`
84
+ — no tsconfig/TypeChecker) and resolves type **aliases**, **unions**,
85
+ **intersections**, and **`Omit`/`Pick`** in the props type (`type V='a'|'b'; variant: V`
86
+ → `['a','b']`). Opaque / imported types (`ComponentPropsWithoutRef<'button'>`,
87
+ `HTMLAttributes`) stay **empty** — never a DOM-attribute dump. When typescript is
88
+ absent (Codex / greenfield / non-TS stack) or a file fails to parse, it falls back
89
+ to the zero-dep **regex** path for that file (today's behaviour, no regression).
90
+
91
+ **`variant_prop` (the variant axis).** A prop is a variant-axis *candidate* iff its
92
+ resolved type is a pure string-literal union; the winner is the first by the rank
93
+ `variant > tone > intent > kind > appearance > state > status > color > severity`,
94
+ else the sole candidate, else `''`. `variants` is the literal members of
95
+ `variant_prop`'s type. Consumers (render harness, `@dsCard` compiler) use
96
+ `variant_prop` as the axis key — so a `Chip` whose axis is `tone` mounts `tone`,
97
+ not a phantom `variant`. **Legacy HEADs** (pre-v4.77.0, no `variant_prop`) **default
98
+ the axis to `'variant'`** when `variants` is non-empty, so existing registries keep
99
+ rendering identically; a one-time re-extract backfills the field (see
100
+ `/design-system-init` UPGRADE mode).
80
101
  | **Agentic** (doc-reviewer) | `purpose`, `token_bindings`, `a11y`, `related`, `must_rules`, `canonical_for`, `use_when`, `selection_closed`, `last_verified`; plus `status: deprecated` as a judgment | **Never clobbered** when non-empty. Empty agentic fields = "not yet enriched", filled later by `doc-reviewer` / `ds-drift`, never auto-invented. |
81
102
 
82
103
  ### Selection-policy fields (the closed-set layer)
@@ -600,7 +600,7 @@ and the advisory check 6 (mirror, non-blocking):
600
600
  2. **Modified primitive ⇒ updated spec**: did the change alter a primitive's
601
601
  props / variants / accessibility behavior? If yes, the corresponding
602
602
  `components/<Name>.md` MUST be updated to match — including its **frontmatter
603
- HEAD** (deterministic fields `props`/`variants`/`composes`/`source_sha`
603
+ HEAD** (deterministic fields `props`/`variant_prop`/`variants`/`composes`/`source_sha`
604
604
  regenerated from source; agentic fields re-verified). Stale spec =
605
605
  `DS_COMPONENT_STALE` finding, task blocked. (A prose-only spec lacking a HEAD
606
606
  is NOT stale merely for that — see `agents/component-manifest-schema.md`
@@ -40,7 +40,7 @@ BALDART wraps the **standard** (DTCG) and owns only a thin generator — the sam
40
40
  |---|---|---|
41
41
  | HEAD schema | `framework/agents/component-manifest-schema.md` | SSOT for the frontmatter fields + read/regeneration/leniency contract |
42
42
  | Gate + cascade | `framework/agents/design-system-protocol.md` | BLOCKING reads, drift codes, token SSOT inversion, Authority Matrix |
43
- | Deterministic extractor | `framework/.claude/skills/design-system-init/scripts/extract-manifest.mjs` (`extractFile`) + `extract-one.mjs` (single-component wrapper) | zero-dep Node; name/source/source_sha/props/variants/composes (Codex-portable) |
43
+ | Deterministic extractor | `framework/.claude/skills/design-system-init/scripts/extract-manifest.mjs` (`extractFile`) + `extract-one.mjs` (single-component wrapper) + `extract-ts.mjs` (optional TS-aware resolver) | Node; name/source/source_sha/props/variant_prop/variants/composes. **TS-aware** when `typescript` resolves from the consumer root (aliases/unions/intersections/Omit/Pick); **zero-dep regex fallback** otherwise (Codex-portable) |
44
44
  | Spec template | `framework/templates/component-spec.template.md` | CANONICAL prose-body template (SSOT); serializer fills it on creation via `--prose-template` (default) |
45
45
  | Bootstrap/upgrade | `framework/.claude/skills/design-system-init/SKILL.md` | greenfield + upgrade modes |
46
46
  | Token emitters | `src/utils/token-emitters/{index,ts,css}.js` | DTCG → stack-native output (REGISTRY pattern) |
@@ -72,8 +72,11 @@ has_design_system: true (no separate flag — the manifest rides on it)
72
72
  ## 5. Invariants (do not break)
73
73
 
74
74
  - **Codex portability**: the READ path and the deterministic half (extractor,
75
- `baldart tokens build`) run with **no subagent**. Only agentic enrichment needs
76
- Claude; under Codex it degrades to empty/TODO fields never a hard failure.
75
+ `baldart tokens build`) run with **no subagent**. The TS-aware resolver
76
+ (`extract-ts.mjs`) is **optional** it uses the consumer's own `typescript` when
77
+ resolvable and falls back to the **zero-dep regex** path per file otherwise, so
78
+ Codex / non-TS stacks are unaffected. Only agentic enrichment needs Claude; under
79
+ Codex it degrades to empty/TODO fields — never a hard failure.
77
80
  - **No twin**: ONE artifact per component (the spec, evolved) and ONE token SSOT
78
81
  (`.tokens.json`); `tokens-reference.md` and `tokens.ts`/CSS are **generated
79
82
  views/outputs**, never hand-authored sources. The INDEX is a generated router.
@@ -24,8 +24,9 @@ source: {{file_path}}
24
24
  source_sha: {{source_sha}}
25
25
  status: {{status}} # stable | experimental | deprecated (default stable)
26
26
  category: {{category}} # primitive | composite | feature
27
- props: {{props_yaml}} # name → { type, required, default } ({} if unresolved)
28
- variants: {{variants_yaml}} # [] if none / unresolved
27
+ props: {{props_yaml}} # name → { type, required, default } ({} if unresolved; TS-aware: resolves aliases/unions/intersections/Omit/Pick)
28
+ variant_prop: {{variant_prop}} # which prop is the variant axis (ranked; '' if none / unresolved; legacy HEADs default to 'variant')
29
+ variants: {{variants_yaml}} # [] if none / unresolved (literal members of variant_prop's type)
29
30
  composes: {{composes_yaml}} # in-registry primitives this imports
30
31
  # AGENTIC fields (curated by doc-reviewer — preserved across regeneration):
31
32
  purpose: "{{purpose}}"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "baldart",
3
- "version": "4.76.0",
3
+ "version": "4.77.0",
4
4
  "description": "Claude Agent Framework - Reusable framework for coordinating AI agents and humans in software projects",
5
5
  "bin": {
6
6
  "baldart": "./bin/baldart.js"