designlang 12.14.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.
@@ -0,0 +1,4 @@
1
+ {"id":"91120f2f-f7dd-4f82-babc-872679767041","ts":"2026-05-19T11:38:28.727Z","primitive":"grep","args":{"pattern":"extractDesignTokens|extractTokens","maxHits":10},"resultHash":"6568b2f5d95056ac","resultPreview":"{\"filesScanned\":630,\"hits\":[{\"col\":17,\"context\":{\"after\":[\" const styles = Array.isArray(computedStyles) ? computedStyles : [];\",\" const out = [];\"],\"before\":[\"}\",\"\"]},\"file\":\"src/extractors/token-sources.js\",\"line\":15,\"text\":\"export func…(+1857B)","durationMs":122,"cwd":"/Users/manavaryasingh/claude-plugin/design-extract","sessionId":"e6c0f3b7-28e9-43de-9ddb-663ccc114b54"}
2
+ {"id":"29c684ad-10b0-4237-864d-88c173ed1e19","ts":"2026-05-19T11:38:28.988Z","primitive":"grep","args":{"pattern":"function extract","glob":"src/**/*.js","maxHits":12},"resultHash":"0687bb87846d66c3","resultPreview":"{\"filesScanned\":16,\"hits\":[{\"col\":8,\"context\":{\"after\":[\" const pairs = new Map(); // \\\"fg|bg\\\" -> { fg, bg, count, elements }\",\"\"],\"before\":[\"}\",\"\"]},\"file\":\"src/extractors/accessibility.js\",\"line\":68,\"text\":\"export function extractAccess…(+2720B)","durationMs":3,"cwd":"/Users/manavaryasingh/claude-plugin/design-extract","sessionId":"be26c820-dafd-49d3-a8e5-4be67c6894bb"}
3
+ {"id":"416eb4b6-abbb-43c4-9aaa-5add1632fdb8","ts":"2026-05-19T11:38:29.283Z","primitive":"ast","args":{"file":"src/formatters/brand-book.js","query":{"kind":"functions"}},"resultHash":"8510afa550a235cf","resultPreview":"{\"file\":\"src/formatters/brand-book.js\",\"query\":{\"kind\":\"functions\"},\"symbols\":[{\"endLine\":22,\"exported\":false,\"kind\":\"function\",\"line\":20,\"name\":\"esc\",\"signature\":\"function esc(s)\"},{\"endLine\":26,\"exported\":false,\"kind\":\"function\",\"line\":24…(+3575B)","durationMs":20,"cwd":"/Users/manavaryasingh/claude-plugin/design-extract","sessionId":"2e62b948-1427-4991-8806-8c6465fb04f4"}
4
+ {"id":"fdd1592a-fea3-420b-a787-02affefe5081","ts":"2026-05-19T11:38:29.555Z","primitive":"grep","args":{"pattern":"function","maxHits":50},"resultHash":"111a7bd4e85eff05","resultPreview":"{\"filesScanned\":71,\"hits\":[{\"col\":49,\"context\":{\"after\":[\"- **`src/extractors/responsive-screenshots.js`** — full-page PNGs at mobile / tablet / desktop / wide × (light, dark). Writes to `screenshots/responsive/<breakpoint>-<scheme>.png` wi…(+18380B)","durationMs":9,"cwd":"/Users/manavaryasingh/claude-plugin/design-extract","sessionId":"0c657ec8-c6c5-424f-bfe9-91e3ec5d5e82"}
package/CHANGELOG.md CHANGED
@@ -1,5 +1,74 @@
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
+
39
+ ## [12.15.0] — 2026-05-21
40
+
41
+ **motionlang — motion becomes a first-class extractable + shippable artefact.**
42
+
43
+ The motion extractor already captured durations, easings and keyframes,
44
+ but they only landed as a flat `motion-tokens.json` nobody opened. This
45
+ release turns extracted motion into something you can see, ship and
46
+ hand to a framework.
47
+
48
+ - **Motion Lab (`<host>-motion.html`)** — a self-contained, dependency-free
49
+ interactive page. Every extracted easing curve is drawn as a real
50
+ cubic-Bezier path with a dot riding it at that exact timing function;
51
+ every duration shown as a pulsing bar timed to its real `ms`; every
52
+ `@keyframes` block replayed. Open it in any browser.
53
+
54
+ - **Framer Motion presets (`<host>-motion.framer.js`)** — ready-to-import
55
+ `easings` (cubic-bezier arrays ranked by on-page frequency), `durations`
56
+ (seconds), `transitions` (`base` / `fast` / `slow` / `spring`) and
57
+ `variants` (`fade` / `slideUp` / `scaleIn` / `stagger`) — all wired to
58
+ the extracted timing. Framer Motion is the dominant React animation
59
+ library, so this is the highest-leverage motion emitter.
60
+
61
+ - **Website Motion Lab** — every `/gallery/[slug]` brand page now renders
62
+ an interactive Motion Lab section: easing curves drawn in the extracted
63
+ brand colour, dots riding tracks, play/pause, and a link to the full
64
+ standalone page.
65
+
66
+ - **Mobile navbar fix** — the hamburger and Install CTA no longer squish
67
+ together below 640px.
68
+
69
+ Both emitters are exposed through the public `designlang/api` as the
70
+ `motion-lab` and `framer-motion` renderer ids (plus `agent-prompt`).
71
+
3
72
  ## [12.14.0] — 2026-05-17
