designlang 12.3.0 → 12.7.1
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/.claude-plugin/marketplace.json +15 -7
- package/.claude-plugin/plugin.json +19 -8
- package/CHANGELOG.md +230 -0
- package/README.md +38 -8
- package/SUPPORT.md +22 -0
- package/bin/design-extract.js +234 -0
- package/commands/battle.md +27 -0
- package/commands/brand.md +59 -0
- package/commands/extract.md +41 -0
- package/commands/grade.md +29 -0
- package/commands/pack.md +37 -0
- package/commands/remix.md +29 -0
- package/commands/theme-swap.md +42 -0
- package/package.json +3 -3
- package/src/ci.js +36 -2
- package/src/formatters/brand-book.js +1052 -0
- package/src/formatters/theme-swap.js +272 -0
- package/src/pack.js +376 -0
- package/src/recolor.js +199 -0
- package/src/utils/color-gamut.js +64 -0
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
// designlang theme-swap — editorial side-by-side preview HTML.
|
|
2
|
+
// Renders the original and recoloured palettes against each other so the
|
|
3
|
+
// user can see exactly what shifts when the brand primary changes.
|
|
4
|
+
|
|
5
|
+
const FONT_DISPLAY = 'Instrument Serif';
|
|
6
|
+
const FONT_BODY = 'Inter';
|
|
7
|
+
const FONT_MONO = 'JetBrains Mono';
|
|
8
|
+
|
|
9
|
+
function esc(s) {
|
|
10
|
+
return String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function host(url) {
|
|
14
|
+
try { return new URL(url).hostname; } catch { return String(url || ''); }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function topPalette(design, n = 12) {
|
|
18
|
+
const all = (design?.colors?.all || []).slice(0, n);
|
|
19
|
+
return all.map(c => esc(c?.hex || c)).filter(Boolean);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function formatThemeSwap(originalDesign, recoloredDesign, opts = {}) {
|
|
23
|
+
if (!originalDesign || !recoloredDesign) throw new Error('formatThemeSwap: both designs required');
|
|
24
|
+
|
|
25
|
+
const url = originalDesign.meta?.url || '';
|
|
26
|
+
const hostName = host(url);
|
|
27
|
+
const swap = recoloredDesign.meta?.themeSwap || {};
|
|
28
|
+
const fromHex = swap.from || '#000000';
|
|
29
|
+
const toHex = swap.to || '#000000';
|
|
30
|
+
const hueShift = swap.hueShift ?? 0;
|
|
31
|
+
const changed = swap.changedColors ?? 0;
|
|
32
|
+
const date = new Date(originalDesign.meta?.timestamp || Date.now()).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' });
|
|
33
|
+
|
|
34
|
+
const beforeColors = topPalette(originalDesign, 14);
|
|
35
|
+
const afterColors = topPalette(recoloredDesign, 14);
|
|
36
|
+
const ogTitle = `${hostName} · theme-swap · ${fromHex} → ${toHex}`;
|
|
37
|
+
const ogDesc = `Recoloured ${hostName} around ${toHex}. Hue shift ${hueShift.toFixed?.(1) ?? hueShift}°. ${changed} colours changed.`;
|
|
38
|
+
|
|
39
|
+
return `<!doctype html>
|
|
40
|
+
<html lang="en">
|
|
41
|
+
<head>
|
|
42
|
+
<meta charset="utf-8">
|
|
43
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
44
|
+
<title>${esc(ogTitle)}</title>
|
|
45
|
+
<meta name="description" content="${esc(ogDesc)}">
|
|
46
|
+
<meta property="og:title" content="${esc(ogTitle)}">
|
|
47
|
+
<meta property="og:description" content="${esc(ogDesc)}">
|
|
48
|
+
<meta property="og:type" content="article">
|
|
49
|
+
<meta name="twitter:card" content="summary_large_image">
|
|
50
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
51
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
52
|
+
<link href="https://fonts.googleapis.com/css2?family=${encodeURIComponent(FONT_DISPLAY)}&family=${encodeURIComponent(FONT_BODY)}:wght@400;500;600&family=${encodeURIComponent(FONT_MONO)}:wght@400;500&display=swap" rel="stylesheet">
|
|
53
|
+
<style>
|
|
54
|
+
:root {
|
|
55
|
+
--paper: #f7f5ef;
|
|
56
|
+
--ink: #141414;
|
|
57
|
+
--ink-soft: #555049;
|
|
58
|
+
--rule: #e5e1d6;
|
|
59
|
+
--from: ${esc(fromHex)};
|
|
60
|
+
--to: ${esc(toHex)};
|
|
61
|
+
--display: '${FONT_DISPLAY}', 'Times New Roman', serif;
|
|
62
|
+
--body: '${FONT_BODY}', -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
|
|
63
|
+
--mono: '${FONT_MONO}', ui-monospace, 'SF Mono', monospace;
|
|
64
|
+
}
|
|
65
|
+
[data-theme="dark"] {
|
|
66
|
+
--paper: #0e0d0b;
|
|
67
|
+
--ink: #f0ece2;
|
|
68
|
+
--ink-soft: #9b9589;
|
|
69
|
+
--rule: #2a2823;
|
|
70
|
+
}
|
|
71
|
+
* { box-sizing: border-box; }
|
|
72
|
+
html, body { margin: 0; padding: 0; }
|
|
73
|
+
body { background: var(--paper); color: var(--ink); font-family: var(--body); font-size: 16px; line-height: 1.55; -webkit-font-smoothing: antialiased; transition: background .25s, color .25s; }
|
|
74
|
+
.wrap { max-width: 980px; margin: 0 auto; padding: 56px 40px 96px; }
|
|
75
|
+
@media (max-width: 640px) { .wrap { padding: 32px 22px 64px; } }
|
|
76
|
+
|
|
77
|
+
.topbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 56px; font-size: 13px; }
|
|
78
|
+
.brand { font-family: var(--display); font-size: 22px; }
|
|
79
|
+
.brand a { color: var(--ink); text-decoration: none; border-bottom: 1px solid var(--rule); padding-bottom: 1px; }
|
|
80
|
+
.topbar nav { display: flex; gap: 18px; align-items: center; color: var(--ink-soft); }
|
|
81
|
+
.theme-btn { background: transparent; border: 1px solid var(--rule); color: var(--ink-soft); font-size: 12px; padding: 6px 12px; border-radius: 999px; cursor: pointer; letter-spacing: .04em; text-transform: uppercase; font-family: var(--body); }
|
|
82
|
+
.theme-btn:hover { color: var(--ink); border-color: var(--ink); }
|
|
83
|
+
|
|
84
|
+
.kicker { text-transform: uppercase; letter-spacing: .18em; font-size: 11px; color: var(--ink-soft); margin-bottom: 14px; }
|
|
85
|
+
h1.title { font-family: var(--display); font-weight: 400; font-size: clamp(40px, 6vw, 72px); line-height: 1.02; margin: 0 0 36px; letter-spacing: -.01em; }
|
|
86
|
+
h1.title em { font-style: italic; color: var(--ink-soft); padding: 0 .12em; }
|
|
87
|
+
|
|
88
|
+
/* — Hero swatches — */
|
|
89
|
+
.hero-swap { display: grid; grid-template-columns: 1fr auto 1fr; gap: 28px; align-items: center; padding: 8px 0 56px; border-bottom: 1px solid var(--rule); }
|
|
90
|
+
.swatch-card { text-align: center; }
|
|
91
|
+
.swatch-block { width: 100%; height: clamp(180px, 22vw, 240px); border-radius: 6px; box-shadow: inset 0 0 0 1px rgba(0,0,0,.06); margin-bottom: 14px; }
|
|
92
|
+
.swatch-block.from { background: var(--from); }
|
|
93
|
+
.swatch-block.to { background: var(--to); }
|
|
94
|
+
.swatch-card code { font-family: var(--mono); font-size: 14px; letter-spacing: .04em; }
|
|
95
|
+
.swatch-card .lbl { font-family: var(--mono); font-size: 11px; text-transform: uppercase; letter-spacing: .14em; color: var(--ink-soft); display: block; margin-top: 6px; }
|
|
96
|
+
.arrow { font-family: var(--display); font-size: clamp(36px, 5vw, 56px); color: var(--ink-soft); font-style: italic; }
|
|
97
|
+
@media (max-width: 640px) { .hero-swap { grid-template-columns: 1fr; gap: 12px; } .arrow { padding: 8px 0; } }
|
|
98
|
+
|
|
99
|
+
/* — Stats strip — */
|
|
100
|
+
.stats { display: flex; gap: 28px; flex-wrap: wrap; padding: 32px 0 0; font-family: var(--mono); font-size: 12px; text-transform: uppercase; letter-spacing: .08em; color: var(--ink-soft); }
|
|
101
|
+
.stats span strong { color: var(--ink); margin-right: 6px; }
|
|
102
|
+
|
|
103
|
+
section { padding: 56px 0; border-bottom: 1px solid var(--rule); }
|
|
104
|
+
section:last-of-type { border-bottom: 0; }
|
|
105
|
+
section > h2 { font-family: var(--display); font-weight: 400; font-size: 32px; margin: 0 0 8px; letter-spacing: -.005em; }
|
|
106
|
+
section > h2 + .lead { color: var(--ink-soft); margin: 0 0 32px; max-width: 60ch; }
|
|
107
|
+
|
|
108
|
+
.palettes { display: grid; grid-template-columns: 1fr 1fr; gap: 32px; }
|
|
109
|
+
@media (max-width: 640px) { .palettes { grid-template-columns: 1fr; } }
|
|
110
|
+
.pal h3 { font-family: var(--display); font-weight: 400; font-size: 22px; margin: 0 0 6px; }
|
|
111
|
+
.pal .pal-sub { font-family: var(--mono); font-size: 11px; text-transform: uppercase; letter-spacing: .08em; color: var(--ink-soft); margin-bottom: 16px; }
|
|
112
|
+
.chips { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 8px; list-style: none; padding: 0; margin: 0; }
|
|
113
|
+
.chip { display: flex; align-items: center; gap: 8px; padding: 6px 10px 6px 6px; background: rgba(0,0,0,.02); border: 1px solid var(--rule); border-radius: 6px; }
|
|
114
|
+
[data-theme="dark"] .chip { background: rgba(255,255,255,.03); }
|
|
115
|
+
.chip .swatch { width: 22px; height: 22px; border-radius: 4px; box-shadow: inset 0 0 0 1px rgba(0,0,0,.06); flex: 0 0 auto; }
|
|
116
|
+
.chip code { font-family: var(--mono); font-size: 11px; color: var(--ink-soft); }
|
|
117
|
+
|
|
118
|
+
/* — Mockup row — */
|
|
119
|
+
.mock { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; }
|
|
120
|
+
@media (max-width: 640px) { .mock { grid-template-columns: 1fr; } }
|
|
121
|
+
.mock-card { padding: 28px; border-radius: 10px; border: 1px solid var(--rule); background: var(--paper); }
|
|
122
|
+
.mock-card .mock-eyebrow { font-family: var(--mono); font-size: 11px; text-transform: uppercase; letter-spacing: .14em; color: var(--ink-soft); margin-bottom: 14px; }
|
|
123
|
+
.mock-card h4 { font-family: var(--display); font-weight: 400; font-size: 24px; margin: 0 0 8px; }
|
|
124
|
+
.mock-card p { color: var(--ink-soft); margin: 0 0 18px; font-size: 14px; }
|
|
125
|
+
.mock-cta { display: inline-block; padding: 10px 18px; border-radius: 6px; color: white; font-weight: 500; font-size: 13px; text-decoration: none; }
|
|
126
|
+
.mock-cta.from { background: var(--from); }
|
|
127
|
+
.mock-cta.to { background: var(--to); }
|
|
128
|
+
.mock-card .ring { display: inline-block; padding: 2px 10px; border-radius: 999px; font-size: 11px; font-family: var(--mono); margin-bottom: 14px; border: 1px solid currentColor; }
|
|
129
|
+
.mock-card.from .ring { color: var(--from); }
|
|
130
|
+
.mock-card.to .ring { color: var(--to); }
|
|
131
|
+
|
|
132
|
+
footer { padding: 48px 0 0; font-size: 13px; color: var(--ink-soft); display: flex; justify-content: space-between; align-items: end; flex-wrap: wrap; gap: 16px; }
|
|
133
|
+
footer .sig { font-family: var(--display); font-size: 22px; color: var(--ink); }
|
|
134
|
+
footer code { font-family: var(--mono); }
|
|
135
|
+
footer .stamp { font-family: var(--mono); font-size: 11px; text-transform: uppercase; letter-spacing: .08em; }
|
|
136
|
+
|
|
137
|
+
@media print {
|
|
138
|
+
body { background: white; color: black; }
|
|
139
|
+
.topbar nav, .theme-btn { display: none; }
|
|
140
|
+
section, .hero-swap { page-break-inside: avoid; border-color: #ddd; }
|
|
141
|
+
}
|
|
142
|
+
</style>
|
|
143
|
+
</head>
|
|
144
|
+
<body>
|
|
145
|
+
<div class="wrap">
|
|
146
|
+
<header class="topbar">
|
|
147
|
+
<div class="brand"><a href="https://designlang.app">designlang</a></div>
|
|
148
|
+
<nav>
|
|
149
|
+
<span>Theme Swap</span>
|
|
150
|
+
<button class="theme-btn" id="themeBtn" type="button">Theme</button>
|
|
151
|
+
</nav>
|
|
152
|
+
</header>
|
|
153
|
+
|
|
154
|
+
<p class="kicker">Theme Swap · ${esc(date)}</p>
|
|
155
|
+
<h1 class="title">${esc(hostName)} <em>recoloured</em>.</h1>
|
|
156
|
+
|
|
157
|
+
<div class="hero-swap">
|
|
158
|
+
<div class="swatch-card">
|
|
159
|
+
<div class="swatch-block from"></div>
|
|
160
|
+
<code>${esc(fromHex)}</code>
|
|
161
|
+
<span class="lbl">Source primary</span>
|
|
162
|
+
</div>
|
|
163
|
+
<div class="arrow">→</div>
|
|
164
|
+
<div class="swatch-card">
|
|
165
|
+
<div class="swatch-block to"></div>
|
|
166
|
+
<code>${esc(toHex)}</code>
|
|
167
|
+
<span class="lbl">New primary</span>
|
|
168
|
+
</div>
|
|
169
|
+
</div>
|
|
170
|
+
|
|
171
|
+
<div class="stats">
|
|
172
|
+
<span><strong>${(typeof hueShift === 'number' ? hueShift.toFixed(1) : hueShift)}°</strong>hue shift</span>
|
|
173
|
+
<span><strong>${changed}</strong>colours changed</span>
|
|
174
|
+
<span><strong>${(originalDesign.colors?.neutrals || []).length}</strong>neutrals preserved</span>
|
|
175
|
+
<span><strong>${(originalDesign.typography?.scale || []).length}</strong>type sizes unchanged</span>
|
|
176
|
+
</div>
|
|
177
|
+
|
|
178
|
+
<section>
|
|
179
|
+
<h2>The palette, side by side.</h2>
|
|
180
|
+
<p class="lead">Brand inks rotate; neutrals (greys, surfaces, body text) stay put. Spacing, type, motion, and component anatomy are untouched.</p>
|
|
181
|
+
<div class="palettes">
|
|
182
|
+
<div class="pal">
|
|
183
|
+
<h3>Original</h3>
|
|
184
|
+
<p class="pal-sub">${esc(hostName)} as extracted</p>
|
|
185
|
+
<ul class="chips">
|
|
186
|
+
${beforeColors.map(c => `<li class="chip"><span class="swatch" style="background:${c}"></span><code>${c}</code></li>`).join('')}
|
|
187
|
+
</ul>
|
|
188
|
+
</div>
|
|
189
|
+
<div class="pal">
|
|
190
|
+
<h3>Recoloured</h3>
|
|
191
|
+
<p class="pal-sub">around ${esc(toHex)}</p>
|
|
192
|
+
<ul class="chips">
|
|
193
|
+
${afterColors.map(c => `<li class="chip"><span class="swatch" style="background:${c}"></span><code>${c}</code></li>`).join('')}
|
|
194
|
+
</ul>
|
|
195
|
+
</div>
|
|
196
|
+
</div>
|
|
197
|
+
</section>
|
|
198
|
+
|
|
199
|
+
<section>
|
|
200
|
+
<h2>What it feels like.</h2>
|
|
201
|
+
<p class="lead">Same component shape, same typography, same spacing — just a different brand voice.</p>
|
|
202
|
+
<div class="mock">
|
|
203
|
+
<div class="mock-card from">
|
|
204
|
+
<div class="mock-eyebrow">Original</div>
|
|
205
|
+
<span class="ring">Brand</span>
|
|
206
|
+
<h4>Build something they remember.</h4>
|
|
207
|
+
<p>The original tokens, untouched. This is what the site ships today.</p>
|
|
208
|
+
<a href="#" class="mock-cta from">Get started</a>
|
|
209
|
+
</div>
|
|
210
|
+
<div class="mock-card to">
|
|
211
|
+
<div class="mock-eyebrow">Recoloured</div>
|
|
212
|
+
<span class="ring">Brand</span>
|
|
213
|
+
<h4>Build something they remember.</h4>
|
|
214
|
+
<p>Same shape, swapped primary. Drop these tokens into your project.</p>
|
|
215
|
+
<a href="#" class="mock-cta to">Get started</a>
|
|
216
|
+
</div>
|
|
217
|
+
</div>
|
|
218
|
+
</section>
|
|
219
|
+
|
|
220
|
+
<footer>
|
|
221
|
+
<div>
|
|
222
|
+
<div class="sig">designlang</div>
|
|
223
|
+
<div>Run your own: <code>npx designlang theme-swap ${esc(hostName)} --primary ${esc(toHex)}</code></div>
|
|
224
|
+
</div>
|
|
225
|
+
<div class="stamp">${esc(date)} · v${esc(opts.version || '')}</div>
|
|
226
|
+
</footer>
|
|
227
|
+
</div>
|
|
228
|
+
|
|
229
|
+
<script>
|
|
230
|
+
(function () {
|
|
231
|
+
var btn = document.getElementById('themeBtn');
|
|
232
|
+
var saved = null;
|
|
233
|
+
try { saved = localStorage.getItem('dl-theme'); } catch (e) {}
|
|
234
|
+
if (saved) document.documentElement.setAttribute('data-theme', saved);
|
|
235
|
+
btn && btn.addEventListener('click', function () {
|
|
236
|
+
var cur = document.documentElement.getAttribute('data-theme') === 'dark' ? '' : 'dark';
|
|
237
|
+
if (cur) document.documentElement.setAttribute('data-theme', cur);
|
|
238
|
+
else document.documentElement.removeAttribute('data-theme');
|
|
239
|
+
try { localStorage.setItem('dl-theme', cur); } catch (e) {}
|
|
240
|
+
});
|
|
241
|
+
})();
|
|
242
|
+
</script>
|
|
243
|
+
</body>
|
|
244
|
+
</html>`;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export function formatThemeSwapMarkdown(originalDesign, recoloredDesign) {
|
|
248
|
+
const url = originalDesign?.meta?.url || '';
|
|
249
|
+
const hostName = host(url);
|
|
250
|
+
const swap = recoloredDesign?.meta?.themeSwap || {};
|
|
251
|
+
const fromHex = swap.from || '—';
|
|
252
|
+
const toHex = swap.to || '—';
|
|
253
|
+
const hueShift = swap.hueShift ?? 0;
|
|
254
|
+
const lines = [
|
|
255
|
+
`# Theme swap — ${hostName}`,
|
|
256
|
+
``,
|
|
257
|
+
`**${fromHex} → ${toHex}** · hue shift ${hueShift}° · ${swap.changedColors ?? 0} colours changed`,
|
|
258
|
+
``,
|
|
259
|
+
`## Palette diff`,
|
|
260
|
+
``,
|
|
261
|
+
`| Original | Recoloured |`,
|
|
262
|
+
`|---|---|`,
|
|
263
|
+
...originalDesign.colors?.all?.slice(0, 14).map((c, i) => {
|
|
264
|
+
const before = c?.hex || '';
|
|
265
|
+
const after = recoloredDesign.colors?.all?.[i]?.hex || '';
|
|
266
|
+
return `| \`${before}\` | \`${after}\` |`;
|
|
267
|
+
}) || [],
|
|
268
|
+
``,
|
|
269
|
+
`_Run again: \`npx designlang theme-swap ${hostName} --primary ${toHex}\`_`,
|
|
270
|
+
];
|
|
271
|
+
return lines.join('\n');
|
|
272
|
+
}
|
package/src/pack.js
ADDED
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
// designlang pack — bundle every emitter output into a single, polished
|
|
2
|
+
// design-system directory. One artifact a designer or dev can clone, drop
|
|
3
|
+
// into a project, or zip up and send to a client.
|
|
4
|
+
//
|
|
5
|
+
// Layout:
|
|
6
|
+
// <host>-design-system/
|
|
7
|
+
// README.md
|
|
8
|
+
// LICENSE.txt
|
|
9
|
+
// tokens/
|
|
10
|
+
// design-tokens.json DTCG (primitive + semantic)
|
|
11
|
+
// tokens.flat.json legacy flat
|
|
12
|
+
// tailwind.config.js
|
|
13
|
+
// variables.css
|
|
14
|
+
// figma-variables.json
|
|
15
|
+
// motion-tokens.json
|
|
16
|
+
// theme.js React theme object
|
|
17
|
+
// components/
|
|
18
|
+
// anatomy.tsx typed React stubs
|
|
19
|
+
// storybook/ runnable Storybook project
|
|
20
|
+
// starter/ minimal Next.js or HTML starter
|
|
21
|
+
// prompts/
|
|
22
|
+
// v0.txt
|
|
23
|
+
// lovable.txt
|
|
24
|
+
// cursor.md
|
|
25
|
+
// claude-artifacts.md
|
|
26
|
+
// recipes/<component>.md …
|
|
27
|
+
// extras/
|
|
28
|
+
// voice.json
|
|
29
|
+
// prompt-pack.md single-file rollup
|
|
30
|
+
|
|
31
|
+
import { mkdirSync, writeFileSync, statSync } from 'fs';
|
|
32
|
+
import { join } from 'path';
|
|
33
|
+
|
|
34
|
+
import { formatDtcgTokens } from './formatters/dtcg-tokens.js';
|
|
35
|
+
import { formatTokens } from './formatters/tokens.js';
|
|
36
|
+
import { formatTailwind } from './formatters/tailwind.js';
|
|
37
|
+
import { formatCssVars } from './formatters/css-vars.js';
|
|
38
|
+
import { formatFigma } from './formatters/figma.js';
|
|
39
|
+
import { formatMotionTokens } from './formatters/motion-tokens.js';
|
|
40
|
+
import { formatReactTheme } from './formatters/theme.js';
|
|
41
|
+
import { formatStorybook } from './formatters/storybook.js';
|
|
42
|
+
import { formatAnatomyStubs } from './extractors/component-anatomy.js';
|
|
43
|
+
import { buildPromptPack } from './formatters/prompt-pack.js';
|
|
44
|
+
import { generateClone } from './clone.js';
|
|
45
|
+
|
|
46
|
+
function nameFromUrl(url) {
|
|
47
|
+
try {
|
|
48
|
+
const u = new URL(url);
|
|
49
|
+
return u.hostname.replace(/[^a-z0-9]+/gi, '-').replace(/^-|-$/g, '').toLowerCase();
|
|
50
|
+
} catch {
|
|
51
|
+
return 'design-system';
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function host(url) {
|
|
56
|
+
try { return new URL(url).hostname; } catch { return String(url || ''); }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function exists(p) {
|
|
60
|
+
try { statSync(p); return true; } catch { return false; }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Normalise emitter output to a writable string. Different formatters return
|
|
64
|
+
// different shapes (string, object, undefined when feature absent) — coerce
|
|
65
|
+
// here so callers don't have to know.
|
|
66
|
+
function toText(content, fallback = '') {
|
|
67
|
+
if (content == null) return fallback;
|
|
68
|
+
if (typeof content === 'string') return content;
|
|
69
|
+
if (Buffer.isBuffer(content)) return content;
|
|
70
|
+
return JSON.stringify(content, null, 2);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function writeFile(path, content) {
|
|
74
|
+
mkdirSync(join(path, '..'), { recursive: true });
|
|
75
|
+
writeFileSync(path, toText(content), 'utf-8');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function buildReadme(design, opts) {
|
|
79
|
+
const meta = design.meta || {};
|
|
80
|
+
const hostName = host(meta.url);
|
|
81
|
+
const grade = design.score?.grade || '—';
|
|
82
|
+
const overall = design.score?.overall ?? '—';
|
|
83
|
+
const families = (design.typography?.families || []).map(f => (typeof f === 'string' ? f : f.name)).filter(Boolean).slice(0, 3);
|
|
84
|
+
const colorCount = (design.colors?.all || []).length;
|
|
85
|
+
const spacingBase = design.spacing?.base ?? '—';
|
|
86
|
+
const componentLib = design.componentLibrary?.library || 'unknown';
|
|
87
|
+
const stack = design.stack?.framework || 'unknown';
|
|
88
|
+
|
|
89
|
+
return `# ${hostName} — design system pack
|
|
90
|
+
|
|
91
|
+
> Built from \`${meta.url || ''}\` on ${new Date(meta.timestamp || Date.now()).toISOString().slice(0, 10)} by [designlang](https://designlang.app) v${opts.version || ''}.
|
|
92
|
+
|
|
93
|
+
A single, polished bundle of every artifact designlang emits for ${hostName}: tokens, components, a runnable Storybook, a minimal starter, and paste-ready prompts for v0 / Lovable / Cursor / Claude Artifacts.
|
|
94
|
+
|
|
95
|
+
## At a glance
|
|
96
|
+
|
|
97
|
+
- **Grade:** ${grade} (${overall}/100)
|
|
98
|
+
- **Stack:** ${stack} · component library: ${componentLib}
|
|
99
|
+
- **Type families:** ${families.length ? families.join(', ') : '—'}
|
|
100
|
+
- **Palette:** ${colorCount} colors
|
|
101
|
+
- **Spacing base:** ${spacingBase}
|
|
102
|
+
|
|
103
|
+
## What's in this pack
|
|
104
|
+
|
|
105
|
+
\`\`\`
|
|
106
|
+
${nameFromUrl(meta.url || '')}-design-system/
|
|
107
|
+
├── README.md ← you are here
|
|
108
|
+
├── LICENSE.txt
|
|
109
|
+
├── tokens/ ← DTCG + Tailwind + CSS vars + Figma vars + motion + theme.js
|
|
110
|
+
├── components/ ← typed React stubs (anatomy.tsx)
|
|
111
|
+
├── storybook/ ← runnable Storybook project
|
|
112
|
+
├── starter/ ← minimal starter app
|
|
113
|
+
├── prompts/ ← v0 · Lovable · Cursor · Claude Artifacts
|
|
114
|
+
└── extras/ ← voice fingerprint + recipe cards
|
|
115
|
+
\`\`\`
|
|
116
|
+
|
|
117
|
+
## Install the tokens
|
|
118
|
+
|
|
119
|
+
### Tailwind
|
|
120
|
+
|
|
121
|
+
\`\`\`js
|
|
122
|
+
// tailwind.config.js
|
|
123
|
+
import config from './tokens/tailwind.config.js';
|
|
124
|
+
export default config;
|
|
125
|
+
\`\`\`
|
|
126
|
+
|
|
127
|
+
### CSS variables
|
|
128
|
+
|
|
129
|
+
\`\`\`html
|
|
130
|
+
<link rel="stylesheet" href="tokens/variables.css">
|
|
131
|
+
\`\`\`
|
|
132
|
+
|
|
133
|
+
### Figma
|
|
134
|
+
|
|
135
|
+
In Figma → Variables panel → import \`tokens/figma-variables.json\`.
|
|
136
|
+
|
|
137
|
+
### Storybook
|
|
138
|
+
|
|
139
|
+
\`\`\`bash
|
|
140
|
+
cd storybook && npm install && npm run storybook
|
|
141
|
+
\`\`\`
|
|
142
|
+
|
|
143
|
+
## Provenance
|
|
144
|
+
|
|
145
|
+
This pack was extracted from a publicly-accessible URL and represents the *observable design language* of that site at the time of capture. Token values are inferred from computed styles — no source files were accessed. See \`LICENSE.txt\` for usage guidance.
|
|
146
|
+
|
|
147
|
+
Re-pack at any time:
|
|
148
|
+
|
|
149
|
+
\`\`\`bash
|
|
150
|
+
npx designlang pack ${hostName}
|
|
151
|
+
\`\`\`
|
|
152
|
+
`;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function buildLicense(design) {
|
|
156
|
+
const hostName = host(design.meta?.url);
|
|
157
|
+
const date = new Date(design.meta?.timestamp || Date.now()).toISOString().slice(0, 10);
|
|
158
|
+
return `Design System Pack — Provenance
|
|
159
|
+
================================
|
|
160
|
+
|
|
161
|
+
Source: ${design.meta?.url || hostName}
|
|
162
|
+
Captured: ${date}
|
|
163
|
+
Tool: designlang (https://designlang.app, MIT)
|
|
164
|
+
|
|
165
|
+
The token values, type scale, spacing system, and component anatomy in
|
|
166
|
+
this pack were inferred from the publicly-accessible computed styles of
|
|
167
|
+
the source URL via a headless browser. No source files, proprietary
|
|
168
|
+
assets, or copyrighted media were accessed or included.
|
|
169
|
+
|
|
170
|
+
You are free to use these values as a starting point, reference, or
|
|
171
|
+
inspiration. The packaging itself (this README, the bundle layout, the
|
|
172
|
+
emitter output formats) is released under MIT by the designlang project.
|
|
173
|
+
|
|
174
|
+
Trademarks, logos, brand names, and other identifiable assets of the
|
|
175
|
+
source remain the property of their respective owners and are not
|
|
176
|
+
licensed by this pack. Do not pass off this pack as the original site's
|
|
177
|
+
official design system without permission.
|
|
178
|
+
`;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function buildStarter(design) {
|
|
182
|
+
// Simple, dependency-free HTML starter that consumes tokens/variables.css
|
|
183
|
+
// and emits a hero + button preview. The full clone (Next.js) is left as
|
|
184
|
+
// an opt-in via --with-clone.
|
|
185
|
+
const hostName = host(design.meta?.url);
|
|
186
|
+
const families = (design.typography?.families || []).map(f => (typeof f === 'string' ? f : f.name)).filter(Boolean);
|
|
187
|
+
const display = families[0] || 'system-ui';
|
|
188
|
+
const heading = (design.voice?.sampleHeadings || [])[0] || `Built from ${hostName}`;
|
|
189
|
+
return `<!doctype html>
|
|
190
|
+
<html lang="en">
|
|
191
|
+
<head>
|
|
192
|
+
<meta charset="utf-8">
|
|
193
|
+
<title>${hostName} starter</title>
|
|
194
|
+
<link rel="stylesheet" href="../tokens/variables.css">
|
|
195
|
+
<style>
|
|
196
|
+
body {
|
|
197
|
+
margin: 0;
|
|
198
|
+
font-family: ${JSON.stringify(display)}, -apple-system, BlinkMacSystemFont, sans-serif;
|
|
199
|
+
background: var(--color-background, #fff);
|
|
200
|
+
color: var(--color-text, #111);
|
|
201
|
+
line-height: 1.5;
|
|
202
|
+
}
|
|
203
|
+
main { max-width: 840px; margin: 0 auto; padding: 64px 32px; }
|
|
204
|
+
h1 { font-size: clamp(40px, 6vw, 72px); line-height: 1.05; margin: 0 0 18px; }
|
|
205
|
+
p { font-size: 18px; max-width: 56ch; color: var(--color-text-secondary, #555); }
|
|
206
|
+
.cta {
|
|
207
|
+
display: inline-block;
|
|
208
|
+
padding: 14px 28px;
|
|
209
|
+
margin-top: 28px;
|
|
210
|
+
background: var(--color-primary, #0a0a0a);
|
|
211
|
+
color: var(--color-on-primary, #fff);
|
|
212
|
+
border-radius: var(--radius-md, 8px);
|
|
213
|
+
text-decoration: none;
|
|
214
|
+
font-weight: 600;
|
|
215
|
+
}
|
|
216
|
+
.cta:hover { opacity: 0.9; }
|
|
217
|
+
.meta { margin-top: 64px; font-size: 12px; color: #888; }
|
|
218
|
+
.meta a { color: inherit; }
|
|
219
|
+
</style>
|
|
220
|
+
</head>
|
|
221
|
+
<body>
|
|
222
|
+
<main>
|
|
223
|
+
<h1>${heading}</h1>
|
|
224
|
+
<p>This starter is wired to the tokens in <code>tokens/variables.css</code>. Edit the variables and watch this page change. Drop in your own components and use the same tokens to keep the visual language consistent.</p>
|
|
225
|
+
<a class="cta" href="#">Get started</a>
|
|
226
|
+
<p class="meta">Generated by <a href="https://designlang.app">designlang</a>. Re-pack with <code>npx designlang pack ${hostName}</code>.</p>
|
|
227
|
+
</main>
|
|
228
|
+
</body>
|
|
229
|
+
</html>
|
|
230
|
+
`;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function buildPromptPackDocument(design) {
|
|
234
|
+
const pack = buildPromptPack(design);
|
|
235
|
+
const lines = [
|
|
236
|
+
'# Prompt pack',
|
|
237
|
+
'',
|
|
238
|
+
`Paste-ready prompts for ${host(design.meta?.url)}. Use the variant that matches your tool.`,
|
|
239
|
+
'',
|
|
240
|
+
];
|
|
241
|
+
for (const [name, body] of Object.entries(pack)) {
|
|
242
|
+
if (name === 'recipes') continue;
|
|
243
|
+
const text = typeof body === 'string' ? body : JSON.stringify(body, null, 2);
|
|
244
|
+
lines.push(`## ${name}\n`, '```', text.trim(), '```\n');
|
|
245
|
+
}
|
|
246
|
+
if (Array.isArray(pack.recipes) && pack.recipes.length) {
|
|
247
|
+
lines.push('## Recipes\n');
|
|
248
|
+
for (const recipe of pack.recipes) {
|
|
249
|
+
const text = typeof recipe.content === 'string' ? recipe.content : JSON.stringify(recipe.content, null, 2);
|
|
250
|
+
lines.push(`### ${recipe.name || 'recipe'}\n`, text.trim(), '\n');
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return lines.join('\n');
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Build a design-system pack on disk.
|
|
258
|
+
*
|
|
259
|
+
* @param {object} design — full design from extractDesignLanguage()
|
|
260
|
+
* @param {object} opts
|
|
261
|
+
* @param {string} opts.outDir — where to write the pack
|
|
262
|
+
* @param {string} opts.version — designlang version, embedded in README
|
|
263
|
+
* @param {boolean} [opts.withClone=true] — include the Next.js starter
|
|
264
|
+
* @returns {object} { dir, files: string[] }
|
|
265
|
+
*/
|
|
266
|
+
export function buildPack(design, opts = {}) {
|
|
267
|
+
const outDir = opts.outDir;
|
|
268
|
+
if (!outDir) throw new Error('pack: outDir is required');
|
|
269
|
+
const written = [];
|
|
270
|
+
|
|
271
|
+
mkdirSync(outDir, { recursive: true });
|
|
272
|
+
|
|
273
|
+
// Top-level
|
|
274
|
+
const readmePath = join(outDir, 'README.md');
|
|
275
|
+
writeFile(readmePath, buildReadme(design, opts));
|
|
276
|
+
written.push(readmePath);
|
|
277
|
+
|
|
278
|
+
const licensePath = join(outDir, 'LICENSE.txt');
|
|
279
|
+
writeFile(licensePath, buildLicense(design));
|
|
280
|
+
written.push(licensePath);
|
|
281
|
+
|
|
282
|
+
// tokens/
|
|
283
|
+
const tokensDir = join(outDir, 'tokens');
|
|
284
|
+
mkdirSync(tokensDir, { recursive: true });
|
|
285
|
+
|
|
286
|
+
// Each formatter has a different return shape — toText() (called by
|
|
287
|
+
// writeFile) handles both objects and pre-stringified JSON correctly.
|
|
288
|
+
// formatDtcgTokens returns an object; formatTokens / formatFigma /
|
|
289
|
+
// formatMotionTokens return JSON strings directly; formatTailwind /
|
|
290
|
+
// formatCssVars / formatReactTheme return code strings.
|
|
291
|
+
const tokenWrites = [
|
|
292
|
+
['design-tokens.json', formatDtcgTokens(design)],
|
|
293
|
+
['tokens.flat.json', formatTokens(design)],
|
|
294
|
+
['tailwind.config.js', formatTailwind(design)],
|
|
295
|
+
['variables.css', formatCssVars(design)],
|
|
296
|
+
['figma-variables.json', formatFigma(design)],
|
|
297
|
+
['motion-tokens.json', formatMotionTokens(design.motion || {})],
|
|
298
|
+
['theme.js', formatReactTheme(design)],
|
|
299
|
+
];
|
|
300
|
+
for (const [name, content] of tokenWrites) {
|
|
301
|
+
const p = join(tokensDir, name);
|
|
302
|
+
writeFile(p, content);
|
|
303
|
+
written.push(p);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// components/
|
|
307
|
+
const componentsDir = join(outDir, 'components');
|
|
308
|
+
mkdirSync(componentsDir, { recursive: true });
|
|
309
|
+
const anatomyPath = join(componentsDir, 'anatomy.tsx');
|
|
310
|
+
writeFile(anatomyPath, formatAnatomyStubs(design.componentAnatomy || []));
|
|
311
|
+
written.push(anatomyPath);
|
|
312
|
+
|
|
313
|
+
// storybook/
|
|
314
|
+
const sbFiles = formatStorybook(design);
|
|
315
|
+
for (const [relPath, content] of Object.entries(sbFiles)) {
|
|
316
|
+
const p = join(outDir, 'storybook', relPath);
|
|
317
|
+
mkdirSync(join(p, '..'), { recursive: true });
|
|
318
|
+
writeFile(p, content);
|
|
319
|
+
written.push(p);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// starter/ — minimal HTML by default; full Next.js when --with-clone
|
|
323
|
+
const starterDir = join(outDir, 'starter');
|
|
324
|
+
if (opts.withClone) {
|
|
325
|
+
try {
|
|
326
|
+
generateClone(design, starterDir);
|
|
327
|
+
written.push(starterDir);
|
|
328
|
+
} catch (err) {
|
|
329
|
+
// Clone is best-effort — fall back to HTML starter if it errors.
|
|
330
|
+
mkdirSync(starterDir, { recursive: true });
|
|
331
|
+
writeFile(join(starterDir, 'index.html'), buildStarter(design));
|
|
332
|
+
written.push(join(starterDir, 'index.html'));
|
|
333
|
+
}
|
|
334
|
+
} else {
|
|
335
|
+
mkdirSync(starterDir, { recursive: true });
|
|
336
|
+
const starterPath = join(starterDir, 'index.html');
|
|
337
|
+
writeFile(starterPath, buildStarter(design));
|
|
338
|
+
written.push(starterPath);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// prompts/
|
|
342
|
+
const pack = buildPromptPack(design);
|
|
343
|
+
const promptsDir = join(outDir, 'prompts');
|
|
344
|
+
mkdirSync(promptsDir, { recursive: true });
|
|
345
|
+
for (const [name, content] of Object.entries(pack)) {
|
|
346
|
+
if (name === 'recipes') continue;
|
|
347
|
+
const p = join(promptsDir, name);
|
|
348
|
+
writeFile(p, content);
|
|
349
|
+
written.push(p);
|
|
350
|
+
}
|
|
351
|
+
if (Array.isArray(pack.recipes) && pack.recipes.length) {
|
|
352
|
+
const recipesDir = join(promptsDir, 'recipes');
|
|
353
|
+
mkdirSync(recipesDir, { recursive: true });
|
|
354
|
+
for (const recipe of pack.recipes) {
|
|
355
|
+
// Each recipe = { name, content }; sanitise name to a safe filename.
|
|
356
|
+
const safeName = String(recipe.name || 'recipe').replace(/[^a-z0-9-_]+/gi, '-').toLowerCase();
|
|
357
|
+
const p = join(recipesDir, `${safeName}.md`);
|
|
358
|
+
writeFile(p, recipe.content);
|
|
359
|
+
written.push(p);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// extras/
|
|
364
|
+
const extrasDir = join(outDir, 'extras');
|
|
365
|
+
mkdirSync(extrasDir, { recursive: true });
|
|
366
|
+
if (design.voice) {
|
|
367
|
+
const p = join(extrasDir, 'voice.json');
|
|
368
|
+
writeFile(p, JSON.stringify(design.voice, null, 2));
|
|
369
|
+
written.push(p);
|
|
370
|
+
}
|
|
371
|
+
const promptDocPath = join(extrasDir, 'prompt-pack.md');
|
|
372
|
+
writeFile(promptDocPath, buildPromptPackDocument(design));
|
|
373
|
+
written.push(promptDocPath);
|
|
374
|
+
|
|
375
|
+
return { dir: outDir, files: written };
|
|
376
|
+
}
|