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/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
+ }
@@ -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
+ }