@skbkontur/colors 2.0.0-alpha.6 → 2.0.1

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 (43) hide show
  1. package/.gitignore +10 -0
  2. package/.npmignore +10 -0
  3. package/CHANGELOG.md +117 -0
  4. package/__docs__/Colors.docs.stories.tsx +1578 -0
  5. package/__docs__/Colors.mdx +228 -0
  6. package/__docs__/ColorsAPI.docs.stories.tsx +954 -0
  7. package/__docs__/ColorsAPI.mdx +133 -0
  8. package/__stories__/colors.stories.tsx +452 -0
  9. package/__tests__/convert-color.test.ts +23 -0
  10. package/__tests__/create-tokens-from-figma.test.ts +162 -0
  11. package/__tests__/format-variable.test.ts +16 -0
  12. package/__tests__/get-colors-base.test.ts +55 -0
  13. package/__tests__/get-colors.test.ts +75 -0
  14. package/__tests__/get-interactions.test.ts +37 -0
  15. package/__tests__/get-logo.test.ts +24 -0
  16. package/__tests__/get-palette.test.ts +43 -0
  17. package/__tests__/get-promo.test.ts +32 -0
  18. package/colors-default-dark.d.ts +319 -0
  19. package/colors-default-dark.js +319 -0
  20. package/colors-default-dark.ts +332 -0
  21. package/colors-default-light.d.ts +319 -0
  22. package/colors-default-light.js +319 -0
  23. package/colors-default-light.ts +336 -0
  24. package/package.json +25 -28
  25. package/scripts/create-tokens-files.ts +424 -0
  26. package/scripts/create-tokens-from-figma.ts +376 -0
  27. package/scripts/figma-tokens-base.json +3499 -0
  28. package/scripts/figma-tokens.json +710 -0
  29. package/tokens/brand-blue-deep_accent-brand.css +1 -1
  30. package/tokens/brand-blue-deep_accent-gray.css +1 -1
  31. package/tokens/brand-blue_accent-brand.css +1 -1
  32. package/tokens/brand-blue_accent-gray.css +1 -1
  33. package/tokens/brand-green_accent-brand.css +1 -1
  34. package/tokens/brand-green_accent-gray.css +1 -1
  35. package/tokens/brand-mint_accent-brand.css +1 -1
  36. package/tokens/brand-mint_accent-gray.css +1 -1
  37. package/tokens/brand-orange_accent-gray.css +1 -1
  38. package/tokens/brand-purple_accent-brand.css +1 -1
  39. package/tokens/brand-purple_accent-gray.css +1 -1
  40. package/tokens/brand-red_accent-gray.css +1 -1
  41. package/tokens/brand-violet_accent-brand.css +1 -1
  42. package/tokens/brand-violet_accent-gray.css +1 -1
  43. package/tsconfig.json +8 -0
