@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.
Files changed (36) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/internal/color/colorToneRecipes.d.ts +23 -0
  3. package/dist/internal/color/colorToneRecipes.d.ts.map +1 -0
  4. package/dist/internal/color/colorToneRecipes.js +139 -0
  5. package/dist/internal/color/colorToneRecipes.js.map +1 -0
  6. package/dist/internal/color/index.d.ts +2 -1
  7. package/dist/internal/color/index.d.ts.map +1 -1
  8. package/dist/internal/color/index.js +1 -0
  9. package/dist/internal/color/index.js.map +1 -1
  10. package/dist/internal/color/roleScales.d.ts +2 -1
  11. package/dist/internal/color/roleScales.d.ts.map +1 -1
  12. package/dist/internal/color/roleScales.js +33 -5
  13. package/dist/internal/color/roleScales.js.map +1 -1
  14. package/dist/internal/color/scales.d.ts +2 -0
  15. package/dist/internal/color/scales.d.ts.map +1 -1
  16. package/dist/internal/color/scales.js +16 -9
  17. package/dist/internal/color/scales.js.map +1 -1
  18. package/dist/theme/createZoraThemeConfig.js +2 -2
  19. package/dist/theme/createZoraThemeConfig.js.map +1 -1
  20. package/dist/theme/types.d.ts +6 -4
  21. package/dist/theme/types.d.ts.map +1 -1
  22. package/dist/theme/types.js +20 -1
  23. package/dist/theme/types.js.map +1 -1
  24. package/dist/theme/zoraDefaultTheme.js +1 -1
  25. package/dist/theme/zoraDefaultTheme.js.map +1 -1
  26. package/package.json +2 -2
  27. package/src/internal/color/colorToneRecipes.test.ts +89 -0
  28. package/src/internal/color/colorToneRecipes.ts +167 -0
  29. package/src/internal/color/index.ts +9 -0
  30. package/src/internal/color/roleScales.test.ts +72 -99
  31. package/src/internal/color/roleScales.ts +36 -6
  32. package/src/internal/color/scales.ts +27 -10
  33. package/src/theme/createZoraThemeConfig.test.ts +5 -5
  34. package/src/theme/createZoraThemeConfig.ts +2 -2
  35. package/src/theme/types.ts +26 -10
  36. 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 hueRoles = buildHueRoles([
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
- expectRoleOrder(scales, expectedOrder);
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 hueRoles = buildHueRoles([
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
- const hueRoles = buildHueRoles([
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 hueRoles = buildHueRoles([
129
- { role: 'primary', hue: 115, sourceSlotId: 'base' },
130
- { role: 'secondary', hue: 210, sourceSlotId: 'a' },
131
- { role: 'accent', hue: 300, sourceSlotId: 'b' },
132
- { role: 'highlight', hue: 25, sourceSlotId: 'c' },
133
- { role: 'surfaceTint', hue: 165, sourceSlotId: 'a' },
134
- ]);
135
-
136
- const scales = createZoraRoleColorScales({ hueRoles, seed: '#0f766e' });
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 hueRoles = buildHueRoles([
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('neutral scale has low chroma', () => {
175
- const hueRoles = buildHueRoles([
176
- { role: 'primary', hue: 120, sourceSlotId: 'base' },
177
- { role: 'secondary', hue: 200, sourceSlotId: 'a' },
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 scales = createZoraRoleColorScales({ hueRoles, seed: '#0f766e' });
184
- const neutral = getZoraRoleColorScale(scales, 'neutral').scale;
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(() => createZoraRoleColorScales({ hueRoles, seed: '#0f766e' })).toThrow('highlight');
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 hueRoles = buildHueRoles([
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 assignments: ZoraComputedHueRoles['assignments'] = [
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({ hueRoles: options.hueRoles, seedChroma, role: 'primary' }),
86
- createHueBackedRoleScale({ hueRoles: options.hueRoles, seedChroma, role: 'secondary' }),
87
- createHueBackedRoleScale({ hueRoles: options.hueRoles, seedChroma, role: 'accent' }),
88
- createHueBackedRoleScale({ hueRoles: options.hueRoles, seedChroma, role: 'highlight' }),
89
- createHueBackedRoleScale({ hueRoles: options.hueRoles, seedChroma, role: 'surfaceTint' }),
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 = ROLE_CHROMA_FACTOR[options.role];
93
- return resolvePrimaryScaleChroma(options.seedChroma * factor, step);
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 {