From d762ba50342cd96e4387a51bac12a0c4c43c648c Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Tue, 8 Sep 2026 07:09:05 +0300 Subject: [PATCH] feat(ui): establish shared design system foundations --- .storybook/preview.tsx | 9 +- docs/STRUCTURE.md | 9 +- src/app/routes/AppRoutes.test.tsx | 7 +- src/app/theme.ts | 24 --- src/app/ui/App.tsx | 8 +- .../ui/BrokerageAccountList.stories.tsx | 2 +- src/main.tsx | 1 - .../components/AccountPageLoading.stories.tsx | 2 +- .../PortfolioRefreshState.stories.tsx | 2 +- .../ui/components/PositionCard.stories.tsx | 2 +- .../ui/components/RecentEvents.stories.tsx | 2 +- src/shared/theme/Foundations.stories.tsx | 202 ++++++++++++++++++ src/shared/theme/index.ts | 3 + src/{app => shared/theme}/styles.css | 4 +- src/shared/theme/theme.ts | 69 ++++++ src/shared/ui/Button.stories.tsx | 58 +++++ src/shared/ui/LoadingSkeleton.stories.tsx | 35 +++ src/shared/ui/StateView.stories.tsx | 2 +- 18 files changed, 401 insertions(+), 40 deletions(-) delete mode 100644 src/app/theme.ts create mode 100644 src/shared/theme/Foundations.stories.tsx create mode 100644 src/shared/theme/index.ts rename src/{app => shared/theme}/styles.css (55%) create mode 100644 src/shared/theme/theme.ts create mode 100644 src/shared/ui/Button.stories.tsx create mode 100644 src/shared/ui/LoadingSkeleton.stories.tsx diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index 4957207..f7314f6 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -1,11 +1,10 @@ import "@mantine/core/styles.css"; import "@mantine/dates/styles.css"; import "dayjs/locale/ru"; -import "../src/app/styles.css"; import "../src/shared/i18n"; import { MantineProvider } from "@mantine/core"; import type { Preview } from "@storybook/react-vite"; -import { theme } from "../src/app/theme"; +import { cssVariablesResolver, theme } from "@/shared/theme"; const colorSchemeManager = { get: () => "light" as const, @@ -18,11 +17,17 @@ const colorSchemeManager = { const preview: Preview = { parameters: { layout: "padded", + options: { + storySort: { + order: ["Design System", ["Foundations", "Components"], "Product"], + }, + }, controls: { expanded: true }, }, decorators: [ (Story) => ( pages -> features -> entities -> shared`. -- `app` - providers, routes и глобальные стили. +- `app` - providers и routes; общий визуальный фундамент принадлежит shared/theme. - `pages` - страницы маршрутов. - `features` - пользовательские сценарии, включая authentication. - `entities` - доменные модели и их UI/API. @@ -11,3 +11,10 @@ Каждый слайс предоставляет наружу только public API через `index.ts`; внутренние файлы соседний слой не импортирует напрямую. Для cross-slice imports используйте alias `@/` и public API, например `@/entities/portfolio`. Vite и TypeScript используют одинаковый alias. + +`shared/theme/index.ts` экспортирует единую Mantine theme, семантические color +roles и CSSVariablesResolver и подключает базовые styles. App и Storybook +подключают свои MantineProvider к этому public API. Scales принимаются из Mantine, +локальные domain styles остаются у владельцев. `shared/ui` предоставляет +StateView и LoadingSkeleton; их каталог не включает route-aware AppShell. +Подробнее: [дизайн-система](../../docs/development/design-system.md). diff --git a/src/app/routes/AppRoutes.test.tsx b/src/app/routes/AppRoutes.test.tsx index e3594cd..81d6a53 100644 --- a/src/app/routes/AppRoutes.test.tsx +++ b/src/app/routes/AppRoutes.test.tsx @@ -6,9 +6,9 @@ import { MemoryRouter } from "react-router"; import { describe, expect, it } from "vitest"; import { AuthProvider } from "@/features/auth"; +import { cssVariablesResolver, theme } from "@/shared/theme"; import { createTestQueryClient } from "@/test/query"; import { server } from "@/test/server"; -import { theme } from "../theme"; import { AppRoutes } from "./AppRoutes"; describe("AppRoutes", () => { @@ -16,7 +16,10 @@ describe("AppRoutes", () => { return render( - + diff --git a/src/app/theme.ts b/src/app/theme.ts deleted file mode 100644 index d9147c2..0000000 --- a/src/app/theme.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { createTheme } from "@mantine/core"; - -const theme = createTheme({ - primaryColor: "blue", - defaultRadius: "md", - fontFamily: - '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif', - colors: { - blue: [ - "#eff6ff", - "#dbeafe", - "#bfdbfe", - "#93c5fd", - "#60a5fa", - "#3b82f6", - "#2563eb", - "#1d4ed8", - "#1e40af", - "#1e3a8a", - ], - }, -}); - -export { theme }; diff --git a/src/app/ui/App.tsx b/src/app/ui/App.tsx index 652f94d..5621e02 100644 --- a/src/app/ui/App.tsx +++ b/src/app/ui/App.tsx @@ -4,8 +4,8 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { FC } from "react"; import { BrowserRouter } from "react-router"; import { AppRoutes, RouteErrorBoundary } from "@/app/routes"; -import { theme } from "@/app/theme"; import { AuthProvider } from "@/features/auth"; +import { cssVariablesResolver, theme } from "@/shared/theme"; const queryClient = new QueryClient({ defaultOptions: { @@ -15,7 +15,11 @@ const queryClient = new QueryClient({ const App: FC = () => { return ( - + diff --git a/src/entities/brokerage-account/ui/BrokerageAccountList.stories.tsx b/src/entities/brokerage-account/ui/BrokerageAccountList.stories.tsx index 0fa73d2..26a02ae 100644 --- a/src/entities/brokerage-account/ui/BrokerageAccountList.stories.tsx +++ b/src/entities/brokerage-account/ui/BrokerageAccountList.stories.tsx @@ -19,7 +19,7 @@ const syntheticAccount = { } satisfies BrokerageAccount; const meta = { - title: "WealthLedger/BrokerageAccountList", + title: "Product/BrokerageAccountList", component: BrokerageAccountList, tags: ["autodocs"], argTypes: { diff --git a/src/main.tsx b/src/main.tsx index 0fe96a6..ba4b2f0 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -3,7 +3,6 @@ import { createRoot } from "react-dom/client"; import "@mantine/core/styles.css"; import "@mantine/dates/styles.css"; import "dayjs/locale/ru"; -import "./app/styles.css"; import "./shared/i18n"; import { App } from "./app"; diff --git a/src/pages/account/ui/components/AccountPageLoading.stories.tsx b/src/pages/account/ui/components/AccountPageLoading.stories.tsx index d9e270a..4bad647 100644 --- a/src/pages/account/ui/components/AccountPageLoading.stories.tsx +++ b/src/pages/account/ui/components/AccountPageLoading.stories.tsx @@ -2,7 +2,7 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { AccountPageLoading } from "./AccountPageLoading.view"; const meta = { - title: "WealthLedger/AccountPageLoading", + title: "Product/AccountPageLoading", component: AccountPageLoading, tags: ["autodocs"], parameters: { diff --git a/src/pages/account/ui/components/PortfolioRefreshState.stories.tsx b/src/pages/account/ui/components/PortfolioRefreshState.stories.tsx index 08161aa..7b259c2 100644 --- a/src/pages/account/ui/components/PortfolioRefreshState.stories.tsx +++ b/src/pages/account/ui/components/PortfolioRefreshState.stories.tsx @@ -5,7 +5,7 @@ import { PortfolioRefreshState } from "./PortfolioRefreshState.view"; import { PortfolioTotals } from "./PortfolioTotals.view"; const meta = { - title: "WealthLedger/PortfolioRefreshState", + title: "Product/PortfolioRefreshState", component: PortfolioRefreshState, tags: ["autodocs"], args: { refreshing: false, refreshError: false, retry: fn() }, diff --git a/src/pages/account/ui/components/PositionCard.stories.tsx b/src/pages/account/ui/components/PositionCard.stories.tsx index 3324434..a00c986 100644 --- a/src/pages/account/ui/components/PositionCard.stories.tsx +++ b/src/pages/account/ui/components/PositionCard.stories.tsx @@ -20,7 +20,7 @@ const syntheticPosition = { } satisfies AccountPagePosition; const meta = { - title: "WealthLedger/PositionCard", + title: "Product/PositionCard", component: PositionCard, tags: ["autodocs"], argTypes: { diff --git a/src/pages/account/ui/components/RecentEvents.stories.tsx b/src/pages/account/ui/components/RecentEvents.stories.tsx index 891a03e..4ace323 100644 --- a/src/pages/account/ui/components/RecentEvents.stories.tsx +++ b/src/pages/account/ui/components/RecentEvents.stories.tsx @@ -17,7 +17,7 @@ const syntheticEvent = { const eventsPath = "/accounts/storybook-account/events"; const meta = { - title: "WealthLedger/RecentEvents", + title: "Product/RecentEvents", component: RecentEvents, tags: ["autodocs"], argTypes: { diff --git a/src/shared/theme/Foundations.stories.tsx b/src/shared/theme/Foundations.stories.tsx new file mode 100644 index 0000000..324249f --- /dev/null +++ b/src/shared/theme/Foundations.stories.tsx @@ -0,0 +1,202 @@ +import { + Box, + Code, + Paper, + parseThemeColor, + SimpleGrid, + Stack, + Text, + Title, + useMantineTheme, +} from "@mantine/core"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import type { FC } from "react"; +import { colorRoles } from "./index"; + +const roleUsage: Record = { + canvas: "Фон приложения", + surface: "Поверхность Paper / формы", + subtle: "Приглушённая поверхность сводки", + text: "Основной текст страницы", + muted: "Вторичный текст; проверяйте контраст при выборе фона", + border: "Граница поверхности", + primary: "Основное действие и активная навигация", + info: "Информационное сообщение", + positive: "Положительный результат; дополните знаком и текстом", + negative: "Ошибка / отрицательный результат; дополните текстом", + warning: "Предупреждение; не мелкий текст на белом фоне", +}; + +const ColorsView: FC = () => { + const theme = useMantineTheme(); + return ( + + Цвета и семантические роли + + Светлая схема WealthLedger. Роли ссылаются на палитры Mantine, а не + хранят вторую копию цветов. Цвет не заменяет подпись состояния. + + + {Object.entries(colorRoles).map(([role, color]) => ( + + + + + ))} + + {["blue", "gray", "dark", "green", "red", "yellow", "grape"].map( + (palette) => ( + + {palette} + + {theme.colors[palette].map((value, shade) => ( + + + ))} + + + ), + )} + + Blue — фирменная палитра. Gray/dark — нейтрали; green/red/yellow — + статусы. Grape — дополнительный цвет графиков, его предметное значение + задаёт Product. Палитры не гарантируют контраст любых сочетаний. + + + ); +}; + +const TypographyView: FC = () => { + const theme = useMantineTheme(); + return ( + + Типографика + Основной шрифт: {theme.fontFamily} + Моноширинный: {theme.fontFamilyMonospace} + + Title задаёт смысловую иерархию; выбирайте order по структуре страницы. + Text — основной текст, size sm/xs — подписи; mono — идентификаторы. + + {Object.entries(theme.headings.sizes).map(([name, value]) => ( + + + {name} · Заголовок WealthLedger + + + {value.fontSize} / {value.lineHeight} · weight{" "} + {theme.headings.fontWeight} + + + ))} + {Object.entries(theme.fontSizes).map(([size, value]) => ( + + Размер {size} · Пример основного текста + + {value} / {theme.lineHeights[size]} + + + ))} + + Веса:{" "} + {Object.entries(theme.fontWeights) + .map(([name, value]) => `${name} ${value}`) + .join("; ")} + + + ); +}; + +const LayoutView: FC = () => { + const theme = useMantineTheme(); + return ( + + Интервалы, форма и адаптивность + + Используйте Mantine props gap/p/radius/shadow или --mantine-* в CSS. + Шкалы приняты из установленной Mantine без копий чисел. Default radius: + {theme.defaultRadius}; scale: {theme.scale}. Размеры конкретных карточек + и их особые тени остаются локальными. + + Spacing + {Object.entries(theme.spacing).map(([name, value]) => ( + + + {name} · {value} + + + ))} + Radius + + {Object.entries(theme.radius).map(([name, value]) => ( + + + {name} · {value} + + + ))} + + Shadows + {Object.entries(theme.shadows).map(([name, value]) => ( + + {name} + {value} + + ))} + Breakpoints + + Именованные пороги responsive props. Узкий экран — base; не фиксируйте + ширину контента по размеру desktop. + + {Object.entries(theme.breakpoints).map(([name, value]) => ( + + {name} · {value} + + ))} + + ); +}; + +const meta = { + title: "Design System/Foundations", + tags: ["autodocs"], + parameters: { + controls: { disable: true }, + docs: { + description: { + component: + "Единый shared/theme приложения и каталога. Все образцы читают effective Mantine theme. Показаны используемые шкалы и роли, а не весь Mantine. Светлая схема; контраст, смысловые заголовки и отсутствие передачи статуса только цветом проверяются в конкретной композиции.", + }, + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; +export const Colors: Story = { name: "Цвета", render: () => }; +export const Typography: Story = { + name: "Типографика", + render: () => , +}; +export const Layout: Story = { + name: "Интервалы и форма", + render: () => , +}; diff --git a/src/shared/theme/index.ts b/src/shared/theme/index.ts new file mode 100644 index 0000000..0bb6bdd --- /dev/null +++ b/src/shared/theme/index.ts @@ -0,0 +1,3 @@ +import "./styles.css"; + +export { colorRoles, cssVariablesResolver, theme } from "./theme"; diff --git a/src/app/styles.css b/src/shared/theme/styles.css similarity index 55% rename from src/app/styles.css rename to src/shared/theme/styles.css index d520723..222205d 100644 --- a/src/app/styles.css +++ b/src/shared/theme/styles.css @@ -3,8 +3,8 @@ } body { margin: 0; - background: var(--mantine-color-gray-2); - color: var(--mantine-color-dark-7); + background: var(--wl-color-canvas); + color: var(--wl-color-text); } a { color: inherit; diff --git a/src/shared/theme/theme.ts b/src/shared/theme/theme.ts new file mode 100644 index 0000000..8530b43 --- /dev/null +++ b/src/shared/theme/theme.ts @@ -0,0 +1,69 @@ +import { + type CSSVariablesResolver, + createTheme, + DEFAULT_THEME, + getThemeColor, +} from "@mantine/core"; + +// Native Mantine color references, not a second palette. Light scheme only. +const colorRoles = { + canvas: "gray.2", + surface: "white", + subtle: "gray.0", + text: "dark.7", + muted: "gray.6", + border: "gray.3", + primary: "blue", + info: "blue.6", + positive: "green.6", + negative: "red.6", + warning: "yellow.6", +} as const; + +const fontFamily = + '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif'; + +const theme = createTheme({ + primaryColor: "blue", + defaultRadius: "md", + fontFamily, + // Explicitly adopt the installed Mantine scales without duplicating values. + fontFamilyMonospace: DEFAULT_THEME.fontFamilyMonospace, + fontSizes: DEFAULT_THEME.fontSizes, + fontWeights: DEFAULT_THEME.fontWeights, + lineHeights: DEFAULT_THEME.lineHeights, + headings: { ...DEFAULT_THEME.headings, fontFamily }, + spacing: DEFAULT_THEME.spacing, + radius: DEFAULT_THEME.radius, + shadows: DEFAULT_THEME.shadows, + breakpoints: DEFAULT_THEME.breakpoints, + primaryShade: DEFAULT_THEME.primaryShade, + focusRing: DEFAULT_THEME.focusRing, + colors: { + blue: [ + "#eff6ff", + "#dbeafe", + "#bfdbfe", + "#93c5fd", + "#60a5fa", + "#3b82f6", + "#2563eb", + "#1d4ed8", + "#1e40af", + "#1e3a8a", + ], + }, +}); + +const cssVariablesResolver: CSSVariablesResolver = (resolvedTheme) => ({ + variables: Object.fromEntries( + Object.entries(colorRoles).map(([role, color]) => [ + `--wl-color-${role}`, + getThemeColor(color, resolvedTheme), + ]), + ), + light: {}, + dark: {}, +}); + +export { colorRoles, cssVariablesResolver, theme }; diff --git a/src/shared/ui/Button.stories.tsx b/src/shared/ui/Button.stories.tsx new file mode 100644 index 0000000..418c27e --- /dev/null +++ b/src/shared/ui/Button.stories.tsx @@ -0,0 +1,58 @@ +import { Button, type ButtonProps } from "@mantine/core"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, fn, userEvent, within } from "storybook/test"; + +type ButtonStoryArgs = ButtonProps & { onClick: () => void }; + +const meta = { + title: "Design System/Components/Button", + component: Button, + tags: ["autodocs"], + args: { + children: "Выполнить пример", + onClick: fn(), + loading: false, + disabled: false, + variant: "filled", + }, + argTypes: { + children: { control: "text" }, + variant: { control: "select", options: ["filled", "default", "subtle"] }, + loading: { control: "boolean" }, + disabled: { control: "boolean" }, + onClick: { control: false }, + }, + parameters: { + docs: { + description: { + component: + "Прямой Mantine Button с общей темой WealthLedger, без wrapper. Filled — основное действие; default — вторичное действие/retry; subtle — малозаметное вспомогательное действие. Radius md, primary blue и keyboard focus приходят из темы. Дайте кнопке доступное имя; loading означает mutation, disabled — недоступное действие. Loading/disabled не исполняют callback. Actions и interaction проверяют локальный callback, не API recovery.", + }, + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; +export const Primary: Story = { + name: "Основное действие", + play: async ({ canvasElement, args }) => { + const button = within(canvasElement).getByRole("button", { + name: String(args.children), + }); + button.focus(); + await expect(button).toHaveFocus(); + await userEvent.keyboard("{Enter}"); + await expect(args.onClick).toHaveBeenCalled(); + }, +}; +export const Secondary: Story = { + name: "Вторичное действие", + args: { variant: "default" }, +}; +export const Subtle: Story = { + name: "Вспомогательное действие", + args: { variant: "subtle" }, +}; +export const Loading: Story = { name: "Выполнение", args: { loading: true } }; +export const Disabled: Story = { name: "Недоступно", args: { disabled: true } }; diff --git a/src/shared/ui/LoadingSkeleton.stories.tsx b/src/shared/ui/LoadingSkeleton.stories.tsx new file mode 100644 index 0000000..42ab8e0 --- /dev/null +++ b/src/shared/ui/LoadingSkeleton.stories.tsx @@ -0,0 +1,35 @@ +import { Paper, Skeleton } from "@mantine/core"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { LoadingSkeleton } from "./LoadingSkeleton"; + +const meta = { + title: "Design System/Components/LoadingSkeleton", + component: LoadingSkeleton, + tags: ["autodocs"], + args: { label: "Загрузка содержимого" }, + argTypes: { + label: { control: "text" }, + children: { table: { disable: true } }, + }, + parameters: { + docs: { + description: { + component: + "Общая status/busy область для initial или partial loading. Передайте доступную метку и только декоративные Skeleton shapes без интерактивных элементов. Геометрию определяет владелец section/page (см. Product/AccountPageLoading). При refetch сохраняйте уже видимые данные; для действия используйте Button loading. Этот пример не запускает запрос и не демонстрирует query lifecycle.", + }, + }, + }, + render: (args) => ( + + + + + + + + ), +} satisfies Meta; + +export default meta; +type Story = StoryObj; +export const Initial: Story = { name: "Загрузка области" }; diff --git a/src/shared/ui/StateView.stories.tsx b/src/shared/ui/StateView.stories.tsx index fc6e283..29f7398 100644 --- a/src/shared/ui/StateView.stories.tsx +++ b/src/shared/ui/StateView.stories.tsx @@ -10,7 +10,7 @@ type StateStoryArgs = ComponentProps & { }; const meta = { - title: "WealthLedger/StateView", + title: "Design System/Components/StateView", component: StateView, tags: ["autodocs"], args: {