diff --git a/packages/cli/src/linter/dtcg/handler.test.ts b/packages/cli/src/linter/dtcg/handler.test.ts index 1933852f..ffa9ef0f 100644 --- a/packages/cli/src/linter/dtcg/handler.test.ts +++ b/packages/cli/src/linter/dtcg/handler.test.ts @@ -14,6 +14,7 @@ import { describe, test, expect } from 'bun:test'; import { DtcgEmitterHandler } from './handler.js'; +import { lint } from '../lint.js'; import type { DesignSystemState, ResolvedColor, ResolvedDimension, ResolvedTypography } from '../model/spec.js'; function emptyState(overrides?: Partial): DesignSystemState { @@ -170,7 +171,28 @@ describe('DtcgEmitterHandler', () => { expect(value['letterSpacing']).toEqual({ value: 0.5, unit: 'px' }); }); - test('typography with missing fields omits them from $value', () => { + test('typography without letterSpacing emits a 0px default', () => { + const body: ResolvedTypography = { + type: 'typography', + fontFamily: 'Manrope, Arial, sans-serif', + fontSize: makeDim(16, 'px'), + fontWeight: 400, + lineHeight: makeDim(1.6, ''), + }; + + const state = emptyState({ + typography: new Map([['body-md', body]]), + }); + + const result = handler.execute(state); + expect(result.success).toBe(true); + if (!result.success) return; + + const value = ((result.data['typography'] as Record)['body-md'] as Record)['$value'] as Record; + expect(value['letterSpacing']).toEqual({ value: 0, unit: 'px' }); + }); + + test('typography with missing fields omits them from $value except default letterSpacing', () => { const minimal: ResolvedTypography = { type: 'typography', fontFamily: 'Roboto', @@ -189,6 +211,57 @@ describe('DtcgEmitterHandler', () => { expect(value['fontSize']).toBeUndefined(); expect(value['fontWeight']).toBeUndefined(); expect(value['lineHeight']).toBeUndefined(); - expect(value['letterSpacing']).toBeUndefined(); + expect(value['letterSpacing']).toEqual({ value: 0, unit: 'px' }); + }); + + test('exported typography includes all DTCG required properties', () => { + const body: ResolvedTypography = { + type: 'typography', + fontFamily: 'Manrope', + fontSize: makeDim(16, 'px'), + fontWeight: 400, + lineHeight: makeDim(1.6, ''), + }; + + const state = emptyState({ + typography: new Map([['body-md', body]]), + }); + + const result = handler.execute(state); + expect(result.success).toBe(true); + if (!result.success) return; + + const value = ((result.data['typography'] as Record)['body-md'] as Record)['$value'] as Record; + expect(Object.keys(value).sort()).toEqual( + ['fontFamily', 'fontSize', 'fontWeight', 'letterSpacing', 'lineHeight'].sort(), + ); + expect(value['fontFamily']).toBe('Manrope'); + expect(value['fontSize']).toEqual({ value: 16, unit: 'px' }); + expect(value['fontWeight']).toBe(400); + expect(value['letterSpacing']).toEqual({ value: 0, unit: 'px' }); + expect(value['lineHeight']).toBe(1.6); + }); + + test('YAML numeric lineHeight round-trips through lint into required DTCG typography props', () => { + const report = lint(`--- +typography: + body-md: + fontFamily: Manrope + fontSize: 16px + fontWeight: 400 + lineHeight: 1.6 +--- +# Spec +`); + const result = handler.execute(report.designSystem); + expect(result.success).toBe(true); + if (!result.success) return; + + const value = ((result.data['typography'] as Record)['body-md'] as Record)['$value'] as Record; + expect(Object.keys(value).sort()).toEqual( + ['fontFamily', 'fontSize', 'fontWeight', 'letterSpacing', 'lineHeight'].sort(), + ); + expect(value['lineHeight']).toBe(1.6); + expect(value['letterSpacing']).toEqual({ value: 0, unit: 'px' }); }); }); diff --git a/packages/cli/src/linter/dtcg/handler.ts b/packages/cli/src/linter/dtcg/handler.ts index f4418226..0d90cb76 100644 --- a/packages/cli/src/linter/dtcg/handler.ts +++ b/packages/cli/src/linter/dtcg/handler.ts @@ -17,6 +17,9 @@ import type { DesignSystemState, ResolvedColor, ResolvedDimension, ResolvedTypog const DTCG_SCHEMA_URL = 'https://www.designtokens.org/schemas/2025.10/format.json'; +/** DESIGN.md has no required letterSpacing field; DTCG typography requires one. */ +const DEFAULT_LETTER_SPACING: DtcgDimensionValue = { value: 0, unit: 'px' }; + /** * Pure function mapping DesignSystemState → DTCG tokens.json (W3C Design Tokens Format Module 2025.10). * No side effects. @@ -101,7 +104,9 @@ export class DtcgEmitterHandler implements DtcgEmitterSpec { if (typo.fontFamily) value.fontFamily = typo.fontFamily; if (typo.fontSize) value.fontSize = this.dimToValue(typo.fontSize); if (typo.fontWeight !== undefined) value.fontWeight = typo.fontWeight; - if (typo.letterSpacing) value.letterSpacing = this.dimToValue(typo.letterSpacing); + value.letterSpacing = typo.letterSpacing + ? this.dimToValue(typo.letterSpacing) + : DEFAULT_LETTER_SPACING; if (typo.lineHeight) { // DTCG lineHeight is a unitless multiplier of fontSize. // Our model stores it as a ResolvedDimension. Convert if possible. diff --git a/packages/cli/src/linter/model/handler.test.ts b/packages/cli/src/linter/model/handler.test.ts index e4023074..43fa79cd 100644 --- a/packages/cli/src/linter/model/handler.test.ts +++ b/packages/cli/src/linter/model/handler.test.ts @@ -476,6 +476,28 @@ describe('ModelHandler', () => { expect(headline?.fontWeight).toBe(700); }); + it('keeps unquoted YAML numeric lineHeight as a unitless multiplier', () => { + const result = handler.execute(makeParsed({ + typography: { + 'body-md': { fontFamily: 'Inter', fontSize: '16px', fontWeight: 400, lineHeight: 1.6 }, + }, + })); + const body = result.designSystem.typography.get('body-md'); + expect(body?.lineHeight?.value).toBe(1.6); + expect(body?.lineHeight?.unit).toBe(''); + expect(result.findings.filter((f) => f.path?.includes('lineHeight') === true)).toHaveLength(0); + }); + + it('rejects unquoted YAML numeric fontSize because a unit is required', () => { + const result = handler.execute(makeParsed({ + typography: { + 'body-md': { fontFamily: 'Inter', fontSize: 16, fontWeight: 400 }, + }, + })); + expect(result.designSystem.typography.get('body-md')?.fontSize).toBeUndefined(); + expect(result.findings.some((f) => f.path === 'typography.body-md.fontSize' && f.severity === 'error')).toBe(true); + }); + it('warns about unrecognized typography sub-properties that are silently dropped', () => { const result = handler.execute(makeParsed({ typography: { diff --git a/packages/cli/src/linter/model/handler.ts b/packages/cli/src/linter/model/handler.ts index 9ea1193b..1ac6b291 100644 --- a/packages/cli/src/linter/model/handler.ts +++ b/packages/cli/src/linter/model/handler.ts @@ -377,6 +377,25 @@ function parseTypography(props: Record, path: string, f const dimensionProps = ['fontSize', 'lineHeight', 'letterSpacing'] as const; for (const prop of dimensionProps) { const raw = props[prop]; + if (typeof raw === 'number' && Number.isFinite(raw)) { + // YAML parses unquoted unitless values as numbers. lineHeight is a + // unitless multiplier in both DESIGN.md and DTCG; other dimension + // properties still require an explicit unit. + if (prop === 'lineHeight') { + result[prop] = { + type: 'dimension', + value: raw, + unit: '', + }; + } else { + findings.push({ + severity: 'error', + path: `${path}.${prop}`, + message: `'${raw}' is not a valid dimension. Include a unit (px, rem, or em).`, + }); + } + continue; + } if (typeof raw === 'string') { if (isParseableDimension(raw)) { const parsed = parseDimension(raw);