@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.
Files changed (59) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1 -0
  3. package/dist/contrast.d.ts +7 -0
  4. package/dist/contrast.d.ts.map +1 -0
  5. package/dist/contrast.js +13 -0
  6. package/dist/contrast.js.map +1 -0
  7. package/dist/culori-fn.d.ts +2 -0
  8. package/dist/culori-fn.d.ts.map +1 -0
  9. package/dist/culori-fn.js +2 -0
  10. package/dist/culori-fn.js.map +1 -0
  11. package/dist/culori.d.ts +2 -0
  12. package/dist/culori.d.ts.map +1 -0
  13. package/dist/culori.js +2 -0
  14. package/dist/culori.js.map +1 -0
  15. package/dist/harmony.d.ts +20 -0
  16. package/dist/harmony.d.ts.map +1 -0
  17. package/dist/harmony.js +60 -0
  18. package/dist/harmony.js.map +1 -0
  19. package/dist/hex.d.ts +10 -0
  20. package/dist/hex.d.ts.map +1 -0
  21. package/dist/hex.js +24 -0
  22. package/dist/hex.js.map +1 -0
  23. package/dist/index.d.ts +8 -0
  24. package/dist/index.d.ts.map +1 -0
  25. package/dist/index.js +8 -0
  26. package/dist/index.js.map +1 -0
  27. package/dist/internal-culori.d.ts +25 -0
  28. package/dist/internal-culori.d.ts.map +1 -0
  29. package/dist/internal-culori.js +62 -0
  30. package/dist/internal-culori.js.map +1 -0
  31. package/dist/neutral.d.ts +15 -0
  32. package/dist/neutral.d.ts.map +1 -0
  33. package/dist/neutral.js +41 -0
  34. package/dist/neutral.js.map +1 -0
  35. package/dist/semantics.d.ts +13 -0
  36. package/dist/semantics.d.ts.map +1 -0
  37. package/dist/semantics.js +31 -0
  38. package/dist/semantics.js.map +1 -0
  39. package/dist/swatches.d.ts +27 -0
  40. package/dist/swatches.d.ts.map +1 -0
  41. package/dist/swatches.js +109 -0
  42. package/dist/swatches.js.map +1 -0
  43. package/dist/theme-colors.d.ts +31 -0
  44. package/dist/theme-colors.d.ts.map +1 -0
  45. package/dist/theme-colors.js +44 -0
  46. package/dist/theme-colors.js.map +1 -0
  47. package/package.json +86 -0
  48. package/src/color-theory.test.ts +91 -0
  49. package/src/contrast.ts +22 -0
  50. package/src/culori-fn.ts +1 -0
  51. package/src/culori.ts +1 -0
  52. package/src/harmony.ts +92 -0
  53. package/src/hex.ts +29 -0
  54. package/src/index.ts +7 -0
  55. package/src/internal-culori.ts +100 -0
  56. package/src/neutral.ts +68 -0
  57. package/src/semantics.ts +60 -0
  58. package/src/swatches.ts +151 -0
  59. package/src/theme-colors.ts +77 -0
