@ankhorage/surface 0.2.3 → 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 (71) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/dist/examples/DocsExamples.d.ts.map +1 -1
  3. package/dist/examples/DocsExamples.js +0 -2
  4. package/dist/examples/DocsExamples.js.map +1 -1
  5. package/dist/internal/resolvers/resolveControlSize.d.ts +2 -2
  6. package/dist/internal/resolvers/resolveControlSize.d.ts.map +1 -1
  7. package/dist/internal/resolvers/resolveControlSize.js.map +1 -1
  8. package/dist/internal/resolvers/resolveIconSize.d.ts +2 -2
  9. package/dist/internal/resolvers/resolveIconSize.d.ts.map +1 -1
  10. package/dist/internal/resolvers/resolveIconSize.js.map +1 -1
  11. package/dist/internal/resolvers/resolveInteractiveColors.d.ts +3 -3
  12. package/dist/internal/resolvers/resolveInteractiveColors.d.ts.map +1 -1
  13. package/dist/internal/resolvers/resolveInteractiveColors.js.map +1 -1
  14. package/dist/internal/resolvers/resolveSelectionControlColors.d.ts +2 -2
  15. package/dist/internal/resolvers/resolveSelectionControlColors.d.ts.map +1 -1
  16. package/dist/internal/resolvers/resolveSelectionControlColors.js.map +1 -1
  17. package/dist/internal/resolvers/resolveTextColor.d.ts +3 -3
  18. package/dist/internal/resolvers/resolveTextColor.d.ts.map +1 -1
  19. package/dist/internal/resolvers/resolveTextColor.js.map +1 -1
  20. package/dist/internal/resolvers/resolveTextStyles.d.ts +3 -3
  21. package/dist/internal/resolvers/resolveTextStyles.d.ts.map +1 -1
  22. package/dist/internal/resolvers/resolveTextStyles.js.map +1 -1
  23. package/dist/internal/resolvers/resolveTone.d.ts +2 -2
  24. package/dist/internal/resolvers/resolveTone.d.ts.map +1 -1
  25. package/dist/internal/resolvers/resolveTone.js.map +1 -1
  26. package/dist/layout/Container.d.ts +2 -2
  27. package/dist/layout/Container.d.ts.map +1 -1
  28. package/dist/layout/Container.js.map +1 -1
  29. package/dist/layout/helpers.d.ts +9 -9
  30. package/dist/layout/helpers.d.ts.map +1 -1
  31. package/dist/layout/helpers.js.map +1 -1
  32. package/dist/primitives/heading/resolveHeadingStyle.d.ts +2 -2
  33. package/dist/primitives/heading/resolveHeadingStyle.d.ts.map +1 -1
  34. package/dist/primitives/heading/resolveHeadingStyle.js.map +1 -1
  35. package/dist/primitives/icon/Icon.d.ts +3 -3
  36. package/dist/primitives/icon/Icon.d.ts.map +1 -1
  37. package/dist/primitives/icon/Icon.js.map +1 -1
  38. package/dist/theme/ThemeContext.d.ts +3 -3
  39. package/dist/theme/ThemeContext.d.ts.map +1 -1
  40. package/dist/theme/ThemeContext.js.map +1 -1
  41. package/dist/theme/colorEngine.d.ts +10 -11
  42. package/dist/theme/colorEngine.d.ts.map +1 -1
  43. package/dist/theme/colorEngine.js +102 -412
  44. package/dist/theme/colorEngine.js.map +1 -1
  45. package/dist/theme/createTheme.d.ts +3 -3
  46. package/dist/theme/createTheme.d.ts.map +1 -1
  47. package/dist/theme/createTheme.js +2 -4
  48. package/dist/theme/createTheme.js.map +1 -1
  49. package/dist/theme/types.d.ts +5 -17
  50. package/dist/theme/types.d.ts.map +1 -1
  51. package/dist/theme/types.js.map +1 -1
  52. package/package.json +4 -4
  53. package/src/examples/DocsExamples.tsx +0 -2
  54. package/src/internal/resolvers/resolveControlSize.ts +5 -2
  55. package/src/internal/resolvers/resolveIconSize.ts +2 -2
  56. package/src/internal/resolvers/resolveInteractiveColors.ts +3 -3
  57. package/src/internal/resolvers/resolveSelectionControlColors.ts +2 -2
  58. package/src/internal/resolvers/resolveTextColor.ts +3 -3
  59. package/src/internal/resolvers/resolveTextStyles.ts +6 -6
  60. package/src/internal/resolvers/resolveTone.ts +2 -2
  61. package/src/layout/Container.tsx +2 -2
  62. package/src/layout/helpers.test.ts +2 -2
  63. package/src/layout/helpers.ts +12 -9
  64. package/src/primitives/heading/resolveHeadingStyle.ts +2 -2
  65. package/src/primitives/icon/Icon.tsx +3 -3
  66. package/src/theme/ThemeContext.tsx +2 -2
  67. package/src/theme/colorEngine.test.ts +158 -154
  68. package/src/theme/colorEngine.ts +128 -477
  69. package/src/theme/createTheme.ts +6 -8
  70. package/src/theme/types.ts +15 -18
  71. package/src/utils/deepMerge.test.ts +0 -4
