@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,145 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from 'bun:test';
|
|
2
|
-
|
|
3
|
-
import type { ZoraHexColor } from '../../theme/types';
|
|
4
|
-
import { normalizeHueDegrees } from './hue';
|
|
5
|
-
import {
|
|
6
|
-
computeZoraHarmony,
|
|
7
|
-
parseHexToOklch,
|
|
8
|
-
type ZoraComputedHarmony,
|
|
9
|
-
type ZoraHarmonySlot,
|
|
10
|
-
type ZoraHarmonySlotId,
|
|
11
|
-
} from './index';
|
|
12
|
-
|
|
13
|
-
function hueDeltaDegrees(a: number, b: number): number {
|
|
14
|
-
const raw = Math.abs(a - b) % 360;
|
|
15
|
-
return Math.min(raw, 360 - raw);
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
function expectNormalizedHue(hue: number) {
|
|
19
|
-
expect(hue).toBeGreaterThanOrEqual(0);
|
|
20
|
-
expect(hue).toBeLessThan(360);
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
function getSlotIds(slots: readonly ZoraHarmonySlot[]): ZoraHarmonySlotId[] {
|
|
24
|
-
return slots.map((slot) => slot.id);
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
describe('computeZoraHarmony', () => {
|
|
28
|
-
test('monochromatic returns 1 slot', () => {
|
|
29
|
-
const seed: ZoraHexColor = '#0f766e';
|
|
30
|
-
const harmony: ZoraComputedHarmony = computeZoraHarmony(seed, 'monochromatic');
|
|
31
|
-
|
|
32
|
-
expect(harmony.orderedSlots).toHaveLength(1);
|
|
33
|
-
expect(harmony.orderedSlots[0].id).toBe('base');
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
test('complementary returns 2 slots', () => {
|
|
37
|
-
const seed: ZoraHexColor = '#0f766e';
|
|
38
|
-
const harmony: ZoraComputedHarmony = computeZoraHarmony(seed, 'complementary');
|
|
39
|
-
|
|
40
|
-
expect(harmony.orderedSlots).toHaveLength(2);
|
|
41
|
-
expect(harmony.orderedSlots[0].id).toBe('base');
|
|
42
|
-
expect(harmony.orderedSlots[1].id).toBe('a');
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
test('analogous returns 3 slots', () => {
|
|
46
|
-
const seed: ZoraHexColor = '#0f766e';
|
|
47
|
-
const harmony: ZoraComputedHarmony = computeZoraHarmony(seed, 'analogous');
|
|
48
|
-
|
|
49
|
-
expect(harmony.orderedSlots).toHaveLength(3);
|
|
50
|
-
expect(getSlotIds(harmony.orderedSlots)).toEqual(['base', 'a', 'b']);
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
test('splitComplementary returns 3 slots', () => {
|
|
54
|
-
const seed: ZoraHexColor = '#0f766e';
|
|
55
|
-
const harmony: ZoraComputedHarmony = computeZoraHarmony(seed, 'splitComplementary');
|
|
56
|
-
|
|
57
|
-
expect(harmony.orderedSlots).toHaveLength(3);
|
|
58
|
-
expect(getSlotIds(harmony.orderedSlots)).toEqual(['base', 'a', 'b']);
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
test('triadic returns 3 slots', () => {
|
|
62
|
-
const seed: ZoraHexColor = '#0f766e';
|
|
63
|
-
const harmony: ZoraComputedHarmony = computeZoraHarmony(seed, 'triadic');
|
|
64
|
-
|
|
65
|
-
expect(harmony.orderedSlots).toHaveLength(3);
|
|
66
|
-
expect(getSlotIds(harmony.orderedSlots)).toEqual(['base', 'a', 'b']);
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
test('tetradic returns 4 slots', () => {
|
|
70
|
-
const seed: ZoraHexColor = '#0f766e';
|
|
71
|
-
const harmony: ZoraComputedHarmony = computeZoraHarmony(seed, 'tetradic');
|
|
72
|
-
|
|
73
|
-
expect(harmony.orderedSlots).toHaveLength(4);
|
|
74
|
-
expect(getSlotIds(harmony.orderedSlots)).toEqual(['base', 'a', 'b', 'c']);
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
test('every computed hue is normalized to [0, 360)', () => {
|
|
78
|
-
const seed: ZoraHexColor = '#0f766e';
|
|
79
|
-
const harmonies = [
|
|
80
|
-
'monochromatic',
|
|
81
|
-
'complementary',
|
|
82
|
-
'analogous',
|
|
83
|
-
'splitComplementary',
|
|
84
|
-
'triadic',
|
|
85
|
-
'tetradic',
|
|
86
|
-
] as const;
|
|
87
|
-
|
|
88
|
-
for (const kind of harmonies) {
|
|
89
|
-
const computed = computeZoraHarmony(seed, kind);
|
|
90
|
-
for (const slot of computed.orderedSlots) {
|
|
91
|
-
expectNormalizedHue(slot.hue);
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
test('base slot approximately matches parsed seed hue for a non-neutral seed', () => {
|
|
97
|
-
const seed: ZoraHexColor = '#ff00ff';
|
|
98
|
-
const seedOklch = parseHexToOklch(seed);
|
|
99
|
-
|
|
100
|
-
const computed = computeZoraHarmony(seed, 'complementary');
|
|
101
|
-
const [base] = computed.orderedSlots;
|
|
102
|
-
|
|
103
|
-
expect(base).toBeDefined();
|
|
104
|
-
if (!base) {
|
|
105
|
-
throw new Error('Expected a base harmony slot.');
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
expect(base.id).toBe('base');
|
|
109
|
-
expect(hueDeltaDegrees(seedOklch.h, base.hue)).toBeLessThan(0.5);
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
test('neutral/low-chroma seed uses a stable fallback hue', () => {
|
|
113
|
-
const seed: ZoraHexColor = '#808080';
|
|
114
|
-
const computed = computeZoraHarmony(seed, 'monochromatic');
|
|
115
|
-
|
|
116
|
-
expect(computed.orderedSlots).toHaveLength(1);
|
|
117
|
-
expect(computed.orderedSlots[0].hue).toBe(260);
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
test('is deterministic for the same seed/harmony', () => {
|
|
121
|
-
const seed: ZoraHexColor = '#0f766e';
|
|
122
|
-
|
|
123
|
-
expect(computeZoraHarmony(seed, 'triadic')).toEqual(computeZoraHarmony(seed, 'triadic'));
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
test('complementary slots are based on base hue + 180', () => {
|
|
127
|
-
const seed: ZoraHexColor = '#0f766e';
|
|
128
|
-
const computed = computeZoraHarmony(seed, 'complementary');
|
|
129
|
-
const [base, a] = computed.orderedSlots;
|
|
130
|
-
|
|
131
|
-
expect(base).toBeDefined();
|
|
132
|
-
expect(a).toBeDefined();
|
|
133
|
-
if (!base || !a) {
|
|
134
|
-
throw new Error('Expected complementary harmony slots.');
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
expect(hueDeltaDegrees(a.hue, normalizeHueDegrees(base.hue + 180))).toBeLessThan(0.0001);
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
test('throws on invalid seed in test/development', () => {
|
|
141
|
-
const invalid: ZoraHexColor = '#nope';
|
|
142
|
-
|
|
143
|
-
expect(() => computeZoraHarmony(invalid, 'triadic')).toThrow();
|
|
144
|
-
});
|
|
145
|
-
});
|
|
@@ -1,96 +0,0 @@
|
|
|
1
|
-
import type { ZoraColorHarmony, ZoraHexColor } from '../../theme/types';
|
|
2
|
-
import { normalizeHueDegrees, rotateHueDegrees } from './hue';
|
|
3
|
-
import { parseHexToOklch } from './oklch';
|
|
4
|
-
|
|
5
|
-
export type ZoraHarmonySlotId = 'base' | 'a' | 'b' | 'c';
|
|
6
|
-
|
|
7
|
-
export interface ZoraHarmonySlot {
|
|
8
|
-
id: ZoraHarmonySlotId;
|
|
9
|
-
hue: number;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
export interface ZoraComputedHarmony {
|
|
13
|
-
kind: ZoraColorHarmony;
|
|
14
|
-
orderedSlots: readonly ZoraHarmonySlot[];
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
const DEFAULT_HARMONY_HUE_DEGREES = 260;
|
|
18
|
-
const MIN_SEED_CHROMA_FOR_HARMONY_HUE = 0.03;
|
|
19
|
-
|
|
20
|
-
function createSlot(id: ZoraHarmonySlotId, hue: number): ZoraHarmonySlot {
|
|
21
|
-
return { id, hue: normalizeHueDegrees(hue) };
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
function resolveSeedHueDegrees(seed: ZoraHexColor): number {
|
|
25
|
-
const parsed = parseHexToOklch(seed);
|
|
26
|
-
|
|
27
|
-
if (!Number.isFinite(parsed.h) || parsed.c < MIN_SEED_CHROMA_FOR_HARMONY_HUE) {
|
|
28
|
-
return DEFAULT_HARMONY_HUE_DEGREES;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
return normalizeHueDegrees(parsed.h);
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
export function computeZoraHarmony(
|
|
35
|
-
seed: ZoraHexColor,
|
|
36
|
-
harmony: ZoraColorHarmony,
|
|
37
|
-
): ZoraComputedHarmony {
|
|
38
|
-
const baseHue = resolveSeedHueDegrees(seed);
|
|
39
|
-
|
|
40
|
-
if (harmony === 'monochromatic') {
|
|
41
|
-
return {
|
|
42
|
-
kind: harmony,
|
|
43
|
-
orderedSlots: [createSlot('base', baseHue)],
|
|
44
|
-
};
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
if (harmony === 'complementary') {
|
|
48
|
-
return {
|
|
49
|
-
kind: harmony,
|
|
50
|
-
orderedSlots: [createSlot('base', baseHue), createSlot('a', rotateHueDegrees(baseHue, 180))],
|
|
51
|
-
};
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
if (harmony === 'analogous') {
|
|
55
|
-
return {
|
|
56
|
-
kind: harmony,
|
|
57
|
-
orderedSlots: [
|
|
58
|
-
createSlot('base', baseHue),
|
|
59
|
-
createSlot('a', rotateHueDegrees(baseHue, -30)),
|
|
60
|
-
createSlot('b', rotateHueDegrees(baseHue, 30)),
|
|
61
|
-
],
|
|
62
|
-
};
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
if (harmony === 'splitComplementary') {
|
|
66
|
-
return {
|
|
67
|
-
kind: harmony,
|
|
68
|
-
orderedSlots: [
|
|
69
|
-
createSlot('base', baseHue),
|
|
70
|
-
createSlot('a', rotateHueDegrees(baseHue, 150)),
|
|
71
|
-
createSlot('b', rotateHueDegrees(baseHue, 210)),
|
|
72
|
-
],
|
|
73
|
-
};
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
if (harmony === 'triadic') {
|
|
77
|
-
return {
|
|
78
|
-
kind: harmony,
|
|
79
|
-
orderedSlots: [
|
|
80
|
-
createSlot('base', baseHue),
|
|
81
|
-
createSlot('a', rotateHueDegrees(baseHue, 120)),
|
|
82
|
-
createSlot('b', rotateHueDegrees(baseHue, 240)),
|
|
83
|
-
],
|
|
84
|
-
};
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
return {
|
|
88
|
-
kind: harmony,
|
|
89
|
-
orderedSlots: [
|
|
90
|
-
createSlot('base', baseHue),
|
|
91
|
-
createSlot('a', rotateHueDegrees(baseHue, 90)),
|
|
92
|
-
createSlot('b', rotateHueDegrees(baseHue, 180)),
|
|
93
|
-
createSlot('c', rotateHueDegrees(baseHue, 270)),
|
|
94
|
-
],
|
|
95
|
-
};
|
|
96
|
-
}
|
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from 'bun:test';
|
|
2
|
-
|
|
3
|
-
import { normalizeHueDegrees, rotateHueDegrees } from './hue';
|
|
4
|
-
|
|
5
|
-
describe('normalizeHueDegrees', () => {
|
|
6
|
-
test('normalizes negative hues into [0, 360)', () => {
|
|
7
|
-
expect(normalizeHueDegrees(-30)).toBe(330);
|
|
8
|
-
expect(normalizeHueDegrees(-390)).toBe(330);
|
|
9
|
-
});
|
|
10
|
-
|
|
11
|
-
test('normalizes hues >= 360 into [0, 360)', () => {
|
|
12
|
-
expect(normalizeHueDegrees(390)).toBe(30);
|
|
13
|
-
expect(normalizeHueDegrees(720)).toBe(0);
|
|
14
|
-
});
|
|
15
|
-
|
|
16
|
-
test('preserves in-range hues', () => {
|
|
17
|
-
expect(normalizeHueDegrees(0)).toBe(0);
|
|
18
|
-
expect(normalizeHueDegrees(12.5)).toBe(12.5);
|
|
19
|
-
expect(normalizeHueDegrees(359.99)).toBe(359.99);
|
|
20
|
-
});
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
describe('rotateHueDegrees', () => {
|
|
24
|
-
test('rotates and normalizes', () => {
|
|
25
|
-
expect(rotateHueDegrees(350, 30)).toBe(20);
|
|
26
|
-
expect(rotateHueDegrees(10, -30)).toBe(340);
|
|
27
|
-
});
|
|
28
|
-
});
|
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
export {
|
|
2
|
-
getZoraColorToneRecipe,
|
|
3
|
-
getZoraColorToneRoleChromaFactor,
|
|
4
|
-
type ZoraColorToneLaneRecipe,
|
|
5
|
-
type ZoraColorToneRecipe,
|
|
6
|
-
type ZoraColorToneRoleChromaFactors,
|
|
7
|
-
} from './colorToneRecipes';
|
|
8
|
-
export {
|
|
9
|
-
computeZoraHarmony,
|
|
10
|
-
type ZoraComputedHarmony,
|
|
11
|
-
type ZoraHarmonySlot,
|
|
12
|
-
type ZoraHarmonySlotId,
|
|
13
|
-
} from './harmony';
|
|
14
|
-
export { parseHexToOklch } from './oklch';
|
|
15
|
-
export { resolveModePrimaryColor } from './primary';
|
|
16
|
-
export {
|
|
17
|
-
assignZoraHarmonyRoleHues,
|
|
18
|
-
getZoraHueRoleAssignment,
|
|
19
|
-
type ZoraComputedHueRoles,
|
|
20
|
-
type ZoraHueRoleAssignment,
|
|
21
|
-
type ZoraHueRoleId,
|
|
22
|
-
} from './roleHues';
|
|
23
|
-
export {
|
|
24
|
-
createZoraRoleColorScales,
|
|
25
|
-
getZoraRoleColorScale,
|
|
26
|
-
ZORA_COLOR_SCALE_ROLE_ORDER,
|
|
27
|
-
type ZoraColorScaleRoleId,
|
|
28
|
-
type ZoraComputedRoleColorScales,
|
|
29
|
-
type ZoraRoleColorScale,
|
|
30
|
-
} from './roleScales';
|
|
31
|
-
export {
|
|
32
|
-
createZoraColorScale,
|
|
33
|
-
type CreateZoraColorScaleOptions,
|
|
34
|
-
type CreateZoraHueScaleOptions,
|
|
35
|
-
createZoraNeutralScale,
|
|
36
|
-
createZoraPrimaryScale,
|
|
37
|
-
type ZoraHueScaleRoleId,
|
|
38
|
-
} from './scales';
|
|
39
|
-
export {
|
|
40
|
-
createZoraSemanticColorTokens,
|
|
41
|
-
getReadableTextColor,
|
|
42
|
-
type ZoraSemanticColorTokens,
|
|
43
|
-
} from './semanticTokens';
|
|
44
|
-
export { ZORA_COLOR_SCALE_STEPS, type ZoraColorScale, type ZoraColorScaleStep } from './types';
|
|
@@ -1,65 +0,0 @@
|
|
|
1
|
-
import { converter, formatHex, parse, toGamut } from 'culori';
|
|
2
|
-
|
|
3
|
-
import type { ZoraHexColor } from '../../theme/types';
|
|
4
|
-
import { normalizeHueDegrees } from './hue';
|
|
5
|
-
import type { ZoraOklchColor } from './types';
|
|
6
|
-
|
|
7
|
-
const toOklch = converter('oklch');
|
|
8
|
-
const gamutMapToSrgb = toGamut('rgb', 'oklch');
|
|
9
|
-
|
|
10
|
-
function isSixDigitHexColor(value: string): value is ZoraHexColor {
|
|
11
|
-
return /^#[0-9a-fA-F]{6}$/.test(value);
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export function parseHexToOklch(hex: ZoraHexColor): ZoraOklchColor {
|
|
15
|
-
if (!isSixDigitHexColor(hex)) {
|
|
16
|
-
throw new Error(`Expected a 6-digit hex color like '#0f766e', got '${String(hex)}'.`);
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
const parsed = parse(hex);
|
|
20
|
-
if (!parsed) {
|
|
21
|
-
throw new Error(`Unable to parse hex color '${String(hex)}'.`);
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
const oklch = toOklch(parsed);
|
|
25
|
-
if (typeof oklch.l !== 'number' || typeof oklch.c !== 'number') {
|
|
26
|
-
throw new Error(`Unable to convert hex color '${String(hex)}' to OKLCH.`);
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
return {
|
|
30
|
-
l: oklch.l,
|
|
31
|
-
c: oklch.c,
|
|
32
|
-
h: normalizeHueDegrees(typeof oklch.h === 'number' ? oklch.h : 0),
|
|
33
|
-
};
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export function formatOklchAsHex(color: ZoraOklchColor): ZoraHexColor {
|
|
37
|
-
const mapped = gamutMapToSrgb({ mode: 'oklch', l: color.l, c: color.c, h: color.h });
|
|
38
|
-
const hex = formatHex(mapped);
|
|
39
|
-
|
|
40
|
-
if (!hex || !isSixDigitHexColor(hex)) {
|
|
41
|
-
throw new Error('Unable to format OKLCH color as a 6-digit hex value.');
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
const normalized = hex.toLowerCase();
|
|
45
|
-
if (!isSixDigitHexColor(normalized)) {
|
|
46
|
-
throw new Error('Unable to format OKLCH color as a 6-digit hex value.');
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
return normalized;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
export function clampOklchToGamut(color: ZoraOklchColor): ZoraOklchColor {
|
|
53
|
-
const mapped = gamutMapToSrgb({ mode: 'oklch', l: color.l, c: color.c, h: color.h });
|
|
54
|
-
const clamped = toOklch(mapped);
|
|
55
|
-
|
|
56
|
-
if (typeof clamped.l !== 'number' || typeof clamped.c !== 'number') {
|
|
57
|
-
throw new Error('Unable to clamp OKLCH color to sRGB gamut.');
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
return {
|
|
61
|
-
l: clamped.l,
|
|
62
|
-
c: clamped.c,
|
|
63
|
-
h: normalizeHueDegrees(typeof clamped.h === 'number' ? clamped.h : 0),
|
|
64
|
-
};
|
|
65
|
-
}
|
|
@@ -1,105 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from 'bun:test';
|
|
2
|
-
|
|
3
|
-
import type { ZoraHexColor } from '../../theme/types';
|
|
4
|
-
import { parseHexToOklch } from './oklch';
|
|
5
|
-
import { resolveModePrimaryColor } from './primary';
|
|
6
|
-
|
|
7
|
-
function isSixDigitHexColor(value: string): value is ZoraHexColor {
|
|
8
|
-
return /^#[0-9a-f]{6}$/.test(value);
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
function hueDeltaDegrees(a: number, b: number): number {
|
|
12
|
-
const raw = Math.abs(a - b) % 360;
|
|
13
|
-
return Math.min(raw, 360 - raw);
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
describe('resolveModePrimaryColor', () => {
|
|
17
|
-
test('returns valid hex for light and dark mode', () => {
|
|
18
|
-
const seed: ZoraHexColor = '#0f766e';
|
|
19
|
-
|
|
20
|
-
const light = resolveModePrimaryColor(seed, 'light');
|
|
21
|
-
const dark = resolveModePrimaryColor(seed, 'dark');
|
|
22
|
-
|
|
23
|
-
expect(isSixDigitHexColor(light)).toBe(true);
|
|
24
|
-
expect(isSixDigitHexColor(dark)).toBe(true);
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
test('derives different colors for light vs dark for a typical saturated seed', () => {
|
|
28
|
-
const seed: ZoraHexColor = '#0f766e';
|
|
29
|
-
|
|
30
|
-
const light = resolveModePrimaryColor(seed, 'light');
|
|
31
|
-
const dark = resolveModePrimaryColor(seed, 'dark');
|
|
32
|
-
|
|
33
|
-
expect(light).not.toBe(dark);
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
test('dark derived primary has higher OKLCH lightness than the light derived primary', () => {
|
|
37
|
-
const seed: ZoraHexColor = '#0f766e';
|
|
38
|
-
|
|
39
|
-
const light = resolveModePrimaryColor(seed, 'light');
|
|
40
|
-
const dark = resolveModePrimaryColor(seed, 'dark');
|
|
41
|
-
|
|
42
|
-
const lightOklch = parseHexToOklch(light);
|
|
43
|
-
const darkOklch = parseHexToOklch(dark);
|
|
44
|
-
|
|
45
|
-
expect(darkOklch.l).toBeGreaterThan(lightOklch.l);
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
test('preserves hue approximately for a non-neutral seed', () => {
|
|
49
|
-
const seed: ZoraHexColor = '#0f766e';
|
|
50
|
-
const seedOklch = parseHexToOklch(seed);
|
|
51
|
-
|
|
52
|
-
const light = resolveModePrimaryColor(seed, 'light');
|
|
53
|
-
const dark = resolveModePrimaryColor(seed, 'dark');
|
|
54
|
-
|
|
55
|
-
const lightOklch = parseHexToOklch(light);
|
|
56
|
-
const darkOklch = parseHexToOklch(dark);
|
|
57
|
-
|
|
58
|
-
expect(hueDeltaDegrees(seedOklch.h, lightOklch.h)).toBeLessThan(20);
|
|
59
|
-
expect(hueDeltaDegrees(seedOklch.h, darkOklch.h)).toBeLessThan(20);
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
test('bounds chroma', () => {
|
|
63
|
-
const seed: ZoraHexColor = '#ff00ff';
|
|
64
|
-
|
|
65
|
-
const light = resolveModePrimaryColor(seed, 'light');
|
|
66
|
-
const dark = resolveModePrimaryColor(seed, 'dark');
|
|
67
|
-
|
|
68
|
-
const lightOklch = parseHexToOklch(light);
|
|
69
|
-
const darkOklch = parseHexToOklch(dark);
|
|
70
|
-
|
|
71
|
-
expect(lightOklch.c).toBeLessThanOrEqual(0.18 + 0.01);
|
|
72
|
-
expect(darkOklch.c).toBeLessThanOrEqual(0.2 + 0.01);
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
test('handles low-chroma colors without crashing', () => {
|
|
76
|
-
const seed: ZoraHexColor = '#808080';
|
|
77
|
-
|
|
78
|
-
const light = resolveModePrimaryColor(seed, 'light');
|
|
79
|
-
const dark = resolveModePrimaryColor(seed, 'dark');
|
|
80
|
-
|
|
81
|
-
expect(isSixDigitHexColor(light)).toBe(true);
|
|
82
|
-
expect(isSixDigitHexColor(dark)).toBe(true);
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
test('throws on invalid hex in test/development', () => {
|
|
86
|
-
const invalid: ZoraHexColor = '#nope';
|
|
87
|
-
|
|
88
|
-
expect(() => resolveModePrimaryColor(invalid, 'light')).toThrow();
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
test('falls back in production when input is invalid', () => {
|
|
92
|
-
const invalid: ZoraHexColor = '#nope';
|
|
93
|
-
|
|
94
|
-
const originalEnv = process.env.NODE_ENV;
|
|
95
|
-
process.env.NODE_ENV = 'production';
|
|
96
|
-
|
|
97
|
-
try {
|
|
98
|
-
const resolved = resolveModePrimaryColor(invalid, 'light');
|
|
99
|
-
expect(resolved).not.toBe('#0f766e');
|
|
100
|
-
expect(resolved).toBe(resolveModePrimaryColor('#0f766e', 'light'));
|
|
101
|
-
} finally {
|
|
102
|
-
process.env.NODE_ENV = originalEnv;
|
|
103
|
-
}
|
|
104
|
-
});
|
|
105
|
-
});
|
|
@@ -1,64 +0,0 @@
|
|
|
1
|
-
import type { ZoraHexColor, ZoraThemeMode } from '../../theme/types';
|
|
2
|
-
import { clampOklchToGamut, formatOklchAsHex, parseHexToOklch } from './oklch';
|
|
3
|
-
import type { ZoraOklchColor } from './types';
|
|
4
|
-
|
|
5
|
-
const FALLBACK_PRIMARY_COLOR: ZoraHexColor = '#0f766e';
|
|
6
|
-
|
|
7
|
-
const LIGHT_PRIMARY_LIGHTNESS_TARGET = 0.52;
|
|
8
|
-
const DARK_PRIMARY_LIGHTNESS_TARGET = 0.72;
|
|
9
|
-
|
|
10
|
-
const MIN_PRIMARY_CHROMA = 0.04;
|
|
11
|
-
const MAX_LIGHT_PRIMARY_CHROMA = 0.18;
|
|
12
|
-
const MAX_DARK_PRIMARY_CHROMA = 0.2;
|
|
13
|
-
|
|
14
|
-
const LIGHTNESS_BLEND = 0.85;
|
|
15
|
-
|
|
16
|
-
function clampNumber(value: number, min: number, max: number): number {
|
|
17
|
-
return Math.max(min, Math.min(value, max));
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
function resolveModePrimaryTargetLightness(mode: ZoraThemeMode): number {
|
|
21
|
-
return mode === 'dark' ? DARK_PRIMARY_LIGHTNESS_TARGET : LIGHT_PRIMARY_LIGHTNESS_TARGET;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
function resolveModePrimaryMaxChroma(mode: ZoraThemeMode): number {
|
|
25
|
-
return mode === 'dark' ? MAX_DARK_PRIMARY_CHROMA : MAX_LIGHT_PRIMARY_CHROMA;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export function resolveModePrimaryColor(
|
|
29
|
-
primaryColor: ZoraHexColor,
|
|
30
|
-
mode: ZoraThemeMode,
|
|
31
|
-
): ZoraHexColor {
|
|
32
|
-
let seed: ZoraOklchColor;
|
|
33
|
-
|
|
34
|
-
try {
|
|
35
|
-
seed = parseHexToOklch(primaryColor);
|
|
36
|
-
} catch (error) {
|
|
37
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
38
|
-
|
|
39
|
-
if (process.env.NODE_ENV === 'production') {
|
|
40
|
-
console.warn(`Invalid ZORA primaryColor '${primaryColor}'. Falling back. ${message}`);
|
|
41
|
-
return resolveModePrimaryColor(FALLBACK_PRIMARY_COLOR, mode);
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
throw error instanceof Error ? error : new Error(message);
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
const targetLightness = resolveModePrimaryTargetLightness(mode);
|
|
48
|
-
const maxChroma = resolveModePrimaryMaxChroma(mode);
|
|
49
|
-
|
|
50
|
-
const blendedLightness = seed.l + (targetLightness - seed.l) * LIGHTNESS_BLEND;
|
|
51
|
-
const boundedLightness = clampNumber(blendedLightness, 0.12, 0.92);
|
|
52
|
-
|
|
53
|
-
const cappedChroma = clampNumber(seed.c, 0, maxChroma);
|
|
54
|
-
const boundedChroma =
|
|
55
|
-
seed.c < MIN_PRIMARY_CHROMA ? cappedChroma : Math.max(cappedChroma, MIN_PRIMARY_CHROMA);
|
|
56
|
-
|
|
57
|
-
const derived = clampOklchToGamut({
|
|
58
|
-
l: boundedLightness,
|
|
59
|
-
c: boundedChroma,
|
|
60
|
-
h: seed.h,
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
return formatOklchAsHex(derived);
|
|
64
|
-
}
|