@cloud-ru/ds-theme 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 (247) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/LICENSE +201 -0
  3. package/README.md +365 -0
  4. package/dist/cjs/components/ChildThemeProvider/ChildThemeProvider.d.ts +24 -0
  5. package/dist/cjs/components/ChildThemeProvider/ChildThemeProvider.js +25 -0
  6. package/dist/cjs/components/ChildThemeProvider/index.d.ts +1 -0
  7. package/dist/cjs/components/ChildThemeProvider/index.js +17 -0
  8. package/dist/cjs/components/RootThemeProvider/RootThemeProvider.d.ts +49 -0
  9. package/dist/cjs/components/RootThemeProvider/RootThemeProvider.js +30 -0
  10. package/dist/cjs/components/RootThemeProvider/index.d.ts +1 -0
  11. package/dist/cjs/components/RootThemeProvider/index.js +17 -0
  12. package/dist/cjs/components/ThemeProvider/ThemeProvider.d.ts +28 -0
  13. package/dist/cjs/components/ThemeProvider/ThemeProvider.js +16 -0
  14. package/dist/cjs/components/ThemeProvider/index.d.ts +1 -0
  15. package/dist/cjs/components/ThemeProvider/index.js +17 -0
  16. package/dist/cjs/components/ThemeScope/ThemeScope.d.ts +32 -0
  17. package/dist/cjs/components/ThemeScope/ThemeScope.js +65 -0
  18. package/dist/cjs/components/ThemeScope/index.d.ts +1 -0
  19. package/dist/cjs/components/ThemeScope/index.js +17 -0
  20. package/dist/cjs/components/index.d.ts +3 -0
  21. package/dist/cjs/components/index.js +19 -0
  22. package/dist/cjs/constants/appearance.d.ts +22 -0
  23. package/dist/cjs/constants/appearance.js +27 -0
  24. package/dist/cjs/constants/colorScheme.d.ts +17 -0
  25. package/dist/cjs/constants/colorScheme.js +23 -0
  26. package/dist/cjs/constants/index.d.ts +2 -0
  27. package/dist/cjs/constants/index.js +18 -0
  28. package/dist/cjs/context/appearanceContext.d.ts +20 -0
  29. package/dist/cjs/context/appearanceContext.js +22 -0
  30. package/dist/cjs/context/index.d.ts +4 -0
  31. package/dist/cjs/context/index.js +7 -0
  32. package/dist/cjs/context/themeContext.d.ts +12 -0
  33. package/dist/cjs/context/themeContext.js +15 -0
  34. package/dist/cjs/hooks/index.d.ts +4 -0
  35. package/dist/cjs/hooks/index.js +20 -0
  36. package/dist/cjs/hooks/useApplyCustomTheme.d.ts +27 -0
  37. package/dist/cjs/hooks/useApplyCustomTheme.js +39 -0
  38. package/dist/cjs/hooks/useColorScheme.d.ts +37 -0
  39. package/dist/cjs/hooks/useColorScheme.js +80 -0
  40. package/dist/cjs/hooks/useIsomorphicLayoutEffect.d.ts +7 -0
  41. package/dist/cjs/hooks/useIsomorphicLayoutEffect.js +16 -0
  42. package/dist/cjs/hooks/useThemeClassnames.d.ts +11 -0
  43. package/dist/cjs/hooks/useThemeClassnames.js +18 -0
  44. package/dist/cjs/hooks/useThemeConfig.d.ts +24 -0
  45. package/dist/cjs/hooks/useThemeConfig.js +15 -0
  46. package/dist/cjs/index.d.ts +7 -0
  47. package/dist/cjs/index.js +23 -0
  48. package/dist/cjs/keys.d.ts +4 -0
  49. package/dist/cjs/keys.js +19 -0
  50. package/dist/cjs/package.json +3 -0
  51. package/dist/cjs/ssr.d.ts +15 -0
  52. package/dist/cjs/ssr.js +38 -0
  53. package/dist/cjs/store/globalStore.d.ts +29 -0
  54. package/dist/cjs/store/globalStore.js +72 -0
  55. package/dist/cjs/store/index.d.ts +1 -0
  56. package/dist/cjs/store/index.js +17 -0
  57. package/dist/cjs/types/appearance.d.ts +23 -0
  58. package/dist/cjs/types/appearance.js +3 -0
  59. package/dist/cjs/types/colorScheme.d.ts +5 -0
  60. package/dist/cjs/types/colorScheme.js +2 -0
  61. package/dist/cjs/types/index.d.ts +2 -0
  62. package/dist/cjs/types/index.js +18 -0
  63. package/dist/cjs/utils/colorSchemeFromHeaders.d.ts +22 -0
  64. package/dist/cjs/utils/colorSchemeFromHeaders.js +44 -0
  65. package/dist/cjs/utils/colorSchemeStorage.d.ts +35 -0
  66. package/dist/cjs/utils/colorSchemeStorage.js +85 -0
  67. package/dist/cjs/utils/customTheme/buildBrandPaletteCss.d.ts +11 -0
  68. package/dist/cjs/utils/customTheme/buildBrandPaletteCss.js +35 -0
  69. package/dist/cjs/utils/customTheme/buildBrandPaletteVars.d.ts +11 -0
  70. package/dist/cjs/utils/customTheme/buildBrandPaletteVars.js +42 -0
  71. package/dist/cjs/utils/customTheme/color/apca.d.ts +4 -0
  72. package/dist/cjs/utils/customTheme/color/apca.js +62 -0
  73. package/dist/cjs/utils/customTheme/color/colorSpace.d.ts +5 -0
  74. package/dist/cjs/utils/customTheme/color/colorSpace.js +72 -0
  75. package/dist/cjs/utils/customTheme/color/conversions.d.ts +17 -0
  76. package/dist/cjs/utils/customTheme/color/conversions.js +101 -0
  77. package/dist/cjs/utils/customTheme/color/hex.d.ts +10 -0
  78. package/dist/cjs/utils/customTheme/color/hex.js +34 -0
  79. package/dist/cjs/utils/customTheme/color/index.d.ts +4 -0
  80. package/dist/cjs/utils/customTheme/color/index.js +20 -0
  81. package/dist/cjs/utils/customTheme/color/oklch.d.ts +3 -0
  82. package/dist/cjs/utils/customTheme/color/oklch.js +21 -0
  83. package/dist/cjs/utils/customTheme/color/types.d.ts +40 -0
  84. package/dist/cjs/utils/customTheme/color/types.js +2 -0
  85. package/dist/cjs/utils/customTheme/constants.d.ts +31 -0
  86. package/dist/cjs/utils/customTheme/constants.js +49 -0
  87. package/dist/cjs/utils/customTheme/generateBrandPalette.d.ts +10 -0
  88. package/dist/cjs/utils/customTheme/generateBrandPalette.js +25 -0
  89. package/dist/cjs/utils/customTheme/index.d.ts +4 -0
  90. package/dist/cjs/utils/customTheme/index.js +20 -0
  91. package/dist/cjs/utils/getThemeClassnames.d.ts +17 -0
  92. package/dist/cjs/utils/getThemeClassnames.js +38 -0
  93. package/dist/cjs/utils/index.d.ts +7 -0
  94. package/dist/cjs/utils/index.js +23 -0
  95. package/dist/cjs/utils/mergeAppearance.d.ts +7 -0
  96. package/dist/cjs/utils/mergeAppearance.js +17 -0
  97. package/dist/cjs/utils/resolveColorScheme.d.ts +7 -0
  98. package/dist/cjs/utils/resolveColorScheme.js +15 -0
  99. package/dist/cjs/utils/themeBootstrap.d.ts +12 -0
  100. package/dist/cjs/utils/themeBootstrap.js +19 -0
  101. package/dist/esm/components/ChildThemeProvider/ChildThemeProvider.d.ts +24 -0
  102. package/dist/esm/components/ChildThemeProvider/ChildThemeProvider.js +22 -0
  103. package/dist/esm/components/ChildThemeProvider/index.d.ts +1 -0
  104. package/dist/esm/components/ChildThemeProvider/index.js +1 -0
  105. package/dist/esm/components/RootThemeProvider/RootThemeProvider.d.ts +49 -0
  106. package/dist/esm/components/RootThemeProvider/RootThemeProvider.js +27 -0
  107. package/dist/esm/components/RootThemeProvider/index.d.ts +1 -0
  108. package/dist/esm/components/RootThemeProvider/index.js +1 -0
  109. package/dist/esm/components/ThemeProvider/ThemeProvider.d.ts +28 -0
  110. package/dist/esm/components/ThemeProvider/ThemeProvider.js +13 -0
  111. package/dist/esm/components/ThemeProvider/index.d.ts +1 -0
  112. package/dist/esm/components/ThemeProvider/index.js +1 -0
  113. package/dist/esm/components/ThemeScope/ThemeScope.d.ts +32 -0
  114. package/dist/esm/components/ThemeScope/ThemeScope.js +59 -0
  115. package/dist/esm/components/ThemeScope/index.d.ts +1 -0
  116. package/dist/esm/components/ThemeScope/index.js +1 -0
  117. package/dist/esm/components/index.d.ts +3 -0
  118. package/dist/esm/components/index.js +3 -0
  119. package/dist/esm/constants/appearance.d.ts +22 -0
  120. package/dist/esm/constants/appearance.js +24 -0
  121. package/dist/esm/constants/colorScheme.d.ts +17 -0
  122. package/dist/esm/constants/colorScheme.js +20 -0
  123. package/dist/esm/constants/index.d.ts +2 -0
  124. package/dist/esm/constants/index.js +2 -0
  125. package/dist/esm/context/appearanceContext.d.ts +20 -0
  126. package/dist/esm/context/appearanceContext.js +18 -0
  127. package/dist/esm/context/index.d.ts +4 -0
  128. package/dist/esm/context/index.js +2 -0
  129. package/dist/esm/context/themeContext.d.ts +12 -0
  130. package/dist/esm/context/themeContext.js +11 -0
  131. package/dist/esm/hooks/index.d.ts +4 -0
  132. package/dist/esm/hooks/index.js +4 -0
  133. package/dist/esm/hooks/useApplyCustomTheme.d.ts +27 -0
  134. package/dist/esm/hooks/useApplyCustomTheme.js +36 -0
  135. package/dist/esm/hooks/useColorScheme.d.ts +37 -0
  136. package/dist/esm/hooks/useColorScheme.js +76 -0
  137. package/dist/esm/hooks/useIsomorphicLayoutEffect.d.ts +7 -0
  138. package/dist/esm/hooks/useIsomorphicLayoutEffect.js +13 -0
  139. package/dist/esm/hooks/useThemeClassnames.d.ts +11 -0
  140. package/dist/esm/hooks/useThemeClassnames.js +15 -0
  141. package/dist/esm/hooks/useThemeConfig.d.ts +24 -0
  142. package/dist/esm/hooks/useThemeConfig.js +12 -0
  143. package/dist/esm/index.d.ts +7 -0
  144. package/dist/esm/index.js +7 -0
  145. package/dist/esm/keys.d.ts +4 -0
  146. package/dist/esm/keys.js +16 -0
  147. package/dist/esm/ssr.d.ts +15 -0
  148. package/dist/esm/ssr.js +15 -0
  149. package/dist/esm/store/globalStore.d.ts +29 -0
  150. package/dist/esm/store/globalStore.js +69 -0
  151. package/dist/esm/store/index.d.ts +1 -0
  152. package/dist/esm/store/index.js +1 -0
  153. package/dist/esm/types/appearance.d.ts +23 -0
  154. package/dist/esm/types/appearance.js +2 -0
  155. package/dist/esm/types/colorScheme.d.ts +5 -0
  156. package/dist/esm/types/colorScheme.js +1 -0
  157. package/dist/esm/types/index.d.ts +2 -0
  158. package/dist/esm/types/index.js +2 -0
  159. package/dist/esm/utils/colorSchemeFromHeaders.d.ts +22 -0
  160. package/dist/esm/utils/colorSchemeFromHeaders.js +40 -0
  161. package/dist/esm/utils/colorSchemeStorage.d.ts +35 -0
  162. package/dist/esm/utils/colorSchemeStorage.js +81 -0
  163. package/dist/esm/utils/customTheme/buildBrandPaletteCss.d.ts +11 -0
  164. package/dist/esm/utils/customTheme/buildBrandPaletteCss.js +32 -0
  165. package/dist/esm/utils/customTheme/buildBrandPaletteVars.d.ts +11 -0
  166. package/dist/esm/utils/customTheme/buildBrandPaletteVars.js +39 -0
  167. package/dist/esm/utils/customTheme/color/apca.d.ts +4 -0
  168. package/dist/esm/utils/customTheme/color/apca.js +59 -0
  169. package/dist/esm/utils/customTheme/color/colorSpace.d.ts +5 -0
  170. package/dist/esm/utils/customTheme/color/colorSpace.js +68 -0
  171. package/dist/esm/utils/customTheme/color/conversions.d.ts +17 -0
  172. package/dist/esm/utils/customTheme/color/conversions.js +91 -0
  173. package/dist/esm/utils/customTheme/color/hex.d.ts +10 -0
  174. package/dist/esm/utils/customTheme/color/hex.js +29 -0
  175. package/dist/esm/utils/customTheme/color/index.d.ts +4 -0
  176. package/dist/esm/utils/customTheme/color/index.js +4 -0
  177. package/dist/esm/utils/customTheme/color/oklch.d.ts +3 -0
  178. package/dist/esm/utils/customTheme/color/oklch.js +18 -0
  179. package/dist/esm/utils/customTheme/color/types.d.ts +40 -0
  180. package/dist/esm/utils/customTheme/color/types.js +1 -0
  181. package/dist/esm/utils/customTheme/constants.d.ts +31 -0
  182. package/dist/esm/utils/customTheme/constants.js +46 -0
  183. package/dist/esm/utils/customTheme/generateBrandPalette.d.ts +10 -0
  184. package/dist/esm/utils/customTheme/generateBrandPalette.js +22 -0
  185. package/dist/esm/utils/customTheme/index.d.ts +4 -0
  186. package/dist/esm/utils/customTheme/index.js +4 -0
  187. package/dist/esm/utils/getThemeClassnames.d.ts +17 -0
  188. package/dist/esm/utils/getThemeClassnames.js +34 -0
  189. package/dist/esm/utils/index.d.ts +7 -0
  190. package/dist/esm/utils/index.js +7 -0
  191. package/dist/esm/utils/mergeAppearance.d.ts +7 -0
  192. package/dist/esm/utils/mergeAppearance.js +14 -0
  193. package/dist/esm/utils/resolveColorScheme.d.ts +7 -0
  194. package/dist/esm/utils/resolveColorScheme.js +12 -0
  195. package/dist/esm/utils/themeBootstrap.d.ts +12 -0
  196. package/dist/esm/utils/themeBootstrap.js +16 -0
  197. package/dist/tsconfig.cjs.tsbuildinfo +1 -0
  198. package/dist/tsconfig.esm.tsbuildinfo +1 -0
  199. package/package.json +45 -0
  200. package/src/components/ChildThemeProvider/ChildThemeProvider.tsx +54 -0
  201. package/src/components/ChildThemeProvider/index.ts +1 -0
  202. package/src/components/RootThemeProvider/RootThemeProvider.tsx +87 -0
  203. package/src/components/RootThemeProvider/index.ts +1 -0
  204. package/src/components/ThemeProvider/ThemeProvider.tsx +41 -0
  205. package/src/components/ThemeProvider/index.ts +1 -0
  206. package/src/components/ThemeScope/ThemeScope.tsx +106 -0
  207. package/src/components/ThemeScope/index.ts +1 -0
  208. package/src/components/index.ts +3 -0
  209. package/src/constants/appearance.ts +27 -0
  210. package/src/constants/colorScheme.ts +23 -0
  211. package/src/constants/index.ts +2 -0
  212. package/src/context/appearanceContext.tsx +39 -0
  213. package/src/context/index.ts +4 -0
  214. package/src/context/themeContext.ts +18 -0
  215. package/src/hooks/index.ts +4 -0
  216. package/src/hooks/useApplyCustomTheme.ts +56 -0
  217. package/src/hooks/useColorScheme.ts +131 -0
  218. package/src/hooks/useIsomorphicLayoutEffect.ts +17 -0
  219. package/src/hooks/useThemeClassnames.ts +18 -0
  220. package/src/hooks/useThemeConfig.tsx +44 -0
  221. package/src/index.ts +7 -0
  222. package/src/keys.ts +18 -0
  223. package/src/ssr.ts +25 -0
  224. package/src/store/globalStore.ts +93 -0
  225. package/src/store/index.ts +1 -0
  226. package/src/types/appearance.ts +29 -0
  227. package/src/types/colorScheme.ts +7 -0
  228. package/src/types/index.ts +2 -0
  229. package/src/utils/colorSchemeFromHeaders.ts +57 -0
  230. package/src/utils/colorSchemeStorage.ts +117 -0
  231. package/src/utils/customTheme/buildBrandPaletteCss.ts +37 -0
  232. package/src/utils/customTheme/buildBrandPaletteVars.ts +56 -0
  233. package/src/utils/customTheme/color/apca.ts +74 -0
  234. package/src/utils/customTheme/color/colorSpace.ts +82 -0
  235. package/src/utils/customTheme/color/conversions.ts +113 -0
  236. package/src/utils/customTheme/color/hex.ts +39 -0
  237. package/src/utils/customTheme/color/index.ts +4 -0
  238. package/src/utils/customTheme/color/oklch.ts +22 -0
  239. package/src/utils/customTheme/color/types.ts +32 -0
  240. package/src/utils/customTheme/constants.ts +57 -0
  241. package/src/utils/customTheme/generateBrandPalette.ts +30 -0
  242. package/src/utils/customTheme/index.ts +4 -0
  243. package/src/utils/getThemeClassnames.ts +40 -0
  244. package/src/utils/index.ts +7 -0
  245. package/src/utils/mergeAppearance.ts +16 -0
  246. package/src/utils/resolveColorScheme.ts +15 -0
  247. package/src/utils/themeBootstrap.ts +18 -0
