designlang 12.13.0 → 12.15.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,77 @@
1
1
  # Changelog
2
2
 
3
+ ## [12.15.0] — 2026-05-21
4
+
5
+ **motionlang — motion becomes a first-class extractable + shippable artefact.**
6
+
7
+ The motion extractor already captured durations, easings and keyframes,
8
+ but they only landed as a flat `motion-tokens.json` nobody opened. This
9
+ release turns extracted motion into something you can see, ship and
10
+ hand to a framework.
11
+
12
+ - **Motion Lab (`<host>-motion.html`)** — a self-contained, dependency-free
13
+ interactive page. Every extracted easing curve is drawn as a real
14
+ cubic-Bezier path with a dot riding it at that exact timing function;
15
+ every duration shown as a pulsing bar timed to its real `ms`; every
16
+ `@keyframes` block replayed. Open it in any browser.
17
+
18
+ - **Framer Motion presets (`<host>-motion.framer.js`)** — ready-to-import
19
+ `easings` (cubic-bezier arrays ranked by on-page frequency), `durations`
20
+ (seconds), `transitions` (`base` / `fast` / `slow` / `spring`) and
21
+ `variants` (`fade` / `slideUp` / `scaleIn` / `stagger`) — all wired to
22
+ the extracted timing. Framer Motion is the dominant React animation
23
+ library, so this is the highest-leverage motion emitter.
24
+
25
+ - **Website Motion Lab** — every `/gallery/[slug]` brand page now renders
26
+ an interactive Motion Lab section: easing curves drawn in the extracted
27
+ brand colour, dots riding tracks, play/pause, and a link to the full
28
+ standalone page.
29
+
30
+ - **Mobile navbar fix** — the hamburger and Install CTA no longer squish
31
+ together below 640px.
32
+
33
+ Both emitters are exposed through the public `designlang/api` as the
34
+ `motion-lab` and `framer-motion` renderer ids (plus `agent-prompt`).
35
+
36
+ ## [12.14.0] — 2026-05-17
37
+
38
+ **Real downloadable PDFs everywhere + a one-shot agent prompt every AI can paste.**
39
+
40
+ Two big shipping themes: every extraction now produces a real
41
+ print-ready PDF on demand (not just an HTML brand book), and every
42
+ extraction writes a self-contained agent prompt that drops into any
43
+ LLM — Claude, GPT, Gemini, Cursor, Windsurf, v0.
44
+
45
+ - **`--pdf` flag in the main `extract` command** — renders the
46
+ brand-book HTML to a 13-chapter PDF (chapter bookmarks, running
47
+ footer, embedded fonts, selectable text). Verified ~440KB per brand.
48
+
49
+ ```bash
50
+ npx designlang stripe.com --pdf --paper letter --landscape
51
+ ```
52
+
53
+ - **One-shot agent prompt (`<host>-AGENT.md`)** — a single file you
54
+ paste into any AI agent. Includes colour roles, type, spacing, radii,
55
+ motion, voice (tone / pronoun / CTA verbs / real headlines),
56
+ component anatomy, WCAG score, 7 build rules ("never invent a hex",
57
+ "snap spacing to scale", etc.), and a manifest of every other
58
+ artefact designlang produced for context.
59
+
60
+ - **Server-side PDF endpoint** — `GET /api/pdf/<hash>` renders the
61
+ cached extraction's brand book to PDF via the same Browserless /
62
+ Chromium path the `/api/extract` route uses. Cached for an hour on
63
+ CDN edge with 24h stale-while-revalidate.
64
+
65
+ - **Website UI** — `/gallery/[slug]` hero CTA is now
66
+ `Download brand book PDF`; new full-width "Agent prompt" section
67
+ with a `Copy NN.NKB prompt` button. Post-extraction share row gains
68
+ `Download brand PDF` and `Copy agent prompt`.
69
+
70
+ - **Pre-rendered static assets** — all 8 featured brand books in
71
+ `website/public/gallery/<slug>/` now include the new `.brand.pdf`,
72
+ `-AGENT.md`, `-reset.css`, `-gradients.css/.json` files so the
73
+ static gallery has working downloads from minute one.
74
+
3
75
  ## [12.13.0] — 2026-05-16
