diff --git a/src/components/ClipboardArea/ClipboardArea.scss b/src/components/ClipboardArea/ClipboardArea.scss
index 3eda2335970..71e124fa790 100644
--- a/src/components/ClipboardArea/ClipboardArea.scss
+++ b/src/components/ClipboardArea/ClipboardArea.scss
@@ -7,6 +7,14 @@ $block: '.#{variables.$ns}clipboard-area';
#{$block} {
display: flex;
+ &__popover {
+ width: 100%;
+
+ .g-popover__handler {
+ width: 100%;
+ }
+ }
+
&__popup {
--g-popup-background-color: #3e3235;
--g-popup-border-color: #3e3235;
diff --git a/src/components/ClipboardArea/ClipboardArea.tsx b/src/components/ClipboardArea/ClipboardArea.tsx
index b2653065bd7..161c5a63013 100644
--- a/src/components/ClipboardArea/ClipboardArea.tsx
+++ b/src/components/ClipboardArea/ClipboardArea.tsx
@@ -30,6 +30,7 @@ export const ClipboardArea: React.FC
= ({
return isNeedPopup ? (
= ({code, tooltipContent, className}) => {
+ return (
+
+ {(status) => (
+
+ )}
+
+ );
+};
diff --git a/src/components/ColorPickerInput/ColorPickerInput.scss b/src/components/ColorPickerInput/ColorPickerInput.scss
deleted file mode 100644
index dbd6883958f..00000000000
--- a/src/components/ColorPickerInput/ColorPickerInput.scss
+++ /dev/null
@@ -1,20 +0,0 @@
-@use '../../variables.scss';
-
-$block: '.#{variables.$ns}color-picker';
-
-#{$block} {
- &__preview {
- width: 16px;
- height: 16px;
- margin-inline-start: var(--g-spacing-2);
- margin-inline-end: var(--g-spacing-1);
- border-radius: var(--g-border-radius-xs);
- }
-
- &__input {
- width: 100%;
- height: 0;
- opacity: 0;
- border: 1px solid transparent;
- }
-}
diff --git a/src/components/ColorPickerInput/ColorPickerInput.tsx b/src/components/ColorPickerInput/ColorPickerInput.tsx
deleted file mode 100644
index d3dea9c30fd..00000000000
--- a/src/components/ColorPickerInput/ColorPickerInput.tsx
+++ /dev/null
@@ -1,112 +0,0 @@
-import {Palette} from 'landing-icons';
-import {Button, Flex, Icon, TextInput, TextInputProps} from 'landing-uikit';
-import {useTranslation} from 'next-i18next';
-import React, {ChangeEventHandler, useCallback, useRef, useState} from 'react';
-
-import {block} from '../../utils';
-
-import './ColorPickerInput.scss';
-import {ColorPreview} from './ColorPreview';
-import {NativeColorPicker} from './NativeColorPicker';
-import {hexRegexp, parseRgbStringToHex, rgbRegexp, rgbaRegexp} from './utils';
-
-const b = block('color-picker');
-
-export interface ColorPickerInputProps {
- defaultValue: string;
- name?: string;
- value?: string;
- onChange?: (color: string) => void;
- errorMessage?: string;
-}
-
-export const ColorPickerInput = ({
- name,
- value,
- onChange: onChangeExternal,
- defaultValue,
- errorMessage,
-}: ColorPickerInputProps) => {
- const {t} = useTranslation('component');
-
- const [color, setColor] = useState(defaultValue);
- const [inputValue, setInputValue] = useState(defaultValue);
- const [validationError, setValidationError] = useState();
-
- const colorInputRef = useRef(null);
-
- const managedValue = value || inputValue;
-
- const onChange: ChangeEventHandler = useCallback(
- (event) => {
- const newValue = event.target.value.replaceAll(' ', '');
- onChangeExternal?.(newValue);
- setInputValue(newValue);
- setValidationError(undefined);
-
- if (
- !newValue ||
- new RegExp(hexRegexp, 'g').test(newValue) ||
- new RegExp(rgbaRegexp, 'g').test(newValue)
- ) {
- setColor(newValue);
-
- return;
- }
-
- if (new RegExp(rgbRegexp, 'g').test(newValue)) {
- const hexColor = parseRgbStringToHex(newValue);
-
- setColor(hexColor);
- return;
- }
- },
- [onChangeExternal],
- );
-
- const onNativeInputChange: ChangeEventHandler = useCallback((e) => {
- const newValue = e.target.value.toUpperCase();
-
- setColor(newValue);
- setInputValue(newValue);
- }, []);
-
- const onBlur = useCallback(() => {
- if (
- !managedValue ||
- (!new RegExp(hexRegexp, 'g').test(managedValue) &&
- !new RegExp(rgbRegexp, 'g').test(managedValue) &&
- !new RegExp(rgbaRegexp, 'g').test(managedValue))
- ) {
- setValidationError('invalid');
- }
- }, [managedValue]);
-
- return (
-
- }
- endContent={
-
- }
- onBlur={onBlur}
- />
-
-
- );
-};
diff --git a/src/components/ColorPickerInput/ColorPreview.tsx b/src/components/ColorPickerInput/ColorPreview.tsx
deleted file mode 100644
index 29b23540590..00000000000
--- a/src/components/ColorPickerInput/ColorPreview.tsx
+++ /dev/null
@@ -1,15 +0,0 @@
-import React from 'react';
-
-import {block} from '../../utils';
-
-import './ColorPickerInput.scss';
-
-export interface ColorPreviewProps {
- color: string;
-}
-
-const b = block('color-picker__preview');
-
-export const ColorPreview = ({color}: ColorPreviewProps) => {
- return ;
-};
diff --git a/src/components/ColorPickerInput/NativeColorPicker.tsx b/src/components/ColorPickerInput/NativeColorPicker.tsx
deleted file mode 100644
index 2f30307dacc..00000000000
--- a/src/components/ColorPickerInput/NativeColorPicker.tsx
+++ /dev/null
@@ -1,18 +0,0 @@
-import React, {ChangeEventHandler, forwardRef} from 'react';
-
-import {block} from '../../utils';
-
-import './ColorPickerInput.scss';
-
-export interface NativeColorPickerProps {
- value: string;
- onChange: ChangeEventHandler;
-}
-
-const b = block('color-picker__input');
-
-export const NativeColorPicker = forwardRef(
- ({value, onChange}, ref) => {
- return ;
- },
-);
diff --git a/src/components/ColorPickerInput/utils.ts b/src/components/ColorPickerInput/utils.ts
deleted file mode 100644
index 7b44fa2fd1d..00000000000
--- a/src/components/ColorPickerInput/utils.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-export const hexRegexp = /^#[a-fA-F0-9]{6}$/;
-export const rgbRegexp = /^rgb\((\d{1,3}, ?){2}(\d{1,3})\)$/;
-export const rgbaRegexp = /^rgba\((\d{1,3}, ?){3}((0(,|\.)[0-9]{1,2})|1)\)$/;
-
-const numberRegexp = /\b\d+\b/g;
-
-export const parseRgbStringToHex = (rgbString: string) => {
- let hexColor = '#';
- rgbString.match(new RegExp(numberRegexp, 'g'))?.forEach((val) => {
- const hex = Number(val).toString(16);
-
- hexColor += hex?.length === 1 ? `0${hex}` : hex;
- });
-
- return hexColor;
-};
diff --git a/src/components/Icons/IconDialog/UsageExample/UsageExample.scss b/src/components/Icons/IconDialog/UsageExample/UsageExample.scss
index 177fd39ae79..f55d745ffa9 100644
--- a/src/components/Icons/IconDialog/UsageExample/UsageExample.scss
+++ b/src/components/Icons/IconDialog/UsageExample/UsageExample.scss
@@ -13,45 +13,4 @@ $block: '.#{variables.$ns}icon-usage-example';
margin-bottom: 12px;
color: rgba(255, 255, 255, 0.7);
}
-
- &__wrapper {
- display: flex;
- flex-direction: row;
- width: 100%;
- background: var(--g-color-base-background);
- border-radius: 16px;
- padding: 16px 48px 16px 16px;
- position: relative;
-
- &:hover {
- cursor: pointer;
-
- #{$block}__code {
- color: #fff;
- }
-
- #{$block}__copy-icon {
- color: #fff;
- }
- }
- }
-
- &__code {
- @include pcStyles.text-size(code-2);
- flex-grow: 1;
- font-family: var(--g-font-family-monospace);
- color: rgba(255, 255, 255, 0.7);
- margin-right: 12px;
- transition: color 0.1s ease-in-out;
-
- &_copied {
- color: #fff;
- }
- }
-
- &__copy-button {
- position: absolute;
- right: 16px;
- top: 16px;
- }
}
diff --git a/src/components/Icons/IconDialog/UsageExample/UsageExample.tsx b/src/components/Icons/IconDialog/UsageExample/UsageExample.tsx
index 3cf2c8ff4d8..ab9f9ce2fca 100644
--- a/src/components/Icons/IconDialog/UsageExample/UsageExample.tsx
+++ b/src/components/Icons/IconDialog/UsageExample/UsageExample.tsx
@@ -2,8 +2,7 @@ import {useTranslation} from 'next-i18next';
import React from 'react';
import {block} from '../../../../utils';
-import {ClipboardArea} from '../../../ClipboardArea/ClipboardArea';
-import {ClipboardIcon} from '../../../ClipboardIcon/ClipboardIcon';
+import {CodeExample} from '../../../CodeExample/CodeExample';
import type {IconItem} from '../../types';
import './UsageExample.scss';
@@ -29,29 +28,14 @@ export const UsageExample: React.FC = ({icon, variant}) => {
{variant === 'react' ? t('icons:usage_reactComponent') : t('icons:usage_svg')}
-
- {(status) => (
-
-
- {importCode}
-
-
-
-
-
- )}
-
+ />
);
};
diff --git a/src/components/Libraries/Libraries.tsx b/src/components/Libraries/Libraries.tsx
index 591a8245380..f97f31fe784 100644
--- a/src/components/Libraries/Libraries.tsx
+++ b/src/components/Libraries/Libraries.tsx
@@ -8,9 +8,9 @@ import starIcon from '../../assets/icons/star.svg';
import versionIcon from '../../assets/icons/version.svg';
import {block, getLibsList} from '../../utils';
import {Link} from '../Link';
+import {TagItem, Tags} from '../Tags/Tags';
import './Libraries.scss';
-import {TagItem, Tags} from './Tags/Tags';
const b = block('libraries');
diff --git a/src/components/Libraries/Tags/Tags.scss b/src/components/Libraries/Tags/Tags.scss
deleted file mode 100644
index 6ec0c6b6d73..00000000000
--- a/src/components/Libraries/Tags/Tags.scss
+++ /dev/null
@@ -1,32 +0,0 @@
-@use '~@gravity-ui/page-constructor/styles/variables.scss' as pcVariables;
-@use '../../../variables.scss';
-
-$block: '.#{variables.$ns}tags';
-
-#{$block} {
- display: flex;
- overflow-x: auto;
-
- --g-scrollbar-width: 0;
-
- &__tag {
- padding: 11px 24px;
- margin-left: 8px;
- font-size: 15px;
- line-height: 20px;
- font-weight: 400;
- border-radius: 34px;
- border: 1px solid rgba(255, 255, 255, 0.15);
- cursor: pointer;
-
- &:first-child {
- margin-left: 0;
- }
-
- &_active {
- color: #ffbe5c;
- background: rgba(255, 190, 92, 0.1);
- border: 1px solid transparent;
- }
- }
-}
diff --git a/src/components/Libraries/Tags/Tags.tsx b/src/components/Libraries/Tags/Tags.tsx
deleted file mode 100644
index 74c19a98dc9..00000000000
--- a/src/components/Libraries/Tags/Tags.tsx
+++ /dev/null
@@ -1,40 +0,0 @@
-import React from 'react';
-
-import {block} from '../../../utils';
-
-import './Tags.scss';
-
-const b = block('tags');
-
-export type TagItem = {
- title: string;
- value: string;
-};
-
-type Props = {
- value: string;
- items: TagItem[];
- onChange: (newValue: string) => void;
-};
-
-export const Tags: React.FC
= ({value, items, onChange}) => {
- return (
-
- {items.map((item) => {
- return (
-
{
- onChange(item.value);
- }}
- className={b('tag', {active: item.value === value})}
- >
- {item.title}
-
- );
- })}
-
- );
-};
diff --git a/src/components/SelectableCard/SelectableCard.scss b/src/components/SelectableCard/SelectableCard.scss
new file mode 100644
index 00000000000..0f25b2429d4
--- /dev/null
+++ b/src/components/SelectableCard/SelectableCard.scss
@@ -0,0 +1,31 @@
+@use '../../variables.scss';
+
+$block: '.#{variables.$ns}selectable-card';
+
+#{$block} {
+ position: relative;
+ display: flex;
+ padding: 22px 12px;
+ height: 80px;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ text-align: center;
+
+ &__icon {
+ position: absolute;
+ top: 4px;
+ right: 4px;
+ color: var(--g-color-base-brand);
+ }
+
+ &__fake-button {
+ width: 69px;
+ height: 28px;
+ background-color: var(--g-color-base-brand);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ user-select: none;
+ }
+}
diff --git a/src/components/SelectableCard/SelectableCard.tsx b/src/components/SelectableCard/SelectableCard.tsx
new file mode 100644
index 00000000000..2954300d40a
--- /dev/null
+++ b/src/components/SelectableCard/SelectableCard.tsx
@@ -0,0 +1,54 @@
+import {CircleCheckFill} from '@gravity-ui/icons';
+import {Card, type CardProps, DOMProps, Text, TextProps} from '@gravity-ui/uikit';
+import React from 'react';
+
+import {block} from '../../utils';
+
+import './SelectableCard.scss';
+
+const b = block('selectable-card');
+
+export type SelecableCardProps = {
+ /**
+ * Text to display inside
+ */
+ text: string;
+ /**
+ * Flag to show only text without decoration
+ */
+ pureText?: boolean;
+ /**
+ * Props for inner Text component
+ */
+ textProps?: TextProps;
+} & Pick &
+ Pick;
+
+const CardContent = ({
+ text,
+ pureText,
+ textProps,
+}: Pick) => {
+ const props: Record = pureText
+ ? {variant: 'body-2'}
+ : {color: 'inverted-primary', className: b('fake-button')};
+ return (
+
+ {text}
+
+ );
+};
+
+export const SelectableCard = ({
+ selected,
+ pureText,
+ text,
+ onClick,
+ className,
+ textProps,
+}: SelecableCardProps) => (
+
+
+ {selected && }
+
+);
diff --git a/src/components/Tags/Tags.scss b/src/components/Tags/Tags.scss
new file mode 100644
index 00000000000..edd586f3da4
--- /dev/null
+++ b/src/components/Tags/Tags.scss
@@ -0,0 +1,16 @@
+@use '../../../node_modules/@gravity-ui/page-constructor/styles/variables' as pcVariables;
+@use '../../variables';
+
+$block: '.#{variables.$ns}tags';
+
+#{$block} {
+ --g-scrollbar-width: 0;
+
+ overflow-x: auto;
+
+ &__tag {
+ &_selected {
+ --g-color-base-selection: var(--g-color-base-warning-light);
+ }
+ }
+}
diff --git a/src/components/Tags/Tags.tsx b/src/components/Tags/Tags.tsx
new file mode 100644
index 00000000000..1df68dda9e5
--- /dev/null
+++ b/src/components/Tags/Tags.tsx
@@ -0,0 +1,44 @@
+import {Button, Flex} from '@gravity-ui/uikit';
+import React from 'react';
+
+import {block} from '../../utils';
+
+import './Tags.scss';
+
+const b = block('tags');
+
+export type TagItem = {
+ title: string;
+ value: T;
+};
+
+interface TagsProps {
+ value: T;
+ items: TagItem[];
+ onChange: (newValue: T) => void;
+ className?: string;
+}
+
+export function Tags({value, items, onChange, className}: TagsProps) {
+ return (
+
+ {items.map((item) => {
+ return (
+
+ );
+ })}
+
+ );
+}
diff --git a/src/components/Themes/Themes.scss b/src/components/Themes/Themes.scss
new file mode 100644
index 00000000000..53f417921de
--- /dev/null
+++ b/src/components/Themes/Themes.scss
@@ -0,0 +1,69 @@
+@use '~@gravity-ui/page-constructor/styles/variables.scss' as pcVariables;
+@use '~@gravity-ui/uikit/styles/mixins' as ukitMixins;
+@use '../../variables.scss';
+
+$block: '.#{variables.$ns}themes';
+
+#{$block} {
+ margin-block-start: calc(var(--g-spacing-base) * 16);
+
+ &__title {
+ margin-block-end: var(--g-spacing-6);
+
+ &__text {
+ @include ukitMixins.text-display-4();
+
+ @media (max-width: map-get(pcVariables.$gridBreakpoints, 'sm')) {
+ @include ukitMixins.text-display-2();
+ }
+ }
+ }
+
+ &__header-actions {
+ margin-block-end: calc(var(--g-spacing-base) * 8);
+ justify-content: space-between;
+
+ @media (max-width: map-get(pcVariables.$gridBreakpoints, 'md') - 1) {
+ flex-direction: column;
+ justify-content: flex-start;
+ }
+ }
+
+ & &__tabs {
+ display: flex;
+ overflow: auto;
+ flex-wrap: wrap;
+
+ @media (max-width: map-get(pcVariables.$gridBreakpoints, 'md') - 1) {
+ /* stylelint-disable */
+ flex-wrap: nowrap !important;
+ margin: 0 -24px;
+ padding: 0 24px;
+ }
+ }
+
+ &__export-theme-btn {
+ --g-button-border-radius: 8px;
+ width: fit-content;
+
+ @media (max-width: map-get(pcVariables.$gridBreakpoints, 'md') - 1) {
+ margin-top: var(--g-spacing-6);
+ }
+
+ @media (max-width: map-get(pcVariables.$gridBreakpoints, 'sm')) {
+ width: 100%;
+ }
+ }
+
+ &__export-theme-btn {
+ border-radius: var(--g-border-radius-m);
+ }
+
+ &__content {
+ padding: calc(var(--g-spacing-base) * 12) 0;
+
+ @media (max-width: map-get(pcVariables.$gridBreakpoints, 'sm')) {
+ padding: calc(var(--g-spacing-base) * 6) 0;
+ }
+ }
+}
diff --git a/src/components/Themes/Themes.tsx b/src/components/Themes/Themes.tsx
new file mode 100644
index 00000000000..84e2bcac275
--- /dev/null
+++ b/src/components/Themes/Themes.tsx
@@ -0,0 +1,102 @@
+import {Col, Grid, Row} from '@gravity-ui/page-constructor';
+import {ArrowUpFromSquare} from 'landing-icons';
+import {Button, Flex, Icon, Text} from 'landing-uikit';
+import {useTranslation} from 'next-i18next';
+import React, {useMemo, useState} from 'react';
+
+import {block} from '../../utils';
+import {TagItem, Tags} from '../Tags/Tags';
+
+import './Themes.scss';
+import {DEFAULT_THEME} from './lib/constants';
+import {BorderRadiusTab} from './ui/BorderRadiusTab/BorderRadiusTab';
+import {ColorsTab} from './ui/ColorsTab/ColorsTab';
+import {PreviewTab} from './ui/PreviewTab/PreviewTab';
+import {ThemeCreatorContextProvider} from './ui/ThemeCreatorContextProvider';
+import {ThemeExportDialog} from './ui/ThemeExportDialog/ThemeExportDialog';
+import {TypographyTab} from './ui/TypographyTab/TypographyTab';
+
+const b = block('themes');
+
+enum ThemeTab {
+ Colors = 'colors',
+ Typography = 'typography',
+ BorderRadius = 'borderRadius',
+ Preview = 'preview',
+}
+
+const tabToComponent: Record = {
+ [ThemeTab.Colors]: ColorsTab,
+ [ThemeTab.Typography]: TypographyTab,
+ [ThemeTab.BorderRadius]: BorderRadiusTab,
+ [ThemeTab.Preview]: PreviewTab,
+};
+
+export const Themes = () => {
+ const {t} = useTranslation('themes');
+
+ const [isExportDialogVisible, toggleExportDialog] = React.useReducer(
+ (isOpen) => !isOpen,
+ false,
+ );
+
+ const tags: TagItem[] = useMemo(
+ () => [
+ {
+ value: ThemeTab.Colors,
+ title: t('tags_colors'),
+ },
+ {
+ value: ThemeTab.Typography,
+ title: t('tags_typography'),
+ },
+ {
+ value: ThemeTab.BorderRadius,
+ title: t('tags_borderRadius'),
+ },
+ {
+ value: ThemeTab.Preview,
+ title: t('tags_preview'),
+ },
+ ],
+ [t],
+ );
+
+ const [activeTab, setActiveTab] = useState(ThemeTab.Colors);
+
+ const TabComponent = tabToComponent[activeTab];
+
+ return (
+
+
+
+
+ {t('title')}
+
+
+
+
+
+
+
+
+ {TabComponent ? : null}
+
+
+
+
+ );
+};
diff --git a/src/components/Themes/hooks/index.ts b/src/components/Themes/hooks/index.ts
new file mode 100644
index 00000000000..e68e06b27c8
--- /dev/null
+++ b/src/components/Themes/hooks/index.ts
@@ -0,0 +1,4 @@
+export {useThemeCreator, useThemeCreatorMethods} from './useThemeCreator';
+export {useThemePalette, useThemePaletteColor} from './useThemePalette';
+export {useThemeUtilityColor} from './useThemeUtilityColor';
+export {useThemePrivateColorOptions} from './useThemePrivateColorOptions';
diff --git a/src/components/Themes/hooks/useThemeCreator.ts b/src/components/Themes/hooks/useThemeCreator.ts
new file mode 100644
index 00000000000..c7e62a37b0c
--- /dev/null
+++ b/src/components/Themes/hooks/useThemeCreator.ts
@@ -0,0 +1,6 @@
+import React from 'react';
+
+import {ThemeCreatorContext, ThemeCreatorMethodsContext} from '../lib/themeCreatorContext';
+
+export const useThemeCreator = () => React.useContext(ThemeCreatorContext);
+export const useThemeCreatorMethods = () => React.useContext(ThemeCreatorMethodsContext);
diff --git a/src/components/Themes/hooks/useThemePalette.ts b/src/components/Themes/hooks/useThemePalette.ts
new file mode 100644
index 00000000000..71ffd953664
--- /dev/null
+++ b/src/components/Themes/hooks/useThemePalette.ts
@@ -0,0 +1,36 @@
+import React from 'react';
+
+import {getThemePalette} from '../lib/themeCreatorUtils';
+import type {ThemeVariant} from '../lib/types';
+
+import {useThemeCreator, useThemeCreatorMethods} from './useThemeCreator';
+
+export const useThemePalette = () => {
+ const themeState = useThemeCreator();
+ return React.useMemo(() => getThemePalette(themeState), [themeState]);
+};
+
+type UseThemePaletteColorParams = {
+ token: string;
+ theme: ThemeVariant;
+};
+
+export const useThemePaletteColor = ({token, theme}: UseThemePaletteColorParams) => {
+ const themeState = useThemeCreator();
+ const {updateColor} = useThemeCreatorMethods();
+
+ const value = React.useMemo(() => themeState.palette[theme][token], [themeState, token, theme]);
+
+ const updateValue = React.useCallback(
+ (newValue: string) => {
+ updateColor({
+ theme,
+ title: token,
+ value: newValue,
+ });
+ },
+ [token, theme, updateColor],
+ );
+
+ return [value, updateValue] as const;
+};
diff --git a/src/components/Themes/hooks/useThemePrivateColorOptions.ts b/src/components/Themes/hooks/useThemePrivateColorOptions.ts
new file mode 100644
index 00000000000..6aa706b024c
--- /dev/null
+++ b/src/components/Themes/hooks/useThemePrivateColorOptions.ts
@@ -0,0 +1,15 @@
+import React from 'react';
+
+import {getThemeColorOptions} from '../lib/themeCreatorUtils';
+import {ThemeVariant} from '../lib/types';
+
+import {useThemeCreator} from './useThemeCreator';
+
+export const useThemePrivateColorOptions = (themeVariant: ThemeVariant) => {
+ const themeState = useThemeCreator();
+
+ return React.useMemo(
+ () => getThemeColorOptions({themeState, themeVariant}),
+ [themeState, themeVariant],
+ );
+};
diff --git a/src/components/Themes/hooks/useThemeUtilityColor.ts b/src/components/Themes/hooks/useThemeUtilityColor.ts
new file mode 100644
index 00000000000..2160797535e
--- /dev/null
+++ b/src/components/Themes/hooks/useThemeUtilityColor.ts
@@ -0,0 +1,30 @@
+import React from 'react';
+
+import type {ColorsOptions, ThemeVariant} from '../lib/types';
+
+import {useThemeCreator, useThemeCreatorMethods} from './useThemeCreator';
+
+type UseThemeColorParams = {
+ name: keyof ColorsOptions;
+ theme: ThemeVariant;
+};
+
+export const useThemeUtilityColor = ({name, theme}: UseThemeColorParams) => {
+ const themeState = useThemeCreator();
+ const {changeUtilityColor} = useThemeCreatorMethods();
+
+ const value = React.useMemo(() => themeState.colors[theme][name], [themeState, name, theme]);
+
+ const updateValue = React.useCallback(
+ (newValue: string) => {
+ changeUtilityColor({
+ themeVariant: theme,
+ name,
+ value: newValue,
+ });
+ },
+ [name, theme, changeUtilityColor],
+ );
+
+ return [value, updateValue] as const;
+};
diff --git a/src/components/Themes/lib/constants.ts b/src/components/Themes/lib/constants.ts
new file mode 100644
index 00000000000..86b28a1aefb
--- /dev/null
+++ b/src/components/Themes/lib/constants.ts
@@ -0,0 +1,333 @@
+import {RadiusPresetName, RadiusValue, type ThemeOptions, type ThemeVariant} from './types';
+import {defaultTypographyPreset} from './typography/constants';
+
+export const THEME_COLOR_VARIABLE_PREFIX = '--g-color';
+
+export const THEME_BORDER_RADIUS_VARIABLE_PREFIX = '--g-border-radius';
+
+export const DEFAULT_NEW_COLOR_TITLE = 'New color';
+
+export const DEFAULT_BRAND_COLORS = [
+ 'rgb(203,255,92)',
+ 'rgb(0,41,255)',
+ 'rgb(49,78,60)',
+ 'rgb(108,145,201)',
+ 'rgb(255,190,92)',
+ 'rgb(255,92,92)',
+] as const;
+
+export const TEXT_CONTRAST_COLORS: Record = {
+ dark: {
+ white: 'rgb(255, 255, 255)',
+ black: 'rgba(0, 0, 0, 0.9)', // --g-color-private-black-900
+ },
+ light: {
+ white: 'rgb(255, 255, 255)',
+ black: 'rgba(0, 0, 0, 0.85)', // --g-color-private-black-850
+ },
+};
+
+export const DEFAULT_PALETTE: ThemeOptions['palette'] = {
+ light: {
+ white: 'rgb(255, 255, 255)',
+ black: 'rgb(0, 0, 0)',
+ brand: DEFAULT_BRAND_COLORS[0],
+ orange: 'rgb(255, 119, 0)',
+ green: 'rgb(59, 201, 53)',
+ yellow: 'rgb(255, 219, 77)',
+ red: 'rgb(255, 4, 0)',
+ blue: 'rgb(82, 130, 255)',
+ 'cool-grey': 'rgb(107, 132, 153)',
+ purple: 'rgb(143, 82, 204)',
+ },
+ dark: {
+ white: 'rgb(255, 255, 255)',
+ black: 'rgb(0, 0, 0)',
+ brand: DEFAULT_BRAND_COLORS[0],
+ orange: 'rgb(200, 99, 12)',
+ green: 'rgb(91, 181, 87)',
+ yellow: 'rgb(255, 203, 0)',
+ red: 'rgb(232, 73, 69)',
+ blue: 'rgb(82, 130, 255)',
+ 'cool-grey': 'rgb(96, 128, 156)',
+ purple: 'rgb(143, 82, 204)',
+ },
+};
+
+export const DEFAULT_PALETTE_TOKENS = new Set(Object.keys(DEFAULT_PALETTE.light));
+
+export const DEFAULT_RADIUS: RadiusValue = {
+ xs: '3',
+ s: '5',
+ m: '6',
+ l: '8',
+ xl: '10',
+};
+
+export const RADIUS_PRESETS: Record = {
+ [RadiusPresetName.Regular]: DEFAULT_RADIUS,
+ [RadiusPresetName.Circled]: {
+ xs: '10',
+ s: '12',
+ m: '14',
+ l: '18',
+ xl: '22',
+ },
+ [RadiusPresetName.Squared]: {
+ xs: '0',
+ s: '0',
+ m: '0',
+ l: '0',
+ xl: '0',
+ },
+ [RadiusPresetName.Custom]: DEFAULT_RADIUS,
+};
+
+// Default colors mappings (values from gravity-ui styles)
+// https://github.com/gravity-ui/uikit/tree/main/styles/themes
+export const DEFAULT_COLORS: ThemeOptions['colors'] = {
+ light: {
+ 'base-background': 'rgb(255,255,255)',
+ 'base-brand-hover': 'private.brand.600-solid',
+ 'base-selection': 'private.brand.200',
+ 'base-selection-hover': 'private.brand.300',
+ 'line-brand': 'private.brand.600-solid',
+ 'text-brand': 'private.brand.700-solid',
+ 'text-brand-heavy': 'private.brand.700-solid',
+ 'text-brand-contrast': TEXT_CONTRAST_COLORS.light.black,
+ 'text-link': 'private.brand.600-solid',
+ 'text-link-hover': 'private.orange.800-solid',
+ 'text-link-visited': 'private.purple.550-solid',
+ 'text-link-visited-hover': 'private.purple.800-solid',
+ },
+ dark: {
+ 'base-background': 'rgb(34,29,34)',
+ 'base-brand-hover': 'private.brand.650-solid',
+ 'base-selection': 'private.brand.150',
+ 'base-selection-hover': 'private.brand.200',
+ 'line-brand': 'private.brand.600-solid',
+ 'text-brand': 'private.brand.600-solid',
+ 'text-brand-heavy': 'private.brand.700-solid',
+ 'text-brand-contrast': TEXT_CONTRAST_COLORS.dark.black,
+ 'text-link': 'private.brand.550-solid',
+ 'text-link-hover': 'private.brand.700-solid',
+ 'text-link-visited': 'private.purple.700-solid',
+ 'text-link-visited-hover': 'private.purple.850-solid',
+ },
+};
+
+export const DEFAULT_THEME: ThemeOptions = {
+ palette: DEFAULT_PALETTE,
+ colors: DEFAULT_COLORS,
+ borders: {
+ preset: RadiusPresetName.Regular,
+ values: RADIUS_PRESETS[RadiusPresetName.Regular],
+ },
+ typography: defaultTypographyPreset,
+};
+
+export type BrandPreset = {
+ brandColor: typeof DEFAULT_BRAND_COLORS[number];
+ colors: ThemeOptions['colors'];
+};
+
+export const BRAND_COLORS_PRESETS: BrandPreset[] = [
+ {
+ brandColor: 'rgb(203,255,92)',
+ colors: {
+ light: {
+ 'base-background': 'rgb(255,255,255)',
+ 'base-brand-hover': 'private.brand.600-solid',
+ 'base-selection': 'private.brand.200',
+ 'base-selection-hover': 'private.brand.300',
+ 'line-brand': 'private.brand.600-solid',
+ 'text-brand': 'private.brand.700-solid',
+ 'text-brand-heavy': 'private.brand.700-solid',
+ 'text-brand-contrast': TEXT_CONTRAST_COLORS.light.black,
+ 'text-link': 'private.brand.600-solid',
+ 'text-link-hover': 'private.orange.800-solid',
+ 'text-link-visited': 'private.purple.550-solid',
+ 'text-link-visited-hover': 'private.purple.800-solid',
+ },
+ dark: {
+ 'base-background': 'rgb(34,29,34)',
+ 'base-brand-hover': 'private.brand.650-solid',
+ 'base-selection': 'private.brand.150',
+ 'base-selection-hover': 'private.brand.200',
+ 'line-brand': 'private.brand.600-solid',
+ 'text-brand': 'private.brand.600-solid',
+ 'text-brand-heavy': 'private.brand.700-solid',
+ 'text-brand-contrast': TEXT_CONTRAST_COLORS.dark.black,
+ 'text-link': 'private.brand.550-solid',
+ 'text-link-hover': 'private.brand.700-solid',
+ 'text-link-visited': 'private.purple.700-solid',
+ 'text-link-visited-hover': 'private.purple.850-solid',
+ },
+ },
+ },
+ {
+ brandColor: 'rgb(0,41,255)',
+ colors: {
+ light: {
+ 'base-background': 'rgb(255,255,255)',
+ 'base-brand-hover': 'private.brand.600-solid',
+ 'base-selection': 'private.brand.200',
+ 'base-selection-hover': 'private.brand.300',
+ 'line-brand': 'private.brand.600-solid',
+ 'text-brand': 'private.brand.700-solid',
+ 'text-brand-heavy': 'private.brand.700-solid',
+ 'text-brand-contrast': TEXT_CONTRAST_COLORS.light.white,
+ 'text-link': 'private.brand.600-solid',
+ 'text-link-hover': 'private.orange.800-solid',
+ 'text-link-visited': 'private.purple.550-solid',
+ 'text-link-visited-hover': 'private.purple.800-solid',
+ },
+ dark: {
+ 'base-background': 'rgb(34,29,34)',
+ 'base-brand-hover': 'private.brand.650-solid',
+ 'base-selection': 'private.brand.150',
+ 'base-selection-hover': 'private.brand.200',
+ 'line-brand': 'private.brand.600-solid',
+ 'text-brand': 'private.brand.600-solid',
+ 'text-brand-heavy': 'private.brand.700-solid',
+ 'text-brand-contrast': TEXT_CONTRAST_COLORS.dark.white,
+ 'text-link': 'private.brand.550-solid',
+ 'text-link-hover': 'private.brand.700-solid',
+ 'text-link-visited': 'private.purple.700-solid',
+ 'text-link-visited-hover': 'private.purple.850-solid',
+ },
+ },
+ },
+ {
+ brandColor: 'rgb(49,78,60)',
+ colors: {
+ light: {
+ 'base-background': 'rgb(255,255,255)',
+ 'base-brand-hover': 'private.brand.600-solid',
+ 'base-selection': 'private.brand.200',
+ 'base-selection-hover': 'private.brand.300',
+ 'line-brand': 'private.brand.600-solid',
+ 'text-brand': 'private.brand.700-solid',
+ 'text-brand-heavy': 'private.brand.700-solid',
+ 'text-brand-contrast': TEXT_CONTRAST_COLORS.light.white,
+ 'text-link': 'private.brand.600-solid',
+ 'text-link-hover': 'private.orange.800-solid',
+ 'text-link-visited': 'private.purple.550-solid',
+ 'text-link-visited-hover': 'private.purple.800-solid',
+ },
+ dark: {
+ 'base-background': 'rgb(34,29,34)',
+ 'base-brand-hover': 'private.brand.650-solid',
+ 'base-selection': 'private.brand.150',
+ 'base-selection-hover': 'private.brand.200',
+ 'line-brand': 'private.brand.600-solid',
+ 'text-brand': 'private.brand.600-solid',
+ 'text-brand-heavy': 'private.brand.700-solid',
+ 'text-brand-contrast': TEXT_CONTRAST_COLORS.dark.white,
+ 'text-link': 'private.brand.550-solid',
+ 'text-link-hover': 'private.brand.700-solid',
+ 'text-link-visited': 'private.purple.700-solid',
+ 'text-link-visited-hover': 'private.purple.850-solid',
+ },
+ },
+ },
+ {
+ brandColor: 'rgb(108,145,201)',
+ colors: {
+ light: {
+ 'base-background': 'rgb(255,255,255)',
+ 'base-brand-hover': 'private.brand.600-solid',
+ 'base-selection': 'private.brand.200',
+ 'base-selection-hover': 'private.brand.300',
+ 'line-brand': 'private.brand.600-solid',
+ 'text-brand': 'private.brand.700-solid',
+ 'text-brand-heavy': 'private.brand.700-solid',
+ 'text-brand-contrast': TEXT_CONTRAST_COLORS.light.white,
+ 'text-link': 'private.brand.600-solid',
+ 'text-link-hover': 'private.orange.800-solid',
+ 'text-link-visited': 'private.purple.550-solid',
+ 'text-link-visited-hover': 'private.purple.800-solid',
+ },
+ dark: {
+ 'base-background': 'rgb(34,29,34)',
+ 'base-brand-hover': 'private.brand.650-solid',
+ 'base-selection': 'private.brand.150',
+ 'base-selection-hover': 'private.brand.200',
+ 'line-brand': 'private.brand.600-solid',
+ 'text-brand': 'private.brand.600-solid',
+ 'text-brand-heavy': 'private.brand.700-solid',
+ 'text-brand-contrast': TEXT_CONTRAST_COLORS.dark.white,
+ 'text-link': 'private.brand.550-solid',
+ 'text-link-hover': 'private.brand.700-solid',
+ 'text-link-visited': 'private.purple.700-solid',
+ 'text-link-visited-hover': 'private.purple.850-solid',
+ },
+ },
+ },
+ {
+ brandColor: 'rgb(255,190,92)',
+ colors: {
+ light: {
+ 'base-background': 'rgb(255,255,255)',
+ 'base-brand-hover': 'private.brand.600-solid',
+ 'base-selection': 'private.brand.200',
+ 'base-selection-hover': 'private.brand.300',
+ 'line-brand': 'private.brand.600-solid',
+ 'text-brand': 'private.brand.700-solid',
+ 'text-brand-heavy': 'private.brand.700-solid',
+ 'text-brand-contrast': TEXT_CONTRAST_COLORS.light.black,
+ 'text-link': 'private.brand.600-solid',
+ 'text-link-hover': 'private.orange.800-solid',
+ 'text-link-visited': 'private.purple.550-solid',
+ 'text-link-visited-hover': 'private.purple.800-solid',
+ },
+ dark: {
+ 'base-background': 'rgb(34,29,34)',
+ 'base-brand-hover': 'private.brand.650-solid',
+ 'base-selection': 'private.brand.150',
+ 'base-selection-hover': 'private.brand.200',
+ 'line-brand': 'private.brand.600-solid',
+ 'text-brand': 'private.brand.600-solid',
+ 'text-brand-heavy': 'private.brand.700-solid',
+ 'text-brand-contrast': TEXT_CONTRAST_COLORS.dark.black,
+ 'text-link': 'private.brand.550-solid',
+ 'text-link-hover': 'private.brand.700-solid',
+ 'text-link-visited': 'private.purple.700-solid',
+ 'text-link-visited-hover': 'private.purple.850-solid',
+ },
+ },
+ },
+ {
+ brandColor: 'rgb(255,92,92)',
+ colors: {
+ light: {
+ 'base-background': 'rgb(255,255,255)',
+ 'base-brand-hover': 'private.brand.600-solid',
+ 'base-selection': 'private.brand.200',
+ 'base-selection-hover': 'private.brand.300',
+ 'line-brand': 'private.brand.600-solid',
+ 'text-brand': 'private.brand.700-solid',
+ 'text-brand-heavy': 'private.brand.700-solid',
+ 'text-brand-contrast': TEXT_CONTRAST_COLORS.light.white,
+ 'text-link': 'private.brand.600-solid',
+ 'text-link-hover': 'private.orange.800-solid',
+ 'text-link-visited': 'private.purple.550-solid',
+ 'text-link-visited-hover': 'private.purple.800-solid',
+ },
+ dark: {
+ 'base-background': 'rgb(34,29,34)',
+ 'base-brand-hover': 'private.brand.650-solid',
+ 'base-selection': 'private.brand.150',
+ 'base-selection-hover': 'private.brand.200',
+ 'line-brand': 'private.brand.600-solid',
+ 'text-brand': 'private.brand.600-solid',
+ 'text-brand-heavy': 'private.brand.700-solid',
+ 'text-brand-contrast': TEXT_CONTRAST_COLORS.dark.white,
+ 'text-link': 'private.brand.550-solid',
+ 'text-link-hover': 'private.brand.700-solid',
+ 'text-link-visited': 'private.purple.700-solid',
+ 'text-link-visited-hover': 'private.purple.850-solid',
+ },
+ },
+ },
+];
diff --git a/src/components/Themes/lib/privateColors/constants.ts b/src/components/Themes/lib/privateColors/constants.ts
new file mode 100644
index 00000000000..16569307cb0
--- /dev/null
+++ b/src/components/Themes/lib/privateColors/constants.ts
@@ -0,0 +1,289 @@
+export const themeXd = {
+ light: {
+ white: {
+ privateSolidVariables: [],
+ privateVariables: [
+ 950, 900, 850, 800, 750, 700, 650, 600, 550, 500, 450, 400, 350, 300, 250, 200, 150,
+ 100, 70, 50,
+ ],
+ colorsMap: {
+ 50: {a: 0.05, c: 1},
+ 70: {a: 0.07, c: 1},
+ 100: {a: 0.1, c: 1},
+ 150: {a: 0.15, c: 1},
+ 200: {a: 0.2, c: 1},
+ 250: {a: 0.25, c: 1},
+ 300: {a: 0.3, c: 1},
+ 350: {a: 0.35, c: 1},
+ 400: {a: 0.4, c: 1},
+ 450: {a: 0.45, c: 1},
+ 500: {a: 0.5, c: 1},
+ 550: {a: 0.55, c: 1},
+ 600: {a: 0.6, c: 1},
+ 650: {a: 0.65, c: 1},
+ 700: {a: 0.7, c: 1},
+ 750: {a: 0.75, c: 1},
+ 800: {a: 0.8, c: 1},
+ 850: {a: 0.85, c: 1},
+ 900: {a: 0.9, c: 1},
+ 950: {a: 0.95, c: 1},
+ 1000: {a: 1, c: 1},
+ },
+ },
+ black: {
+ privateSolidVariables: [
+ 950, 900, 850, 800, 750, 700, 650, 600, 550, 500, 450, 400, 350, 300, 250, 200, 150,
+ 100, 50,
+ ],
+ privateVariables: [
+ 950, 900, 850, 800, 750, 700, 650, 600, 550, 500, 450, 400, 350, 300, 250, 200, 150,
+ 100, 50,
+ ],
+ colorsMap: {
+ 50: {a: 0.05, c: -1},
+ 70: {a: 0.07, c: -1},
+ 100: {a: 0.1, c: -1},
+ 150: {a: 0.15, c: -1},
+ 200: {a: 0.2, c: -1},
+ 250: {a: 0.25, c: -1},
+ 300: {a: 0.3, c: -1},
+ 350: {a: 0.35, c: -1},
+ 400: {a: 0.4, c: -1},
+ 450: {a: 0.45, c: -1},
+ 500: {a: 0.5, c: -1},
+ 550: {a: 0.55, c: -1},
+ 600: {a: 0.6, c: -1},
+ 650: {a: 0.65, c: -1},
+ 700: {a: 0.7, c: -1},
+ 750: {a: 0.75, c: -1},
+ 800: {a: 0.8, c: -1},
+ 850: {a: 0.85, c: -1},
+ 900: {a: 0.9, c: -1},
+ 950: {a: 0.95, c: -1},
+ 1000: {a: 1, c: -1},
+ },
+ },
+ },
+ dark: {
+ white: {
+ privateSolidVariables: [
+ 950, 900, 850, 800, 750, 700, 650, 600, 550, 500, 450, 400, 350, 300, 250, 200, 150,
+ 100, 70, 50,
+ ],
+ privateVariables: [
+ 950, 900, 850, 800, 750, 700, 650, 600, 550, 500, 450, 400, 350, 300, 250, 200, 150,
+ 100, 70, 50,
+ ],
+ colorsMap: {
+ 50: {a: 0.05, c: -1},
+ 70: {a: 0.07, c: -1},
+ 100: {a: 0.1, c: -1},
+ 150: {a: 0.15, c: -1},
+ 200: {a: 0.2, c: -1},
+ 250: {a: 0.25, c: -1},
+ 300: {a: 0.3, c: -1},
+ 350: {a: 0.35, c: -1},
+ 400: {a: 0.4, c: -1},
+ 450: {a: 0.45, c: -1},
+ 500: {a: 0.5, c: -1},
+ 550: {a: 0.55, c: -1},
+ 600: {a: 0.6, c: -1},
+ 650: {a: 0.65, c: -1},
+ 700: {a: 0.7, c: -1},
+ 750: {a: 0.75, c: -1},
+ 800: {a: 0.8, c: -1},
+ 850: {a: 0.85, c: -1},
+ 900: {a: 0.9, c: -1},
+ 950: {a: 0.95, c: -1},
+ 1000: {a: 1, c: -1},
+ },
+ },
+ black: {
+ privateSolidVariables: [],
+ privateVariables: [
+ 950, 900, 850, 800, 750, 700, 650, 600, 550, 500, 450, 400, 350, 300, 250, 200, 150,
+ 100, 50, 20,
+ ],
+ colorsMap: {
+ 20: {a: 0.02, c: 1},
+ 50: {a: 0.05, c: 1},
+ 100: {a: 0.1, c: 1},
+ 150: {a: 0.15, c: 1},
+ 200: {a: 0.2, c: 1},
+ 250: {a: 0.25, c: 1},
+ 300: {a: 0.3, c: 1},
+ 350: {a: 0.35, c: 1},
+ 400: {a: 0.4, c: 1},
+ 450: {a: 0.45, c: 1},
+ 500: {a: 0.5, c: 1},
+ 550: {a: 0.55, c: 1},
+ 600: {a: 0.6, c: 1},
+ 650: {a: 0.65, c: 1},
+ 700: {a: 0.7, c: 1},
+ 750: {a: 0.75, c: 1},
+ 800: {a: 0.8, c: 1},
+ 850: {a: 0.85, c: 1},
+ 900: {a: 0.9, c: 1},
+ 950: {a: 0.95, c: 1},
+ 1000: {a: 1, c: 1},
+ },
+ },
+ 'White Opaque': {
+ privateVariables: [150],
+ colorsMap: {
+ 50: {a: 0.05, c: 1},
+ 70: {a: 0.07, c: 1},
+ 100: {a: 0.1, c: 1},
+ 150: {a: 0.15, c: 1},
+ 200: {a: 0.2, c: 1},
+ 250: {a: 0.25, c: 1},
+ 300: {a: 0.3, c: 1},
+ 350: {a: 0.35, c: 1},
+ 400: {a: 0.4, c: 1},
+ 450: {a: 0.45, c: 1},
+ 500: {a: 0.5, c: 1},
+ 550: {a: 0.55, c: 1},
+ 600: {a: 0.6, c: 1},
+ 650: {a: 0.65, c: 1},
+ 700: {a: 0.7, c: 1},
+ 750: {a: 0.75, c: 1},
+ 800: {a: 0.8, c: 1},
+ 850: {a: 0.85, c: 1},
+ 900: {a: 0.9, c: 1},
+ 950: {a: 0.95, c: 1},
+ 1000: {a: 1, c: 1},
+ },
+ },
+ },
+ 'light-hc': {
+ white: {
+ privateSolidVariables: [],
+ privateVariables: [
+ 950, 900, 850, 800, 750, 700, 650, 600, 550, 500, 450, 400, 350, 300, 250, 200, 150,
+ 100, 70, 50,
+ ],
+ colorsMap: {
+ 50: {a: 0.05, c: 1},
+ 70: {a: 0.07, c: 1},
+ 100: {a: 0.1, c: 1},
+ 150: {a: 0.15, c: 1},
+ 200: {a: 0.2, c: 1},
+ 250: {a: 0.25, c: 1},
+ 300: {a: 0.3, c: 1},
+ 350: {a: 0.35, c: 1},
+ 400: {a: 0.4, c: 1},
+ 450: {a: 0.45, c: 1},
+ 500: {a: 0.5, c: 1},
+ 550: {a: 0.55, c: 1},
+ 600: {a: 0.6, c: 1},
+ 650: {a: 0.65, c: 1},
+ 700: {a: 0.7, c: 1},
+ 750: {a: 0.75, c: 1},
+ 800: {a: 0.8, c: 1},
+ 850: {a: 0.85, c: 1},
+ 900: {a: 0.9, c: 1},
+ 950: {a: 0.95, c: 1},
+ 1000: {a: 1, c: 1},
+ },
+ },
+ Black: {
+ privateSolidVariables: [
+ 950, 900, 850, 800, 750, 700, 650, 600, 550, 500, 450, 400, 350, 300, 250, 200, 150,
+ 100, 50, 20,
+ ],
+ privateVariables: [
+ 950, 900, 850, 800, 750, 700, 650, 600, 550, 500, 450, 400, 350, 300, 250, 200, 150,
+ 100, 70, 50,
+ ],
+ colorsMap: {
+ 20: {a: 0.02, c: 1},
+ 50: {a: 0.05, c: 1},
+ 70: {a: 0.07, c: 1},
+ 100: {a: 0.1, c: 1},
+ 150: {a: 0.15, c: 1},
+ 200: {a: 0.2, c: 1},
+ 250: {a: 0.25, c: 1},
+ 300: {a: 0.3, c: 1},
+ 350: {a: 0.35, c: 1},
+ 400: {a: 0.4, c: 1},
+ 450: {a: 0.45, c: 1},
+ 500: {a: 0.5, c: 1},
+ 550: {a: 0.55, c: 1},
+ 600: {a: 0.6, c: 1},
+ 650: {a: 0.65, c: 1},
+ 700: {a: 0.7, c: 1},
+ 750: {a: 0.75, c: 1},
+ 800: {a: 0.8, c: 1},
+ 850: {a: 0.85, c: 1},
+ 900: {a: 0.9, c: 1},
+ 950: {a: 0.95, c: 1},
+ 1000: {a: 1, c: 1},
+ },
+ },
+ },
+ 'dark-hc': {
+ white: {
+ privateSolidVariables: [
+ 950, 900, 850, 800, 750, 700, 650, 600, 550, 500, 450, 400, 350, 300, 250, 200, 150,
+ 100, 70, 50,
+ ],
+ privateVariables: [
+ 950, 900, 850, 800, 750, 700, 650, 600, 550, 500, 450, 400, 350, 300, 250, 200, 150,
+ 100, 70, 50,
+ ],
+ colorsMap: {
+ 50: {a: 0.05, c: 1},
+ 70: {a: 0.07, c: 1},
+ 100: {a: 0.1, c: 1},
+ 150: {a: 0.15, c: 1},
+ 200: {a: 0.2, c: 1},
+ 250: {a: 0.25, c: 1},
+ 300: {a: 0.3, c: 1},
+ 350: {a: 0.35, c: 1},
+ 400: {a: 0.4, c: 1},
+ 450: {a: 0.45, c: 1},
+ 500: {a: 0.5, c: 1},
+ 550: {a: 0.55, c: 1},
+ 600: {a: 0.6, c: 1},
+ 650: {a: 0.65, c: 1},
+ 700: {a: 0.7, c: 1},
+ 750: {a: 0.75, c: 1},
+ 800: {a: 0.8, c: 1},
+ 850: {a: 0.85, c: 1},
+ 900: {a: 0.9, c: 1},
+ 950: {a: 0.95, c: 1},
+ 1000: {a: 1, c: 1},
+ },
+ },
+ black: {
+ privateSolidVariables: [],
+ privateVariables: [
+ 950, 900, 850, 800, 750, 700, 650, 600, 550, 500, 450, 400, 350, 300, 250, 200, 150,
+ 100, 50, 20,
+ ],
+ colorsMap: {
+ 20: {a: 0.02, c: 1},
+ 50: {a: 0.05, c: 1},
+ 100: {a: 0.1, c: 1},
+ 150: {a: 0.15, c: 1},
+ 200: {a: 0.2, c: 1},
+ 250: {a: 0.25, c: 1},
+ 300: {a: 0.3, c: 1},
+ 350: {a: 0.35, c: 1},
+ 400: {a: 0.4, c: 1},
+ 450: {a: 0.45, c: 1},
+ 500: {a: 0.5, c: 1},
+ 550: {a: 0.55, c: 1},
+ 600: {a: 0.6, c: 1},
+ 650: {a: 0.65, c: 1},
+ 700: {a: 0.7, c: 1},
+ 750: {a: 0.75, c: 1},
+ 800: {a: 0.8, c: 1},
+ 850: {a: 0.85, c: 1},
+ 900: {a: 0.9, c: 1},
+ 950: {a: 0.95, c: 1},
+ 1000: {a: 1, c: 1},
+ },
+ },
+ },
+};
diff --git a/src/components/Themes/lib/privateColors/index.ts b/src/components/Themes/lib/privateColors/index.ts
new file mode 100644
index 00000000000..af32a8c8b0d
--- /dev/null
+++ b/src/components/Themes/lib/privateColors/index.ts
@@ -0,0 +1 @@
+export {generatePrivateColors} from './utils';
diff --git a/src/components/Themes/lib/privateColors/utils.ts b/src/components/Themes/lib/privateColors/utils.ts
new file mode 100644
index 00000000000..0d27b14c263
--- /dev/null
+++ b/src/components/Themes/lib/privateColors/utils.ts
@@ -0,0 +1,98 @@
+import chroma from 'chroma-js';
+
+import {themeXd} from './constants';
+
+const privateSolidVariables = [
+ 1000, 950, 900, 850, 800, 750, 700, 650, 600, 500, 450, 400, 350, 300, 250, 200, 150, 100, 50,
+];
+const privateVariables = [500, 450, 400, 350, 300, 250, 200, 150, 100, 50];
+const colorsMap = {
+ 50: {a: 0.1, c: -1},
+ 100: {a: 0.15, c: -1},
+ 150: {a: 0.2, c: -1},
+ 200: {a: 0.3, c: -1},
+ 250: {a: 0.4, c: -1},
+ 300: {a: 0.5, c: -1},
+ 350: {a: 0.6, c: -1},
+ 400: {a: 0.7, c: -1},
+ 450: {a: 0.8, c: -1},
+ 500: {a: 0.9, c: -1},
+ 550: {a: 1, c: 1},
+ 600: {a: 0.9, c: 1},
+ 650: {a: 0.8, c: 1},
+ 700: {a: 0.7, c: 1},
+ 750: {a: 0.6, c: 1},
+ 800: {a: 0.5, c: 1},
+ 850: {a: 0.4, c: 1},
+ 900: {a: 0.3, c: 1},
+ 950: {a: 0.2, c: 1},
+ 1000: {a: 0.15, c: 1},
+};
+
+type Theme = 'light' | 'dark';
+
+type GeneratePrivateColorsArgs = {
+ theme: Theme;
+ colorToken: string;
+ colorValue: string;
+ lightBg: string;
+ darkBg: string;
+};
+
+export const generatePrivateColors = ({
+ theme,
+ colorToken,
+ colorValue,
+ lightBg,
+ darkBg,
+}: GeneratePrivateColorsArgs) => {
+ const privateColors: Record = {};
+
+ if (!chroma.valid(colorValue)) {
+ throw Error('Not valid color for chroma');
+ }
+
+ let colorsMapInternal = colorsMap;
+
+ if (colorToken === 'white' || colorToken === 'black') {
+ colorsMapInternal = themeXd[theme][colorToken].colorsMap;
+ }
+
+ const pallete = Object.entries(colorsMapInternal).reduce((res, [key, {a, c}]) => {
+ const solidColor = chroma.mix(colorValue, c > 0 ? darkBg : lightBg, 1 - a, 'rgb').css();
+
+ const alphaColor = chroma(colorValue).alpha(a).css();
+
+ res[key] = [solidColor, alphaColor];
+
+ return res;
+ }, {} as Record);
+
+ let privateSolidVariablesInternal = privateSolidVariables;
+ let privateVariablesInternal = privateVariables;
+
+ if (colorToken === 'white' || colorToken === 'black') {
+ privateSolidVariablesInternal = themeXd[theme][colorToken].privateSolidVariables;
+ privateVariablesInternal = themeXd[theme][colorToken].privateVariables;
+ }
+
+ // Set 550 Solid Color
+ privateColors['550-solid'] = chroma(colorValue).css();
+
+ // Set 50-1000 Solid Colors, except 550 Solid Color
+ privateSolidVariablesInternal.forEach((varName) => {
+ privateColors[`${varName}-solid`] = chroma(pallete[varName][0]).css();
+ });
+
+ // Set 50-500 Colors
+ privateVariablesInternal.forEach((varName) => {
+ privateColors[`${varName}`] = chroma(pallete[varName][1]).css();
+ });
+
+ if (theme === 'dark' && colorToken === 'white') {
+ const updatedColor = chroma(pallete[150][0]).alpha(0.95).css();
+ privateColors['opaque-150'] = chroma(updatedColor).css();
+ }
+
+ return privateColors;
+};
diff --git a/src/components/Themes/lib/themeCreatorContext.ts b/src/components/Themes/lib/themeCreatorContext.ts
new file mode 100644
index 00000000000..41cb161d590
--- /dev/null
+++ b/src/components/Themes/lib/themeCreatorContext.ts
@@ -0,0 +1,60 @@
+import noop from 'lodash/noop';
+import {createContext} from 'react';
+
+import {BrandPreset, DEFAULT_THEME} from './constants';
+import {initThemeCreator} from './themeCreatorUtils';
+import type {
+ AddColorToThemeParams,
+ AddFontFamilyTypeParams,
+ ChangeRadiusPresetInThemeParams,
+ ChangeUtilityColorInThemeParams,
+ RenameColorInThemeParams,
+ UpdateAdvancedTypographySettingsParams,
+ UpdateColorInThemeParams,
+ UpdateCustomRadiusPresetInThemeParams,
+ UpdateFontFamilyParams,
+ UpdateFontFamilyTypeTitleParams,
+} from './themeCreatorUtils';
+import type {ThemeCreatorState} from './types';
+
+export const ThemeCreatorContext = createContext(
+ initThemeCreator(DEFAULT_THEME),
+);
+
+export interface ThemeCreatorMethodsContextType {
+ addColor: (params?: AddColorToThemeParams) => void;
+ updateColor: (params: UpdateColorInThemeParams) => void;
+ removeColor: (title: string) => void;
+ renameColor: (params: RenameColorInThemeParams) => void;
+ changeUtilityColor: (params: ChangeUtilityColorInThemeParams) => void;
+ applyBrandPreset: (preset: BrandPreset) => void;
+ changeRadiusPreset: (params: ChangeRadiusPresetInThemeParams) => void;
+ updateCustomRadiusPreset: (params: UpdateCustomRadiusPresetInThemeParams) => void;
+ updateFontFamily: (params: UpdateFontFamilyParams) => void;
+ addFontFamilyType: (params: AddFontFamilyTypeParams) => void;
+ updateFontFamilyTypeTitle: (params: UpdateFontFamilyTypeTitleParams) => void;
+ removeFontFamilyType: ({fontType}: {fontType: string}) => void;
+ updateAdvancedTypographySettings: (params: UpdateAdvancedTypographySettingsParams) => void;
+ updateAdvancedTypography: () => void;
+ openMainSettings: () => void;
+ setAdvancedMode: (enabled: boolean) => void;
+}
+
+export const ThemeCreatorMethodsContext = createContext({
+ addColor: noop,
+ updateColor: noop,
+ removeColor: noop,
+ renameColor: noop,
+ changeUtilityColor: noop,
+ applyBrandPreset: noop,
+ changeRadiusPreset: noop,
+ updateCustomRadiusPreset: noop,
+ updateFontFamily: noop,
+ addFontFamilyType: noop,
+ updateFontFamilyTypeTitle: noop,
+ removeFontFamilyType: noop,
+ updateAdvancedTypographySettings: noop,
+ updateAdvancedTypography: noop,
+ openMainSettings: noop,
+ setAdvancedMode: noop,
+});
diff --git a/src/components/Themes/lib/themeCreatorExport.ts b/src/components/Themes/lib/themeCreatorExport.ts
new file mode 100644
index 00000000000..27f3c21be1a
--- /dev/null
+++ b/src/components/Themes/lib/themeCreatorExport.ts
@@ -0,0 +1,194 @@
+import {DEFAULT_PALETTE, DEFAULT_THEME} from './constants';
+import {
+ createBorderRadiusPresetForExport,
+ createFontImportsForExport,
+ createPrivateColorCssVariable,
+ createPrivateColorCssVariableFromToken,
+ createPrivateColorToken,
+ createTypographyPresetForExport,
+ createUtilityColorCssVariable,
+ isPrivateColorToken,
+} from './themeCreatorUtils';
+import type {ColorOption, ThemeCreatorState, ThemeVariant} from './types';
+
+const COMMON_VARIABLES_TEMPLATE_NAME = '%COMMON_VARIABLES%';
+const LIGHT_THEME_VARIABLES_TEMPLATE_NAME = '%LIGHT_THEME_VARIABLES%';
+const DARK_THEME_VARIABLES_TEMPLATE_NAME = '%DARK_THEME_VARIABLES%';
+const FONTS_TEMPLATE_NAME = '%IMPORT_FONTS%';
+
+const SCSS_TEMPLATE = `
+@use '@gravity-ui/uikit/styles/themes';
+
+${FONTS_TEMPLATE_NAME}
+
+.g-root {
+ @include themes.g-theme-common;
+
+ ${COMMON_VARIABLES_TEMPLATE_NAME}
+
+ &_theme_light {
+ @include themes.g-theme-light;
+
+ ${LIGHT_THEME_VARIABLES_TEMPLATE_NAME}
+ }
+
+ &_theme_dark {
+ @include themes.g-theme-dark;
+
+ ${DARK_THEME_VARIABLES_TEMPLATE_NAME}
+ }
+}
+`.trim();
+
+export type ExportFormat = 'scss' | 'json';
+
+type ExportThemeParams = {
+ themeState: ThemeCreatorState;
+ format?: ExportFormat;
+ ignoreDefaultValues?: boolean;
+ forPreview?: boolean;
+};
+
+const isBackgroundColorChanged = (themeState: ThemeCreatorState) => {
+ return (
+ DEFAULT_THEME.colors.dark['base-background'] !==
+ themeState.colors.dark['base-background'] ||
+ DEFAULT_THEME.colors.light['base-background'] !== themeState.colors.light['base-background']
+ );
+};
+
+export function exportTheme({
+ themeState,
+ format = 'scss',
+ ignoreDefaultValues = true,
+ forPreview = true,
+}: ExportThemeParams) {
+ if (format === 'json') {
+ throw new Error('Not implemented');
+ }
+
+ const {paletteTokens, palette} = themeState;
+ const backgroundColorChanged = isBackgroundColorChanged(themeState);
+
+ const prepareThemeVariables = (themeVariant: ThemeVariant) => {
+ let cssVariables = '';
+ const privateColors: Record = {};
+
+ themeState.tokens.forEach((token) => {
+ // Dont export colors that are equals to default (except brand color)
+ // Private colors recalculate when background color changes
+ const valueEqualsToDefault =
+ DEFAULT_PALETTE[themeVariant][token] === themeState.palette[themeVariant][token] &&
+ token !== 'brand' &&
+ !backgroundColorChanged;
+
+ if (valueEqualsToDefault && ignoreDefaultValues) {
+ return;
+ }
+
+ const needExportColor =
+ backgroundColorChanged || token === 'brand' || !valueEqualsToDefault;
+
+ if (!needExportColor) {
+ return;
+ }
+
+ if (paletteTokens[token]?.privateColors[themeVariant]) {
+ Object.entries(paletteTokens[token].privateColors[themeVariant]!).forEach(
+ ([privateColorCode, color]) => {
+ privateColors[createPrivateColorToken(token, privateColorCode)] = color;
+ cssVariables += `${createPrivateColorCssVariable(
+ token,
+ privateColorCode,
+ )}: ${color}${forPreview ? ' !important' : ''};\n`;
+ },
+ );
+ cssVariables += '\n';
+ }
+ });
+
+ cssVariables += '\n';
+
+ cssVariables += `${createUtilityColorCssVariable('base-brand')}: ${
+ palette[themeVariant].brand
+ }${forPreview ? ' !important' : ''};\n`;
+
+ Object.entries(themeState.colors[themeVariant]).forEach(
+ ([colorName, colorOrPrivateToken]) => {
+ if (
+ ignoreDefaultValues &&
+ DEFAULT_THEME.colors[themeVariant][colorName as ColorOption] ===
+ colorOrPrivateToken
+ ) {
+ return;
+ }
+
+ const color = isPrivateColorToken(colorOrPrivateToken)
+ ? `var(${createPrivateColorCssVariableFromToken(colorOrPrivateToken)})`
+ : colorOrPrivateToken;
+
+ cssVariables += `${createUtilityColorCssVariable(colorName)}: ${color}${
+ forPreview ? ' !important' : ''
+ };\n`;
+ },
+ );
+
+ if (forPreview) {
+ cssVariables += createBorderRadiusPresetForExport({
+ borders: themeState.borders,
+ forPreview,
+ ignoreDefaultValues,
+ });
+
+ cssVariables += createTypographyPresetForExport({
+ typography: themeState.typography,
+ ignoreDefaultValues,
+ forPreview,
+ });
+ }
+
+ return cssVariables.trim();
+ };
+
+ const prepareCommonThemeVariables = () => {
+ const borderRadiusVariabels = createBorderRadiusPresetForExport({
+ borders: themeState.borders,
+ forPreview,
+ ignoreDefaultValues,
+ });
+
+ const typographyVariables = createTypographyPresetForExport({
+ typography: themeState.typography,
+ ignoreDefaultValues,
+ forPreview,
+ });
+
+ return borderRadiusVariabels + '\n' + typographyVariables;
+ };
+
+ return {
+ fontImports: createFontImportsForExport(themeState.typography.baseSetting.fontFamilies),
+ common: prepareCommonThemeVariables(),
+ light: prepareThemeVariables('light'),
+ dark: prepareThemeVariables('dark'),
+ };
+}
+
+type ExportThemeForDialogParams = Pick;
+
+export function exportThemeForDialog({themeState, format = 'scss'}: ExportThemeForDialogParams) {
+ if (format === 'json') {
+ return 'not implemented';
+ }
+
+ const {common, light, dark, fontImports} = exportTheme({
+ themeState,
+ format,
+ forPreview: false,
+ });
+
+ return SCSS_TEMPLATE.replace(FONTS_TEMPLATE_NAME, fontImports)
+ .replace(COMMON_VARIABLES_TEMPLATE_NAME, common.replaceAll('\n', '\n'.padEnd(5)))
+ .replace(LIGHT_THEME_VARIABLES_TEMPLATE_NAME, light.replaceAll('\n', '\n'.padEnd(9)))
+ .replace(DARK_THEME_VARIABLES_TEMPLATE_NAME, dark.replaceAll('\n', '\n'.padEnd(9)));
+}
diff --git a/src/components/Themes/lib/themeCreatorImport.ts b/src/components/Themes/lib/themeCreatorImport.ts
new file mode 100644
index 00000000000..fb5eeccd3c5
--- /dev/null
+++ b/src/components/Themes/lib/themeCreatorImport.ts
@@ -0,0 +1 @@
+// TODO implement import logic
diff --git a/src/components/Themes/lib/themeCreatorUtils.ts b/src/components/Themes/lib/themeCreatorUtils.ts
new file mode 100644
index 00000000000..532b44fb8bf
--- /dev/null
+++ b/src/components/Themes/lib/themeCreatorUtils.ts
@@ -0,0 +1,869 @@
+import {TextProps} from 'landing-uikit';
+import capitalize from 'lodash/capitalize';
+import cloneDeep from 'lodash/cloneDeep';
+import kebabCase from 'lodash/kebabCase';
+import lowerCase from 'lodash/lowerCase';
+import {v4 as uuidv4} from 'uuid';
+
+import {
+ BrandPreset,
+ DEFAULT_NEW_COLOR_TITLE,
+ DEFAULT_PALETTE_TOKENS,
+ RADIUS_PRESETS,
+ THEME_BORDER_RADIUS_VARIABLE_PREFIX,
+ THEME_COLOR_VARIABLE_PREFIX,
+} from './constants';
+import {generatePrivateColors} from './privateColors';
+import type {
+ BordersOption,
+ ColorsOptions,
+ Palette,
+ PaletteTokens,
+ PrivateColors,
+ RadiusValue,
+ ThemeCreatorState,
+ ThemeOptions,
+ ThemeVariant,
+} from './types';
+import {CustomFontSelectType, RadiusPresetName, TypographyOptions} from './types';
+import {DefaultFontFamilyType, TextVariants, defaultTypographyPreset} from './typography/constants';
+import {
+ createFontFamilyVariable,
+ createFontLinkImport,
+ createTextFontFamilyVariable,
+ createTextFontSizeVariable,
+ createTextFontWeightVariable,
+ createTextLineHeightVariable,
+ getCustomFontTypeKey,
+} from './typography/utils';
+
+function createColorToken(title: string) {
+ return kebabCase(title);
+}
+
+function createTitleFromToken(token: string) {
+ return capitalize(lowerCase(token));
+}
+
+export function createPrivateColorToken(mainColorToken: string, privateColorCode: string) {
+ return `private.${mainColorToken}.${privateColorCode}`;
+}
+
+export function isPrivateColorToken(privateColorToken?: string) {
+ if (!privateColorToken) {
+ return false;
+ }
+
+ const parts = privateColorToken.split('.');
+
+ if (parts.length !== 3 || parts[0] !== 'private') {
+ return false;
+ }
+
+ return true;
+}
+
+export function parsePrivateColorToken(privateColorToken: string) {
+ const parts = privateColorToken.split('.');
+
+ if (parts.length !== 3 || parts[0] !== 'private') {
+ return undefined;
+ }
+
+ return {
+ mainColorToken: parts[1],
+ privateColorCode: parts[2],
+ };
+}
+
+export function createPrivateColorCssVariable(mainColorToken: string, privateColorCode: string) {
+ return `${THEME_COLOR_VARIABLE_PREFIX}-private-${mainColorToken}-${privateColorCode}`;
+}
+
+export function createPrivateColorCssVariableFromToken(privateColorToken: string) {
+ const result = parsePrivateColorToken(privateColorToken);
+
+ if (result) {
+ return createPrivateColorCssVariable(result.mainColorToken, result.privateColorCode);
+ }
+
+ return '';
+}
+
+export function createUtilityColorCssVariable(colorName: string) {
+ return `${THEME_COLOR_VARIABLE_PREFIX}-${colorName}`;
+}
+
+function isManuallyCreatedToken(token: string) {
+ return !DEFAULT_PALETTE_TOKENS.has(token);
+}
+
+function createNewColorTitle(currentPaletteTokens: PaletteTokens) {
+ let i = 0;
+
+ // eslint-disable-next-line no-constant-condition
+ while (true) {
+ const title = i === 0 ? DEFAULT_NEW_COLOR_TITLE : `${DEFAULT_NEW_COLOR_TITLE} ${i}`;
+ const token = createColorToken(title);
+
+ if (!currentPaletteTokens[token]) {
+ return title;
+ }
+
+ i++;
+ }
+}
+
+function createPrivateColors({
+ themeVariant,
+ colorToken,
+ colorValue,
+ theme,
+}: {
+ colorToken: string;
+ colorValue: string;
+ themeVariant: ThemeVariant;
+ theme: ThemeOptions;
+}): PrivateColors {
+ return generatePrivateColors({
+ theme: themeVariant,
+ colorToken,
+ colorValue,
+ lightBg:
+ themeVariant === 'light'
+ ? theme.colors.light['base-background']
+ : theme.colors.dark['base-background'],
+ darkBg:
+ themeVariant === 'light'
+ ? theme.colors.dark['base-background']
+ : theme.colors.light['base-background'],
+ });
+}
+
+function createPalleteTokens(theme: ThemeOptions): PaletteTokens {
+ const {palette} = theme;
+ const tokens = Object.keys(palette.light);
+
+ return tokens.reduce(
+ (acc, token) => ({
+ ...acc,
+ [token]: {
+ title: createTitleFromToken(token),
+ privateColors: {
+ light: palette.light[token]
+ ? createPrivateColors({
+ colorToken: token,
+ colorValue: palette.light[token],
+ theme,
+ themeVariant: 'light',
+ })
+ : undefined,
+ dark: palette.dark[token]
+ ? createPrivateColors({
+ colorToken: token,
+ colorValue: palette.dark[token],
+ theme,
+ themeVariant: 'dark',
+ })
+ : undefined,
+ },
+ },
+ }),
+ {},
+ );
+}
+
+export type UpdateColorInThemeParams = {
+ /** The title of the color to update. */
+ title: string;
+ /** The theme variant to update. */
+ theme: ThemeVariant;
+ /** The new value of the color. */
+ value: string;
+};
+
+/**
+ * Updates a color in the given theme state.
+ *
+ * @param {ThemeCreatorState} themeState - The current state of the theme.
+ * @param {UpdateColorInThemeParams} params - The parameters for the color update.
+ * @returns {ThemeCreatorState} The updated theme state.
+ */
+export function updateColorInTheme(
+ themeState: ThemeCreatorState,
+ params: UpdateColorInThemeParams,
+): ThemeCreatorState {
+ const newThemeState = {...themeState};
+ const token = createColorToken(params.title);
+
+ if (params.theme === 'light') {
+ if (!newThemeState.palette.light[token]) {
+ newThemeState.palette.light[token] = '';
+ }
+
+ newThemeState.palette.light[token] = params.value;
+ }
+
+ if (params.theme === 'dark') {
+ if (!newThemeState.palette.dark[token]) {
+ newThemeState.palette.dark[token] = '';
+ }
+
+ newThemeState.palette.dark[token] = params.value;
+ }
+
+ const privateColors = createPrivateColors({
+ colorToken: token,
+ colorValue: params.value,
+ theme: newThemeState,
+ themeVariant: params.theme,
+ });
+
+ newThemeState.paletteTokens[token] = {
+ ...newThemeState.paletteTokens[token],
+ title: params.title,
+ privateColors: {
+ light:
+ params.theme === 'light'
+ ? privateColors
+ : newThemeState.paletteTokens[token]?.privateColors?.light,
+ dark:
+ params.theme === 'dark'
+ ? privateColors
+ : newThemeState.paletteTokens[token]?.privateColors?.dark,
+ },
+ };
+
+ const isNewToken = !themeState.paletteTokens[token];
+ if (isNewToken) {
+ newThemeState.tokens.push(token);
+ }
+
+ return newThemeState;
+}
+
+export type AddColorToThemeParams =
+ | {
+ title?: string;
+ colors?: Partial>;
+ }
+ | undefined;
+
+/**
+ * Adds a new color to the given theme state.
+ *
+ * @param {ThemeCreatorState} themeState - The current state of the theme.
+ * @param {AddColorToThemeParams} params - The parameters of the adding color.
+ * @returns {ThemeCreatorState} The updated theme state with the new color added.
+ */
+export function addColorToTheme(
+ themeState: ThemeCreatorState,
+ params: AddColorToThemeParams,
+): ThemeCreatorState {
+ const newThemeState = {...themeState};
+ const title = params?.title ?? createNewColorTitle(themeState.paletteTokens);
+ const token = createColorToken(title);
+
+ if (!themeState.palette.dark[token]) {
+ newThemeState.palette.dark = {
+ ...newThemeState.palette.dark,
+ [token]: '',
+ };
+ }
+
+ if (!themeState.palette.light[token]) {
+ newThemeState.palette.light = {
+ ...newThemeState.palette.light,
+ [token]: '',
+ };
+ }
+
+ if (params?.colors?.dark) {
+ newThemeState.palette.dark = {
+ ...newThemeState.palette.dark,
+ [token]: params.colors.dark,
+ };
+ }
+
+ if (params?.colors?.light) {
+ newThemeState.palette.light = {
+ ...newThemeState.palette.light,
+ [token]: params.colors.light,
+ };
+ }
+
+ newThemeState.paletteTokens = {
+ ...newThemeState.paletteTokens,
+ [token]: {
+ ...newThemeState.paletteTokens[token],
+ title,
+ privateColors: {
+ light: params?.colors?.light
+ ? createPrivateColors({
+ colorToken: token,
+ colorValue: params.colors.light,
+ theme: newThemeState,
+ themeVariant: 'light',
+ })
+ : undefined,
+ dark: params?.colors?.dark
+ ? createPrivateColors({
+ colorToken: token,
+ colorValue: params.colors.dark,
+ theme: newThemeState,
+ themeVariant: 'dark',
+ })
+ : undefined,
+ },
+ isCustom: true,
+ },
+ };
+
+ newThemeState.tokens = [...newThemeState.tokens, token];
+
+ return newThemeState;
+}
+
+export function removeColorFromTheme(
+ themeState: ThemeCreatorState,
+ colorTitle: string,
+): ThemeCreatorState {
+ const newThemeState = {...themeState};
+ const token = createColorToken(colorTitle);
+
+ delete newThemeState.palette.dark[token];
+ delete newThemeState.palette.light[token];
+ delete newThemeState.paletteTokens[token];
+
+ newThemeState.tokens = newThemeState.tokens.filter((t) => t !== token);
+
+ return newThemeState;
+}
+
+export type RenameColorInThemeParams = {
+ oldTitle: string;
+ newTitle: string;
+};
+
+export function renameColorInTheme(
+ themeState: ThemeCreatorState,
+ {oldTitle, newTitle}: RenameColorInThemeParams,
+): ThemeCreatorState {
+ const newThemeState = {...themeState};
+ const oldToken = createColorToken(oldTitle);
+ const newToken = createColorToken(newTitle);
+
+ if (newThemeState.paletteTokens[oldToken]) {
+ newThemeState.paletteTokens[newToken] = {
+ ...newThemeState.paletteTokens[oldToken],
+ title: newTitle,
+ };
+ newThemeState.palette.dark[newToken] = newThemeState.palette.dark[oldToken];
+ newThemeState.palette.light[newToken] = newThemeState.palette.light[oldToken];
+ }
+
+ newThemeState.tokens = newThemeState.tokens.map((token) =>
+ token === oldToken ? newToken : token,
+ );
+
+ delete newThemeState.palette.dark[oldToken];
+ delete newThemeState.palette.light[oldToken];
+ delete newThemeState.paletteTokens[oldToken];
+
+ return newThemeState;
+}
+
+export type ThemeColorOption = {
+ token: string;
+ title: string;
+ color: string;
+ privateColors: {
+ token: string;
+ title: string;
+ color: string;
+ }[];
+};
+
+/**
+ * Generates theme color options from the given palette tokens and theme variant.
+ *
+ * @param {Object} params - The parameters for generating theme color options.
+ * @param {PaletteTokens} params.paletteTokens - The palette tokens to generate options from.
+ * @param {ThemeVariant} params.themeVariant - The theme variant to filter private colors (light, dark).
+ * @returns {ThemeColorOption[]} The generated theme color options.
+ */
+export function getThemeColorOptions({
+ themeState,
+ themeVariant,
+}: {
+ themeState: ThemeCreatorState;
+ themeVariant: ThemeVariant;
+}) {
+ const {tokens, paletteTokens, palette} = themeState;
+
+ return tokens.reduce((acc, token) => {
+ if (paletteTokens[token]?.privateColors[themeVariant]) {
+ return [
+ ...acc,
+ {
+ token,
+ color: palette[themeVariant][token],
+ title: paletteTokens[token].title,
+ privateColors: Object.entries(
+ paletteTokens[token].privateColors[themeVariant]!,
+ ).map(([privateColorCode, color]) => ({
+ token: createPrivateColorToken(token, privateColorCode),
+ title: createPrivateColorCssVariable(token, privateColorCode),
+ color,
+ })),
+ },
+ ];
+ }
+
+ return acc;
+ }, []);
+}
+
+export type ChangeUtilityColorInThemeParams = {
+ themeVariant: ThemeVariant;
+ name: keyof ColorsOptions;
+ value: string;
+};
+
+export function changeUtilityColorInTheme(
+ themeState: ThemeCreatorState,
+ {themeVariant, name, value}: ChangeUtilityColorInThemeParams,
+): ThemeCreatorState {
+ const newState = {...themeState};
+ newState.colors[themeVariant][name] = value;
+
+ if (name === 'base-background') {
+ newState.paletteTokens = createPalleteTokens(newState);
+ }
+
+ return newState;
+}
+
+export function applyBrandPresetToTheme(
+ themeState: ThemeCreatorState,
+ {brandColor, colors}: BrandPreset,
+): ThemeCreatorState {
+ let newState = {...themeState};
+
+ (['light', 'dark'] as const).forEach((theme) => {
+ newState = updateColorInTheme(newState, {
+ theme,
+ title: 'brand',
+ value: brandColor,
+ });
+ });
+
+ newState.colors.light = {...colors.light};
+ newState.colors.dark = {...colors.dark};
+
+ return newState;
+}
+
+export function getThemePalette(theme: ThemeCreatorState): Palette {
+ return theme.tokens.map((token) => {
+ return {
+ title: theme.paletteTokens[token]?.title || '',
+ colors: {
+ light: theme.palette.light[token],
+ dark: theme.palette.dark[token],
+ },
+ isCustom: isManuallyCreatedToken(token),
+ };
+ });
+}
+
+export function initThemeCreator(inputTheme: ThemeOptions): ThemeCreatorState {
+ const theme = cloneDeep(inputTheme);
+ const paletteTokens = createPalleteTokens(theme);
+
+ return {
+ ...theme,
+ paletteTokens,
+ tokens: Object.keys(paletteTokens),
+ showMainSettings: false,
+ advancedModeEnabled: false,
+ changesExist: false,
+ };
+}
+
+export type ChangeRadiusPresetInThemeParams = {
+ radiusPresetName: RadiusPresetName;
+};
+
+export function changeRadiusPresetInTheme(
+ themeState: ThemeCreatorState,
+ {radiusPresetName}: ChangeRadiusPresetInThemeParams,
+): ThemeCreatorState {
+ const newBorderValue = {
+ preset: radiusPresetName,
+ values: {...RADIUS_PRESETS[radiusPresetName]},
+ };
+
+ return {...themeState, borders: newBorderValue};
+}
+
+export type UpdateCustomRadiusPresetInThemeParams = {radiusValue: Partial};
+
+export function updateCustomRadiusPresetInTheme(
+ themeState: ThemeCreatorState,
+ {radiusValue}: UpdateCustomRadiusPresetInThemeParams,
+): ThemeCreatorState {
+ const previousRadiusValues = themeState.borders.values;
+ const newCustomPresetValues = {
+ preset: RadiusPresetName.Custom,
+ values: {...previousRadiusValues, ...radiusValue},
+ };
+
+ return {...themeState, borders: newCustomPresetValues};
+}
+
+function createBorderRadiusCssVariable(radiusSize: string) {
+ return `${THEME_BORDER_RADIUS_VARIABLE_PREFIX}-${radiusSize}`;
+}
+
+/**
+ * Generates ready-to-use in css string with borders variables
+ * @returns string
+ */
+export function createBorderRadiusPresetForExport({
+ borders,
+ forPreview,
+ ignoreDefaultValues,
+}: {
+ borders: BordersOption;
+ ignoreDefaultValues: boolean;
+ forPreview: boolean;
+}) {
+ // Don't export radius preset that are equals to default
+ if (ignoreDefaultValues && borders.preset === RadiusPresetName.Regular) {
+ return '';
+ }
+ let cssString = '';
+ Object.entries(borders.values).forEach(([radiusName, radiusValue]) => {
+ if (radiusValue) {
+ cssString += `${createBorderRadiusCssVariable(radiusName)}: ${radiusValue}px${
+ forPreview ? ' !important' : ''
+ };\n`;
+ }
+ });
+ return cssString;
+}
+
+export type UpdateFontFamilyParams = {
+ fontType: DefaultFontFamilyType | string;
+ fontWebsite?: string;
+ isCustom?: boolean;
+ customType?: string;
+ value?: {
+ title: string;
+ key: string;
+ link: string;
+ alternatives: string[];
+ };
+};
+
+export function updateFontFamilyInTheme(
+ themeState: ThemeCreatorState,
+ {fontType, value, isCustom, fontWebsite, customType}: UpdateFontFamilyParams,
+): ThemeCreatorState {
+ const previousFontFamilySettings = themeState.typography.baseSetting.fontFamilies;
+
+ const newFontFamilySettings = {
+ ...previousFontFamilySettings,
+ [fontType]: {
+ ...previousFontFamilySettings[fontType],
+ ...(value || {}),
+ isCustom,
+ customType: customType || previousFontFamilySettings[fontType].customType,
+ fontWebsite,
+ },
+ };
+
+ return {
+ ...themeState,
+ typography: {
+ ...themeState.typography,
+ baseSetting: {
+ ...themeState.typography.baseSetting,
+ fontFamilies: newFontFamilySettings,
+ },
+ },
+ };
+}
+
+export type AddFontFamilyTypeParams = {
+ title: string;
+};
+
+export function addFontFamilyTypeInTheme(
+ themeState: ThemeCreatorState,
+ {title}: AddFontFamilyTypeParams,
+): ThemeCreatorState {
+ const {customFontFamilyType} = themeState.typography.baseSetting;
+ const newFontType = `custom-font-type-${uuidv4()}`;
+
+ const newCustomFontFamily = [
+ ...customFontFamilyType,
+ {
+ value: newFontType,
+ content: title,
+ },
+ ];
+
+ return {
+ ...themeState,
+ typography: {
+ ...themeState.typography,
+ baseSetting: {
+ ...themeState.typography.baseSetting,
+ fontFamilies: {
+ ...themeState.typography.baseSetting.fontFamilies,
+ [newFontType]: {
+ isCustom: true,
+ customType: CustomFontSelectType.GoogleFonts,
+ title: '',
+ key: '',
+ link: '',
+ alternatives: [],
+ },
+ },
+ customFontFamilyType: newCustomFontFamily,
+ },
+ },
+ };
+}
+
+export type UpdateFontFamilyTypeTitleParams = {
+ title: string;
+ familyType: string;
+};
+
+export function updateFontFamilyTypeTitleInTheme(
+ themeState: ThemeCreatorState,
+ {title, familyType}: UpdateFontFamilyTypeTitleParams,
+): ThemeCreatorState {
+ const {customFontFamilyType} = themeState.typography.baseSetting;
+
+ const newCustomFontFamily = customFontFamilyType.map((fontFamilyType) => {
+ return fontFamilyType.value === familyType
+ ? {
+ content: title,
+ value: familyType,
+ }
+ : fontFamilyType;
+ });
+
+ return {
+ ...themeState,
+ typography: {
+ ...themeState.typography,
+ baseSetting: {
+ ...themeState.typography.baseSetting,
+ customFontFamilyType: newCustomFontFamily,
+ },
+ },
+ };
+}
+
+export function removeFontFamilyTypeFromTheme(
+ themeState: ThemeCreatorState,
+ {fontType}: {fontType: string},
+): ThemeCreatorState {
+ const {customFontFamilyType, fontFamilies} = themeState.typography.baseSetting;
+
+ const {[fontType]: _, ...restFontFamilies} = fontFamilies;
+
+ const newCustomFontFamilyType = customFontFamilyType.filter(
+ (fontFamily) => fontFamily.value !== fontType,
+ );
+
+ const newAdvanced = cloneDeep(themeState.typography.advanced);
+
+ // Reset selected font to default
+ Object.entries(newAdvanced).forEach(([textVariant, settings]) => {
+ if (settings.selectedFontFamilyType === fontType) {
+ newAdvanced[textVariant as TextVariants].selectedFontFamilyType =
+ defaultTypographyPreset.advanced[
+ textVariant as TextVariants
+ ].selectedFontFamilyType;
+ }
+ });
+
+ return {
+ ...themeState,
+ typography: {
+ ...themeState.typography,
+ advanced: newAdvanced,
+ baseSetting: {
+ ...themeState.typography.baseSetting,
+ fontFamilies: restFontFamilies,
+ customFontFamilyType: newCustomFontFamilyType,
+ },
+ },
+ };
+}
+
+export type UpdateAdvancedTypographySettingsParams = {
+ key: TextVariants;
+ fontWeight?: number;
+ selectedFontFamilyType?: string;
+ sizeKey?: Exclude;
+ fontSize?: number;
+ lineHeight?: number;
+};
+
+export function updateAdvancedTypographySettingsInTheme(
+ themeState: ThemeCreatorState,
+ {
+ key,
+ fontSize,
+ selectedFontFamilyType,
+ sizeKey,
+ fontWeight,
+ lineHeight,
+ }: UpdateAdvancedTypographySettingsParams,
+): ThemeCreatorState {
+ const previousTypographyAdvancedSettings = themeState.typography.advanced;
+
+ const newSizes = sizeKey
+ ? {
+ [sizeKey]: {
+ ...previousTypographyAdvancedSettings[key].sizes[sizeKey],
+ fontSize:
+ fontSize ?? previousTypographyAdvancedSettings[key].sizes[sizeKey]?.fontSize,
+ lineHeight:
+ lineHeight ??
+ previousTypographyAdvancedSettings[key].sizes[sizeKey]?.lineHeight,
+ },
+ }
+ : {};
+
+ const newTypographyAdvancedSettings = {
+ ...previousTypographyAdvancedSettings,
+ [key]: {
+ ...previousTypographyAdvancedSettings[key],
+ fontWeight: fontWeight ?? previousTypographyAdvancedSettings[key].fontWeight,
+ selectedFontFamilyType:
+ selectedFontFamilyType ??
+ previousTypographyAdvancedSettings[key].selectedFontFamilyType,
+ sizes: {
+ ...previousTypographyAdvancedSettings[key].sizes,
+ ...newSizes,
+ },
+ },
+ };
+
+ return {
+ ...themeState,
+ typography: {
+ ...themeState.typography,
+ advanced: {
+ ...newTypographyAdvancedSettings,
+ },
+ },
+ };
+}
+
+export const updateAdvancedTypographyInTheme = (
+ themeState: ThemeCreatorState,
+): ThemeCreatorState => {
+ return {
+ ...themeState,
+ typography: {
+ ...themeState.typography,
+ isAdvancedActive: !themeState.typography.isAdvancedActive,
+ },
+ };
+};
+
+export const createFontImportsForExport = (
+ fontFamily: TypographyOptions['baseSetting']['fontFamilies'],
+) => {
+ let cssString = '';
+
+ Object.entries(fontFamily).forEach(([, value]) => {
+ cssString += `${createFontLinkImport(value.link)}\n`;
+ });
+
+ return cssString;
+};
+
+export const createTypographyPresetForExport = ({
+ typography,
+ forPreview,
+}: {
+ typography: TypographyOptions;
+ ignoreDefaultValues: boolean;
+ forPreview: boolean;
+}) => {
+ const {baseSetting, advanced} = typography;
+ let cssString = '';
+
+ Object.entries(baseSetting.fontFamilies).forEach(([key, value]) => {
+ const customFontKey = getCustomFontTypeKey(key, baseSetting.customFontFamilyType);
+
+ cssString += `${createFontFamilyVariable(
+ customFontKey ? kebabCase(customFontKey) : key,
+ value.title,
+ value.alternatives,
+ forPreview,
+ )}\n`;
+ });
+
+ Object.entries(advanced).forEach(([key, data]) => {
+ const defaultAdvancedSetting = defaultTypographyPreset.advanced[key as TextVariants];
+
+ if (defaultAdvancedSetting.selectedFontFamilyType !== data.selectedFontFamilyType) {
+ const customFontTypeKey = getCustomFontTypeKey(
+ data.selectedFontFamilyType,
+ baseSetting.customFontFamilyType,
+ );
+
+ cssString += `${createTextFontFamilyVariable(
+ key as TextVariants,
+ customFontTypeKey ? kebabCase(customFontTypeKey) : data.selectedFontFamilyType,
+ forPreview,
+ )}\n`;
+ }
+ if (defaultAdvancedSetting.fontWeight !== data.fontWeight) {
+ cssString += `${createTextFontWeightVariable(
+ key as TextVariants,
+ data.fontWeight,
+ forPreview,
+ )}\n`;
+ cssString += '\n';
+ }
+
+ Object.entries(data.sizes).forEach(([sizeKey, sizeData]) => {
+ if (
+ defaultAdvancedSetting.sizes[sizeKey as Exclude]
+ ?.fontSize !== sizeData.fontSize
+ ) {
+ cssString += `${createTextFontSizeVariable(
+ sizeKey as TextProps['variant'],
+ sizeData.fontSize,
+ forPreview,
+ )}\n`;
+ }
+
+ if (
+ defaultAdvancedSetting.sizes[sizeKey as Exclude]
+ ?.lineHeight !== sizeData.lineHeight
+ ) {
+ cssString += `${createTextLineHeightVariable(
+ sizeKey as TextProps['variant'],
+ sizeData.lineHeight,
+ forPreview,
+ )}\n`;
+ cssString += '\n';
+ }
+ });
+ });
+
+ return cssString;
+};
diff --git a/src/components/Themes/lib/types.ts b/src/components/Themes/lib/types.ts
new file mode 100644
index 00000000000..8a7cc1662b4
--- /dev/null
+++ b/src/components/Themes/lib/types.ts
@@ -0,0 +1,130 @@
+import {TextProps} from 'landing-uikit';
+
+import {DefaultFontFamilyType, TextVariants} from './typography/constants';
+
+export type ThemeVariant = 'light' | 'dark';
+
+export type PaletteOptions = {
+ brand: string;
+ [key: string]: string;
+};
+
+export type ColorsOptions = {
+ 'base-background': string;
+ 'base-brand-hover': string;
+ 'base-selection': string;
+ 'base-selection-hover': string;
+ 'line-brand': string;
+ 'text-brand': string;
+ 'text-brand-heavy': string;
+ 'text-brand-contrast': string;
+ 'text-link': string;
+ 'text-link-hover': string;
+ 'text-link-visited': string;
+ 'text-link-visited-hover': string;
+};
+
+export type ColorOption = keyof ColorsOptions;
+
+export type RadiusSizeName = 'xs' | 's' | 'm' | 'l' | 'xl';
+
+export enum RadiusPresetName {
+ Regular = 'radius_regular',
+ Circled = 'radius_circled',
+ Squared = 'radius_squared',
+ Custom = 'radius_custom',
+}
+
+export type RadiusValue = Record;
+
+export type BordersOption = {
+ preset: RadiusPresetName;
+ values: RadiusValue;
+};
+
+export enum CustomFontSelectType {
+ GoogleFonts = 'google-fonts',
+ Manual = 'manual',
+}
+
+export type TypographyOptions = {
+ baseSetting: {
+ defaultFontFamilyType: {
+ value: DefaultFontFamilyType;
+ content: string;
+ }[];
+ customFontFamilyType: {
+ value: string;
+ content: string;
+ }[];
+ fontFamilies: Record<
+ string,
+ {
+ title: string;
+ key: string;
+ link: string;
+ alternatives: string[];
+ isCustom?: boolean;
+ customType?: string;
+ fontWebsite?: string;
+ }
+ >;
+ };
+ isAdvancedActive: boolean;
+ advanced: Record<
+ TextVariants,
+ {
+ title: string;
+ fontWeight: number;
+ selectedFontFamilyType: DefaultFontFamilyType;
+ sizes: Partial<
+ Record<
+ Exclude,
+ {
+ title: string;
+ fontSize: number;
+ lineHeight: number;
+ }
+ >
+ >;
+ }
+ >;
+};
+
+export interface ThemeOptions {
+ /** Values of solid colors, from which private colors are calculated */
+ palette: Record;
+ /** Utility colors that used in components (background, link, brand-text, etc.) */
+ colors: Record;
+ borders: BordersOption;
+ typography: TypographyOptions;
+}
+
+export type PrivateColors = Record;
+
+type PaletteToken = {
+ /** Title that will using in UI */
+ title: string;
+ /** Is color manually created */
+ isCustom?: boolean;
+ /** Auto-generated private colors for each theme variant */
+ privateColors: Record;
+};
+
+export type PaletteTokens = Record;
+
+export interface ThemeCreatorState extends ThemeOptions {
+ /** Mapping color tokens to their information (title and private colors) */
+ paletteTokens: PaletteTokens;
+ /** All available palette tokens in theme */
+ tokens: string[];
+ showMainSettings: boolean;
+ advancedModeEnabled: boolean;
+ changesExist: boolean;
+}
+
+export type Palette = {
+ title: string;
+ isCustom?: boolean;
+ colors: Record;
+}[];
diff --git a/src/components/Themes/lib/typography/constants.ts b/src/components/Themes/lib/typography/constants.ts
new file mode 100644
index 00000000000..0a7dc8f7b7c
--- /dev/null
+++ b/src/components/Themes/lib/typography/constants.ts
@@ -0,0 +1,214 @@
+import {CustomFontSelectType, TypographyOptions} from '../types';
+
+export const THEME_FONT_FAMILY_PREFIX = '--g-font-family';
+export const THEME_TEXT_PREFIX = '--g-text';
+
+export enum DefaultFontFamilyType {
+ Sans = 'sans',
+ Monospace = 'monospace',
+}
+
+export enum TextVariants {
+ Body = 'body',
+ Caption = 'caption',
+ Header = 'header',
+ Subheader = 'subheader',
+ Display = 'display',
+ Code = 'code',
+}
+
+export const FONT_WEIGHTS = [100, 200, 300, 400, 500, 600, 700, 800, 900];
+
+export const GOOGLE_FONTS_DOWNLOAD_HOST = 'https://fonts.googleapis.com/css2';
+export const GOOGLE_FONTS_FONT_PREVIEW_HOST = 'https://fonts.google.com/specimen/';
+
+export const DEFAULT_FONTS: Record = {
+ sans: ["'Helvetica Neue'", "'Helvetica'", "'Arial'", 'sans-serif'],
+ monospace: [
+ "'Menlo'",
+ "'Monaco'",
+ "'Consolas'",
+ "'Ubuntu Mono'",
+ "'Liberation Mono'",
+ "'DejaVu Sans Mono'",
+ "'Courier New'",
+ "'Courier'",
+ 'monospace',
+ ],
+};
+
+export const defaultTypographyPreset: TypographyOptions = {
+ baseSetting: {
+ customFontFamilyType: [],
+ defaultFontFamilyType: [
+ {value: DefaultFontFamilyType.Sans, content: 'Sans Font Family'},
+ {value: DefaultFontFamilyType.Monospace, content: 'Monospace Font Family'},
+ ],
+ fontFamilies: {
+ [DefaultFontFamilyType.Sans]: {
+ title: 'Inter',
+ key: 'inter',
+ link: 'https://fonts.googleapis.com/css2?family=Inter:wght@400;600&display=swap',
+ customType: CustomFontSelectType.GoogleFonts,
+ alternatives: DEFAULT_FONTS[DefaultFontFamilyType.Sans],
+ },
+ [DefaultFontFamilyType.Monospace]: {
+ title: 'Roboto Mono',
+ key: 'roboto_mono',
+ link: 'https://fonts.googleapis.com/css2?family=Roboto+Mono:ital,wght@0,100..700;1,100..700&display=swap',
+ customType: CustomFontSelectType.GoogleFonts,
+ alternatives: DEFAULT_FONTS[DefaultFontFamilyType.Monospace],
+ },
+ },
+ },
+ isAdvancedActive: false,
+ advanced: {
+ [TextVariants.Body]: {
+ title: 'Body Text',
+ fontWeight: 400,
+ selectedFontFamilyType: DefaultFontFamilyType.Sans,
+ sizes: {
+ 'body-short': {
+ title: 'Body 1 Short',
+ fontSize: 13,
+ lineHeight: 16,
+ },
+ 'body-1': {
+ title: 'Body 1',
+ fontSize: 13,
+ lineHeight: 18,
+ },
+ 'body-2': {
+ title: 'Body 2',
+ fontSize: 15,
+ lineHeight: 20,
+ },
+ 'body-3': {
+ title: 'Body 3',
+ fontSize: 17,
+ lineHeight: 24,
+ },
+ },
+ },
+ [TextVariants.Caption]: {
+ title: 'Caption',
+ fontWeight: 400,
+ selectedFontFamilyType: DefaultFontFamilyType.Sans,
+ sizes: {
+ 'caption-1': {
+ title: 'Caption 1',
+ fontSize: 9,
+ lineHeight: 12,
+ },
+ 'caption-2': {
+ title: 'Caption 2',
+ fontSize: 11,
+ lineHeight: 16,
+ },
+ },
+ },
+ [TextVariants.Header]: {
+ title: 'Header',
+ fontWeight: 600,
+ selectedFontFamilyType: DefaultFontFamilyType.Sans,
+ sizes: {
+ 'header-1': {
+ title: 'Header 1',
+ fontSize: 20,
+ lineHeight: 24,
+ },
+ 'header-2': {
+ title: 'Header 2',
+ fontSize: 24,
+ lineHeight: 28,
+ },
+ },
+ },
+ [TextVariants.Subheader]: {
+ title: 'Subheader',
+ fontWeight: 600,
+ selectedFontFamilyType: DefaultFontFamilyType.Sans,
+ sizes: {
+ 'subheader-1': {
+ title: 'Subheader 1',
+ fontSize: 13,
+ lineHeight: 18,
+ },
+ 'subheader-2': {
+ title: 'Subheader 2',
+ fontSize: 15,
+ lineHeight: 20,
+ },
+ 'subheader-3': {
+ title: 'Subheader 3',
+ fontSize: 17,
+ lineHeight: 24,
+ },
+ },
+ },
+ [TextVariants.Display]: {
+ title: 'Display',
+ fontWeight: 600,
+ selectedFontFamilyType: DefaultFontFamilyType.Sans,
+ sizes: {
+ 'display-1': {
+ title: 'Display 1',
+ fontSize: 28,
+ lineHeight: 36,
+ },
+ 'display-2': {
+ title: 'Display 2',
+ fontSize: 32,
+ lineHeight: 40,
+ },
+ 'display-3': {
+ title: 'Display 3',
+ fontSize: 40,
+ lineHeight: 48,
+ },
+ 'display-4': {
+ title: 'Display 4',
+ fontSize: 48,
+ lineHeight: 52,
+ },
+ },
+ },
+ [TextVariants.Code]: {
+ title: 'Code',
+ fontWeight: 600,
+ selectedFontFamilyType: DefaultFontFamilyType.Monospace,
+ sizes: {
+ 'code-1': {
+ title: 'Code 1',
+ fontSize: 12,
+ lineHeight: 18,
+ },
+ 'code-inline-1': {
+ title: 'Code Inline 1',
+ fontSize: 12,
+ lineHeight: 14,
+ },
+ 'code-2': {
+ title: 'Code 2',
+ fontSize: 14,
+ lineHeight: 20,
+ },
+ 'code-inline-2': {
+ title: 'Code Inline 2',
+ fontSize: 14,
+ lineHeight: 16,
+ },
+ 'code-3': {
+ title: 'Code 3',
+ fontSize: 16,
+ lineHeight: 24,
+ },
+ 'code-inline-3': {
+ title: 'Code Inline 3',
+ fontSize: 16,
+ lineHeight: 20,
+ },
+ },
+ },
+ },
+};
diff --git a/src/components/Themes/lib/typography/utils.ts b/src/components/Themes/lib/typography/utils.ts
new file mode 100644
index 00000000000..bd06d307525
--- /dev/null
+++ b/src/components/Themes/lib/typography/utils.ts
@@ -0,0 +1,80 @@
+import {TextProps} from 'landing-uikit';
+
+import {TypographyOptions} from '../types';
+
+import {
+ GOOGLE_FONTS_DOWNLOAD_HOST,
+ THEME_FONT_FAMILY_PREFIX,
+ THEME_TEXT_PREFIX,
+ TextVariants,
+} from './constants';
+
+export const createFontLinkImport = (fontLink: string) => {
+ return `@import url('${fontLink}');`;
+};
+
+export const createFontFamilyVariable = (
+ fontFamilyType: string,
+ value: string,
+ alternatives: string[],
+ forPreview: boolean,
+) => {
+ return `${THEME_FONT_FAMILY_PREFIX}-${fontFamilyType}: '${value}'${
+ alternatives.length ? `, ${alternatives.join(', ')}` : ''
+ }${forPreview ? '!important' : ''};`;
+};
+
+export const createTextFontWeightVariable = (
+ textVariant: TextVariants,
+ value: number,
+ forPreview: boolean,
+) => {
+ return `${THEME_TEXT_PREFIX}-${textVariant}-font-weight: ${value}${
+ forPreview ? '!important' : ''
+ };`;
+};
+
+export const createTextFontFamilyVariable = (
+ textVariant: TextVariants,
+ value: string,
+ forPreview: boolean,
+) => {
+ return `${THEME_TEXT_PREFIX}-${textVariant}-font-family: var(${THEME_FONT_FAMILY_PREFIX}-${value})${
+ forPreview ? '!important' : ''
+ };`;
+};
+
+export const createTextFontSizeVariable = (
+ variant: TextProps['variant'],
+ value: number,
+ forPreview: boolean,
+) => {
+ return `${THEME_TEXT_PREFIX}-${variant}-font-size: ${value}px${
+ forPreview ? '!important' : ''
+ };`;
+};
+
+export const createTextLineHeightVariable = (
+ variant: TextProps['variant'],
+ value: number,
+ forPreview: boolean,
+) => {
+ return `${THEME_TEXT_PREFIX}-${variant}-line-height: ${value}px${
+ forPreview ? '!important' : ''
+ };`;
+};
+
+export const generateGoogleFontDownloadLink = (fontName?: string) => {
+ if (!fontName) {
+ return '';
+ }
+
+ return `${GOOGLE_FONTS_DOWNLOAD_HOST}?family=${fontName}&display=swap`;
+};
+
+export const getCustomFontTypeKey = (
+ key: string,
+ customFontFamilyType: TypographyOptions['baseSetting']['customFontFamilyType'],
+) => {
+ return customFontFamilyType.find((setting) => setting.value === key)?.content.toLowerCase();
+};
diff --git a/src/components/Themes/ui/BasicPalette/AddColorButton.scss b/src/components/Themes/ui/BasicPalette/AddColorButton.scss
new file mode 100644
index 00000000000..f9dd80f30b3
--- /dev/null
+++ b/src/components/Themes/ui/BasicPalette/AddColorButton.scss
@@ -0,0 +1,14 @@
+@use '../../../../variables.scss';
+@use '~@gravity-ui/page-constructor/styles/variables.scss' as pcVariables;
+
+$block: '.#{variables.$ns}theme-palette-add-color-button';
+
+#{$block} {
+ --g-button-border-radius: var(--g-spacing-2);
+ margin-top: var(--g-spacing-4);
+ width: min-content;
+
+ @media (max-width: map-get(pcVariables.$gridBreakpoints, 'sm')) {
+ width: 100%;
+ }
+}
diff --git a/src/components/Themes/ui/BasicPalette/AddColorButton.tsx b/src/components/Themes/ui/BasicPalette/AddColorButton.tsx
new file mode 100644
index 00000000000..d85752b7838
--- /dev/null
+++ b/src/components/Themes/ui/BasicPalette/AddColorButton.tsx
@@ -0,0 +1,24 @@
+import {Plus} from 'landing-icons';
+import {Button, Icon} from 'landing-uikit';
+import {useTranslation} from 'next-i18next';
+import React from 'react';
+
+import {block} from '../../../../utils';
+
+import './AddColorButton.scss';
+
+const b = block('theme-palette-add-color-button');
+
+interface AddColorButtonProps {
+ onClick: () => void;
+}
+
+export const AddColorButton: React.FC = ({onClick}) => {
+ const {t} = useTranslation('themes');
+
+ return (
+
+ );
+};
diff --git a/src/components/Themes/ui/BasicPalette/BasicPalette.tsx b/src/components/Themes/ui/BasicPalette/BasicPalette.tsx
new file mode 100644
index 00000000000..6b2f793bfb9
--- /dev/null
+++ b/src/components/Themes/ui/BasicPalette/BasicPalette.tsx
@@ -0,0 +1,61 @@
+import {useTranslation} from 'next-i18next';
+import React from 'react';
+
+import {useThemeCreatorMethods, useThemePalette} from '../../hooks';
+import {ThemeVariant} from '../../lib/types';
+import {ColorPickerInput} from '../ColorPickerInput/ColorPickerInput';
+import {ThemableSettings} from '../ThemableSettings/ThemableSettings';
+import {ThemableRow} from '../ThemableSettings/types';
+import {ThemeSection} from '../ThemeSection';
+
+import {AddColorButton} from './AddColorButton';
+import {PaletteColorEditor} from './PaletteColorEditor';
+
+const hiddenColors = new Set(['white', 'black', 'brand']);
+
+export const BasicPalette = () => {
+ const {t} = useTranslation('themes');
+
+ const {addColor, removeColor, updateColor, renameColor} = useThemeCreatorMethods();
+ const origPalette = useThemePalette();
+
+ const palette = React.useMemo(
+ () => origPalette.filter(({title}) => !hiddenColors.has(title.toLowerCase())),
+ [origPalette],
+ );
+
+ const rows = React.useMemo(
+ () =>
+ palette.map((paletteColorData) => ({
+ id: paletteColorData.title,
+ title: paletteColorData.title,
+ renderTitle: () => (
+
+ ),
+ render: (currentTheme: ThemeVariant) => (
+
+ updateColor({theme: currentTheme, title: paletteColorData.title, value})
+ }
+ />
+ ),
+ })),
+ [palette, removeColor, renameColor],
+ );
+
+ return (
+
+ }
+ />
+
+ );
+};
diff --git a/src/components/Themes/ui/BasicPalette/PaletteColorEditor.scss b/src/components/Themes/ui/BasicPalette/PaletteColorEditor.scss
new file mode 100644
index 00000000000..0673e5d65ba
--- /dev/null
+++ b/src/components/Themes/ui/BasicPalette/PaletteColorEditor.scss
@@ -0,0 +1,56 @@
+@use '../../../../variables.scss';
+@use '~@gravity-ui/page-constructor/styles/variables.scss' as pcVariables;
+
+$block: '.#{variables.$ns}theme-palette-color-editor';
+
+#{$block} {
+ display: flex;
+ gap: var(--g-spacing-2);
+
+ @media (max-width: map-get(pcVariables.$gridBreakpoints, 'sm')) {
+ flex-direction: column-reverse;
+ }
+
+ &__default-title {
+ padding: var(--g-spacing-2) 0;
+
+ @media (max-width: map-get(pcVariables.$gridBreakpoints, 'sm')) {
+ padding: 0;
+ }
+ }
+
+ & &__input {
+ padding-inline-start: 9px;
+
+ @media (max-width: map-get(pcVariables.$gridBreakpoints, 'sm')) {
+ padding-inline-start: 3px;
+ }
+ }
+
+ &__input-title {
+ display: none;
+
+ @media (max-width: map-get(pcVariables.$gridBreakpoints, 'sm')) {
+ display: block;
+ padding-left: var(--g-spacing-2);
+ padding-top: 1px;
+ }
+ }
+
+ &__header {
+ display: flex;
+
+ @media (max-width: map-get(pcVariables.$gridBreakpoints, 'sm')) {
+ justify-content: space-between;
+ align-items: center;
+ }
+ }
+
+ &__title {
+ display: none;
+
+ @media (max-width: map-get(pcVariables.$gridBreakpoints, 'sm')) {
+ display: block;
+ }
+ }
+}
diff --git a/src/components/Themes/ui/BasicPalette/PaletteColorEditor.tsx b/src/components/Themes/ui/BasicPalette/PaletteColorEditor.tsx
new file mode 100644
index 00000000000..aed0838ee2c
--- /dev/null
+++ b/src/components/Themes/ui/BasicPalette/PaletteColorEditor.tsx
@@ -0,0 +1,83 @@
+import {TrashBin} from 'landing-icons';
+import {Button, Icon, Text, TextInput} from 'landing-uikit';
+import debounce from 'lodash/debounce';
+import React from 'react';
+
+import {block} from '../../../../utils';
+import {Palette} from '../../lib/types';
+
+import './PaletteColorEditor.scss';
+
+const b = block('theme-palette-color-editor');
+
+interface PaletteColorEditorProps {
+ paletteColorData: Palette[0];
+ onUpdateTitle: (params: {oldTitle: string; newTitle: string}) => void;
+ onDelete: (title: string) => void;
+}
+
+export const PaletteColorEditor: React.FC = ({
+ onDelete,
+ onUpdateTitle,
+ paletteColorData,
+}) => {
+ const {title, isCustom} = paletteColorData;
+
+ const [localTitle, setLocalTitle] = React.useState(title);
+
+ React.useEffect(() => {
+ setLocalTitle(title);
+ }, [title]);
+
+ const handleDelete = React.useCallback(() => onDelete(title), [onDelete, title]);
+
+ const updateTitle = React.useCallback(
+ (newTitle: string) => onUpdateTitle({oldTitle: title, newTitle}),
+ [title, onUpdateTitle],
+ );
+
+ const debouncedUpdateTitle = React.useMemo(() => debounce(updateTitle, 500), [updateTitle]);
+
+ const handleUpdateTitle = React.useCallback(
+ (newTitle: string) => {
+ setLocalTitle(newTitle);
+ debouncedUpdateTitle(newTitle);
+ },
+ [debouncedUpdateTitle],
+ );
+
+ if (!isCustom) {
+ return (
+
+ {title}
+
+ );
+ }
+
+ return (
+
+
+ Name:
+
+ }
+ />
+
+
+ New color
+
+
+
+
+ );
+};
diff --git a/src/components/Themes/ui/BorderRadiusTab/BorderCard/BorderCard.tsx b/src/components/Themes/ui/BorderRadiusTab/BorderCard/BorderCard.tsx
new file mode 100644
index 00000000000..f26562d7f1c
--- /dev/null
+++ b/src/components/Themes/ui/BorderRadiusTab/BorderCard/BorderCard.tsx
@@ -0,0 +1,32 @@
+import {useTranslation} from 'next-i18next';
+import React, {useCallback} from 'react';
+import {SelectableCard} from 'src/components/SelectableCard/SelectableCard';
+import {RADIUS_PRESETS} from 'src/components/Themes/lib/constants';
+import {RadiusPresetName} from 'src/components/Themes/lib/types';
+
+export type BorderCardProps = {
+ preset: RadiusPresetName;
+ selected: boolean;
+ onClick: (preset: RadiusPresetName) => void;
+};
+
+export const BorderCard = ({selected, preset, onClick}: BorderCardProps) => {
+ const {t} = useTranslation('themes');
+
+ const handleClick = useCallback(() => {
+ onClick(preset);
+ }, [preset]);
+
+ const displayName = t(preset);
+ const borderRadiusStyle = {borderRadius: RADIUS_PRESETS[preset]?.m + 'px'};
+
+ return (
+
+ );
+};
diff --git a/src/components/Themes/ui/BorderRadiusTab/BorderPresets/BorderPresets.tsx b/src/components/Themes/ui/BorderRadiusTab/BorderPresets/BorderPresets.tsx
new file mode 100644
index 00000000000..5af16520186
--- /dev/null
+++ b/src/components/Themes/ui/BorderRadiusTab/BorderPresets/BorderPresets.tsx
@@ -0,0 +1,49 @@
+import {Col, Row} from 'landing-uikit';
+import {useTranslation} from 'next-i18next';
+import React, {useCallback} from 'react';
+import {ChangeRadiusPresetInThemeParams} from 'src/components/Themes/lib/themeCreatorUtils';
+import {RadiusPresetName} from 'src/components/Themes/lib/types';
+
+import {ThemeSection} from '../../ThemeSection';
+import {BorderCard, BorderCardProps} from '../BorderCard/BorderCard';
+
+const ColCard = (props: BorderCardProps) => (
+
+
+
+);
+
+const PRESETS_ORDER = [
+ RadiusPresetName.Regular,
+ RadiusPresetName.Circled,
+ RadiusPresetName.Squared,
+ RadiusPresetName.Custom,
+];
+
+export type BorderPresetsProps = {
+ selectedPreset: RadiusPresetName;
+ onClick: (preset: ChangeRadiusPresetInThemeParams) => void;
+};
+
+export const BorderPresets = ({selectedPreset, onClick}: BorderPresetsProps) => {
+ const {t} = useTranslation('themes');
+
+ const handleClick = useCallback((preset: RadiusPresetName) => {
+ onClick({radiusPresetName: preset});
+ }, []);
+
+ return (
+
+
+ {PRESETS_ORDER.map((preset) => (
+
+ ))}
+
+
+ );
+};
diff --git a/src/components/Themes/ui/BorderRadiusTab/BorderRadiusTab.scss b/src/components/Themes/ui/BorderRadiusTab/BorderRadiusTab.scss
new file mode 100644
index 00000000000..d67246ae836
--- /dev/null
+++ b/src/components/Themes/ui/BorderRadiusTab/BorderRadiusTab.scss
@@ -0,0 +1,15 @@
+@use '~@gravity-ui/uikit/styles/themes/_index.scss' as themes;
+@use '~@gravity-ui/page-constructor/styles/variables.scss' as pcVariables;
+@use '../../../../variables.scss';
+
+$block: '.#{variables.$ns}border-radius-tab';
+
+#{$block} {
+ @include themes.g-theme-common; //restore default uikit styles for components
+
+ gap: calc(var(--g-spacing-base) * 24);
+
+ @media (max-width: map-get(pcVariables.$gridBreakpoints, 'sm')) {
+ gap: calc(var(--g-spacing-base) * 12);
+ }
+}
diff --git a/src/components/Themes/ui/BorderRadiusTab/BorderRadiusTab.tsx b/src/components/Themes/ui/BorderRadiusTab/BorderRadiusTab.tsx
new file mode 100644
index 00000000000..d4e32d9610a
--- /dev/null
+++ b/src/components/Themes/ui/BorderRadiusTab/BorderRadiusTab.tsx
@@ -0,0 +1,34 @@
+import {Flex} from 'landing-uikit';
+import React from 'react';
+
+import {block} from '../../../../utils';
+import {useThemeCreator, useThemeCreatorMethods} from '../../hooks';
+import {RadiusPresetName} from '../../lib/types';
+import {ExportThemeSection} from '../ExportThemeSection/ExportThemeSection';
+
+import {BorderPresets} from './BorderPresets/BorderPresets';
+import './BorderRadiusTab.scss';
+import {ComponentPreview} from './ComponentPreview/ComponentPreview';
+import {CustomRadius} from './CustomRadius/CustomRadius';
+
+const b = block('border-radius-tab');
+
+export const BorderRadiusTab = () => {
+ const themeState = useThemeCreator();
+
+ const {changeRadiusPreset, updateCustomRadiusPreset} = useThemeCreatorMethods();
+
+ const preset = themeState.borders.preset;
+ const values = themeState.borders.values;
+
+ return (
+
+
+ {preset === RadiusPresetName.Custom && (
+
+ )}
+
+
+
+ );
+};
diff --git a/src/components/Themes/ui/BorderRadiusTab/ComponentPreview/ComponentPreview.tsx b/src/components/Themes/ui/BorderRadiusTab/ComponentPreview/ComponentPreview.tsx
new file mode 100644
index 00000000000..430191079f4
--- /dev/null
+++ b/src/components/Themes/ui/BorderRadiusTab/ComponentPreview/ComponentPreview.tsx
@@ -0,0 +1,23 @@
+import {useTranslation} from 'next-i18next';
+import React from 'react';
+
+import {useThemeCreator} from '../../../hooks';
+import {exportTheme} from '../../../lib/themeCreatorExport';
+import {ThemeSection} from '../../ThemeSection';
+import {Showcase} from '../Showcase/Showcase';
+
+export const ComponentPreview = () => {
+ const {t} = useTranslation('themes');
+ const themeState = useThemeCreator();
+
+ const themeStyles = React.useMemo(
+ () => exportTheme({themeState, ignoreDefaultValues: false}),
+ [themeState],
+ );
+
+ return (
+
+
+
+ );
+};
diff --git a/src/components/Themes/ui/BorderRadiusTab/CustomRadius/CustomRadius.scss b/src/components/Themes/ui/BorderRadiusTab/CustomRadius/CustomRadius.scss
new file mode 100644
index 00000000000..ac2e41b4dbf
--- /dev/null
+++ b/src/components/Themes/ui/BorderRadiusTab/CustomRadius/CustomRadius.scss
@@ -0,0 +1,28 @@
+@use '~@gravity-ui/uikit/styles/themes/_index.scss' as themes;
+@use '~@gravity-ui/page-constructor/styles/variables.scss' as pcVariables;
+@use '../../../../../variables.scss';
+@use '../../../../../mixins.scss' as baseMixins;
+
+$root: '.g-root';
+$block: '.#{variables.$ns}custom-radius';
+
+// Workaround for missing theme class in ThemeProvider
+$workaroundBlockDarkTheme: {$block}_theme_dark;
+
+#{$block} {
+ &__px {
+ margin-inline: 8px;
+ }
+
+ &__radius-input-row {
+ align-items: center;
+ input::-webkit-outer-spin-button,
+ input::-webkit-inner-spin-button {
+ appearance: none;
+ margin: 0;
+ }
+ input[type='number'] {
+ appearance: textfield;
+ }
+ }
+}
diff --git a/src/components/Themes/ui/BorderRadiusTab/CustomRadius/CustomRadius.tsx b/src/components/Themes/ui/BorderRadiusTab/CustomRadius/CustomRadius.tsx
new file mode 100644
index 00000000000..3f530187ffb
--- /dev/null
+++ b/src/components/Themes/ui/BorderRadiusTab/CustomRadius/CustomRadius.tsx
@@ -0,0 +1,74 @@
+import {Col, Flex, Row, Text, TextInput} from 'landing-uikit';
+import {useTranslation} from 'next-i18next';
+import React, {useCallback, useMemo} from 'react';
+import {UpdateCustomRadiusPresetInThemeParams} from 'src/components/Themes/lib/themeCreatorUtils';
+import {RadiusSizeName, RadiusValue} from 'src/components/Themes/lib/types';
+
+import {block} from '../../../../../utils';
+import {ThemeSection} from '../../ThemeSection';
+
+import './CustomRadius.scss';
+
+const b = block('custom-radius');
+
+type RadiusInputProps = {
+ radiusSizeName: RadiusSizeName;
+ onUpdate: (param: UpdateCustomRadiusPresetInThemeParams) => void;
+ value?: string;
+};
+
+const RadiusInputRow = ({radiusSizeName, onUpdate, value}: RadiusInputProps) => {
+ const {t} = useTranslation('themes');
+
+ const text = useMemo(() => t('radius') + ` ${radiusSizeName.toUpperCase()}`, [radiusSizeName]);
+
+ const handleUpdate = useCallback(
+ (newValue: string) => {
+ onUpdate({radiusValue: {[radiusSizeName]: newValue}});
+ },
+ [radiusSizeName],
+ );
+
+ return (
+
+
+ {text}
+
+
+
+ px
+
+ }
+ />
+
+
+ );
+};
+
+type CustomRadiusProps = {
+ values: RadiusValue;
+ onUpdate: (param: UpdateCustomRadiusPresetInThemeParams) => void;
+};
+
+export const CustomRadius = ({onUpdate, values}: CustomRadiusProps) => {
+ const {t} = useTranslation('themes');
+
+ return (
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/src/components/Themes/ui/BorderRadiusTab/Showcase/Showcase.scss b/src/components/Themes/ui/BorderRadiusTab/Showcase/Showcase.scss
new file mode 100644
index 00000000000..cc7e8220669
--- /dev/null
+++ b/src/components/Themes/ui/BorderRadiusTab/Showcase/Showcase.scss
@@ -0,0 +1,27 @@
+@use '~@gravity-ui/uikit/styles/themes/_index.scss' as themes;
+@use '~@gravity-ui/page-constructor/styles/variables.scss' as pcVariables;
+@use '../../../../../variables.scss';
+@use '../../../../../mixins.scss' as baseMixins;
+
+$root: '.g-root';
+$block: '.#{variables.$ns}border-radius-showcase';
+
+#{$block} {
+ padding: 40px;
+ border-radius: 24px;
+
+ &__column-transform {
+ @media (max-width: (map-get(pcVariables.$gridBreakpoints, 'md') - 1)) {
+ flex-direction: column;
+ width: 100%;
+ }
+ }
+
+ &__text-input-block {
+ flex-grow: 1;
+ @media (max-width: (map-get(pcVariables.$gridBreakpoints, 'lg') - 1)) {
+ flex-direction: column;
+ width: 100%;
+ }
+ }
+}
diff --git a/src/components/Themes/ui/BorderRadiusTab/Showcase/Showcase.tsx b/src/components/Themes/ui/BorderRadiusTab/Showcase/Showcase.tsx
new file mode 100644
index 00000000000..419414e8f89
--- /dev/null
+++ b/src/components/Themes/ui/BorderRadiusTab/Showcase/Showcase.tsx
@@ -0,0 +1,127 @@
+import {PencilToLine} from 'landing-icons';
+import {
+ Button,
+ Flex,
+ FlexProps,
+ Label,
+ RadioButton,
+ TextInput,
+ Theme,
+ ThemeProvider,
+} from 'landing-uikit';
+import type {ButtonProps} from 'landing-uikit';
+import {useTranslation} from 'next-i18next';
+import React, {useMemo} from 'react';
+
+import {block} from '../../../../../utils';
+
+import './Showcase.scss';
+
+const b = block('border-radius-showcase');
+
+export type ShowcaseProps = {
+ color?: string;
+ theme: Theme;
+ style?: string;
+};
+
+type ShowcaseBlockProps = FlexProps & {
+ text: string;
+};
+
+const BlockWrapper = (props: FlexProps) => (
+
+ {props.children}
+
+);
+const LabelBlock = (props: ShowcaseBlockProps) => (
+
+
+
+
+
+);
+
+const getIconSize = (size: ButtonProps['size']) => {
+ switch (size) {
+ case 'xs':
+ return 12;
+ case 'xl':
+ return 20;
+ default:
+ return 16;
+ }
+};
+
+const ShowcaseButton = ({size, children}: Pick) => {
+ const iconSize = getIconSize(size);
+ return (
+
+ );
+};
+
+const ButtonBlock = (props: ShowcaseBlockProps) => (
+
+ {props.text}
+ {props.text}
+ {props.text}
+ {props.text}
+ {props.text}
+
+);
+
+const RadioButtonBlock = (props: ShowcaseBlockProps) => {
+ const radioButtonOptions = useMemo(
+ () => [
+ {value: '1', content: props.text},
+ {value: '2', content: props.text},
+ ],
+ [],
+ );
+ return (
+
+
+
+
+
+
+ );
+};
+const TextInputBlock = (props: ShowcaseBlockProps) => (
+
+
+
+
+
+
+);
+
+const borderRadiusShowcaseCn = b();
+
+export const Showcase: React.FC = ({color, theme, style}) => {
+ const {t} = useTranslation('themes');
+
+ return (
+
+ {style ? : null}
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/src/components/Themes/ui/BrandColors/BrandColors.scss b/src/components/Themes/ui/BrandColors/BrandColors.scss
new file mode 100644
index 00000000000..e3b7f14e213
--- /dev/null
+++ b/src/components/Themes/ui/BrandColors/BrandColors.scss
@@ -0,0 +1,65 @@
+@use '../../../../variables.scss';
+@use '~@gravity-ui/page-constructor/styles/variables.scss' as pcVariables;
+
+$block: '.#{variables.$ns}brand-colors';
+
+#{$block} {
+ gap: 32px;
+
+ &__brand-color-picker {
+ display: flex;
+ gap: 2px;
+ overflow: auto;
+
+ @media (max-width: map-get(pcVariables.$gridBreakpoints, 'sm')) {
+ margin: 0 -24px;
+ padding: 0 24px;
+ }
+ }
+
+ &__color {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border: 2px solid transparent;
+ border-radius: 12px;
+ padding: 6px;
+ cursor: pointer;
+
+ &_custom {
+ --color-value: conic-gradient(
+ from 180deg at 50% 50%,
+ #fa00ff -47.18deg,
+ #ffd028 46.82deg,
+ #00e6bd 138.38deg,
+ #6932de 223.7deg,
+ #fa00ff 312.82deg,
+ #ffd028 406.82deg
+ );
+
+ gap: 8px;
+ padding-right: 18px;
+ border-color: rgba(255, 255, 255, 0.15);
+ }
+
+ &_selected {
+ border-color: var(--color-value);
+
+ {$block}__color_custom {
+ border-color: rgba(255, 197, 108, 1);
+ }
+ }
+
+ &-inner {
+ background: var(--color-value);
+ border-radius: 5px;
+ height: 32px;
+ width: 32px;
+ }
+ }
+
+ &__switch-button {
+ --g-button-border-radius: 8px;
+ width: min-content;
+ }
+}
diff --git a/src/components/Themes/ui/BrandColors/BrandColors.tsx b/src/components/Themes/ui/BrandColors/BrandColors.tsx
new file mode 100644
index 00000000000..e857956124d
--- /dev/null
+++ b/src/components/Themes/ui/BrandColors/BrandColors.tsx
@@ -0,0 +1,104 @@
+import {Sliders} from 'landing-icons';
+import {Button, Flex, Icon, Text} from 'landing-uikit';
+import React from 'react';
+
+import {block} from '../../../../utils';
+import {useThemeCreatorMethods, useThemePaletteColor} from '../../hooks';
+import {BRAND_COLORS_PRESETS} from '../../lib/constants';
+import {ThemeSection} from '../ThemeSection';
+
+import './BrandColors.scss';
+
+const b = block('brand-colors');
+
+interface BrandColorsProps {
+ showThemeEditButton?: boolean;
+ onEditThemeClick: () => void;
+ onSelectCustomColor: () => void;
+}
+
+export const BrandColors: React.FC = ({
+ showThemeEditButton,
+ onEditThemeClick,
+ onSelectCustomColor,
+}) => {
+ const [customModeEnabled, setCustomMode] = React.useState(false);
+
+ const [lightBrandColor] = useThemePaletteColor({
+ token: 'brand',
+ theme: 'light',
+ });
+ const [darkBrandColor] = useThemePaletteColor({
+ token: 'brand',
+ theme: 'dark',
+ });
+
+ const {applyBrandPreset} = useThemeCreatorMethods();
+
+ const activeColorIndex = React.useMemo(() => {
+ return BRAND_COLORS_PRESETS.findIndex(
+ (value) => value.brandColor === lightBrandColor && value.brandColor === darkBrandColor,
+ );
+ }, [lightBrandColor, darkBrandColor]);
+
+ const setBrandPreset = React.useCallback(
+ (index: number) => {
+ setCustomMode(false);
+
+ if (activeColorIndex === index) {
+ return;
+ }
+
+ applyBrandPreset(BRAND_COLORS_PRESETS[index]);
+ },
+ [activeColorIndex, applyBrandPreset],
+ );
+
+ const handleSelectCustomColor = React.useCallback(() => {
+ setCustomMode(true);
+ onSelectCustomColor();
+ }, [onSelectCustomColor]);
+
+ return (
+
+
+
+ {BRAND_COLORS_PRESETS.map((value, index) => (
+
setBrandPreset(index)}
+ >
+
+
+ ))}
+
+
+
+ {showThemeEditButton && (
+
+ )}
+
+ );
+};
diff --git a/src/components/Themes/ui/ColorPickerInput/ColorPickerInput.scss b/src/components/Themes/ui/ColorPickerInput/ColorPickerInput.scss
new file mode 100644
index 00000000000..bce7eb6580e
--- /dev/null
+++ b/src/components/Themes/ui/ColorPickerInput/ColorPickerInput.scss
@@ -0,0 +1,34 @@
+@use '../../../../variables.scss';
+
+$block: '.#{variables.$ns}color-picker';
+
+#{$block} {
+ --g-border-radius-xl: 8px;
+ flex-grow: 1;
+ position: relative;
+
+ &__text-input {
+ z-index: 1;
+ }
+
+ &__preview {
+ margin-inline-start: var(--g-spacing-2);
+ margin-inline-end: var(--g-spacing-1);
+
+ &_with-border {
+ border: 1px solid var(--g-color-line-generic);
+ }
+ }
+
+ &__input {
+ width: 35px;
+ opacity: 0;
+ padding: 0;
+ margin: 0;
+ border: 0;
+ position: absolute;
+ bottom: 0;
+ right: 0;
+ z-index: 0;
+ }
+}
diff --git a/src/components/Themes/ui/ColorPickerInput/ColorPickerInput.tsx b/src/components/Themes/ui/ColorPickerInput/ColorPickerInput.tsx
new file mode 100644
index 00000000000..ee69d733e22
--- /dev/null
+++ b/src/components/Themes/ui/ColorPickerInput/ColorPickerInput.tsx
@@ -0,0 +1,139 @@
+import {Palette} from 'landing-icons';
+import {Button, Flex, Icon, TextInput, TextInputProps} from 'landing-uikit';
+import debounce from 'lodash/debounce';
+import {useTranslation} from 'next-i18next';
+import React, {ChangeEventHandler, useCallback, useEffect, useRef, useState} from 'react';
+
+import {block} from '../../../../utils';
+import {ColorPreview} from '../ColorPreview/ColorPreview';
+
+import './ColorPickerInput.scss';
+import {NativeColorPicker} from './NativeColorPicker';
+import {getValidColor, isValidColor} from './utils';
+
+const b = block('color-picker');
+
+export interface ColorPickerInputProps {
+ defaultValue: string;
+ name?: string;
+ value?: string;
+ onChange: (color: string) => void;
+ errorMessage?: string;
+ size?: TextInputProps['size'];
+ withBorderInPreview?: boolean;
+}
+
+export const ColorPickerInput = ({
+ name,
+ value,
+ onChange: onChangeExternal,
+ defaultValue,
+ errorMessage,
+ size = 'l',
+ withBorderInPreview,
+}: ColorPickerInputProps) => {
+ const {t} = useTranslation('themes');
+
+ const debouncedExternalChange = React.useMemo(
+ () => debounce(onChangeExternal, 200),
+ [onChangeExternal],
+ );
+
+ const [color, setColor] = useState(() => {
+ const validColor = getValidColor(defaultValue);
+
+ return validColor ?? '';
+ });
+
+ const [inputValue, setInputValue] = useState(value ?? defaultValue);
+ const [validationError, setValidationError] = useState();
+
+ const colorInputRef = useRef(null);
+
+ const validateAndChangeExternal = React.useCallback(
+ (newValue: string, formatValueToHex = false) => {
+ if (!isValidColor(newValue)) {
+ setValidationError('invalid');
+ return;
+ }
+
+ setValidationError(undefined);
+
+ let formattedValue = newValue;
+
+ if (formatValueToHex) {
+ const validColor = getValidColor(newValue);
+ if (validColor !== undefined) {
+ formattedValue = validColor;
+ }
+ }
+
+ setInputValue(formattedValue);
+ setColor(formattedValue);
+ debouncedExternalChange(formattedValue);
+ },
+ [debouncedExternalChange],
+ );
+
+ const onChange: ChangeEventHandler = useCallback((event) => {
+ const newValue = event.target.value;
+ setInputValue(newValue);
+ setValidationError(undefined);
+ }, []);
+
+ const onNativeInputChange: ChangeEventHandler = useCallback(
+ (e) => {
+ const newValue = e.target.value.toUpperCase();
+ setInputValue(newValue);
+ validateAndChangeExternal(newValue, true);
+ },
+ [validateAndChangeExternal],
+ );
+
+ const onBlur = useCallback(() => {
+ validateAndChangeExternal(inputValue);
+ }, [inputValue, validateAndChangeExternal]);
+
+ useEffect(() => {
+ // Dont validate if not initial value
+ if (!value && !defaultValue) {
+ return;
+ }
+
+ validateAndChangeExternal(value ?? defaultValue);
+ }, [value, defaultValue]);
+
+ return (
+
+
+ }
+ endContent={
+
+ }
+ onBlur={onBlur}
+ />
+
+
+ );
+};
diff --git a/src/components/Themes/ui/ColorPickerInput/NativeColorPicker.tsx b/src/components/Themes/ui/ColorPickerInput/NativeColorPicker.tsx
new file mode 100644
index 00000000000..d38b529d500
--- /dev/null
+++ b/src/components/Themes/ui/ColorPickerInput/NativeColorPicker.tsx
@@ -0,0 +1,35 @@
+import React, {ChangeEventHandler, forwardRef} from 'react';
+
+import {block} from '../../../../utils';
+
+import './ColorPickerInput.scss';
+import {getValidColor} from './utils';
+
+export interface NativeColorPickerProps {
+ value: string;
+ onChange: ChangeEventHandler;
+}
+
+const b = block('color-picker__input');
+
+export const NativeColorPicker = forwardRef(
+ ({value, onChange}, ref) => {
+ const normalizedValue = React.useMemo(() => {
+ try {
+ return getValidColor(value);
+ } catch (_err) {
+ return value;
+ }
+ }, [value]);
+
+ return (
+
+ );
+ },
+);
diff --git a/src/components/Themes/ui/ColorPickerInput/utils.ts b/src/components/Themes/ui/ColorPickerInput/utils.ts
new file mode 100644
index 00000000000..c2fa475d8f1
--- /dev/null
+++ b/src/components/Themes/ui/ColorPickerInput/utils.ts
@@ -0,0 +1,45 @@
+import chroma from 'chroma-js';
+
+export const hexRegexp = /^#[a-fA-F0-9]{6}$/;
+export const rgbRegexp = /^rgb\((\d{1,3}, ?){2}(\d{1,3})\)$/;
+export const rgbaRegexp = /^rgba\((\d{1,3}, ?){3}((0(,|\.)[0-9]{1,2})|1)\)$/;
+
+const numberRegexp = /\b\d+\b/g;
+
+export const parseRgbStringToHex = (rgbString: string) => {
+ let hexColor = '#';
+ rgbString.match(new RegExp(numberRegexp, 'g'))?.forEach((val) => {
+ const hex = Number(val).toString(16);
+
+ hexColor += hex?.length === 1 ? `0${hex}` : hex;
+ });
+
+ return hexColor;
+};
+
+export const isValidColor = (textColor: string) => {
+ try {
+ chroma(textColor);
+ return true;
+ } catch (_err) {
+ return false;
+ }
+};
+
+export const getValidColor = (textColor: string) => {
+ const testColor = textColor.replaceAll(' ', '');
+
+ if (
+ !testColor ||
+ new RegExp(hexRegexp, 'g').test(testColor) ||
+ new RegExp(rgbaRegexp, 'g').test(testColor)
+ ) {
+ return textColor;
+ }
+
+ if (new RegExp(rgbRegexp, 'g').test(testColor)) {
+ return chroma(testColor).hex();
+ }
+
+ return undefined;
+};
diff --git a/src/components/Themes/ui/ColorPreview/ColorPreview.scss b/src/components/Themes/ui/ColorPreview/ColorPreview.scss
new file mode 100644
index 00000000000..3ef56f7f9ba
--- /dev/null
+++ b/src/components/Themes/ui/ColorPreview/ColorPreview.scss
@@ -0,0 +1,35 @@
+@use '../../../../variables.scss';
+
+$block: '.#{variables.$ns}color-preview';
+
+#{$block} {
+ --chess: rgb(235, 235, 235);
+ --surface: rgb(255, 255, 255);
+ --opacity-pattern: repeating-conic-gradient(var(--chess) 0% 25%, var(--surface) 0% 50%) 50% /
+ 8px 8px;
+
+ width: 16px;
+ height: 16px;
+ border-radius: var(--g-border-radius-xs);
+ overflow: hidden;
+ position: relative;
+
+ &__color {
+ position: relative;
+ width: 100%;
+ height: 100%;
+ }
+
+ &_with-opacity {
+ &::before {
+ content: '';
+ display: block;
+ position: absolute;
+ left: 0;
+ top: 0;
+ width: 100%;
+ height: 100%;
+ background: var(--opacity-pattern);
+ }
+ }
+}
diff --git a/src/components/Themes/ui/ColorPreview/ColorPreview.tsx b/src/components/Themes/ui/ColorPreview/ColorPreview.tsx
new file mode 100644
index 00000000000..235b8c69100
--- /dev/null
+++ b/src/components/Themes/ui/ColorPreview/ColorPreview.tsx
@@ -0,0 +1,22 @@
+import React from 'react';
+
+import {block} from '../../../../utils';
+
+import './ColorPreview.scss';
+
+export interface ColorPreviewProps {
+ color?: string;
+ className?: string;
+}
+
+const b = block('color-preview');
+
+const isColorWithOpacity = (color?: string) => !color || color?.startsWith('rgba');
+
+export const ColorPreview = ({color, className}: ColorPreviewProps) => {
+ return (
+
+ );
+};
diff --git a/src/components/Themes/ui/ColorsTab/ColorsTab.scss b/src/components/Themes/ui/ColorsTab/ColorsTab.scss
new file mode 100644
index 00000000000..a2061ec559c
--- /dev/null
+++ b/src/components/Themes/ui/ColorsTab/ColorsTab.scss
@@ -0,0 +1,12 @@
+@use '../../../../variables.scss';
+@use '~@gravity-ui/page-constructor/styles/variables.scss' as pcVariables;
+
+$block: '.#{variables.$ns}colors-tab';
+
+#{$block} {
+ gap: calc(var(--g-spacing-base) * 24);
+
+ @media (max-width: map-get(pcVariables.$gridBreakpoints, 'sm')) {
+ gap: calc(var(--g-spacing-base) * 12);
+ }
+}
diff --git a/src/components/Themes/ui/ColorsTab/ColorsTab.tsx b/src/components/Themes/ui/ColorsTab/ColorsTab.tsx
new file mode 100644
index 00000000000..afaaa2e83a4
--- /dev/null
+++ b/src/components/Themes/ui/ColorsTab/ColorsTab.tsx
@@ -0,0 +1,116 @@
+import {Flex} from 'landing-uikit';
+import {Trans, useTranslation} from 'next-i18next';
+import React from 'react';
+
+import {block} from '../../../../utils';
+import {useThemeCreator, useThemeCreatorMethods} from '../../hooks';
+import {BasicPalette} from '../BasicPalette/BasicPalette';
+import {BrandColors} from '../BrandColors/BrandColors';
+import {ComponentPreview} from '../ComponentPreview/ComponentPreview';
+import {ExportThemeSection} from '../ExportThemeSection/ExportThemeSection';
+import {MainSettings} from '../MainSettings/MainSettings';
+import {EditableColorOption, PrivateColorsSettings} from '../PrivateColorsSettings';
+
+import './ColorsTab.scss';
+
+const b = block('colors-tab');
+
+const ADVANCED_COLORS_OPTIONS: EditableColorOption[] = [
+ {
+ title: 'Hovered Brand Color',
+ name: 'base-brand-hover',
+ },
+ {
+ title: 'Brand Text',
+ name: 'text-brand',
+ },
+ {
+ title: 'Higher Contrast Brand Text',
+ name: 'text-brand-heavy',
+ },
+ {
+ title: 'Brand Line Color',
+ name: 'line-brand',
+ },
+ {
+ title: 'Selection Background',
+ name: 'base-selection',
+ },
+ {
+ title: 'Hovered Selection Background',
+ name: 'base-selection-hover',
+ },
+];
+
+const ADDITIONAL_COLORS_OPTIONS: EditableColorOption[] = [
+ {
+ title: 'Link',
+ name: 'text-link',
+ },
+ {
+ title: 'Hovered Link',
+ name: 'text-link-hover',
+ },
+ {
+ title: 'Visited Link',
+ name: 'text-link-visited',
+ },
+ {
+ title: 'Hovered Visited Link',
+ name: 'text-link-visited-hover',
+ },
+];
+
+export const ColorsTab = () => {
+ const {t} = useTranslation('themes');
+
+ const {advancedModeEnabled, showMainSettings} = useThemeCreator();
+ const {setAdvancedMode, openMainSettings} = useThemeCreatorMethods();
+
+ const toggleAdvancedMode = React.useCallback(
+ () => setAdvancedMode(!advancedModeEnabled),
+ [setAdvancedMode, advancedModeEnabled],
+ );
+
+ const handleSelectCustomColor = React.useCallback(() => {
+ openMainSettings();
+ setAdvancedMode(true);
+ }, [openMainSettings, setAdvancedMode]);
+
+ return (
+
+
+ {showMainSettings && (
+
+ )}
+ {advancedModeEnabled && (
+
+
+
+
+
+ }
+ options={ADVANCED_COLORS_OPTIONS}
+ />
+
+
+ )}
+
+
+
+ );
+};
diff --git a/src/components/Themes/ui/ComponentPreview/ComponentPreview.tsx b/src/components/Themes/ui/ComponentPreview/ComponentPreview.tsx
new file mode 100644
index 00000000000..093427eda4a
--- /dev/null
+++ b/src/components/Themes/ui/ComponentPreview/ComponentPreview.tsx
@@ -0,0 +1,37 @@
+import {Flex} from 'landing-uikit';
+import {useTranslation} from 'next-i18next';
+import dynamic from 'next/dynamic';
+import React from 'react';
+
+import {useThemeCreator} from '../../hooks';
+import {exportTheme} from '../../lib/themeCreatorExport';
+import {ThemeSection} from '../ThemeSection';
+
+const Showcase = dynamic(
+ () =>
+ import('../../../../blocks/Examples/components/Showcase/Showcase').then(
+ (res) => res.Showcase,
+ ),
+ {
+ ssr: false,
+ },
+);
+
+export const ComponentPreview = () => {
+ const {t} = useTranslation('themes');
+ const themeState = useThemeCreator();
+
+ const themeStyles = React.useMemo(
+ () => exportTheme({themeState, ignoreDefaultValues: false}),
+ [themeState],
+ );
+
+ return (
+
+
+
+
+
+
+ );
+};
diff --git a/src/components/Themes/ui/ExportThemeSection/ExportThemeSection.scss b/src/components/Themes/ui/ExportThemeSection/ExportThemeSection.scss
new file mode 100644
index 00000000000..61663003648
--- /dev/null
+++ b/src/components/Themes/ui/ExportThemeSection/ExportThemeSection.scss
@@ -0,0 +1,11 @@
+@use '../../../../variables.scss';
+
+$block: '.#{variables.$ns}export-theme-section';
+
+#{$block} {
+ --g-button-border-radius: 8px;
+
+ &__export-button {
+ width: min-content;
+ }
+}
diff --git a/src/components/Themes/ui/ExportThemeSection/ExportThemeSection.tsx b/src/components/Themes/ui/ExportThemeSection/ExportThemeSection.tsx
new file mode 100644
index 00000000000..170a01ebdec
--- /dev/null
+++ b/src/components/Themes/ui/ExportThemeSection/ExportThemeSection.tsx
@@ -0,0 +1,27 @@
+import {ArrowUpFromSquare} from 'landing-icons';
+import {Button, Icon} from 'landing-uikit';
+import {useTranslation} from 'next-i18next';
+import React from 'react';
+
+import {block} from '../../../../utils';
+import {ThemeExportDialog} from '../ThemeExportDialog/ThemeExportDialog';
+import {ThemeSection} from '../ThemeSection';
+
+import './ExportThemeSection.scss';
+
+const b = block('export-theme-section');
+
+export const ExportThemeSection = () => {
+ const {t} = useTranslation('themes');
+ const [isDialogVisible, toggleDialog] = React.useReducer((isOpen) => !isOpen, false);
+
+ return (
+
+
+
+
+ );
+};
diff --git a/src/components/Themes/ui/MainSettings/MainSettings.scss b/src/components/Themes/ui/MainSettings/MainSettings.scss
new file mode 100644
index 00000000000..1acbadbdd64
--- /dev/null
+++ b/src/components/Themes/ui/MainSettings/MainSettings.scss
@@ -0,0 +1,28 @@
+@use '../../../../variables.scss';
+@use '~@gravity-ui/page-constructor/styles/variables.scss' as pcVariables;
+
+$block: '.#{variables.$ns}main-settings';
+
+#{$block} {
+ --g-button-border-radius: 8px;
+ --g-text-input-border-radius: 8px;
+
+ &__switch-button {
+ margin-top: var(--g-spacing-3);
+ width: min-content;
+ }
+
+ &__text-card {
+ width: 50%;
+ }
+
+ &__text-contrast-title {
+ height: 80px;
+ display: flex;
+ align-items: center;
+
+ @media (max-width: map-get(pcVariables.$gridBreakpoints, 'md')) {
+ height: auto;
+ }
+ }
+}
diff --git a/src/components/Themes/ui/MainSettings/MainSettings.tsx b/src/components/Themes/ui/MainSettings/MainSettings.tsx
new file mode 100644
index 00000000000..a18e7a093d5
--- /dev/null
+++ b/src/components/Themes/ui/MainSettings/MainSettings.tsx
@@ -0,0 +1,152 @@
+import {Sliders} from 'landing-icons';
+import {Button, Flex, Icon, Text} from 'landing-uikit';
+import {useTranslation} from 'next-i18next';
+import React from 'react';
+
+import {block} from '../../../../utils';
+import {SelectableCard} from '../../../SelectableCard/SelectableCard';
+import {useThemePaletteColor, useThemeUtilityColor} from '../../hooks';
+import {TEXT_CONTRAST_COLORS} from '../../lib/constants';
+import type {ColorsOptions, ThemeVariant} from '../../lib/types';
+import {ColorPickerInput} from '../ColorPickerInput/ColorPickerInput';
+import {ThemableSettings} from '../ThemableSettings/ThemableSettings';
+import {ThemableRow} from '../ThemableSettings/types';
+import {ThemeSection} from '../ThemeSection';
+
+import './MainSettings.scss';
+
+const b = block('main-settings');
+
+const BASE_CARD_BUTTON_STYLES = {
+ borderRadius: '8px',
+ padding: '10px 16px',
+ height: 'auto',
+ width: 'auto',
+};
+
+interface ThemeUtilityColorEditorProps {
+ name: keyof ColorsOptions;
+ theme: ThemeVariant;
+}
+
+const ThemeUtilityColorEditor: React.FC = ({name, theme}) => {
+ const [color, setColor] = useThemeUtilityColor({
+ name,
+ theme,
+ });
+
+ return (
+
+ );
+};
+
+const BrandColorEditor: React.FC<{theme: ThemeVariant}> = ({theme}) => {
+ const [brandColor, setBrandColor] = useThemePaletteColor({token: 'brand', theme});
+
+ return (
+
+ );
+};
+
+const TextContrastColorEditor: React.FC<{theme: ThemeVariant}> = ({theme}) => {
+ const [brandTextColor, setBrandTextColor] = useThemeUtilityColor({
+ name: 'text-brand-contrast',
+ theme,
+ });
+
+ const [brandColor] = useThemePaletteColor({token: 'brand', theme});
+
+ return (
+
+ setBrandTextColor(TEXT_CONTRAST_COLORS[theme].black)}
+ textProps={{
+ style: {
+ ...BASE_CARD_BUTTON_STYLES,
+ color: TEXT_CONTRAST_COLORS[theme].black,
+ backgroundColor: brandColor,
+ },
+ }}
+ />
+ setBrandTextColor(TEXT_CONTRAST_COLORS[theme].white)}
+ textProps={{
+ style: {
+ ...BASE_CARD_BUTTON_STYLES,
+ color: TEXT_CONTRAST_COLORS[theme].white,
+ backgroundColor: brandColor,
+ },
+ }}
+ />
+
+ );
+};
+
+interface MainSettingsProps {
+ advancedModeEnabled: boolean;
+ toggleAdvancedMode: () => void;
+}
+
+export const MainSettings: React.FC = ({
+ advancedModeEnabled,
+ toggleAdvancedMode,
+}) => {
+ const {t} = useTranslation('themes');
+
+ const rows = React.useMemo(() => {
+ return [
+ {
+ id: 'base-background',
+ title: t('page_background'),
+ render: (theme) => ,
+ },
+ {
+ id: 'brand',
+ title: t('brand_color'),
+ render: (theme) => ,
+ },
+ {
+ id: 'text-brand-contrast',
+ title: 'Text on Brand',
+ render: (theme) => ,
+ renderTitle: () => (
+
+ Text on Brand
+
+ ),
+ },
+ ];
+ }, [t]);
+
+ return (
+
+
+
+ {advancedModeEnabled ? t('hide_advanced_settings') : t('advanced_settings')}
+
+ }
+ />
+
+ );
+};
diff --git a/src/components/Themes/ui/PreviewTab/CardsPreview/CardsPreview.scss b/src/components/Themes/ui/PreviewTab/CardsPreview/CardsPreview.scss
new file mode 100644
index 00000000000..bce0faaed4e
--- /dev/null
+++ b/src/components/Themes/ui/PreviewTab/CardsPreview/CardsPreview.scss
@@ -0,0 +1,26 @@
+@use '../../../../../variables';
+@use '~@gravity-ui/page-constructor/styles/variables.scss' as pcVariables;
+@use '~@gravity-ui/uikit/styles/themes/_index.scss' as themes;
+
+$block: '.#{variables.$ns}cards-preview';
+
+#{$block} {
+ width: 100%;
+ height: 100%;
+ overflow: auto;
+
+ &__card {
+ width: 295px;
+ height: 430px;
+ padding: var(--g-spacing-1) var(--g-spacing-1) 0;
+
+ &__content {
+ padding: var(--g-spacing-5) var(--g-spacing-4);
+ height: 100%;
+
+ &__footer {
+ margin-block-start: auto;
+ }
+ }
+ }
+}
diff --git a/src/components/Themes/ui/PreviewTab/CardsPreview/CardsPreview.tsx b/src/components/Themes/ui/PreviewTab/CardsPreview/CardsPreview.tsx
new file mode 100644
index 00000000000..049b146692f
--- /dev/null
+++ b/src/components/Themes/ui/PreviewTab/CardsPreview/CardsPreview.tsx
@@ -0,0 +1,59 @@
+import {Card, Flex, Text, User} from 'landing-uikit';
+import React from 'react';
+
+import avatar1Asset from '../../../../../assets/avatar-1.png';
+import {block} from '../../../../../utils';
+import {cardData} from '../constants';
+
+import './CardsPreview.scss';
+
+const b = block('cards-preview');
+
+const PreviewCard = ({
+ imgSrc,
+ title,
+ text,
+ date,
+ user,
+}: {
+ imgSrc: string;
+ title: string;
+ text: string;
+ date: string;
+ user: string;
+}) => {
+ return (
+
+
+
+
+
+ {title}
+ {text}
+
+
+ {date}
+
+
+
+
+
+ );
+};
+
+export const CardsPreview = ({justify}: {justify: string}) => {
+ return (
+
+ Cards
+
+ {cardData.map((card, index) => (
+
+ ))}
+
+
+ );
+};
diff --git a/src/components/Themes/ui/PreviewTab/DashboardsPreview/DashboardPreview.scss b/src/components/Themes/ui/PreviewTab/DashboardsPreview/DashboardPreview.scss
new file mode 100644
index 00000000000..6f59ea1c54b
--- /dev/null
+++ b/src/components/Themes/ui/PreviewTab/DashboardsPreview/DashboardPreview.scss
@@ -0,0 +1,18 @@
+@use '../../../../../variables';
+@use '~@gravity-ui/page-constructor/styles/variables.scss' as pcVariables;
+@use '~@gravity-ui/uikit/styles/themes/_index.scss' as themes;
+
+$block: '.#{variables.$ns}dashboards-preview';
+
+#{$block} {
+ overflow-y: auto;
+
+ &__card {
+ flex: 1;
+ padding: var(--g-spacing-5);
+ }
+
+ &__dashboard-wrapper {
+ height: 230px;
+ }
+}
diff --git a/src/components/Themes/ui/PreviewTab/DashboardsPreview/DashboardPreview.tsx b/src/components/Themes/ui/PreviewTab/DashboardsPreview/DashboardPreview.tsx
new file mode 100644
index 00000000000..10d420d6cca
--- /dev/null
+++ b/src/components/Themes/ui/PreviewTab/DashboardsPreview/DashboardPreview.tsx
@@ -0,0 +1,80 @@
+import ChartKit, {settings} from '@gravity-ui/chartkit';
+import {D3Plugin} from '@gravity-ui/chartkit/d3';
+import {Card, Col, Container, Flex, Row, Text} from 'landing-uikit';
+import React, {PropsWithChildren} from 'react';
+
+import {block} from '../../../../../utils';
+import {
+ areaDashboardData,
+ barXDashboardData,
+ dotsDashboardData,
+ linesDashboardData,
+ pieDashboardData,
+} from '../constants';
+
+import './DashboardPreview.scss';
+
+interface StyleCardProps extends PropsWithChildren {}
+
+settings.set({plugins: [D3Plugin]});
+
+const b = block('dashboards-preview');
+
+const StyledCard = ({children}: StyleCardProps) => {
+ return (
+
+ {children}
+
+ );
+};
+
+export const DashboardPreview = ({justify}: {justify: string}) => {
+ return (
+
+ Dashboard
+
+
+
+ About
+
+
+ A dashboard is a visual representation of key performance indicators
+ (KPIs) that helps businesses monitor and analyze their performance in
+ real time. It typically includes graphs, charts, and tables that
+ summarize data from multiple sources, such as financial reports,
+ customer surveys, and operational metrics. Dashboards allow businesses
+ to quickly identify trends and opportunities for improvement, making
+ them a valuable tool for decision-making and strategy development.
+
+
+
+
+
+ {[barXDashboardData, linesDashboardData, areaDashboardData].map(
+ (data, index) => (
+
+
+
+
+
+
+
+ ),
+ )}
+
+
+ {[pieDashboardData, dotsDashboardData].map((data, index) => (
+
+
+
+
+
+
+
+ ))}
+
+
+
+
+ );
+};
diff --git a/src/components/Themes/ui/PreviewTab/FormPreview/FormPreview.scss b/src/components/Themes/ui/PreviewTab/FormPreview/FormPreview.scss
new file mode 100644
index 00000000000..203467e4b85
--- /dev/null
+++ b/src/components/Themes/ui/PreviewTab/FormPreview/FormPreview.scss
@@ -0,0 +1,13 @@
+@use '../../../../../variables';
+@use '~@gravity-ui/page-constructor/styles/variables.scss' as pcVariables;
+@use '~@gravity-ui/uikit/styles/themes/_index.scss' as themes;
+
+$block: '.#{variables.$ns}form-preview';
+
+#{$block} {
+ overflow: auto;
+
+ &__wrapper {
+ width: 600px;
+ }
+}
diff --git a/src/components/Themes/ui/PreviewTab/FormPreview/FormPreview.tsx b/src/components/Themes/ui/PreviewTab/FormPreview/FormPreview.tsx
new file mode 100644
index 00000000000..6fc8e375de0
--- /dev/null
+++ b/src/components/Themes/ui/PreviewTab/FormPreview/FormPreview.tsx
@@ -0,0 +1,82 @@
+import {FormRow} from '@gravity-ui/components';
+import {ArrowLeft} from 'landing-icons';
+import {Button, Flex, Icon, Select, Text, TextArea} from 'landing-uikit';
+import React from 'react';
+
+import {block} from '../../../../../utils';
+import {labels, projects, users} from '../constants';
+
+import './FormPreview.scss';
+
+const b = block('form-preview');
+
+export const FormPreview = ({justify}: {justify: string}) => {
+ return (
+
+
+ User edit
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/src/components/Themes/ui/PreviewTab/PreviewTab.scss b/src/components/Themes/ui/PreviewTab/PreviewTab.scss
new file mode 100644
index 00000000000..8e9cd41316b
--- /dev/null
+++ b/src/components/Themes/ui/PreviewTab/PreviewTab.scss
@@ -0,0 +1,65 @@
+@use '../../../../variables';
+@use '~@gravity-ui/page-constructor/styles/variables.scss' as pcVariables;
+@use '~@gravity-ui/uikit/styles/themes/_index.scss' as themes;
+
+$block: '.#{variables.$ns}themes-preview-layout';
+
+#{$block} {
+ height: 800px;
+ width: 100%;
+ border-radius: 16px;
+ border: 1px solid var(--g-color-line-generic);
+
+ &__aside-header {
+ --gn-aside-header-item-icon-color: var(--g-color-base-brand);
+
+ --gn-aside-header-item-current-background-color: var(--g-color-base-brand);
+ --gn-aside-header-item-current-icon-color: var(--g-color-base-background);
+
+ border-radius: 16px;
+
+ & .gn-aside-header__pane-container .gn-aside-header__aside {
+ border-radius: 16px;
+ }
+
+ & .gn-aside-header__pane-container,
+ & .gn-aside-header__pane-container .gn-aside-header__aside {
+ height: 100%;
+ }
+ }
+
+ &__header-actions {
+ &_hidden {
+ display: none;
+ }
+
+ @media (max-width: map-get(pcVariables.$gridBreakpoints, 'sm') - 1) {
+ display: none;
+ }
+ }
+
+ &__content {
+ width: 100%;
+ height: calc(100% - 40px);
+ overflow: hidden;
+ padding: var(--g-spacing-6) calc(var(--g-spacing-1) * 24);
+
+ @media (max-width: map-get(pcVariables.$gridBreakpoints, 'lg')) {
+ padding: var(--g-spacing-6) calc(var(--g-spacing-10));
+ }
+
+ @media (max-width: map-get(pcVariables.$gridBreakpoints, 'md')) {
+ padding: var(--g-spacing-6) calc(var(--g-spacing-6));
+ }
+ }
+
+ &_theme_dark {
+ scrollbar-color: var(--g-color-scroll-handle) var(--g-color-scroll-track);
+ @include themes.g-theme-dark;
+ }
+
+ &_theme_light {
+ scrollbar-color: var(--g-color-scroll-handle) var(--g-color-scroll-track);
+ @include themes.g-theme-light;
+ }
+}
diff --git a/src/components/Themes/ui/PreviewTab/PreviewTab.tsx b/src/components/Themes/ui/PreviewTab/PreviewTab.tsx
new file mode 100644
index 00000000000..28696199016
--- /dev/null
+++ b/src/components/Themes/ui/PreviewTab/PreviewTab.tsx
@@ -0,0 +1,249 @@
+import {ActionBar, AsideHeader, FooterItem} from '@gravity-ui/navigation';
+import {Breadcrumbs, Flex, Icon, RadioButton, Text, Theme, ThemeProvider} from '@gravity-ui/uikit';
+import {
+ ChartAreaStackedNormalized,
+ Gear,
+ LayoutList,
+ Moon,
+ Person,
+ SquareBars,
+ SquareChartBar,
+ Sun,
+ TextAlignCenter,
+ TextAlignJustify,
+ TextAlignLeft,
+} from 'landing-icons';
+import React, {CSSProperties, Fragment, useState} from 'react';
+
+import gravityUi from '../../../../assets/icons/gravity-ui.svg';
+import {block} from '../../../../utils';
+import {useThemeCreator} from '../../hooks';
+import {exportTheme} from '../../lib/themeCreatorExport';
+
+import {CardsPreview} from './CardsPreview/CardsPreview';
+import {DashboardPreview} from './DashboardsPreview/DashboardPreview';
+import {FormPreview} from './FormPreview/FormPreview';
+import './PreviewTab.scss';
+import {TablePreview} from './TablePreview/TablePreview';
+
+const b = block('themes-preview-layout');
+
+interface PreviewLayoutProps {
+ title: string;
+ id: string;
+ breadCrumbsItems: string[];
+ styles: ReturnType;
+ children: (props: any) => React.ReactNode;
+}
+
+const PreviewLayout = ({breadCrumbsItems, children, styles, id}: PreviewLayoutProps) => {
+ const [theme, setTheme] = useState('dark');
+ const [justify, setJustify] = useState('flex-start');
+ const [isCompact, setCompact] = useState(true);
+
+ const onAlignmentChange = (event: React.ChangeEvent) => {
+ setJustify(event.target.value);
+ };
+
+ const onThemeChange = (event: React.ChangeEvent) => {
+ setTheme(event.target.value as 'light' | 'dark');
+ };
+
+ const renderContent = () => {
+ return (
+
+
+
+
+
+ ({text, action() {}})),
+ ]}
+ firstDisplayedItemsCount={1}
+ />
+
+
+
+
+ {/* Hide alignment in MVP */}
+
+ ,
+ },
+ {
+ value: 'center',
+ content: ,
+ },
+ {
+ value: 'space-between',
+ content: ,
+ },
+ ]}
+ />
+
+
+ ,
+ },
+ {
+ value: 'dark',
+ content: ,
+ },
+ ]}
+ />
+
+
+
+
+
+ {children({justify})}
+
+
+ );
+ };
+
+ return (
+
+ {styles ? (
+
+ ) : null}
+
+
+
(
+
+ {},
+ }}
+ compact={compact}
+ />
+
+ makeItem({...p, icon: }),
+ }}
+ />
+
+ )}
+ />
+
+
+ );
+};
+
+const previewComponents = [
+ {id: 'table', Component: TablePreview, title: 'Table', breadCrumbsItems: ['Table']},
+ {
+ id: 'form',
+ Component: FormPreview,
+ title: 'User edit',
+ breadCrumbsItems: ['Table', 'User edit'],
+ },
+ {
+ id: 'dashboard',
+ Component: DashboardPreview,
+ title: 'Dashboard',
+ breadCrumbsItems: ['Dashboard'],
+ },
+ {id: 'cards', Component: CardsPreview, title: 'Cards', breadCrumbsItems: ['Cards']},
+];
+
+export const PreviewTab = () => {
+ const themeState = useThemeCreator();
+
+ const themeStyles = React.useMemo(
+ () => exportTheme({themeState, ignoreDefaultValues: false}),
+ [themeState],
+ );
+ return (
+
+ UI Samples
+
+ {previewComponents.map(({Component, title, breadCrumbsItems, id}, index) => {
+ return (
+
+ {(props) => }
+
+ );
+ })}
+
+ );
+};
diff --git a/src/components/Themes/ui/PreviewTab/TablePreview/TablePreview.scss b/src/components/Themes/ui/PreviewTab/TablePreview/TablePreview.scss
new file mode 100644
index 00000000000..21b4e3b1779
--- /dev/null
+++ b/src/components/Themes/ui/PreviewTab/TablePreview/TablePreview.scss
@@ -0,0 +1,19 @@
+@use '../../../../../variables';
+@use '~@gravity-ui/page-constructor/styles/variables.scss' as pcVariables;
+@use '~@gravity-ui/uikit/styles/themes/_index.scss' as themes;
+
+$block: '.#{variables.$ns}table-preview';
+
+#{$block} {
+ width: 900px;
+
+ &__wrapper {
+ overflow-x: hidden;
+ }
+
+ &__table-wrapper {
+ max-width: 100%;
+ overflow-x: auto;
+ white-space: nowrap;
+ }
+}
diff --git a/src/components/Themes/ui/PreviewTab/TablePreview/TablePreview.tsx b/src/components/Themes/ui/PreviewTab/TablePreview/TablePreview.tsx
new file mode 100644
index 00000000000..a4858a29811
--- /dev/null
+++ b/src/components/Themes/ui/PreviewTab/TablePreview/TablePreview.tsx
@@ -0,0 +1,148 @@
+import {Copy, Pencil, Plus, TrashBin} from 'landing-icons';
+import {
+ Button,
+ Flex,
+ Icon,
+ Label,
+ LabelProps,
+ Pagination,
+ PaginationProps,
+ Select,
+ Table,
+ TableAction,
+ TableColumnConfig,
+ TableDataItem,
+ Text,
+ TextInput,
+ User,
+ withTableActions,
+ withTableSelection,
+ withTableSorting,
+} from 'landing-uikit';
+import React from 'react';
+
+import avatar1Asset from '../../../../../assets/avatar-1.png';
+import {block} from '../../../../../utils';
+import {labels, projects, tableData} from '../constants';
+
+import './TablePreview.scss';
+
+type TableData = {
+ user: string;
+ project: string;
+ status: {
+ theme: LabelProps['theme'];
+ title: string;
+ };
+ updated: string;
+};
+
+const tableColumns: TableColumnConfig[] = [
+ {
+ id: 'user',
+ name: 'User',
+ template: ({user}) => {
+ return ;
+ },
+ meta: {
+ sort: true,
+ },
+ },
+ {
+ id: 'project',
+ name: 'Project',
+ width: '100%',
+ meta: {
+ sort: true,
+ },
+ },
+ {
+ id: 'status',
+ name: 'Status',
+ template: ({status}) => {
+ return ;
+ },
+ },
+ {
+ id: 'updated',
+ name: 'Updated',
+ },
+];
+
+const getRowActions = (): TableAction>[] => {
+ return [
+ {
+ text: 'Edit',
+ theme: 'normal',
+ icon: ,
+ handler: () => {},
+ },
+ {
+ text: 'Copy',
+ handler: () => {},
+ theme: 'normal',
+ icon: ,
+ },
+ {
+ text: 'Remove',
+ handler: () => {},
+ icon: ,
+ theme: 'danger',
+ },
+ ];
+};
+
+const b = block('table-preview');
+
+const SelectionTable = withTableSelection(withTableSorting(withTableActions(Table)));
+
+export const TablePreview = ({justify}: {justify: string}) => {
+ const [tableSelectedIds, setTableSelectedIds] = React.useState(['1']);
+ const [state, setState] = React.useState({page: 1, pageSize: 10, total: 1000});
+
+ const handleUpdate: PaginationProps['onUpdate'] = (page, pageSize) => {
+ setState((prevState) => ({...prevState, page, pageSize}));
+ };
+
+ return (
+
+ Table
+
+
+
+
+
+
+
+ []}
+ data={tableData}
+ selectedIds={tableSelectedIds}
+ getRowActions={getRowActions}
+ onSelectionChange={setTableSelectedIds}
+ />
+
+
+
+ );
+};
diff --git a/src/components/Themes/ui/PreviewTab/constants.ts b/src/components/Themes/ui/PreviewTab/constants.ts
new file mode 100644
index 00000000000..bdc412e6302
--- /dev/null
+++ b/src/components/Themes/ui/PreviewTab/constants.ts
@@ -0,0 +1,1501 @@
+import {ChartKitWidgetData} from '@gravity-ui/chartkit';
+import {dateTime} from '@gravity-ui/date-utils';
+
+import previewCard1 from '../../../../assets/preview-card-1.png';
+import previewCard2 from '../../../../assets/preview-card-2.png';
+import previewCard3 from '../../../../assets/preview-card-3.png';
+
+export const labels = [
+ {title: 'In progress', theme: 'info'},
+ {title: 'Open', theme: 'success'},
+ {title: 'Testing', theme: 'unknown'},
+ {title: 'Some mistakes', theme: 'danger'},
+];
+
+export const projects = [
+ 'WWCC - incorrect - fix',
+ 'ARC - down lux parameters',
+ 'ASMR - double',
+ 'WWCC - store',
+ 'LUX - programming',
+ 'DSLL - second screen',
+ 'ARC - exstamina',
+ 'ASMR - loader connections',
+ 'LUX - check',
+ 'DSLL - second screen with zoom',
+];
+
+export const users = [
+ 'John Smith',
+ 'David Jones',
+ 'Michael Johnson',
+ 'William Wilson',
+ 'Charles Carter',
+ 'Robert Rodriguez',
+ 'Alexander Adams',
+];
+
+export const tableData = new Array(10).fill(0).map((_, index) => {
+ return {
+ user: users[(index + 1) % 7],
+ project: projects[(index + 1) % 10],
+ updated: dateTime()
+ .add(Math.round(Math.random() * 30), 'days')
+ .format('DD.MM.YYYY hh:mm'),
+ status: labels[(index + 1) % 4],
+ };
+});
+
+export const cardData = [
+ {
+ imgSrc: previewCard1.src,
+ title: 'Limited availability of Managed service for Elasticsearch',
+ text: 'As of July 20, we are suspending the introduction of new functionalities and introducing restrictions on the deployment of clusters for customers new to the service.',
+ date: '10 Mar 2023, 19:37',
+ user: 'John Smith',
+ },
+ {
+ imgSrc: previewCard2.src,
+ title: 'Uploading Audit Trails to Managed Service for OpenSearch',
+ text: 'Today, we’ll talk about how to set up the Audit Trails service to upload audit logs to Managed Service for OpenSearch.',
+ date: '3 Mar 2023, 19:37',
+ user: 'John Smith',
+ },
+ {
+ imgSrc: previewCard3.src,
+ title: 'Managed Service for OpenSearch now public',
+ text: 'On January 23, the distributed search and analytics service entered General Availability. The service now supports third-party authentication and authorization (SAML) providers.',
+ date: '3 Mar 2023, 19:37',
+ user: 'John Smith',
+ },
+ {
+ imgSrc: previewCard1.src,
+ title: 'Managed Service for OpenSearch now public',
+ text: 'On January 23, the distributed search and analytics service entered General Availability. The service now supports third-party authentication and authorization (SAML) providers.',
+ date: '3 Mar 2023, 19:37',
+ user: 'John Smith',
+ },
+ {
+ imgSrc: previewCard2.src,
+ title: 'Managed Service for OpenSearch now public',
+ text: 'On January 23, the distributed search and analytics service entered General Availability. The service now supports third-party authentication and authorization (SAML) providers.',
+ date: '8 Apr 2023, 10:17',
+ user: 'John Smith',
+ },
+ {
+ imgSrc: previewCard3.src,
+ title: 'Managed Service for OpenSearch now public',
+ text: 'On January 23, the distributed search and analytics service entered General Availability. The service now supports third-party authentication and authorization (SAML) providers.',
+ date: '4 Mar 2023, 12:37',
+ user: 'John Smith',
+ },
+ {
+ imgSrc: previewCard1.src,
+ title: 'Managed Service for OpenSearch now public',
+ text: 'On January 23, the distributed search and analytics service entered General Availability. The service now supports third-party authentication and authorization (SAML) providers.',
+ date: '3 Mar 2023, 19:37',
+ user: 'John Smith',
+ },
+ {
+ imgSrc: previewCard2.src,
+ title: 'Managed Service for OpenSearch now public',
+ text: 'On January 23, the distributed search and analytics service entered General Availability. The service now supports third-party authentication and authorization (SAML) providers.',
+ date: '8 Apr 2023, 10:17',
+ user: 'John Smith',
+ },
+ {
+ imgSrc: previewCard3.src,
+ title: 'Managed Service for OpenSearch now public',
+ text: 'On January 23, the distributed search and analytics service entered General Availability. The service now supports third-party authentication and authorization (SAML) providers.',
+ date: '4 Mar 2023, 12:37',
+ user: 'John Smith',
+ },
+];
+
+export const barXDashboardData: ChartKitWidgetData = {
+ series: {
+ data: [
+ {
+ type: 'bar-x',
+ stacking: 'normal',
+ color: 'var(--g-color-text-info)',
+ name: 'Switch',
+ data: [
+ {
+ x: '2023',
+ y: 25,
+ },
+ {
+ x: '2022',
+ y: 17,
+ },
+ {
+ x: '2021',
+ y: 26,
+ },
+ {
+ x: '2020',
+ y: 37,
+ },
+ {
+ x: '2024',
+ y: 2,
+ },
+ ],
+ },
+ {
+ type: 'bar-x',
+ stacking: 'normal',
+ color: 'var(--g-color-text-utility)',
+ name: 'iOS',
+ data: [
+ {
+ x: '2023',
+ y: 1,
+ },
+ {
+ x: '2021',
+ y: 1,
+ },
+ {
+ x: '2020',
+ y: 1,
+ },
+ {
+ x: '2019',
+ y: 2,
+ },
+ {
+ x: '2018',
+ y: 2,
+ },
+ {
+ x: '2017',
+ y: 3,
+ },
+ {
+ x: '2016',
+ y: 3,
+ },
+ {
+ x: '2015',
+ y: 1,
+ },
+ ],
+ },
+ {
+ type: 'bar-x',
+ stacking: 'normal',
+ name: 'WIIU',
+ color: 'var(--g-color-text-misc)',
+ data: [
+ {
+ x: '2020',
+ y: 4,
+ },
+ {
+ x: '2024',
+ y: 14,
+ },
+ {
+ x: '2021',
+ y: 18,
+ },
+ ],
+ },
+ {
+ type: 'bar-x',
+ stacking: 'normal',
+ color: 'var(--g-color-text-danger)',
+ name: 'WII',
+ data: [
+ {
+ x: '2021',
+ y: 9,
+ },
+ {
+ x: '2022',
+ y: 6,
+ },
+ {
+ x: '2023',
+ y: 6,
+ },
+ {
+ x: '2024',
+ y: 26,
+ },
+ ],
+ },
+ {
+ type: 'bar-x',
+ stacking: 'normal',
+ color: 'var(--g-color-text-brand)',
+ name: 'DS',
+ data: [
+ {
+ x: '2021',
+ y: 5,
+ },
+ {
+ x: '2022',
+ y: 9,
+ },
+ {
+ x: '2023',
+ y: 43,
+ },
+ {
+ x: '2024',
+ y: 64,
+ },
+ ],
+ },
+ ],
+ },
+ xAxis: {
+ type: 'category',
+ categories: ['2020', '2021', '2022', '2023', '2024'],
+ },
+ legend: {
+ enabled: false,
+ },
+};
+
+export const linesDashboardData: ChartKitWidgetData = {
+ legend: {
+ enabled: false,
+ },
+ series: {
+ data: [
+ {
+ type: 'line',
+ color: 'var(--g-color-text-info)',
+ data: [
+ {
+ x: 1397419200000,
+ y: -81,
+ },
+ {
+ x: 1397332800000,
+ y: -84,
+ },
+ {
+ x: 1397246400000,
+ y: -84,
+ },
+ {
+ x: 1397160000000,
+ y: -84,
+ },
+ {
+ x: 1397073600000,
+ y: -82,
+ },
+ {
+ x: 1396987200000,
+ y: -83,
+ },
+ {
+ x: 1396900800000,
+ y: -83,
+ },
+ {
+ x: 1396814400000,
+ y: -83,
+ },
+ {
+ x: 1396728000000,
+ y: -83,
+ },
+ {
+ x: 1396641600000,
+ y: -82,
+ },
+ {
+ x: 1396555200000,
+ y: -82,
+ },
+ {
+ x: 1396468800000,
+ y: -85,
+ },
+ {
+ x: 1396382400000,
+ y: -84,
+ },
+ {
+ x: 1396296000000,
+ y: -85,
+ },
+ {
+ x: 1396209600000,
+ y: -82,
+ },
+ {
+ x: 1396123200000,
+ y: -83,
+ },
+ {
+ x: 1396036800000,
+ y: -82,
+ },
+ {
+ x: 1395950400000,
+ y: -82,
+ },
+ {
+ x: 1395864000000,
+ y: -84,
+ },
+ {
+ x: 1395777600000,
+ y: -84,
+ },
+ {
+ x: 1395691200000,
+ y: -83,
+ },
+ {
+ x: 1395604800000,
+ y: -82,
+ },
+ {
+ x: 1395432000000,
+ y: -83,
+ },
+ {
+ x: 1395345600000,
+ y: -84,
+ },
+ {
+ x: 1395259200000,
+ y: -84,
+ },
+ {
+ x: 1395172800000,
+ y: -82,
+ },
+ {
+ x: 1395086400000,
+ y: -85,
+ },
+ {
+ x: 1395000000000,
+ y: -84,
+ },
+ {
+ x: 1394913600000,
+ y: -85,
+ },
+ {
+ x: 1394827200000,
+ y: -84,
+ },
+ {
+ x: 1394740800000,
+ y: -84,
+ },
+ {
+ x: 1394654400000,
+ y: -85,
+ },
+ {
+ x: 1394568000000,
+ y: -86,
+ },
+ {
+ x: 1394481600000,
+ y: -86,
+ },
+ {
+ x: 1394395200000,
+ y: -86,
+ },
+ {
+ x: 1394308800000,
+ y: -85,
+ },
+ {
+ x: 1394222400000,
+ y: -86,
+ },
+ {
+ x: 1394136000000,
+ y: -87,
+ },
+ {
+ x: 1394049600000,
+ y: -85,
+ },
+ {
+ x: 1393963200000,
+ y: -85,
+ },
+ {
+ x: 1393876800000,
+ y: -86,
+ },
+ {
+ x: 1393790400000,
+ y: -85,
+ },
+ {
+ x: 1393704000000,
+ y: -85,
+ },
+ {
+ x: 1393617600000,
+ y: -85,
+ },
+ {
+ x: 1393531200000,
+ y: -85,
+ },
+ {
+ x: 1393444800000,
+ y: -85,
+ },
+ {
+ x: 1393358400000,
+ y: -84,
+ },
+ {
+ x: 1393272000000,
+ y: -84,
+ },
+ {
+ x: 1393185600000,
+ y: -86,
+ },
+ {
+ x: 1393099200000,
+ y: -85,
+ },
+ {
+ x: 1393012800000,
+ y: -85,
+ },
+ {
+ x: 1392926400000,
+ y: -87,
+ },
+ {
+ x: 1392840000000,
+ y: -86,
+ },
+ {
+ x: 1392753600000,
+ y: -85,
+ },
+ {
+ x: 1392667200000,
+ y: -85,
+ },
+ {
+ x: 1392580800000,
+ y: -85,
+ },
+ {
+ x: 1392494400000,
+ y: -86,
+ },
+ {
+ x: 1392408000000,
+ y: -84,
+ },
+ {
+ x: 1392235200000,
+ y: -85,
+ },
+ {
+ x: 1392148800000,
+ y: -84,
+ },
+ {
+ x: 1392062400000,
+ y: -84,
+ },
+ {
+ x: 1391976000000,
+ y: -85,
+ },
+ {
+ x: 1391889600000,
+ y: -85,
+ },
+ {
+ x: 1391803200000,
+ y: -83,
+ },
+ {
+ x: 1391716800000,
+ y: -83,
+ },
+ {
+ x: 1391630400000,
+ y: -88,
+ },
+ {
+ x: 1391544000000,
+ y: -86,
+ },
+ {
+ x: 1391457600000,
+ y: -87,
+ },
+ {
+ x: 1391371200000,
+ y: -88,
+ },
+ {
+ x: 1391284800000,
+ y: -87,
+ },
+ {
+ x: 1391198400000,
+ y: -87,
+ },
+ {
+ x: 1391112000000,
+ y: -87,
+ },
+ {
+ x: 1391025600000,
+ y: -86,
+ },
+ {
+ x: 1390939200000,
+ y: -86,
+ },
+ {
+ x: 1390852800000,
+ y: -87,
+ },
+ {
+ x: 1390766400000,
+ y: -87,
+ },
+ {
+ x: 1390680000000,
+ y: -85,
+ },
+ {
+ x: 1390593600000,
+ y: -85,
+ },
+ {
+ x: 1390507200000,
+ y: -85,
+ },
+ {
+ x: 1390420800000,
+ y: -87,
+ },
+ {
+ x: 1390334400000,
+ y: -86,
+ },
+ {
+ x: 1390248000000,
+ y: -86,
+ },
+ {
+ x: 1390161600000,
+ y: -85,
+ },
+ {
+ x: 1390075200000,
+ y: -86,
+ },
+ {
+ x: 1389988800000,
+ y: -86,
+ },
+ {
+ x: 1389902400000,
+ y: -86,
+ },
+ {
+ x: 1389816000000,
+ y: -86,
+ },
+ {
+ x: 1389729600000,
+ y: -86,
+ },
+ {
+ x: 1389643200000,
+ y: -86,
+ },
+ {
+ x: 1389556800000,
+ y: -85,
+ },
+ {
+ x: 1389470400000,
+ y: -85,
+ },
+ {
+ x: 1389384000000,
+ y: -86,
+ },
+ {
+ x: 1389297600000,
+ y: -83,
+ },
+ {
+ x: 1389211200000,
+ y: -85,
+ },
+ {
+ x: 1389124800000,
+ y: -86,
+ },
+ {
+ x: 1388952000000,
+ y: -85,
+ },
+ {
+ x: 1388865600000,
+ y: -85,
+ },
+ {
+ x: 1388779200000,
+ y: -86,
+ },
+ {
+ x: 1388692800000,
+ y: -87,
+ },
+ {
+ x: 1388606400000,
+ y: -86,
+ },
+ ],
+ name: 'Min Temperature',
+ yAxis: 0,
+ },
+ {
+ type: 'line',
+ color: 'var(--g-color-text-brand)',
+ data: [
+ {
+ x: 1397419200000,
+ y: -25,
+ },
+ {
+ x: 1397332800000,
+ y: -20,
+ },
+ {
+ x: 1397246400000,
+ y: -25,
+ },
+ {
+ x: 1397160000000,
+ y: -26,
+ },
+ {
+ x: 1397073600000,
+ y: -24,
+ },
+ {
+ x: 1396987200000,
+ y: -25,
+ },
+ {
+ x: 1396900800000,
+ y: -22,
+ },
+ {
+ x: 1396814400000,
+ y: -23,
+ },
+ {
+ x: 1396728000000,
+ y: -26,
+ },
+ {
+ x: 1396641600000,
+ y: -26,
+ },
+ {
+ x: 1396555200000,
+ y: -26,
+ },
+ {
+ x: 1396468800000,
+ y: -27,
+ },
+ {
+ x: 1396382400000,
+ y: -24,
+ },
+ {
+ x: 1396296000000,
+ y: -28,
+ },
+ {
+ x: 1396209600000,
+ y: -24,
+ },
+ {
+ x: 1396123200000,
+ y: -26,
+ },
+ {
+ x: 1396036800000,
+ y: -25,
+ },
+ {
+ x: 1395950400000,
+ y: -27,
+ },
+ {
+ x: 1395864000000,
+ y: -27,
+ },
+ {
+ x: 1395777600000,
+ y: -28,
+ },
+ {
+ x: 1395691200000,
+ y: -24,
+ },
+ {
+ x: 1395604800000,
+ y: -22,
+ },
+ {
+ x: 1395432000000,
+ y: -23,
+ },
+ {
+ x: 1395345600000,
+ y: -27,
+ },
+ {
+ x: 1395259200000,
+ y: -26,
+ },
+ {
+ x: 1395172800000,
+ y: -26,
+ },
+ {
+ x: 1395086400000,
+ y: -23,
+ },
+ {
+ x: 1395000000000,
+ y: -27,
+ },
+ {
+ x: 1394913600000,
+ y: -27,
+ },
+ {
+ x: 1394827200000,
+ y: -26,
+ },
+ {
+ x: 1394740800000,
+ y: -26,
+ },
+ {
+ x: 1394654400000,
+ y: -27,
+ },
+ {
+ x: 1394568000000,
+ y: -28,
+ },
+ {
+ x: 1394481600000,
+ y: -28,
+ },
+ {
+ x: 1394395200000,
+ y: -27,
+ },
+ {
+ x: 1394308800000,
+ y: -27,
+ },
+ {
+ x: 1394222400000,
+ y: -27,
+ },
+ {
+ x: 1394136000000,
+ y: -31,
+ },
+ {
+ x: 1394049600000,
+ y: -23,
+ },
+ {
+ x: 1393963200000,
+ y: -23,
+ },
+ {
+ x: 1393876800000,
+ y: -23,
+ },
+ {
+ x: 1393790400000,
+ y: -27,
+ },
+ {
+ x: 1393704000000,
+ y: -26,
+ },
+ {
+ x: 1393617600000,
+ y: -29,
+ },
+ {
+ x: 1393531200000,
+ y: -29,
+ },
+ {
+ x: 1393444800000,
+ y: -31,
+ },
+ {
+ x: 1393358400000,
+ y: -22,
+ },
+ {
+ x: 1393272000000,
+ y: -26,
+ },
+ {
+ x: 1393185600000,
+ y: -29,
+ },
+ {
+ x: 1393099200000,
+ y: -28,
+ },
+ {
+ x: 1393012800000,
+ y: -27,
+ },
+ {
+ x: 1392926400000,
+ y: -23,
+ },
+ {
+ x: 1392840000000,
+ y: -28,
+ },
+ {
+ x: 1392753600000,
+ y: -29,
+ },
+ {
+ x: 1392667200000,
+ y: -34,
+ },
+ {
+ x: 1392580800000,
+ y: -29,
+ },
+ {
+ x: 1392494400000,
+ y: -27,
+ },
+ {
+ x: 1392408000000,
+ y: -26,
+ },
+ {
+ x: 1392235200000,
+ y: -28,
+ },
+ {
+ x: 1392148800000,
+ y: -27,
+ },
+ {
+ x: 1392062400000,
+ y: -29,
+ },
+ {
+ x: 1391976000000,
+ y: -23,
+ },
+ {
+ x: 1391889600000,
+ y: -25,
+ },
+ {
+ x: 1391803200000,
+ y: -28,
+ },
+ {
+ x: 1391716800000,
+ y: -29,
+ },
+ {
+ x: 1391630400000,
+ y: -29,
+ },
+ {
+ x: 1391544000000,
+ y: -29,
+ },
+ {
+ x: 1391457600000,
+ y: -30,
+ },
+ {
+ x: 1391371200000,
+ y: -23,
+ },
+ {
+ x: 1391284800000,
+ y: -22,
+ },
+ {
+ x: 1391198400000,
+ y: -28,
+ },
+ {
+ x: 1391112000000,
+ y: -23,
+ },
+ {
+ x: 1391025600000,
+ y: -26,
+ },
+ {
+ x: 1390939200000,
+ y: -23,
+ },
+ {
+ x: 1390852800000,
+ y: -24,
+ },
+ {
+ x: 1390766400000,
+ y: -29,
+ },
+ {
+ x: 1390680000000,
+ y: -27,
+ },
+ {
+ x: 1390593600000,
+ y: -25,
+ },
+ {
+ x: 1390507200000,
+ y: -26,
+ },
+ {
+ x: 1390420800000,
+ y: -26,
+ },
+ {
+ x: 1390334400000,
+ y: -24,
+ },
+ {
+ x: 1390248000000,
+ y: -25,
+ },
+ {
+ x: 1390161600000,
+ y: -29,
+ },
+ {
+ x: 1390075200000,
+ y: -27,
+ },
+ {
+ x: 1389988800000,
+ y: -25,
+ },
+ {
+ x: 1389902400000,
+ y: -23,
+ },
+ {
+ x: 1389816000000,
+ y: -29,
+ },
+ {
+ x: 1389729600000,
+ y: -29,
+ },
+ {
+ x: 1389643200000,
+ y: -24,
+ },
+ {
+ x: 1389556800000,
+ y: -31,
+ },
+ {
+ x: 1389470400000,
+ y: -31,
+ },
+ {
+ x: 1389384000000,
+ y: -30,
+ },
+ {
+ x: 1389297600000,
+ y: -29,
+ },
+ {
+ x: 1389211200000,
+ y: -25,
+ },
+ {
+ x: 1389124800000,
+ y: -27,
+ },
+ {
+ x: 1388952000000,
+ y: -29,
+ },
+ {
+ x: 1388865600000,
+ y: -29,
+ },
+ {
+ x: 1388779200000,
+ y: -28,
+ },
+ {
+ x: 1388692800000,
+ y: -30,
+ },
+ {
+ x: 1388606400000,
+ y: -28,
+ },
+ ],
+ name: 'Max Temperature',
+ yAxis: 1,
+ },
+ ],
+ },
+ yAxis: [{}, {}],
+};
+
+export const areaDashboardData: ChartKitWidgetData = {
+ series: {
+ data: [
+ {
+ type: 'area',
+ stacking: 'normal',
+ color: 'var(--g-color-text-utility)',
+ name: 'Switch',
+ data: [
+ {
+ x: '2017',
+ y: 24,
+ },
+ {
+ x: '2018',
+ y: 35,
+ },
+ {
+ x: '2019',
+ y: 38,
+ },
+ {
+ x: '2020',
+ y: 37,
+ },
+ {
+ x: '2021',
+ y: 26,
+ },
+ {
+ x: '2022',
+ y: 17,
+ },
+ {
+ x: '2023',
+ y: 25,
+ },
+ {
+ x: '2024',
+ y: 2,
+ },
+ ],
+ },
+ {
+ type: 'area',
+ stacking: 'normal',
+ name: 'iOS',
+ color: 'var(--g-color-text-positive)',
+ data: [
+ {
+ x: '2017',
+ y: 3,
+ },
+ {
+ x: '2018',
+ y: 2,
+ },
+ {
+ x: '2019',
+ y: 2,
+ },
+ {
+ x: '2020',
+ y: 1,
+ },
+ {
+ x: '2021',
+ y: 1,
+ },
+ {
+ x: '2023',
+ y: 1,
+ },
+ ],
+ },
+ {
+ type: 'area',
+ stacking: 'normal',
+ name: '3DS',
+ color: 'var(--g-color-text-brand)',
+ data: [
+ {
+ x: '2015',
+ y: 22,
+ },
+ {
+ x: '2016',
+ y: 37,
+ },
+ {
+ x: '2017',
+ y: 42,
+ },
+ {
+ x: '2018',
+ y: 12,
+ },
+ {
+ x: '2019',
+ y: 3,
+ },
+ {
+ x: '2024',
+ y: 3,
+ },
+ ],
+ },
+ {
+ type: 'area',
+ stacking: 'normal',
+ name: 'WIIU',
+ color: 'var(--g-color-text-utility)',
+ data: [
+ {
+ x: '2015',
+ y: 18,
+ },
+ {
+ x: '2016',
+ y: 14,
+ },
+ {
+ x: '2017',
+ y: 4,
+ },
+ {
+ x: '2020',
+ y: 7,
+ },
+ ],
+ },
+ {
+ type: 'area',
+ stacking: 'normal',
+ name: 'WII',
+ color: 'var(--g-color-text-danger)',
+ data: [
+ {
+ x: '2015',
+ y: 20,
+ },
+ {
+ x: '2016',
+ y: 68,
+ },
+ {
+ x: '2017',
+ y: 27,
+ },
+ {
+ x: '2018',
+ y: 26,
+ },
+ {
+ x: '2019',
+ y: 26,
+ },
+ {
+ x: '2020',
+ y: 6,
+ },
+ {
+ x: '2021',
+ y: 9,
+ },
+ ],
+ },
+ ],
+ },
+ legend: {
+ enabled: false,
+ },
+ xAxis: {
+ type: 'category',
+ categories: [
+ '2015',
+ '2016',
+ '2017',
+ '2018',
+ '2019',
+ '2020',
+ '2021',
+ '2022',
+ '2023',
+ '2024',
+ ],
+ },
+};
+
+export const pieDashboardData: ChartKitWidgetData = {
+ series: {
+ data: [
+ {
+ type: 'pie',
+ data: [
+ {
+ name: 'Switch',
+ value: 209,
+ color: 'var(--g-color-text-danger)',
+ },
+ {
+ name: 'iOS',
+ value: 14,
+ color: 'var(--g-color-text-utility)',
+ },
+ {
+ name: '3DS',
+ value: 259,
+ color: 'var(--g-color-text-info)',
+ },
+ {
+ name: 'WIIU',
+ value: 80,
+ color: 'var(--g-color-text-positive)',
+ },
+ {
+ name: 'WII',
+ value: 188,
+ color: 'var(--g-color-text-warning)',
+ },
+ {
+ name: 'DS',
+ value: 196,
+ color: 'var(--g-color-text-brand)',
+ },
+ ],
+ },
+ ],
+ },
+ legend: {
+ enabled: false,
+ },
+};
+
+export const dotsDashboardData: ChartKitWidgetData = {
+ legend: {
+ enabled: false,
+ },
+ series: {
+ data: [
+ {
+ color: 'var(--g-color-base-brand)',
+ name: 'Min Temp',
+ type: 'scatter',
+ data: [
+ {
+ x: 1689886800000,
+ y: 9,
+ },
+ {
+ x: 1689109200000,
+ y: 7.6,
+ },
+ {
+ x: 1688072400000,
+ y: 5.4,
+ },
+ {
+ x: 1687294800000,
+ y: 8.4,
+ },
+ {
+ x: 1687294800000,
+ y: 8.6,
+ },
+ {
+ x: 1687294800000,
+ y: 8.5,
+ },
+ {
+ x: 1683838800000,
+ y: 8.2,
+ },
+ {
+ x: 1682370000000,
+ y: 8.8,
+ },
+ {
+ x: 1682024400000,
+ y: 8.1,
+ },
+ {
+ x: 1679004000000,
+ y: 8.6,
+ },
+ {
+ x: 1678312800000,
+ y: 7.8,
+ },
+ {
+ x: 1677189600000,
+ y: 8.8,
+ },
+ {
+ x: 1675807200000,
+ y: 8.7,
+ },
+ {
+ x: 1674165600000,
+ y: 6.6,
+ },
+ {
+ x: 1670364000000,
+ y: 7.3,
+ },
+ {
+ x: 1668722400000,
+ y: 4,
+ },
+ {
+ x: 1668722400000,
+ y: 3.4,
+ },
+ {
+ x: 1668722400000,
+ y: 8,
+ },
+ {
+ x: 1666904400000,
+ y: 7.1,
+ },
+ {
+ x: 1662670800000,
+ y: 6.8,
+ },
+ {
+ x: 1660683600000,
+ y: 7.3,
+ },
+ {
+ x: 1659560400000,
+ y: 6.8,
+ },
+ {
+ x: 1659042000000,
+ y: 8.5,
+ },
+ ],
+ },
+ {
+ color: 'var(--g-color-text-danger)',
+ name: 'Max Temp',
+ type: 'scatter',
+ data: [
+ {
+ x: 1687838800000,
+ y: 8.2,
+ },
+ {
+ x: 1678370000000,
+ y: 8.8,
+ },
+ {
+ x: 1685838800000,
+ y: 8.2,
+ },
+ {
+ x: 1672370000000,
+ y: 8.8,
+ },
+ {
+ x: 1662024400000,
+ y: 8.1,
+ },
+ {
+ x: 1679004000000,
+ y: 8.6,
+ },
+ {
+ x: 1678312800000,
+ y: 7.8,
+ },
+ {
+ x: 1687189600000,
+ y: 8.8,
+ },
+ {
+ x: 1671807200000,
+ y: 8.7,
+ },
+ {
+ x: 1645165600000,
+ y: 6.6,
+ },
+ {
+ x: 1667364000000,
+ y: 7.3,
+ },
+ {
+ x: 1670722400000,
+ y: 4,
+ },
+ {
+ x: 1667822400000,
+ y: 3.4,
+ },
+ {
+ x: 1660922400000,
+ y: 8,
+ },
+ {
+ x: 1661204400000,
+ y: 7.1,
+ },
+ ],
+ },
+ ],
+ },
+ yAxis: [{}, {}],
+};
diff --git a/src/components/Themes/ui/PrivateColorSelect/PrivateColorSelect.scss b/src/components/Themes/ui/PrivateColorSelect/PrivateColorSelect.scss
new file mode 100644
index 00000000000..abd5efbc501
--- /dev/null
+++ b/src/components/Themes/ui/PrivateColorSelect/PrivateColorSelect.scss
@@ -0,0 +1,31 @@
+@use '~@gravity-ui/page-constructor/styles/variables.scss' as pcVariables;
+@use '../../../../variables.scss';
+
+$block: '.#{variables.$ns}private-colors-select';
+
+#{$block} {
+ --g-button-border-radius: 8px;
+ --g-text-input-border-radius: 8px;
+ --g-color-base-selection: var(--g-color-private-yellow-150);
+
+ &__preview {
+ margin-inline-start: var(--g-spacing-2);
+ margin-inline-end: var(--g-spacing-1);
+ }
+
+ &__input {
+ cursor: pointer;
+
+ input {
+ cursor: pointer;
+ }
+ }
+
+ &__arrow-button {
+ --g-button-background-color-hover: transparent;
+ }
+
+ &__customize-button {
+ flex-shrink: 0;
+ }
+}
diff --git a/src/components/Themes/ui/PrivateColorSelect/PrivateColorSelect.tsx b/src/components/Themes/ui/PrivateColorSelect/PrivateColorSelect.tsx
new file mode 100644
index 00000000000..7f91b708de2
--- /dev/null
+++ b/src/components/Themes/ui/PrivateColorSelect/PrivateColorSelect.tsx
@@ -0,0 +1,145 @@
+import {ChevronDown, PencilToLine} from 'landing-icons';
+import {Button, Flex, Icon, Popup, Sheet, TextInput, ThemeProvider} from 'landing-uikit';
+import React from 'react';
+
+import {useIsMobile} from '../../../../hooks/useIsMobile';
+import {block} from '../../../../utils';
+import {isPrivateColorToken} from '../../lib/themeCreatorUtils';
+import {ColorPickerInput} from '../ColorPickerInput/ColorPickerInput';
+import {ColorPreview} from '../ColorPreview/ColorPreview';
+
+import './PrivateColorSelect.scss';
+import {PrivateColorSelectPopupContent} from './PrivateColorSelectPopupContent';
+import type {ColorGroup} from './types';
+
+const b = block('private-colors-select');
+
+interface PrivateColorSelectProps {
+ value?: string;
+ defaultValue: string;
+ onChange: (color: string) => void;
+ groups: ColorGroup[];
+}
+
+export const PrivateColorSelect: React.FC = ({
+ groups,
+ value,
+ defaultValue,
+ onChange,
+}) => {
+ const isMobile = useIsMobile();
+
+ const containerRef = React.useRef(null);
+ const [showPopup, setShowPopup] = React.useState(false);
+ const isCustomValue = !isPrivateColorToken(value);
+
+ const handleChange = React.useCallback(
+ (newVal: string) => {
+ onChange(newVal);
+ setShowPopup(false);
+ },
+ [onChange],
+ );
+
+ const switchMode = React.useCallback(() => {
+ if (isCustomValue) {
+ onChange(defaultValue);
+ } else {
+ onChange('');
+ setShowPopup(false);
+ }
+ }, [isCustomValue, onChange, defaultValue, showPopup]);
+
+ const privateColor = React.useMemo(() => {
+ const colorGroup = value
+ ? groups.find((group) => group.privateColors.some((color) => color.token === value))
+ : undefined;
+
+ return value
+ ? colorGroup?.privateColors?.find((color) => color.token === value)
+ : undefined;
+ }, [groups, value]);
+
+ const toggleShowPopup = React.useCallback(() => setShowPopup((prev) => !prev), []);
+ const closePopup = React.useCallback(() => setShowPopup(false), []);
+
+ const handleClickArrowButton = React.useCallback(
+ (event: React.MouseEvent) => {
+ event.stopPropagation();
+ toggleShowPopup();
+ },
+ [toggleShowPopup],
+ );
+
+ return (
+
+ {isCustomValue ? (
+
+ ) : (
+
+ }
+ endContent={
+
+
+
+ }
+ controlProps={{
+ readOnly: true,
+ }}
+ onFocus={toggleShowPopup}
+ />
+ )}
+
+
+ {isMobile ? (
+
+
+
+ ) : (
+
+
+
+ )}
+
+
+ );
+};
diff --git a/src/components/Themes/ui/PrivateColorSelect/PrivateColorSelectPopupContent.scss b/src/components/Themes/ui/PrivateColorSelect/PrivateColorSelectPopupContent.scss
new file mode 100644
index 00000000000..105592cb9b5
--- /dev/null
+++ b/src/components/Themes/ui/PrivateColorSelect/PrivateColorSelectPopupContent.scss
@@ -0,0 +1,71 @@
+@use '~@gravity-ui/page-constructor/styles/variables.scss' as pcVariables;
+@use '../../../../variables.scss';
+
+$block: '.#{variables.$ns}private-colors-select-popup';
+
+#{$block} {
+ display: flex;
+ background-color: #383438;
+
+ &_version {
+ &_desktop {
+ width: 465px;
+ height: 472px;
+ border-radius: 8px;
+
+ @media (max-width: map-get(pcVariables.$gridBreakpoints, 'lg')) {
+ height: 370px;
+ }
+
+ @media (max-width: map-get(pcVariables.$gridBreakpoints, 'md')) {
+ height: 300px;
+ }
+ }
+
+ &_mobile {
+ flex-direction: column;
+
+ #{$block}__left {
+ border-right: none;
+ }
+ }
+ }
+
+ &__left,
+ &__right {
+ padding: var(--g-spacing-2) var(--g-spacing-2) 0;
+ height: 100%;
+ overflow: auto;
+ }
+
+ &__left {
+ min-width: 150px;
+ border-right: 1px solid var(--g-color-line-generic);
+ }
+
+ &__right {
+ flex-grow: 1;
+ }
+
+ &__colors-list {
+ &-item {
+ --g-color-base-selection: rgba(255, 190, 92, 0.2);
+ border-radius: var(--g-border-radius-m);
+ cursor: pointer;
+ margin-bottom: 2px;
+ }
+ }
+
+ &__color-item {
+ display: flex;
+ align-items: center;
+ gap: var(--g-spacing-1);
+ padding: 5px var(--g-spacing-2);
+ }
+
+ &__colors-select {
+ width: 100%;
+ --g-border-radius-xl: 8px;
+ padding-bottom: var(--g-spacing-3);
+ }
+}
diff --git a/src/components/Themes/ui/PrivateColorSelect/PrivateColorSelectPopupContent.tsx b/src/components/Themes/ui/PrivateColorSelect/PrivateColorSelectPopupContent.tsx
new file mode 100644
index 00000000000..99ea21d9551
--- /dev/null
+++ b/src/components/Themes/ui/PrivateColorSelect/PrivateColorSelectPopupContent.tsx
@@ -0,0 +1,159 @@
+import {List, Select, SelectOption, Text} from 'landing-uikit';
+import React from 'react';
+
+import {block} from '../../../../utils';
+import {parsePrivateColorToken} from '../../lib/themeCreatorUtils';
+import {ColorPreview} from '../ColorPreview/ColorPreview';
+
+import './PrivateColorSelectPopupContent.scss';
+import type {BaseColor, ColorGroup} from './types';
+
+const b = block('private-colors-select-popup');
+
+type ColorItemProps = {
+ color: string;
+ title: string;
+};
+
+const ColorItem: React.FC = ({title, color}) => {
+ return (
+
+
+ {title}
+
+ );
+};
+
+interface ColorsListProps {
+ colors: BaseColor[];
+ value?: string;
+ onSelect: (value: string) => void;
+ view?: 'select' | 'list';
+}
+
+const ColorsList: React.FC = ({colors, value, onSelect, view = 'list'}) => {
+ const selectedIndex = React.useMemo(
+ () => colors.findIndex((item) => item.token === value),
+ [colors, value],
+ );
+
+ const handleSelect = React.useCallback(
+ (item: BaseColor) => {
+ onSelect(item.token);
+ },
+ [onSelect],
+ );
+
+ const renderItem = React.useCallback(
+ (item: BaseColor) => ,
+ [],
+ );
+
+ const selectOptions = React.useMemo(() => {
+ return colors.map((color) => ({data: color, value: color.token}));
+ }, [colors]);
+
+ const renderOption = React.useCallback(
+ (option: SelectOption) => {
+ return (
+
+ {renderItem(option.data as BaseColor)}
+
+ );
+ },
+ [renderItem],
+ );
+
+ const handleSelectChange = React.useCallback(
+ (newToken: string[]) => {
+ const newColor = colors.find((item) => item.token === newToken[0]);
+ if (newColor) {
+ handleSelect(newColor);
+ }
+ },
+ [colors, handleSelect],
+ );
+
+ return view === 'select' ? (
+
+ className={b('colors-select')}
+ size="xl"
+ options={selectOptions}
+ renderOption={renderOption}
+ renderSelectedOption={renderOption}
+ popupPlacement={'top-end'}
+ value={value ? [value] : undefined}
+ onUpdate={handleSelectChange}
+ disablePortal
+ />
+ ) : (
+
+ items={colors}
+ filterable={false}
+ virtualized={false}
+ selectedItemIndex={selectedIndex}
+ onItemClick={handleSelect}
+ renderItem={renderItem}
+ className={b('colors-list')}
+ itemClassName={b('colors-list-item')}
+ />
+ );
+};
+
+interface PrivateColorSelectPopupContentProps {
+ groups: ColorGroup[];
+ value?: string;
+ onChange: (token: string) => void;
+ version?: 'mobile' | 'desktop';
+}
+
+export const PrivateColorSelectPopupContent: React.FC = ({
+ value,
+ groups,
+ onChange,
+ version = 'desktop',
+}) => {
+ const colorsRef = React.useRef(null);
+
+ const [currentGroupToken, setCurrentGroupToken] = React.useState(() =>
+ value ? parsePrivateColorToken(value)?.mainColorToken : undefined,
+ );
+
+ React.useEffect(() => {
+ const mainColorToken = value ? parsePrivateColorToken(value)?.mainColorToken : undefined;
+
+ if (mainColorToken) {
+ setCurrentGroupToken(mainColorToken);
+ }
+ }, [value]);
+
+ const groupToken = currentGroupToken || groups[0].token;
+
+ React.useEffect(() => {
+ colorsRef.current?.scrollTo({
+ top: 0,
+ behavior: 'smooth',
+ });
+ }, [groupToken]);
+
+ const groupPrivateColors = React.useMemo(
+ () => groups.find(({token}) => token === groupToken)?.privateColors || [],
+ [groups, groupToken],
+ );
+
+ return (
+
+ );
+};
diff --git a/src/components/Themes/ui/PrivateColorSelect/index.ts b/src/components/Themes/ui/PrivateColorSelect/index.ts
new file mode 100644
index 00000000000..80cd714d352
--- /dev/null
+++ b/src/components/Themes/ui/PrivateColorSelect/index.ts
@@ -0,0 +1 @@
+export {PrivateColorSelect} from './PrivateColorSelect';
diff --git a/src/components/Themes/ui/PrivateColorSelect/types.ts b/src/components/Themes/ui/PrivateColorSelect/types.ts
new file mode 100644
index 00000000000..920d8052f6b
--- /dev/null
+++ b/src/components/Themes/ui/PrivateColorSelect/types.ts
@@ -0,0 +1,9 @@
+export type BaseColor = {
+ token: string;
+ title: string;
+ color: string;
+};
+
+export type ColorGroup = BaseColor & {
+ privateColors: BaseColor[];
+};
diff --git a/src/components/Themes/ui/PrivateColorsSettings/PrivateColorsSettings.scss b/src/components/Themes/ui/PrivateColorsSettings/PrivateColorsSettings.scss
new file mode 100644
index 00000000000..5027c5c591f
--- /dev/null
+++ b/src/components/Themes/ui/PrivateColorsSettings/PrivateColorsSettings.scss
@@ -0,0 +1,31 @@
+@use '../../../../variables.scss';
+@use '~@gravity-ui/page-constructor/styles/variables.scss' as pcVariables;
+
+$block: '.#{variables.$ns}private-colors-settings';
+
+#{$block} {
+ & &__row {
+ --gc-form-row-label-width: 400px;
+ --gc-form-row-field-height: 36px;
+ margin-block-end: 16px;
+
+ .gc-form-row__right {
+ width: 400px;
+ flex-grow: 0;
+ }
+
+ @media (max-width: map-get(pcVariables.$gridBreakpoints, 'lg')) {
+ --gc-form-row-label-width: 329px;
+ }
+
+ @media (max-width: map-get(pcVariables.$gridBreakpoints, 'sm')) {
+ --gc-form-row-label-width: auto;
+ flex-direction: column;
+ align-items: flex-start;
+
+ .gc-form-row__right {
+ width: 100%;
+ }
+ }
+ }
+}
diff --git a/src/components/Themes/ui/PrivateColorsSettings/PrivateColorsSettings.tsx b/src/components/Themes/ui/PrivateColorsSettings/PrivateColorsSettings.tsx
new file mode 100644
index 00000000000..990f86ba717
--- /dev/null
+++ b/src/components/Themes/ui/PrivateColorsSettings/PrivateColorsSettings.tsx
@@ -0,0 +1,81 @@
+import React from 'react';
+
+import {block} from '../../../../utils';
+import {useThemePrivateColorOptions, useThemeUtilityColor} from '../../hooks';
+import {DEFAULT_THEME} from '../../lib/constants';
+import {ThemeColorOption} from '../../lib/themeCreatorUtils';
+import type {ColorsOptions, ThemeVariant} from '../../lib/types';
+import {PrivateColorSelect} from '../PrivateColorSelect';
+import {ThemableSettings} from '../ThemableSettings/ThemableSettings';
+import {ThemableRow} from '../ThemableSettings/types';
+import {ThemeSection} from '../ThemeSection';
+
+import './PrivateColorsSettings.scss';
+
+const b = block('private-colors-settings');
+
+interface PrivateColorEditorProps {
+ name: keyof ColorsOptions;
+ theme: ThemeVariant;
+ colorGroups: ThemeColorOption[];
+}
+
+const PrivateColorEditor: React.FC = ({name, theme, colorGroups}) => {
+ const [color, setColor] = useThemeUtilityColor({
+ name,
+ theme,
+ });
+
+ return (
+
+ );
+};
+
+export type EditableColorOption = {
+ title: string;
+ name: keyof ColorsOptions;
+};
+
+interface PrivateColorsSettingsProps {
+ title: string;
+ cardsTitle: React.ReactNode;
+ options: EditableColorOption[];
+}
+
+export const PrivateColorsSettings: React.FC = ({
+ title,
+ cardsTitle,
+ options,
+}) => {
+ const themePrivateColorLightOptions = useThemePrivateColorOptions('light');
+ const themePrivateColorDarkOptions = useThemePrivateColorOptions('dark');
+
+ const rows = React.useMemo(() => {
+ return options.map((option) => ({
+ id: option.name,
+ title: option.title,
+ render: (theme) => (
+
+ ),
+ }));
+ }, [options, themePrivateColorLightOptions, themePrivateColorDarkOptions]);
+
+ return (
+
+
+
+ );
+};
diff --git a/src/components/Themes/ui/PrivateColorsSettings/index.ts b/src/components/Themes/ui/PrivateColorsSettings/index.ts
new file mode 100644
index 00000000000..821d74e0cad
--- /dev/null
+++ b/src/components/Themes/ui/PrivateColorsSettings/index.ts
@@ -0,0 +1,2 @@
+export {PrivateColorsSettings} from './PrivateColorsSettings';
+export type {EditableColorOption} from './PrivateColorsSettings';
diff --git a/src/components/Themes/ui/ThemableSettings/ThemableCard.scss b/src/components/Themes/ui/ThemableSettings/ThemableCard.scss
new file mode 100644
index 00000000000..25e82582022
--- /dev/null
+++ b/src/components/Themes/ui/ThemableSettings/ThemableCard.scss
@@ -0,0 +1,51 @@
+@use '../../../../variables.scss';
+@use '~@gravity-ui/page-constructor/styles/variables.scss' as pcVariables;
+
+$block: '.#{variables.$ns}themable-card';
+
+#{$block} {
+ padding: 30px var(--g-spacing-8);
+ border-radius: 16px;
+ border: 1px solid var(--g-color-line-generic);
+
+ &_light {
+ background: #fff;
+ color: var(--g-color-text-brand-contrast);
+ }
+
+ &__title {
+ color: var(--g-color-base-background);
+ }
+
+ &__theme-root {
+ background: transparent;
+ --g-button-border-radius: 8px;
+ }
+
+ &__row {
+ display: flex;
+ flex-direction: column;
+
+ &-title {
+ display: none;
+
+ @media (max-width: map-get(pcVariables.$gridBreakpoints, 'md')) {
+ display: inline;
+ }
+ }
+
+ @media (max-width: map-get(pcVariables.$gridBreakpoints, 'md')) {
+ display: grid;
+ gap: var(--g-spacing-4);
+ grid-template-columns: 150px auto;
+ align-items: center;
+ }
+
+ @media (max-width: map-get(pcVariables.$gridBreakpoints, 'sm')) {
+ display: flex;
+ flex-direction: column;
+ gap: var(--g-spacing-2);
+ align-items: normal;
+ }
+ }
+}
diff --git a/src/components/Themes/ui/ThemableSettings/ThemableCard.tsx b/src/components/Themes/ui/ThemableSettings/ThemableCard.tsx
new file mode 100644
index 00000000000..37ba3360cd3
--- /dev/null
+++ b/src/components/Themes/ui/ThemableSettings/ThemableCard.tsx
@@ -0,0 +1,73 @@
+import {Moon, Sun} from 'landing-icons';
+import {Flex, Icon, Text, ThemeProvider} from 'landing-uikit';
+import {useTranslation} from 'next-i18next';
+import React from 'react';
+
+import {block} from '../../../../utils';
+import {useThemeUtilityColor} from '../../hooks';
+import {ThemeVariant} from '../../lib/types';
+import {ThemePicker} from '../ThemePicker';
+
+import './ThemableCard.scss';
+import {ThemableRow} from './types';
+
+const b = block('themable-card');
+
+interface ThemableCardProps {
+ rows: ThemableRow[];
+ theme: ThemeVariant;
+ onChangeTheme?: (newTheme: ThemeVariant) => void;
+ showTitle?: boolean;
+}
+
+export const ThemableCard: React.FC = ({
+ rows,
+ theme,
+ onChangeTheme,
+ showTitle = true,
+}) => {
+ const {t} = useTranslation('themes');
+ const [backgroundColor] = useThemeUtilityColor({name: 'base-background', theme});
+
+ const renderRow = React.useCallback(
+ (row: ThemableRow) => {
+ return (
+
+
+ {row.renderTitle ? (
+ row.renderTitle()
+ ) : (
+ {row.title}
+ )}
+
+ {row.render(theme)}
+
+ );
+ },
+ [theme],
+ );
+
+ return (
+
+
+ {showTitle && (
+
+
+
+ {theme === 'dark' ? t('dark_theme') : t('light_theme')}
+
+
+ )}
+ {onChangeTheme && }
+
+ {rows.map(renderRow)}
+
+
+
+ );
+};
diff --git a/src/components/Themes/ui/ThemableSettings/ThemableSettings.scss b/src/components/Themes/ui/ThemableSettings/ThemableSettings.scss
new file mode 100644
index 00000000000..f7c1eb2f200
--- /dev/null
+++ b/src/components/Themes/ui/ThemableSettings/ThemableSettings.scss
@@ -0,0 +1,28 @@
+@use '../../../../variables.scss';
+@use '~@gravity-ui/page-constructor/styles/variables.scss' as pcVariables;
+
+$block: '.#{variables.$ns}themable-settings';
+
+#{$block} {
+ &__columns {
+ width: 380px;
+ max-width: 380px;
+
+ @media (max-width: map-get(pcVariables.$gridBreakpoints, 'md')) {
+ display: none;
+ }
+
+ @media (max-width: map-get(pcVariables.$gridBreakpoints, 'lg')) {
+ width: 328px;
+ max-width: 328px;
+ }
+ }
+
+ &__dark-card {
+ display: block;
+
+ @media (max-width: map-get(pcVariables.$gridBreakpoints, 'lg')) {
+ display: none;
+ }
+ }
+}
diff --git a/src/components/Themes/ui/ThemableSettings/ThemableSettings.tsx b/src/components/Themes/ui/ThemableSettings/ThemableSettings.tsx
new file mode 100644
index 00000000000..888c8b1e0be
--- /dev/null
+++ b/src/components/Themes/ui/ThemableSettings/ThemableSettings.tsx
@@ -0,0 +1,52 @@
+import {BREAKPOINTS, useWindowBreakpoint} from '@gravity-ui/page-constructor';
+import {Col, Flex} from 'landing-uikit';
+import React from 'react';
+
+import {block} from '../../../../utils';
+import {ThemeVariant} from '../../lib/types';
+
+import {ThemableCard} from './ThemableCard';
+import './ThemableSettings.scss';
+import {ThemableSettingsRows} from './ThemableSettingsRows';
+import {ThemableRow} from './types';
+
+const b = block('themable-settings');
+
+interface ThemableSettingsProps {
+ title: React.ReactNode;
+ rows: ThemableRow[];
+ addButton?: React.ReactNode;
+}
+
+export const ThemableSettings: React.FC = ({title, rows, addButton}) => {
+ const [theme, setTheme] = React.useState('light');
+
+ const breakpoint = useWindowBreakpoint();
+ const isTabletOrMobile = breakpoint < BREAKPOINTS.lg;
+ const isMobile = breakpoint < BREAKPOINTS.md;
+
+ return (
+
+
+
+
+
+ {isMobile && addButton}
+
+
+
+
+
+
+ );
+};
diff --git a/src/components/Themes/ui/ThemableSettings/ThemableSettingsRows.scss b/src/components/Themes/ui/ThemableSettings/ThemableSettingsRows.scss
new file mode 100644
index 00000000000..a9a7e175a51
--- /dev/null
+++ b/src/components/Themes/ui/ThemableSettings/ThemableSettingsRows.scss
@@ -0,0 +1,20 @@
+@use '../../../../variables.scss';
+@use '~@gravity-ui/page-constructor/styles/variables.scss' as pcVariables;
+
+$block: '.#{variables.$ns}themable-settings-rows';
+
+#{$block} {
+ --g-button-border-radius: var(--g-spacing-2);
+ --g-text-input-border-radius: 8px;
+
+ padding-top: var(--g-spacing-9);
+
+ @media (min-width: map-get(pcVariables.$gridBreakpoints, 'lg')) {
+ padding-top: var(--g-spacing-4);
+ }
+
+ &__title {
+ margin-bottom: var(--g-spacing-4);
+ min-height: 48px;
+ }
+}
diff --git a/src/components/Themes/ui/ThemableSettings/ThemableSettingsRows.tsx b/src/components/Themes/ui/ThemableSettings/ThemableSettingsRows.tsx
new file mode 100644
index 00000000000..74228e0bc65
--- /dev/null
+++ b/src/components/Themes/ui/ThemableSettings/ThemableSettingsRows.tsx
@@ -0,0 +1,45 @@
+import {Flex, Text, sp} from 'landing-uikit';
+import React from 'react';
+
+import {block} from '../../../../utils';
+
+import './ThemableSettingsRows.scss';
+import {ThemableRow} from './types';
+
+const b = block('themable-settings-rows');
+
+interface ThemableSettingsRowsProps {
+ className?: string;
+ rows: ThemableRow[];
+ title: React.ReactNode;
+ appendNode?: React.ReactNode;
+}
+
+export const ThemableSettingsRows: React.FC = ({
+ className,
+ rows,
+ title,
+ appendNode,
+}) => {
+ return (
+
+
+ {title}
+
+
+ {rows.map((row) => (
+
+ {row.renderTitle ? (
+ row.renderTitle()
+ ) : (
+
+ {row.title}
+
+ )}
+
+ ))}
+ {appendNode}
+
+
+ );
+};
diff --git a/src/components/Themes/ui/ThemableSettings/types.ts b/src/components/Themes/ui/ThemableSettings/types.ts
new file mode 100644
index 00000000000..5db2da89d58
--- /dev/null
+++ b/src/components/Themes/ui/ThemableSettings/types.ts
@@ -0,0 +1,10 @@
+import React from 'react';
+
+import {ThemeVariant} from '../../lib/types';
+
+export type ThemableRow = {
+ id: string;
+ title: string;
+ renderTitle?: () => React.ReactNode;
+ render: (theme: ThemeVariant) => React.ReactNode;
+};
diff --git a/src/components/Themes/ui/ThemeCreatorContextProvider.tsx b/src/components/Themes/ui/ThemeCreatorContextProvider.tsx
new file mode 100644
index 00000000000..367f35df502
--- /dev/null
+++ b/src/components/Themes/ui/ThemeCreatorContextProvider.tsx
@@ -0,0 +1,384 @@
+import Head from 'next/head';
+import React from 'react';
+
+import {useOnLeavePageConfirmation} from '../../../hooks/useOnLeavePageConfirmation';
+import {BrandPreset} from '../lib/constants';
+import {ThemeCreatorContext, ThemeCreatorMethodsContext} from '../lib/themeCreatorContext';
+import type {ThemeCreatorMethodsContextType} from '../lib/themeCreatorContext';
+import type {
+ AddColorToThemeParams,
+ AddFontFamilyTypeParams,
+ ChangeRadiusPresetInThemeParams,
+ ChangeUtilityColorInThemeParams,
+ RenameColorInThemeParams,
+ UpdateAdvancedTypographySettingsParams,
+ UpdateColorInThemeParams,
+ UpdateCustomRadiusPresetInThemeParams,
+ UpdateFontFamilyParams,
+ UpdateFontFamilyTypeTitleParams,
+} from '../lib/themeCreatorUtils';
+import {
+ addColorToTheme,
+ addFontFamilyTypeInTheme,
+ applyBrandPresetToTheme,
+ changeRadiusPresetInTheme,
+ changeUtilityColorInTheme,
+ initThemeCreator,
+ removeColorFromTheme,
+ removeFontFamilyTypeFromTheme,
+ renameColorInTheme,
+ updateAdvancedTypographyInTheme,
+ updateAdvancedTypographySettingsInTheme,
+ updateColorInTheme,
+ updateCustomRadiusPresetInTheme,
+ updateFontFamilyInTheme,
+ updateFontFamilyTypeTitleInTheme,
+} from '../lib/themeCreatorUtils';
+import type {ThemeCreatorState, ThemeOptions} from '../lib/types';
+
+type ThemeCreatorAction =
+ | {
+ type: 'addColor';
+ payload?: AddColorToThemeParams;
+ }
+ | {
+ type: 'updateColor';
+ payload: UpdateColorInThemeParams;
+ }
+ | {
+ type: 'removeColor';
+ payload: string;
+ }
+ | {
+ type: 'renameColor';
+ payload: RenameColorInThemeParams;
+ }
+ | {
+ type: 'changeUtilityColor';
+ payload: ChangeUtilityColorInThemeParams;
+ }
+ | {
+ type: 'applyBrandPreset';
+ payload: BrandPreset;
+ }
+ | {
+ type: 'changeRadiusPreset';
+ payload: ChangeRadiusPresetInThemeParams;
+ }
+ | {
+ type: 'updateCustomRadiusPreset';
+ payload: UpdateCustomRadiusPresetInThemeParams;
+ }
+ | {
+ type: 'updateFontFamily';
+ payload: UpdateFontFamilyParams;
+ }
+ | {
+ type: 'addFontFamilyType';
+ payload: AddFontFamilyTypeParams;
+ }
+ | {
+ type: 'removeFontFamilyType';
+ payload: {fontType: string};
+ }
+ | {
+ type: 'updateFontFamilyTypeTitle';
+ payload: UpdateFontFamilyTypeTitleParams;
+ }
+ | {
+ type: 'updateAdvancedTypographySettings';
+ payload: UpdateAdvancedTypographySettingsParams;
+ }
+ | {
+ type: 'updateAdvancedTypography';
+ }
+ | {
+ type: 'reinitialize';
+ payload: ThemeOptions;
+ }
+ | {
+ type: 'openMainSettings';
+ }
+ | {
+ type: 'setAdvancedMode';
+ payload: boolean;
+ };
+
+const themeCreatorReducer = (
+ prevState: ThemeCreatorState,
+ action: ThemeCreatorAction,
+): ThemeCreatorState => {
+ const newState = {
+ ...prevState,
+ changesExist: true,
+ };
+
+ switch (action.type) {
+ case 'addColor':
+ return addColorToTheme(newState, action.payload);
+ case 'removeColor':
+ return removeColorFromTheme(newState, action.payload);
+ case 'renameColor':
+ return renameColorInTheme(newState, action.payload);
+ case 'updateColor':
+ return updateColorInTheme(newState, action.payload);
+ case 'changeUtilityColor':
+ return changeUtilityColorInTheme(newState, action.payload);
+ case 'applyBrandPreset':
+ return applyBrandPresetToTheme(newState, action.payload);
+ case 'changeRadiusPreset':
+ return changeRadiusPresetInTheme(newState, action.payload);
+ case 'updateCustomRadiusPreset':
+ return updateCustomRadiusPresetInTheme(newState, action.payload);
+ case 'addFontFamilyType':
+ return addFontFamilyTypeInTheme(newState, action.payload);
+ case 'removeFontFamilyType':
+ return removeFontFamilyTypeFromTheme(newState, action.payload);
+ case 'updateFontFamilyTypeTitle':
+ return updateFontFamilyTypeTitleInTheme(newState, action.payload);
+ case 'updateFontFamily':
+ return updateFontFamilyInTheme(newState, action.payload);
+ case 'updateAdvancedTypographySettings':
+ return updateAdvancedTypographySettingsInTheme(newState, action.payload);
+ case 'updateAdvancedTypography':
+ return updateAdvancedTypographyInTheme(newState);
+ case 'openMainSettings':
+ return {
+ ...newState,
+ showMainSettings: true,
+ };
+ case 'setAdvancedMode':
+ return {
+ ...newState,
+ advancedModeEnabled: action.payload,
+ };
+ case 'reinitialize':
+ return initThemeCreator(action.payload);
+ default:
+ return prevState;
+ }
+};
+
+interface ThemeCreatorProps extends React.PropsWithChildren {
+ initialTheme: ThemeOptions;
+}
+
+export const ThemeCreatorContextProvider: React.FC = ({
+ initialTheme,
+ children,
+}) => {
+ const prevInitialTheme = React.useRef(initialTheme);
+
+ const [themeCreator, dispatchThemeCreator] = React.useReducer(
+ themeCreatorReducer,
+ undefined,
+ () => initThemeCreator(initialTheme),
+ );
+
+ React.useEffect(() => {
+ if (prevInitialTheme.current !== initialTheme) {
+ prevInitialTheme.current = initialTheme;
+ dispatchThemeCreator({
+ type: 'reinitialize',
+ payload: initialTheme,
+ });
+ }
+ }, [initialTheme]);
+
+ const addColor = React.useCallback((payload) => {
+ dispatchThemeCreator({
+ type: 'addColor',
+ payload,
+ });
+ }, []);
+
+ const renameColor = React.useCallback(
+ (payload) => {
+ dispatchThemeCreator({
+ type: 'renameColor',
+ payload,
+ });
+ },
+ [],
+ );
+
+ const removeColor = React.useCallback(
+ (payload) => {
+ dispatchThemeCreator({
+ type: 'removeColor',
+ payload,
+ });
+ },
+ [],
+ );
+
+ const updateColor = React.useCallback(
+ (payload) => {
+ dispatchThemeCreator({
+ type: 'updateColor',
+ payload,
+ });
+ },
+ [],
+ );
+
+ const changeUtilityColor = React.useCallback<
+ ThemeCreatorMethodsContextType['changeUtilityColor']
+ >((payload) => {
+ dispatchThemeCreator({
+ type: 'changeUtilityColor',
+ payload,
+ });
+ }, []);
+
+ const applyBrandPreset = React.useCallback(
+ (payload) => {
+ dispatchThemeCreator({
+ type: 'applyBrandPreset',
+ payload,
+ });
+ },
+ [],
+ );
+
+ const changeRadiusPreset = React.useCallback<
+ ThemeCreatorMethodsContextType['changeRadiusPreset']
+ >((payload) => {
+ dispatchThemeCreator({
+ type: 'changeRadiusPreset',
+ payload,
+ });
+ }, []);
+
+ const updateCustomRadiusPreset = React.useCallback<
+ ThemeCreatorMethodsContextType['updateCustomRadiusPreset']
+ >((payload) => {
+ dispatchThemeCreator({
+ type: 'updateCustomRadiusPreset',
+ payload,
+ });
+ }, []);
+
+ const updateFontFamily = React.useCallback(
+ (payload) => {
+ dispatchThemeCreator({
+ type: 'updateFontFamily',
+ payload,
+ });
+ },
+ [],
+ );
+
+ const addFontFamilyType = React.useCallback<
+ ThemeCreatorMethodsContextType['addFontFamilyType']
+ >((payload) => {
+ dispatchThemeCreator({
+ type: 'addFontFamilyType',
+ payload,
+ });
+ }, []);
+
+ const updateFontFamilyTypeTitle = React.useCallback<
+ ThemeCreatorMethodsContextType['updateFontFamilyTypeTitle']
+ >((payload) => {
+ dispatchThemeCreator({
+ type: 'updateFontFamilyTypeTitle',
+ payload,
+ });
+ }, []);
+
+ const removeFontFamilyType = React.useCallback<
+ ThemeCreatorMethodsContextType['removeFontFamilyType']
+ >((payload) => {
+ dispatchThemeCreator({
+ type: 'removeFontFamilyType',
+ payload,
+ });
+ }, []);
+
+ const updateAdvancedTypographySettings = React.useCallback<
+ ThemeCreatorMethodsContextType['updateAdvancedTypographySettings']
+ >((payload) => {
+ dispatchThemeCreator({
+ type: 'updateAdvancedTypographySettings',
+ payload,
+ });
+ }, []);
+
+ const updateAdvancedTypography = React.useCallback<
+ ThemeCreatorMethodsContextType['updateAdvancedTypography']
+ >(() => {
+ dispatchThemeCreator({
+ type: 'updateAdvancedTypography',
+ });
+ }, []);
+
+ const openMainSettings = React.useCallback(() => {
+ dispatchThemeCreator({
+ type: 'openMainSettings',
+ });
+ }, []);
+
+ const setAdvancedMode = React.useCallback((enabled: boolean) => {
+ dispatchThemeCreator({
+ type: 'setAdvancedMode',
+ payload: enabled,
+ });
+ }, []);
+
+ const methods = React.useMemo(
+ () => ({
+ addColor,
+ renameColor,
+ removeColor,
+ updateColor,
+ changeUtilityColor,
+ applyBrandPreset,
+ changeRadiusPreset,
+ updateCustomRadiusPreset,
+ addFontFamilyType,
+ removeFontFamilyType,
+ updateAdvancedTypographySettings,
+ updateFontFamilyTypeTitle,
+ updateAdvancedTypography,
+ updateFontFamily,
+ openMainSettings,
+ setAdvancedMode,
+ }),
+ [
+ addColor,
+ renameColor,
+ removeColor,
+ updateColor,
+ changeUtilityColor,
+ applyBrandPreset,
+ changeRadiusPreset,
+ updateCustomRadiusPreset,
+ addFontFamilyType,
+ removeFontFamilyType,
+ updateAdvancedTypographySettings,
+ updateAdvancedTypography,
+ updateFontFamily,
+ updateFontFamilyTypeTitle,
+ openMainSettings,
+ setAdvancedMode,
+ ],
+ );
+
+ useOnLeavePageConfirmation(themeCreator.changesExist);
+
+ return (
+
+
+
+
+
+
+ {children}
+
+
+ );
+};
diff --git a/src/components/Themes/ui/ThemeExportDialog/ThemeExportDialog.scss b/src/components/Themes/ui/ThemeExportDialog/ThemeExportDialog.scss
new file mode 100644
index 00000000000..a1cb4d3236f
--- /dev/null
+++ b/src/components/Themes/ui/ThemeExportDialog/ThemeExportDialog.scss
@@ -0,0 +1,9 @@
+@use '../../../../variables.scss';
+
+$block: '.#{variables.$ns}theme-export-dialog';
+
+#{$block} {
+ &__code {
+ --code-example-max-height: max(100px, calc(100vh - 60px - 54px - 92px - 56px));
+ }
+}
diff --git a/src/components/Themes/ui/ThemeExportDialog/ThemeExportDialog.tsx b/src/components/Themes/ui/ThemeExportDialog/ThemeExportDialog.tsx
new file mode 100644
index 00000000000..6cf084f6707
--- /dev/null
+++ b/src/components/Themes/ui/ThemeExportDialog/ThemeExportDialog.tsx
@@ -0,0 +1,41 @@
+import {Dialog, Flex} from 'landing-uikit';
+import {useTranslation} from 'next-i18next';
+import React from 'react';
+
+import {block} from '../../../../utils';
+import {CodeExample} from '../../../CodeExample/CodeExample';
+import {useThemeCreator} from '../../hooks';
+import {ExportFormat, exportThemeForDialog} from '../../lib/themeCreatorExport';
+
+import './ThemeExportDialog.scss';
+
+const b = block('theme-export-dialog');
+
+interface ThemeExportDialogProps {
+ isOpen: boolean;
+ onClose: () => void;
+}
+
+export const ThemeExportDialog: React.FC = ({isOpen, onClose}) => {
+ const {t} = useTranslation('themes');
+ const themeState = useThemeCreator();
+
+ const [format] = React.useState('scss');
+
+ const themeStyles = React.useMemo(
+ () => exportThemeForDialog({themeState, format}),
+ [themeState, format],
+ );
+
+ return (
+
+ );
+};
diff --git a/src/components/Themes/ui/ThemePicker/ThemePicker.scss b/src/components/Themes/ui/ThemePicker/ThemePicker.scss
new file mode 100644
index 00000000000..5d8f8af4106
--- /dev/null
+++ b/src/components/Themes/ui/ThemePicker/ThemePicker.scss
@@ -0,0 +1,12 @@
+@use '~@gravity-ui/page-constructor/styles/variables.scss' as pcVariables;
+@use '../../../../variables.scss';
+
+$block: '.#{variables.$ns}theme-picker';
+
+#{$block} {
+ --g-border-radius-xl: 8px;
+
+ &__option {
+ margin-top: 13px;
+ }
+}
diff --git a/src/components/Themes/ui/ThemePicker/ThemePicker.tsx b/src/components/Themes/ui/ThemePicker/ThemePicker.tsx
new file mode 100644
index 00000000000..b954c24b7fc
--- /dev/null
+++ b/src/components/Themes/ui/ThemePicker/ThemePicker.tsx
@@ -0,0 +1,47 @@
+import {Moon, Sun} from 'landing-icons';
+import {Flex, Icon, RadioButton, Text} from 'landing-uikit';
+import React from 'react';
+
+import {block} from '../../../../utils';
+import type {ThemeVariant} from '../../lib/types';
+
+import './ThemePicker.scss';
+
+const b = block('theme-picker');
+
+interface ThemePickerProps {
+ value: ThemeVariant;
+ lightThemeTitle?: string;
+ darkThemeTitle?: string;
+ onUpdate: (value: ThemeVariant) => void;
+}
+
+export const ThemePicker: React.FC = ({
+ value,
+ onUpdate,
+ lightThemeTitle = 'Light',
+ darkThemeTitle = 'Dark',
+}) => {
+ return (
+ className={b()} size="xl" value={value} onUpdate={onUpdate}>
+
+
+ {lightThemeTitle}
+
+ }
+ />
+
+
+ {darkThemeTitle}
+
+ }
+ />
+
+ );
+};
diff --git a/src/components/Themes/ui/ThemePicker/index.ts b/src/components/Themes/ui/ThemePicker/index.ts
new file mode 100644
index 00000000000..9abdd97d808
--- /dev/null
+++ b/src/components/Themes/ui/ThemePicker/index.ts
@@ -0,0 +1 @@
+export {ThemePicker} from './ThemePicker';
diff --git a/src/components/Themes/ui/ThemeSection.scss b/src/components/Themes/ui/ThemeSection.scss
new file mode 100644
index 00000000000..f1ce06904db
--- /dev/null
+++ b/src/components/Themes/ui/ThemeSection.scss
@@ -0,0 +1,24 @@
+@use '../../../variables.scss';
+@use '~@gravity-ui/page-constructor/styles/variables.scss' as pcVariables;
+@use '~@gravity-ui/uikit/styles/mixins' as ukitMixins;
+
+$block: '.#{variables.$ns}theme-section';
+
+#{$block} {
+ display: flex;
+ flex-direction: column;
+
+ gap: calc(var(--g-spacing-base) * 8);
+
+ @media (max-width: map-get(pcVariables.$gridBreakpoints, 'sm')) {
+ gap: calc(var(--g-spacing-base) * 8);
+ }
+
+ &__title {
+ @include ukitMixins.text-display-2();
+
+ @media (max-width: map-get(pcVariables.$gridBreakpoints, 'md')) {
+ @include ukitMixins.text-header-2();
+ }
+ }
+}
diff --git a/src/components/Themes/ui/ThemeSection.tsx b/src/components/Themes/ui/ThemeSection.tsx
new file mode 100644
index 00000000000..d460e715083
--- /dev/null
+++ b/src/components/Themes/ui/ThemeSection.tsx
@@ -0,0 +1,25 @@
+import {Flex, Text} from 'landing-uikit';
+import React from 'react';
+
+import {block} from '../../../utils';
+
+import './ThemeSection.scss';
+
+const b = block('theme-section');
+
+interface ThemeSectionProps {
+ title: string;
+ children?: React.ReactNode;
+ className?: string;
+}
+
+export const ThemeSection: React.FC = ({title, className, children}) => {
+ return (
+
+
+ {title}
+
+ {children}
+
+ );
+};
diff --git a/src/components/Themes/ui/TypographyTab/AdvanceTypographySettings.tsx b/src/components/Themes/ui/TypographyTab/AdvanceTypographySettings.tsx
new file mode 100644
index 00000000000..96e233001cb
--- /dev/null
+++ b/src/components/Themes/ui/TypographyTab/AdvanceTypographySettings.tsx
@@ -0,0 +1,166 @@
+import {FormRow} from '@gravity-ui/components';
+import {Card, Col, Flex, Row, Select, Slider, Text, TextInput, TextProps} from 'landing-uikit';
+import React, {useMemo} from 'react';
+
+import {block} from '../../../../utils';
+import {useThemeCreator, useThemeCreatorMethods} from '../../hooks';
+import {DefaultFontFamilyType, FONT_WEIGHTS, TextVariants} from '../../lib/typography/constants';
+
+import './TypographyTab.scss';
+
+const b = block('typography-tab__advance-settings');
+
+const EndContent = () => (
+
+ px
+
+);
+
+export const AdvanceTypographySettings = () => {
+ const {
+ typography: {advanced, baseSetting},
+ } = useThemeCreator();
+
+ const {updateAdvancedTypographySettings} = useThemeCreatorMethods();
+
+ const fontTypeOptions = useMemo(
+ () => [...baseSetting.defaultFontFamilyType, ...baseSetting.customFontFamilyType],
+ [baseSetting],
+ );
+
+ return (
+
+ {Object.entries(advanced).map(([key, setting]) => (
+
+
+
+
+ {setting.title}
+
+
+
+ {
+ updateAdvancedTypographySettings({
+ key: key as TextVariants,
+ selectedFontFamilyType:
+ fontFamilyType[0] as DefaultFontFamilyType,
+ });
+ }}
+ width="max"
+ name={`font-family-${key}`}
+ id={`font-family-${key}`}
+ placeholder="Choose font-family"
+ options={fontTypeOptions}
+ />
+
+
+
+
+ {
+ updateAdvancedTypographySettings({
+ key: key as TextVariants,
+ fontWeight: fontWeight as number,
+ });
+ }}
+ />
+
+
+
+
+
+
+
+ {Object.entries(setting.sizes).map(([sizeKey, sizeData]) => (
+
+
+ {sizeData.title}
+
+
+
+
+ {
+ const calculatedFontSize =
+ Number(fontSize) < 0
+ ? 0
+ : Number(fontSize) > 50
+ ? 50
+ : Number(fontSize);
+
+ updateAdvancedTypographySettings({
+ key: key as TextVariants,
+ sizeKey:
+ sizeKey as TextProps['variant'],
+ fontSize: calculatedFontSize,
+ });
+ }}
+ type="number"
+ endContent={}
+ />
+
+
+ {
+ const calculatedLineHeight =
+ Number(lineHeight) < 0
+ ? 0
+ : Number(lineHeight) > 50
+ ? 50
+ : Number(lineHeight);
+
+ updateAdvancedTypographySettings({
+ key: key as TextVariants,
+ sizeKey:
+ sizeKey as TextProps['variant'],
+ lineHeight: calculatedLineHeight,
+ });
+ }}
+ type="number"
+ controlProps={{min: 1}}
+ endContent={}
+ />
+
+
+
+
+ ))}
+
+
+
+
+ ))}
+
+ );
+};
diff --git a/src/components/Themes/ui/TypographyTab/FontFamilyPicker.tsx b/src/components/Themes/ui/TypographyTab/FontFamilyPicker.tsx
new file mode 100644
index 00000000000..95f3e2f0a00
--- /dev/null
+++ b/src/components/Themes/ui/TypographyTab/FontFamilyPicker.tsx
@@ -0,0 +1,456 @@
+import {FormRow} from '@gravity-ui/components';
+import {Plus, TrashBin} from 'landing-icons';
+import {
+ Button,
+ Col,
+ Flex,
+ Icon,
+ Popover,
+ RadioButton,
+ RadioButtonOption,
+ Row,
+ Text,
+ TextArea,
+ TextInput,
+ TextInputProps,
+} from 'landing-uikit';
+import React, {useCallback, useState} from 'react';
+
+import {block} from '../../../../utils';
+import {SelectableCard} from '../../../SelectableCard/SelectableCard';
+import {useThemeCreator, useThemeCreatorMethods} from '../../hooks';
+import {CustomFontSelectType} from '../../lib/types';
+import {
+ DEFAULT_FONTS,
+ DefaultFontFamilyType,
+ GOOGLE_FONTS_FONT_PREVIEW_HOST,
+} from '../../lib/typography/constants';
+import {generateGoogleFontDownloadLink} from '../../lib/typography/utils';
+
+import './TypographyTab.scss';
+
+const customFontType: RadioButtonOption[] = [
+ {
+ value: CustomFontSelectType.GoogleFonts,
+ content: 'Import From Google Fonts',
+ },
+ {
+ value: CustomFontSelectType.Manual,
+ content: 'Manual',
+ },
+];
+
+const FONT_FAMILIES_OPTION: {
+ name: string;
+ variableName: DefaultFontFamilyType;
+ fonts: {title: string; key: string; link: string}[];
+}[] = [
+ {
+ name: 'Sans Font Family',
+ variableName: DefaultFontFamilyType.Sans,
+ fonts: [
+ {
+ title: 'Inter',
+ key: 'inter',
+ link: 'https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap',
+ },
+ {
+ title: 'Merriweather',
+ key: 'merriweather',
+ link: 'https://fonts.googleapis.com/css2?family=Merriweather:ital,wght@0,300;0,400;0,700;0,900;1,300;1,400;1,700;1,900&display=swap',
+ },
+ {
+ title: 'Titillium Web',
+ key: 'titillium_web',
+ link: 'https://fonts.googleapis.com/css2?family=Titillium+Web:ital,wght@0,200;0,300;0,400;0,600;0,700;0,900;1,200;1,300;1,400;1,600;1,700&display=swap',
+ },
+ ],
+ },
+ {
+ name: 'Monospace Font Family',
+ variableName: DefaultFontFamilyType.Monospace,
+ fonts: [
+ {
+ title: 'Roboto Mono',
+ key: 'roboto_mono',
+ link: 'https://fonts.googleapis.com/css2?family=Roboto+Mono:ital,wght@0,100..700;1,100..700&display=swap',
+ },
+ {
+ title: 'Ubuntu Mono',
+ key: 'ubuntu_mono',
+ link: 'https://fonts.googleapis.com/css2?family=Ubuntu+Mono:ital,wght@0,400;0,700;1,400;1,700&display=swap',
+ },
+ {
+ title: 'IBM Plex Mono',
+ key: 'ibm_plex_mono',
+ link: 'https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;1,100;1,200;1,300;1,400;1,500;1,600;1,700&display=swap',
+ },
+ ],
+ },
+];
+
+const b = block('typography-tab__font');
+
+const CustomFontFamily = ({
+ fontType,
+ withExtraContent,
+ ExtraContent,
+}: {
+ fontType: string;
+ withExtraContent?: boolean;
+ ExtraContent?: React.ReactNode;
+}) => {
+ const {
+ typography: {
+ baseSetting: {fontFamilies},
+ },
+ } = useThemeCreator();
+
+ const [validationState, setValidationState] =
+ useState(undefined);
+ const {updateFontFamily} = useThemeCreatorMethods();
+
+ const onGoogleFontInputChange = useCallback(
+ (event: React.ChangeEvent) => {
+ const value = event.target.value;
+
+ if (value.startsWith(GOOGLE_FONTS_FONT_PREVIEW_HOST)) {
+ setValidationState(undefined);
+ } else {
+ setValidationState('invalid');
+ }
+
+ const dirtyFontName = value.split('/').at(-1);
+
+ const extraDataIndex = dirtyFontName?.indexOf('?');
+
+ const fontName =
+ (extraDataIndex === -1 ? dirtyFontName : dirtyFontName?.slice(0, extraDataIndex)) ??
+ '';
+
+ const link = generateGoogleFontDownloadLink(fontName);
+
+ updateFontFamily({
+ fontType,
+ fontWebsite: value,
+ isCustom: true,
+ customType: CustomFontSelectType.GoogleFonts,
+ value: {
+ title: fontName.replaceAll('+', ' '),
+ key: fontName.replaceAll('+', '-').toLowerCase(),
+ link,
+ alternatives: fontFamilies[fontType].alternatives,
+ },
+ });
+ },
+ [fontType],
+ );
+
+ return (
+
+
+ {
+ updateFontFamily({
+ fontType,
+ isCustom: true,
+ customType: value,
+ fontWebsite: '',
+ value: {
+ title: '',
+ key: '',
+ link: '',
+ alternatives: [],
+ },
+ });
+ }}
+ />
+ {withExtraContent && ExtraContent}
+
+ {fontFamilies[fontType].customType === CustomFontSelectType.GoogleFonts && (
+
+ )}
+
+ {fontFamilies[fontType].customType === CustomFontSelectType.Manual && (
+
+ {
+ const fontName = event.target.value;
+
+ updateFontFamily({
+ fontType,
+ isCustom: true,
+ value: {
+ title: fontName,
+ key: fontName.replaceAll(' ', '-').toLowerCase(),
+ link: fontFamilies[fontType].link,
+ alternatives: fontFamilies[fontType].alternatives,
+ },
+ });
+ }}
+ />
+
+ Alternatives}
+ >
+
+ Font Link}
+ >
+
+
+ )}
+
+ );
+};
+
+export const FontFamilyPicker = () => {
+ const {
+ typography: {
+ baseSetting: {fontFamilies, customFontFamilyType},
+ advanced,
+ },
+ } = useThemeCreator();
+
+ const {updateFontFamily, addFontFamilyType, removeFontFamilyType, updateFontFamilyTypeTitle} =
+ useThemeCreatorMethods();
+
+ const getFontUsages = React.useCallback(
+ (fontType: string) => {
+ return Object.entries(advanced)
+ .filter(
+ ([, textVariantSetting]) =>
+ textVariantSetting.selectedFontFamilyType === fontType,
+ )
+ .map(([, textVariantSetting]) => textVariantSetting.title);
+ },
+ [advanced],
+ );
+
+ return (
+
+ Fonts
+ {FONT_FAMILIES_OPTION.map((option) => (
+
+
+ {option.name}
+
+
+
+
+ {option.fonts.map((font) => (
+
+ {
+ updateFontFamily({
+ fontType: option.variableName,
+ isCustom: false,
+ value: {
+ title: font.title,
+ key: font.key,
+ link: font.link,
+ alternatives:
+ DEFAULT_FONTS[option.variableName],
+ },
+ });
+ }}
+ />
+
+ ))}
+
+ {
+ updateFontFamily({
+ fontType: option.variableName,
+ customType: CustomFontSelectType.GoogleFonts,
+ isCustom: true,
+ value: {
+ title: '',
+ key: '',
+ link: '',
+ alternatives: [],
+ },
+ });
+ }}
+ />
+
+
+
+ {fontFamilies[option.variableName].isCustom && (
+
+ )}
+
+
+
+
+ ))}
+ {customFontFamilyType.map((fType) => (
+
+
+ {
+ const value = event.target.value;
+
+ updateFontFamilyTypeTitle({title: value, familyType: fType.value});
+ }}
+ placeholder="Enter font alias"
+ className={b('additional-font-input')}
+ />
+
+
+
+
+ This font is currently in use in blocks:{' '}
+ {getFontUsages(fType.value).join(', ')}. If
+ you delete it, we'll replace it with a default font.
+
+
+
+ }
+ disabled={getFontUsages(fType.value).length === 0}
+ >
+
+
+ }
+ />
+
+
+ ))}
+
+
+ );
+};
diff --git a/src/components/Themes/ui/TypographyTab/Preview.tsx b/src/components/Themes/ui/TypographyTab/Preview.tsx
new file mode 100644
index 00000000000..7da0d1f544b
--- /dev/null
+++ b/src/components/Themes/ui/TypographyTab/Preview.tsx
@@ -0,0 +1,180 @@
+import {Card, Col, Flex, Row, Text, TextProps, ThemeProvider} from 'landing-uikit';
+import React from 'react';
+
+import {block} from '../../../../utils';
+import {useThemeCreator} from '../../hooks';
+import {exportTheme} from '../../lib/themeCreatorExport';
+
+import './TypographyTab.scss';
+
+const TYPOGRAPHY_STYLES_PREVIEW: {
+ title: string;
+ variants: {title: string; variant: TextProps['variant']}[];
+}[] = [
+ {
+ title: 'Caption',
+ variants: [
+ {
+ title: 'Caption 1',
+ variant: 'caption-1',
+ },
+ {
+ title: 'Caption 2',
+ variant: 'caption-2',
+ },
+ ],
+ },
+ {
+ title: 'Body Text',
+ variants: [
+ {
+ title: 'Body 1 Short',
+ variant: 'body-short',
+ },
+ {
+ title: 'Body 1',
+ variant: 'body-1',
+ },
+ {
+ title: 'Body 2',
+ variant: 'body-2',
+ },
+ {
+ title: 'Body 3',
+ variant: 'body-3',
+ },
+ ],
+ },
+ {
+ title: 'Code',
+ variants: [
+ {
+ title: 'Code 1',
+ variant: 'code-1',
+ },
+ {
+ title: 'Code 1 Inline',
+ variant: 'code-inline-1',
+ },
+ {
+ title: 'Code 2',
+ variant: 'code-2',
+ },
+ {
+ title: 'Code 2 Inline',
+ variant: 'code-inline-2',
+ },
+ {
+ title: 'Code 3',
+ variant: 'code-3',
+ },
+ {
+ title: 'Code 3 Inline',
+ variant: 'code-inline-3',
+ },
+ ],
+ },
+ {
+ title: 'Subheader',
+ variants: [
+ {
+ title: 'Subheader 1',
+ variant: 'subheader-1',
+ },
+ {
+ title: 'Subheader 2',
+ variant: 'subheader-2',
+ },
+ {
+ title: 'Subheader 3',
+ variant: 'subheader-3',
+ },
+ ],
+ },
+ {
+ title: 'Header',
+ variants: [
+ {
+ title: 'Header 1',
+ variant: 'header-1',
+ },
+ {
+ title: 'Header 2',
+ variant: 'header-2',
+ },
+ ],
+ },
+ {
+ title: 'Display',
+ variants: [
+ {
+ title: 'Display 1',
+ variant: 'display-1',
+ },
+ {
+ title: 'Display 2',
+ variant: 'display-2',
+ },
+ {
+ title: 'Display 3',
+ variant: 'display-3',
+ },
+ {
+ title: 'Display 4',
+ variant: 'display-4',
+ },
+ ],
+ },
+];
+
+const b = block('typography-tab__preview');
+
+export const Preview = () => {
+ const themeState = useThemeCreator();
+
+ const themeStyles = React.useMemo(
+ () => exportTheme({themeState, ignoreDefaultValues: false}),
+ [themeState],
+ );
+
+ return (
+
+ Typography Styles
+
+
+ {themeStyles ? (
+
+ ) : null}
+
+ {TYPOGRAPHY_STYLES_PREVIEW.map(({title, variants}) => {
+ return (
+
+
+
+ {title}
+
+ {variants.map((variant) => (
+
+ {variant.title}
+
+ ))}
+
+
+ );
+ })}
+
+
+
+
+ );
+};
diff --git a/src/components/Themes/ui/TypographyTab/TypographyTab.scss b/src/components/Themes/ui/TypographyTab/TypographyTab.scss
new file mode 100644
index 00000000000..d1512102478
--- /dev/null
+++ b/src/components/Themes/ui/TypographyTab/TypographyTab.scss
@@ -0,0 +1,154 @@
+@use '../../../../variables';
+@use '~@gravity-ui/page-constructor/styles/variables.scss' as pcVariables;
+@use '~@gravity-ui/uikit/styles/themes/_index.scss' as themes;
+@use '~@gravity-ui/uikit/styles/mixins' as ukitMixins;
+
+$block: '.#{variables.$ns}typography-tab';
+
+#{$block} {
+ --g-text-input-border-radius: 8px;
+ --g-button-border-radius: 8px;
+
+ gap: calc(var(--g-spacing-base) * 24);
+
+ @media (max-width: map-get(pcVariables.$gridBreakpoints, 'sm')) {
+ gap: calc(var(--g-spacing-base) * 12);
+ }
+
+ &__font {
+ &__additional-font-input {
+ width: 380px;
+
+ @media (max-width: map-get(pcVariables.$gridBreakpoints, 'lg')) {
+ width: 100%;
+ }
+ }
+
+ &__additional-font-add-btn {
+ @media (max-width: map-get(pcVariables.$gridBreakpoints, 'sm')) {
+ width: 100%;
+ }
+ }
+
+ &__font-card {
+ &__text {
+ @media (max-width: map-get(pcVariables.$gridBreakpoints, 'md')) {
+ @include ukitMixins.text-body-3();
+ }
+
+ &_fontType_inter {
+ font-family: Inter, sans-serif;
+ }
+
+ &_fontType_merriweather {
+ font-family: Merriweather, sans-serif;
+ }
+
+ &_fontType_titillium_web {
+ font-family: 'Titillium Web', sans-serif;
+ }
+
+ &_fontType_roboto_mono {
+ font-family: 'Roboto Mono', monospace;
+ }
+
+ &_fontType_ubuntu_mono {
+ font-family: 'Ubuntu Mono', monospace;
+ }
+
+ &_fontType_ibm_plex_mono {
+ font-family: 'IBM Plex Mono', monospace;
+ }
+ }
+ }
+
+ &__custom-font-radio-button {
+ --g-border-radius-xl: 8px;
+ align-self: flex-start;
+ }
+
+ &__custom-font-textarea {
+ --g-border-radius-xl: 8px;
+ }
+
+ &__custom-font-textarea-wrapper {
+ margin-block-end: 0;
+ }
+ }
+
+ &__preview {
+ border-radius: 16px;
+ /* stylelint-disable */
+ --g-text-display-4-font-size: 48px !important;
+ --g-text-display-4-line-height: 52px !important;
+
+ &__wrapper {
+ padding: 80px;
+ margin-block-start: 0 !important;
+
+ @media (max-width: map-get(pcVariables.$gridBreakpoints, 'lg')) {
+ padding: 0 var(--g-spacing-10) var(--g-spacing-10) var(--g-spacing-10);
+ }
+ }
+
+ &__variant-title {
+ --g-color-text-brand: rgb(233, 174, 86);
+ --g-text-subheader-3-line-height: 24px;
+ --g-text-subheader-3-font-size: 17px;
+ --g-text-subheader-font-weight: 600;
+ }
+ }
+
+ &__advance-settings {
+ &__wrapper {
+ width: 100%;
+ gap: calc(var(--g-spacing-1) * 24);
+ }
+
+ &__raw-wrapper {
+ width: 100%;
+ }
+
+ &__settings-card {
+ --g-color-base-background: rgb(34, 29, 34);
+ background-color: var(--g-color-base-background);
+
+ flex: 1 1 66%;
+
+ padding: var(--g-spacing-10) calc(var(--g-spacing-10) * 2);
+ width: 100%;
+
+ @media (max-width: map-get(pcVariables.$gridBreakpoints, 'lg')) {
+ padding: var(--g-spacing-8);
+ }
+
+ @media (max-width: map-get(pcVariables.$gridBreakpoints, 'sm')) {
+ padding: var(--g-spacing-5);
+ }
+ }
+
+ &__font-select {
+ --g-border-radius-xl: 8px;
+ }
+
+ &__input-setting-end-content {
+ margin-inline: var(--g-spacing-2);
+ }
+
+ &__setting-input {
+ width: 225px;
+
+ @media (max-width: map-get(pcVariables.$gridBreakpoints, 'md')) {
+ width: 210px;
+ }
+
+ @media (max-width: map-get(pcVariables.$gridBreakpoints, 'sm')) {
+ width: 100%;
+ }
+ }
+ }
+
+ &__advanced-button {
+ width: min-content;
+ }
+}
diff --git a/src/components/Themes/ui/TypographyTab/TypographyTab.tsx b/src/components/Themes/ui/TypographyTab/TypographyTab.tsx
new file mode 100644
index 00000000000..78070f99eec
--- /dev/null
+++ b/src/components/Themes/ui/TypographyTab/TypographyTab.tsx
@@ -0,0 +1,42 @@
+import {Sliders} from 'landing-icons';
+import {Button, Flex, Icon} from 'landing-uikit';
+import React from 'react';
+
+import {block} from '../../../../utils';
+import {useThemeCreator, useThemeCreatorMethods} from '../../hooks';
+import {ExportThemeSection} from '../ExportThemeSection/ExportThemeSection';
+
+import {AdvanceTypographySettings} from './AdvanceTypographySettings';
+import {FontFamilyPicker} from './FontFamilyPicker';
+import {Preview} from './Preview';
+import './TypographyTab.scss';
+
+const b = block('typography-tab');
+
+export const TypographyTab = () => {
+ const {
+ typography: {isAdvancedActive},
+ } = useThemeCreator();
+
+ const {updateAdvancedTypography} = useThemeCreatorMethods();
+
+ return (
+
+
+
+
+
+
+ {isAdvancedActive && }
+
+
+ );
+};
diff --git a/src/content/menu.ts b/src/content/menu.ts
index 52bec4ede14..27ee9c12c70 100644
--- a/src/content/menu.ts
+++ b/src/content/menu.ts
@@ -22,4 +22,8 @@ export const menu: MenuItem[] = [
titleKey: 'menu_icons',
url: '/icons',
},
+ {
+ titleKey: 'menu_themes',
+ url: '/themes',
+ },
];
diff --git a/src/hooks/useOnLeavePageConfirmation.ts b/src/hooks/useOnLeavePageConfirmation.ts
new file mode 100644
index 00000000000..b4be391358d
--- /dev/null
+++ b/src/hooks/useOnLeavePageConfirmation.ts
@@ -0,0 +1,33 @@
+import Router from 'next/router';
+import {useEffect} from 'react';
+
+export const useOnLeavePageConfirmation = (unsavedChanges: boolean) => {
+ useEffect(() => {
+ // For reloading.
+ window.onbeforeunload = () => {
+ if (unsavedChanges) {
+ return 'You have unsaved changes. Do you really want to leave?';
+ }
+
+ return null;
+ };
+
+ const routeChangeStart = () => {
+ if (unsavedChanges) {
+ const ok = confirm('You have unsaved changes. Do you really want to leave?');
+ if (!ok) {
+ Router.events.emit('routeChangeError');
+ // eslint-disable-next-line no-throw-literal
+ throw 'Abort route change. Please ignore this error.';
+ }
+ }
+ };
+
+ Router.events.on('routeChangeStart', routeChangeStart);
+
+ return () => {
+ Router.events.off('routeChangeStart', routeChangeStart);
+ window.onbeforeunload = null;
+ };
+ }, [unsavedChanges]);
+};
diff --git a/src/pages/themes.tsx b/src/pages/themes.tsx
new file mode 100644
index 00000000000..9550d64b404
--- /dev/null
+++ b/src/pages/themes.tsx
@@ -0,0 +1,2 @@
+// Support for default locale without path prefix
+export {default, getStaticProps} from '../[locale]/themes';