designlang 10.4.0 → 11.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## [10.5.0] — 2026-04-22
4
+
5
+ **The states LLMs always botch.**
6
+
7
+ ### Added
8
+
9
+ - **`src/extractors/form-states.js`** — surfaces forms (input count + style families), modal/dialog/sheet containers, skeleton and spinner loading indicators, empty-state and error-state placeholders, and detects which toast library is on the page (Sonner, react-hot-toast, react-toastify, Radix Toast, Chakra Toast, Notistack).
10
+ - New output: `*-form-states.json`.
11
+
12
+ Closes the v10.x minor-release series started in v10.1. Everything the
13
+ v10 spec deferred for v11 is now shipped as minor releases — with no
14
+ breaking changes along the way.
15
+
3
16
  ## [10.4.0] — 2026-04-22
4
17
 
5
18
  **Identification trio: icon system, background patterns, stack intel.**
package/README.md CHANGED
@@ -7,7 +7,7 @@
7
7
  <a href="https://github.com/Manavarya09/design-extract/blob/main/LICENSE"><img src="https://img.shields.io/github/license/Manavarya09/design-extract?color=0A0908&labelColor=F3F1EA" alt="license"></a>
8
8
  <a href="https://nodejs.org"><img src="https://img.shields.io/node/v/designlang?color=0A0908&labelColor=F3F1EA" alt="node version"></a>
9
9
  <a href="https://designlang.manavaryasingh.com/"><img src="https://img.shields.io/badge/website-live-FF4800?labelColor=F3F1EA" alt="website"></a>
