designlang 4.0.1 → 6.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +66 -5
  2. package/bin/design-extract.js +269 -70
  3. package/package.json +9 -4
  4. package/src/apply.js +65 -0
  5. package/src/config.js +36 -0
  6. package/src/crawler.js +247 -82
  7. package/src/darkdiff.js +65 -0
  8. package/src/extractors/animations.js +76 -8
  9. package/src/extractors/borders.js +40 -5
  10. package/src/extractors/components.js +100 -1
  11. package/src/extractors/fonts.js +82 -0
  12. package/src/extractors/gradients.js +100 -0
  13. package/src/extractors/icons.js +80 -0
  14. package/src/extractors/images.js +76 -0
  15. package/src/extractors/shadows.js +60 -17
  16. package/src/extractors/spacing.js +31 -2
  17. package/src/extractors/variables.js +20 -1
  18. package/src/extractors/zindex.js +65 -0
  19. package/src/formatters/figma.js +66 -47
  20. package/src/formatters/markdown.js +98 -0
  21. package/src/formatters/preview.js +65 -22
  22. package/src/formatters/svelte-theme.js +40 -0
  23. package/src/formatters/tailwind.js +57 -4
  24. package/src/formatters/theme.js +134 -0
  25. package/src/formatters/vue-theme.js +44 -0
  26. package/src/formatters/wordpress.js +84 -0
  27. package/src/history.js +8 -1
  28. package/src/index.js +54 -16
  29. package/src/utils.js +68 -0
  30. package/tests/cli.test.js +34 -0
  31. package/tests/extractors.test.js +661 -0
  32. package/tests/formatters.test.js +477 -0
  33. package/tests/utils.test.js +413 -0
  34. package/website/app/api/extract/route.js +85 -0
  35. package/website/app/components/Extractor.js +184 -0
  36. package/website/app/globals.css +291 -0
  37. package/website/app/page.js +13 -0
  38. package/website/next.config.mjs +10 -1
  39. package/website/package-lock.json +356 -0
  40. package/website/package.json +4 -1
@@ -46,15 +46,149 @@ export function formatReactTheme(design) {
46
46
  theme.shadows[s.label] = s.raw;
47
47
  }
48
48
 
49
+ // Component variant tokens for interactive states
50
+ theme.states = {
51
+ hover: { opacity: 0.08 },
52
+ focus: { opacity: 0.12 },
53
+ active: { opacity: 0.16 },
54
+ disabled: { opacity: 0.38 },
55
+ };
56
+
57
+ // Build MUI v5 theme
58
+ const muiTheme = buildMuiTheme(design);
59
+
60
+ // Build TypeScript type definition comment
61
+ const tsType = buildTypeComment(theme);
62
+
49
63
  return `// React Theme — extracted from ${design.meta.url}
50
64
  // Compatible with: Chakra UI, Stitches, Vanilla Extract, or any CSS-in-JS
51
65
 
66
+ ${tsType}
67
+
52
68
  export const theme = ${JSON.stringify(theme, null, 2)};
53
69
 
70
+ // MUI v5 theme
71
+ export const muiTheme = ${JSON.stringify(muiTheme, null, 2)};
72
+
54
73
  export default theme;
55
74
  `;
56
75
  }
57
76
 
