designlang 6.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 (35) 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 +111 -1
  7. package/bin/design-extract.js +88 -2
  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 +5 -4
  11. package/src/config.js +23 -0
  12. package/src/crawler.js +116 -0
  13. package/src/extractors/a11y-remediation.js +47 -0
  14. package/src/extractors/component-clusters.js +39 -0
  15. package/src/extractors/css-health.js +151 -0
  16. package/src/extractors/scoring.js +20 -1
  17. package/src/extractors/semantic-regions.js +44 -0
  18. package/src/extractors/stack-fingerprint.js +88 -0
  19. package/src/formatters/_token-ref.js +44 -0
  20. package/src/formatters/agent-rules.js +116 -0
  21. package/src/formatters/android-compose.js +164 -0
  22. package/src/formatters/dtcg-tokens.js +175 -0
  23. package/src/formatters/flutter-dart.js +130 -0
  24. package/src/formatters/ios-swiftui.js +161 -0
  25. package/src/formatters/markdown.js +25 -0
  26. package/src/formatters/wordpress.js +183 -0
  27. package/src/index.js +30 -0
  28. package/src/mcp/resources.js +64 -0
  29. package/src/mcp/server.js +110 -0
  30. package/src/mcp/tools.js +149 -0
  31. package/tests/cli.test.js +50 -0
  32. package/tests/extractors.test.js +131 -0
  33. package/tests/formatters.test.js +232 -0
  34. package/tests/mcp.test.js +68 -0
  35. 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/tests/cli.test.js CHANGED
@@ -2,6 +2,7 @@ import { describe, it } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
3
  import { execFileSync } from 'node:child_process';
4
4
  import { resolve } from 'node:path';
5
+ import { parsePlatforms, mergeConfig } from '../src/config.js';
5
6
 
6
7
  const CLI_PATH = resolve(import.meta.dirname, '..', 'bin', 'design-extract.js');
7
8
 
@@ -31,4 +32,53 @@ describe('CLI', () => {
31
32
  assert.ok(err.status !== 0);
32
33
  }
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
+ });
34
84
  });
@@ -11,6 +11,11 @@ import { extractLayout } from '../src/extractors/layout.js';
11
11
  import { extractGradients } from '../src/extractors/gradients.js';
12
12
  import { extractZIndex } from '../src/extractors/zindex.js';
13
13
  import { scoreDesignSystem } from '../src/extractors/scoring.js';
14
+ import { extractStackFingerprint } from '../src/extractors/stack-fingerprint.js';
15
+ import { extractCssHealth } from '../src/extractors/css-health.js';
16
+ import { remediateFailingPairs } from '../src/extractors/a11y-remediation.js';
17
+ import { extractSemanticRegions } from '../src/extractors/semantic-regions.js';
18
+ import { clusterComponents } from '../src/extractors/component-clusters.js';
14
19
 
15
20
  // ── Shared fixture defaults ─────────────────────────────────────
16
21
 
@@ -659,3 +664,129 @@ describe('scoreDesignSystem', () => {
659
664
  assert.ok(result.issues.some(i => i.includes('primary')));
660
665
  });
661
666
  });
