designlang 12.15.0 → 12.16.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 +36 -0
- package/bin/design-extract.js +6 -0
- package/package.json +1 -1
- package/src/api.js +9 -0
- package/src/formatters/framer-motion.js +90 -2
- package/src/formatters/motion-css.js +136 -0
- package/src/formatters/motion-one.js +191 -0
- package/src/formatters/motion-tailwind.js +120 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,41 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [12.16.0] — 2026-06-06
|
|
4
|
+
|
|
5
|
+
**motionlang grows two framework-agnostic emitters + the website PDF download is fixed.**
|
|
6
|
+
|
|
7
|
+
The motion language already shipped to Framer Motion and Motion One. This
|
|
8
|
+
release covers the two surfaces that needed no framework at all — plain CSS
|
|
9
|
+
and Tailwind — and fixes the brand-book PDF download on the website.
|
|
10
|
+
|
|
11
|
+
- **Motion CSS (`<host>-motion.css`)** — drop-in stylesheet: `--duration-*`
|
|
12
|
+
and `--ease-*` custom properties from the live page, reusable `@keyframes`
|
|
13
|
+
(`fade-in` / `slide-up` / `scale-in` / `pop`, plus any on-page `@keyframes`
|
|
14
|
+
reconstructed from their real steps), `.mo-*` utility classes wired to the
|
|
15
|
+
vars, and a `prefers-reduced-motion: reduce` guard so motion degrades to
|
|
16
|
+
none for users who ask for it. No build step, no framework.
|
|
17
|
+
|
|
18
|
+
- **Tailwind motion preset (`<host>-motion.tailwind.js`)** — a require-able
|
|
19
|
+
`theme.extend` block mapping the extracted timing onto Tailwind's own
|
|
20
|
+
scales: `transitionDuration`, `transitionTimingFunction` (incl. a `spring`
|
|
21
|
+
curve when an overshoot bezier is detected), `keyframes` and `animation`
|
|
22
|
+
utilities (`animate-slide-up`, …). Merge into `tailwind.config` and use
|
|
23
|
+
class names instead of magic numbers.
|
|
24
|
+
|
|
25
|
+
Both are exposed through `designlang/api` as the `motion-css` and
|
|
26
|
+
`motion-tailwind` renderer ids, alongside the existing `framer-motion` and
|
|
27
|
+
`motion-one` emitters.
|
|
28
|
+
|
|
29
|
+
- **Website: brand-book PDF download fixed.** Downloads sometimes produced a
|
|
30
|
+
file that wouldn't open. Root cause: the streaming extractor wrote its
|
|
31
|
+
Blob cache fire-and-forget *after* the response closed, so on serverless
|
|
32
|
+
the write was killed before completing — leaving `/api/pdf/<hash>` with no
|
|
33
|
+
cached design and returning a 404 JSON body that the browser dutifully
|
|
34
|
+
saved as `host-brand.pdf`. The cache write is now awaited before the
|
|
35
|
+
stream closes, and the download button fetches + validates the response is
|
|
36
|
+
actually a PDF before saving (and surfaces an error instead of writing a
|
|
37
|
+
broken file).
|
|
38
|
+
|
|
3
39
|
## [12.15.0] — 2026-05-21
|
|
4
40
|
|
|
5
41
|
**motionlang — motion becomes a first-class extractable + shippable artefact.**
|
package/bin/design-extract.js
CHANGED
|
@@ -29,6 +29,9 @@ import { formatGradientsCss, formatGradientsJson } from '../src/formatters/gradi
|
|
|
29
29
|
import { formatAgentPrompt } from '../src/formatters/agent-prompt.js';
|
|
30
30
|
import { formatMotionLab } from '../src/formatters/motion-lab.js';
|
|
31
31
|
import { formatFramerMotion } from '../src/formatters/framer-motion.js';
|
|
32
|
+
import { formatMotionOne } from '../src/formatters/motion-one.js';
|
|
33
|
+
import { formatMotionCss } from '../src/formatters/motion-css.js';
|
|
34
|
+
import { formatMotionTailwind } from '../src/formatters/motion-tailwind.js';
|
|
32
35
|
import { formatCssVars } from '../src/formatters/css-vars.js';
|
|
33
36
|
import { formatPreview } from '../src/formatters/preview.js';
|
|
34
37
|
import { formatFigma } from '../src/formatters/figma.js';
|
|
@@ -355,6 +358,9 @@ program
|
|
|
355
358
|
{ name: `${prefix}-AGENT.md`, content: formatAgentPrompt(design), label: 'Agent system prompt (paste into Claude/GPT/Cursor)' },
|
|
356
359
|
{ name: `${prefix}-motion.html`, content: formatMotionLab(design), label: 'Motion lab (interactive easing/duration/keyframe page)' },
|
|
357
360
|
{ name: `${prefix}-motion.framer.js`, content: formatFramerMotion(design), label: 'Framer Motion presets (transitions + variants)' },
|
|
361
|
+
{ name: `${prefix}-motion.one.js`, content: formatMotionOne(design), label: 'Motion One presets (animate() + springs + scroll)' },
|
|
362
|
+
{ name: `${prefix}-motion.css`, content: formatMotionCss(design), label: 'Motion CSS (vars + keyframes + utilities + reduced-motion)' },
|
|
363
|
+
{ name: `${prefix}-motion.tailwind.js`, content: formatMotionTailwind(design), label: 'Tailwind motion preset (duration/easing/keyframes/animation)' },
|
|
358
364
|
{ name: `${prefix}-variables.css`, content: formatCssVars(design), label: 'CSS Variables' },
|
|
359
365
|
{ name: `${prefix}-preview.html`, content: formatPreview(design), label: 'Visual Preview' },
|
|
360
366
|
{ name: `${prefix}-figma-variables.json`, content: formatFigma(design), label: 'Figma Variables' },
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "designlang",
|
|
3
|
-
"version": "12.
|
|
3
|
+
"version": "12.16.0",
|
|
4
4
|
"description": "Extract the complete design language from any website and ship it — clone to a working Next.js starter, guard tokens with a CI drift bot, or browse everything in a local studio. Outputs W3C DTCG tokens, motion tokens, typed anatomy stubs, Tailwind config, and ready-to-paste v0 / Lovable / Cursor / Claude-Artifacts prompts.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/api.js
CHANGED
|
@@ -39,6 +39,9 @@ import { formatAgentRules } from './formatters/agent-rules.js';
|
|
|
39
39
|
import { formatAgentPrompt } from './formatters/agent-prompt.js';
|
|
40
40
|
import { formatMotionLab } from './formatters/motion-lab.js';
|
|
41
41
|
import { formatFramerMotion } from './formatters/framer-motion.js';
|
|
42
|
+
import { formatMotionOne } from './formatters/motion-one.js';
|
|
43
|
+
import { formatMotionCss } from './formatters/motion-css.js';
|
|
44
|
+
import { formatMotionTailwind } from './formatters/motion-tailwind.js';
|
|
42
45
|
|
|
43
46
|
// Utils
|
|
44
47
|
import { compressPalette } from './utils/palette-compress.js';
|
|
@@ -92,6 +95,9 @@ export const RENDERERS = Object.freeze({
|
|
|
92
95
|
'agent-prompt': (d) => formatAgentPrompt(d),
|
|
93
96
|
'motion-lab': (d) => formatMotionLab(d),
|
|
94
97
|
'framer-motion': (d) => formatFramerMotion(d),
|
|
98
|
+
'motion-one': (d) => formatMotionOne(d),
|
|
99
|
+
'motion-css': (d) => formatMotionCss(d),
|
|
100
|
+
'motion-tailwind': (d) => formatMotionTailwind(d),
|
|
95
101
|
|
|
96
102
|
// frameworks
|
|
97
103
|
'react-theme': (d) => formatReactTheme(d),
|
|
@@ -156,6 +162,9 @@ export function renderAll(design, opts = {}) {
|
|
|
156
162
|
'agent-prompt': 'AGENT.md',
|
|
157
163
|
'motion-lab': 'motion.html',
|
|
158
164
|
'framer-motion': 'motion.framer.js',
|
|
165
|
+
'motion-one': 'motion.one.js',
|
|
166
|
+
'motion-css': 'motion.css',
|
|
167
|
+
'motion-tailwind': 'motion.tailwind.js',
|
|
159
168
|
'react-theme': 'theme.js',
|
|
160
169
|
'shadcn-theme': 'shadcn-theme.css',
|
|
161
170
|
'vue-theme': 'theme.vue.js',
|
|
@@ -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,191 @@
|
|
|
1
|
+
// Motion One emitter — `<host>-motion.one.js`.
|
|
2
|
+
//
|
|
3
|
+
// Sibling of the Framer Motion emitter, targeting the `motion` package
|
|
4
|
+
// (a.k.a. Motion One / Motion for the Web) — a ~3kb library that wraps
|
|
5
|
+
// the Web Animations API and ships first-class spring + scroll helpers.
|
|
6
|
+
//
|
|
7
|
+
// We emit:
|
|
8
|
+
// • `easings` — cubic-bezier arrays keyed by classified family
|
|
9
|
+
// • `durations` — extracted timings in seconds
|
|
10
|
+
// • `springs` — Motion One spring options (stiffness/damping/mass)
|
|
11
|
+
// derived from detected overshoot beziers; falls back
|
|
12
|
+
// to a sensible default when the site has no springs.
|
|
13
|
+
// • `keyframes` — animation maps reconstructed from on-page @keyframes
|
|
14
|
+
// • `animations`— ready-to-call wrappers around `animate()` so users
|
|
15
|
+
// can do `animations.fadeIn('#hero')` without thinking
|
|
16
|
+
// about timing.
|
|
17
|
+
// • `inView` / `scroll` helpers when the site actually uses
|
|
18
|
+
// scroll-/view-timeline.
|
|
19
|
+
|
|
20
|
+
function bezier(raw) {
|
|
21
|
+
if (!raw) return null;
|
|
22
|
+
const m = String(raw).match(/cubic-bezier\(\s*([-\d.]+)\s*,\s*([-\d.]+)\s*,\s*([-\d.]+)\s*,\s*([-\d.]+)\s*\)/i);
|
|
23
|
+
if (m) return [parseFloat(m[1]), parseFloat(m[2]), parseFloat(m[3]), parseFloat(m[4])];
|
|
24
|
+
const named = {
|
|
25
|
+
linear: [0, 0, 1, 1], ease: [0.25, 0.1, 0.25, 1],
|
|
26
|
+
'ease-in': [0.42, 0, 1, 1], 'ease-out': [0, 0, 0.58, 1], 'ease-in-out': [0.42, 0, 0.58, 1],
|
|
27
|
+
};
|
|
28
|
+
return named[String(raw).trim()] || null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function camel(s) {
|
|
32
|
+
return String(s || '').replace(/[^a-zA-Z0-9]+(.)/g, (_, c) => c.toUpperCase()).replace(/^[0-9]/, '_$&');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Rough inverse of a critically-damped second-order response:
|
|
36
|
+
// shorter durations → stiffer, deeper overshoot → less damping.
|
|
37
|
+
// Not physically exact — just a useful starting point that matches feel.
|
|
38
|
+
function springFromBezier(pts, ms) {
|
|
39
|
+
const [, y1, , y2] = pts;
|
|
40
|
+
const overshoot = Math.max(0, Math.max(y1 - 1, y2 - 1, -y1, -y2));
|
|
41
|
+
const seconds = Math.max(0.1, (ms || 400) / 1000);
|
|
42
|
+
const stiffness = Math.round(Math.min(700, Math.max(80, 600 / seconds)));
|
|
43
|
+
const damping = Math.round(Math.max(8, 30 - overshoot * 40));
|
|
44
|
+
return { stiffness, damping, mass: 1 };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function formatMotionOne(design) {
|
|
48
|
+
const host = (() => { try { return new URL(design?.meta?.url).hostname.replace(/^www\./, ''); } catch { return 'site'; } })();
|
|
49
|
+
const durations = Array.isArray(design?.motion?.durations) ? design.motion.durations : [];
|
|
50
|
+
const easings = Array.isArray(design?.motion?.easings) ? design.motion.easings : [];
|
|
51
|
+
const springs = Array.isArray(design?.motion?.springs) ? design.motion.springs : [];
|
|
52
|
+
const keyframes = Array.isArray(design?.motion?.keyframes) ? design.motion.keyframes : [];
|
|
53
|
+
const scroll = design?.motion?.scrollLinked || { present: false, signals: [] };
|
|
54
|
+
|
|
55
|
+
const durSec = (d) => ((d.ms || parseInt(d.css || d.value || d, 10) || 300) / 1000);
|
|
56
|
+
const defaultDur = durations.length
|
|
57
|
+
? durSec(durations[Math.min(2, durations.length - 1)])
|
|
58
|
+
: 0.3;
|
|
59
|
+
|
|
60
|
+
// Easings: keep families by frequency, dedupe by family key, fall back to a
|
|
61
|
+
// good Motion One default if the page didn't expose anything parseable.
|
|
62
|
+
const sortedEase = [...easings].sort((a, b) => (b.count || 0) - (a.count || 0));
|
|
63
|
+
const easeEntries = [];
|
|
64
|
+
sortedEase.forEach((e, i) => {
|
|
65
|
+
const pts = bezier(e.raw);
|
|
66
|
+
if (!pts) return;
|
|
67
|
+
const key = e.family && e.family !== 'custom' ? camel(e.family) : `custom${i + 1}`;
|
|
68
|
+
if (easeEntries.find((x) => x.key === key)) return;
|
|
69
|
+
easeEntries.push({ key, pts, count: e.count });
|
|
70
|
+
});
|
|
71
|
+
if (easeEntries.length === 0) {
|
|
72
|
+
easeEntries.push({ key: 'standard', pts: [0.25, 0.1, 0.25, 1], count: 0 });
|
|
73
|
+
}
|
|
74
|
+
const primaryEaseKey = easeEntries[0].key;
|
|
75
|
+
|
|
76
|
+
// Springs: derive Motion One options from any detected overshoot beziers.
|
|
77
|
+
const springEntries = [];
|
|
78
|
+
springs.forEach((s, i) => {
|
|
79
|
+
const pts = bezier(s.raw);
|
|
80
|
+
if (!pts) return;
|
|
81
|
+
springEntries.push({ key: i === 0 ? 'soft' : `spring${i + 1}`, opts: springFromBezier(pts, defaultDur * 1000) });
|
|
82
|
+
});
|
|
83
|
+
if (springEntries.length === 0) {
|
|
84
|
+
springEntries.push({ key: 'soft', opts: { stiffness: 320, damping: 30, mass: 1 } });
|
|
85
|
+
}
|
|
86
|
+
const primarySpringKey = springEntries[0].key;
|
|
87
|
+
|
|
88
|
+
const lines = [
|
|
89
|
+
'// Motion One presets — generated by designlang (motionlang)',
|
|
90
|
+
`// Source: ${design?.meta?.url || host}`,
|
|
91
|
+
`// ${new Date().toISOString()}`,
|
|
92
|
+
'//',
|
|
93
|
+
'// import { animate } from \'motion\';',
|
|
94
|
+
'// import { animations, easings, durations } from \'./' + host + '-motion.one.js\';',
|
|
95
|
+
'//',
|
|
96
|
+
'// animations.fadeIn(\'#hero\');',
|
|
97
|
+
'// animations.slideUp(\'.card\', { delay: 0.1 });',
|
|
98
|
+
'',
|
|
99
|
+
'/** Easing curves extracted from the live page, as Motion One cubic-bezier arrays. */',
|
|
100
|
+
'export const easings = {',
|
|
101
|
+
];
|
|
102
|
+
for (const { key, pts, count } of easeEntries) {
|
|
103
|
+
lines.push(` ${key}: [${pts.join(', ')}],${count ? ` // ${count}× on page` : ''}`);
|
|
104
|
+
}
|
|
105
|
+
lines.push('};', '');
|
|
106
|
+
|
|
107
|
+
lines.push('/** Duration presets (seconds), extracted from the live page. */');
|
|
108
|
+
lines.push('export const durations = {');
|
|
109
|
+
if (durations.length) {
|
|
110
|
+
for (const d of durations) {
|
|
111
|
+
const name = camel(d.name || `ms${d.ms}`);
|
|
112
|
+
lines.push(` ${name}: ${durSec(d)},`);
|
|
113
|
+
}
|
|
114
|
+
} else {
|
|
115
|
+
lines.push(' fast: 0.15,', ' base: 0.3,', ' slow: 0.5,');
|
|
116
|
+
}
|
|
117
|
+
lines.push('};', '');
|
|
118
|
+
|
|
119
|
+
lines.push('/** Motion One spring options — pass as the `type: \'spring\'` block of an animate() call. */');
|
|
120
|
+
lines.push('export const springs = {');
|
|
121
|
+
for (const { key, opts } of springEntries) {
|
|
122
|
+
lines.push(` ${key}: { type: 'spring', stiffness: ${opts.stiffness}, damping: ${opts.damping}, mass: ${opts.mass} },`);
|
|
123
|
+
}
|
|
124
|
+
lines.push('};', '');
|
|
125
|
+
|
|
126
|
+
// Reconstructed keyframes — Motion One accepts arrays per-property, so
|
|
127
|
+
// we flatten step values per property and emit a usable animation map.
|
|
128
|
+
const usedKeyframes = keyframes.filter(k => k.used && k.steps && k.steps.length);
|
|
129
|
+
if (usedKeyframes.length) {
|
|
130
|
+
lines.push('/** @keyframes blocks reconstructed as Motion One keyframe maps. */');
|
|
131
|
+
lines.push('export const keyframes = {');
|
|
132
|
+
for (const kf of usedKeyframes.slice(0, 16)) {
|
|
133
|
+
const propMap = {};
|
|
134
|
+
for (const step of kf.steps) {
|
|
135
|
+
for (const part of (step.style || '').split(';')) {
|
|
136
|
+
const [p, v] = part.split(':').map(s => (s || '').trim());
|
|
137
|
+
if (!p || !v) continue;
|
|
138
|
+
(propMap[p] ||= []).push(v);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
const entries = Object.entries(propMap)
|
|
142
|
+
.filter(([, vals]) => vals.length >= 2)
|
|
143
|
+
.map(([prop, vals]) => ` ${JSON.stringify(camel(prop))}: ${JSON.stringify(vals)}`);
|
|
144
|
+
if (!entries.length) continue;
|
|
145
|
+
lines.push(` ${camel(kf.name)}: {`);
|
|
146
|
+
lines.push(entries.join(',\n'));
|
|
147
|
+
lines.push(' },');
|
|
148
|
+
}
|
|
149
|
+
lines.push('};', '');
|
|
150
|
+
} else {
|
|
151
|
+
lines.push('/** No on-page @keyframes were attached to elements; emit empty for shape parity. */');
|
|
152
|
+
lines.push('export const keyframes = {};', '');
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
lines.push('/** Animate-on-call helpers. Each takes a Motion One target + optional override. */');
|
|
156
|
+
lines.push('import { animate } from \'motion\';');
|
|
157
|
+
lines.push('');
|
|
158
|
+
lines.push('const _t = { duration: ' + defaultDur + ', easing: easings.' + primaryEaseKey + ' };');
|
|
159
|
+
lines.push('');
|
|
160
|
+
lines.push('export const animations = {');
|
|
161
|
+
lines.push(' fadeIn: (target, opts = {}) =>');
|
|
162
|
+
lines.push(' animate(target, { opacity: [0, 1] }, { ..._t, ...opts }),');
|
|
163
|
+
lines.push(' fadeOut: (target, opts = {}) =>');
|
|
164
|
+
lines.push(' animate(target, { opacity: [1, 0] }, { ..._t, ...opts }),');
|
|
165
|
+
lines.push(' slideUp: (target, opts = {}) =>');
|
|
166
|
+
lines.push(' animate(target, { opacity: [0, 1], y: [16, 0] }, { ..._t, ...opts }),');
|
|
167
|
+
lines.push(' slideDown: (target, opts = {}) =>');
|
|
168
|
+
lines.push(' animate(target, { opacity: [0, 1], y: [-16, 0] }, { ..._t, ...opts }),');
|
|
169
|
+
lines.push(' scaleIn: (target, opts = {}) =>');
|
|
170
|
+
lines.push(' animate(target, { opacity: [0, 1], scale: [0.96, 1] }, { ..._t, ...opts }),');
|
|
171
|
+
lines.push(' pop: (target, opts = {}) =>');
|
|
172
|
+
lines.push(' animate(target, { scale: [0.9, 1] }, { ...springs.' + primarySpringKey + ', ...opts }),');
|
|
173
|
+
lines.push('};', '');
|
|
174
|
+
|
|
175
|
+
if (scroll.present) {
|
|
176
|
+
lines.push('/** Site uses scroll- or view-timeline. Helpers wired to Motion One\'s `inView` / `scroll`. */');
|
|
177
|
+
lines.push('import { inView, scroll } from \'motion\';');
|
|
178
|
+
lines.push('');
|
|
179
|
+
lines.push('export const enterOnView = (target, variant = animations.slideUp) =>');
|
|
180
|
+
lines.push(' inView(target, (el) => { variant(el.target); });');
|
|
181
|
+
lines.push('');
|
|
182
|
+
lines.push('export const parallaxY = (target, distance = 80) =>');
|
|
183
|
+
lines.push(' scroll(animate(target, { y: [0, -distance] }, { easing: \'linear\' }));');
|
|
184
|
+
lines.push('');
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
lines.push('export default { easings, durations, springs, keyframes, animations };');
|
|
188
|
+
lines.push('');
|
|
189
|
+
|
|
190
|
+
return lines.join('\n');
|
|
191
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// Tailwind motion emitter — `<host>-motion.tailwind.js`.
|
|
2
|
+
//
|
|
3
|
+
// Emits a `theme.extend` block to merge into tailwind.config.{js,ts}.
|
|
4
|
+
// Maps the extracted motion language onto Tailwind's own scales:
|
|
5
|
+
// • transitionDuration → `duration-xs`, `duration-md`, …
|
|
6
|
+
// • transitionTimingFunction → `ease-out`, `ease-spring`, …
|
|
7
|
+
// • keyframes + animation → `animate-fade-in`, `animate-slide-up`,
|
|
8
|
+
// `animate-pop`, plus any used on-page @keyframes reconstructed.
|
|
9
|
+
//
|
|
10
|
+
// Tailwind is the dominant utility-CSS framework, so this lets a team
|
|
11
|
+
// adopt a site's real timing with class names instead of magic numbers.
|
|
12
|
+
|
|
13
|
+
function bezier(raw) {
|
|
14
|
+
if (!raw) return null;
|
|
15
|
+
const m = String(raw).match(/cubic-bezier\(\s*([-\d.]+)\s*,\s*([-\d.]+)\s*,\s*([-\d.]+)\s*,\s*([-\d.]+)\s*\)/i);
|
|
16
|
+
if (m) return `cubic-bezier(${parseFloat(m[1])}, ${parseFloat(m[2])}, ${parseFloat(m[3])}, ${parseFloat(m[4])})`;
|
|
17
|
+
const named = {
|
|
18
|
+
linear: 'linear', ease: 'ease', 'ease-in': 'ease-in',
|
|
19
|
+
'ease-out': 'ease-out', 'ease-in-out': 'ease-in-out',
|
|
20
|
+
};
|
|
21
|
+
return named[String(raw).trim()] || null;
|
|
22
|
+
}
|
|
23
|
+
|
|
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
|
+
// Turn @keyframes steps into a Tailwind keyframes object: { '0%': { ... } }.
|
|
34
|
+
function stepsToObject(steps) {
|
|
35
|
+
const obj = {};
|
|
36
|
+
for (const s of steps || []) {
|
|
37
|
+
const offset = s.offset === 'from' ? '0%' : s.offset === 'to' ? '100%' : s.offset;
|
|
38
|
+
if (!offset) continue;
|
|
39
|
+
const decl = {};
|
|
40
|
+
for (const part of String(s.style || '').split(';')) {
|
|
41
|
+
const [p, v] = part.split(':').map(x => (x || '').trim());
|
|
42
|
+
if (p && v) decl[p.replace(/-([a-z])/g, (_, c) => c.toUpperCase())] = v;
|
|
43
|
+
}
|
|
44
|
+
if (Object.keys(decl).length) obj[offset] = decl;
|
|
45
|
+
}
|
|
46
|
+
return obj;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function formatMotionTailwind(design) {
|
|
50
|
+
const host = (() => { try { return new URL(design?.meta?.url).hostname.replace(/^www\./, ''); } catch { return 'site'; } })();
|
|
51
|
+
const durations = Array.isArray(design?.motion?.durations) ? design.motion.durations : [];
|
|
52
|
+
const easings = Array.isArray(design?.motion?.easings) ? design.motion.easings : [];
|
|
53
|
+
const springs = Array.isArray(design?.motion?.springs) ? design.motion.springs : [];
|
|
54
|
+
const keyframes = Array.isArray(design?.motion?.keyframes) ? design.motion.keyframes : [];
|
|
55
|
+
|
|
56
|
+
const defaultDur = durations.length
|
|
57
|
+
? (durations[Math.min(2, durations.length - 1)].css || '300ms')
|
|
58
|
+
: '300ms';
|
|
59
|
+
|
|
60
|
+
const transitionDuration = {};
|
|
61
|
+
if (durations.length) {
|
|
62
|
+
for (const d of durations) transitionDuration[kebab(d.name || `ms-${d.ms}`)] = d.css;
|
|
63
|
+
} else {
|
|
64
|
+
Object.assign(transitionDuration, { fast: '150ms', base: '300ms', slow: '500ms' });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const transitionTimingFunction = {};
|
|
68
|
+
[...easings].sort((a, b) => (b.count || 0) - (a.count || 0)).forEach((e, i) => {
|
|
69
|
+
const css = bezier(e.raw);
|
|
70
|
+
if (!css) return;
|
|
71
|
+
const key = e.family && e.family !== 'custom' ? kebab(e.family) : `custom-${i + 1}`;
|
|
72
|
+
if (!transitionTimingFunction[key]) transitionTimingFunction[key] = css;
|
|
73
|
+
});
|
|
74
|
+
const firstSpring = springs.map(s => bezier(s.raw)).find(Boolean);
|
|
75
|
+
if (firstSpring) transitionTimingFunction.spring = firstSpring;
|
|
76
|
+
if (Object.keys(transitionTimingFunction).length === 0) {
|
|
77
|
+
transitionTimingFunction.standard = 'cubic-bezier(0.25, 0.1, 0.25, 1)';
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Built-in vocabulary, shared with the CSS / JS emitters.
|
|
81
|
+
const kf = {
|
|
82
|
+
'fade-in': { '0%': { opacity: '0' }, '100%': { opacity: '1' } },
|
|
83
|
+
'slide-up': { '0%': { opacity: '0', transform: 'translateY(16px)' }, '100%': { opacity: '1', transform: 'none' } },
|
|
84
|
+
'scale-in': { '0%': { opacity: '0', transform: 'scale(0.96)' }, '100%': { opacity: '1', transform: 'none' } },
|
|
85
|
+
pop: { '0%': { transform: 'scale(0.9)' }, '60%': { transform: 'scale(1.03)' }, '100%': { transform: 'scale(1)' } },
|
|
86
|
+
};
|
|
87
|
+
for (const k of keyframes.filter(k => k.used && k.steps && k.steps.length).slice(0, 12)) {
|
|
88
|
+
const name = kebab(k.name);
|
|
89
|
+
if (kf[name]) continue;
|
|
90
|
+
const obj = stepsToObject(k.steps);
|
|
91
|
+
if (Object.keys(obj).length) kf[name] = obj;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const easeRef = transitionTimingFunction['ease-out'] ? 'ease-out'
|
|
95
|
+
: Object.keys(transitionTimingFunction)[0];
|
|
96
|
+
const animation = {};
|
|
97
|
+
for (const name of Object.keys(kf)) {
|
|
98
|
+
const timing = name === 'pop' && firstSpring ? 'spring' : easeRef;
|
|
99
|
+
animation[name] = `${name} ${defaultDur} var(--tw-ease-${timing}, ${transitionTimingFunction[timing]}) both`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const extend = { transitionDuration, transitionTimingFunction, keyframes: kf, animation };
|
|
103
|
+
|
|
104
|
+
return [
|
|
105
|
+
'// Tailwind motion preset — generated by designlang (motionlang)',
|
|
106
|
+
`// Source: ${design?.meta?.url || host}`,
|
|
107
|
+
`// ${new Date().toISOString()}`,
|
|
108
|
+
'//',
|
|
109
|
+
'// Merge `extend` into your tailwind.config theme.extend, e.g.:',
|
|
110
|
+
`// const motion = require('./${host}-motion.tailwind');`,
|
|
111
|
+
'// module.exports = { theme: { extend: { ...motion.extend } } };',
|
|
112
|
+
'//',
|
|
113
|
+
'// Then: <div class="animate-slide-up" /> or `duration-md ease-spring`.',
|
|
114
|
+
'',
|
|
115
|
+
`const extend = ${JSON.stringify(extend, null, 2)};`,
|
|
116
|
+
'',
|
|
117
|
+
'module.exports = { extend };',
|
|
118
|
+
'',
|
|
119
|
+
].join('\n');
|
|
120
|
+
}
|