@oxyhq/bloom 0.7.7 → 0.8.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/lib/commonjs/normalize-colors.d.js +2 -0
- package/lib/commonjs/normalize-colors.d.js.map +1 -0
- package/lib/commonjs/theme/apply-dark-class.js +8 -24
- package/lib/commonjs/theme/apply-dark-class.js.map +1 -1
- package/lib/commonjs/theme/build-theme.js +61 -57
- package/lib/commonjs/theme/build-theme.js.map +1 -1
- package/lib/commonjs/theme/color-presets.js +9 -9
- package/lib/commonjs/theme/color-scope/index.web.js +7 -14
- package/lib/commonjs/theme/color-scope/index.web.js.map +1 -1
- package/lib/commonjs/theme/color-scope/style-builder.js +7 -6
- package/lib/commonjs/theme/color-scope/style-builder.js.map +1 -1
- package/lib/commonjs/theme/color-space.js +76 -0
- package/lib/commonjs/theme/color-space.js.map +1 -0
- package/lib/commonjs/theme/index.js +0 -6
- package/lib/commonjs/theme/index.js.map +1 -1
- package/lib/commonjs/theme/native-root-vars.native.js +28 -18
- package/lib/commonjs/theme/native-root-vars.native.js.map +1 -1
- package/lib/commonjs/theme/preset-vars.js +20 -152
- package/lib/commonjs/theme/preset-vars.js.map +1 -1
- package/lib/commonjs/theme/token-registry.js +123 -0
- package/lib/commonjs/theme/token-registry.js.map +1 -0
- package/lib/module/normalize-colors.d.js +2 -0
- package/lib/module/normalize-colors.d.js.map +1 -0
- package/lib/module/theme/apply-dark-class.js +8 -19
- package/lib/module/theme/apply-dark-class.js.map +1 -1
- package/lib/module/theme/build-theme.js +61 -57
- package/lib/module/theme/build-theme.js.map +1 -1
- package/lib/module/theme/color-presets.js +9 -9
- package/lib/module/theme/color-scope/index.web.js +7 -14
- package/lib/module/theme/color-scope/index.web.js.map +1 -1
- package/lib/module/theme/color-scope/style-builder.js +7 -6
- package/lib/module/theme/color-scope/style-builder.js.map +1 -1
- package/lib/module/theme/color-space.js +69 -0
- package/lib/module/theme/color-space.js.map +1 -0
- package/lib/module/theme/index.js +1 -1
- package/lib/module/theme/index.js.map +1 -1
- package/lib/module/theme/native-root-vars.native.js +29 -18
- package/lib/module/theme/native-root-vars.native.js.map +1 -1
- package/lib/module/theme/preset-vars.js +19 -150
- package/lib/module/theme/preset-vars.js.map +1 -1
- package/lib/module/theme/token-registry.js +113 -0
- package/lib/module/theme/token-registry.js.map +1 -0
- package/lib/typescript/commonjs/theme/apply-dark-class.d.ts +5 -10
- package/lib/typescript/commonjs/theme/apply-dark-class.d.ts.map +1 -1
- package/lib/typescript/commonjs/theme/build-theme.d.ts.map +1 -1
- package/lib/typescript/commonjs/theme/color-presets.d.ts +9 -9
- package/lib/typescript/commonjs/theme/color-scope/index.web.d.ts.map +1 -1
- package/lib/typescript/commonjs/theme/color-scope/style-builder.d.ts +5 -2
- package/lib/typescript/commonjs/theme/color-scope/style-builder.d.ts.map +1 -1
- package/lib/typescript/commonjs/theme/color-space.d.ts +28 -0
- package/lib/typescript/commonjs/theme/color-space.d.ts.map +1 -0
- package/lib/typescript/commonjs/theme/index.d.ts +1 -2
- package/lib/typescript/commonjs/theme/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/theme/native-root-vars.native.d.ts +25 -15
- package/lib/typescript/commonjs/theme/native-root-vars.native.d.ts.map +1 -1
- package/lib/typescript/commonjs/theme/preset-vars.d.ts +11 -74
- package/lib/typescript/commonjs/theme/preset-vars.d.ts.map +1 -1
- package/lib/typescript/commonjs/theme/token-registry.d.ts +53 -0
- package/lib/typescript/commonjs/theme/token-registry.d.ts.map +1 -0
- package/lib/typescript/commonjs/theme/types.d.ts +3 -0
- package/lib/typescript/commonjs/theme/types.d.ts.map +1 -1
- package/lib/typescript/module/theme/apply-dark-class.d.ts +5 -10
- package/lib/typescript/module/theme/apply-dark-class.d.ts.map +1 -1
- package/lib/typescript/module/theme/build-theme.d.ts.map +1 -1
- package/lib/typescript/module/theme/color-presets.d.ts +9 -9
- package/lib/typescript/module/theme/color-scope/index.web.d.ts.map +1 -1
- package/lib/typescript/module/theme/color-scope/style-builder.d.ts +5 -2
- package/lib/typescript/module/theme/color-scope/style-builder.d.ts.map +1 -1
- package/lib/typescript/module/theme/color-space.d.ts +28 -0
- package/lib/typescript/module/theme/color-space.d.ts.map +1 -0
- package/lib/typescript/module/theme/index.d.ts +1 -2
- package/lib/typescript/module/theme/index.d.ts.map +1 -1
- package/lib/typescript/module/theme/native-root-vars.native.d.ts +25 -15
- package/lib/typescript/module/theme/native-root-vars.native.d.ts.map +1 -1
- package/lib/typescript/module/theme/preset-vars.d.ts +11 -74
- package/lib/typescript/module/theme/preset-vars.d.ts.map +1 -1
- package/lib/typescript/module/theme/token-registry.d.ts +53 -0
- package/lib/typescript/module/theme/token-registry.d.ts.map +1 -0
- package/lib/typescript/module/theme/types.d.ts +3 -0
- package/lib/typescript/module/theme/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/BloomColorScope.test.tsx +5 -5
- package/src/__tests__/BloomThemeProvider.test.tsx +4 -3
- package/src/__tests__/apply-dark-class.test.ts +26 -21
- package/src/__tests__/native-root-vars.test.ts +8 -7
- package/src/__tests__/preset-vars.test.ts +31 -77
- package/src/__tests__/theme.test.ts +6 -6
- package/src/normalize-colors.d.ts +22 -0
- package/src/react-native-css.d.ts +3 -3
- package/src/theme/__tests__/__fixtures__/golden-resolved-tokens.json +574 -0
- package/src/theme/__tests__/__snapshots__/visual-gallery.test.tsx.snap +838 -0
- package/src/theme/__tests__/color-space.test.ts +28 -0
- package/src/theme/__tests__/golden-tokens.test.ts +33 -0
- package/src/theme/__tests__/runtime-contract.test.ts +63 -0
- package/src/theme/__tests__/theme-colors-parity.test.ts +115 -0
- package/src/theme/__tests__/token-registry.test.ts +30 -0
- package/src/theme/__tests__/visual-gallery.test.tsx +10 -0
- package/src/theme/apply-dark-class.ts +8 -17
- package/src/theme/build-theme.ts +59 -62
- package/src/theme/color-presets.ts +9 -9
- package/src/theme/color-scope/index.web.tsx +7 -14
- package/src/theme/color-scope/style-builder.ts +7 -4
- package/src/theme/color-space.ts +56 -0
- package/src/theme/index.ts +1 -6
- package/src/theme/native-root-vars.native.ts +28 -18
- package/src/theme/preset-vars.ts +17 -178
- package/src/theme/token-registry.ts +159 -0
- package/src/theme/types.ts +3 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { oklchToSrgb, srgbToRgbString, parseRgbString, deltaE } from '../color-space';
|
|
2
|
+
|
|
3
|
+
describe('color-space', () => {
|
|
4
|
+
it('converts oklch white/black correctly', () => {
|
|
5
|
+
expect(oklchToSrgb({ l: 1, c: 0, h: 0 })).toEqual({ r: 255, g: 255, b: 255 });
|
|
6
|
+
expect(oklchToSrgb({ l: 0, c: 0, h: 0 })).toEqual({ r: 0, g: 0, b: 0 });
|
|
7
|
+
});
|
|
8
|
+
it('round-trips a known oxy purple within ΔE 1', () => {
|
|
9
|
+
// hsl(277 66% 56%) === rgb(160 69 217) (current oracle)
|
|
10
|
+
const rgb = oklchToSrgb({ l: 0.5783, c: 0.22, h: 309.31 });
|
|
11
|
+
expect(deltaE(rgb, { r: 160, g: 69, b: 217 })).toBeLessThanOrEqual(1.0);
|
|
12
|
+
});
|
|
13
|
+
it('formats rgb as space-separated modern syntax', () => {
|
|
14
|
+
expect(srgbToRgbString({ r: 31, g: 153, b: 239 })).toBe('rgb(31 153 239)');
|
|
15
|
+
expect(srgbToRgbString({ r: 31, g: 153, b: 239 }, 0.1)).toBe('rgb(31 153 239 / 0.1)');
|
|
16
|
+
});
|
|
17
|
+
it('parses modern + legacy rgb strings', () => {
|
|
18
|
+
expect(parseRgbString('rgb(31 153 239)')).toEqual({ r: 31, g: 153, b: 239 });
|
|
19
|
+
expect(parseRgbString('rgb(31, 153, 239)')).toEqual({ r: 31, g: 153, b: 239 });
|
|
20
|
+
});
|
|
21
|
+
it('throws loudly on non-rgb input instead of silently returning black', () => {
|
|
22
|
+
expect(() => parseRgbString('277 50% 5%')).toThrow(
|
|
23
|
+
'parseRgbString: expected an rgb() string, got: 277 50% 5%',
|
|
24
|
+
);
|
|
25
|
+
expect(() => parseRgbString('hsl(277 50% 5%)')).toThrow(/expected an rgb\(\) string/);
|
|
26
|
+
expect(() => parseRgbString('')).toThrow(/expected an rgb\(\) string/);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import goldenResolvedTokens from './__fixtures__/golden-resolved-tokens.json';
|
|
2
|
+
import { APP_COLOR_NAMES } from '../color-presets';
|
|
3
|
+
import { getResolvedTokens } from '../token-registry';
|
|
4
|
+
|
|
5
|
+
// `golden-resolved-tokens.json` is the FROZEN parity oracle: the exact resolved
|
|
6
|
+
// rgb for every preset×mode×token that the legacy resolved-color pipeline
|
|
7
|
+
// produced (captured once, each resolved-color key remapped to its base `--X`).
|
|
8
|
+
// It is IMMUTABLE — never regenerate it to chase new output. These tests prove
|
|
9
|
+
// the canonical `getResolvedTokens` pipeline reproduces that palette EXACTLY
|
|
10
|
+
// (byte-for-byte rgb), so the refactor to a single rgb token pipeline does not
|
|
11
|
+
// shift a single color.
|
|
12
|
+
const fixture = goldenResolvedTokens as Record<string, Record<string, string>>;
|
|
13
|
+
|
|
14
|
+
describe('golden token parity (frozen oracle)', () => {
|
|
15
|
+
for (const name of APP_COLOR_NAMES) {
|
|
16
|
+
for (const mode of ['light', 'dark'] as const) {
|
|
17
|
+
it(`${name}/${mode} resolves to the frozen rgb palette exactly`, () => {
|
|
18
|
+
const expected = fixture[`${name}/${mode}`];
|
|
19
|
+
expect(expected).toBeDefined();
|
|
20
|
+
|
|
21
|
+
const resolved = getResolvedTokens(name, mode);
|
|
22
|
+
// For every token the oracle ever resolved, the canonical pipeline must
|
|
23
|
+
// produce the identical rgb string. (The fixture covers exactly the keys
|
|
24
|
+
// that had a resolved-color companion; extended-only tokens —
|
|
25
|
+
// chart-*/sidebar-*/content-area — were never in the rgb oracle and are
|
|
26
|
+
// covered by the canonical-token coverage test instead.)
|
|
27
|
+
const actual: Record<string, string | undefined> = {};
|
|
28
|
+
for (const key of Object.keys(expected ?? {})) actual[key] = resolved[key];
|
|
29
|
+
expect(actual).toEqual(expected);
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import normalizeColor from '@react-native/normalize-colors';
|
|
6
|
+
import { Platform } from 'react-native';
|
|
7
|
+
|
|
8
|
+
import { buildTheme } from '../build-theme';
|
|
9
|
+
import { applyColorPresetVars } from '../apply-dark-class';
|
|
10
|
+
import { getResolvedTokens } from '../token-registry';
|
|
11
|
+
|
|
12
|
+
// `@react-native/normalize-colors` is the exact parser React Native (native) and
|
|
13
|
+
// react-native-web use behind `StyleSheet`/`processColor`. If it returns `null`
|
|
14
|
+
// for a value, that color is UNPARSEABLE and the element renders nothing. These
|
|
15
|
+
// tests pin the runtime contract for the canonical rgb token pipeline: every
|
|
16
|
+
// JS `theme.colors` value must parse, the web document vars must be full colors
|
|
17
|
+
// (not bare HSL triples), and the historically mislabeled aliases must be fixed.
|
|
18
|
+
|
|
19
|
+
it('every theme.colors value parses via normalize-colors (native/RNW StyleSheet)', () => {
|
|
20
|
+
const { colors } = buildTheme('oxy', 'dark');
|
|
21
|
+
for (const v of Object.values(colors)) {
|
|
22
|
+
expect(normalizeColor(v)).not.toBeNull(); // null = unparseable -> renders nothing
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe('web var(--primary) resolves to a real color (not a bare triple)', () => {
|
|
27
|
+
const originalOS = Platform.OS;
|
|
28
|
+
|
|
29
|
+
beforeEach(() => {
|
|
30
|
+
Platform.OS = 'web';
|
|
31
|
+
document.documentElement.removeAttribute('style');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
afterEach(() => {
|
|
35
|
+
Platform.OS = originalOS;
|
|
36
|
+
document.documentElement.removeAttribute('style');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('writes --primary as a resolved rgb() color', () => {
|
|
40
|
+
applyColorPresetVars('oxy', 'dark');
|
|
41
|
+
const v = document.documentElement.style.getPropertyValue('--primary').trim();
|
|
42
|
+
expect(v).toMatch(/^rgb\(/);
|
|
43
|
+
expect(normalizeColor(v)).not.toBeNull();
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('restored legacy alias invariants hold', () => {
|
|
48
|
+
// Pre-0.8.0 semantics, restored in 0.8.1: `secondary` mirrors `primary`,
|
|
49
|
+
// `card` and `primaryLight` are the page surface, `primaryDark` is the page
|
|
50
|
+
// background. All read straight from the same resolved rgb token map, so the
|
|
51
|
+
// alias values are byte-identical to their source tokens.
|
|
52
|
+
for (const preset of ['oxy', 'blue'] as const) {
|
|
53
|
+
for (const mode of ['light', 'dark'] as const) {
|
|
54
|
+
const { colors } = buildTheme(preset, mode);
|
|
55
|
+
const t = getResolvedTokens(preset, mode);
|
|
56
|
+
expect(colors.secondary).toBe(colors.primary);
|
|
57
|
+
expect(colors.secondary).toBe(t['--primary']);
|
|
58
|
+
expect(colors.card).toBe(t['--surface']);
|
|
59
|
+
expect(colors.primaryLight).toBe(t['--surface']);
|
|
60
|
+
expect(colors.primaryDark).toBe(t['--background']);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
});
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment node
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import normalizeColor from '@react-native/normalize-colors';
|
|
6
|
+
|
|
7
|
+
import { buildTheme } from '../build-theme';
|
|
8
|
+
import type { AppColorName } from '../color-presets';
|
|
9
|
+
import type { ThemeColors } from '../types';
|
|
10
|
+
|
|
11
|
+
// Parity guard for the 0.8.1 restoration of the pre-0.8.0 `theme.colors`
|
|
12
|
+
// derived/alias values. 0.8.0 changed several derived/alias mappings, visibly
|
|
13
|
+
// altering dark (and light) mode for consuming components. 0.8.1 restores the
|
|
14
|
+
// EXACT pre-0.8.0 colors while keeping the single rgb pipeline (the old logic
|
|
15
|
+
// emitted `hsl(h,s%,l%)` strings; the new logic emits the `rgb(...)` of the same
|
|
16
|
+
// color). These oracles are the VERBATIM pre-0.8.0 outputs (commit 00a4cf3:
|
|
17
|
+
// `git show 00a4cf3:src/theme/build-theme.ts`). We assert visual equality via
|
|
18
|
+
// ΔE on the parsed sRGB — `hsl(...)` and the matching `rgb(...)` parse to the
|
|
19
|
+
// same bytes under the RN/RNW/browser `normalizeColor`, so ΔE is 0.
|
|
20
|
+
|
|
21
|
+
type Rgb = { r: number; g: number; b: number };
|
|
22
|
+
|
|
23
|
+
function parse(color: string): Rgb {
|
|
24
|
+
const n = normalizeColor(color);
|
|
25
|
+
if (n === null || n === undefined) {
|
|
26
|
+
throw new Error(`unparseable color: ${color}`);
|
|
27
|
+
}
|
|
28
|
+
// normalizeColor returns 0xRRGGBBAA.
|
|
29
|
+
return { r: (n >>> 24) & 0xff, g: (n >>> 16) & 0xff, b: (n >>> 8) & 0xff };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function deltaE(a: Rgb, b: Rgb): number {
|
|
33
|
+
return Math.sqrt((a.r - b.r) ** 2 + (a.g - b.g) ** 2 + (a.b - b.b) ** 2) / Math.sqrt(3);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The pre-0.8.0 `theme.colors` for a (preset, mode) pair, expressed in the EXACT
|
|
38
|
+
* source forms commit 00a4cf3 produced — token-backed values as `hsl(H, S%, L%)`
|
|
39
|
+
* (from the preset's raw triples) and computed tints via `hsl(hue, s%, l%)`.
|
|
40
|
+
*
|
|
41
|
+
* We only assert the fields the restoration touches plus the load-bearing
|
|
42
|
+
* aliases (primaryLight/primaryDark/secondary/card). Status colors and
|
|
43
|
+
* shadow/overlay were never changed, so they are intentionally omitted.
|
|
44
|
+
*/
|
|
45
|
+
type Oracle = Partial<Record<keyof ThemeColors, string>>;
|
|
46
|
+
|
|
47
|
+
const ORACLES: Array<{ preset: AppColorName; mode: 'light' | 'dark'; old: Oracle }> = [
|
|
48
|
+
{
|
|
49
|
+
// blue: primary 205 87% 53%, surface dark 205 20% 18%, background dark 205 50% 5%, destructive hue 0.
|
|
50
|
+
preset: 'blue',
|
|
51
|
+
mode: 'dark',
|
|
52
|
+
old: {
|
|
53
|
+
secondary: 'hsl(205, 87%, 53%)', // == primary
|
|
54
|
+
primary: 'hsl(205, 87%, 53%)',
|
|
55
|
+
card: 'hsl(205, 20%, 18%)', // == surface
|
|
56
|
+
primaryLight: 'hsl(205, 20%, 18%)', // == surface
|
|
57
|
+
primaryDark: 'hsl(205, 50%, 5%)', // == background
|
|
58
|
+
primarySubtle: 'hsl(205, 50%, 10%)',
|
|
59
|
+
primarySubtleForeground: 'hsl(205, 70%, 65%)',
|
|
60
|
+
negative: 'hsl(0, 84%, 45%)',
|
|
61
|
+
negativeForeground: '#FFFFFF',
|
|
62
|
+
negativeSubtle: 'hsl(0, 50%, 10%)',
|
|
63
|
+
negativeSubtleForeground: 'hsl(0, 70%, 65%)',
|
|
64
|
+
contrast50: 'hsl(205, 15%, 12%)',
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
// oxy: primary 277 66% 56%, surface dark 277 20% 18%, background dark 277 50% 5%, destructive hue 0.
|
|
69
|
+
preset: 'oxy',
|
|
70
|
+
mode: 'dark',
|
|
71
|
+
old: {
|
|
72
|
+
secondary: 'hsl(277, 66%, 56%)',
|
|
73
|
+
primary: 'hsl(277, 66%, 56%)',
|
|
74
|
+
card: 'hsl(277, 20%, 18%)',
|
|
75
|
+
primaryLight: 'hsl(277, 20%, 18%)',
|
|
76
|
+
primaryDark: 'hsl(277, 50%, 5%)',
|
|
77
|
+
primarySubtle: 'hsl(277, 50%, 10%)',
|
|
78
|
+
primarySubtleForeground: 'hsl(277, 70%, 65%)',
|
|
79
|
+
negative: 'hsl(0, 84%, 45%)',
|
|
80
|
+
negativeForeground: '#FFFFFF',
|
|
81
|
+
negativeSubtle: 'hsl(0, 50%, 10%)',
|
|
82
|
+
negativeSubtleForeground: 'hsl(0, 70%, 65%)',
|
|
83
|
+
contrast50: 'hsl(277, 15%, 12%)',
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
// blue light: surface 205 58% 94%, background 205 55% 96%.
|
|
88
|
+
preset: 'blue',
|
|
89
|
+
mode: 'light',
|
|
90
|
+
old: {
|
|
91
|
+
secondary: 'hsl(205, 87%, 53%)',
|
|
92
|
+
primary: 'hsl(205, 87%, 53%)',
|
|
93
|
+
card: 'hsl(205, 58%, 94%)',
|
|
94
|
+
primaryLight: 'hsl(205, 58%, 94%)',
|
|
95
|
+
primaryDark: 'hsl(205, 55%, 96%)',
|
|
96
|
+
primarySubtle: 'hsl(205, 70%, 93%)',
|
|
97
|
+
primarySubtleForeground: 'hsl(205, 90%, 25%)',
|
|
98
|
+
negative: 'hsl(0, 84%, 45%)',
|
|
99
|
+
negativeForeground: '#FFFFFF',
|
|
100
|
+
negativeSubtle: 'hsl(0, 90%, 95%)',
|
|
101
|
+
negativeSubtleForeground: 'hsl(0, 80%, 40%)',
|
|
102
|
+
contrast50: 'hsl(205, 10%, 93%)',
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
];
|
|
106
|
+
|
|
107
|
+
describe.each(ORACLES)('theme.colors parity with pre-0.8.0 ($preset/$mode)', ({ preset, mode, old }) => {
|
|
108
|
+
const { colors } = buildTheme(preset, mode);
|
|
109
|
+
|
|
110
|
+
it.each(Object.entries(old))('%s matches the pre-0.8.0 color (ΔE ≤ 1)', (key, oldColor) => {
|
|
111
|
+
const newColor = colors[key as keyof ThemeColors];
|
|
112
|
+
const dE = deltaE(parse(oldColor), parse(newColor));
|
|
113
|
+
expect(dE).toBeLessThanOrEqual(1);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import goldenResolvedTokens from './__fixtures__/golden-resolved-tokens.json';
|
|
2
|
+
import { CANONICAL_TOKENS, getResolvedTokens } from '../token-registry';
|
|
3
|
+
import { APP_COLOR_NAMES } from '../color-presets';
|
|
4
|
+
import { getPresetVars } from '../preset-vars';
|
|
5
|
+
|
|
6
|
+
const fixture = goldenResolvedTokens as Record<string, Record<string, string>>;
|
|
7
|
+
|
|
8
|
+
it('canonical token list covers every base+extended preset var', () => {
|
|
9
|
+
const fromPreset = Object.keys(getPresetVars('oxy', 'light'));
|
|
10
|
+
for (const k of fromPreset) expect(CANONICAL_TOKENS).toContain(k.replace(/^--/, ''));
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
describe('getResolvedTokens parity vs frozen oracle (exact rgb)', () => {
|
|
14
|
+
for (const name of APP_COLOR_NAMES) {
|
|
15
|
+
for (const mode of ['light', 'dark'] as const) {
|
|
16
|
+
it(`${name}/${mode}`, () => {
|
|
17
|
+
const oracle = fixture[`${name}/${mode}`];
|
|
18
|
+
expect(oracle).toBeDefined();
|
|
19
|
+
|
|
20
|
+
const resolved = getResolvedTokens(name, mode); // Record<'--x','rgb(...)'>
|
|
21
|
+
for (const [base, oracleRgb] of Object.entries(oracle ?? {})) {
|
|
22
|
+
expect(resolved[base]).toBeDefined();
|
|
23
|
+
// Exact match — the canonical pipeline reproduces the legacy
|
|
24
|
+
// resolved-color rgb byte-for-byte, so there is no palette drift.
|
|
25
|
+
expect(resolved[base]).toBe(oracleRgb);
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { buildTheme } from '../build-theme';
|
|
2
|
+
import { APP_COLOR_NAMES } from '../color-presets';
|
|
3
|
+
|
|
4
|
+
it('theme.colors snapshot for all presets (catches unintended palette shifts)', () => {
|
|
5
|
+
const all = Object.fromEntries(
|
|
6
|
+
APP_COLOR_NAMES.flatMap((n) => (['light', 'dark'] as const).map(
|
|
7
|
+
(m) => [`${n}/${m}`, buildTheme(n, m).colors] as const)),
|
|
8
|
+
);
|
|
9
|
+
expect(all).toMatchSnapshot();
|
|
10
|
+
});
|
|
@@ -1,11 +1,6 @@
|
|
|
1
1
|
import { Platform } from 'react-native';
|
|
2
2
|
import { APP_COLOR_PRESETS, type AppColorName } from './color-presets';
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
// Re-exported so the web var-contract helper is reachable from this file (the
|
|
6
|
-
// home of the web write path). Defined in `preset-vars.ts` next to the related
|
|
7
|
-
// HSL parsing so native (which never imports this module) doesn't pull it in.
|
|
8
|
-
export { toWebColorValue } from './preset-vars';
|
|
3
|
+
import { getResolvedTokens } from './token-registry';
|
|
9
4
|
|
|
10
5
|
export function applyDarkClass(resolved: 'light' | 'dark') {
|
|
11
6
|
if (Platform.OS === 'web' && typeof document !== 'undefined') {
|
|
@@ -21,16 +16,12 @@ export function applyDarkClass(resolved: 'light' | 'dark') {
|
|
|
21
16
|
* -------------------------------------------------------------------
|
|
22
17
|
* The shadcn/Tailwind-v4 web apps compile their color utilities to reference the
|
|
23
18
|
* BASE token directly (`.bg-background { background-color: var(--background) }`),
|
|
24
|
-
* so on web the base `--x` tokens MUST be FULL CSS colors.
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* unchanged. See `toWebColorValue`.
|
|
19
|
+
* so on web the base `--x` tokens MUST be FULL CSS colors. `getResolvedTokens`
|
|
20
|
+
* returns every canonical token already resolved to an sRGB `rgb(...)` string,
|
|
21
|
+
* so `var(--x)` resolves to a valid color directly — no per-value wrapping. The
|
|
22
|
+
* same rgb values feed native (`color-mix` alpha utilities resolve on sRGB), so
|
|
23
|
+
* web and native share one canonical token pipeline.
|
|
30
24
|
*
|
|
31
|
-
* NATIVE writes the SAME tokens as RAW HSL triples (no `hsl()` wrapper) via
|
|
32
|
-
* `rootVariables` (`native-root-vars.native.ts`), consumed through bloom's
|
|
33
|
-
* native `global.css` `hsl(var(--x))` indirection — that path is untouched here.
|
|
34
25
|
* Includes extended tokens (card, chart-*, content-area, sidebar-*) so consumer
|
|
35
26
|
* apps don't need to synthesize them.
|
|
36
27
|
*/
|
|
@@ -38,10 +29,10 @@ export function applyColorPresetVars(preset: AppColorName, resolved: 'light' | '
|
|
|
38
29
|
if (Platform.OS !== 'web' || typeof document === 'undefined') return;
|
|
39
30
|
if (!APP_COLOR_PRESETS[preset]) return;
|
|
40
31
|
|
|
41
|
-
const vars =
|
|
32
|
+
const vars = getResolvedTokens(preset, resolved);
|
|
42
33
|
const root = document.documentElement.style;
|
|
43
34
|
|
|
44
35
|
for (const [key, value] of Object.entries(vars)) {
|
|
45
|
-
root.setProperty(key,
|
|
36
|
+
root.setProperty(key, value);
|
|
46
37
|
}
|
|
47
38
|
}
|
package/src/theme/build-theme.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Platform } from 'react-native';
|
|
2
|
-
import { APP_COLOR_PRESETS, type AppColorName
|
|
2
|
+
import { APP_COLOR_PRESETS, type AppColorName } from './color-presets';
|
|
3
3
|
import { getAdaptiveColors } from './adaptive-colors';
|
|
4
|
+
import { getResolvedTokens, hslToSrgb } from './token-registry';
|
|
4
5
|
import type { Theme, ThemeColors } from './types';
|
|
5
6
|
|
|
6
7
|
/**
|
|
@@ -15,91 +16,87 @@ export const STATUS_COLORS = {
|
|
|
15
16
|
} as const;
|
|
16
17
|
|
|
17
18
|
/**
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
19
|
+
* Extract the integer hue from a shadcn-style raw HSL triple (`'H S% L%'`,
|
|
20
|
+
* optionally with an alpha tail). Used to seed the computed brand tints below
|
|
21
|
+
* from the preset's primary/destructive hue, exactly as pre-0.8.0.
|
|
21
22
|
*/
|
|
22
|
-
function
|
|
23
|
-
const
|
|
24
|
-
const triple = parts[0] ?? '0 0% 0%';
|
|
25
|
-
const components = triple.replace(/\s+/g, ', ');
|
|
26
|
-
|
|
27
|
-
if (parts.length === 2) {
|
|
28
|
-
const alpha = parseFloat(parts[1] ?? '100') / 100;
|
|
29
|
-
return `hsla(${components}, ${alpha})`;
|
|
30
|
-
}
|
|
31
|
-
return `hsl(${components})`;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
function extractHue(hslVar: string): number {
|
|
35
|
-
const first = hslVar.split(/\s+/)[0] ?? '0';
|
|
23
|
+
function extractHue(triple: string): number {
|
|
24
|
+
const first = (triple.split(/\s+/)[0] ?? '0').replace(/deg$/i, '');
|
|
36
25
|
const hue = parseInt(first, 10);
|
|
37
26
|
return Number.isFinite(hue) ? hue : 0;
|
|
38
27
|
}
|
|
39
28
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
29
|
+
/**
|
|
30
|
+
* Build the JS `theme.colors` object from the SAME canonical rgb token source
|
|
31
|
+
* the web/native CSS-var writes use (`getResolvedTokens`). JS styles and the
|
|
32
|
+
* `var(--x)` document tokens therefore share one rgb pipeline — no second,
|
|
33
|
+
* drift-prone HSL conversion lives here.
|
|
34
|
+
*
|
|
35
|
+
* Token-backed values read straight from the resolved rgb map via `g(...)`.
|
|
36
|
+
* The brand tints (primarySubtle/negative/contrast50/...) reproduce the EXACT
|
|
37
|
+
* pre-0.8.0 colors — computed from the preset's primary/destructive HUE with
|
|
38
|
+
* the historical `hsl(h, s%, l%)` math — but are emitted as `rgb(...)` by
|
|
39
|
+
* routing the triple through the registry's `hslToSrgb`, so the single-format
|
|
40
|
+
* pipeline is preserved. (`hsl(h,s%,l%)` and the `rgb(...)` of the same color
|
|
41
|
+
* are byte-identical under the RN/RNW/browser parser.)
|
|
42
|
+
*/
|
|
48
43
|
function buildColorsFromPreset(
|
|
49
44
|
preset: AppColorName,
|
|
50
45
|
resolved: 'light' | 'dark',
|
|
51
46
|
): ThemeColors {
|
|
52
|
-
const
|
|
53
|
-
const tokens = resolved === 'dark' ? config.dark : config.light;
|
|
47
|
+
const t = getResolvedTokens(preset, resolved);
|
|
54
48
|
const isDark = resolved === 'dark';
|
|
55
49
|
|
|
56
|
-
|
|
57
|
-
const
|
|
50
|
+
// Read a resolved `rgb(...)` token by its bare name (no leading `--`).
|
|
51
|
+
const g = (k: string): string => t[`--${k}`] ?? 'rgb(0 0 0)';
|
|
52
|
+
|
|
53
|
+
// Convert a computed `H S% L%` triple to the canonical `rgb(...)` via the
|
|
54
|
+
// registry's single HSL→sRGB conversion (matches the old `hsl(h,s%,l%)`).
|
|
55
|
+
const hslRgb = (h: number, s: number, l: number): string => hslToSrgb(`${h} ${s}% ${l}%`);
|
|
58
56
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
const
|
|
62
|
-
const
|
|
63
|
-
const
|
|
57
|
+
// Brand hues seed the computed tints from the preset's raw HSL triples,
|
|
58
|
+
// exactly as the pre-0.8.0 logic did.
|
|
59
|
+
const presetTokens = isDark ? APP_COLOR_PRESETS[preset].dark : APP_COLOR_PRESETS[preset].light;
|
|
60
|
+
const pHue = extractHue(presetTokens['--primary'] ?? '0 0% 50%');
|
|
61
|
+
const dHue = extractHue(presetTokens['--destructive'] ?? '0 0% 0%');
|
|
64
62
|
|
|
65
63
|
return {
|
|
66
|
-
background,
|
|
67
|
-
backgroundSecondary: surface,
|
|
68
|
-
backgroundTertiary:
|
|
64
|
+
background: g('background'),
|
|
65
|
+
backgroundSecondary: g('surface'),
|
|
66
|
+
backgroundTertiary: g('muted'),
|
|
69
67
|
|
|
70
|
-
text:
|
|
71
|
-
textSecondary:
|
|
72
|
-
textTertiary:
|
|
68
|
+
text: g('foreground'),
|
|
69
|
+
textSecondary: g('muted-foreground'),
|
|
70
|
+
textTertiary: g('muted-foreground'),
|
|
73
71
|
|
|
74
|
-
border:
|
|
75
|
-
borderLight:
|
|
72
|
+
border: g('border'),
|
|
73
|
+
borderLight: g('input'),
|
|
76
74
|
|
|
77
|
-
primary:
|
|
78
|
-
primaryForeground,
|
|
79
|
-
// Legacy aliases
|
|
80
|
-
//
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
primaryDark: background,
|
|
75
|
+
primary: g('primary'),
|
|
76
|
+
primaryForeground: g('primary-foreground'),
|
|
77
|
+
// Legacy aliases (see types.ts): `primaryLight` is the page surface tint,
|
|
78
|
+
// `primaryDark` is the page background — retained for downstream consumers.
|
|
79
|
+
primaryLight: g('surface'),
|
|
80
|
+
primaryDark: g('background'),
|
|
84
81
|
|
|
85
|
-
// `secondary` historically
|
|
86
|
-
secondary:
|
|
82
|
+
// `secondary` historically mirrors `primary`. Retained for compatibility.
|
|
83
|
+
secondary: g('primary'),
|
|
87
84
|
|
|
88
|
-
tint:
|
|
89
|
-
icon:
|
|
90
|
-
iconActive:
|
|
85
|
+
tint: g('primary'),
|
|
86
|
+
icon: g('muted-foreground'),
|
|
87
|
+
iconActive: g('primary'),
|
|
91
88
|
|
|
92
89
|
...STATUS_COLORS,
|
|
93
90
|
|
|
94
|
-
primarySubtle: isDark ?
|
|
95
|
-
primarySubtleForeground: isDark ?
|
|
96
|
-
negative:
|
|
91
|
+
primarySubtle: isDark ? hslRgb(pHue, 50, 10) : hslRgb(pHue, 70, 93),
|
|
92
|
+
primarySubtleForeground: isDark ? hslRgb(pHue, 70, 65) : hslRgb(pHue, 90, 25),
|
|
93
|
+
negative: hslRgb(dHue, 84, 45),
|
|
97
94
|
negativeForeground: '#FFFFFF',
|
|
98
|
-
negativeSubtle: isDark ?
|
|
99
|
-
negativeSubtleForeground: isDark ?
|
|
100
|
-
contrast50: isDark ?
|
|
95
|
+
negativeSubtle: isDark ? hslRgb(dHue, 50, 10) : hslRgb(dHue, 90, 95),
|
|
96
|
+
negativeSubtleForeground: isDark ? hslRgb(dHue, 70, 65) : hslRgb(dHue, 80, 40),
|
|
97
|
+
contrast50: isDark ? hslRgb(pHue, 15, 12) : hslRgb(pHue, 10, 93),
|
|
101
98
|
|
|
102
|
-
card: surface,
|
|
99
|
+
card: g('surface'),
|
|
103
100
|
shadow: isDark ? 'rgba(0, 0, 0, 0.3)' : 'rgba(0, 0, 0, 0.1)',
|
|
104
101
|
overlay: 'rgba(0, 0, 0, 0.5)',
|
|
105
102
|
};
|
|
@@ -6,17 +6,17 @@ export type AppColorName = 'teal' | 'blue' | 'green' | 'amber' | 'yellow' | 'red
|
|
|
6
6
|
* Keys carry a leading `--` for historical reasons (shadcn-style CSS variable
|
|
7
7
|
* names) — keep in mind the values are platform-agnostic **raw HSL triples**
|
|
8
8
|
* (e.g. `'185 100% 20%'` or `'185 100% 20% / 0.5'`), not CSS-resolved colors.
|
|
9
|
-
* The
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
9
|
+
* The single canonical resolver `getResolvedTokens` (in `token-registry.ts`)
|
|
10
|
+
* converts this raw map to sRGB `rgb(...)` for every runtime write — the same
|
|
11
|
+
* values drive BOTH layers:
|
|
12
|
+
* - the web layer: `getResolvedTokens`'s `rgb(...)` strings are written to
|
|
13
|
+
* `document.documentElement` / a `BloomColorScope` element, so `var(--x)` —
|
|
14
|
+
* the form Tailwind v4 `@theme inline` compiles its color utilities to —
|
|
15
|
+
* resolves to a valid color directly, and
|
|
16
16
|
* - the native layer (`buildTheme` resolves the raw triples into `hsl(...)`
|
|
17
17
|
* strings consumable by React Native styles; `native-root-vars.native.ts`
|
|
18
|
-
* writes the
|
|
19
|
-
*
|
|
18
|
+
* writes the same canonical `rgb(...)` values into react-native-css's
|
|
19
|
+
* `rootVariables` family).
|
|
20
20
|
*
|
|
21
21
|
* The `--` prefix is an implementation detail we will drop in a future major.
|
|
22
22
|
*/
|
|
@@ -3,27 +3,20 @@ import React, { Children, cloneElement, isValidElement, useContext, useMemo } fr
|
|
|
3
3
|
import { BloomThemeContext, type BloomThemeContextValue } from '../BloomThemeProvider';
|
|
4
4
|
import { buildTheme } from '../build-theme';
|
|
5
5
|
import type { AppColorName } from '../color-presets';
|
|
6
|
-
import {
|
|
7
|
-
import { buildScopeVars } from './style-builder';
|
|
6
|
+
import { getResolvedTokens } from '../token-registry';
|
|
8
7
|
|
|
9
8
|
/**
|
|
10
|
-
* `
|
|
11
|
-
*
|
|
12
|
-
* element's inline `style`,
|
|
13
|
-
* `var(--x)` directly
|
|
14
|
-
*
|
|
15
|
-
* non-color tokens pass through. See `toWebColorValue` in `../preset-vars`.
|
|
9
|
+
* `getResolvedTokens` returns every canonical token already resolved to an sRGB
|
|
10
|
+
* `rgb(...)` string — the SAME pipeline the document-root web writer and the
|
|
11
|
+
* native writer use. Scoped onto an element's inline `style`, Tailwind v4's
|
|
12
|
+
* compiled utilities read these as `var(--x)` directly, so each base token is a
|
|
13
|
+
* full CSS color with no per-value wrapping needed.
|
|
16
14
|
*/
|
|
17
15
|
function buildWebScopeVars(
|
|
18
16
|
colorPreset: AppColorName,
|
|
19
17
|
mode: 'light' | 'dark',
|
|
20
18
|
): React.CSSProperties {
|
|
21
|
-
|
|
22
|
-
const out: Record<string, string> = {};
|
|
23
|
-
for (const [key, value] of Object.entries(raw)) {
|
|
24
|
-
out[key] = toWebColorValue(key, value);
|
|
25
|
-
}
|
|
26
|
-
return out as React.CSSProperties;
|
|
19
|
+
return getResolvedTokens(colorPreset, mode) as React.CSSProperties;
|
|
27
20
|
}
|
|
28
21
|
|
|
29
22
|
export interface BloomColorScopeProps {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type React from 'react';
|
|
2
2
|
import { Platform, type StyleProp, type ViewStyle } from 'react-native';
|
|
3
3
|
|
|
4
|
-
import {
|
|
4
|
+
import { getResolvedTokens } from '../token-registry';
|
|
5
5
|
import type { AppColorName } from '../color-presets';
|
|
6
6
|
import { lazyRequire } from '../../utils/lazy-require';
|
|
7
7
|
|
|
@@ -25,14 +25,17 @@ const getNativeWindVars = lazyRequire<NativeWindVarsModule>('nativewind');
|
|
|
25
25
|
|
|
26
26
|
/**
|
|
27
27
|
* Build the CSS custom-property map for a preset, ready to be applied to a
|
|
28
|
-
* subtree.
|
|
29
|
-
*
|
|
28
|
+
* subtree. Every token is resolved to an sRGB `rgb(...)` string via
|
|
29
|
+
* `getResolvedTokens` — the single canonical token pipeline shared by web and
|
|
30
|
+
* native — so Tailwind v4 `@theme` utilities (e.g. `bg-background`) and
|
|
31
|
+
* `color-mix`-based alpha utilities (`bg-primary/10`) both resolve against the
|
|
32
|
+
* same color values. Keys retain the leading `--`.
|
|
30
33
|
*/
|
|
31
34
|
export function buildScopeVars(
|
|
32
35
|
colorPreset: AppColorName,
|
|
33
36
|
mode: 'light' | 'dark',
|
|
34
37
|
): Record<string, string> {
|
|
35
|
-
return
|
|
38
|
+
return getResolvedTokens(colorPreset, mode);
|
|
36
39
|
}
|
|
37
40
|
|
|
38
41
|
/**
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
export interface Oklch { l: number; c: number; h: number } // l 0..1, c 0..~0.4, h deg
|
|
2
|
+
export interface Rgb { r: number; g: number; b: number } // 0..255 integers
|
|
3
|
+
|
|
4
|
+
const clamp = (n: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, n));
|
|
5
|
+
const srgbGamma = (x: number) =>
|
|
6
|
+
x <= 0.0031308 ? 12.92 * x : 1.055 * Math.pow(x, 1 / 2.4) - 0.055;
|
|
7
|
+
|
|
8
|
+
/** OKLCH → linear OKLab → linear sRGB → gamma sRGB → 0..255. Pure. */
|
|
9
|
+
export function oklchToSrgb({ l, c, h }: Oklch): Rgb {
|
|
10
|
+
const hr = (h * Math.PI) / 180;
|
|
11
|
+
const a = c * Math.cos(hr);
|
|
12
|
+
const b2 = c * Math.sin(hr);
|
|
13
|
+
const l_ = l + 0.3963377774 * a + 0.2158037573 * b2;
|
|
14
|
+
const m_ = l - 0.1055613458 * a - 0.0638541728 * b2;
|
|
15
|
+
const s_ = l - 0.0894841775 * a - 1.291485548 * b2;
|
|
16
|
+
const lc = l_ ** 3, mc = m_ ** 3, sc = s_ ** 3;
|
|
17
|
+
const lr = +4.0767416621 * lc - 3.3077115913 * mc + 0.2309699292 * sc;
|
|
18
|
+
const lg = -1.2684380046 * lc + 2.6097574011 * mc - 0.3413193965 * sc;
|
|
19
|
+
const lb = -0.0041960863 * lc - 0.7034186147 * mc + 1.707614701 * sc;
|
|
20
|
+
return {
|
|
21
|
+
r: Math.round(clamp(srgbGamma(lr), 0, 1) * 255),
|
|
22
|
+
g: Math.round(clamp(srgbGamma(lg), 0, 1) * 255),
|
|
23
|
+
b: Math.round(clamp(srgbGamma(lb), 0, 1) * 255),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function srgbToRgbString({ r, g, b }: Rgb, alpha?: number): string {
|
|
28
|
+
return alpha === undefined || alpha >= 1
|
|
29
|
+
? `rgb(${r} ${g} ${b})`
|
|
30
|
+
: `rgb(${r} ${g} ${b} / ${alpha})`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Parse an `rgb(...)` / `rgba(...)` string into an {@link Rgb}.
|
|
35
|
+
*
|
|
36
|
+
* Contract: input MUST be an `rgb(...)` / `rgba(...)` string (e.g. a resolved
|
|
37
|
+
* token from `getResolvedTokens`). Both modern (`rgb(r g b)`) and legacy
|
|
38
|
+
* (`rgb(r, g, b)`) syntaxes are accepted. Anything else throws — this is a
|
|
39
|
+
* canonical-pipeline primitive and a non-rgb input signals a real bug at the
|
|
40
|
+
* call site, never a value to silently coerce to black.
|
|
41
|
+
*
|
|
42
|
+
* @throws {Error} if `s` is not an `rgb()` / `rgba()` string.
|
|
43
|
+
*/
|
|
44
|
+
export function parseRgbString(s: string): Rgb {
|
|
45
|
+
const inner = s.match(/rgba?\(([^)]+)\)/i)?.[1];
|
|
46
|
+
if (inner === undefined) {
|
|
47
|
+
throw new Error(`parseRgbString: expected an rgb() string, got: ${s}`);
|
|
48
|
+
}
|
|
49
|
+
const [r, g, b] = inner.split(/[ ,/]+/).map((n) => parseInt(n, 10));
|
|
50
|
+
return { r: r ?? 0, g: g ?? 0, b: b ?? 0 };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Cheap perceptual-ish distance in sRGB (sufficient for a ≤1 rounding gate). */
|
|
54
|
+
export function deltaE(a: Rgb, b: Rgb): number {
|
|
55
|
+
return Math.sqrt((a.r - b.r) ** 2 + (a.g - b.g) ** 2 + (a.b - b.b) ** 2) / Math.sqrt(3);
|
|
56
|
+
}
|