@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.
Files changed (114) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/README.md +11 -13
  3. package/dist/components/heading/resolveHeadingRecipe.d.ts +2 -2
  4. package/dist/components/heading/resolveHeadingRecipe.d.ts.map +1 -1
  5. package/dist/components/heading/resolveHeadingRecipe.js.map +1 -1
  6. package/dist/components/text/resolveTextRecipe.d.ts +2 -2
  7. package/dist/components/text/resolveTextRecipe.d.ts.map +1 -1
  8. package/dist/components/text/resolveTextRecipe.js.map +1 -1
  9. package/dist/patterns/theme-composer/ThemeComposer.d.ts.map +1 -1
  10. package/dist/patterns/theme-composer/ThemeComposer.js +10 -86
  11. package/dist/patterns/theme-composer/ThemeComposer.js.map +1 -1
  12. package/dist/patterns/theme-composer/index.d.ts +1 -1
  13. package/dist/patterns/theme-composer/index.d.ts.map +1 -1
  14. package/dist/patterns/theme-composer/index.js.map +1 -1
  15. package/dist/patterns/theme-composer/types.d.ts +1 -13
  16. package/dist/patterns/theme-composer/types.d.ts.map +1 -1
  17. package/dist/patterns/theme-composer/types.js.map +1 -1
  18. package/dist/theme/createZoraThemeConfig.d.ts +1 -1
  19. package/dist/theme/createZoraThemeConfig.d.ts.map +1 -1
  20. package/dist/theme/createZoraThemeConfig.js +5 -6
  21. package/dist/theme/createZoraThemeConfig.js.map +1 -1
  22. package/dist/theme/index.d.ts +1 -1
  23. package/dist/theme/index.d.ts.map +1 -1
  24. package/dist/theme/index.js.map +1 -1
  25. package/dist/theme/types.d.ts +16 -11
  26. package/dist/theme/types.d.ts.map +1 -1
  27. package/dist/theme/types.js +1 -20
  28. package/dist/theme/types.js.map +1 -1
  29. package/dist/theme/useZoraTheme.d.ts +1 -1
  30. package/dist/theme/zoraDefaultTheme.js +1 -1
  31. package/dist/theme/zoraDefaultTheme.js.map +1 -1
  32. package/package.json +4 -4
  33. package/src/components/heading/resolveHeadingRecipe.test.ts +30 -5
  34. package/src/components/heading/resolveHeadingRecipe.ts +6 -6
  35. package/src/components/text/resolveTextRecipe.test.ts +30 -5
  36. package/src/components/text/resolveTextRecipe.ts +6 -6
  37. package/src/patterns/theme-composer/ThemeComposer.test.ts +9 -141
  38. package/src/patterns/theme-composer/ThemeComposer.tsx +10 -131
  39. package/src/patterns/theme-composer/index.ts +1 -6
  40. package/src/patterns/theme-composer/types.ts +1 -15
  41. package/src/showcaseCoverage.test.ts +14 -0
  42. package/src/theme/createZoraThemeConfig.test.ts +51 -26
  43. package/src/theme/createZoraThemeConfig.ts +7 -7
  44. package/src/theme/index.ts +1 -3
  45. package/src/theme/types.ts +22 -34
  46. package/src/theme/zoraDefaultTheme.ts +1 -1
  47. package/dist/internal/color/colorToneRecipes.d.ts +0 -23
  48. package/dist/internal/color/colorToneRecipes.d.ts.map +0 -1
  49. package/dist/internal/color/colorToneRecipes.js +0 -139
  50. package/dist/internal/color/colorToneRecipes.js.map +0 -1
  51. package/dist/internal/color/harmony.d.ts +0 -12
  52. package/dist/internal/color/harmony.d.ts.map +0 -1
  53. package/dist/internal/color/harmony.js +0 -69
  54. package/dist/internal/color/harmony.js.map +0 -1
  55. package/dist/internal/color/hue.d.ts +0 -3
  56. package/dist/internal/color/hue.d.ts.map +0 -1
  57. package/dist/internal/color/hue.js +0 -7
  58. package/dist/internal/color/hue.js.map +0 -1
  59. package/dist/internal/color/index.d.ts +0 -10
  60. package/dist/internal/color/index.d.ts.map +0 -1
  61. package/dist/internal/color/index.js +0 -10
  62. package/dist/internal/color/index.js.map +0 -1
  63. package/dist/internal/color/oklch.d.ts +0 -6
  64. package/dist/internal/color/oklch.d.ts.map +0 -1
  65. package/dist/internal/color/oklch.js +0 -50
  66. package/dist/internal/color/oklch.js.map +0 -1
  67. package/dist/internal/color/primary.d.ts +0 -3
  68. package/dist/internal/color/primary.d.ts.map +0 -1
  69. package/dist/internal/color/primary.js +0 -44
  70. package/dist/internal/color/primary.js.map +0 -1
  71. package/dist/internal/color/roleHues.d.ts +0 -15
  72. package/dist/internal/color/roleHues.d.ts.map +0 -1
  73. package/dist/internal/color/roleHues.js +0 -103
  74. package/dist/internal/color/roleHues.js.map +0 -1
  75. package/dist/internal/color/roleScales.d.ts +0 -20
  76. package/dist/internal/color/roleScales.d.ts.map +0 -1
  77. package/dist/internal/color/roleScales.js +0 -79
  78. package/dist/internal/color/roleScales.js.map +0 -1
  79. package/dist/internal/color/scales.d.ts +0 -19
  80. package/dist/internal/color/scales.d.ts.map +0 -1
  81. package/dist/internal/color/scales.js +0 -135
  82. package/dist/internal/color/scales.js.map +0 -1
  83. package/dist/internal/color/semanticTokens.d.ts +0 -28
  84. package/dist/internal/color/semanticTokens.d.ts.map +0 -1
  85. package/dist/internal/color/semanticTokens.js +0 -84
  86. package/dist/internal/color/semanticTokens.js.map +0 -1
  87. package/dist/internal/color/types.d.ts +0 -10
  88. package/dist/internal/color/types.d.ts.map +0 -1
  89. package/dist/internal/color/types.js +0 -4
  90. package/dist/internal/color/types.js.map +0 -1
  91. package/dist/patterns/theme-composer/recommendations.d.ts +0 -14
  92. package/dist/patterns/theme-composer/recommendations.d.ts.map +0 -1
  93. package/dist/patterns/theme-composer/recommendations.js +0 -58
  94. package/dist/patterns/theme-composer/recommendations.js.map +0 -1
  95. package/src/internal/color/colorToneRecipes.test.ts +0 -89
  96. package/src/internal/color/colorToneRecipes.ts +0 -167
  97. package/src/internal/color/harmony.test.ts +0 -145
  98. package/src/internal/color/harmony.ts +0 -96
  99. package/src/internal/color/hue.test.ts +0 -28
  100. package/src/internal/color/hue.ts +0 -7
  101. package/src/internal/color/index.ts +0 -44
  102. package/src/internal/color/oklch.ts +0 -65
  103. package/src/internal/color/primary.test.ts +0 -105
  104. package/src/internal/color/primary.ts +0 -64
  105. package/src/internal/color/roleHues.test.ts +0 -197
  106. package/src/internal/color/roleHues.ts +0 -142
  107. package/src/internal/color/roleScales.test.ts +0 -220
  108. package/src/internal/color/roleScales.ts +0 -127
  109. package/src/internal/color/scales.test.ts +0 -151
  110. package/src/internal/color/scales.ts +0 -194
  111. package/src/internal/color/semanticTokens.test.ts +0 -170
  112. package/src/internal/color/semanticTokens.ts +0 -114
  113. package/src/internal/color/types.ts +0 -15
  114. package/src/patterns/theme-composer/recommendations.ts +0 -85
