designlang 11.0.2 โ 11.2.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/SECURITY.md +46 -0
- package/bin/design-extract.js +63 -0
- package/package.json +1 -1
- package/src/formatters/design-md.js +319 -0
- package/src/formatters/storybook.js +225 -0
- package/src/replay.js +131 -0
package/SECURITY.md
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# Security Policy
|
|
2
|
+
|
|
3
|
+
## Supported versions
|
|
4
|
+
|
|
5
|
+
| Version | Supported |
|
|
6
|
+
| ------- | ------------------ |
|
|
7
|
+
| 11.x | :white_check_mark: |
|
|
8
|
+
| 10.x | :white_check_mark: (critical only) |
|
|
9
|
+
| < 10 | :x: |
|
|
10
|
+
|
|
11
|
+
Only the latest minor of each supported major receives fixes.
|
|
12
|
+
|
|
13
|
+
## Reporting a vulnerability
|
|
14
|
+
|
|
15
|
+
**Please don't open a public issue.** Use GitHub's private vulnerability reporting instead:
|
|
16
|
+
|
|
17
|
+
๐ <https://github.com/Manavarya09/design-extract/security/advisories/new>
|
|
18
|
+
|
|
19
|
+
What to include:
|
|
20
|
+
|
|
21
|
+
- A description of the issue and its impact.
|
|
22
|
+
- A minimal repro (URL being extracted, CLI flags, OS + Node version).
|
|
23
|
+
- Suggested severity and, if you have one, a proposed fix.
|
|
24
|
+
|
|
25
|
+
### What to expect
|
|
26
|
+
|
|
27
|
+
- Acknowledgement within **72 hours**.
|
|
28
|
+
- A triage verdict (accepted / needs info / out of scope) within **7 days**.
|
|
29
|
+
- Public disclosure coordinated with you after a fix ships โ usually as part of a patch release.
|
|
30
|
+
|
|
31
|
+
## Scope
|
|
32
|
+
|
|
33
|
+
In scope:
|
|
34
|
+
|
|
35
|
+
- The `designlang` CLI and all subcommands (`extract`, `clone`, `ci`, `studio`, `replay`, `mcp`, etc.).
|
|
36
|
+
- The MCP server (`src/mcp/server.js`).
|
|
37
|
+
- The hosted extractor website (`website/`) when used at its canonical URL.
|
|
38
|
+
- The Figma, Chrome, Raycast, and VS Code extensions shipped in this repo.
|
|
39
|
+
|
|
40
|
+
Out of scope:
|
|
41
|
+
|
|
42
|
+
- Vulnerabilities that require the user to run `designlang` against a page **they themselves control** (i.e. self-owned XSS in content they feed us).
|
|
43
|
+
- Denial of service via crafted sites that simply take a long time to extract โ we already time-bound Playwright operations.
|
|
44
|
+
- Browser-level shadow-DOM opacity (closed shadow roots are unreachable by web platform design, not a designlang bug).
|
|
45
|
+
|
|
46
|
+
Thanks for taking the time to report responsibly.
|
package/bin/design-extract.js
CHANGED
|
@@ -85,6 +85,7 @@ program
|
|
|
85
85
|
.option('--insecure', 'ignore HTTPS/SSL certificate errors (self-signed, dev, proxies)')
|
|
86
86
|
.option('--ignore <selectors...>', 'CSS selectors to remove before extraction')
|
|
87
87
|
.option('--ignore-widgets', 'Also ignore a curated list of third-party widgets (Intercom, Drift, HubSpot chat, cookie banners, reCAPTCHA, etc.) See `designlang widgets`.')
|
|
88
|
+
.option('--storybook', 'Emit a runnable Storybook project (stories/, .storybook/, package.json) alongside the extraction')
|
|
88
89
|
.option('--selector <css>', 'only extract design from elements matching this CSS selector (e.g. ".pricing-card")')
|
|
89
90
|
.option('--system-chrome', 'use the system Chrome install instead of the bundled Chromium (skips the 150MB Playwright download)')
|
|
90
91
|
.option('--tokens-legacy', 'Emit pre-v7 flat token JSON (backward compat)')
|
|
@@ -93,6 +94,7 @@ program
|
|
|
93
94
|
.option('--smart', 'use optional LLM fallback when heuristic classifiers have low confidence (needs OPENAI_API_KEY or ANTHROPIC_API_KEY)')
|
|
94
95
|
.option('--pages <n>', 'crawl N canonical pages (pricing/docs/blog/about/product) in addition to the homepage', parseInt)
|
|
95
96
|
.option('--no-prompts', 'skip writing the prompt-pack directory')
|
|
97
|
+
.option('--no-design-md', 'skip writing the agent-native DESIGN.md (single-file, 8-section, YAML front matter)')
|
|
96
98
|
.option('--responsive-shots', 'capture full-page PNGs at 4 breakpoints ร (light,dark)')
|
|
97
99
|
.option('--perf', 'measure Core Web Vitals + bundle profile (LCP/CLS/INP, JS/CSS/font/img bytes, third-party count)')
|
|
98
100
|
.option('--json', 'output raw JSON to stdout (for CI/CD)')
|
|
@@ -351,6 +353,13 @@ program
|
|
|
351
353
|
}
|
|
352
354
|
files.push({ name: `${prefix}-voice.json`, content: JSON.stringify(design.voice || {}, null, 2), label: 'Brand Voice' });
|
|
353
355
|
|
|
356
|
+
// v11.2: agent-native single-file DESIGN.md (compatible with the
|
|
357
|
+
// 8-canonical-section convention; default-on, opt-out via --no-design-md).
|
|
358
|
+
if (merged.designMd !== false) {
|
|
359
|
+
const { formatDesignMd } = await import('../src/formatters/design-md.js');
|
|
360
|
+
files.push({ name: `${prefix}-DESIGN.md`, content: formatDesignMd(design), label: 'DESIGN.md (agent-native)' });
|
|
361
|
+
}
|
|
362
|
+
|
|
354
363
|
// v10: page intent + section roles + visual DNA + component library + multi-page + prompt pack.
|
|
355
364
|
files.push({ name: `${prefix}-intent.json`, content: JSON.stringify({ pageIntent: design.pageIntent, sectionRoles: design.sectionRoles }, null, 2), label: 'Page Intent + Section Roles' });
|
|
356
365
|
files.push({ name: `${prefix}-visual-dna.json`, content: JSON.stringify({ materialLanguage: design.materialLanguage, imageryStyle: design.imageryStyle, backgroundPatterns: design.backgroundPatterns }, null, 2), label: 'Visual DNA' });
|
|
@@ -473,6 +482,20 @@ program
|
|
|
473
482
|
}
|
|
474
483
|
}
|
|
475
484
|
|
|
485
|
+
// Storybook project (opt-in via --storybook)
|
|
486
|
+
if (merged.storybook && Array.isArray(design.componentAnatomy) && design.componentAnatomy.length > 0) {
|
|
487
|
+
const { formatStorybook } = await import('../src/formatters/storybook.js');
|
|
488
|
+
const sbFiles = formatStorybook(design);
|
|
489
|
+
const sbDir = join(outDir, `${prefix}-storybook`);
|
|
490
|
+
mkdirSync(sbDir, { recursive: true });
|
|
491
|
+
for (const [rel, content] of Object.entries(sbFiles)) {
|
|
492
|
+
const p = join(sbDir, rel);
|
|
493
|
+
mkdirSync(join(p, '..'), { recursive: true });
|
|
494
|
+
writeFileSync(p, content, 'utf-8');
|
|
495
|
+
platformFiles.push({ path: p, label: `Storybook (${rel})` });
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
476
499
|
// Save to history
|
|
477
500
|
if (opts.history !== false) {
|
|
478
501
|
const histInfo = saveSnapshot(design);
|
|
@@ -1062,6 +1085,46 @@ program
|
|
|
1062
1085
|
}
|
|
1063
1086
|
});
|
|
1064
1087
|
|
|
1088
|
+
// โโ Replay โ record a short WebM of motion from a URL โโโโโ
|
|
1089
|
+
program
|
|
1090
|
+
.command('replay <url>')
|
|
1091
|
+
.description('Record a short WebM clip of a site\'s motion (scroll + hover). Optional MP4 if ffmpeg is on PATH.')
|
|
1092
|
+
.option('-o, --out <dir>', 'output directory', './design-extract-output')
|
|
1093
|
+
.option('-n, --name <name>', 'output file prefix', 'motion-replay')
|
|
1094
|
+
.option('-d, --duration <s>', 'duration in seconds (2-15)', parseInt, 5)
|
|
1095
|
+
.option('-w, --width <px>', 'viewport width', parseInt, 1280)
|
|
1096
|
+
.option('--height <px>', 'viewport height', parseInt, 800)
|
|
1097
|
+
.option('--mp4', 'also emit an MP4 (requires ffmpeg on PATH)')
|
|
1098
|
+
.action(async (url, opts) => {
|
|
1099
|
+
if (!url.startsWith('http')) url = `https://${url}`;
|
|
1100
|
+
validateUrl(url);
|
|
1101
|
+
const spinner = ora('Recording motion replay...').start();
|
|
1102
|
+
try {
|
|
1103
|
+
const { recordReplay } = await import('../src/replay.js');
|
|
1104
|
+
const r = await recordReplay(url, {
|
|
1105
|
+
out: opts.out,
|
|
1106
|
+
prefix: opts.name,
|
|
1107
|
+
duration: opts.duration,
|
|
1108
|
+
width: opts.width,
|
|
1109
|
+
height: opts.height,
|
|
1110
|
+
mp4: opts.mp4,
|
|
1111
|
+
});
|
|
1112
|
+
if (!r.webm) {
|
|
1113
|
+
spinner.fail('No video was produced. The browser may have blocked recording; try a different URL.');
|
|
1114
|
+
process.exit(1);
|
|
1115
|
+
}
|
|
1116
|
+
spinner.succeed(`Replay captured (${r.duration}s)`);
|
|
1117
|
+
console.log('');
|
|
1118
|
+
console.log(` ${chalk.green('โ')} ${chalk.cyan(r.webm)} โ WebM`);
|
|
1119
|
+
if (r.mp4) console.log(` ${chalk.green('โ')} ${chalk.cyan(r.mp4)} โ MP4`);
|
|
1120
|
+
else if (opts.mp4) console.log(` ${chalk.gray('note: ffmpeg not found on PATH; MP4 skipped')}`);
|
|
1121
|
+
console.log('');
|
|
1122
|
+
} catch (err) {
|
|
1123
|
+
spinner.fail(err.message);
|
|
1124
|
+
process.exit(1);
|
|
1125
|
+
}
|
|
1126
|
+
});
|
|
1127
|
+
|
|
1065
1128
|
// โโ Widgets โ print the curated third-party widget ignore list โ
|
|
1066
1129
|
program
|
|
1067
1130
|
.command('widgets')
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "designlang",
|
|
3
|
-
"version": "11.0
|
|
3
|
+
"version": "11.2.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": {
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
// DESIGN.md โ agent-native single-file output. Compatible with the
|
|
2
|
+
// 8-canonical-section convention (Overview ยท Colors ยท Typography ยท Layout
|
|
3
|
+
// ยท Elevation and Depth ยท Shapes ยท Components ยท Do's and Don'ts), plus
|
|
4
|
+
// YAML front matter holding the machine-readable token snapshot.
|
|
5
|
+
//
|
|
6
|
+
// Designed to be a drop-in replacement for design-extractor.com's
|
|
7
|
+
// DESIGN.md output, but driven by the v10/v11 semantic layer (intent,
|
|
8
|
+
// material, voice, anatomy, library detection) rather than just colors.
|
|
9
|
+
|
|
10
|
+
import { readFileSync } from 'fs';
|
|
11
|
+
|
|
12
|
+
function yamlString(v) {
|
|
13
|
+
if (v == null) return '~';
|
|
14
|
+
if (typeof v === 'number' || typeof v === 'boolean') return String(v);
|
|
15
|
+
const s = String(v);
|
|
16
|
+
if (s === '' || /[:#&*!?[\]{},|>%@`'"\n]/.test(s) || /^\s|\s$/.test(s)) {
|
|
17
|
+
return JSON.stringify(s);
|
|
18
|
+
}
|
|
19
|
+
return s;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function yamlList(arr, indent = ' ') {
|
|
23
|
+
if (!arr || arr.length === 0) return '[]';
|
|
24
|
+
return '\n' + arr.map(v => `${indent}- ${yamlString(v)}`).join('\n');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function yamlMap(obj, indent = ' ') {
|
|
28
|
+
const entries = Object.entries(obj || {}).filter(([, v]) => v != null);
|
|
29
|
+
if (entries.length === 0) return '{}';
|
|
30
|
+
return '\n' + entries.map(([k, v]) => `${indent}${k}: ${yamlString(v)}`).join('\n');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function topColor(colors, role) {
|
|
34
|
+
return colors?.[role]?.hex || null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function fmtPx(v) {
|
|
38
|
+
if (v == null) return null;
|
|
39
|
+
const n = typeof v === 'number' ? v : parseFloat(String(v));
|
|
40
|
+
return Number.isFinite(n) ? `${Math.round(n)}px` : String(v);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function uniq(arr) { return [...new Set(arr.filter(Boolean))]; }
|
|
44
|
+
|
|
45
|
+
function pickHeading(voice, fallback) {
|
|
46
|
+
const s = (voice?.sampleHeadings || []).find(h => h && h.length > 4 && h.length < 80);
|
|
47
|
+
return s || fallback;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function ratioLine(v, n) { return `${v} (${n})`; }
|
|
51
|
+
|
|
52
|
+
// โโโ Section renderers โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
53
|
+
|
|
54
|
+
function sectionOverview(design) {
|
|
55
|
+
const intent = design.pageIntent?.type || 'landing';
|
|
56
|
+
const intentConf = design.pageIntent?.confidence;
|
|
57
|
+
const material = design.materialLanguage?.label || 'flat';
|
|
58
|
+
const matConf = design.materialLanguage?.confidence;
|
|
59
|
+
const library = design.componentLibrary?.library;
|
|
60
|
+
const libConf = design.componentLibrary?.confidence;
|
|
61
|
+
const order = (design.sectionRoles?.readingOrder || []).join(' โ ') || 'โ';
|
|
62
|
+
const voice = design.voice || {};
|
|
63
|
+
const tone = voice.tone || 'neutral';
|
|
64
|
+
const lede = pickHeading(voice, design.meta?.title || '');
|
|
65
|
+
const url = design.meta?.url || '';
|
|
66
|
+
|
|
67
|
+
const lines = [];
|
|
68
|
+
lines.push(`A **${intent}** page${intentConf ? ` (heuristic confidence ${intentConf})` : ''}, dressed in **${material}** material${matConf ? ` (${matConf})` : ''}.`);
|
|
69
|
+
if (library && library !== 'unknown') {
|
|
70
|
+
lines.push('');
|
|
71
|
+
lines.push(`Component library appears to be **${library}**${libConf ? ` (${libConf})` : ''}.`);
|
|
72
|
+
}
|
|
73
|
+
if (lede) {
|
|
74
|
+
lines.push('');
|
|
75
|
+
lines.push(`> "${lede}"`);
|
|
76
|
+
}
|
|
77
|
+
lines.push('');
|
|
78
|
+
lines.push(`The author writes in a **${tone}** voice; headings tend to be **${voice.headingStyle || 'sentence'}** case and **${voice.headingLengthClass || 'balanced'}**.`);
|
|
79
|
+
lines.push('');
|
|
80
|
+
lines.push(`Reading order detected on the source: \`${order}\`.`);
|
|
81
|
+
if (url) {
|
|
82
|
+
lines.push('');
|
|
83
|
+
lines.push(`Source: <${url}>.`);
|
|
84
|
+
}
|
|
85
|
+
return lines.join('\n');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function sectionColors(design) {
|
|
89
|
+
const c = design.colors || {};
|
|
90
|
+
const lines = ['| role | hex | usage |', '|---|---|---|'];
|
|
91
|
+
const rows = [
|
|
92
|
+
['primary', c.primary?.hex, c.primary?.count],
|
|
93
|
+
['secondary', c.secondary?.hex, c.secondary?.count],
|
|
94
|
+
['accent', c.accent?.hex, c.accent?.count],
|
|
95
|
+
['background', c.backgrounds?.[0], 'โ'],
|
|
96
|
+
['foreground', c.text?.[0], 'โ'],
|
|
97
|
+
];
|
|
98
|
+
for (const [role, hex, count] of rows) {
|
|
99
|
+
if (!hex) continue;
|
|
100
|
+
lines.push(`| ${role} | \`${hex}\` | ${count ?? 'โ'} |`);
|
|
101
|
+
}
|
|
102
|
+
const neutrals = (c.neutrals || []).slice(0, 5);
|
|
103
|
+
if (neutrals.length) {
|
|
104
|
+
lines.push('');
|
|
105
|
+
lines.push('**Neutrals:** ' + neutrals.map(n => `\`${n.hex}\``).join(' ยท '));
|
|
106
|
+
}
|
|
107
|
+
if (c.all?.length) {
|
|
108
|
+
lines.push('');
|
|
109
|
+
lines.push(`**Total unique colors detected:** ${c.all.length}.`);
|
|
110
|
+
}
|
|
111
|
+
return lines.join('\n');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function sectionTypography(design) {
|
|
115
|
+
const t = design.typography || {};
|
|
116
|
+
const lines = [];
|
|
117
|
+
if (t.families?.length) {
|
|
118
|
+
lines.push('**Families**');
|
|
119
|
+
for (const f of t.families.slice(0, 4)) {
|
|
120
|
+
lines.push(`- \`${f.name}\`${f.weights ? ` โ weights ${[...new Set(f.weights)].join(', ')}` : ''}${f.count ? ` ยท ${f.count} uses` : ''}`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (t.body?.size) {
|
|
124
|
+
lines.push('');
|
|
125
|
+
lines.push(`**Body size:** \`${t.body.size}px\` / line-height \`${t.body.lineHeight ?? '1.5'}\`.`);
|
|
126
|
+
}
|
|
127
|
+
if (t.headings?.length) {
|
|
128
|
+
lines.push('');
|
|
129
|
+
lines.push('**Heading scale**');
|
|
130
|
+
lines.push('| level | size | weight | line-height |');
|
|
131
|
+
lines.push('|---|---|---|---|');
|
|
132
|
+
for (const [i, h] of t.headings.slice(0, 4).entries()) {
|
|
133
|
+
lines.push(`| h${i + 1} | \`${h.size}px\` | \`${h.weight}\` | \`${h.lineHeight}\` |`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return lines.join('\n');
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function sectionLayout(design) {
|
|
140
|
+
const sp = design.spacing || {};
|
|
141
|
+
const bp = design.breakpoints || [];
|
|
142
|
+
const layout = design.layout || {};
|
|
143
|
+
const lines = [];
|
|
144
|
+
if (sp.base) lines.push(`**Spacing base:** \`${sp.base}px\` increments.`);
|
|
145
|
+
if (sp.scale?.length) lines.push(`**Scale:** ${sp.scale.slice(0, 10).map(s => `\`${(s.value ?? s)}px\``).join(' ยท ')}`);
|
|
146
|
+
if (layout.gridCount != null || layout.flexCount != null) {
|
|
147
|
+
lines.push('');
|
|
148
|
+
lines.push(`**Layout primitives:** ${layout.gridCount ?? 0} grid containers ยท ${layout.flexCount ?? 0} flex containers.`);
|
|
149
|
+
}
|
|
150
|
+
if (bp.length) {
|
|
151
|
+
lines.push('');
|
|
152
|
+
lines.push(`**Breakpoints:** ${bp.map(b => `\`${b}px\``).join(' ยท ')}`);
|
|
153
|
+
}
|
|
154
|
+
return lines.join('\n') || '_No layout signals captured._';
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function sectionElevation(design) {
|
|
158
|
+
const sh = design.shadows?.values || [];
|
|
159
|
+
const z = design.zIndex || {};
|
|
160
|
+
const lines = [];
|
|
161
|
+
if (sh.length) {
|
|
162
|
+
lines.push('**Shadow scale**');
|
|
163
|
+
for (const s of sh.slice(0, 6)) {
|
|
164
|
+
lines.push(`- \`${s.label || '?'}\` โ \`${s.raw || s.value}\``);
|
|
165
|
+
}
|
|
166
|
+
} else {
|
|
167
|
+
lines.push('_No discrete shadow tokens detected โ flat material._');
|
|
168
|
+
}
|
|
169
|
+
if (z.allValues?.length) {
|
|
170
|
+
lines.push('');
|
|
171
|
+
lines.push(`**Z-index layers:** ${z.allValues.length}${z.issues?.length ? ` ยท โ ${z.issues.length} issue(s)` : ''}`);
|
|
172
|
+
}
|
|
173
|
+
return lines.join('\n');
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function sectionShapes(design) {
|
|
177
|
+
const r = design.borders?.radii || [];
|
|
178
|
+
const lines = [];
|
|
179
|
+
if (r.length) {
|
|
180
|
+
lines.push('**Radius scale**');
|
|
181
|
+
for (const x of r.slice(0, 6)) lines.push(`- \`${x.label || '?'}\` โ \`${x.value}px\``);
|
|
182
|
+
} else {
|
|
183
|
+
lines.push('_No discrete radius tokens detected โ sharp/brutalist shapes._');
|
|
184
|
+
}
|
|
185
|
+
return lines.join('\n');
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function sectionComponents(design) {
|
|
189
|
+
const lines = [];
|
|
190
|
+
const detected = Object.keys(design.components || {});
|
|
191
|
+
if (detected.length) {
|
|
192
|
+
lines.push(`**Detected patterns:** ${detected.map(c => `\`${c}\``).join(' ยท ')}`);
|
|
193
|
+
lines.push('');
|
|
194
|
+
}
|
|
195
|
+
const anatomies = design.componentAnatomy || [];
|
|
196
|
+
if (anatomies.length) {
|
|
197
|
+
lines.push('**Anatomy**');
|
|
198
|
+
lines.push('| kind | variants | sizes | instances |');
|
|
199
|
+
lines.push('|---|---|---|---|');
|
|
200
|
+
for (const a of anatomies.slice(0, 8)) {
|
|
201
|
+
const variants = (a.props?.variant || []).join(', ') || 'โ';
|
|
202
|
+
const sizes = (a.props?.size || []).join(', ') || 'โ';
|
|
203
|
+
lines.push(`| ${a.kind} | ${variants} | ${sizes} | ${a.totalInstances ?? 'โ'} |`);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
if (!lines.length) lines.push('_No component anatomy extracted._');
|
|
207
|
+
return lines.join('\n');
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function sectionDosDonts(design) {
|
|
211
|
+
const voice = design.voice || {};
|
|
212
|
+
const score = design.score || {};
|
|
213
|
+
const a11y = design.accessibility || {};
|
|
214
|
+
const lines = [];
|
|
215
|
+
|
|
216
|
+
lines.push("**Do's**");
|
|
217
|
+
const dos = [];
|
|
218
|
+
const ctas = (voice.ctaVerbs || []).slice(0, 3).map(v => v.value).filter(Boolean);
|
|
219
|
+
if (ctas.length) dos.push(`Use \`${ctas.join('\`, \`')}\` as the primary verbs in CTAs โ these dominate the source.`);
|
|
220
|
+
if (voice.headingStyle) dos.push(`Write headings in **${voice.headingStyle}** case, **${voice.headingLengthClass || 'balanced'}** length.`);
|
|
221
|
+
if (voice.pronoun && voice.pronoun !== 'neutral') dos.push(`Address the reader with the pronoun posture **${voice.pronoun}**.`);
|
|
222
|
+
if (design.materialLanguage?.label) dos.push(`Stay inside the **${design.materialLanguage.label}** material โ match shadow and radius habits.`);
|
|
223
|
+
if (!dos.length) dos.push('_No strong directional signals captured._');
|
|
224
|
+
for (const d of dos) lines.push(`- ${d}`);
|
|
225
|
+
|
|
226
|
+
lines.push('');
|
|
227
|
+
lines.push("**Don'ts**");
|
|
228
|
+
const donts = [];
|
|
229
|
+
if (a11y.failCount > 0) donts.push(`Don't ship copy on the colors flagged in accessibility โ ${a11y.failCount} contrast pair(s) fail WCAG AA on the source itself.`);
|
|
230
|
+
if (score.issues?.length) for (const i of score.issues.slice(0, 4)) donts.push(`Don't ${i.toLowerCase().replace(/^./, c => c.toLowerCase())}.`);
|
|
231
|
+
if (!donts.length) donts.push("_No anti-patterns surfaced. Don't invent new tokens โ reuse the scale above._");
|
|
232
|
+
for (const d of donts) lines.push(`- ${d}`);
|
|
233
|
+
|
|
234
|
+
return lines.join('\n');
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// โโโ YAML front matter โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
238
|
+
|
|
239
|
+
function frontMatter(design, version) {
|
|
240
|
+
const url = design.meta?.url || '';
|
|
241
|
+
const title = design.meta?.title || '';
|
|
242
|
+
const c = design.colors || {};
|
|
243
|
+
const t = design.typography || {};
|
|
244
|
+
const sp = design.spacing || {};
|
|
245
|
+
const r = design.borders?.radii || [];
|
|
246
|
+
const sh = design.shadows?.values || [];
|
|
247
|
+
|
|
248
|
+
const lines = ['---'];
|
|
249
|
+
lines.push(`site: ${yamlString(title || url)}`);
|
|
250
|
+
if (url) lines.push(`url: ${yamlString(url)}`);
|
|
251
|
+
lines.push(`generated_at: ${yamlString(new Date().toISOString())}`);
|
|
252
|
+
lines.push(`generator: ${yamlString(`designlang@${version}`)}`);
|
|
253
|
+
if (design.pageIntent?.type) lines.push(`intent: ${yamlString(design.pageIntent.type)}`);
|
|
254
|
+
if (design.materialLanguage?.label) lines.push(`material: ${yamlString(design.materialLanguage.label)}`);
|
|
255
|
+
if (design.componentLibrary?.library && design.componentLibrary.library !== 'unknown') {
|
|
256
|
+
lines.push(`library: ${yamlString(design.componentLibrary.library)}`);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
lines.push('tokens:');
|
|
260
|
+
lines.push(' colors:' + yamlMap({
|
|
261
|
+
primary: topColor(c, 'primary'),
|
|
262
|
+
secondary: topColor(c, 'secondary'),
|
|
263
|
+
accent: topColor(c, 'accent'),
|
|
264
|
+
background: c.backgrounds?.[0],
|
|
265
|
+
foreground: c.text?.[0],
|
|
266
|
+
}, ' '));
|
|
267
|
+
lines.push(' typography:' + yamlMap({
|
|
268
|
+
sans: t.families?.[0]?.name,
|
|
269
|
+
mono: t.families?.find(f => /mono/i.test(f.name))?.name,
|
|
270
|
+
base: t.body?.size,
|
|
271
|
+
}, ' '));
|
|
272
|
+
lines.push(' spacing:' + yamlMap({
|
|
273
|
+
base: sp.base,
|
|
274
|
+
scale: sp.scale?.length ? '[' + sp.scale.slice(0, 10).map(s => (s.value ?? s)).join(', ') + ']' : null,
|
|
275
|
+
}, ' '));
|
|
276
|
+
if (r.length) {
|
|
277
|
+
lines.push(' radii:' + yamlMap(Object.fromEntries(r.slice(0, 6).map(x => [x.label || `r${x.value}`, x.value])), ' '));
|
|
278
|
+
}
|
|
279
|
+
if (sh.length) {
|
|
280
|
+
lines.push(' shadows:' + yamlMap(Object.fromEntries(sh.slice(0, 4).map(x => [x.label || 'shadow', x.raw || x.value])), ' '));
|
|
281
|
+
}
|
|
282
|
+
lines.push('---');
|
|
283
|
+
return lines.join('\n');
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// โโโ Main โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
287
|
+
|
|
288
|
+
let CACHED_VERSION = null;
|
|
289
|
+
function pkgVersion() {
|
|
290
|
+
if (CACHED_VERSION) return CACHED_VERSION;
|
|
291
|
+
try {
|
|
292
|
+
const url = new URL('../../package.json', import.meta.url);
|
|
293
|
+
CACHED_VERSION = JSON.parse(readFileSync(url, 'utf-8')).version;
|
|
294
|
+
} catch { CACHED_VERSION = '0.0.0'; }
|
|
295
|
+
return CACHED_VERSION;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export function formatDesignMd(design) {
|
|
299
|
+
const version = pkgVersion();
|
|
300
|
+
const fm = frontMatter(design, version);
|
|
301
|
+
|
|
302
|
+
const sections = [
|
|
303
|
+
['Overview', sectionOverview(design)],
|
|
304
|
+
['Colors', sectionColors(design)],
|
|
305
|
+
['Typography', sectionTypography(design)],
|
|
306
|
+
['Layout', sectionLayout(design)],
|
|
307
|
+
['Elevation and Depth', sectionElevation(design)],
|
|
308
|
+
['Shapes', sectionShapes(design)],
|
|
309
|
+
['Components', sectionComponents(design)],
|
|
310
|
+
["Do's and Don'ts", sectionDosDonts(design)],
|
|
311
|
+
];
|
|
312
|
+
|
|
313
|
+
const body = sections.map(([h, b]) => `# ${h}\n\n${b || '_โ_'}\n`).join('\n');
|
|
314
|
+
|
|
315
|
+
const sourceUrl = design.meta?.url || '';
|
|
316
|
+
const footer = `\n---\n_Generated by [designlang](https://github.com/Manavarya09/design-extract) v${version} from <${sourceUrl}>._\n_Compatible with the DESIGN.md convention pioneered by [design-extractor.com](https://www.design-extractor.com) โ extended with intent, material, voice, anatomy, and library detection._\n`;
|
|
317
|
+
|
|
318
|
+
return `${fm}\n\n${body}${footer}`;
|
|
319
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
// Storybook emitter โ turns extracted componentAnatomy + design tokens into
|
|
2
|
+
// a runnable CSF3 Storybook project. Writes stories/*, .storybook/*, and a
|
|
3
|
+
// README showing the install + run steps. Zero Storybook dependency in
|
|
4
|
+
// designlang itself; the emitted project installs its own.
|
|
5
|
+
//
|
|
6
|
+
// Consumer wiring (bin):
|
|
7
|
+
// if (merged.storybook && (design.componentAnatomy || []).length) {
|
|
8
|
+
// const { formatStorybook } = await import('../src/formatters/storybook.js');
|
|
9
|
+
// const files = formatStorybook(design);
|
|
10
|
+
// for (const [rel, content] of Object.entries(files)) {
|
|
11
|
+
// writeFileSync(join(outDir, 'storybook', rel), content);
|
|
12
|
+
// }
|
|
13
|
+
// }
|
|
14
|
+
|
|
15
|
+
function cap(s) { return s.charAt(0).toUpperCase() + s.slice(1); }
|
|
16
|
+
|
|
17
|
+
function htmlTagFor(kind) {
|
|
18
|
+
return kind === 'input' ? 'input'
|
|
19
|
+
: kind === 'link' ? 'a'
|
|
20
|
+
: kind === 'card' ? 'div'
|
|
21
|
+
: 'button';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function storyFor(anatomy, tokenVars) {
|
|
25
|
+
const Name = cap(anatomy.kind);
|
|
26
|
+
const variants = anatomy.props.variant.length ? anatomy.props.variant : ['default'];
|
|
27
|
+
const sizes = anatomy.props.size.length ? anatomy.props.size : ['md'];
|
|
28
|
+
const tag = htmlTagFor(anatomy.kind);
|
|
29
|
+
const hasIcon = !!anatomy.slots?.icon;
|
|
30
|
+
const hasBadge = !!anatomy.slots?.badge;
|
|
31
|
+
|
|
32
|
+
const sampleLabel = anatomy.kind === 'card' ? 'Card content'
|
|
33
|
+
: anatomy.kind === 'input' ? ''
|
|
34
|
+
: anatomy.kind === 'link' ? 'Read more'
|
|
35
|
+
: 'Button';
|
|
36
|
+
|
|
37
|
+
// Render via inline React to keep the emitted project dependency-light.
|
|
38
|
+
const render = `(args) => {
|
|
39
|
+
const style = {
|
|
40
|
+
fontFamily: 'var(--font-sans, inherit)',
|
|
41
|
+
padding: args.size === 'sm' ? '6px 12px' : args.size === 'lg' ? '14px 22px' : '10px 16px',
|
|
42
|
+
borderRadius: 'var(--radius, 8px)',
|
|
43
|
+
background: args.variant === 'secondary' ? 'transparent'
|
|
44
|
+
: args.variant === 'outline' ? 'transparent'
|
|
45
|
+
: 'var(--color-primary, #3b82f6)',
|
|
46
|
+
color: args.variant === 'secondary' || args.variant === 'outline' ? 'var(--color-foreground, #111)' : '#fff',
|
|
47
|
+
border: args.variant === 'outline' ? '1px solid var(--color-foreground, #111)' : 'none',
|
|
48
|
+
fontWeight: 500,
|
|
49
|
+
cursor: 'pointer',
|
|
50
|
+
};
|
|
51
|
+
return React.createElement('${tag}', { style, 'data-variant': args.variant, 'data-size': args.size },
|
|
52
|
+
'${sampleLabel}'${hasIcon ? ", React.createElement('span', { style: { marginLeft: 8 } }, 'โ')" : ''}${hasBadge ? ", React.createElement('span', { style: { marginLeft: 6, padding: '2px 6px', background: '#f59e0b', borderRadius: 99, color: '#fff', fontSize: 11 } }, '3')" : ''}
|
|
53
|
+
);
|
|
54
|
+
}`;
|
|
55
|
+
|
|
56
|
+
return `import * as React from 'react';
|
|
57
|
+
import type { Meta, StoryObj } from '@storybook/react';
|
|
58
|
+
|
|
59
|
+
// Extracted from a live site by \`designlang\`. No runtime library โ
|
|
60
|
+
// these stories render inline to stay dependency-free.
|
|
61
|
+
const ${Name}: React.FC<{ variant?: string; size?: string }> = ${render};
|
|
62
|
+
|
|
63
|
+
const meta: Meta<typeof ${Name}> = {
|
|
64
|
+
title: 'Extracted/${Name}',
|
|
65
|
+
component: ${Name},
|
|
66
|
+
tags: ['autodocs'],
|
|
67
|
+
argTypes: {
|
|
68
|
+
variant: { control: 'select', options: [${variants.map(v => `'${v}'`).join(', ')}] },
|
|
69
|
+
size: { control: 'select', options: [${sizes.map(s => `'${s}'`).join(', ')}] },
|
|
70
|
+
},
|
|
71
|
+
parameters: {
|
|
72
|
+
docs: {
|
|
73
|
+
description: {
|
|
74
|
+
component: '${anatomy.kind} โ ${anatomy.totalInstances || 0} instances detected across the page.',
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
export default meta;
|
|
80
|
+
|
|
81
|
+
type Story = StoryObj<typeof ${Name}>;
|
|
82
|
+
|
|
83
|
+
${variants.map(v => `export const ${cap(v)}: Story = { args: { variant: '${v}', size: '${sizes[0]}' } };`).join('\n')}
|
|
84
|
+
|
|
85
|
+
export const Sizes: Story = {
|
|
86
|
+
render: () => React.createElement('div', { style: { display: 'flex', gap: 12, alignItems: 'center' } },
|
|
87
|
+
${sizes.map(s => `React.createElement(${Name}, { key: '${s}', variant: '${variants[0]}', size: '${s}' })`).join(',\n ')}
|
|
88
|
+
),
|
|
89
|
+
};
|
|
90
|
+
`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function tokensCss(design) {
|
|
94
|
+
const colors = design.colors || {};
|
|
95
|
+
const primary = colors.primary?.hex || '#3b82f6';
|
|
96
|
+
const secondary = colors.secondary?.hex || '#8b5cf6';
|
|
97
|
+
const accent = colors.accent?.hex || '#f59e0b';
|
|
98
|
+
const bg = colors.backgrounds?.[0] || '#ffffff';
|
|
99
|
+
const fg = colors.text?.[0] || '#171717';
|
|
100
|
+
const radii = design.borders?.radii || [];
|
|
101
|
+
const radius = radii.find(r => r.label === 'md')?.value ?? 8;
|
|
102
|
+
const shadow = design.shadows?.values?.find(s => s.label === 'md')?.raw || '0 4px 6px rgba(0,0,0,0.1)';
|
|
103
|
+
const font = design.typography?.families?.[0]?.name || 'Inter';
|
|
104
|
+
return `:root {
|
|
105
|
+
--color-primary: ${primary};
|
|
106
|
+
--color-secondary: ${secondary};
|
|
107
|
+
--color-accent: ${accent};
|
|
108
|
+
--color-background: ${bg};
|
|
109
|
+
--color-foreground: ${fg};
|
|
110
|
+
--radius: ${radius}px;
|
|
111
|
+
--shadow: ${shadow};
|
|
112
|
+
--font-sans: '${font}', system-ui, sans-serif;
|
|
113
|
+
}
|
|
114
|
+
body { background: var(--color-background); color: var(--color-foreground); font-family: var(--font-sans); }
|
|
115
|
+
`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function formatStorybook(design) {
|
|
119
|
+
const anatomies = design.componentAnatomy || [];
|
|
120
|
+
const files = {};
|
|
121
|
+
|
|
122
|
+
// Story files (one per kind).
|
|
123
|
+
for (const a of anatomies) {
|
|
124
|
+
const Name = cap(a.kind);
|
|
125
|
+
files[`stories/${Name}.stories.tsx`] = storyFor(a, design);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// A tokens page so people can see the palette/scale inline.
|
|
129
|
+
files['stories/Tokens.mdx'] = `import { Meta, ColorPalette, ColorItem, Typeset } from '@storybook/blocks';
|
|
130
|
+
|
|
131
|
+
<Meta title="Extracted/Tokens" />
|
|
132
|
+
|
|
133
|
+
# Design tokens
|
|
134
|
+
|
|
135
|
+
Extracted from ${design.meta?.url || 'the target site'} by [designlang](https://github.com/Manavarya09/design-extract).
|
|
136
|
+
|
|
137
|
+
<ColorPalette>
|
|
138
|
+
<ColorItem title="primary" colors={{ primary: '${design.colors?.primary?.hex || '#3b82f6'}' }} />
|
|
139
|
+
<ColorItem title="secondary" colors={{ secondary: '${design.colors?.secondary?.hex || '#8b5cf6'}' }} />
|
|
140
|
+
<ColorItem title="accent" colors={{ accent: '${design.colors?.accent?.hex || '#f59e0b'}' }} />
|
|
141
|
+
<ColorItem title="background" colors={{ background: '${design.colors?.backgrounds?.[0] || '#ffffff'}' }} />
|
|
142
|
+
<ColorItem title="foreground" colors={{ foreground: '${design.colors?.text?.[0] || '#171717'}' }} />
|
|
143
|
+
</ColorPalette>
|
|
144
|
+
`;
|
|
145
|
+
|
|
146
|
+
files['stories/tokens.css'] = tokensCss(design);
|
|
147
|
+
|
|
148
|
+
files['.storybook/main.ts'] = `import type { StorybookConfig } from '@storybook/react-vite';
|
|
149
|
+
|
|
150
|
+
const config: StorybookConfig = {
|
|
151
|
+
stories: ['../stories/**/*.mdx', '../stories/**/*.stories.@(ts|tsx)'],
|
|
152
|
+
addons: ['@storybook/addon-essentials', '@storybook/addon-docs'],
|
|
153
|
+
framework: { name: '@storybook/react-vite', options: {} },
|
|
154
|
+
};
|
|
155
|
+
export default config;
|
|
156
|
+
`;
|
|
157
|
+
|
|
158
|
+
files['.storybook/preview.ts'] = `import type { Preview } from '@storybook/react';
|
|
159
|
+
import './../stories/tokens.css';
|
|
160
|
+
|
|
161
|
+
const preview: Preview = {
|
|
162
|
+
parameters: {
|
|
163
|
+
backgrounds: { default: 'paper' },
|
|
164
|
+
controls: { matchers: { color: /(background|color)$/i } },
|
|
165
|
+
},
|
|
166
|
+
};
|
|
167
|
+
export default preview;
|
|
168
|
+
`;
|
|
169
|
+
|
|
170
|
+
files['package.json'] = JSON.stringify({
|
|
171
|
+
name: `${(design.meta?.title || 'extracted').toLowerCase().replace(/[^a-z0-9]/g, '-').slice(0, 40) || 'extracted'}-storybook`,
|
|
172
|
+
private: true,
|
|
173
|
+
version: '0.1.0',
|
|
174
|
+
type: 'module',
|
|
175
|
+
scripts: {
|
|
176
|
+
storybook: 'storybook dev -p 6006',
|
|
177
|
+
'build-storybook': 'storybook build',
|
|
178
|
+
},
|
|
179
|
+
dependencies: { react: '^19.0.0', 'react-dom': '^19.0.0' },
|
|
180
|
+
devDependencies: {
|
|
181
|
+
'@storybook/addon-essentials': '^8.0.0',
|
|
182
|
+
'@storybook/addon-docs': '^8.0.0',
|
|
183
|
+
'@storybook/blocks': '^8.0.0',
|
|
184
|
+
'@storybook/react': '^8.0.0',
|
|
185
|
+
'@storybook/react-vite': '^8.0.0',
|
|
186
|
+
'@types/react': '^19.0.0',
|
|
187
|
+
'@types/react-dom': '^19.0.0',
|
|
188
|
+
storybook: '^8.0.0',
|
|
189
|
+
typescript: '^5.0.0',
|
|
190
|
+
vite: '^5.0.0',
|
|
191
|
+
},
|
|
192
|
+
}, null, 2);
|
|
193
|
+
|
|
194
|
+
files['tsconfig.json'] = JSON.stringify({
|
|
195
|
+
compilerOptions: {
|
|
196
|
+
target: 'ES2020',
|
|
197
|
+
module: 'ESNext',
|
|
198
|
+
moduleResolution: 'Bundler',
|
|
199
|
+
jsx: 'react-jsx',
|
|
200
|
+
strict: true,
|
|
201
|
+
esModuleInterop: true,
|
|
202
|
+
skipLibCheck: true,
|
|
203
|
+
},
|
|
204
|
+
include: ['stories/**/*', '.storybook/**/*'],
|
|
205
|
+
}, null, 2);
|
|
206
|
+
|
|
207
|
+
files['README.md'] = `# ${design.meta?.title || 'Extracted'} ยท Storybook
|
|
208
|
+
|
|
209
|
+
Auto-generated by \`designlang <url> --storybook\`.
|
|
210
|
+
|
|
211
|
+
## Stories
|
|
212
|
+
${anatomies.map(a => `- **${cap(a.kind)}** โ ${a.props.variant.length || 1} variant(s), ${a.props.size.length || 1} size(s), ${a.totalInstances || 0} detected`).join('\n') || '_No anatomy detected on the source page._'}
|
|
213
|
+
|
|
214
|
+
## Run
|
|
215
|
+
|
|
216
|
+
\`\`\`
|
|
217
|
+
npm install
|
|
218
|
+
npm run storybook
|
|
219
|
+
\`\`\`
|
|
220
|
+
|
|
221
|
+
Opens at http://localhost:6006.
|
|
222
|
+
`;
|
|
223
|
+
|
|
224
|
+
return files;
|
|
225
|
+
}
|
package/src/replay.js
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// Motion replay โ records a short WebM of a site with scripted
|
|
2
|
+
// interactions (hover/scroll) so the captured motion actually fires.
|
|
3
|
+
// Zero new deps: Playwright ships video recording out of the box.
|
|
4
|
+
//
|
|
5
|
+
// Output: <prefix>-motion.webm (+ optional MP4 if ffmpeg is on PATH)
|
|
6
|
+
//
|
|
7
|
+
// Usage: designlang replay <url> [--duration 5] [--out dir]
|
|
8
|
+
|
|
9
|
+
import { chromium } from 'playwright';
|
|
10
|
+
import { mkdirSync, existsSync, readdirSync, statSync, renameSync, unlinkSync, rmdirSync } from 'fs';
|
|
11
|
+
import { resolve, join } from 'path';
|
|
12
|
+
import { spawnSync } from 'child_process';
|
|
13
|
+
|
|
14
|
+
async function ensureFfmpeg() {
|
|
15
|
+
try {
|
|
16
|
+
const r = spawnSync('ffmpeg', ['-version'], { stdio: 'ignore' });
|
|
17
|
+
return r.status === 0;
|
|
18
|
+
} catch { return false; }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function toMp4(webmPath) {
|
|
22
|
+
const mp4Path = webmPath.replace(/\.webm$/, '.mp4');
|
|
23
|
+
const r = spawnSync('ffmpeg', ['-y', '-i', webmPath, '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-movflags', '+faststart', mp4Path], { stdio: 'ignore' });
|
|
24
|
+
return r.status === 0 ? mp4Path : null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function recordReplay(url, opts = {}) {
|
|
28
|
+
const outDir = resolve(opts.out || './design-extract-output');
|
|
29
|
+
mkdirSync(outDir, { recursive: true });
|
|
30
|
+
const prefix = opts.prefix || 'motion-replay';
|
|
31
|
+
const duration = Math.max(2, Math.min(15, parseInt(opts.duration) || 5));
|
|
32
|
+
const width = parseInt(opts.width) || 1280;
|
|
33
|
+
const height = parseInt(opts.height) || 800;
|
|
34
|
+
|
|
35
|
+
const videoDir = join(outDir, `.playwright-video-${Date.now()}`);
|
|
36
|
+
mkdirSync(videoDir, { recursive: true });
|
|
37
|
+
|
|
38
|
+
const browser = await chromium.launch({ headless: true });
|
|
39
|
+
const context = await browser.newContext({
|
|
40
|
+
viewport: { width, height },
|
|
41
|
+
recordVideo: { dir: videoDir, size: { width, height } },
|
|
42
|
+
reducedMotion: 'no-preference',
|
|
43
|
+
});
|
|
44
|
+
const page = await context.newPage();
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
await page.goto(url, { waitUntil: 'networkidle', timeout: 25000 });
|
|
48
|
+
} catch {
|
|
49
|
+
try { await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 15000 }); } catch {}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Scripted motion pass: slow scroll โ hover first CTA โ scroll back.
|
|
53
|
+
const frameMs = 50;
|
|
54
|
+
const steps = Math.floor((duration * 1000) / frameMs);
|
|
55
|
+
for (let i = 0; i < steps; i++) {
|
|
56
|
+
const progress = i / steps;
|
|
57
|
+
// Slow smooth scroll across the page
|
|
58
|
+
await page.evaluate((p) => {
|
|
59
|
+
const max = Math.max(document.body.scrollHeight - window.innerHeight, 0);
|
|
60
|
+
window.scrollTo({ top: max * p, behavior: 'auto' });
|
|
61
|
+
}, progress).catch(() => {});
|
|
62
|
+
// Partway through, hover a button to trigger transition states.
|
|
63
|
+
if (i === Math.floor(steps / 2)) {
|
|
64
|
+
try {
|
|
65
|
+
const btn = await page.$('button, [role="button"], a.btn, .button, [class*="btn"]');
|
|
66
|
+
if (btn) await btn.hover({ timeout: 1000 }).catch(() => {});
|
|
67
|
+
} catch {}
|
|
68
|
+
}
|
|
69
|
+
await page.waitForTimeout(frameMs);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const videoPromise = page.video();
|
|
73
|
+
await context.close();
|
|
74
|
+
await browser.close();
|
|
75
|
+
|
|
76
|
+
// Move the captured WebM out of the Playwright temp dir.
|
|
77
|
+
const finalWebm = join(outDir, `${prefix}.webm`);
|
|
78
|
+
try {
|
|
79
|
+
const video = await videoPromise;
|
|
80
|
+
if (video && typeof video.path === 'function') {
|
|
81
|
+
const src = await video.path();
|
|
82
|
+
if (src && existsSync(src)) {
|
|
83
|
+
renameSync(src, finalWebm);
|
|
84
|
+
}
|
|
85
|
+
} else {
|
|
86
|
+
// Fallback: grab whatever .webm Playwright wrote to the dir.
|
|
87
|
+
const produced = readdirSync(videoDir).filter(f => f.endsWith('.webm'))
|
|
88
|
+
.map(f => ({ f, t: statSync(join(videoDir, f)).mtimeMs }))
|
|
89
|
+
.sort((a, b) => b.t - a.t)[0];
|
|
90
|
+
if (produced) renameSync(join(videoDir, produced.f), finalWebm);
|
|
91
|
+
}
|
|
92
|
+
} catch { /* no video โ fall through with null result */ }
|
|
93
|
+
|
|
94
|
+
// Clean the scratch dir.
|
|
95
|
+
try {
|
|
96
|
+
for (const f of readdirSync(videoDir)) unlinkSync(join(videoDir, f));
|
|
97
|
+
rmdirSync(videoDir);
|
|
98
|
+
} catch {}
|
|
99
|
+
|
|
100
|
+
const result = {
|
|
101
|
+
url,
|
|
102
|
+
webm: existsSync(finalWebm) ? finalWebm : null,
|
|
103
|
+
mp4: null,
|
|
104
|
+
duration,
|
|
105
|
+
width,
|
|
106
|
+
height,
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
if (result.webm && opts.mp4 && await ensureFfmpeg()) {
|
|
110
|
+
const mp4 = toMp4(result.webm);
|
|
111
|
+
if (mp4) result.mp4 = mp4;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return result;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Tiny snippet that can be spliced into the existing HTML preview to embed
|
|
118
|
+
// the replay next to the static screenshot. Consumer decides where to place.
|
|
119
|
+
export function replayEmbedHtml(result) {
|
|
120
|
+
if (!result?.webm) return '';
|
|
121
|
+
const src = result.webm.split(/[\\/]/).pop();
|
|
122
|
+
return `<figure class="motion-replay" style="margin:24px 0">
|
|
123
|
+
<video autoplay loop muted playsinline style="width:100%;border:1px solid #0a0908">
|
|
124
|
+
<source src="${src}" type="video/webm" />
|
|
125
|
+
${result.mp4 ? `<source src="${result.mp4.split(/[\\/]/).pop()}" type="video/mp4" />` : ''}
|
|
126
|
+
</video>
|
|
127
|
+
<figcaption style="font-family:var(--font-mono, monospace);font-size:11px;letter-spacing:0.12em;text-transform:uppercase;color:#403c34;margin-top:8px">
|
|
128
|
+
motion replay ยท ${result.duration}s ยท ${result.width}ร${result.height}
|
|
129
|
+
</figcaption>
|
|
130
|
+
</figure>`;
|
|
131
|
+
}
|