@ankhorage/zora 0.12.3 → 0.13.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/CHANGELOG.md +12 -0
- package/dist/internal/color/colorToneRecipes.d.ts +23 -0
- package/dist/internal/color/colorToneRecipes.d.ts.map +1 -0
- package/dist/internal/color/colorToneRecipes.js +139 -0
- package/dist/internal/color/colorToneRecipes.js.map +1 -0
- package/dist/internal/color/index.d.ts +2 -1
- package/dist/internal/color/index.d.ts.map +1 -1
- package/dist/internal/color/index.js +1 -0
- package/dist/internal/color/index.js.map +1 -1
- package/dist/internal/color/roleScales.d.ts +2 -1
- package/dist/internal/color/roleScales.d.ts.map +1 -1
- package/dist/internal/color/roleScales.js +33 -5
- package/dist/internal/color/roleScales.js.map +1 -1
- package/dist/internal/color/scales.d.ts +2 -0
- package/dist/internal/color/scales.d.ts.map +1 -1
- package/dist/internal/color/scales.js +16 -9
- package/dist/internal/color/scales.js.map +1 -1
- package/dist/theme/createZoraThemeConfig.js +2 -2
- package/dist/theme/createZoraThemeConfig.js.map +1 -1
- package/dist/theme/types.d.ts +6 -4
- package/dist/theme/types.d.ts.map +1 -1
- package/dist/theme/types.js +20 -1
- package/dist/theme/types.js.map +1 -1
- package/dist/theme/zoraDefaultTheme.js +1 -1
- package/dist/theme/zoraDefaultTheme.js.map +1 -1
- package/package.json +2 -2
- package/src/internal/color/colorToneRecipes.test.ts +89 -0
- package/src/internal/color/colorToneRecipes.ts +167 -0
- package/src/internal/color/index.ts +9 -0
- package/src/internal/color/roleScales.test.ts +72 -99
- package/src/internal/color/roleScales.ts +36 -6
- package/src/internal/color/scales.ts +27 -10
- package/src/theme/createZoraThemeConfig.test.ts +5 -5
- package/src/theme/createZoraThemeConfig.ts +2 -2
- package/src/theme/types.ts +26 -10
- package/src/theme/zoraDefaultTheme.ts +1 -1
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
|
|
3
|
+
import { ZORA_COLOR_TONES } from '../../theme/types';
|
|
4
|
+
import {
|
|
5
|
+
getZoraColorToneRecipe,
|
|
6
|
+
getZoraColorToneRoleChromaFactor,
|
|
7
|
+
type ZoraColorToneRecipe,
|
|
8
|
+
type ZoraHueScaleRoleId,
|
|
9
|
+
} from './index';
|
|
10
|
+
|
|
11
|
+
const HUE_BACKED_ROLES: readonly ZoraHueScaleRoleId[] = [
|
|
12
|
+
'primary',
|
|
13
|
+
'secondary',
|
|
14
|
+
'accent',
|
|
15
|
+
'highlight',
|
|
16
|
+
'surfaceTint',
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
function expectFinitePositive(value: number) {
|
|
20
|
+
expect(Number.isFinite(value)).toBe(true);
|
|
21
|
+
expect(value).toBeGreaterThan(0);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function expectSaneRecipe(recipe: ZoraColorToneRecipe) {
|
|
25
|
+
expectFinitePositive(recipe.maxChroma);
|
|
26
|
+
expectFinitePositive(recipe.minMidChroma);
|
|
27
|
+
expect(recipe.minMidChroma).toBeLessThan(recipe.maxChroma);
|
|
28
|
+
|
|
29
|
+
for (const role of HUE_BACKED_ROLES) {
|
|
30
|
+
expectFinitePositive(getZoraColorToneRoleChromaFactor(recipe, role));
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe('color tone recipes', () => {
|
|
35
|
+
test('every ZORA color tone has a recipe', () => {
|
|
36
|
+
for (const colorTone of ZORA_COLOR_TONES) {
|
|
37
|
+
expect(getZoraColorToneRecipe(colorTone).colorTone).toBe(colorTone);
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('every recipe has sane chroma values and all hue-backed role factors', () => {
|
|
42
|
+
for (const colorTone of ZORA_COLOR_TONES) {
|
|
43
|
+
expectSaneRecipe(getZoraColorToneRecipe(colorTone));
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('records the initial background and foreground lane pairings', () => {
|
|
48
|
+
expect(getZoraColorToneRecipe('fluorescent').laneRecipe).toEqual({
|
|
49
|
+
backgroundTone: 'obsidian',
|
|
50
|
+
foregroundTone: 'fluorescent',
|
|
51
|
+
});
|
|
52
|
+
expect(getZoraColorToneRecipe('obsidian').laneRecipe).toEqual({
|
|
53
|
+
backgroundTone: 'obsidian',
|
|
54
|
+
foregroundTone: 'fluorescent',
|
|
55
|
+
});
|
|
56
|
+
expect(getZoraColorToneRecipe('pastel').laneRecipe).toEqual({
|
|
57
|
+
backgroundTone: 'pastel',
|
|
58
|
+
foregroundTone: 'jewel',
|
|
59
|
+
});
|
|
60
|
+
expect(getZoraColorToneRecipe('earth').laneRecipe).toEqual({
|
|
61
|
+
backgroundTone: 'earth',
|
|
62
|
+
foregroundTone: 'mineral',
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('surface tint remains lower intensity than foreground action roles', () => {
|
|
67
|
+
for (const colorTone of ZORA_COLOR_TONES) {
|
|
68
|
+
const recipe = getZoraColorToneRecipe(colorTone);
|
|
69
|
+
const surfaceTint = getZoraColorToneRoleChromaFactor(recipe, 'surfaceTint');
|
|
70
|
+
|
|
71
|
+
expect(surfaceTint).toBeLessThan(getZoraColorToneRoleChromaFactor(recipe, 'primary'));
|
|
72
|
+
expect(surfaceTint).toBeLessThan(getZoraColorToneRoleChromaFactor(recipe, 'accent'));
|
|
73
|
+
expect(surfaceTint).toBeLessThan(getZoraColorToneRoleChromaFactor(recipe, 'highlight'));
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('high-energy tones allow higher chroma than neutral and pastel', () => {
|
|
78
|
+
expect(getZoraColorToneRecipe('fluorescent').maxChroma).toBeGreaterThan(
|
|
79
|
+
getZoraColorToneRecipe('neutral').maxChroma,
|
|
80
|
+
);
|
|
81
|
+
expect(getZoraColorToneRecipe('fluorescent').maxChroma).toBeGreaterThan(
|
|
82
|
+
getZoraColorToneRecipe('pastel').maxChroma,
|
|
83
|
+
);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('getter is deterministic', () => {
|
|
87
|
+
expect(getZoraColorToneRecipe('jewel')).toEqual(getZoraColorToneRecipe('jewel'));
|
|
88
|
+
});
|
|
89
|
+
});
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import type { ZoraColorTone } from '../../theme/types';
|
|
2
|
+
import type { ZoraHueScaleRoleId } from './scales';
|
|
3
|
+
|
|
4
|
+
export interface ZoraColorToneLaneRecipe {
|
|
5
|
+
backgroundTone: ZoraColorTone;
|
|
6
|
+
foregroundTone: ZoraColorTone;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface ZoraColorToneRoleChromaFactors {
|
|
10
|
+
primary: number;
|
|
11
|
+
secondary: number;
|
|
12
|
+
accent: number;
|
|
13
|
+
highlight: number;
|
|
14
|
+
surfaceTint: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ZoraColorToneRecipe {
|
|
18
|
+
colorTone: ZoraColorTone;
|
|
19
|
+
laneRecipe: ZoraColorToneLaneRecipe;
|
|
20
|
+
roleChromaFactors: ZoraColorToneRoleChromaFactors;
|
|
21
|
+
maxChroma: number;
|
|
22
|
+
minMidChroma: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const ZORA_COLOR_TONE_RECIPES = {
|
|
26
|
+
neutral: {
|
|
27
|
+
colorTone: 'neutral',
|
|
28
|
+
laneRecipe: { backgroundTone: 'neutral', foregroundTone: 'jewel' },
|
|
29
|
+
roleChromaFactors: {
|
|
30
|
+
primary: 0.72,
|
|
31
|
+
secondary: 0.48,
|
|
32
|
+
accent: 0.56,
|
|
33
|
+
highlight: 0.6,
|
|
34
|
+
surfaceTint: 0.12,
|
|
35
|
+
},
|
|
36
|
+
maxChroma: 0.14,
|
|
37
|
+
minMidChroma: 0.025,
|
|
38
|
+
},
|
|
39
|
+
pastel: {
|
|
40
|
+
colorTone: 'pastel',
|
|
41
|
+
laneRecipe: { backgroundTone: 'pastel', foregroundTone: 'jewel' },
|
|
42
|
+
roleChromaFactors: {
|
|
43
|
+
primary: 0.58,
|
|
44
|
+
secondary: 0.48,
|
|
45
|
+
accent: 0.55,
|
|
46
|
+
highlight: 0.62,
|
|
47
|
+
surfaceTint: 0.2,
|
|
48
|
+
},
|
|
49
|
+
maxChroma: 0.12,
|
|
50
|
+
minMidChroma: 0.02,
|
|
51
|
+
},
|
|
52
|
+
earth: {
|
|
53
|
+
colorTone: 'earth',
|
|
54
|
+
laneRecipe: { backgroundTone: 'earth', foregroundTone: 'mineral' },
|
|
55
|
+
roleChromaFactors: {
|
|
56
|
+
primary: 0.64,
|
|
57
|
+
secondary: 0.52,
|
|
58
|
+
accent: 0.58,
|
|
59
|
+
highlight: 0.6,
|
|
60
|
+
surfaceTint: 0.16,
|
|
61
|
+
},
|
|
62
|
+
maxChroma: 0.13,
|
|
63
|
+
minMidChroma: 0.022,
|
|
64
|
+
},
|
|
65
|
+
mineral: {
|
|
66
|
+
colorTone: 'mineral',
|
|
67
|
+
laneRecipe: { backgroundTone: 'mineral', foregroundTone: 'jewel' },
|
|
68
|
+
roleChromaFactors: {
|
|
69
|
+
primary: 0.7,
|
|
70
|
+
secondary: 0.56,
|
|
71
|
+
accent: 0.64,
|
|
72
|
+
highlight: 0.68,
|
|
73
|
+
surfaceTint: 0.16,
|
|
74
|
+
},
|
|
75
|
+
maxChroma: 0.14,
|
|
76
|
+
minMidChroma: 0.025,
|
|
77
|
+
},
|
|
78
|
+
muted: {
|
|
79
|
+
colorTone: 'muted',
|
|
80
|
+
laneRecipe: { backgroundTone: 'muted', foregroundTone: 'jewel' },
|
|
81
|
+
roleChromaFactors: {
|
|
82
|
+
primary: 0.6,
|
|
83
|
+
secondary: 0.5,
|
|
84
|
+
accent: 0.56,
|
|
85
|
+
highlight: 0.6,
|
|
86
|
+
surfaceTint: 0.14,
|
|
87
|
+
},
|
|
88
|
+
maxChroma: 0.12,
|
|
89
|
+
minMidChroma: 0.02,
|
|
90
|
+
},
|
|
91
|
+
jewel: {
|
|
92
|
+
colorTone: 'jewel',
|
|
93
|
+
laneRecipe: { backgroundTone: 'neutral', foregroundTone: 'jewel' },
|
|
94
|
+
roleChromaFactors: {
|
|
95
|
+
primary: 1,
|
|
96
|
+
secondary: 0.72,
|
|
97
|
+
accent: 0.85,
|
|
98
|
+
highlight: 1,
|
|
99
|
+
surfaceTint: 0.18,
|
|
100
|
+
},
|
|
101
|
+
maxChroma: 0.2,
|
|
102
|
+
minMidChroma: 0.04,
|
|
103
|
+
},
|
|
104
|
+
fluorescent: {
|
|
105
|
+
colorTone: 'fluorescent',
|
|
106
|
+
laneRecipe: { backgroundTone: 'obsidian', foregroundTone: 'fluorescent' },
|
|
107
|
+
roleChromaFactors: {
|
|
108
|
+
primary: 1.12,
|
|
109
|
+
secondary: 0.82,
|
|
110
|
+
accent: 1.05,
|
|
111
|
+
highlight: 1.18,
|
|
112
|
+
surfaceTint: 0.22,
|
|
113
|
+
},
|
|
114
|
+
maxChroma: 0.24,
|
|
115
|
+
minMidChroma: 0.045,
|
|
116
|
+
},
|
|
117
|
+
obsidian: {
|
|
118
|
+
colorTone: 'obsidian',
|
|
119
|
+
laneRecipe: { backgroundTone: 'obsidian', foregroundTone: 'fluorescent' },
|
|
120
|
+
roleChromaFactors: {
|
|
121
|
+
primary: 1.08,
|
|
122
|
+
secondary: 0.78,
|
|
123
|
+
accent: 1,
|
|
124
|
+
highlight: 1.12,
|
|
125
|
+
surfaceTint: 0.2,
|
|
126
|
+
},
|
|
127
|
+
maxChroma: 0.22,
|
|
128
|
+
minMidChroma: 0.04,
|
|
129
|
+
},
|
|
130
|
+
vaporwave: {
|
|
131
|
+
colorTone: 'vaporwave',
|
|
132
|
+
laneRecipe: { backgroundTone: 'pastel', foregroundTone: 'fluorescent' },
|
|
133
|
+
roleChromaFactors: {
|
|
134
|
+
primary: 0.95,
|
|
135
|
+
secondary: 0.72,
|
|
136
|
+
accent: 1,
|
|
137
|
+
highlight: 1.08,
|
|
138
|
+
surfaceTint: 0.24,
|
|
139
|
+
},
|
|
140
|
+
maxChroma: 0.2,
|
|
141
|
+
minMidChroma: 0.035,
|
|
142
|
+
},
|
|
143
|
+
monochromeAccent: {
|
|
144
|
+
colorTone: 'monochromeAccent',
|
|
145
|
+
laneRecipe: { backgroundTone: 'neutral', foregroundTone: 'jewel' },
|
|
146
|
+
roleChromaFactors: {
|
|
147
|
+
primary: 0.88,
|
|
148
|
+
secondary: 0.36,
|
|
149
|
+
accent: 0.95,
|
|
150
|
+
highlight: 0.88,
|
|
151
|
+
surfaceTint: 0.08,
|
|
152
|
+
},
|
|
153
|
+
maxChroma: 0.18,
|
|
154
|
+
minMidChroma: 0.035,
|
|
155
|
+
},
|
|
156
|
+
} satisfies Record<ZoraColorTone, ZoraColorToneRecipe>;
|
|
157
|
+
|
|
158
|
+
export function getZoraColorToneRecipe(colorTone: ZoraColorTone): ZoraColorToneRecipe {
|
|
159
|
+
return ZORA_COLOR_TONE_RECIPES[colorTone];
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function getZoraColorToneRoleChromaFactor(
|
|
163
|
+
recipe: ZoraColorToneRecipe,
|
|
164
|
+
role: ZoraHueScaleRoleId,
|
|
165
|
+
): number {
|
|
166
|
+
return recipe.roleChromaFactors[role];
|
|
167
|
+
}
|
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
export {
|
|
2
|
+
getZoraColorToneRecipe,
|
|
3
|
+
getZoraColorToneRoleChromaFactor,
|
|
4
|
+
type ZoraColorToneLaneRecipe,
|
|
5
|
+
type ZoraColorToneRecipe,
|
|
6
|
+
type ZoraColorToneRoleChromaFactors,
|
|
7
|
+
} from './colorToneRecipes';
|
|
1
8
|
export {
|
|
2
9
|
computeZoraHarmony,
|
|
3
10
|
type ZoraComputedHarmony,
|
|
@@ -24,7 +31,9 @@ export {
|
|
|
24
31
|
export {
|
|
25
32
|
createZoraColorScale,
|
|
26
33
|
type CreateZoraColorScaleOptions,
|
|
34
|
+
type CreateZoraHueScaleOptions,
|
|
27
35
|
createZoraNeutralScale,
|
|
28
36
|
createZoraPrimaryScale,
|
|
37
|
+
type ZoraHueScaleRoleId,
|
|
29
38
|
} from './scales';
|
|
30
39
|
export { ZORA_COLOR_SCALE_STEPS, type ZoraColorScale, type ZoraColorScaleStep } from './types';
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test';
|
|
2
2
|
|
|
3
|
-
import type { ZoraHexColor } from '../../theme/types';
|
|
3
|
+
import type { ZoraColorTone, ZoraHexColor } from '../../theme/types';
|
|
4
4
|
import {
|
|
5
5
|
assignZoraHarmonyRoleHues,
|
|
6
6
|
computeZoraHarmony,
|
|
@@ -10,10 +10,8 @@ import {
|
|
|
10
10
|
ZORA_COLOR_SCALE_ROLE_ORDER,
|
|
11
11
|
ZORA_COLOR_SCALE_STEPS,
|
|
12
12
|
type ZoraColorScale,
|
|
13
|
-
type ZoraColorScaleRoleId,
|
|
14
13
|
type ZoraColorScaleStep,
|
|
15
14
|
type ZoraComputedHueRoles,
|
|
16
|
-
type ZoraComputedRoleColorScales,
|
|
17
15
|
type ZoraRoleColorScale,
|
|
18
16
|
} from './index';
|
|
19
17
|
|
|
@@ -51,6 +49,24 @@ function buildHueRoles(assignments: ZoraComputedHueRoles['assignments']): ZoraCo
|
|
|
51
49
|
};
|
|
52
50
|
}
|
|
53
51
|
|
|
52
|
+
function createCompleteHueRoles(): ZoraComputedHueRoles {
|
|
53
|
+
return buildHueRoles([
|
|
54
|
+
{ role: 'primary', hue: 120, sourceSlotId: 'base' },
|
|
55
|
+
{ role: 'secondary', hue: 200, sourceSlotId: 'a' },
|
|
56
|
+
{ role: 'accent', hue: 280, sourceSlotId: 'b' },
|
|
57
|
+
{ role: 'highlight', hue: 20, sourceSlotId: 'c' },
|
|
58
|
+
{ role: 'surfaceTint', hue: 160, sourceSlotId: 'a' },
|
|
59
|
+
]);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function createScales(colorTone: ZoraColorTone = 'jewel') {
|
|
63
|
+
return createZoraRoleColorScales({
|
|
64
|
+
colorTone,
|
|
65
|
+
hueRoles: createCompleteHueRoles(),
|
|
66
|
+
seed: '#0f766e',
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
54
70
|
function requireHueBackedSourceHue(role: ZoraRoleColorScale): number {
|
|
55
71
|
if (typeof role.sourceHue !== 'number') {
|
|
56
72
|
throw new Error(`[zora] Expected "${role.role}" role scale to include a sourceHue.`);
|
|
@@ -58,47 +74,19 @@ function requireHueBackedSourceHue(role: ZoraRoleColorScale): number {
|
|
|
58
74
|
return role.sourceHue;
|
|
59
75
|
}
|
|
60
76
|
|
|
61
|
-
function expectRoleOrder(
|
|
62
|
-
scales: ZoraComputedRoleColorScales,
|
|
63
|
-
expected: readonly ZoraColorScaleRoleId[],
|
|
64
|
-
) {
|
|
65
|
-
expect(scales.roles.map((entry) => entry.role)).toEqual([...expected]);
|
|
66
|
-
}
|
|
67
|
-
|
|
68
77
|
describe('createZoraRoleColorScales', () => {
|
|
69
78
|
test('returns all roles exactly once in deterministic order', () => {
|
|
70
|
-
const
|
|
71
|
-
{ role: 'primary', hue: 120, sourceSlotId: 'base' },
|
|
72
|
-
{ role: 'secondary', hue: 200, sourceSlotId: 'a' },
|
|
73
|
-
{ role: 'accent', hue: 280, sourceSlotId: 'b' },
|
|
74
|
-
{ role: 'highlight', hue: 20, sourceSlotId: 'c' },
|
|
75
|
-
{ role: 'surfaceTint', hue: 160, sourceSlotId: 'a' },
|
|
76
|
-
]);
|
|
77
|
-
|
|
78
|
-
const scales: ZoraComputedRoleColorScales = createZoraRoleColorScales({
|
|
79
|
-
hueRoles,
|
|
80
|
-
seed: '#0f766e',
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
const expectedOrder: readonly ZoraColorScaleRoleId[] = ZORA_COLOR_SCALE_ROLE_ORDER;
|
|
84
|
-
expect(scales.roles).toHaveLength(expectedOrder.length);
|
|
79
|
+
const scales = createScales();
|
|
85
80
|
|
|
86
|
-
|
|
81
|
+
expect(scales.roles).toHaveLength(ZORA_COLOR_SCALE_ROLE_ORDER.length);
|
|
82
|
+
expect(scales.roles.map((entry) => entry.role)).toEqual([...ZORA_COLOR_SCALE_ROLE_ORDER]);
|
|
87
83
|
expect(new Set(scales.roles.map((entry) => entry.role)).size).toBe(
|
|
88
84
|
ZORA_COLOR_SCALE_ROLE_ORDER.length,
|
|
89
85
|
);
|
|
90
86
|
});
|
|
91
87
|
|
|
92
88
|
test('every scale contains exactly all 50–950 keys and valid lowercase 6-digit hex values', () => {
|
|
93
|
-
const
|
|
94
|
-
{ role: 'primary', hue: 120, sourceSlotId: 'base' },
|
|
95
|
-
{ role: 'secondary', hue: 200, sourceSlotId: 'a' },
|
|
96
|
-
{ role: 'accent', hue: 280, sourceSlotId: 'b' },
|
|
97
|
-
{ role: 'highlight', hue: 20, sourceSlotId: 'c' },
|
|
98
|
-
{ role: 'surfaceTint', hue: 160, sourceSlotId: 'a' },
|
|
99
|
-
]);
|
|
100
|
-
|
|
101
|
-
const scales = createZoraRoleColorScales({ hueRoles, seed: '#0f766e' });
|
|
89
|
+
const scales = createScales();
|
|
102
90
|
|
|
103
91
|
for (const role of scales.roles) {
|
|
104
92
|
assertScaleKeys(role.scale);
|
|
@@ -109,31 +97,21 @@ describe('createZoraRoleColorScales', () => {
|
|
|
109
97
|
});
|
|
110
98
|
|
|
111
99
|
test('output is deterministic for the same input', () => {
|
|
112
|
-
|
|
113
|
-
{ role: 'primary', hue: 120, sourceSlotId: 'base' },
|
|
114
|
-
{ role: 'secondary', hue: 200, sourceSlotId: 'a' },
|
|
115
|
-
{ role: 'accent', hue: 280, sourceSlotId: 'b' },
|
|
116
|
-
{ role: 'highlight', hue: 20, sourceSlotId: 'c' },
|
|
117
|
-
{ role: 'surfaceTint', hue: 160, sourceSlotId: 'a' },
|
|
118
|
-
]);
|
|
119
|
-
|
|
120
|
-
const seed: ZoraHexColor = '#0f766e';
|
|
121
|
-
|
|
122
|
-
expect(createZoraRoleColorScales({ hueRoles, seed })).toEqual(
|
|
123
|
-
createZoraRoleColorScales({ hueRoles, seed }),
|
|
124
|
-
);
|
|
100
|
+
expect(createScales()).toEqual(createScales());
|
|
125
101
|
});
|
|
126
102
|
|
|
127
103
|
test('role hue is preserved approximately for mid steps', () => {
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
104
|
+
const scales = createZoraRoleColorScales({
|
|
105
|
+
colorTone: 'jewel',
|
|
106
|
+
hueRoles: buildHueRoles([
|
|
107
|
+
{ role: 'primary', hue: 115, sourceSlotId: 'base' },
|
|
108
|
+
{ role: 'secondary', hue: 210, sourceSlotId: 'a' },
|
|
109
|
+
{ role: 'accent', hue: 300, sourceSlotId: 'b' },
|
|
110
|
+
{ role: 'highlight', hue: 25, sourceSlotId: 'c' },
|
|
111
|
+
{ role: 'surfaceTint', hue: 165, sourceSlotId: 'a' },
|
|
112
|
+
]),
|
|
113
|
+
seed: '#0f766e',
|
|
114
|
+
});
|
|
137
115
|
|
|
138
116
|
const midSteps: ZoraColorScaleStep[] = [400, 500, 600, 700];
|
|
139
117
|
for (const roleId of ['primary', 'secondary', 'accent', 'highlight', 'surfaceTint'] as const) {
|
|
@@ -148,21 +126,11 @@ describe('createZoraRoleColorScales', () => {
|
|
|
148
126
|
});
|
|
149
127
|
|
|
150
128
|
test('surfaceTint scale has lower chroma than primary/accent/highlight for mid steps', () => {
|
|
151
|
-
const
|
|
152
|
-
{ role: 'primary', hue: 120, sourceSlotId: 'base' },
|
|
153
|
-
{ role: 'secondary', hue: 200, sourceSlotId: 'a' },
|
|
154
|
-
{ role: 'accent', hue: 280, sourceSlotId: 'b' },
|
|
155
|
-
{ role: 'highlight', hue: 20, sourceSlotId: 'c' },
|
|
156
|
-
{ role: 'surfaceTint', hue: 160, sourceSlotId: 'a' },
|
|
157
|
-
]);
|
|
158
|
-
|
|
159
|
-
const scales = createZoraRoleColorScales({ hueRoles, seed: '#0f766e' });
|
|
160
|
-
|
|
129
|
+
const scales = createScales('jewel');
|
|
161
130
|
const surfaceTint = getZoraRoleColorScale(scales, 'surfaceTint').scale;
|
|
162
131
|
const primary = getZoraRoleColorScale(scales, 'primary').scale;
|
|
163
132
|
const accent = getZoraRoleColorScale(scales, 'accent').scale;
|
|
164
133
|
const highlight = getZoraRoleColorScale(scales, 'highlight').scale;
|
|
165
|
-
|
|
166
134
|
const step: ZoraColorScaleStep = 500;
|
|
167
135
|
const surfaceTintChroma = parseHexToOklch(surfaceTint[step]).c;
|
|
168
136
|
|
|
@@ -171,17 +139,36 @@ describe('createZoraRoleColorScales', () => {
|
|
|
171
139
|
expect(surfaceTintChroma).toBeLessThan(parseHexToOklch(highlight[step]).c);
|
|
172
140
|
});
|
|
173
141
|
|
|
174
|
-
test('
|
|
175
|
-
const
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
{ role: 'accent', hue: 280, sourceSlotId: 'b' },
|
|
179
|
-
{ role: 'highlight', hue: 20, sourceSlotId: 'c' },
|
|
180
|
-
{ role: 'surfaceTint', hue: 160, sourceSlotId: 'a' },
|
|
181
|
-
]);
|
|
142
|
+
test('colorTone changes role chroma behavior internally', () => {
|
|
143
|
+
const neutral = createScales('neutral');
|
|
144
|
+
const fluorescent = createScales('fluorescent');
|
|
145
|
+
const step: ZoraColorScaleStep = 500;
|
|
182
146
|
|
|
183
|
-
const
|
|
184
|
-
const
|
|
147
|
+
const neutralPrimary = parseHexToOklch(getZoraRoleColorScale(neutral, 'primary').scale[step]).c;
|
|
148
|
+
const fluorescentPrimary = parseHexToOklch(
|
|
149
|
+
getZoraRoleColorScale(fluorescent, 'primary').scale[step],
|
|
150
|
+
).c;
|
|
151
|
+
|
|
152
|
+
expect(fluorescentPrimary).toBeGreaterThan(neutralPrimary);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test('pastel keeps surfaceTint lower chroma than fluorescent foreground roles', () => {
|
|
156
|
+
const pastel = createScales('pastel');
|
|
157
|
+
const fluorescent = createScales('fluorescent');
|
|
158
|
+
const step: ZoraColorScaleStep = 500;
|
|
159
|
+
|
|
160
|
+
const pastelSurfaceTint = parseHexToOklch(
|
|
161
|
+
getZoraRoleColorScale(pastel, 'surfaceTint').scale[step],
|
|
162
|
+
).c;
|
|
163
|
+
const fluorescentAccent = parseHexToOklch(
|
|
164
|
+
getZoraRoleColorScale(fluorescent, 'accent').scale[step],
|
|
165
|
+
).c;
|
|
166
|
+
|
|
167
|
+
expect(pastelSurfaceTint).toBeLessThan(fluorescentAccent);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test('neutral scale has low chroma', () => {
|
|
171
|
+
const neutral = getZoraRoleColorScale(createScales(), 'neutral').scale;
|
|
185
172
|
|
|
186
173
|
for (const { hex } of getStepValues(neutral)) {
|
|
187
174
|
expect(parseHexToOklch(hex).c).toBeLessThanOrEqual(0.03);
|
|
@@ -196,19 +183,13 @@ describe('createZoraRoleColorScales', () => {
|
|
|
196
183
|
{ role: 'surfaceTint', hue: 160, sourceSlotId: 'a' },
|
|
197
184
|
]);
|
|
198
185
|
|
|
199
|
-
expect(() =>
|
|
186
|
+
expect(() =>
|
|
187
|
+
createZoraRoleColorScales({ colorTone: 'jewel', hueRoles, seed: '#0f766e' }),
|
|
188
|
+
).toThrow('highlight');
|
|
200
189
|
});
|
|
201
190
|
|
|
202
191
|
test('no role scale contains pure black/white by default', () => {
|
|
203
|
-
const
|
|
204
|
-
{ role: 'primary', hue: 120, sourceSlotId: 'base' },
|
|
205
|
-
{ role: 'secondary', hue: 200, sourceSlotId: 'a' },
|
|
206
|
-
{ role: 'accent', hue: 280, sourceSlotId: 'b' },
|
|
207
|
-
{ role: 'highlight', hue: 20, sourceSlotId: 'c' },
|
|
208
|
-
{ role: 'surfaceTint', hue: 160, sourceSlotId: 'a' },
|
|
209
|
-
]);
|
|
210
|
-
|
|
211
|
-
const scales = createZoraRoleColorScales({ hueRoles, seed: '#0f766e' });
|
|
192
|
+
const scales = createScales();
|
|
212
193
|
|
|
213
194
|
for (const role of scales.roles) {
|
|
214
195
|
assertNoPureBlackOrWhite(role.scale);
|
|
@@ -216,18 +197,10 @@ describe('createZoraRoleColorScales', () => {
|
|
|
216
197
|
});
|
|
217
198
|
|
|
218
199
|
test('does not mutate input hueRoles', () => {
|
|
219
|
-
const
|
|
220
|
-
{ role: 'primary', hue: 120, sourceSlotId: 'base' },
|
|
221
|
-
{ role: 'secondary', hue: 200, sourceSlotId: 'a' },
|
|
222
|
-
{ role: 'accent', hue: 280, sourceSlotId: 'b' },
|
|
223
|
-
{ role: 'highlight', hue: 20, sourceSlotId: 'c' },
|
|
224
|
-
{ role: 'surfaceTint', hue: 160, sourceSlotId: 'a' },
|
|
225
|
-
];
|
|
226
|
-
|
|
227
|
-
const hueRoles: ZoraComputedHueRoles = buildHueRoles(assignments);
|
|
200
|
+
const hueRoles = createCompleteHueRoles();
|
|
228
201
|
const snapshot = JSON.stringify(hueRoles);
|
|
229
202
|
|
|
230
|
-
createZoraRoleColorScales({ hueRoles, seed: '#0f766e' });
|
|
203
|
+
createZoraRoleColorScales({ colorTone: 'jewel', hueRoles, seed: '#0f766e' });
|
|
231
204
|
|
|
232
205
|
expect(JSON.stringify(hueRoles)).toBe(snapshot);
|
|
233
206
|
});
|
|
@@ -237,7 +210,7 @@ describe('createZoraRoleColorScales', () => {
|
|
|
237
210
|
const harmony = computeZoraHarmony(seed, 'tetradic');
|
|
238
211
|
const hueRoles = assignZoraHarmonyRoleHues(harmony);
|
|
239
212
|
|
|
240
|
-
const scales = createZoraRoleColorScales({ hueRoles, seed });
|
|
213
|
+
const scales = createZoraRoleColorScales({ colorTone: 'jewel', hueRoles, seed });
|
|
241
214
|
|
|
242
215
|
expect(scales.roles.map((entry) => entry.role)).toEqual([...ZORA_COLOR_SCALE_ROLE_ORDER]);
|
|
243
216
|
for (const role of scales.roles) {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type { ZoraHexColor } from '../../theme/types';
|
|
1
|
+
import type { ZoraColorTone, ZoraHexColor } from '../../theme/types';
|
|
2
|
+
import { getZoraColorToneRecipe } from './colorToneRecipes';
|
|
2
3
|
import { parseHexToOklch } from './oklch';
|
|
3
4
|
import {
|
|
4
5
|
getZoraHueRoleAssignment,
|
|
@@ -56,16 +57,19 @@ function resolveSeedChroma(seed: ZoraHexColor): number {
|
|
|
56
57
|
}
|
|
57
58
|
|
|
58
59
|
function createHueBackedRoleScale(options: {
|
|
60
|
+
colorTone: ZoraColorTone;
|
|
59
61
|
hueRoles: ZoraComputedHueRoles;
|
|
60
62
|
seedChroma: number;
|
|
61
63
|
role: ZoraHueRoleId;
|
|
62
64
|
}): ZoraRoleColorScale {
|
|
63
65
|
const assignment = getZoraHueRoleAssignment(options.hueRoles, options.role);
|
|
66
|
+
const colorToneRecipe = getZoraColorToneRecipe(options.colorTone);
|
|
64
67
|
const hueScaleRole: ZoraHueScaleRoleId = options.role;
|
|
65
68
|
const hueScaleOptions: CreateZoraHueScaleOptions = {
|
|
66
69
|
hue: assignment.hue,
|
|
67
70
|
seedChroma: options.seedChroma,
|
|
68
71
|
role: hueScaleRole,
|
|
72
|
+
colorToneRecipe,
|
|
69
73
|
};
|
|
70
74
|
|
|
71
75
|
return {
|
|
@@ -78,15 +82,41 @@ function createHueBackedRoleScale(options: {
|
|
|
78
82
|
export function createZoraRoleColorScales(options: {
|
|
79
83
|
hueRoles: ZoraComputedHueRoles;
|
|
80
84
|
seed: ZoraHexColor;
|
|
85
|
+
colorTone: ZoraColorTone;
|
|
81
86
|
}): ZoraComputedRoleColorScales {
|
|
82
87
|
const seedChroma = resolveSeedChroma(options.seed);
|
|
83
88
|
|
|
84
89
|
const roles: ZoraRoleColorScale[] = [
|
|
85
|
-
createHueBackedRoleScale({
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
+
createHueBackedRoleScale({
|
|
91
|
+
colorTone: options.colorTone,
|
|
92
|
+
hueRoles: options.hueRoles,
|
|
93
|
+
seedChroma,
|
|
94
|
+
role: 'primary',
|
|
95
|
+
}),
|
|
96
|
+
createHueBackedRoleScale({
|
|
97
|
+
colorTone: options.colorTone,
|
|
98
|
+
hueRoles: options.hueRoles,
|
|
99
|
+
seedChroma,
|
|
100
|
+
role: 'secondary',
|
|
101
|
+
}),
|
|
102
|
+
createHueBackedRoleScale({
|
|
103
|
+
colorTone: options.colorTone,
|
|
104
|
+
hueRoles: options.hueRoles,
|
|
105
|
+
seedChroma,
|
|
106
|
+
role: 'accent',
|
|
107
|
+
}),
|
|
108
|
+
createHueBackedRoleScale({
|
|
109
|
+
colorTone: options.colorTone,
|
|
110
|
+
hueRoles: options.hueRoles,
|
|
111
|
+
seedChroma,
|
|
112
|
+
role: 'highlight',
|
|
113
|
+
}),
|
|
114
|
+
createHueBackedRoleScale({
|
|
115
|
+
colorTone: options.colorTone,
|
|
116
|
+
hueRoles: options.hueRoles,
|
|
117
|
+
seedChroma,
|
|
118
|
+
role: 'surfaceTint',
|
|
119
|
+
}),
|
|
90
120
|
{
|
|
91
121
|
role: 'neutral',
|
|
92
122
|
scale: createZoraNeutralScale(options.seed),
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { ZoraHexColor } from '../../theme/types';
|
|
2
|
+
import type { ZoraColorToneRecipe } from './colorToneRecipes';
|
|
3
|
+
import { getZoraColorToneRoleChromaFactor } from './colorToneRecipes';
|
|
2
4
|
import { clampOklchToGamut, formatOklchAsHex, parseHexToOklch } from './oklch';
|
|
3
5
|
import { type ZoraColorScale, type ZoraColorScaleStep } from './types';
|
|
4
6
|
|
|
@@ -13,6 +15,7 @@ export interface CreateZoraHueScaleOptions {
|
|
|
13
15
|
hue: number;
|
|
14
16
|
seedChroma: number;
|
|
15
17
|
role: ZoraHueScaleRoleId;
|
|
18
|
+
colorToneRecipe: ZoraColorToneRecipe;
|
|
16
19
|
}
|
|
17
20
|
|
|
18
21
|
const PRIMARY_LIGHTNESS_BY_STEP: Record<ZoraColorScaleStep, number> = {
|
|
@@ -62,14 +65,6 @@ const MIN_PRIMARY_SCALE_CHROMA = 0.04;
|
|
|
62
65
|
const NEUTRAL_CHROMA = 0.012;
|
|
63
66
|
const DEFAULT_NEUTRAL_HUE_DEGREES = 260;
|
|
64
67
|
|
|
65
|
-
const ROLE_CHROMA_FACTOR = {
|
|
66
|
-
primary: 1,
|
|
67
|
-
secondary: 0.72,
|
|
68
|
-
accent: 0.85,
|
|
69
|
-
highlight: 1,
|
|
70
|
-
surfaceTint: 0.18,
|
|
71
|
-
} satisfies Record<ZoraHueScaleRoleId, number>;
|
|
72
|
-
|
|
73
68
|
function clampNumber(value: number, min: number, max: number): number {
|
|
74
69
|
return Math.max(min, Math.min(value, max));
|
|
75
70
|
}
|
|
@@ -85,12 +80,34 @@ function resolvePrimaryScaleChroma(seedChroma: number, step: ZoraColorScaleStep)
|
|
|
85
80
|
return shouldEnforceMin ? Math.max(bounded, MIN_PRIMARY_SCALE_CHROMA) : bounded;
|
|
86
81
|
}
|
|
87
82
|
|
|
83
|
+
function resolveRoleScaleChroma(options: {
|
|
84
|
+
seedChroma: number;
|
|
85
|
+
step: ZoraColorScaleStep;
|
|
86
|
+
maxChroma: number;
|
|
87
|
+
minMidChroma: number;
|
|
88
|
+
}): number {
|
|
89
|
+
const cappedSeedChroma = clampNumber(options.seedChroma, 0, options.maxChroma);
|
|
90
|
+
const multiplier = PRIMARY_CHROMA_MULTIPLIER_BY_STEP[options.step];
|
|
91
|
+
const scaled = cappedSeedChroma * multiplier;
|
|
92
|
+
const bounded = clampNumber(scaled, 0, options.maxChroma);
|
|
93
|
+
const shouldEnforceMin =
|
|
94
|
+
options.step >= 300 && options.step <= 700 && options.seedChroma >= options.minMidChroma;
|
|
95
|
+
|
|
96
|
+
return shouldEnforceMin ? Math.max(bounded, options.minMidChroma) : bounded;
|
|
97
|
+
}
|
|
98
|
+
|
|
88
99
|
function resolveHueScaleChroma(
|
|
89
100
|
options: CreateZoraHueScaleOptions,
|
|
90
101
|
step: ZoraColorScaleStep,
|
|
91
102
|
): number {
|
|
92
|
-
const factor =
|
|
93
|
-
|
|
103
|
+
const factor = getZoraColorToneRoleChromaFactor(options.colorToneRecipe, options.role);
|
|
104
|
+
|
|
105
|
+
return resolveRoleScaleChroma({
|
|
106
|
+
seedChroma: options.seedChroma * factor,
|
|
107
|
+
step,
|
|
108
|
+
maxChroma: options.colorToneRecipe.maxChroma,
|
|
109
|
+
minMidChroma: options.colorToneRecipe.minMidChroma,
|
|
110
|
+
});
|
|
94
111
|
}
|
|
95
112
|
|
|
96
113
|
function createScaleEntries(options: CreateZoraColorScaleOptions): ZoraColorScale {
|