Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 107 additions & 14 deletions bun.lock

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
{
"name": "design-monorepo",
"private": true,
"packageManager": "bun@1.1.20",
"workspaces": [
"packages/*"
],
Expand Down
10 changes: 5 additions & 5 deletions packages/cli/src/linter/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,11 +72,11 @@ The palette uses a deep "Evergreen" primary for health-sector credibility.
// ── Tailwind assertions ─────────────────────────────────────────
expect(result.tailwindConfig.success).toBe(true);
if (result.tailwindConfig.success) {
const config = result.tailwindConfig.data;
expect(config.theme?.extend?.colors?.['primary']).toBe('#647d66');
expect(config.theme?.extend?.fontFamily?.['headline-lg']).toContain('Google Sans Display');
expect(config.theme?.extend?.borderRadius?.['full']).toBe('9999px');
expect(config.theme?.extend?.spacing?.['gutter-s']).toBe('8px');
const css = result.tailwindConfig.data;
expect(css).toContain('--color-primary: #647d66;');
expect(css).toContain("--typography-headline-lg-font-family: Google Sans Display;");
expect(css).toContain('--border-radius-full: 9999px;');
expect(css).toContain('--spacing-gutter-s: 8px;');
}
});

Expand Down
138 changes: 44 additions & 94 deletions packages/cli/src/linter/tailwind/handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,103 +13,53 @@
// limitations under the License.

import { describe, it, expect } from 'bun:test';
import type { DesignSystemState } from '../model/spec.js';
import { TailwindEmitterHandler } from './handler.js';
import { ModelHandler } from '../model/handler.js';
import type { ParsedDesignSystem } from '../parser/spec.js';

