refactor: enforce frontend view model boundaries
Frontend verify / commits (pull_request) Successful in 28s
Frontend verify / security (pull_request) Successful in 10s
Frontend verify / verify (pull_request) Successful in 8m19s

This commit is contained in:
2026-09-28 18:48:23 +03:00
parent 5b69003516
commit ca0ad4f824
287 changed files with 8558 additions and 4538 deletions
@@ -1,365 +0,0 @@
.icon {
overflow: visible;
& path {
--motion: none;
transform-box: fill-box;
transform-origin: center;
}
}
.deposit path:nth-child(-n + 2) {
transform-box: view-box;
transform-origin: 12px 9px;
--motion: bank-operation-coin-turn 1000ms ease-in-out both;
}
.repayment path:last-child {
--motion: bank-operation-ruble-in 1000ms ease-out both;
}
.topup path:last-child {
--motion: bank-operation-plus-in 900ms ease-out both;
}
.transfer path {
--shift: 3px;
--motion: bank-operation-exchange 700ms ease-in-out both;
&:last-child {
--shift: -3px;
}
}
.growth {
& path:nth-child(2) {
stroke-dasharray: 1;
--motion: bank-operation-draw 450ms ease-out both;
}
& path:last-child {
--motion: bank-operation-arrow-rise 350ms ease-out 400ms both;
}
}
.server {
& path:nth-child(3) {
--motion: bank-operation-blink 500ms ease-in-out both;
}
& path:nth-child(4) {
--motion: bank-operation-blink 500ms ease-in-out 220ms both;
}
}
.basket {
& path:not(:last-child) {
--motion: bank-operation-settle 850ms ease-in-out both;
}
& path:last-child {
opacity: 0;
fill: currentColor;
--motion: bank-operation-grocery-drop 850ms ease-out both;
}
}
.wine {
--motion: bank-operation-cheers 1100ms ease-in-out both;
transform-origin: 12px 21px;
& path:nth-child(2) {
fill: currentColor;
fill-opacity: 0.2;
transform-origin: bottom;
--motion: bank-operation-fill-glass 1100ms ease-out both;
}
}
.coffee {
& path:nth-child(3),
& path:nth-child(4) {
--motion: bank-operation-steam 1000ms ease-out var(--delay, 0ms) both;
}
& path:nth-child(4) {
--delay: 180ms;
}
}
.grid path {
--x: -1.5px;
--y: -1.5px;
--turn: 0deg;
--motion: bank-operation-rearrange 850ms ease-in-out both;
&:nth-child(2) {
--x: 1.5px;
--turn: 90deg;
}
&:nth-child(3) {
--y: 1.5px;
}
&:nth-child(4) {
--x: 1.5px;
--y: 1.5px;
}
}
.wifi path {
--motion: bank-operation-signal 450ms ease-out var(--delay, 0ms) both;
&:nth-child(1) {
--delay: 360ms;
}
&:nth-child(2) {
--delay: 240ms;
}
&:nth-child(3) {
--delay: 120ms;
}
}
.car {
--motion: bank-operation-drive 1100ms ease-in-out both;
& path:nth-child(-n + 3) {
--motion: bank-operation-suspension 275ms ease-in-out 4 both;
}
& path:nth-last-child(-n + 2) {
--motion: bank-operation-wheel-roll 1100ms ease-in-out both;
}
}
@media (prefers-reduced-motion: no-preference) {
.icon[data-icon-animating] {
animation: var(--motion, none);
& path {
animation: var(--motion, none);
}
}
}
@keyframes :global(bank-operation-coin-turn) {
0%,
100% {
transform: scaleX(1);
}
35%,
65% {
transform: scaleX(0.12);
}
50% {
transform: scaleX(-1);
}
}
@keyframes :global(bank-operation-settle) {
0%,
30%,
100% {
transform: translateY(0);
}
45% {
transform: translateY(1.5px);
}
65% {
transform: translateY(-0.5px);
}
}
@keyframes :global(bank-operation-ruble-in) {
0% {
opacity: 0;
transform: translateY(-9px) scale(0.7);
}
25% {
opacity: 1;
}
65% {
transform: translateY(0) scale(1.15);
}
80% {
transform: scale(0.95);
}
100% {
opacity: 1;
transform: scale(1);
}
}
@keyframes :global(bank-operation-plus-in) {
0% {
opacity: 0;
transform: translateY(-5px) scale(0.5);
}
55% {
opacity: 1;
transform: translateY(0) scale(1.3);
}
75% {
transform: scale(0.9);
}
100% {
opacity: 1;
transform: scale(1);
}
}
@keyframes :global(bank-operation-exchange) {
0%,
100% {
transform: translateX(0);
}
45% {
transform: translateX(var(--shift));
}
}
@keyframes :global(bank-operation-draw) {
from {
stroke-dashoffset: 1;
}
to {
stroke-dashoffset: 0;
}
}
@keyframes :global(bank-operation-arrow-rise) {
0%,
100% {
transform: translateY(0);
}
45% {
transform: translateY(-2px);
}
}
@keyframes :global(bank-operation-blink) {
0%,
100% {
stroke-width: 1.6;
}
45% {
stroke: var(--mantine-color-green-6);
stroke-width: 3;
}
}
@keyframes :global(bank-operation-grocery-drop) {
0% {
opacity: 0;
transform: translateY(-5px);
}
20% {
opacity: 1;
}
55% {
opacity: 1;
transform: translateY(7px);
}
80%,
100% {
opacity: 0;
transform: translateY(7px);
}
}
@keyframes :global(bank-operation-fill-glass) {
0% {
transform: scaleY(0.05);
}
45%,
75% {
transform: scaleY(1);
fill-opacity: 0.5;
}
100% {
transform: scaleY(1);
fill-opacity: 0.2;
}
}
@keyframes :global(bank-operation-cheers) {
0%,
40%,
100% {
transform: rotate(0);
}
65% {
transform: rotate(8deg);
}
}
@keyframes :global(bank-operation-steam) {
0% {
opacity: 0;
transform: translateY(2px);
}
25% {
opacity: 1;
}
70% {
opacity: 0;
transform: translateY(-3px);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
@keyframes :global(bank-operation-rearrange) {
0%,
100% {
transform: translate(0, 0) rotate(0);
}
50% {
transform: translate(var(--x), var(--y)) rotate(var(--turn));
}
}
@keyframes :global(bank-operation-signal) {
0% {
opacity: 0.1;
}
100% {
opacity: 1;
}
}
@keyframes :global(bank-operation-drive) {
0% {
opacity: 0;
transform: translateX(-4px);
}
20% {
opacity: 1;
}
80% {
opacity: 1;
transform: translateX(3px);
}
90% {
opacity: 0;
transform: translateX(4px);
}
91% {
opacity: 0;
transform: translateX(0);
}
100% {
opacity: 1;
transform: translateX(0);
}
}
@keyframes :global(bank-operation-suspension) {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-0.5px);
}
}
@keyframes :global(bank-operation-wheel-roll) {
from {
transform: rotate(0);
}
to {
transform: rotate(360deg);
}
}
@@ -1,9 +1,17 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Stack } from "@mantine/core";
import { OperationIcon } from "@wealthledger/icons";
import { EntryCard } from "@wealthledger/ui";
import { BankOperationIcon } from "./BankOperationIcon";
import { bankOperationAppearance } from "../../model/operation-appearance";
const BankOperationIcon = (props: {
categoryName?: null | string;
kind: "expense" | "refund" | "topup" | "transfer";
}) => (
<OperationIcon {...bankOperationAppearance(props.kind, props.categoryName)} />
);
const meta = {
args: { categoryName: "Продукты", kind: "expense" },
@@ -11,8 +19,11 @@ const meta = {
tags: ["autodocs"],
title: "Product/BankCards/OperationIcons",
} satisfies Meta<typeof BankOperationIcon>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Categories: Story = {
render: () => (
<Stack maw={640}>
@@ -55,6 +66,7 @@ export const Categories: Story = {
valueLabel="Расход"
/>
))}
<EntryCard
color="green"
icon={<BankOperationIcon kind="topup" />}
@@ -1,12 +1,21 @@
import { render } from "@testing-library/react";
import { OperationIcon } from "@wealthledger/icons";
import { expect, it } from "vitest";
import { BankOperationIcon } from "./BankOperationIcon";
import { bankOperationAppearance } from "../../model/operation-appearance";
const BankOperationIcon = (props: {
categoryName?: null | string;
kind: "expense" | "refund" | "topup" | "transfer";
}) => (
<OperationIcon {...bankOperationAppearance(props.kind, props.categoryName)} />
);
it("matches normalized category names, falls back after renaming and distinguishes operation kinds", () => {
const { container, rerender } = render(
<BankOperationIcon categoryName="Жильё" kind="expense" />,
);
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish.
const path = () => container.querySelector("path")?.getAttribute("d");
const housing = path();
@@ -15,16 +24,25 @@ it("matches normalized category names, falls back after renaming and distinguish
rerender(<BankOperationIcon categoryName="Другое" kind="expense" />);
const fallback = path();
expect(fallback).not.toBe(housing);
for (const categoryName of ["Моя категория", "constructor", /* eslint-disable-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract. */ null]) {
for (const categoryName of [
"Моя категория",
"constructor",
// eslint-disable-next-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract.
null,
]) {
rerender(<BankOperationIcon categoryName={categoryName} kind="expense" />);
expect(path()).toBe(fallback);
}
const paths = [fallback, housing];
for (const kind of ["topup", "transfer", "refund"] as const) {
rerender(<BankOperationIcon categoryName="Жильё" kind={kind} />);
expect(path()).toBeTruthy();
paths.push(path());
}
expect(new Set(paths).size).toBe(5);
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish.
expect(container.querySelector("svg")).toHaveAttribute("aria-hidden", "true");
@@ -34,8 +52,10 @@ it("provides a distinct icon for every existing named category", () => {
const { container, rerender } = render(
<BankOperationIcon categoryName="Другое" kind="expense" />,
);
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish.
const paths = new Set([container.querySelector("path")?.getAttribute("d")]);
for (const categoryName of [
"Алкоголь",
"Вклады",
@@ -1,39 +0,0 @@
import type { FC } from "react";
import { bindIconAnimation } from "@/shared/lib";
import type { BankOperation } from "../../api/queries";
import { bankOperationAppearance } from "../../model/operation-appearance";
import classes from "./BankOperationIcon.module.css";
type BankOperationIconProps = {
categoryName?: null | string;
kind: BankOperation["kind"];
};
const BankOperationIcon: FC<BankOperationIconProps> = (props) => {
const icon = bankOperationAppearance(props.kind, props.categoryName);
return (
<svg
aria-hidden="true"
className={`${classes.icon} ${icon.animation ? classes[icon.animation] : ""}`}
fill="none"
focusable="false"
height="24"
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.6"
style={{ color: `var(--mantine-color-${icon.color}-7)`, flexShrink: 0 }}
viewBox="0 0 24 24"
width="24"
>
{icon.path.split(" M").map((part, index) => (
<path d={index ? `M${part}` : part} key={part} pathLength={1} />
))}
</svg>
);
};
export { BankOperationIcon };