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 +13 -0
- package/README.md +2 -2
- package/bin/design-extract.js +63 -0
- package/package.json +2 -2
- package/src/ci.js +140 -0
- package/src/clone.js +365 -134
- package/src/extractors/form-states.js +109 -0
- package/src/index.js +3 -0
- package/src/studio.js +316 -0
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
|
-
|
|
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
|
-
**[
|
|
637
|
+
**[https://designlang.manavaryasingh.com](https://designlang.manavaryasingh.com/)** — the brutalist product page.
|
|
638
638
|
|
|
639
639
|
## Contributing
|
|
640
640
|
|
package/bin/design-extract.js
CHANGED
|
@@ -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": "
|
|
4
|
-
"description": "Extract the complete design language from any website
|
|
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
|
|
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
|
+
“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.”
|
|
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
|
-
//
|
|
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 ${
|
|
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
|
-
//
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
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:
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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
|
+
}
|