@@ -0,0 +1,954 @@
1
+ import React from 'react';
2
+
3
+ import { Gapped, Select, Input } from '@skbkontur/react-ui';
4
+ import { css, injectGlobal } from '@skbkontur/react-ui/lib/theming/Emotion';
5
+ import { SearchLoupeIcon16Regular } from '@skbkontur/icons/icons/SearchLoupeIcon/SearchLoupeIcon16Regular';
6
+ import type { Meta } from '@skbkontur/react-ui/typings/stories';
7
+
8
+ import { brand as brandSwatch } from '../lib/consts/default-swatch';
9
+ import { getColorsBase } from '../lib/get-colors-base';
10
+ import { getColors } from '../lib/get-colors';
11
+ import type { TokensBase } from '../lib/types/tokens-base';
12
+ import type { ColorFormat } from '../lib/utils/convert-color';
13
+
14
+ interface TokenPair {
15
+ key: string;
16
+ value: {
17
+ light: string;
18
+ dark: string;
19
+ };
20
+ }
21
+
22
+ interface BaseTokenDisplay {
23
+ key: string;
24
+ value: string;
25
+ }
26
+
27
+ type TTransformedTokens = Record<string, TokenPair>;
28
+
29
+ type TGroupedTokens = Record<string, { key: string; value: TokenPair }[]>;
30
+
31
+ injectGlobal(`
32
+ [data-role=preview]:has([data-colors-controls]) {
33
+ padding: 0 !important;
34
+ }
35
+ `);
36
+
37
+ export default {
38
+ title: 'Colors/Colors API',
39
+ parameters: {
40
+ creevey: {
41
+ skip: true,
42
+ },
43
+ },
44
+ } as Meta;
45
+
46
+ /**
47
+ * Добавление/изменение токенов доступно через `overrides` с заполнением их базовыми значениями `base` и параметрами `params`
48
+ */
49
+ export const ColorsPaletteOverridesStory = () => {
50
+ const camelCaseToKebabCase = (str: string) => {
51
+ return str.replace(/[A-Z]/g, (match) => `-${match.toLowerCase()}`);
52
+ };
53
+
54
+ const kebabCaseToCamelCase = (str: string) => {
55
+ return str.replace(/-(\w)/g, (_, c) => c.toUpperCase());
56
+ };
57
+
58
+ function flattenObject(obj: any, prefix = ''): any {
59
+ let result: any = {};
60
+ for (const key in obj) {
61
+ if (Object.prototype.hasOwnProperty.call(obj, key)) {
62
+ const newKey = prefix ? `${prefix}-${camelCaseToKebabCase(key)}` : key;
63
+ if (typeof obj[key] === 'object' && obj[key] !== null && !Array.isArray(obj[key])) {
64
+ result = { ...result, ...flattenObject(obj[key], newKey) };
65
+ } else {
66
+ result[newKey] = obj[key];
67
+ }
68
+ }
69
+ }
70
+ return result;
71
+ }
72
+
73
+ function transformThemeObject(obj: any): TTransformedTokens {
74
+ const transformed: TTransformedTokens = {};
75
+
76
+ for (const mode in obj) {
77
+ if (Object.prototype.hasOwnProperty.call(obj, mode)) {
78
+ const modeKeys = obj[mode];
79
+
80
+ for (const prefixedKey in modeKeys) {
81
+ if (Object.prototype.hasOwnProperty.call(modeKeys, prefixedKey)) {
82
+ const value = modeKeys[prefixedKey];
83
+ let unprefixedKey = prefixedKey;
84
+
85
+ if (prefixedKey.startsWith(mode) && prefixedKey.length > mode.length) {
86
+ const firstCharAfterPrefix = prefixedKey[mode.length];
87
+ if (firstCharAfterPrefix === firstCharAfterPrefix.toUpperCase()) {
88
+ unprefixedKey = prefixedKey.substring(mode.length);
89
+ }
90
+ }
91
+
92
+ if (!transformed[unprefixedKey]) {
93
+ transformed[unprefixedKey] = { key: unprefixedKey, value: { light: '', dark: '' } };
94
+ }
95
+
96
+ if (mode === 'light' || mode === 'dark') {
97
+ transformed[unprefixedKey].value[mode] = value;
98
+ }
99
+ }
100
+ }
101
+ }
102
+ }
103
+
104
+ return transformed;
105
+ }
106
+
107
+ const groupTokensByFirstWord = (tokens: TTransformedTokens): TGroupedTokens => {
108
+ return Object.entries(tokens).reduce((acc: TGroupedTokens, [fullTokenName, value]) => {
109
+ const match = fullTokenName.match(/([A-Z][a-z0-9]+)/g);
110
+ const firstWord = match && match.length >= 1 ? match[0] : '';
111
+
112
+ if (!acc[firstWord]) {
113
+ acc[firstWord] = [];
114
+ }
115
+ acc[firstWord].push({ key: fullTokenName, value });
116
+ return acc;
117
+ }, {});
118
+ };
119
+
120
+ const GROUP_HEADER_STICKY_TOP = '57px';
121
+ const LIGHT_TEXT_COLOR = '#222';
122
+
123
+ const styles = {
124
+ colors: css`
125
+ display: flex;
126
+ flex-direction: column;
127
+ `,
128
+ colorGroup: css`
129
+ margin-bottom: 64px;
130
+ `,
131
+ filterRow: css`
132
+ position: sticky;
133
+ z-index: 10;
134
+ display: flex;
135
+ flex-wrap: wrap;
136
+ gap: 24px;
137
+ padding: 16px;
138
+ top: 0;
139
+ background: white;
140
+ border-bottom: 1px solid rgba(0, 0, 0, 0.1);
141
+ font-size: 14px;
142
+ `,
143
+ headerRow: css`
144
+ display: flex;
145
+ align-items: center;
146
+ gap: 16px;
147
+ padding: 4px 16px;
148
+ font-weight: 700;
149
+ line-height: 1.2;
150
+ border-bottom: 1px solid rgba(0, 0, 0, 0.1);
151
+ position: sticky;
152
+ top: ${GROUP_HEADER_STICKY_TOP};
153
+ z-index: 10;
154
+ background: white;
155
+ color: ${LIGHT_TEXT_COLOR};
156
+ font-size: 14px;
157
+ `,
158
+ displayRow: css`
159
+ display: flex;
160
+ align-items: center;
161
+ gap: 16px;
162
+ padding: 8px 16px;
163
+ font-size: 14px;
164
+ margin: 0;
165
+ border-bottom: 1px solid rgba(0, 0, 0, 0.04);
166
+ background: none;
167
+ text-align: left;
168
+ font-family: inherit;
169
+ `,
170
+ colorName: css`
171
+ flex: 1;
172
+ `,
173
+ colorTileWrapper: css`
174
+ position: relative;
175
+ z-index: 1;
176
+ display: flex;
177
+ flex-direction: column;
178
+ align-items: center;
179
+ gap: 4px;
180
+ width: 140px;
181
+ `,
182
+ colorTile: css`
183
+ height: 32px;
184
+ width: 32px;
185
+ border: 1px solid rgba(0, 0, 0, 0.08);
186
+ border-radius: 8px;
187
+ flex-shrink: 0;
188
+ `,
189
+ colorHex: css`
190
+ font-size: 12px;
191
+ font-weight: 600;
192
+ white-space: nowrap;
193
+ color: #8b8b8b;
194
+ `,
195
+ groupHeader: css`
196
+ position: sticky;
197
+ top: ${GROUP_HEADER_STICKY_TOP};
198
+ z-index: 10;
199
+ background: white;
200
+ color: ${LIGHT_TEXT_COLOR};
201
+ font-size: 14px;
202
+ line-height: 1;
203
+ padding: 4px 16px;
204
+ font-weight: bold;
205
+ `,
206
+ controls: css`
207
+ position: sticky;
208
+ z-index: 10;
209
+ bottom: 0;
210
+ padding: 8px;
211
+ background: white;
212
+ box-shadow: 0 -1px rgba(0, 0, 0, 0.15);
213
+ margin-top: auto;
214
+ `,
215
+ };
216
+
217
+ const colorOptions = Object.keys(brandSwatch);
218
+ const defaultBrandColor = colorOptions[0];
219
+ const defaultAccentColor = 'brand';
220
+ const colorFormatOptions = ['Web (hex/rgba)', 'Web (oklch)', 'iOS/Android (hex-aarrggbb)'];
221
+ const baseAccentOptions = ['gray', 'brand', 'custom'];
222
+
223
+ const [brand, setBrand] = React.useState(defaultBrandColor);
224
+ const [accent, setAccent] = React.useState(defaultAccentColor);
225
+ const [colorFormat, setColorFormat] = React.useState(colorFormatOptions[0]);
226
+ const [customBrandColor, setCustomBrandColor] = React.useState('#FFDD2D');
227
+ const [customAccentColor, setCustomAccentColor] = React.useState('#FFDD2D');
228
+
229
+ const getBrandColorForSwatch = React.useCallback(() => {
230
+ if (brand === 'custom') {
231
+ return customBrandColor.trim() !== ''
232
+ ? customBrandColor
233
+ : brandSwatch[defaultBrandColor as keyof typeof brandSwatch];
234
+ }
235
+ return brandSwatch[brand as keyof typeof brandSwatch];
236
+ }, [brand, customBrandColor]);
237
+
238
+ const renderColorItem = (color: string, text: string) => {
239
+ return (
240
+ <div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
241
+ <div
242
+ style={{
243
+ flexShrink: 0,
244
+ background: color,
245
+ width: 12,
246
+ height: 12,
247
+ borderRadius: 4,
248
+ }}
249
+ />
250
+ {text}
251
+ </div>
252
+ );
253
+ };
254
+
255
+ const renderBrandItem = (value: string) => {
256
+ if (value === 'custom') {
257
+ const colorToDisplay = customBrandColor.trim() !== '' ? customBrandColor : '#999';
258
+ return renderColorItem(colorToDisplay, '#custom-hex');
259
+ }
260
+ return renderColorItem(brandSwatch[value as keyof typeof brandSwatch], value);
261
+ };
262
+
263
+ const renderAccentItemValuePalette = (value: string) => {
264
+ if (value === 'custom') {
265
+ const colorToDisplay = customAccentColor.trim() !== '' ? customAccentColor : '#999';
266
+ return renderColorItem(colorToDisplay, '#custom-hex');
267
+ }
268
+ const color = value === 'gray' ? '#3d3d3d' : getBrandColorForSwatch();
269
+ return renderColorItem(color, value);
270
+ };
271
+
272
+ const renderAccentMenuItemPalette = (value: string) => {
273
+ const content = renderAccentItemValuePalette(value);
274
+ return <div key={value}>{content}</div>;
275
+ };
276
+
277
+ const handleCustomBrandColorValueChange = React.useCallback((newColor: string) => {
278
+ setCustomBrandColor(newColor);
279
+ }, []);
280
+
281
+ const handleCustomAccentColorValueChange = React.useCallback((newColor: string) => {
282
+ setCustomAccentColor(newColor);
283
+ }, []);
284
+
285
+ const getOutputFormatParam = (format: string): ColorFormat => {
286
+ switch (format) {
287
+ case 'iOS/Android (hex-aarrggbb)':
288
+ return 'hex-aarrggbb';
289
+ case 'Web (oklch)':
290
+ return 'oklch';
291
+ case 'Web (hex/rgba)':
292
+ default:
293
+ return 'hex/rgba';
294
+ }
295
+ };
296
+ const outputFormatParam = getOutputFormatParam(colorFormat);
297
+
298
+ const safeBrandColor = React.useMemo(() => {
299
+ if (brand !== 'custom') {
300
+ return brand;
301
+ }
302
+ return customBrandColor.trim() !== '' ? customBrandColor : defaultBrandColor;
303
+ }, [brand, customBrandColor]);
304
+
305
+ const safeAccentColor = React.useMemo(() => {
306
+ if (accent !== 'custom') {
307
+ return accent;
308
+ }
309
+ return customAccentColor.trim() !== '' ? customAccentColor : defaultAccentColor;
310
+ }, [accent, customAccentColor]);
311
+
312
+ const effectiveAccentColor = safeAccentColor;
313
+
314
+ const overrides = (base: TokensBase) => ({
315
+ light: {
316
+ textCustom1: base.accent?.palette?.normal[40] || base.whiteAlpha[20],
317
+ textCustom2: base.brand.palette.normal[56],
318
+ },
319
+ dark: {
320
+ textCustom1: base.accent?.palette?.vivid[72] || base.blackAlpha[20],
321
+ textCustom2: base.brand.palette?.vivid[88],
322
+ },
323
+ });
324
+
325
+ let KonturColors = Object.entries(
326
+ flattenObject({
327
+ light: getColors({
328
+ brand: safeBrandColor,
329
+ accent: effectiveAccentColor,
330
+ theme: 'light',
331
+ overrides,
332
+ format: outputFormatParam,
333
+ }),
334
+ dark: getColors({
335
+ brand: safeBrandColor,
336
+ accent: effectiveAccentColor,
337
+ theme: 'dark',
338
+ overrides,
339
+ format: outputFormatParam,
340
+ }),
341
+ })
342
+ ).reduce((acc: any, [key, value]) => {
343
+ acc[kebabCaseToCamelCase(key)] = value;
344
+ return acc;
345
+ }, {}) as Record<string, string>;
346
+
347
+ const colorGroups = Object.entries(KonturColors).reduce(
348
+ (acc: Record<string, Record<string, string>>, [colorKey, colorValue]) => {
349
+ const firstWord =
350
+ ['greenMint', 'blueDark'].find((color) => colorKey.match(color)) || colorKey.match(/^[a-z]+/)![0]!;
351
+ acc[firstWord] = { ...acc[firstWord], [colorKey]: colorValue };
352
+ return acc;
353
+ },
354
+ {} as Record<string, Record<string, string>>
355
+ );
356
+
357
+ const allGroupedTokens = groupTokensByFirstWord(transformThemeObject(colorGroups));
358
+
359
+ const convertHexAlphaToWebFormat = (color: string) =>
360
+ color.length === 9 ? '#' + color.slice(3, 9) + color.slice(1, 3) : color;
361
+
362
+ return (
363
+ <>
364
+ <div className={styles.colors} data-colors-controls>
365
+ <textarea
366
+ disabled
367
+ style={{
368
+ height: 230,
369
+ padding: 4,
370
+ border: 0,
371
+ borderBottom: '1px solid rgba(0,0,0,.1)',
372
+ resize: 'none',
373
+ }}
374
+ >
375
+ {`getColors({
376
+ brand: '${brand}',
377
+ accent: '${accent}',
378
+ format: '${outputFormatParam}',
379
+ overrides: (base) => ({
380
+ light: {
381
+ textCustom1: base.accent?.palette?.normal[40],
382
+ textCustom2: base.brand.palette?.normal[56],
383
+ },
384
+ dark: {
385
+ textCustom1: base.accent?.palette?.vivid[72],
386
+ textCustom2: base.brand.palette?.vivid[88],
387
+ }
388
+ })
389
+ })`}
390
+ </textarea>
391
+ <div className={styles.filterRow}>
392
+ <Gapped>
393
+ <label htmlFor="overrides-brand">Brand</label>
394
+ <Select
395
+ id="overrides-brand"
396
+ items={[...colorOptions, 'custom']}
397
+ value={brand}
398
+ width={140}
399
+ onValueChange={setBrand}
400
+ renderValue={renderBrandItem}
401
+ renderItem={renderBrandItem}
402
+ />
403
+ {brand === 'custom' && (
404
+ <Input
405
+ maxLength={7}
406
+ width={80}
407
+ value={customBrandColor}
408
+ onValueChange={handleCustomBrandColorValueChange}
409
+ placeholder="#RRGGBB"
410
+ />
411
+ )}
412
+ </Gapped>
413
+
414
+ <Gapped>
415
+ <label htmlFor="overrides-accent">Accent</label>
416
+ <Select
417
+ id="overrides-accent"
418
+ width={140}
419
+ items={baseAccentOptions}
420
+ value={accent}
421
+ onValueChange={setAccent}
422
+ renderValue={renderAccentItemValuePalette}
423
+ renderItem={renderAccentMenuItemPalette}
424
+ />
425
+ {accent === 'custom' && (
426
+ <Input
427
+ maxLength={7}
428
+ width={80}
429
+ value={customAccentColor}
430
+ onValueChange={handleCustomAccentColorValueChange}
431
+ placeholder="#RRGGBB"
432
+ />
433
+ )}
434
+ </Gapped>
435
+
436
+ <Gapped style={{ marginLeft: 'auto' }}>
437
+ <label htmlFor="overrides-format">Format</label>
438
+ <Select
439
+ width={238}
440
+ id="overrides-format"
441
+ items={colorFormatOptions}
442
+ value={colorFormat}
443
+ onValueChange={setColorFormat}
444
+ />
445
+ </Gapped>
446
+ </div>
447
+ <div>
448
+ <div className={styles.headerRow}>
449
+ <span className={styles.colorName}>Token</span>
450
+ <div className={styles.colorTileWrapper} style={{ borderLeft: '1px solid rgba(0, 0, 0, 0.08)' }}>
451
+ <span>Light</span>
452
+ </div>
453
+ <div className={styles.colorTileWrapper} style={{ borderLeft: '1px solid rgba(0, 0, 0, 0.08)' }}>
454
+ <span>Dark</span>
455
+ </div>
456
+ </div>
457
+ <div style={{ paddingBottom: 68 }}>
458
+ {Object.entries(allGroupedTokens).map(([groupName, tokens], i) => (
459
+ <React.Fragment key={groupName}>
460
+ <div className={styles.groupHeader} style={{ margin: i !== 0 ? '24px 0 16px' : '12px 0 0' }}>
461
+ {groupName}
462
+ </div>
463
+ {tokens.map(({ key, value }) => (
464
+ <div key={key} className={styles.displayRow}>
465
+ <span className={styles.colorName}>{key}</span>
466
+ <div className={styles.colorTileWrapper}>
467
+ <div
468
+ className={styles.colorTile}
469
+ style={{
470
+ backgroundColor:
471
+ colorFormat === 'iOS/Android (hex-aarrggbb)'
472
+ ? convertHexAlphaToWebFormat(value?.value.light)
473
+ : value?.value.light,
474
+ }}
475
+ />
476
+ <span className={styles.colorHex} style={{ color: LIGHT_TEXT_COLOR }}>
477
+ {value?.value.light?.replace(/, /g, ',')}
478
+ </span>
479
+ </div>
480
+ <div
481
+ className={styles.colorTileWrapper}
482
+ style={{ background: '#3d3d3d', borderRadius: 4, boxShadow: '0 0 0 2px #3d3d3d' }}
483
+ >
484
+ <div
485
+ className={styles.colorTile}
486
+ style={{
487
+ backgroundColor:
488
+ colorFormat === 'iOS/Android (hex-aarrggbb)'
489
+ ? convertHexAlphaToWebFormat(value?.value.dark)
490
+ : value?.value.dark,
491
+ }}
492
+ />
493
+ <span className={styles.colorHex} style={{ color: 'white' }}>
494
+ {value?.value.dark?.replace(/, /g, ',')}
495
+ </span>
496
+ </div>
497
+ </div>
498
+ ))}
499
+ </React.Fragment>
500
+ ))}
501
+ </div>
502
+ </div>
503
+ </div>
504
+ </>
505
+ );
506
+ };
507
+
508
+ ColorsPaletteOverridesStory.storyName = 'Кастомные токены';
509
+
510
+ /**
511
+ * Список токенов базовой палитры. Переменные подставляются в семантические токены через `overrides`
512
+ */
513
+ export const BaseTokensStory = () => {
514
+ const camelCaseToKebabCase = (str: string) => {
515
+ return str.replace(/[A-Z]/g, (match) => `-${match.toLowerCase()}`);
516
+ };
517
+
518
+ const kebabCaseToCamelCase = (str: string) => {
519
+ return str.replace(/-(\w)/g, (_, c) => c.toUpperCase());
520
+ };
521
+
522
+ function flattenObject(obj: any, prefix = ''): any {
523
+ let result: any = {};
524
+ for (const key in obj) {
525
+ if (Object.prototype.hasOwnProperty.call(obj, key)) {
526
+ const newKey = prefix ? `${prefix}-${camelCaseToKebabCase(key)}` : key;
527
+ if (typeof obj[key] === 'object' && obj[key] !== null && !Array.isArray(obj[key])) {
528
+ result = { ...result, ...flattenObject(obj[key], newKey) };
529
+ } else {
530
+ result[newKey] = obj[key];
531
+ }
532
+ }
533
+ }
534
+ return result;
535
+ }
536
+
537
+ const convertHexAlphaToWebFormat = (color: string) =>
538
+ color.length === 9 ? '#' + color.slice(3, 9) + color.slice(1, 3) : color;
539
+
540
+ const GROUP_HEADER_STICKY_TOP = '57px';
541
+ const LIGHT_TEXT_COLOR = '#222';
542
+
543
+ const styles: Record<string, string> = {
544
+ colors: css`
545
+ display: flex;
546
+ flex-direction: column;
547
+ height: 900px;
548
+ min-height: 400px;
549
+ max-height: calc(100vh - 120px);
550
+ overflow-y: auto;
551
+ overflow-x: hidden;
552
+ `,
553
+ colorGroup: css`
554
+ margin-bottom: 64px;
555
+ `,
556
+ filterRow: css`
557
+ position: sticky;
558
+ z-index: 10;
559
+ display: flex;
560
+ flex-wrap: wrap;
561
+ gap: 24px;
562
+ padding: 16px;
563
+ top: 0;
564
+ background: white;
565
+ border-bottom: 1px solid rgba(0, 0, 0, 0.1);
566
+ font-size: 14px;
567
+ `,
568
+ headerRow: css`
569
+ display: flex;
570
+ align-items: center;
571
+ gap: 16px;
572
+ padding: 4px 16px;
573
+ font-weight: 700;
574
+ line-height: 1.2;
575
+ border-bottom: 1px solid rgba(0, 0, 0, 0.1);
576
+ position: sticky;
577
+ top: ${GROUP_HEADER_STICKY_TOP};
578
+ z-index: 10;
579
+ background: white;
580
+ color: ${LIGHT_TEXT_COLOR};
581
+ font-size: 14px;
582
+ `,
583
+ displayRow: css`
584
+ display: flex;
585
+ align-items: center;
586
+ gap: 16px;
587
+ padding: 8px 16px;
588
+ font-size: 14px;
589
+ margin: 0;
590
+ border-bottom: 1px solid rgba(0, 0, 0, 0.04);
591
+ background: none;
592
+ text-align: left;
593
+ font-family: inherit;
594
+ `,
595
+ colorName: css`
596
+ flex: 1;
597
+ `,
598
+ colorTileWrapper: css`
599
+ position: relative;
600
+ z-index: 1;
601
+ display: flex;
602
+ flex-direction: column;
603
+ align-items: center;
604
+ gap: 4px;
605
+ width: 140px;
606
+ `,
607
+ colorTile: css`
608
+ height: 32px;
609
+ width: 32px;
610
+ border: 1px solid rgba(0, 0, 0, 0.08);
611
+ border-radius: 8px;
612
+ flex-shrink: 0;
613
+ `,
614
+ colorHex: css`
615
+ font-size: 12px;
616
+ font-weight: 600;
617
+ white-space: nowrap;
618
+ color: #8b8b8b;
619
+ `,
620
+ groupHeader: css`
621
+ position: sticky;
622
+ top: ${GROUP_HEADER_STICKY_TOP};
623
+ z-index: 10;
624
+ background: white;
625
+ color: ${LIGHT_TEXT_COLOR};
626
+ font-size: 14px;
627
+ line-height: 1;
628
+ padding: 4px 16px;
629
+ font-weight: bold;
630
+ `,
631
+ controls: css`
632
+ position: sticky;
633
+ z-index: 10;
634
+ bottom: 0;
635
+ padding: 8px;
636
+ background: white;
637
+ box-shadow: 0 -1px rgba(0, 0, 0, 0.15);
638
+ margin-top: auto;
639
+ `,
640
+ };
641
+
642
+ const colorOptions = Object.keys(brandSwatch);
643
+ const defaultBrandColor = colorOptions[0];
644
+ const defaultAccentColor = 'brand';
645
+ const baseAccentOptions = ['gray', 'brand', 'custom'];
646
+ const colorFormatOptions = ['Web (hex/rgba)', 'Web (oklch)', 'iOS/Android (hex-aarrggbb)'];
647
+
648
+ const [brand, setBrand] = React.useState(defaultBrandColor);
649
+ const [accent, setAccent] = React.useState(defaultAccentColor);
650
+ const [filter, setFilter] = React.useState('');
651
+ const [colorFormat, setColorFormat] = React.useState(colorFormatOptions[0]);
652
+ const [customBrandColor, setCustomBrandColor] = React.useState('#FFDD2D');
653
+ const [customAccentColor, setCustomAccentColor] = React.useState('#FFDD2D');
654
+
655
+ const handleCustomBrandColorValueChange = React.useCallback((newColor: string) => {
656
+ setCustomBrandColor(newColor);
657
+ }, []);
658
+
659
+ const handleCustomAccentColorValueChange = React.useCallback((newColor: string) => {
660
+ setCustomAccentColor(newColor);
661
+ }, []);
662
+
663
+ const getOutputFormatParam = (format: string): ColorFormat => {
664
+ switch (format) {
665
+ case 'iOS/Android (hex-aarrggbb)':
666
+ return 'hex-aarrggbb';
667
+ case 'Web (oklch)':
668
+ return 'oklch';
669
+ case 'Web (hex/rgba)':
670
+ default:
671
+ return 'hex/rgba';
672
+ }
673
+ };
674
+ const outputFormatParam = getOutputFormatParam(colorFormat);
675
+
676
+ const getBrandColorForSwatch = React.useCallback(() => {
677
+ if (brand === 'custom') {
678
+ return customBrandColor.trim() !== ''
679
+ ? customBrandColor
680
+ : brandSwatch[defaultBrandColor as keyof typeof brandSwatch];
681
+ }
682
+ return brandSwatch[brand as keyof typeof brandSwatch];
683
+ }, [brand, customBrandColor]);
684
+
685
+ const renderColorItem = (color: string, text: string) => {
686
+ return (
687
+ <div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
688
+ <div
689
+ style={{
690
+ flexShrink: 0,
691
+ background: color,
692
+ width: 12,
693
+ height: 12,
694
+ borderRadius: 4,
695
+ }}
696
+ />
697
+ {text}
698
+ </div>
699
+ );
700
+ };
701
+
702
+ const renderBrandItem = (value: string) => {
703
+ if (value === 'custom') {
704
+ const colorToDisplay = customBrandColor.trim() !== '' ? customBrandColor : '#999';
705
+ return renderColorItem(colorToDisplay, '#custom-hex');
706
+ }
707
+ return renderColorItem(brandSwatch[value as keyof typeof brandSwatch], value);
708
+ };
709
+
710
+ const renderAccentItemValuePalette = (value: string) => {
711
+ if (value === 'custom') {
712
+ const colorToDisplay = customAccentColor.trim() !== '' ? customAccentColor : '#999';
713
+ return renderColorItem(colorToDisplay, '#custom-hex');
714
+ }
715
+ const color = value === 'gray' ? '#3d3d3d' : getBrandColorForSwatch();
716
+ return renderColorItem(color, value);
717
+ };
718
+
719
+ const renderAccentMenuItemPalette = (value: string) => {
720
+ const content = renderAccentItemValuePalette(value);
721
+ return <div key={value}>{content}</div>;
722
+ };
723
+
724
+ const safeBrandColor = React.useMemo(() => {
725
+ if (brand !== 'custom') {
726
+ return brand;
727
+ }
728
+ return customBrandColor.trim() !== '' ? customBrandColor : defaultBrandColor;
729
+ }, [brand, customBrandColor]);
730
+
731
+ const safeAccentColor = React.useMemo(() => {
732
+ if (accent !== 'custom') {
733
+ return accent;
734
+ }
735
+ return customAccentColor.trim() !== '' ? customAccentColor : defaultAccentColor;
736
+ }, [accent, customAccentColor]);
737
+
738
+ const effectiveAccentColor = safeAccentColor;
739
+
740
+ const baseTokensRaw = React.useMemo(() => {
741
+ return getColorsBase({
742
+ brand: safeBrandColor,
743
+ accent: effectiveAccentColor,
744
+ format: outputFormatParam,
745
+ });
746
+ }, [safeBrandColor, effectiveAccentColor, outputFormatParam]);
747
+
748
+ const tokensToDisplay: BaseTokenDisplay[] = React.useMemo(() => {
749
+ const flattened = flattenObject(baseTokensRaw);
750
+ const result: BaseTokenDisplay[] = [];
751
+ const uniqueKeys = new Set<string>();
752
+
753
+ const formatKey = (flatKey: string) => {
754
+ const scaleMatch = flatKey.match(/^(gray|whiteAlpha|blackAlpha|onBrand|onAccent)-(\d+)$/);
755
+ if (scaleMatch) {
756
+ const root = kebabCaseToCamelCase(scaleMatch[1]);
757
+ const scale = scaleMatch[2];
758
+ return `${root}[${scale}]`;
759
+ }
760
+
761
+ const customizablePaletteMatch = flatKey.match(/(.*)-(vivid|normal|dim)-(\d+)/);
762
+ if (customizablePaletteMatch) {
763
+ const root = customizablePaletteMatch[1].replace(/-/g, '.');
764
+ const palette = customizablePaletteMatch[2];
765
+ const scale = customizablePaletteMatch[3];
766
+ return `${root}.${palette}[${scale}]`;
767
+ }
768
+
769
+ const themedMatch = flatKey.match(/^(.*)-(light|dark)$/);
770
+ if (themedMatch) {
771
+ const root = themedMatch[1].replace(/-/g, '.');
772
+ const theme = themedMatch[2];
773
+ return `${root}.${theme}`;
774
+ }
775
+
776
+ return flatKey.replace(/-/g, '.');
777
+ };
778
+
779
+ for (const key in flattened) {
780
+ if (Object.prototype.hasOwnProperty.call(flattened, key)) {
781
+ let value = flattened[key];
782
+
783
+ if (value === null || value === undefined) {
784
+ continue;
785
+ }
786
+
787
+ if (typeof value === 'string') {
788
+ const displayKey = formatKey(key);
789
+
790
+ if (!uniqueKeys.has(displayKey)) {
791
+ result.push({
792
+ key: displayKey,
793
+ value: value,
794
+ });
795
+ uniqueKeys.add(displayKey);
796
+ }
797
+ }
798
+ }
799
+ }
800
+
801
+ return result.sort((a, b) => a.key.localeCompare(b.key));
802
+ }, [baseTokensRaw]);
803
+
804
+ const filterBaseTokens = (tokens: BaseTokenDisplay[]) => {
805
+ if (!filter) return tokens;
806
+
807
+ const filterLower = filter.toLowerCase();
808
+
809
+ return tokens.filter((token) => {
810
+ return token.key.toLowerCase().includes(filterLower) || token.value.toLowerCase().includes(filterLower);
811
+ });
812
+ };
813
+
814
+ const groupTokensByRootBase = (tokens: BaseTokenDisplay[]): Record<string, BaseTokenDisplay[]> => {
815
+ return tokens.reduce((acc: Record<string, BaseTokenDisplay[]>, token) => {
816
+ const match = token.key.match(/^([a-z]+)/i);
817
+ const root = match ? match[1] : 'other';
818
+
819
+ if (!acc[root]) {
820
+ acc[root] = [];
821
+ }
822
+ acc[root].push(token);
823
+ return acc;
824
+ }, {});
825
+ };
826
+
827
+ const filteredTokens = filterBaseTokens(tokensToDisplay);
828
+ const groupedBaseTokens = groupTokensByRootBase(filteredTokens);
829
+
830
+ const BASE_TOKEN_ORDER = [
831
+ 'brand',
832
+ 'accent',
833
+ 'warning',
834
+ 'error',
835
+ 'success',
836
+ 'gray',
837
+ 'whiteAlpha',
838
+ 'blackAlpha',
839
+ 'onBrand',
840
+ 'customizable',
841
+ ];
842
+
843
+ const sortedGroupedTokens: Record<string, BaseTokenDisplay[]> = {};
844
+
845
+ BASE_TOKEN_ORDER.forEach((root) => {
846
+ if (groupedBaseTokens[root]) {
847
+ sortedGroupedTokens[root] = groupedBaseTokens[root];
848
+ delete groupedBaseTokens[root];
849
+ }
850
+ });
851
+ Object.assign(sortedGroupedTokens, groupedBaseTokens);
852
+
853
+ return (
854
+ <div className={styles.colors} data-colors-controls>
855
+ <div className={styles.filterRow}>
856
+ <Gapped>
857
+ <label htmlFor="base-brand">Brand</label>
858
+ <Select
859
+ id="base-brand"
860
+ items={[...colorOptions, 'custom']}
861
+ value={brand}
862
+ width={140}
863
+ onValueChange={setBrand}
864
+ renderValue={renderBrandItem}
865
+ renderItem={renderBrandItem}
866
+ />
867
+ {brand === 'custom' && (
868
+ <Input
869
+ maxLength={7}
870
+ width={80}
871
+ value={customBrandColor}
872
+ onValueChange={handleCustomBrandColorValueChange}
873
+ placeholder="#RRGGBB"
874
+ />
875
+ )}
876
+ </Gapped>
877
+
878
+ <Gapped>
879
+ <label htmlFor="base-accent">Accent</label>
880
+ <Select
881
+ id="base-accent"
882
+ width={140}
883
+ items={baseAccentOptions}
884
+ value={accent}
885
+ onValueChange={setAccent}
886
+ renderValue={renderAccentItemValuePalette}
887
+ renderItem={renderAccentMenuItemPalette}
888
+ />
889
+ {accent === 'custom' && (
890
+ <Input
891
+ maxLength={7}
892
+ width={80}
893
+ value={customAccentColor}
894
+ onValueChange={handleCustomAccentColorValueChange}
895
+ placeholder="#RRGGBB"
896
+ />
897
+ )}
898
+ </Gapped>
899
+
900
+ <Gapped style={{ marginLeft: 'auto' }}>
901
+ <label htmlFor="base-format">Format</label>
902
+ <Select
903
+ width={238}
904
+ id="base-format"
905
+ items={colorFormatOptions}
906
+ value={colorFormat}
907
+ onValueChange={setColorFormat}
908
+ />
909
+ </Gapped>
910
+ </div>
911
+ <div className={styles.headerRow}>
912
+ <span className={styles.colorName}>Token</span>
913
+ <div className={styles.colorTileWrapper} style={{ borderLeft: '1px solid rgba(0, 0, 0, 0.08)' }}>
914
+ <span>Value</span>
915
+ </div>
916
+ </div>
917
+ {Object.entries(sortedGroupedTokens).map(([groupName, tokens], i) => (
918
+ <React.Fragment key={groupName}>
919
+ <div className={styles.groupHeader} style={{ margin: i !== 0 ? '24px 0 16px' : '12px 0 0' }}>
920
+ {groupName}
921
+ </div>
922
+ {tokens.map(({ key, value }) => (
923
+ <div key={key} className={styles.displayRow}>
924
+ <span className={styles.colorName}>{key}</span>
925
+ <div className={styles.colorTileWrapper}>
926
+ <div
927
+ className={styles.colorTile}
928
+ style={{
929
+ backgroundColor:
930
+ colorFormat === 'iOS/Android (hex-aarrggbb)' ? convertHexAlphaToWebFormat(value) : value,
931
+ }}
932
+ />
933
+ <span className={styles.colorHex} style={{ color: LIGHT_TEXT_COLOR }}>
934
+ {value?.replace(/, /g, ',')}
935
+ </span>
936
+ </div>
937
+ </div>
938
+ ))}
939
+ </React.Fragment>
940
+ ))}
941
+ <div className={styles.controls}>
942
+ <Input
943
+ width="50%"
944
+ value={filter}
945
+ onValueChange={setFilter}
946
+ placeholder="Введите название токена или цвет"
947
+ rightIcon={<SearchLoupeIcon16Regular />}
948
+ />
949
+ </div>
950
+ </div>
951
+ );
952
+ };
953
+
954
+ BaseTokensStory.storyName = 'Токены базовой палитры';