667
+
668
+ // ── extractStackFingerprint ─────────────────────────────────────
669
+
670
+ describe('extractStackFingerprint', () => {
671
+ it('detects Next.js from __NEXT_DATA__', () => {
672
+ const out = extractStackFingerprint({ windowGlobals: ['__NEXT_DATA__'], scripts: [], metas: [], classNameSample: [] });
673
+ assert.equal(out.framework, 'next');
674
+ });
675
+
676
+ it('detects Tailwind from utility-heavy classNames', () => {
677
+ const out = extractStackFingerprint({
678
+ windowGlobals: [],
679
+ scripts: [],
680
+ metas: [],
681
+ classNameSample: [
682
+ 'flex items-center gap-4 text-sm text-gray-600',
683
+ 'px-4 py-2 rounded-md bg-blue-500',
684
+ 'grid grid-cols-3 md:grid-cols-4',
685
+ 'flex justify-center',
686
+ 'p-4 shadow-md',
687
+ 'mt-4 text-lg',
688
+ ],
689
+ });
690
+ assert.equal(out.css.layer, 'tailwind');
691
+ assert.ok(out.css.tailwind.utilities.length > 0);
692
+ });
693
+
694
+ it('returns unknown when nothing matches', () => {
695
+ const out = extractStackFingerprint({ windowGlobals: [], scripts: [], metas: [], classNameSample: ['foo', 'bar'] });
696
+ assert.equal(out.framework, 'unknown');
697
+ assert.equal(out.css.layer, 'unknown');
698
+ });
699
+ });
700
+
701
+ // ── extractCssHealth ────────────────────────────────────────────
702
+
703
+ describe('extractCssHealth', () => {
704
+ const payload = [{
705
+ url: 'https://x.com/a.css',
706
+ text: '.a{color:red}.a{color:red}.b{color:blue !important}.c-webkit-foo{color:x}@keyframes fade{0%{opacity:0}100%{opacity:1}\n}',
707
+ totalBytes: 1000,
708
+ ranges: [{ start: 0, end: 400 }], // 60% unused
709
+ }];
710
+
711
+ it('counts !important', () => {
712
+ const r = extractCssHealth(payload);
713
+ assert.equal(r.importantCount, 1);
714
+ });
715
+
716
+ it('counts duplicate declarations', () => {
717
+ const r = extractCssHealth(payload);
718
+ assert.ok(r.duplicates >= 1);
719
+ });
720
+
721
+ it('reports unused bytes', () => {
722
+ const r = extractCssHealth(payload);
723
+ assert.equal(r.unusedBytes, 600);
724
+ assert.equal(r.usedBytes, 400);
725
+ });
726
+
727
+ it('catalogs keyframes', () => {
728
+ const r = extractCssHealth(payload);
729
+ assert.ok(r.keyframes.some(k => k.name === 'fade'));
730
+ });
731
+ });
732
+
733
+ // ── remediateFailingPairs ───────────────────────────────────────
734
+
735
+ describe('remediateFailingPairs', () => {
736
+ it('suggests a palette color that passes AA', () => {
737
+ const failing = [{ fg: '#777777', bg: '#ffffff', ratio: 3.5, rule: 'AA-normal' }];
738
+ const palette = ['#000000', '#222222', '#555555', '#cccccc'];
739
+ const out = remediateFailingPairs(failing, palette);
740
+ assert.equal(out.length, 1);
741
+ assert.ok(out[0].suggestion);
742
+ assert.ok(out[0].suggestion.newRatio >= 4.5);
743
+ });
744
+
745
+ it('returns null suggestion when no palette color passes', () => {
746
+ const failing = [{ fg: '#eee', bg: '#fff', ratio: 1.1, rule: 'AA-normal' }];
747
+ const palette = ['#dedede'];
748
+ const out = remediateFailingPairs(failing, palette);
749
+ assert.equal(out[0].suggestion, null);
750
+ });
751
+ });
752
+
753
+ // ── extractSemanticRegions ──────────────────────────────────────
754
+
755
+ describe('extractSemanticRegions', () => {
756
+ it('labels header as nav', () => {
757
+ const out = extractSemanticRegions([{ tag: 'header', role: '', className: '', id: '', text: 'Home About', headings: [], buttonCount: 3, cardCount: 0, bounds: { x: 0, y: 0, w: 1280, h: 80 } }]);
758
+ assert.equal(out[0].role, 'nav');
759
+ });
760
+
761
+ it('labels section with CTA + heading as hero', () => {
762
+ const out = extractSemanticRegions([{ tag: 'section', role: '', className: 'hero', id: '', text: 'Welcome', headings: ['Build better'], buttonCount: 2, cardCount: 0, bounds: { x: 0, y: 80, w: 1280, h: 600 } }]);
763
+ assert.equal(out[0].role, 'hero');
764
+ });
765
+
766
+ it('labels pricing based on cards + keyword', () => {
767
+ const out = extractSemanticRegions([{ tag: 'section', role: '', className: '', id: '', text: 'Basic $9/mo Pro $29/mo Team $99/mo', headings: ['Pricing'], buttonCount: 3, cardCount: 3, bounds: { x: 0, y: 0, w: 1280, h: 400 } }]);
768
+ assert.equal(out[0].role, 'pricing');
769
+ });
770
+ });
771
+
772
+ // ── clusterComponents ───────────────────────────────────────────
773
+
774
+ describe('clusterComponents', () => {
775
+ it('collapses identical instances into one entry', () => {
776
+ const els = Array.from({ length: 5 }, () => ({ kind: 'button', structuralHash: 'button>span', styleVector: [16, 8, 0, 0], css: { bg: '#f00' } }));
777
+ const out = clusterComponents(els);
778
+ assert.equal(out.length, 1);
779
+ assert.equal(out[0].instanceCount, 5);
780
+ });
781
+
782
+ it('separates variants with different style vectors', () => {
783
+ const els = [
784
+ { kind: 'button', structuralHash: 'button>span', styleVector: [16, 8, 0, 0], css: { bg: '#f00' } },
785
+ { kind: 'button', structuralHash: 'button>span', styleVector: [16, 8, 0, 0], css: { bg: '#f00' } },
786
+ { kind: 'button', structuralHash: 'button>span', styleVector: [0, 0, 16, 8], css: { bg: '#0f0' } },
787
+ ];
788
+ const out = clusterComponents(els);
789
+ assert.equal(out.length, 1);
790
+ assert.equal(out[0].variants.length, 2);
791
+ });
792
+ });