package/src/neutral.ts ADDED
@@ -0,0 +1,68 @@
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
+ function pickTintSourceHex(
20
+ roleColors: GeneratedHarmonyRoleColors,
21
+ harmony: ColorHarmony,
22
+ ): HexColor {
23
+ const byMapping: Partial<Record<ColorHarmony, HexColor | undefined>> = {
24
+ monochromatic: roleColors.primary.hex,
25
+ complementary: roleColors.secondary?.hex,
26
+ analogous: roleColors.tertiary?.hex,
27
+ splitComplementary: roleColors.tertiary?.hex,
28
+ triadic: roleColors.tertiary?.hex,
29
+ tetradic: roleColors.tertiary?.hex,
30
+ };
31
+
32
+ return (
33
+ byMapping[harmony] ??
34
+ roleColors.tertiary?.hex ??
35
+ roleColors.secondary?.hex ??
36
+ roleColors.primary.hex
37
+ );
38
+ }
39
+
40
+ function clampNumber(value: number, min: number, max: number): number {
41
+ if (!Number.isFinite(value)) return min;
42
+ return Math.min(max, Math.max(min, value));
43
+ }
44
+
45
+ export function generateNeutralSwatch(roleColors: GeneratedHarmonyRoleColors): NeutralSwatchResult {
46
+ const tintSourceHex = pickTintSourceHex(roleColors, roleColors.harmony);
47
+ const tintSource = parseHexToOklch(tintSourceHex);
48
+
49
+ const primary = parseHexToOklch(roleColors.primary.hex);
50
+
51
+ const preferGray = tintSource.c < MIN_HUEFUL_CHROMA;
52
+
53
+ const hue = roleColors.harmony === 'monochromatic' ? primary.h : tintSource.h;
54
+ const sourceChroma = roleColors.harmony === 'monochromatic' ? primary.c : tintSource.c;
55
+ const neutralHue = preferGray ? 0 : normalizeHueDegrees(hue);
56
+ const neutralChroma = preferGray ? 0 : clampNumber(sourceChroma * 0.06, 0.004, 0.012);
57
+
58
+ const neutralKeyColor = oklchToHex({
59
+ mode: 'oklch',
60
+ l: 0.6,
61
+ c: neutralChroma,
62
+ h: neutralHue,
63
+ });
64
+
65
+ const { swatch, diagnostics } = generateColorSwatch(neutralKeyColor);
66
+
67
+ return { neutralKeyColor, neutral: swatch, diagnostics };
68
+ }
@@ -0,0 +1,60 @@
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
+ };
@@ -0,0 +1,151 @@
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
+ function clamp01(value: number): number {
52
+ if (!Number.isFinite(value)) return 0;
53
+ return Math.min(1, Math.max(0, value));
54
+ }
55
+
56
+ function chromaMultiplierForStep(step: ColorSwatchStep): number {
57
+ if (step <= 200) return 0.55;
58
+ if (step <= 400) return 0.75;
59
+ if (step === COLOR_SWATCH_BASE_STEP) return 1;
60
+ if (step <= 700) return 0.9;
61
+ return 0.8;
62
+ }
63
+
64
+ export function generateColorSwatch(baseColor: HexColor): {
65
+ swatch: ColorSwatch;
66
+ diagnostics: ColorSwatchDiagnostics;
67
+ } {
68
+ const baseOklch = parseHexToOklch(baseColor);
69
+ const baseOffset = baseOklch.l - BASELINE_LIGHTNESS_BY_STEP[COLOR_SWATCH_BASE_STEP];
70
+
71
+ const warnings: ColorSwatchWarning[] = [];
72
+ const swatch = {} as ColorSwatch;
73
+ const lightnessEntries: number[] = [];
74
+ const adjacentDeltas: number[] = [];
75
+
76
+ for (const step of COLOR_SWATCH_STEPS) {
77
+ if (step === COLOR_SWATCH_BASE_STEP) {
78
+ swatch[step] = baseColor;
79
+ lightnessEntries.push(baseOklch.l);
80
+ continue;
81
+ }
82
+
83
+ const targetL = clamp01(BASELINE_LIGHTNESS_BY_STEP[step] + baseOffset);
84
+ const targetC = clamp01(baseOklch.c * chromaMultiplierForStep(step));
85
+ const target = { ...baseOklch, l: targetL, c: targetC };
86
+ const hex = oklchToHex(target);
87
+ swatch[step] = hex;
88
+
89
+ const candidateOklch = parseHexToOklch(hex);
90
+ lightnessEntries.push(candidateOklch.l);
91
+
92
+ const deltaEFromBase = deltaEoklch(baseOklch, candidateOklch);
93
+ if (deltaEFromBase < 0.02) {
94
+ warnings.push({
95
+ code: 'weak_step',
96
+ step,
97
+ deltaEFromBase,
98
+ message: `Swatch step ${step} is visually close to the base color.`,
99
+ });
100
+ }
101
+ }
102
+
103
+ for (let index = 1; index < COLOR_SWATCH_STEPS.length; index++) {
104
+ const previousStep = COLOR_SWATCH_STEPS[index - 1];
105
+ const currentStep = COLOR_SWATCH_STEPS[index];
106
+ if (previousStep === undefined || currentStep === undefined) continue;
107
+
108
+ const previous = parseHexToOklch(swatch[previousStep]);
109
+ const current = parseHexToOklch(swatch[currentStep]);
110
+ const adjacentDelta = deltaEoklch(previous, current);
111
+ adjacentDeltas.push(adjacentDelta);
112
+
113
+ if (adjacentDelta < MIN_USABLE_ADJACENT_DELTA) {
114
+ warnings.push({
115
+ code: 'weak_adjacent_delta',
116
+ step: currentStep,
117
+ deltaEFromBase: adjacentDelta,
118
+ message: `Swatch step ${currentStep} is visually close to adjacent step ${previousStep}.`,
119
+ });
120
+ }
121
+ }
122
+
123
+ const minAdjacentDelta = Math.min(...adjacentDeltas);
124
+ const maxAdjacentDelta = Math.max(...adjacentDeltas);
125
+ const minLightness = Math.min(...lightnessEntries);
126
+ const maxLightness = Math.max(...lightnessEntries);
127
+ const lightnessRange = maxLightness - minLightness;
128
+
129
+ if (lightnessRange < MIN_USABLE_LIGHTNESS_RANGE) {
130
+ warnings.push({
131
+ code: 'limited_lightness_range',
132
+ message: 'Generated swatch has a limited lightness range.',
133
+ });
134
+ }
135
+
136
+ return {
137
+ swatch,
138
+ diagnostics: {
139
+ isUsable:
140
+ minAdjacentDelta >= MIN_USABLE_ADJACENT_DELTA &&
141
+ lightnessRange >= MIN_USABLE_LIGHTNESS_RANGE,
142
+ warnings,
143
+ minAdjacentDelta,
144
+ maxAdjacentDelta,
145
+ lightnessRange: {
146
+ min: minLightness,
147
+ max: maxLightness,
148
+ },
149
+ },
150
+ };
151
+ }
@@ -0,0 +1,77 @@
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
+ export function getThemeModePrimaryHex(mode: ThemeModeColorInput): HexColor {
32
+ return parseHexColorOrThrow(mode.primaryColor);
33
+ }
34
+
35
+ export function generateThemeModeColors(mode: ThemeModeColorInput): GeneratedThemeModeColors {
36
+ const primaryHex = getThemeModePrimaryHex(mode);
37
+ const harmonyRoleColors = generateHarmonyRoleColors(primaryHex, mode.harmony);
38
+
39
+ const primarySwatch = generateColorSwatch(harmonyRoleColors.primary.hex).swatch;
40
+ const secondarySwatch = harmonyRoleColors.secondary
41
+ ? generateColorSwatch(harmonyRoleColors.secondary.hex).swatch
42
+ : undefined;
43
+ const tertiarySwatch = harmonyRoleColors.tertiary
44
+ ? generateColorSwatch(harmonyRoleColors.tertiary.hex).swatch
45
+ : undefined;
46
+ const quaternarySwatch = harmonyRoleColors.quaternary
47
+ ? generateColorSwatch(harmonyRoleColors.quaternary.hex).swatch
48
+ : undefined;
49
+
50
+ const neutral = generateNeutralSwatch(harmonyRoleColors);
51
+ const swatches: GeneratedThemeSwatches = {
52
+ primary: primarySwatch,
53
+ neutral: neutral.neutral,
54
+ ...(secondarySwatch ? { secondary: secondarySwatch } : {}),
55
+ ...(tertiarySwatch ? { tertiary: tertiarySwatch } : {}),
56
+ ...(quaternarySwatch ? { quaternary: quaternarySwatch } : {}),
57
+ };
58
+
59
+ return {
60
+ harmonyRoleColors,
61
+ swatches,
62
+ neutral: {
63
+ neutralKeyColor: neutral.neutralKeyColor,
64
+ diagnostics: neutral.diagnostics,
65
+ },
66
+ };
67
+ }
68
+
69
+ export function generateThemeColors(theme: ThemeColorInput): {
70
+ light: GeneratedThemeModeColors;
71
+ dark: GeneratedThemeModeColors;
72
+ } {
73
+ return {
74
+ light: generateThemeModeColors(theme.light),
75
+ dark: generateThemeModeColors(theme.dark),
76
+ };
77
+ }