4
76
 
5
77
  **Six new emitters + a public programmatic API.**
@@ -26,6 +26,9 @@ import { formatTailwindV4 } from '../src/formatters/tailwind-v4.js';
26
26
  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
+ 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';
29
32
  import { formatCssVars } from '../src/formatters/css-vars.js';
30
33
  import { formatPreview } from '../src/formatters/preview.js';
31
34
  import { formatFigma } from '../src/formatters/figma.js';
@@ -113,6 +116,9 @@ program
113
116
  .option('--responsive-shots', 'capture full-page PNGs at 4 breakpoints × (light,dark)')
114
117
  .option('--perf', 'measure Core Web Vitals + bundle profile (LCP/CLS/INP, JS/CSS/font/img bytes, third-party count)')
115
118
  .option('--palette <n>', 'compress the extracted palette to N perceptually distinct colours via LAB-space k-means (default: emit every unique colour)', parseInt)
119
+ .option('--pdf', 'also render a print-ready brand-book PDF (chapter bookmarks, running footer, embedded tokens)')
120
+ .option('--paper <size>', 'PDF paper size when --pdf is set: a4 | letter | tabloid', 'a4')
121
+ .option('--landscape', 'PDF landscape orientation when --pdf is set')
116
122
  .option('--json', 'output raw JSON to stdout (for CI/CD)')
117
123
  .option('--json-pretty', 'output formatted JSON to stdout')
118
124
  .option('--no-history', 'skip saving to history')
@@ -346,6 +352,9 @@ program
346
352
  { name: `${prefix}-reset.css`, content: formatCssReset(design), label: 'Brand-aware CSS reset' },
347
353
  { name: `${prefix}-gradients.css`, content: formatGradientsCss(design), label: 'Extracted gradients (utility classes)' },
348
354
  { name: `${prefix}-gradients.json`, content: formatGradientsJson(design), label: 'Extracted gradients (structured)' },
355
+ { name: `${prefix}-AGENT.md`, content: formatAgentPrompt(design), label: 'Agent system prompt (paste into Claude/GPT/Cursor)' },
356
+ { name: `${prefix}-motion.html`, content: formatMotionLab(design), label: 'Motion lab (interactive easing/duration/keyframe page)' },
357
+ { name: `${prefix}-motion.framer.js`, content: formatFramerMotion(design), label: 'Framer Motion presets (transitions + variants)' },
349
358
  { name: `${prefix}-variables.css`, content: formatCssVars(design), label: 'CSS Variables' },
350
359
  { name: `${prefix}-preview.html`, content: formatPreview(design), label: 'Visual Preview' },
351
360
  { name: `${prefix}-figma-variables.json`, content: formatFigma(design), label: 'Figma Variables' },
@@ -448,6 +457,32 @@ program
448
457
  writeFileSync(join(outDir, file.name), file.content, 'utf-8');
449
458
  }
450
459
 