77
+ function buildTypeComment(theme) {
78
+ const colorKeys = Object.keys(theme.colors || {}).map(k => ` ${k}: string;`).join('\n');
79
+ const fontKeys = Object.keys(theme.fonts || {}).map(k => ` ${k}: string;`).join('\n');
80
+ const sizeKeys = Object.keys(theme.fontSizes || {}).map(k => ` '${k}': string;`).join('\n');
81
+ const spaceKeys = Object.keys(theme.space || {}).map(k => ` '${k}': string;`).join('\n');
82
+ const radiiKeys = Object.keys(theme.radii || {}).map(k => ` ${k}: string;`).join('\n');
83
+ const shadowKeys = Object.keys(theme.shadows || {}).map(k => ` ${k}: string;`).join('\n');
84
+
85
+ return `/**
86
+ * TypeScript type definition for this theme:
87
+ *
88
+ * interface Theme {
89
+ * colors: {
90
+ ${colorKeys}
91
+ * };
92
+ * fonts: {
93
+ ${fontKeys}
94
+ * };
95
+ * fontSizes: {
96
+ ${sizeKeys}
97
+ * };
98
+ * space: {
99
+ ${spaceKeys}
100
+ * };
101
+ * radii: {
102
+ ${radiiKeys}
103
+ * };
104
+ * shadows: {
105
+ ${shadowKeys}
106
+ * };
107
+ * states: {
108
+ * hover: { opacity: number };
109
+ * focus: { opacity: number };
110
+ * active: { opacity: number };
111
+ * disabled: { opacity: number };
112
+ * };
113
+ * }
114
+ */`;
115
+ }
116
+
117
+ function buildMuiTheme(design) {
118
+ const { colors, typography, borders, shadows } = design;
119
+ const mui = { palette: {}, typography: {}, shape: {}, shadows: [] };
120
+
121
+ // Palette
122
+ if (colors.primary) {
123
+ mui.palette.primary = { main: colors.primary.hex };
124
+ const pHsl = toHslParts(colors.primary.hex);
125
+ if (pHsl) {
126
+ mui.palette.primary.light = `hsl(${pHsl.h}, ${pHsl.s}%, ${Math.min(pHsl.l + 15, 95)}%)`;
127
+ mui.palette.primary.dark = `hsl(${pHsl.h}, ${pHsl.s}%, ${Math.max(pHsl.l - 15, 10)}%)`;
128
+ }
129
+ }
130
+ if (colors.secondary) {
131
+ mui.palette.secondary = { main: colors.secondary.hex };
132
+ const sHsl = toHslParts(colors.secondary.hex);
133
+ if (sHsl) {
134
+ mui.palette.secondary.light = `hsl(${sHsl.h}, ${sHsl.s}%, ${Math.min(sHsl.l + 15, 95)}%)`;
135
+ mui.palette.secondary.dark = `hsl(${sHsl.h}, ${sHsl.s}%, ${Math.max(sHsl.l - 15, 10)}%)`;
136
+ }
137
+ }
138
+ mui.palette.background = {};
139
+ if (colors.backgrounds.length > 0) mui.palette.background.default = colors.backgrounds[0];
140
+ if (colors.backgrounds.length > 1) mui.palette.background.paper = colors.backgrounds[1];
141
+ else if (colors.backgrounds.length > 0) mui.palette.background.paper = colors.backgrounds[0];
142
+ mui.palette.text = {};
143
+ if (colors.text.length > 0) mui.palette.text.primary = colors.text[0];
144
+ if (colors.text.length > 1) mui.palette.text.secondary = colors.text[1];
145
+
146
+ // Typography
147
+ const bodyFont = typography.families.find(f => f.usage === 'body');
148
+ const headingFont = typography.families.find(f => f.usage === 'headings');
149
+ mui.typography.fontFamily = bodyFont ? `'${bodyFont.name}', sans-serif` : undefined;
150
+ for (const s of typography.scale.slice(0, 6)) {
151
+ const level = s.size >= 32 ? 'h1' : s.size >= 24 ? 'h2' : s.size >= 20 ? 'h3' : s.size >= 16 ? 'body1' : 'body2';
152
+ mui.typography[level] = {
153
+ fontSize: `${s.size}px`,
154
+ fontWeight: s.weight || 400,
155
+ lineHeight: s.lineHeight || 1.5,
156
+ };
157
+ if (headingFont && level.startsWith('h')) {
158
+ mui.typography[level].fontFamily = `'${headingFont.name}', sans-serif`;
159
+ }
160
+ }
161
+
162
+ // Shape
163
+ if (borders.radii.length > 0) {
164
+ const md = borders.radii.find(r => r.label === 'md') || borders.radii[0];
165
+ mui.shape.borderRadius = md.value;
166
+ }
167
+
168
+ // Shadows (first few)
169
+ mui.shadows = shadows.values.slice(0, 5).map(s => s.raw);
170
+
171
+ return mui;
172
+ }
173
+
174
+ function toHslParts(hex) {
175
+ if (!hex) return null;
176
+ const h = hex.replace('#', '');
177
+ const r = parseInt(h.slice(0, 2), 16) / 255;
178
+ const g = parseInt(h.slice(2, 4), 16) / 255;
179
+ const b = parseInt(h.slice(4, 6), 16) / 255;
180
+ const max = Math.max(r, g, b), min = Math.min(r, g, b);
181
+ const l = (max + min) / 2;
182
+ if (max === min) return { h: 0, s: 0, l: Math.round(l * 100) };
183
+ const d = max - min;
184
+ const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
185
+ let hue;
186
+ if (max === r) hue = ((g - b) / d + (g < b ? 6 : 0)) / 6;
187
+ else if (max === g) hue = ((b - r) / d + 2) / 6;
188
+ else hue = ((r - g) / d + 4) / 6;
189
+ return { h: Math.round(hue * 360), s: Math.round(s * 100), l: Math.round(l * 100) };
190
+ }
191
+
58
192
  export function formatShadcnTheme(design) {
59
193
  const { colors, borders } = design;
60
194
  const lines = ['@layer base {', ' :root {'];
@@ -0,0 +1,44 @@
1
+ import { rgbToHsl } from '../utils.js';
2
+
3
+ export function formatVueTheme(design) {
4
+ const { colors, typography, spacing, borders, shadows } = design;
5
+ const lines = [];
6
+
7
+ lines.push('// Vuetify 3 theme configuration');
8
+ lines.push('// Generated by designlang');
9
+ lines.push('');
10
+ lines.push('export const designlangTheme = {');
11
+ lines.push(' dark: false,');
12
+ lines.push(' colors: {');
13
+ if (colors.primary) lines.push(` primary: '${colors.primary.hex}',`);
14
+ if (colors.secondary) lines.push(` secondary: '${colors.secondary.hex}',`);
15
+ if (colors.accent) lines.push(` accent: '${colors.accent.hex}',`);
16
+ if (colors.backgrounds.length > 0) lines.push(` background: '${colors.backgrounds[0]}',`);
17
+ if (colors.text.length > 0) lines.push(` 'on-background': '${colors.text[0]}',`);
18
+ lines.push(' },');
19
+ lines.push('};');
20
+ lines.push('');
21
+
22
+ // CSS variables for non-Vuetify usage
23
+ lines.push('// CSS custom properties');
24
+ lines.push('export const cssVariables = `');
25
+ lines.push(':root {');
26
+ if (colors.primary) lines.push(` --dl-color-primary: ${colors.primary.hex};`);
27
+ if (colors.secondary) lines.push(` --dl-color-secondary: ${colors.secondary.hex};`);
28
+ for (const [i, n] of colors.neutrals.slice(0, 8).entries()) {
29
+ lines.push(` --dl-color-neutral-${i + 1}: ${n.hex};`);
30
+ }
31
+ for (const [i, val] of spacing.scale.slice(0, 12).entries()) {
32
+ lines.push(` --dl-spacing-${i + 1}: ${val}px;`);
33
+ }
34
+ for (const r of borders.radii) {
35
+ lines.push(` --dl-radius-${r.label}: ${r.value}px;`);
36
+ }
37
+ if (typography.families.length > 0) {
38
+ lines.push(` --dl-font-primary: '${typography.families[0].name}', sans-serif;`);
39
+ }
40
+ lines.push('}');
41
+ lines.push('`;');
42
+
43
+ return lines.join('\n');
44
+ }
@@ -0,0 +1,84 @@
1
+ export function formatWordPress(design) {
2
+ const { colors, typography, spacing } = design;
3
+
4
+ const theme = {
5
+ $schema: "https://schemas.wp.org/trunk/theme.json",
6
+ version: 3,
7
+ settings: {
8
+ color: {
9
+ palette: [],
10
+ gradients: [],
11
+ },
12
+ typography: {
13
+ fontFamilies: [],
14
+ fontSizes: [],
15
+ },
16
+ spacing: {
17
+ spacingSizes: [],
18
+ },
19
+ layout: {
20
+ contentSize: "1200px",
21
+ wideSize: "1400px",
22
+ },
23
+ },
24
+ styles: {
25
+ color: {},
26
+ typography: {},
27
+ spacing: {},
28
+ },
29
+ };
30
+
31
+ // Colors
32
+ if (colors.primary) theme.settings.color.palette.push({ slug: 'primary', color: colors.primary.hex, name: 'Primary' });
33
+ if (colors.secondary) theme.settings.color.palette.push({ slug: 'secondary', color: colors.secondary.hex, name: 'Secondary' });
34
+ if (colors.accent) theme.settings.color.palette.push({ slug: 'accent', color: colors.accent.hex, name: 'Accent' });
35
+ for (let i = 0; i < Math.min(colors.neutrals.length, 5); i++) {
36
+ theme.settings.color.palette.push({ slug: `neutral-${i + 1}`, color: colors.neutrals[i].hex, name: `Neutral ${i + 1}` });
37
+ }
38
+ for (const bg of colors.backgrounds.slice(0, 3)) {
39
+ theme.settings.color.palette.push({ slug: `bg-${bg.replace('#', '')}`, color: bg, name: `Background ${bg}` });
40
+ }
41
+
42
+ // Typography
43
+ for (const fam of typography.families) {
44
+ theme.settings.typography.fontFamilies.push({ fontFamily: fam.name, slug: fam.name.toLowerCase().replace(/\s+/g, '-'), name: fam.name });
45
+ }
46
+ for (const s of typography.scale.slice(0, 8)) {
47
+ theme.settings.typography.fontSizes.push({ size: `${s.size}px`, slug: `size-${s.size}`, name: `${s.size}px` });
48
+ }
49
+
50
+ // Spacing
51
+ for (let i = 0; i < Math.min(spacing.scale.length, 8); i++) {
52
+ const val = spacing.scale[i];
53
+ theme.settings.spacing.spacingSizes.push({ size: `${val}px`, slug: `spacing-${val}`, name: `${val}px` });
54
+ }
55
+
56
+ // Layout from extracted containers
57
+ if (design.layout && design.layout.containerWidths.length > 0) {
58
+ theme.settings.layout.contentSize = design.layout.containerWidths[0].maxWidth;
59
+ }
60
+
61
+ // Body styles
62
+ if (typography.body) {
63
+ theme.styles.typography.fontSize = `${typography.body.size}px`;
64
+ theme.styles.typography.lineHeight = typography.body.lineHeight;
65
+ }
66
+ if (typography.families.length > 0) {
67
+ theme.styles.typography.fontFamily = typography.families[0].name;
68
+ }
69
+ if (colors.backgrounds.length > 0) {
70
+ theme.styles.color.background = colors.backgrounds[0];
71
+ }
72
+ if (colors.text.length > 0) {
73
+ theme.styles.color.text = colors.text[0];
74
+ }
75
+
76
+ // Gradients from design
77
+ if (design.gradients && design.gradients.gradients) {
78
+ for (const g of design.gradients.gradients.slice(0, 5)) {
79
+ theme.settings.color.gradients.push({ slug: `gradient-${theme.settings.color.gradients.length + 1}`, gradient: g.raw, name: `Gradient ${theme.settings.color.gradients.length + 1}` });
80
+ }
81
+ }
82
+
83
+ return JSON.stringify(theme, null, 2);
84
+ }
package/src/history.js CHANGED
@@ -2,8 +2,9 @@
2
2
 
3
3
  import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
4
4
  import { join } from 'path';
5
+ import { homedir } from 'os';
5
6
 
6
- const HISTORY_DIR = join(process.env.HOME || process.env.USERPROFILE || '.', '.designlang');
7
+ const HISTORY_DIR = join(homedir(), '.designlang');
7
8
 
8
9
  function ensureDir() {
9
10
  mkdirSync(HISTORY_DIR, { recursive: true });
@@ -50,6 +51,12 @@ export function saveSnapshot(design) {
50
51
  };
51
52
 
52
53
  history.push(snapshot);
54
+
55
+ // Prune oldest entries if history exceeds 50 snapshots
56
+ if (history.length > 50) {
57
+ history = history.slice(history.length - 50);
58
+ }
59
+
53
60
  writeFileSync(file, JSON.stringify(history, null, 2), 'utf-8');
54
61
  return { hostname, snapshotCount: history.length, file };
55
62
  }
package/src/index.js CHANGED
@@ -11,10 +11,23 @@ import { extractComponents } from './extractors/components.js';
11
11
  import { extractAccessibility } from './extractors/accessibility.js';
12
12
  import { extractLayout } from './extractors/layout.js';
13
13
  import { scoreDesignSystem } from './extractors/scoring.js';
14
+ import { extractGradients } from './extractors/gradients.js';
15
+ import { extractZIndex } from './extractors/zindex.js';
16
+ import { extractIcons } from './extractors/icons.js';
17
+ import { extractFonts } from './extractors/fonts.js';
18
+ import { extractImageStyles } from './extractors/images.js';
19
+
20
+ function safeExtract(fn, ...args) {
21
+ try { return fn(...args); } catch { return null; }
22
+ }
14
23
 
15
24
  export async function extractDesignLanguage(url, options = {}) {
16
- const rawData = await crawlPage(url, options);
25
+ const rawData = await crawlPage(url, {
26
+ ...options,
27
+ ignore: options.ignore,
28
+ });
17
29
  const styles = rawData.light.computedStyles;
30
+ const warnings = [];
18
31
 
19
32
  const design = {
20
33
  meta: {
@@ -24,29 +37,48 @@ export async function extractDesignLanguage(url, options = {}) {
24
37
  elementCount: styles.length,
25
38
  pagesAnalyzed: rawData.pagesAnalyzed || 1,
26
39
  },
27
- colors: extractColors(styles),
28
- typography: extractTypography(styles),
29
- spacing: extractSpacing(styles),
30
- shadows: extractShadows(styles),
31
- borders: extractBorders(styles),
32
- variables: extractVariables(rawData.light.cssVariables),
33
- breakpoints: extractBreakpoints(rawData.light.mediaQueries),
34
- animations: extractAnimations(styles, rawData.light.keyframes),
35
- components: extractComponents(styles),
36
- accessibility: extractAccessibility(styles),
37
- layout: extractLayout(styles),
40
+ colors: safeExtract(extractColors, styles) || { primary: null, secondary: null, accent: null, neutrals: [], backgrounds: [], text: [], gradients: [], all: [] },
41
+ typography: safeExtract(extractTypography, styles) || { families: [], scale: [] },
42
+ spacing: safeExtract(extractSpacing, styles) || { scale: [], base: null },
43
+ shadows: safeExtract(extractShadows, styles) || { values: [] },
44
+ borders: safeExtract(extractBorders, styles) || { radii: [] },
45
+ variables: safeExtract(extractVariables, rawData.light.cssVariables) || {},
46
+ breakpoints: safeExtract(extractBreakpoints, rawData.light.mediaQueries) || [],
47
+ animations: safeExtract(extractAnimations, styles, rawData.light.keyframes) || { transitions: [], keyframes: [] },
48
+ components: safeExtract(extractComponents, styles) || {},
49
+ accessibility: safeExtract(extractAccessibility, styles) || { score: 0, failCount: 0 },
50
+ layout: safeExtract(extractLayout, styles) || { gridCount: 0, flexCount: 0 },
51
+ gradients: safeExtract(extractGradients, styles) || { count: 0 },
52
+ zIndex: safeExtract(extractZIndex, styles) || { allValues: [], issues: [] },
53
+ icons: rawData.light.icons ? (safeExtract(extractIcons, rawData.light.icons) || { icons: [], count: 0 }) : { icons: [], count: 0 },
54
+ fonts: rawData.light.fontData ? (safeExtract(extractFonts, rawData.light.fontData) || { fonts: [], systemFonts: [] }) : { fonts: [], systemFonts: [] },
55
+ images: rawData.light.images ? (safeExtract(extractImageStyles, rawData.light.images) || { patterns: [], aspectRatios: [] }) : { patterns: [], aspectRatios: [] },
38
56
  componentScreenshots: rawData.componentScreenshots || {},
39
- score: null, // populated below
57
+ score: null,
40
58
  };
41
59
 
60
+ // Track which extractors failed
61
+ const extractorChecks = [
62
+ ['colors', design.colors], ['typography', design.typography], ['spacing', design.spacing],
63
+ ['shadows', design.shadows], ['borders', design.borders], ['variables', design.variables],
64
+ ['breakpoints', design.breakpoints], ['animations', design.animations], ['components', design.components],
65
+ ['accessibility', design.accessibility], ['layout', design.layout], ['gradients', design.gradients],
66
+ ['zIndex', design.zIndex],
67
+ ];
68
+ for (const [name, result] of extractorChecks) {
69
+ if (result === null) warnings.push(`${name} extractor failed`);
70
+ }
71
+ design.warnings = warnings;
72
+
42
73
  if (rawData.dark) {
43
74
  design.darkMode = {
44
- colors: extractColors(rawData.dark.computedStyles),
45
- variables: extractVariables(rawData.dark.cssVariables),
75
+ colors: safeExtract(extractColors, rawData.dark.computedStyles) || { primary: null, secondary: null, accent: null, neutrals: [], backgrounds: [], text: [], gradients: [], all: [] },
76
+ variables: safeExtract(extractVariables, rawData.dark.cssVariables) || {},
46
77
  };
47
78
  }
48
79
 
49
- design.score = scoreDesignSystem(design);
80
+ design.score = safeExtract(scoreDesignSystem, design);
81
+ if (design.score === null) warnings.push('scoring failed');
50
82
 
51
83
  return design;
52
84
  }
@@ -59,6 +91,9 @@ export { formatCssVars } from './formatters/css-vars.js';
59
91
  export { formatPreview } from './formatters/preview.js';
60
92
  export { formatFigma } from './formatters/figma.js';
61
93
  export { formatReactTheme, formatShadcnTheme } from './formatters/theme.js';
94
+ export { formatWordPress } from './formatters/wordpress.js';
95
+ export { formatVueTheme } from './formatters/vue-theme.js';
96
+ export { formatSvelteTheme } from './formatters/svelte-theme.js';
62
97
  export { diffDesigns, formatDiffMarkdown, formatDiffHtml } from './diff.js';
63
98
  export { saveSnapshot, getHistory, formatHistoryMarkdown } from './history.js';
64
99
  export { captureResponsive } from './extractors/responsive.js';
@@ -68,3 +103,6 @@ export { compareBrands, formatBrandMatrix, formatBrandMatrixHtml } from './multi
68
103
  export { generateClone } from './clone.js';
69
104
  export { scoreDesignSystem } from './extractors/scoring.js';
70
105
  export { watchSite } from './watch.js';
106
+ export { diffDarkMode } from './darkdiff.js';
107
+ export { applyDesign } from './apply.js';
108
+ export { loadConfig, mergeConfig } from './config.js';
package/src/utils.js CHANGED
@@ -20,6 +20,29 @@ const NAMED_COLORS = {
20
20
  maroon: { r: 128, g: 0, b: 0, a: 1 },
21
21
  };
22
22
 
23
+ function oklabToRgb(L, a, b) {
24
+ // OKLab -> linear sRGB
25
+ const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
26
+ const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
27
+ const s_ = L - 0.0894841775 * a - 1.2914855480 * b;
28
+ const l = l_ * l_ * l_;
29
+ const m = m_ * m_ * m_;
30
+ const s = s_ * s_ * s_;
31
+ let r = +4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s;
32
+ let g = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s;
33
+ let bl = -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s;
34
+ // Clamp to [0,255]
35
+ const clamp = v => Math.round(Math.max(0, Math.min(1, v)) * 255);
36
+ return { r: clamp(r), g: clamp(g), b: clamp(bl) };
37
+ }
38
+
39
+ function oklchToRgb(L, C, H) {
40
+ const hRad = H * Math.PI / 180;
41
+ const a = C * Math.cos(hRad);
42
+ const b = C * Math.sin(hRad);
43
+ return oklabToRgb(L, a, b);
44
+ }
45
+
23
46
  export function parseColor(str) {
24
47
  if (!str || str === 'none' || str === 'currentcolor' || str === 'inherit' || str === 'initial') return null;
25
48
  str = str.trim().toLowerCase();
@@ -58,6 +81,51 @@ export function parseColor(str) {
58
81
  return { ...rgb, a: hslMatch[4] !== undefined ? +hslMatch[4] : 1 };
59
82
  }
60
83
 
84
+ // hsl modern: hsl(210 50% 40%) or hsl(210 50% 40% / 0.5)
85
+ const hslModern = str.match(/hsla?\(\s*([\d.]+)\s+([\d.]+)%\s+([\d.]+)%\s*(?:\/\s*([\d.]+%?))?\s*\)/);
86
+ if (hslModern) {
87
+ const rgb = hslToRgb(+hslModern[1], +hslModern[2], +hslModern[3]);
88
+ let a = 1;
89
+ if (hslModern[4] !== undefined) {
90
+ a = hslModern[4].endsWith('%') ? parseFloat(hslModern[4]) / 100 : +hslModern[4];
91
+ }
92
+ return { ...rgb, a };
93
+ }
94
+
95
+ // oklch(L C H) or oklch(L C H / a)
96
+ const oklchMatch = str.match(/oklch\(\s*([\d.]+)\s+([\d.]+)\s+([\d.]+)\s*(?:\/\s*([\d.]+%?))?\s*\)/);
97
+ if (oklchMatch) {
98
+ const rgb = oklchToRgb(+oklchMatch[1], +oklchMatch[2], +oklchMatch[3]);
99
+ let a = 1;
100
+ if (oklchMatch[4] !== undefined) {
101
+ a = oklchMatch[4].endsWith('%') ? parseFloat(oklchMatch[4]) / 100 : +oklchMatch[4];
102
+ }
103
+ return { ...rgb, a };
104
+ }
105
+
106
+ // oklab(L a b) or oklab(L a b / alpha)
107
+ const oklabMatch = str.match(/oklab\(\s*([\d.e+-]+)\s+([\d.e+-]+)\s+([\d.e+-]+)\s*(?:\/\s*([\d.]+%?))?\s*\)/);
108
+ if (oklabMatch) {
109
+ const rgb = oklabToRgb(+oklabMatch[1], +oklabMatch[2], +oklabMatch[3]);
110
+ let a = 1;
111
+ if (oklabMatch[4] !== undefined) {
112
+ a = oklabMatch[4].endsWith('%') ? parseFloat(oklabMatch[4]) / 100 : +oklabMatch[4];
113
+ }
114
+ return { ...rgb, a };
115
+ }
116
+
117
+ // color-mix(in srgb, color1 pct, color2)
118
+ const mixMatch = str.match(/color-mix\(\s*in\s+\w+\s*,\s*(.+?)\s*,\s*(.+?)\s*\)/);
119
+ if (mixMatch) {
120
+ const part1 = mixMatch[1].trim().replace(/\s+\d+%$/, '');
121
+ const part2 = mixMatch[2].trim().replace(/\s+\d+%$/, '');
122
+ const c1 = parseColor(part1);
123
+ const c2 = parseColor(part2);
124
+ if (c1 && c2) {
125
+ return { r: Math.round((c1.r + c2.r) / 2), g: Math.round((c1.g + c2.g) / 2), b: Math.round((c1.b + c2.b) / 2), a: (c1.a + c2.a) / 2 };
126
+ }
127
+ }
128
+
61
129
  return null;
62
130
  }
63
131
 
@@ -0,0 +1,34 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { execFileSync } from 'node:child_process';
4
+ import { resolve } from 'node:path';
5
+
6
+ const CLI_PATH = resolve(import.meta.dirname, '..', 'bin', 'design-extract.js');
7
+
8
+ describe('CLI', () => {
9
+ it('shows help with --help', () => {
10
+ const output = execFileSync('node', [CLI_PATH, '--help'], { encoding: 'utf-8' });
11
+ assert.ok(output.includes('designlang'));
12
+ assert.ok(output.includes('Extract'));
13
+ });
14
+
15
+ it('shows version with --version', () => {
16
+ const output = execFileSync('node', [CLI_PATH, '--version'], { encoding: 'utf-8' });
17
+ assert.ok(output.trim().match(/^\d+\.\d+\.\d+$/));
18
+ });
19
+
20
+ it('shows version number 6.0.0', () => {
21
+ const output = execFileSync('node', [CLI_PATH, '--version'], { encoding: 'utf-8' });
22
+ assert.equal(output.trim(), '6.0.0');
23
+ });
24
+
25
+ it('exits with error when no arguments provided', () => {
26
+ try {
27
+ execFileSync('node', [CLI_PATH], { encoding: 'utf-8', stdio: 'pipe' });
28
+ assert.fail('Should have thrown');
29
+ } catch (err) {
30
+ // Commander exits with code 1 when required argument is missing
31
+ assert.ok(err.status !== 0);
32
+ }
33
+ });
34
+ });