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.
@@ -1,7 +1,8 @@
1
1
  // Optional LLM fallback for low-confidence classifications. No SDK deps — we
2
- // hit the OpenAI or Anthropic REST API directly via global fetch. Runs only
3
- // when the user passes --smart AND an API key is available in env. Silently
4
- // no-ops otherwise so the core extractor stays zero-config.
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 detectProvider() {
23
- if (process.env.ANTHROPIC_API_KEY) return 'anthropic';
24
- if (process.env.OPENAI_API_KEY) return 'openai';
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': process.env.ANTHROPIC_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: process.env.DESIGNLANG_MODEL || 'claude-haiku-4-5-20251001',
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 callOpenAI(system, user) {
69
- const res = await fetch('https://api.openai.com/v1/chat/completions', {
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 ${process.env.OPENAI_API_KEY}`,
102
+ 'authorization': `Bearer ${config.apiKey}`,
73
103
  'content-type': 'application/json',
74
104
  },
75
105
  body: JSON.stringify({
76
- model: process.env.DESIGNLANG_MODEL || 'gpt-4o-mini',
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(`openai ${res.status}`);
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(provider, system, user) {
91
- if (provider === 'anthropic') return callAnthropic(system, user);
92
- return callOpenAI(system, user);
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 provider = detectProvider();
105
- if (!provider) return { applied: false, reason: 'no API key (set OPENAI_API_KEY or ANTHROPIC_API_KEY)' };
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(provider, spec.system, user);
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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[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
+ }