460
+ // Brand book — always emit the HTML (cheap, ~100KB). Optionally
461
+ // render it to PDF behind --pdf (needs Playwright, ~3–5s).
462
+ try {
463
+ const brandHtml = formatBrandBook(design, { version: PKG_VERSION });
464
+ const brandHtmlPath = join(outDir, `${prefix}.brand.html`);
465
+ writeFileSync(brandHtmlPath, brandHtml, 'utf-8');
466
+ files.push({ name: `${prefix}.brand.html`, label: 'Brand book (HTML)' });
467
+
468
+ if (opts.pdf) {
469
+ spinner.text = 'Rendering brand book PDF...';
470
+ const pdfPath = join(outDir, `${prefix}.brand.pdf`);
471
+ await htmlToPdf(brandHtml, {
472
+ paper: opts.paper || 'a4',
473
+ landscape: !!opts.landscape,
474
+ outPath: pdfPath,
475
+ metadata: {
476
+ title: `${new URL(design?.meta?.url || `https://${prefix}`).hostname} brand guidelines`,
477
+ subject: `${prefix} brand guidelines`,
478
+ },
479
+ });
480
+ files.push({ name: `${prefix}.brand.pdf`, label: 'Brand book (PDF · print-ready)' });
481
+ }
482
+ } catch (e) {
483
+ if (!merged.quiet) console.warn(`(brand book skipped: ${e.message})`);
484
+ }
485
+
451
486
  // Multi-platform emission (v7.0). web is already emitted above.
452
487
  const platforms = merged.platforms || ['web'];
453
488
  const dtcgTokens = formatDtcgTokens(design);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "designlang",
3
- "version": "12.13.0",
3
+ "version": "12.15.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,9 @@ 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';
39
42
 
40
43
  // Utils
41
44
  import { compressPalette } from './utils/palette-compress.js';
