@ankhorage/color-theory 0.0.2 → 0.0.3
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/CHANGELOG.md +6 -0
- package/package.json +3 -4
- package/src/color-theory.test.ts +0 -91
- package/src/contrast.ts +0 -25
- package/src/culori-fn.ts +0 -1
- package/src/culori.ts +0 -1
- package/src/harmony.ts +0 -95
- package/src/hex.ts +0 -38
- package/src/index.ts +0 -7
- package/src/internal-culori.ts +0 -79
- package/src/neutral.ts +0 -77
- package/src/semantics.ts +0 -60
- package/src/swatches.ts +0 -160
- package/src/theme-colors.ts +0 -86
package/CHANGELOG.md
CHANGED
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ankhorage/color-theory",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.0.
|
|
4
|
+
"version": "0.0.3",
|
|
5
5
|
"description": "Standalone color theory, harmony, swatch, contrast, and theme color generation utilities.",
|
|
6
6
|
"homepage": "https://github.com/ankhorage/color-theory#readme",
|
|
7
7
|
"bugs": {
|
|
@@ -29,7 +29,6 @@
|
|
|
29
29
|
},
|
|
30
30
|
"files": [
|
|
31
31
|
"dist",
|
|
32
|
-
"src",
|
|
33
32
|
"package.json",
|
|
34
33
|
"README.md",
|
|
35
34
|
"CHANGELOG.md",
|
|
@@ -37,8 +36,8 @@
|
|
|
37
36
|
],
|
|
38
37
|
"exports": {
|
|
39
38
|
".": {
|
|
40
|
-
"react-native": "./
|
|
41
|
-
"browser": "./
|
|
39
|
+
"react-native": "./dist/index.js",
|
|
40
|
+
"browser": "./dist/index.js",
|
|
42
41
|
"types": "./dist/index.d.ts",
|
|
43
42
|
"import": "./dist/index.js",
|
|
44
43
|
"default": "./dist/index.js"
|
package/src/color-theory.test.ts
DELETED
|
@@ -1,91 +0,0 @@
|
|
|
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
DELETED
|
@@ -1,25 +0,0 @@
|
|
|
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
|
-
/***
|
|
14
|
-
Return the readable black or white foreground color with the stronger contrast against a background color.
|
|
15
|
-
*/
|
|
16
|
-
export function getReadableForeground(background: HexColor): ReadableForegroundResult {
|
|
17
|
-
const contrastOnBlack = contrastRatio(background, BLACK);
|
|
18
|
-
const contrastOnWhite = contrastRatio(background, WHITE);
|
|
19
|
-
|
|
20
|
-
if (contrastOnBlack >= contrastOnWhite) {
|
|
21
|
-
return { foreground: BLACK, contrast: contrastOnBlack };
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
return { foreground: WHITE, contrast: contrastOnWhite };
|
|
25
|
-
}
|
package/src/culori-fn.ts
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export * from 'culori/fn';
|
package/src/culori.ts
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export * from 'culori';
|
package/src/harmony.ts
DELETED
|
@@ -1,95 +0,0 @@
|
|
|
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
|
-
/***
|
|
52
|
-
Generate role-based harmony colors from a primary color and harmony strategy.
|
|
53
|
-
*/
|
|
54
|
-
export function generateHarmonyRoleColors(
|
|
55
|
-
primaryColor: HexColor,
|
|
56
|
-
harmony: ColorHarmony,
|
|
57
|
-
): GeneratedHarmonyRoleColors {
|
|
58
|
-
const base = parseHexToOklch(primaryColor);
|
|
59
|
-
const roles = ROLE_ORDER_BY_HARMONY[harmony];
|
|
60
|
-
const offsets = OFFSETS_BY_HARMONY[harmony];
|
|
61
|
-
const baseHue = normalizeHueDegrees(base.h);
|
|
62
|
-
|
|
63
|
-
const colors: GeneratedHarmonyRoleColor[] = [];
|
|
64
|
-
|
|
65
|
-
for (let index = 0; index < roles.length; index++) {
|
|
66
|
-
const role = roles[index];
|
|
67
|
-
if (!role) continue;
|
|
68
|
-
|
|
69
|
-
const hueDegrees = normalizeHueDegrees(baseHue + (offsets[index] ?? 0));
|
|
70
|
-
colors.push({
|
|
71
|
-
role,
|
|
72
|
-
hex: role === 'primary' ? primaryColor : oklchToHex({ ...base, h: hueDegrees }),
|
|
73
|
-
hueDegrees,
|
|
74
|
-
source: role === 'primary' ? 'selected' : 'generated',
|
|
75
|
-
});
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
const primary = colors.find((color) => color.role === 'primary');
|
|
79
|
-
if (!primary) {
|
|
80
|
-
throw new Error('[color-theory] Expected generated harmony role colors to include primary.');
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
const secondary = colors.find((color) => color.role === 'secondary');
|
|
84
|
-
const tertiary = colors.find((color) => color.role === 'tertiary');
|
|
85
|
-
const quaternary = colors.find((color) => color.role === 'quaternary');
|
|
86
|
-
|
|
87
|
-
return {
|
|
88
|
-
harmony,
|
|
89
|
-
colors,
|
|
90
|
-
primary,
|
|
91
|
-
...(secondary ? { secondary } : {}),
|
|
92
|
-
...(tertiary ? { tertiary } : {}),
|
|
93
|
-
...(quaternary ? { quaternary } : {}),
|
|
94
|
-
};
|
|
95
|
-
}
|
package/src/hex.ts
DELETED
|
@@ -1,38 +0,0 @@
|
|
|
1
|
-
export type HexColor = string & { readonly __hexColorBrand: unique symbol };
|
|
2
|
-
|
|
3
|
-
const HEX6_CASE_INSENSITIVE_REGEX = /^#[0-9a-fA-F]{6}$/;
|
|
4
|
-
|
|
5
|
-
/***
|
|
6
|
-
Check whether a string is a valid six-digit hex color.
|
|
7
|
-
*/
|
|
8
|
-
export function isHexColor(value: string): value is HexColor {
|
|
9
|
-
return HEX6_CASE_INSENSITIVE_REGEX.test(value);
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
/***
|
|
13
|
-
Parse a string as a six-digit hex color and return null when it is invalid.
|
|
14
|
-
*/
|
|
15
|
-
export function parseHexColor(value: string): HexColor | null {
|
|
16
|
-
if (!HEX6_CASE_INSENSITIVE_REGEX.test(value)) return null;
|
|
17
|
-
return value as HexColor;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
/***
|
|
21
|
-
Parse a string as a six-digit hex color or throw when it is invalid.
|
|
22
|
-
*/
|
|
23
|
-
export function parseHexColorOrThrow(value: string): HexColor {
|
|
24
|
-
const parsed = parseHexColor(value);
|
|
25
|
-
if (!parsed) {
|
|
26
|
-
throw new Error(`Invalid hex color (expected #RRGGBB or #rrggbb): ${JSON.stringify(value)}`);
|
|
27
|
-
}
|
|
28
|
-
return parsed;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
/***
|
|
32
|
-
Assert that a string is a valid six-digit hex color.
|
|
33
|
-
*/
|
|
34
|
-
export function assertHexColor(value: string): asserts value is HexColor {
|
|
35
|
-
if (!isHexColor(value)) {
|
|
36
|
-
throw new Error(`Invalid hex color (expected #RRGGBB or #rrggbb): ${JSON.stringify(value)}`);
|
|
37
|
-
}
|
|
38
|
-
}
|
package/src/index.ts
DELETED
package/src/internal-culori.ts
DELETED
|
@@ -1,79 +0,0 @@
|
|
|
1
|
-
import { converter, differenceEuclidean, formatHex, toGamut, wcagContrast } from 'culori';
|
|
2
|
-
|
|
3
|
-
import type { HexColor } from './hex';
|
|
4
|
-
import { parseHexColorOrThrow } from './hex';
|
|
5
|
-
|
|
6
|
-
interface OklchColor {
|
|
7
|
-
mode: 'oklch';
|
|
8
|
-
l: number;
|
|
9
|
-
c: number;
|
|
10
|
-
h: number;
|
|
11
|
-
alpha?: number;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
interface RgbColor {
|
|
15
|
-
mode: 'rgb';
|
|
16
|
-
r: number;
|
|
17
|
-
g: number;
|
|
18
|
-
b: number;
|
|
19
|
-
alpha?: number;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
const toOklch = converter('oklch');
|
|
23
|
-
const toRgbGamut = toGamut('rgb', 'oklch');
|
|
24
|
-
const deltaE = differenceEuclidean('oklch');
|
|
25
|
-
|
|
26
|
-
/***
|
|
27
|
-
Clamp a number to a finite minimum and maximum range.
|
|
28
|
-
*/
|
|
29
|
-
function clampNumber(value: number, min: number, max: number): number {
|
|
30
|
-
if (!Number.isFinite(value)) return min;
|
|
31
|
-
return Math.min(max, Math.max(min, value));
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/***
|
|
35
|
-
Normalize hue degrees into the zero-to-three-sixty range.
|
|
36
|
-
*/
|
|
37
|
-
export function normalizeHueDegrees(hueDegrees: number): number {
|
|
38
|
-
const hue = ((hueDegrees % 360) + 360) % 360;
|
|
39
|
-
return hue === 360 ? 0 : hue;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/***
|
|
43
|
-
Convert a hex color into a normalized OKLCH color object.
|
|
44
|
-
*/
|
|
45
|
-
export function parseHexToOklch(hex: HexColor): OklchColor {
|
|
46
|
-
const color = toOklch(hex) as unknown as OklchColor | undefined;
|
|
47
|
-
if (color?.mode !== 'oklch') {
|
|
48
|
-
throw new Error(`Failed to convert hex to OKLCH: ${hex}`);
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
const l = clampNumber(color.l, 0, 1);
|
|
52
|
-
const c = clampNumber(color.c, 0, 0.4);
|
|
53
|
-
const h = normalizeHueDegrees(Number.isFinite(color.h) ? color.h : 0);
|
|
54
|
-
|
|
55
|
-
return { mode: 'oklch', l, c, h, alpha: color.alpha };
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/***
|
|
59
|
-
Convert an OKLCH color object into an in-gamut hex color.
|
|
60
|
-
*/
|
|
61
|
-
export function oklchToHex(oklch: OklchColor): HexColor {
|
|
62
|
-
const gamutRgb = toRgbGamut(oklch) as unknown as RgbColor | undefined;
|
|
63
|
-
const hex = formatHex(gamutRgb ?? oklch);
|
|
64
|
-
return parseHexColorOrThrow(hex);
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/***
|
|
68
|
-
Calculate the perceptual OKLCH distance between two OKLCH colors.
|
|
69
|
-
*/
|
|
70
|
-
export function deltaEoklch(a: OklchColor, b: OklchColor): number {
|
|
71
|
-
return deltaE(a, b);
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/***
|
|
75
|
-
Calculate the WCAG contrast ratio between two color inputs.
|
|
76
|
-
*/
|
|
77
|
-
export function contrastRatio(colorA: string | object, colorB: string | object): number {
|
|
78
|
-
return wcagContrast(colorA as never, colorB as never);
|
|
79
|
-
}
|
package/src/neutral.ts
DELETED
|
@@ -1,77 +0,0 @@
|
|
|
1
|
-
import type { ColorHarmony, GeneratedHarmonyRoleColors } from './harmony';
|
|
2
|
-
import type { HexColor } from './hex';
|
|
3
|
-
import { normalizeHueDegrees, oklchToHex, parseHexToOklch } from './internal-culori';
|
|
4
|
-
import { type ColorSwatch, type ColorSwatchDiagnostics, generateColorSwatch } from './swatches';
|
|
5
|
-
|
|
6
|
-
export const MIN_HUEFUL_CHROMA = 0.015;
|
|
7
|
-
|
|
8
|
-
export interface NeutralSwatchResult {
|
|
9
|
-
neutralKeyColor: HexColor;
|
|
10
|
-
neutral: ColorSwatch;
|
|
11
|
-
diagnostics: ColorSwatchDiagnostics;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export interface GeneratedNeutralMetadata {
|
|
15
|
-
neutralKeyColor: HexColor;
|
|
16
|
-
diagnostics: ColorSwatchDiagnostics;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
/***
|
|
20
|
-
Pick the harmony color that should tint the generated neutral swatch.
|
|
21
|
-
*/
|
|
22
|
-
function pickTintSourceHex(
|
|
23
|
-
roleColors: GeneratedHarmonyRoleColors,
|
|
24
|
-
harmony: ColorHarmony,
|
|
25
|
-
): HexColor {
|
|
26
|
-
const byMapping: Partial<Record<ColorHarmony, HexColor | undefined>> = {
|
|
27
|
-
monochromatic: roleColors.primary.hex,
|
|
28
|
-
complementary: roleColors.secondary?.hex,
|
|
29
|
-
analogous: roleColors.tertiary?.hex,
|
|
30
|
-
splitComplementary: roleColors.tertiary?.hex,
|
|
31
|
-
triadic: roleColors.tertiary?.hex,
|
|
32
|
-
tetradic: roleColors.tertiary?.hex,
|
|
33
|
-
};
|
|
34
|
-
|
|
35
|
-
return (
|
|
36
|
-
byMapping[harmony] ??
|
|
37
|
-
roleColors.tertiary?.hex ??
|
|
38
|
-
roleColors.secondary?.hex ??
|
|
39
|
-
roleColors.primary.hex
|
|
40
|
-
);
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/***
|
|
44
|
-
Clamp a number to a finite minimum and maximum range.
|
|
45
|
-
*/
|
|
46
|
-
function clampNumber(value: number, min: number, max: number): number {
|
|
47
|
-
if (!Number.isFinite(value)) return min;
|
|
48
|
-
return Math.min(max, Math.max(min, value));
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/***
|
|
52
|
-
Generate a softly tinted neutral swatch from generated harmony role colors.
|
|
53
|
-
*/
|
|
54
|
-
export function generateNeutralSwatch(roleColors: GeneratedHarmonyRoleColors): NeutralSwatchResult {
|
|
55
|
-
const tintSourceHex = pickTintSourceHex(roleColors, roleColors.harmony);
|
|
56
|
-
const tintSource = parseHexToOklch(tintSourceHex);
|
|
57
|
-
|
|
58
|
-
const primary = parseHexToOklch(roleColors.primary.hex);
|
|
59
|
-
|
|
60
|
-
const preferGray = tintSource.c < MIN_HUEFUL_CHROMA;
|
|
61
|
-
|
|
62
|
-
const hue = roleColors.harmony === 'monochromatic' ? primary.h : tintSource.h;
|
|
63
|
-
const sourceChroma = roleColors.harmony === 'monochromatic' ? primary.c : tintSource.c;
|
|
64
|
-
const neutralHue = preferGray ? 0 : normalizeHueDegrees(hue);
|
|
65
|
-
const neutralChroma = preferGray ? 0 : clampNumber(sourceChroma * 0.06, 0.004, 0.012);
|
|
66
|
-
|
|
67
|
-
const neutralKeyColor = oklchToHex({
|
|
68
|
-
mode: 'oklch',
|
|
69
|
-
l: 0.6,
|
|
70
|
-
c: neutralChroma,
|
|
71
|
-
h: neutralHue,
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
const { swatch, diagnostics } = generateColorSwatch(neutralKeyColor);
|
|
75
|
-
|
|
76
|
-
return { neutralKeyColor, neutral: swatch, diagnostics };
|
|
77
|
-
}
|
package/src/semantics.ts
DELETED
|
@@ -1,60 +0,0 @@
|
|
|
1
|
-
import type { GeneratedColorRole } from './harmony';
|
|
2
|
-
import type { ColorSwatchStep } from './swatches';
|
|
3
|
-
|
|
4
|
-
export type ThemeColorMode = 'light' | 'dark';
|
|
5
|
-
|
|
6
|
-
export type SemanticColorToken =
|
|
7
|
-
| 'background'
|
|
8
|
-
| 'surface'
|
|
9
|
-
| 'surfaceRaised'
|
|
10
|
-
| 'border'
|
|
11
|
-
| 'divider'
|
|
12
|
-
| 'text'
|
|
13
|
-
| 'textMuted'
|
|
14
|
-
| 'disabledBg'
|
|
15
|
-
| 'disabledText'
|
|
16
|
-
| 'brand'
|
|
17
|
-
| 'brandEmphasis'
|
|
18
|
-
| 'action'
|
|
19
|
-
| 'actionEmphasis';
|
|
20
|
-
|
|
21
|
-
export type SemanticColorRole = 'neutral' | GeneratedColorRole;
|
|
22
|
-
|
|
23
|
-
export interface SemanticColorReference {
|
|
24
|
-
role: SemanticColorRole;
|
|
25
|
-
step: ColorSwatchStep;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export type SemanticColorReferenceMap = Record<SemanticColorToken, SemanticColorReference>;
|
|
29
|
-
|
|
30
|
-
export const LIGHT_SEMANTIC_COLOR_REFERENCES: SemanticColorReferenceMap = {
|
|
31
|
-
background: { role: 'neutral', step: 50 },
|
|
32
|
-
surface: { role: 'neutral', step: 100 },
|
|
33
|
-
surfaceRaised: { role: 'neutral', step: 50 },
|
|
34
|
-
border: { role: 'neutral', step: 200 },
|
|
35
|
-
divider: { role: 'neutral', step: 200 },
|
|
36
|
-
text: { role: 'neutral', step: 900 },
|
|
37
|
-
textMuted: { role: 'neutral', step: 700 },
|
|
38
|
-
disabledBg: { role: 'neutral', step: 200 },
|
|
39
|
-
disabledText: { role: 'neutral', step: 500 },
|
|
40
|
-
brand: { role: 'primary', step: 600 },
|
|
41
|
-
brandEmphasis: { role: 'primary', step: 700 },
|
|
42
|
-
action: { role: 'primary', step: 600 },
|
|
43
|
-
actionEmphasis: { role: 'primary', step: 700 },
|
|
44
|
-
};
|
|
45
|
-
|
|
46
|
-
export const DARK_SEMANTIC_COLOR_REFERENCES: SemanticColorReferenceMap = {
|
|
47
|
-
background: { role: 'neutral', step: 950 },
|
|
48
|
-
surface: { role: 'neutral', step: 900 },
|
|
49
|
-
surfaceRaised: { role: 'neutral', step: 900 },
|
|
50
|
-
border: { role: 'neutral', step: 800 },
|
|
51
|
-
divider: { role: 'neutral', step: 800 },
|
|
52
|
-
text: { role: 'neutral', step: 50 },
|
|
53
|
-
textMuted: { role: 'neutral', step: 200 },
|
|
54
|
-
disabledBg: { role: 'neutral', step: 800 },
|
|
55
|
-
disabledText: { role: 'neutral', step: 500 },
|
|
56
|
-
brand: { role: 'primary', step: 400 },
|
|
57
|
-
brandEmphasis: { role: 'primary', step: 300 },
|
|
58
|
-
action: { role: 'primary', step: 400 },
|
|
59
|
-
actionEmphasis: { role: 'primary', step: 300 },
|
|
60
|
-
};
|
package/src/swatches.ts
DELETED
|
@@ -1,160 +0,0 @@
|
|
|
1
|
-
import type { HexColor } from './hex';
|
|
2
|
-
import { deltaEoklch, oklchToHex, parseHexToOklch } from './internal-culori';
|
|
3
|
-
|
|
4
|
-
export const COLOR_SWATCH_STEPS = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950] as const;
|
|
5
|
-
export type ColorSwatchStep = (typeof COLOR_SWATCH_STEPS)[number];
|
|
6
|
-
|
|
7
|
-
export const COLOR_SWATCH_BASE_STEP = 500 as const;
|
|
8
|
-
|
|
9
|
-
export type ColorSwatch = Record<ColorSwatchStep, HexColor>;
|
|
10
|
-
|
|
11
|
-
export type ColorSwatchWarningCode =
|
|
12
|
-
| 'weak_step'
|
|
13
|
-
| 'weak_adjacent_delta'
|
|
14
|
-
| 'limited_lightness_range';
|
|
15
|
-
|
|
16
|
-
export interface ColorSwatchWarning {
|
|
17
|
-
code: ColorSwatchWarningCode;
|
|
18
|
-
step?: ColorSwatchStep;
|
|
19
|
-
message: string;
|
|
20
|
-
deltaEFromBase?: number;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export interface ColorSwatchDiagnostics {
|
|
24
|
-
isUsable: boolean;
|
|
25
|
-
warnings: readonly ColorSwatchWarning[];
|
|
26
|
-
minAdjacentDelta: number;
|
|
27
|
-
maxAdjacentDelta: number;
|
|
28
|
-
lightnessRange: {
|
|
29
|
-
min: number;
|
|
30
|
-
max: number;
|
|
31
|
-
};
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
const BASELINE_LIGHTNESS_BY_STEP: Record<ColorSwatchStep, number> = {
|
|
35
|
-
50: 0.985,
|
|
36
|
-
100: 0.967,
|
|
37
|
-
200: 0.928,
|
|
38
|
-
300: 0.872,
|
|
39
|
-
400: 0.707,
|
|
40
|
-
500: 0.551,
|
|
41
|
-
600: 0.446,
|
|
42
|
-
700: 0.373,
|
|
43
|
-
800: 0.278,
|
|
44
|
-
900: 0.21,
|
|
45
|
-
950: 0.13,
|
|
46
|
-
};
|
|
47
|
-
|
|
48
|
-
const MIN_USABLE_ADJACENT_DELTA = 0.012;
|
|
49
|
-
const MIN_USABLE_LIGHTNESS_RANGE = 0.35;
|
|
50
|
-
|
|
51
|
-
/***
|
|
52
|
-
Clamp a number into the normalized zero-to-one range.
|
|
53
|
-
*/
|
|
54
|
-
function clamp01(value: number): number {
|
|
55
|
-
if (!Number.isFinite(value)) return 0;
|
|
56
|
-
return Math.min(1, Math.max(0, value));
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/***
|
|
60
|
-
Return the chroma multiplier used for a swatch step.
|
|
61
|
-
*/
|
|
62
|
-
function chromaMultiplierForStep(step: ColorSwatchStep): number {
|
|
63
|
-
if (step <= 200) return 0.55;
|
|
64
|
-
if (step <= 400) return 0.75;
|
|
65
|
-
if (step === COLOR_SWATCH_BASE_STEP) return 1;
|
|
66
|
-
if (step <= 700) return 0.9;
|
|
67
|
-
return 0.8;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/***
|
|
71
|
-
Generate a full color swatch and diagnostics from a base color.
|
|
72
|
-
*/
|
|
73
|
-
export function generateColorSwatch(baseColor: HexColor): {
|
|
74
|
-
swatch: ColorSwatch;
|
|
75
|
-
diagnostics: ColorSwatchDiagnostics;
|
|
76
|
-
} {
|
|
77
|
-
const baseOklch = parseHexToOklch(baseColor);
|
|
78
|
-
const baseOffset = baseOklch.l - BASELINE_LIGHTNESS_BY_STEP[COLOR_SWATCH_BASE_STEP];
|
|
79
|
-
|
|
80
|
-
const warnings: ColorSwatchWarning[] = [];
|
|
81
|
-
const swatch = {} as ColorSwatch;
|
|
82
|
-
const lightnessEntries: number[] = [];
|
|
83
|
-
const adjacentDeltas: number[] = [];
|
|
84
|
-
|
|
85
|
-
for (const step of COLOR_SWATCH_STEPS) {
|
|
86
|
-
if (step === COLOR_SWATCH_BASE_STEP) {
|
|
87
|
-
swatch[step] = baseColor;
|
|
88
|
-
lightnessEntries.push(baseOklch.l);
|
|
89
|
-
continue;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
const targetL = clamp01(BASELINE_LIGHTNESS_BY_STEP[step] + baseOffset);
|
|
93
|
-
const targetC = clamp01(baseOklch.c * chromaMultiplierForStep(step));
|
|
94
|
-
const target = { ...baseOklch, l: targetL, c: targetC };
|
|
95
|
-
const hex = oklchToHex(target);
|
|
96
|
-
swatch[step] = hex;
|
|
97
|
-
|
|
98
|
-
const candidateOklch = parseHexToOklch(hex);
|
|
99
|
-
lightnessEntries.push(candidateOklch.l);
|
|
100
|
-
|
|
101
|
-
const deltaEFromBase = deltaEoklch(baseOklch, candidateOklch);
|
|
102
|
-
if (deltaEFromBase < 0.02) {
|
|
103
|
-
warnings.push({
|
|
104
|
-
code: 'weak_step',
|
|
105
|
-
step,
|
|
106
|
-
deltaEFromBase,
|
|
107
|
-
message: `Swatch step ${step} is visually close to the base color.`,
|
|
108
|
-
});
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
for (let index = 1; index < COLOR_SWATCH_STEPS.length; index++) {
|
|
113
|
-
const previousStep = COLOR_SWATCH_STEPS[index - 1];
|
|
114
|
-
const currentStep = COLOR_SWATCH_STEPS[index];
|
|
115
|
-
if (previousStep === undefined || currentStep === undefined) continue;
|
|
116
|
-
|
|
117
|
-
const previous = parseHexToOklch(swatch[previousStep]);
|
|
118
|
-
const current = parseHexToOklch(swatch[currentStep]);
|
|
119
|
-
const adjacentDelta = deltaEoklch(previous, current);
|
|
120
|
-
adjacentDeltas.push(adjacentDelta);
|
|
121
|
-
|
|
122
|
-
if (adjacentDelta < MIN_USABLE_ADJACENT_DELTA) {
|
|
123
|
-
warnings.push({
|
|
124
|
-
code: 'weak_adjacent_delta',
|
|
125
|
-
step: currentStep,
|
|
126
|
-
deltaEFromBase: adjacentDelta,
|
|
127
|
-
message: `Swatch step ${currentStep} is visually close to adjacent step ${previousStep}.`,
|
|
128
|
-
});
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
const minAdjacentDelta = Math.min(...adjacentDeltas);
|
|
133
|
-
const maxAdjacentDelta = Math.max(...adjacentDeltas);
|
|
134
|
-
const minLightness = Math.min(...lightnessEntries);
|
|
135
|
-
const maxLightness = Math.max(...lightnessEntries);
|
|
136
|
-
const lightnessRange = maxLightness - minLightness;
|
|
137
|
-
|
|
138
|
-
if (lightnessRange < MIN_USABLE_LIGHTNESS_RANGE) {
|
|
139
|
-
warnings.push({
|
|
140
|
-
code: 'limited_lightness_range',
|
|
141
|
-
message: 'Generated swatch has a limited lightness range.',
|
|
142
|
-
});
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
return {
|
|
146
|
-
swatch,
|
|
147
|
-
diagnostics: {
|
|
148
|
-
isUsable:
|
|
149
|
-
minAdjacentDelta >= MIN_USABLE_ADJACENT_DELTA &&
|
|
150
|
-
lightnessRange >= MIN_USABLE_LIGHTNESS_RANGE,
|
|
151
|
-
warnings,
|
|
152
|
-
minAdjacentDelta,
|
|
153
|
-
maxAdjacentDelta,
|
|
154
|
-
lightnessRange: {
|
|
155
|
-
min: minLightness,
|
|
156
|
-
max: maxLightness,
|
|
157
|
-
},
|
|
158
|
-
},
|
|
159
|
-
};
|
|
160
|
-
}
|
package/src/theme-colors.ts
DELETED
|
@@ -1,86 +0,0 @@
|
|
|
1
|
-
import { type GeneratedHarmonyRoleColors, generateHarmonyRoleColors } from './harmony';
|
|
2
|
-
import type { HexColor } from './hex';
|
|
3
|
-
import { parseHexColorOrThrow } from './hex';
|
|
4
|
-
import { type GeneratedNeutralMetadata, generateNeutralSwatch } from './neutral';
|
|
5
|
-
import { type ColorSwatch, generateColorSwatch } from './swatches';
|
|
6
|
-
|
|
7
|
-
export interface ThemeModeColorInput {
|
|
8
|
-
primaryColor: string;
|
|
9
|
-
harmony: GeneratedHarmonyRoleColors['harmony'];
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
export interface ThemeColorInput {
|
|
13
|
-
light: ThemeModeColorInput;
|
|
14
|
-
dark: ThemeModeColorInput;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export interface GeneratedThemeSwatches {
|
|
18
|
-
primary: ColorSwatch;
|
|
19
|
-
secondary?: ColorSwatch;
|
|
20
|
-
tertiary?: ColorSwatch;
|
|
21
|
-
quaternary?: ColorSwatch;
|
|
22
|
-
neutral: ColorSwatch;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export interface GeneratedThemeModeColors {
|
|
26
|
-
harmonyRoleColors: GeneratedHarmonyRoleColors;
|
|
27
|
-
swatches: GeneratedThemeSwatches;
|
|
28
|
-
neutral: GeneratedNeutralMetadata;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
/***
|
|
32
|
-
Parse the primary color configured for a theme mode.
|
|
33
|
-
*/
|
|
34
|
-
export function getThemeModePrimaryHex(mode: ThemeModeColorInput): HexColor {
|
|
35
|
-
return parseHexColorOrThrow(mode.primaryColor);
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/***
|
|
39
|
-
Generate harmony role colors, swatches, and neutral metadata for a theme mode.
|
|
40
|
-
*/
|
|
41
|
-
export function generateThemeModeColors(mode: ThemeModeColorInput): GeneratedThemeModeColors {
|
|
42
|
-
const primaryHex = getThemeModePrimaryHex(mode);
|
|
43
|
-
const harmonyRoleColors = generateHarmonyRoleColors(primaryHex, mode.harmony);
|
|
44
|
-
|
|
45
|
-
const primarySwatch = generateColorSwatch(harmonyRoleColors.primary.hex).swatch;
|
|
46
|
-
const secondarySwatch = harmonyRoleColors.secondary
|
|
47
|
-
? generateColorSwatch(harmonyRoleColors.secondary.hex).swatch
|
|
48
|
-
: undefined;
|
|
49
|
-
const tertiarySwatch = harmonyRoleColors.tertiary
|
|
50
|
-
? generateColorSwatch(harmonyRoleColors.tertiary.hex).swatch
|
|
51
|
-
: undefined;
|
|
52
|
-
const quaternarySwatch = harmonyRoleColors.quaternary
|
|
53
|
-
? generateColorSwatch(harmonyRoleColors.quaternary.hex).swatch
|
|
54
|
-
: undefined;
|
|
55
|
-
|
|
56
|
-
const neutral = generateNeutralSwatch(harmonyRoleColors);
|
|
57
|
-
const swatches: GeneratedThemeSwatches = {
|
|
58
|
-
primary: primarySwatch,
|
|
59
|
-
neutral: neutral.neutral,
|
|
60
|
-
...(secondarySwatch ? { secondary: secondarySwatch } : {}),
|
|
61
|
-
...(tertiarySwatch ? { tertiary: tertiarySwatch } : {}),
|
|
62
|
-
...(quaternarySwatch ? { quaternary: quaternarySwatch } : {}),
|
|
63
|
-
};
|
|
64
|
-
|
|
65
|
-
return {
|
|
66
|
-
harmonyRoleColors,
|
|
67
|
-
swatches,
|
|
68
|
-
neutral: {
|
|
69
|
-
neutralKeyColor: neutral.neutralKeyColor,
|
|
70
|
-
diagnostics: neutral.diagnostics,
|
|
71
|
-
},
|
|
72
|
-
};
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/***
|
|
76
|
-
Generate light and dark theme color outputs from theme color input.
|
|
77
|
-
*/
|
|
78
|
-
export function generateThemeColors(theme: ThemeColorInput): {
|
|
79
|
-
light: GeneratedThemeModeColors;
|
|
80
|
-
dark: GeneratedThemeModeColors;
|
|
81
|
-
} {
|
|
82
|
-
return {
|
|
83
|
-
light: generateThemeModeColors(theme.light),
|
|
84
|
-
dark: generateThemeModeColors(theme.dark),
|
|
85
|
-
};
|
|
86
|
-
}
|