@ankhorage/surface 0.1.4 → 0.1.6

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 (292) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +23 -184
  3. package/dist/components/badge/Badge.js.map +1 -1
  4. package/dist/components/badge/index.js.map +1 -1
  5. package/dist/components/badge/types.js.map +1 -1
  6. package/dist/components/button/Button.js.map +1 -1
  7. package/dist/components/button/index.js.map +1 -1
  8. package/dist/components/button/types.js.map +1 -1
  9. package/dist/components/card/Card.js.map +1 -1
  10. package/dist/components/card/index.js.map +1 -1
  11. package/dist/components/card/types.js.map +1 -1
  12. package/dist/components/checkbox/Checkbox.js.map +1 -1
  13. package/dist/components/checkbox/index.js.map +1 -1
  14. package/dist/components/checkbox/types.js.map +1 -1
  15. package/dist/components/drawer/Drawer.js.map +1 -1
  16. package/dist/components/drawer/index.js.map +1 -1
  17. package/dist/components/drawer/types.js.map +1 -1
  18. package/dist/components/field/Field.js.map +1 -1
  19. package/dist/components/field/index.js.map +1 -1
  20. package/dist/components/field/types.js.map +1 -1
  21. package/dist/components/helper-text/HelperText.js.map +1 -1
  22. package/dist/components/helper-text/index.js.map +1 -1
  23. package/dist/components/helper-text/types.js.map +1 -1
  24. package/dist/components/icon-button/IconButton.js.map +1 -1
  25. package/dist/components/icon-button/index.js.map +1 -1
  26. package/dist/components/icon-button/types.js.map +1 -1
  27. package/dist/components/label/Label.js.map +1 -1
  28. package/dist/components/label/index.js.map +1 -1
  29. package/dist/components/label/types.js.map +1 -1
  30. package/dist/components/list-item/ListItem.js.map +1 -1
  31. package/dist/components/list-item/index.js.map +1 -1
  32. package/dist/components/list-item/types.js.map +1 -1
  33. package/dist/components/menu/Menu.js.map +1 -1
  34. package/dist/components/menu/index.js.map +1 -1
  35. package/dist/components/menu/navigation.js.map +1 -1
  36. package/dist/components/menu/types.js.map +1 -1
  37. package/dist/components/modal/Modal.js.map +1 -1
  38. package/dist/components/modal/index.js.map +1 -1
  39. package/dist/components/modal/types.js.map +1 -1
  40. package/dist/components/radio/Radio.js.map +1 -1
  41. package/dist/components/radio/index.js.map +1 -1
  42. package/dist/components/radio/types.js.map +1 -1
  43. package/dist/components/switch/Switch.js.map +1 -1
  44. package/dist/components/switch/index.js.map +1 -1
  45. package/dist/components/switch/types.js.map +1 -1
  46. package/dist/components/tabs/Tab.js.map +1 -1
  47. package/dist/components/tabs/TabList.js.map +1 -1
  48. package/dist/components/tabs/TabPanel.js.map +1 -1
  49. package/dist/components/tabs/Tabs.js.map +1 -1
  50. package/dist/components/tabs/a11y.js.map +1 -1
  51. package/dist/components/tabs/context.js.map +1 -1
  52. package/dist/components/tabs/index.js.map +1 -1
  53. package/dist/components/tabs/navigation.js.map +1 -1
  54. package/dist/components/tabs/types.js.map +1 -1
  55. package/dist/components/text-input/TextInput.js.map +1 -1
  56. package/dist/components/text-input/index.js.map +1 -1
  57. package/dist/components/text-input/types.js.map +1 -1
  58. package/dist/components/textarea/Textarea.js.map +1 -1
  59. package/dist/components/textarea/index.js.map +1 -1
  60. package/dist/components/textarea/types.js.map +1 -1
  61. package/dist/components/toast/Toast.js.map +1 -1
  62. package/dist/components/toast/ToastProvider.js.map +1 -1
  63. package/dist/components/toast/index.js.map +1 -1
  64. package/dist/components/toast/types.js.map +1 -1
  65. package/dist/components/tooltip/Tooltip.js.map +1 -1
  66. package/dist/components/tooltip/index.js.map +1 -1
  67. package/dist/components/tooltip/types.js.map +1 -1
  68. package/dist/context/FontContext.js.map +1 -1
  69. package/dist/context/TranslationContext.js.map +1 -1
  70. package/dist/core/responsive/ResponsiveProvider.js.map +1 -1
  71. package/dist/core/responsive/breakpoints.js.map +1 -1
  72. package/dist/core/responsive/getBreakpointFromWidth.js.map +1 -1
  73. package/dist/core/responsive/index.js.map +1 -1
  74. package/dist/core/responsive/resolve.js.map +1 -1
  75. package/dist/core/responsive/types.js.map +1 -1
  76. package/dist/core/responsive/useBreakpoint.js.map +1 -1
  77. package/dist/examples/DocsExamples.js.map +1 -1
  78. package/dist/index.js.map +1 -1
  79. package/dist/internal/focus/FocusScope.js.map +1 -1
  80. package/dist/internal/focus/useFocusManager.js.map +1 -1
  81. package/dist/internal/overlay/OverlayProvider.js.map +1 -1
  82. package/dist/internal/overlay/Portal.js.map +1 -1
  83. package/dist/internal/overlay/useOverlayStack.js.map +1 -1
  84. package/dist/internal/resolvers/index.js.map +1 -1
  85. package/dist/internal/resolvers/resolveControlSize.js.map +1 -1
  86. package/dist/internal/resolvers/resolveFieldPresentation.js.map +1 -1
  87. package/dist/internal/resolvers/resolveFieldState.js.map +1 -1
  88. package/dist/internal/resolvers/resolveFocusRingStyles.js.map +1 -1
  89. package/dist/internal/resolvers/resolveIconSize.js.map +1 -1
  90. package/dist/internal/resolvers/resolveIndicatorSize.js.map +1 -1
  91. package/dist/internal/resolvers/resolveInteractiveColors.js.map +1 -1
  92. package/dist/internal/resolvers/resolveInteractiveState.js.map +1 -1
  93. package/dist/internal/resolvers/resolveOverlayAnimation.js.map +1 -1
  94. package/dist/internal/resolvers/resolveOverlayZIndex.js.map +1 -1
  95. package/dist/internal/resolvers/resolveSelectionControlBehavior.js.map +1 -1
  96. package/dist/internal/resolvers/resolveSelectionControlColors.js.map +1 -1
  97. package/dist/internal/resolvers/resolveTextColor.js.map +1 -1
  98. package/dist/internal/resolvers/resolveTextStyles.js.map +1 -1
  99. package/dist/internal/resolvers/resolveTone.js.map +1 -1
  100. package/dist/internal/useControllableState.js.map +1 -1
  101. package/dist/layout/Box.js.map +1 -1
  102. package/dist/layout/Center.js.map +1 -1
  103. package/dist/layout/Container.js.map +1 -1
  104. package/dist/layout/Divider.js.map +1 -1
  105. package/dist/layout/Grid.js.map +1 -1
  106. package/dist/layout/Inline.js.map +1 -1
  107. package/dist/layout/Show.js.map +1 -1
  108. package/dist/layout/Spacer.js.map +1 -1
  109. package/dist/layout/Stack.js.map +1 -1
  110. package/dist/layout/Surface.js.map +1 -1
  111. package/dist/layout/Template.js.map +1 -1
  112. package/dist/layout/helpers.js.map +1 -1
  113. package/dist/layout/index.js.map +1 -1
  114. package/dist/primitives/button-base/ButtonBase.js.map +1 -1
  115. package/dist/primitives/button-base/index.js.map +1 -1
  116. package/dist/primitives/button-base/types.js.map +1 -1
  117. package/dist/primitives/heading/Heading.js.map +1 -1
  118. package/dist/primitives/heading/index.js.map +1 -1
  119. package/dist/primitives/heading/resolveHeadingStyle.js.map +1 -1
  120. package/dist/primitives/heading/types.js.map +1 -1
  121. package/dist/primitives/icon/Icon.js.map +1 -1
  122. package/dist/primitives/icon/index.js.map +1 -1
  123. package/dist/primitives/icon/resolveExpoIconComponent.js.map +1 -1
  124. package/dist/primitives/text/Text.js.map +1 -1
  125. package/dist/primitives/text/index.js.map +1 -1
  126. package/dist/primitives/text/types.js.map +1 -1
  127. package/dist/theme/ThemeContext.js.map +1 -1
  128. package/dist/theme/colorEngine.js.map +1 -1
  129. package/dist/theme/createTheme.js.map +1 -1
  130. package/dist/theme/index.js.map +1 -1
  131. package/dist/theme/resolveToken.js.map +1 -1
  132. package/dist/theme/types.js.map +1 -1
  133. package/dist/utils/deepEqual.js.map +1 -1
  134. package/dist/utils/deepMerge.js.map +1 -1
  135. package/package.json +4 -1
  136. package/src/components/badge/Badge.tsx +47 -0
  137. package/src/components/badge/index.ts +2 -0
  138. package/src/components/badge/types.ts +13 -0
  139. package/src/components/button/Button.tsx +104 -0
  140. package/src/components/button/index.ts +2 -0
  141. package/src/components/button/types.ts +26 -0
  142. package/src/components/card/Card.tsx +81 -0
  143. package/src/components/card/index.ts +2 -0
  144. package/src/components/card/types.ts +11 -0
  145. package/src/components/checkbox/Checkbox.tsx +111 -0
  146. package/src/components/checkbox/index.ts +2 -0
  147. package/src/components/checkbox/types.ts +19 -0
  148. package/src/components/drawer/Drawer.tsx +92 -0
  149. package/src/components/drawer/index.ts +2 -0
  150. package/src/components/drawer/types.ts +10 -0
  151. package/src/components/field/Field.tsx +43 -0
  152. package/src/components/field/index.ts +2 -0
  153. package/src/components/field/types.ts +13 -0
  154. package/src/components/helper-text/HelperText.tsx +12 -0
  155. package/src/components/helper-text/index.ts +2 -0
  156. package/src/components/helper-text/types.ts +9 -0
  157. package/src/components/icon-button/IconButton.tsx +60 -0
  158. package/src/components/icon-button/index.ts +2 -0
  159. package/src/components/icon-button/types.ts +19 -0
  160. package/src/components/label/Label.tsx +17 -0
  161. package/src/components/label/index.ts +2 -0
  162. package/src/components/label/types.ts +10 -0
  163. package/src/components/list-item/ListItem.tsx +72 -0
  164. package/src/components/list-item/index.ts +2 -0
  165. package/src/components/list-item/types.ts +11 -0
  166. package/src/components/menu/Menu.tsx +180 -0
  167. package/src/components/menu/index.ts +2 -0
  168. package/src/components/menu/navigation.test.ts +21 -0
  169. package/src/components/menu/navigation.ts +34 -0
  170. package/src/components/menu/types.ts +16 -0
  171. package/src/components/modal/Modal.tsx +87 -0
  172. package/src/components/modal/index.ts +2 -0
  173. package/src/components/modal/types.ts +9 -0
  174. package/src/components/radio/Radio.tsx +116 -0
  175. package/src/components/radio/index.ts +2 -0
  176. package/src/components/radio/types.ts +19 -0
  177. package/src/components/switch/Switch.tsx +116 -0
  178. package/src/components/switch/index.ts +2 -0
  179. package/src/components/switch/types.ts +19 -0
  180. package/src/components/tabs/Tab.tsx +82 -0
  181. package/src/components/tabs/TabList.tsx +51 -0
  182. package/src/components/tabs/TabPanel.tsx +29 -0
  183. package/src/components/tabs/Tabs.tsx +67 -0
  184. package/src/components/tabs/a11y.test.ts +15 -0
  185. package/src/components/tabs/a11y.ts +15 -0
  186. package/src/components/tabs/context.tsx +31 -0
  187. package/src/components/tabs/index.ts +5 -0
  188. package/src/components/tabs/navigation.test.ts +21 -0
  189. package/src/components/tabs/navigation.ts +32 -0
  190. package/src/components/tabs/types.ts +27 -0
  191. package/src/components/text-input/TextInput.tsx +116 -0
  192. package/src/components/text-input/index.ts +2 -0
  193. package/src/components/text-input/types.ts +32 -0
  194. package/src/components/textarea/Textarea.tsx +15 -0
  195. package/src/components/textarea/index.ts +2 -0
  196. package/src/components/textarea/types.ts +5 -0
  197. package/src/components/toast/Toast.tsx +54 -0
  198. package/src/components/toast/ToastProvider.tsx +114 -0
  199. package/src/components/toast/index.ts +3 -0
  200. package/src/components/toast/types.ts +16 -0
  201. package/src/components/tooltip/Tooltip.tsx +109 -0
  202. package/src/components/tooltip/index.ts +2 -0
  203. package/src/components/tooltip/types.ts +9 -0
  204. package/src/context/FontContext.tsx +59 -0
  205. package/src/context/TranslationContext.tsx +54 -0
  206. package/src/core/responsive/ResponsiveProvider.tsx +31 -0
  207. package/src/core/responsive/breakpoints.ts +9 -0
  208. package/src/core/responsive/getBreakpointFromWidth.test.ts +15 -0
  209. package/src/core/responsive/getBreakpointFromWidth.ts +10 -0
  210. package/src/core/responsive/index.ts +6 -0
  211. package/src/core/responsive/resolve.test.ts +25 -0
  212. package/src/core/responsive/resolve.ts +24 -0
  213. package/src/core/responsive/types.ts +10 -0
  214. package/src/core/responsive/useBreakpoint.ts +9 -0
  215. package/src/examples/DocsExamples.tsx +116 -0
  216. package/src/index.test.ts +64 -0
  217. package/src/index.ts +55 -0
  218. package/src/internal/focus/FocusScope.tsx +66 -0
  219. package/src/internal/focus/useFocusManager.test.ts +44 -0
  220. package/src/internal/focus/useFocusManager.ts +142 -0
  221. package/src/internal/overlay/OverlayProvider.tsx +74 -0
  222. package/src/internal/overlay/Portal.tsx +38 -0
  223. package/src/internal/overlay/useOverlayStack.test.ts +31 -0
  224. package/src/internal/overlay/useOverlayStack.ts +61 -0
  225. package/src/internal/resolvers/index.ts +15 -0
  226. package/src/internal/resolvers/resolveControlSize.test.ts +25 -0
  227. package/src/internal/resolvers/resolveControlSize.ts +45 -0
  228. package/src/internal/resolvers/resolveFieldPresentation.test.ts +31 -0
  229. package/src/internal/resolvers/resolveFieldPresentation.ts +30 -0
  230. package/src/internal/resolvers/resolveFieldState.test.ts +22 -0
  231. package/src/internal/resolvers/resolveFieldState.ts +36 -0
  232. package/src/internal/resolvers/resolveFocusRingStyles.ts +14 -0
  233. package/src/internal/resolvers/resolveIconSize.ts +6 -0
  234. package/src/internal/resolvers/resolveIndicatorSize.test.ts +19 -0
  235. package/src/internal/resolvers/resolveIndicatorSize.ts +47 -0
  236. package/src/internal/resolvers/resolveInteractiveColors.test.ts +57 -0
  237. package/src/internal/resolvers/resolveInteractiveColors.ts +134 -0
  238. package/src/internal/resolvers/resolveInteractiveState.test.ts +14 -0
  239. package/src/internal/resolvers/resolveInteractiveState.ts +15 -0
  240. package/src/internal/resolvers/resolveOverlayAnimation.test.ts +15 -0
  241. package/src/internal/resolvers/resolveOverlayAnimation.ts +24 -0
  242. package/src/internal/resolvers/resolveOverlayZIndex.test.ts +15 -0
  243. package/src/internal/resolvers/resolveOverlayZIndex.ts +13 -0
  244. package/src/internal/resolvers/resolveSelectionControlBehavior.test.ts +52 -0
  245. package/src/internal/resolvers/resolveSelectionControlBehavior.ts +23 -0
  246. package/src/internal/resolvers/resolveSelectionControlColors.test.ts +44 -0
  247. package/src/internal/resolvers/resolveSelectionControlColors.ts +81 -0
  248. package/src/internal/resolvers/resolveTextColor.test.ts +23 -0
  249. package/src/internal/resolvers/resolveTextColor.ts +40 -0
  250. package/src/internal/resolvers/resolveTextStyles.test.ts +27 -0
  251. package/src/internal/resolvers/resolveTextStyles.ts +95 -0
  252. package/src/internal/resolvers/resolveTone.ts +19 -0
  253. package/src/internal/useControllableState.ts +28 -0
  254. package/src/layout/Box.tsx +79 -0
  255. package/src/layout/Center.tsx +22 -0
  256. package/src/layout/Container.tsx +43 -0
  257. package/src/layout/Divider.tsx +26 -0
  258. package/src/layout/Grid.tsx +83 -0
  259. package/src/layout/Inline.tsx +9 -0
  260. package/src/layout/Show.tsx +15 -0
  261. package/src/layout/Spacer.tsx +22 -0
  262. package/src/layout/Stack.tsx +67 -0
  263. package/src/layout/Surface.tsx +70 -0
  264. package/src/layout/Template.tsx +85 -0
  265. package/src/layout/helpers.test.ts +71 -0
  266. package/src/layout/helpers.ts +208 -0
  267. package/src/layout/index.ts +22 -0
  268. package/src/primitives/button-base/ButtonBase.tsx +81 -0
  269. package/src/primitives/button-base/index.ts +2 -0
  270. package/src/primitives/button-base/types.ts +16 -0
  271. package/src/primitives/heading/Heading.tsx +60 -0
  272. package/src/primitives/heading/index.ts +2 -0
  273. package/src/primitives/heading/resolveHeadingStyle.test.ts +31 -0
  274. package/src/primitives/heading/resolveHeadingStyle.ts +17 -0
  275. package/src/primitives/heading/types.ts +13 -0
  276. package/src/primitives/icon/Icon.tsx +40 -0
  277. package/src/primitives/icon/index.ts +2 -0
  278. package/src/primitives/icon/resolveExpoIconComponent.test.ts +29 -0
  279. package/src/primitives/icon/resolveExpoIconComponent.ts +20 -0
  280. package/src/primitives/text/Text.tsx +66 -0
  281. package/src/primitives/text/index.ts +2 -0
  282. package/src/primitives/text/types.ts +18 -0
  283. package/src/theme/ThemeContext.tsx +95 -0
  284. package/src/theme/colorEngine.test.ts +114 -0
  285. package/src/theme/colorEngine.ts +480 -0
  286. package/src/theme/createTheme.ts +121 -0
  287. package/src/theme/index.ts +5 -0
  288. package/src/theme/resolveToken.ts +32 -0
  289. package/src/theme/types.ts +188 -0
  290. package/src/utils/deepEqual.ts +34 -0
  291. package/src/utils/deepMerge.test.ts +117 -0
  292. package/src/utils/deepMerge.ts +29 -0
