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.
- package/.engram/journal.jsonl +4 -0
- package/CHANGELOG.md +69 -0
- package/bin/design-extract.js +10 -0
- package/package.json +1 -1
- package/src/api.js +18 -0
- package/src/formatters/framer-motion.js +198 -0
- package/src/formatters/motion-css.js +136 -0
- package/src/formatters/motion-lab.js +180 -0
- package/src/formatters/motion-one.js +191 -0
- package/src/formatters/motion-tailwind.js +120 -0
|
@@ -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.**
|
package/bin/design-extract.js
CHANGED
|
@@ -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.
|
|
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
|
+
{ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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
|
+
}
|