designlang 8.0.0 → 10.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,214 @@
1
+ // Generates ready-to-paste prompts for v0, Lovable, Cursor, and Claude
2
+ // Artifacts — plus atomic per-component "recipe cards". The point is to get an
3
+ // LLM to reproduce a site's look without the user having to hand-translate the
4
+ // extracted JSON. We inline the tokens, voice, section order, and library
5
+ // guidance so the model has everything it needs in one message.
6
+
7
+ function colorList(design) {
8
+ const all = (design.colors?.all || []).map(c => c.hex).filter(Boolean);
9
+ return [...new Set(all)].slice(0, 14);
10
+ }
11
+
12
+ function typeFamilies(design) {
13
+ return [...new Set((design.typography?.families || []).filter(Boolean))].slice(0, 4);
14
+ }
15
+
16
+ function scaleSnippet(scale = []) {
17
+ if (!scale.length) return '(not detected)';
18
+ return scale.slice(0, 8).map(s => (s.value || s) + (s.label ? ` (${s.label})` : '')).join(', ');
19
+ }
20
+
21
+ function radiiSnippet(borders) {
22
+ const r = (borders?.radii || []).slice(0, 6).map(x => (x.value || x).toString());
23
+ return r.length ? r.join(', ') : '(none)';
24
+ }
25
+
26
+ function shadowSnippet(shadows) {
27
+ const s = (shadows?.values || []).slice(0, 3).map(x => (x.value || x).toString());
28
+ return s.length ? s.join(' | ') : '(none)';
29
+ }
30
+
31
+ function librarySuggest(library) {
32
+ if (!library || library.library === 'unknown') return null;
33
+ const map = {
34
+ 'shadcn/ui': 'Use shadcn/ui components (Button, Card, Dialog, Input, Sheet, Tabs). Pair with Tailwind.',
35
+ 'radix-ui': 'Use Radix UI primitives for accessibility. Style with your preferred CSS solution.',
36
+ 'headlessui': 'Use Headless UI primitives styled with Tailwind.',
37
+ 'mui': 'Use MUI v5 components with a custom ThemeProvider matching the tokens below.',
38
+ 'chakra-ui': 'Use Chakra UI components with a custom extendTheme({}) block.',
39
+ 'mantine': 'Use Mantine UI components with MantineProvider theme overrides.',
40
+ 'ant-design': 'Use Ant Design v5 with ConfigProvider theme tokens.',
41
+ 'bootstrap': 'Use Bootstrap 5 utility classes. Customize via CSS variables.',
42
+ 'heroui': 'Use HeroUI/NextUI components.',
43
+ 'tailwind-ui': 'Use Tailwind UI patterns — no component library runtime, just Tailwind classes.',
44
+ 'tailwindcss': 'Use plain Tailwind CSS without a component library.',
45
+ 'vuetify': 'Use Vuetify 3 components with a custom theme object.',
46
+ };
47
+ return map[library.library] || null;
48
+ }
49
+
50
+ function sectionList(sectionRoles) {
51
+ if (!sectionRoles || !sectionRoles.sections) return [];
52
+ return sectionRoles.sections
53
+ .filter(s => s.role && s.role !== 'content' && s.role !== 'nav')
54
+ .map(s => {
55
+ const slot = s.slots?.heading ? ` — heading: "${s.slots.heading.slice(0, 80)}"` : '';
56
+ return `- ${s.role}${slot}`;
57
+ });
58
+ }
59
+
60
+ function voiceBlock(voice) {
61
+ if (!voice) return '';
62
+ const parts = [];
63
+ if (voice.tone) parts.push(`Tone: ${voice.tone}`);
64
+ if (voice.headingStyle) parts.push(`Headings: ${voice.headingStyle}`);
65
+ if (voice.pronounPosture) parts.push(`Pronoun posture: ${voice.pronounPosture}`);
66
+ if ((voice.ctaVerbs || []).length) parts.push(`CTA verbs: ${(voice.ctaVerbs || []).slice(0, 6).join(', ')}`);
67
+ return parts.join(' · ');
68
+ }
69
+
70
+ function coreBrief(design, opts = {}) {
71
+ const colors = colorList(design);
72
+ const fonts = typeFamilies(design);
73
+ const spacing = scaleSnippet(design.spacing?.scale);
74
+ const radii = radiiSnippet(design.borders);
75
+ const shadows = shadowSnippet(design.shadows);
76
+ const material = design.materialLanguage?.label || 'flat';
77
+ const intent = design.pageIntent?.type || 'landing';
78
+ const sections = sectionList(design.sectionRoles);
79
+ const voice = voiceBlock(design.voice);
80
+ const lib = librarySuggest(design.componentLibrary);
81
+ return {
82
+ colors, fonts, spacing, radii, shadows, material, intent, sections, voice, lib,
83
+ };
84
+ }
85
+
86
+ export function formatV0Prompt(design) {
87
+ const b = coreBrief(design);
88
+ return [
89
+ `Build a ${b.intent} page with this exact visual language.`,
90
+ '',
91
+ 'COLORS:',
92
+ b.colors.map(c => ` ${c}`).join('\n'),
93
+ '',
94
+ `FONTS: ${b.fonts.join(', ') || 'system-ui'}`,
95
+ `SPACING: ${b.spacing}`,
96
+ `RADIUS: ${b.radii}`,
97
+ `SHADOWS: ${b.shadows}`,
98
+ `MATERIAL LANGUAGE: ${b.material}`,
99
+ b.voice ? `VOICE: ${b.voice}` : '',
100
+ b.lib ? `LIBRARY: ${b.lib}` : '',
101
+ '',
102
+ 'SECTIONS (in order):',
103
+ (b.sections.length ? b.sections : ['- hero', '- features', '- cta', '- footer']).join('\n'),
104
+ '',
105
+ 'Use Tailwind. Match these tokens exactly. Keep the material language consistent.',
106
+ ].filter(Boolean).join('\n');
107
+ }
108
+
109
+ export function formatLovablePrompt(design) {
110
+ const b = coreBrief(design);
111
+ return [
112
+ `Clone the design language of this ${b.intent} page and build a fresh equivalent.`,
113
+ '',
114
+ `Visual feel: ${b.material}. ${b.voice || ''}`,
115
+ `Primary palette: ${b.colors.slice(0, 5).join(', ')}.`,
116
+ `Typography: ${b.fonts.join(', ') || 'system-ui'}.`,
117
+ `Corner radius vocabulary: ${b.radii}.`,
118
+ `Shadow vocabulary: ${b.shadows}.`,
119
+ b.lib ? `Use: ${b.lib}` : '',
120
+ '',
121
+ 'Page structure:',
122
+ (b.sections.length ? b.sections : ['- hero', '- features', '- social proof', '- cta', '- footer']).join('\n'),
123
+ ].filter(Boolean).join('\n');
124
+ }
125
+
126
+ export function formatCursorPrompt(design) {
127
+ const b = coreBrief(design);
128
+ return [
129
+ '# Design brief',
130
+ '',
131
+ `Page type: **${b.intent}**.`,
132
+ `Material language: **${b.material}**.`,
133
+ b.voice ? `Voice: ${b.voice}.` : '',
134
+ '',
135
+ '## Tokens',
136
+ '',
137
+ '```ts',
138
+ 'export const tokens = {',
139
+ ` colors: [${b.colors.map(c => `'${c}'`).join(', ')}],`,
140
+ ` fonts: [${b.fonts.map(f => `'${f}'`).join(', ')}],`,
141
+ ` radii: [${(design.borders?.radii || []).slice(0, 6).map(r => `'${(r.value || r)}'`).join(', ')}],`,
142
+ ` shadows: [${(design.shadows?.values || []).slice(0, 3).map(s => `'${(s.value || s).replace(/'/g, "\\'")}'`).join(', ')}],`,
143
+ '};',
144
+ '```',
145
+ '',
146
+ '## Sections',
147
+ (b.sections.length ? b.sections : ['- hero', '- features', '- cta', '- footer']).join('\n'),
148
+ '',
149
+ b.lib ? `## Library\n\n${b.lib}` : '',
150
+ ].filter(Boolean).join('\n');
151
+ }
152
+
153
+ export function formatClaudeArtifactPrompt(design) {
154
+ const b = coreBrief(design);
155
+ return [
156
+ 'Create a React artifact that reproduces this brand\'s design language.',
157
+ '',
158
+ `Page intent: ${b.intent}.`,
159
+ `Material language: ${b.material}.`,
160
+ b.voice ? `Voice: ${b.voice}.` : '',
161
+ b.lib ? `Library preference: ${b.lib}` : '',
162
+ '',
163
+ `Colors to use: ${b.colors.join(', ')}.`,
164
+ `Fonts: ${b.fonts.join(', ') || 'system-ui'}.`,
165
+ `Radius vocabulary: ${b.radii}.`,
166
+ '',
167
+ 'Sections:',
168
+ (b.sections.length ? b.sections : ['- hero', '- features', '- cta', '- footer']).join('\n'),
169
+ '',
170
+ 'Use Tailwind via CDN, lucide-react for icons, and keep the material language consistent across sections. Do not add extra decorative elements outside this vocabulary.',
171
+ ].filter(Boolean).join('\n');
172
+ }
173
+
174
+ export function formatRecipeCards(design) {
175
+ const clusters = design.componentClusters || [];
176
+ if (!clusters.length) return [];
177
+ const b = coreBrief(design);
178
+ return clusters.slice(0, 12).map((c, i) => {
179
+ const name = c.name || c.kind || `component-${i + 1}`;
180
+ const signals = [
181
+ b.lib,
182
+ `Radius: ${b.radii}`,
183
+ `Shadows: ${b.shadows}`,
184
+ ].filter(Boolean).join(' · ');
185
+ return {
186
+ name,
187
+ content: [
188
+ `# Recipe: ${name}`,
189
+ '',
190
+ `Build one ${name} component that matches this brand.`,
191
+ '',
192
+ `Palette: ${b.colors.slice(0, 6).join(', ')}`,
193
+ `Typography: ${b.fonts.join(', ') || 'system-ui'}`,
194
+ `Material: ${b.material}`,
195
+ signals ? `Signals: ${signals}` : '',
196
+ '',
197
+ '## Anatomy (detected)',
198
+ '```json',
199
+ JSON.stringify(c, null, 2).slice(0, 1500),
200
+ '```',
201
+ ].filter(Boolean).join('\n'),
202
+ };
203
+ });
204
+ }
205
+
206
+ export function buildPromptPack(design) {
207
+ return {
208
+ 'v0.txt': formatV0Prompt(design),
209
+ 'lovable.txt': formatLovablePrompt(design),
210
+ 'cursor.md': formatCursorPrompt(design),
211
+ 'claude-artifacts.md': formatClaudeArtifactPrompt(design),
212
+ recipes: formatRecipeCards(design),
213
+ };
214
+ }
package/src/index.js CHANGED
@@ -25,7 +25,16 @@ import { extractModernCss } from './extractors/modern-css.js';
25
25
  import { extractWideGamut } from './extractors/wide-gamut.js';
