designlang 1.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.
@@ -0,0 +1,305 @@
1
+ import { pxToRem } from '../utils.js';
2
+
3
+ export function formatMarkdown(design) {
4
+ const lines = [];
5
+ const { meta, colors, typography, spacing, shadows, borders, variables, breakpoints, animations, components } = design;
6
+
7
+ lines.push(`# Design Language: ${meta.title || 'Unknown Site'}`);
8
+ lines.push('');
9
+ lines.push(`> Extracted from \`${meta.url}\` on ${new Date(meta.timestamp).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })}`);
10
+ lines.push(`> ${meta.elementCount} elements analyzed`);
11
+ lines.push('');
12
+ lines.push('This document describes the complete design language of the website. It is structured for AI/LLM consumption — use it to faithfully recreate the visual design in any framework.');
13
+ lines.push('');
14
+
15
+ // ── Colors ──
16
+ lines.push('## Color Palette');
17
+ lines.push('');
18
+ if (colors.primary) {
19
+ lines.push('### Primary Colors');
20
+ lines.push('');
21
+ lines.push('| Role | Hex | RGB | HSL | Usage Count |');
22
+ lines.push('|------|-----|-----|-----|-------------|');
23
+ if (colors.primary) lines.push(`| Primary | \`${colors.primary.hex}\` | rgb(${colors.primary.rgb.r}, ${colors.primary.rgb.g}, ${colors.primary.rgb.b}) | hsl(${colors.primary.hsl.h}, ${colors.primary.hsl.s}%, ${colors.primary.hsl.l}%) | ${colors.primary.count} |`);
24
+ if (colors.secondary) lines.push(`| Secondary | \`${colors.secondary.hex}\` | rgb(${colors.secondary.rgb.r}, ${colors.secondary.rgb.g}, ${colors.secondary.rgb.b}) | hsl(${colors.secondary.hsl.h}, ${colors.secondary.hsl.s}%, ${colors.secondary.hsl.l}%) | ${colors.secondary.count} |`);
25
+ if (colors.accent) lines.push(`| Accent | \`${colors.accent.hex}\` | rgb(${colors.accent.rgb.r}, ${colors.accent.rgb.g}, ${colors.accent.rgb.b}) | hsl(${colors.accent.hsl.h}, ${colors.accent.hsl.s}%, ${colors.accent.hsl.l}%) | ${colors.accent.count} |`);
26
+ lines.push('');
27
+ }
28
+
29
+ if (colors.neutrals.length > 0) {
30
+ lines.push('### Neutral Colors');
31
+ lines.push('');
32
+ lines.push('| Hex | HSL | Usage Count |');
33
+ lines.push('|-----|-----|-------------|');
34
+ for (const c of colors.neutrals.slice(0, 12)) {
35
+ lines.push(`| \`${c.hex}\` | hsl(${c.hsl.h}, ${c.hsl.s}%, ${c.hsl.l}%) | ${c.count} |`);
36
+ }
37
+ lines.push('');
38
+ }
39
+
40
+ if (colors.backgrounds.length > 0) {
41
+ lines.push('### Background Colors');
42
+ lines.push('');
43
+ lines.push(`Used on large-area elements: ${colors.backgrounds.map(h => `\`${h}\``).join(', ')}`);
44
+ lines.push('');
45
+ }
46
+
47
+ if (colors.text.length > 0) {
48
+ lines.push('### Text Colors');
49
+ lines.push('');
50
+ lines.push(`Text color palette: ${colors.text.map(h => `\`${h}\``).join(', ')}`);
51
+ lines.push('');
52
+ }
53
+
54
+ if (colors.gradients.length > 0) {
55
+ lines.push('### Gradients');
56
+ lines.push('');
57
+ for (const g of colors.gradients) {
58
+ lines.push('```css');
59
+ lines.push(`background-image: ${g};`);
60
+ lines.push('```');
61
+ lines.push('');
62
+ }
63
+ }
64
+
65
+ if (colors.all.length > 0) {
66
+ lines.push('### Full Color Inventory');
67
+ lines.push('');
68
+ lines.push('| Hex | Contexts | Count |');
69
+ lines.push('|-----|----------|-------|');
70
+ for (const c of colors.all.slice(0, 30)) {
71
+ lines.push(`| \`${c.hex}\` | ${c.contexts.join(', ')} | ${c.count} |`);
72
+ }
73
+ lines.push('');
74
+ }
75
+
76
+ // ── Typography ──
77
+ lines.push('## Typography');
78
+ lines.push('');
79
+
80
+ if (typography.families.length > 0) {
81
+ lines.push('### Font Families');
82
+ lines.push('');
83
+ for (const f of typography.families) {
84
+ lines.push(`- **${f.name}** — used for ${f.usage} (${f.count} elements)`);
85
+ }
86
+ lines.push('');
87
+ }
88
+
89
+ if (typography.scale.length > 0) {
90
+ lines.push('### Type Scale');
91
+ lines.push('');
92
+ lines.push('| Size (px) | Size (rem) | Weight | Line Height | Letter Spacing | Used On |');
93
+ lines.push('|-----------|------------|--------|-------------|----------------|---------|');
94
+ for (const s of typography.scale.slice(0, 15)) {
95
+ lines.push(`| ${s.size}px | ${pxToRem(s.size)}rem | ${s.weight} | ${s.lineHeight} | ${s.letterSpacing} | ${s.tags.slice(0, 4).join(', ')} |`);
96
+ }
97
+ lines.push('');
98
+ }
99
+
100
+ if (typography.headings.length > 0) {
101
+ lines.push('### Heading Scale');
102
+ lines.push('');
103
+ lines.push('```css');
104
+ for (const h of typography.headings) {
105
+ const tag = h.tags.find(t => /^h[1-6]$/.test(t)) || 'h';
106
+ lines.push(`${tag} { font-size: ${h.size}px; font-weight: ${h.weight}; line-height: ${h.lineHeight}; }`);
107
+ }
108
+ lines.push('```');
109
+ lines.push('');
110
+ }
111
+
112
+ if (typography.body) {
113
+ lines.push('### Body Text');
114
+ lines.push('');
115
+ lines.push('```css');
116
+ lines.push(`body { font-size: ${typography.body.size}px; font-weight: ${typography.body.weight}; line-height: ${typography.body.lineHeight}; }`);
117
+ lines.push('```');
118
+ lines.push('');
119
+ }
120
+
121
+ if (typography.weights.length > 0) {
122
+ lines.push('### Font Weights in Use');
123
+ lines.push('');
124
+ lines.push(typography.weights.map(w => `\`${w.weight}\` (${w.count}x)`).join(', '));
125
+ lines.push('');
126
+ }
127
+
128
+ // ── Spacing ──
129
+ lines.push('## Spacing');
130
+ lines.push('');
131
+ if (spacing.base) {
132
+ lines.push(`**Base unit:** ${spacing.base}px`);
133
+ lines.push('');
134
+ }
135
+ if (spacing.scale.length > 0) {
136
+ lines.push('| Token | Value | Rem |');
137
+ lines.push('|-------|-------|-----|');
138
+ for (const v of spacing.scale.slice(0, 20)) {
139
+ lines.push(`| spacing-${v} | ${v}px | ${pxToRem(v)}rem |`);
140
+ }
141
+ lines.push('');
142
+ }
143
+
144
+ // ── Borders ──
145
+ if (borders.radii.length > 0) {
146
+ lines.push('## Border Radii');
147
+ lines.push('');
148
+ lines.push('| Label | Value | Count |');
149
+ lines.push('|-------|-------|-------|');
150
+ for (const r of borders.radii) {
151
+ lines.push(`| ${r.label} | ${r.value}px | ${r.count} |`);
152
+ }
153
+ lines.push('');
154
+ }
155
+
156
+ // ── Shadows ──
157
+ if (shadows.values.length > 0) {
158
+ lines.push('## Box Shadows');
159
+ lines.push('');
160
+ for (const s of shadows.values) {
161
+ lines.push(`**${s.label}${s.inset ? ' (inset)' : ''}** — blur: ${s.blur}px`);
162
+ lines.push('```css');
163
+ lines.push(`box-shadow: ${s.raw};`);
164
+ lines.push('```');
165
+ lines.push('');
166
+ }
167
+ }
168
+
169
+ // ── CSS Variables ──
170
+ const varCategories = Object.entries(variables).filter(([, v]) => Object.keys(v).length > 0);
171
+ if (varCategories.length > 0) {
172
+ lines.push('## CSS Custom Properties');
173
+ lines.push('');
174
+ for (const [category, vars] of varCategories) {
175
+ lines.push(`### ${category.charAt(0).toUpperCase() + category.slice(1)}`);
176
+ lines.push('');
177
+ lines.push('```css');
178
+ for (const [name, value] of Object.entries(vars)) {
179
+ lines.push(`${name}: ${value};`);
180
+ }
181
+ lines.push('```');
182
+ lines.push('');
183
+ }
184
+ }
185
+
186
+ // ── Breakpoints ──
187
+ if (breakpoints.length > 0) {
188
+ lines.push('## Breakpoints');
189
+ lines.push('');
190
+ lines.push('| Name | Value | Type |');
191
+ lines.push('|------|-------|------|');
192
+ for (const bp of breakpoints) {
193
+ lines.push(`| ${bp.label} | ${bp.value}px | ${bp.type} |`);
194
+ }
195
+ lines.push('');
196
+ }
197
+
198
+ // ── Animations ──
199
+ if (animations.transitions.length > 0 || animations.keyframes.length > 0) {
200
+ lines.push('## Transitions & Animations');
201
+ lines.push('');
202
+
203
+ if (animations.easings.length > 0) {
204
+ lines.push(`**Easing functions:** ${animations.easings.map(e => `\`${e}\``).join(', ')}`);
205
+ lines.push('');
206
+ }
207
+ if (animations.durations.length > 0) {
208
+ lines.push(`**Durations:** ${animations.durations.map(d => `\`${d}\``).join(', ')}`);
209
+ lines.push('');
210
+ }
211
+
212
+ if (animations.transitions.length > 0) {
213
+ lines.push('### Common Transitions');
214
+ lines.push('');
215
+ lines.push('```css');
216
+ for (const t of animations.transitions.slice(0, 10)) {
217
+ lines.push(`transition: ${t};`);
218
+ }
219
+ lines.push('```');
220
+ lines.push('');
221
+ }
222
+
223
+ if (animations.keyframes.length > 0) {
224
+ lines.push('### Keyframe Animations');
225
+ lines.push('');
226
+ for (const kf of animations.keyframes.slice(0, 10)) {
227
+ lines.push(`**${kf.name}**`);
228
+ lines.push('```css');
229
+ lines.push(`@keyframes ${kf.name} {`);
230
+ for (const step of kf.steps) {
231
+ lines.push(` ${step.offset} { ${step.style} }`);
232
+ }
233
+ lines.push('}');
234
+ lines.push('```');
235
+ lines.push('');
236
+ }
237
+ }
238
+ }
239
+
240
+ // ── Components ──
241
+ if (Object.keys(components).length > 0) {
242
+ lines.push('## Component Patterns');
243
+ lines.push('');
244
+ lines.push('Detected UI component patterns and their most common styles:');
245
+ lines.push('');
246
+
247
+ for (const [name, comp] of Object.entries(components)) {
248
+ lines.push(`### ${name.charAt(0).toUpperCase() + name.slice(1)} (${comp.count} instances)`);
249
+ lines.push('');
250
+ lines.push('```css');
251
+ lines.push(`.${name.slice(0, -1)} {`);
252
+ for (const [prop, val] of Object.entries(comp.baseStyle)) {
253
+ const cssProp = prop.replace(/([A-Z])/g, '-$1').toLowerCase();
254
+ lines.push(` ${cssProp}: ${val};`);
255
+ }
256
+ lines.push('}');
257
+ lines.push('```');
258
+ lines.push('');
259
+ }
260
+ }
261
+
262
+ // ── Dark Mode ──
263
+ if (design.darkMode) {
264
+ lines.push('## Dark Mode');
265
+ lines.push('');
266
+ lines.push('The site has a distinct dark mode color scheme:');
267
+ lines.push('');
268
+ const dc = design.darkMode.colors;
269
+ if (dc.primary) lines.push(`- **Primary:** \`${dc.primary.hex}\``);
270
+ if (dc.secondary) lines.push(`- **Secondary:** \`${dc.secondary.hex}\``);
271
+ if (dc.backgrounds.length > 0) lines.push(`- **Backgrounds:** ${dc.backgrounds.map(h => `\`${h}\``).join(', ')}`);
272
+ if (dc.text.length > 0) lines.push(`- **Text:** ${dc.text.slice(0, 5).map(h => `\`${h}\``).join(', ')}`);
273
+ lines.push('');
274
+
275
+ const darkVars = Object.entries(design.darkMode.variables).filter(([, v]) => Object.keys(v).length > 0);
276
+ if (darkVars.length > 0) {
277
+ lines.push('### Dark Mode CSS Variables');
278
+ lines.push('');
279
+ lines.push('```css');
280
+ for (const [, vars] of darkVars) {
281
+ for (const [name, value] of Object.entries(vars)) {
282
+ lines.push(`${name}: ${value};`);
283
+ }
284
+ }
285
+ lines.push('```');
286
+ lines.push('');
287
+ }
288
+ }
289
+
290
+ // ── Quick Start ──
291
+ lines.push('## Quick Start');
292
+ lines.push('');
293
+ lines.push('To recreate this design in a new project:');
294
+ lines.push('');
295
+ if (typography.families.length > 0) {
296
+ const fontName = typography.families[0].name;
297
+ lines.push(`1. **Install fonts:** Add \`${fontName}\` from Google Fonts or your font provider`);
298
+ }
299
+ lines.push(`2. **Import CSS variables:** Copy \`variables.css\` into your project`);
300
+ lines.push(`3. **Tailwind users:** Use the generated \`tailwind.config.js\` to extend your theme`);
301
+ lines.push(`4. **Design tokens:** Import \`design-tokens.json\` for tooling integration`);
302
+ lines.push('');
303
+
304
+ return lines.join('\n');
305
+ }
@@ -0,0 +1,81 @@
1
+ export function formatTailwind(design) {
2
+ const config = {
3
+ colors: {},
4
+ fontFamily: {},
5
+ fontSize: {},
6
+ spacing: {},
7
+ borderRadius: {},
8
+ boxShadow: {},
9
+ screens: {},
10
+ zIndex: {},
11
+ transitionDuration: {},
12
+ transitionTimingFunction: {},
13
+ };
14
+
15
+ // Colors
16
+ if (design.colors.primary) config.colors.primary = design.colors.primary.hex;
17
+ if (design.colors.secondary) config.colors.secondary = design.colors.secondary.hex;
18
+ if (design.colors.accent) config.colors.accent = design.colors.accent.hex;
19
+ for (let i = 0; i < design.colors.neutrals.length && i < 10; i++) {
20
+ config.colors[`neutral-${i * 100 || 50}`] = design.colors.neutrals[i].hex;
21
+ }
22
+ if (design.colors.backgrounds.length > 0) config.colors.background = design.colors.backgrounds[0];
23
+ if (design.colors.text.length > 0) config.colors.foreground = design.colors.text[0];
24
+
25
+ // Typography — first family becomes 'sans', second becomes 'mono' or 'heading'
26
+ for (let i = 0; i < design.typography.families.length; i++) {
27
+ const f = design.typography.families[i];
28
+ let key;
29
+ if (f.usage === 'headings') key = 'heading';
30
+ else if (f.usage === 'body') key = 'body';
31
+ else if (i === 0) key = 'sans';
32
+ else if (f.name.toLowerCase().includes('mono')) key = 'mono';
33
+ else key = i === 1 ? 'heading' : `font${i}`;
34
+ config.fontFamily[key] = [f.name, 'sans-serif'];
35
+ }
36
+
37
+ for (const s of design.typography.scale.slice(0, 15)) {
38
+ config.fontSize[`${s.size}`] = [`${s.size}px`, { lineHeight: s.lineHeight, letterSpacing: s.letterSpacing !== 'normal' ? s.letterSpacing : undefined }];
39
+ }
40
+
41
+ // Spacing
42
+ for (const [name, value] of Object.entries(design.spacing.tokens)) {
43
+ config.spacing[name] = value;
44
+ }
45
+
46
+ // Border radius
47
+ for (const r of design.borders.radii) {
48
+ config.borderRadius[r.label] = `${r.value}px`;
49
+ }
50
+
51
+ // Shadows
52
+ for (const s of design.shadows.values) {
53
+ config.boxShadow[s.label] = s.raw;
54
+ }
55
+
56
+ // Breakpoints
57
+ for (const bp of design.breakpoints) {
58
+ if (bp.type === 'min-width') {
59
+ config.screens[bp.label] = `${bp.value}px`;
60
+ }
61
+ }
62
+
63
+ // Clean empty objects
64
+ for (const [key, val] of Object.entries(config)) {
65
+ if (typeof val === 'object' && Object.keys(val).length === 0) delete config[key];
66
+ }
67
+
68
+ const configStr = JSON.stringify(config, null, 4)
69
+ // Unquote simple keys (letters, digits, underscores only)
70
+ .replace(/"([a-zA-Z_]\w*)":/g, '$1:')
71
+ // Replace double quotes with single quotes for values
72
+ .replace(/"/g, "'");
73
+
74
+ return `/** @type {import('tailwindcss').Config} */
75
+ export default {
76
+ theme: {
77
+ extend: ${configStr},
78
+ },
79
+ };
80
+ `;
81
+ }
@@ -0,0 +1,61 @@
1
+ export function formatTokens(design) {
2
+ const tokens = {};
3
+
4
+ // Colors
5
+ tokens.color = {};
6
+ if (design.colors.primary) tokens.color.primary = { $value: design.colors.primary.hex, $type: 'color' };
7
+ if (design.colors.secondary) tokens.color.secondary = { $value: design.colors.secondary.hex, $type: 'color' };
8
+ if (design.colors.accent) tokens.color.accent = { $value: design.colors.accent.hex, $type: 'color' };
9
+
10
+ for (let i = 0; i < design.colors.neutrals.length && i < 10; i++) {
11
+ tokens.color[`neutral-${i}`] = { $value: design.colors.neutrals[i].hex, $type: 'color' };
12
+ }
13
+
14
+ for (let i = 0; i < design.colors.backgrounds.length; i++) {
15
+ tokens.color[`background-${i}`] = { $value: design.colors.backgrounds[i], $type: 'color' };
16
+ }
17
+
18
+ for (let i = 0; i < design.colors.text.length && i < 5; i++) {
19
+ tokens.color[`text-${i}`] = { $value: design.colors.text[i], $type: 'color' };
20
+ }
21
+
22
+ // Typography
23
+ tokens.fontFamily = {};
24
+ for (const f of design.typography.families) {
25
+ tokens.fontFamily[f.usage === 'headings' ? 'heading' : f.usage === 'body' ? 'body' : f.name.toLowerCase().replace(/\s+/g, '-')] = {
26
+ $value: f.name,
27
+ $type: 'fontFamily',
28
+ };
29
+ }
30
+
31
+ tokens.fontSize = {};
32
+ for (const s of design.typography.scale.slice(0, 15)) {
33
+ tokens.fontSize[`${s.size}`] = { $value: `${s.size}px`, $type: 'dimension' };
34
+ }
35
+
36
+ // Spacing
37
+ tokens.spacing = {};
38
+ for (const [name, value] of Object.entries(design.spacing.tokens)) {
39
+ tokens.spacing[name] = { $value: value, $type: 'dimension' };
40
+ }
41
+
42
+ // Border radius
43
+ tokens.borderRadius = {};
44
+ for (const r of design.borders.radii) {
45
+ tokens.borderRadius[r.label] = { $value: `${r.value}px`, $type: 'dimension' };
46
+ }
47
+
48
+ // Shadows
49
+ tokens.shadow = {};
50
+ for (const s of design.shadows.values) {
51
+ tokens.shadow[s.label] = { $value: s.raw, $type: 'shadow' };
52
+ }
53
+
54
+ // Breakpoints
55
+ tokens.breakpoint = {};
56
+ for (const bp of design.breakpoints) {
57
+ tokens.breakpoint[bp.label] = { $value: `${bp.value}px`, $type: 'dimension' };
58
+ }
59
+
60
+ return JSON.stringify(tokens, null, 2);
61
+ }
package/src/index.js ADDED
@@ -0,0 +1,48 @@
1
+ import { crawlPage } from './crawler.js';
2
+ import { extractColors } from './extractors/colors.js';
3
+ import { extractTypography } from './extractors/typography.js';
4
+ import { extractSpacing } from './extractors/spacing.js';
5
+ import { extractShadows } from './extractors/shadows.js';
6
+ import { extractBorders } from './extractors/borders.js';
7
+ import { extractVariables } from './extractors/variables.js';
8
+ import { extractBreakpoints } from './extractors/breakpoints.js';
9
+ import { extractAnimations } from './extractors/animations.js';
10
+ import { extractComponents } from './extractors/components.js';
11
+
12
+ export async function extractDesignLanguage(url, options = {}) {
13
+ const rawData = await crawlPage(url, options);
14
+ const styles = rawData.light.computedStyles;
15
+
16
+ const design = {
17
+ meta: {
18
+ url: rawData.url,
19
+ title: rawData.title,
20
+ timestamp: new Date().toISOString(),
21
+ elementCount: styles.length,
22
+ },
23
+ colors: extractColors(styles),
24
+ typography: extractTypography(styles),
25
+ spacing: extractSpacing(styles),
26
+ shadows: extractShadows(styles),
27
+ borders: extractBorders(styles),
28
+ variables: extractVariables(rawData.light.cssVariables),
29
+ breakpoints: extractBreakpoints(rawData.light.mediaQueries),
30
+ animations: extractAnimations(styles, rawData.light.keyframes),
31
+ components: extractComponents(styles),
32
+ };
33
+
34
+ if (rawData.dark) {
35
+ design.darkMode = {
36
+ colors: extractColors(rawData.dark.computedStyles),
37
+ variables: extractVariables(rawData.dark.cssVariables),
38
+ };
39
+ }
40
+
41
+ return design;
42
+ }
43
+
44
+ export { crawlPage } from './crawler.js';
45
+ export { formatTokens } from './formatters/tokens.js';
46
+ export { formatMarkdown } from './formatters/markdown.js';
47
+ export { formatTailwind } from './formatters/tailwind.js';
48
+ export { formatCssVars } from './formatters/css-vars.js';
package/src/utils.js ADDED
@@ -0,0 +1,179 @@
1
+ // Named CSS colors (subset — the 17 standard + common extras)
2
+ const NAMED_COLORS = {
3
+ transparent: { r: 0, g: 0, b: 0, a: 0 },
4
+ black: { r: 0, g: 0, b: 0, a: 1 },
5
+ white: { r: 255, g: 255, b: 255, a: 1 },
6
+ red: { r: 255, g: 0, b: 0, a: 1 },
7
+ green: { r: 0, g: 128, b: 0, a: 1 },
8
+ blue: { r: 0, g: 0, b: 255, a: 1 },
9
+ yellow: { r: 255, g: 255, b: 0, a: 1 },
10
+ cyan: { r: 0, g: 255, b: 255, a: 1 },
11
+ magenta: { r: 255, g: 0, b: 255, a: 1 },
12
+ gray: { r: 128, g: 128, b: 128, a: 1 },
13
+ grey: { r: 128, g: 128, b: 128, a: 1 },
14
+ orange: { r: 255, g: 165, b: 0, a: 1 },
15
+ purple: { r: 128, g: 0, b: 128, a: 1 },
16
+ pink: { r: 255, g: 192, b: 203, a: 1 },
17
+ navy: { r: 0, g: 0, b: 128, a: 1 },
18
+ teal: { r: 0, g: 128, b: 128, a: 1 },
19
+ silver: { r: 192, g: 192, b: 192, a: 1 },
20
+ maroon: { r: 128, g: 0, b: 0, a: 1 },
21
+ };
22
+
23
+ export function parseColor(str) {
24
+ if (!str || str === 'none' || str === 'currentcolor' || str === 'inherit' || str === 'initial') return null;
25
+ str = str.trim().toLowerCase();
26
+
27
+ if (NAMED_COLORS[str]) return { ...NAMED_COLORS[str] };
28
+
29
+ // hex: #RGB, #RGBA, #RRGGBB, #RRGGBBAA
30
+ if (str.startsWith('#')) {
31
+ const hex = str.slice(1);
32
+ if (hex.length === 3) return { r: parseInt(hex[0] + hex[0], 16), g: parseInt(hex[1] + hex[1], 16), b: parseInt(hex[2] + hex[2], 16), a: 1 };
33
+ if (hex.length === 4) return { r: parseInt(hex[0] + hex[0], 16), g: parseInt(hex[1] + hex[1], 16), b: parseInt(hex[2] + hex[2], 16), a: parseInt(hex[3] + hex[3], 16) / 255 };
34
+ if (hex.length === 6) return { r: parseInt(hex.slice(0, 2), 16), g: parseInt(hex.slice(2, 4), 16), b: parseInt(hex.slice(4, 6), 16), a: 1 };
35
+ if (hex.length === 8) return { r: parseInt(hex.slice(0, 2), 16), g: parseInt(hex.slice(2, 4), 16), b: parseInt(hex.slice(4, 6), 16), a: parseInt(hex.slice(6, 8), 16) / 255 };
36
+ }
37
+
38
+ // rgb(r, g, b) or rgba(r, g, b, a)
39
+ const rgbMatch = str.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*([\d.]+))?\s*\)/);
40
+ if (rgbMatch) {
41
+ return { r: +rgbMatch[1], g: +rgbMatch[2], b: +rgbMatch[3], a: rgbMatch[4] !== undefined ? +rgbMatch[4] : 1 };
42
+ }
43
+
44
+ // Modern syntax: rgb(r g b / a)
45
+ const rgbModern = str.match(/rgba?\(\s*(\d+)\s+(\d+)\s+(\d+)\s*(?:\/\s*([\d.]+%?))?\s*\)/);
46
+ if (rgbModern) {
47
+ let a = 1;
48
+ if (rgbModern[4] !== undefined) {
49
+ a = rgbModern[4].endsWith('%') ? parseFloat(rgbModern[4]) / 100 : +rgbModern[4];
50
+ }
51
+ return { r: +rgbModern[1], g: +rgbModern[2], b: +rgbModern[3], a };
52
+ }
53
+
54
+ // hsl(h, s%, l%) or hsla(h, s%, l%, a)
55
+ const hslMatch = str.match(/hsla?\(\s*([\d.]+)\s*,\s*([\d.]+)%\s*,\s*([\d.]+)%\s*(?:,\s*([\d.]+))?\s*\)/);
56
+ if (hslMatch) {
57
+ const rgb = hslToRgb(+hslMatch[1], +hslMatch[2], +hslMatch[3]);
58
+ return { ...rgb, a: hslMatch[4] !== undefined ? +hslMatch[4] : 1 };
59
+ }
60
+
61
+ return null;
62
+ }
63
+
64
+ export function rgbToHex({ r, g, b }) {
65
+ const toHex = (n) => Math.round(n).toString(16).padStart(2, '0');
66
+ return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
67
+ }
68
+
69
+ export function rgbToHsl({ r, g, b }) {
70
+ r /= 255; g /= 255; b /= 255;
71
+ const max = Math.max(r, g, b), min = Math.min(r, g, b);
72
+ const l = (max + min) / 2;
73
+ if (max === min) return { h: 0, s: 0, l: Math.round(l * 100) };
74
+ const d = max - min;
75
+ const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
76
+ let h;
77
+ if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
78
+ else if (max === g) h = ((b - r) / d + 2) / 6;
79
+ else h = ((r - g) / d + 4) / 6;
80
+ return { h: Math.round(h * 360), s: Math.round(s * 100), l: Math.round(l * 100) };
81
+ }
82
+
83
+ function hslToRgb(h, s, l) {
84
+ h /= 360; s /= 100; l /= 100;
85
+ if (s === 0) { const v = Math.round(l * 255); return { r: v, g: v, b: v }; }
86
+ const hue2rgb = (p, q, t) => {
87
+ if (t < 0) t += 1;
88
+ if (t > 1) t -= 1;
89
+ if (t < 1/6) return p + (q - p) * 6 * t;
90
+ if (t < 1/2) return q;
91
+ if (t < 2/3) return p + (q - p) * (2/3 - t) * 6;
92
+ return p;
93
+ };
94
+ const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
95
+ const p = 2 * l - q;
96
+ return {
97
+ r: Math.round(hue2rgb(p, q, h + 1/3) * 255),
98
+ g: Math.round(hue2rgb(p, q, h) * 255),
99
+ b: Math.round(hue2rgb(p, q, h - 1/3) * 255),
100
+ };
101
+ }
102
+
103
+ export function colorDistance(c1, c2) {
104
+ return Math.sqrt((c1.r - c2.r) ** 2 + (c1.g - c2.g) ** 2 + (c1.b - c2.b) ** 2);
105
+ }
106
+
107
+ export function isSaturated({ r, g, b }) {
108
+ const { s } = rgbToHsl({ r, g, b });
109
+ return s > 10;
110
+ }
111
+
112
+ export function clusterColors(colors, threshold = 15) {
113
+ const clusters = [];
114
+ for (const color of colors) {
115
+ const existing = clusters.find(c => colorDistance(c.representative, color.parsed) < threshold);
116
+ if (existing) {
117
+ existing.members.push(color);
118
+ existing.count += color.count;
119
+ } else {
120
+ clusters.push({ representative: color.parsed, hex: color.hex, members: [color], count: color.count });
121
+ }
122
+ }
123
+ return clusters.sort((a, b) => b.count - a.count);
124
+ }
125
+
126
+ export function clusterValues(values, threshold) {
127
+ const sorted = [...values].sort((a, b) => a - b);
128
+ const groups = [];
129
+ for (const v of sorted) {
130
+ const last = groups[groups.length - 1];
131
+ if (last && Math.abs(v - last.representative) <= threshold) {
132
+ last.members.push(v);
133
+ } else {
134
+ groups.push({ representative: v, members: [v] });
135
+ }
136
+ }
137
+ return groups.map(g => g.representative);
138
+ }
139
+
140
+ export function parseCSSValue(str) {
141
+ if (!str || str === 'normal' || str === 'auto' || str === 'none') return null;
142
+ const match = str.match(/^([\d.]+)(px|rem|em|%|vw|vh|pt)?$/);
143
+ if (!match) return null;
144
+ return { value: parseFloat(match[1]), unit: match[2] || '' };
145
+ }
146
+
147
+ export function remToPx(rem, base = 16) {
148
+ return rem * base;
149
+ }
150
+
151
+ export function pxToRem(px, base = 16) {
152
+ return +(px / base).toFixed(4);
153
+ }
154
+
155
+ export function safeName(str) {
156
+ return str.replace(/[^a-zA-Z0-9-_]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '').toLowerCase();
157
+ }
158
+
159
+ export function nameFromUrl(url) {
160
+ try {
161
+ const hostname = new URL(url).hostname;
162
+ return safeName(hostname.replace(/^www\./, ''));
163
+ } catch {
164
+ return 'unknown-site';
165
+ }
166
+ }
167
+
168
+ export function detectScale(values) {
169
+ if (values.length < 3) return { base: null, scale: values };
170
+ const candidates = [2, 4, 6, 8];
171
+ let bestBase = null;
172
+ let bestScore = 0;
173
+ for (const base of candidates) {
174
+ const score = values.filter(v => v > 0 && v % base === 0).length / values.length;
175
+ if (score > bestScore) { bestScore = score; bestBase = base; }
176
+ }
177
+ if (bestScore >= 0.6) return { base: bestBase, scale: values };
178
+ return { base: null, scale: values };
179
+ }