package/src/index.ts ADDED
@@ -0,0 +1,7 @@
1
+ export * from './components';
2
+ export * from './hooks';
3
+ export * from './context';
4
+ export * from './constants';
5
+ export * from './types';
6
+ export * from './utils';
7
+ export * from './store';
package/src/keys.ts ADDED
@@ -0,0 +1,18 @@
1
+ import { providerKey } from '@cloud-ru/ds-context-kit';
2
+
3
+ /**
4
+ * `Symbol.for`-ключи домена theme-appearance — обе системы синглтонов в одном месте:
5
+ * - `context` — объект React-контекста оформления (через `providerKey` → dev-warn о смешанных версиях);
6
+ * - `store` — глобальный мутабельный стор оформления (кросс-root мост хост→MFE).
7
+ *
8
+ * Одна контракт-версия на домен бампается ТОЛЬКО при несовместимом сломе формы значения и двигает
9
+ * ОБА ключа вместе. Ортогонально мажору ПАКЕТА. Строки менять НЕЛЬЗЯ (форк синглтона = случайный
10
+ * `:v2`). НЕ путать с доменом `theme` старого `ThemeContext` (themeMap/changeTheme). См.
11
+ * providers-standard.md.
12
+ */
13
+ const CONTRACT_VERSION = 1;
14
+
15
+ export const THEME_KEYS = {
16
+ context: providerKey('theme-appearance', CONTRACT_VERSION),
17
+ store: `@cloud-ru/ds:global-theme-store:v${CONTRACT_VERSION}`,
18
+ } as const;
package/src/ssr.ts ADDED
@@ -0,0 +1,25 @@
1
+ // SSR-безопасный субпуть `@cloud-ru/ds-theme/ssr`: только чистые символы (без React-контекста/хуков),
2
+ // чтобы импорт в React Server Components не тянул `createContext`. Зеркало `@cloud-ru/ds-adaptive/ssr`.
3
+
4
+ export { BRAND, BRAND_ROLE, DENSITY } from './constants/appearance';
5
+ export { COLOR_SCHEME, THEME_OVERRIDE, THEME_OVERRIDE_STORAGE_KEY } from './constants/colorScheme';
6
+ export type { Brand, BrandRole, Density, ThemeAppearance } from './types/appearance';
7
+ export type { ColorScheme, ThemeOverride } from './types/colorScheme';
8
+ export { mergeAppearance } from './utils/mergeAppearance';
9
+ export { getThemeClassnameList, getThemeClassnames } from './utils/getThemeClassnames';
10
+ export { resolveColorScheme } from './utils/resolveColorScheme';
11
+ export { getColorSchemeFromHeaders, getThemeOverrideFromHeaders } from './utils/colorSchemeFromHeaders';
12
+ export { getThemeBootstrapScript } from './utils/themeBootstrap';
13
+ // Кастомный бренд-цвет — чистые (без React/DOM) генератор палитры и сборщик CSS-переменных, чтобы
14
+ // SSR/RSC мог инжектить `--sn-brand-color-primary-*` строкой и убрать мигание до гидрации.
15
+ export { generateBrandPalette } from './utils/customTheme/generateBrandPalette';
16
+ export type { BrandPalette } from './utils/customTheme/generateBrandPalette';
17
+ export { buildBrandPaletteVars } from './utils/customTheme/buildBrandPaletteVars';
18
+ export { buildBrandPaletteCss } from './utils/customTheme/buildBrandPaletteCss';
19
+ export {
20
+ BASE_BRAND_PALETTE,
21
+ BRAND_PRIMARY_TONES,
22
+ BRAND_PRIMARY_VAR_PREFIX,
23
+ PRIMARY_ACCENT_TONE,
24
+ } from './utils/customTheme/constants';
25
+ export type { BrandPrimaryTone } from './utils/customTheme/constants';
@@ -0,0 +1,93 @@
1
+ import { ThemeAppearanceContextValue, ThemeAppearanceStore } from '../context/appearanceContext';
2
+ import { THEME_KEYS } from '../keys';
3
+ import { ThemeAppearance } from '../types/appearance';
4
+ import { mergeAppearance } from '../utils/mergeAppearance';
5
+
6
+ export type GlobalThemeStore = {
7
+ /** Стор для `<RootThemeProvider store={...} />`. */
8
+ store: ThemeAppearanceStore;
9
+ /** Текущее оформление. */
10
+ getAppearance(): ThemeAppearance;
11
+ /** Слить патч в оформление и уведомить все подписанные провайдеры. */
12
+ setAppearance(patch: Partial<ThemeAppearance>): void;
13
+ };
14
+
15
+ // Ключ — из локального keys.ts (одна точка бампа версии для контекста и стора домена).
16
+ const REGISTRY_KEY = Symbol.for(THEME_KEYS.store);
17
+
18
+ type Registry = { [REGISTRY_KEY]?: GlobalThemeStore };
19
+
20
+ // SSR-snapshot — пустое оформление без сеттера. Мутабельный глобал на сервере запрещён (утечёт
21
+ // между запросами), поэтому серверный рендер всегда отдаёт дефолт; реальные классы на SSR ставит
22
+ // строка на `<html>` (см. providers-standard.md).
23
+ const SERVER_VALUE: ThemeAppearanceContextValue = { appearance: {}, setAppearance: undefined };
24
+
25
+ const APPEARANCE_KEYS: (keyof ThemeAppearance)[] = ['colorScheme', 'brand', 'brandRole', 'density', 'acrylic'];
26
+
27
+ function isSameAppearance(a: ThemeAppearance, b: ThemeAppearance): boolean {
28
+ return APPEARANCE_KEYS.every(key => a[key] === b[key]);
29
+ }
30
+
31
+ /**
32
+ * Глобальный singleton-стор оформления, общий для всех React-корней процесса: микрофронтов
33
+ * single-spa, островков Astro и любых сценариев, где один `RootThemeProvider` нельзя поставить
34
+ * общим предком в одном дереве. Хранится в `globalThis` через `Symbol.for`, поэтому любой модуль
35
+ * получает один экземпляр без проброса по импортам и переживает несколько копий пакета.
36
+ *
37
+ * Shell контейнера меняет оформление через `setAppearance({ colorScheme, density, ... })` — все
38
+ * подписанные `RootThemeProvider` реэмитят полный набор `sn-*` классов. Когда у приложения один
39
+ * React-корень — используйте `<RootThemeProvider value={…} />`, этот хелпер нужен только для multi-root.
40
+ *
41
+ * ```tsx
42
+ * // там, где меняется тема (один раз):
43
+ * getGlobalThemeStore().setAppearance({ colorScheme });
44
+ *
45
+ * // в каждом независимом корне:
46
+ * <RootThemeProvider store={getGlobalThemeStore().store} rootRef={htmlRef}>{root}</RootThemeProvider>;
47
+ * ```
48
+ */
49
+ export function getGlobalThemeStore(): GlobalThemeStore {
50
+ const registry = globalThis as Registry;
51
+
52
+ if (!registry[REGISTRY_KEY]) {
53
+ let appearance: ThemeAppearance = {};
54
+ // Стабильный snapshot: новый объект только после смены оформления, иначе `useSyncExternalStore`
55
+ // зациклится на «getSnapshot вернул новую ссылку».
56
+ let cache: ThemeAppearanceContextValue | undefined;
57
+ const listeners = new Set<() => void>();
58
+
59
+ const setAppearance = (patch: Partial<ThemeAppearance>): void => {
60
+ const next = mergeAppearance(appearance, patch);
61
+ if (isSameAppearance(appearance, next)) return;
62
+ appearance = next;
63
+ cache = undefined;
64
+ listeners.forEach(listener => listener());
65
+ };
66
+
67
+ const read = (): ThemeAppearanceContextValue => {
68
+ if (!cache) {
69
+ cache = { appearance, setAppearance };
70
+ }
71
+
72
+ return cache;
73
+ };
74
+
75
+ registry[REGISTRY_KEY] = {
76
+ store: {
77
+ subscribe: onChange => {
78
+ listeners.add(onChange);
79
+
80
+ return () => {
81
+ listeners.delete(onChange);
82
+ };
83
+ },
84
+ getSnapshot: read,
85
+ getServerSnapshot: () => SERVER_VALUE,
86
+ },
87
+ getAppearance: () => appearance,
88
+ setAppearance,
89
+ };
90
+ }
91
+
92
+ return registry[REGISTRY_KEY];
93
+ }
@@ -0,0 +1 @@
1
+ export * from './globalStore';
@@ -0,0 +1,29 @@
1
+ // Тип через `(typeof X)[keyof typeof X]` — чтобы не тянуть `@cloud-ru/ds-utils` (RSC-safe, как в colorScheme).
2
+
3
+ import { BRAND, BRAND_ROLE, DENSITY } from '../constants/appearance';
4
+ import { ColorScheme } from './colorScheme';
5
+
6
+ export type Density = (typeof DENSITY)[keyof typeof DENSITY];
7
+
8
+ export type Brand = (typeof BRAND)[keyof typeof BRAND];
9
+
10
+ export type BrandRole = (typeof BRAND_ROLE)[keyof typeof BRAND_ROLE];
11
+
12
+ /**
13
+ * Набор осей оформления, которые определяют полный набор `sn-*` классов на DOM-границе. Любая
14
+ * незаданная ось наследуется от вышестоящего провайдера (слияние в `ChildThemeProvider`) — но на сам
15
+ * элемент всё равно эмитится **полный** набор классов, потому что токены `@cloud-ru/figma-variables`
16
+ * не переопределяются по одной оси через CSS-каскад (см. providers-standard.md).
17
+ */
18
+ export type ThemeAppearance = {
19
+ /** Цветовая схема — `sn-light` / `sn-dark`. */
20
+ colorScheme?: ColorScheme;
21
+ /** Бренд — `sn-brandA` … */
22
+ brand?: Brand;
23
+ /** Роль бренда (палитра) — `sn-main` … */
24
+ brandRole?: BrandRole;
25
+ /** Плотность — `sn-comfort` / `sn-compact` / `sn-spacious`. */
26
+ density?: Density;
27
+ /** Акрил (blur-материал) — `sn-yes` при `true`, иначе `sn-no`. */
28
+ acrylic?: boolean;
29
+ };
@@ -0,0 +1,7 @@
1
+ import { COLOR_SCHEME, THEME_OVERRIDE } from '../constants/colorScheme';
2
+
3
+ /** Разрешённая цветовая схема (то, что реально применяется на корень). */
4
+ export type ColorScheme = (typeof COLOR_SCHEME)[keyof typeof COLOR_SCHEME];
5
+
6
+ /** Пользовательский выбор: фиксированная схема или `system`. */
7
+ export type ThemeOverride = (typeof THEME_OVERRIDE)[keyof typeof THEME_OVERRIDE];
@@ -0,0 +1,2 @@
1
+ export * from './appearance';
2
+ export * from './colorScheme';
@@ -0,0 +1,57 @@
1
+ import { COLOR_SCHEME, THEME_OVERRIDE, THEME_OVERRIDE_STORAGE_KEY } from '../constants/colorScheme';
2
+ import { ColorScheme, ThemeOverride } from '../types/colorScheme';
3
+ import { resolveColorScheme } from './resolveColorScheme';
4
+
5
+ function isOverride(value: string | undefined): value is ThemeOverride {
6
+ return value === THEME_OVERRIDE.Light || value === THEME_OVERRIDE.Dark || value === THEME_OVERRIDE.System;
7
+ }
8
+
9
+ function parseOverrideFromCookie(
10
+ cookieHeader: string | null | undefined,
11
+ storageKey: string,
12
+ ): ThemeOverride | undefined {
13
+ if (!cookieHeader) {
14
+ return undefined;
15
+ }
16
+
17
+ for (const part of cookieHeader.split(';')) {
18
+ const [name, ...rest] = part.trim().split('=');
19
+ if (name === storageKey) {
20
+ const value = decodeURIComponent(rest.join('='));
21
+ if (isOverride(value)) {
22
+ return value;
23
+ }
24
+ }
25
+ }
26
+
27
+ return undefined;
28
+ }
29
+
30
+ type HeadersLike = { get(name: string): string | null };
31
+
32
+ /**
33
+ * SSR-резолв override из cookie заголовка запроса (стенд-ин бэкенд-сессии: в сервисе тут читается
34
+ * сохранённая тема пользователя). Прокидывается в `useColorScheme({ initialOverride })` —
35
+ * детерминированный первый рендер без hydration mismatch и без прыжка подсветки переключателя.
36
+ */
37
+ export function getThemeOverrideFromHeaders(
38
+ headers: HeadersLike,
39
+ options?: { storageKey?: string },
40
+ ): ThemeOverride | undefined {
41
+ const storageKey = options?.storageKey ?? THEME_OVERRIDE_STORAGE_KEY;
42
+
43
+ return parseOverrideFromCookie(headers.get('cookie'), storageKey);
44
+ }
45
+
46
+ /**
47
+ * SSR-резолв итоговой схемы из заголовков (зеркало `getAdaptive(userAgent)` для adaptive):
48
+ * cookie `snack-uikit-theme` даёт override, `Sec-CH-Prefers-Color-Scheme` (client hint) — системную тему.
49
+ * Включите client hint ответными заголовками `Accept-CH`/`Critical-CH`, иначе на первом визите
50
+ * хинта нет (системная вернётся `light`), а корректную тему доставит inline-bootstrap до отрисовки.
51
+ */
52
+ export function getColorSchemeFromHeaders(headers: HeadersLike, options?: { storageKey?: string }): ColorScheme {
53
+ const override = getThemeOverrideFromHeaders(headers, options);
54
+ const systemPrefersDark = headers.get('sec-ch-prefers-color-scheme') === COLOR_SCHEME.Dark;
55
+
56
+ return resolveColorScheme(override, systemPrefersDark);
57
+ }
@@ -0,0 +1,117 @@
1
+ import { THEME_OVERRIDE, THEME_OVERRIDE_STORAGE_KEY } from '../constants/colorScheme';
2
+ import { ThemeOverride } from '../types/colorScheme';
3
+
4
+ /* eslint-disable @cloud-ru/ssr-safe-react/domApi -- guarded by isBrowser(); адаптеры трогают DOM только при вызове методов */
5
+
6
+ function isBrowser(): boolean {
7
+ return Boolean(typeof window !== 'undefined' && window.document && window.document.createElement);
8
+ }
9
+
10
+ function isOverride(value: string | undefined): value is ThemeOverride {
11
+ return value === THEME_OVERRIDE.Light || value === THEME_OVERRIDE.Dark || value === THEME_OVERRIDE.System;
12
+ }
13
+
14
+ function readCookieOverride(storageKey: string): ThemeOverride | undefined {
15
+ if (!isBrowser()) {
16
+ return undefined;
17
+ }
18
+ const match = document.cookie.match(new RegExp(`(?:^|; )${storageKey}=([^;]*)`));
19
+ const value = match ? decodeURIComponent(match[1]) : undefined;
20
+
21
+ return isOverride(value) ? value : undefined;
22
+ }
23
+
24
+ /**
25
+ * Точка расширения персиста цветовой схемы. DS не «зашивает» хранилище: дефолт — in-memory
26
+ * (`createInMemoryColorSchemeStorage`, без персиста между перезагрузками). Персист подключает
27
+ * потребитель явным адаптером: `createCookieColorSchemeStorage` (no-flash SSR) или собственный
28
+ * (localStorage / бэкенд-сессия — `read()` возвращает `undefined`, начальное даёт `initialOverride`
29
+ * из server-сессии, `write()` шлёт POST, `subscribe()` — push через SSE/ws). Контракт намеренно про
30
+ * одну ось `override`; brand/density персистит сам апп. Модель `useColorScheme` при любом адаптере
31
+ * не меняется.
32
+ */
33
+ export type ColorSchemeStorage = {
34
+ /** Синхронно прочитать сохранённый override. Для бэкенд-адаптера — `undefined` (см. `initialOverride`). */
35
+ read(): ThemeOverride | undefined;
36
+ /** Персистнуть выбор (cookie / POST на бэкенд). */
37
+ write(next: ThemeOverride): void;
38
+ /** Подписка на внешние изменения (кросс-таб / push). Возвращает отписку. */
39
+ subscribe(onChange: () => void): () => void;
40
+ };
41
+
42
+ /**
43
+ * Дефолтный storage-адаптер: держит выбор в памяти модуля на время жизни страницы. Переживает
44
+ * ремаунты и синхронит несколько хуков между собой, но НЕ персистит между перезагрузками — DS
45
+ * намеренно не пишет в cookie/localStorage без явного согласия потребителя. Для персиста подключите
46
+ * `createCookieColorSchemeStorage` (или свой адаптер) через проп `storage`.
47
+ */
48
+ export function createInMemoryColorSchemeStorage(): ColorSchemeStorage {
49
+ let value: ThemeOverride | undefined;
50
+ const listeners = new Set<() => void>();
51
+
52
+ return {
53
+ read: () => value,
54
+ write: next => {
55
+ value = next;
56
+ listeners.forEach(listener => listener());
57
+ },
58
+ subscribe: onChange => {
59
+ listeners.add(onChange);
60
+
61
+ return () => {
62
+ listeners.delete(onChange);
63
+ };
64
+ },
65
+ };
66
+ }
67
+
68
+ /**
69
+ * Cookie storage-адаптер (opt-in): cookie как источник истины (читается и на сервере, и inline-
70
+ * bootstrap'ом) + `BroadcastChannel` для живой кросс-таб синхронизации. Нужен для no-flash SSR —
71
+ * подключается потребителем через `useColorScheme({ storage: createCookieColorSchemeStorage() })`.
72
+ * Создание SSR-безопасно: DOM/Channel трогаются только при вызове методов, не в фабрике.
73
+ */
74
+ export function createCookieColorSchemeStorage(options?: {
75
+ storageKey?: string;
76
+ channelName?: string;
77
+ }): ColorSchemeStorage {
78
+ const storageKey = options?.storageKey ?? THEME_OVERRIDE_STORAGE_KEY;
79
+ const channelName = options?.channelName ?? `${storageKey}-channel`;
80
+ let channel: BroadcastChannel | null = null;
81
+
82
+ const getChannel = (): BroadcastChannel | null => {
83
+ if (channel) {
84
+ return channel;
85
+ }
86
+ if (!isBrowser() || typeof BroadcastChannel === 'undefined') {
87
+ return null;
88
+ }
89
+ channel = new BroadcastChannel(channelName);
90
+
91
+ return channel;
92
+ };
93
+
94
+ return {
95
+ read: () => readCookieOverride(storageKey),
96
+ write: next => {
97
+ if (!isBrowser()) {
98
+ return;
99
+ }
100
+ // max-age ~1 год, lax — cookie уходит на сервер для SSR-резолва следующей загрузки.
101
+ document.cookie = `${storageKey}=${encodeURIComponent(next)};path=/;max-age=31536000;samesite=lax`;
102
+ getChannel()?.postMessage(next);
103
+ },
104
+ subscribe: onChange => {
105
+ const ch = getChannel();
106
+ if (!ch) {
107
+ return () => {};
108
+ }
109
+ const handler = () => onChange();
110
+ ch.addEventListener('message', handler);
111
+
112
+ return () => ch.removeEventListener('message', handler);
113
+ },
114
+ };
115
+ }
116
+
117
+ /* eslint-enable @cloud-ru/ssr-safe-react/domApi */
@@ -0,0 +1,37 @@
1
+ import { BRAND } from '../../constants/appearance';
2
+ import { buildBrandPaletteVars } from './buildBrandPaletteVars';
3
+
4
+ // Правило нацелено на сами бренд-классы (`.sn-brandA/B/C/D/E`), а не на один элемент inline: компоненты,
5
+ // переэмитящие набор `sn-*` на своих обёртках (Table/Stepper/Alert через `useThemeClassnames`), заново
6
+ // объявляют `--sn-brand-color-primary-*` из класса бренда — inline на предке они перекрыли бы, правило
7
+ // на том же бренд-классе — нет.
8
+ // Один seed переопределяет сразу все бренд-классы (потребитель не знает, в какой бренд-слот завернётся
9
+ // его поддерево). TODO FF-8813: возможность задавать seed индивидуально для каждого бренда (brandA/B/C/D/E).
10
+ const BRAND_CLASS_SELECTOR = `:is(${Object.values(BRAND)
11
+ .map(brand => `.sn-${brand}`)
12
+ .join(',')})`;
13
+
14
+ /**
15
+ * Собирает текст CSS-правила, переопределяющего бренд-палитру из seed-цвета на бренд-классах.
16
+ *
17
+ * - Без `scope` — глобально (`:is(.sn-brandA,…){…}`), для white-label в корне приложения: побеждает
18
+ * по source order на всех бренд-классах страницы, включая порталы.
19
+ * - Со `scope` (CSS-селектор корня поддерева) — ограничивает область потомками и самим корнем; выше
20
+ * токенной специфичности, поэтому порядок не важен.
21
+ *
22
+ * Невалидный hex → `null`.
23
+ */
24
+ export function buildBrandPaletteCss(seedColor: string, scope?: string): string | null {
25
+ const vars = buildBrandPaletteVars(seedColor);
26
+ if (!vars) {
27
+ return null;
28
+ }
29
+
30
+ const declarations = Object.entries(vars)
31
+ .map(([name, value]) => `${name}:${value}`)
32
+ .join(';');
33
+
34
+ const selector = scope ? `${scope} ${BRAND_CLASS_SELECTOR},${scope}${BRAND_CLASS_SELECTOR}` : BRAND_CLASS_SELECTOR;
35
+
36
+ return `${selector}{${declarations}}`;
37
+ }
@@ -0,0 +1,56 @@
1
+ import { apcaContrast } from './color';
2
+ import {
3
+ ACTIVATED_ALPHA_SUFFIX,
4
+ BRAND_ACTIVATED_VAR,
5
+ BRAND_PRIMARY_VAR_PREFIX,
6
+ NEAR_DARK_TONE,
7
+ NEAR_WHITE_TONE,
8
+ PRIMARY_ACCENT_TONE,
9
+ TRANSPARENT_ALPHA_SUFFIX,
10
+ } from './constants';
11
+ import { generateBrandPalette } from './generateBrandPalette';
12
+
13
+ const WHITE_HEX = '#ffffff';
14
+
15
+ // Порог APCA-контраста белого текста на акценте: ниже него акцент считается слишком светлым и текст
16
+ // инвертируется на тёмный.
17
+ const WHITE_ON_ACCENT_MIN_CONTRAST = 50;
18
+
19
+ /**
20
+ * Собирает карту CSS-переменных `--sn-brand-color-primary-*` из seed-цвета для инъекции в DOM.
21
+ * Кроме числовых тонов задаёт `transparent` (акцент с alpha) и `onAccentLight`/`onAccentDark`
22
+ * (текст на светлой/тёмной акцентной заливке). Семантический слой `--sn-theme-color-primary-*`
23
+ * каскадит из этих тонов, поэтому одна карта красит и light, и dark. Невалидный hex → `null`.
24
+ *
25
+ * Текст на акценте: если белый на акценте контрастен (APCA ≥ 50) — светлая схема берёт near-white,
26
+ * тёмная near-dark; если акцент слишком светлый для белого — обе схемы инвертируются. Одно решение
27
+ * по контрасту seed-акцента с белым, зеркальное для схем.
28
+ */
29
+ export function buildBrandPaletteVars(seedColor: string): Record<string, string> | null {
30
+ const palette = generateBrandPalette(seedColor);
31
+ if (!palette) {
32
+ return null;
33
+ }
34
+
35
+ const vars: Record<string, string> = {};
36
+
37
+ for (const [tone, hex] of Object.entries(palette)) {
38
+ vars[`${BRAND_PRIMARY_VAR_PREFIX}${tone}`] = hex;
39
+ }
40
+
41
+ const accent = palette[PRIMARY_ACCENT_TONE];
42
+ const nearWhite = palette[NEAR_WHITE_TONE];
43
+ const nearDark = palette[NEAR_DARK_TONE];
44
+ const accentTooLightForWhite = apcaContrast(accent, WHITE_HEX) < WHITE_ON_ACCENT_MIN_CONTRAST;
45
+
46
+ vars[`${BRAND_PRIMARY_VAR_PREFIX}transparent`] = `${accent}${TRANSPARENT_ALPHA_SUFFIX}`;
47
+ vars[`${BRAND_PRIMARY_VAR_PREFIX}onAccentLight`] = accentTooLightForWhite ? nearDark : nearWhite;
48
+ vars[`${BRAND_PRIMARY_VAR_PREFIX}onAccentDark`] = accentTooLightForWhite ? nearWhite : nearDark;
49
+
50
+ // Activated-заливки = акцент с alpha (выбранная строка таблицы, active-состояния следуют за брендом).
51
+ vars[BRAND_ACTIVATED_VAR.default] = `${accent}${ACTIVATED_ALPHA_SUFFIX.default}`;
52
+ vars[BRAND_ACTIVATED_VAR.hovered] = `${accent}${ACTIVATED_ALPHA_SUFFIX.hovered}`;
53
+ vars[BRAND_ACTIVATED_VAR.pressed] = `${accent}${ACTIVATED_ALPHA_SUFFIX.pressed}`;
54
+
55
+ return vars;
56
+ }
@@ -0,0 +1,74 @@
1
+ // APCA (Accessible Perceptual Contrast Algorithm), реализация SA98G (версия 0.0.98G-4g).
2
+ // Используется только для выбора читаемого текста на акцентной заливке при генерации палитры.
3
+
4
+ import { parseHex } from './hex';
5
+ import { RGB } from './types';
6
+
7
+ // Константы SA98G — менять нельзя, иначе разъедется с эталоном контраста APCA.
8
+ const MAIN_TRC = 2.4;
9
+ const S_RCO = 0.2126729;
10
+ const S_GCO = 0.7151522;
11
+ const S_BCO = 0.072175;
12
+ const NORM_BG = 0.56;
13
+ const NORM_TXT = 0.57;
14
+ const REV_TXT = 0.62;
15
+ const REV_BG = 0.65;
16
+ const BLK_THRS = 0.022;
17
+ const BLK_CLMP = 1.414;
18
+ const SCALE_BOW = 1.14;
19
+ const SCALE_WOB = 1.14;
20
+ const LO_BOW_OFFSET = 0.027;
21
+ const LO_WOB_OFFSET = 0.027;
22
+ const DELTA_Y_MIN = 0.0005;
23
+ const LO_CLIP = 0.1;
24
+
25
+ /** sRGB-каналы 0–255 → относительная яркость `Y` для APCA. */
26
+ function sRGBtoY([r, g, b]: RGB): number {
27
+ const linearize = (channel: number): number => Math.pow(channel / 255, MAIN_TRC);
28
+
29
+ return S_RCO * linearize(r) + S_GCO * linearize(g) + S_BCO * linearize(b);
30
+ }
31
+
32
+ /** Мягкий клэмп яркости у чёрного (soft black clamp). */
33
+ function softClampBlack(y: number): number {
34
+ return y > BLK_THRS ? y : y + Math.pow(BLK_THRS - y, BLK_CLMP);
35
+ }
36
+
37
+ /** Сырой APCA-контраст `Lc` между яркостью текста и фона. Полярность важна, не переставлять аргументы. */
38
+ function apcaLc(txtY: number, bgY: number): number {
39
+ if (Number.isNaN(txtY) || Number.isNaN(bgY) || Math.min(txtY, bgY) < 0 || Math.max(txtY, bgY) > 1.1) {
40
+ return 0;
41
+ }
42
+
43
+ const text = softClampBlack(txtY);
44
+ const bg = softClampBlack(bgY);
45
+
46
+ if (Math.abs(bg - text) < DELTA_Y_MIN) {
47
+ return 0;
48
+ }
49
+
50
+ if (bg > text) {
51
+ // Тёмный текст на светлом фоне (BoW).
52
+ const sapc = (Math.pow(bg, NORM_BG) - Math.pow(text, NORM_TXT)) * SCALE_BOW;
53
+
54
+ return sapc < LO_CLIP ? 0 : (sapc - LO_BOW_OFFSET) * 100;
55
+ }
56
+
57
+ // Светлый текст на тёмном фоне (WoB) — отрицательный по контракту APCA.
58
+ const sapc = (Math.pow(bg, REV_BG) - Math.pow(text, REV_TXT)) * SCALE_WOB;
59
+
60
+ return sapc > -LO_CLIP ? 0 : (sapc + LO_WOB_OFFSET) * 100;
61
+ }
62
+
63
+ /**
64
+ * Абсолютный APCA-контраст `Lc` текста на фоне (оба — hex). Невалидный hex → `0`.
65
+ */
66
+ export function apcaContrast(backgroundHex: string, textHex: string): number {
67
+ const bg = parseHex(backgroundHex);
68
+ const text = parseHex(textHex);
69
+ if (!bg || !text) {
70
+ return 0;
71
+ }
72
+
73
+ return Math.round(Math.abs(apcaLc(sRGBtoY(text), sRGBtoY(bg))));
74
+ }
@@ -0,0 +1,82 @@
1
+ // Разбор и синтез цветов в модели OKLCH. Алгоритм forceIntoGamut — CSS Color 4
2
+ // (Chris Lilley, https://svgees.us/).
3
+
4
+ import { gamSRGB, linSRGB, linSRGBToXYZ, xyzToLinSRGB } from './conversions';
5
+ import { parseHex, srgbToHex } from './hex';
6
+ import { oklch } from './oklch';
7
+ import { LCH, RGB, TColor, XYZ } from './types';
8
+
9
+ const clamp = (value: number, min: number, max: number): number => Math.min(max, Math.max(min, value));
10
+
11
+ const xyz2rgb = (xyz: XYZ): RGB => gamSRGB(xyzToLinSRGB(xyz));
12
+ const rgb2xyz = (rgb: RGB): XYZ => linSRGBToXYZ(linSRGB(rgb));
13
+ const lch2rgb = (lch: LCH): RGB => xyz2rgb(oklch.lch2xyz(lch));
14
+ const rgb2lch = (rgb: RGB): LCH => oklch.xyz2lch(rgb2xyz(rgb));
15
+
16
+ function isWithinGamut(rgb: RGB): boolean {
17
+ const epsilon = 0.000005;
18
+
19
+ return rgb.every(channel => channel >= 0 - epsilon && channel <= 1 + epsilon);
20
+ }
21
+
22
+ /**
23
+ * Загоняет LCH-цвет в sRGB-гамму: держит `l` и `h`, бинарным поиском ужимает `c` до границы гаммы.
24
+ */
25
+ function forceIntoGamut(lch: LCH): RGB {
26
+ let rgb = lch2rgb(lch);
27
+ if (isWithinGamut(rgb)) {
28
+ return rgb;
29
+ }
30
+
31
+ const [l, , h] = lch;
32
+ let hiC = lch[1];
33
+ let loC = 0;
34
+ let c = lch[1] / 2;
35
+ const epsilon = 0.0001;
36
+
37
+ while (hiC - loC > epsilon) {
38
+ rgb = lch2rgb([l, c, h]);
39
+ if (isWithinGamut(rgb)) {
40
+ loC = c;
41
+ } else {
42
+ hiC = c;
43
+ }
44
+ c = (hiC + loC) / 2;
45
+ }
46
+
47
+ return rgb;
48
+ }
49
+
50
+ /** LCH → разобранный цвет. `hex` вычисляется лениво (с загонкой в гамму при выходе за неё). */
51
+ export function lch2color(lch: LCH): TColor {
52
+ const srgb = xyz2rgb(oklch.lch2xyz(lch));
53
+ const withinSRGB = isWithinGamut(srgb);
54
+ const [r, g, b] = srgb.map(channel => clamp(channel * 255, 0, 255));
55
+ const [l, c, h] = lch;
56
+
57
+ return {
58
+ l,
59
+ c,
60
+ h,
61
+ r,
62
+ g,
63
+ b,
64
+ get hex(): string {
65
+ return srgbToHex(withinSRGB ? srgb : forceIntoGamut(lch));
66
+ },
67
+ };
68
+ }
69
+
70
+ /** hex → разобранный цвет (OKLCH-компоненты + sRGB-каналы). Невалидный hex → `null`. */
71
+ export function hex2color(hex: string): TColor | null {
72
+ const parsed = parseHex(hex);
73
+ if (!parsed) {
74
+ return null;
75
+ }
76
+
77
+ const normalized = parsed.map(channel => channel / 255) as RGB;
78
+ const [l, c, h] = rgb2lch(normalized);
79
+ const [r, g, b] = parsed;
80
+
81
+ return { l, c, h, r, g, b, hex: srgbToHex(normalized) };
82
+ }