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.
- package/CHANGELOG.md +62 -0
- package/CODE_OF_CONDUCT.md +132 -0
- package/README.md +26 -6
- package/assets/atlas-cloud-logo.svg +55 -0
- package/bin/design-extract.js +11 -1
- package/commands/extract.md +9 -1
- package/docs/INTERVIEW_PREP.md +98 -0
- package/package.json +4 -4
- package/src/api.js +9 -0
- package/src/classifiers/smart.js +54 -22
- package/src/formatters/framer-motion.js +90 -2
- package/src/formatters/motion-css.js +136 -0
- package/src/formatters/motion-gsap.js +131 -0
- package/src/formatters/motion-one.js +191 -0
- package/src/formatters/motion-tailwind.js +120 -0
- package/src/formatters/motion-waapi.js +140 -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
|
}
|
|
@@ -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: {
|
|
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
|
-
|
|
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
|
+
}
|