refactor: establish local frontend workspace boundaries
Conventional commits / commits (pull_request) Successful in 24s
Frontend security / security (pull_request) Successful in 9s
Frontend verify / verify (pull_request) Successful in 5m18s
Frontend security / security (push) Successful in 10s
Frontend verify / verify (push) Successful in 6m45s

OpenSpec change: evolve-frontend-stack
This commit was merged in pull request #34.
This commit is contained in:
2026-09-22 22:11:02 +03:00
parent c86be7bf73
commit 85fcfe3d07
101 changed files with 1487 additions and 666 deletions
-258
View File
@@ -1,258 +0,0 @@
.content {
max-width: 1120px;
margin-inline: auto;
}
.header {
align-items: center;
display: flex;
padding: 0 var(--mantine-spacing-md);
}
.bottomNav {
display: none;
}
.navIcon {
display: inline-flex;
flex-shrink: 0;
}
.portfolioIcon {
color: var(--mantine-color-blue-7);
}
.cardsIcon {
color: var(--mantine-color-grape-7);
& svg {
overflow: visible;
}
}
.rubles {
pointer-events: none;
color: var(--mantine-color-green-8);
stroke: currentColor;
& path {
opacity: 0;
--flight-x: -7px;
--flight-rotation: -20deg;
--flight-delay: 0ms;
}
& path:nth-child(2) {
--flight-x: 0px;
--flight-rotation: 8deg;
--flight-delay: 120ms;
}
& path:nth-child(3) {
--flight-x: 7px;
--flight-rotation: 24deg;
--flight-delay: 240ms;
}
}
.stocksIcon {
color: var(--mantine-color-green-8);
}
.bondsIcon {
color: var(--mantine-color-orange-8);
}
.settingsIcon {
color: var(--mantine-color-gray-7);
}
.portfolioRuble {
stroke: var(--mantine-color-green-8);
opacity: 0;
}
.stockArrow {
transform-origin: 21px 5px;
}
.topSlider,
.bottomSlider {
fill: var(--mantine-color-body);
}
@media (prefers-reduced-motion: no-preference) {
.navLink {
transition: background-color 180ms ease-in-out;
}
.drawStroke,
.couponLines path {
stroke-dasharray: 1;
}
.topSlider {
--slider-offset: 3px;
--slider-delay: 0ms;
}
.bottomSlider {
--slider-offset: -3px;
--slider-delay: 160ms;
}
.couponLines path:nth-child(2) {
--line-delay: 140ms;
}
.couponLines path:nth-child(3) {
--line-delay: 280ms;
}
svg[data-icon-animating] {
.rubles path {
animation: ruble-fly 800ms ease-out var(--flight-delay) both;
}
.portfolioHandle {
animation: handle-lift 1200ms ease-in-out;
}
.portfolioLid {
animation: lid-open 1200ms ease-in-out;
}
.portfolioRuble {
animation: portfolio-ruble-reveal 1200ms ease-in-out;
}
.stockArrow {
animation: arrow-pop 300ms ease-out 400ms both;
}
.couponLines path {
animation: stroke-draw 300ms ease-out var(--line-delay, 0ms) both;
}
.drawStroke {
animation: stroke-draw 450ms ease-out;
}
:is(.topSlider, .bottomSlider) {
animation: slider-slide 650ms ease-in-out var(--slider-delay) both;
}
}
}
@keyframes ruble-fly {
0% {
opacity: 0;
transform: translate(0, 4px);
}
20%,
50% {
opacity: 1;
}
100% {
opacity: 0;
transform: translate(var(--flight-x), -10px) rotate(var(--flight-rotation));
}
}
@keyframes handle-lift {
0%,
100% {
transform: translateY(0);
}
20% {
transform: translateY(-3px);
}
40%,
75% {
transform: translateY(-3px);
}
}
@keyframes lid-open {
0%,
15%,
100% {
transform: translateY(0);
}
40%,
75% {
transform: translateY(-3px);
}
}
@keyframes portfolio-ruble-reveal {
0%,
25%,
100% {
opacity: 0;
transform: translateY(2px);
}
40%,
75% {
opacity: 1;
transform: translateY(0);
}
}
@keyframes arrow-pop {
0% {
opacity: 0;
transform: scale(0.7);
}
60% {
opacity: 1;
transform: scale(1.2);
}
100% {
opacity: 1;
transform: scale(1);
}
}
@keyframes stroke-draw {
0% {
stroke-dashoffset: 1;
}
100% {
stroke-dashoffset: 0;
}
}
@keyframes slider-slide {
0%,
100% {
transform: translateX(0);
}
50% {
transform: translateX(var(--slider-offset));
}
}
@media (max-width: 47.99375em) {
.bottomNav {
background: var(--mantine-color-white);
border-top: 1px solid var(--mantine-color-gray-3);
bottom: 0;
display: flex;
justify-content: space-around;
left: 0;
padding: var(--mantine-spacing-xs) 2px;
gap: 2px;
font-size: 11px;
& a {
min-width: 0;
padding: 8px 2px;
text-align: center;
}
position: fixed;
right: 0;
z-index: 200;
}
.active {
color: var(--mantine-primary-color-filled);
font-weight: 600;
}
.main {
padding-bottom: 5rem;
}
}
-161
View File
@@ -1,161 +0,0 @@
import { AppShell as MantineAppShell, NavLink, Text } from "@mantine/core";
import type { FC, ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Outlet, NavLink as RouterNavLink, useLocation } from "react-router";
import { bindIconAnimation } from "../lib";
import classes from "./AppShell.module.css";
type AppShellProps = {
contentAboveOutlet?: ReactNode;
};
const AppShell: FC<AppShellProps> = (props) => {
const { contentAboveOutlet } = props;
const { t } = useTranslation();
const { pathname } = useLocation();
const items = [
{
to: "/accounts",
label: t("navigation.accounts"),
iconClassName: classes.portfolioIcon,
icon: (
<>
<path d="M3 10v9h18v-9" />
<path className={classes.portfolioHandle} d="M8 6V4h8v2" />
<path
className={classes.portfolioLid}
d="M3 10V8a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v2H3Zm6 0v3h6v-3"
/>
<path
className={classes.portfolioRuble}
d="M10 18v-7h3a2 2 0 0 1 0 4H9m0 1.5h4"
/>
</>
),
},
{
to: "/cards",
label: t("navigation.cards"),
iconClassName: classes.cardsIcon,
icon: (
<>
<path d="M3 5h18v14H3V5Zm0 4h18M6 15h4" />
<g className={classes.rubles}>
<path d="M10 12V4h3a2 2 0 0 1 0 4H9m0 2h4" />
<path d="M10 12V4h3a2 2 0 0 1 0 4H9m0 2h4" />
<path d="M10 12V4h3a2 2 0 0 1 0 4H9m0 2h4" />
</g>
</>
),
},
{
to: "/stocks",
label: t("navigation.stocks"),
iconClassName: classes.stocksIcon,
icon: (
<>
<path d="M3 3v18h18" />
<path
className={classes.drawStroke}
pathLength={1}
d="M7 14l5-5 4 3 5-7"
/>
<path className={classes.stockArrow} d="M16 5h5v5" />
</>
),
},
{
to: "/bonds",
label: t("navigation.bonds"),
iconClassName: classes.bondsIcon,
icon: (
<>
<path d="M6 3h12v18l-3-2-3 2-3-2-3 2V3Z" />
<g className={classes.couponLines}>
<path pathLength={1} d="M9 7h6" />
<path pathLength={1} d="M9 11h6" />
<path pathLength={1} d="M9 15h3" />
</g>
</>
),
},
{
to: "/settings",
label: t("navigation.settings"),
iconClassName: classes.settingsIcon,
icon: (
<>
<path d="M4 7h16M4 17h16" />
<path className={classes.topSlider} d="M10 4v6h4V4h-4Z" />
<path className={classes.bottomSlider} d="M10 14v6h4v-6h-4Z" />
</>
),
},
];
return (
<MantineAppShell
header={{ height: 60 }}
navbar={{ width: 220, breakpoint: "sm", collapsed: { mobile: true } }}
padding="md"
>
<MantineAppShell.Header className={classes.header}>
<Text fw={700}>{t("appName")}</Text>
</MantineAppShell.Header>
<MantineAppShell.Navbar p="sm">
{items.map((item) => (
<NavLink
component={RouterNavLink}
className={classes.navLink}
key={item.to}
to={item.to}
label={item.label}
leftSection={
<span
className={`${classes.navIcon} ${item.iconClassName ?? ""}`}
>
<svg
ref={bindIconAnimation}
width={20}
height={20}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
focusable="false"
>
{item.icon}
</svg>
</span>
}
active={pathname.startsWith(item.to)}
/>
))}
</MantineAppShell.Navbar>
<MantineAppShell.Main className={classes.main}>
<div className={classes.content}>
{contentAboveOutlet}
<Outlet />
</div>
</MantineAppShell.Main>
<nav className={classes.bottomNav} aria-label={t("appName")}>
{items.map((item) => (
<RouterNavLink
className={
pathname.startsWith(item.to) ? classes.active : undefined
}
key={item.to}
to={item.to}
>
{item.label}
</RouterNavLink>
))}
</nav>
</MantineAppShell>
);
};
export { AppShell };
-95
View File
@@ -1,95 +0,0 @@
.row {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: var(--mantine-spacing-sm);
align-items: center;
padding: var(--mantine-spacing-md);
border-color: var(--mantine-color-gray-3);
background: var(--mantine-color-gray-0);
}
.identity,
.value {
min-width: 0;
}
.value {
text-align: right;
white-space: nowrap;
}
.positive {
color: var(--wl-color-positive);
}
.negative {
color: var(--wl-color-negative);
}
.neutral {
color: var(--mantine-color-gray-7);
}
@media (max-width: 36em) {
.row {
grid-template-columns: auto minmax(0, 1fr);
}
.value {
grid-column: 2;
text-align: left;
}
}
.valueFooter {
display: grid;
align-items: center;
justify-items: end;
min-height: 28px;
}
.valueLabel,
.actions {
grid-area: 1 / 1;
transition: opacity 180ms ease;
}
.valueLabel {
opacity: 0;
}
.actions {
display: flex;
}
@media (hover: hover) and (pointer: fine) {
.valueLabel {
opacity: 1;
}
.actions {
opacity: 0;
pointer-events: none;
}
.row:hover .actions,
.row:focus-within .actions {
opacity: 1;
pointer-events: auto;
}
.row:hover .valueLabel,
.row:focus-within .valueLabel {
opacity: 0;
}
}
@media (max-width: 36em) {
.valueFooter {
justify-items: start;
}
}
@media (pointer: coarse) {
.actions button {
min-width: 36px;
min-height: 36px;
}
}
@media (prefers-reduced-motion: reduce) {
.valueLabel,
.actions {
transition: none;
}
}
-72
View File
@@ -1,72 +0,0 @@
import { Avatar, Paper, Stack, Text } from "@mantine/core";
import { type FC, type ReactNode, useState } from "react";
import classes from "./EntryCard.module.css";
type EntryCardProps = {
title: string;
metadata: string;
details?: ReactNode;
actions?: ReactNode;
initial: string;
icon?: ReactNode;
color?: string;
imageSrc?: string | null;
value: string;
valueLabel: string;
tone: "positive" | "negative" | "neutral";
};
const EntryCard: FC<EntryCardProps> = (props) => {
const [imageFailed, setImageFailed] = useState(false);
return (
<Paper
className={classes.row}
withBorder
style={
props.color
? {
background: `color-mix(in srgb, var(--mantine-color-${props.color}-0) 45%, white)`,
}
: undefined
}
>
<Avatar
alt=""
aria-hidden="true"
radius="md"
size={40}
src={imageFailed ? null : props.imageSrc}
onError={() => setImageFailed(true)}
>
{props.icon ?? props.initial}
</Avatar>
<Stack className={classes.identity} gap={2}>
<Text fw={700} lineClamp={1}>
{props.title}
</Text>
<Text c="dimmed" size="sm">
{props.metadata}
</Text>
{props.details}
</Stack>
<Stack className={classes.value} gap={2}>
<Text className={classes[props.tone]} fw={700}>
{props.value}
</Text>
<div className={props.actions ? classes.valueFooter : undefined}>
<Text
className={`${classes[props.tone]} ${props.actions ? classes.valueLabel : ""}`}
size="sm"
>
{props.valueLabel}
</Text>
{props.actions && (
<div className={classes.actions}>{props.actions}</div>
)}
</div>
</Stack>
</Paper>
);
};
export { EntryCard };
-35
View File
@@ -1,35 +0,0 @@
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: "Загрузка области" };
-18
View File
@@ -1,18 +0,0 @@
import { Stack } from "@mantine/core";
import type { FC, PropsWithChildren } from "react";
type LoadingSkeletonProps = PropsWithChildren<{
label: string;
}>;
const LoadingSkeleton: FC<LoadingSkeletonProps> = (props) => {
const { children, label } = props;
return (
<Stack aria-busy="true" aria-label={label} gap="md" role="status">
{children}
</Stack>
);
};
export { LoadingSkeleton };
-78
View File
@@ -1,78 +0,0 @@
import { Button } from "@mantine/core";
import type { Meta, StoryObj } from "@storybook/react-vite";
import type { ComponentProps } from "react";
import { fn } from "storybook/test";
import { StateView } from "./StateView";
type StateStoryArgs = ComponentProps<typeof StateView> & {
withAction: boolean;
onAction: () => void;
};
const meta = {
title: "Design System/Components/StateView",
component: StateView,
tags: ["autodocs"],
args: {
title: "Нет учебных данных",
text: "Синтетическое пустое состояние. Пользовательские данные не загружаются.",
error: false,
withAction: false,
onAction: fn(),
},
argTypes: {
title: { control: "text" },
text: { control: "text" },
error: { control: "boolean" },
action: { table: { disable: true } },
withAction: {
control: "boolean",
description: "Показать необязательное действие StateView",
},
onAction: { control: false },
},
parameters: {
docs: {
description: {
component:
"Общее empty/error состояние WealthLedger с необязательным действием. Error сохраняет alert semantics. Для initial loading используйте skeleton страницы, для refetch — локальное предупреждение рядом с сохранёнными данными. Кнопка вызывает только Actions; API recovery и полный пользовательский сценарий здесь не проверяются.",
},
},
},
render: (args) => {
const { withAction, onAction, ...stateProps } = args;
return (
<StateView
{...stateProps}
action={
withAction ? (
<Button onClick={onAction} variant="default">
{args.error ? "Повторить локально" : "Выполнить пример"}
</Button>
) : undefined
}
/>
);
},
} satisfies Meta<StateStoryArgs>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Empty: Story = { name: "Пустое состояние" };
export const EmptyWithAction: Story = {
name: "Пустое состояние с действием",
args: { withAction: true },
};
export const Failed: Story = {
name: "Ошибка",
args: {
error: true,
title: "Учебная ошибка",
text: "Демонстрация недоступных данных без вызова API.",
},
};
export const ErrorWithAction: Story = {
name: "Ошибка с retry",
args: { ...Failed.args, withAction: true },
};
-37
View File
@@ -1,37 +0,0 @@
import { MantineProvider } from "@mantine/core";
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { LoadingSkeleton } from "./LoadingSkeleton";
import { StateView } from "./StateView";
describe("StateView", () => {
it("renders an accessible structural loading region", () => {
render(
<MantineProvider>
<LoadingSkeleton label="Загрузка">
<div />
</LoadingSkeleton>
</MantineProvider>,
);
expect(screen.getByRole("status", { name: "Загрузка" })).toHaveAttribute(
"aria-busy",
"true",
);
});
it("renders an accessible error state", () => {
render(
<MantineProvider>
<StateView
error
text="Попробуйте обновить страницу позже."
title="Что-то пошло не так"
/>
,
</MantineProvider>,
);
expect(screen.getByRole("alert")).toHaveTextContent("Что-то пошло не так");
});
});
-26
View File
@@ -1,26 +0,0 @@
import { Paper, Stack, Text, Title } from "@mantine/core";
import type { FC, ReactNode } from "react";
type StateViewProps = {
title: string;
text: string;
action?: ReactNode;
error?: boolean;
};
const StateView: FC<StateViewProps> = (props) => {
const { title, text, action, error = false } = props;
const role = error ? "alert" : undefined;
return (
<Paper p="xl" radius="md" shadow="xs" withBorder role={role}>
<Stack align="flex-start" gap="sm">
<Title order={2}>{title}</Title>
<Text c="dimmed">{text}</Text>
{action}
</Stack>
</Paper>
);
};
export { StateView };
-4
View File
@@ -1,4 +0,0 @@
export { AppShell } from "./AppShell";
export { EntryCard } from "./EntryCard";
export { LoadingSkeleton } from "./LoadingSkeleton";
export { StateView } from "./StateView";