From 203246033f5ba41cc47bd63f1b7069ee5ad7d716 Mon Sep 17 00:00:00 2001 From: Rastrapon <58354560+Rastraponovich@users.noreply.github.com> Date: Tue, 13 Jan 2026 11:17:52 +0300 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B0=20=D1=84=D1=83=D0=BD=D0=BA=D1=86=D0=B8?= =?UTF-8?q?=D0=BE=D0=BD=D0=B0=D0=BB=D1=8C=D0=BD=D0=BE=D1=81=D1=82=D1=8C=20?= =?UTF-8?q?=D0=BF=D0=B5=D1=80=D0=B5=D0=BA=D0=BB=D1=8E=D1=87=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D1=8F=20=D1=82=D0=B5=D0=BC=D1=8B=20=D0=B8=20=D0=BE=D0=B1?= =?UTF-8?q?=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD=D1=8B=20UI=20=D0=BA=D0=BE?= =?UTF-8?q?=D0=BC=D0=BF=D0=BE=D0=BD=D0=B5=D0=BD=D1=82=D1=8B=20(#26)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Добавлена функция переключения темы, позволяющая пользователям выбирать между светлой, темной и системной темами. - Добавлены новые зависимости: `@withease/web-api` и `class-variance-authority`. - Рефакторинг CSS для поддержки темизации с использованием кастомных свойств. - Обновлены различные UI компоненты для использования темо-зависимых стилей, улучшена визуальная согласованность приложения. - Улучшена доступность и удобство использования компонентов select и toggle. - Улучшен хедер с добавлением элемента управления переключением темы. --- package-lock.json | 23 ++ package.json | 2 + src/features/theme-toggle/config.ts | 30 +++ src/features/theme-toggle/index.ts | 1 + src/features/theme-toggle/lib.ts | 69 ++++++ src/features/theme-toggle/model.ts | 71 +++++++ src/features/theme-toggle/view.tsx | 35 +++ src/index.css | 213 ++++++++++++++++--- src/pages/main-page.tsx | 4 +- src/shared/ui/button/view.tsx | 66 +++--- src/shared/ui/dialog/dialog-backdrop.tsx | 2 +- src/shared/ui/dialog/dialog-close-button.tsx | 2 +- src/shared/ui/dialog/dialog-header.tsx | 2 +- src/shared/ui/dialog/dialog.tsx | 2 +- src/shared/ui/input/input-number.tsx | 2 +- src/shared/ui/select/view.tsx | 6 +- src/shared/ui/slider/view.tsx | 4 +- src/shared/ui/toggle/view.tsx | 14 +- src/widgets/header/view.tsx | 13 +- src/widgets/stage/ui/image-canvas.tsx | 2 +- src/widgets/stage/view.tsx | 4 +- 21 files changed, 483 insertions(+), 84 deletions(-) create mode 100644 src/features/theme-toggle/config.ts create mode 100644 src/features/theme-toggle/index.ts create mode 100644 src/features/theme-toggle/lib.ts create mode 100644 src/features/theme-toggle/model.ts create mode 100644 src/features/theme-toggle/view.tsx diff --git a/package-lock.json b/package-lock.json index 8f1809a..87eae37 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,8 @@ "version": "1.4.0", "dependencies": { "@withease/factories": "^1.0.5", + "@withease/web-api": "^1.3.0", + "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "effector": "^23.4.4", "effector-react": "^23.3.0", @@ -2627,6 +2629,15 @@ "integrity": "sha512-8qcAyBDxuI3F1Qf5ib39pzSfSma9TxyVH/ALOrtkaqnlTuRni76BMbwUyPgCTaEefPOO8sOQKe4SxZbs3p51dQ==", "license": "MIT" }, + "node_modules/@withease/web-api": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@withease/web-api/-/web-api-1.3.0.tgz", + "integrity": "sha512-ULQu1yOoKDom7kGNg2KHeb3OJm09CbNRBZfJ/H9Mpuwia7Cydw8o0vHQEtDFyPmwYm7OIhCNEw/ILZUHzNJCQQ==", + "license": "MIT", + "peerDependencies": { + "effector": "^22.5.0 || ^23.0.0" + } + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -2890,6 +2901,18 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, "node_modules/cli-cursor": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", diff --git a/package.json b/package.json index 937fe1d..6161946 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,8 @@ }, "dependencies": { "@withease/factories": "^1.0.5", + "@withease/web-api": "^1.3.0", + "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "effector": "^23.4.4", "effector-react": "^23.3.0", diff --git a/src/features/theme-toggle/config.ts b/src/features/theme-toggle/config.ts new file mode 100644 index 0000000..0bd5518 --- /dev/null +++ b/src/features/theme-toggle/config.ts @@ -0,0 +1,30 @@ +import { Monitor, Moon, Sun } from 'lucide-react'; + +export const THEME_STORAGE_KEY = 'app-theme'; + +export const ThemeKeys = { + dark: 'dark', + light: 'light', + system: 'system', +} as const; + +export type ThemeKey = (typeof ThemeKeys)[keyof typeof ThemeKeys]; +export type ResolvedTheme = Omit; + +export const THEMES: ThemeKey[] = [ + ThemeKeys.light, + ThemeKeys.dark, + ThemeKeys.system, +]; + +export const THEME_LABELS: Record = { + [ThemeKeys.light]: 'Светлая', + [ThemeKeys.dark]: 'Темная', + [ThemeKeys.system]: 'Системная', +}; + +export const THEME_ICONS: Record = { + [ThemeKeys.light]: Sun, + [ThemeKeys.dark]: Moon, + [ThemeKeys.system]: Monitor, +}; diff --git a/src/features/theme-toggle/index.ts b/src/features/theme-toggle/index.ts new file mode 100644 index 0000000..90aafb7 --- /dev/null +++ b/src/features/theme-toggle/index.ts @@ -0,0 +1 @@ +export { ThemeToggle } from './view'; diff --git a/src/features/theme-toggle/lib.ts b/src/features/theme-toggle/lib.ts new file mode 100644 index 0000000..58426ee --- /dev/null +++ b/src/features/theme-toggle/lib.ts @@ -0,0 +1,69 @@ +import { + type ResolvedTheme, + THEME_STORAGE_KEY, + type ThemeKey, + ThemeKeys, +} from './config'; + +/** + * Получить сохраненную тему из localStorage + */ +export function getStoredTheme(): ThemeKey | null { + if (typeof globalThis.window === 'undefined') { + return null; + } + + const stored = localStorage.getItem(THEME_STORAGE_KEY); + if ( + stored === ThemeKeys.light || + stored === ThemeKeys.dark || + stored === ThemeKeys.system + ) { + return stored; + } + + return null; +} + +/** + * Сохранить тему в localStorage + * @todo cделать как эффект + */ +export function saveTheme(theme: ThemeKey): void { + if (typeof globalThis.window === 'undefined') { + return; + } + + localStorage.setItem(THEME_STORAGE_KEY, theme); +} + +/** + * Разрешить тему (преобразовать 'system' в 'light' или 'dark') + */ +export function resolveTheme( + theme: ThemeKey, + prefersDarkMode: boolean, +): ResolvedTheme { + if (theme === ThemeKeys.system) { + return prefersDarkMode ? ThemeKeys.dark : ThemeKeys.light; + } + + return theme; +} + +/** + * Применить тему к документу (добавить/удалить класс dark на ) + */ +export function applyThemeToHtml(resolvedTheme: ResolvedTheme): void { + if (typeof globalThis.document === 'undefined') { + return; + } + + const root = globalThis.document.documentElement; + + if (resolvedTheme === ThemeKeys.dark) { + root.classList.add(ThemeKeys.dark); + } else { + root.classList.remove(ThemeKeys.dark); + } +} diff --git a/src/features/theme-toggle/model.ts b/src/features/theme-toggle/model.ts new file mode 100644 index 0000000..33c337f --- /dev/null +++ b/src/features/theme-toggle/model.ts @@ -0,0 +1,71 @@ +import { trackMediaQuery } from '@withease/web-api'; +import { createEffect, createEvent, createStore, sample } from 'effector'; + +import { applicationStarted } from '~/shared/init'; + +import type { ResolvedTheme, ThemeKey } from './config'; +import { ThemeKeys } from './config'; +import { + applyThemeToHtml, + getStoredTheme, + resolveTheme, + saveTheme, +} from './lib'; + +// Интеграция с @withease/web-api для отслеживания системной темы +const { $matches: $prefersDarkMode } = trackMediaQuery( + '(prefers-color-scheme: dark)', + { + setup: applicationStarted, + }, +); + +export const themeChanged = createEvent(); + +const applyThemeFx = createEffect(resolvedTheme => { + applyThemeToHtml(resolvedTheme); +}); + +export const $theme = createStore(ThemeKeys.system); +export const $resolvedTheme = createStore(ThemeKeys.light); + +// Инициализация темы при старте приложения +sample({ + clock: applicationStarted, + fn: () => { + const stored = getStoredTheme(); + return stored ?? ThemeKeys.system; + }, + target: $theme, +}); + +// Сохранение темы в localStorage при изменении +sample({ + clock: themeChanged, + fn: theme => { + saveTheme(theme); + return theme; + }, + target: $theme, +}); + +// Разрешение темы на основе $theme и системных настроек +sample({ + clock: [$theme, $prefersDarkMode], + source: { theme: $theme, prefersDarkMode: $prefersDarkMode }, + fn: ({ theme, prefersDarkMode }) => { + const resolved = resolveTheme(theme, prefersDarkMode); + // Синхронное применение при инициализации для предотвращения мигания + if (typeof globalThis.window !== 'undefined') { + applyThemeToHtml(resolved); + } + return resolved; + }, + target: $resolvedTheme, +}); + +// Применение темы к HTML при изменении resolvedTheme +sample({ + clock: $resolvedTheme, + target: applyThemeFx, +}); diff --git a/src/features/theme-toggle/view.tsx b/src/features/theme-toggle/view.tsx new file mode 100644 index 0000000..70bc0f7 --- /dev/null +++ b/src/features/theme-toggle/view.tsx @@ -0,0 +1,35 @@ +import { useUnit } from 'effector-react'; + +import { cn } from '~/shared/lib/cn'; + +import { THEMES, THEME_ICONS, THEME_LABELS, type ThemeKey } from './config'; +import { $theme, themeChanged } from './model'; + +export function ThemeToggle() { + const [theme, changeTheme] = useUnit([$theme, themeChanged]); + + const Icon = THEME_ICONS[theme]; + + return ( +
+ + + +
+ ); +} diff --git a/src/index.css b/src/index.css index c24a203..907b3f8 100644 --- a/src/index.css +++ b/src/index.css @@ -1,21 +1,195 @@ @import 'tailwindcss'; :root { - font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; - line-height: 1.5; - font-weight: 400; - color-scheme: light dark; - color: rgba(255, 255, 255, 0.87); - background-color: #242424; +} + +@custom-variant dark (&:where(.dark, .dark *)); + +@layer base { + html { + font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; + line-height: 1.5; + font-weight: 400; + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + } + + html { + color: rgba(0, 0, 0, 0.87); + background-color: var(--color-bg-app); + } + + html.light { + color: rgba(0, 0, 0, 0.87); + background-color: var(--color-bg-app); + } + + html.dark { + color: rgba(255, 255, 255, 0.87); + background-color: var(--color-bg-app); + } + + body { + margin: 0; + display: flex; + place-items: center; + min-width: 320px; + min-height: 100vh; + } + + :root { + /* Primary цвета - Indigo (современный и приятный) */ + --primary: var(--color-indigo-600); + --primary-hover: var(--color-indigo-700); + --primary-light: var(--color-indigo-50); + --primary-border: var(--color-indigo-200); + + /* Danger цвета */ + --danger: var(--color-red-600); + --danger-hover: var(--color-red-700); + --danger-light: var(--color-red-50); + --danger-border: var(--color-red-200); + + /* Фон приложения */ + --bg-app: var(--color-white); + --bg-surface: var(--color-white); + --bg-surface-elevated: var(--color-zinc-50); + --bg-subtle: var(--color-zinc-100); + --bg-workspace: var(--color-zinc-200); + + /* Текст */ + --text-primary: var(--color-zinc-900); + --text-secondary: var(--color-zinc-600); + --text-tertiary: var(--color-zinc-500); + --text-inverse: var(--color-white); + + /* Границы */ + --border-subtle: var(--color-zinc-200); + --border-default: var(--color-zinc-300); + --border-strong: var(--color-zinc-400); + + /* Secondary кнопка */ + --bg-secondary: var(--color-zinc-100); + --bg-secondary-hover: var(--color-zinc-200); + --text-secondary-button: var(--color-zinc-900); + + /* Ghost кнопка */ + --ghost-text: var(--color-zinc-600); + --ghost-text-hover: var(--color-zinc-900); + --ghost-hover-bg: var(--color-zinc-100); + + /* Акцентные элементы */ + --accent-subtle: var(--color-indigo-50); + --accent-border: var(--color-indigo-200); + } + + .dark { + /* Primary цвета для темной темы */ + --primary: var(--color-indigo-500); + --primary-hover: var(--color-indigo-600); + --primary-light: var(--color-indigo-950); + --primary-border: var(--color-indigo-800); + + /* Danger цвета */ + --danger: var(--color-red-500); + --danger-hover: var(--color-red-600); + --danger-light: var(--color-red-950); + --danger-border: var(--color-red-800); + + /* Фон приложения */ + --bg-app: var(--color-zinc-900); + --bg-surface: var(--color-zinc-800); + --bg-surface-elevated: var(--color-zinc-700); + --bg-subtle: var(--color-zinc-700); + --bg-workspace: var(--color-zinc-700); + + /* Текст */ + --text-primary: var(--color-zinc-100); + --text-secondary: var(--color-zinc-400); + --text-tertiary: var(--color-zinc-500); + --text-inverse: var(--color-white); + + /* Границы */ + --border-subtle: var(--color-zinc-700); + --border-default: var(--color-zinc-600); + --border-strong: var(--color-zinc-500); + + /* Secondary кнопка */ + --bg-secondary: var(--color-zinc-800); + --bg-secondary-hover: var(--color-zinc-700); + --text-secondary-button: var(--color-zinc-100); + + /* Ghost кнопка */ + --ghost-text: var(--color-zinc-400); + --ghost-text-hover: var(--color-zinc-100); + --ghost-hover-bg: var(--color-zinc-800); - font-synthesis: none; - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; + /* Акцентные элементы */ + --accent-subtle: var(--color-indigo-950); + --accent-border: var(--color-indigo-800); + } + + button, + a { + cursor: pointer; + -webkit-tap-highlight-color: transparent; + touch-action: manipulation; + } + + img { + user-select: none; + pointer-events: none; + } } @theme { + /* Primary цвета */ + --color-primary: var(--primary); + --color-primary-hover: var(--primary-hover); + --color-primary-light: var(--primary-light); + --color-primary-border: var(--primary-border); + + /* Danger цвета */ + --color-danger: var(--danger); + --color-danger-hover: var(--danger-hover); + --color-danger-light: var(--danger-light); + --color-danger-border: var(--danger-border); + + /* Фон */ + --color-app: var(--bg-app); + --color-surface: var(--bg-surface); + --color-surface-elevated: var(--bg-surface-elevated); + --color-subtle: var(--bg-subtle); + --color-workspace: var(--bg-workspace); + + /* Текст */ + --color-text-primary: var(--text-primary); + --color-text-secondary: var(--text-secondary); + --color-text-tertiary: var(--text-tertiary); + --color-text-inverse: var(--text-inverse); + + /* Границы */ + --color-border-subtle: var(--border-subtle); + --color-border-default: var(--border-default); + --color-border-strong: var(--border-strong); + + /* Secondary кнопка */ + --color-bg-secondary: var(--bg-secondary); + --color-bg-secondary-hover: var(--bg-secondary-hover); + --color-text-secondary-button: var(--text-secondary-button); + + /* Ghost кнопка */ + --color-ghost-text: var(--ghost-text); + --color-ghost-text-hover: var(--ghost-text-hover); + --color-ghost-hover-bg: var(--ghost-hover-bg); + + /* Акцентные элементы */ + --color-accent-subtle: var(--accent-subtle); + --color-accent-border: var(--accent-border); + --animate-fade-in-scale: fade-in-scale 0.3s ease-out; --animate-fade-out-scale: fade-out-scale 0.3s ease-out; @@ -40,22 +214,3 @@ } } } - -@layer base { - body { - margin: 0; - display: flex; - place-items: center; - min-width: 320px; - min-height: 100vh; - } - - button { - cursor: pointer; - } - - img { - user-select: none; - pointer-events: none; - } -} diff --git a/src/pages/main-page.tsx b/src/pages/main-page.tsx index b6d9c5d..adf9864 100644 --- a/src/pages/main-page.tsx +++ b/src/pages/main-page.tsx @@ -4,11 +4,11 @@ import { CanvasStage } from '~/widgets/stage'; export function MainPage() { return ( -
+
-
+
diff --git a/src/shared/ui/button/view.tsx b/src/shared/ui/button/view.tsx index e48f1cb..34883d6 100644 --- a/src/shared/ui/button/view.tsx +++ b/src/shared/ui/button/view.tsx @@ -1,4 +1,6 @@ -import React from 'react'; +import React, { forwardRef } from 'react'; + +import { cva } from 'class-variance-authority'; import { cn } from '~/shared/lib/cn'; @@ -6,34 +8,38 @@ interface ButtonProps extends React.ButtonHTMLAttributes { variant?: 'primary' | 'secondary' | 'ghost' | 'danger'; size?: 'sm' | 'md' | 'lg'; } -const variants = { - primary: 'bg-blue-600 text-white hover:bg-blue-700', - secondary: 'bg-zinc-800 text-zinc-100 hover:bg-zinc-700', - ghost: 'bg-transparent text-zinc-400 hover:text-zinc-100 hover:bg-zinc-800', - danger: 'bg-red-600 text-white hover:bg-red-700', -}; +const variants = cva( + 'inline-flex items-center justify-center rounded-md font-medium transition-colors disabled:pointer-events-none disabled:opacity-50', + { + variants: { + variant: { + primary: 'bg-primary text-text-inverse hover:bg-primary-hover', + secondary: + 'bg-bg-secondary text-text-secondary-button hover:bg-bg-secondary-hover', + ghost: + 'bg-transparent text-ghost-text hover:text-ghost-text-hover hover:bg-ghost-hover-bg', + danger: 'bg-danger text-text-inverse hover:bg-danger-hover', + }, + size: { + sm: 'px-3 py-1.5 text-sm', + md: 'px-4 py-2', + lg: 'px-6 py-3 text-lg', + }, + }, + }, +); -const sizes = { - sm: 'px-3 py-1.5 text-sm', - md: 'px-4 py-2', - lg: 'px-6 py-3 text-lg', -}; +export const Button = forwardRef( + (props, ref) => { + const { className, variant = 'primary', size = 'md', ...rest } = props; -export const Button: React.FC = ({ - className, - variant = 'primary', - size = 'md', - ...props -}) => { - return ( - +
+ ); +} + export function Sidebar() { return ( - + ); } function Header() { return (
-

Коррекция

+

Коррекция

); @@ -49,7 +89,7 @@ function Filters() { -
+