@@ -0,0 +1,95 @@
1
+ import React, { createContext, useContext, useMemo } from 'react';
2
+
3
+ import { useFontContext } from '../context/FontContext';
4
+ import { ResponsiveProvider } from '../core/responsive/ResponsiveProvider';
5
+ import { OverlayProvider } from '../internal/overlay/OverlayProvider';
6
+ import { isDeepEqual } from '../utils/deepEqual';
7
+ import { deepMerge } from '../utils/deepMerge';
8
+ import { createTheme } from './createTheme';
9
+ import type { AnkhTheme, ThemeConfig } from './types';
10
+
11
+ const defaultTheme = createTheme();
12
+
13
+ export const ThemeContext = createContext<{
14
+ theme: AnkhTheme;
15
+ mode: 'light' | 'dark';
16
+ setThemeConfig: (config: Partial<ThemeConfig>) => void;
17
+ setMode: (mode: 'light' | 'dark') => void;
18
+ _hasProvider?: boolean;
19
+ }>({
20
+ theme: defaultTheme,
21
+ mode: 'light',
22
+ setThemeConfig: () => {
23
+ /* fallback */
24
+ },
25
+ setMode: () => {
26
+ /* fallback */
27
+ },
28
+ _hasProvider: false,
29
+ });
30
+
31
+ export const ThemeProvider = ({
32
+ children,
33
+ initialConfig,
34
+ initialMode = 'light',
35
+ }: {
36
+ children: React.ReactNode;
37
+ initialConfig?: Partial<ThemeConfig>;
38
+ initialMode?: 'light' | 'dark';
39
+ }) => {
40
+ const [config, setConfig] = React.useState<ThemeConfig>(() =>
41
+ initialConfig ? deepMerge(defaultTheme.config, initialConfig) : defaultTheme.config,
42
+ );
43
+ const [mode, setMode] = React.useState<'light' | 'dark'>(initialMode);
44
+ const { activeFontId } = useFontContext();
45
+
46
+ // Keep state in sync with prop for real-time Studio updates
47
+ React.useEffect(() => {
48
+ if (initialConfig) {
49
+ setConfig((prev) => {
50
+ const merged = deepMerge(prev, initialConfig);
51
+ if (isDeepEqual(prev, merged)) return prev;
52
+ return merged;
53
+ });
54
+ }
55
+ }, [initialConfig]);
56
+
57
+ const theme = useMemo(
58
+ () => createTheme(config, mode, activeFontId),
59
+ [config, mode, activeFontId],
60
+ );
61
+
62
+ const value = useMemo(
63
+ () => ({
64
+ theme,
65
+ mode,
66
+ setThemeConfig: (newConfig: Partial<ThemeConfig>) =>
67
+ setConfig((prev) => deepMerge(prev, newConfig)),
68
+ setMode,
69
+ _hasProvider: true,
70
+ }),
71
+ [theme, mode],
72
+ );
73
+
74
+ return (
75
+ <ResponsiveProvider>
76
+ <ThemeContext.Provider value={value}>
77
+ <OverlayProvider>{children}</OverlayProvider>
78
+ </ThemeContext.Provider>
79
+ </ResponsiveProvider>
80
+ );
81
+ };
82
+
83
+ export const useTheme = () => {
84
+ return useContext(ThemeContext);
85
+ };
86
+
87
+ export const useThemeConfig = () => {
88
+ const { setThemeConfig } = useTheme();
89
+ return setThemeConfig;
90
+ };
91
+
92
+ export const useThemeMode = () => {
93
+ const { mode, setMode } = useTheme();
94
+ return { mode, setMode };
95
+ };
@@ -0,0 +1,114 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+ import { oklch } from 'culori';
3
+
4
+ import { generatePalette } from './colorEngine';
5
+ import type { ThemeConfig } from './types';
6
+
7
+ const mockConfig: ThemeConfig = {
8
+ id: 'test',
9
+ name: 'Test Theme',
10
+ light: {
11
+ primaryColor: '#3B82F6',
12
+ harmony: 'triadic',
13
+ systemTone: 'neutral',
14
+ },
15
+ dark: {
16
+ primaryColor: '#3B82F6',
17
+ harmony: 'triadic',
18
+ systemTone: 'neutral',
19
+ },
20
+ };
21
+
22
+ describe('colorEngine', () => {
23
+ it('should generate a stable palette with deterministic chroma hierarchy', () => {
24
+ const { colors, semantics, scales } = generatePalette(mockConfig, 'light');
25
+
26
+ // Primary should be defined
27
+ expect(colors.primary).toBeDefined();
28
+
29
+ // Verify neutral surface chroma is strictly limited
30
+ const neutralBg = oklch(semantics.neutral.bg);
31
+ expect(neutralBg?.c).toBeLessThanOrEqual(0.021); // Small epsilon for float
32
+
33
+ // Verify presence of new tokens
34
+ expect(semantics.neutral.bgSubtle).toBeDefined();
35
+ expect(semantics.brand.onSolidText).toBeDefined();
36
+ expect(semantics.brand.softBg).toBeDefined();
37
+ expect(semantics.surface.default).toBe(semantics.neutral.surface);
38
+ expect(semantics.content.muted).toBe(semantics.neutral.textMuted);
39
+ expect(semantics.border.focus).toBe(semantics.brand.outline);
40
+ expect(semantics.action.primary.base).toBe(semantics.brand.base);
41
+ expect(semantics.action.danger.base).toBe(semantics.danger.base);
42
+
43
+ // Verify scale coverage
44
+ const primaryScale = scales.primary;
45
+ const neutralScale = scales.neutral;
46
+ expect(primaryScale).toBeDefined();
47
+ expect(neutralScale).toBeDefined();
48
+ if (!primaryScale || !neutralScale) throw new Error('Expected generated scales');
49
+ expect(Object.keys(primaryScale)).toHaveLength(11);
50
+ expect(neutralScale[50]).toBeDefined();
51
+ expect(neutralScale[950]).toBeDefined();
52
+ });
53
+
54
+ it('should respect triadic hue offsets (120 degrees)', () => {
55
+ const { colors } = generatePalette(mockConfig, 'light');
56
+
57
+ const p = oklch(colors.primary);
58
+ const s = oklch(colors.secondary);
59
+ const a = oklch(colors.accent);
60
+
61
+ if (p && s && a) {
62
+ const h1 = p.h ?? 0;
63
+ const h2 = s.h ?? 0;
64
+ const h3 = a.h ?? 0;
65
+
66
+ // Check distance (allowing for small float rounding and perceptual shift)
67
+ const diff1 = Math.abs((h2 - h1 + 360) % 360);
68
+ const diff2 = Math.abs((h3 - h1 + 360) % 360);
69
+
70
+ expect(diff1).toBeGreaterThan(115);
71
+ expect(diff1).toBeLessThan(125);
72
+ expect(diff2).toBeGreaterThan(235);
73
+ expect(diff2).toBeLessThan(245);
74
+ }
75
+ });
76
+
77
+ it('should handle monochromatic harmony (one hue)', () => {
78
+ const config = {
79
+ ...mockConfig,
80
+ light: { ...mockConfig.light, harmony: 'monochromatic' as const },
81
+ };
82
+ const { colors } = generatePalette(config, 'light');
83
+
84
+ const p = oklch(colors.primary);
85
+ const s = oklch(colors.secondary);
86
+
87
+ expect(p?.h).toBeCloseTo(s?.h ?? 0, 0);
88
+ });
89
+
90
+ it('should generate dark mode colors correctly', () => {
91
+ const { colors, semantics } = generatePalette(mockConfig, 'dark');
92
+
93
+ const bg = oklch(colors.background);
94
+ expect(bg?.l).toBeLessThan(0.2); // Should be dark
95
+ expect(colors.background).toBe(semantics.neutral.bg);
96
+ expect(semantics.content.inverse).toBe(semantics.brand.onSolidText);
97
+ });
98
+
99
+ it('should fall back to a default color if primaryColor is invalid', () => {
100
+ const config = {
101
+ ...mockConfig,
102
+ light: { ...mockConfig.light, primaryColor: 'invalid-color' },
103
+ };
104
+
105
+ // Should not throw
106
+ const { colors } = generatePalette(config, 'light');
107
+ expect(colors.primary).toBeDefined();
108
+ // Default fallback blue #3B82F6 in OKLCH
109
+ const p = oklch(colors.primary);
110
+ const fallbackBlue = oklch('#3B82F6');
111
+ expect(fallbackBlue).toBeDefined();
112
+ expect(p?.h).toBeCloseTo(fallbackBlue?.h ?? 0, 0);
113
+ });
114
+ });
@@ -0,0 +1,480 @@
1
+ import { formatHex, modeOklch, oklch, useMode } from 'culori';
2
+
3
+ import type {
4
+ ActionSemantics,
5
+ BorderSemantics,
6
+ ColorHarmony,
7
+ ColorScale,
8
+ ColorTone,
9
+ ContentSemantics,
10
+ NeutralSemantics,
11
+ RoleSemantics,
12
+ SurfaceSemantics,
13
+ SystemTone,
14
+ ThemeConfig,
15
+ ThemeSemantics,
16
+ } from './types';
17
+
18
+ useMode(modeOklch);
19
+
20
+ interface OklchColor {
21
+ mode: 'oklch';
22
+ l: number;
23
+ c: number;
24
+ h?: number;
25
+ }
26
+
27
+ export const SCALE_STEPS = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950] as const;
28
+
29
+ /**
30
+ * Deterministic Lightness Curves (OKLCH L)
31
+ */
32
+ const LIGHTNESS_CURVES = {
33
+ light: [0.98, 0.95, 0.9, 0.82, 0.72, 0.62, 0.52, 0.42, 0.32, 0.22, 0.15],
34
+ dark: [0.12, 0.16, 0.2, 0.26, 0.34, 0.62, 0.7, 0.78, 0.86, 0.92, 0.96],
35
+ } as const;
36
+
37
+ /**
38
+ * Lightness anchors per tone
39
+ */
40
+ const TONE_LIGHTNESS_ANCHORS: Record<ColorTone, number> = {
41
+ grayscale: 0.62,
42
+ earth: 0.55,
43
+ pastel: 0.7,
44
+ jewel: 0.62,
45
+ fluorescent: 0.65,
46
+ };
47
+
48
+ /**
49
+ * Chroma curve per step to prevent "tinted whites" and "glow"
50
+ * Peak energy at 500, falloff at extremes.
51
+ */
52
+ const CHROMA_BY_STEP = [0.1, 0.18, 0.3, 0.45, 0.7, 1.0, 0.92, 0.8, 0.6, 0.4, 0.25] as const;
53
+
54
+ /**
55
+ * Deterministic Chroma Anchors (OKLCH C)
56
+ */
57
+ const CHROMA_ANCHORS: Record<ColorTone, number> = {
58
+ grayscale: 0,
59
+ earth: 0.05,
60
+ pastel: 0.08,
61
+ jewel: 0.16,
62
+ fluorescent: 0.26,
63
+ };
64
+
65
+ /**
66
+ * Chroma Hierarchy Rule
67
+ */
68
+ const CHROMA_HIERARCHY = {
69
+ primary: 1.0,
70
+ secondary: 0.7,
71
+ accent: 0.4,
72
+ surface: 0.1,
73
+ } as const;
74
+
75
+ /**
76
+ * Semantic Step Mapping
77
+ */
78
+ const SEMANTIC_STEPS = {
79
+ light: {
80
+ bg: 0, // 50
81
+ bgSubtle: 1, // 100
82
+ surface: 1, // 100
83
+ surfaceHover: 2, // 200
84
+ surfaceActive: 3, // 300
85
+ border: 3, // 300
86
+ borderStrong: 4, // 400
87
+ divider: 2, // 200
88
+ text: 9, // 900
89
+ textMuted: 7, // 700
90
+ textSubtle: 6, // 600
91
+ solid: 5, // 500
92
+ softBg: 1, // 100
93
+ softHover: 2, // 200
94
+ softActive: 3, // 300
95
+ outline: 4, // 400
96
+ },
97
+ dark: {
98
+ bg: 0, // 50
99
+ bgSubtle: 1, // 100
100
+ surface: 1, // 100
101
+ surfaceHover: 2, // 200
102
+ surfaceActive: 3, // 300
103
+ border: 3, // 300
104
+ borderStrong: 4, // 400
105
+ divider: 2, // 200
106
+ text: 10, // 950
107
+ textMuted: 8, // 800
108
+ textSubtle: 7, // 700
109
+ solid: 5, // 500
110
+ softBg: 1, // 100
111
+ softHover: 2, // 200
112
+ softActive: 3, // 300
113
+ outline: 4, // 400
114
+ },
115
+ } as const;
116
+
117
+ export function generateColorScale(baseColor: OklchColor, isDark: boolean): ColorScale {
118
+ const scale: Partial<ColorScale> = {};
119
+ const curve = isDark ? LIGHTNESS_CURVES.dark : LIGHTNESS_CURVES.light;
120
+
121
+ // Dark-mode chroma rule: reduce chroma by 25%
122
+ const chromaMultiplier = isDark ? 0.75 : 1.0;
123
+ const targetChroma = baseColor.c * chromaMultiplier;
124
+
125
+ SCALE_STEPS.forEach((step, index) => {
126
+ const lightness = curve[index];
127
+ const chromaByStep = CHROMA_BY_STEP[index];
128
+ if (lightness === undefined || chromaByStep === undefined) {
129
+ return;
130
+ }
131
+ // Apply chroma falloff curve per step
132
+ const stepChroma = targetChroma * chromaByStep;
133
+ const stepColor = { ...baseColor, l: lightness, c: stepChroma };
134
+ scale[step as keyof ColorScale] = formatHex(stepColor);
135
+ });
136
+
137
+ return scale as ColorScale;
138
+ }
139
+
140
+ /**
141
+ * Deterministic Harmony Hues
142
+ */
143
+ function getHarmonyHues(baseHue: number, mode: ColorHarmony): number[] {
144
+ const h = (baseHue + 360) % 360;
145
+ switch (mode) {
146
+ case 'monochromatic':
147
+ return [h];
148
+ case 'analogous':
149
+ return [h, (h - 30 + 360) % 360, (h + 30) % 360];
150
+ case 'complementary':
151
+ return [h, (h + 180) % 360];
152
+ case 'splitComplementary':
153
+ return [h, (h + 150) % 360, (h + 210) % 360];
154
+ case 'triadic':
155
+ return [h, (h + 120) % 360, (h + 240) % 360];
156
+ case 'tetradic':
157
+ return [h, (h + 90) % 360, (h + 180) % 360, (h + 270) % 360];
158
+ default:
159
+ return [h];
160
+ }
161
+ }
162
+
163
+ /**
164
+ * System Tone Assignment Logic
165
+ */
166
+ function getSystemToneMapping(system: SystemTone): {
167
+ bg: ColorTone;
168
+ surface: ColorTone;
169
+ primary: ColorTone;
170
+ secondary: ColorTone;
171
+ accent: ColorTone;
172
+ highlight: ColorTone;
173
+ } {
174
+ switch (system) {
175
+ case 'pastel':
176
+ return {
177
+ bg: 'pastel',
178
+ surface: 'pastel',
179
+ primary: 'pastel',
180
+ secondary: 'pastel',
181
+ accent: 'jewel',
182
+ highlight: 'fluorescent',
183
+ };
184
+ case 'earth':
185
+ return {
186
+ bg: 'earth',
187
+ surface: 'earth',
188
+ primary: 'earth',
189
+ secondary: 'earth',
190
+ accent: 'jewel',
191
+ highlight: 'jewel',
192
+ };
193
+ case 'jewel':
194
+ return {
195
+ bg: 'grayscale',
196
+ surface: 'grayscale',
197
+ primary: 'jewel',
198
+ secondary: 'jewel',
199
+ accent: 'jewel',
200
+ highlight: 'fluorescent',
201
+ };
202
+ case 'fluorescent':
203
+ return {
204
+ bg: 'grayscale',
205
+ surface: 'grayscale',
206
+ primary: 'jewel',
207
+ secondary: 'jewel',
208
+ accent: 'fluorescent',
209
+ highlight: 'fluorescent',
210
+ };
211
+ default: // neutral
212
+ return {
213
+ bg: 'grayscale',
214
+ surface: 'grayscale',
215
+ primary: 'jewel',
216
+ secondary: 'pastel',
217
+ accent: 'jewel',
218
+ highlight: 'fluorescent',
219
+ };
220
+ }
221
+ }
222
+
223
+ /**
224
+ * Simple OKLCH L-based contrast check
225
+ */
226
+ function getBestContrast(solidHex: string, neutral50: string, neutral950: string): string {
227
+ const solid = oklch(solidHex);
228
+ const n50 = oklch(neutral50);
229
+ const n950 = oklch(neutral950);
230
+
231
+ if (!solid || !n50 || !n950) return neutral50;
232
+
233
+ const diff50 = Math.abs(solid.l - n50.l);
234
+ const diff950 = Math.abs(solid.l - n950.l);
235
+
236
+ return diff50 >= diff950 ? neutral50 : neutral950;
237
+ }
238
+
239
+ export function generatePalette(
240
+ config: ThemeConfig,
241
+ mode: 'light' | 'dark' = 'light',
242
+ ): {
243
+ colors: Record<string, string>;
244
+ scales: Record<string, ColorScale>;
245
+ semantics: ThemeSemantics;
246
+ } {
247
+ const modeConfig = mode === 'dark' ? config.dark : config.light;
248
+ const { primaryColor, harmony, systemTone } = modeConfig;
249
+
250
+ let base = oklch(primaryColor) as OklchColor | undefined;
251
+ if (!base) {
252
+ console.warn(
253
+ `[colorEngine] Invalid primary color: "${primaryColor}". Falling back to default blue.`,
254
+ );
255
+ base = oklch('#3B82F6') as OklchColor;
256
+ }
257
+
258
+ const baseHue = base.h ?? 0;
259
+ const hues = getHarmonyHues(baseHue, harmony);
260
+ const toneMap = getSystemToneMapping(systemTone);
261
+
262
+ // 1. Resolve Chromas
263
+ const getC = (t: ColorTone) => CHROMA_ANCHORS[t];
264
+ const getHue = (index: number, fallback: number) => hues[index] ?? fallback;
265
+
266
+ const primaryChroma = getC(toneMap.primary);
267
+ const secondaryChroma = getC(toneMap.secondary) * CHROMA_HIERARCHY.secondary;
268
+ const tertiaryChroma = getC(toneMap.accent) * CHROMA_HIERARCHY.accent;
269
+ const highlightChroma = getC(toneMap.highlight);
270
+ const surfaceChroma = Math.min(0.02, getC(toneMap.bg) * CHROMA_HIERARCHY.surface);
271
+
272
+ // 2. Stable Role Assignment
273
+ let pHue = getHue(0, baseHue);
274
+ let sHue = getHue(0, baseHue);
275
+ let aHue = getHue(0, baseHue);
276
+ let hHue =
277
+ harmony === 'tetradic' ? getHue(3, getHue(0, baseHue)) : (getHue(0, baseHue) + 60) % 360; // Yellow-ish offset if no tetradic
278
+
279
+ switch (harmony) {
280
+ case 'complementary':
281
+ pHue = getHue(0, pHue);
282
+ aHue = getHue(1, pHue);
283
+ sHue = (getHue(0, pHue) + 30) % 360;
284
+ break;
285
+ case 'splitComplementary':
286
+ case 'triadic':
287
+ pHue = getHue(0, pHue);
288
+ sHue = getHue(1, pHue);
289
+ aHue = getHue(2, sHue);
290
+ break;
291
+ case 'tetradic':
292
+ pHue = getHue(0, pHue);
293
+ sHue = getHue(1, pHue);
294
+ aHue = getHue(2, sHue);
295
+ hHue = getHue(3, aHue);
296
+ break;
297
+ case 'analogous':
298
+ pHue = getHue(1, pHue);
299
+ sHue = getHue(0, pHue);
300
+ aHue = getHue(2, sHue);
301
+ break;
302
+ }
303
+
304
+ // 3. Build Bases with tuned lightness
305
+ const getL = (t: ColorTone) => TONE_LIGHTNESS_ANCHORS[t];
306
+
307
+ const primaryBase: OklchColor = {
308
+ mode: 'oklch',
309
+ l: getL(toneMap.primary),
310
+ c: primaryChroma,
311
+ h: pHue,
312
+ };
313
+ const secondaryBase: OklchColor = {
314
+ mode: 'oklch',
315
+ l: getL(toneMap.secondary),
316
+ c: secondaryChroma,
317
+ h: sHue,
318
+ };
319
+ const accentBase: OklchColor = {
320
+ mode: 'oklch',
321
+ l: getL(toneMap.accent),
322
+ c: tertiaryChroma,
323
+ h: aHue,
324
+ };
325
+ const highlightBase: OklchColor = {
326
+ mode: 'oklch',
327
+ l: getL(toneMap.highlight),
328
+ c: highlightChroma,
329
+ h: hHue,
330
+ };
331
+ const dangerBase: OklchColor = {
332
+ mode: 'oklch',
333
+ l: 0.6,
334
+ c: 0.2,
335
+ h: 25,
336
+ };
337
+ const successBase: OklchColor = {
338
+ mode: 'oklch',
339
+ l: 0.6,
340
+ c: 0.2,
341
+ h: 145,
342
+ };
343
+ const warningBase: OklchColor = {
344
+ mode: 'oklch',
345
+ l: 0.75,
346
+ c: 0.15,
347
+ h: 85,
348
+ };
349
+
350
+ const neutralBase: OklchColor =
351
+ surfaceChroma === 0
352
+ ? { mode: 'oklch', l: 0.62, c: 0 }
353
+ : { mode: 'oklch', l: 0.62, c: surfaceChroma, h: 260 };
354
+
355
+ // 4. Generate Scales
356
+ const isDark = mode === 'dark';
357
+ const scales = {
358
+ primary: generateColorScale(primaryBase, isDark),
359
+ secondary: generateColorScale(secondaryBase, isDark),
360
+ accent: generateColorScale(accentBase, isDark),
361
+ highlight: generateColorScale(highlightBase, isDark),
362
+ neutral: generateColorScale(neutralBase, isDark),
363
+ danger: generateColorScale(dangerBase, isDark),
364
+ success: generateColorScale(successBase, isDark),
365
+ warning: generateColorScale(warningBase, isDark),
366
+ };
367
+
368
+ // 5. Mappings
369
+ const steps = isDark ? SEMANTIC_STEPS.dark : SEMANTIC_STEPS.light;
370
+ const getStep = (s: ColorScale, idx: number) => {
371
+ const key = SCALE_STEPS[idx] ?? SCALE_STEPS[SCALE_STEPS.length - 1] ?? 950;
372
+ return s[key];
373
+ };
374
+
375
+ const getNeutralMapping = (scale: ColorScale): NeutralSemantics => ({
376
+ bg: getStep(scale, steps.bg),
377
+ bgSubtle: getStep(scale, steps.bgSubtle),
378
+ surface: getStep(scale, steps.surface),
379
+ surfaceHover: getStep(scale, steps.surfaceHover),
380
+ surfaceActive: getStep(scale, steps.surfaceActive),
381
+ border: getStep(scale, steps.border),
382
+ borderStrong: getStep(scale, steps.borderStrong),
383
+ divider: getStep(scale, steps.divider),
384
+ text: getStep(scale, steps.text),
385
+ textMuted: getStep(scale, steps.textMuted),
386
+ textSubtle: getStep(scale, steps.textSubtle),
387
+ });
388
+
389
+ const getColorMapping = (scale: ColorScale, neutralScale: ColorScale): RoleSemantics => {
390
+ const solid = getStep(scale, steps.solid);
391
+ const softChromaLimit = 0.08;
392
+
393
+ // Chroma capping for soft tokens
394
+ const getSoftStep = (idx: number) => {
395
+ const hex = getStep(scale, idx);
396
+ const color = oklch(hex);
397
+ if (color && color.c > softChromaLimit) {
398
+ return formatHex({ ...color, c: softChromaLimit });
399
+ }
400
+ return hex;
401
+ };
402
+
403
+ return {
404
+ base: solid,
405
+ hover: getStep(scale, 6), // 600
406
+ strong: getStep(scale, 7), // 700
407
+ softBg: getSoftStep(steps.softBg),
408
+ softHover: getSoftStep(steps.softHover),
409
+ softActive: getSoftStep(steps.softActive),
410
+ outline: getStep(scale, steps.outline),
411
+ onSolidText: getBestContrast(solid, getStep(neutralScale, 0), getStep(neutralScale, 10)),
412
+ };
413
+ };
414
+
415
+ const neutral = getNeutralMapping(scales.neutral);
416
+ const brand = getColorMapping(scales.primary, scales.neutral);
417
+ const neutralAction = getColorMapping(scales.neutral, scales.neutral);
418
+ const danger = getColorMapping(scales.danger, scales.neutral);
419
+ const success = getColorMapping(scales.success, scales.neutral);
420
+ const warning = getColorMapping(scales.warning, scales.neutral);
421
+
422
+ const colors: Record<string, string> = {
423
+ primary: getStep(scales.primary, steps.solid),
424
+ secondary: getStep(scales.secondary, steps.solid),
425
+ accent: getStep(scales.accent, steps.solid),
426
+ highlight: getStep(scales.highlight, steps.solid),
427
+ background: neutral.bg,
428
+ surface: neutral.surface,
429
+ text: neutral.text,
430
+ textSecondary: neutral.textMuted,
431
+ border: neutral.border,
432
+ error: getStep(scales.danger, steps.solid),
433
+ success: getStep(scales.success, steps.solid),
434
+ warning: getStep(scales.warning, steps.solid),
435
+ };
436
+
437
+ const surface: SurfaceSemantics = {
438
+ default: neutral.surface,
439
+ subtle: neutral.bgSubtle,
440
+ raised: neutral.surface,
441
+ };
442
+
443
+ const content: ContentSemantics = {
444
+ default: neutral.text,
445
+ muted: neutral.textMuted,
446
+ subtle: neutral.textSubtle,
447
+ inverse: brand.onSolidText,
448
+ };
449
+
450
+ const border: BorderSemantics = {
451
+ default: neutral.border,
452
+ strong: neutral.borderStrong,
453
+ focus: brand.outline,
454
+ };
455
+
456
+ const action: ActionSemantics = {
457
+ primary: brand,
458
+ neutral: neutralAction,
459
+ danger,
460
+ };
461
+
462
+ return {
463
+ colors,
464
+ scales,
465
+ semantics: {
466
+ neutral,
467
+ brand,
468
+ secondary: getColorMapping(scales.secondary, scales.neutral),
469
+ accent: getColorMapping(scales.accent, scales.neutral),
470
+ highlight: getColorMapping(scales.highlight, scales.neutral),
471
+ danger,
472
+ success,
473
+ warning,
474
+ surface,
475
+ content,
476
+ border,
477
+ action,
478
+ },
479
+ };
480
+ }