designlang 5.0.0 → 7.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 (51) hide show
  1. package/.github/FUNDING.yml +1 -0
  2. package/.github/ISSUE_TEMPLATE/bug_report.yml +62 -0
  3. package/.github/ISSUE_TEMPLATE/config.yml +8 -0
  4. package/.github/ISSUE_TEMPLATE/feature_request.yml +28 -0
  5. package/CHANGELOG.md +43 -0
  6. package/README.md +177 -6
  7. package/bin/design-extract.js +302 -92
  8. package/docs/superpowers/plans/2026-04-18-designlang-v7.md +1121 -0
  9. package/docs/superpowers/specs/2026-04-18-designlang-v7-design.md +150 -0
  10. package/package.json +13 -7
  11. package/src/config.js +59 -0
  12. package/src/crawler.js +297 -95
  13. package/src/extractors/a11y-remediation.js +47 -0
  14. package/src/extractors/animations.js +37 -5
  15. package/src/extractors/borders.js +40 -5
  16. package/src/extractors/component-clusters.js +39 -0
  17. package/src/extractors/components.js +77 -1
  18. package/src/extractors/css-health.js +151 -0
  19. package/src/extractors/gradients.js +25 -5
  20. package/src/extractors/scoring.js +20 -1
  21. package/src/extractors/semantic-regions.js +44 -0
  22. package/src/extractors/shadows.js +60 -17
  23. package/src/extractors/spacing.js +31 -2
  24. package/src/extractors/stack-fingerprint.js +88 -0
  25. package/src/extractors/variables.js +20 -1
  26. package/src/formatters/_token-ref.js +44 -0
  27. package/src/formatters/agent-rules.js +116 -0
  28. package/src/formatters/android-compose.js +164 -0
  29. package/src/formatters/dtcg-tokens.js +175 -0
  30. package/src/formatters/figma.js +66 -47
  31. package/src/formatters/flutter-dart.js +130 -0
  32. package/src/formatters/ios-swiftui.js +161 -0
  33. package/src/formatters/markdown.js +25 -0
  34. package/src/formatters/preview.js +65 -22
  35. package/src/formatters/svelte-theme.js +40 -0
  36. package/src/formatters/tailwind.js +57 -4
  37. package/src/formatters/theme.js +134 -0
  38. package/src/formatters/vue-theme.js +44 -0
  39. package/src/formatters/wordpress.js +267 -0
  40. package/src/history.js +8 -1
  41. package/src/index.js +76 -20
  42. package/src/mcp/resources.js +64 -0
  43. package/src/mcp/server.js +110 -0
  44. package/src/mcp/tools.js +149 -0
  45. package/src/utils.js +68 -0
  46. package/tests/cli.test.js +84 -0
  47. package/tests/extractors.test.js +792 -0
  48. package/tests/formatters.test.js +709 -0
  49. package/tests/mcp.test.js +68 -0
  50. package/tests/utils.test.js +413 -0
  51. package/website/app/globals.css +11 -11