@@ -1,8 +1,13 @@
1
+ import type { SemanticColorToken } from '@ankhorage/color-theory';
2
+ import {
3
+ DARK_SEMANTIC_COLOR_REFERENCES,
4
+ LIGHT_SEMANTIC_COLOR_REFERENCES,
5
+ } from '@ankhorage/color-theory';
6
+ import type { ThemeConfig } from '@ankhorage/contracts';
1
7
  import { describe, expect, it } from 'bun:test';
2
- import { oklch } from 'culori';
3
8
 
4
- import { generatePalette } from './colorEngine';
5
- import type { ColorTone, ThemeConfig, ThemeSemantics } from './types';
9
+ import { generatePalette, resolveSemanticColors } from './colorEngine';
10
+ import type { ThemeSemantics } from './types';
6
11
 
7
12
  const mockConfig: ThemeConfig = {
8
13
  id: 'test',
@@ -10,53 +15,19 @@ const mockConfig: ThemeConfig = {
10
15
  light: {
11
16
  primaryColor: '#3B82F6',
12
17
  harmony: 'triadic',
13
- colorTone: 'neutral',
14
18
  },
15
19
  dark: {
16
20
  primaryColor: '#3B82F6',
17
21
  harmony: 'triadic',
18
- colorTone: 'neutral',
19
22
  },
20
23
  };
21
24
 
22
- function configForColorTone(colorTone: ColorTone): ThemeConfig {
23
- return {
24
- ...mockConfig,
25
- light: {
26
- ...mockConfig.light,
27
- colorTone,
28
- },
29
- dark: {
30
- ...mockConfig.dark,
31
- colorTone,
32
- },
33
- };
34
- }
35
-
36
- function lightness(hex: string): number {
37
- const color = oklch(hex);
38
- if (!color) {
39
- throw new Error(`Expected valid OKLCH color for ${hex}.`);
40
- }
41
- return color.l;
42
- }
43
-
44
- function chroma(hex: string): number {
45
- const color = oklch(hex);
46
- if (!color) {
47
- throw new Error(`Expected valid OKLCH color for ${hex}.`);
48
- }
49
- return color.c;
50
- }
51
-
52
25
  function expectRequiredSemanticRoles(semantics: ThemeSemantics) {
53
26
  expect(semantics.neutral.bg).toBeDefined();
54
27
  expect(semantics.neutral.surface).toBeDefined();
55
28
  expect(semantics.neutral.text).toBeDefined();
56
29
  expect(semantics.brand.base).toBeDefined();
57
30
  expect(semantics.secondary.base).toBeDefined();
58
- expect(semantics.accent.base).toBeDefined();
59
- expect(semantics.highlight.base).toBeDefined();
60
31
  expect(semantics.danger.base).toBeDefined();
61
32
  expect(semantics.success.base).toBeDefined();
62
33
  expect(semantics.warning.base).toBeDefined();
@@ -69,159 +40,192 @@ function expectRequiredSemanticRoles(semantics: ThemeSemantics) {
69
40
  }
70
41
 
71
42
  describe('colorEngine', () => {
72
- it('should generate a stable palette with deterministic chroma hierarchy', () => {
73
- const { colors, semantics, scales } = generatePalette(mockConfig, 'light');
43
+ it('generates a valid palette for light mode', () => {
44
+ const { colors, swatches, semantics } = generatePalette(mockConfig, 'light');
74
45
 
75
- // Primary should be defined
76
46
  expect(colors.primary).toBeDefined();
47
+ expect(swatches.primary).toBeDefined();
48
+ expect(swatches.neutral).toBeDefined();
49
+ expect(Object.keys(swatches.primary)).toHaveLength(11);
50
+ expect(Object.keys(swatches.neutral)).toHaveLength(11);
77
51
 
78
- // Verify neutral surface chroma is strictly limited
79
- const neutralBg = oklch(semantics.neutral.bg);
80
- expect(neutralBg?.c).toBeLessThanOrEqual(0.021); // Small epsilon for float
81
-
82
- // Verify presence of new tokens
83
- expect(semantics.neutral.bgSubtle).toBeDefined();
84
- expect(semantics.brand.onSolidText).toBeDefined();
85
- expect(semantics.brand.softBg).toBeDefined();
52
+ // Surface semantic aliases
86
53
  expect(semantics.surface.default).toBe(semantics.neutral.surface);
87
54
  expect(semantics.content.muted).toBe(semantics.neutral.textMuted);
88
55
  expect(semantics.border.focus).toBe(semantics.brand.outline);
89
56
  expect(semantics.action.primary.base).toBe(semantics.brand.base);
90
57
  expect(semantics.action.danger.base).toBe(semantics.danger.base);
91
58
 
92
- // Verify scale coverage
93
- const primaryScale = scales.primary;
94
- const neutralScale = scales.neutral;
95
- expect(primaryScale).toBeDefined();
96
- expect(neutralScale).toBeDefined();
97
- if (!primaryScale || !neutralScale) throw new Error('Expected generated scales');
98
- expect(Object.keys(primaryScale)).toHaveLength(11);
99
- expect(neutralScale[50]).toBeDefined();
100
- expect(neutralScale[950]).toBeDefined();
59
+ // Surface runtime semantic aliases derived from ordinal swatches
60
+ expect(semantics.accent.base).toBeDefined();
61
+ expect(semantics.highlight.base).toBeDefined();
62
+ // accent/highlight are Surface semantic aliases (tertiary/quaternary fallback to primary when absent)
63
+ expect(semantics.accent.base).toBe(colors.accent);
64
+ expect(semantics.highlight.base).toBe(colors.highlight);
101
65
  });
102
66
 
103
- it('should respect triadic hue offsets (120 degrees)', () => {
104
- const { colors } = generatePalette(mockConfig, 'light');
105
-
106
- const p = oklch(colors.primary);
107
- const s = oklch(colors.secondary);
108
- const a = oklch(colors.accent);
109
-
110
- if (p && s && a) {
111
- const h1 = p.h ?? 0;
112
- const h2 = s.h ?? 0;
113
- const h3 = a.h ?? 0;
114
-
115
- // Check distance (allowing for small float rounding and perceptual shift)
116
- const diff1 = Math.abs((h2 - h1 + 360) % 360);
117
- const diff2 = Math.abs((h3 - h1 + 360) % 360);
67
+ it('generates a valid palette for dark mode', () => {
68
+ const { colors, semantics } = generatePalette(mockConfig, 'dark');
118
69
 
119
- expect(diff1).toBeGreaterThan(115);
120
- expect(diff1).toBeLessThan(125);
121
- expect(diff2).toBeGreaterThan(235);
122
- expect(diff2).toBeLessThan(245);
123
- }
70
+ expect(colors.background).toBe(semantics.neutral.bg);
71
+ expect(semantics.content.inverse).toBe(semantics.brand.onSolidText);
124
72
  });
125
73
 
126
- it('should handle monochromatic harmony (one hue)', () => {
127
- const config = {
128
- ...mockConfig,
129
- light: { ...mockConfig.light, harmony: 'monochromatic' as const },
130
- };
131
- const { colors } = generatePalette(config, 'light');
74
+ it('uses mode-aware role semantics for dark mode soft states', () => {
75
+ const light = generatePalette(mockConfig, 'light');
76
+ const dark = generatePalette(mockConfig, 'dark');
132
77
 
133
- const p = oklch(colors.primary);
134
- const s = oklch(colors.secondary);
78
+ expect(light.semantics.brand.softBg).toBe(light.swatches.primary[100]);
79
+ expect(light.semantics.brand.softHover).toBe(light.swatches.primary[200]);
80
+ expect(light.semantics.brand.softActive).toBe(light.swatches.primary[300]);
81
+ expect(dark.semantics.brand.softBg).toBe(dark.swatches.primary[900]);
82
+ expect(dark.semantics.brand.softHover).toBe(dark.swatches.primary[800]);
83
+ expect(dark.semantics.brand.softActive).toBe(dark.swatches.primary[700]);
84
+ });
135
85
 
136
- expect(p?.h).toBeCloseTo(s?.h ?? 0, 0);
86
+ it('preserves the primary color at swatch step 500 in light mode', () => {
87
+ const { swatches } = generatePalette(mockConfig, 'light');
88
+ expect(swatches.primary[500]).toBe(mockConfig.light.primaryColor);
137
89
  });
138
90
 
139
- it('should generate dark mode colors correctly', () => {
140
- const { colors, semantics } = generatePalette(mockConfig, 'dark');
91
+ it('preserves the primary color at swatch step 500 in dark mode', () => {
92
+ const { swatches } = generatePalette(mockConfig, 'dark');
93
+ expect(swatches.primary[500]).toBe(mockConfig.dark.primaryColor);
94
+ });
141
95
 
142
- const bg = oklch(colors.background);
143
- expect(bg?.l).toBeLessThan(0.2); // Should be dark
144
- expect(colors.background).toBe(semantics.neutral.bg);
145
- expect(semantics.content.inverse).toBe(semantics.brand.onSolidText);
96
+ it('provides a neutral swatch with neutralKeyColor at step 500', () => {
97
+ const { swatches } = generatePalette(mockConfig, 'light');
98
+ // neutral swatch must have a step 500 entry
99
+ expect(swatches.neutral[500]).toBeDefined();
146
100
  });
147
101
 
148
- it('should fall back to a default color if primaryColor is invalid', () => {
102
+ it('throws deterministically on invalid primary color', () => {
149
103
  const config = {
150
104
  ...mockConfig,
151
- light: { ...mockConfig.light, primaryColor: 'invalid-color' },
105
+ light: { ...mockConfig.light, primaryColor: 'not-a-hex-color' },
152
106
  };
153
-
154
- // Should not throw
155
- const { colors } = generatePalette(config, 'light');
156
- expect(colors.primary).toBeDefined();
157
- // Default fallback blue #3B82F6 in OKLCH
158
- const p = oklch(colors.primary);
159
- const fallbackBlue = oklch('#3B82F6');
160
- expect(fallbackBlue).toBeDefined();
161
- expect(p?.h).toBeCloseTo(fallbackBlue?.h ?? 0, 0);
107
+ expect(() => generatePalette(config, 'light')).toThrow();
162
108
  });
163
109
 
164
- it('keeps fluorescent and obsidian dark surfaces low-lightness', () => {
165
- const fluorescent = generatePalette(configForColorTone('fluorescent'), 'dark');
166
- const obsidian = generatePalette(configForColorTone('obsidian'), 'dark');
167
-
168
- expect(lightness(fluorescent.semantics.neutral.bg)).toBeLessThan(0.2);
169
- expect(lightness(fluorescent.semantics.surface.default)).toBeLessThan(0.2);
170
- expect(lightness(obsidian.semantics.neutral.bg)).toBeLessThan(0.2);
171
- expect(lightness(obsidian.semantics.surface.default)).toBeLessThan(0.2);
110
+ it('throws deterministically on invalid primary color in dark mode', () => {
111
+ const config = {
112
+ ...mockConfig,
113
+ dark: { ...mockConfig.dark, primaryColor: 'rgb(0,0,0)' },
114
+ };
115
+ expect(() => generatePalette(config, 'dark')).toThrow();
172
116
  });
173
117
 
174
- it('keeps fluorescent action colors higher chroma than neutral actions', () => {
175
- const fluorescent = generatePalette(configForColorTone('fluorescent'), 'light');
176
- const neutral = generatePalette(configForColorTone('neutral'), 'light');
177
-
178
- expect(chroma(fluorescent.semantics.brand.base)).toBeGreaterThan(
179
- chroma(neutral.semantics.action.neutral.base),
180
- );
181
- expect(chroma(fluorescent.semantics.accent.base)).toBeGreaterThan(
182
- chroma(neutral.semantics.action.neutral.base),
183
- );
118
+ it('emits required semantic roles for all harmonies', () => {
119
+ const harmonies = [
120
+ 'monochromatic',
121
+ 'analogous',
122
+ 'complementary',
123
+ 'triadic',
124
+ 'tetradic',
125
+ 'splitComplementary',
126
+ ] as const;
127
+
128
+ for (const harmony of harmonies) {
129
+ const config = {
130
+ ...mockConfig,
131
+ light: { ...mockConfig.light, harmony },
132
+ dark: { ...mockConfig.dark, harmony },
133
+ };
134
+ const light = generatePalette(config, 'light');
135
+ const dark = generatePalette(config, 'dark');
136
+ expectRequiredSemanticRoles(light.semantics);
137
+ expectRequiredSemanticRoles(dark.semantics);
138
+ }
184
139
  });
185
140
 
186
- it('keeps pastel backgrounds lower chroma than foreground and action colors', () => {
187
- const { semantics } = generatePalette(configForColorTone('pastel'), 'light');
188
-
189
- expect(chroma(semantics.neutral.bg)).toBeLessThan(chroma(semantics.brand.base));
190
- expect(chroma(semantics.neutral.bg)).toBeLessThan(chroma(semantics.accent.base));
191
- expect(chroma(semantics.surface.default)).toBeLessThan(chroma(semantics.action.primary.base));
141
+ it('generates ordinal chromatic role swatches (no accent/highlight as swatch keys)', () => {
142
+ const { swatches } = generatePalette(mockConfig, 'light');
143
+ const keys = Object.keys(swatches);
144
+ expect(keys).not.toContain('accent');
145
+ expect(keys).not.toContain('highlight');
146
+ expect(keys).not.toContain('surfaceTint');
147
+ expect(keys).not.toContain('base');
148
+ // ordinal roles are present
149
+ expect(keys).toContain('primary');
150
+ expect(keys).toContain('neutral');
192
151
  });
193
152
 
194
- it('keeps earth action colors aligned with mineral-like chroma', () => {
195
- const earth = generatePalette(configForColorTone('earth'), 'light');
196
- const mineral = generatePalette(configForColorTone('mineral'), 'light');
197
-
198
- expect(chroma(earth.semantics.brand.base)).toBeGreaterThan(chroma(earth.semantics.neutral.bg));
199
- expect(chroma(earth.semantics.secondary.base)).toBeCloseTo(
200
- chroma(mineral.semantics.secondary.base),
201
- 3,
202
- );
153
+ it('semantic resolver maps all SemanticColorToken entries for light mode', () => {
154
+ const { swatches } = generatePalette(mockConfig, 'light');
155
+ // Build a minimal GeneratedThemeModeColors for resolver testing
156
+ const generated = {
157
+ harmonyRoleColors: {} as never,
158
+ swatches,
159
+ neutral: { neutralKeyColor: swatches.neutral[500], diagnostics: {} as never },
160
+ };
161
+ const resolved = resolveSemanticColors(generated, LIGHT_SEMANTIC_COLOR_REFERENCES);
162
+ const tokens: SemanticColorToken[] = [
163
+ 'background',
164
+ 'surface',
165
+ 'surfaceRaised',
166
+ 'border',
167
+ 'divider',
168
+ 'text',
169
+ 'textMuted',
170
+ 'disabledBg',
171
+ 'disabledText',
172
+ 'brand',
173
+ 'brandEmphasis',
174
+ 'action',
175
+ 'actionEmphasis',
176
+ ];
177
+ for (const token of tokens) {
178
+ expect(resolved[token]).toBeDefined();
179
+ expect(typeof resolved[token]).toBe('string');
180
+ }
203
181
  });
204
182
 
205
- it('emits required semantic roles for every color tone', () => {
206
- const colorTones: readonly ColorTone[] = [
207
- 'neutral',
208
- 'pastel',
209
- 'earth',
210
- 'mineral',
211
- 'muted',
212
- 'jewel',
213
- 'fluorescent',
214
- 'obsidian',
215
- 'vaporwave',
216
- 'monochromeAccent',
183
+ it('semantic resolver maps all SemanticColorToken entries for dark mode', () => {
184
+ const { swatches } = generatePalette(mockConfig, 'dark');
185
+ const generated = {
186
+ harmonyRoleColors: {} as never,
187
+ swatches,
188
+ neutral: { neutralKeyColor: swatches.neutral[500], diagnostics: {} as never },
189
+ };
190
+ const resolved = resolveSemanticColors(generated, DARK_SEMANTIC_COLOR_REFERENCES);
191
+ const tokens: SemanticColorToken[] = [
192
+ 'background',
193
+ 'surface',
194
+ 'surfaceRaised',
195
+ 'border',
196
+ 'divider',
197
+ 'text',
198
+ 'textMuted',
199
+ 'disabledBg',
200
+ 'disabledText',
201
+ 'brand',
202
+ 'brandEmphasis',
203
+ 'action',
204
+ 'actionEmphasis',
217
205
  ];
206
+ for (const token of tokens) {
207
+ expect(resolved[token]).toBeDefined();
208
+ }
209
+ });
218
210
 
219
- for (const colorTone of colorTones) {
220
- const light = generatePalette(configForColorTone(colorTone), 'light');
221
- const dark = generatePalette(configForColorTone(colorTone), 'dark');
211
+ it('neutral semantics power backgrounds, borders, text in light mode', () => {
212
+ const { semantics } = generatePalette(mockConfig, 'light');
213
+ expect(semantics.neutral.bg).toBeDefined();
214
+ expect(semantics.neutral.bgSubtle).toBeDefined();
215
+ expect(semantics.neutral.surface).toBeDefined();
216
+ expect(semantics.neutral.surfaceHover).toBeDefined();
217
+ expect(semantics.neutral.border).toBeDefined();
218
+ expect(semantics.neutral.borderStrong).toBeDefined();
219
+ expect(semantics.neutral.divider).toBeDefined();
220
+ expect(semantics.neutral.text).toBeDefined();
221
+ expect(semantics.neutral.textMuted).toBeDefined();
222
+ expect(semantics.neutral.textSubtle).toBeDefined();
223
+ });
222
224
 
223
- expectRequiredSemanticRoles(light.semantics);
224
- expectRequiredSemanticRoles(dark.semantics);
225
- }
225
+ it('readable foregrounds are generated for brand base in both modes', () => {
226
+ const light = generatePalette(mockConfig, 'light');
227
+ const dark = generatePalette(mockConfig, 'dark');
228
+ expect(light.semantics.brand.onSolidText).toMatch(/^#/);
229
+ expect(dark.semantics.brand.onSolidText).toMatch(/^#/);
226
230
  });
227
231
  });