feat(ui): establish shared design system foundations

This commit is contained in:
2026-09-08 07:09:05 +03:00
parent 36698322b7
commit d762ba5034
18 changed files with 401 additions and 40 deletions
+7 -2
View File
@@ -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) => (
<MantineProvider
cssVariablesResolver={cssVariablesResolver}
theme={theme}
forceColorScheme="light"
colorSchemeManager={colorSchemeManager}
+8 -1
View File
@@ -2,7 +2,7 @@
Проект использует FSD-направление импортов: `app -> 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).
+5 -2
View File
@@ -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(
<QueryClientProvider client={createTestQueryClient()}>
<MemoryRouter initialEntries={[path]}>
<MantineProvider theme={theme}>
<MantineProvider
cssVariablesResolver={cssVariablesResolver}
theme={theme}
>
<AuthProvider>
<AppRoutes />
</AuthProvider>
-24
View File
@@ -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 };
+6 -2
View File
@@ -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 (
<MantineProvider theme={theme} forceColorScheme="light">
<MantineProvider
cssVariablesResolver={cssVariablesResolver}
theme={theme}
forceColorScheme="light"
>
<DatesProvider settings={{ locale: "ru" }}>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
@@ -19,7 +19,7 @@ const syntheticAccount = {
} satisfies BrokerageAccount;
const meta = {
title: "WealthLedger/BrokerageAccountList",
title: "Product/BrokerageAccountList",
component: BrokerageAccountList,
tags: ["autodocs"],
argTypes: {
-1
View File
@@ -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";
@@ -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: {
@@ -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() },
@@ -20,7 +20,7 @@ const syntheticPosition = {
} satisfies AccountPagePosition;
const meta = {
title: "WealthLedger/PositionCard",
title: "Product/PositionCard",
component: PositionCard,
tags: ["autodocs"],
argTypes: {
@@ -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: {
+202
View File
@@ -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<keyof typeof colorRoles, string> = {
canvas: "Фон приложения",
surface: "Поверхность Paper / формы",
subtle: "Приглушённая поверхность сводки",
text: "Основной текст страницы",
muted: "Вторичный текст; проверяйте контраст при выборе фона",
border: "Граница поверхности",
primary: "Основное действие и активная навигация",
info: "Информационное сообщение",
positive: "Положительный результат; дополните знаком и текстом",
negative: "Ошибка / отрицательный результат; дополните текстом",
warning: "Предупреждение; не мелкий текст на белом фоне",
};
const ColorsView: FC = () => {
const theme = useMantineTheme();
return (
<Stack>
<Title order={1}>Цвета и семантические роли</Title>
<Text>
Светлая схема WealthLedger. Роли ссылаются на палитры Mantine, а не
хранят вторую копию цветов. Цвет не заменяет подпись состояния.
</Text>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }}>
{Object.entries(colorRoles).map(([role, color]) => (
<Paper key={role} p="md" withBorder>
<Stack gap="xs">
<Box h={48} bg={`var(--wl-color-${role})`} aria-hidden="true" />
<Text fw={700}>{role}</Text>
<Code>--wl-color-{role}</Code>
<Text size="sm">
{color} · {parseThemeColor({ color, theme }).value}
</Text>
<Text size="sm">
{roleUsage[role as keyof typeof colorRoles]}
</Text>
</Stack>
</Paper>
))}
</SimpleGrid>
{["blue", "gray", "dark", "green", "red", "yellow", "grape"].map(
(palette) => (
<Stack key={palette} gap="xs">
<Title order={2}>{palette}</Title>
<SimpleGrid cols={{ base: 2, sm: 5, lg: 10 }}>
{theme.colors[palette].map((value, shade) => (
<Paper key={value} p="xs" withBorder>
<Box h={40} bg={value} aria-hidden="true" />
<Text size="sm">
{palette}.{shade}
</Text>
<Text size="xs">{value}</Text>
</Paper>
))}
</SimpleGrid>
</Stack>
),
)}
<Text>
Blue — фирменная палитра. Gray/dark — нейтрали; green/red/yellow —
статусы. Grape — дополнительный цвет графиков, его предметное значение
задаёт Product. Палитры не гарантируют контраст любых сочетаний.
</Text>
</Stack>
);
};
const TypographyView: FC = () => {
const theme = useMantineTheme();
return (
<Stack style={{ overflowWrap: "anywhere" }}>
<Title order={1}>Типографика</Title>
<Text>Основной шрифт: {theme.fontFamily}</Text>
<Text ff="monospace">Моноширинный: {theme.fontFamilyMonospace}</Text>
<Text>
Title задаёт смысловую иерархию; выбирайте order по структуре страницы.
Text — основной текст, size sm/xs — подписи; mono — идентификаторы.
</Text>
{Object.entries(theme.headings.sizes).map(([name, value]) => (
<Paper key={name} withBorder p="md">
<Text
fz={value.fontSize}
lh={value.lineHeight}
fw={theme.headings.fontWeight}
>
{name} · Заголовок WealthLedger
</Text>
<Text size="sm">
{value.fontSize} / {value.lineHeight} · weight{" "}
{theme.headings.fontWeight}
</Text>
</Paper>
))}
{Object.entries(theme.fontSizes).map(([size, value]) => (
<Paper key={size} p="md" withBorder>
<Text size={size}>Размер {size} · Пример основного текста</Text>
<Text size="sm">
{value} / {theme.lineHeights[size]}
</Text>
</Paper>
))}
<Text>
Веса:{" "}
{Object.entries(theme.fontWeights)
.map(([name, value]) => `${name} ${value}`)
.join("; ")}
</Text>
</Stack>
);
};
const LayoutView: FC = () => {
const theme = useMantineTheme();
return (
<Stack style={{ overflowWrap: "anywhere" }}>
<Title order={1}>Интервалы, форма и адаптивность</Title>
<Text>
Используйте Mantine props gap/p/radius/shadow или --mantine-* в CSS.
Шкалы приняты из установленной Mantine без копий чисел. Default radius:
{theme.defaultRadius}; scale: {theme.scale}. Размеры конкретных карточек
и их особые тени остаются локальными.
</Text>
<Title order={2}>Spacing</Title>
{Object.entries(theme.spacing).map(([name, value]) => (
<Paper key={name} p="sm" withBorder>
<Text>
{name} · {value}
</Text>
<Box w={value} h={16} bg="blue" aria-hidden="true" />
</Paper>
))}
<Title order={2}>Radius</Title>
<SimpleGrid cols={{ base: 1, sm: 3 }}>
{Object.entries(theme.radius).map(([name, value]) => (
<Paper key={name} radius={name} p="md" withBorder>
<Text>
{name} · {value}
</Text>
</Paper>
))}
</SimpleGrid>
<Title order={2}>Shadows</Title>
{Object.entries(theme.shadows).map(([name, value]) => (
<Paper key={name} shadow={name} p="md" withBorder>
<Text fw={700}>{name}</Text>
<Text size="sm">{value}</Text>
</Paper>
))}
<Title order={2}>Breakpoints</Title>
<Text>
Именованные пороги responsive props. Узкий экран — base; не фиксируйте
ширину контента по размеру desktop.
</Text>
{Object.entries(theme.breakpoints).map(([name, value]) => (
<Text key={name}>
{name} · {value}
</Text>
))}
</Stack>
);
};
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<typeof meta>;
export const Colors: Story = { name: "Цвета", render: () => <ColorsView /> };
export const Typography: Story = {
name: "Типографика",
render: () => <TypographyView />,
};
export const Layout: Story = {
name: "Интервалы и форма",
render: () => <LayoutView />,
};
+3
View File
@@ -0,0 +1,3 @@
import "./styles.css";
export { colorRoles, cssVariablesResolver, theme } from "./theme";
@@ -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;
+69
View File
@@ -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 };
+58
View File
@@ -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<ButtonStoryArgs>;
export default meta;
type Story = StoryObj<ButtonStoryArgs>;
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 } };
+35
View File
@@ -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) => (
<Paper p="md" withBorder>
<LoadingSkeleton {...args}>
<Skeleton h={24} w="60%" />
<Skeleton h={72} />
<Skeleton h={16} w="80%" />
</LoadingSkeleton>
</Paper>
),
} satisfies Meta<typeof LoadingSkeleton>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Initial: Story = { name: "Загрузка области" };
+1 -1
View File
@@ -10,7 +10,7 @@ type StateStoryArgs = ComponentProps<typeof StateView> & {
};
const meta = {
title: "WealthLedger/StateView",
title: "Design System/Components/StateView",
component: StateView,
tags: ["autodocs"],
args: {