@@ -86,6 +89,9 @@ export const RENDERERS = Object.freeze({
86
89
  'brand-book-html': (d) => formatBrandBook(d),
87
90
  'gradients-css': (d) => formatGradientsCss(d),
88
91
  'gradients-json': (d) => formatGradientsJson(d),
92
+ 'agent-prompt': (d) => formatAgentPrompt(d),
93
+ 'motion-lab': (d) => formatMotionLab(d),
94
+ 'framer-motion': (d) => formatFramerMotion(d),
89
95
 
90
96
  // frameworks
91
97
  'react-theme': (d) => formatReactTheme(d),
@@ -147,6 +153,9 @@ export function renderAll(design, opts = {}) {
147
153
  'brand-book-html': 'brand.html',
148
154
  'gradients-css': 'gradients.css',
149
155
  'gradients-json': 'gradients.json',
156
+ 'agent-prompt': 'AGENT.md',
157
+ 'motion-lab': 'motion.html',
158
+ 'framer-motion': 'motion.framer.js',
150
159
  'react-theme': 'theme.js',
151
160
  'shadcn-theme': 'shadcn-theme.css',
152
161
  'vue-theme': 'theme.vue.js',
@@ -0,0 +1,214 @@
1
+ // One-shot agent prompt — `<host>-AGENT.md`.
2
+ //
3
+ // Drop the contents into Claude / GPT / Gemini / Cursor / Windsurf
4
+ // and the agent will build any UI you ask for *in the extracted brand*.
5
+ //
6
+ // Distinct from the existing prompt-pack (which targets v0 / Lovable /
7
+ // Cursor with tool-specific syntax). This file is one self-contained
8
+ // system-prompt that works in any context window — every token, every
9
+ // component anatomy slot, every brand voice rule, ready to paste.
10
+
11
+ function hex(c) { return c?.hex || c || null; }
12
+ function pickHex(role, design) { return hex(design?.colors?.[role]) || null; }
13
+
14
+ function topN(arr, n) {
15
+ if (Array.isArray(arr)) return arr.slice(0, n);
16
+ if (arr && typeof arr === 'object') return Object.values(arr).slice(0, n);
17
+ return [];
18
+ }
19
+
20
+ function listColors(design) {
21
+ const out = [];
22
+ for (const role of ['primary', 'secondary', 'accent', 'background', 'foreground']) {
23
+ const v = pickHex(role, design);
24
+ if (v) out.push(`- ${role.padEnd(11)} ${v}`);
25
+ }
26
+ const neutrals = topN(design?.colors?.neutrals, 6).map((n) => hex(n)).filter(Boolean);
27
+ if (neutrals.length) out.push(`- neutrals ${neutrals.join(' · ')}`);
28
+ return out.join('\n');
29
+ }
30
+
31
+ function listType(design) {
32
+ const fams = topN(design?.typography?.families, 4).map((f) => f?.name || f).filter(Boolean);
33
+ const weights = topN(design?.typography?.weights, 6).map((w) => w?.weight || w?.value || w).filter(Boolean);
34
+ const base = design?.typography?.base || 16;
35
+ return [
36
+ fams.length ? `- families ${fams.join(' · ')}` : null,
37
+ weights.length ? `- weights ${weights.join(' · ')}` : null,
38
+ `- base size ${base}px`,
39
+ ].filter(Boolean).join('\n');
40
+ }
41
+
42
+ function listSpacing(design) {
43
+ const scale = topN(design?.spacing?.scale, 12).map((px) => `${px}px`);
44
+ if (!scale.length) return null;
45
+ return `- scale ${scale.join(' · ')}`;
46
+ }
47
+
48
+ function listRadii(design) {
49
+ const radii = topN(design?.borders?.radii, 6).map((r) => `${typeof r === 'object' ? r.value : r}px`);
50
+ if (!radii.length) return null;
51
+ return `- scale ${radii.join(' · ')}`;
52
+ }
53
+
54
+ function listMotion(design) {
55
+ const durs = topN(design?.motion?.durations, 4).map((d) => `${typeof d === 'object' ? (d.value || d.ms) : d}ms`);
56
+ const eas = topN(design?.motion?.easings, 4).map((e) => typeof e === 'object' ? e.value : e).filter(Boolean);
57
+ return [
58
+ durs.length ? `- durations ${durs.join(' · ')}` : null,
59
+ eas.length ? `- easings ${eas.join(' · ')}` : null,
60
+ ].filter(Boolean).join('\n');
61
+ }
62
+
63
+ function listVoice(design) {
64
+ const v = design?.voice || {};
65
+ const ctas = topN(v.ctaVerbs, 6).map((c) => c?.value || c).filter(Boolean);
66
+ const headings = topN(v.headlines || v.headings, 3).map((h) => h?.text || h).filter(Boolean);
67
+ return [
68
+ v.tone ? `- tone ${v.tone}` : null,
69
+ v.pronoun ? `- pronoun ${v.pronoun}` : null,
70
+ v.headingStyle ? `- headings ${v.headingStyle}` : null,
71
+ ctas.length ? `- CTA verbs ${ctas.join(' · ')}` : null,
72
+ headings.length ? `- real headlines:\n${headings.map(h => ` > "${String(h).slice(0, 120)}"`).join('\n')}` : null,
73
+ ].filter(Boolean).join('\n');
74
+ }
75
+
76
+ function listAnatomy(design) {
77
+ const list = design?.componentAnatomy || design?.componentClusters || [];
78
+ if (!Array.isArray(list) || list.length === 0) return null;
79
+ return topN(list, 8).map((c) => {
80
+ const kind = String(c?.kind || c?.name || 'component');
81
+ const variants = topN(c?.variants, 4).map(String).join(' · ') || '—';
82
+ const slots = topN(c?.slots, 4).map(String).join(' · ') || '—';
83
+ return `- ${kind.padEnd(10)} variants: ${variants} · slots: ${slots}`;
84
+ }).join('\n');
85
+ }
86
+
87
+ function listA11y(design) {
88
+ const a = design?.accessibility || {};
89
+ const score = a.score ?? null;
90
+ const fails = a.failCount ?? (a.remediation?.length ?? 0);
91
+ return `- WCAG score ${score == null ? '—' : `${score}%`} · failing pairs: ${fails}`;
92
+ }
93
+
94
+ export function formatAgentPrompt(design) {
95
+ const host = design?.meta?.url ? new URL(design.meta.url).hostname.replace(/^www\./, '') : 'this site';
96
+ const title = design?.meta?.title || host;
97
+ const intent = design?.pageIntent?.label || 'landing';
98
+ const material = design?.materialLanguage?.label || 'flat';
99
+ const library = design?.componentLibrary?.label || null;
100
+ const grade = design?.score?.grade || null;
101
+
102
+ const blocks = [];
103
+ blocks.push(
104
+ `# You are building UI in the ${host} design system.`,
105
+ '',
106
+ `Source: ${design?.meta?.url || '—'}`,
107
+ `Extracted by designlang on ${new Date().toISOString().slice(0, 10)}.`,
108
+ '',
109
+ '## Brand at a glance',
110
+ '',
111
+ `- title ${title}`,
112
+ `- page intent ${intent}`,
113
+ `- material ${material}`,
114
+ library ? `- library ${library}` : null,
115
+ grade ? `- design grade ${grade}` : null,
116
+ '',
117
+ '## Colour',
118
+ '',
119
+ listColors(design) || '_(no colour roles detected)_',
120
+ '',
121
+ '## Typography',
122
+ '',
123
+ listType(design),
124
+ '',
125
+ );
126
+
127
+ const spacing = listSpacing(design);
128
+ if (spacing) blocks.push('## Spacing', '', spacing, '');
129
+
130
+ const radii = listRadii(design);
131
+ if (radii) blocks.push('## Radii', '', radii, '');
132
+
133
+ const motion = listMotion(design);
134
+ if (motion) blocks.push('## Motion', '', motion, '');
135
+
136
+ const voice = listVoice(design);
137
+ if (voice) blocks.push('## Voice', '', voice, '');
138
+
139
+ const anatomy = listAnatomy(design);
140
+ if (anatomy) blocks.push('## Component anatomy', '', anatomy, '');
141
+
142
+ blocks.push('## Accessibility', '', listA11y(design), '');
143
+
144
+ blocks.push(
145
+ '## Build rules',
146
+ '',
147
+ '1. Use the colours above. **Never invent a new hex.** If you need a',
148
+ ' shade between two existing colours, derive it via HSL adjustment',
149
+ ' from the closest extracted colour and call out the derivation.',
150
+ '2. Use the extracted typography families. If you need a missing weight,',
151
+ ' pick the nearest available weight from the list and note it.',
152
+ '3. Snap spacing values to the scale above. No off-scale paddings or',
153
+ ' margins.',
154
+ '4. Snap border radii to the scale above.',
155
+ '5. Match the voice: same tone, same pronoun stance, same heading',
156
+ ' style. Reuse the listed CTA verbs.',
157
+ '6. Aim for WCAG AA contrast minimum. When the brand colours fail,',
158
+ ' prefer the foreground colour on the background colour rather than',
159
+ ' mid-tone neutrals.',
160
+ '7. Reuse component anatomy when it exists — do not invent novel',
161
+ ' structures for things the site already has.',
162
+ '',
163
+ '## Available context files',
164
+ '',
165
+ 'designlang wrote these alongside this prompt. Reach for them when',
166
+ 'you need ground truth:',
167
+ '',
168
+ '- `<host>-design-tokens.json` — DTCG primitive · semantic · composite tokens',
169
+ '- `<host>-tailwind.config.js` — Tailwind v3 config',
170
+ '- `<host>-tailwind-v4.css` — Tailwind v4 `@theme` block',
171
+ '- `<host>-tokens.d.ts` — TypeScript literal-union types',
172
+ '- `<host>-variables.css` — bare CSS custom properties',
173
+ '- `<host>-reset.css` — brand-aware base styles',
174
+ '- `<host>-gradients.css` — `.grad-N` utility classes',
175
+ '- `<host>-anatomy.tsx` — typed React component scaffolds',
176
+ '- `<host>-shadcn-theme.css` — shadcn/ui theme',
177
+ '- `<host>-theme.js` — React / Vue / Svelte theme object',
178
+ '- `<host>-mcp.json` — MCP server payload (load via stdio)',
179
+ '- `<host>.brand.pdf` — print-ready 13-chapter brand book',
180
+ '',
181
+ 'When you reference the system in code, prefer importing from these',
182
+ 'files over hard-coding values.',
183
+ '',
184
+ '## Output expectations',
185
+ '',
186
+ 'When asked to "build a pricing page" or "make a card" or any UI:',
187
+ '',
188
+ '- Produce a single self-contained component file in the appropriate',
189
+ ' framework (React / Vue / Svelte — match what the user is using).',
190
+ '- Use Tailwind utility classes wired to the v4 `@theme` if Tailwind',
191
+ ' is available; otherwise use the CSS custom properties from',
192
+ ' `variables.css`.',
193
+ '- Write the headline copy using the brand voice; do not invent',
194
+ ' generic Lorem.',
195
+ '- Annotate any choice where you had to bend the system, with a',
196
+ ' one-line `// note:` comment explaining what and why.',
197
+ '',
198
+ `## One-line install`,
199
+ '',
200
+ '```bash',
201
+ `npx designlang ${host}`,
202
+ '```',
203
+ '',
204
+ 'Run this against any other URL to extract its system in the same',
205
+ 'shape as the one above.',
206
+ '',
207
+ '---',
208
+ '',
209
+ `Generated by designlang. Re-extract by running \`npx designlang ${host}\`.`,
210
+ '',
211
+ );
212
+
213
+ return blocks.filter((b) => b !== null).join('\n');
214
+ }
@@ -0,0 +1,110 @@
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
+ function bezier(raw) {
10
+ if (!raw) return null;
11
+ const m = String(raw).match(/cubic-bezier\(\s*([-\d.]+)\s*,\s*([-\d.]+)\s*,\s*([-\d.]+)\s*,\s*([-\d.]+)\s*\)/i);
12
+ if (m) return [parseFloat(m[1]), parseFloat(m[2]), parseFloat(m[3]), parseFloat(m[4])];
13
+ const named = {
14
+ linear: [0, 0, 1, 1], ease: [0.25, 0.1, 0.25, 1],
15
+ 'ease-in': [0.42, 0, 1, 1], 'ease-out': [0, 0, 0.58, 1], 'ease-in-out': [0.42, 0, 0.58, 1],
16
+ };
17
+ return named[String(raw).trim()] || null;
18
+ }
19
+
20
+ function camel(s) {
21
+ return String(s || '').replace(/[^a-zA-Z0-9]+(.)/g, (_, c) => c.toUpperCase()).replace(/^[0-9]/, '_$&');
22
+ }
23
+
24
+ export function formatFramerMotion(design) {
25
+ const host = (() => { try { return new URL(design?.meta?.url).hostname.replace(/^www\./, ''); } catch { return 'site'; } })();
26
+ const durations = Array.isArray(design?.motion?.durations) ? design.motion.durations : [];
27
+ const easings = Array.isArray(design?.motion?.easings) ? design.motion.easings : [];
28
+
29
+ // Pick a default duration (the most common "medium" feel) and easing.
30
+ const durSec = (d) => ((d.ms || parseInt(d.css || d.value || d, 10) || 300) / 1000);
31
+ const defaultDur = durations.length
32
+ ? durSec(durations[Math.min(2, durations.length - 1)])
33
+ : 0.3;
34
+ const sortedEase = [...easings].sort((a, b) => (b.count || 0) - (a.count || 0));
35
+ const defaultEase = bezier(sortedEase[0]?.raw) || [0.25, 1, 0.5, 1];
36
+
37
+ const lines = [
38
+ '// Framer Motion presets — generated by designlang (motionlang)',
39
+ `// Source: ${design?.meta?.url || host}`,
40
+ `// ${new Date().toISOString()}`,
41
+ '//',
42
+ '// import { transitions, variants } from \'./' + host + '-motion.framer\';',
43
+ '// <motion.div variants={variants.slideUp} initial="hidden" animate="show"',
44
+ '// transition={transitions.base} />',
45
+ '',
46
+ '/** Easing curves extracted from the live page, as Framer cubic-bezier arrays. */',
47
+ 'export const easings = {',
48
+ ];
49
+
50
+ const easeEntries = [];
51
+ sortedEase.forEach((e, i) => {
52
+ const pts = bezier(e.raw);
53
+ if (!pts) return;
54
+ const key = e.family && e.family !== 'custom' ? camel(e.family) : `custom${i + 1}`;
55
+ if (easeEntries.find((x) => x.key === key)) return;
56
+ easeEntries.push({ key, pts, raw: e.raw, count: e.count });
57
+ });
58
+ if (easeEntries.length === 0) {
59
+ easeEntries.push({ key: 'standard', pts: defaultEase, raw: 'fallback', count: 0 });
60
+ }
61
+ for (const { key, pts, count } of easeEntries) {
62
+ lines.push(` ${key}: [${pts.join(', ')}],${count ? ` // ${count}× on page` : ''}`);
63
+ }
64
+ lines.push('};', '');
65
+
66
+ lines.push('/** Duration presets (seconds), extracted from the live page. */');
67
+ lines.push('export const durations = {');
68
+ if (durations.length) {
69
+ for (const d of durations) {
70
+ const name = camel(d.name || `ms${d.ms}`);
71
+ lines.push(` ${name}: ${durSec(d)},`);
72
+ }
73
+ } else {
74
+ lines.push(' fast: 0.15,', ' base: 0.3,', ' slow: 0.5,');
75
+ }
76
+ lines.push('};', '');
77
+
78
+ const primaryEaseKey = easeEntries[0].key;
79
+ lines.push('/** Ready-to-spread Framer Motion transition objects. */');
80
+ lines.push('export const transitions = {');
81
+ lines.push(` base: { duration: ${defaultDur}, ease: easings.${primaryEaseKey} },`);
82
+ lines.push(` fast: { duration: ${Math.max(0.1, defaultDur * 0.5).toFixed(3)}, ease: easings.${primaryEaseKey} },`);
83
+ lines.push(` slow: { duration: ${(defaultDur * 1.8).toFixed(3)}, ease: easings.${primaryEaseKey} },`);
84
+ lines.push(` spring: { type: 'spring', stiffness: 320, damping: 30 },`);
85
+ lines.push('};', '');
86
+
87
+ lines.push('/** Common variants wired to the extracted timing. */');
88
+ lines.push('export const variants = {');
89
+ lines.push(' fade: {');
90
+ lines.push(' hidden: { opacity: 0 },');
91
+ lines.push(' show: { opacity: 1, transition: transitions.base },');
92
+ lines.push(' },');
93
+ lines.push(' slideUp: {');
94
+ lines.push(' hidden: { opacity: 0, y: 16 },');
95
+ lines.push(' show: { opacity: 1, y: 0, transition: transitions.base },');
96
+ lines.push(' },');
97
+ lines.push(' scaleIn: {');
98
+ lines.push(' hidden: { opacity: 0, scale: 0.96 },');
99
+ lines.push(' show: { opacity: 1, scale: 1, transition: transitions.base },');
100
+ lines.push(' },');
101
+ lines.push(' stagger: {');
102
+ lines.push(' hidden: {},');
103
+ lines.push(' show: { transition: { staggerChildren: ' + Math.max(0.04, defaultDur * 0.25).toFixed(3) + ' } },');
104
+ lines.push(' },');
105
+ lines.push('};', '');
106
+ lines.push('export default { easings, durations, transitions, variants };');
107
+ lines.push('');
108
+
109
+ return lines.join('\n');
110
+ }
@@ -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
+ }