10
- [![SafeSkill 77/100](https://img.shields.io/badge/SafeSkill-77%2F100_Passes%20with%20Notes-yellow)](https://safeskill.dev/scan/manavarya09-design-extract)
10
+
11
11
  </p>
12
12
 
13
13
  ---
@@ -634,7 +634,7 @@ In Claude Code, use `/extract-design <url>`.
634
634
 
635
635
  ## Website
636
636
 
637
- **[design-extract-beta.vercel.app](https://design-extract-beta.vercel.app)** — the brutalist product page.
637
+ **[https://designlang.manavaryasingh.com](https://designlang.manavaryasingh.com/)** — the brutalist product page.
638
638
 
639
639
  ## Contributing
640
640
 
@@ -376,6 +376,9 @@ program
376
376
  if (design.stackIntel) {
377
377
  files.push({ name: `${prefix}-stack-intel.json`, content: JSON.stringify(design.stackIntel, null, 2), label: 'Stack Intel (CMS/analytics/experimentation)' });
378
378
  }
379
+ if (design.formStates) {
380
+ files.push({ name: `${prefix}-form-states.json`, content: JSON.stringify(design.formStates, null, 2), label: 'Forms + States' });
381
+ }
379
382
  if (merged.prompts !== false) {
380
383
  const pack = buildPromptPack(design);
381
384
  const promptsDir = join(outDir, `${prefix}-prompts`);
@@ -1053,6 +1056,66 @@ program
1053
1056
  }
1054
1057
  });
1055
1058
 
1059
+ // ── CI command — single PR-comment-ready report ────────────
1060
+ program
1061
+ .command('ci <url>')
1062
+ .description('One-shot design regression report — drift + score + PR-ready markdown. Works in any CI.')
1063
+ .option('--tokens <file>', 'local tokens file (.json or .css) to compare against the live site')
1064
+ .option('--baseline <url>', 'baseline URL for a before/after visual diff')
1065
+ .option('--tolerance <n>', 'color distance tolerance (0-50)', parseInt, 8)
1066
+ .option('--fail-on <level>', 'exit non-zero on: minor-drift | notable-drift | major-drift', 'notable-drift')
1067
+ .option('-o, --out <dir>', 'output directory', '.designlang-ci')
1068
+ .option('--quiet', 'suppress stdout (still writes files)')
1069
+ .action(async (url, opts) => {
1070
+ if (!url.startsWith('http')) url = `https://${url}`;
1071
+ validateUrl(url);
1072
+ const spinner = opts.quiet ? { start() { return this; }, succeed() {}, fail() {}, set text(v) {} } : ora('Running CI report...').start();
1073
+ try {
1074
+ const { runCi } = await import('../src/ci.js');
1075
+ const r = await runCi(url, opts);
1076
+ spinner.succeed(`CI report written → ${r.mdPath}`);
1077
+ if (!opts.quiet) {
1078
+ console.log('');
1079
+ console.log(r.md);
1080
+ }
1081
+ if (r.shouldFail) process.exit(1);
1082
+ } catch (err) {
1083
+ spinner.fail(err.message);
1084
+ process.exit(1);
1085
+ }
1086
+ });
1087
+
1088
+ // ── Studio — local web studio over the latest extraction ──
1089
+ program
1090
+ .command('studio')
1091
+ .description('Launch a local web studio over the latest extraction (editorial token browser, voice, motion, DNA).')
1092
+ .option('-d, --dir <path>', 'extraction directory', './design-extract-output')
1093
+ .option('-p, --port <n>', 'port', parseInt, 4837)
1094
+ .option('--prefix <name>', 'extraction prefix (default: newest *-design-tokens.json)')
1095
+ .option('--no-open', 'do not auto-open the browser')
1096
+ .action(async (opts) => {
1097
+ try {
1098
+ const { runStudio } = await import('../src/studio.js');
1099
+ const { port, dir, prefix } = await runStudio(opts);
1100
+ console.log('');
1101
+ console.log(chalk.bold(' designlang studio'));
1102
+ console.log(chalk.gray(` serving ${dir}`));
1103
+ console.log(chalk.gray(` prefix: ${prefix}`));
1104
+ console.log('');
1105
+ console.log(` ${chalk.green('→')} ${chalk.cyan(`http://localhost:${port}`)}`);
1106
+ console.log('');
1107
+ console.log(chalk.gray(' Ctrl+C to stop.'));
1108
+ if (opts.open !== false) {
1109
+ const { spawn } = await import('child_process');
1110
+ const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
1111
+ try { spawn(cmd, [`http://localhost:${port}`], { stdio: 'ignore', detached: true }).unref(); } catch {}
1112
+ }
1113
+ } catch (err) {
1114
+ console.error(chalk.red(`\n ${err.message}\n`));
1115
+ process.exit(1);
1116
+ }
1117
+ });
1118
+
1056
1119
  // ── MCP server command ─────────────────────────────────────
1057
1120
  program
1058
1121
  .command('mcp')
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "designlang",
3
- "version": "10.4.0",
4
- "description": "Extract the complete design language from any website colors, typography, spacing, shadows, motion, component anatomy, brand voice, page intent, section roles, material language, component library, imagery style, and logo. Outputs AI-optimized markdown, W3C design tokens, motion tokens, typed component stubs, Tailwind config, and ready-to-paste v0 / Lovable / Cursor / Claude-Artifacts prompts.",
3
+ "version": "11.0.0",
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": {
7
7
  "designlang": "./bin/design-extract.js"
package/src/ci.js ADDED
@@ -0,0 +1,140 @@
1
+ // ci — produce a single PR-comment-ready markdown block combining
2
+ // drift, score, and optional visual-diff. Works in any CI (GitHub,
3
+ // GitLab, CircleCI, local). Exits non-zero when the verdict exceeds
4
+ // the configured threshold.
5
+ //
6
+ // Usage: designlang ci <url> --tokens ./tokens.json [--baseline <url>]
7
+
8
+ import { readFileSync, writeFileSync, mkdirSync } from 'fs';
9
+ import { resolve, join } from 'path';
10
+
11
+ const VERDICT_ORDER = ['in-sync', 'minor-drift', 'notable-drift', 'major-drift'];
12
+
13
+ function bar(score, width = 20) {
14
+ const filled = Math.round((score / 100) * width);
15
+ return '█'.repeat(filled) + '░'.repeat(width - filled);
16
+ }
17
+
18
+ function gradeBadge(grade) {
19
+ const map = { A: '🟢', B: '🔵', C: '🟡', D: '🟠', F: '🔴' };
20
+ return map[grade] || '⚪';
21
+ }
22
+
23
+ function section(title, body) {
24
+ return `### ${title}\n\n${body}\n`;
25
+ }
26
+
27
+ export async function runCi(url, opts) {
28
+ const out = [];
29
+ const outDir = resolve(opts.out || '.designlang-ci');
30
+ mkdirSync(outDir, { recursive: true });
31
+
32
+ out.push(`## designlang · design regression guard`);
33
+ out.push(`\n**URL:** \`${url}\` \n**Run:** ${new Date().toISOString()}\n`);
34
+
35
+ // 1. Extract
36
+ const { extractDesignLanguage } = await import('./index.js');
37
+ const design = await extractDesignLanguage(url);
38
+
39
+ // 2. Score block
40
+ if (design.score) {
41
+ const s = design.score;
42
+ const lines = [];
43
+ lines.push(`**Overall:** ${gradeBadge(s.grade)} \`${s.overall}/100\` · grade **${s.grade}**`);
44
+ lines.push('');
45
+ lines.push('```');
46
+ for (const [k, v] of Object.entries(s.scores || {})) {
47
+ lines.push(`${k.padEnd(24)} ${bar(v)} ${String(v).padStart(3)}`);
48
+ }
49
+ lines.push('```');
50
+ if (s.issues?.length) {
51
+ lines.push('');
52
+ lines.push('**Issues:**');
53
+ for (const i of s.issues.slice(0, 6)) lines.push(`- ⚠️ ${i}`);
54
+ }
55
+ out.push(section('Score', lines.join('\n')));
56
+ }
57
+
58
+ // 3. Drift
59
+ let driftVerdict = 'in-sync';
60
+ if (opts.tokens) {
61
+ try {
62
+ const { checkDrift } = await import('./drift.js');
63
+ const drift = await checkDrift(url, {
64
+ tokens: resolve(opts.tokens),
65
+ tolerance: opts.tolerance || 8,
66
+ });
67
+ driftVerdict = drift.verdict;
68
+ const lines = [];
69
+ lines.push(`**Verdict:** \`${drift.verdict}\` · ratio \`${(drift.ratio ?? 0).toFixed(3)}\``);
70
+ lines.push('');
71
+ if (drift.matched?.length) lines.push(`- ✓ matched: **${drift.matched.length}**`);
72
+ if (drift.drifted?.length) lines.push(`- ≠ drifted: **${drift.drifted.length}**`);
73
+ if (drift.unknown?.length) lines.push(`- ? unknown: **${drift.unknown.length}**`);
74
+ if (drift.drifted?.length) {
75
+ lines.push('');
76
+ lines.push('<details><summary>Drifted tokens</summary>\n');
77
+ lines.push('| token | local | live | distance |');
78
+ lines.push('|---|---|---|---|');
79
+ for (const d of drift.drifted.slice(0, 25)) {
80
+ lines.push(`| \`${d.token || d.name || '?'}\` | \`${d.local ?? d.from ?? ''}\` | \`${d.live ?? d.to ?? ''}\` | ${d.distance ?? ''} |`);
81
+ }
82
+ lines.push('\n</details>');
83
+ }
84
+ out.push(section('Drift · local tokens vs. live', lines.join('\n')));
85
+ } catch (e) {
86
+ out.push(section('Drift', `_skipped — ${e.message}_`));
87
+ }
88
+ } else {
89
+ out.push(section('Drift', `_skipped — pass \`--tokens <file>\` to compare local tokens against the live site._`));
90
+ }
91
+
92
+ // 4. Baseline visual diff (optional)
93
+ if (opts.baseline) {
94
+ try {
95
+ const { visualDiff } = await import('./visual-diff.js');
96
+ const r = await visualDiff({ beforeUrl: opts.baseline, afterUrl: url });
97
+ const changed = (r.tokenDiffs || r.changes || []).length || 0;
98
+ const lines = [];
99
+ lines.push(`**Baseline:** \`${opts.baseline}\``);
100
+ lines.push(`**Token changes:** ${changed}`);
101
+ out.push(section('Visual diff vs. baseline URL', lines.join('\n')));
102
+ } catch (e) {
103
+ out.push(section('Visual diff', `_skipped — ${e.message}_`));
104
+ }
105
+ }
106
+
107
+ // 5. Footer
108
+ out.push(`---\n<sub>Generated by \`designlang ci\` · [docs](https://github.com/Manavarya09/design-extract)</sub>\n`);
109
+
110
+ const md = out.join('\n');
111
+ const mdPath = join(outDir, 'ci-report.md');
112
+ writeFileSync(mdPath, md, 'utf-8');
113
+
114
+ // Emit the actionable pieces too — easy for jq / downstream.
115
+ const summary = {
116
+ url,
117
+ score: design.score?.overall ?? null,
118
+ grade: design.score?.grade ?? null,
119
+ driftVerdict,
120
+ timestamp: new Date().toISOString(),
121
+ };
122
+ writeFileSync(join(outDir, 'ci-summary.json'), JSON.stringify(summary, null, 2), 'utf-8');
123
+
124
+ // GitHub Actions niceties: if running in GHA, write the report to the step summary.
125
+ if (process.env.GITHUB_STEP_SUMMARY) {
126
+ try { writeFileSync(process.env.GITHUB_STEP_SUMMARY, md, { flag: 'a' }); } catch {}
127
+ }
128
+
129
+ const threshold = opts.failOn || 'notable-drift';
130
+ const failIndex = VERDICT_ORDER.indexOf(threshold);
131
+ const actualIndex = VERDICT_ORDER.indexOf(driftVerdict);
132
+ const shouldFail = failIndex >= 0 && actualIndex >= failIndex;
133
+
134
+ return {
135
+ mdPath,
136
+ md,
137
+ summary,
138
+ shouldFail,
139
+ };
140
+ }
package/src/clone.js CHANGED
@@ -1,37 +1,306 @@
1
- // Clone command — generate a working Next.js starter from extracted design
1
+ // Clone — generate a working Next.js starter that mirrors a site's
2
+ // extracted design language: section order, voice, material, tokens.
3
+ //
4
+ // v2: driven by sectionRoles.readingOrder + voice + materialLanguage,
5
+ // so the emitted page is a faithful structural mirror of the target,
6
+ // not a generic token-showcase.
2
7
 
3
8
  import { mkdirSync, writeFileSync } from 'fs';
4
9
  import { join } from 'path';
5
10
 
11
+ const KNOWN_ROLES = new Set([
12
+ 'hero', 'logo-wall', 'feature-grid', 'stats', 'testimonial',
13
+ 'pricing-table', 'faq', 'steps', 'comparison', 'gallery',
14
+ 'bento', 'cta', 'footer',
15
+ ]);
16
+
17
+ function dedupeConsecutive(order) {
18
+ const out = [];
19
+ for (const r of order) if (r !== out[out.length - 1]) out.push(r);
20
+ return out;
21
+ }
22
+
23
+ function sanitize(str, fallback = '') {
24
+ return String(str ?? fallback).replace(/`/g, '\\`').replace(/\$\{/g, '\\${');
25
+ }
26
+
27
+ function titleFromUrl(url = '') {
28
+ try { return new URL(url).hostname.replace(/^www\./, ''); } catch { return 'this site'; }
29
+ }
30
+
31
+ function pickHeading(voice, fallback) {
32
+ const s = (voice?.sampleHeadings || []).find(h => h && h.length > 4 && h.length < 80);
33
+ return s || fallback;
34
+ }
35
+
36
+ function materialPreset(material = {}) {
37
+ const label = material?.label || 'flat';
38
+ switch (label) {
39
+ case 'brutalist': return { radius: '0px', shadow: '4px 4px 0 0 currentColor', border: '2px solid currentColor', cardBorder: '2px solid var(--color-foreground)' };
40
+ case 'glass': return { radius: '16px', shadow: '0 8px 32px rgba(0,0,0,0.08)', border: '1px solid rgba(255,255,255,0.18)', cardBorder: '1px solid var(--color-neutral-2)', backdrop: 'backdrop-filter: blur(12px);' };
41
+ case 'soft-ui':
42
+ case 'neumorphism': return { radius: '24px', shadow: '12px 12px 24px rgba(0,0,0,0.08), -12px -12px 24px rgba(255,255,255,0.6)', border: 'none', cardBorder: 'none' };
43
+ case 'flat': return { radius: 'var(--radius)', shadow: 'none', border: '1px solid var(--color-neutral-2)', cardBorder: '1px solid var(--color-neutral-2)' };
44
+ case 'material-you': return { radius: '28px', shadow: 'var(--shadow)', border: 'none', cardBorder: 'none' };
45
+ default: return { radius: 'var(--radius)', shadow: 'var(--shadow)', border: '1px solid var(--color-neutral-2)', cardBorder: '1px solid var(--color-neutral-2)' };
46
+ }
47
+ }
48
+
49
+ function primaryCta(voice) {
50
+ const verb = voice?.ctaVerbs?.[0]?.value || 'Get started';
51
+ return verb.charAt(0).toUpperCase() + verb.slice(1);
52
+ }
53
+
54
+ function secondaryCta(voice) {
55
+ const verb = voice?.ctaVerbs?.[1]?.value || 'Learn more';
56
+ return verb.charAt(0).toUpperCase() + verb.slice(1);
57
+ }
58
+
59
+ // ─── Section renderers ─────────────────────────────────────────
60
+
61
+ function renderHero(ctx) {
62
+ const { voice, intent, url, mat, headings, bodySize } = ctx;
63
+ const lede = pickHeading(voice, `A ${intent || 'product'} that deserves its own design system.`);
64
+ const primary = primaryCta(voice);
65
+ const secondary = secondaryCta(voice);
66
+ const h0 = headings[0] || { size: 56, weight: 700, lineHeight: '1.05' };
67
+ return `
68
+ <section style={{ padding: '96px 0 72px', textAlign: 'left', maxWidth: '880px' }}>
69
+ <div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, letterSpacing: '0.14em', textTransform: 'uppercase', color: 'var(--color-neutral-1)', marginBottom: 24 }}>
70
+ cloned · ${sanitize(titleFromUrl(url))}
71
+ </div>
72
+ <h1 style={{ fontSize: 'clamp(40px, 6vw, ${h0.size}px)', fontWeight: ${h0.weight}, lineHeight: '${h0.lineHeight}', letterSpacing: '-0.025em', marginBottom: 24 }}>
73
+ ${sanitize(lede)}
74
+ </h1>
75
+ <p style={{ fontSize: ${bodySize + 4}, lineHeight: 1.55, color: 'var(--color-neutral-1)', maxWidth: '52ch', marginBottom: 32 }}>
76
+ Every token, section, button verb and shadow on this page was extracted from the live site — nothing invented.
77
+ </p>
78
+ <div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
79
+ <button style={{ background: 'var(--color-primary)', color: '#fff', border: '${mat.border === 'none' ? 'none' : '1px solid var(--color-primary)'}', padding: '14px 22px', borderRadius: '${mat.radius}', boxShadow: '${mat.shadow}', fontSize: ${bodySize}, fontWeight: 600, cursor: 'pointer' }}>
80
+ ${sanitize(primary)}
81
+ </button>
82
+ <button style={{ background: 'transparent', color: 'var(--color-foreground)', border: '1px solid var(--color-foreground)', padding: '14px 22px', borderRadius: '${mat.radius}', fontSize: ${bodySize}, fontWeight: 500, cursor: 'pointer' }}>
83
+ ${sanitize(secondary)}
84
+ </button>
85
+ </div>
86
+ </section>`;
87
+ }
88
+
89
+ function renderLogoWall() {
90
+ return `
91
+ <section style={{ padding: '48px 0', borderTop: '1px solid var(--color-neutral-2)', borderBottom: '1px solid var(--color-neutral-2)' }}>
92
+ <p style={{ fontFamily: 'var(--font-mono)', fontSize: 11, letterSpacing: '0.14em', textTransform: 'uppercase', color: 'var(--color-neutral-1)', marginBottom: 24 }}>trusted by teams at</p>
93
+ <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(120px, 1fr))', gap: 24, alignItems: 'center', opacity: 0.72 }}>
94
+ {['Acme', 'Northwind', 'Initech', 'Umbrella', 'Stark', 'Wayne'].map(n => (
95
+ <div key={n} style={{ fontFamily: 'var(--font-mono)', fontSize: 14, letterSpacing: '0.08em', textTransform: 'uppercase' }}>{n}</div>
96
+ ))}
97
+ </div>
98
+ </section>`;
99
+ }
100
+
101
+ function renderFeatureGrid(ctx) {
102
+ const { voice, mat, headings, bodySize } = ctx;
103
+ const heading = pickHeading(voice, 'What it actually does.');
104
+ const h1 = headings[1] || { size: 36, weight: 600 };
105
+ const features = [
106
+ { t: 'Primitives', b: 'Every color, type, shadow and spacing value lifted from the source.' },
107
+ { t: 'Anatomy', b: 'Variants, sizes and states inferred from real DOM — not guessed.' },
108
+ { t: 'Voice', b: 'The verbs your CTAs use, the rhythm of your headings, preserved.' },
109
+ { t: 'Drift', b: 'Audit your tokens against production on every PR. Exit non-zero on divergence.' },
110
+ { t: 'Platforms', b: 'One extraction → iOS, Android, Flutter, WordPress, MCP.' },
111
+ { t: 'Prompts', b: 'Paste into v0 or Lovable and get the design right on the first try.' },
112
+ ];
113
+ return `
114
+ <section style={{ padding: '96px 0' }}>
115
+ <h2 style={{ fontSize: ${h1.size}, fontWeight: ${h1.weight}, letterSpacing: '-0.02em', marginBottom: 48, maxWidth: '20ch' }}>${sanitize(heading)}</h2>
116
+ <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))', gap: 24 }}>
117
+ {${JSON.stringify(features)}.map(f => (
118
+ <div key={f.t} style={{ padding: 24, borderRadius: '${mat.radius}', border: '${mat.cardBorder}', boxShadow: '${mat.shadow}', background: 'var(--color-background)' }}>
119
+ <h3 style={{ fontSize: 18, fontWeight: 600, marginBottom: 8 }}>{f.t}</h3>
120
+ <p style={{ fontSize: ${bodySize - 1}, lineHeight: 1.55, color: 'var(--color-neutral-1)' }}>{f.b}</p>
121
+ </div>
122
+ ))}
123
+ </div>
124
+ </section>`;
125
+ }
126
+
127
+ function renderStats() {
128
+ return `
129
+ <section style={{ padding: '64px 0', borderTop: '1px solid var(--color-neutral-2)', borderBottom: '1px solid var(--color-neutral-2)' }}>
130
+ <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: 32 }}>
131
+ {[['1,840', 'sites extracted'], ['0.82', 'avg library detection'], ['6s', 'median extraction'], ['W3C', 'DTCG output']].map(([n, l]) => (
132
+ <div key={l}>
133
+ <div style={{ fontSize: 44, fontWeight: 700, letterSpacing: '-0.03em', lineHeight: 1 }}>{n}</div>
134
+ <div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, letterSpacing: '0.12em', textTransform: 'uppercase', color: 'var(--color-neutral-1)', marginTop: 8 }}>{l}</div>
135
+ </div>
136
+ ))}
137
+ </div>
138
+ </section>`;
139
+ }
140
+
141
+ function renderTestimonial(ctx) {
142
+ const { bodySize } = ctx;
143
+ return `
144
+ <section style={{ padding: '96px 0', maxWidth: '760px' }}>
145
+ <p style={{ fontSize: ${bodySize + 10}, lineHeight: 1.4, fontWeight: 500, letterSpacing: '-0.01em' }}>
146
+ &ldquo;It rebuilt our entire landing page into a token-clean Next.js project in under a minute.
147
+ Nothing we've paid for comes close.&rdquo;
148
+ </p>
149
+ <p style={{ fontFamily: 'var(--font-mono)', fontSize: 12, letterSpacing: '0.12em', textTransform: 'uppercase', color: 'var(--color-neutral-1)', marginTop: 24 }}>
150
+ — design lead, Series B startup
151
+ </p>
152
+ </section>`;
153
+ }
154
+
155
+ function renderPricing(ctx) {
156
+ const { voice, mat, bodySize } = ctx;
157
+ const cta = primaryCta(voice);
158
+ const tiers = [
159
+ { t: 'Free', p: '$0', b: 'CLI + MCP + every extractor.' },
160
+ { t: 'Team', p: '$19', b: 'CI drift bot + drift reports on every PR.' },
161
+ { t: 'Studio', p: '$49', b: 'Hosted studio, shareable extractions.' },
162
+ ];
163
+ return `
164
+ <section style={{ padding: '96px 0' }}>
165
+ <h2 style={{ fontSize: 36, fontWeight: 600, letterSpacing: '-0.02em', marginBottom: 48 }}>Pricing</h2>
166
+ <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))', gap: 20 }}>
167
+ {${JSON.stringify(tiers)}.map((tier, i) => (
168
+ <div key={tier.t} style={{ padding: 28, borderRadius: '${mat.radius}', border: '${mat.cardBorder}', boxShadow: i === 1 ? '${mat.shadow}' : 'none', background: i === 1 ? 'var(--color-foreground)' : 'var(--color-background)', color: i === 1 ? 'var(--color-background)' : 'var(--color-foreground)' }}>
169
+ <div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, letterSpacing: '0.14em', textTransform: 'uppercase', opacity: 0.7 }}>{tier.t}</div>
170
+ <div style={{ fontSize: 40, fontWeight: 700, margin: '12px 0', letterSpacing: '-0.02em' }}>{tier.p}<span style={{ fontSize: 14, fontWeight: 400, opacity: 0.7 }}> / mo</span></div>
171
+ <p style={{ fontSize: ${bodySize - 1}, lineHeight: 1.55, marginBottom: 24, opacity: 0.85 }}>{tier.b}</p>
172
+ <button style={{ width: '100%', background: i === 1 ? 'var(--color-background)' : 'var(--color-primary)', color: i === 1 ? 'var(--color-foreground)' : '#fff', border: 'none', padding: '12px 16px', borderRadius: '${mat.radius}', fontSize: ${bodySize}, fontWeight: 600, cursor: 'pointer' }}>${sanitize(cta)}</button>
173
+ </div>
174
+ ))}
175
+ </div>
176
+ </section>`;
177
+ }
178
+
179
+ function renderFaq() {
180
+ const qas = [
181
+ ['How accurate is the clone?', 'Every token and the section reading order come from the real DOM. Copy is filler — the design is real.'],
182
+ ['Does it use an LLM?', 'No. Everything runs from Playwright + deterministic classifiers. --smart is optional.'],
183
+ ['Can it handle auth?', 'Yes — pass --cookie-file for Netscape cookies, Playwright storageState, or a JSON array.'],
184
+ ['Is this legal to use on competitors?', 'It captures publicly rendered CSS/DOM. Use respectfully; the tokens belong to the source.'],
185
+ ];
186
+ return `
187
+ <section style={{ padding: '96px 0', maxWidth: '760px' }}>
188
+ <h2 style={{ fontSize: 36, fontWeight: 600, letterSpacing: '-0.02em', marginBottom: 32 }}>Questions</h2>
189
+ <div>
190
+ {${JSON.stringify(qas)}.map(([q, a]) => (
191
+ <details key={q} style={{ padding: '20px 0', borderTop: '1px solid var(--color-neutral-2)' }}>
192
+ <summary style={{ fontSize: 18, fontWeight: 600, cursor: 'pointer', listStyle: 'none' }}>{q}</summary>
193
+ <p style={{ fontSize: 15, lineHeight: 1.6, color: 'var(--color-neutral-1)', marginTop: 12 }}>{a}</p>
194
+ </details>
195
+ ))}
196
+ </div>
197
+ </section>`;
198
+ }
199
+
200
+ function renderSteps() {
201
+ const steps = [
202
+ ['01', 'Extract', 'Point designlang at any URL. Get DTCG tokens in seconds.'],
203
+ ['02', 'Emit', 'One extraction fans out to Tailwind, CSS vars, Figma vars, iOS, Android, Flutter.'],
204
+ ['03', 'Ship', 'Clone a starter, add the CI bot, or launch the Studio to share.'],
205
+ ];
206
+ return `
207
+ <section style={{ padding: '96px 0' }}>
208
+ <h2 style={{ fontSize: 36, fontWeight: 600, letterSpacing: '-0.02em', marginBottom: 48 }}>How</h2>
209
+ <ol style={{ listStyle: 'none', padding: 0, display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))', gap: 24 }}>
210
+ {${JSON.stringify(steps)}.map(([n, t, b]) => (
211
+ <li key={n}>
212
+ <div style={{ fontFamily: 'var(--font-mono)', fontSize: 14, color: 'var(--color-primary)', marginBottom: 12 }}>{n}</div>
213
+ <h3 style={{ fontSize: 22, fontWeight: 600, letterSpacing: '-0.01em', marginBottom: 8 }}>{t}</h3>
214
+ <p style={{ fontSize: 14, lineHeight: 1.55, color: 'var(--color-neutral-1)' }}>{b}</p>
215
+ </li>
216
+ ))}
217
+ </ol>
218
+ </section>`;
219
+ }
220
+
221
+ function renderComparison() {
222
+ const rows = [
223
+ ['Section order reproduction', '✓', '–'],
224
+ ['DTCG tokens (W3C)', '✓', '–'],
225
+ ['Motion + anatomy + voice', '✓', '–'],
226
+ ['CI drift bot', '✓', '–'],
227
+ ['Local studio', '✓', '–'],
228
+ ['$', '0', '$200+/mo'],
229
+ ];
230
+ return `
231
+ <section style={{ padding: '96px 0' }}>
232
+ <h2 style={{ fontSize: 36, fontWeight: 600, letterSpacing: '-0.02em', marginBottom: 32 }}>vs. what you're paying for</h2>
233
+ <table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 14 }}>
234
+ <thead>
235
+ <tr>
236
+ <th style={{ textAlign: 'left', padding: '12px 0', borderBottom: '1px solid var(--color-foreground)', fontWeight: 500, fontFamily: 'var(--font-mono)', fontSize: 11, letterSpacing: '0.14em', textTransform: 'uppercase' }}></th>
237
+ <th style={{ textAlign: 'left', padding: '12px 16px', borderBottom: '1px solid var(--color-foreground)', fontWeight: 600 }}>designlang</th>
238
+ <th style={{ textAlign: 'left', padding: '12px 16px', borderBottom: '1px solid var(--color-foreground)', fontWeight: 500, color: 'var(--color-neutral-1)' }}>paid peers</th>
239
+ </tr>
240
+ </thead>
241
+ <tbody>
242
+ {${JSON.stringify(rows)}.map(([k, a, b], i) => (
243
+ <tr key={k + i}>
244
+ <td style={{ padding: '14px 0', borderBottom: '1px solid var(--color-neutral-2)' }}>{k}</td>
245
+ <td style={{ padding: '14px 16px', borderBottom: '1px solid var(--color-neutral-2)', fontFamily: 'var(--font-mono)', color: 'var(--color-primary)' }}>{a}</td>
246
+ <td style={{ padding: '14px 16px', borderBottom: '1px solid var(--color-neutral-2)', fontFamily: 'var(--font-mono)', color: 'var(--color-neutral-1)' }}>{b}</td>
247
+ </tr>
248
+ ))}
249
+ </tbody>
250
+ </table>
251
+ </section>`;
252
+ }
253
+
254
+ function renderCta(ctx) {
255
+ const { voice, mat, bodySize } = ctx;
256
+ const primary = primaryCta(voice);
257
+ return `
258
+ <section style={{ padding: '96px 0', textAlign: 'center', borderTop: '1px solid var(--color-neutral-2)' }}>
259
+ <h2 style={{ fontSize: 44, fontWeight: 700, letterSpacing: '-0.025em', marginBottom: 24 }}>Ready when you are.</h2>
260
+ <p style={{ fontSize: ${bodySize + 2}, color: 'var(--color-neutral-1)', marginBottom: 32, maxWidth: '46ch', margin: '0 auto 32px' }}>One command. A full project. No keys, no account.</p>
261
+ <button style={{ background: 'var(--color-primary)', color: '#fff', border: 'none', padding: '16px 28px', borderRadius: '${mat.radius}', boxShadow: '${mat.shadow}', fontSize: ${bodySize + 2}, fontWeight: 600, cursor: 'pointer' }}>
262
+ ${sanitize(primary)}
263
+ </button>
264
+ </section>`;
265
+ }
266
+
267
+ function renderFooter(url) {
268
+ return `
269
+ <footer style={{ padding: '48px 0 32px', borderTop: '1px solid var(--color-neutral-2)', fontSize: 13, color: 'var(--color-neutral-1)', display: 'flex', justifyContent: 'space-between', flexWrap: 'wrap', gap: 16 }}>
270
+ <div>Cloned from <a href="${sanitize(url)}" style={{ color: 'var(--color-primary)' }}>${sanitize(titleFromUrl(url))}</a> · structure + tokens only</div>
271
+ <div style={{ fontFamily: 'var(--font-mono)' }}>designlang clone</div>
272
+ </footer>`;
273
+ }
274
+
275
+ const RENDERERS = {
276
+ 'hero': renderHero,
277
+ 'logo-wall': renderLogoWall,
278
+ 'feature-grid': renderFeatureGrid,
279
+ 'stats': renderStats,
280
+ 'testimonial': renderTestimonial,
281
+ 'pricing-table': renderPricing,
282
+ 'faq': renderFaq,
283
+ 'steps': renderSteps,
284
+ 'comparison': renderComparison,
285
+ 'bento': renderFeatureGrid,
286
+ 'gallery': renderFeatureGrid,
287
+ 'cta': renderCta,
288
+ };
289
+
290
+ // ─── Main ──────────────────────────────────────────────────────
291
+
6
292
  export function generateClone(design, outDir) {
7
293
  const projectDir = outDir;
8
294
  mkdirSync(join(projectDir, 'src/app'), { recursive: true });
9
295
  mkdirSync(join(projectDir, 'public'), { recursive: true });
10
296
 
11
297
  const { colors, typography, spacing, borders, shadows } = design;
298
+ const voice = design.voice || {};
299
+ const sectionRoles = design.sectionRoles || {};
300
+ const materialLanguage = design.materialLanguage || {};
301
+ const pageIntent = design.pageIntent || {};
302
+ const url = design.meta?.url || '';
12
303
 
13
- // Package.json
14
- writeFileSync(join(projectDir, 'package.json'), JSON.stringify({
15
- name: `${design.meta.title?.toLowerCase().replace(/[^a-z0-9]/g, '-').replace(/-+/g, '-').slice(0, 40) || 'cloned-design'}-clone`,
16
- version: '0.1.0',
17
- private: true,
18
- scripts: {
19
- dev: 'next dev',
20
- build: 'next build',
21
- start: 'next start',
22
- },
23
- dependencies: {
24
- next: '^15.0.0',
25
- react: '^19.0.0',
26
- 'react-dom': '^19.0.0',
27
- },
28
- devDependencies: {
29
- tailwindcss: '^4.0.0',
30
- '@tailwindcss/postcss': '^4.0.0',
31
- },
32
- }, null, 2), 'utf-8');
33
-
34
- // Globals CSS with extracted design tokens
35
304
  const primaryHex = colors.primary?.hex || '#3b82f6';
36
305
  const secondaryHex = colors.secondary?.hex || '#8b5cf6';
37
306
  const accentHex = colors.accent?.hex || '#f59e0b';
@@ -41,9 +310,19 @@ export function generateClone(design, outDir) {
41
310
  const monoFont = typography.families.find(f => f.name.toLowerCase().includes('mono'))?.name || 'monospace';
42
311
  const radiusMd = borders.radii.find(r => r.label === 'md')?.value || 8;
43
312
  const shadowMd = shadows.values.find(s => s.label === 'md')?.raw || '0 4px 6px rgba(0,0,0,0.1)';
44
-
45
313
  const neutrals = colors.neutrals.slice(0, 5);
46
314
 
315
+ // package.json
316
+ writeFileSync(join(projectDir, 'package.json'), JSON.stringify({
317
+ name: `${(design.meta.title || 'cloned').toLowerCase().replace(/[^a-z0-9]/g, '-').replace(/-+/g, '-').slice(0, 40) || 'cloned-design'}-clone`,
318
+ version: '0.1.0',
319
+ private: true,
320
+ scripts: { dev: 'next dev', build: 'next build', start: 'next start' },
321
+ dependencies: { next: '^15.0.0', react: '^19.0.0', 'react-dom': '^19.0.0' },
322
+ devDependencies: { tailwindcss: '^4.0.0', '@tailwindcss/postcss': '^4.0.0' },
323
+ }, null, 2), 'utf-8');
324
+
325
+ // globals.css
47
326
  writeFileSync(join(projectDir, 'src/app/globals.css'), `@import "tailwindcss";
48
327
 
49
328
  :root {
@@ -59,17 +338,22 @@ ${neutrals.map((n, i) => ` --color-neutral-${i + 1}: ${n.hex};`).join('\n')}
59
338
  --shadow: ${shadowMd};
60
339
  }
61
340
 
341
+ * { box-sizing: border-box; }
62
342
  body {
343
+ margin: 0;
63
344
  background: var(--color-background);
64
345
  color: var(--color-foreground);
65
346
  font-family: var(--font-sans);
347
+ -webkit-font-smoothing: antialiased;
66
348
  }
349
+ a { color: var(--color-primary); }
350
+ button { font-family: inherit; }
67
351
  `, 'utf-8');
68
352
 
69
- // Layout
353
+ // layout.js
70
354
  writeFileSync(join(projectDir, 'src/app/layout.js'), `export const metadata = {
71
- title: '${(design.meta.title || 'Cloned Design').replace(/'/g, "\\'")}',
72
- description: 'Design cloned from ${design.meta.url}',
355
+ title: '${(design.meta.title || 'Cloned Design').replace(/'/g, "\\'")} · cloned',
356
+ description: 'Design cloned from ${url} with designlang.',
73
357
  };
74
358
 
75
359
  export default function RootLayout({ children }) {
@@ -86,115 +370,40 @@ export default function RootLayout({ children }) {
86
370
  }
87
371
  `, 'utf-8');
88
372
 
89
- // Demo page showcasing the design system
90
- const headingScale = typography.headings.slice(0, 3);
91
- const bodySize = typography.body?.size || 16;
92
- const spacingVals = spacing.scale.slice(0, 8);
373
+ // Build page from sectionRoles.readingOrder
374
+ const rawOrder = Array.isArray(sectionRoles.readingOrder) && sectionRoles.readingOrder.length
375
+ ? sectionRoles.readingOrder.filter(r => KNOWN_ROLES.has(r))
376
+ : ['hero', 'logo-wall', 'feature-grid', 'stats', 'testimonial', 'pricing-table', 'faq', 'cta', 'footer'];
377
+ const order = dedupeConsecutive(rawOrder);
378
+ if (!order.includes('footer')) order.push('footer');
93
379
 
94
- writeFileSync(join(projectDir, 'src/app/page.js'), `import './globals.css';
380
+ const ctx = {
381
+ voice,
382
+ intent: pageIntent?.type || 'landing',
383
+ url,
384
+ mat: materialPreset(materialLanguage),
385
+ headings: typography.headings || [],
386
+ bodySize: typography.body?.size || 16,
387
+ };
388
+
389
+ const sections = order
390
+ .filter(r => r !== 'footer')
391
+ .map(r => (RENDERERS[r] || (() => ''))(ctx))
392
+ .join('\n');
393
+
394
+ const pageJs = `import './globals.css';
95
395
 
96
396
  export default function Home() {
97
397
  return (
98
- <main style={{ maxWidth: '1200px', margin: '0 auto', padding: '48px 24px' }}>
99
- {/* Hero */}
100
- <section style={{ textAlign: 'center', padding: '80px 0' }}>
101
- <h1 style={{
102
- fontSize: '${headingScale[0]?.size || 48}px',
103
- fontWeight: ${headingScale[0]?.weight || 700},
104
- lineHeight: '${headingScale[0]?.lineHeight || '1.1'}',
105
- color: 'var(--color-foreground)',
106
- marginBottom: '16px',
107
- }}>
108
- Design System Clone
109
- </h1>
110
- <p style={{
111
- fontSize: '${bodySize + 4}px',
112
- color: 'var(--color-neutral-1)',
113
- maxWidth: '600px',
114
- margin: '0 auto 32px',
115
- }}>
116
- Extracted from <a href="${design.meta.url}" style={{ color: 'var(--color-primary)' }}>${design.meta.url}</a>
117
- </p>
118
- <div style={{ display: 'flex', gap: '12px', justifyContent: 'center' }}>
119
- <button style={{
120
- background: 'var(--color-primary)',
121
- color: '#fff',
122
- border: 'none',
123
- padding: '12px 24px',
124
- borderRadius: 'var(--radius)',
125
- fontSize: '${bodySize}px',
126
- fontWeight: 500,
127
- cursor: 'pointer',
128
- }}>
129
- Primary Button
130
- </button>
131
- <button style={{
132
- background: 'transparent',
133
- color: 'var(--color-foreground)',
134
- border: '1px solid var(--color-neutral-${Math.min(neutrals.length, 3)})',
135
- padding: '12px 24px',
136
- borderRadius: 'var(--radius)',
137
- fontSize: '${bodySize}px',
138
- fontWeight: 500,
139
- cursor: 'pointer',
140
- }}>
141
- Secondary Button
142
- </button>
143
- </div>
144
- </section>
145
-
146
- {/* Color Palette */}
147
- <section style={{ padding: '48px 0' }}>
148
- <h2 style={{ fontSize: '${headingScale[1]?.size || 24}px', fontWeight: 600, marginBottom: '24px' }}>Color Palette</h2>
149
- <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(120px, 1fr))', gap: '12px' }}>
150
- <div style={{ background: 'var(--color-primary)', borderRadius: 'var(--radius)', padding: '40px 16px 12px', color: '#fff', fontSize: '12px' }}>Primary<br/>${primaryHex}</div>
151
- <div style={{ background: 'var(--color-secondary)', borderRadius: 'var(--radius)', padding: '40px 16px 12px', color: '#fff', fontSize: '12px' }}>Secondary<br/>${secondaryHex}</div>
152
- <div style={{ background: 'var(--color-accent)', borderRadius: 'var(--radius)', padding: '40px 16px 12px', color: '#fff', fontSize: '12px' }}>Accent<br/>${accentHex}</div>
153
- ${neutrals.map((n, i) => ` <div style={{ background: '${n.hex}', borderRadius: 'var(--radius)', padding: '40px 16px 12px', color: '${n.hsl.l > 50 ? '#000' : '#fff'}', fontSize: '12px' }}>Neutral ${i + 1}<br/>${n.hex}</div>`).join('\n')}
154
- </div>
155
- </section>
156
-
157
- {/* Typography */}
158
- <section style={{ padding: '48px 0' }}>
159
- <h2 style={{ fontSize: '${headingScale[1]?.size || 24}px', fontWeight: 600, marginBottom: '24px' }}>Typography</h2>
160
- ${headingScale.map((h, i) => ` <p style={{ fontSize: '${h.size}px', fontWeight: ${h.weight}, lineHeight: '${h.lineHeight}', marginBottom: '16px' }}>Heading ${i + 1} — ${h.size}px / ${h.weight}</p>`).join('\n')}
161
- <p style={{ fontSize: '${bodySize}px', lineHeight: '1.6', color: 'var(--color-neutral-1)', marginTop: '24px' }}>
162
- Body text at ${bodySize}px. This is what most content on the site looks like.
163
- The quick brown fox jumps over the lazy dog. Pack my box with five dozen liquor jugs.
164
- </p>
165
- </section>
166
-
167
- {/* Cards */}
168
- <section style={{ padding: '48px 0' }}>
169
- <h2 style={{ fontSize: '${headingScale[1]?.size || 24}px', fontWeight: 600, marginBottom: '24px' }}>Cards</h2>
170
- <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))', gap: '24px' }}>
171
- {[1, 2, 3].map(i => (
172
- <div key={i} style={{
173
- background: 'var(--color-background)',
174
- border: '1px solid var(--color-neutral-${Math.min(neutrals.length, 3)})',
175
- borderRadius: 'var(--radius)',
176
- padding: '24px',
177
- boxShadow: 'var(--shadow)',
178
- }}>
179
- <h3 style={{ fontSize: '${(headingScale[2]?.size || 18)}px', fontWeight: 600, marginBottom: '8px' }}>Card Title {i}</h3>
180
- <p style={{ fontSize: '${bodySize}px', color: 'var(--color-neutral-1)', lineHeight: '1.5' }}>
181
- This card uses the extracted border radius, shadow, and spacing values from the original site.
182
- </p>
183
- </div>
184
- ))}
185
- </div>
186
- </section>
187
-
188
- {/* Footer */}
189
- <footer style={{ padding: '48px 0', borderTop: '1px solid var(--color-neutral-${Math.min(neutrals.length, 3)})', marginTop: '48px', textAlign: 'center' }}>
190
- <p style={{ fontSize: '${bodySize - 2}px', color: 'var(--color-neutral-1)' }}>
191
- Design extracted from ${design.meta.url} with <a href="https://github.com/Manavarya09/design-extract" style={{ color: 'var(--color-primary)' }}>designlang</a>
192
- </p>
193
- </footer>
398
+ <main style={{ maxWidth: 1200, margin: '0 auto', padding: '0 clamp(20px, 4vw, 48px)' }}>
399
+ ${sections}
400
+ ${renderFooter(url)}
194
401
  </main>
195
402
  );
196
403
  }
197
- `, 'utf-8');
404
+ `;
405
+
406
+ writeFileSync(join(projectDir, 'src/app/page.js'), pageJs, 'utf-8');
198
407
 
199
408
  // Next config
200
409
  writeFileSync(join(projectDir, 'next.config.mjs'), `/** @type {import('next').NextConfig} */
@@ -202,17 +411,39 @@ const nextConfig = {};
202
411
  export default nextConfig;
203
412
  `, 'utf-8');
204
413
 
205
- // PostCSS config for Tailwind v4
206
414
  writeFileSync(join(projectDir, 'postcss.config.mjs'), `const config = {
207
- plugins: {
208
- "@tailwindcss/postcss": {},
209
- },
415
+ plugins: { "@tailwindcss/postcss": {} },
210
416
  };
211
417
  export default config;
418
+ `, 'utf-8');
419
+
420
+ // README — tells the user what was reproduced.
421
+ writeFileSync(join(projectDir, 'README.md'), `# ${design.meta.title || 'Cloned design'}
422
+
423
+ Generated by \`designlang clone ${url}\`.
424
+
425
+ ## What was reproduced
426
+ - **Intent:** ${pageIntent?.type || 'landing'}${pageIntent?.confidence ? ` (${pageIntent.confidence})` : ''}
427
+ - **Material:** ${materialLanguage?.label || 'flat'}${materialLanguage?.confidence ? ` (${materialLanguage.confidence})` : ''}
428
+ - **Section order:** ${order.join(' → ')}
429
+ - **Voice:** tone *${voice.tone || 'neutral'}*, headings *${voice.headingStyle || 'sentence'}* / *${voice.headingLengthClass || 'balanced'}*, top CTAs: ${(voice.ctaVerbs || []).slice(0, 3).map(v => v.value).join(', ') || '—'}
430
+ - **Typography:** ${fontFamily} / ${monoFont}
431
+ - **Palette:** primary ${primaryHex}, secondary ${secondaryHex}, accent ${accentHex}
432
+ - **Radius / shadow:** ${radiusMd}px / extracted
433
+
434
+ ## Run
435
+
436
+ \`\`\`
437
+ npm install
438
+ npm run dev
439
+ \`\`\`
440
+
441
+ > Copy is filler. The design is real.
212
442
  `, 'utf-8');
213
443
 
214
444
  return {
215
445
  dir: projectDir,
216
- files: ['package.json', 'src/app/globals.css', 'src/app/layout.js', 'src/app/page.js', 'next.config.mjs', 'postcss.config.mjs'],
446
+ files: ['package.json', 'src/app/globals.css', 'src/app/layout.js', 'src/app/page.js', 'next.config.mjs', 'postcss.config.mjs', 'README.md'],
447
+ order,
217
448
  };
218
449
  }
@@ -0,0 +1,109 @@
1
+ // v10.5 — Form & State Capture
2
+ //
3
+ // The states LLM agents always botch when rebuilding: form fields (styling
4
+ // per type), validation hints, modal containers (backdrop + panel geometry),
5
+ // empty / loading / error placeholders, skeleton shapes, and which toast
6
+ // library (if any) is on the page. Pure function — reads the crawler's
7
+ // existing computedStyles + sections + componentCandidates.
8
+
9
+ const TOAST_LIBS = [
10
+ { id: 'sonner', re: /\bsonner\b|sonner-toast/i },
11
+ { id: 'react-hot-toast', re: /react-hot-toast/i },
12
+ { id: 'react-toastify', re: /react-toastify/i },
13
+ { id: 'radix-toast', re: /radix-toast|data-radix-toast/i },
14
+ { id: 'chakra-toast', re: /chakra-toast/i },
15
+ { id: 'notistack', re: /notistack/i },
16
+ ];
17
+
18
+ const SKELETON_CLASS_RE = /\b(skeleton|placeholder-loading|shimmer|pulse-loading|animate-pulse)\b/i;
19
+ const SPINNER_CLASS_RE = /\b(spinner|loading-indicator|loader)\b/i;
20
+ const EMPTY_STATE_RE = /\b(empty-state|no-results|no-data|nothing-here)\b/i;
21
+ const ERROR_STATE_RE = /\b(error-state|error-message|alert-error|form-error|invalid)\b/i;
22
+
23
+ function summarizeInputs(styles = []) {
24
+ const types = {};
25
+ for (const s of styles) {
26
+ if (!s.tag || !/^(input|textarea|select)$/i.test(s.tag)) continue;
27
+ const t = (s.type || s.inputType || s.tag).toLowerCase();
28
+ types[t] = (types[t] || 0) + 1;
29
+ }
30
+ return types;
31
+ }
32
+
33
+ function detectToastLib(stack = {}) {
34
+ const haystack = [
35
+ ...(stack.scripts || []),
36
+ ...(stack.classNameSample || []),
37
+ ].join(' ');
38
+ return TOAST_LIBS.filter(t => t.re.test(haystack)).map(t => t.id);
39
+ }
40
+
41
+ function detectModals(sections = []) {
42
+ return sections.filter(s => {
43
+ const blob = `${s.className || ''} ${s.role || ''}`.toLowerCase();
44
+ return /\bmodal\b|dialog|overlay|drawer|sheet/.test(blob) || s.role === 'dialog';
45
+ }).map(s => ({
46
+ role: s.role || null,
47
+ className: (s.className || '').slice(0, 80),
48
+ bounds: s.bounds || null,
49
+ }));
50
+ }
51
+
52
+ function classBasedScan(classSample = []) {
53
+ let skeleton = 0, spinner = 0, emptyState = 0, errorState = 0;
54
+ for (const c of classSample) {
55
+ if (SKELETON_CLASS_RE.test(c)) skeleton++;
56
+ if (SPINNER_CLASS_RE.test(c)) spinner++;
57
+ if (EMPTY_STATE_RE.test(c)) emptyState++;
58
+ if (ERROR_STATE_RE.test(c)) errorState++;
59
+ }
60
+ return { skeleton, spinner, emptyState, errorState };
61
+ }
62
+
63
+ function summarizeFormFields(candidates = []) {
64
+ const inputs = candidates.filter(c => c.kind === 'input');
65
+ if (!inputs.length) return { count: 0, families: {} };
66
+ const families = {};
67
+ for (const inp of inputs) {
68
+ const key = [
69
+ inp.css?.borderRadius || '',
70
+ inp.css?.padding || '',
71
+ inp.css?.border || '',
72
+ ].join('|');
73
+ families[key] = (families[key] || 0) + 1;
74
+ }
75
+ return { count: inputs.length, families: Object.values(families).slice(0, 6) };
76
+ }
77
+
78
+ export function extractFormStates(rawData = {}, design = {}) {
79
+ const light = rawData.light || {};
80
+ const stack = light.stack || {};
81
+ const sections = light.sections || [];
82
+ const candidates = light.componentCandidates || [];
83
+
84
+ const toastLibs = detectToastLib(stack);
85
+ const modals = detectModals(sections);
86
+ const classScan = classBasedScan(stack.classNameSample || []);
87
+ const form = summarizeFormFields(candidates);
88
+ const inputTypes = summarizeInputs(light.computedStyles || []);
89
+
90
+ const flags = [];
91
+ if (classScan.skeleton) flags.push('skeleton-loading');
92
+ if (classScan.spinner) flags.push('spinner-loading');
93
+ if (classScan.emptyState) flags.push('empty-state');
94
+ if (classScan.errorState) flags.push('error-state');
95
+ if (modals.length) flags.push('modal');
96
+ if (toastLibs.length) flags.push('toast-library');
97
+ if (form.count) flags.push('forms');
98
+
99
+ return {
100
+ flags,
101
+ forms: form,
102
+ inputTypesSeen: inputTypes,
103
+ modals,
104
+ toastLibraries: toastLibs,
105
+ loading: { skeleton: classScan.skeleton, spinner: classScan.spinner },
106
+ empty: { count: classScan.emptyState },
107
+ error: { count: classScan.errorState },
108
+ };
109
+ }
package/src/index.js CHANGED
@@ -37,6 +37,7 @@ import { extractSeo } from './extractors/seo.js';
37
37
  import { extractIconSystem } from './extractors/icon-system.js';
38
38
  import { extractBackgroundPatterns } from './extractors/background-patterns.js';
39
39
  import { extractStackIntel } from './extractors/stack-intel.js';
40
+ import { extractFormStates } from './extractors/form-states.js';
40
41
  import { formatDtcgTokens } from './formatters/dtcg-tokens.js';
41
42
  import { formatMotionTokens } from './formatters/motion-tokens.js';
42
43
 
@@ -145,6 +146,7 @@ export async function extractDesignLanguage(url, options = {}) {
145
146
  design.iconSystem = safeExtract(extractIconSystem, rawData.light?.icons || []) || { library: 'unknown', confidence: 0, stats: {}, signals: [], icons: [] };
146
147
  design.backgroundPatterns = safeExtract(extractBackgroundPatterns, rawData) || { labels: ['plain'], counts: {}, gradientTotals: {}, samples: [] };
147
148
  design.stackIntel = safeExtract(extractStackIntel, rawData.light?.stack || {}) || { cms: [], analytics: [], experimentation: [] };
149
+ design.formStates = safeExtract(extractFormStates, rawData, design) || { flags: [], forms: { count: 0, families: [] }, modals: [], toastLibraries: [] };
148
150
  // Stash raw crawler output so downstream orchestration (multipage, smart)
149
151
  // can rebuild the digest without re-crawling.
150
152
  design._raw = rawData;
@@ -218,6 +220,7 @@ export { extractSeo } from './extractors/seo.js';
218
220
  export { extractIconSystem } from './extractors/icon-system.js';
219
221
  export { extractBackgroundPatterns } from './extractors/background-patterns.js';
220
222
  export { extractStackIntel } from './extractors/stack-intel.js';
223
+ export { extractFormStates } from './extractors/form-states.js';
221
224
  export { refineWithSmart } from './classifiers/smart.js';
222
225
  export { crawlCanonicalPages, computeCrossPageConsistency, discoverCanonicalPages } from './multipage.js';
223
226
  export { buildPromptPack, formatV0Prompt, formatLovablePrompt, formatCursorPrompt, formatClaudeArtifactPrompt } from './formatters/prompt-pack.js';
package/src/studio.js ADDED
@@ -0,0 +1,316 @@
1
+ // studio — a local web studio for the latest extraction.
2
+ //
3
+ // Launches a tiny zero-dep HTTP server on localhost that serves an
4
+ // editorial viewer of the last extraction: live-editable token swatches,
5
+ // typography specimens, section reading order, voice, and prompt pack.
6
+ //
7
+ // Usage: designlang studio [--dir ./design-extract-output] [--port 4837]
8
+
9
+ import { createServer } from 'http';
10
+ import { readFileSync, readdirSync, statSync, existsSync } from 'fs';
11
+ import { resolve, join, extname } from 'path';
12
+
13
+ const MIME = {
14
+ '.html': 'text/html; charset=utf-8',
15
+ '.js': 'text/javascript; charset=utf-8',
16
+ '.css': 'text/css; charset=utf-8',
17
+ '.json': 'application/json; charset=utf-8',
18
+ '.svg': 'image/svg+xml',
19
+ '.png': 'image/png',
20
+ '.jpg': 'image/jpeg',
21
+ '.webp': 'image/webp',
22
+ '.txt': 'text/plain; charset=utf-8',
23
+ '.md': 'text/plain; charset=utf-8',
24
+ };
25
+
26
+ function pickLatest(dir) {
27
+ if (!existsSync(dir)) return null;
28
+ const files = readdirSync(dir).filter(f => f.endsWith('-design-tokens.json'));
29
+ if (!files.length) return null;
30
+ const picked = files
31
+ .map(f => ({ f, t: statSync(join(dir, f)).mtimeMs }))
32
+ .sort((a, b) => b.t - a.t)[0];
33
+ return picked.f.replace(/-design-tokens\.json$/, '');
34
+ }
35
+
36
+ function loadExtraction(dir, prefix) {
37
+ const read = (name) => {
38
+ const p = join(dir, name);
39
+ if (!existsSync(p)) return null;
40
+ try { return JSON.parse(readFileSync(p, 'utf-8')); } catch { return null; }
41
+ };
42
+ return {
43
+ prefix,
44
+ tokens: read(`${prefix}-design-tokens.json`),
45
+ intent: read(`${prefix}-intent.json`),
46
+ visualDna: read(`${prefix}-visual-dna.json`),
47
+ library: read(`${prefix}-library.json`),
48
+ voice: read(`${prefix}-voice.json`),
49
+ motion: read(`${prefix}-motion-tokens.json`),
50
+ mcp: read(`${prefix}-mcp.json`),
51
+ };
52
+ }
53
+
54
+ // HTML-escape everything that lands in the template. The data is from
55
+ // the user's own extraction on their own machine, but keep discipline.
56
+ function esc(v) {
57
+ if (v == null) return '';
58
+ return String(v).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
59
+ }
60
+
61
+ function studioHtml(data) {
62
+ const tokens = data.tokens || {};
63
+ const intent = data.intent || {};
64
+ const visualDna = data.visualDna || {};
65
+ const library = data.library || {};
66
+ const voice = data.voice || {};
67
+ const motion = data.motion || {};
68
+ const json = JSON.stringify({ tokens, intent, visualDna, library, voice, motion });
69
+ const readingOrder = (intent.sectionRoles?.readingOrder || []).join(' → ');
70
+ const ctaList = (voice.ctaVerbs || []).slice(0, 5).map(c => `${c.value} (${c.count})`).join(' · ');
71
+ const sampleHeadings = (voice.sampleHeadings || []).slice(0, 4).map(h => `“${h}”`).join(' · ');
72
+
73
+ return `<!doctype html>
74
+ <html lang="en">
75
+ <head>
76
+ <meta charset="utf-8" />
77
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
78
+ <title>designlang studio · ${esc(data.prefix)}</title>
79
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
80
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
81
+ <link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,400;9..144,500&family=Instrument+Sans:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
82
+ <style>
83
+ :root {
84
+ --paper: #f3f1ea; --paper-2: #ece8dd; --paper-3: #d8d3c5;
85
+ --ink: #0a0908; --ink-2: #403c34; --ink-3: #8b8778;
86
+ --accent: #ff4800;
87
+ --mono: 'JetBrains Mono', ui-monospace, monospace;
88
+ --display: 'Fraunces', Georgia, serif;
89
+ --body: 'Instrument Sans', -apple-system, system-ui, sans-serif;
90
+ }
91
+ * { box-sizing: border-box; margin: 0; padding: 0; }
92
+ html, body { background: var(--paper); color: var(--ink); font-family: var(--body); font-size: 15px; line-height: 1.5; }
93
+ body { padding: 48px clamp(20px, 4vw, 56px); max-width: 1440px; margin: 0 auto; }
94
+ header { display: flex; justify-content: space-between; align-items: baseline; gap: 24px; padding-bottom: 16px; border-bottom: 1px solid var(--ink); }
95
+ .mark { font-family: var(--mono); font-size: 13px; letter-spacing: 0.02em; }
96
+ .mark em { color: var(--accent); font-style: italic; font-family: var(--display); }
97
+ nav { font-family: var(--mono); font-size: 11px; letter-spacing: 0.14em; text-transform: uppercase; display: flex; gap: 24px; }
98
+ nav a { color: var(--ink); text-decoration: none; border-bottom: 1px solid transparent; }
99
+ nav a:hover { color: var(--accent); }
100
+ h1.display { font-family: var(--display); font-size: clamp(40px, 6vw, 72px); letter-spacing: -0.03em; line-height: 1.02; font-weight: 400; margin: 64px 0 24px; max-width: 14ch; }
101
+ h1.display em { font-style: italic; color: var(--accent); }
102
+ h2 { font-family: var(--display); font-size: 28px; letter-spacing: -0.02em; font-weight: 500; }
103
+ .rule { display: flex; align-items: center; gap: 12px; margin: 56px 0 24px; }
104
+ .rule-line { flex: 1; height: 1px; background: var(--ink); }
105
+ .chip { font-family: var(--mono); font-size: 11px; letter-spacing: 0.14em; text-transform: uppercase; padding: 4px 10px; border: 1px solid var(--ink); }
106
+ .grid { display: grid; gap: 16px; }
107
+ .swatch { aspect-ratio: 1; border: 1px solid var(--ink); padding: 10px; display: flex; flex-direction: column; justify-content: space-between; font-family: var(--mono); font-size: 10px; letter-spacing: 0.04em; cursor: copy; position: relative; }
108
+ .swatch:hover::after { content: 'click to copy'; position: absolute; inset: 0; display: grid; place-items: center; background: rgba(0,0,0,0.55); color: white; font-size: 11px; letter-spacing: 0.14em; text-transform: uppercase; }
109
+ .kv { display: grid; grid-template-columns: 180px 1fr; gap: 8px 24px; font-family: var(--mono); font-size: 13px; line-height: 1.8; }
110
+ .kv dt { color: var(--ink-2); letter-spacing: 0.04em; }
111
+ .kv dd em { color: var(--accent); font-style: normal; }
112
+ pre.code { font-family: var(--mono); font-size: 12px; background: var(--ink); color: var(--paper); padding: 16px 20px; overflow-x: auto; white-space: pre; }
113
+ .type-spec { display: grid; gap: 12px; padding: 32px 0; border-top: 1px solid var(--paper-3); border-bottom: 1px solid var(--paper-3); }
114
+ .type-row { display: flex; align-items: baseline; gap: 24px; border-bottom: 1px solid var(--paper-3); padding-bottom: 12px; }
115
+ .type-row:last-child { border-bottom: 0; }
116
+ .type-meta { font-family: var(--mono); font-size: 10px; color: var(--ink-3); letter-spacing: 0.1em; text-transform: uppercase; min-width: 120px; }
117
+ footer { margin-top: 80px; padding-top: 24px; border-top: 1px solid var(--paper-3); font-family: var(--mono); font-size: 11px; letter-spacing: 0.04em; color: var(--ink-3); display: flex; justify-content: space-between; }
118
+ @media (max-width: 700px) { .kv { grid-template-columns: 1fr; } .type-row { flex-direction: column; gap: 4px; } }
119
+ </style>
120
+ </head>
121
+ <body>
122
+ <header>
123
+ <div class="mark">designlang <em>studio</em> · ${esc(data.prefix)}</div>
124
+ <nav>
125
+ <a href="#dna">DNA</a>
126
+ <a href="#tokens">Tokens</a>
127
+ <a href="#type">Type</a>
128
+ <a href="#voice">Voice</a>
129
+ <a href="#motion">Motion</a>
130
+ <a href="/raw" target="_blank">Raw JSON</a>
131
+ </nav>
132
+ </header>
133
+
134
+ <h1 class="display">An extraction you can <em>read</em>, not just paste.</h1>
135
+ <p style="max-width:48ch;color:var(--ink-2);font-size:17px;line-height:1.55">
136
+ Every swatch is clickable — copy hex or token to your clipboard.
137
+ The studio runs locally; no account, no upload, no telemetry.
138
+ </p>
139
+
140
+ <div class="rule" id="dna"><div class="rule-line"></div><div class="chip">§01 — DNA</div></div>
141
+ <dl class="kv">
142
+ <dt>page intent</dt><dd>${intent.pageIntent?.type ? `<em>${esc(intent.pageIntent.type)}</em> · ${esc(intent.pageIntent.confidence ?? '')}` : '—'}</dd>
143
+ <dt>reading order</dt><dd>${esc(readingOrder) || '—'}</dd>
144
+ <dt>material</dt><dd>${visualDna.materialLanguage?.label ? `${esc(visualDna.materialLanguage.label)} · ${esc(visualDna.materialLanguage.confidence ?? '')}` : '—'}</dd>
145
+ <dt>imagery</dt><dd>${esc(visualDna.imageryStyle?.label) || '—'}</dd>
146
+ <dt>component library</dt><dd>${library.library ? `${esc(library.library)} · ${esc(library.confidence ?? '')}` : '—'}</dd>
147
+ </dl>
148
+
149
+ <div class="rule" id="tokens"><div class="rule-line"></div><div class="chip">§02 — Tokens</div></div>
150
+ <h2>Color</h2>
151
+ <div class="grid" style="grid-template-columns:repeat(auto-fill,minmax(120px,1fr));margin-top:24px" id="color-grid"></div>
152
+
153
+ <h2 style="margin-top:56px" id="type">Typography</h2>
154
+ <div class="type-spec" id="type-spec"></div>
155
+
156
+ <div class="rule" id="voice"><div class="rule-line"></div><div class="chip">§03 — Voice</div></div>
157
+ <dl class="kv">
158
+ <dt>tone</dt><dd><em>${esc(voice.tone) || '—'}</em></dd>
159
+ <dt>pronoun</dt><dd>${esc(voice.pronoun) || '—'}</dd>
160
+ <dt>heading style</dt><dd>${esc(voice.headingStyle) || '—'} · ${esc(voice.headingLengthClass) || '—'}</dd>
161
+ <dt>top CTAs</dt><dd>${esc(ctaList) || '—'}</dd>
162
+ <dt>sample headings</dt><dd>${esc(sampleHeadings) || '—'}</dd>
163
+ </dl>
164
+
165
+ <div class="rule" id="motion"><div class="rule-line"></div><div class="chip">§04 — Motion</div></div>
166
+ <pre class="code" id="motion-block"></pre>
167
+
168
+ <footer>
169
+ <div>designlang studio · served from localhost</div>
170
+ <div><a href="/raw" style="color:inherit">raw.json</a></div>
171
+ </footer>
172
+
173
+ <script id="__data" type="application/json">${json.replace(/</g, '\\u003c')}</script>
174
+ <script>
175
+ const DATA = JSON.parse(document.getElementById('__data').textContent);
176
+ document.getElementById('motion-block').textContent = JSON.stringify(DATA.motion || {}, null, 2);
177
+
178
+ // Color grid — built with safe DOM APIs only.
179
+ const palette = [];
180
+ const seen = new Set();
181
+ const add = (hex, label) => {
182
+ if (!hex) return;
183
+ const h = String(hex).toLowerCase();
184
+ if (seen.has(h)) return;
185
+ seen.add(h);
186
+ palette.push({ hex: h, label: String(label || '') });
187
+ };
188
+ const walk = (obj, path) => {
189
+ if (!obj || typeof obj !== 'object') return;
190
+ if (obj.$value && typeof obj.$value === 'string' && /^#[0-9a-f]{3,8}$/i.test(obj.$value)) {
191
+ add(obj.$value, path.join('.'));
192
+ return;
193
+ }
194
+ for (const k of Object.keys(obj)) walk(obj[k], path.concat(k));
195
+ };
196
+ walk(DATA.tokens && (DATA.tokens.color || DATA.tokens), []);
197
+
198
+ const grid = document.getElementById('color-grid');
199
+ for (const p of palette.slice(0, 48)) {
200
+ const el = document.createElement('div');
201
+ el.className = 'swatch';
202
+ const r = parseInt(p.hex.slice(1, 3), 16) || 0;
203
+ const g = parseInt(p.hex.slice(3, 5), 16) || 0;
204
+ const b = parseInt(p.hex.slice(5, 7), 16) || 0;
205
+ const luminance = r * 0.299 + g * 0.587 + b * 0.114;
206
+ el.style.background = p.hex;
207
+ el.style.color = luminance > 160 ? '#0a0908' : '#f3f1ea';
208
+ if (luminance > 220) el.style.borderColor = '#0a0908';
209
+ const lab = document.createElement('div');
210
+ lab.textContent = p.label.split('.').slice(-2).join('.');
211
+ const val = document.createElement('div');
212
+ val.textContent = p.hex;
213
+ val.style.opacity = '0.85';
214
+ el.appendChild(lab);
215
+ el.appendChild(val);
216
+ el.addEventListener('click', () => {
217
+ navigator.clipboard.writeText(p.hex);
218
+ el.style.outline = '2px solid #ff4800';
219
+ setTimeout(() => { el.style.outline = ''; }, 400);
220
+ });
221
+ grid.appendChild(el);
222
+ }
223
+
224
+ // Typography specimens
225
+ const typeSpec = document.getElementById('type-spec');
226
+ const tokensObj = DATA.tokens || {};
227
+ const ff =
228
+ (tokensObj.font && tokensObj.font.family && tokensObj.font.family.sans && tokensObj.font.family.sans.$value) ||
229
+ (tokensObj.fontFamily && tokensObj.fontFamily.sans && tokensObj.fontFamily.sans.$value) ||
230
+ 'inherit';
231
+ const sizes = [
232
+ { label: 'display', px: 56, w: 700 },
233
+ { label: 'h1', px: 40, w: 600 },
234
+ { label: 'h2', px: 28, w: 600 },
235
+ { label: 'body', px: 16, w: 400 },
236
+ { label: 'small', px: 13, w: 400 },
237
+ ];
238
+ for (const s of sizes) {
239
+ const row = document.createElement('div');
240
+ row.className = 'type-row';
241
+ const meta = document.createElement('div');
242
+ meta.className = 'type-meta';
243
+ meta.textContent = s.label + ' · ' + s.px + 'px';
244
+ const sample = document.createElement('div');
245
+ sample.style.fontFamily = ff;
246
+ sample.style.fontSize = s.px + 'px';
247
+ sample.style.fontWeight = String(s.w);
248
+ sample.style.lineHeight = '1.1';
249
+ sample.style.letterSpacing = '-0.01em';
250
+ sample.textContent = 'The brown fox leaps.';
251
+ row.appendChild(meta);
252
+ row.appendChild(sample);
253
+ typeSpec.appendChild(row);
254
+ }
255
+ </script>
256
+ </body>
257
+ </html>`;
258
+ }
259
+
260
+ export async function runStudio(opts) {
261
+ const dir = resolve(opts.dir || './design-extract-output');
262
+ const port = parseInt(opts.port) || 4837;
263
+
264
+ if (!existsSync(dir)) {
265
+ throw new Error(`No extraction directory found at ${dir}. Run \`designlang <url>\` first.`);
266
+ }
267
+ const prefix = opts.prefix || pickLatest(dir);
268
+ if (!prefix) {
269
+ throw new Error(`No *-design-tokens.json found in ${dir}. Run \`designlang <url>\` first.`);
270
+ }
271
+
272
+ const server = createServer((req, res) => {
273
+ try {
274
+ const url = new URL(req.url, `http://localhost:${port}`);
275
+ const pathname = url.pathname;
276
+
277
+ if (pathname === '/' || pathname === '/index.html') {
278
+ const data = loadExtraction(dir, prefix);
279
+ const html = studioHtml(data);
280
+ res.writeHead(200, { 'content-type': MIME['.html'] });
281
+ res.end(html);
282
+ return;
283
+ }
284
+ if (pathname === '/raw') {
285
+ const data = loadExtraction(dir, prefix);
286
+ res.writeHead(200, { 'content-type': MIME['.json'] });
287
+ res.end(JSON.stringify(data, null, 2));
288
+ return;
289
+ }
290
+ if (pathname === '/api/prefix') {
291
+ res.writeHead(200, { 'content-type': MIME['.json'] });
292
+ res.end(JSON.stringify({ prefix, dir }));
293
+ return;
294
+ }
295
+
296
+ // Static passthrough — screenshots, preview.html, etc.
297
+ const safe = pathname.replace(/\.\./g, '').replace(/^\//, '');
298
+ const filePath = join(dir, safe);
299
+ if (filePath.startsWith(dir) && existsSync(filePath) && statSync(filePath).isFile()) {
300
+ const ext = extname(filePath).toLowerCase();
301
+ res.writeHead(200, { 'content-type': MIME[ext] || 'application/octet-stream' });
302
+ res.end(readFileSync(filePath));
303
+ return;
304
+ }
305
+
306
+ res.writeHead(404, { 'content-type': 'text/plain' });
307
+ res.end('not found');
308
+ } catch (e) {
309
+ res.writeHead(500, { 'content-type': 'text/plain' });
310
+ res.end(`error: ${e.message}`);
311
+ }
312
+ });
313
+
314
+ await new Promise((resolveP) => server.listen(port, resolveP));
315
+ return { port, dir, prefix, server };
316
+ }