Skip to content

Commit b15fb89

Browse files
committed
feat(m3): component_map reports unmatchedProps (component-extension TODOs)
Add candidate.unmatchedProps — the actionable inverse of matchedProps: Figma component-property axes (variant / boolean / text) with no matching prop on the candidate code component. Codegen surfaces these as extension TODOs instead of silently dropping design intent (a leading icon, a `required` flag, an active state). Also diff props on the override (map-file) path, which previously never resolved the scanned component: resolveOverrideComponent looks it up by repo-relative path then name, so map-file mappings now report both matchedProps and unmatchedProps. Driven by a hard-screen codegen A/B (Design A 15131:1478 案件管理) where "reuse (high)" was lossy — Button lacked Show icon_L/R, Input lacked the date/select/required axes, Sidebar lacked the active item. Live-verified: those axes now land in unmatchedProps with zero join regression. 494 tests green (vitest).
1 parent cdaafc1 commit b15fb89

4 files changed

Lines changed: 72 additions & 3 deletions

File tree

packages/server/src/join/component-map.ts

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,13 @@ export interface ComponentMapping {
4949
confidence: number;
5050
/** Variant axes that also exist as props on the matched component. */
5151
matchedProps: string[];
52+
/**
53+
* Figma component-property axes (variant / boolean / text) with no matching prop on the
54+
* candidate — the actionable inverse of matchedProps. The component must be extended to carry
55+
* them (e.g. a leading-icon toggle, a `required` flag, an active/selected state). Codegen
56+
* surfaces these as component-extension TODOs instead of silently dropping the design intent.
57+
*/
58+
unmatchedProps: string[];
5259
};
5360
status: MappingStatus;
5461
/** Which path produced the mapping. */
@@ -122,6 +129,35 @@ const statusFor = (confidence: number, threshold: number): MappingStatus => {
122129
return 'unmapped';
123130
};
124131

