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,110 @@
1
+ // Thin stdio MCP server glue. Loads the latest extraction from the given
2
+ // output directory and wires buildResources + buildTools into the SDK.
3
+
4
+ import { existsSync, readdirSync, readFileSync, statSync } from 'fs';
5
+ import { join, resolve } from 'path';
6
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
7
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
8
+ import {
9
+ ListResourcesRequestSchema,
10
+ ReadResourceRequestSchema,
11
+ ListToolsRequestSchema,
12
+ CallToolRequestSchema,
13
+ } from '@modelcontextprotocol/sdk/types.js';
14
+ import { buildResources } from './resources.js';
15
+ import { buildTools } from './tools.js';
16
+
17
+ // Best-effort reconstruction of a design object from the files on disk.
18
+ // We only need what the tools/resources consume: tokens + regions +
19
+ // componentClusters + accessibility.remediation + cssHealth + colors.all.
20
+ function loadExtraction(outputDir) {
21
+ if (!existsSync(outputDir)) return null;
22
+ const entries = readdirSync(outputDir);
23
+ const tokenFiles = entries
24
+ .filter((f) => f.endsWith('-design-tokens.json'))
25
+ .map((f) => {
26
+ const p = join(outputDir, f);
27
+ return { path: p, mtime: statSync(p).mtimeMs, prefix: f.replace(/-design-tokens\.json$/, '') };
28
+ })
29
+ .sort((a, b) => b.mtime - a.mtime);
30
+
31
+ if (!tokenFiles.length) return null;
32
+ const latest = tokenFiles[0];
33
+
34
+ let tokens;
35
+ try { tokens = JSON.parse(readFileSync(latest.path, 'utf-8')); } catch { return null; }
36
+
37
+ // Legacy (pre-v7) tokens don't have primitive/semantic. Skip silently.
38
+ if (!tokens?.primitive || !tokens?.semantic) {
39
+ return { tokens: null, design: {} };
40
+ }
41
+
42
+ // Load the MCP companion written at extraction time (`*-mcp.json`).
43
+ // Falls back to empty shape if absent — older extractions stay usable for
44
+ // token resources/tools even without regions/components/health.
45
+ const companionPath = join(outputDir, `${latest.prefix}-mcp.json`);
46
+ let companion = null;
47
+ if (existsSync(companionPath)) {
48
+ try { companion = JSON.parse(readFileSync(companionPath, 'utf-8')); } catch { /* ignore */ }
49
+ }
50
+
51
+ const design = {
52
+ colors: { all: companion?.colors?.all || [] },
53
+ regions: companion?.regions || [],
54
+ componentClusters: companion?.componentClusters || [],
55
+ accessibility: { remediation: companion?.accessibility?.remediation || [] },
56
+ cssHealth: companion?.cssHealth ?? null,
57
+ };
58
+
59
+ return { tokens, design };
60
+ }
61
+
62
+ export async function run({ outputDir }) {
63
+ const resolved = resolve(outputDir || './design-extract-output');
64
+ const loaded = loadExtraction(resolved);
65
+ const tokens = loaded?.tokens ?? null;
66
+ const design = loaded?.design ?? {};
67
+
68
+ const resources = buildResources({ design, tokens });
69
+ const tools = buildTools({ design, tokens });
70
+
71
+ const server = new Server(
72
+ { name: 'designlang', version: '7.0.0' },
73
+ { capabilities: { resources: {}, tools: {} } },
74
+ );
75
+
76
+ server.setRequestHandler(ListResourcesRequestSchema, async () => ({
77
+ resources: tokens ? resources.list() : [],
78
+ }));
79
+
80
+ server.setRequestHandler(ReadResourceRequestSchema, async (req) => {
81
+ if (!tokens) throw Object.assign(new Error('no extraction loaded'), { code: -32002 });
82
+ const { uri } = req.params;
83
+ const r = resources.read(uri);
84
+ return { contents: [r] };
85
+ });
86
+
87
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: tools.list() }));
88
+
89
+ server.setRequestHandler(CallToolRequestSchema, async (req) => {
90
+ if (!tokens) {
91
+ return {
92
+ isError: true,
93
+ content: [{ type: 'text', text: 'no extraction loaded' }],
94
+ };
95
+ }
96
+ const { name, arguments: args } = req.params;
97
+ try {
98
+ const result = await tools.call(name, args);
99
+ return { content: [{ type: 'text', text: JSON.stringify(result) }] };
100
+ } catch (err) {
101
+ return {
102
+ isError: true,
103
+ content: [{ type: 'text', text: err?.message || String(err) }],
104
+ };
105
+ }
106
+ });
107
+
108
+ const transport = new StdioServerTransport();
109
+ await server.connect(transport);
110
+ }
@@ -0,0 +1,149 @@
1
+ // MCP tools builder. Pure/testable — returns { list, call } over the
2
+ // loaded design + tokens. No transport concerns here.
3
+
4
+ import { remediateFailingPairs } from '../extractors/a11y-remediation.js';
5
+
6
+ function rpcError(code, message) {
7
+ const e = new Error(message);
8
+ e.code = code;
9
+ return e;
10
+ }
11
+
12
+ // Flatten a DTCG token tree to [{ path, $type, $value }] leaves.
13
+ function flattenTokens(tree, prefix, out) {
14
+ if (tree == null || typeof tree !== 'object') return;
15
+ if ('$value' in tree) {
16
+ out.push({ path: prefix, $type: tree.$type, $value: tree.$value });
17
+ return;
18
+ }
19
+ for (const key of Object.keys(tree)) {
20
+ const next = prefix ? `${prefix}.${key}` : key;
21
+ flattenTokens(tree[key], next, out);
22
+ }
23
+ }
24
+
25
+ function paletteHexes(design) {
26
+ const all = design?.colors?.all || [];
27
+ const out = [];
28
+ for (const entry of all) {
29
+ if (!entry) continue;
30
+ if (typeof entry === 'string') out.push(entry);
31
+ else if (typeof entry === 'object' && typeof entry.hex === 'string') out.push(entry.hex);
32
+ }
33
+ return out;
34
+ }
35
+
36
+ const TOOL_DEFS = [
37
+ {
38
+ name: 'search_tokens',
39
+ description: 'Case-insensitive substring match over all token dot-paths.',
40
+ inputSchema: {
41
+ type: 'object',
42
+ properties: { query: { type: 'string', description: 'substring to search for' } },
43
+ required: ['query'],
44
+ },
45
+ },
46
+ {
47
+ name: 'find_nearest_color',
48
+ description: 'Find the nearest palette color that passes the requested WCAG contrast rule against the given hex.',
49
+ inputSchema: {
50
+ type: 'object',
51
+ properties: {
52
+ hex: { type: 'string', description: 'background hex (e.g. #ffffff)' },
53
+ level: { type: 'string', enum: ['AA-normal', 'AA-large', 'AAA-normal', 'AAA-large'] },
54
+ },
55
+ required: ['hex', 'level'],
56
+ },
57
+ },
58
+ {
59
+ name: 'get_region',
60
+ description: 'Return the region with the given role (e.g. hero, nav, footer).',
61
+ inputSchema: {
62
+ type: 'object',
63
+ properties: { name: { type: 'string' } },
64
+ required: ['name'],
65
+ },
66
+ },
67
+ {
68
+ name: 'get_component',
69
+ description: 'Return the component cluster with matching kind, optionally narrowed to a variant index.',
70
+ inputSchema: {
71
+ type: 'object',
72
+ properties: {
73
+ name: { type: 'string' },
74
+ variant: { type: 'number' },
75
+ },
76
+ required: ['name'],
77
+ },
78
+ },
79
+ {
80
+ name: 'list_failing_contrast_pairs',
81
+ description: 'Return the accessibility remediation array (failing fg/bg pairs with suggestions).',
82
+ inputSchema: { type: 'object', properties: {} },
83
+ },
84
+ ];
85
+
86
+ export function buildTools({ design, tokens }) {
87
+ // Pre-flatten for search.
88
+ const flat = [];
89
+ flattenTokens(tokens?.primitive, 'primitive', flat);
90
+ flattenTokens(tokens?.semantic, 'semantic', flat);
91
+
92
+ async function searchTokens(args) {
93
+ if (!args || typeof args.query !== 'string') throw rpcError(-32602, 'search_tokens: query must be a string');
94
+ const q = args.query.toLowerCase();
95
+ const matches = flat.filter(t => t.path.toLowerCase().includes(q));
96
+ return { matches };
97
+ }
98
+
99
+ async function findNearestColor(args) {
100
+ if (!args || typeof args.hex !== 'string') throw rpcError(-32602, 'find_nearest_color: hex must be a string');
101
+ const validLevels = new Set(['AA-normal', 'AA-large', 'AAA-normal', 'AAA-large']);
102
+ if (!validLevels.has(args.level)) throw rpcError(-32602, 'find_nearest_color: level must be one of AA-normal|AA-large|AAA-normal|AAA-large');
103
+ const palette = paletteHexes(design);
104
+ const [res] = remediateFailingPairs(
105
+ [{ fg: '#000000', bg: args.hex, ratio: 0, rule: args.level }],
106
+ palette,
107
+ );
108
+ if (!res?.suggestion) return { color: null, newRatio: null };
109
+ return { color: res.suggestion.color, newRatio: res.suggestion.newRatio, replace: res.suggestion.replace };
110
+ }
111
+
112
+ async function getRegion(args) {
113
+ if (!args || typeof args.name !== 'string') throw rpcError(-32602, 'get_region: name must be a string');
114
+ const regions = design?.regions || [];
115
+ return regions.find(r => r && (r.role === args.name || r.name === args.name)) || null;
116
+ }
117
+
118
+ async function getComponent(args) {
119
+ if (!args || typeof args.name !== 'string') throw rpcError(-32602, 'get_component: name must be a string');
120
+ const clusters = design?.componentClusters || [];
121
+ const found = clusters.find(c => c && c.kind === args.name);
122
+ if (!found) return null;
123
+ if (typeof args.variant === 'number' && Array.isArray(found.variants)) {
124
+ return found.variants[args.variant] ?? null;
125
+ }
126
+ return found;
127
+ }
128
+
129
+ async function listFailing() {
130
+ return design?.accessibility?.remediation || [];
131
+ }
132
+
133
+ const dispatch = {
134
+ search_tokens: searchTokens,
135
+ find_nearest_color: findNearestColor,
136
+ get_region: getRegion,
137
+ get_component: getComponent,
138
+ list_failing_contrast_pairs: listFailing,
139
+ };
140
+
141
+ return {
142
+ list() { return TOOL_DEFS.slice(); },
143
+ async call(name, args) {
144
+ const fn = dispatch[name];
145
+ if (!fn) throw rpcError(-32602, `Unknown tool: ${name}`);
146
+ return await fn(args || {});
147
+ },
148
+ };
149
+ }
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,84 @@
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
+ import { parsePlatforms, mergeConfig } from '../src/config.js';
6
+
7
+ const CLI_PATH = resolve(import.meta.dirname, '..', 'bin', 'design-extract.js');
8
+
9
+ describe('CLI', () => {
10
+ it('shows help with --help', () => {
11
+ const output = execFileSync('node', [CLI_PATH, '--help'], { encoding: 'utf-8' });
12
+ assert.ok(output.includes('designlang'));
13
+ assert.ok(output.includes('Extract'));
14
+ });
15
+
16
+ it('shows version with --version', () => {
17
+ const output = execFileSync('node', [CLI_PATH, '--version'], { encoding: 'utf-8' });
18
+ assert.ok(output.trim().match(/^\d+\.\d+\.\d+$/));
19
+ });
20
+
21
+ it('shows version number 6.0.0', () => {
22
+ const output = execFileSync('node', [CLI_PATH, '--version'], { encoding: 'utf-8' });
23
+ assert.equal(output.trim(), '6.0.0');
24
+ });
25
+
26
+ it('exits with error when no arguments provided', () => {
27
+ try {
28
+ execFileSync('node', [CLI_PATH], { encoding: 'utf-8', stdio: 'pipe' });
29
+ assert.fail('Should have thrown');
30
+ } catch (err) {
31
+ // Commander exits with code 1 when required argument is missing
32
+ assert.ok(err.status !== 0);
33
+ }
34
+ });
35
+
36
+ it('lists --platforms option in help output', () => {
37
+ const output = execFileSync('node', [CLI_PATH, '--help'], { encoding: 'utf-8' });
38
+ assert.ok(output.includes('--platforms'));
39
+ });
40
+ });
41
+
42
+ describe('parsePlatforms', () => {
43
+ it('defaults to web only', () => {
44
+ assert.deepEqual(parsePlatforms('web'), ['web']);
45
+ });
46
+
47
+ it('parses comma-separated values', () => {
48
+ assert.deepEqual(parsePlatforms('ios,android'), ['web', 'ios', 'android']);
49
+ });
50
+
51
+ it('expands "all" to every known platform', () => {
52
+ assert.deepEqual(parsePlatforms('all'), ['web', 'ios', 'android', 'flutter', 'wordpress']);
53
+ });
54
+
55
+ it('always includes web (additive)', () => {
56
+ assert.ok(parsePlatforms('ios').includes('web'));
57
+ assert.ok(parsePlatforms('wordpress').includes('web'));
58
+ });
59
+
60
+ it('ignores unknown platforms', () => {
61
+ assert.deepEqual(parsePlatforms('ios,badplatform,android'), ['web', 'ios', 'android']);
62
+ });
63
+
64
+ it('accepts arrays', () => {
65
+ assert.deepEqual(parsePlatforms(['ios', 'flutter']), ['web', 'ios', 'flutter']);
66
+ });
67
+ });
68
+
69
+ describe('mergeConfig platforms', () => {
70
+ it('threads CLI --platforms through mergeConfig', () => {
71
+ const merged = mergeConfig({ platforms: 'ios,flutter' }, {});
72
+ assert.deepEqual(merged.platforms, ['web', 'ios', 'flutter']);
73
+ });
74
+
75
+ it('honors platforms from config file', () => {
76
+ const merged = mergeConfig({}, { platforms: 'android' });
77
+ assert.deepEqual(merged.platforms, ['web', 'android']);
78
+ });
79
+
80
+ it('defaults to [web] when neither CLI nor config provides platforms', () => {
81
+ const merged = mergeConfig({}, {});
82
+ assert.deepEqual(merged.platforms, ['web']);
83
+ });
84
+ });