designlang 12.4.0 → 12.8.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/.claude-plugin/marketplace.json +15 -7
- package/.claude-plugin/plugin.json +19 -8
- package/CHANGELOG.md +254 -0
- package/README.md +34 -4
- package/SUPPORT.md +22 -0
- package/bin/design-extract.js +295 -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/pair.md +68 -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/pair.js +331 -0
- package/src/formatters/theme-swap.js +272 -0
- package/src/fuse.js +154 -0
- package/src/recolor.js +199 -0
- package/src/utils/color-gamut.js +64 -0
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
// designlang pair — editorial preview HTML for a fused design.
|
|
2
|
+
//
|
|
3
|
+
// Shows both source sites + the fused result side-by-side, with a clear
|
|
4
|
+
// matrix of which axis came from which source. Companion to the brand
|
|
5
|
+
// book, which the same fused design also feeds into.
|
|
6
|
+
|
|
7
|
+
const FONT_DISPLAY = 'Instrument Serif';
|
|
8
|
+
const FONT_BODY = 'Inter';
|
|
9
|
+
const FONT_MONO = 'JetBrains Mono';
|
|
10
|
+
|
|
11
|
+
function esc(s) {
|
|
12
|
+
return String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function host(url) {
|
|
16
|
+
try { return new URL(url).hostname; } catch { return String(url || ''); }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function familyName(f) {
|
|
20
|
+
if (!f) return '';
|
|
21
|
+
if (typeof f === 'string') return f;
|
|
22
|
+
return f.name || f.family || '';
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function paletteStrip(design, n = 10) {
|
|
26
|
+
const all = (design?.colors?.all || []).slice(0, n);
|
|
27
|
+
if (!all.length) return '<span class="muted">—</span>';
|
|
28
|
+
return all.map(c => `<span class="chip" style="background:${esc(c?.hex || c)}" title="${esc(c?.hex || c)}"></span>`).join('');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function primaryHex(design) {
|
|
32
|
+
return design?.colors?.primary?.hex
|
|
33
|
+
|| (design?.colors?.all || []).find(c => c?.hex)?.hex
|
|
34
|
+
|| '#141414';
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function topFamily(design) {
|
|
38
|
+
const f = (design?.typography?.families || [])[0];
|
|
39
|
+
return familyName(f) || 'system-ui';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function topHeading(design) {
|
|
43
|
+
const headings = (design?.voice?.sampleHeadings || []).filter(h => typeof h === 'string' && h.length > 4 && h.length < 120);
|
|
44
|
+
return headings[0] || 'Quick brown fox jumps over the lazy dog.';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function formatPair(designA, designB, fused, summary, opts = {}) {
|
|
48
|
+
if (!designA || !designB || !fused) throw new Error('formatPair: all three designs are required');
|
|
49
|
+
|
|
50
|
+
const hostA = host(designA.meta?.url);
|
|
51
|
+
const hostB = host(designB.meta?.url);
|
|
52
|
+
const axes = summary?.axes || fused.fusedAxes || {};
|
|
53
|
+
const accent = primaryHex(fused);
|
|
54
|
+
|
|
55
|
+
const date = new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
|
|
56
|
+
const ogTitle = `${hostA} × ${hostB} · designlang pair`;
|
|
57
|
+
const ogDesc = `Fused design system — ${hostA} crossed with ${hostB} along ${Object.keys(axes).length} axes.`;
|
|
58
|
+
|
|
59
|
+
// Six axes drive the matrix. We render a 6-row table so the viewer
|
|
60
|
+
// can see at a glance which dimension came from which source.
|
|
61
|
+
const axisLabels = [
|
|
62
|
+
['colors', 'Colour'],
|
|
63
|
+
['typography', 'Typography'],
|
|
64
|
+
['spacing', 'Spacing'],
|
|
65
|
+
['shape', 'Shape'],
|
|
66
|
+
['motion', 'Motion'],
|
|
67
|
+
['voice', 'Voice'],
|
|
68
|
+
['components', 'Components'],
|
|
69
|
+
];
|
|
70
|
+
|
|
71
|
+
const sampleHead = topHeading(fused);
|
|
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>${esc(ogTitle)}</title>
|
|
79
|
+
<meta name="description" content="${esc(ogDesc)}">
|
|
80
|
+
<meta property="og:title" content="${esc(ogTitle)}">
|
|
81
|
+
<meta property="og:description" content="${esc(ogDesc)}">
|
|
82
|
+
<meta property="og:type" content="article">
|
|
83
|
+
<meta name="twitter:card" content="summary_large_image">
|
|
84
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
85
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
86
|
+
<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">
|
|
87
|
+
<style>
|
|
88
|
+
:root {
|
|
89
|
+
--paper: #f6f3ec;
|
|
90
|
+
--paper-2: #efebe1;
|
|
91
|
+
--ink: #131313;
|
|
92
|
+
--ink-soft: #555048;
|
|
93
|
+
--ink-faint: #8a8579;
|
|
94
|
+
--rule: #e0dccf;
|
|
95
|
+
--accent: ${esc(accent)};
|
|
96
|
+
--display: '${FONT_DISPLAY}', 'Times New Roman', serif;
|
|
97
|
+
--body: '${FONT_BODY}', -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
|
|
98
|
+
--mono: '${FONT_MONO}', ui-monospace, 'SF Mono', monospace;
|
|
99
|
+
}
|
|
100
|
+
[data-theme="dark"] {
|
|
101
|
+
--paper: #0d0c0a;
|
|
102
|
+
--paper-2: #15140f;
|
|
103
|
+
--ink: #ece8de;
|
|
104
|
+
--ink-soft: #9d978a;
|
|
105
|
+
--ink-faint: #5b574e;
|
|
106
|
+
--rule: #292621;
|
|
107
|
+
}
|
|
108
|
+
* { box-sizing: border-box; }
|
|
109
|
+
html, body { margin: 0; padding: 0; }
|
|
110
|
+
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; }
|
|
111
|
+
a { color: var(--ink); border-bottom: 1px solid var(--rule); padding-bottom: 1px; text-decoration: none; }
|
|
112
|
+
a:hover { border-color: var(--ink); }
|
|
113
|
+
code { font-family: var(--mono); font-size: .92em; }
|
|
114
|
+
.muted { color: var(--ink-soft); }
|
|
115
|
+
|
|
116
|
+
.wrap { max-width: 980px; margin: 0 auto; padding: 56px 40px 96px; }
|
|
117
|
+
@media (max-width: 640px) { .wrap { padding: 32px 22px 64px; } }
|
|
118
|
+
|
|
119
|
+
.topbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 56px; font-size: 13px; }
|
|
120
|
+
.brand { font-family: var(--display); font-size: 22px; }
|
|
121
|
+
.brand a { border-bottom: 1px solid var(--rule); }
|
|
122
|
+
.topbar nav { display: flex; gap: 18px; align-items: center; color: var(--ink-soft); }
|
|
123
|
+
.theme-btn { background: transparent; border: 1px solid var(--rule); color: var(--ink-soft); font-size: 11px; padding: 6px 12px; border-radius: 999px; cursor: pointer; letter-spacing: .12em; text-transform: uppercase; font-family: var(--body); }
|
|
124
|
+
.theme-btn:hover { color: var(--ink); border-color: var(--ink); }
|
|
125
|
+
|
|
126
|
+
.kicker { text-transform: uppercase; letter-spacing: .18em; font-size: 11px; color: var(--ink-soft); font-family: var(--mono); margin: 0 0 14px; }
|
|
127
|
+
h1.title { font-family: var(--display); font-weight: 400; font-size: clamp(40px, 6vw, 76px); line-height: 1.02; margin: 0 0 36px; letter-spacing: -.01em; }
|
|
128
|
+
h1.title em { font-style: italic; color: var(--ink-soft); padding: 0 .12em; }
|
|
129
|
+
|
|
130
|
+
/* — Hero crossover — */
|
|
131
|
+
.crossover { display: grid; grid-template-columns: 1fr auto 1fr 1fr; gap: 18px; align-items: stretch; padding: 12px 0 56px; border-bottom: 1px solid var(--rule); }
|
|
132
|
+
@media (max-width: 760px) { .crossover { grid-template-columns: 1fr; } .crossover .arrow { display: none; } }
|
|
133
|
+
.source-card, .fused-card { padding: 22px 22px 18px; border-radius: 8px; background: var(--paper-2); box-shadow: inset 0 0 0 1px var(--rule); display: flex; flex-direction: column; gap: 14px; min-height: 220px; }
|
|
134
|
+
.fused-card { background: var(--accent); color: ${/* readable on accent */''}; box-shadow: inset 0 0 0 1px rgba(0,0,0,.08); position: relative; overflow: hidden; }
|
|
135
|
+
.source-card .lbl, .fused-card .lbl { font-family: var(--mono); font-size: 10px; letter-spacing: .14em; text-transform: uppercase; opacity: .82; }
|
|
136
|
+
.source-card .host, .fused-card .host { font-family: var(--display); font-size: 24px; line-height: 1.1; word-break: break-all; }
|
|
137
|
+
.source-card .chips, .fused-card .chips { display: flex; gap: 4px; flex-wrap: wrap; }
|
|
138
|
+
.chip { width: 18px; height: 18px; border-radius: 3px; box-shadow: inset 0 0 0 1px rgba(0,0,0,.08); flex: 0 0 auto; }
|
|
139
|
+
.source-card .fam, .fused-card .fam { font-family: var(--mono); font-size: 11px; opacity: .82; }
|
|
140
|
+
.arrow { font-family: var(--display); font-style: italic; font-size: clamp(22px, 3vw, 36px); color: var(--ink-soft); align-self: center; padding: 0 8px; }
|
|
141
|
+
.fused-card .lbl, .fused-card .fam, .fused-card .chip { color: white; }
|
|
142
|
+
.fused-card .host { color: white; }
|
|
143
|
+
|
|
144
|
+
/* — Axis matrix — */
|
|
145
|
+
section { padding: 56px 0; border-bottom: 1px solid var(--rule); }
|
|
146
|
+
section:last-of-type { border-bottom: 0; }
|
|
147
|
+
section > h2 { font-family: var(--display); font-weight: 400; font-size: clamp(28px, 3.5vw, 40px); line-height: 1.04; margin: 0 0 8px; letter-spacing: -.005em; }
|
|
148
|
+
section > h2 + .lead { color: var(--ink-soft); margin: 0 0 28px; max-width: 60ch; }
|
|
149
|
+
|
|
150
|
+
.matrix { width: 100%; border-collapse: collapse; font-size: 14px; }
|
|
151
|
+
.matrix th, .matrix td { padding: 14px 12px; text-align: left; border-bottom: 1px solid var(--rule); vertical-align: middle; }
|
|
152
|
+
.matrix th { font-family: var(--mono); font-size: 10px; letter-spacing: .12em; text-transform: uppercase; color: var(--ink-faint); border-bottom: 1px solid var(--ink); }
|
|
153
|
+
.matrix .axis-name { font-family: var(--display); font-size: 18px; }
|
|
154
|
+
.matrix .src { font-family: var(--mono); font-size: 11px; letter-spacing: .04em; color: var(--ink-soft); }
|
|
155
|
+
.matrix .src.from-a { color: var(--ink); }
|
|
156
|
+
.matrix .src.from-b { color: var(--ink); }
|
|
157
|
+
.matrix .pill { display: inline-block; padding: 3px 8px; border-radius: 999px; font-family: var(--mono); font-size: 10px; letter-spacing: .12em; text-transform: uppercase; border: 1px solid var(--rule); background: var(--paper-2); }
|
|
158
|
+
.matrix .pill.from-a { border-color: var(--ink-soft); }
|
|
159
|
+
.matrix .pill.from-b { border-color: var(--accent); color: var(--ink); background: color-mix(in srgb, var(--accent) 14%, var(--paper-2)); }
|
|
160
|
+
|
|
161
|
+
/* — Specimen of the fused design — */
|
|
162
|
+
.specimen { padding: 32px; background: var(--paper-2); border-radius: 8px; box-shadow: inset 0 0 0 1px var(--rule); }
|
|
163
|
+
.spec-quote { font-family: var(--display); font-weight: 400; font-size: clamp(28px, 4vw, 48px); line-height: 1.05; margin: 0 0 16px; }
|
|
164
|
+
.spec-meta { font-family: var(--mono); font-size: 11px; text-transform: uppercase; letter-spacing: .14em; color: var(--ink-soft); display: flex; gap: 24px; flex-wrap: wrap; }
|
|
165
|
+
|
|
166
|
+
/* — Try the fused button — */
|
|
167
|
+
.mock-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 18px; padding: 18px 0; }
|
|
168
|
+
.mock-card { padding: 24px; border-radius: 10px; background: var(--paper-2); box-shadow: inset 0 0 0 1px var(--rule); display: flex; flex-direction: column; gap: 14px; align-items: flex-start; }
|
|
169
|
+
.mock-eyebrow { font-family: var(--mono); font-size: 10px; letter-spacing: .14em; text-transform: uppercase; color: var(--ink-faint); }
|
|
170
|
+
.mock-title { font-family: var(--display); font-size: 24px; line-height: 1.1; margin: 0; }
|
|
171
|
+
.mock-cta { font-family: var(--body); font-weight: 500; font-size: 14px; padding: 10px 18px; border-radius: 6px; background: var(--accent); color: white; border: 0; cursor: pointer; }
|
|
172
|
+
|
|
173
|
+
/* — Footer — */
|
|
174
|
+
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; }
|
|
175
|
+
footer .sig { font-family: var(--display); font-size: 22px; color: var(--ink); }
|
|
176
|
+
footer .stamp { font-family: var(--mono); font-size: 11px; text-transform: uppercase; letter-spacing: .14em; }
|
|
177
|
+
|
|
178
|
+
@media print {
|
|
179
|
+
body { background: white; color: black; }
|
|
180
|
+
.topbar nav, .theme-btn { display: none; }
|
|
181
|
+
section, .crossover { page-break-inside: avoid; border-color: #ddd; }
|
|
182
|
+
}
|
|
183
|
+
</style>
|
|
184
|
+
</head>
|
|
185
|
+
<body>
|
|
186
|
+
<div class="wrap">
|
|
187
|
+
<header class="topbar">
|
|
188
|
+
<div class="brand"><a href="https://designlang.app">designlang</a></div>
|
|
189
|
+
<nav>
|
|
190
|
+
<span>Pair</span>
|
|
191
|
+
<button class="theme-btn" id="themeBtn" type="button">Theme</button>
|
|
192
|
+
</nav>
|
|
193
|
+
</header>
|
|
194
|
+
|
|
195
|
+
<p class="kicker">Design Pair · ${esc(date)}</p>
|
|
196
|
+
<h1 class="title">${esc(hostA)} <em>×</em> ${esc(hostB)}</h1>
|
|
197
|
+
|
|
198
|
+
<div class="crossover">
|
|
199
|
+
<div class="source-card">
|
|
200
|
+
<span class="lbl">A</span>
|
|
201
|
+
<div class="host">${esc(hostA)}</div>
|
|
202
|
+
<div class="chips">${paletteStrip(designA)}</div>
|
|
203
|
+
<div class="fam">${esc(topFamily(designA))}</div>
|
|
204
|
+
</div>
|
|
205
|
+
<div class="arrow">×</div>
|
|
206
|
+
<div class="source-card">
|
|
207
|
+
<span class="lbl">B</span>
|
|
208
|
+
<div class="host">${esc(hostB)}</div>
|
|
209
|
+
<div class="chips">${paletteStrip(designB)}</div>
|
|
210
|
+
<div class="fam">${esc(topFamily(designB))}</div>
|
|
211
|
+
</div>
|
|
212
|
+
<div class="fused-card">
|
|
213
|
+
<span class="lbl">Fused</span>
|
|
214
|
+
<div class="host">${esc(hostA)} × ${esc(hostB)}</div>
|
|
215
|
+
<div class="chips">${paletteStrip(fused)}</div>
|
|
216
|
+
<div class="fam">${esc(topFamily(fused))}</div>
|
|
217
|
+
</div>
|
|
218
|
+
</div>
|
|
219
|
+
|
|
220
|
+
<section>
|
|
221
|
+
<h2>Which axis came from where</h2>
|
|
222
|
+
<p class="lead">Each row is one dimension of the design system. The pill shows whether the fused design inherited it from <strong>A</strong> (${esc(hostA)}) or <strong>B</strong> (${esc(hostB)}).</p>
|
|
223
|
+
<table class="matrix">
|
|
224
|
+
<thead>
|
|
225
|
+
<tr>
|
|
226
|
+
<th>Axis</th>
|
|
227
|
+
<th>Source</th>
|
|
228
|
+
<th>Inherited</th>
|
|
229
|
+
</tr>
|
|
230
|
+
</thead>
|
|
231
|
+
<tbody>
|
|
232
|
+
${axisLabels.map(([key, label]) => {
|
|
233
|
+
const src = axes[key] || 'a';
|
|
234
|
+
const sourceHost = src === 'a' ? hostA : hostB;
|
|
235
|
+
return `
|
|
236
|
+
<tr>
|
|
237
|
+
<td class="axis-name">${esc(label)}</td>
|
|
238
|
+
<td class="src">${esc(sourceHost)}</td>
|
|
239
|
+
<td><span class="pill from-${src}">From ${src.toUpperCase()}</span></td>
|
|
240
|
+
</tr>
|
|
241
|
+
`;
|
|
242
|
+
}).join('')}
|
|
243
|
+
</tbody>
|
|
244
|
+
</table>
|
|
245
|
+
</section>
|
|
246
|
+
|
|
247
|
+
<section>
|
|
248
|
+
<h2>The fused identity</h2>
|
|
249
|
+
<p class="lead">Specimen of the merged design — palette + radii from one source, type + voice from the other.</p>
|
|
250
|
+
<div class="specimen" style="font-family: ${esc(topFamily(fused))}, '${FONT_DISPLAY}', serif;">
|
|
251
|
+
<p class="spec-quote">${esc(sampleHead)}</p>
|
|
252
|
+
<div class="spec-meta">
|
|
253
|
+
<span>Primary · <code>${esc(primaryHex(fused).toUpperCase())}</code></span>
|
|
254
|
+
<span>Display · <code>${esc(topFamily(fused))}</code></span>
|
|
255
|
+
<span>Tone · <code>${esc(fused.voice?.tone || 'neutral')}</code></span>
|
|
256
|
+
</div>
|
|
257
|
+
</div>
|
|
258
|
+
|
|
259
|
+
<div class="mock-row">
|
|
260
|
+
<div class="mock-card">
|
|
261
|
+
<span class="mock-eyebrow">Primary action</span>
|
|
262
|
+
<h4 class="mock-title">Built from the fused tokens</h4>
|
|
263
|
+
<button type="button" class="mock-cta">Try this style</button>
|
|
264
|
+
</div>
|
|
265
|
+
<div class="mock-card">
|
|
266
|
+
<span class="mock-eyebrow">Run again</span>
|
|
267
|
+
<h4 class="mock-title">Different axes, different fusion</h4>
|
|
268
|
+
<code>npx designlang pair ${esc(hostA)} ${esc(hostB)} --type-from a</code>
|
|
269
|
+
</div>
|
|
270
|
+
</div>
|
|
271
|
+
</section>
|
|
272
|
+
|
|
273
|
+
<footer>
|
|
274
|
+
<div>
|
|
275
|
+
<div class="sig">designlang</div>
|
|
276
|
+
<div>Re-run: <code>npx designlang pair ${esc(hostA)} ${esc(hostB)}</code></div>
|
|
277
|
+
</div>
|
|
278
|
+
<div class="stamp">${esc(date)} · v${esc(opts.version || '')}</div>
|
|
279
|
+
</footer>
|
|
280
|
+
</div>
|
|
281
|
+
|
|
282
|
+
<script>
|
|
283
|
+
(function () {
|
|
284
|
+
var btn = document.getElementById('themeBtn');
|
|
285
|
+
var saved = null;
|
|
286
|
+
try { saved = localStorage.getItem('dl-theme'); } catch (e) {}
|
|
287
|
+
if (saved) document.documentElement.setAttribute('data-theme', saved);
|
|
288
|
+
btn && btn.addEventListener('click', function () {
|
|
289
|
+
var cur = document.documentElement.getAttribute('data-theme') === 'dark' ? '' : 'dark';
|
|
290
|
+
if (cur) document.documentElement.setAttribute('data-theme', cur);
|
|
291
|
+
else document.documentElement.removeAttribute('data-theme');
|
|
292
|
+
try { localStorage.setItem('dl-theme', cur); } catch (e) {}
|
|
293
|
+
});
|
|
294
|
+
})();
|
|
295
|
+
</script>
|
|
296
|
+
</body>
|
|
297
|
+
</html>`;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export function formatPairMarkdown(designA, designB, fused, summary) {
|
|
301
|
+
const hostA = host(designA.meta?.url);
|
|
302
|
+
const hostB = host(designB.meta?.url);
|
|
303
|
+
const axes = summary?.axes || fused.fusedAxes || {};
|
|
304
|
+
const lines = [
|
|
305
|
+
`# ${hostA} × ${hostB}`,
|
|
306
|
+
``,
|
|
307
|
+
`_Design pair fused by designlang on ${new Date().toISOString().slice(0, 10)}._`,
|
|
308
|
+
``,
|
|
309
|
+
`## Axes`,
|
|
310
|
+
``,
|
|
311
|
+
`| Axis | Source |`,
|
|
312
|
+
`|---|---|`,
|
|
313
|
+
`| Colour | ${axes.colors === 'b' ? hostB : hostA} |`,
|
|
314
|
+
`| Typography | ${axes.typography === 'a' ? hostA : hostB} |`,
|
|
315
|
+
`| Spacing | ${axes.spacing === 'b' ? hostB : hostA} |`,
|
|
316
|
+
`| Shape | ${axes.shape === 'b' ? hostB : hostA} |`,
|
|
317
|
+
`| Motion | ${axes.motion === 'b' ? hostB : hostA} |`,
|
|
318
|
+
`| Voice | ${axes.voice === 'a' ? hostA : hostB} |`,
|
|
319
|
+
`| Components | ${axes.components === 'a' ? hostA : hostB} |`,
|
|
320
|
+
``,
|
|
321
|
+
`## Fused identity`,
|
|
322
|
+
``,
|
|
323
|
+
`- Primary: \`${primaryHex(fused)}\``,
|
|
324
|
+
`- Display: ${topFamily(fused)}`,
|
|
325
|
+
`- Tone: ${fused.voice?.tone || 'neutral'}`,
|
|
326
|
+
``,
|
|
327
|
+
`---`,
|
|
328
|
+
`_Re-run: \`npx designlang pair ${hostA} ${hostB}\`_`,
|
|
329
|
+
];
|
|
330
|
+
return lines.join('\n');
|
|
331
|
+
}
|
|
@@ -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
|
+
}
|