designlang 12.4.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 +194 -0
- package/README.md +31 -4
- package/SUPPORT.md +22 -0
- package/bin/design-extract.js +181 -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/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/recolor.js
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
// designlang theme-swap — recolour an extracted design around a new brand
|
|
2
|
+
// primary while preserving perceptual structure (lightness + chroma stay
|
|
3
|
+
// close, only hue shifts). Operates on the design object produced by
|
|
4
|
+
// extractDesignLanguage so every downstream emitter (DTCG, Tailwind,
|
|
5
|
+
// shadcn, Figma vars, CSS vars) inherits the change for free.
|
|
6
|
+
|
|
7
|
+
import { hexToOklch, oklchToHex } from './utils/color-gamut.js';
|
|
8
|
+
|
|
9
|
+
// Below this chroma we treat the colour as a neutral and leave it alone.
|
|
10
|
+
// Real-world brand palettes have chroma in the 0.05–0.30 range; pure
|
|
11
|
+
// greys sit under ~0.02. 0.04 is a defensible split that keeps body text,
|
|
12
|
+
// surfaces, and rule lines untouched while moving accents / brand inks.
|
|
13
|
+
const NEUTRAL_CHROMA_MAX = 0.04;
|
|
14
|
+
|
|
15
|
+
function normaliseHex(s) {
|
|
16
|
+
if (typeof s !== 'string') return null;
|
|
17
|
+
const t = s.trim().toLowerCase();
|
|
18
|
+
if (!t) return null;
|
|
19
|
+
const m = t.match(/^#?([0-9a-f]{3}|[0-9a-f]{6})$/);
|
|
20
|
+
if (!m) return null;
|
|
21
|
+
let body = m[1];
|
|
22
|
+
if (body.length === 3) body = body.split('').map(c => c + c).join('');
|
|
23
|
+
return '#' + body;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function hueDelta(h0, h1) {
|
|
27
|
+
// Shortest signed distance between two hues (-180..180).
|
|
28
|
+
let d = h1 - h0;
|
|
29
|
+
while (d > 180) d -= 360;
|
|
30
|
+
while (d < -180) d += 360;
|
|
31
|
+
return d;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function rotateHue(h, delta) {
|
|
35
|
+
let r = h + delta;
|
|
36
|
+
while (r < 0) r += 360;
|
|
37
|
+
while (r >= 360) r -= 360;
|
|
38
|
+
return r;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Decide whether to recolour a given hex. We always preserve neutrals so
|
|
42
|
+
// body text, surfaces, and rule lines look the same. We optionally allow
|
|
43
|
+
// the caller to pin the lightness target (for the *primary* swap itself,
|
|
44
|
+
// where the user's `--primary` value should land exactly).
|
|
45
|
+
function recolourHex(hex, { hueShift, neutralKeep = true, target = null }) {
|
|
46
|
+
const oklch = hexToOklch(hex);
|
|
47
|
+
if (!oklch) return hex;
|
|
48
|
+
if (target) {
|
|
49
|
+
// Used for the primary itself — copy the target's L, C, h verbatim
|
|
50
|
+
// so the user gets exactly the colour they asked for, not a rotation.
|
|
51
|
+
return oklchToHex(target);
|
|
52
|
+
}
|
|
53
|
+
if (neutralKeep && oklch.C < NEUTRAL_CHROMA_MAX) return hex;
|
|
54
|
+
return oklchToHex({ L: oklch.L, C: oklch.C, h: rotateHue(oklch.h, hueShift) });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Detect the "primary" anchor we'll rotate the rest of the palette around.
|
|
58
|
+
// Order of preference:
|
|
59
|
+
// 1. design.colors.primary.hex (extractor's classification)
|
|
60
|
+
// 2. the most-used non-neutral colour in design.colors.all
|
|
61
|
+
// 3. fall back to the first non-neutral entry
|
|
62
|
+
function detectPrimary(design) {
|
|
63
|
+
const fromExtractor = design?.colors?.primary?.hex;
|
|
64
|
+
if (fromExtractor) {
|
|
65
|
+
const norm = normaliseHex(fromExtractor);
|
|
66
|
+
if (norm) return norm;
|
|
67
|
+
}
|
|
68
|
+
const all = (design?.colors?.all || []).filter(c => c?.hex && hexToOklch(c.hex));
|
|
69
|
+
// Most-used coloured (non-neutral) value.
|
|
70
|
+
const coloured = all
|
|
71
|
+
.filter(c => {
|
|
72
|
+
const o = hexToOklch(c.hex);
|
|
73
|
+
return o && o.C >= NEUTRAL_CHROMA_MAX;
|
|
74
|
+
})
|
|
75
|
+
.sort((a, b) => (b.count || 0) - (a.count || 0));
|
|
76
|
+
if (coloured.length) return normaliseHex(coloured[0].hex);
|
|
77
|
+
if (all.length) return normaliseHex(all[0].hex);
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Recolour an extracted design around a new brand primary.
|
|
83
|
+
*
|
|
84
|
+
* @param {object} design — the full design returned by extractDesignLanguage()
|
|
85
|
+
* @param {object} opts
|
|
86
|
+
* @param {string} opts.primary — target primary colour as hex (#rrggbb)
|
|
87
|
+
* @param {boolean} [opts.preserveLightness=true]
|
|
88
|
+
* When true, the rotation only changes hue (default). When false, the
|
|
89
|
+
* target's lightness/chroma are also propagated to the rest of the
|
|
90
|
+
* palette — leaves a heavier brand stamp, often too aggressive.
|
|
91
|
+
* @param {string|null} [opts.fromPrimary=null]
|
|
92
|
+
* Override the auto-detected source primary. Useful when the extractor
|
|
93
|
+
* misclassifies (e.g. a neutral got promoted by usage count).
|
|
94
|
+
* @returns {object} { design, summary } — recoloured design plus a small
|
|
95
|
+
* summary of what changed (used by the formatter for the diff view).
|
|
96
|
+
*/
|
|
97
|
+
export function recolorDesign(design, opts = {}) {
|
|
98
|
+
if (!design) throw new Error('recolorDesign: design is required');
|
|
99
|
+
const targetHex = normaliseHex(opts.primary);
|
|
100
|
+
if (!targetHex) {
|
|
101
|
+
throw new Error(`recolorDesign: invalid --primary "${opts.primary}". Expected hex like "#ff4800".`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const sourceHex = normaliseHex(opts.fromPrimary) || detectPrimary(design);
|
|
105
|
+
if (!sourceHex) {
|
|
106
|
+
throw new Error('recolorDesign: could not detect a source primary in the design (no coloured tokens found)');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const sourceOklch = hexToOklch(sourceHex);
|
|
110
|
+
const targetOklch = hexToOklch(targetHex);
|
|
111
|
+
if (!sourceOklch || !targetOklch) {
|
|
112
|
+
throw new Error('recolorDesign: failed to parse source/target hex into OKLCH');
|
|
113
|
+
}
|
|
114
|
+
const hueShift = hueDelta(sourceOklch.h, targetOklch.h);
|
|
115
|
+
|
|
116
|
+
// Walk the design and rebuild a recoloured copy. We mutate a deep clone
|
|
117
|
+
// so the caller's original stays intact.
|
|
118
|
+
const out = JSON.parse(JSON.stringify(design));
|
|
119
|
+
const changes = [];
|
|
120
|
+
const seen = new Set();
|
|
121
|
+
function swap(hex, opts2 = {}) {
|
|
122
|
+
const norm = normaliseHex(hex);
|
|
123
|
+
if (!norm) return hex;
|
|
124
|
+
const next = recolourHex(norm, { hueShift, ...opts2 });
|
|
125
|
+
if (next !== norm && !seen.has(norm + '→' + next)) {
|
|
126
|
+
seen.add(norm + '→' + next);
|
|
127
|
+
changes.push({ from: norm, to: next });
|
|
128
|
+
}
|
|
129
|
+
return next;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// 1) The primary itself — pin to the user's target hex.
|
|
133
|
+
if (out.colors?.primary?.hex) {
|
|
134
|
+
out.colors.primary.hex = swap(out.colors.primary.hex, { target: targetOklch });
|
|
135
|
+
}
|
|
136
|
+
// 2) Secondary / accent — rotate by the hue delta.
|
|
137
|
+
for (const k of ['secondary', 'accent']) {
|
|
138
|
+
if (out.colors?.[k]?.hex) out.colors[k].hex = swap(out.colors[k].hex);
|
|
139
|
+
}
|
|
140
|
+
// 3) Every entry in colors.all (the canonical palette).
|
|
141
|
+
if (Array.isArray(out.colors?.all)) {
|
|
142
|
+
out.colors.all = out.colors.all.map(c => {
|
|
143
|
+
if (!c?.hex) return c;
|
|
144
|
+
// Pin the source primary slot to the target exactly so it shows up
|
|
145
|
+
// verbatim in shadcn/Tailwind output.
|
|
146
|
+
const isSourcePrimary = normaliseHex(c.hex) === sourceHex;
|
|
147
|
+
return { ...c, hex: swap(c.hex, isSourcePrimary ? { target: targetOklch } : {}) };
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
// 4) Neutrals — explicitly *don't* rotate. Surfaces, text, rule lines
|
|
151
|
+
// should stay readable. (We rely on neutralKeep inside swap().)
|
|
152
|
+
if (Array.isArray(out.colors?.neutrals)) {
|
|
153
|
+
out.colors.neutrals = out.colors.neutrals.map(c => c?.hex ? { ...c, hex: swap(c.hex) } : c);
|
|
154
|
+
}
|
|
155
|
+
// 5) Backgrounds + text — same neutral-preserving logic.
|
|
156
|
+
if (Array.isArray(out.colors?.backgrounds)) {
|
|
157
|
+
out.colors.backgrounds = out.colors.backgrounds.map(swap);
|
|
158
|
+
}
|
|
159
|
+
if (Array.isArray(out.colors?.text)) {
|
|
160
|
+
out.colors.text = out.colors.text.map(swap);
|
|
161
|
+
}
|
|
162
|
+
// 6) Gradients — rebuild every stop.
|
|
163
|
+
if (Array.isArray(out.colors?.gradients)) {
|
|
164
|
+
out.colors.gradients = out.colors.gradients.map(g => {
|
|
165
|
+
if (typeof g !== 'string') return g;
|
|
166
|
+
return g.replace(/#[0-9a-fA-F]{3,6}\b/g, swap);
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
if (out.gradients?.gradients && Array.isArray(out.gradients.gradients)) {
|
|
170
|
+
out.gradients.gradients = out.gradients.gradients.map(g => {
|
|
171
|
+
if (typeof g === 'string') return g.replace(/#[0-9a-fA-F]{3,6}\b/g, swap);
|
|
172
|
+
if (g?.raw) return { ...g, raw: g.raw.replace(/#[0-9a-fA-F]{3,6}\b/g, swap) };
|
|
173
|
+
return g;
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
// 7) CSS variables — rotate any value that looks like a hex.
|
|
177
|
+
if (out.variables && typeof out.variables === 'object') {
|
|
178
|
+
for (const cat of Object.keys(out.variables)) {
|
|
179
|
+
const obj = out.variables[cat];
|
|
180
|
+
if (obj && typeof obj === 'object') {
|
|
181
|
+
for (const k of Object.keys(obj)) {
|
|
182
|
+
if (typeof obj[k] === 'string') obj[k] = obj[k].replace(/#[0-9a-fA-F]{3,6}\b/g, swap);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
out.meta = {
|
|
189
|
+
...(out.meta || {}),
|
|
190
|
+
themeSwap: {
|
|
191
|
+
from: sourceHex,
|
|
192
|
+
to: targetHex,
|
|
193
|
+
hueShift: Math.round(hueShift * 100) / 100,
|
|
194
|
+
changedColors: changes.length,
|
|
195
|
+
},
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
return { design: out, summary: { from: sourceHex, to: targetHex, hueShift, changes } };
|
|
199
|
+
}
|
package/src/utils/color-gamut.js
CHANGED
|
@@ -80,3 +80,67 @@ export function oklchLikeToHex(raw) {
|
|
|
80
80
|
: oklabToSrgb(parsed.L, parsed.a, parsed.b);
|
|
81
81
|
return rgbToHex(r, g, b);
|
|
82
82
|
}
|
|
83
|
+
|
|
84
|
+
// ── Inverse direction (sRGB → OKLab → OKLCH) ───────────────────
|
|
85
|
+
// Forward Björn Ottosson formulas. Used by the recolor pipeline so we can
|
|
86
|
+
// hue-rotate brand palettes while preserving perceptual lightness.
|
|
87
|
+
|
|
88
|
+
function srgbToLinear(x) {
|
|
89
|
+
if (x <= 0.04045) return x / 12.92;
|
|
90
|
+
return Math.pow((x + 0.055) / 1.055, 2.4);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function srgbToOklab(r, g, b) {
|
|
94
|
+
// sRGB inputs in 0..1, gamma-encoded.
|
|
95
|
+
const rl = srgbToLinear(r);
|
|
96
|
+
const gl = srgbToLinear(g);
|
|
97
|
+
const bl = srgbToLinear(b);
|
|
98
|
+
|
|
99
|
+
const l = 0.4122214708 * rl + 0.5363325363 * gl + 0.0514459929 * bl;
|
|
100
|
+
const m = 0.2119034982 * rl + 0.6806995451 * gl + 0.1073969566 * bl;
|
|
101
|
+
const s = 0.0883024619 * rl + 0.2817188376 * gl + 0.6299787005 * bl;
|
|
102
|
+
|
|
103
|
+
const l_ = Math.cbrt(l);
|
|
104
|
+
const m_ = Math.cbrt(m);
|
|
105
|
+
const s_ = Math.cbrt(s);
|
|
106
|
+
|
|
107
|
+
return [
|
|
108
|
+
0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_,
|
|
109
|
+
1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_,
|
|
110
|
+
0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_,
|
|
111
|
+
];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function srgbToOklch(r, g, b) {
|
|
115
|
+
const [L, a, bx] = srgbToOklab(r, g, b);
|
|
116
|
+
const C = Math.sqrt(a * a + bx * bx);
|
|
117
|
+
let h = Math.atan2(bx, a) * 180 / Math.PI;
|
|
118
|
+
if (h < 0) h += 360;
|
|
119
|
+
return { L, C, h };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Hex string → { L, C, h } in OKLCH. Returns null on parse failure.
|
|
123
|
+
export function hexToOklch(hex) {
|
|
124
|
+
if (typeof hex !== 'string') return null;
|
|
125
|
+
const m = hex.replace(/^#/, '').match(/^([0-9a-f]{3}|[0-9a-f]{6})$/i);
|
|
126
|
+
if (!m) return null;
|
|
127
|
+
let s = m[1];
|
|
128
|
+
if (s.length === 3) s = s.split('').map(c => c + c).join('');
|
|
129
|
+
const r = parseInt(s.slice(0, 2), 16) / 255;
|
|
130
|
+
const g = parseInt(s.slice(2, 4), 16) / 255;
|
|
131
|
+
const b = parseInt(s.slice(4, 6), 16) / 255;
|
|
132
|
+
return srgbToOklch(r, g, b);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// { L, C, h } → hex string. Clamps to sRGB gamut by reducing chroma if the
|
|
136
|
+
// colour falls outside displayable range.
|
|
137
|
+
export function oklchToHex({ L, C, h }) {
|
|
138
|
+
// Try the requested chroma, then back off if any channel goes out of range.
|
|
139
|
+
for (let factor = 1; factor >= 0; factor -= 0.05) {
|
|
140
|
+
const [r, g, b] = oklchToSrgb(L, C * factor, h);
|
|
141
|
+
if (r >= -1e-4 && r <= 1.0001 && g >= -1e-4 && g <= 1.0001 && b >= -1e-4 && b <= 1.0001) {
|
|
142
|
+
return rgbToHex(r, g, b);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return rgbToHex(...oklchToSrgb(L, 0, h));
|
|
146
|
+
}
|