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 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.**
@@ -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.15.0",
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: { 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,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
+ }