@@ -0,0 +1,44 @@
1
+ // Internal helper: resolve DTCG reference strings like "{primitive.color.brand.primary}"
2
+ // against a token tree, following chains until we reach a non-reference $value.
3
+
4
+ const REF_PATTERN = /^\{([^}]+)\}$/;
5
+
6
+ function parseRef(value) {
7
+ if (typeof value !== 'string') return null;
8
+ const match = value.match(REF_PATTERN);
9
+ return match ? match[1] : null;
10
+ }
11
+
12
+ function getAtPath(tokens, path) {
13
+ const parts = path.split('.');
14
+ let node = tokens;
15
+ for (const part of parts) {
16
+ if (node == null || typeof node !== 'object') return undefined;
17
+ node = node[part];
18
+ }
19
+ return node;
20
+ }
21
+
22
+ // Resolve a token at the given dotted path to its leaf $value (following references).
23
+ // Returns undefined if the path is missing or a reference cannot be resolved.
24
+ export function resolveRef(tokens, path, seen = new Set()) {
25
+ if (seen.has(path)) return undefined; // cycle
26
+ seen.add(path);
27
+
28
+ const node = getAtPath(tokens, path);
29
+ if (node == null) return undefined;
30
+
31
+ // If we got a token object with $value
32
+ if (typeof node === 'object' && '$value' in node) {
33
+ const inner = node.$value;
34
+ const refPath = parseRef(inner);
35
+ if (refPath) return resolveRef(tokens, refPath, seen);
36
+ return inner;
37
+ }
38
+
39
+ // If the node is itself a reference string
40
+ const refPath = parseRef(node);
41
+ if (refPath) return resolveRef(tokens, refPath, seen);
42
+
43
+ return node;
44
+ }
@@ -0,0 +1,116 @@
1
+ // Agent rules emitter. Produces ready-to-drop files that teach coding agents
2
+ // (Cursor, Claude Code, generic) to prefer the extracted design tokens
3
+ // instead of inventing new colors/typography.
4
+ //
5
+ // Output: { relativePath: fileContent } — caller is responsible for writing.
6
+
7
+ import { resolveRef } from './_token-ref.js';
8
+
9
+ function hostFromUrl(url) {
10
+ try { return new URL(url).hostname; } catch { return url || 'unknown'; }
11
+ }
12
+
13
+ // Resolve a semantic token path to its concrete leaf value, or fall back.
14
+ function resolveSemantic(tokens, path, fallback) {
15
+ const v = resolveRef(tokens, path);
16
+ return (typeof v === 'string' && v) ? v : fallback;
17
+ }
18
+
19
+ function firstFontFamily(tokens) {
20
+ const fam = tokens?.primitive?.fontFamily || {};
21
+ const keys = Object.keys(fam);
22
+ if (!keys.length) return 'system-ui';
23
+ const v = fam[keys[0]]?.$value;
24
+ return typeof v === 'string' ? v : 'system-ui';
25
+ }
26
+
27
+ function buildBody({ url, tokens, design, iso }) {
28
+ const actionPrimary = resolveSemantic(tokens, 'semantic.color.action.primary', '#000000');
29
+ const surfaceDefault = resolveSemantic(tokens, 'semantic.color.surface.default', '#ffffff');
30
+ const textBody = resolveSemantic(tokens, 'semantic.color.text.body', '#111111');
31
+ const radiusControl = resolveSemantic(tokens, 'semantic.radius.control', '0px');
32
+ const fontFamily = firstFontFamily(tokens);
33
+
34
+ const lines = [];
35
+ lines.push(`Source: ${url}`);
36
+ lines.push(`Extracted by designlang v7.0.0 on ${iso}`);
37
+ lines.push('');
38
+ lines.push('## Semantic tokens (use these)');
39
+ lines.push(`- color.action.primary: ${actionPrimary}`);
40
+ lines.push(`- color.surface.default: ${surfaceDefault}`);
41
+ lines.push(`- color.text.body: ${textBody}`);
42
+ lines.push(`- radius.control: ${radiusControl}`);
43
+ lines.push(`- typography.body.fontFamily: ${fontFamily}`);
44
+
45
+ const regions = Array.isArray(design?.regions) ? design.regions : [];
46
+ if (regions.length) {
47
+ lines.push('');
48
+ lines.push('## Regions');
49
+ const names = regions.map(r => r.role || r.name).filter(Boolean);
50
+ for (const n of names) lines.push(`- ${n}`);
51
+ }
52
+
53
+ lines.push('');
54
+ lines.push('## How to use');
55
+ lines.push('- Prefer `semantic.*` tokens over `primitive.*`.');
56
+ lines.push('- Never invent new tokens or hex values; reuse the ones above.');
57
+ lines.push('- When a value is missing, pick the closest existing semantic token and flag the gap.');
58
+ lines.push('- Reference tokens by their dotted path (e.g. `semantic.color.action.primary`).');
59
+
60
+ return { body: lines.join('\n'), actionPrimary };
61
+ }
62
+
63
+ function cursorFile({ url, body }) {
64
+ const front = [
65
+ '---',
66
+ `description: Design system extracted from ${url} — use these tokens, do not invent new ones.`,
67
+ 'globs: **/*.{ts,tsx,jsx,js,css,scss,html,vue,svelte,swift,kt,dart,php}',
68
+ 'alwaysApply: true',
69
+ '---',
70
+ '',
71
+ '# Design system reference',
72
+ ].join('\n');
73
+ return `${front}\n${body}\n`;
74
+ }
75
+
76
+ function claudeSkillFile({ url, body }) {
77
+ const host = hostFromUrl(url);
78
+ const front = [
79
+ '---',
80
+ 'name: designlang-tokens',
81
+ `description: Use when styling UI for ${host} — references the extracted design system tokens instead of inventing colors, spacing, or typography.`,
82
+ '---',
83
+ '',
84
+ '# designlang tokens',
85
+ ].join('\n');
86
+ return `${front}\n${body}\n`;
87
+ }
88
+
89
+ function claudeFragmentFile({ url, body }) {
90
+ // Plain H2 section ready to append to a project's CLAUDE.md (no frontmatter).
91
+ return `## Design system (via designlang)\n\n${body}\n`;
92
+ }
93
+
94
+ function agentsMdFile({ url, body }) {
95
+ const head = [
96
+ '# Agent instructions — design system',
97
+ '',
98
+ `This project follows the design system extracted from ${url}.`,
99
+ 'Any coding agent working here must use the tokens below and avoid inventing new ones.',
100
+ '',
101
+ ].join('\n');
102
+ return `${head}${body}\n`;
103
+ }
104
+
105
+ export function formatAgentRules({ design, tokens, url }) {
106
+ const resolvedUrl = url || tokens?.$metadata?.source || design?.meta?.url || 'unknown';
107
+ const iso = tokens?.$metadata?.generatedAt || new Date().toISOString();
108
+ const { body } = buildBody({ url: resolvedUrl, tokens, design: design || {}, iso });
109
+
110
+ return {
111
+ '.cursor/rules/designlang.mdc': cursorFile({ url: resolvedUrl, body }),
112
+ '.claude/skills/designlang/SKILL.md': claudeSkillFile({ url: resolvedUrl, body }),
113
+ 'CLAUDE.md.fragment': claudeFragmentFile({ url: resolvedUrl, body }),
114
+ 'agents.md': agentsMdFile({ url: resolvedUrl, body }),
115
+ };
116
+ }
@@ -0,0 +1,164 @@
1
+ // Android Compose emitter — consumes a DTCG token object and produces three
2
+ // files: Theme.kt (Kotlin Compose), colors.xml, and dimens.xml.
3
+
4
+ import { resolveRef } from './_token-ref.js';
5
+
6
+ const HEADER_VERSION = '7.0.0';
7
+
8
+ function* walkLeaves(node, prefix) {
9
+ if (node == null || typeof node !== 'object') return;
10
+ if ('$value' in node && '$type' in node) {
11
+ yield { path: prefix, token: node };
12
+ return;
13
+ }
14
+ for (const key of Object.keys(node)) {
15
+ yield* walkLeaves(node[key], prefix ? `${prefix}.${key}` : key);
16
+ }
17
+ }
18
+
19
+ // "semantic.color.action.primary" → "action.primary" → "ActionPrimary"
20
+ function pascalFromPath(path) {
21
+ const parts = path.split('.');
22
+ const trimmed = parts.slice(1); // drop root (primitive/semantic)
23
+ let segs;
24
+ if (trimmed[0] === 'color' && trimmed.length >= 3) {
25
+ segs = trimmed.slice(1);
26
+ } else {
27
+ segs = trimmed;
28
+ }
29
+ return segs
30
+ .map((s) => s.charAt(0).toUpperCase() + s.slice(1))
31
+ .join('')
32
+ .replace(/[^a-zA-Z0-9]/g, '');
33
+ }
34
+
35
+ // "semantic.color.action.primary" → "action_primary"
36
+ function snakeFromPath(path) {
37
+ const parts = path.split('.');
38
+ const trimmed = parts.slice(1);
39
+ let segs;
40
+ if (trimmed[0] === 'color' && trimmed.length >= 3) {
41
+ segs = trimmed.slice(1);
42
+ } else {
43
+ segs = trimmed;
44
+ }
45
+ return segs
46
+ .map((s) => s.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase())
47
+ .join('_')
48
+ .replace(/[^a-z0-9_]/g, '');
49
+ }
50
+
51
+ // Normalize "#rgb"/"#rrggbb"/"#aarrggbb" to "FFRRGGBB" (alpha-first, uppercase).
52
+ function hexToArgb(hex) {
53
+ if (typeof hex !== 'string') return null;
54
+ let h = hex.trim().replace(/^#/, '');
55
+ if (h.length === 3) h = h.split('').map((c) => c + c).join('');
56
+ if (h.length === 6 && /^[0-9a-fA-F]{6}$/.test(h)) return `FF${h.toUpperCase()}`;
57
+ if (h.length === 8 && /^[0-9a-fA-F]{8}$/.test(h)) return h.toUpperCase();
58
+ return null;
59
+ }
60
+
61
+ function dimensionToNumber(value) {
62
+ if (typeof value === 'number') return value;
63
+ if (typeof value !== 'string') return null;
64
+ const m = value.match(/^(-?\d+(?:\.\d+)?)(px|pt|dp|rem|em)?$/);
65
+ if (!m) return null;
66
+ const n = parseFloat(m[1]);
67
+ if (m[2] === 'rem' || m[2] === 'em') return n * 16;
68
+ return n;
69
+ }
70
+
71
+ function collectColors(tokens) {
72
+ const entries = [];
73
+ const sem = tokens?.semantic?.color;
74
+ if (sem) {
75
+ for (const leaf of walkLeaves(sem, 'semantic.color')) {
76
+ if (leaf.token.$type !== 'color') continue;
77
+ const resolved = resolveRef(tokens, leaf.path);
78
+ const argb = hexToArgb(resolved);
79
+ if (!argb) continue;
80
+ entries.push({ path: leaf.path, argb });
81
+ }
82
+ }
83
+ const prim = tokens?.primitive?.color;
84
+ if (prim) {
85
+ for (const leaf of walkLeaves(prim, 'primitive.color')) {
86
+ if (leaf.token.$type !== 'color') continue;
87
+ const resolved = resolveRef(tokens, leaf.path);
88
+ const argb = hexToArgb(resolved);
89
+ if (!argb) continue;
90
+ entries.push({ path: leaf.path, argb });
91
+ }
92
+ }
93
+ return entries;
94
+ }
95
+
96
+ function collectDimensions(tokens) {
97
+ const entries = [];
98
+ const spacing = tokens?.primitive?.spacing || {};
99
+ for (const key of Object.keys(spacing)) {
100
+ const tok = spacing[key];
101
+ if (!tok || tok.$type !== 'dimension') continue;
102
+ const n = dimensionToNumber(resolveRef(tokens, `primitive.spacing.${key}`));
103
+ if (n == null) continue;
104
+ entries.push({ path: `primitive.spacing.${key}`, value: n });
105
+ }
106
+ const radius = tokens?.primitive?.radius || {};
107
+ for (const key of Object.keys(radius)) {
108
+ const tok = radius[key];
109
+ if (!tok || tok.$type !== 'dimension') continue;
110
+ const n = dimensionToNumber(resolveRef(tokens, `primitive.radius.${key}`));
111
+ if (n == null) continue;
112
+ entries.push({ path: `primitive.radius.${key}`, value: n });
113
+ }
114
+ return entries;
115
+ }
116
+
117
+ function buildThemeKt(tokens, colors, dims) {
118
+ const source = tokens?.$metadata?.source || '';
119
+ const lines = [];
120
+ lines.push(`// Generated by designlang v${HEADER_VERSION}`);
121
+ if (source) lines.push(`// Source: ${source}`);
122
+ lines.push('package com.designlang.tokens');
123
+ lines.push('');
124
+ lines.push('import androidx.compose.ui.graphics.Color');
125
+ lines.push('import androidx.compose.ui.unit.dp');
126
+ lines.push('');
127
+ lines.push('object DesignTokens {');
128
+ for (const { path, argb } of colors) {
129
+ lines.push(` val ${pascalFromPath(path)} = Color(0x${argb})`);
130
+ }
131
+ for (const { path, value } of dims) {
132
+ lines.push(` val ${pascalFromPath(path)} = ${value}.dp`);
133
+ }
134
+ lines.push('}');
135
+ return lines.join('\n') + '\n';
136
+ }
137
+
138
+ function buildColorsXml(colors) {
139
+ const lines = ['<?xml version="1.0" encoding="utf-8"?>', '<resources>'];
140
+ for (const { path, argb } of colors) {
141
+ lines.push(` <color name="${snakeFromPath(path)}">#${argb}</color>`);
142
+ }
143
+ lines.push('</resources>');
144
+ return lines.join('\n') + '\n';
145
+ }
146
+
147
+ function buildDimensXml(dims) {
148
+ const lines = ['<?xml version="1.0" encoding="utf-8"?>', '<resources>'];
149
+ for (const { path, value } of dims) {
150
+ lines.push(` <dimen name="${snakeFromPath(path)}">${value}dp</dimen>`);
151
+ }
152
+ lines.push('</resources>');
153
+ return lines.join('\n') + '\n';
154
+ }
155
+
156
+ export function formatAndroidCompose(tokens) {
157
+ const colors = collectColors(tokens);
158
+ const dims = collectDimensions(tokens);
159
+ return {
160
+ 'Theme.kt': buildThemeKt(tokens, colors, dims),
161
+ 'colors.xml': buildColorsXml(colors),
162
+ 'dimens.xml': buildDimensXml(dims),
163
+ };
164
+ }
@@ -0,0 +1,175 @@
1
+ // DTCG v1 token formatter.
2
+ // Input: design object from extractDesignLanguage.
3
+ // Output: { $metadata, primitive, semantic } — every leaf is { $value, $type }.
4
+
5
+ function token(value, type, extensions) {
6
+ const t = { $value: value, $type: type };
7
+ if (extensions) t.$extensions = extensions;
8
+ return t;
9
+ }
10
+
11
+ function ref(path) { return `{${path}}`; }
12
+
13
+ // Normalize a color entry — may be a string hex or { hex } object.
14
+ function colorValue(v) {
15
+ if (v == null) return null;
16
+ if (typeof v === 'string') return v;
17
+ if (typeof v === 'object' && typeof v.hex === 'string') return v.hex;
18
+ return null;
19
+ }
20
+
21
+ // Normalize a dimension entry — may be a string ('4px'), a number (4), or { value } object.
22
+ function dimensionValue(v) {
23
+ if (v == null) return null;
24
+ if (typeof v === 'string') return v;
25
+ if (typeof v === 'number') return `${v}px`;
26
+ if (typeof v === 'object') {
27
+ if (typeof v.value === 'number') return `${v.value}px`;
28
+ if (typeof v.value === 'string') return v.value;
29
+ }
30
+ return null;
31
+ }
32
+
33
+ // Normalize a shadow entry — may be a string or { raw } object.
34
+ function shadowValue(v) {
35
+ if (v == null) return null;
36
+ if (typeof v === 'string') return v;
37
+ if (typeof v === 'object' && typeof v.raw === 'string') return v.raw;
38
+ return null;
39
+ }
40
+
41
+ // Normalize a font family entry — may be a string or { name } object.
42
+ function fontFamilyValue(v) {
43
+ if (v == null) return null;
44
+ if (typeof v === 'string') return v;
45
+ if (typeof v === 'object' && typeof v.name === 'string') return v.name;
46
+ return null;
47
+ }
48
+
49
+ // Normalize a typography scale size — may be number (px) or string ('16px').
50
+ function fontSizeValue(v) {
51
+ if (v == null) return null;
52
+ if (typeof v === 'string') return v;
53
+ if (typeof v === 'number') return `${v}px`;
54
+ return null;
55
+ }
56
+
57
+ function buildPrimitive(design) {
58
+ const brand = colorValue(design.colors?.primary) || colorValue(design.colors?.all?.[0]) || '#000000';
59
+ const secondary = colorValue(design.colors?.secondary);
60
+
61
+ const neutrals = {};
62
+ const rawNeutrals = design.colors?.neutrals || [];
63
+ for (let i = 0; i < rawNeutrals.length; i++) {
64
+ const cv = colorValue(rawNeutrals[i]);
65
+ if (cv) neutrals[`n${i * 100 + 100}`] = token(cv, 'color');
66
+ }
67
+
68
+ const background = {};
69
+ const rawBackgrounds = design.colors?.backgrounds || [];
70
+ for (let i = 0; i < rawBackgrounds.length; i++) {
71
+ const cv = colorValue(rawBackgrounds[i]);
72
+ if (cv) background[`bg${i}`] = token(cv, 'color');
73
+ }
74
+
75
+ const text = {};
76
+ const rawText = design.colors?.text || [];
77
+ for (let i = 0; i < rawText.length; i++) {
78
+ const cv = colorValue(rawText[i]);
79
+ if (cv) text[`text${i}`] = token(cv, 'color');
80
+ }
81
+
82
+ const color = {
83
+ brand: {
84
+ primary: token(brand, 'color'),
85
+ ...(secondary && { secondary: token(secondary, 'color') }),
86
+ },
87
+ neutral: neutrals,
88
+ background,
89
+ text,
90
+ };
91
+
92
+ const spacing = {};
93
+ const rawSpacing = design.spacing?.scale || [];
94
+ for (let i = 0; i < rawSpacing.length; i++) {
95
+ const dv = dimensionValue(rawSpacing[i]);
96
+ if (dv) spacing[`s${i}`] = token(dv, 'dimension');
97
+ }
98
+
99
+ const radius = {};
100
+ const rawRadii = design.borders?.radii || [];
101
+ for (let i = 0; i < rawRadii.length; i++) {
102
+ const dv = dimensionValue(rawRadii[i]);
103
+ if (dv) radius[`r${i}`] = token(dv, 'dimension');
104
+ }
105
+
106
+ const shadow = {};
107
+ const rawShadows = design.shadows?.values || [];
108
+ for (let i = 0; i < rawShadows.length; i++) {
109
+ const sv = shadowValue(rawShadows[i]);
110
+ if (sv) shadow[`sh${i}`] = token(sv, 'shadow');
111
+ }
112
+
113
+ const fontFamily = {};
114
+ const rawFamilies = design.typography?.families || [];
115
+ for (let i = 0; i < rawFamilies.length; i++) {
116
+ const fv = fontFamilyValue(rawFamilies[i]);
117
+ if (fv) fontFamily[`f${i}`] = token(fv, 'fontFamily');
118
+ }
119
+
120
+ return { color, spacing, radius, shadow, fontFamily };
121
+ }
122
+
123
+ function buildSemantic(design, primitive) {
124
+ const firstRadiusKey = Object.keys(primitive.radius)[0] || 'r0';
125
+ const firstShadowKey = Object.keys(primitive.shadow)[0] || 'sh0';
126
+
127
+ const color = {
128
+ action: {
129
+ primary: token(ref('primitive.color.brand.primary'), 'color'),
130
+ },
131
+ surface: {
132
+ default: token(ref('primitive.color.background.bg0'), 'color'),
133
+ },
134
+ text: {
135
+ body: token(ref('primitive.color.text.text0'), 'color'),
136
+ },
137
+ };
138
+ if (primitive.color.brand.secondary) {
139
+ color.action.secondary = token(ref('primitive.color.brand.secondary'), 'color');
140
+ }
141
+
142
+ const firstFamily = fontFamilyValue(design.typography?.families?.[0]) || 'system-ui';
143
+ const firstScale = design.typography?.scale?.[0] || {};
144
+ const typography = {
145
+ body: token({
146
+ fontFamily: firstFamily,
147
+ fontSize: fontSizeValue(firstScale.size) || '16px',
148
+ fontWeight: firstScale.weight || '400',
149
+ lineHeight: firstScale.lineHeight || '1.5',
150
+ }, 'typography'),
151
+ };
152
+
153
+ const radius = {
154
+ control: token(ref(`primitive.radius.${firstRadiusKey}`), 'dimension'),
155
+ };
156
+
157
+ const shadow = {
158
+ elevated: token(ref(`primitive.shadow.${firstShadowKey}`), 'shadow'),
159
+ };
160
+
161
+ return { color, typography, radius, shadow };
162
+ }
163
+
164
+ export function formatDtcgTokens(design) {
165
+ const primitive = buildPrimitive(design);
166
+ const semantic = buildSemantic(design, primitive);
167
+ const $metadata = {
168
+ generator: 'designlang',
169
+ version: '7.0.0',
170
+ spec: 'https://design-tokens.github.io/community-group/format/',
171
+ };
172
+ if (design.meta?.url) $metadata.source = design.meta.url;
173
+ if (design.meta?.timestamp) $metadata.generatedAt = design.meta.timestamp;
174
+ return { $metadata, primitive, semantic };
175
+ }
@@ -1,76 +1,95 @@
1
1
  // Figma Variables JSON format (compatible with Figma Variables import)
2
2
  export function formatFigma(design) {
3
- const variables = [];
3
+ const collections = [];
4
4
 
5
- // Colors
5
+ // --- Brand collection (colors with light/dark modes) ---
6
+ const brandVars = [];
7
+ const hasDarkMode = !!design.darkMode;
8
+ const brandModes = hasDarkMode ? ['light', 'dark'] : ['light'];
9
+
10
+ // Brand colors
6
11
  if (design.colors.primary) {
7
- variables.push(colorVar('color/primary', design.colors.primary.hex));
12
+ const v = { name: 'color/primary', type: 'COLOR', values: { light: colorVal(design.colors.primary.hex) } };
13
+ if (hasDarkMode && design.darkMode.colors.primary) v.values.dark = colorVal(design.darkMode.colors.primary.hex);
14
+ else if (hasDarkMode) v.values.dark = v.values.light;
15
+ brandVars.push(v);
8
16
  }
9
17
  if (design.colors.secondary) {
10
- variables.push(colorVar('color/secondary', design.colors.secondary.hex));
18
+ const v = { name: 'color/secondary', type: 'COLOR', values: { light: colorVal(design.colors.secondary.hex) } };
19
+ if (hasDarkMode && design.darkMode.colors.secondary) v.values.dark = colorVal(design.darkMode.colors.secondary.hex);
20
+ else if (hasDarkMode) v.values.dark = v.values.light;
21
+ brandVars.push(v);
11
22
  }
12
23
  if (design.colors.accent) {
13
- variables.push(colorVar('color/accent', design.colors.accent.hex));
24
+ const v = { name: 'color/accent', type: 'COLOR', values: { light: colorVal(design.colors.accent.hex) } };
25
+ if (hasDarkMode && design.darkMode.colors.accent) v.values.dark = v.values.light;
26
+ brandVars.push(v);
14
27
  }
28
+
29
+ // Neutrals
15
30
  for (let i = 0; i < design.colors.neutrals.length && i < 10; i++) {
16
- variables.push(colorVar(`color/neutral/${i * 100 || 50}`, design.colors.neutrals[i].hex));
31
+ const label = i * 100 || 50;
32
+ const v = { name: `color/neutral/${label}`, type: 'COLOR', values: { light: colorVal(design.colors.neutrals[i].hex) } };
33
+ if (hasDarkMode && design.darkMode.colors.neutrals[i]) v.values.dark = colorVal(design.darkMode.colors.neutrals[i].hex);
34
+ else if (hasDarkMode) v.values.dark = v.values.light;
35
+ brandVars.push(v);
17
36
  }
37
+
38
+ // Semantic colors (backgrounds, text)
18
39
  for (let i = 0; i < design.colors.backgrounds.length; i++) {
19
- variables.push(colorVar(`color/background/${i === 0 ? 'default' : i}`, design.colors.backgrounds[i]));
40
+ const label = i === 0 ? 'default' : `${i}`;
41
+ const v = { name: `color/background/${label}`, type: 'COLOR', values: { light: colorVal(design.colors.backgrounds[i]) } };
42
+ if (hasDarkMode && design.darkMode.colors.backgrounds[i]) v.values.dark = colorVal(design.darkMode.colors.backgrounds[i]);
43
+ else if (hasDarkMode) v.values.dark = v.values.light;
44
+ brandVars.push(v);
20
45
  }
21
46
  for (let i = 0; i < design.colors.text.length && i < 5; i++) {
22
- variables.push(colorVar(`color/text/${i === 0 ? 'default' : i}`, design.colors.text[i]));
47
+ const label = i === 0 ? 'default' : `${i}`;
48
+ const v = { name: `color/text/${label}`, type: 'COLOR', values: { light: colorVal(design.colors.text[i]) } };
49
+ if (hasDarkMode && design.darkMode.colors.text[i]) v.values.dark = colorVal(design.darkMode.colors.text[i]);
50
+ else if (hasDarkMode) v.values.dark = v.values.light;
51
+ brandVars.push(v);
23
52
  }
24
53
 
25
- // Spacing
26
- for (const v of design.spacing.scale.slice(0, 20)) {
27
- variables.push({ name: `spacing/${v}`, type: 'FLOAT', value: v, scopes: ['GAP', 'ALL_SCOPES'] });
54
+ collections.push({ name: 'Brand', modes: brandModes, variables: brandVars });
55
+
56
+ // --- Typography collection ---
57
+ const typoVars = [];
58
+ for (const s of design.typography.scale.slice(0, 12)) {
59
+ typoVars.push({ name: `font/size/${s.size}`, type: 'FLOAT', values: { default: s.size } });
60
+ if (s.weight) {
61
+ typoVars.push({ name: `font/weight/${s.size}`, type: 'FLOAT', values: { default: parseInt(s.weight) || 400 } });
62
+ }
63
+ if (s.lineHeight && s.lineHeight !== 'normal') {
64
+ const lh = parseFloat(s.lineHeight);
65
+ if (!isNaN(lh)) {
66
+ typoVars.push({ name: `font/lineHeight/${s.size}`, type: 'FLOAT', values: { default: lh } });
67
+ }
68
+ }
69
+ }
70
+ if (typoVars.length > 0) {
71
+ collections.push({ name: 'Typography', modes: ['default'], variables: typoVars });
28
72
  }
29
73
 
74
+ // --- Spacing collection ---
75
+ const spacingVars = [];
76
+ for (const v of design.spacing.scale.slice(0, 20)) {
77
+ spacingVars.push({ name: `spacing/${v}`, type: 'FLOAT', values: { default: v } });
78
+ }
30
79
  // Border radius
31
80
  for (const r of design.borders.radii) {
32
- variables.push({ name: `radius/${r.label}`, type: 'FLOAT', value: r.value, scopes: ['CORNER_RADIUS'] });
81
+ spacingVars.push({ name: `radius/${r.label}`, type: 'FLOAT', values: { default: r.value } });
33
82
  }
34
-
35
- // Font sizes
36
- for (const s of design.typography.scale.slice(0, 12)) {
37
- variables.push({ name: `fontSize/${s.size}`, type: 'FLOAT', value: s.size, scopes: ['FONT_SIZE'] });
83
+ if (spacingVars.length > 0) {
84
+ collections.push({ name: 'Spacing', modes: ['default'], variables: spacingVars });
38
85
  }
39
86
 
40
- const collection = {
41
- name: `Design Language — ${design.meta.title || 'Extracted'}`,
42
- modes: [{ name: 'Default', variables }],
43
- };
44
-
45
- // Add dark mode if available
46
- if (design.darkMode) {
47
- const darkVars = [];
48
- const dc = design.darkMode.colors;
49
- if (dc.primary) darkVars.push(colorVar('color/primary', dc.primary.hex));
50
- if (dc.secondary) darkVars.push(colorVar('color/secondary', dc.secondary.hex));
51
- for (let i = 0; i < dc.neutrals.length && i < 10; i++) {
52
- darkVars.push(colorVar(`color/neutral/${i * 100 || 50}`, dc.neutrals[i].hex));
53
- }
54
- for (let i = 0; i < dc.backgrounds.length; i++) {
55
- darkVars.push(colorVar(`color/background/${i === 0 ? 'default' : i}`, dc.backgrounds[i]));
56
- }
57
- for (let i = 0; i < dc.text.length && i < 5; i++) {
58
- darkVars.push(colorVar(`color/text/${i === 0 ? 'default' : i}`, dc.text[i]));
59
- }
60
- collection.modes.push({ name: 'Dark', variables: darkVars });
61
- }
62
-
63
- return JSON.stringify(collection, null, 2);
87
+ return JSON.stringify({ collections }, null, 2);
64
88
  }
65
89
 
66
- function colorVar(name, hex) {
90
+ function colorVal(hex) {
67
91
  const rgb = hexToRgb(hex);
68
- return {
69
- name,
70
- type: 'COLOR',
71
- value: { r: rgb.r / 255, g: rgb.g / 255, b: rgb.b / 255, a: 1 },
72
- scopes: ['ALL_SCOPES'],
73
- };
92
+ return { r: rgb.r / 255, g: rgb.g / 255, b: rgb.b / 255, a: 1 };
74
93
  }
75
94
 
76
95
  function hexToRgb(hex) {