@@ -1,151 +0,0 @@
1
- import { describe, expect, test } from 'bun:test';
2
-
3
- import type { ZoraHexColor } from '../../theme/types';
4
- import {
5
- createZoraColorScale,
6
- type CreateZoraColorScaleOptions,
7
- createZoraNeutralScale,
8
- createZoraPrimaryScale,
9
- parseHexToOklch,
10
- ZORA_COLOR_SCALE_STEPS,
11
- type ZoraColorScale,
12
- type ZoraColorScaleStep,
13
- } from './index';
14
-
15
- function isSixDigitLowercaseHexColor(value: string): value is ZoraHexColor {
16
- return /^#[0-9a-f]{6}$/.test(value);
17
- }
18
-
19
- function hueDeltaDegrees(a: number, b: number): number {
20
- const raw = Math.abs(a - b) % 360;
21
- return Math.min(raw, 360 - raw);
22
- }
23
-
24
- function getStepValues(scale: ZoraColorScale): { step: ZoraColorScaleStep; hex: ZoraHexColor }[] {
25
- return ZORA_COLOR_SCALE_STEPS.map((step) => ({ step, hex: scale[step] }));
26
- }
27
-
28
- function assertScaleKeys(scale: ZoraColorScale) {
29
- const keys = Object.keys(scale)
30
- .map((key) => Number(key))
31
- .sort((a, b) => a - b);
32
-
33
- expect(keys).toEqual([...ZORA_COLOR_SCALE_STEPS]);
34
- }
35
-
36
- function assertDecreasingLightness(scale: ZoraColorScale) {
37
- const lightness = getStepValues(scale).map(({ hex }) => parseHexToOklch(hex).l);
38
-
39
- for (let index = 0; index < lightness.length - 1; index += 1) {
40
- expect(lightness[index]).toBeGreaterThan(lightness[index + 1]);
41
- }
42
- }
43
-
44
- describe('createZoraPrimaryScale', () => {
45
- test('matches createZoraColorScale({ role: primary })', () => {
46
- const seed: ZoraHexColor = '#0f766e';
47
- const options: CreateZoraColorScaleOptions = { seed, role: 'primary' };
48
-
49
- expect(createZoraPrimaryScale(seed)).toEqual(createZoraColorScale(options));
50
- });
51
-
52
- test('returns all 50–950 keys and valid lowercase 6-digit hex values', () => {
53
- const seed: ZoraHexColor = '#0f766e';
54
- const scale = createZoraPrimaryScale(seed);
55
-
56
- assertScaleKeys(scale);
57
-
58
- for (const { hex } of getStepValues(scale)) {
59
- expect(isSixDigitLowercaseHexColor(hex)).toBe(true);
60
- }
61
- });
62
-
63
- test('is deterministic for the same seed', () => {
64
- const seed: ZoraHexColor = '#0f766e';
65
-
66
- expect(createZoraPrimaryScale(seed)).toEqual(createZoraPrimaryScale(seed));
67
- });
68
-
69
- test('lightness decreases from 50 to 950', () => {
70
- const seed: ZoraHexColor = '#0f766e';
71
- const scale = createZoraPrimaryScale(seed);
72
-
73
- assertDecreasingLightness(scale);
74
- });
75
-
76
- test('preserves hue approximately for mid steps', () => {
77
- const seed: ZoraHexColor = '#0f766e';
78
- const seedOklch = parseHexToOklch(seed);
79
- const scale = createZoraPrimaryScale(seed);
80
-
81
- const midSteps: ZoraColorScaleStep[] = [400, 500, 600, 700];
82
- for (const step of midSteps) {
83
- const oklch = parseHexToOklch(scale[step]);
84
- expect(hueDeltaDegrees(seedOklch.h, oklch.h)).toBeLessThan(20);
85
- }
86
- });
87
-
88
- test('mid steps have higher chroma than extremes for a saturated seed', () => {
89
- const seed: ZoraHexColor = '#ff00ff';
90
- const scale = createZoraPrimaryScale(seed);
91
-
92
- const chroma50 = parseHexToOklch(scale[50]).c;
93
- const chroma950 = parseHexToOklch(scale[950]).c;
94
- const chroma500 = parseHexToOklch(scale[500]).c;
95
- const chroma600 = parseHexToOklch(scale[600]).c;
96
-
97
- const extremes = Math.max(chroma50, chroma950);
98
- const mid = Math.max(chroma500, chroma600);
99
-
100
- expect(mid).toBeGreaterThan(extremes);
101
- });
102
-
103
- test('bounds saturated inputs to stay in gamut', () => {
104
- const seed: ZoraHexColor = '#ff00ff';
105
- const scale = createZoraPrimaryScale(seed);
106
-
107
- for (const { hex } of getStepValues(scale)) {
108
- expect(isSixDigitLowercaseHexColor(hex)).toBe(true);
109
- expect(() => parseHexToOklch(hex)).not.toThrow();
110
- }
111
- });
112
-
113
- test('throws on invalid seed in test/development', () => {
114
- const invalid: ZoraHexColor = '#nope';
115
-
116
- expect(() => createZoraPrimaryScale(invalid)).toThrow();
117
- });
118
- });
119
-
120
- describe('createZoraNeutralScale', () => {
121
- test('works with and without a seed', () => {
122
- const seeded = createZoraNeutralScale('#0f766e');
123
- const unseeded = createZoraNeutralScale();
124
-
125
- assertScaleKeys(seeded);
126
- assertScaleKeys(unseeded);
127
- });
128
-
129
- test('returns low-chroma neutrals', () => {
130
- const scale = createZoraNeutralScale('#0f766e');
131
-
132
- for (const { hex } of getStepValues(scale)) {
133
- const oklch = parseHexToOklch(hex);
134
- expect(oklch.c).toBeLessThanOrEqual(0.03);
135
- }
136
- });
137
-
138
- test('lightness decreases from 50 to 950', () => {
139
- const scale = createZoraNeutralScale('#0f766e');
140
-
141
- assertDecreasingLightness(scale);
142
- });
143
-
144
- test('avoids pure black/white in the generated steps', () => {
145
- const scale = createZoraNeutralScale('#0f766e');
146
- const values = getStepValues(scale).map(({ hex }) => hex);
147
-
148
- expect(values).not.toContain('#000000');
149
- expect(values).not.toContain('#ffffff');
150
- });
151
- });
@@ -1,194 +0,0 @@
1
- import type { ZoraHexColor } from '../../theme/types';
2
- import type { ZoraColorToneRecipe } from './colorToneRecipes';
3
- import { getZoraColorToneRoleChromaFactor } from './colorToneRecipes';
4
- import { clampOklchToGamut, formatOklchAsHex, parseHexToOklch } from './oklch';
5
- import { type ZoraColorScale, type ZoraColorScaleStep } from './types';
6
-
7
- export interface CreateZoraColorScaleOptions {
8
- seed: ZoraHexColor;
9
- role?: 'primary' | 'neutral';
10
- }
11
-
12
- export type ZoraHueScaleRoleId = 'primary' | 'secondary' | 'accent' | 'highlight' | 'surfaceTint';
13
-
14
- export interface CreateZoraHueScaleOptions {
15
- hue: number;
16
- seedChroma: number;
17
- role: ZoraHueScaleRoleId;
18
- colorToneRecipe: ZoraColorToneRecipe;
19
- }
20
-
21
- const PRIMARY_LIGHTNESS_BY_STEP: Record<ZoraColorScaleStep, number> = {
22
- 50: 0.97,
23
- 100: 0.93,
24
- 200: 0.86,
25
- 300: 0.78,
26
- 400: 0.68,
27
- 500: 0.58,
28
- 600: 0.5,
29
- 700: 0.42,
30
- 800: 0.34,
31
- 900: 0.27,
32
- 950: 0.2,
33
- };
34
-
35
- const NEUTRAL_LIGHTNESS_BY_STEP: Record<ZoraColorScaleStep, number> = {
36
- 50: 0.98,
37
- 100: 0.95,
38
- 200: 0.89,
39
- 300: 0.8,
40
- 400: 0.68,
41
- 500: 0.55,
42
- 600: 0.44,
43
- 700: 0.34,
44
- 800: 0.25,
45
- 900: 0.18,
46
- 950: 0.12,
47
- };
48
-
49
- const PRIMARY_CHROMA_MULTIPLIER_BY_STEP: Record<ZoraColorScaleStep, number> = {
50
- 50: 0.2,
51
- 100: 0.3,
52
- 200: 0.45,
53
- 300: 0.7,
54
- 400: 0.95,
55
- 500: 1,
56
- 600: 0.95,
57
- 700: 0.85,
58
- 800: 0.65,
59
- 900: 0.45,
60
- 950: 0.3,
61
- };
62
-
63
- const MAX_PRIMARY_SCALE_CHROMA = 0.2;
64
- const MIN_PRIMARY_SCALE_CHROMA = 0.04;
65
- const NEUTRAL_CHROMA = 0.012;
66
- const DEFAULT_NEUTRAL_HUE_DEGREES = 260;
67
-
68
- function clampNumber(value: number, min: number, max: number): number {
69
- return Math.max(min, Math.min(value, max));
70
- }
71
-
72
- function resolvePrimaryScaleChroma(seedChroma: number, step: ZoraColorScaleStep): number {
73
- const cappedSeedChroma = clampNumber(seedChroma, 0, MAX_PRIMARY_SCALE_CHROMA);
74
- const multiplier = PRIMARY_CHROMA_MULTIPLIER_BY_STEP[step];
75
- const scaled = cappedSeedChroma * multiplier;
76
-
77
- const bounded = clampNumber(scaled, 0, MAX_PRIMARY_SCALE_CHROMA);
78
- const shouldEnforceMin = step >= 300 && step <= 700 && seedChroma >= MIN_PRIMARY_SCALE_CHROMA;
79
-
80
- return shouldEnforceMin ? Math.max(bounded, MIN_PRIMARY_SCALE_CHROMA) : bounded;
81
- }
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
-
99
- function resolveHueScaleChroma(
100
- options: CreateZoraHueScaleOptions,
101
- step: ZoraColorScaleStep,
102
- ): number {
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
- });
111
- }
112
-
113
- function createScaleEntries(options: CreateZoraColorScaleOptions): ZoraColorScale {
114
- const seed = parseHexToOklch(options.seed);
115
-
116
- if (options.role === 'neutral') {
117
- const hue = typeof seed.h === 'number' ? seed.h : DEFAULT_NEUTRAL_HUE_DEGREES;
118
-
119
- return createScaleFromRamp({
120
- hue,
121
- chroma: NEUTRAL_CHROMA,
122
- lightnessByStep: NEUTRAL_LIGHTNESS_BY_STEP,
123
- });
124
- }
125
-
126
- return createScaleFromRamp({
127
- hue: seed.h,
128
- chromaByStep: (step) => resolvePrimaryScaleChroma(seed.c, step),
129
- lightnessByStep: PRIMARY_LIGHTNESS_BY_STEP,
130
- });
131
- }
132
-
133
- interface CreateScaleFromRampOptions {
134
- hue: number;
135
- chroma?: number;
136
- chromaByStep?: (step: ZoraColorScaleStep) => number;
137
- lightnessByStep: Record<ZoraColorScaleStep, number>;
138
- }
139
-
140
- function createScaleColor(
141
- options: CreateScaleFromRampOptions,
142
- step: ZoraColorScaleStep,
143
- ): ZoraHexColor {
144
- const lightness = options.lightnessByStep[step];
145
- const chroma =
146
- typeof options.chromaByStep === 'function' ? options.chromaByStep(step) : (options.chroma ?? 0);
147
-
148
- const clamped = clampOklchToGamut({
149
- l: clampNumber(lightness, 0, 1),
150
- c: clampNumber(chroma, 0, 1),
151
- h: options.hue,
152
- });
153
-
154
- return formatOklchAsHex(clamped);
155
- }
156
-
157
- function createScaleFromRamp(options: CreateScaleFromRampOptions): ZoraColorScale {
158
- return {
159
- 50: createScaleColor(options, 50),
160
- 100: createScaleColor(options, 100),
161
- 200: createScaleColor(options, 200),
162
- 300: createScaleColor(options, 300),
163
- 400: createScaleColor(options, 400),
164
- 500: createScaleColor(options, 500),
165
- 600: createScaleColor(options, 600),
166
- 700: createScaleColor(options, 700),
167
- 800: createScaleColor(options, 800),
168
- 900: createScaleColor(options, 900),
169
- 950: createScaleColor(options, 950),
170
- };
171
- }
172
-
173
- export function createZoraColorScale(options: CreateZoraColorScaleOptions): ZoraColorScale {
174
- return createScaleEntries({
175
- seed: options.seed,
176
- role: options.role,
177
- });
178
- }
179
-
180
- export function createZoraPrimaryScale(seed: ZoraHexColor): ZoraColorScale {
181
- return createZoraColorScale({ seed, role: 'primary' });
182
- }
183
-
184
- export function createZoraNeutralScale(seed: ZoraHexColor = '#94a3b8'): ZoraColorScale {
185
- return createZoraColorScale({ seed, role: 'neutral' });
186
- }
187
-
188
- export function createZoraHueScale(options: CreateZoraHueScaleOptions): ZoraColorScale {
189
- return createScaleFromRamp({
190
- hue: options.hue,
191
- chromaByStep: (step) => resolveHueScaleChroma(options, step),
192
- lightnessByStep: PRIMARY_LIGHTNESS_BY_STEP,
193
- });
194
- }
@@ -1,170 +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
- createZoraSemanticColorTokens,
9
- getReadableTextColor,
10
- type ZoraComputedRoleColorScales,
11
- type ZoraSemanticColorTokens,
12
- } from './index';
13
-
14
- function isSixDigitLowercaseHex(value: string): value is ZoraHexColor {
15
- return /^#[0-9a-f]{6}$/.test(value);
16
- }
17
-
18
- function getLightness(hex: ZoraHexColor): number {
19
- // Intentionally a simple RGB average rather than OKLCH — serves as a
20
- // perceptual proxy for test assertions only. The production implementation
21
- // in getReadableTextColor uses OKLCH lightness via parseHexToOklch.
22
- const r = parseInt(hex.slice(1, 3), 16) / 255;
23
- const g = parseInt(hex.slice(3, 5), 16) / 255;
24
- const b = parseInt(hex.slice(5, 7), 16) / 255;
25
- return (r + g + b) / 3;
26
- }
27
-
28
- function buildRoleScales(colorTone: ZoraColorTone = 'jewel'): ZoraComputedRoleColorScales {
29
- const seed: ZoraHexColor = '#0f766e';
30
- const harmony = computeZoraHarmony(seed, 'tetradic');
31
- const hueRoles = assignZoraHarmonyRoleHues(harmony);
32
- return createZoraRoleColorScales({ colorTone, hueRoles, seed });
33
- }
34
-
35
- function buildLight(colorTone: ZoraColorTone = 'jewel'): ZoraSemanticColorTokens {
36
- return createZoraSemanticColorTokens({
37
- roleScales: buildRoleScales(colorTone),
38
- mode: 'light',
39
- colorTone,
40
- });
41
- }
42
-
43
- function buildDark(colorTone: ZoraColorTone = 'jewel'): ZoraSemanticColorTokens {
44
- return createZoraSemanticColorTokens({
45
- roleScales: buildRoleScales(colorTone),
46
- mode: 'dark',
47
- colorTone,
48
- });
49
- }
50
-
51
- describe('createZoraSemanticColorTokens', () => {
52
- test('all output values are lowercase 6-digit hex', () => {
53
- const light = buildLight();
54
- const dark = buildDark();
55
-
56
- for (const tokens of [light, dark]) {
57
- for (const key of Object.keys(tokens) as (keyof ZoraSemanticColorTokens)[]) {
58
- const value = tokens[key];
59
- expect(isSixDigitLowercaseHex(value), `${key} must be lowercase 6-digit hex`).toBe(true);
60
- }
61
- }
62
- });
63
-
64
- test('light mode background is lighter than dark mode background', () => {
65
- const light = buildLight();
66
- const dark = buildDark();
67
-
68
- expect(getLightness(light.background)).toBeGreaterThan(getLightness(dark.background));
69
- });
70
-
71
- test('text has opposite lightness direction from background (light mode)', () => {
72
- const light = buildLight();
73
-
74
- expect(getLightness(light.text)).toBeLessThan(getLightness(light.background));
75
- });
76
-
77
- test('text has opposite lightness direction from background (dark mode)', () => {
78
- const dark = buildDark();
79
-
80
- expect(getLightness(dark.text)).toBeGreaterThan(getLightness(dark.background));
81
- });
82
-
83
- test('primary, accent, and highlight are distinct from background and surface (light)', () => {
84
- const light = buildLight();
85
-
86
- for (const token of [light.primary, light.accent, light.highlight] as const) {
87
- expect(token).not.toBe(light.background);
88
- expect(token).not.toBe(light.surface);
89
- }
90
- });
91
-
92
- test('primary, accent, and highlight are distinct from background and surface (dark)', () => {
93
- const dark = buildDark();
94
-
95
- for (const token of [dark.primary, dark.accent, dark.highlight] as const) {
96
- expect(token).not.toBe(dark.background);
97
- expect(token).not.toBe(dark.surface);
98
- }
99
- });
100
-
101
- test('onPrimary is readable against primary (light mode)', () => {
102
- const light = buildLight();
103
- const primaryL = getLightness(light.primary);
104
- const onPrimaryL = getLightness(light.onPrimary);
105
-
106
- expect(Math.abs(primaryL - onPrimaryL)).toBeGreaterThan(0.3);
107
- });
108
-
109
- test('onPrimary is readable against primary (dark mode)', () => {
110
- const dark = buildDark();
111
- const primaryL = getLightness(dark.primary);
112
- const onPrimaryL = getLightness(dark.onPrimary);
113
-
114
- expect(Math.abs(primaryL - onPrimaryL)).toBeGreaterThan(0.3);
115
- });
116
-
117
- test('onAccent is readable against accent (light mode)', () => {
118
- const light = buildLight();
119
- const accentL = getLightness(light.accent);
120
- const onAccentL = getLightness(light.onAccent);
121
-
122
- expect(Math.abs(accentL - onAccentL)).toBeGreaterThan(0.3);
123
- });
124
-
125
- test('colorTone changes at least one output token between neutral and fluorescent', () => {
126
- const lightNeutral = buildLight('neutral');
127
- const lightFluorescent = buildLight('fluorescent');
128
-
129
- const tokens: (keyof ZoraSemanticColorTokens)[] = [
130
- 'primary',
131
- 'secondary',
132
- 'accent',
133
- 'highlight',
134
- ];
135
-
136
- const anyChanged = tokens.some((k) => lightNeutral[k] !== lightFluorescent[k]);
137
- expect(anyChanged).toBe(true);
138
- });
139
-
140
- test('output is deterministic for same input', () => {
141
- const first = buildLight();
142
- const second = buildLight();
143
-
144
- expect(first).toEqual(second);
145
- });
146
-
147
- test('surface is distinct from surfaceRaised in dark mode', () => {
148
- const dark = buildDark();
149
-
150
- expect(dark.surface).not.toBe(dark.surfaceRaised);
151
- });
152
- });
153
-
154
- describe('getReadableTextColor', () => {
155
- test('returns the candidate with greater lightness difference from background', () => {
156
- const background: ZoraHexColor = '#ffffff';
157
- const dark: ZoraHexColor = '#111111';
158
- const light: ZoraHexColor = '#eeeeee';
159
-
160
- expect(getReadableTextColor(background, [dark, light])).toBe(dark);
161
- });
162
-
163
- test('returns the lighter candidate when background is dark', () => {
164
- const background: ZoraHexColor = '#111111';
165
- const dark: ZoraHexColor = '#333333';
166
- const light: ZoraHexColor = '#eeeeee';
167
-
168
- expect(getReadableTextColor(background, [dark, light])).toBe(light);
169
- });
170
- });
@@ -1,114 +0,0 @@
1
- import type { ZoraColorTone, ZoraHexColor, ZoraThemeMode } from '../../theme/types';
2
- import { parseHexToOklch } from './oklch';
3
- import { getZoraRoleColorScale, type ZoraComputedRoleColorScales } from './roleScales';
4
-
5
- export interface ZoraSemanticColorTokens {
6
- background: ZoraHexColor;
7
- surface: ZoraHexColor;
8
- surfaceRaised: ZoraHexColor;
9
- surfaceTint: ZoraHexColor;
10
- border: ZoraHexColor;
11
- text: ZoraHexColor;
12
- textMuted: ZoraHexColor;
13
- primary: ZoraHexColor;
14
- secondary: ZoraHexColor;
15
- accent: ZoraHexColor;
16
- highlight: ZoraHexColor;
17
- onPrimary: ZoraHexColor;
18
- onAccent: ZoraHexColor;
19
- }
20
-
21
- /**
22
- * Selects the more readable of two candidate hex colors against a given background,
23
- * using OKLCH lightness as a simple contrast proxy.
24
- */
25
- export function getReadableTextColor(
26
- background: ZoraHexColor,
27
- candidates: readonly [ZoraHexColor, ZoraHexColor],
28
- ): ZoraHexColor {
29
- const bgL = parseHexToOklch(background).l;
30
- const [a, b] = candidates;
31
- const diffA = Math.abs(parseHexToOklch(a).l - bgL);
32
- const diffB = Math.abs(parseHexToOklch(b).l - bgL);
33
- return diffA >= diffB ? a : b;
34
- }
35
-
36
- export function createZoraSemanticColorTokens(options: {
37
- roleScales: ZoraComputedRoleColorScales;
38
- mode: ZoraThemeMode;
39
- colorTone: ZoraColorTone;
40
- }): ZoraSemanticColorTokens {
41
- // colorTone is accepted now and reserved for future per-tone step overrides
42
- // (e.g. obsidian/pastel may shift surface selections differently).
43
- const { roleScales, mode } = options;
44
-
45
- const neutral = getZoraRoleColorScale(roleScales, 'neutral').scale;
46
- const surfaceTintScale = getZoraRoleColorScale(roleScales, 'surfaceTint').scale;
47
- const primaryScale = getZoraRoleColorScale(roleScales, 'primary').scale;
48
- const secondaryScale = getZoraRoleColorScale(roleScales, 'secondary').scale;
49
- const accentScale = getZoraRoleColorScale(roleScales, 'accent').scale;
50
- const highlightScale = getZoraRoleColorScale(roleScales, 'highlight').scale;
51
-
52
- if (mode === 'light') {
53
- const background = neutral[50];
54
- const surface = neutral[100];
55
- const surfaceRaised = neutral[50];
56
- const surfaceTint = surfaceTintScale[100];
57
- const border = neutral[200];
58
- const text = neutral[900];
59
- const textMuted = neutral[700];
60
- const primary = primaryScale[600];
61
- const secondary = secondaryScale[600];
62
- const accent = accentScale[600];
63
- const highlight = highlightScale[600];
64
- const onPrimary = getReadableTextColor(primary, [neutral[50], neutral[950]]);
65
- const onAccent = getReadableTextColor(accent, [neutral[50], neutral[950]]);
66
-
67
- return {
68
- background,
69
- surface,
70
- surfaceRaised,
71
- surfaceTint,
72
- border,
73
- text,
74
- textMuted,
75
- primary,
76
- secondary,
77
- accent,
78
- highlight,
79
- onPrimary,
80
- onAccent,
81
- };
82
- }
83
-
84
- // dark mode
85
- const background = neutral[950];
86
- const surface = neutral[900];
87
- const surfaceRaised = neutral[800];
88
- const surfaceTint = surfaceTintScale[900];
89
- const border = neutral[700];
90
- const text = neutral[50];
91
- const textMuted = neutral[300];
92
- const primary = primaryScale[400];
93
- const secondary = secondaryScale[400];
94
- const accent = accentScale[400];
95
- const highlight = highlightScale[400];
96
- const onPrimary = getReadableTextColor(primary, [neutral[50], neutral[950]]);
97
- const onAccent = getReadableTextColor(accent, [neutral[50], neutral[950]]);
98
-
99
- return {
100
- background,
101
- surface,
102
- surfaceRaised,
103
- surfaceTint,
104
- border,
105
- text,
106
- textMuted,
107
- primary,
108
- secondary,
109
- accent,
110
- highlight,
111
- onPrimary,
112
- onAccent,
113
- };
114
- }
@@ -1,15 +0,0 @@
1
- import type { ZoraHexColor } from '../../theme/types';
2
-
3
- export interface ZoraOklchColor {
4
- l: number;
5
- c: number;
6
- h: number;
7
- }
8
-
9
- export const ZORA_COLOR_SCALE_STEPS = [
10
- 50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950,
11
- ] as const;
12
-
13
- export type ZoraColorScaleStep = (typeof ZORA_COLOR_SCALE_STEPS)[number];
14
-
15
- export type ZoraColorScale = Record<ZoraColorScaleStep, ZoraHexColor>;