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,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
+ }
@@ -0,0 +1,184 @@
1
+ // Motion v2 — rich motion language extraction.
2
+ // Classifies easings into semantic families, detects springs/bounces,
3
+ // catches scroll/view-timeline usage, and emits motion tokens (duration, easing, spring).
4
+
5
+ const MS = v => {
6
+ if (!v) return 0;
7
+ const m = String(v).match(/(-?\d+\.?\d*)(m?s)?/);
8
+ if (!m) return 0;
9
+ const n = parseFloat(m[1]);
10
+ return m[2] === 's' ? n * 1000 : n;
11
+ };
12
+
13
+ const DURATION_NAMES = [
14
+ { max: 80, name: 'instant' },
15
+ { max: 150, name: 'xs' },
16
+ { max: 250, name: 'sm' },
17
+ { max: 400, name: 'md' },
18
+ { max: 700, name: 'lg' },
19
+ { max: 1200, name: 'xl' },
20
+ { max: Infinity, name: 'xxl' },
21
+ ];
22
+
23
+ function nameDuration(ms) {
24
+ return DURATION_NAMES.find(d => ms <= d.max).name;
25
+ }
26
+
27
+ function classifyCubicBezier(raw) {
28
+ const m = raw.match(/cubic-bezier\(\s*([-\d.]+)\s*,\s*([-\d.]+)\s*,\s*([-\d.]+)\s*,\s*([-\d.]+)\s*\)/);
29
+ if (!m) return { family: 'custom', raw };
30
+ const [x1, y1, x2, y2] = m.slice(1).map(Number);
31
+ if (y1 < 0 || y2 > 1 || y2 < 0 || y1 > 1) return { family: 'spring', raw, overshoot: true };
32
+ if (x1 < 0.2 && x2 > 0.8) return { family: 'ease-in-out', raw };
33
+ if (x1 < 0.2 && y1 < 0.2) return { family: 'ease-out', raw };
34
+ if (x2 > 0.8 && y2 > 0.8) return { family: 'ease-in', raw };
35
+ if (y1 < x1 && y2 < x2) return { family: 'ease-in', raw };
36
+ if (y1 > x1 && y2 > x2) return { family: 'ease-out', raw };
37
+ return { family: 'custom', raw };
38
+ }
39
+
40
+ function classifyEasing(raw) {
41
+ if (!raw) return { family: 'linear', raw };
42
+ if (raw === 'linear') return { family: 'linear', raw };
43
+ if (raw === 'ease' || raw === 'ease-in-out') return { family: 'ease-in-out', raw };
44
+ if (raw === 'ease-in') return { family: 'ease-in', raw };
45
+ if (raw === 'ease-out') return { family: 'ease-out', raw };
46
+ if (/cubic-bezier/.test(raw)) return classifyCubicBezier(raw);
47
+ if (/steps/.test(raw)) return { family: 'steps', raw };
48
+ return { family: 'custom', raw };
49
+ }
50
+
51
+ function isBounceKeyframe(kf) {
52
+ if (!kf.steps || kf.steps.length < 3) return false;
53
+ const first = kf.steps.find(s => s.offset === '0%' || s.offset === 'from');
54
+ const last = kf.steps.find(s => s.offset === '100%' || s.offset === 'to');
55
+ if (!first || !last) return false;
56
+ return first.style.replace(/\s+/g, '') === last.style.replace(/\s+/g, '');
57
+ }
58
+
59
+ function keyframeKind(kf) {
60
+ const props = new Set();
61
+ const values = [];
62
+ for (const step of kf.steps || []) {
63
+ for (const part of (step.style || '').split(';')) {
64
+ const [p, v] = part.split(':').map(s => (s || '').trim());
65
+ if (p) props.add(p);
66
+ if (v) values.push(v);
67
+ }
68
+ }
69
+ const has = p => props.has(p);
70
+ const anyValue = re => values.some(v => re.test(v));
71
+ if (has('transform') && anyValue(/translate/i)) {
72
+ if (anyValue(/translateY\(-?\d/)) return 'slide-y';
73
+ if (anyValue(/translateX\(-?\d/)) return 'slide-x';
74
+ return 'slide';
75
+ }
76
+ if (has('opacity') && !has('transform')) return 'fade';
77
+ if (has('opacity') && has('transform')) return 'reveal';
78
+ if (anyValue(/rotate/)) return 'rotate';
79
+ if (anyValue(/scale/)) return 'scale';
80
+ if (isBounceKeyframe(kf)) return 'pulse';
81
+ return 'custom';
82
+ }
83
+
84
+ export function extractMotion(computedStyles, keyframes = []) {
85
+ const transitions = new Set();
86
+ const easingRaw = new Set();
87
+ const durations = [];
88
+ const animationRefs = new Map();
89
+ const transitionedProps = {};
90
+ const scrollSignals = new Set();
91
+ let animatingElements = 0;
92
+
93
+ for (const el of computedStyles) {
94
+ let isAnimating = false;
95
+ if (el.transition && el.transition !== 'all 0s ease 0s' && el.transition !== 'none') {
96
+ transitions.add(el.transition);
97
+ isAnimating = true;
98
+ for (const m of el.transition.matchAll(/(?<![(\d])(\d+\.?\d*m?s)(?![)\w])/g)) durations.push(MS(m[1]));
99
+ for (const m of el.transition.matchAll(/(ease|ease-in|ease-out|ease-in-out|linear|cubic-bezier\([^)]+\)|steps\([^)]+\))/g)) easingRaw.add(m[1]);
100
+ for (const part of el.transition.split(',')) {
101
+ const prop = part.trim().split(/\s+/)[0];
102
+ if (prop && prop !== 'all') transitionedProps[prop] = (transitionedProps[prop] || 0) + 1;
103
+ }
104
+ }
105
+ if (el.animation && el.animation !== 'none 0s ease 0s 1 normal none running' && el.animation !== 'none') {
106
+ const nameMatch = el.animation.match(/([a-zA-Z_][\w-]*)\s*$/) || el.animation.match(/^([a-zA-Z_][\w-]*)/);
107
+ if (nameMatch) {
108
+ const name = nameMatch[1];
109
+ if (name !== 'none' && name !== 'running' && name !== 'paused') {
110
+ animationRefs.set(name, (animationRefs.get(name) || 0) + 1);
111
+ }
112
+ }
113
+ isAnimating = true;
114
+ }
115
+ if (el.animationTimeline && el.animationTimeline !== 'auto' && el.animationTimeline !== 'none' && el.animationTimeline !== '') {
116
+ scrollSignals.add(el.animationTimeline);
117
+ }
118
+ if (el.viewTimelineName) scrollSignals.add(`view:${el.viewTimelineName}`);
119
+ if (el.scrollTimelineName) scrollSignals.add(`scroll:${el.scrollTimelineName}`);
120
+ if (isAnimating) animatingElements++;
121
+ }
122
+
123
+ const uniqueDurations = [...new Set(durations.filter(d => d > 0))].sort((a, b) => a - b);
124
+ const durationTokens = uniqueDurations.map(ms => ({
125
+ name: nameDuration(ms),
126
+ ms,
127
+ css: ms >= 1000 ? `${ms / 1000}s` : `${ms}ms`,
128
+ }));
129
+ // dedupe by name — keep first (smallest) per bucket
130
+ const seenName = new Set();
131
+ const namedDurations = [];
132
+ for (const t of durationTokens) {
133
+ if (seenName.has(t.name)) continue;
134
+ seenName.add(t.name);
135
+ namedDurations.push(t);
136
+ }
137
+
138
+ const easings = [...easingRaw].map(e => ({ ...classifyEasing(e), count: computedStyles.filter(c => (c.transition || '').includes(e)).length }));
139
+ const springs = easings.filter(e => e.family === 'spring');
140
+
141
+ const enrichedKeyframes = (keyframes || []).map(kf => ({
142
+ name: kf.name,
143
+ steps: kf.steps,
144
+ kind: keyframeKind(kf),
145
+ isBounce: isBounceKeyframe(kf),
146
+ used: animationRefs.has(kf.name),
147
+ usageCount: animationRefs.get(kf.name) || 0,
148
+ propertiesAnimated: [...new Set((kf.steps || []).flatMap(s => (s.style || '').split(';').map(d => d.split(':')[0].trim()).filter(Boolean)))],
149
+ }));
150
+
151
+ const transitionTop = Object.entries(transitionedProps).sort((a, b) => b[1] - a[1]).slice(0, 10).map(([property, count]) => ({ property, count }));
152
+
153
+ // Motion language fingerprint — what does this site's motion *feel* like?
154
+ const totalUses = easings.reduce((s, e) => s + e.count, 0);
155
+ const share = family => easings.filter(e => e.family === family).reduce((s, e) => s + e.count, 0) / (totalUses || 1);
156
+ const feel = springs.length > 0
157
+ ? 'springy'
158
+ : share('ease-out') > 0.5
159
+ ? 'responsive'
160
+ : share('ease-in-out') > 0.5
161
+ ? 'smooth'
162
+ : share('linear') > 0.5
163
+ ? 'mechanical'
164
+ : 'mixed';
165
+
166
+ return {
167
+ durations: namedDurations,
168
+ easings,
169
+ springs,
170
+ keyframes: enrichedKeyframes,
171
+ transitionedProperties: transitionTop,
172
+ scrollLinked: {
173
+ present: scrollSignals.size > 0,
174
+ signals: [...scrollSignals],
175
+ },
176
+ stats: {
177
+ animatingElements,
178
+ transitionCount: transitions.size,
179
+ keyframeCount: enrichedKeyframes.length,
180
+ keyframeUnused: enrichedKeyframes.filter(k => !k.used).length,
181
+ },
182
+ feel,
183
+ };
184
+ }