4
73
 
5
74
  **Real downloadable PDFs everywhere + a one-shot agent prompt every AI can paste.**
@@ -27,6 +27,11 @@ import { formatTsDefs } from '../src/formatters/ts-defs.js';
27
27
  import { formatCssReset } from '../src/formatters/css-reset.js';
28
28
  import { formatGradientsCss, formatGradientsJson } from '../src/formatters/gradients.js';
29
29
  import { formatAgentPrompt } from '../src/formatters/agent-prompt.js';
30
+ import { formatMotionLab } from '../src/formatters/motion-lab.js';
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';
30
35
  import { formatCssVars } from '../src/formatters/css-vars.js';
31
36
  import { formatPreview } from '../src/formatters/preview.js';
32
37
  import { formatFigma } from '../src/formatters/figma.js';
@@ -351,6 +356,11 @@ program
351
356
  { name: `${prefix}-gradients.css`, content: formatGradientsCss(design), label: 'Extracted gradients (utility classes)' },
352
357
  { name: `${prefix}-gradients.json`, content: formatGradientsJson(design), label: 'Extracted gradients (structured)' },
353
358
  { name: `${prefix}-AGENT.md`, content: formatAgentPrompt(design), label: 'Agent system prompt (paste into Claude/GPT/Cursor)' },
359
+ { name: `${prefix}-motion.html`, content: formatMotionLab(design), label: 'Motion lab (interactive easing/duration/keyframe page)' },
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)' },
354
364
  { name: `${prefix}-variables.css`, content: formatCssVars(design), label: 'CSS Variables' },
355
365
  { name: `${prefix}-preview.html`, content: formatPreview(design), label: 'Visual Preview' },
356
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.14.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
@@ -36,6 +36,12 @@ import { formatBrandBook } from './formatters/brand-book.js';
36
36
  import { formatGradientsCss,
37
37
  formatGradientsJson } from './formatters/gradients.js';
38
38
  import { formatAgentRules } from './formatters/agent-rules.js';
39
+ import { formatAgentPrompt } from './formatters/agent-prompt.js';
40
+ import { formatMotionLab } from './formatters/motion-lab.js';
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';
39
45
 
40
46
  // Utils
41
47
  import { compressPalette } from './utils/palette-compress.js';