const emitter = new TailwindEmitterHandler();
const modelHandler = new ModelHandler();
describe('TailwindV4EmitterHandler', () => {
it('should export design tokens to Tailwind v4 CSS variables', () => {
const mockState: DesignSystemState = {
name: 'Test Design System',
description: 'A test',
findings: [],
symbolTable: new Map(),
sections: [],
colors: new Map([
['primary', { type: 'color', hex: '#1A1C1E', r: 26, g: 28, b: 30, a: 1, luminance: 0.005 }],
['secondary', { type: 'color', hex: '#6C7278', r: 108, g: 114, b: 120, a: 1, luminance: 0.17 }],
]),
typography: new Map([
['h1', {
type: 'typography',
fontFamily: 'Public Sans',
fontSize: { type: 'dimension', value: 3, unit: 'rem' },
}],
]),
spacing: new Map([
['sm', { type: 'dimension', value: 8, unit: 'px' }],
]),
rounded: new Map([
['md', { type: 'dimension', value: 8, unit: 'px' }],
]),
components: new Map(),
};

function buildState(overrides: Partial<ParsedDesignSystem> = {}) {
const parsed: ParsedDesignSystem = { sourceMap: new Map(), ...overrides };
const result = modelHandler.execute(parsed);
const hasErrors = result.findings.some(d => d.severity === 'error');
if (hasErrors) {
throw new Error(`Model build failed: ${result.findings.map(d => d.message).join(', ')}`);
}
return result.designSystem;
}
const handler = new TailwindEmitterHandler();
const result = handler.execute(mockState);

describe('TailwindEmitterHandler', () => {
// ── Cycle 22: Colors map to theme.extend.colors ─────────────────
describe('colors mapping', () => {
it('maps resolved colors to theme.extend.colors', () => {
const state = buildState({
colors: { primary: '#647D66', secondary: '#ff0000' },
});
const result = emitter.execute(state);
if (!result.success) throw new Error('Expected success');
const config = result.data;
expect(config.theme.extend.colors?.['primary']).toBe('#647d66');
expect(config.theme.extend.colors?.['secondary']).toBe('#ff0000');
});
});

// ── Cycle 23: Typography maps to fontFamily + fontSize ──────────
describe('typography mapping', () => {
it('maps typography scales to fontFamily and fontSize', () => {
const state = buildState({
typography: {
'headline-lg': {
fontFamily: 'Google Sans Display',
fontSize: '42px',
fontWeight: 500,
lineHeight: '50px',
letterSpacing: '1.2px',
},
'body-lg': {
fontFamily: 'Roboto',
fontSize: '14px',
fontWeight: 400,
lineHeight: '20px',
},
},
});
const result = emitter.execute(state);
if (!result.success) throw new Error('Expected success');
const config = result.data;

// fontFamily
expect(config.theme.extend.fontFamily?.['headline-lg']).toContain('Google Sans Display');
expect(config.theme.extend.fontFamily?.['body-lg']).toContain('Roboto');

// fontSize with metadata tuple
const hlFontSize = config.theme.extend.fontSize?.['headline-lg'];
expect(hlFontSize).toBeDefined();
expect(hlFontSize?.[0]).toBe('42px');
expect(hlFontSize?.[1]?.['lineHeight']).toBe('50px');
expect(hlFontSize?.[1]?.['letterSpacing']).toBe('1.2px');
});
});

// ── Cycle 24: Rounded + spacing map correctly ───────────────────
describe('dimensions mapping', () => {
it('maps rounded to borderRadius and spacing to spacing', () => {
const state = buildState({
rounded: { regular: '4px', lg: '8px', full: '9999px' },
spacing: { 'gutter-s': '8px', 'gutter-l': '16px' },
});
const result = emitter.execute(state);
if (!result.success) throw new Error('Expected success');
const config = result.data;

expect(config.theme.extend.borderRadius?.['regular']).toBe('4px');
expect(config.theme.extend.borderRadius?.['lg']).toBe('8px');
expect(config.theme.extend.borderRadius?.['full']).toBe('9999px');

expect(config.theme.extend.spacing?.['gutter-s']).toBe('8px');
expect(config.theme.extend.spacing?.['gutter-l']).toBe('16px');
});
});

// ── Empty state produces empty config ─────────────────────────────
describe('empty state', () => {
it('produces a valid config with empty extend sections', () => {
const state = buildState({});
const result = emitter.execute(state);
if (!result.success) throw new Error('Expected success');
const config = result.data;
expect(config.theme.extend).toBeDefined();
});
expect(result.success).toBe(true);
if (result.success) {
const expectedCss = [
'@theme {',
' --color-primary: #1A1C1E;',
' --color-secondary: #6C7278;',
" --typography-h1-font-family: Public Sans;",
" --typography-h1-font-size: 3rem;",
' --spacing-sm: 8px;',
' --border-radius-md: 8px;',
'}',
].join('\n');
expect(result.data).toEqual(expectedCss);
}
});
});
72 changes: 23 additions & 49 deletions packages/cli/src/linter/tailwind/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,71 +13,45 @@
// limitations under the License.

import type { TailwindEmitterSpec, TailwindEmitterResult } from './spec.js';
import type { DesignSystemState, ResolvedDimension } from '../model/spec.js';
import type { DesignSystemState } from '../model/spec.js';

function toKebabCase(s: string) {
return s
.replace(/([a-z0-9]|(?=[A-Z]))([A-Z])/g, '$1-$2')
.toLowerCase()
.replace(/^-/, '');
}

/**
* Pure function mapping DesignSystemState → Tailwind theme.extend config.
* Pure function mapping DesignSystemState → Tailwind v4 CSS variables.
* No side effects.
*/
export class TailwindEmitterHandler implements TailwindEmitterSpec {
execute(state: DesignSystemState): TailwindEmitterResult {
return {
success: true,
data: {
theme: {
extend: {
colors: this.mapColors(state),
fontFamily: this.mapFontFamilies(state),
fontSize: this.mapFontSizes(state),
borderRadius: this.mapDimensions(state.rounded),
spacing: this.mapDimensions(state.spacing),
},
},
}
};
}
const lines: string[] = ['@theme {'];

private mapColors(state: DesignSystemState): Record<string, string> {
const result: Record<string, string> = {};
for (const [name, color] of state.colors) {
result[name] = color.hex;
lines.push(` --color-${toKebabCase(name)}: ${color.hex};`);
}
return result;
}

private mapFontFamilies(state: DesignSystemState): Record<string, string[]> {
const result: Record<string, string[]> = {};
for (const [name, typo] of state.typography) {
if (typo.fontFamily) {
result[name] = [typo.fontFamily];
}
const prefix = ` --typography-${toKebabCase(name)}`;
if (typo.fontFamily) lines.push(`${prefix}-font-family: ${typo.fontFamily};`);
if (typo.fontSize) lines.push(`${prefix}-font-size: ${typo.fontSize.value}${typo.fontSize.unit};`);
if (typo.fontWeight) lines.push(`${prefix}-font-weight: ${typo.fontWeight};`);
if (typo.letterSpacing) lines.push(`${prefix}-letter-spacing: ${typo.letterSpacing.value}${typo.letterSpacing.unit};`);
if (typo.lineHeight) lines.push(`${prefix}-line-height: ${typo.lineHeight.value}${typo.lineHeight.unit || ''};`);
}
return result;
}

private mapFontSizes(state: DesignSystemState): Record<string, [string, Record<string, string>]> {
const result: Record<string, [string, Record<string, string>]> = {};
for (const [name, typo] of state.typography) {
if (typo.fontSize) {
const meta: Record<string, string> = {};
if (typo.lineHeight) meta['lineHeight'] = this.dimToString(typo.lineHeight);
if (typo.letterSpacing) meta['letterSpacing'] = this.dimToString(typo.letterSpacing);
if (typo.fontWeight !== undefined) meta['fontWeight'] = String(typo.fontWeight);
result[name] = [this.dimToString(typo.fontSize), meta];
}
for (const [name, dim] of state.spacing) {
lines.push(` --spacing-${toKebabCase(name)}: ${dim.value}${dim.unit};`);
}
return result;
}

private mapDimensions(dims: Map<string, { value: number; unit: string }>): Record<string, string> {
const result: Record<string, string> = {};
for (const [name, dim] of dims) {
result[name] = this.dimToString(dim);
for (const [name, dim] of state.rounded) {
lines.push(` --border-radius-${toKebabCase(name)}: ${dim.value}${dim.unit};`);
}
return result;
}

private dimToString(dim: { value: number; unit: string }): string {
return `${dim.value}${dim.unit}`;
lines.push('}');
return { success: true, data: lines.join('\n') };
}
}
13 changes: 3 additions & 10 deletions packages/cli/src/linter/tailwind/spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,19 +32,12 @@ export type TailwindThemeExtend = z.infer<typeof TailwindThemeExtendSchema>;
export const TailwindEmitterResultSchema = z.discriminatedUnion('success', [
z.object({
success: z.literal(true),
data: z.object({
theme: z.object({
extend: TailwindThemeExtendSchema
})
})
data: z.string(),
}),
z.object({
success: z.literal(false),
error: z.object({
code: z.string(),
message: z.string()
})
})
error: z.instanceof(Error),
}),
]);

export type TailwindEmitterResult = z.infer<typeof TailwindEmitterResultSchema>;
Expand Down