26
26
  import { extractTokenSources } from './extractors/token-sources.js';
27
27
  import { extractInteractionStates } from './extractors/interaction-states.js';
28
+ import { extractMotion } from './extractors/motion.js';
29
+ import { extractComponentAnatomy, formatAnatomyStubs } from './extractors/component-anatomy.js';
30
+ import { extractVoice } from './extractors/voice.js';
31
+ import { extractPageIntent } from './extractors/page-intent.js';
32
+ import { extractSectionRoles } from './extractors/section-roles.js';
33
+ import { extractComponentLibrary } from './extractors/component-library.js';
34
+ import { extractMaterialLanguage } from './extractors/material-language.js';
35
+ import { extractImageryStyle } from './extractors/imagery-style.js';
28
36
  import { formatDtcgTokens } from './formatters/dtcg-tokens.js';
37
+ import { formatMotionTokens } from './formatters/motion-tokens.js';
29
38
 
30
39
  function safeExtract(fn, ...args) {
31
40
  try { return fn(...args); } catch { return null; }
@@ -73,6 +82,9 @@ export async function extractDesignLanguage(url, options = {}) {
73
82
  wideGamut: safeExtract(extractWideGamut, rawData.light.modernColors || []) || { oklch: { count: 0, samples: [] }, oklab: { count: 0, samples: [] }, colorMix: { count: 0, samples: [] }, lightDark: { count: 0, samples: [] }, displayP3: { count: 0, samples: [] }, rec2020: { count: 0, samples: [] }, totalCount: 0 },
74
83
  tokenSources: [],
75
84
  interactionStates: safeExtract(extractInteractionStates, rawData.interactState || rawData.light.interactState) || { scrollSettled: false, menusOpened: 0, hover: { sampled: 0, changed: 0, deltas: [] }, accordionsOpened: 0, modals: [] },
85
+ motion: safeExtract(extractMotion, styles, rawData.light.keyframes) || { durations: [], easings: [], springs: [], keyframes: [], scrollLinked: { present: false, signals: [] }, stats: {}, feel: 'unknown' },
86
+ componentAnatomy: safeExtract(extractComponentAnatomy, rawData.light.componentCandidates) || [],
87
+ voice: safeExtract(extractVoice, { componentCandidates: rawData.light.componentCandidates, sections: rawData.light.sections }) || { tone: 'neutral', ctaVerbs: [], buttonPatterns: [], sampleHeadings: [] },
76
88
  score: null,
77
89
  };
78
90
 
@@ -118,6 +130,17 @@ export async function extractDesignLanguage(url, options = {}) {
118
130
 
119
131
  design.tokenSources = safeExtract(extractTokenSources, design, styles) || [];
120
132
 
133
+ // v10: page intent, section roles, component library, material language,
134
+ // imagery style. All additive — no existing field is modified.
135
+ design.pageIntent = safeExtract(extractPageIntent, rawData, { url: rawData.url, title: rawData.title }) || { type: 'unknown', confidence: 0, signals: [] };
136
+ design.sectionRoles = safeExtract(extractSectionRoles, rawData.light?.sections || [], design.regions, design.pageIntent) || { sections: [], counts: {}, readingOrder: [] };
137
+ design.componentLibrary = safeExtract(extractComponentLibrary, rawData.light?.stack || {}) || { library: 'unknown', confidence: 0, evidence: [], alternates: [] };
138
+ design.materialLanguage = safeExtract(extractMaterialLanguage, design) || { label: 'flat', confidence: 0, signals: [], metrics: {} };
139
+ design.imageryStyle = safeExtract(extractImageryStyle, rawData.light?.images || []) || { label: 'none', confidence: 0, counts: {}, signals: [] };
140
+ // Stash raw crawler output so downstream orchestration (multipage, smart)
141
+ // can rebuild the digest without re-crawling.
142
+ design._raw = rawData;
143
+
121
144
  // Per-route token extraction (Tier 2 multi-page reconciliation).
122
145
  if (Array.isArray(rawData.routes) && rawData.routes.length > 0) {
123
146
  design.routes = rawData.routes.map(r => {
@@ -165,3 +188,20 @@ export { watchSite } from './watch.js';
165
188
  export { diffDarkMode } from './darkdiff.js';
166
189
  export { applyDesign } from './apply.js';
167
190
  export { loadConfig, mergeConfig } from './config.js';
191
+ export { extractMotion } from './extractors/motion.js';
192
+ export { formatMotionTokens } from './formatters/motion-tokens.js';
193
+ export { extractComponentAnatomy, formatAnatomyStubs } from './extractors/component-anatomy.js';
194
+ export { extractVoice } from './extractors/voice.js';
195
+ export { lintTokens } from './lint.js';
196
+ export { checkDrift, formatDriftMarkdown } from './drift.js';
197
+ export { visualDiff, formatVisualDiffHtml } from './visual-diff.js';
198
+ // v10
199
+ export { extractPageIntent } from './extractors/page-intent.js';
200
+ export { extractSectionRoles } from './extractors/section-roles.js';
201
+ export { extractComponentLibrary } from './extractors/component-library.js';
202
+ export { extractMaterialLanguage } from './extractors/material-language.js';
203
+ export { extractImageryStyle } from './extractors/imagery-style.js';
204
+ export { extractLogo } from './extractors/logo.js';
205
+ export { refineWithSmart } from './classifiers/smart.js';
206
+ export { crawlCanonicalPages, computeCrossPageConsistency, discoverCanonicalPages } from './multipage.js';
207
+ export { buildPromptPack, formatV0Prompt, formatLovablePrompt, formatCursorPrompt, formatClaudeArtifactPrompt } from './formatters/prompt-pack.js';
package/src/lint.js ADDED
@@ -0,0 +1,198 @@
1
+ // designlang lint — audit a local token file for the same issues scoring flags on live sites.
2
+ // Supports DTCG, flat design-tokens.json, Tailwind config (partial), and CSS variable files.
3
+
4
+ import { readFileSync } from 'fs';
5
+ import { extname } from 'path';
6
+
7
+ function hexToRgb(hex) {
8
+ const m = hex.replace('#', '').match(/^([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i);
9
+ if (!m) return null;
10
+ return { r: parseInt(m[1], 16), g: parseInt(m[2], 16), b: parseInt(m[3], 16) };
11
+ }
12
+
13
+ function relLum({ r, g, b }) {
14
+ const f = v => { v /= 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); };
15
+ return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b);
16
+ }
17
+
18
+ function contrast(a, b) {
19
+ const la = relLum(a), lb = relLum(b);
20
+ const [hi, lo] = la > lb ? [la, lb] : [lb, la];
21
+ return (hi + 0.05) / (lo + 0.05);
22
+ }
23
+
24
+ function flattenDtcg(obj, prefix = '', out = []) {
25
+ for (const [k, v] of Object.entries(obj || {})) {
26
+ if (k.startsWith('$')) continue;
27
+ if (v && typeof v === 'object') {
28
+ if ('$value' in v) {
29
+ out.push({ name: prefix ? `${prefix}.${k}` : k, value: v.$value, type: v.$type });
30
+ } else {
31
+ flattenDtcg(v, prefix ? `${prefix}.${k}` : k, out);
32
+ }
33
+ }
34
+ }
35
+ return out;
36
+ }
37
+
38
+ function loadTokens(file) {
39
+ const raw = readFileSync(file, 'utf8');
40
+ const ext = extname(file);
41
+ if (ext === '.json') {
42
+ const json = JSON.parse(raw);
43
+ const flat = flattenDtcg(json);
44
+ if (flat.length) return flat;
45
+ // flat fallback: { colors: { primary: '#000' }, ... }
46
+ const out = [];
47
+ for (const [group, entries] of Object.entries(json)) {
48
+ if (!entries || typeof entries !== 'object') continue;
49
+ for (const [k, v] of Object.entries(entries)) {
50
+ out.push({ name: `${group}.${k}`, value: String(v), type: group.replace(/s$/, '') });
51
+ }
52
+ }
53
+ return out;
54
+ }
55
+ if (ext === '.css') {
56
+ const out = [];
57
+ for (const m of raw.matchAll(/--([\w-]+):\s*([^;]+);/g)) {
58
+ out.push({ name: m[1], value: m[2].trim(), type: 'unknown' });
59
+ }
60
+ return out;
61
+ }
62
+ throw new Error(`Unsupported file type: ${ext}. Use .json or .css.`);
63
+ }
64
+
65
+ function isColor(t) { return t.type === 'color' || /^#[\da-f]{3,8}$/i.test(t.value) || /^rgb/i.test(t.value); }
66
+ function toHex(v) {
67
+ const m = v.match(/^#([a-f\d]{3,8})$/i);
68
+ if (m) {
69
+ const h = m[1];
70
+ if (h.length === 3) return '#' + h.split('').map(c => c + c).join('');
71
+ return '#' + h.slice(0, 6);
72
+ }
73
+ const rgb = v.match(/rgba?\(\s*(\d+)\s*,?\s*(\d+)\s*,?\s*(\d+)/i);
74
+ if (rgb) return '#' + [rgb[1], rgb[2], rgb[3]].map(n => (+n).toString(16).padStart(2, '0')).join('');
75
+ return null;
76
+ }
77
+
78
+ function dedupeClose(hexes, threshold = 8) {
79
+ const near = [];
80
+ for (let i = 0; i < hexes.length; i++) {
81
+ for (let j = i + 1; j < hexes.length; j++) {
82
+ const a = hexToRgb(hexes[i].value), b = hexToRgb(hexes[j].value);
83
+ if (!a || !b) continue;
84
+ const dist = Math.sqrt((a.r - b.r) ** 2 + (a.g - b.g) ** 2 + (a.b - b.b) ** 2);
85
+ if (dist > 0 && dist < threshold) near.push({ a: hexes[i].name, b: hexes[j].name, distance: Math.round(dist) });
86
+ }
87
+ }
88
+ return near;
89
+ }
90
+
91
+ function parsePx(v) {
92
+ const m = String(v).match(/^(-?\d*\.?\d+)px$/);
93
+ return m ? parseFloat(m[1]) : null;
94
+ }
95
+
96
+ function checkScale(values, label) {
97
+ const nums = values.map(parsePx).filter(n => n !== null && n >= 0).sort((a, b) => a - b);
98
+ if (nums.length < 3) return null;
99
+ const gaps = [];
100
+ for (let i = 1; i < nums.length; i++) gaps.push(nums[i] - nums[i - 1]);
101
+ // Heuristic: ratio wildly inconsistent?
102
+ const ratios = [];
103
+ for (let i = 1; i < nums.length; i++) if (nums[i - 1] > 0) ratios.push(nums[i] / nums[i - 1]);
104
+ const avg = ratios.reduce((s, r) => s + r, 0) / (ratios.length || 1);
105
+ const variance = ratios.reduce((s, r) => s + (r - avg) ** 2, 0) / (ratios.length || 1);
106
+ return { label, count: nums.length, values: nums, avgRatio: +avg.toFixed(2), variance: +variance.toFixed(3) };
107
+ }
108
+
109
+ export function lintTokens(file) {
110
+ const tokens = loadTokens(file);
111
+ const findings = [];
112
+ const scorecard = {};
113
+
114
+ const colors = tokens.filter(isColor).map(t => ({ name: t.name, value: toHex(t.value) || t.value })).filter(t => /^#[\da-f]{6}$/i.test(t.value));
115
+ const neighbors = dedupeClose(colors);
116
+ if (neighbors.length) {
117
+ findings.push({
118
+ severity: 'warn',
119
+ rule: 'color-sprawl',
120
+ message: `${neighbors.length} near-duplicate color pair(s) within 8 RGB units`,
121
+ detail: neighbors.slice(0, 5),
122
+ });
123
+ }
124
+ scorecard.colorDiscipline = Math.max(0, 100 - neighbors.length * 8);
125
+
126
+ const spacing = tokens.filter(t => /spacing|space|gap|size/i.test(t.name));
127
+ const spaceCheck = checkScale(spacing.map(t => t.value), 'spacing');
128
+ if (spaceCheck && spaceCheck.variance > 0.25) {
129
+ findings.push({
130
+ severity: 'warn',
131
+ rule: 'spacing-scale-inconsistent',
132
+ message: `Spacing scale ratios vary (variance ${spaceCheck.variance}). Consider a consistent ratio (1.5x, 2x).`,
133
+ detail: spaceCheck,
134
+ });
135
+ }
136
+ scorecard.spacingSystem = spaceCheck ? Math.max(30, 100 - Math.round(spaceCheck.variance * 200)) : 50;
137
+
138
+ const radii = tokens.filter(t => /radius|radii/i.test(t.name));
139
+ if (radii.length > 8) {
140
+ findings.push({
141
+ severity: 'warn',
142
+ rule: 'radius-sprawl',
143
+ message: `${radii.length} radius tokens — consider collapsing to 4-6.`,
144
+ });
145
+ }
146
+ scorecard.borderRadii = Math.max(30, 100 - Math.max(0, radii.length - 6) * 10);
147
+
148
+ const shadows = tokens.filter(t => /shadow|elevation/i.test(t.name));
149
+ if (shadows.length > 10) {
150
+ findings.push({
151
+ severity: 'info',
152
+ rule: 'shadow-sprawl',
153
+ message: `${shadows.length} shadow tokens — rarely need more than 6 elevation levels.`,
154
+ });
155
+ }
156
+ scorecard.shadows = Math.max(30, 100 - Math.max(0, shadows.length - 6) * 8);
157
+
158
+ // Contrast: try to find text/bg pairs by name.
159
+ const palette = colors;
160
+ const bgs = palette.filter(c => /bg|background|surface/i.test(c.name));
161
+ const fgs = palette.filter(c => /fg|text|foreground|ink|label/i.test(c.name));
162
+ const contrastFails = [];
163
+ for (const fg of fgs) {
164
+ for (const bg of bgs) {
165
+ const fgRgb = hexToRgb(fg.value), bgRgb = hexToRgb(bg.value);
166
+ if (!fgRgb || !bgRgb) continue;
167
+ const c = contrast(fgRgb, bgRgb);
168
+ if (c < 4.5) contrastFails.push({ fg: fg.name, bg: bg.name, ratio: +c.toFixed(2) });
169
+ }
170
+ }
171
+ if (contrastFails.length) {
172
+ findings.push({
173
+ severity: 'error',
174
+ rule: 'contrast-wcag-aa',
175
+ message: `${contrastFails.length} fg/bg pair(s) fail WCAG AA (4.5:1)`,
176
+ detail: contrastFails.slice(0, 10),
177
+ });
178
+ }
179
+ scorecard.accessibility = contrastFails.length ? Math.max(20, 100 - contrastFails.length * 12) : 100;
180
+
181
+ const avg = Math.round(Object.values(scorecard).reduce((s, v) => s + v, 0) / Object.keys(scorecard).length);
182
+ const grade = avg >= 90 ? 'A' : avg >= 80 ? 'B' : avg >= 70 ? 'C' : avg >= 60 ? 'D' : 'F';
183
+
184
+ return {
185
+ file,
186
+ tokenCount: tokens.length,
187
+ score: avg,
188
+ grade,
189
+ scorecard,
190
+ findings,
191
+ stats: {
192
+ colors: colors.length,
193
+ spacing: spacing.length,
194
+ radii: radii.length,
195
+ shadows: shadows.length,
196
+ },
197
+ };
198
+ }