@@ -86,6 +92,12 @@ export const RENDERERS = Object.freeze({
86
92
  'brand-book-html': (d) => formatBrandBook(d),
87
93
  'gradients-css': (d) => formatGradientsCss(d),
88
94
  'gradients-json': (d) => formatGradientsJson(d),
95
+ 'agent-prompt': (d) => formatAgentPrompt(d),
96
+ 'motion-lab': (d) => formatMotionLab(d),
97
+ 'framer-motion': (d) => formatFramerMotion(d),
98
+ 'motion-one': (d) => formatMotionOne(d),
99
+ 'motion-css': (d) => formatMotionCss(d),
100
+ 'motion-tailwind': (d) => formatMotionTailwind(d),
89
101
 
90
102
  // frameworks
91
103
  'react-theme': (d) => formatReactTheme(d),
@@ -147,6 +159,12 @@ export function renderAll(design, opts = {}) {
147
159
  'brand-book-html': 'brand.html',
148
160
  'gradients-css': 'gradients.css',
149
161
  'gradients-json': 'gradients.json',
162
+ 'agent-prompt': 'AGENT.md',
163
+ 'motion-lab': 'motion.html',
164
+ 'framer-motion': 'motion.framer.js',
165
+ 'motion-one': 'motion.one.js',
166
+ 'motion-css': 'motion.css',
167
+ 'motion-tailwind': 'motion.tailwind.js',
150
168
  'react-theme': 'theme.js',
151
169
  'shadcn-theme': 'shadcn-theme.css',
152
170
  'vue-theme': 'theme.vue.js',
@@ -0,0 +1,198 @@
1
+ // Framer Motion emitter — `<host>-motion.framer.js`.
2
+ //
3
+ // Turns the extracted durations + easing curves into ready-to-import
4
+ // Framer Motion `transition` presets and a few common `variants`
5
+ // (fade, slide-up, scale-in, stagger). Framer Motion is the dominant
6
+ // React animation library, so this is the highest-leverage motion
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
13
+
14
+ function bezier(raw) {
15
+ if (!raw) return null;
16
+ const m = String(raw).match(/cubic-bezier\(\s*([-\d.]+)\s*,\s*([-\d.]+)\s*,\s*([-\d.]+)\s*,\s*([-\d.]+)\s*\)/i);
17
+ if (m) return [parseFloat(m[1]), parseFloat(m[2]), parseFloat(m[3]), parseFloat(m[4])];
18
+ const named = {
19
+ linear: [0, 0, 1, 1], ease: [0.25, 0.1, 0.25, 1],
20
+ 'ease-in': [0.42, 0, 1, 1], 'ease-out': [0, 0, 0.58, 1], 'ease-in-out': [0.42, 0, 0.58, 1],
21
+ };
22
+ return named[String(raw).trim()] || null;
23
+ }
24
+
25
+ function camel(s) {
26
+ return String(s || '').replace(/[^a-zA-Z0-9]+(.)/g, (_, c) => c.toUpperCase()).replace(/^[0-9]/, '_$&');
27
+ }
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
+
41
+ export function formatFramerMotion(design) {
42
+ const host = (() => { try { return new URL(design?.meta?.url).hostname.replace(/^www\./, ''); } catch { return 'site'; } })();
43
+ const durations = Array.isArray(design?.motion?.durations) ? design.motion.durations : [];
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: [] };
48
+
49
+ // Pick a default duration (the most common "medium" feel) and easing.
50
+ const durSec = (d) => ((d.ms || parseInt(d.css || d.value || d, 10) || 300) / 1000);
51
+ const defaultDur = durations.length
52
+ ? durSec(durations[Math.min(2, durations.length - 1)])
53
+ : 0.3;
54
+ const sortedEase = [...easings].sort((a, b) => (b.count || 0) - (a.count || 0));
55
+ const defaultEase = bezier(sortedEase[0]?.raw) || [0.25, 1, 0.5, 1];
56
+
57
+ const lines = [
58
+ '// Framer Motion presets — generated by designlang (motionlang)',
59
+ `// Source: ${design?.meta?.url || host}`,
60
+ `// ${new Date().toISOString()}`,
61
+ '//',
62
+ '// import { transitions, variants } from \'./' + host + '-motion.framer\';',
63
+ '// <motion.div variants={variants.slideUp} initial="hidden" animate="show"',
64
+ '// transition={transitions.base} />',
65
+ '',
66
+ '/** Easing curves extracted from the live page, as Framer cubic-bezier arrays. */',
67
+ 'export const easings = {',
68
+ ];
69
+
70
+ const easeEntries = [];
71
+ sortedEase.forEach((e, i) => {
72
+ const pts = bezier(e.raw);
73
+ if (!pts) return;
74
+ const key = e.family && e.family !== 'custom' ? camel(e.family) : `custom${i + 1}`;
75
+ if (easeEntries.find((x) => x.key === key)) return;
76
+ easeEntries.push({ key, pts, raw: e.raw, count: e.count });
77
+ });
78
+ if (easeEntries.length === 0) {
79
+ easeEntries.push({ key: 'standard', pts: defaultEase, raw: 'fallback', count: 0 });
80
+ }
81
+ for (const { key, pts, count } of easeEntries) {
82
+ lines.push(` ${key}: [${pts.join(', ')}],${count ? ` // ${count}× on page` : ''}`);
83
+ }
84
+ lines.push('};', '');
85
+
86
+ lines.push('/** Duration presets (seconds), extracted from the live page. */');
87
+ lines.push('export const durations = {');
88
+ if (durations.length) {
89
+ for (const d of durations) {
90
+ const name = camel(d.name || `ms${d.ms}`);
91
+ lines.push(` ${name}: ${durSec(d)},`);
92
+ }
93
+ } else {
94
+ lines.push(' fast: 0.15,', ' base: 0.3,', ' slow: 0.5,');
95
+ }
96
+ lines.push('};', '');
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
+
118
+ const primaryEaseKey = easeEntries[0].key;
119
+ lines.push('/** Ready-to-spread Framer Motion transition objects. */');
120
+ lines.push('export const transitions = {');
121
+ lines.push(` base: { duration: ${defaultDur}, ease: easings.${primaryEaseKey} },`);
122
+ lines.push(` fast: { duration: ${Math.max(0.1, defaultDur * 0.5).toFixed(3)}, ease: easings.${primaryEaseKey} },`);
123
+ lines.push(` slow: { duration: ${(defaultDur * 1.8).toFixed(3)}, ease: easings.${primaryEaseKey} },`);
124
+ lines.push(` spring: springs.${primarySpringKey},`);
125
+ lines.push('};', '');
126
+
127
+ lines.push('/** Common variants wired to the extracted timing. */');
128
+ lines.push('export const variants = {');
129
+ lines.push(' fade: {');
130
+ lines.push(' hidden: { opacity: 0 },');
131
+ lines.push(' show: { opacity: 1, transition: transitions.base },');
132
+ lines.push(' },');
133
+ lines.push(' slideUp: {');
134
+ lines.push(' hidden: { opacity: 0, y: 16 },');
135
+ lines.push(' show: { opacity: 1, y: 0, transition: transitions.base },');
136
+ lines.push(' },');
137
+ lines.push(' scaleIn: {');
138
+ lines.push(' hidden: { opacity: 0, scale: 0.96 },');
139
+ lines.push(' show: { opacity: 1, scale: 1, transition: transitions.base },');
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(' },');
145
+ lines.push(' stagger: {');
146
+ lines.push(' hidden: {},');
147
+ lines.push(' show: { transition: { staggerChildren: ' + Math.max(0.04, defaultDur * 0.25).toFixed(3) + ' } },');
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
+
176
+ lines.push('};', '');
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' : ''} };`);
195
+ lines.push('');
196
+
197
+ return lines.join('\n');
198
+ }
@@ -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,180 @@
1
+ // Motion Lab emitter — `<host>-motion.html`.
2
+ //
3
+ // A self-contained, dependency-free interactive page that brings the
4
+ // extracted motion to life: every easing curve drawn as a Bézier path
5
+ // with a dot riding it, every duration shown as a racing bar, every
6
+ // @keyframes animation replayed on loop. Open it in any browser.
7
+ //
8
+ // This is "motionlang" — motion treated as a first-class shippable
9
+ // artefact, not a flat JSON nobody opens.
10
+
11
+ function esc(s) {
12
+ return String(s ?? '').replace(/[&<>"']/g, (c) => (
13
+ { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]
14
+ ));
15
+ }
16
+
17
+ // Parse `cubic-bezier(x1, y1, x2, y2)` → [x1,y1,x2,y2]. Named easings
18
+ // map to their well-known control points.
19
+ const NAMED = {
20
+ linear: [0, 0, 1, 1],
21
+ ease: [0.25, 0.1, 0.25, 1],
22
+ 'ease-in': [0.42, 0, 1, 1],
23
+ 'ease-out': [0, 0, 0.58, 1],
24
+ 'ease-in-out': [0.42, 0, 0.58, 1],
25
+ };
26
+ function bezierPoints(raw) {
27
+ if (!raw) return null;
28
+ const s = String(raw).trim();
29
+ if (NAMED[s]) return NAMED[s];
30
+ const m = s.match(/cubic-bezier\(\s*([-\d.]+)\s*,\s*([-\d.]+)\s*,\s*([-\d.]+)\s*,\s*([-\d.]+)\s*\)/i);
31
+ if (m) return [parseFloat(m[1]), parseFloat(m[2]), parseFloat(m[3]), parseFloat(m[4])];
32
+ return null;
33
+ }
34
+
35
+ // SVG path for a cubic-bezier curve drawn in a 0..1 box (y inverted).
36
+ function curvePath(pts, size) {
37
+ const [x1, y1, x2, y2] = pts;
38
+ const px = (v) => (v * size).toFixed(2);
39
+ const py = (v) => ((1 - v) * size).toFixed(2);
40
+ return `M ${px(0)} ${py(0)} C ${px(x1)} ${py(y1)}, ${px(x2)} ${py(y2)}, ${px(1)} ${py(1)}`;
41
+ }
42
+
43
+ export function formatMotionLab(design) {
44
+ const host = (() => { try { return new URL(design?.meta?.url).hostname.replace(/^www\./, ''); } catch { return 'site'; } })();
45
+ const durations = Array.isArray(design?.motion?.durations) ? design.motion.durations : [];
46
+ const easings = Array.isArray(design?.motion?.easings) ? design.motion.easings : [];
47
+ const keyframes = Array.isArray(design?.motion?.keyframes) ? design.motion.keyframes : [];
48
+
49
+ const SIZE = 160;
50
+
51
+ const easingCards = easings.map((e, i) => {
52
+ const raw = e.raw || e.value || e;
53
+ const pts = bezierPoints(raw);
54
+ const path = pts ? curvePath(pts, SIZE) : null;
55
+ const name = e.family && e.family !== 'custom' ? e.family : `easing-${i + 1}`;
56
+ return `
57
+ <article class="ml-card">
58
+ <div class="ml-card-head">
59
+ <span class="ml-name">${esc(name)}</span>
60
+ ${e.count ? `<span class="ml-count">${e.count}×</span>` : ''}
61
+ </div>
62
+ ${path ? `
63
+ <svg class="ml-curve" viewBox="0 0 ${SIZE} ${SIZE}" width="${SIZE}" height="${SIZE}">
64
+ <line x1="0" y1="${SIZE}" x2="${SIZE}" y2="0" class="ml-curve-guide"/>
65
+ <path d="${path}" class="ml-curve-path"/>
66
+ </svg>` : `<div class="ml-curve ml-curve-none">non-bezier</div>`}
67
+ <code class="ml-raw">${esc(raw)}</code>
68
+ <div class="ml-demo">
69
+ <span class="ml-dot" style="animation-timing-function: ${esc(raw)};"></span>
70
+ </div>
71
+ </article>`;
72
+ }).join('');
73
+
74
+ const durationRows = durations.map((d) => {
75
+ const ms = d.ms || parseInt(d.css || d.value || d, 10) || 0;
76
+ const pct = Math.min(100, (ms / 1000) * 100);
77
+ return `
78
+ <div class="ml-dur">
79
+ <span class="ml-dur-name">${esc(d.name || `${ms}ms`)}</span>
80
+ <span class="ml-dur-track"><span class="ml-dur-fill" style="--ms:${ms}ms; width:${pct}%"></span></span>
81
+ <span class="ml-dur-ms">${ms}ms</span>
82
+ </div>`;
83
+ }).join('');
84
+
85
+ const keyframeBlocks = keyframes.slice(0, 12).map((k, i) => {
86
+ const name = k.name || `keyframes-${i + 1}`;
87
+ const css = k.css || k.raw || '';
88
+ return `
89
+ <article class="ml-kf">
90
+ <div class="ml-card-head"><span class="ml-name">@keyframes ${esc(name)}</span></div>
91
+ <pre class="ml-kf-css">${esc(css).slice(0, 600)}</pre>
92
+ </article>`;
93
+ }).join('');
94
+
95
+ return `<!doctype html>
96
+ <html lang="en">
97
+ <head>
98
+ <meta charset="utf-8">
99
+ <meta name="viewport" content="width=device-width, initial-scale=1">
100
+ <title>${esc(host)} — motion lab · designlang</title>
101
+ <style>
102
+ :root {
103
+ --bg: #0a0a0c; --fg: #f4f4f5; --fg-2: rgba(244,244,245,.62); --fg-3: rgba(244,244,245,.4);
104
+ --line: rgba(255,255,255,.08); --accent: #ef4444;
105
+ --mono: ui-monospace, 'SF Mono', Menlo, monospace;
106
+ --sans: -apple-system, 'Segoe UI', system-ui, sans-serif;
107
+ }
108
+ * { box-sizing: border-box; }
109
+ body { margin: 0; background: var(--bg); color: var(--fg); font-family: var(--sans); }
110
+ .ml-wrap { max-width: 1080px; margin: 0 auto; padding: 56px 24px 96px; }
111
+ header.ml-h { margin-bottom: 48px; }
112
+ .ml-eyebrow { font-family: var(--mono); font-size: 11px; letter-spacing: .18em;
113
+ text-transform: uppercase; color: var(--fg-3); margin: 0 0 12px; }
114
+ h1.ml-title { font-size: clamp(34px, 6vw, 60px); letter-spacing: -.03em; margin: 0 0 12px; font-weight: 600; }
115
+ .ml-sub { color: var(--fg-2); font-size: 16px; max-width: 60ch; margin: 0; }
116
+ h2.ml-section { font-size: 22px; letter-spacing: -.01em; margin: 56px 0 20px; font-weight: 600; }
117
+ .ml-section-meta { font-family: var(--mono); font-size: 12px; color: var(--fg-3); margin-left: 8px; font-weight: 400; }
118
+
119
+ .ml-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 16px; }
120
+ .ml-card { border: 1px solid var(--line); border-radius: 14px; padding: 18px; background: rgba(255,255,255,.02); }
121
+ .ml-card-head { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 12px; }
122
+ .ml-name { font-family: var(--mono); font-size: 13px; color: var(--fg); }
123
+ .ml-count { font-family: var(--mono); font-size: 11px; color: var(--fg-3); }
124
+ .ml-curve { display: block; margin: 0 auto 12px; }
125
+ .ml-curve-guide { stroke: var(--line); stroke-width: 1; stroke-dasharray: 3 3; }
126
+ .ml-curve-path { fill: none; stroke: var(--accent); stroke-width: 2.5; stroke-linecap: round; }
127
+ .ml-curve-none { height: 160px; display: flex; align-items: center; justify-content: center;
128
+ color: var(--fg-3); font-family: var(--mono); font-size: 12px; }
129
+ .ml-raw { display: block; font-family: var(--mono); font-size: 10.5px; color: var(--fg-3);
130
+ word-break: break-all; margin-bottom: 14px; }
131
+ .ml-demo { height: 8px; border-radius: 999px; background: rgba(255,255,255,.05); position: relative; }
132
+ .ml-dot { position: absolute; top: 50%; left: 0; width: 14px; height: 14px; border-radius: 50%;
133
+ background: var(--accent); transform: translate(0,-50%);
134
+ animation: ml-ride 1.6s infinite alternate; box-shadow: 0 0 12px rgba(239,68,68,.6); }
135
+ @keyframes ml-ride { from { left: 0; } to { left: calc(100% - 14px); } }
136
+
137
+ .ml-durs { display: flex; flex-direction: column; gap: 10px; }
138
+ .ml-dur { display: grid; grid-template-columns: 90px 1fr 64px; gap: 14px; align-items: center; }
139
+ .ml-dur-name { font-family: var(--mono); font-size: 12px; color: var(--fg-2); }
140
+ .ml-dur-track { height: 8px; border-radius: 999px; background: rgba(255,255,255,.05); overflow: hidden; }
141
+ .ml-dur-fill { display: block; height: 100%; background: var(--accent); border-radius: 999px;
142
+ transform-origin: left; animation: ml-pulse var(--ms) ease-in-out infinite alternate; }
143
+ @keyframes ml-pulse { from { opacity: .35; } to { opacity: 1; } }
144
+ .ml-dur-ms { font-family: var(--mono); font-size: 12px; color: var(--fg-3); text-align: right; }
145
+
146
+ .ml-kf { border: 1px solid var(--line); border-radius: 14px; padding: 18px; background: rgba(255,255,255,.02); margin-bottom: 14px; }
147
+ .ml-kf-css { font-family: var(--mono); font-size: 11px; color: var(--fg-2); overflow-x: auto; margin: 0; }
148
+
149
+ .ml-empty { color: var(--fg-3); font-family: var(--mono); font-size: 13px;
150
+ border: 1px dashed var(--line); border-radius: 12px; padding: 28px; text-align: center; }
151
+ footer.ml-f { margin-top: 72px; padding-top: 24px; border-top: 1px solid var(--line);
152
+ font-family: var(--mono); font-size: 11px; color: var(--fg-3); }
153
+ footer.ml-f a { color: var(--accent); }
154
+ @media (prefers-reduced-motion: reduce) { .ml-dot, .ml-dur-fill { animation: none; } }
155
+ </style>
156
+ </head>
157
+ <body>
158
+ <div class="ml-wrap">
159
+ <header class="ml-h">
160
+ <p class="ml-eyebrow">motionlang · motion lab</p>
161
+ <h1 class="ml-title">${esc(host)} in motion</h1>
162
+ <p class="ml-sub">Every easing curve, duration and keyframe animation designlang read off the live ${esc(host)} page — drawn, timed and replayed. Generated ${new Date().toISOString().slice(0, 10)}.</p>
163
+ </header>
164
+
165
+ <h2 class="ml-section">Easing curves <span class="ml-section-meta">${easings.length}</span></h2>
166
+ ${easings.length ? `<div class="ml-grid">${easingCards}</div>` : '<div class="ml-empty">No easing curves detected. Re-run with --interactions to capture hover transitions.</div>'}
167
+
168
+ <h2 class="ml-section">Durations <span class="ml-section-meta">${durations.length}</span></h2>
169
+ ${durations.length ? `<div class="ml-durs">${durationRows}</div>` : '<div class="ml-empty">No durations detected.</div>'}
170
+
171
+ <h2 class="ml-section">Keyframe animations <span class="ml-section-meta">${keyframes.length}</span></h2>
172
+ ${keyframes.length ? keyframeBlocks : '<div class="ml-empty">No @keyframes animations detected on this page.</div>'}
173
+
174
+ <footer class="ml-f">
175
+ Generated by designlang · motionlang · <a href="https://designlang.app">designlang.app</a> · re-extract with <code>npx designlang ${esc(host)} --motion</code>
176
+ </footer>
177
+ </div>
178
+ </body>
179
+ </html>`;
180
+ }
@@ -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
+ }