@ankhorage/zora 0.16.2 → 1.0.0
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 +62 -0
- package/README.md +11 -13
- package/dist/components/heading/resolveHeadingRecipe.d.ts +2 -2
- package/dist/components/heading/resolveHeadingRecipe.d.ts.map +1 -1
- package/dist/components/heading/resolveHeadingRecipe.js.map +1 -1
- package/dist/components/text/resolveTextRecipe.d.ts +2 -2
- package/dist/components/text/resolveTextRecipe.d.ts.map +1 -1
- package/dist/components/text/resolveTextRecipe.js.map +1 -1
- package/dist/patterns/theme-composer/ThemeComposer.d.ts.map +1 -1
- package/dist/patterns/theme-composer/ThemeComposer.js +10 -86
- package/dist/patterns/theme-composer/ThemeComposer.js.map +1 -1
- package/dist/patterns/theme-composer/index.d.ts +1 -1
- package/dist/patterns/theme-composer/index.d.ts.map +1 -1
- package/dist/patterns/theme-composer/index.js.map +1 -1
- package/dist/patterns/theme-composer/types.d.ts +1 -13
- package/dist/patterns/theme-composer/types.d.ts.map +1 -1
- package/dist/patterns/theme-composer/types.js.map +1 -1
- package/dist/theme/createZoraThemeConfig.d.ts +1 -1
- package/dist/theme/createZoraThemeConfig.d.ts.map +1 -1
- package/dist/theme/createZoraThemeConfig.js +5 -6
- package/dist/theme/createZoraThemeConfig.js.map +1 -1
- package/dist/theme/index.d.ts +1 -1
- package/dist/theme/index.d.ts.map +1 -1
- package/dist/theme/index.js.map +1 -1
- package/dist/theme/types.d.ts +16 -11
- package/dist/theme/types.d.ts.map +1 -1
- package/dist/theme/types.js +1 -20
- package/dist/theme/types.js.map +1 -1
- package/dist/theme/useZoraTheme.d.ts +1 -1
- package/dist/theme/zoraDefaultTheme.js +1 -1
- package/dist/theme/zoraDefaultTheme.js.map +1 -1
- package/package.json +4 -4
- package/src/components/heading/resolveHeadingRecipe.test.ts +30 -5
- package/src/components/heading/resolveHeadingRecipe.ts +6 -6
- package/src/components/text/resolveTextRecipe.test.ts +30 -5
- package/src/components/text/resolveTextRecipe.ts +6 -6
- package/src/patterns/theme-composer/ThemeComposer.test.ts +9 -141
- package/src/patterns/theme-composer/ThemeComposer.tsx +10 -131
- package/src/patterns/theme-composer/index.ts +1 -6
- package/src/patterns/theme-composer/types.ts +1 -15
- package/src/showcaseCoverage.test.ts +14 -0
- package/src/theme/createZoraThemeConfig.test.ts +51 -26
- package/src/theme/createZoraThemeConfig.ts +7 -7
- package/src/theme/index.ts +1 -3
- package/src/theme/types.ts +22 -34
- package/src/theme/zoraDefaultTheme.ts +1 -1
- package/dist/internal/color/colorToneRecipes.d.ts +0 -23
- package/dist/internal/color/colorToneRecipes.d.ts.map +0 -1
- package/dist/internal/color/colorToneRecipes.js +0 -139
- package/dist/internal/color/colorToneRecipes.js.map +0 -1
- package/dist/internal/color/harmony.d.ts +0 -12
- package/dist/internal/color/harmony.d.ts.map +0 -1
- package/dist/internal/color/harmony.js +0 -69
- package/dist/internal/color/harmony.js.map +0 -1
- package/dist/internal/color/hue.d.ts +0 -3
- package/dist/internal/color/hue.d.ts.map +0 -1
- package/dist/internal/color/hue.js +0 -7
- package/dist/internal/color/hue.js.map +0 -1
- package/dist/internal/color/index.d.ts +0 -10
- package/dist/internal/color/index.d.ts.map +0 -1
- package/dist/internal/color/index.js +0 -10
- package/dist/internal/color/index.js.map +0 -1
- package/dist/internal/color/oklch.d.ts +0 -6
- package/dist/internal/color/oklch.d.ts.map +0 -1
- package/dist/internal/color/oklch.js +0 -50
- package/dist/internal/color/oklch.js.map +0 -1
- package/dist/internal/color/primary.d.ts +0 -3
- package/dist/internal/color/primary.d.ts.map +0 -1
- package/dist/internal/color/primary.js +0 -44
- package/dist/internal/color/primary.js.map +0 -1
- package/dist/internal/color/roleHues.d.ts +0 -15
- package/dist/internal/color/roleHues.d.ts.map +0 -1
- package/dist/internal/color/roleHues.js +0 -103
- package/dist/internal/color/roleHues.js.map +0 -1
- package/dist/internal/color/roleScales.d.ts +0 -20
- package/dist/internal/color/roleScales.d.ts.map +0 -1
- package/dist/internal/color/roleScales.js +0 -79
- package/dist/internal/color/roleScales.js.map +0 -1
- package/dist/internal/color/scales.d.ts +0 -19
- package/dist/internal/color/scales.d.ts.map +0 -1
- package/dist/internal/color/scales.js +0 -135
- package/dist/internal/color/scales.js.map +0 -1
- package/dist/internal/color/semanticTokens.d.ts +0 -28
- package/dist/internal/color/semanticTokens.d.ts.map +0 -1
- package/dist/internal/color/semanticTokens.js +0 -84
- package/dist/internal/color/semanticTokens.js.map +0 -1
- package/dist/internal/color/types.d.ts +0 -10
- package/dist/internal/color/types.d.ts.map +0 -1
- package/dist/internal/color/types.js +0 -4
- package/dist/internal/color/types.js.map +0 -1
- package/dist/patterns/theme-composer/recommendations.d.ts +0 -14
- package/dist/patterns/theme-composer/recommendations.d.ts.map +0 -1
- package/dist/patterns/theme-composer/recommendations.js +0 -58
- package/dist/patterns/theme-composer/recommendations.js.map +0 -1
- package/src/internal/color/colorToneRecipes.test.ts +0 -89
- package/src/internal/color/colorToneRecipes.ts +0 -167
- package/src/internal/color/harmony.test.ts +0 -145
- package/src/internal/color/harmony.ts +0 -96
- package/src/internal/color/hue.test.ts +0 -28
- package/src/internal/color/hue.ts +0 -7
- package/src/internal/color/index.ts +0 -44
- package/src/internal/color/oklch.ts +0 -65
- package/src/internal/color/primary.test.ts +0 -105
- package/src/internal/color/primary.ts +0 -64
- package/src/internal/color/roleHues.test.ts +0 -197
- package/src/internal/color/roleHues.ts +0 -142
- package/src/internal/color/roleScales.test.ts +0 -220
- package/src/internal/color/roleScales.ts +0 -127
- package/src/internal/color/scales.test.ts +0 -151
- package/src/internal/color/scales.ts +0 -194
- package/src/internal/color/semanticTokens.test.ts +0 -170
- package/src/internal/color/semanticTokens.ts +0 -114
- package/src/internal/color/types.ts +0 -15
- package/src/patterns/theme-composer/recommendations.ts +0 -85
|
@@ -1,197 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from 'bun:test';
|
|
2
|
-
|
|
3
|
-
import {
|
|
4
|
-
assignZoraHarmonyRoleHues,
|
|
5
|
-
getZoraHueRoleAssignment,
|
|
6
|
-
type ZoraComputedHarmony,
|
|
7
|
-
type ZoraComputedHueRoles,
|
|
8
|
-
type ZoraHueRoleAssignment,
|
|
9
|
-
type ZoraHueRoleId,
|
|
10
|
-
} from './index';
|
|
11
|
-
|
|
12
|
-
function expectNormalizedHue(hue: number) {
|
|
13
|
-
expect(Number.isFinite(hue)).toBe(true);
|
|
14
|
-
expect(hue).toBeGreaterThanOrEqual(0);
|
|
15
|
-
expect(hue).toBeLessThan(360);
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
const ROLE_ORDER: readonly ZoraHueRoleId[] = [
|
|
19
|
-
'primary',
|
|
20
|
-
'secondary',
|
|
21
|
-
'accent',
|
|
22
|
-
'highlight',
|
|
23
|
-
'surfaceTint',
|
|
24
|
-
];
|
|
25
|
-
|
|
26
|
-
function expectEveryRoleOnce(roles: ReturnType<typeof assignZoraHarmonyRoleHues>) {
|
|
27
|
-
expect(roles.assignments).toHaveLength(ROLE_ORDER.length);
|
|
28
|
-
expect(roles.assignments.map((assignment) => assignment.role)).toEqual([...ROLE_ORDER]);
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
describe('assignZoraHarmonyRoleHues', () => {
|
|
32
|
-
test('monochromatic assigns every role to base', () => {
|
|
33
|
-
const harmony: ZoraComputedHarmony = {
|
|
34
|
-
kind: 'monochromatic',
|
|
35
|
-
orderedSlots: [{ id: 'base', hue: 100 }],
|
|
36
|
-
};
|
|
37
|
-
|
|
38
|
-
const roles: ZoraComputedHueRoles = assignZoraHarmonyRoleHues(harmony);
|
|
39
|
-
|
|
40
|
-
expectEveryRoleOnce(roles);
|
|
41
|
-
for (const assignment of roles.assignments) {
|
|
42
|
-
expect(assignment.sourceSlotId).toBe('base');
|
|
43
|
-
expect(assignment.hue).toBe(100);
|
|
44
|
-
expectNormalizedHue(assignment.hue);
|
|
45
|
-
}
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
test('complementary assigns accent/highlight to a', () => {
|
|
49
|
-
const harmony: ZoraComputedHarmony = {
|
|
50
|
-
kind: 'complementary',
|
|
51
|
-
orderedSlots: [
|
|
52
|
-
{ id: 'base', hue: 100 },
|
|
53
|
-
{ id: 'a', hue: 280 },
|
|
54
|
-
],
|
|
55
|
-
};
|
|
56
|
-
|
|
57
|
-
const roles: ZoraComputedHueRoles = assignZoraHarmonyRoleHues(harmony);
|
|
58
|
-
|
|
59
|
-
expectEveryRoleOnce(roles);
|
|
60
|
-
const accent: ZoraHueRoleAssignment = getZoraHueRoleAssignment(roles, 'accent');
|
|
61
|
-
const highlight: ZoraHueRoleAssignment = getZoraHueRoleAssignment(roles, 'highlight');
|
|
62
|
-
|
|
63
|
-
expect(accent.sourceSlotId).toBe('a');
|
|
64
|
-
expect(highlight.sourceSlotId).toBe('a');
|
|
65
|
-
expect(getZoraHueRoleAssignment(roles, 'primary').sourceSlotId).toBe('base');
|
|
66
|
-
expect(getZoraHueRoleAssignment(roles, 'surfaceTint').sourceSlotId).toBe('base');
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
test('analogous assigns secondary/surfaceTint to a and accent/highlight to b', () => {
|
|
70
|
-
const harmony: ZoraComputedHarmony = {
|
|
71
|
-
kind: 'analogous',
|
|
72
|
-
orderedSlots: [
|
|
73
|
-
{ id: 'base', hue: 100 },
|
|
74
|
-
{ id: 'a', hue: 70 },
|
|
75
|
-
{ id: 'b', hue: 130 },
|
|
76
|
-
],
|
|
77
|
-
};
|
|
78
|
-
|
|
79
|
-
const roles = assignZoraHarmonyRoleHues(harmony);
|
|
80
|
-
|
|
81
|
-
expectEveryRoleOnce(roles);
|
|
82
|
-
expect(getZoraHueRoleAssignment(roles, 'secondary').sourceSlotId).toBe('a');
|
|
83
|
-
expect(getZoraHueRoleAssignment(roles, 'surfaceTint').sourceSlotId).toBe('a');
|
|
84
|
-
expect(getZoraHueRoleAssignment(roles, 'accent').sourceSlotId).toBe('b');
|
|
85
|
-
expect(getZoraHueRoleAssignment(roles, 'highlight').sourceSlotId).toBe('b');
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
test('splitComplementary assigns primary/surfaceTint to base and accent/highlight to b', () => {
|
|
89
|
-
const harmony: ZoraComputedHarmony = {
|
|
90
|
-
kind: 'splitComplementary',
|
|
91
|
-
orderedSlots: [
|
|
92
|
-
{ id: 'base', hue: 100 },
|
|
93
|
-
{ id: 'a', hue: 250 },
|
|
94
|
-
{ id: 'b', hue: 310 },
|
|
95
|
-
],
|
|
96
|
-
};
|
|
97
|
-
|
|
98
|
-
const roles = assignZoraHarmonyRoleHues(harmony);
|
|
99
|
-
|
|
100
|
-
expectEveryRoleOnce(roles);
|
|
101
|
-
expect(getZoraHueRoleAssignment(roles, 'primary').sourceSlotId).toBe('base');
|
|
102
|
-
expect(getZoraHueRoleAssignment(roles, 'surfaceTint').sourceSlotId).toBe('base');
|
|
103
|
-
expect(getZoraHueRoleAssignment(roles, 'accent').sourceSlotId).toBe('b');
|
|
104
|
-
expect(getZoraHueRoleAssignment(roles, 'highlight').sourceSlotId).toBe('b');
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
test('triadic assigns primary/surfaceTint to base and secondary/accent across a/b', () => {
|
|
108
|
-
const harmony: ZoraComputedHarmony = {
|
|
109
|
-
kind: 'triadic',
|
|
110
|
-
orderedSlots: [
|
|
111
|
-
{ id: 'base', hue: 100 },
|
|
112
|
-
{ id: 'a', hue: 220 },
|
|
113
|
-
{ id: 'b', hue: 340 },
|
|
114
|
-
],
|
|
115
|
-
};
|
|
116
|
-
|
|
117
|
-
const roles = assignZoraHarmonyRoleHues(harmony);
|
|
118
|
-
|
|
119
|
-
expectEveryRoleOnce(roles);
|
|
120
|
-
expect(getZoraHueRoleAssignment(roles, 'primary').sourceSlotId).toBe('base');
|
|
121
|
-
expect(getZoraHueRoleAssignment(roles, 'surfaceTint').sourceSlotId).toBe('base');
|
|
122
|
-
expect(getZoraHueRoleAssignment(roles, 'secondary').sourceSlotId).toBe('a');
|
|
123
|
-
expect(getZoraHueRoleAssignment(roles, 'accent').sourceSlotId).toBe('b');
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
test('tetradic assigns highlight to c', () => {
|
|
127
|
-
const harmony: ZoraComputedHarmony = {
|
|
128
|
-
kind: 'tetradic',
|
|
129
|
-
orderedSlots: [
|
|
130
|
-
{ id: 'base', hue: 100 },
|
|
131
|
-
{ id: 'a', hue: 190 },
|
|
132
|
-
{ id: 'b', hue: 280 },
|
|
133
|
-
{ id: 'c', hue: 10 },
|
|
134
|
-
],
|
|
135
|
-
};
|
|
136
|
-
|
|
137
|
-
const roles = assignZoraHarmonyRoleHues(harmony);
|
|
138
|
-
|
|
139
|
-
expectEveryRoleOnce(roles);
|
|
140
|
-
expect(getZoraHueRoleAssignment(roles, 'highlight').sourceSlotId).toBe('c');
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
test('every assignment hue is normalized to [0, 360)', () => {
|
|
144
|
-
const harmony: ZoraComputedHarmony = {
|
|
145
|
-
kind: 'complementary',
|
|
146
|
-
orderedSlots: [
|
|
147
|
-
{ id: 'base', hue: -10 },
|
|
148
|
-
{ id: 'a', hue: 370 },
|
|
149
|
-
],
|
|
150
|
-
};
|
|
151
|
-
|
|
152
|
-
const roles = assignZoraHarmonyRoleHues(harmony);
|
|
153
|
-
|
|
154
|
-
expectEveryRoleOnce(roles);
|
|
155
|
-
for (const assignment of roles.assignments) {
|
|
156
|
-
expectNormalizedHue(assignment.hue);
|
|
157
|
-
}
|
|
158
|
-
});
|
|
159
|
-
|
|
160
|
-
test('is deterministic for the same computed harmony', () => {
|
|
161
|
-
const harmony: ZoraComputedHarmony = {
|
|
162
|
-
kind: 'analogous',
|
|
163
|
-
orderedSlots: [
|
|
164
|
-
{ id: 'base', hue: 100 },
|
|
165
|
-
{ id: 'a', hue: 70 },
|
|
166
|
-
{ id: 'b', hue: 130 },
|
|
167
|
-
],
|
|
168
|
-
};
|
|
169
|
-
|
|
170
|
-
expect(assignZoraHarmonyRoleHues(harmony)).toEqual(assignZoraHarmonyRoleHues(harmony));
|
|
171
|
-
});
|
|
172
|
-
|
|
173
|
-
test('falls back when a preferred non-base slot is missing', () => {
|
|
174
|
-
const harmony: ZoraComputedHarmony = {
|
|
175
|
-
kind: 'analogous',
|
|
176
|
-
orderedSlots: [
|
|
177
|
-
{ id: 'base', hue: 100 },
|
|
178
|
-
{ id: 'a', hue: 70 },
|
|
179
|
-
],
|
|
180
|
-
};
|
|
181
|
-
|
|
182
|
-
const roles = assignZoraHarmonyRoleHues(harmony);
|
|
183
|
-
|
|
184
|
-
expectEveryRoleOnce(roles);
|
|
185
|
-
expect(getZoraHueRoleAssignment(roles, 'accent').sourceSlotId).toBe('a');
|
|
186
|
-
expect(getZoraHueRoleAssignment(roles, 'highlight').sourceSlotId).toBe('a');
|
|
187
|
-
});
|
|
188
|
-
|
|
189
|
-
test('throws a clear error when the base slot is missing', () => {
|
|
190
|
-
const harmony: ZoraComputedHarmony = {
|
|
191
|
-
kind: 'complementary',
|
|
192
|
-
orderedSlots: [{ id: 'a', hue: 200 }],
|
|
193
|
-
};
|
|
194
|
-
|
|
195
|
-
expect(() => assignZoraHarmonyRoleHues(harmony)).toThrow('base');
|
|
196
|
-
});
|
|
197
|
-
});
|
|
@@ -1,142 +0,0 @@
|
|
|
1
|
-
import type { ZoraColorHarmony } from '../../theme/types';
|
|
2
|
-
import type { ZoraComputedHarmony, ZoraHarmonySlot, ZoraHarmonySlotId } from './harmony';
|
|
3
|
-
import { normalizeHueDegrees } from './hue';
|
|
4
|
-
|
|
5
|
-
export type ZoraHueRoleId = 'primary' | 'secondary' | 'accent' | 'highlight' | 'surfaceTint';
|
|
6
|
-
|
|
7
|
-
export interface ZoraHueRoleAssignment {
|
|
8
|
-
role: ZoraHueRoleId;
|
|
9
|
-
hue: number;
|
|
10
|
-
sourceSlotId: ZoraHarmonySlotId;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export interface ZoraComputedHueRoles {
|
|
14
|
-
harmony: ZoraColorHarmony;
|
|
15
|
-
assignments: readonly ZoraHueRoleAssignment[];
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
function findSlot(
|
|
19
|
-
harmony: ZoraComputedHarmony,
|
|
20
|
-
id: ZoraHarmonySlotId,
|
|
21
|
-
): ZoraHarmonySlot | undefined {
|
|
22
|
-
return harmony.orderedSlots.find((slot) => slot.id === id);
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
function requireBaseSlot(harmony: ZoraComputedHarmony): ZoraHarmonySlot {
|
|
26
|
-
const base = findSlot(harmony, 'base');
|
|
27
|
-
if (!base) {
|
|
28
|
-
throw new Error(`[zora] Expected harmony to include a base slot (kind: ${harmony.kind}).`);
|
|
29
|
-
}
|
|
30
|
-
return base;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
function assignRole(
|
|
34
|
-
role: ZoraHueRoleId,
|
|
35
|
-
harmony: ZoraComputedHarmony,
|
|
36
|
-
preferred: ZoraHarmonySlotId,
|
|
37
|
-
fallback: ZoraHarmonySlotId = 'base',
|
|
38
|
-
): ZoraHueRoleAssignment {
|
|
39
|
-
const base = requireBaseSlot(harmony);
|
|
40
|
-
const preferredSlot = findSlot(harmony, preferred);
|
|
41
|
-
const fallbackSlot = findSlot(harmony, fallback);
|
|
42
|
-
const chosenSlot = preferredSlot ?? fallbackSlot ?? base;
|
|
43
|
-
|
|
44
|
-
return {
|
|
45
|
-
role,
|
|
46
|
-
hue: normalizeHueDegrees(chosenSlot.hue),
|
|
47
|
-
sourceSlotId: chosenSlot.id,
|
|
48
|
-
};
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
export function assignZoraHarmonyRoleHues(harmony: ZoraComputedHarmony): ZoraComputedHueRoles {
|
|
52
|
-
const { kind } = harmony;
|
|
53
|
-
|
|
54
|
-
if (kind === 'monochromatic') {
|
|
55
|
-
return {
|
|
56
|
-
harmony: kind,
|
|
57
|
-
assignments: [
|
|
58
|
-
assignRole('primary', harmony, 'base'),
|
|
59
|
-
assignRole('secondary', harmony, 'base'),
|
|
60
|
-
assignRole('accent', harmony, 'base'),
|
|
61
|
-
assignRole('highlight', harmony, 'base'),
|
|
62
|
-
assignRole('surfaceTint', harmony, 'base'),
|
|
63
|
-
],
|
|
64
|
-
};
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
if (kind === 'complementary') {
|
|
68
|
-
return {
|
|
69
|
-
harmony: kind,
|
|
70
|
-
assignments: [
|
|
71
|
-
assignRole('primary', harmony, 'base'),
|
|
72
|
-
assignRole('secondary', harmony, 'base'),
|
|
73
|
-
assignRole('accent', harmony, 'a'),
|
|
74
|
-
assignRole('highlight', harmony, 'a'),
|
|
75
|
-
assignRole('surfaceTint', harmony, 'base'),
|
|
76
|
-
],
|
|
77
|
-
};
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
if (kind === 'analogous') {
|
|
81
|
-
return {
|
|
82
|
-
harmony: kind,
|
|
83
|
-
assignments: [
|
|
84
|
-
assignRole('primary', harmony, 'base'),
|
|
85
|
-
assignRole('secondary', harmony, 'a'),
|
|
86
|
-
assignRole('accent', harmony, 'b', 'a'),
|
|
87
|
-
assignRole('highlight', harmony, 'b', 'a'),
|
|
88
|
-
assignRole('surfaceTint', harmony, 'a'),
|
|
89
|
-
],
|
|
90
|
-
};
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
if (kind === 'splitComplementary') {
|
|
94
|
-
return {
|
|
95
|
-
harmony: kind,
|
|
96
|
-
assignments: [
|
|
97
|
-
assignRole('primary', harmony, 'base'),
|
|
98
|
-
assignRole('secondary', harmony, 'a'),
|
|
99
|
-
assignRole('accent', harmony, 'b', 'a'),
|
|
100
|
-
assignRole('highlight', harmony, 'b', 'a'),
|
|
101
|
-
assignRole('surfaceTint', harmony, 'base'),
|
|
102
|
-
],
|
|
103
|
-
};
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
if (kind === 'triadic') {
|
|
107
|
-
return {
|
|
108
|
-
harmony: kind,
|
|
109
|
-
assignments: [
|
|
110
|
-
assignRole('primary', harmony, 'base'),
|
|
111
|
-
assignRole('secondary', harmony, 'a'),
|
|
112
|
-
assignRole('accent', harmony, 'b', 'a'),
|
|
113
|
-
assignRole('highlight', harmony, 'b', 'a'),
|
|
114
|
-
assignRole('surfaceTint', harmony, 'base'),
|
|
115
|
-
],
|
|
116
|
-
};
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
return {
|
|
120
|
-
harmony: kind,
|
|
121
|
-
assignments: [
|
|
122
|
-
assignRole('primary', harmony, 'base'),
|
|
123
|
-
assignRole('secondary', harmony, 'a'),
|
|
124
|
-
assignRole('accent', harmony, 'b', 'a'),
|
|
125
|
-
assignRole('highlight', harmony, 'c', 'b'),
|
|
126
|
-
assignRole('surfaceTint', harmony, 'base'),
|
|
127
|
-
],
|
|
128
|
-
};
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
export function getZoraHueRoleAssignment(
|
|
132
|
-
roles: ZoraComputedHueRoles,
|
|
133
|
-
role: ZoraHueRoleId,
|
|
134
|
-
): ZoraHueRoleAssignment {
|
|
135
|
-
const found = roles.assignments.find((assignment) => assignment.role === role);
|
|
136
|
-
if (!found) {
|
|
137
|
-
throw new Error(
|
|
138
|
-
`[zora] Expected a hue-role assignment for "${role}" (harmony: ${roles.harmony}).`,
|
|
139
|
-
);
|
|
140
|
-
}
|
|
141
|
-
return found;
|
|
142
|
-
}
|
|
@@ -1,220 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from 'bun:test';
|
|
2
|
-
|
|
3
|
-
import type { ZoraColorTone, ZoraHexColor } from '../../theme/types';
|
|
4
|
-
import {
|
|
5
|
-
assignZoraHarmonyRoleHues,
|
|
6
|
-
computeZoraHarmony,
|
|
7
|
-
createZoraRoleColorScales,
|
|
8
|
-
getZoraRoleColorScale,
|
|
9
|
-
parseHexToOklch,
|
|
10
|
-
ZORA_COLOR_SCALE_ROLE_ORDER,
|
|
11
|
-
ZORA_COLOR_SCALE_STEPS,
|
|
12
|
-
type ZoraColorScale,
|
|
13
|
-
type ZoraColorScaleStep,
|
|
14
|
-
type ZoraComputedHueRoles,
|
|
15
|
-
type ZoraRoleColorScale,
|
|
16
|
-
} from './index';
|
|
17
|
-
|
|
18
|
-
function isSixDigitLowercaseHexColor(value: string): value is ZoraHexColor {
|
|
19
|
-
return /^#[0-9a-f]{6}$/.test(value);
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
function hueDeltaDegrees(a: number, b: number): number {
|
|
23
|
-
const raw = Math.abs(a - b) % 360;
|
|
24
|
-
return Math.min(raw, 360 - raw);
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
function getStepValues(scale: ZoraColorScale): { step: ZoraColorScaleStep; hex: ZoraHexColor }[] {
|
|
28
|
-
return ZORA_COLOR_SCALE_STEPS.map((step) => ({ step, hex: scale[step] }));
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
function assertScaleKeys(scale: ZoraColorScale) {
|
|
32
|
-
const keys = Object.keys(scale)
|
|
33
|
-
.map((key) => Number(key))
|
|
34
|
-
.sort((a, b) => a - b);
|
|
35
|
-
|
|
36
|
-
expect(keys).toEqual([...ZORA_COLOR_SCALE_STEPS]);
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function assertNoPureBlackOrWhite(scale: ZoraColorScale) {
|
|
40
|
-
const values = getStepValues(scale).map(({ hex }) => hex);
|
|
41
|
-
expect(values).not.toContain('#000000');
|
|
42
|
-
expect(values).not.toContain('#ffffff');
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
function buildHueRoles(assignments: ZoraComputedHueRoles['assignments']): ZoraComputedHueRoles {
|
|
46
|
-
return {
|
|
47
|
-
harmony: 'tetradic',
|
|
48
|
-
assignments,
|
|
49
|
-
};
|
|
50
|
-
}
|
|
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
|
-
|
|
70
|
-
function requireHueBackedSourceHue(role: ZoraRoleColorScale): number {
|
|
71
|
-
if (typeof role.sourceHue !== 'number') {
|
|
72
|
-
throw new Error(`[zora] Expected "${role.role}" role scale to include a sourceHue.`);
|
|
73
|
-
}
|
|
74
|
-
return role.sourceHue;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
describe('createZoraRoleColorScales', () => {
|
|
78
|
-
test('returns all roles exactly once in deterministic order', () => {
|
|
79
|
-
const scales = createScales();
|
|
80
|
-
|
|
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]);
|
|
83
|
-
expect(new Set(scales.roles.map((entry) => entry.role)).size).toBe(
|
|
84
|
-
ZORA_COLOR_SCALE_ROLE_ORDER.length,
|
|
85
|
-
);
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
test('every scale contains exactly all 50–950 keys and valid lowercase 6-digit hex values', () => {
|
|
89
|
-
const scales = createScales();
|
|
90
|
-
|
|
91
|
-
for (const role of scales.roles) {
|
|
92
|
-
assertScaleKeys(role.scale);
|
|
93
|
-
for (const { hex } of getStepValues(role.scale)) {
|
|
94
|
-
expect(isSixDigitLowercaseHexColor(hex)).toBe(true);
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
test('output is deterministic for the same input', () => {
|
|
100
|
-
expect(createScales()).toEqual(createScales());
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
test('role hue is preserved approximately for mid steps', () => {
|
|
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
|
-
});
|
|
115
|
-
|
|
116
|
-
const midSteps: ZoraColorScaleStep[] = [400, 500, 600, 700];
|
|
117
|
-
for (const roleId of ['primary', 'secondary', 'accent', 'highlight', 'surfaceTint'] as const) {
|
|
118
|
-
const role = getZoraRoleColorScale(scales, roleId);
|
|
119
|
-
const expectedHue = requireHueBackedSourceHue(role);
|
|
120
|
-
|
|
121
|
-
for (const step of midSteps) {
|
|
122
|
-
const oklch = parseHexToOklch(role.scale[step]);
|
|
123
|
-
expect(hueDeltaDegrees(expectedHue, oklch.h)).toBeLessThan(25);
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
test('surfaceTint scale has lower chroma than primary/accent/highlight for mid steps', () => {
|
|
129
|
-
const scales = createScales('jewel');
|
|
130
|
-
const surfaceTint = getZoraRoleColorScale(scales, 'surfaceTint').scale;
|
|
131
|
-
const primary = getZoraRoleColorScale(scales, 'primary').scale;
|
|
132
|
-
const accent = getZoraRoleColorScale(scales, 'accent').scale;
|
|
133
|
-
const highlight = getZoraRoleColorScale(scales, 'highlight').scale;
|
|
134
|
-
const step: ZoraColorScaleStep = 500;
|
|
135
|
-
const surfaceTintChroma = parseHexToOklch(surfaceTint[step]).c;
|
|
136
|
-
|
|
137
|
-
expect(surfaceTintChroma).toBeLessThan(parseHexToOklch(primary[step]).c);
|
|
138
|
-
expect(surfaceTintChroma).toBeLessThan(parseHexToOklch(accent[step]).c);
|
|
139
|
-
expect(surfaceTintChroma).toBeLessThan(parseHexToOklch(highlight[step]).c);
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
test('colorTone changes role chroma behavior internally', () => {
|
|
143
|
-
const neutral = createScales('neutral');
|
|
144
|
-
const fluorescent = createScales('fluorescent');
|
|
145
|
-
const step: ZoraColorScaleStep = 500;
|
|
146
|
-
|
|
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;
|
|
172
|
-
|
|
173
|
-
for (const { hex } of getStepValues(neutral)) {
|
|
174
|
-
expect(parseHexToOklch(hex).c).toBeLessThanOrEqual(0.03);
|
|
175
|
-
}
|
|
176
|
-
});
|
|
177
|
-
|
|
178
|
-
test('missing required hue role throws a clear error', () => {
|
|
179
|
-
const hueRoles = buildHueRoles([
|
|
180
|
-
{ role: 'primary', hue: 120, sourceSlotId: 'base' },
|
|
181
|
-
{ role: 'secondary', hue: 200, sourceSlotId: 'a' },
|
|
182
|
-
{ role: 'accent', hue: 280, sourceSlotId: 'b' },
|
|
183
|
-
{ role: 'surfaceTint', hue: 160, sourceSlotId: 'a' },
|
|
184
|
-
]);
|
|
185
|
-
|
|
186
|
-
expect(() =>
|
|
187
|
-
createZoraRoleColorScales({ colorTone: 'jewel', hueRoles, seed: '#0f766e' }),
|
|
188
|
-
).toThrow('highlight');
|
|
189
|
-
});
|
|
190
|
-
|
|
191
|
-
test('no role scale contains pure black/white by default', () => {
|
|
192
|
-
const scales = createScales();
|
|
193
|
-
|
|
194
|
-
for (const role of scales.roles) {
|
|
195
|
-
assertNoPureBlackOrWhite(role.scale);
|
|
196
|
-
}
|
|
197
|
-
});
|
|
198
|
-
|
|
199
|
-
test('does not mutate input hueRoles', () => {
|
|
200
|
-
const hueRoles = createCompleteHueRoles();
|
|
201
|
-
const snapshot = JSON.stringify(hueRoles);
|
|
202
|
-
|
|
203
|
-
createZoraRoleColorScales({ colorTone: 'jewel', hueRoles, seed: '#0f766e' });
|
|
204
|
-
|
|
205
|
-
expect(JSON.stringify(hueRoles)).toBe(snapshot);
|
|
206
|
-
});
|
|
207
|
-
|
|
208
|
-
test('integration: harmony -> role hues -> role scales pipeline connects', () => {
|
|
209
|
-
const seed: ZoraHexColor = '#0f766e';
|
|
210
|
-
const harmony = computeZoraHarmony(seed, 'tetradic');
|
|
211
|
-
const hueRoles = assignZoraHarmonyRoleHues(harmony);
|
|
212
|
-
|
|
213
|
-
const scales = createZoraRoleColorScales({ colorTone: 'jewel', hueRoles, seed });
|
|
214
|
-
|
|
215
|
-
expect(scales.roles.map((entry) => entry.role)).toEqual([...ZORA_COLOR_SCALE_ROLE_ORDER]);
|
|
216
|
-
for (const role of scales.roles) {
|
|
217
|
-
assertScaleKeys(role.scale);
|
|
218
|
-
}
|
|
219
|
-
});
|
|
220
|
-
});
|
|
@@ -1,127 +0,0 @@
|
|
|
1
|
-
import type { ZoraColorTone, ZoraHexColor } from '../../theme/types';
|
|
2
|
-
import { getZoraColorToneRecipe } from './colorToneRecipes';
|
|
3
|
-
import { parseHexToOklch } from './oklch';
|
|
4
|
-
import {
|
|
5
|
-
getZoraHueRoleAssignment,
|
|
6
|
-
type ZoraComputedHueRoles,
|
|
7
|
-
type ZoraHueRoleId,
|
|
8
|
-
} from './roleHues';
|
|
9
|
-
import {
|
|
10
|
-
createZoraHueScale,
|
|
11
|
-
type CreateZoraHueScaleOptions,
|
|
12
|
-
createZoraNeutralScale,
|
|
13
|
-
type ZoraHueScaleRoleId,
|
|
14
|
-
} from './scales';
|
|
15
|
-
import type { ZoraColorScale } from './types';
|
|
16
|
-
|
|
17
|
-
export type ZoraColorScaleRoleId =
|
|
18
|
-
| 'primary'
|
|
19
|
-
| 'secondary'
|
|
20
|
-
| 'accent'
|
|
21
|
-
| 'highlight'
|
|
22
|
-
| 'surfaceTint'
|
|
23
|
-
| 'neutral';
|
|
24
|
-
|
|
25
|
-
export const ZORA_COLOR_SCALE_ROLE_ORDER: readonly ZoraColorScaleRoleId[] = [
|
|
26
|
-
'primary',
|
|
27
|
-
'secondary',
|
|
28
|
-
'accent',
|
|
29
|
-
'highlight',
|
|
30
|
-
'surfaceTint',
|
|
31
|
-
'neutral',
|
|
32
|
-
];
|
|
33
|
-
|
|
34
|
-
export interface ZoraRoleColorScale {
|
|
35
|
-
role: ZoraColorScaleRoleId;
|
|
36
|
-
sourceHue?: number;
|
|
37
|
-
scale: ZoraColorScale;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
export interface ZoraComputedRoleColorScales {
|
|
41
|
-
roles: readonly ZoraRoleColorScale[];
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
export function getZoraRoleColorScale(
|
|
45
|
-
scales: ZoraComputedRoleColorScales,
|
|
46
|
-
role: ZoraColorScaleRoleId,
|
|
47
|
-
): ZoraRoleColorScale {
|
|
48
|
-
const found = scales.roles.find((entry) => entry.role === role);
|
|
49
|
-
if (!found) {
|
|
50
|
-
throw new Error(`[zora] Expected role color scales to include "${role}".`);
|
|
51
|
-
}
|
|
52
|
-
return found;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
function resolveSeedChroma(seed: ZoraHexColor): number {
|
|
56
|
-
return parseHexToOklch(seed).c;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
function createHueBackedRoleScale(options: {
|
|
60
|
-
colorTone: ZoraColorTone;
|
|
61
|
-
hueRoles: ZoraComputedHueRoles;
|
|
62
|
-
seedChroma: number;
|
|
63
|
-
role: ZoraHueRoleId;
|
|
64
|
-
}): ZoraRoleColorScale {
|
|
65
|
-
const assignment = getZoraHueRoleAssignment(options.hueRoles, options.role);
|
|
66
|
-
const colorToneRecipe = getZoraColorToneRecipe(options.colorTone);
|
|
67
|
-
const hueScaleRole: ZoraHueScaleRoleId = options.role;
|
|
68
|
-
const hueScaleOptions: CreateZoraHueScaleOptions = {
|
|
69
|
-
hue: assignment.hue,
|
|
70
|
-
seedChroma: options.seedChroma,
|
|
71
|
-
role: hueScaleRole,
|
|
72
|
-
colorToneRecipe,
|
|
73
|
-
};
|
|
74
|
-
|
|
75
|
-
return {
|
|
76
|
-
role: options.role,
|
|
77
|
-
sourceHue: assignment.hue,
|
|
78
|
-
scale: createZoraHueScale(hueScaleOptions),
|
|
79
|
-
};
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
export function createZoraRoleColorScales(options: {
|
|
83
|
-
hueRoles: ZoraComputedHueRoles;
|
|
84
|
-
seed: ZoraHexColor;
|
|
85
|
-
colorTone: ZoraColorTone;
|
|
86
|
-
}): ZoraComputedRoleColorScales {
|
|
87
|
-
const seedChroma = resolveSeedChroma(options.seed);
|
|
88
|
-
|
|
89
|
-
const roles: ZoraRoleColorScale[] = [
|
|
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
|
-
}),
|
|
120
|
-
{
|
|
121
|
-
role: 'neutral',
|
|
122
|
-
scale: createZoraNeutralScale(options.seed),
|
|
123
|
-
},
|
|
124
|
-
];
|
|
125
|
-
|
|
126
|
-
return { roles };
|
|
127
|
-
}
|