132+
/**
133+
* Split a usage's Figma axes into those the candidate already has as props (matchedProps) and those
134+
* it lacks (unmatchedProps — the component-extension TODOs). Same casefold predicate for both, so
135+
* matched ∪ unmatched == variantAxes.
136+
*/
137+
const partitionAxes = (
138+
variantAxes: readonly string[],
139+
component: ScannedComponent,
140+
): { matchedProps: string[]; unmatchedProps: string[] } => {
141+
const codeProps = new Set(component.propNames.map(p => p.toLowerCase()));
142+
const matchedProps: string[] = [];
143+
const unmatchedProps: string[] = [];
144+
for (const axis of variantAxes) {
145+
(codeProps.has(axis.toLowerCase()) ? matchedProps : unmatchedProps).push(axis);
146+
}
147+
return { matchedProps, unmatchedProps };
148+
};
149+
150+
/**
151+
* The scanned component an override points to, so its props can be diffed against the Figma axes
152+
* even on the map-file path. Match by repo-relative path first, then by component name.
153+
*/
154+
const resolveOverrideComponent = (
155+
override: { name: string; filePath: string },
156+
scanned: readonly ScannedComponent[],
157+
): ScannedComponent | undefined =>
158+
scanned.find(c => c.filePath === override.filePath) ??
159+
scanned.find(c => norm(c.name) === norm(override.name));
160+
125161
export interface JoinOptions {
126162
threshold: number;
127163
/** Explicit figmaName → code target overrides (highest authority). */
@@ -144,13 +180,18 @@ const joinOne = (
144180

145181
const override = opts.overrides?.get(usage.name) ?? opts.overrides?.get(norm(usage.name));
146182
if (override !== undefined) {
183+
const component = resolveOverrideComponent(override, scanned);
184+
const { matchedProps, unmatchedProps } = component
185+
? partitionAxes(usage.variantAxes, component)
186+
: { matchedProps: [], unmatchedProps: [] };
147187
return {
148188
...shared,
149189
candidate: {
150190
name: override.name,
151191
filePath: override.filePath,
152192
confidence: 1,
153-
matchedProps: [],
193+
matchedProps,
194+
unmatchedProps,
154195
},
155196
status: 'high',
156197
source: 'map-file',
@@ -164,8 +205,7 @@ const joinOne = (
164205

165206
// Variant bonus: reward code props that cover the instance's variant axes, but only once the name
166207
// already plausibly matches, so an unrelated component can't be promoted on prop overlap alone.
167-
const codeProps = new Set(match.component.propNames.map(p => p.toLowerCase()));
168-
const matchedProps = usage.variantAxes.filter(axis => codeProps.has(axis.toLowerCase()));
208+
const { matchedProps, unmatchedProps } = partitionAxes(usage.variantAxes, match.component);
169209
const bonus = Math.min(MAX_VARIANT_BONUS, matchedProps.length * VARIANT_BONUS_PER_PROP);
170210
const confidence = Math.min(1, Number((match.score + bonus).toFixed(3)));
171211

@@ -176,6 +216,7 @@ const joinOne = (
176216
filePath: match.component.filePath,
177217
confidence,
178218
matchedProps,
219+
unmatchedProps,
179220
},
180221
status: statusFor(confidence, opts.threshold),
181222
source: 'scan',

packages/server/src/tools/component-map.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ export const componentMapToolDefinition: Tool = {
4848
'they can be reused instead of regenerated. Joins the grounded Figma component names (and their ' +
4949
'variant axes) against an AST scan of the project; an explicit docs/figma-component-map.md row ' +
5050
'overrides the fuzzy match. Each distinct component is mapped once with all its instance ids. ' +
51+
'A mapped candidate also reports matchedProps (Figma axes the component already has) and ' +
52+
'unmatchedProps (axes it lacks → component-extension TODOs). ' +
5153
'Returns { mappings (candidate + confidence + status high/medium/low/unmapped), unmapped, profile }.',
5254
inputSchema: {
5355
type: 'object',

packages/server/test/join/component-map.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,28 @@ describe('joinComponents', () => {
6262
expect(m?.candidate?.matchedProps).toEqual(['Size', 'Variant']);
6363
});
6464

65+
it('reports Figma axes the candidate lacks as unmatchedProps (extension TODOs)', () => {
66+
const [m] = joinComponents(
67+
[usage({ name: 'Button', variantAxes: ['Size', 'Variant', 'Show icon_L', 'State'] })],
68+
scanned, // Button props: size, variant
69+
{ threshold: 0.7 },
70+
);
71+
expect(m?.candidate?.matchedProps).toEqual(['Size', 'Variant']);
72+
expect(m?.candidate?.unmatchedProps).toEqual(['Show icon_L', 'State']);
73+
});
74+
75+
it('diffs props on the override path too, resolving the scanned component', () => {
76+
const overrides = new Map([['btn', { name: 'Button', filePath: 'src/components/Button.tsx' }]]);
77+
const [m] = joinComponents(
78+
[usage({ name: 'btn', variantAxes: ['Size', 'Show icon_L'] })],
79+
scanned, // Button at src/components/Button.tsx, props: size, variant
80+
{ threshold: 0.7, overrides },
81+
);
82+
expect(m?.source).toBe('map-file');
83+
expect(m?.candidate?.matchedProps).toEqual(['Size']);
84+
expect(m?.candidate?.unmatchedProps).toEqual(['Show icon_L']);
85+
});
86+
6587
it('flags unmapped when nothing is close', () => {
6688
const [m] = joinComponents([usage({ name: 'Tooltip' })], scanned, { threshold: 0.7 });
6789
expect(m?.status).toBe('unmapped');

packages/skills/figma-codegen/SKILL.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@ the rendered image.
3131
- Each entry's `instances[]` carries the per-instance `props` (resolved variant / boolean / text
3232
values, e.g. `{ Size: "Medium", Type: "Primary", "show 必填": true }`). Wire those onto the reused
3333
component — one element per instance, with its own props — instead of a single generic element.
34+
- `candidate.unmatchedProps`: Figma axes the reused component has **no prop for** (e.g. a leading
35+
icon, a `required` flag, an active state). Reuse the component, but surface these as
36+
component-extension TODOs — don't silently drop them or fake them with ad-hoc markup.
3437
3. **`token_map`** → every Figma variable joined to a project token with `status` + `ref` (the Tailwind
3538
utility base or `var(--…)`) + `matchedBy` (`name` / `value`).
3639
- mapped: reference `candidate.ref` (e.g. `bg-primary-500`, `var(--color-primary-500)`) — never the
@@ -53,3 +56,4 @@ token references for color/spacing/radius/typography.
5356
- Never write a config file or wizard prompt; everything is inferred from the project + the three tools.
5457
- If a reused component lacks a prop the design needs (e.g. a `required` field, a password toggle), say
5558
so — that's a real extension the component needs, not something to fake with ad-hoc markup.
59+
`component_map` reports these directly as `candidate.unmatchedProps`; turn them into TODOs.

0 commit comments

Comments
 (0)