@ankhorage/color-theory 0.0.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/LICENSE +21 -0
- package/README.md +1 -0
- package/dist/contrast.d.ts +7 -0
- package/dist/contrast.d.ts.map +1 -0
- package/dist/contrast.js +13 -0
- package/dist/contrast.js.map +1 -0
- package/dist/culori-fn.d.ts +2 -0
- package/dist/culori-fn.d.ts.map +1 -0
- package/dist/culori-fn.js +2 -0
- package/dist/culori-fn.js.map +1 -0
- package/dist/culori.d.ts +2 -0
- package/dist/culori.d.ts.map +1 -0
- package/dist/culori.js +2 -0
- package/dist/culori.js.map +1 -0
- package/dist/harmony.d.ts +20 -0
- package/dist/harmony.d.ts.map +1 -0
- package/dist/harmony.js +60 -0
- package/dist/harmony.js.map +1 -0
- package/dist/hex.d.ts +10 -0
- package/dist/hex.d.ts.map +1 -0
- package/dist/hex.js +24 -0
- package/dist/hex.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/internal-culori.d.ts +25 -0
- package/dist/internal-culori.d.ts.map +1 -0
- package/dist/internal-culori.js +62 -0
- package/dist/internal-culori.js.map +1 -0
- package/dist/neutral.d.ts +15 -0
- package/dist/neutral.d.ts.map +1 -0
- package/dist/neutral.js +41 -0
- package/dist/neutral.js.map +1 -0
- package/dist/semantics.d.ts +13 -0
- package/dist/semantics.d.ts.map +1 -0
- package/dist/semantics.js +31 -0
- package/dist/semantics.js.map +1 -0
- package/dist/swatches.d.ts +27 -0
- package/dist/swatches.d.ts.map +1 -0
- package/dist/swatches.js +109 -0
- package/dist/swatches.js.map +1 -0
- package/dist/theme-colors.d.ts +31 -0
- package/dist/theme-colors.d.ts.map +1 -0
- package/dist/theme-colors.js +44 -0
- package/dist/theme-colors.js.map +1 -0
- package/package.json +86 -0
- package/src/color-theory.test.ts +91 -0
- package/src/contrast.ts +22 -0
- package/src/culori-fn.ts +1 -0
- package/src/culori.ts +1 -0
- package/src/harmony.ts +92 -0
- package/src/hex.ts +29 -0
- package/src/index.ts +7 -0
- package/src/internal-culori.ts +100 -0
- package/src/neutral.ts +68 -0
- package/src/semantics.ts +60 -0
- package/src/swatches.ts +151 -0
- package/src/theme-colors.ts +77 -0
package/dist/swatches.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { deltaEoklch, oklchToHex, parseHexToOklch } from './internal-culori';
|
|
2
|
+
export const COLOR_SWATCH_STEPS = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950];
|
|
3
|
+
export const COLOR_SWATCH_BASE_STEP = 500;
|
|
4
|
+
const BASELINE_LIGHTNESS_BY_STEP = {
|
|
5
|
+
50: 0.985,
|
|
6
|
+
100: 0.967,
|
|
7
|
+
200: 0.928,
|
|
8
|
+
300: 0.872,
|
|
9
|
+
400: 0.707,
|
|
10
|
+
500: 0.551,
|
|
11
|
+
600: 0.446,
|
|
12
|
+
700: 0.373,
|
|
13
|
+
800: 0.278,
|
|
14
|
+
900: 0.21,
|
|
15
|
+
950: 0.13,
|
|
16
|
+
};
|
|
17
|
+
const MIN_USABLE_ADJACENT_DELTA = 0.012;
|
|
18
|
+
const MIN_USABLE_LIGHTNESS_RANGE = 0.35;
|
|
19
|
+
function clamp01(value) {
|
|
20
|
+
if (!Number.isFinite(value))
|
|
21
|
+
return 0;
|
|
22
|
+
return Math.min(1, Math.max(0, value));
|
|
23
|
+
}
|
|
24
|
+
function chromaMultiplierForStep(step) {
|
|
25
|
+
if (step <= 200)
|
|
26
|
+
return 0.55;
|
|
27
|
+
if (step <= 400)
|
|
28
|
+
return 0.75;
|
|
29
|
+
if (step === COLOR_SWATCH_BASE_STEP)
|
|
30
|
+
return 1;
|
|
31
|
+
if (step <= 700)
|
|
32
|
+
return 0.9;
|
|
33
|
+
return 0.8;
|
|
34
|
+
}
|
|
35
|
+
export function generateColorSwatch(baseColor) {
|
|
36
|
+
const baseOklch = parseHexToOklch(baseColor);
|
|
37
|
+
const baseOffset = baseOklch.l - BASELINE_LIGHTNESS_BY_STEP[COLOR_SWATCH_BASE_STEP];
|
|
38
|
+
const warnings = [];
|
|
39
|
+
const swatch = {};
|
|
40
|
+
const lightnessEntries = [];
|
|
41
|
+
const adjacentDeltas = [];
|
|
42
|
+
for (const step of COLOR_SWATCH_STEPS) {
|
|
43
|
+
if (step === COLOR_SWATCH_BASE_STEP) {
|
|
44
|
+
swatch[step] = baseColor;
|
|
45
|
+
lightnessEntries.push(baseOklch.l);
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
const targetL = clamp01(BASELINE_LIGHTNESS_BY_STEP[step] + baseOffset);
|
|
49
|
+
const targetC = clamp01(baseOklch.c * chromaMultiplierForStep(step));
|
|
50
|
+
const target = { ...baseOklch, l: targetL, c: targetC };
|
|
51
|
+
const hex = oklchToHex(target);
|
|
52
|
+
swatch[step] = hex;
|
|
53
|
+
const candidateOklch = parseHexToOklch(hex);
|
|
54
|
+
lightnessEntries.push(candidateOklch.l);
|
|
55
|
+
const deltaEFromBase = deltaEoklch(baseOklch, candidateOklch);
|
|
56
|
+
if (deltaEFromBase < 0.02) {
|
|
57
|
+
warnings.push({
|
|
58
|
+
code: 'weak_step',
|
|
59
|
+
step,
|
|
60
|
+
deltaEFromBase,
|
|
61
|
+
message: `Swatch step ${step} is visually close to the base color.`,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
for (let index = 1; index < COLOR_SWATCH_STEPS.length; index++) {
|
|
66
|
+
const previousStep = COLOR_SWATCH_STEPS[index - 1];
|
|
67
|
+
const currentStep = COLOR_SWATCH_STEPS[index];
|
|
68
|
+
if (previousStep === undefined || currentStep === undefined)
|
|
69
|
+
continue;
|
|
70
|
+
const previous = parseHexToOklch(swatch[previousStep]);
|
|
71
|
+
const current = parseHexToOklch(swatch[currentStep]);
|
|
72
|
+
const adjacentDelta = deltaEoklch(previous, current);
|
|
73
|
+
adjacentDeltas.push(adjacentDelta);
|
|
74
|
+
if (adjacentDelta < MIN_USABLE_ADJACENT_DELTA) {
|
|
75
|
+
warnings.push({
|
|
76
|
+
code: 'weak_adjacent_delta',
|
|
77
|
+
step: currentStep,
|
|
78
|
+
deltaEFromBase: adjacentDelta,
|
|
79
|
+
message: `Swatch step ${currentStep} is visually close to adjacent step ${previousStep}.`,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
const minAdjacentDelta = Math.min(...adjacentDeltas);
|
|
84
|
+
const maxAdjacentDelta = Math.max(...adjacentDeltas);
|
|
85
|
+
const minLightness = Math.min(...lightnessEntries);
|
|
86
|
+
const maxLightness = Math.max(...lightnessEntries);
|
|
87
|
+
const lightnessRange = maxLightness - minLightness;
|
|
88
|
+
if (lightnessRange < MIN_USABLE_LIGHTNESS_RANGE) {
|
|
89
|
+
warnings.push({
|
|
90
|
+
code: 'limited_lightness_range',
|
|
91
|
+
message: 'Generated swatch has a limited lightness range.',
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
return {
|
|
95
|
+
swatch,
|
|
96
|
+
diagnostics: {
|
|
97
|
+
isUsable: minAdjacentDelta >= MIN_USABLE_ADJACENT_DELTA &&
|
|
98
|
+
lightnessRange >= MIN_USABLE_LIGHTNESS_RANGE,
|
|
99
|
+
warnings,
|
|
100
|
+
minAdjacentDelta,
|
|
101
|
+
maxAdjacentDelta,
|
|
102
|
+
lightnessRange: {
|
|
103
|
+
min: minLightness,
|
|
104
|
+
max: maxLightness,
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
//# sourceMappingURL=swatches.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"swatches.js","sourceRoot":"","sources":["../src/swatches.ts"],"names":[],"mappings":"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","sourcesContent":["import type { HexColor } from './hex';\nimport { deltaEoklch, oklchToHex, parseHexToOklch } from './internal-culori';\n\nexport const COLOR_SWATCH_STEPS = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950] as const;\nexport type ColorSwatchStep = (typeof COLOR_SWATCH_STEPS)[number];\n\nexport const COLOR_SWATCH_BASE_STEP = 500 as const;\n\nexport type ColorSwatch = Record<ColorSwatchStep, HexColor>;\n\nexport type ColorSwatchWarningCode =\n | 'weak_step'\n | 'weak_adjacent_delta'\n | 'limited_lightness_range';\n\nexport interface ColorSwatchWarning {\n code: ColorSwatchWarningCode;\n step?: ColorSwatchStep;\n message: string;\n deltaEFromBase?: number;\n}\n\nexport interface ColorSwatchDiagnostics {\n isUsable: boolean;\n warnings: readonly ColorSwatchWarning[];\n minAdjacentDelta: number;\n maxAdjacentDelta: number;\n lightnessRange: {\n min: number;\n max: number;\n };\n}\n\nconst BASELINE_LIGHTNESS_BY_STEP: Record<ColorSwatchStep, number> = {\n 50: 0.985,\n 100: 0.967,\n 200: 0.928,\n 300: 0.872,\n 400: 0.707,\n 500: 0.551,\n 600: 0.446,\n 700: 0.373,\n 800: 0.278,\n 900: 0.21,\n 950: 0.13,\n};\n\nconst MIN_USABLE_ADJACENT_DELTA = 0.012;\nconst MIN_USABLE_LIGHTNESS_RANGE = 0.35;\n\nfunction clamp01(value: number): number {\n if (!Number.isFinite(value)) return 0;\n return Math.min(1, Math.max(0, value));\n}\n\nfunction chromaMultiplierForStep(step: ColorSwatchStep): number {\n if (step <= 200) return 0.55;\n if (step <= 400) return 0.75;\n if (step === COLOR_SWATCH_BASE_STEP) return 1;\n if (step <= 700) return 0.9;\n return 0.8;\n}\n\nexport function generateColorSwatch(baseColor: HexColor): {\n swatch: ColorSwatch;\n diagnostics: ColorSwatchDiagnostics;\n} {\n const baseOklch = parseHexToOklch(baseColor);\n const baseOffset = baseOklch.l - BASELINE_LIGHTNESS_BY_STEP[COLOR_SWATCH_BASE_STEP];\n\n const warnings: ColorSwatchWarning[] = [];\n const swatch = {} as ColorSwatch;\n const lightnessEntries: number[] = [];\n const adjacentDeltas: number[] = [];\n\n for (const step of COLOR_SWATCH_STEPS) {\n if (step === COLOR_SWATCH_BASE_STEP) {\n swatch[step] = baseColor;\n lightnessEntries.push(baseOklch.l);\n continue;\n }\n\n const targetL = clamp01(BASELINE_LIGHTNESS_BY_STEP[step] + baseOffset);\n const targetC = clamp01(baseOklch.c * chromaMultiplierForStep(step));\n const target = { ...baseOklch, l: targetL, c: targetC };\n const hex = oklchToHex(target);\n swatch[step] = hex;\n\n const candidateOklch = parseHexToOklch(hex);\n lightnessEntries.push(candidateOklch.l);\n\n const deltaEFromBase = deltaEoklch(baseOklch, candidateOklch);\n if (deltaEFromBase < 0.02) {\n warnings.push({\n code: 'weak_step',\n step,\n deltaEFromBase,\n message: `Swatch step ${step} is visually close to the base color.`,\n });\n }\n }\n\n for (let index = 1; index < COLOR_SWATCH_STEPS.length; index++) {\n const previousStep = COLOR_SWATCH_STEPS[index - 1];\n const currentStep = COLOR_SWATCH_STEPS[index];\n if (previousStep === undefined || currentStep === undefined) continue;\n\n const previous = parseHexToOklch(swatch[previousStep]);\n const current = parseHexToOklch(swatch[currentStep]);\n const adjacentDelta = deltaEoklch(previous, current);\n adjacentDeltas.push(adjacentDelta);\n\n if (adjacentDelta < MIN_USABLE_ADJACENT_DELTA) {\n warnings.push({\n code: 'weak_adjacent_delta',\n step: currentStep,\n deltaEFromBase: adjacentDelta,\n message: `Swatch step ${currentStep} is visually close to adjacent step ${previousStep}.`,\n });\n }\n }\n\n const minAdjacentDelta = Math.min(...adjacentDeltas);\n const maxAdjacentDelta = Math.max(...adjacentDeltas);\n const minLightness = Math.min(...lightnessEntries);\n const maxLightness = Math.max(...lightnessEntries);\n const lightnessRange = maxLightness - minLightness;\n\n if (lightnessRange < MIN_USABLE_LIGHTNESS_RANGE) {\n warnings.push({\n code: 'limited_lightness_range',\n message: 'Generated swatch has a limited lightness range.',\n });\n }\n\n return {\n swatch,\n diagnostics: {\n isUsable:\n minAdjacentDelta >= MIN_USABLE_ADJACENT_DELTA &&\n lightnessRange >= MIN_USABLE_LIGHTNESS_RANGE,\n warnings,\n minAdjacentDelta,\n maxAdjacentDelta,\n lightnessRange: {\n min: minLightness,\n max: maxLightness,\n },\n },\n };\n}\n"]}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { type GeneratedHarmonyRoleColors } from './harmony';
|
|
2
|
+
import type { HexColor } from './hex';
|
|
3
|
+
import { type GeneratedNeutralMetadata } from './neutral';
|
|
4
|
+
import { type ColorSwatch } from './swatches';
|
|
5
|
+
export interface ThemeModeColorInput {
|
|
6
|
+
primaryColor: string;
|
|
7
|
+
harmony: GeneratedHarmonyRoleColors['harmony'];
|
|
8
|
+
}
|
|
9
|
+
export interface ThemeColorInput {
|
|
10
|
+
light: ThemeModeColorInput;
|
|
11
|
+
dark: ThemeModeColorInput;
|
|
12
|
+
}
|
|
13
|
+
export interface GeneratedThemeSwatches {
|
|
14
|
+
primary: ColorSwatch;
|
|
15
|
+
secondary?: ColorSwatch;
|
|
16
|
+
tertiary?: ColorSwatch;
|
|
17
|
+
quaternary?: ColorSwatch;
|
|
18
|
+
neutral: ColorSwatch;
|
|
19
|
+
}
|
|
20
|
+
export interface GeneratedThemeModeColors {
|
|
21
|
+
harmonyRoleColors: GeneratedHarmonyRoleColors;
|
|
22
|
+
swatches: GeneratedThemeSwatches;
|
|
23
|
+
neutral: GeneratedNeutralMetadata;
|
|
24
|
+
}
|
|
25
|
+
export declare function getThemeModePrimaryHex(mode: ThemeModeColorInput): HexColor;
|
|
26
|
+
export declare function generateThemeModeColors(mode: ThemeModeColorInput): GeneratedThemeModeColors;
|
|
27
|
+
export declare function generateThemeColors(theme: ThemeColorInput): {
|
|
28
|
+
light: GeneratedThemeModeColors;
|
|
29
|
+
dark: GeneratedThemeModeColors;
|
|
30
|
+
};
|
|
31
|
+
//# sourceMappingURL=theme-colors.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"theme-colors.d.ts","sourceRoot":"","sources":["../src/theme-colors.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,0BAA0B,EAA6B,MAAM,WAAW,CAAC;AACvF,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAC;AAEtC,OAAO,EAAE,KAAK,wBAAwB,EAAyB,MAAM,WAAW,CAAC;AACjF,OAAO,EAAE,KAAK,WAAW,EAAuB,MAAM,YAAY,CAAC;AAEnE,MAAM,WAAW,mBAAmB;IAClC,YAAY,EAAE,MAAM,CAAC;IACrB,OAAO,EAAE,0BAA0B,CAAC,SAAS,CAAC,CAAC;CAChD;AAED,MAAM,WAAW,eAAe;IAC9B,KAAK,EAAE,mBAAmB,CAAC;IAC3B,IAAI,EAAE,mBAAmB,CAAC;CAC3B;AAED,MAAM,WAAW,sBAAsB;IACrC,OAAO,EAAE,WAAW,CAAC;IACrB,SAAS,CAAC,EAAE,WAAW,CAAC;IACxB,QAAQ,CAAC,EAAE,WAAW,CAAC;IACvB,UAAU,CAAC,EAAE,WAAW,CAAC;IACzB,OAAO,EAAE,WAAW,CAAC;CACtB;AAED,MAAM,WAAW,wBAAwB;IACvC,iBAAiB,EAAE,0BAA0B,CAAC;IAC9C,QAAQ,EAAE,sBAAsB,CAAC;IACjC,OAAO,EAAE,wBAAwB,CAAC;CACnC;AAED,wBAAgB,sBAAsB,CAAC,IAAI,EAAE,mBAAmB,GAAG,QAAQ,CAE1E;AAED,wBAAgB,uBAAuB,CAAC,IAAI,EAAE,mBAAmB,GAAG,wBAAwB,CAgC3F;AAED,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,eAAe,GAAG;IAC3D,KAAK,EAAE,wBAAwB,CAAC;IAChC,IAAI,EAAE,wBAAwB,CAAC;CAChC,CAKA"}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { generateHarmonyRoleColors } from './harmony';
|
|
2
|
+
import { parseHexColorOrThrow } from './hex';
|
|
3
|
+
import { generateNeutralSwatch } from './neutral';
|
|
4
|
+
import { generateColorSwatch } from './swatches';
|
|
5
|
+
export function getThemeModePrimaryHex(mode) {
|
|
6
|
+
return parseHexColorOrThrow(mode.primaryColor);
|
|
7
|
+
}
|
|
8
|
+
export function generateThemeModeColors(mode) {
|
|
9
|
+
const primaryHex = getThemeModePrimaryHex(mode);
|
|
10
|
+
const harmonyRoleColors = generateHarmonyRoleColors(primaryHex, mode.harmony);
|
|
11
|
+
const primarySwatch = generateColorSwatch(harmonyRoleColors.primary.hex).swatch;
|
|
12
|
+
const secondarySwatch = harmonyRoleColors.secondary
|
|
13
|
+
? generateColorSwatch(harmonyRoleColors.secondary.hex).swatch
|
|
14
|
+
: undefined;
|
|
15
|
+
const tertiarySwatch = harmonyRoleColors.tertiary
|
|
16
|
+
? generateColorSwatch(harmonyRoleColors.tertiary.hex).swatch
|
|
17
|
+
: undefined;
|
|
18
|
+
const quaternarySwatch = harmonyRoleColors.quaternary
|
|
19
|
+
? generateColorSwatch(harmonyRoleColors.quaternary.hex).swatch
|
|
20
|
+
: undefined;
|
|
21
|
+
const neutral = generateNeutralSwatch(harmonyRoleColors);
|
|
22
|
+
const swatches = {
|
|
23
|
+
primary: primarySwatch,
|
|
24
|
+
neutral: neutral.neutral,
|
|
25
|
+
...(secondarySwatch ? { secondary: secondarySwatch } : {}),
|
|
26
|
+
...(tertiarySwatch ? { tertiary: tertiarySwatch } : {}),
|
|
27
|
+
...(quaternarySwatch ? { quaternary: quaternarySwatch } : {}),
|
|
28
|
+
};
|
|
29
|
+
return {
|
|
30
|
+
harmonyRoleColors,
|
|
31
|
+
swatches,
|
|
32
|
+
neutral: {
|
|
33
|
+
neutralKeyColor: neutral.neutralKeyColor,
|
|
34
|
+
diagnostics: neutral.diagnostics,
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
export function generateThemeColors(theme) {
|
|
39
|
+
return {
|
|
40
|
+
light: generateThemeModeColors(theme.light),
|
|
41
|
+
dark: generateThemeModeColors(theme.dark),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
//# sourceMappingURL=theme-colors.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"theme-colors.js","sourceRoot":"","sources":["../src/theme-colors.ts"],"names":[],"mappings":"AAAA,OAAO,EAAmC,yBAAyB,EAAE,MAAM,WAAW,CAAC;AAEvF,OAAO,EAAE,oBAAoB,EAAE,MAAM,OAAO,CAAC;AAC7C,OAAO,EAAiC,qBAAqB,EAAE,MAAM,WAAW,CAAC;AACjF,OAAO,EAAoB,mBAAmB,EAAE,MAAM,YAAY,CAAC;AA0BnE,MAAM,UAAU,sBAAsB,CAAC,IAAyB;IAC9D,OAAO,oBAAoB,CAAC,IAAI,CAAC,YAAY,CAAC,CAAC;AACjD,CAAC;AAED,MAAM,UAAU,uBAAuB,CAAC,IAAyB;IAC/D,MAAM,UAAU,GAAG,sBAAsB,CAAC,IAAI,CAAC,CAAC;IAChD,MAAM,iBAAiB,GAAG,yBAAyB,CAAC,UAAU,EAAE,IAAI,CAAC,OAAO,CAAC,CAAC;IAE9E,MAAM,aAAa,GAAG,mBAAmB,CAAC,iBAAiB,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,MAAM,CAAC;IAChF,MAAM,eAAe,GAAG,iBAAiB,CAAC,SAAS;QACjD,CAAC,CAAC,mBAAmB,CAAC,iBAAiB,CAAC,SAAS,CAAC,GAAG,CAAC,CAAC,MAAM;QAC7D,CAAC,CAAC,SAAS,CAAC;IACd,MAAM,cAAc,GAAG,iBAAiB,CAAC,QAAQ;QAC/C,CAAC,CAAC,mBAAmB,CAAC,iBAAiB,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC,MAAM;QAC5D,CAAC,CAAC,SAAS,CAAC;IACd,MAAM,gBAAgB,GAAG,iBAAiB,CAAC,UAAU;QACnD,CAAC,CAAC,mBAAmB,CAAC,iBAAiB,CAAC,UAAU,CAAC,GAAG,CAAC,CAAC,MAAM;QAC9D,CAAC,CAAC,SAAS,CAAC;IAEd,MAAM,OAAO,GAAG,qBAAqB,CAAC,iBAAiB,CAAC,CAAC;IACzD,MAAM,QAAQ,GAA2B;QACvC,OAAO,EAAE,aAAa;QACtB,OAAO,EAAE,OAAO,CAAC,OAAO;QACxB,GAAG,CAAC,eAAe,CAAC,CAAC,CAAC,EAAE,SAAS,EAAE,eAAe,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QAC1D,GAAG,CAAC,cAAc,CAAC,CAAC,CAAC,EAAE,QAAQ,EAAE,cAAc,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QACvD,GAAG,CAAC,gBAAgB,CAAC,CAAC,CAAC,EAAE,UAAU,EAAE,gBAAgB,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;KAC9D,CAAC;IAEF,OAAO;QACL,iBAAiB;QACjB,QAAQ;QACR,OAAO,EAAE;YACP,eAAe,EAAE,OAAO,CAAC,eAAe;YACxC,WAAW,EAAE,OAAO,CAAC,WAAW;SACjC;KACF,CAAC;AACJ,CAAC;AAED,MAAM,UAAU,mBAAmB,CAAC,KAAsB;IAIxD,OAAO;QACL,KAAK,EAAE,uBAAuB,CAAC,KAAK,CAAC,KAAK,CAAC;QAC3C,IAAI,EAAE,uBAAuB,CAAC,KAAK,CAAC,IAAI,CAAC;KAC1C,CAAC;AACJ,CAAC","sourcesContent":["import { type GeneratedHarmonyRoleColors, generateHarmonyRoleColors } from './harmony';\nimport type { HexColor } from './hex';\nimport { parseHexColorOrThrow } from './hex';\nimport { type GeneratedNeutralMetadata, generateNeutralSwatch } from './neutral';\nimport { type ColorSwatch, generateColorSwatch } from './swatches';\n\nexport interface ThemeModeColorInput {\n primaryColor: string;\n harmony: GeneratedHarmonyRoleColors['harmony'];\n}\n\nexport interface ThemeColorInput {\n light: ThemeModeColorInput;\n dark: ThemeModeColorInput;\n}\n\nexport interface GeneratedThemeSwatches {\n primary: ColorSwatch;\n secondary?: ColorSwatch;\n tertiary?: ColorSwatch;\n quaternary?: ColorSwatch;\n neutral: ColorSwatch;\n}\n\nexport interface GeneratedThemeModeColors {\n harmonyRoleColors: GeneratedHarmonyRoleColors;\n swatches: GeneratedThemeSwatches;\n neutral: GeneratedNeutralMetadata;\n}\n\nexport function getThemeModePrimaryHex(mode: ThemeModeColorInput): HexColor {\n return parseHexColorOrThrow(mode.primaryColor);\n}\n\nexport function generateThemeModeColors(mode: ThemeModeColorInput): GeneratedThemeModeColors {\n const primaryHex = getThemeModePrimaryHex(mode);\n const harmonyRoleColors = generateHarmonyRoleColors(primaryHex, mode.harmony);\n\n const primarySwatch = generateColorSwatch(harmonyRoleColors.primary.hex).swatch;\n const secondarySwatch = harmonyRoleColors.secondary\n ? generateColorSwatch(harmonyRoleColors.secondary.hex).swatch\n : undefined;\n const tertiarySwatch = harmonyRoleColors.tertiary\n ? generateColorSwatch(harmonyRoleColors.tertiary.hex).swatch\n : undefined;\n const quaternarySwatch = harmonyRoleColors.quaternary\n ? generateColorSwatch(harmonyRoleColors.quaternary.hex).swatch\n : undefined;\n\n const neutral = generateNeutralSwatch(harmonyRoleColors);\n const swatches: GeneratedThemeSwatches = {\n primary: primarySwatch,\n neutral: neutral.neutral,\n ...(secondarySwatch ? { secondary: secondarySwatch } : {}),\n ...(tertiarySwatch ? { tertiary: tertiarySwatch } : {}),\n ...(quaternarySwatch ? { quaternary: quaternarySwatch } : {}),\n };\n\n return {\n harmonyRoleColors,\n swatches,\n neutral: {\n neutralKeyColor: neutral.neutralKeyColor,\n diagnostics: neutral.diagnostics,\n },\n };\n}\n\nexport function generateThemeColors(theme: ThemeColorInput): {\n light: GeneratedThemeModeColors;\n dark: GeneratedThemeModeColors;\n} {\n return {\n light: generateThemeModeColors(theme.light),\n dark: generateThemeModeColors(theme.dark),\n };\n}\n"]}
|
package/package.json
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ankhorage/color-theory",
|
|
3
|
+
"type": "module",
|
|
4
|
+
"version": "0.0.1",
|
|
5
|
+
"description": "Standalone color theory, harmony, swatch, contrast, and theme color generation utilities.",
|
|
6
|
+
"homepage": "https://github.com/ankhorage/color-theory#readme",
|
|
7
|
+
"bugs": {
|
|
8
|
+
"url": "https://github.com/ankhorage/color-theory/issues"
|
|
9
|
+
},
|
|
10
|
+
"license": "MIT",
|
|
11
|
+
"main": "./dist/index.js",
|
|
12
|
+
"sideEffects": false,
|
|
13
|
+
"types": "./dist/index.d.ts",
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "git+https://github.com/ankhorage/color-theory.git"
|
|
17
|
+
},
|
|
18
|
+
"keywords": [
|
|
19
|
+
"color",
|
|
20
|
+
"color-theory",
|
|
21
|
+
"culori",
|
|
22
|
+
"oklch",
|
|
23
|
+
"palette",
|
|
24
|
+
"swatches",
|
|
25
|
+
"theme"
|
|
26
|
+
],
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"culori": "^4.0.2"
|
|
29
|
+
},
|
|
30
|
+
"files": [
|
|
31
|
+
"dist",
|
|
32
|
+
"src",
|
|
33
|
+
"package.json",
|
|
34
|
+
"README.md",
|
|
35
|
+
"CHANGELOG.md",
|
|
36
|
+
"LICENSE"
|
|
37
|
+
],
|
|
38
|
+
"exports": {
|
|
39
|
+
".": {
|
|
40
|
+
"react-native": "./src/index.ts",
|
|
41
|
+
"browser": "./src/index.ts",
|
|
42
|
+
"types": "./dist/index.d.ts",
|
|
43
|
+
"import": "./dist/index.js",
|
|
44
|
+
"default": "./dist/index.js"
|
|
45
|
+
},
|
|
46
|
+
"./culori": {
|
|
47
|
+
"types": "./dist/culori.d.ts",
|
|
48
|
+
"import": "./dist/culori.js",
|
|
49
|
+
"default": "./dist/culori.js"
|
|
50
|
+
},
|
|
51
|
+
"./culori/fn": {
|
|
52
|
+
"types": "./dist/culori-fn.d.ts",
|
|
53
|
+
"import": "./dist/culori-fn.js",
|
|
54
|
+
"default": "./dist/culori-fn.js"
|
|
55
|
+
},
|
|
56
|
+
"./package.json": "./package.json"
|
|
57
|
+
},
|
|
58
|
+
"publishConfig": {
|
|
59
|
+
"access": "public"
|
|
60
|
+
},
|
|
61
|
+
"scripts": {
|
|
62
|
+
"build": "rm -rf dist tsconfig.tsbuildinfo && bun x tsc -p tsconfig.json",
|
|
63
|
+
"changeset": "changeset",
|
|
64
|
+
"changeset:status": "changeset status --since=origin/main",
|
|
65
|
+
"knip": "knip",
|
|
66
|
+
"lint": "eslint . --max-warnings=0",
|
|
67
|
+
"lint:fix": "eslint . --fix --max-warnings=0",
|
|
68
|
+
"format": "prettier --write .",
|
|
69
|
+
"paradox": "bunx @ankhorage/paradox",
|
|
70
|
+
"prepack": "bun run build",
|
|
71
|
+
"test": "bun test src",
|
|
72
|
+
"typecheck": "bun x tsc --noEmit -p tsconfig.json",
|
|
73
|
+
"version-packages": "changeset version"
|
|
74
|
+
},
|
|
75
|
+
"devDependencies": {
|
|
76
|
+
"@ankhorage/devtools": "^1.0.1",
|
|
77
|
+
"@ankhorage/paradox": "^0.0.3",
|
|
78
|
+
"@changesets/cli": "^2.31.0",
|
|
79
|
+
"@types/bun": "^1.3.13",
|
|
80
|
+
"@types/culori": "^4.0.1",
|
|
81
|
+
"@types/node": "^25.6.0",
|
|
82
|
+
"knip": "^5.88.1",
|
|
83
|
+
"typescript": "^5.9.3"
|
|
84
|
+
},
|
|
85
|
+
"packageManager": "bun@1.3.13"
|
|
86
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
COLOR_HARMONIES,
|
|
5
|
+
COLOR_SWATCH_BASE_STEP,
|
|
6
|
+
COLOR_SWATCH_STEPS,
|
|
7
|
+
generateColorSwatch,
|
|
8
|
+
generateHarmonyRoleColors,
|
|
9
|
+
generateNeutralSwatch,
|
|
10
|
+
generateThemeModeColors,
|
|
11
|
+
getReadableForeground,
|
|
12
|
+
parseHexColor,
|
|
13
|
+
parseHexColorOrThrow,
|
|
14
|
+
} from './index';
|
|
15
|
+
|
|
16
|
+
describe('color theory', () => {
|
|
17
|
+
it('parses valid hex colors and rejects invalid values', () => {
|
|
18
|
+
expect(parseHexColor('#3366ff')).toBe('#3366ff');
|
|
19
|
+
expect(parseHexColor('#3366FF')).toBe('#3366FF');
|
|
20
|
+
expect(parseHexColor('3366ff')).toBeNull();
|
|
21
|
+
expect(() => parseHexColorOrThrow('#12345')).toThrow();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('generates harmony role colors with expected role counts', () => {
|
|
25
|
+
const primary = parseHexColorOrThrow('#3366ff');
|
|
26
|
+
const expectedRoleCount: Record<(typeof COLOR_HARMONIES)[number], number> = {
|
|
27
|
+
monochromatic: 1,
|
|
28
|
+
complementary: 2,
|
|
29
|
+
analogous: 3,
|
|
30
|
+
splitComplementary: 3,
|
|
31
|
+
triadic: 3,
|
|
32
|
+
tetradic: 4,
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
for (const harmony of COLOR_HARMONIES) {
|
|
36
|
+
const roleColors = generateHarmonyRoleColors(primary, harmony);
|
|
37
|
+
expect(roleColors.colors.length).toBe(expectedRoleCount[harmony]);
|
|
38
|
+
expect(roleColors.primary.hex).toBe(primary);
|
|
39
|
+
expect(roleColors.primary.source).toBe('selected');
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('generates 11-step swatches that preserve the base color at step 500', () => {
|
|
44
|
+
const base = parseHexColorOrThrow('#3366ff');
|
|
45
|
+
const { swatch, diagnostics } = generateColorSwatch(base);
|
|
46
|
+
|
|
47
|
+
expect(Object.keys(swatch).length).toBe(COLOR_SWATCH_STEPS.length);
|
|
48
|
+
expect(swatch[COLOR_SWATCH_BASE_STEP]).toBe(base);
|
|
49
|
+
expect(diagnostics.lightnessRange.max).toBeGreaterThanOrEqual(diagnostics.lightnessRange.min);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('generates required neutral swatches from harmony role colors', () => {
|
|
53
|
+
const primary = parseHexColorOrThrow('#3366ff');
|
|
54
|
+
const roleColors = generateHarmonyRoleColors(primary, 'triadic');
|
|
55
|
+
const neutral = generateNeutralSwatch(roleColors);
|
|
56
|
+
|
|
57
|
+
expect(neutral.neutral[COLOR_SWATCH_BASE_STEP]).toBe(neutral.neutralKeyColor);
|
|
58
|
+
expect(neutral.diagnostics.lightnessRange.max).toBeGreaterThanOrEqual(
|
|
59
|
+
neutral.diagnostics.lightnessRange.min,
|
|
60
|
+
);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('generates theme mode colors with one canonical swatch container', () => {
|
|
64
|
+
const generated = generateThemeModeColors({ primaryColor: '#3366ff', harmony: 'tetradic' });
|
|
65
|
+
|
|
66
|
+
expect(generated.harmonyRoleColors.primary.hex).toBe('#3366ff');
|
|
67
|
+
expect(generated.swatches.primary[COLOR_SWATCH_BASE_STEP]).toBe('#3366ff');
|
|
68
|
+
expect(generated.swatches.secondary?.[COLOR_SWATCH_BASE_STEP]).toBe(
|
|
69
|
+
generated.harmonyRoleColors.secondary?.hex,
|
|
70
|
+
);
|
|
71
|
+
expect(generated.swatches.tertiary?.[COLOR_SWATCH_BASE_STEP]).toBe(
|
|
72
|
+
generated.harmonyRoleColors.tertiary?.hex,
|
|
73
|
+
);
|
|
74
|
+
expect(generated.swatches.quaternary?.[COLOR_SWATCH_BASE_STEP]).toBe(
|
|
75
|
+
generated.harmonyRoleColors.quaternary?.hex,
|
|
76
|
+
);
|
|
77
|
+
expect(generated.swatches.neutral[COLOR_SWATCH_BASE_STEP]).toBe(
|
|
78
|
+
generated.neutral.neutralKeyColor,
|
|
79
|
+
);
|
|
80
|
+
expect(Object.hasOwn(generated, 'primary')).toBe(false);
|
|
81
|
+
expect(Object.hasOwn(generated, 'secondary')).toBe(false);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('returns black or white readable foreground colors', () => {
|
|
85
|
+
const white = parseHexColorOrThrow('#FFFFFF');
|
|
86
|
+
const foreground = getReadableForeground(white);
|
|
87
|
+
|
|
88
|
+
expect(['#000000', '#FFFFFF']).toContain(foreground.foreground);
|
|
89
|
+
expect(foreground.contrast).toBeGreaterThan(0);
|
|
90
|
+
});
|
|
91
|
+
});
|
package/src/contrast.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { HexColor } from './hex';
|
|
2
|
+
import { parseHexColorOrThrow } from './hex';
|
|
3
|
+
import { contrastRatio } from './internal-culori';
|
|
4
|
+
|
|
5
|
+
export interface ReadableForegroundResult {
|
|
6
|
+
foreground: HexColor;
|
|
7
|
+
contrast: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const BLACK = parseHexColorOrThrow('#000000');
|
|
11
|
+
const WHITE = parseHexColorOrThrow('#FFFFFF');
|
|
12
|
+
|
|
13
|
+
export function getReadableForeground(background: HexColor): ReadableForegroundResult {
|
|
14
|
+
const contrastOnBlack = contrastRatio(background, BLACK);
|
|
15
|
+
const contrastOnWhite = contrastRatio(background, WHITE);
|
|
16
|
+
|
|
17
|
+
if (contrastOnBlack >= contrastOnWhite) {
|
|
18
|
+
return { foreground: BLACK, contrast: contrastOnBlack };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return { foreground: WHITE, contrast: contrastOnWhite };
|
|
22
|
+
}
|
package/src/culori-fn.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from 'culori/fn';
|
package/src/culori.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from 'culori';
|
package/src/harmony.ts
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import type { HexColor } from './hex';
|
|
2
|
+
import { normalizeHueDegrees, oklchToHex, parseHexToOklch } from './internal-culori';
|
|
3
|
+
|
|
4
|
+
export const COLOR_HARMONIES = [
|
|
5
|
+
'monochromatic',
|
|
6
|
+
'analogous',
|
|
7
|
+
'complementary',
|
|
8
|
+
'triadic',
|
|
9
|
+
'tetradic',
|
|
10
|
+
'splitComplementary',
|
|
11
|
+
] as const;
|
|
12
|
+
|
|
13
|
+
export type ColorHarmony = (typeof COLOR_HARMONIES)[number];
|
|
14
|
+
|
|
15
|
+
export type GeneratedColorRole = 'primary' | 'secondary' | 'tertiary' | 'quaternary';
|
|
16
|
+
|
|
17
|
+
export interface GeneratedHarmonyRoleColor {
|
|
18
|
+
role: GeneratedColorRole;
|
|
19
|
+
hex: HexColor;
|
|
20
|
+
hueDegrees: number;
|
|
21
|
+
source: 'selected' | 'generated';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface GeneratedHarmonyRoleColors {
|
|
25
|
+
harmony: ColorHarmony;
|
|
26
|
+
colors: readonly GeneratedHarmonyRoleColor[];
|
|
27
|
+
primary: GeneratedHarmonyRoleColor;
|
|
28
|
+
secondary?: GeneratedHarmonyRoleColor;
|
|
29
|
+
tertiary?: GeneratedHarmonyRoleColor;
|
|
30
|
+
quaternary?: GeneratedHarmonyRoleColor;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const ROLE_ORDER_BY_HARMONY: Record<ColorHarmony, readonly GeneratedColorRole[]> = {
|
|
34
|
+
monochromatic: ['primary'],
|
|
35
|
+
complementary: ['primary', 'secondary'],
|
|
36
|
+
analogous: ['primary', 'secondary', 'tertiary'],
|
|
37
|
+
splitComplementary: ['primary', 'secondary', 'tertiary'],
|
|
38
|
+
triadic: ['primary', 'secondary', 'tertiary'],
|
|
39
|
+
tetradic: ['primary', 'secondary', 'tertiary', 'quaternary'],
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const OFFSETS_BY_HARMONY: Record<ColorHarmony, readonly number[]> = {
|
|
43
|
+
monochromatic: [0],
|
|
44
|
+
analogous: [0, -30, 30],
|
|
45
|
+
complementary: [0, 180],
|
|
46
|
+
splitComplementary: [0, 150, 210],
|
|
47
|
+
triadic: [0, 120, 240],
|
|
48
|
+
tetradic: [0, 90, 180, 270],
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export function generateHarmonyRoleColors(
|
|
52
|
+
primaryColor: HexColor,
|
|
53
|
+
harmony: ColorHarmony,
|
|
54
|
+
): GeneratedHarmonyRoleColors {
|
|
55
|
+
const base = parseHexToOklch(primaryColor);
|
|
56
|
+
const roles = ROLE_ORDER_BY_HARMONY[harmony];
|
|
57
|
+
const offsets = OFFSETS_BY_HARMONY[harmony];
|
|
58
|
+
const baseHue = normalizeHueDegrees(base.h);
|
|
59
|
+
|
|
60
|
+
const colors: GeneratedHarmonyRoleColor[] = [];
|
|
61
|
+
|
|
62
|
+
for (let index = 0; index < roles.length; index++) {
|
|
63
|
+
const role = roles[index];
|
|
64
|
+
if (!role) continue;
|
|
65
|
+
|
|
66
|
+
const hueDegrees = normalizeHueDegrees(baseHue + (offsets[index] ?? 0));
|
|
67
|
+
colors.push({
|
|
68
|
+
role,
|
|
69
|
+
hex: role === 'primary' ? primaryColor : oklchToHex({ ...base, h: hueDegrees }),
|
|
70
|
+
hueDegrees,
|
|
71
|
+
source: role === 'primary' ? 'selected' : 'generated',
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const primary = colors.find((color) => color.role === 'primary');
|
|
76
|
+
if (!primary) {
|
|
77
|
+
throw new Error('[color-theory] Expected generated harmony role colors to include primary.');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const secondary = colors.find((color) => color.role === 'secondary');
|
|
81
|
+
const tertiary = colors.find((color) => color.role === 'tertiary');
|
|
82
|
+
const quaternary = colors.find((color) => color.role === 'quaternary');
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
harmony,
|
|
86
|
+
colors,
|
|
87
|
+
primary,
|
|
88
|
+
...(secondary ? { secondary } : {}),
|
|
89
|
+
...(tertiary ? { tertiary } : {}),
|
|
90
|
+
...(quaternary ? { quaternary } : {}),
|
|
91
|
+
};
|
|
92
|
+
}
|
package/src/hex.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export type HexColor = string & { readonly __hexColorBrand: unique symbol };
|
|
2
|
+
|
|
3
|
+
const HEX6_CASE_INSENSITIVE_REGEX = /^#[0-9a-fA-F]{6}$/;
|
|
4
|
+
|
|
5
|
+
export function isHexColor(value: string): value is HexColor {
|
|
6
|
+
return HEX6_CASE_INSENSITIVE_REGEX.test(value);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function parseHexColor(value: string): HexColor | null {
|
|
10
|
+
if (!HEX6_CASE_INSENSITIVE_REGEX.test(value)) return null;
|
|
11
|
+
return value as HexColor;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function parseHexColorOrThrow(value: string): HexColor {
|
|
15
|
+
const parsed = parseHexColor(value);
|
|
16
|
+
if (!parsed) {
|
|
17
|
+
throw new Error(`Invalid hex color (expected #RRGGBB or #rrggbb): ${JSON.stringify(value)}`);
|
|
18
|
+
}
|
|
19
|
+
return parsed;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const normalizeHexColor = parseHexColor;
|
|
23
|
+
export const normalizeHexColorOrThrow = parseHexColorOrThrow;
|
|
24
|
+
|
|
25
|
+
export function assertHexColor(value: string): asserts value is HexColor {
|
|
26
|
+
if (!isHexColor(value)) {
|
|
27
|
+
throw new Error(`Invalid hex color (expected #RRGGBB or #rrggbb): ${JSON.stringify(value)}`);
|
|
28
|
+
}
|
|
29
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import {
|
|
2
|
+
converter,
|
|
3
|
+
differenceEuclidean,
|
|
4
|
+
formatHex,
|
|
5
|
+
toGamut,
|
|
6
|
+
wcagContrast,
|
|
7
|
+
wcagLuminance,
|
|
8
|
+
} from 'culori';
|
|
9
|
+
|
|
10
|
+
import type { HexColor } from './hex';
|
|
11
|
+
import { parseHexColorOrThrow } from './hex';
|
|
12
|
+
|
|
13
|
+
export interface OklchColor {
|
|
14
|
+
mode: 'oklch';
|
|
15
|
+
l: number;
|
|
16
|
+
c: number;
|
|
17
|
+
h: number;
|
|
18
|
+
alpha?: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface RgbColor {
|
|
22
|
+
mode: 'rgb';
|
|
23
|
+
r: number;
|
|
24
|
+
g: number;
|
|
25
|
+
b: number;
|
|
26
|
+
alpha?: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const toOklch = converter('oklch');
|
|
30
|
+
const toRgb = converter('rgb');
|
|
31
|
+
const toRgbGamut = toGamut('rgb', 'oklch');
|
|
32
|
+
const deltaE = differenceEuclidean('oklch');
|
|
33
|
+
|
|
34
|
+
function clampNumber(value: number, min: number, max: number): number {
|
|
35
|
+
if (!Number.isFinite(value)) return min;
|
|
36
|
+
return Math.min(max, Math.max(min, value));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function normalizeHueDegrees(hueDegrees: number): number {
|
|
40
|
+
const hue = ((hueDegrees % 360) + 360) % 360;
|
|
41
|
+
return hue === 360 ? 0 : hue;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function parseHexToOklch(hex: HexColor): OklchColor {
|
|
45
|
+
const color = toOklch(hex) as unknown as OklchColor | undefined;
|
|
46
|
+
if (color?.mode !== 'oklch') {
|
|
47
|
+
throw new Error(`Failed to convert hex to OKLCH: ${hex}`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const l = clampNumber(color.l, 0, 1);
|
|
51
|
+
const c = clampNumber(color.c, 0, 0.4);
|
|
52
|
+
const h = normalizeHueDegrees(Number.isFinite(color.h) ? color.h : 0);
|
|
53
|
+
|
|
54
|
+
return { mode: 'oklch', l, c, h, alpha: color.alpha };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function oklchToHex(oklch: OklchColor): HexColor {
|
|
58
|
+
const gamutRgb = toRgbGamut(oklch) as unknown as RgbColor | undefined;
|
|
59
|
+
const hex = formatHex(gamutRgb ?? oklch);
|
|
60
|
+
return parseHexColorOrThrow(hex);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function toOklchColor(input: string | object): OklchColor {
|
|
64
|
+
const color = toOklch(input as never) as unknown as OklchColor | undefined;
|
|
65
|
+
if (color?.mode !== 'oklch') {
|
|
66
|
+
throw new Error(`Failed to parse/convert to OKLCH: ${JSON.stringify(input)}`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const l = clampNumber(color.l, 0, 1);
|
|
70
|
+
const c = clampNumber(color.c, 0, 0.4);
|
|
71
|
+
const h = normalizeHueDegrees(Number.isFinite(color.h) ? color.h : 0);
|
|
72
|
+
|
|
73
|
+
return { mode: 'oklch', l, c, h, alpha: color.alpha };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function toHexColor(input: string | object): HexColor {
|
|
77
|
+
const gamutRgb = toRgbGamut(input as never) as unknown as RgbColor | undefined;
|
|
78
|
+
const hex = formatHex(gamutRgb ?? (input as never));
|
|
79
|
+
return parseHexColorOrThrow(hex);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function deltaEoklch(a: OklchColor, b: OklchColor): number {
|
|
83
|
+
return deltaE(a, b);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function contrastRatio(colorA: string | object, colorB: string | object): number {
|
|
87
|
+
return wcagContrast(colorA as never, colorB as never);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function relativeLuminance(color: string | object): number {
|
|
91
|
+
return wcagLuminance(color as never);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function rgbFrom(input: string | object): RgbColor {
|
|
95
|
+
const rgb = toRgb(input as never) as unknown as RgbColor | undefined;
|
|
96
|
+
if (rgb?.mode !== 'rgb') {
|
|
97
|
+
throw new Error(`Failed to convert to rgb: ${JSON.stringify(input)}`);
|
|
98
|
+
}
|
|
99
|
+
return rgb;
|
|
100
|
+
}
|