designlang 9.0.0 → 10.1.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,161 @@
1
+ // v10.1 — Component Screenshots
2
+ //
3
+ // Given a Playwright Page, crop PNG screenshots for each detected component
4
+ // cluster. We re-query the DOM using the same candidate selector the crawler
5
+ // uses, group matches by `kind + variantHint + sizeHint`, and capture up to
6
+ // three representatives per group at 2× retina. Writes into a `screenshots/`
7
+ // subdirectory under the output root and returns an index the bin emits as
8
+ // `*-screenshots.json`.
9
+
10
+ import { chromium } from 'playwright';
11
+ import { mkdirSync } from 'fs';
12
+ import { join } from 'path';
13
+
14
+ const CANDIDATE_SELECTOR = 'button, a[role="button"], .btn, [class*="button"], input[type="text"], input[type="email"], input[type="search"], textarea, [class*="card"]';
15
+
16
+ const FALLBACK_GROUPS = [
17
+ { slug: 'button', selector: 'button:not(:empty), a[role="button"], [class*="btn"]:not(:empty)' },
18
+ { slug: 'card', selector: '[class*="card"]:not(:empty)' },
19
+ { slug: 'input', selector: 'input[type="text"], input[type="email"], input[type="search"], textarea' },
20
+ { slug: 'nav', selector: 'nav, [role="navigation"]' },
21
+ { slug: 'hero', selector: '[class*="hero"], section:first-of-type' },
22
+ ];
23
+
24
+ function slugify(s) {
25
+ return (s || 'component')
26
+ .toString()
27
+ .toLowerCase()
28
+ .replace(/[^a-z0-9]+/g, '-')
29
+ .replace(/^-|-$/g, '') || 'component';
30
+ }
31
+
32
+ async function collectClusteredHandles(page) {
33
+ // In-page classification mirrors what the crawler does for candidates but
34
+ // returns enough to screenshot (unique index so we can re-find handles).
35
+ const groups = await page.evaluate((sel) => {
36
+ const out = {};
37
+ let ix = 0;
38
+ for (const el of document.querySelectorAll(sel)) {
39
+ const r = el.getBoundingClientRect();
40
+ if (r.width < 20 || r.height < 10) continue;
41
+ if (r.width > window.innerWidth || r.height > window.innerHeight * 2) continue;
42
+ const cs = getComputedStyle(el);
43
+ if (cs.visibility === 'hidden' || cs.display === 'none' || parseFloat(cs.opacity) < 0.1) continue;
44
+
45
+ const tag = el.tagName.toLowerCase();
46
+ const cls = typeof el.className === 'string' ? el.className.toLowerCase() : '';
47
+ let kind = 'other';
48
+ if (tag === 'button' || el.getAttribute('role') === 'button' || /\bbtn\b|button/.test(cls)) kind = 'button';
49
+ else if (tag === 'input' || tag === 'textarea') kind = 'input';
50
+ else if (/card/.test(cls)) kind = 'card';
51
+ else if (tag === 'a') kind = 'link';
52
+
53
+ const variant = ((cls.match(/\b(primary|secondary|tertiary|ghost|outline|solid|destructive|danger|success|warning|subtle)\b/) || [])[1]) || 'default';
54
+ const size = ((cls.match(/\b(xs|sm|md|lg|xl|small|medium|large)\b/) || [])[1]) || '';
55
+
56
+ const key = [kind, variant, size].filter(Boolean).join('--');
57
+ if (!out[key]) out[key] = [];
58
+ el.setAttribute('data-dl-shot', String(ix));
59
+ out[key].push({ ix, w: Math.round(r.width), h: Math.round(r.height), kind, variant, size });
60
+ ix++;
61
+ }
62
+ return out;
63
+ }, CANDIDATE_SELECTOR);
64
+ return groups;
65
+ }
66
+
67
+ async function captureGroup(page, key, entries, screenshotDir, maxPerGroup = 3) {
68
+ const out = [];
69
+ const slug = slugify(key);
70
+ let variant = 0;
71
+ for (const info of entries.slice(0, maxPerGroup)) {
72
+ const handle = await page.$(`[data-dl-shot="${info.ix}"]`);
73
+ if (!handle) continue;
74
+ const file = `${slug}-${variant}.png`;
75
+ const path = join(screenshotDir, file);
76
+ try {
77
+ await handle.screenshot({ path, omitBackground: false });
78
+ } catch { continue; }
79
+ out.push({
80
+ cluster: key,
81
+ variant,
82
+ path: `screenshots/${file}`,
83
+ bounds: { w: info.w, h: info.h },
84
+ kind: info.kind,
85
+ variantHint: info.variant,
86
+ sizeHint: info.size,
87
+ retina: true,
88
+ });
89
+ variant++;
90
+ }
91
+ return out;
92
+ }
93
+
94
+ async function captureFallbacks(page, screenshotDir) {
95
+ const out = [];
96
+ for (const g of FALLBACK_GROUPS) {
97
+ try {
98
+ const handles = await page.$$(g.selector);
99
+ for (const h of handles.slice(0, 2)) {
100
+ const box = await h.boundingBox();
101
+ if (!box || box.width < 20 || box.height < 10) continue;
102
+ const path = join(screenshotDir, `${g.slug}-${out.filter(x => x.cluster === g.slug).length}.png`);
103
+ await h.screenshot({ path });
104
+ out.push({
105
+ cluster: g.slug,
106
+ variant: out.filter(x => x.cluster === g.slug).length,
107
+ path: `screenshots/${path.split('screenshots/')[1]}`,
108
+ bounds: { w: Math.round(box.width), h: Math.round(box.height) },
109
+ retina: true,
110
+ fallback: true,
111
+ });
112
+ }
113
+ } catch { /* skip */ }
114
+ }
115
+ return out;
116
+ }
117
+
118
+ // Public entry. Launches a fresh Playwright context at deviceScaleFactor: 2
119
+ // so captures are crisp on retina displays without a second full run.
120
+ export async function captureComponentScreenshotsV10(url, outDir, { width = 1280, height = 800, channel } = {}) {
121
+ const screenshotDir = join(outDir, 'screenshots');
122
+ mkdirSync(screenshotDir, { recursive: true });
123
+
124
+ const browser = await chromium.launch({ headless: true, ...(channel && { channel }) });
125
+ try {
126
+ const context = await browser.newContext({
127
+ viewport: { width, height },
128
+ deviceScaleFactor: 2,
129
+ colorScheme: 'light',
130
+ });
131
+ const page = await context.newPage();
132
+ await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 }).catch(() => {});
133
+ await page.waitForLoadState('networkidle').catch(() => {});
134
+ await page.evaluate(() => document.fonts.ready).catch(() => {});
135
+
136
+ const groups = await collectClusteredHandles(page);
137
+ let components = [];
138
+ for (const [key, entries] of Object.entries(groups)) {
139
+ if (!entries.length) continue;
140
+ const rows = await captureGroup(page, key, entries, screenshotDir);
141
+ components.push(...rows);
142
+ }
143
+
144
+ // If clustering produced nothing (auth / docs pages often do), fall back
145
+ // to the v9 hardcoded selector list so users still get something.
146
+ if (!components.length) {
147
+ components = await captureFallbacks(page, screenshotDir);
148
+ }
149
+
150
+ let fullPage = null;
151
+ try {
152
+ const p = join(screenshotDir, 'full-page.png');
153
+ await page.screenshot({ path: p, fullPage: true });
154
+ fullPage = { path: 'screenshots/full-page.png', retina: true };
155
+ } catch { /* non-fatal */ }
156
+
157
+ return { components, fullPage, count: components.length };
158
+ } finally {
159
+ await browser.close();
160
+ }
161
+ }
@@ -0,0 +1,131 @@
1
+ // Classify the imagery style of a site from the sampled <img> list. No image
2
+ // downloads — we work from the src URL, displayed dimensions, and styling
3
+ // already captured by the crawler. The goal is a fingerprint an LLM can use:
4
+ // "photography-heavy with abstract gradients" vs "flat illustration only".
5
+
6
+ const LABELS = [
7
+ 'photography', '3d-render', 'isometric', 'flat-illustration',
8
+ 'gradient-mesh', 'icon-only', 'screenshot', 'mixed', 'none',
9
+ ];
10
+
11
+ function isSvg(img) {
12
+ const src = (img.src || '').toLowerCase();
13
+ return src.endsWith('.svg') || src.startsWith('data:image/svg+xml') || img.tag === 'svg';
14
+ }
15
+
16
+ function aspectBucket(img) {
17
+ if (!img.width || !img.height) return 'unknown';
18
+ const r = img.width / img.height;
19
+ if (r > 2.2) return 'ultra-wide';
20
+ if (r > 1.4) return 'landscape';
21
+ if (r > 0.85) return 'square-ish';
22
+ if (r > 0.5) return 'portrait';
23
+ return 'tall';
24
+ }
25
+
26
+ function filenameHint(src) {
27
+ const lower = (src || '').toLowerCase();
28
+ const hints = [];
29
+ if (/\b(screenshot|screen-shot|dashboard|ui-|product-ui)\b/.test(lower)) hints.push('screenshot');
30
+ if (/\b(hero|cover|banner)\b/.test(lower)) hints.push('hero');
31
+ if (/\b(3d|render|gltf|iso|isometric)\b/.test(lower)) hints.push('3d');
32
+ if (/\b(illust|illustr|character|mascot)\b/.test(lower)) hints.push('illustration');
33
+ if (/\b(photo|portrait|team|headshot)\b/.test(lower)) hints.push('photo');
34
+ if (/\b(icon|symbol|logo)\b/.test(lower)) hints.push('icon');
35
+ if (/\b(gradient|mesh|blob)\b/.test(lower)) hints.push('mesh');
36
+ return hints;
37
+ }
38
+
39
+ function scoreLabels(images) {
40
+ const tally = Object.fromEntries(LABELS.map(l => [l, 0]));
41
+ const reasons = [];
42
+ let photoish = 0, svgCount = 0, iconCount = 0, heroCount = 0, screenshotCount = 0;
43
+
44
+ for (const img of images) {
45
+ const svg = isSvg(img);
46
+ if (svg) svgCount++;
47
+ const maxSide = Math.max(img.width || 0, img.height || 0);
48
+ if (maxSide < 40 && maxSide > 0) iconCount++;
49
+ const hints = filenameHint(img.src);
50
+ if (hints.includes('screenshot')) { tally.screenshot += 0.4; screenshotCount++; }
51
+ if (hints.includes('3d')) tally['3d-render'] += 0.6;
52
+ if (hints.includes('iso')) tally['isometric'] += 0.6;
53
+ if (hints.includes('illustration')) tally['flat-illustration'] += 0.5;
54
+ if (hints.includes('photo')) { tally.photography += 0.5; photoish++; }
55
+ if (hints.includes('mesh')) tally['gradient-mesh'] += 0.5;
56
+ if (hints.includes('icon')) iconCount++;
57
+ if (hints.includes('hero')) heroCount++;
58
+
59
+ // Ext-based heuristics.
60
+ const src = (img.src || '').toLowerCase();
61
+ if (/\.(jpe?g|webp|avif)(\?|$)/.test(src) && maxSide > 200) {
62
+ tally.photography += 0.15; photoish++;
63
+ reasons.push('raster photo-ext');
64
+ }
65
+ if (src.endsWith('.png') && maxSide > 150 && !svg) {
66
+ tally.photography += 0.05;
67
+ }
68
+ if (svg && maxSide > 100) {
69
+ tally['flat-illustration'] += 0.15;
70
+ }
71
+ }
72
+
73
+ // Normalization heuristics.
74
+ const total = Math.max(1, images.length);
75
+ if (svgCount / total > 0.6) { tally['flat-illustration'] += 0.4; reasons.push('svg-heavy'); }
76
+ if (iconCount / total > 0.7) tally['icon-only'] += 0.6;
77
+ if (photoish / total > 0.5) tally.photography += 0.3;
78
+ if (screenshotCount > 0) reasons.push(`${screenshotCount} screenshot-like`);
79
+
80
+ return { tally, reasons, counts: { total, svgCount, iconCount, heroCount, screenshotCount, photoish } };
81
+ }
82
+
83
+ function dominantAspect(images) {
84
+ const buckets = {};
85
+ for (const img of images) {
86
+ const b = aspectBucket(img);
87
+ buckets[b] = (buckets[b] || 0) + 1;
88
+ }
89
+ const sorted = Object.entries(buckets).sort((a, b) => b[1] - a[1]);
90
+ return sorted[0] ? sorted[0][0] : 'unknown';
91
+ }
92
+
93
+ function borderRadiusProfile(images) {
94
+ const radii = images.map(i => parseFloat(i.borderRadius) || 0).filter(n => !isNaN(n));
95
+ if (!radii.length) return 'none';
96
+ const avg = radii.reduce((a, b) => a + b, 0) / radii.length;
97
+ if (avg > 9999) return 'full';
98
+ if (avg > 20) return 'rounded';
99
+ if (avg > 4) return 'soft';
100
+ return 'square';
101
+ }
102
+
103
+ export function extractImageryStyle(images = []) {
104
+ if (!images.length) {
105
+ return { label: 'none', confidence: 0, counts: {}, aspectRatios: [], radiusProfile: 'none', signals: [] };
106
+ }
107
+ const { tally, reasons, counts } = scoreLabels(images);
108
+ const ranked = Object.entries(tally).sort((a, b) => b[1] - a[1]);
109
+ const [winner, winScore] = ranked[0];
110
+ const [, second] = ranked[1] || [null, 0];
111
+ let label = winScore === 0 ? 'mixed' : winner;
112
+ if (winScore > 0 && second > 0 && (winScore - second) < 0.2) label = 'mixed';
113
+ const confidence = Math.min(1, winScore / Math.max(1, images.length * 0.3));
114
+
115
+ return {
116
+ label,
117
+ confidence: Number(confidence.toFixed(3)),
118
+ counts: {
119
+ total: counts.total,
120
+ svg: counts.svgCount,
121
+ icon: counts.iconCount,
122
+ hero: counts.heroCount,
123
+ screenshot: counts.screenshotCount,
124
+ photoLike: counts.photoish,
125
+ },
126
+ dominantAspect: dominantAspect(images),
127
+ radiusProfile: borderRadiusProfile(images),
128
+ alternates: ranked.filter(([, s]) => s > 0 && s !== winScore).slice(0, 3).map(([l, s]) => ({ label: l, score: Number(s.toFixed(3)) })),
129
+ signals: reasons.slice(0, 10),
130
+ };
131
+ }
@@ -0,0 +1,142 @@
1
+ // Pull the site's logo from the page. Uses a Playwright Page handle because we
2
+ // need the inline SVG source (or the <img> pixels) — computed-styles alone
3
+ // can't recover them. Writes the asset to disk and returns metadata.
4
+ //
5
+ // Strategy:
6
+ // 1) Candidate selectors, in priority order:
7
+ // a. header/nav [aria-label*="logo"] | [class*="logo"] | [id*="logo"]
8
+ // b. header a[href="/"] svg | header a[href="/"] img
9
+ // c. first <svg> in <header>|<nav> with width 16-240 and height 16-120
10
+ // d. first <img> in <header>|<nav> with alt matching site name
11
+ // 2) For SVG: capture `outerHTML`, save as .svg.
12
+ // 3) For <img>: save the bytes via page.request (handles CORS), fallback to
13
+ // element.screenshot() if fetch fails.
14
+ // 4) Compute clearspace by sampling 8 directions from the bounding box and
15
+ // stopping at the first non-whitespace pixel within 80px (very cheap).
16
+
17
+ import { writeFileSync } from 'fs';
18
+ import { join } from 'path';
19
+
20
+ const CANDIDATES = [
21
+ 'header a[href="/"] svg, header [href="/"] svg, nav a[href="/"] svg',
22
+ 'header a[href="/"] img, nav a[href="/"] img',
23
+ 'header [class*="logo" i] svg, nav [class*="logo" i] svg, [id*="logo" i] svg',
24
+ 'header [class*="logo" i] img, nav [class*="logo" i] img, [id*="logo" i] img',
25
+ 'header svg, nav svg',
26
+ 'header img, nav img',
27
+ ];
28
+
29
+ async function findLogoHandle(page) {
30
+ for (const sel of CANDIDATES) {
31
+ try {
32
+ const handles = await page.$$(sel);
33
+ for (const h of handles) {
34
+ const info = await h.evaluate((el) => {
35
+ const r = el.getBoundingClientRect();
36
+ const tag = el.tagName.toLowerCase();
37
+ const w = r.width, hh = r.height;
38
+ if (tag === 'svg') {
39
+ if (w < 16 || w > 260 || hh < 12 || hh > 120) return null;
40
+ return { tag, w, hh, x: r.x, y: r.y, outer: el.outerHTML };
41
+ }
42
+ if (tag === 'img') {
43
+ if (w < 20 || w > 320 || hh < 12 || hh > 120) return null;
44
+ return { tag, w, hh, x: r.x, y: r.y, src: el.currentSrc || el.src, alt: el.getAttribute('alt') || '' };
45
+ }
46
+ return null;
47
+ });
48
+ if (info) return { handle: h, info };
49
+ }
50
+ } catch { /* selector not found, keep looking */ }
51
+ }
52
+ return null;
53
+ }
54
+
55
+ async function fetchImgBytes(page, url) {
56
+ try {
57
+ const resp = await page.request.get(url, { timeout: 10000 });
58
+ if (!resp.ok()) return null;
59
+ const buf = await resp.body();
60
+ return buf;
61
+ } catch { return null; }
62
+ }
63
+
64
+ function guessExt(url) {
65
+ const m = (url || '').toLowerCase().match(/\.(png|jpe?g|webp|gif|svg|ico|avif)(?:$|\?)/);
66
+ return m ? (m[1] === 'jpeg' ? 'jpg' : m[1]) : 'png';
67
+ }
68
+
69
+ export async function extractLogo(page, outDir, prefix) {
70
+ const found = await findLogoHandle(page);
71
+ if (!found) {
72
+ return { found: false };
73
+ }
74
+ const { handle, info } = found;
75
+
76
+ let savedPath = null;
77
+ let ext = null;
78
+
79
+ if (info.tag === 'svg') {
80
+ ext = 'svg';
81
+ savedPath = join(outDir, `${prefix}-logo.svg`);
82
+ writeFileSync(savedPath, info.outer, 'utf-8');
83
+ } else {
84
+ ext = guessExt(info.src);
85
+ savedPath = join(outDir, `${prefix}-logo.${ext}`);
86
+ const bytes = await fetchImgBytes(page, info.src);
87
+ if (bytes) {
88
+ writeFileSync(savedPath, bytes);
89
+ } else {
90
+ // Fallback: screenshot the element.
91
+ try {
92
+ await handle.screenshot({ path: savedPath });
93
+ ext = 'png';
94
+ savedPath = savedPath.replace(/\.(jpe?g|webp|gif|ico|avif)$/i, '.png');
95
+ } catch {
96
+ return { found: true, saved: false, info };
97
+ }
98
+ }
99
+ }
100
+
101
+ // Cheap clearspace: sample the bounding box + 80px margin for document
102
+ // background continuity. Implemented in-page so we don't pull pixels back.
103
+ const clearspace = await page.evaluate(({ x, y, w, h }) => {
104
+ const body = document.body;
105
+ const bg = getComputedStyle(body).backgroundColor;
106
+ // Count how many pixels of margin we have before hitting another element.
107
+ const margin = { top: 0, right: 0, bottom: 0, left: 0 };
108
+ const probe = (dx, dy, max) => {
109
+ for (let d = 2; d <= max; d += 4) {
110
+ const el = document.elementFromPoint(x + dx(d), y + dy(d));
111
+ if (!el) return d;
112
+ // If we're still inside the logo itself, keep going.
113
+ if (el.closest('svg, img, [class*="logo" i]')) continue;
114
+ const r = el.getBoundingClientRect();
115
+ if (r.width < 8 && r.height < 8) continue;
116
+ // If it's the page/body, we hit open space.
117
+ if (el === body || el.tagName === 'HEADER' || el.tagName === 'NAV') return d;
118
+ return d;
119
+ }
120
+ return max;
121
+ };
122
+ margin.top = probe(() => w / 2, d => -d, 80);
123
+ margin.bottom = probe(() => w / 2, d => h + d, 80);
124
+ margin.left = probe(d => -d, () => h / 2, 80);
125
+ margin.right = probe(d => w + d, () => h / 2, 80);
126
+ return { backgroundColor: bg, margin };
127
+ }, info).catch(() => null);
128
+
129
+ return {
130
+ found: true,
131
+ saved: true,
132
+ path: savedPath,
133
+ file: `${prefix}-logo.${ext}`,
134
+ kind: info.tag,
135
+ width: Number(info.w.toFixed(1)),
136
+ height: Number(info.hh.toFixed(1)),
137
+ aspect: Number((info.w / Math.max(1, info.hh)).toFixed(3)),
138
+ src: info.src || null,
139
+ alt: info.alt || null,
140
+ clearspace,
141
+ };
142
+ }
@@ -0,0 +1,152 @@
1
+ // Classify a site's "material language" — the visual tactility vocabulary it
2
+ // uses. Signals are the already-extracted shadows, borders, backdrop filters,
3
+ // saturation, and geometry. Output: one dominant label + secondary signals.
4
+
5
+ const LABELS = [
6
+ 'glassmorphism', 'neumorphism', 'flat', 'brutalist',
7
+ 'skeuomorphic', 'material-you', 'soft-ui', 'mixed',
8
+ ];
9
+
10
+ function parseHexToRgb(hex) {
11
+ if (!hex || !hex.startsWith('#')) return null;
12
+ const h = hex.replace('#', '');
13
+ const full = h.length === 3 ? h.split('').map(c => c + c).join('') : h;
14
+ if (full.length !== 6) return null;
15
+ return { r: parseInt(full.slice(0, 2), 16), g: parseInt(full.slice(2, 4), 16), b: parseInt(full.slice(4, 6), 16) };
16
+ }
17
+
18
+ function rgbSaturation(rgb) {
19
+ if (!rgb) return 0;
20
+ const max = Math.max(rgb.r, rgb.g, rgb.b) / 255;
21
+ const min = Math.min(rgb.r, rgb.g, rgb.b) / 255;
22
+ if (max === 0) return 0;
23
+ return (max - min) / max;
24
+ }
25
+
26
+ function avgSaturation(colors) {
27
+ if (!colors.length) return 0;
28
+ let total = 0, count = 0;
29
+ for (const c of colors) {
30
+ const rgb = parseHexToRgb(c.hex || c);
31
+ if (!rgb) continue;
32
+ total += rgbSaturation(rgb);
33
+ count++;
34
+ }
35
+ return count > 0 ? total / count : 0;
36
+ }
37
+
38
+ function detectBackdropBlur(modernCss = {}, variables = {}) {
39
+ // modernCss.pseudoElements might contain backdrop-filter samples; also look
40
+ // at css-variables for `--backdrop`.
41
+ const samples = [
42
+ ...((modernCss.pseudoElements && modernCss.pseudoElements.samples) || []),
43
+ ...Object.values(variables || {}).flatMap(v => typeof v === 'string' ? [v] : []),
44
+ ].join(' ');
45
+ return /backdrop-filter|backdrop-blur|blur\(\s*\d+px\s*\)/i.test(samples);
46
+ }
47
+
48
+ function shadowComplexity(shadowValues) {
49
+ // Soft-UI / neumorphism use pair-shadows (inset + outer) with low blur and
50
+ // low-saturation grays. Brutalism uses hard black shadows with 0 blur.
51
+ if (!shadowValues.length) return { profile: 'none', avgBlur: 0, maxBlur: 0, insetCount: 0, hardShadowCount: 0, hasPair: false };
52
+ let insetCount = 0, hardShadowCount = 0, totalBlur = 0, maxBlur = 0, pairCount = 0;
53
+ for (const v of shadowValues) {
54
+ const raw = typeof v === 'string' ? v : (v.value || '');
55
+ if (/inset/i.test(raw)) insetCount++;
56
+ // Blur is the third length in `offset-x offset-y blur [spread] color`. The
57
+ // `px` unit is common but optional — `0 0` is a valid zero-blur shadow.
58
+ const blurs = [...raw.matchAll(/(-?\d+(?:\.\d+)?)(?:px)?\s+(-?\d+(?:\.\d+)?)(?:px)?\s+(\d+(?:\.\d+)?)(?:px)?/g)];
59
+ for (const m of blurs) {
60
+ const blur = parseFloat(m[3]);
61
+ totalBlur += blur;
62
+ if (blur > maxBlur) maxBlur = blur;
63
+ if (blur === 0) hardShadowCount++;
64
+ }
65
+ if ((raw.match(/,/g) || []).length >= 1) pairCount++;
66
+ }
67
+ const avgBlur = totalBlur / Math.max(1, shadowValues.length);
68
+ let profile = 'soft';
69
+ if (hardShadowCount > shadowValues.length * 0.5) profile = 'hard';
70
+ else if (maxBlur > 40) profile = 'diffuse';
71
+ return { profile, avgBlur, maxBlur, insetCount, hardShadowCount, hasPair: pairCount > 0 };
72
+ }
73
+
74
+ function borderProfile(radii = [], borderValues = []) {
75
+ const nums = radii.map(r => {
76
+ if (typeof r === 'number') return r;
77
+ if (typeof r === 'string') return parseFloat(r) || 0;
78
+ if (r && typeof r === 'object') return parseFloat(r.value || r.px || 0) || 0;
79
+ return 0;
80
+ }).filter(n => !isNaN(n));
81
+ const avg = nums.length ? nums.reduce((a, b) => a + b, 0) / nums.length : 0;
82
+ const max = nums.length ? Math.max(...nums) : 0;
83
+ const pill = nums.some(n => n >= 9999 || n >= 500);
84
+ const sharp = nums.length && max < 4;
85
+ return { avg, max, pill, sharp };
86
+ }
87
+
88
+ export function extractMaterialLanguage(design = {}) {
89
+ const colors = design.colors?.all || [];
90
+ const shadows = design.shadows?.values || [];
91
+ const radii = design.borders?.radii || [];
92
+ const variables = design.variables || {};
93
+ const modernCss = design.modernCss || {};
94
+
95
+ const sat = avgSaturation(colors);
96
+ const hasBackdropBlur = detectBackdropBlur(modernCss, variables);
97
+ const sh = shadowComplexity(shadows);
98
+ const br = borderProfile(radii);
99
+ const gradientCount = design.gradients?.count || 0;
100
+
101
+ const scores = Object.fromEntries(LABELS.map(l => [l, 0]));
102
+ const signals = [];
103
+
104
+ if (hasBackdropBlur) {
105
+ scores.glassmorphism += 0.6; signals.push({ label: 'glassmorphism', weight: 0.6, detail: 'backdrop-filter present' });
106
+ }
107
+ if (sh.avgBlur > 30 && sat < 0.3 && sh.insetCount > 0 && sh.hasPair) {
108
+ scores.neumorphism += 0.7; signals.push({ label: 'neumorphism', weight: 0.7, detail: 'paired blur + inset + low saturation' });
109
+ }
110
+ if (sh.profile === 'hard' && br.sharp && sat > 0.4) {
111
+ scores.brutalist += 0.75; signals.push({ label: 'brutalist', weight: 0.75, detail: 'hard shadows + sharp corners + saturated' });
112
+ }
113
+ if (shadows.length === 0 && sh.insetCount === 0 && br.avg < 12 && gradientCount < 2) {
114
+ scores.flat += 0.55; signals.push({ label: 'flat', weight: 0.55, detail: 'no shadows, simple radii' });
115
+ }
116
+ if (sh.avgBlur > 60 && sat < 0.4 && !hasBackdropBlur) {
117
+ scores['soft-ui'] += 0.5; signals.push({ label: 'soft-ui', weight: 0.5, detail: 'soft diffuse shadows' });
118
+ }
119
+ if (br.pill && sh.profile === 'soft' && sat > 0.3) {
120
+ scores['material-you'] += 0.45; signals.push({ label: 'material-you', weight: 0.45, detail: 'pill shapes + soft shadows' });
121
+ }
122
+ if (gradientCount > 6 && sat > 0.5) {
123
+ scores.skeuomorphic += 0.35; signals.push({ label: 'skeuomorphic', weight: 0.35, detail: 'heavy gradient usage' });
124
+ }
125
+
126
+ const ranked = Object.entries(scores).sort((a, b) => b[1] - a[1]);
127
+ const [winner, winScore] = ranked[0];
128
+ const [, second] = ranked[1] || [null, 0];
129
+ let label = winScore === 0 ? 'flat' : winner;
130
+ // If top two are close, it's "mixed".
131
+ if (winScore > 0 && second > 0 && (winScore - second) < 0.15) label = 'mixed';
132
+ const confidence = Math.min(1, winScore);
133
+
134
+ return {
135
+ label,
136
+ confidence: Number(confidence.toFixed(3)),
137
+ signals,
138
+ metrics: {
139
+ saturation: Number(sat.toFixed(3)),
140
+ shadowProfile: sh.profile,
141
+ avgShadowBlur: Number(sh.avgBlur.toFixed(1)),
142
+ maxShadowBlur: Number(sh.maxBlur.toFixed(1)),
143
+ insetShadows: sh.insetCount,
144
+ avgRadius: Number(br.avg.toFixed(1)),
145
+ maxRadius: Number(br.max.toFixed(1)),
146
+ hasPill: br.pill,
147
+ hasBackdropBlur,
148
+ gradientCount,
149
+ },
150
+ alternates: ranked.filter(([, s]) => s > 0 && s !== winScore).slice(0, 3).map(([l, s]) => ({ label: l, score: Number(s.toFixed(3)) })),
151
+ };
152
+ }