designlang 12.16.0 → 12.18.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.
- package/CHANGELOG.md +51 -0
- package/CODE_OF_CONDUCT.md +132 -0
- package/README.md +27 -6
- package/assets/atlas-cloud-logo.svg +55 -0
- package/bin/design-extract.js +50 -1
- package/commands/extract.md +9 -1
- package/docs/INTERVIEW_PREP.md +98 -0
- package/package.json +7 -5
- package/src/classifiers/smart.js +54 -22
- package/src/formatters/motion-gsap.js +131 -0
- package/src/formatters/motion-waapi.js +140 -0
- package/src/formatters/verify.js +77 -0
- package/src/verify/diff.js +52 -0
- package/src/verify/index.js +122 -0
- package/src/verify/render.js +45 -0
- package/src/verify/restyle.js +155 -0
- package/src/verify/tokens.js +45 -0
package/src/classifiers/smart.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// Optional LLM fallback for low-confidence classifications. No SDK deps — we
|
|
2
|
-
// hit the
|
|
3
|
-
// when the user passes --smart AND an API key is
|
|
4
|
-
// no-ops otherwise so the core extractor stays
|
|
2
|
+
// hit the Anthropic REST API or an OpenAI-compatible chat endpoint directly via
|
|
3
|
+
// global fetch. Runs only when the user passes --smart AND an API key is
|
|
4
|
+
// available in env. Silently no-ops otherwise so the core extractor stays
|
|
5
|
+
// zero-config.
|
|
5
6
|
//
|
|
6
7
|
// Consumers call `refineWithSmart({ pageIntent, sectionRoles, materialLanguage,
|
|
7
8
|
// componentLibrary }, digest)` — we only hit the network for fields where
|
|
@@ -19,9 +20,38 @@ const TASKS = {
|
|
|
19
20
|
},
|
|
20
21
|
};
|
|
21
22
|
|
|
22
|
-
function
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
function trimTrailingSlash(url) {
|
|
24
|
+
return url ? url.replace(/\/+$/, '') : url;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function resolveSmartProviderConfig(env = process.env) {
|
|
28
|
+
if (env.ANTHROPIC_API_KEY) {
|
|
29
|
+
return {
|
|
30
|
+
provider: 'anthropic',
|
|
31
|
+
apiKey: env.ANTHROPIC_API_KEY,
|
|
32
|
+
model: env.DESIGNLANG_MODEL || 'claude-haiku-4-5-20251001',
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const atlasApiKey = env.ATLASCLOUD_API_KEY || env.ATLAS_CLOUD_API_KEY;
|
|
37
|
+
if (atlasApiKey) {
|
|
38
|
+
return {
|
|
39
|
+
provider: 'atlascloud',
|
|
40
|
+
apiKey: atlasApiKey,
|
|
41
|
+
baseUrl: trimTrailingSlash(env.ATLASCLOUD_API_BASE || env.ATLAS_CLOUD_API_BASE || 'https://api.atlascloud.ai/v1'),
|
|
42
|
+
model: env.DESIGNLANG_MODEL || env.ATLASCLOUD_MODEL || env.ATLAS_CLOUD_MODEL || 'deepseek-ai/deepseek-v4-pro',
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (env.OPENAI_API_KEY) {
|
|
47
|
+
return {
|
|
48
|
+
provider: 'openai',
|
|
49
|
+
apiKey: env.OPENAI_API_KEY,
|
|
50
|
+
baseUrl: trimTrailingSlash(env.OPENAI_API_BASE || env.OPENAI_BASE_URL || 'https://api.openai.com/v1'),
|
|
51
|
+
model: env.DESIGNLANG_MODEL || 'gpt-4o-mini',
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
25
55
|
return null;
|
|
26
56
|
}
|
|
27
57
|
|
|
@@ -44,16 +74,16 @@ function buildDigest({ rawData, design, pageIntent }) {
|
|
|
44
74
|
].join('\n\n');
|
|
45
75
|
}
|
|
46
76
|
|
|
47
|
-
async function callAnthropic(system, user) {
|
|
77
|
+
async function callAnthropic(config, system, user) {
|
|
48
78
|
const res = await fetch('https://api.anthropic.com/v1/messages', {
|
|
49
79
|
method: 'POST',
|
|
50
80
|
headers: {
|
|
51
|
-
'x-api-key':
|
|
81
|
+
'x-api-key': config.apiKey,
|
|
52
82
|
'anthropic-version': '2023-06-01',
|
|
53
83
|
'content-type': 'application/json',
|
|
54
84
|
},
|
|
55
85
|
body: JSON.stringify({
|
|
56
|
-
model:
|
|
86
|
+
model: config.model,
|
|
57
87
|
max_tokens: 200,
|
|
58
88
|
system,
|
|
59
89
|
messages: [{ role: 'user', content: user }],
|
|
@@ -65,15 +95,15 @@ async function callAnthropic(system, user) {
|
|
|
65
95
|
return text;
|
|
66
96
|
}
|
|
67
97
|
|
|
68
|
-
async function
|
|
69
|
-
const res = await fetch(
|
|
98
|
+
async function callOpenAICompatible(config, system, user) {
|
|
99
|
+
const res = await fetch(`${config.baseUrl}/chat/completions`, {
|
|
70
100
|
method: 'POST',
|
|
71
101
|
headers: {
|
|
72
|
-
'authorization': `Bearer ${
|
|
102
|
+
'authorization': `Bearer ${config.apiKey}`,
|
|
73
103
|
'content-type': 'application/json',
|
|
74
104
|
},
|
|
75
105
|
body: JSON.stringify({
|
|
76
|
-
model:
|
|
106
|
+
model: config.model,
|
|
77
107
|
messages: [
|
|
78
108
|
{ role: 'system', content: system },
|
|
79
109
|
{ role: 'user', content: user },
|
|
@@ -82,14 +112,14 @@ async function callOpenAI(system, user) {
|
|
|
82
112
|
response_format: { type: 'json_object' },
|
|
83
113
|
}),
|
|
84
114
|
});
|
|
85
|
-
if (!res.ok) throw new Error(
|
|
115
|
+
if (!res.ok) throw new Error(`${config.provider} ${res.status}`);
|
|
86
116
|
const json = await res.json();
|
|
87
117
|
return json.choices?.[0]?.message?.content || '';
|
|
88
118
|
}
|
|
89
119
|
|
|
90
|
-
async function callLLM(
|
|
91
|
-
if (provider === 'anthropic') return callAnthropic(system, user);
|
|
92
|
-
return
|
|
120
|
+
async function callLLM(config, system, user) {
|
|
121
|
+
if (config.provider === 'anthropic') return callAnthropic(config, system, user);
|
|
122
|
+
return callOpenAICompatible(config, system, user);
|
|
93
123
|
}
|
|
94
124
|
|
|
95
125
|
function parseJsonLoose(text) {
|
|
@@ -101,8 +131,10 @@ function parseJsonLoose(text) {
|
|
|
101
131
|
|
|
102
132
|
export async function refineWithSmart({ enabled, rawData, design, pageIntent, sectionRoles, materialLanguage, componentLibrary }) {
|
|
103
133
|
if (!enabled) return { applied: false, reason: 'disabled' };
|
|
104
|
-
const
|
|
105
|
-
if (!
|
|
134
|
+
const providerConfig = resolveSmartProviderConfig();
|
|
135
|
+
if (!providerConfig) {
|
|
136
|
+
return { applied: false, reason: 'no API key (set OPENAI_API_KEY, ATLASCLOUD_API_KEY, or ANTHROPIC_API_KEY)' };
|
|
137
|
+
}
|
|
106
138
|
|
|
107
139
|
const digest = buildDigest({ rawData, design, pageIntent });
|
|
108
140
|
const updates = {};
|
|
@@ -118,13 +150,13 @@ export async function refineWithSmart({ enabled, rawData, design, pageIntent, se
|
|
|
118
150
|
if (!spec) continue;
|
|
119
151
|
const user = `Digest:\n${digest}\n\nCurrent heuristic result:\n${JSON.stringify(current)}\n\nRespond with the requested JSON.`;
|
|
120
152
|
try {
|
|
121
|
-
const raw = await callLLM(
|
|
153
|
+
const raw = await callLLM(providerConfig, spec.system, user);
|
|
122
154
|
const parsed = parseJsonLoose(raw);
|
|
123
|
-
if (parsed) updates[task] = { ...parsed, smart: true, provider };
|
|
155
|
+
if (parsed) updates[task] = { ...parsed, smart: true, provider: providerConfig.provider };
|
|
124
156
|
} catch (e) {
|
|
125
157
|
errors.push(`${task}: ${e.message}`);
|
|
126
158
|
}
|
|
127
159
|
}
|
|
128
160
|
|
|
129
|
-
return { applied: true, provider, updates, errors };
|
|
161
|
+
return { applied: true, provider: providerConfig.provider, updates, errors };
|
|
130
162
|
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// GSAP emitter — `<host>-motion.gsap.js`.
|
|
2
|
+
//
|
|
3
|
+
// Targets GreenSock (gsap). The extracted cubic-bezier curves are registered
|
|
4
|
+
// as named `CustomEase`s (so the site's exact feel is reproducible), durations
|
|
5
|
+
// come through as seconds, and we emit `gsap.from()` reveal helpers plus a
|
|
6
|
+
// ScrollTrigger reveal when the page actually uses scroll-/view-timeline.
|
|
7
|
+
//
|
|
8
|
+
// We emit:
|
|
9
|
+
// • `eases` — CustomEase SVG-path strings keyed by family
|
|
10
|
+
// • `durations` — extracted timings in seconds
|
|
11
|
+
// • `registerEases(gsap)` — registers every ease as a named CustomEase
|
|
12
|
+
// • `reveals` — gsap.from() helpers (fadeIn / slideUp / scaleIn / pop)
|
|
13
|
+
// • `revealOnScroll()` — ScrollTrigger batch reveal (only when scroll-linked)
|
|
14
|
+
|
|
15
|
+
function bezierPts(raw) {
|
|
16
|
+
if (!raw) return null;
|
|
17
|
+
const m = String(raw).match(/cubic-bezier\(\s*([-\d.]+)\s*,\s*([-\d.]+)\s*,\s*([-\d.]+)\s*,\s*([-\d.]+)\s*\)/i);
|
|
18
|
+
if (m) return [parseFloat(m[1]), parseFloat(m[2]), parseFloat(m[3]), parseFloat(m[4])];
|
|
19
|
+
const named = {
|
|
20
|
+
linear: [0, 0, 1, 1], ease: [0.25, 0.1, 0.25, 1],
|
|
21
|
+
'ease-in': [0.42, 0, 1, 1], 'ease-out': [0, 0, 0.58, 1], 'ease-in-out': [0.42, 0, 0.58, 1],
|
|
22
|
+
};
|
|
23
|
+
return named[String(raw).trim()] || null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function camel(s) {
|
|
27
|
+
return String(s || '').replace(/[^a-zA-Z0-9]+(.)/g, (_, c) => c.toUpperCase()).replace(/^[0-9]/, '_$&');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// GSAP CustomEase accepts SVG cubic-path data; a bezier from (0,0)→(1,1) with
|
|
31
|
+
// the two extracted control points maps directly to one cubic segment.
|
|
32
|
+
function customEasePath([x1, y1, x2, y2]) {
|
|
33
|
+
return `M0,0 C${x1},${y1} ${x2},${y2} 1,1`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function formatMotionGsap(design) {
|
|
37
|
+
const host = (() => { try { return new URL(design?.meta?.url).hostname.replace(/^www\./, ''); } catch { return 'site'; } })();
|
|
38
|
+
const durations = Array.isArray(design?.motion?.durations) ? design.motion.durations : [];
|
|
39
|
+
const easings = Array.isArray(design?.motion?.easings) ? design.motion.easings : [];
|
|
40
|
+
const springs = Array.isArray(design?.motion?.springs) ? design.motion.springs : [];
|
|
41
|
+
const scroll = design?.motion?.scrollLinked || { present: false, signals: [] };
|
|
42
|
+
|
|
43
|
+
const durSec = (d) => ((d.ms || parseInt(d.css || d.value || d, 10) || 300) / 1000);
|
|
44
|
+
const defaultDur = durations.length ? durSec(durations[Math.min(2, durations.length - 1)]) : 0.3;
|
|
45
|
+
|
|
46
|
+
// Eases: dedupe by family, most-used first, fall back to a standard curve.
|
|
47
|
+
const sortedEase = [...easings].sort((a, b) => (b.count || 0) - (a.count || 0));
|
|
48
|
+
const easeEntries = [];
|
|
49
|
+
sortedEase.forEach((e, i) => {
|
|
50
|
+
const pts = bezierPts(e.raw);
|
|
51
|
+
if (!pts) return;
|
|
52
|
+
const key = e.family && e.family !== 'custom' ? camel(e.family) : `custom${i + 1}`;
|
|
53
|
+
if (easeEntries.find((x) => x.key === key)) return;
|
|
54
|
+
easeEntries.push({ key, path: customEasePath(pts), count: e.count });
|
|
55
|
+
});
|
|
56
|
+
springs.forEach((s) => {
|
|
57
|
+
const pts = bezierPts(s.raw);
|
|
58
|
+
if (pts && !easeEntries.find((x) => x.key === 'spring')) {
|
|
59
|
+
easeEntries.push({ key: 'spring', path: customEasePath(pts), count: s.count });
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
if (easeEntries.length === 0) {
|
|
63
|
+
easeEntries.push({ key: 'standard', path: customEasePath([0.25, 0.1, 0.25, 1]), count: 0 });
|
|
64
|
+
}
|
|
65
|
+
const primaryEaseKey = easeEntries[0].key;
|
|
66
|
+
|
|
67
|
+
const lines = [
|
|
68
|
+
'// GSAP presets — generated by designlang (motionlang)',
|
|
69
|
+
`// Source: ${design?.meta?.url || host}`,
|
|
70
|
+
`// ${new Date().toISOString()}`,
|
|
71
|
+
'//',
|
|
72
|
+
"// import gsap from 'gsap';",
|
|
73
|
+
`// import { registerEases, reveals } from './${host}-motion.gsap.js';`,
|
|
74
|
+
'// registerEases(gsap); // one-time: registers CustomEases',
|
|
75
|
+
"// reveals.slideUp('.card');",
|
|
76
|
+
'',
|
|
77
|
+
'/** CustomEase path data extracted from the live page, keyed by family. */',
|
|
78
|
+
'export const eases = {',
|
|
79
|
+
];
|
|
80
|
+
for (const { key, path, count } of easeEntries) {
|
|
81
|
+
lines.push(` ${key}: '${path}',${count ? ` // ${count}× on page` : ''}`);
|
|
82
|
+
}
|
|
83
|
+
lines.push('};', '');
|
|
84
|
+
|
|
85
|
+
lines.push('/** Duration presets (seconds), extracted from the live page. */');
|
|
86
|
+
lines.push('export const durations = {');
|
|
87
|
+
if (durations.length) {
|
|
88
|
+
for (const d of durations) lines.push(` ${camel(d.name || `ms${d.ms}`)}: ${durSec(d)},`);
|
|
89
|
+
} else {
|
|
90
|
+
lines.push(' fast: 0.15,', ' base: 0.3,', ' slow: 0.5,');
|
|
91
|
+
}
|
|
92
|
+
lines.push('};', '');
|
|
93
|
+
|
|
94
|
+
lines.push('/** Register every extracted curve as a named GSAP CustomEase. Call once. */');
|
|
95
|
+
lines.push('export function registerEases(gsap) {');
|
|
96
|
+
lines.push(' const CustomEase = gsap.plugins?.CustomEase || (typeof window !== \'undefined\' && window.CustomEase);');
|
|
97
|
+
lines.push(' if (!CustomEase) {');
|
|
98
|
+
lines.push(" console.warn('[motion.gsap] CustomEase plugin not registered — call gsap.registerPlugin(CustomEase) first.');");
|
|
99
|
+
lines.push(' return;');
|
|
100
|
+
lines.push(' }');
|
|
101
|
+
lines.push(' for (const [name, path] of Object.entries(eases)) CustomEase.create(name, path);');
|
|
102
|
+
lines.push('}');
|
|
103
|
+
lines.push('');
|
|
104
|
+
|
|
105
|
+
lines.push('/** gsap.from() reveal helpers. Pass any GSAP target + optional vars override. */');
|
|
106
|
+
lines.push('export const reveals = {');
|
|
107
|
+
lines.push(` fadeIn: (target, vars = {}) => gsap.from(target, { opacity: 0, duration: ${defaultDur}, ease: '${primaryEaseKey}', ...vars }),`);
|
|
108
|
+
lines.push(` slideUp: (target, vars = {}) => gsap.from(target, { opacity: 0, y: 24, duration: ${defaultDur}, ease: '${primaryEaseKey}', ...vars }),`);
|
|
109
|
+
lines.push(` slideDown: (target, vars = {}) => gsap.from(target, { opacity: 0, y: -24, duration: ${defaultDur}, ease: '${primaryEaseKey}', ...vars }),`);
|
|
110
|
+
lines.push(` scaleIn: (target, vars = {}) => gsap.from(target, { opacity: 0, scale: 0.96, duration: ${defaultDur}, ease: '${primaryEaseKey}', ...vars }),`);
|
|
111
|
+
lines.push(` pop: (target, vars = {}) => gsap.from(target, { scale: 0.9, duration: ${defaultDur}, ease: '${easeEntries.find((e) => e.key === 'spring') ? 'spring' : primaryEaseKey}', ...vars }),`);
|
|
112
|
+
lines.push('};', '');
|
|
113
|
+
|
|
114
|
+
if (scroll.present) {
|
|
115
|
+
lines.push('/** Site uses scroll-/view-timeline. Batch-reveals elements as they enter. */');
|
|
116
|
+
lines.push('/** Requires: gsap.registerPlugin(ScrollTrigger). */');
|
|
117
|
+
lines.push('export function revealOnScroll(targets, vars = {}) {');
|
|
118
|
+
lines.push(' return gsap.utils.toArray(targets).map((el) =>');
|
|
119
|
+
lines.push(' gsap.from(el, {');
|
|
120
|
+
lines.push(` opacity: 0, y: 24, duration: ${defaultDur}, ease: '${primaryEaseKey}', ...vars,`);
|
|
121
|
+
lines.push(" scrollTrigger: { trigger: el, start: 'top 85%', toggleActions: 'play none none reverse' },");
|
|
122
|
+
lines.push(' }));');
|
|
123
|
+
lines.push('}');
|
|
124
|
+
lines.push('');
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
lines.push('export default { eases, durations, registerEases, reveals };');
|
|
128
|
+
lines.push('');
|
|
129
|
+
|
|
130
|
+
return lines.join('\n');
|
|
131
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// Web Animations API emitter — `<host>-motion.waapi.js`.
|
|
2
|
+
//
|
|
3
|
+
// The zero-dependency sibling of the Motion One / Framer emitters. Everything
|
|
4
|
+
// here runs on the browser-native `Element.animate()` — no runtime, no bundle
|
|
5
|
+
// cost. WAAPI accepts cubic-bezier easing strings verbatim, so the extracted
|
|
6
|
+
// curves are reproduced *exactly* (no spring approximation, unlike the
|
|
7
|
+
// library targets).
|
|
8
|
+
//
|
|
9
|
+
// We emit:
|
|
10
|
+
// • `easings` — the page's cubic-bezier strings, keyed by family
|
|
11
|
+
// • `durations` — extracted timings in milliseconds
|
|
12
|
+
// • `keyframes` — keyframe arrays reconstructed from on-page @keyframes,
|
|
13
|
+
// ready to hand straight to `el.animate(frames, timing)`
|
|
14
|
+
// • `animations`— fadeIn / slideUp / scaleIn … helpers returning the live
|
|
15
|
+
// Animation object so callers can await `.finished`
|
|
16
|
+
// • a `prefersReducedMotion()` guard the helpers honour automatically.
|
|
17
|
+
|
|
18
|
+
function bezier(raw) {
|
|
19
|
+
if (!raw) return null;
|
|
20
|
+
const m = String(raw).match(/cubic-bezier\(\s*([-\d.]+)\s*,\s*([-\d.]+)\s*,\s*([-\d.]+)\s*,\s*([-\d.]+)\s*\)/i);
|
|
21
|
+
if (m) return `cubic-bezier(${m[1]}, ${m[2]}, ${m[3]}, ${m[4]})`;
|
|
22
|
+
const named = new Set(['linear', 'ease', 'ease-in', 'ease-out', 'ease-in-out']);
|
|
23
|
+
return named.has(String(raw).trim()) ? String(raw).trim() : null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function camel(s) {
|
|
27
|
+
return String(s || '').replace(/[^a-zA-Z0-9]+(.)/g, (_, c) => c.toUpperCase()).replace(/^[0-9]/, '_$&');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function formatMotionWaapi(design) {
|
|
31
|
+
const host = (() => { try { return new URL(design?.meta?.url).hostname.replace(/^www\./, ''); } catch { return 'site'; } })();
|
|
32
|
+
const durations = Array.isArray(design?.motion?.durations) ? design.motion.durations : [];
|
|
33
|
+
const easings = Array.isArray(design?.motion?.easings) ? design.motion.easings : [];
|
|
34
|
+
const springs = Array.isArray(design?.motion?.springs) ? design.motion.springs : [];
|
|
35
|
+
const keyframes = Array.isArray(design?.motion?.keyframes) ? design.motion.keyframes : [];
|
|
36
|
+
|
|
37
|
+
const ms = (d) => (d.ms || parseInt(d.css || d.value || d, 10) || 300);
|
|
38
|
+
const defaultDur = durations.length ? ms(durations[Math.min(2, durations.length - 1)]) : 300;
|
|
39
|
+
|
|
40
|
+
// Easings: dedupe by family, keep the most-used first, fall back to a
|
|
41
|
+
// standard curve when the page exposed nothing parseable.
|
|
42
|
+
const sortedEase = [...easings].sort((a, b) => (b.count || 0) - (a.count || 0));
|
|
43
|
+
const easeEntries = [];
|
|
44
|
+
sortedEase.forEach((e, i) => {
|
|
45
|
+
const css = bezier(e.raw);
|
|
46
|
+
if (!css) return;
|
|
47
|
+
const key = e.family && e.family !== 'custom' ? camel(e.family) : `custom${i + 1}`;
|
|
48
|
+
if (easeEntries.find((x) => x.key === key)) return;
|
|
49
|
+
easeEntries.push({ key, css, count: e.count });
|
|
50
|
+
});
|
|
51
|
+
// Detected overshoot beziers ride along as a `spring` easing (WAAPI honours
|
|
52
|
+
// the overshoot natively — the curve simply leaves the [0,1] band).
|
|
53
|
+
springs.forEach((s) => {
|
|
54
|
+
const css = bezier(s.raw);
|
|
55
|
+
if (css && !easeEntries.find((x) => x.key === 'spring')) easeEntries.push({ key: 'spring', css, count: s.count });
|
|
56
|
+
});
|
|
57
|
+
if (easeEntries.length === 0) {
|
|
58
|
+
easeEntries.push({ key: 'standard', css: 'cubic-bezier(0.25, 0.1, 0.25, 1)', count: 0 });
|
|
59
|
+
}
|
|
60
|
+
const primaryEaseKey = easeEntries[0].key;
|
|
61
|
+
|
|
62
|
+
const lines = [
|
|
63
|
+
'// Web Animations API presets — generated by designlang (motionlang)',
|
|
64
|
+
`// Source: ${design?.meta?.url || host}`,
|
|
65
|
+
`// ${new Date().toISOString()}`,
|
|
66
|
+
'//',
|
|
67
|
+
'// Zero-dependency. Runs on the browser-native Element.animate().',
|
|
68
|
+
`// import { animations } from './${host}-motion.waapi.js';`,
|
|
69
|
+
"// animations.fadeIn(document.querySelector('#hero'));",
|
|
70
|
+
"// await animations.slideUp(card, { delay: 80 }).finished;",
|
|
71
|
+
'',
|
|
72
|
+
'/** Easing curves extracted from the live page, as WAAPI easing strings. */',
|
|
73
|
+
'export const easings = {',
|
|
74
|
+
];
|
|
75
|
+
for (const { key, css, count } of easeEntries) {
|
|
76
|
+
lines.push(` ${key}: '${css}',${count ? ` // ${count}× on page` : ''}`);
|
|
77
|
+
}
|
|
78
|
+
lines.push('};', '');
|
|
79
|
+
|
|
80
|
+
lines.push('/** Duration presets (milliseconds), extracted from the live page. */');
|
|
81
|
+
lines.push('export const durations = {');
|
|
82
|
+
if (durations.length) {
|
|
83
|
+
for (const d of durations) lines.push(` ${camel(d.name || `ms${d.ms}`)}: ${ms(d)},`);
|
|
84
|
+
} else {
|
|
85
|
+
lines.push(' fast: 150,', ' base: 300,', ' slow: 500,');
|
|
86
|
+
}
|
|
87
|
+
lines.push('};', '');
|
|
88
|
+
|
|
89
|
+
// Reconstructed keyframes — WAAPI takes an array of frame objects, so each
|
|
90
|
+
// step's `prop: value` pairs become one frame.
|
|
91
|
+
const usedKeyframes = keyframes.filter((k) => k.used && k.steps && k.steps.length);
|
|
92
|
+
if (usedKeyframes.length) {
|
|
93
|
+
lines.push('/** @keyframes blocks reconstructed as WAAPI keyframe arrays. */');
|
|
94
|
+
lines.push('export const keyframes = {');
|
|
95
|
+
for (const kf of usedKeyframes.slice(0, 16)) {
|
|
96
|
+
const frames = [];
|
|
97
|
+
for (const step of kf.steps) {
|
|
98
|
+
const frame = {};
|
|
99
|
+
for (const part of (step.style || '').split(';')) {
|
|
100
|
+
const [p, v] = part.split(':').map((x) => (x || '').trim());
|
|
101
|
+
if (p && v) frame[camel(p)] = v;
|
|
102
|
+
}
|
|
103
|
+
const offset = parseFloat(String(step.offset)) / 100;
|
|
104
|
+
if (Number.isFinite(offset)) frame.offset = offset;
|
|
105
|
+
if (Object.keys(frame).length) frames.push(frame);
|
|
106
|
+
}
|
|
107
|
+
if (frames.length >= 2) lines.push(` ${camel(kf.name)}: ${JSON.stringify(frames)},`);
|
|
108
|
+
}
|
|
109
|
+
lines.push('};', '');
|
|
110
|
+
} else {
|
|
111
|
+
lines.push('/** No on-page @keyframes were attached to elements; emit empty for shape parity. */');
|
|
112
|
+
lines.push('export const keyframes = {};', '');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
lines.push('/** Honours the user\'s reduced-motion preference. */');
|
|
116
|
+
lines.push('export const prefersReducedMotion = () =>');
|
|
117
|
+
lines.push(" typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;");
|
|
118
|
+
lines.push('');
|
|
119
|
+
lines.push(`const _timing = { duration: ${defaultDur}, easing: easings.${primaryEaseKey}, fill: 'both' };`);
|
|
120
|
+
lines.push('');
|
|
121
|
+
lines.push('function _animate(el, frames, opts = {}) {');
|
|
122
|
+
lines.push(' const timing = { ..._timing, ...opts };');
|
|
123
|
+
lines.push(' if (prefersReducedMotion()) timing.duration = 0;');
|
|
124
|
+
lines.push(' return el.animate(frames, timing);');
|
|
125
|
+
lines.push('}');
|
|
126
|
+
lines.push('');
|
|
127
|
+
lines.push('/** Animate-on-call helpers. Each returns the live Animation (await `.finished`). */');
|
|
128
|
+
lines.push('export const animations = {');
|
|
129
|
+
lines.push(' fadeIn: (el, opts = {}) => _animate(el, [{ opacity: 0 }, { opacity: 1 }], opts),');
|
|
130
|
+
lines.push(' fadeOut: (el, opts = {}) => _animate(el, [{ opacity: 1 }, { opacity: 0 }], opts),');
|
|
131
|
+
lines.push(" slideUp: (el, opts = {}) => _animate(el, [{ opacity: 0, transform: 'translateY(16px)' }, { opacity: 1, transform: 'translateY(0)' }], opts),");
|
|
132
|
+
lines.push(" slideDown: (el, opts = {}) => _animate(el, [{ opacity: 0, transform: 'translateY(-16px)' }, { opacity: 1, transform: 'translateY(0)' }], opts),");
|
|
133
|
+
lines.push(" scaleIn: (el, opts = {}) => _animate(el, [{ opacity: 0, transform: 'scale(0.96)' }, { opacity: 1, transform: 'scale(1)' }], opts),");
|
|
134
|
+
lines.push('};', '');
|
|
135
|
+
|
|
136
|
+
lines.push('export default { easings, durations, keyframes, animations, prefersReducedMotion };');
|
|
137
|
+
lines.push('');
|
|
138
|
+
|
|
139
|
+
return lines.join('\n');
|
|
140
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// Render a verify report as JSON and a self-contained HTML triptych
|
|
2
|
+
// (original │ rebuilt │ diff-heatmap per component) with the fidelity score.
|
|
3
|
+
|
|
4
|
+
export function formatVerifyJson(report) {
|
|
5
|
+
// Drop the verbose per-property deltas from JSON; keep the attribution summary.
|
|
6
|
+
const slim = {
|
|
7
|
+
...report,
|
|
8
|
+
components: report.components.map(({ deltas, ...c }) => c), // eslint-disable-line no-unused-vars
|
|
9
|
+
};
|
|
10
|
+
return JSON.stringify(slim, null, 2) + '\n';
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function esc(s) {
|
|
14
|
+
return String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function band(n) {
|
|
18
|
+
if (n == null) return '#888';
|
|
19
|
+
if (n >= 90) return '#16a34a';
|
|
20
|
+
if (n >= 75) return '#65a30d';
|
|
21
|
+
if (n >= 55) return '#d97706';
|
|
22
|
+
return '#dc2626';
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function formatVerifyHtml(report) {
|
|
26
|
+
const score = report.fidelity;
|
|
27
|
+
const cards = report.components.map((c) => {
|
|
28
|
+
if (c.status !== 'ok') {
|
|
29
|
+
return `<div class="comp na"><div class="comp-head"><span class="kind">${esc(c.component)}</span><span class="tag">${esc(c.status)}</span></div><p class="reason">${esc(c.reason || '')}</p></div>`;
|
|
30
|
+
}
|
|
31
|
+
const attr = (c.attribution || []).map((a) =>
|
|
32
|
+
`<li><b>${esc(a.family)}</b> — ${a.unmapped ? `${a.unmapped} unmapped` : ''}${a.unmapped && a.moves ? ', ' : ''}${a.moves ? `${a.moves} shifted` : ''}</li>`).join('');
|
|
33
|
+
return `<div class="comp">
|
|
34
|
+
<div class="comp-head"><span class="kind">${esc(c.component)}</span><span class="score" style="color:${band(c.fidelity)}">${c.fidelity}<small>/100</small></span></div>
|
|
35
|
+
<div class="trip">
|
|
36
|
+
<figure><img src="verify/original/${esc(c.component)}.png" alt="original"><figcaption>original</figcaption></figure>
|
|
37
|
+
<figure><img src="verify/rebuilt/${esc(c.component)}.png" alt="rebuilt from tokens"><figcaption>rebuilt from tokens</figcaption></figure>
|
|
38
|
+
<figure><img src="verify/diff/${esc(c.component)}.png" alt="diff"><figcaption>loss heatmap</figcaption></figure>
|
|
39
|
+
</div>
|
|
40
|
+
${attr ? `<ul class="attr">${attr}</ul>` : '<p class="attr none">no significant token loss</p>'}
|
|
41
|
+
</div>`;
|
|
42
|
+
}).join('');
|
|
43
|
+
|
|
44
|
+
return `<!doctype html><html lang="en"><head><meta charset="utf-8">
|
|
45
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
46
|
+
<title>designlang · fidelity · ${esc(report.host)}</title>
|
|
47
|
+
<style>
|
|
48
|
+
:root { --fg:#111; --mut:#666; --line:#e5e5e5; --bg:#fafafa; }
|
|
49
|
+
* { box-sizing: border-box; }
|
|
50
|
+
body { font: 15px/1.6 -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; color: var(--fg); background: var(--bg); margin: 0; padding: 40px 24px; }
|
|
51
|
+
.wrap { max-width: 1080px; margin: 0 auto; }
|
|
52
|
+
h1 { font-size: 28px; margin: 0 0 4px; }
|
|
53
|
+
.sub { color: var(--mut); font-size: 13px; margin: 0 0 28px; }
|
|
54
|
+
.big { display: flex; align-items: baseline; gap: 14px; margin: 0 0 32px; }
|
|
55
|
+
.big .n { font-size: 64px; font-weight: 800; line-height: 1; color: ${band(score)}; }
|
|
56
|
+
.big .l { color: var(--mut); font-size: 13px; text-transform: uppercase; letter-spacing: .08em; }
|
|
57
|
+
.comp { background: #fff; border: 1px solid var(--line); border-radius: 12px; padding: 18px; margin-bottom: 18px; }
|
|
58
|
+
.comp.na { opacity: .7; }
|
|
59
|
+
.comp-head { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 14px; }
|
|
60
|
+
.kind { font-weight: 700; text-transform: capitalize; }
|
|
61
|
+
.score { font-size: 26px; font-weight: 800; } .score small { font-size: 12px; color: var(--mut); font-weight: 500; }
|
|
62
|
+
.tag { font: 11px/1 ui-monospace, monospace; text-transform: uppercase; letter-spacing: .1em; color: var(--mut); border: 1px solid var(--line); padding: 4px 8px; border-radius: 6px; }
|
|
63
|
+
.trip { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
|
|
64
|
+
figure { margin: 0; text-align: center; } figure img { max-width: 100%; border: 1px solid var(--line); border-radius: 8px; background: #fff; }
|
|
65
|
+
figcaption { color: var(--mut); font-size: 11px; margin-top: 6px; text-transform: uppercase; letter-spacing: .06em; }
|
|
66
|
+
.attr { margin: 14px 0 0; padding-left: 18px; color: var(--mut); font-size: 13px; } .attr.none { list-style: none; padding: 0; font-style: italic; }
|
|
67
|
+
.reason { color: var(--mut); font-size: 13px; margin: 0; }
|
|
68
|
+
footer { color: var(--mut); font-size: 12px; margin-top: 24px; }
|
|
69
|
+
</style></head>
|
|
70
|
+
<body><div class="wrap">
|
|
71
|
+
<h1>Fidelity report — ${esc(report.host)}</h1>
|
|
72
|
+
<p class="sub">How faithfully the extracted tokens reconstruct the live components. ${esc(report.generatedAt)}</p>
|
|
73
|
+
<div class="big"><span class="n">${score == null ? '—' : score}</span><span class="l">${score == null ? 'no components scored' : 'site fidelity / 100'}</span></div>
|
|
74
|
+
${cards}
|
|
75
|
+
<footer>Rebuilt using only designlang's extracted tokens. Lower scores point to where tokenization lost information — see the heatmaps. Generated by <b>designlang verify</b>.</footer>
|
|
76
|
+
</div></body></html>`;
|
|
77
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// Pixel-diff two PNG buffers and return a differing-pixel ratio + a heatmap.
|
|
2
|
+
//
|
|
3
|
+
// Sizes rarely match (a snapped-down padding makes the rebuild smaller), so we
|
|
4
|
+
// letterbox both onto a common canvas anchored top-left, padding with white.
|
|
5
|
+
// We never STRETCH — a stretched compare would smear the score into fiction.
|
|
6
|
+
// Padding asymmetry is real signal: if the rebuild is smaller, the uncovered
|
|
7
|
+
// margin shows up as loss, which is correct.
|
|
8
|
+
|
|
9
|
+
import pixelmatch from 'pixelmatch';
|
|
10
|
+
import { PNG } from 'pngjs';
|
|
11
|
+
|
|
12
|
+
function pad(png, width, height, fill = 255) {
|
|
13
|
+
if (png.width === width && png.height === height) return png;
|
|
14
|
+
const out = new PNG({ width, height });
|
|
15
|
+
out.data.fill(fill); // opaque white canvas
|
|
16
|
+
for (let i = 3; i < out.data.length; i += 4) out.data[i] = 255;
|
|
17
|
+
PNG.bitblt(png, out, 0, 0, Math.min(png.width, width), Math.min(png.height, height), 0, 0);
|
|
18
|
+
return out;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// aBuf/bBuf: PNG file buffers. Returns { ratio, width, height, diffPixels, heatmap }.
|
|
22
|
+
export function diffPngBuffers(aBuf, bBuf, { threshold = 0.1 } = {}) {
|
|
23
|
+
const a = PNG.sync.read(aBuf);
|
|
24
|
+
const b = PNG.sync.read(bBuf);
|
|
25
|
+
const width = Math.max(a.width, b.width);
|
|
26
|
+
const height = Math.max(a.height, b.height);
|
|
27
|
+
|
|
28
|
+
const pa = pad(a, width, height);
|
|
29
|
+
const pb = pad(b, width, height);
|
|
30
|
+
const out = new PNG({ width, height });
|
|
31
|
+
|
|
32
|
+
const diffPixels = pixelmatch(pa.data, pb.data, out.data, width, height, {
|
|
33
|
+
threshold,
|
|
34
|
+
includeAA: false,
|
|
35
|
+
alpha: 0.25,
|
|
36
|
+
diffColor: [255, 0, 80],
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const total = width * height;
|
|
40
|
+
return {
|
|
41
|
+
ratio: total ? diffPixels / total : 1,
|
|
42
|
+
width,
|
|
43
|
+
height,
|
|
44
|
+
diffPixels,
|
|
45
|
+
heatmap: PNG.sync.write(out),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Convenience: ratio → 0–100 fidelity, rounded.
|
|
50
|
+
export function ratioToFidelity(ratio) {
|
|
51
|
+
return Math.max(0, Math.min(100, Math.round((1 - ratio) * 100)));
|
|
52
|
+
}
|