Skip to content

Commit f8972fd

Browse files
committed
feat(token_map): font-weight framework-builtin (weight/* → font-bold etc.)
Extend the framework-builtin recognizer to the font-weight named scale. Projects almost never declare font weights in @theme (you just write font-bold), yet designs routinely tokenize them — so weight/* otherwise reads as a false 'unmapped' gap. The step is a font-style name mapped to Tailwind's font-weight utility (the one rename is Regular → normal; also absorbs Semi Bold / ExtraLight / Heavy spelling variants). builtin.scale is 'font-weight' → the model emits font-{step}. Recognizer is now two-kind: open-ended numeric stems (spacing, line-height) and the fixed font-weight name map. Still fallback-only and conservative — an unknown weight name (e.g. Condensed) stays unmapped, gated on profile.styling.system === 'tailwind'. Live-verified against 客戶 X: weight/Bold→font-bold, weight/Regular→font-normal, weight/Medium→font-medium; token_map unmapped now 6, all genuine. 531 tests.
1 parent 5ea3d11 commit f8972fd

5 files changed

Lines changed: 89 additions & 28 deletions

File tree

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

Lines changed: 49 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@ export interface TokenMapping {
2727
/**
2828
* Set only when status is 'framework-builtin': the Tailwind built-in scale this variable belongs
2929
* to. There is no project token to reuse — for scale "spacing", compose the step with the bound
30-
* property (p-4 / gap-4 / m-4); for "line-height", use leading-{step} (e.g. leading-7).
30+
* property (p-4 / gap-4 / m-4); for "line-height", use leading-{step} (leading-7); for
31+
* "font-weight", use font-{step} (font-bold).
3132
*/
3233
builtin?: { scale: string; step: string };
3334
status: MappingStatus;
@@ -206,8 +207,33 @@ const BUILTIN_NUMERIC_STEMS: ReadonlyMap<string, string> = new Map([
206207
['lineheight', 'line-height'],
207208
]);
208209

209-
/** Parse a Tailwind step: an integer, a Figma dash-written half-step (1-5 → 1.5), or px. Else null. */
210-
const parseBuiltinStep = (raw: string): string | null => {
210+
// Named built-in scale: font-weight. Projects almost never declare it in @theme (you just write
211+
// font-bold), yet designs routinely tokenize it — so weight/* otherwise reads as a false gap. The step
212+
// is a font-style name; map it to Tailwind's font-weight utility name (the one rename is Regular →
213+
// normal). Keys are normalized (lowercased, separators stripped) to absorb "Semi Bold" / "ExtraLight".
214+
const FONT_WEIGHT_STEPS: ReadonlyMap<string, string> = new Map([
215+
['thin', 'thin'],
216+
['hairline', 'thin'],
217+
['extralight', 'extralight'],
218+
['ultralight', 'extralight'],
219+
['light', 'light'],
220+
['regular', 'normal'],
221+
['normal', 'normal'],
222+
['book', 'normal'],
223+
['medium', 'medium'],
224+
['semibold', 'semibold'],
225+
['demibold', 'semibold'],
226+
['bold', 'bold'],
227+
['extrabold', 'extrabold'],
228+
['ultrabold', 'extrabold'],
229+
['black', 'black'],
230+
['heavy', 'black'],
231+
]);
232+
233+
const normSeg = (raw: string): string => raw.toLowerCase().replace(/[^a-z0-9]/g, '');
234+
235+
/** Parse a numeric Tailwind step: an integer, a Figma dash-written half-step (1-5 → 1.5), or px. */
236+
const parseNumericStep = (raw: string): string | null => {
211237
const r = raw.trim().toLowerCase();
212238
if (r === 'px') return 'px';
213239
// Figma can't put a dot in a name segment, so 1.5 is authored as "1-5" (value confirms: spacing/1-5
@@ -217,26 +243,30 @@ const parseBuiltinStep = (raw: string): string | null => {
217243
};
218244

219245
/**
220-
* Recognize a Figma variable as a Tailwind built-in numeric scale step, by name only. Deliberately
221-
* conservative — it fires only for the stems in the table above (notably NOT size/*, which is a
222-
* dimension or a font size depending on collection), and only as a fallback after the project-token
223-
* join declined, so it can never override a real reuse. The Figma group separator is "/", so split
224-
* on it: the last segment is the step (its own dash is a half-step), the earlier segments are the
225-
* stem (whose own dashes, e.g. line-height, are part of the name). Returns the scale + step, or
226-
* null.
246+
* Recognize a Figma variable as a Tailwind built-in scale step, by name only. Deliberately
247+
* conservative — it fires only for the stems below (notably NOT size/*, a dimension or a font size
248+
* depending on collection), and only as a fallback after the project-token join declined, so it can
249+
* never override a real reuse. The Figma group separator is "/", so split on it: the last segment
250+
* is the step, the earlier segments are the stem (whose own dashes, e.g. line-height, are part of
251+
* the name). Numeric scales (spacing, line-height) are open-ended; font-weight is a fixed name map.
252+
* Returns the scale + step to compose into a utility (p-4 / leading-7 / font-bold), or null.
227253
*/
228254
const tailwindBuiltinScale = (figmaName: string): { scale: string; step: string } | null => {
229255
const segs = figmaName.trim().split('/');
230256
if (segs.length < 2) return null;
231-
const stem = segs
232-
.slice(0, -1)
233-
.join('')
234-
.toLowerCase()
235-
.replace(/[^a-z0-9]/g, '');
236-
const scale = BUILTIN_NUMERIC_STEMS.get(stem);
237-
if (scale === undefined) return null;
238-
const step = parseBuiltinStep(segs[segs.length - 1] as string);
239-
return step === null ? null : { scale, step };
257+
const stem = normSeg(segs.slice(0, -1).join(''));
258+
const stepRaw = segs[segs.length - 1] as string;
259+
260+
const numericScale = BUILTIN_NUMERIC_STEMS.get(stem);
261+
if (numericScale !== undefined) {
262+
const step = parseNumericStep(stepRaw);
263+
return step === null ? null : { scale: numericScale, step };
264+
}
265+
if (stem === 'weight' || stem === 'fontweight') {
266+
const step = FONT_WEIGHT_STEPS.get(normSeg(stepRaw));
267+
return step === undefined ? null : { scale: 'font-weight', step };
268+
}
269+
return null;
240270
};
241271

242272
const joinOne = (

packages/server/src/prompts/figma-to-code.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ const promptText = (nodeId: string | undefined): string => {
2424
- candidate.unmatchedProps are props the design needs but the component lacks → surface them as component-extension TODOs, never fake them with ad-hoc markup.
2525
- unmapped: build it new in the project's style. For a repeated unmapped component (instanceCount > 1, e.g. a table row), build from its first instance's subtree; drill instances[0].nodeId if it was scoped away.
2626
27-
3. token_map — every Figma variable joined to a project token. Reference candidate.ref (e.g. bg-primary-500 or var(--color-primary-500)), never the raw hex/px. A 'framework-builtin' status (Tailwind built-in scale, e.g. spacing/4, line-height/7) carries builtin.{scale,step}: for spacing compose the step with the bound property (p-4 / gap-4 / m-4), for line-height use leading-{step} (leading-7) — use the utility, not an arbitrary p-[16px], and don't report it as a gap. Only a genuinely 'unmapped' variable is a gap — use the value but call it out, don't hardcode it silently.
27+
3. token_map — every Figma variable joined to a project token. Reference candidate.ref (e.g. bg-primary-500 or var(--color-primary-500)), never the raw hex/px. A 'framework-builtin' status (Tailwind built-in scale, e.g. spacing/4, line-height/7, weight/Bold) carries builtin.{scale,step}: for spacing compose the step with the bound property (p-4 / gap-4 / m-4), for line-height use leading-{step} (leading-7), for font-weight use font-{step} (font-bold) — use the utility, not an arbitrary p-[16px], and don't report it as a gap. Only a genuinely 'unmapped' variable is a gap — use the value but call it out, don't hardcode it silently.
2828
2929
4. get_screenshot (or save_screenshots to write straight to disk) — export the assets grounding can't encode. Logos, photos, and icons have no pixels in the layout tree and otherwise render as grey blocks, often half the visible surface. For each visual-only leaf — a node with an IMAGE fill (a photo), a VECTOR / boolean-op, or an icon instance (e.g. mainComponent.name under Icons/…) — export it (SVG for vector/icon, PNG scale 2 for photos) into the project's asset dir and import the real file. Logos and brand marks are always exported, never typed by hand.
3030

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,9 +48,10 @@ export const tokenMapTool: ToolSpec = {
4848
'existing tokens instead of hard-coded values. Joins the grounded Figma variable names + values ' +
4949
'against tokens parsed from the project CSS (Tailwind v4 @theme or :root custom properties); the ' +
5050
'match is name-based with an exact color value-match as confirmation. On a Tailwind project a ' +
51-
'variable that hits a framework built-in numeric scale (spacing/N, line-height/N) is reported as ' +
51+
'variable that hits a framework built-in scale (spacing/N, line-height/N, weight/*) is reported as ' +
5252
"status 'framework-builtin' with { builtin: { scale, step } } rather than unmapped — it has no " +
53-
'@theme token but the utility (p-4 / gap-4, leading-7) is still usable. tokenSource overrides the ' +
53+
'@theme token but the utility (p-4 / gap-4, leading-7, font-bold) is still usable. tokenSource ' +
54+
'overrides the ' +
5455
'detected styling config; rootDir defaults to the server cwd. Tailwind v3 JS configs are not yet ' +
5556
'parsed (pass tokenSource to a CSS file). Returns { mappings (candidate + confidence + status + ' +
5657
'matchedBy + builtin), unmapped, tokenSource, profile }.',

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

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,36 @@ describe('joinTokens', () => {
180180
expect(m?.builtin).toEqual({ scale: 'line-height', step: '7' });
181181
});
182182

183+
it('maps weight/* to a font-weight built-in, renaming Regular → normal', () => {
184+
const bold = joinTokens([fig('weight/Bold', 'Bold', 'STRING')], tokens, {
185+
threshold: 0.7,
186+
tailwind: true,
187+
})[0];
188+
expect(bold?.status).toBe('framework-builtin');
189+
expect(bold?.builtin).toEqual({ scale: 'font-weight', step: 'bold' });
190+
191+
const regular = joinTokens([fig('weight/Regular', 'Regular', 'STRING')], tokens, {
192+
threshold: 0.7,
193+
tailwind: true,
194+
})[0];
195+
expect(regular?.builtin).toEqual({ scale: 'font-weight', step: 'normal' });
196+
197+
// Tolerates spacing/casing variants of the style name.
198+
const semi = joinTokens([fig('weight/Semi Bold', 'Semi Bold', 'STRING')], tokens, {
199+
threshold: 0.7,
200+
tailwind: true,
201+
})[0];
202+
expect(semi?.builtin?.step).toBe('semibold');
203+
});
204+
205+
it('leaves an unknown weight name unmapped (conservative)', () => {
206+
const [m] = joinTokens([fig('weight/Condensed', 'Condensed', 'STRING')], tokens, {
207+
threshold: 0.7,
208+
tailwind: true,
209+
});
210+
expect(m?.status).toBe('unmapped');
211+
});
212+
183213
it('does not fire on a non-Tailwind project (flag off) — stays unmapped', () => {
184214
const [m] = joinTokens([fig('spacing/4', 16, 'FLOAT')], tokens, { threshold: 0.7 });
185215
expect(m?.status).toBe('unmapped');

packages/skills/figma-codegen/SKILL.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -56,12 +56,12 @@ the rendered image.
5656
- mapped: reference `candidate.ref` (e.g. `bg-primary-500`, `var(--color-primary-500)`) — never the
5757
raw hex/px that `get_design_context` resolved.
5858
- `matchedBy: ['name']` on a color (value drifted): use it, but flag the value mismatch to the user.
59-
- `framework-builtin` (Tailwind projects): the variable is a built-in numeric scale step the project
60-
never redeclares in `@theme` — e.g. `spacing/4`, `line-height/7`. It carries `builtin: { scale,
61-
step }`: for `spacing`, compose the step with the property `get_design_context` bound it to (`p-4`
62-
/ `gap-4` / `m-4`); for `line-height`, use `leading-{step}` (e.g. `leading-7`). Use the utility,
63-
**not** an arbitrary value like `p-[16px]`. This is **not** a gap — never report it as a missing
64-
token.
59+
- `framework-builtin` (Tailwind projects): the variable is a built-in scale step the project never
60+
redeclares in `@theme` — e.g. `spacing/4`, `line-height/7`, `weight/Bold`. It carries `builtin: {
61+
scale, step }`: for `spacing`, compose the step with the property `get_design_context` bound it to
62+
(`p-4` / `gap-4` / `m-4`); for `line-height`, use `leading-{step}` (`leading-7`); for `font-weight`,
63+
use `font-{step}` (`font-bold`). Use the utility, **not** an arbitrary value like `p-[16px]`. This
64+
is **not** a gap — never report it as a missing token.
6565
- in `unmapped`: the design uses a token the project hasn't defined. Don't hardcode silently — use the
6666
value but call out the gap (and offer to add it, or hand off to **figma-sync-tokens**).
6767
4. **`get_screenshot` — export the assets the structural tools can't carry.** Geometry + text grounding

0 commit comments

Comments
 (0)