designlang 12.15.0 → 12.17.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
  }
@@ -5,6 +5,11 @@
5
5
  // (fade, slide-up, scale-in, stagger). Framer Motion is the dominant
6
6
  // React animation library, so this is the highest-leverage motion
7
7
  // emitter for the React ecosystem.
8
+ //
9
+ // v12.16 — now also emits:
10
+ // • spring presets derived from detected overshoot beziers
11
+ // • whileInView variants when the source uses scroll/view-timeline
12
+ // • keyframe variants reconstructed from on-page @keyframes
8
13
 
9
14
  function bezier(raw) {
10
15
  if (!raw) return null;
@@ -21,10 +26,25 @@ function camel(s) {
21
26
  return String(s || '').replace(/[^a-zA-Z0-9]+(.)/g, (_, c) => c.toUpperCase()).replace(/^[0-9]/, '_$&');
22
27
  }
23
28
 
29
+ // Rough inverse of a critically-damped second-order response.
30
+ // Shorter durations → stiffer; deeper overshoot → less damping.
31
+ // Same heuristic the Motion One emitter uses, kept in sync for parity.
32
+ function springFromBezier(pts, ms) {
33
+ const [, y1, , y2] = pts;
34
+ const overshoot = Math.max(0, Math.max(y1 - 1, y2 - 1, -y1, -y2));
35
+ const seconds = Math.max(0.1, (ms || 400) / 1000);
36
+ const stiffness = Math.round(Math.min(700, Math.max(80, 600 / seconds)));
37
+ const damping = Math.round(Math.max(8, 30 - overshoot * 40));
38
+ return { stiffness, damping, mass: 1 };
39
+ }
40
+
24
41
  export function formatFramerMotion(design) {
25
42
  const host = (() => { try { return new URL(design?.meta?.url).hostname.replace(/^www\./, ''); } catch { return 'site'; } })();
26
43
  const durations = Array.isArray(design?.motion?.durations) ? design.motion.durations : [];
27
44
  const easings = Array.isArray(design?.motion?.easings) ? design.motion.easings : [];
45
+ const springs = Array.isArray(design?.motion?.springs) ? design.motion.springs : [];
46
+ const keyframes = Array.isArray(design?.motion?.keyframes) ? design.motion.keyframes : [];
47
+ const scroll = design?.motion?.scrollLinked || { present: false, signals: [] };
28
48
 
29
49
  // Pick a default duration (the most common "medium" feel) and easing.
30
50
  const durSec = (d) => ((d.ms || parseInt(d.css || d.value || d, 10) || 300) / 1000);
@@ -75,13 +95,33 @@ export function formatFramerMotion(design) {
75
95
  }
76
96
  lines.push('};', '');
77
97
 
98
+ // Spring presets — derived from detected overshoot beziers when present,
99
+ // else a single tasteful default so consumers can always reach for one.
100
+ const springEntries = [];
101
+ springs.forEach((s, i) => {
102
+ const pts = bezier(s.raw);
103
+ if (!pts) return;
104
+ springEntries.push({ key: i === 0 ? 'soft' : `spring${i + 1}`, opts: springFromBezier(pts, defaultDur * 1000) });
105
+ });
106
+ if (springEntries.length === 0) {
107
+ springEntries.push({ key: 'soft', opts: { stiffness: 320, damping: 30, mass: 1 } });
108
+ }
109
+ const primarySpringKey = springEntries[0].key;
110
+
111
+ lines.push('/** Spring presets — pass to a Framer Motion `transition` prop. */');
112
+ lines.push('export const springs = {');
113
+ for (const { key, opts } of springEntries) {
114
+ lines.push(` ${key}: { type: 'spring', stiffness: ${opts.stiffness}, damping: ${opts.damping}, mass: ${opts.mass} },`);
115
+ }
116
+ lines.push('};', '');
117
+
78
118
  const primaryEaseKey = easeEntries[0].key;
79
119
  lines.push('/** Ready-to-spread Framer Motion transition objects. */');
80
120
  lines.push('export const transitions = {');
81
121
  lines.push(` base: { duration: ${defaultDur}, ease: easings.${primaryEaseKey} },`);
82
122
  lines.push(` fast: { duration: ${Math.max(0.1, defaultDur * 0.5).toFixed(3)}, ease: easings.${primaryEaseKey} },`);
83
123
  lines.push(` slow: { duration: ${(defaultDur * 1.8).toFixed(3)}, ease: easings.${primaryEaseKey} },`);
84
- lines.push(` spring: { type: 'spring', stiffness: 320, damping: 30 },`);
124
+ lines.push(` spring: springs.${primarySpringKey},`);
85
125
  lines.push('};', '');
86
126
 
87
127
  lines.push('/** Common variants wired to the extracted timing. */');
@@ -98,12 +138,60 @@ export function formatFramerMotion(design) {
98
138
  lines.push(' hidden: { opacity: 0, scale: 0.96 },');
99
139
  lines.push(' show: { opacity: 1, scale: 1, transition: transitions.base },');
100
140
  lines.push(' },');
141
+ lines.push(' pop: {');
142
+ lines.push(' hidden: { opacity: 0, scale: 0.9 },');
143
+ lines.push(` show: { opacity: 1, scale: 1, transition: springs.${primarySpringKey} },`);
144
+ lines.push(' },');
101
145
  lines.push(' stagger: {');
102
146
  lines.push(' hidden: {},');
103
147
  lines.push(' show: { transition: { staggerChildren: ' + Math.max(0.04, defaultDur * 0.25).toFixed(3) + ' } },');
104
148
  lines.push(' },');
149
+
150
+ // Reconstructed keyframe variants — Framer Motion accepts arrays per
151
+ // property to produce keyframe animations. We use the camelCased
152
+ // @keyframes name so the source intent survives.
153
+ const usedKeyframes = keyframes.filter(k => k.used && k.steps && k.steps.length);
154
+ for (const kf of usedKeyframes.slice(0, 8)) {
155
+ const propMap = {};
156
+ for (const step of kf.steps) {
157
+ for (const part of (step.style || '').split(';')) {
158
+ const [p, v] = part.split(':').map(s => (s || '').trim());
159
+ if (!p || !v) continue;
160
+ (propMap[p] ||= []).push(v);
161
+ }
162
+ }
163
+ const entries = Object.entries(propMap)
164
+ .filter(([, vals]) => vals.length >= 2)
165
+ .map(([prop, vals]) => ` ${JSON.stringify(camel(prop))}: ${JSON.stringify(vals)}`);
166
+ if (!entries.length) continue;
167
+ lines.push(` ${camel(kf.name)}: {`);
168
+ lines.push(' hidden: {},');
169
+ lines.push(' show: {');
170
+ lines.push(entries.join(',\n') + ',');
171
+ lines.push(' transition: transitions.base,');
172
+ lines.push(' },');
173
+ lines.push(' },');
174
+ }
175
+
105
176
  lines.push('};', '');
106
- lines.push('export default { easings, durations, transitions, variants };');
177
+
178
+ if (scroll.present) {
179
+ lines.push('/** Site uses scroll- or view-timeline. Drop-in `whileInView` props. */');
180
+ lines.push('export const inView = {');
181
+ lines.push(' fadeIn: {');
182
+ lines.push(' initial: variants.fade.hidden,');
183
+ lines.push(' whileInView: variants.fade.show,');
184
+ lines.push(' viewport: { once: true, amount: 0.3 },');
185
+ lines.push(' },');
186
+ lines.push(' riseIn: {');
187
+ lines.push(' initial: variants.slideUp.hidden,');
188
+ lines.push(' whileInView: variants.slideUp.show,');
189
+ lines.push(' viewport: { once: true, amount: 0.3 },');
190
+ lines.push(' },');
191
+ lines.push('};', '');
192
+ }
193
+
194
+ lines.push(`export default { easings, durations, springs, transitions, variants${scroll.present ? ', inView' : ''} };`);
107
195
  lines.push('');
108
196
 
109
197
  return lines.join('\n');
@@ -0,0 +1,136 @@
1
+ // Motion CSS emitter — `<host>-motion.css`.
2
+ //
3
+ // The framework-agnostic sibling of the Framer Motion / Motion One
4
+ // emitters. Anything that renders HTML can drop this file in: it ships
5
+ // • custom properties — `--duration-*` and `--ease-*` from the live page
6
+ // • `@keyframes` — fade-in / slide-up / scale-in / pop, plus any used
7
+ // on-page @keyframes reconstructed from their real steps
8
+ // • utility classes — `.mo-fade-in`, `.mo-slide-up`, … wired to the vars
9
+ // • a `prefers-reduced-motion: reduce` guard so the whole thing degrades
10
+ // to no-motion for users who ask for it (accessibility by default)
11
+
12
+ function bezier(raw) {
13
+ if (!raw) return null;
14
+ const m = String(raw).match(/cubic-bezier\(\s*([-\d.]+)\s*,\s*([-\d.]+)\s*,\s*([-\d.]+)\s*,\s*([-\d.]+)\s*\)/i);
15
+ if (m) return `cubic-bezier(${parseFloat(m[1])}, ${parseFloat(m[2])}, ${parseFloat(m[3])}, ${parseFloat(m[4])})`;
16
+ const named = {
17
+ linear: 'linear', ease: 'ease', 'ease-in': 'ease-in',
18
+ 'ease-out': 'ease-out', 'ease-in-out': 'ease-in-out',
19
+ };
20
+ return named[String(raw).trim()] || null;
21
+ }
22
+
23
+ // CSS identifiers: lowercase, hyphen-separated, never leading with a digit.
24
+ function kebab(s) {
25
+ return String(s || '')
26
+ .replace(/([a-z0-9])([A-Z])/g, '$1-$2')
27
+ .replace(/[^a-zA-Z0-9]+/g, '-')
28
+ .replace(/^-+|-+$/g, '')
29
+ .toLowerCase()
30
+ .replace(/^(\d)/, '_$1');
31
+ }
32
+
33
+ export function formatMotionCss(design) {
34
+ const host = (() => { try { return new URL(design?.meta?.url).hostname.replace(/^www\./, ''); } catch { return 'site'; } })();
35
+ const durations = Array.isArray(design?.motion?.durations) ? design.motion.durations : [];
36
+ const easings = Array.isArray(design?.motion?.easings) ? design.motion.easings : [];
37
+ const springs = Array.isArray(design?.motion?.springs) ? design.motion.springs : [];
38
+ const keyframes = Array.isArray(design?.motion?.keyframes) ? design.motion.keyframes : [];
39
+
40
+ // Default duration (the most common "medium" feel) and easing.
41
+ const defaultDur = durations.length
42
+ ? (durations[Math.min(2, durations.length - 1)].css || '300ms')
43
+ : '300ms';
44
+ const sortedEase = [...easings].sort((a, b) => (b.count || 0) - (a.count || 0));
45
+
46
+ // Named easing variables, deduped by classified family.
47
+ const easeEntries = [];
48
+ sortedEase.forEach((e, i) => {
49
+ const css = bezier(e.raw);
50
+ if (!css) return;
51
+ const key = e.family && e.family !== 'custom' ? kebab(e.family) : `custom-${i + 1}`;
52
+ if (easeEntries.find((x) => x.key === key)) return;
53
+ easeEntries.push({ key, css, count: e.count });
54
+ });
55
+ const firstSpring = springs.map(s => bezier(s.raw)).find(Boolean);
56
+ if (firstSpring) easeEntries.push({ key: 'spring', css: firstSpring, count: 0 });
57
+ if (easeEntries.length === 0) easeEntries.push({ key: 'standard', css: 'cubic-bezier(0.25, 0.1, 0.25, 1)', count: 0 });
58
+ const defaultEaseVar = easeEntries[0].key;
59
+
60
+ const lines = [
61
+ '/* Motion CSS — generated by designlang (motionlang)',
62
+ ` * Source: ${design?.meta?.url || host}`,
63
+ ` * ${new Date().toISOString()}`,
64
+ ' *',
65
+ ' * <link rel="stylesheet" href="./' + host + '-motion.css">',
66
+ ' * <div class="mo-slide-up">…</div>',
67
+ ' */',
68
+ '',
69
+ ':root {',
70
+ ];
71
+
72
+ if (durations.length) {
73
+ for (const d of durations) lines.push(` --duration-${kebab(d.name || `ms-${d.ms}`)}: ${d.css};`);
74
+ } else {
75
+ lines.push(' --duration-fast: 150ms;', ' --duration-base: 300ms;', ' --duration-slow: 500ms;');
76
+ }
77
+ for (const { key, css, count } of easeEntries) {
78
+ lines.push(` --ease-${key}: ${css};${count ? ` /* ${count}× on page */` : ''}`);
79
+ }
80
+ lines.push('}', '');
81
+
82
+ // Built-in keyframes — the same vocabulary the JS emitters expose.
83
+ lines.push('@keyframes fade-in { from { opacity: 0; } to { opacity: 1; } }');
84
+ lines.push('@keyframes slide-up { from { opacity: 0; transform: translateY(16px); } to { opacity: 1; transform: none; } }');
85
+ lines.push('@keyframes scale-in { from { opacity: 0; transform: scale(0.96); } to { opacity: 1; transform: none; } }');
86
+ lines.push('@keyframes pop { 0% { transform: scale(0.9); } 60% { transform: scale(1.03); } 100% { transform: scale(1); } }');
87
+ lines.push('');
88
+
89
+ // Reconstructed @keyframes that the page actually attached to elements.
90
+ const usedKeyframes = keyframes.filter(k => k.used && k.steps && k.steps.length);
91
+ for (const kf of usedKeyframes.slice(0, 12)) {
92
+ const name = kebab(kf.name);
93
+ if (['fade-in', 'slide-up', 'scale-in', 'pop'].includes(name)) continue;
94
+ const steps = kf.steps
95
+ .map(s => {
96
+ const offset = s.offset === 'from' ? '0%' : s.offset === 'to' ? '100%' : s.offset;
97
+ const body = String(s.style || '').split(';').map(x => x.trim()).filter(Boolean).join('; ');
98
+ return offset && body ? ` ${offset} { ${body}; }` : '';
99
+ })
100
+ .filter(Boolean);
101
+ if (!steps.length) continue;
102
+ lines.push(`@keyframes ${name} {`);
103
+ lines.push(steps.join('\n'));
104
+ lines.push('}', '');
105
+ }
106
+
107
+ // Utility classes wired to the extracted timing.
108
+ lines.push('.mo-animate,');
109
+ lines.push('[class^="mo-"], [class*=" mo-"] {');
110
+ lines.push(` animation-duration: var(--duration-${kebab(durations[Math.min(2, durations.length - 1)]?.name || 'base')}, ${defaultDur});`);
111
+ lines.push(` animation-timing-function: var(--ease-${defaultEaseVar});`);
112
+ lines.push(' animation-fill-mode: both;');
113
+ lines.push('}', '');
114
+
115
+ const utility = (cls, kf, ease) => {
116
+ lines.push(`.mo-${cls} { animation-name: ${kf};${ease ? ` animation-timing-function: var(--ease-${ease});` : ''} }`);
117
+ };
118
+ utility('fade-in', 'fade-in');
119
+ utility('slide-up', 'slide-up');
120
+ utility('scale-in', 'scale-in');
121
+ utility('pop', 'pop', firstSpring ? 'spring' : defaultEaseVar);
122
+ lines.push('');
123
+
124
+ // Accessibility: honour reduced-motion globally.
125
+ lines.push('@media (prefers-reduced-motion: reduce) {');
126
+ lines.push(' *, ::before, ::after {');
127
+ lines.push(' animation-duration: 0.01ms !important;');
128
+ lines.push(' animation-iteration-count: 1 !important;');
129
+ lines.push(' transition-duration: 0.01ms !important;');
130
+ lines.push(' scroll-behavior: auto !important;');
131
+ lines.push(' }');
132
+ lines.push('}');
133
+ lines.push('');
134
+
135
+ return lines.join('\n');
136
+ }
@@ -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
+ }