diff --git a/packages/design-system/src/tokens/components.ts b/packages/design-system/src/tokens/components.ts new file mode 100644 index 0000000..da03e0e --- /dev/null +++ b/packages/design-system/src/tokens/components.ts @@ -0,0 +1,88 @@ +import type { TokenCollection } from './types'; + +export const componentTokens = { + // Button + 'button.font.weight': { $type: 'fontWeight' as const, $value: '{font.weight.semibold}' }, + 'button.radius': { $type: 'dimension' as const, $value: '{radius.md}' }, + 'button.padding.x': { $type: 'dimension' as const, $value: '{space.inset.lg}' }, + 'button.padding.y': { $type: 'dimension' as const, $value: '{space.inset.sm}' }, + 'button.primary.bg': { $type: 'color' as const, $value: '{color.action.primary}' }, + 'button.primary.text': { $type: 'color' as const, $value: '{color.text.inverse}' }, + 'button.primary.hover.bg': { $type: 'color' as const, $value: '{color.action.primaryHover}' }, + 'button.secondary.bg': { $type: 'color' as const, $value: '{color.surface.default}' }, + 'button.secondary.text': { $type: 'color' as const, $value: '{color.text.primary}' }, + 'button.secondary.border': { $type: 'color' as const, $value: '{color.border.default}' }, + 'button.danger.bg': { $type: 'color' as const, $value: '{color.action.danger}' }, + 'button.danger.text': { $type: 'color' as const, $value: '{color.text.inverse}' }, + + // IconButton + 'iconButton.size.sm': { $type: 'dimension' as const, $value: '{size.control.sm}' }, + 'iconButton.size.md': { $type: 'dimension' as const, $value: '{size.control.md}' }, + + // Field + 'field.radius': { $type: 'dimension' as const, $value: '{radius.md}' }, + 'field.border': { $type: 'color' as const, $value: '{color.border.default}' }, + 'field.border.focus': { $type: 'color' as const, $value: '{color.border.focus}' }, + 'field.border.error': { $type: 'color' as const, $value: '{color.feedback.error}' }, + 'field.label.weight': { $type: 'fontWeight' as const, $value: '{font.weight.medium}' }, + + // Checkbox + 'checkbox.radius': { $type: 'dimension' as const, $value: '{radius.sm}' }, + + // Surface + 'surface.radius': { $type: 'dimension' as const, $value: '{radius.lg}' }, + 'surface.padding': { $type: 'dimension' as const, $value: '{space.inset.lg}' }, + + // Card + 'card.radius': { $type: 'dimension' as const, $value: '{radius.lg}' }, + 'card.shadow': { $type: 'shadow' as const, $value: '{shadow.sm}' }, + 'card.padding': { $type: 'dimension' as const, $value: '{space.inset.lg}' }, + + // Chip + 'chip.radius': { $type: 'dimension' as const, $value: '{radius.pill}' }, + 'chip.height': { $type: 'dimension' as const, $value: '{size.control.sm}' }, + + // Badge + 'badge.radius': { $type: 'dimension' as const, $value: '{radius.pill}' }, + 'badge.font.size': { $type: 'dimension' as const, $value: '{font.size.200}' }, + + // Alert + 'alert.radius': { $type: 'dimension' as const, $value: '{radius.md}' }, + 'alert.info.bg': { $type: 'color' as const, $value: '{color.blue.50}' }, + 'alert.info.border': { $type: 'color' as const, $value: '{color.feedback.info}' }, + 'alert.success.bg': { $type: 'color' as const, $value: '{color.green.50}' }, + 'alert.success.border': { $type: 'color' as const, $value: '{color.feedback.success}' }, + 'alert.warning.bg': { $type: 'color' as const, $value: '{color.amber.50}' }, + 'alert.warning.border': { $type: 'color' as const, $value: '{color.feedback.warning}' }, + 'alert.error.bg': { $type: 'color' as const, $value: '{color.red.50}' }, + 'alert.error.border': { $type: 'color' as const, $value: '{color.feedback.error}' }, + + // Dialog + 'dialog.radius': { $type: 'dimension' as const, $value: '{radius.lg}' }, + 'dialog.shadow': { $type: 'shadow' as const, $value: '{shadow.md}' }, + 'dialog.padding': { $type: 'dimension' as const, $value: '{space.6}' }, + + // Skeleton + 'skeleton.radius': { $type: 'dimension' as const, $value: '{radius.sm}' }, + + // Progress + 'progress.height': { $type: 'dimension' as const, $value: '{space.1}' }, + 'progress.radius': { $type: 'dimension' as const, $value: '{radius.pill}' }, + 'progress.bg': { $type: 'color' as const, $value: '{color.neutral.100}' }, + 'progress.fill': { $type: 'color' as const, $value: '{color.action.primary}' }, + + // Table + 'table.header.weight': { $type: 'fontWeight' as const, $value: '{font.weight.semibold}' }, + 'table.cell.padding': { $type: 'dimension' as const, $value: '{space.inset.sm}' }, + 'table.row.hover.bg': { $type: 'color' as const, $value: '{color.surface.subtle}' }, + + // Metric + 'metric.label.size': { $type: 'dimension' as const, $value: '{font.size.200}' }, + 'metric.label.weight': { $type: 'fontWeight' as const, $value: '{font.weight.medium}' }, + 'metric.value.weight': { $type: 'fontWeight' as const, $value: '{font.weight.bold}' }, + 'metric.gap': { $type: 'dimension' as const, $value: '{space.1}' }, + + // PageState + 'pageState.icon.size': { $type: 'dimension' as const, $value: '{space.12}' }, + 'pageState.title.gap': { $type: 'dimension' as const, $value: '{space.4}' }, +} as const satisfies TokenCollection; diff --git a/packages/design-system/src/tokens/index.ts b/packages/design-system/src/tokens/index.ts new file mode 100644 index 0000000..40b3e8c --- /dev/null +++ b/packages/design-system/src/tokens/index.ts @@ -0,0 +1,11 @@ +export { primitiveTokens } from './primitives'; +export { semanticLightTokens } from './semantic.light'; +export { componentTokens } from './components'; +export { resolveToken } from './resolveToken'; +export type { + TokenType, + TokenValue, + TokenDefinition, + TokenCollection, + ResolvedToken, +} from './types'; diff --git a/packages/design-system/src/tokens/primitives.ts b/packages/design-system/src/tokens/primitives.ts new file mode 100644 index 0000000..987c84e --- /dev/null +++ b/packages/design-system/src/tokens/primitives.ts @@ -0,0 +1,103 @@ +import type { TokenCollection } from './types'; + +export const primitiveTokens = { + // Colors — neutral + 'color.neutral.0': { $type: 'color' as const, $value: '#ffffff' }, + 'color.neutral.50': { $type: 'color' as const, $value: '#f5f5f5' }, + 'color.neutral.100': { $type: 'color' as const, $value: '#e8e8e8' }, + 'color.neutral.200': { $type: 'color' as const, $value: '#d1d1d1' }, + 'color.neutral.400': { $type: 'color' as const, $value: '#a3a3a3' }, + 'color.neutral.600': { $type: 'color' as const, $value: '#666666' }, + 'color.neutral.800': { $type: 'color' as const, $value: '#333333' }, + 'color.neutral.900': { $type: 'color' as const, $value: '#1a1a1a' }, + + // Colors — green (positive) + 'color.green.50': { $type: 'color' as const, $value: '#e8f5e9' }, + 'color.green.100': { $type: 'color' as const, $value: '#c8e6c9' }, + 'color.green.600': { $type: 'color' as const, $value: '#2e7d32' }, + 'color.green.700': { $type: 'color' as const, $value: '#176747' }, + 'color.green.800': { $type: 'color' as const, $value: '#1b5e20' }, + + // Colors — red (negative) + 'color.red.50': { $type: 'color' as const, $value: '#ffebee' }, + 'color.red.600': { $type: 'color' as const, $value: '#e53935' }, + 'color.red.700': { $type: 'color' as const, $value: '#c62828' }, + + // Colors — amber (warning) + 'color.amber.50': { $type: 'color' as const, $value: '#fff8e1' }, + 'color.amber.600': { $type: 'color' as const, $value: '#ffb300' }, + + // Colors — blue (info) + 'color.blue.50': { $type: 'color' as const, $value: '#e3f2fd' }, + 'color.blue.600': { $type: 'color' as const, $value: '#1e88e5' }, + + // Spacing (4px grid) + 'space.0': { $type: 'dimension' as const, $value: '0px' }, + 'space.1': { $type: 'dimension' as const, $value: '4px' }, + 'space.2': { $type: 'dimension' as const, $value: '8px' }, + 'space.3': { $type: 'dimension' as const, $value: '12px' }, + 'space.4': { $type: 'dimension' as const, $value: '16px' }, + 'space.5': { $type: 'dimension' as const, $value: '20px' }, + 'space.6': { $type: 'dimension' as const, $value: '24px' }, + 'space.8': { $type: 'dimension' as const, $value: '32px' }, + 'space.10': { $type: 'dimension' as const, $value: '40px' }, + 'space.12': { $type: 'dimension' as const, $value: '48px' }, + + // Border radius + 'radius.none': { $type: 'dimension' as const, $value: '0px' }, + 'radius.sm': { $type: 'dimension' as const, $value: '4px' }, + 'radius.md': { $type: 'dimension' as const, $value: '8px' }, + 'radius.lg': { $type: 'dimension' as const, $value: '12px' }, + 'radius.pill': { $type: 'dimension' as const, $value: '9999px' }, + + // Font family + 'font.family.sans': { + $type: 'fontFamily' as const, + $value: ['Inter', 'system-ui', '-apple-system', 'sans-serif'], + }, + 'font.family.mono': { + $type: 'fontFamily' as const, + $value: ['SFMono-Regular', 'Menlo', 'Monaco', 'Consolas', 'monospace'], + }, + + // Font size (100 = 10px, 700 = 48px scale) + 'font.size.100': { $type: 'dimension' as const, $value: '10px' }, + 'font.size.200': { $type: 'dimension' as const, $value: '12px' }, + 'font.size.300': { $type: 'dimension' as const, $value: '14px' }, + 'font.size.400': { $type: 'dimension' as const, $value: '16px' }, + 'font.size.500': { $type: 'dimension' as const, $value: '20px' }, + 'font.size.600': { $type: 'dimension' as const, $value: '24px' }, + 'font.size.700': { $type: 'dimension' as const, $value: '32px' }, + + // Font weight + 'font.weight.regular': { $type: 'fontWeight' as const, $value: 400 }, + 'font.weight.medium': { $type: 'fontWeight' as const, $value: 500 }, + 'font.weight.semibold': { $type: 'fontWeight' as const, $value: 600 }, + 'font.weight.bold': { $type: 'fontWeight' as const, $value: 700 }, + + // Line height + 'lineHeight.tight': { $type: 'dimension' as const, $value: '1.25' }, + 'lineHeight.normal': { $type: 'dimension' as const, $value: '1.5' }, + 'lineHeight.relaxed': { $type: 'dimension' as const, $value: '1.75' }, + + // Shadows + 'shadow.none': { $type: 'shadow' as const, $value: 'none' }, + 'shadow.sm': { $type: 'shadow' as const, $value: '0px 1px 2px 0px rgba(0,0,0,0.08)' }, + 'shadow.md': { $type: 'shadow' as const, $value: '0px 4px 8px 0px rgba(0,0,0,0.12)' }, + + // Duration + 'duration.instant': { $type: 'duration' as const, $value: 0 }, + 'duration.fast': { $type: 'duration' as const, $value: 150 }, + 'duration.normal': { $type: 'duration' as const, $value: 250 }, + + // Easing + 'easing.standard': { $type: 'cubicBezier' as const, $value: [0.4, 0, 0.2, 1] as const }, + + // Control sizes + 'size.control.sm': { $type: 'dimension' as const, $value: '32px' }, + 'size.control.md': { $type: 'dimension' as const, $value: '40px' }, + + // Focus ring + 'size.focus.ring': { $type: 'dimension' as const, $value: '2px' }, + 'size.focus.offset': { $type: 'dimension' as const, $value: '2px' }, +} as const satisfies TokenCollection; diff --git a/packages/design-system/src/tokens/semantic.light.ts b/packages/design-system/src/tokens/semantic.light.ts new file mode 100644 index 0000000..0cca2c9 --- /dev/null +++ b/packages/design-system/src/tokens/semantic.light.ts @@ -0,0 +1,61 @@ +import type { TokenCollection } from './types'; + +export const semanticLightTokens = { + // Canvas + 'color.canvas': { $type: 'color' as const, $value: '{color.neutral.50}' }, + + // Surface + 'color.surface.default': { $type: 'color' as const, $value: '{color.neutral.0}' }, + 'color.surface.subtle': { $type: 'color' as const, $value: '{color.neutral.50}' }, + 'color.surface.raised': { $type: 'color' as const, $value: '{color.neutral.0}' }, + + // Text + 'color.text.primary': { $type: 'color' as const, $value: '{color.neutral.900}' }, + 'color.text.secondary': { $type: 'color' as const, $value: '{color.neutral.600}' }, + 'color.text.disabled': { $type: 'color' as const, $value: '{color.neutral.400}' }, + 'color.text.inverse': { $type: 'color' as const, $value: '{color.neutral.0}' }, + + // Border + 'color.border.subtle': { $type: 'color' as const, $value: '{color.neutral.100}' }, + 'color.border.default': { $type: 'color' as const, $value: '{color.neutral.200}' }, + 'color.border.strong': { $type: 'color' as const, $value: '{color.neutral.400}' }, + 'color.border.focus': { $type: 'color' as const, $value: '{color.blue.600}' }, + + // Action + 'color.action.primary': { $type: 'color' as const, $value: '{color.green.700}' }, + 'color.action.primaryHover': { $type: 'color' as const, $value: '{color.green.800}' }, + 'color.action.secondary': { $type: 'color' as const, $value: '{color.neutral.600}' }, + 'color.action.danger': { $type: 'color' as const, $value: '{color.red.600}' }, + + // Feedback + 'color.feedback.info': { $type: 'color' as const, $value: '{color.blue.600}' }, + 'color.feedback.success': { $type: 'color' as const, $value: '{color.green.600}' }, + 'color.feedback.warning': { $type: 'color' as const, $value: '{color.amber.600}' }, + 'color.feedback.error': { $type: 'color' as const, $value: '{color.red.600}' }, + + // Finance + 'color.finance.positive': { $type: 'color' as const, $value: '{color.green.700}' }, + 'color.finance.negative': { $type: 'color' as const, $value: '{color.red.700}' }, + 'color.finance.neutral': { $type: 'color' as const, $value: '{color.neutral.600}' }, + + // Typography + 'font.family.body': { $type: 'fontFamily' as const, $value: '{font.family.sans}' }, + 'font.weight.body': { $type: 'fontWeight' as const, $value: '{font.weight.regular}' }, + 'font.weight.heading': { $type: 'fontWeight' as const, $value: '{font.weight.semibold}' }, + 'font.weight.strong': { $type: 'fontWeight' as const, $value: '{font.weight.bold}' }, + // Spacing + 'space.inset.sm': { $type: 'dimension' as const, $value: '{space.2}' }, + 'space.inset.md': { $type: 'dimension' as const, $value: '{space.3}' }, + 'space.inset.lg': { $type: 'dimension' as const, $value: '{space.4}' }, + 'space.stack.sm': { $type: 'dimension' as const, $value: '{space.1}' }, + 'space.stack.md': { $type: 'dimension' as const, $value: '{space.2}' }, + 'space.stack.lg': { $type: 'dimension' as const, $value: '{space.4}' }, + 'space.inline.sm': { $type: 'dimension' as const, $value: '{space.1}' }, + 'space.inline.md': { $type: 'dimension' as const, $value: '{space.2}' }, + 'space.inline.lg': { $type: 'dimension' as const, $value: '{space.3}' }, + + // Focus ring + 'focus.ring.width': { $type: 'dimension' as const, $value: '{size.focus.ring}' }, + 'focus.ring.offset': { $type: 'dimension' as const, $value: '{size.focus.offset}' }, + 'focus.ring.color': { $type: 'color' as const, $value: '{color.blue.600}' }, +} as const satisfies TokenCollection; diff --git a/packages/design-system/src/tokens/tokens.test.ts b/packages/design-system/src/tokens/tokens.test.ts new file mode 100644 index 0000000..a473d6d --- /dev/null +++ b/packages/design-system/src/tokens/tokens.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect } from 'vitest'; +import { primitiveTokens } from './primitives'; +import { semanticLightTokens } from './semantic.light'; +import { componentTokens } from './components'; +import { resolveToken } from './resolveToken'; + +const aliasPattern = /^\{([^}]+)\}$/; +const isAlias = (value: string | number | readonly number[] | readonly string[]): boolean => + typeof value === 'string' && aliasPattern.test(value); + +const allTokens = { + ...primitiveTokens, + ...semanticLightTokens, + ...componentTokens, +} as const; + +describe('token integrity', () => { + it('primitives contain no aliases', () => { + for (const [name, def] of Object.entries(primitiveTokens)) { + expect(isAlias(def.$value), `${name} should not be an alias`).toBe(false); + } + }); + + it('semantic tokens only use aliases', () => { + for (const [name, def] of Object.entries(semanticLightTokens)) { + expect(isAlias(def.$value), `${name} should be an alias`).toBe(true); + } + }); + + it('component tokens only use aliases', () => { + for (const [name, def] of Object.entries(componentTokens)) { + expect(isAlias(def.$value), `${name} should be an alias`).toBe(true); + } + }); + + it('all tokens resolve without errors', () => { + for (const name of Object.keys(allTokens)) { + expect(() => resolveToken(allTokens, name), `Failed to resolve ${name}`).not.toThrow(); + } + }); + + it('has unique names across all collections', () => { + const allNames = [ + ...Object.keys(primitiveTokens), + ...Object.keys(semanticLightTokens), + ...Object.keys(componentTokens), + ]; + const uniqueNames = new Set(allNames); + expect(uniqueNames.size).toBe(allNames.length); + }); + + it('semantic tokens reference only primitive tokens', () => { + const primitiveNames = new Set(Object.keys(primitiveTokens)); + for (const [name, def] of Object.entries(semanticLightTokens)) { + if (typeof def.$value === 'string' && aliasPattern.test(def.$value)) { + const target = def.$value.match(aliasPattern)![1]; + expect(primitiveNames.has(target), `${name} references non-primitive "${target}"`).toBe( + true, + ); + } + } + }); + + it('component tokens avoid direct primitive value literals', () => { + const literalPattern = /(px|#|rgba|rgb)/; + for (const [name, def] of Object.entries(componentTokens)) { + if (typeof def.$value === 'string') { + expect(literalPattern.test(def.$value), `${name} contains a literal value`).toBe(false); + } + } + }); +});