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
package/src/fuse.js
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// designlang pair — fuse two extracted designs across configurable axes.
|
|
2
|
+
//
|
|
3
|
+
// Picks each dimension from one source or the other so designers can run
|
|
4
|
+
// experiments like "Stripe colours × Linear typography × Vercel motion".
|
|
5
|
+
// Input: two full design objects from extractDesignLanguage(), an axis
|
|
6
|
+
// map, and an output URL/title for branding the fused result.
|
|
7
|
+
//
|
|
8
|
+
// We deep-clone source objects before merging so callers' originals stay
|
|
9
|
+
// intact. The fused design is structurally identical to a normal
|
|
10
|
+
// extraction so every downstream emitter (DTCG, Tailwind, shadcn,
|
|
11
|
+
// Figma, brand-book, pack) works on it untouched.
|
|
12
|
+
|
|
13
|
+
const AXES = ['colors', 'typography', 'spacing', 'shape', 'motion', 'voice', 'components'];
|
|
14
|
+
|
|
15
|
+
const DEFAULT_AXES = {
|
|
16
|
+
colors: 'a',
|
|
17
|
+
spacing: 'a',
|
|
18
|
+
shape: 'a',
|
|
19
|
+
motion: 'a',
|
|
20
|
+
typography: 'b',
|
|
21
|
+
voice: 'b',
|
|
22
|
+
components: 'b',
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
function host(url) {
|
|
26
|
+
try { return new URL(url).hostname; } catch { return String(url || ''); }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function clone(x) {
|
|
30
|
+
return x == null ? x : JSON.parse(JSON.stringify(x));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Normalise the user's --<axis>-from flags into a single { axis: 'a'|'b' }
|
|
34
|
+
// map. Anything they don't specify falls through to DEFAULT_AXES, which
|
|
35
|
+
// is calibrated for a clean "visual A × voice B" crossover.
|
|
36
|
+
function resolveAxes(opts = {}) {
|
|
37
|
+
const out = { ...DEFAULT_AXES };
|
|
38
|
+
for (const axis of AXES) {
|
|
39
|
+
const flag = opts[`${axis}From`];
|
|
40
|
+
if (flag === 'a' || flag === 'A') out[axis] = 'a';
|
|
41
|
+
else if (flag === 'b' || flag === 'B') out[axis] = 'b';
|
|
42
|
+
}
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Fuse two extracted designs along configurable axes.
|
|
48
|
+
*
|
|
49
|
+
* @param {object} a — design from extractDesignLanguage(urlA)
|
|
50
|
+
* @param {object} b — design from extractDesignLanguage(urlB)
|
|
51
|
+
* @param {object} opts
|
|
52
|
+
* @param {'a'|'b'} [opts.colorsFrom='a']
|
|
53
|
+
* @param {'a'|'b'} [opts.typographyFrom='b']
|
|
54
|
+
* @param {'a'|'b'} [opts.spacingFrom='a']
|
|
55
|
+
* @param {'a'|'b'} [opts.shapeFrom='a']
|
|
56
|
+
* @param {'a'|'b'} [opts.motionFrom='a']
|
|
57
|
+
* @param {'a'|'b'} [opts.voiceFrom='b']
|
|
58
|
+
* @param {'a'|'b'} [opts.componentsFrom='b']
|
|
59
|
+
* @returns {object} { design, summary }
|
|
60
|
+
*/
|
|
61
|
+
export function fuseDesigns(a, b, opts = {}) {
|
|
62
|
+
if (!a || !b) throw new Error('fuseDesigns: both designs are required');
|
|
63
|
+
const axes = resolveAxes(opts);
|
|
64
|
+
const pick = (axis, sliceA, sliceB) => clone(axes[axis] === 'a' ? sliceA : sliceB);
|
|
65
|
+
const src = (axis) => axes[axis] === 'a' ? a : b;
|
|
66
|
+
|
|
67
|
+
// Start from a deep clone of A so meta-fields (e.g. raw crawler output)
|
|
68
|
+
// have a sensible default. Downstream emitters lean heavily on .meta.url
|
|
69
|
+
// for filenames + titles, so we synthesise a pair-specific URL.
|
|
70
|
+
const fused = clone(a);
|
|
71
|
+
|
|
72
|
+
// Colours — every sub-field in one block (primary, secondary, accent,
|
|
73
|
+
// neutrals, backgrounds, text, gradients, all). Mixing primary from
|
|
74
|
+
// one site and neutrals from another tends to produce off-brand greys,
|
|
75
|
+
// so we keep the whole palette together.
|
|
76
|
+
fused.colors = pick('colors', a.colors, b.colors);
|
|
77
|
+
|
|
78
|
+
// Typography — same logic. Families, scale, weights, headings, body
|
|
79
|
+
// travel as one unit because the type system is tightly coupled.
|
|
80
|
+
fused.typography = pick('typography', a.typography, b.typography);
|
|
81
|
+
// Pull related signals along with the type pick so the brand book
|
|
82
|
+
// renders coherently (specimen lines lean on voice.sampleHeadings).
|
|
83
|
+
if (axes.voice === axes.typography) {
|
|
84
|
+
// already aligned; keep voice-from picked below
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Spacing.
|
|
88
|
+
fused.spacing = pick('spacing', a.spacing, b.spacing);
|
|
89
|
+
// Layout often co-varies with spacing — move it together.
|
|
90
|
+
fused.layout = pick('spacing', a.layout, b.layout);
|
|
91
|
+
|
|
92
|
+
// Shape — radii + shadows + borders.
|
|
93
|
+
fused.borders = pick('shape', a.borders, b.borders);
|
|
94
|
+
fused.shadows = pick('shape', a.shadows, b.shadows);
|
|
95
|
+
|
|
96
|
+
// Motion.
|
|
97
|
+
fused.motion = pick('motion', a.motion, b.motion);
|
|
98
|
+
fused.animations = pick('motion', a.animations, b.animations);
|
|
99
|
+
|
|
100
|
+
// Voice.
|
|
101
|
+
fused.voice = pick('voice', a.voice, b.voice);
|
|
102
|
+
|
|
103
|
+
// Components — anatomy, clusters, library detection.
|
|
104
|
+
fused.componentAnatomy = pick('components', a.componentAnatomy, b.componentAnatomy);
|
|
105
|
+
fused.componentClusters = pick('components', a.componentClusters, b.componentClusters);
|
|
106
|
+
fused.componentLibrary = pick('components', a.componentLibrary, b.componentLibrary);
|
|
107
|
+
fused.components = pick('components', a.components, b.components);
|
|
108
|
+
|
|
109
|
+
// Material language and imagery style track colour by default — they
|
|
110
|
+
// describe the *visual* feel.
|
|
111
|
+
fused.materialLanguage = pick('colors', a.materialLanguage, b.materialLanguage);
|
|
112
|
+
fused.imageryStyle = pick('colors', a.imageryStyle, b.imageryStyle);
|
|
113
|
+
|
|
114
|
+
// CSS variables tend to mirror colour + typography. We keep the
|
|
115
|
+
// variables from whichever source contributed colour, since colour
|
|
116
|
+
// tokens are the dominant variable family.
|
|
117
|
+
fused.variables = pick('colors', a.variables, b.variables);
|
|
118
|
+
|
|
119
|
+
// Score, accessibility, css-health are *measurements* of the source
|
|
120
|
+
// sites — they don't apply to the fused design. Strip them so
|
|
121
|
+
// downstream consumers don't surface stale numbers.
|
|
122
|
+
fused.score = null;
|
|
123
|
+
fused.accessibility = src('colors').accessibility ? clone(src('colors').accessibility) : null;
|
|
124
|
+
fused.cssHealth = null;
|
|
125
|
+
|
|
126
|
+
// Synthesise meta. The pair URL is a virtual identifier so emitters
|
|
127
|
+
// produce non-colliding filenames (e.g. "stripe-x-linear").
|
|
128
|
+
const hostA = host(a.meta?.url);
|
|
129
|
+
const hostB = host(b.meta?.url);
|
|
130
|
+
fused.meta = {
|
|
131
|
+
...(a.meta || {}),
|
|
132
|
+
url: `pair://${hostA}-x-${hostB}`,
|
|
133
|
+
title: `${hostA} × ${hostB}`,
|
|
134
|
+
pairedFrom: { a: a.meta?.url || hostA, b: b.meta?.url || hostB },
|
|
135
|
+
timestamp: new Date().toISOString(),
|
|
136
|
+
elementCount: (a.meta?.elementCount || 0) + (b.meta?.elementCount || 0),
|
|
137
|
+
pagesAnalyzed: 1,
|
|
138
|
+
fusedAxes: axes,
|
|
139
|
+
};
|
|
140
|
+
fused.fusedAxes = axes;
|
|
141
|
+
|
|
142
|
+
// Drop the raw crawler output — it belongs to a single page and
|
|
143
|
+
// confuses any consumer that tries to re-derive things from it.
|
|
144
|
+
delete fused._raw;
|
|
145
|
+
|
|
146
|
+
const summary = {
|
|
147
|
+
a: { url: a.meta?.url, host: hostA },
|
|
148
|
+
b: { url: b.meta?.url, host: hostB },
|
|
149
|
+
axes,
|
|
150
|
+
};
|
|
151
|
+
return { design: fused, summary };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export { AXES, DEFAULT_AXES };
|
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
|
+
}
|