diff --git a/.ai/architecture.md b/.ai/architecture.md
index 877b55d..be5a6cd 100644
--- a/.ai/architecture.md
+++ b/.ai/architecture.md
@@ -51,23 +51,32 @@ Cross-slice consumer использует `@/` и `index.ts` владельца,
композиция принадлежит вышележащему owner. Existing меж-slice исключения
не являются разрешением добавлять новые.
-Направление зависимостей и public imports частично проверяет
-`npm run deps`; Knip проверяет использование exports, а не смысл placement.
+Направление зависимостей, public imports и запрет соседних slices в
+`pages`, `features`, `entities` проверяет `npm run deps`. Внутренние imports
+одного slice и связи shared segments этот запрет не ограничивает.
+Knip проверяет использование exports, а не смысл placement.
## UI foundation
-`shared/theme/tokens.json` — переносимый source design tokens; `theme.ts`
+Private workspace `packages/tokens` экспортирует переносимый source design tokens
+как `@wealthledger/tokens/tokens.json`. Private workspace `packages/ui`
адаптирует доверенный JSON к Mantine без runtime schema. App и Storybook читают
-public API `shared/theme`. Storybook показывает synthetic presentation states,
+`@wealthledger/ui/theme` и явно подключают `@wealthledger/ui/styles.css` после
+Mantine CSS. Потребители используют public exports пакетов напрямую. Storybook показывает synthetic presentation states,
не использует application API. RTL/MSW проверяют поведение приложения отдельно.
-Existing route-aware AppShell расположен в shared/ui; это legacy placement,
-а не образец нового shared primitive. При изменении foundation прочитай
+Route-aware AppShell и его navigation styles принадлежат `app/ui`;
+`ProtectedLayout` использует public API этого владельца. При изменении foundation прочитай
[UI](conventions/ui.md#визуальная-основа) и [Storybook](conventions/storybook.md).
+Статичные Add/Edit/Delete и публичный `IconProps` принадлежат private
+`packages/icons`; package не зависит от UI, domain или motion.
+`BankOperationIcon` и mappings остаются у entity. Card/Cards используют package
+Add/Edit/Delete; restore/category SVG остаются локальными.
+
## Примеры
-- Общая busy-область: существующий [LoadingSkeleton](../src/shared/ui/LoadingSkeleton.tsx), а не domain widget в shared.
+- Общая busy-область: существующий [LoadingSkeleton](../packages/ui/src/LoadingSkeleton.tsx), а не domain widget в shared.
- Account-income state views: [страница](../src/pages/account-income/ui/AccountIncomePage.tsx) и её flat `components/*.view.tsx`; новый page-local filter остаётся здесь.
- Portfolio query: [entity public API](../src/entities/portfolio/index.ts); новая UI-сводка с бизнес-смыслом не становится shared только из-за нескольких экранов.
@@ -75,7 +84,7 @@ Existing route-aware AppShell расположен в shared/ui; это legacy p
После placement/import change: `npm run deps`, `npm run typecheck`, `npm run knip`;
после собственного behavior — применимые tests. Финальный gate указан в AGENTS.
-Existing dependency config не запрещает все same-layer imports: их и смысл
-owner проверяет review. Не объявляй Steiger установленным; graph проверяет
-текущий dependency-cruiser. Review отдельно проверяет смысл owner: разрешённый dependency graph не доказывает
-правильный выбор слоя.
+Graph `src` и `packages` проверяет текущий dependency-cruiser; зависимости
+из packages в код приложения `src` запрещены. Generated `packages/*/dist`
+не анализируется как исходник; все package sources остаются в graph. Review отдельно проверяет смысл
+owner: разрешённый dependency graph не доказывает правильный выбор слоя.
diff --git a/.ai/conventions/storybook.md b/.ai/conventions/storybook.md
index 62fb6b9..1871394 100644
--- a/.ai/conventions/storybook.md
+++ b/.ai/conventions/storybook.md
@@ -1,5 +1,8 @@
# Storybook
+Единый каталог включает stories приложения и `packages/*/src`; общие
+StateView/LoadingSkeleton и их stories принадлежат `packages/ui`.
+
## Когда нужна story
- Shared UI/theme: показывай принятые visual roles и переиспользуемое поведение.
@@ -23,7 +26,7 @@ Stories не достигают application API, auth, VM и query providers д
контракт: keyboard activation, доступность действия, local callback. Не повторяй
RTL suite и не тестируй внутренности Mantine. Существующий
[Button](../../src/shared/ui/Button.stories.tsx) показывает themed control
-без production wrapper; [LoadingSkeleton](../../src/shared/ui/LoadingSkeleton.stories.tsx)
+без production wrapper; [LoadingSkeleton](../../packages/ui/src/LoadingSkeleton.stories.tsx)
— значимые busy states.
`npm run typecheck`, `npm run deps:storybook`, `npm run build:catalog` проверяют
diff --git a/.ai/conventions/ui.md b/.ai/conventions/ui.md
index badd161..a741cce 100644
--- a/.ai/conventions/ui.md
+++ b/.ai/conventions/ui.md
@@ -13,7 +13,10 @@ Mantine component и общую тему. Для выпадающего выбо
Icons ищи у существующего owner (например,
[BankOperationIcon](../../src/entities/bank-card/ui/BankOperationIcon.tsx));
-общая icon library сейчас не принята. Не добавляй пакет ради одного символа. Business/domain
+статичные Add/Edit/Delete доступны в `@wealthledger/icons`. Их SVG декоративны
+по умолчанию; имя action принадлежит кнопке. Standalone meaningful icon получает
+`aria-hidden={false}`, `role="img"` и доступное имя. Category/kind mappings
+и анимации остаются в entity. Не добавляй пакет ради одного символа. Business/domain
components живут у feature/entity/page, даже при повторном использовании.
Wrapper нужен для проектной ответственности, а не ради нового имени
@@ -107,10 +110,29 @@ loading/error/empty/loaded и recovery по применимости. [Storybook
## Визуальная основа
-Общая тема живёт в `src/shared/theme`: `tokens.json` задаёт переносимые значения,
-`theme.ts` адаптирует их к Mantine. Используй public API темы и её semantic roles,
-без отдельной палитры в page/component. Поддерживается светлая схема.
+Private workspace `packages/tokens` владеет переносимыми значениями
+`tokens.json`; `packages/ui/src/theme.ts` читает export пакета
+`@wealthledger/tokens/tokens.json` и адаптирует его к Mantine. Используй
+`@wealthledger/ui/theme` и его semantic roles; runtime подключает
+`@wealthledger/ui/styles.css` после Mantine CSS. Providers остаются у runtime.
+Палитра page/component следует этим semantic roles. Поддерживается светлая схема.
Цвет дополняет подпись или знак; проверяй контраст конкретной пары text/background.
Warning не используй для мелкого текста на белом. Деньги сохраняют валюту, знак
и различие между нулём и неизвестной суммой. Общие состояния демонстрирует
Storybook; presentation stories используют synthetic data без application API.
+
+EntryCard, StateView и LoadingSkeleton принадлежат `packages/ui`; публичный import —
+`@wealthledger/ui`. Переводы, retry и формы skeleton остаются у callers.
+Compatibility re-exports удалены; callers используют public export пакета.
+
+## Modal animation
+
+BankCardForm, OperationForm, DeleteOperation и CategoryManager монтируют Modal
+с `opened=true`: Mantine Transition начинает в состоянии `entered`.
+CSS `modal-appear`/`modal-backdrop-appear` в `packages/ui/src/styles.css`
+обеспечивает появление при mount (220ms, только no-preference). Mantine theme
+владеет exit transition (180ms), overlay blur и completion callback;
+`onExitTransitionEnd` позволяет дождаться закрытия перед unmount и вернуть фокус.
+Удаление CSS без изменения mount lifecycle убирает вступительную анимацию.
+TbankConnection Modal использует toggle `opened`; вывод о разделении фаз
+банковских форм на этот сценарий автоматически не распространяется.
diff --git a/.dockerignore b/.dockerignore
index ec510c1..0c527df 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -13,3 +13,30 @@
!public/**
!docker/
!docker/**
+!packages/
+!packages/tokens/
+!packages/tokens/package.json
+!packages/tokens/tokens.json
+!packages/ui/
+!packages/ui/package.json
+!packages/ui/vite.config.ts
+!packages/ui/tsconfig.json
+!packages/ui/src/
+!packages/ui/src/theme.ts
+!packages/ui/src/styles.css
+!packages/ui/src/index.ts
+!packages/ui/src/StateView.tsx
+!packages/ui/src/LoadingSkeleton.tsx
+!packages/ui/src/EntryCard.tsx
+!packages/ui/src/EntryCard.module.css
+!packages/icons/
+!packages/icons/package.json
+!packages/icons/tsconfig.json
+!packages/icons/vite.config.ts
+!packages/icons/src/
+!packages/icons/src/index.ts
+!packages/icons/src/types.ts
+!packages/icons/src/svg/
+!packages/icons/src/svg/AddIcon.tsx
+!packages/icons/src/svg/EditIcon.tsx
+!packages/icons/src/svg/DeleteIcon.tsx
diff --git a/.gitea/workflows/image.yml b/.gitea/workflows/image.yml
index 67ce36c..d802cea 100644
--- a/.gitea/workflows/image.yml
+++ b/.gitea/workflows/image.yml
@@ -45,15 +45,17 @@ jobs:
run: |
VERSION="${RELEASE_TAG#v}"
if docker manifest inspect "$IMAGE:$VERSION" >/dev/null 2>manifest-error.log; then
- echo 'Refusing to overwrite an existing release tag'; exit 1
+ DIGEST="$(docker buildx imagetools inspect "$IMAGE:$VERSION" --format '{{.Manifest.Digest}}')"
+ else
+ if ! grep -Eq 'manifest unknown|no such manifest' manifest-error.log; then
+ echo 'Cannot prove tag absence; refusing publication'; exit 1
+ fi
+ docker buildx build --target production --build-arg VERSION="$VERSION" --build-arg REVISION="$REVISION" --tag "$IMAGE:$VERSION" --metadata-file image-metadata.json --push .
+ DIGEST="$(node --input-type=commonjs -p 'require("./image-metadata.json")["containerimage.digest"]')"
fi
- if ! grep -Eq 'manifest unknown|no such manifest' manifest-error.log; then
- echo 'Cannot prove tag absence; refusing publication'; exit 1
- fi
- docker buildx build --target production --build-arg VERSION="$VERSION" --build-arg REVISION="$REVISION" --tag "$IMAGE:$VERSION" --metadata-file image-metadata.json --push .
- DIGEST="$(node --input-type=commonjs -p 'require("./image-metadata.json")["containerimage.digest"]')"
docker pull "$IMAGE@$DIGEST"
test "$(docker image inspect "$IMAGE@$DIGEST" --format '{{index .Config.Labels "org.opencontainers.image.revision"}}')" = "$REVISION"
+ test "$(docker image inspect "$IMAGE@$DIGEST" --format '{{index .Config.Labels "org.opencontainers.image.version"}}')" = "$VERSION"
docker run --rm --entrypoint sh "$IMAGE@$DIGEST" -c 'test -s /usr/share/nginx/html/index.html && nginx -t && nginx && wget -q -O /dev/null http://127.0.0.1:8080/'
printf 'Frontend image: %s@%s\n' "$IMAGE" "$DIGEST"
- name: Remove registry session
diff --git a/.storybook/main.ts b/.storybook/main.ts
index ca5d4b1..8557f9c 100644
--- a/.storybook/main.ts
+++ b/.storybook/main.ts
@@ -1,7 +1,7 @@
import type { StorybookConfig } from "@storybook/react-vite";
const config: StorybookConfig = {
- stories: ["../src/**/*.stories.tsx"],
+ stories: ["../src/**/*.stories.tsx", "../packages/*/src/**/*.stories.tsx"],
addons: ["@storybook/addon-docs"],
framework: "@storybook/react-vite",
core: { disableTelemetry: true },
diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx
index b5bfa08..acc23dc 100644
--- a/.storybook/preview.tsx
+++ b/.storybook/preview.tsx
@@ -1,12 +1,13 @@
import "@mantine/core/styles.css";
import "@mantine/charts/styles.css";
import "@mantine/dates/styles.css";
+import "@wealthledger/ui/styles.css";
import "dayjs/locale/ru";
import "../src/shared/i18n";
import { MantineProvider } from "@mantine/core";
import { DatesProvider } from "@mantine/dates";
import type { Preview } from "@storybook/react-vite";
-import { cssVariablesResolver, theme } from "@/shared/theme";
+import { cssVariablesResolver, theme } from "@wealthledger/ui/theme";
const colorSchemeManager = {
get: () => "light" as const,
diff --git a/Dockerfile b/Dockerfile
index 88feb2d..2f3c85c 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -2,10 +2,14 @@ FROM node:24.21.0-bookworm-slim AS dependencies
WORKDIR /app
ENV HUSKY=0
COPY package.json package-lock.json ./
+COPY packages/tokens/package.json ./packages/tokens/
+COPY packages/ui/package.json ./packages/ui/
+COPY packages/icons/package.json ./packages/icons/
RUN npm ci
FROM dependencies AS development
COPY . .
+RUN npm run build:packages
ENV VITE_API_BASE_URL="" API_PROXY_TARGET=http://backend:3000
EXPOSE 8080
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "8080", "--strictPort"]
diff --git a/README.md b/README.md
index e5ee4d9..94f3292 100644
--- a/README.md
+++ b/README.md
@@ -28,7 +28,8 @@ staged diff с полной редакцией секретов, затем за
`Dockerfile` имеет targets `development` (Vite HMR и proxy `/api/` во внутренний backend)
и `production` (nginx на 8080, SPA fallback и same-origin API proxy). Node соответствует `.nvmrc`.
Build context ограничен `.dockerignore`; `.env`, local DB и host node_modules
-не попадают в image.
+не попадают в image. Изменение `packages/tokens` или `packages/ui` требует rebuild development
+image, если локальный запуск использует bind-mount только `src`/`public`.
```bash
docker build --target development -t wealthledger-frontend:development .
@@ -194,3 +195,33 @@ Commits и PR titles — Conventional Commits, включая `spec:`. Верс
администратором, обязательны `verify`, `security`, `commits`; теги `v*` защищены.
Workflow сам не включает эти настройки. Отдельное review approval для одного
владельца не требуется. Npm publishing, deploy и Gitea Releases не включены.
+
+## Локальные пакеты
+
+`packages/tokens` — private npm workspace с единственным export
+`@wealthledger/tokens/tokens.json`. `packages/ui` экспортирует собранные
+`@wealthledger/ui/theme` (JS + declarations) и `@wealthledger/ui/styles.css`.
+Корневой export `@wealthledger/ui` содержит EntryCard, StateView и LoadingSkeleton;
+Потребители импортируют компоненты напрямую из `@wealthledger/ui`.
+CSS явно подключается после Mantine; providers остаются в приложении/Storybook.
+Тема доступна через `@wealthledger/ui/theme`; compatibility re-exports удалены.
+
+JSON не требует сборки. `npm ci` связывает workspaces, а существующие команды
+разработки, проверок и сборки сначала выполняют `npm run build:packages`.
+Library build использует отдельный Vite config без app plugins и TypeScript
+для declarations. После изменения package sources перезапустите `npm run dev`
+или `npm run dev:catalog`; отдельный watch-процесс пока не нужен.
+Начальная версия manifest взята из приложения; отдельный release-процесс
+и публикация не настроены.
+
+Private `@wealthledger/icons` экспортирует `AddIcon`, `EditIcon`, `DeleteIcon`
+и type `IconProps`. Единственный source рисунка — TSX в `packages/icons/src/svg`;
+поставка содержит ESM/declarations, без raw SVG, CSS, Mantine и motion.
+React — peer/external. Размер `size` по умолчанию 24, `strokeWidth` — 1.5;
+цвет наследуется через `currentColor`, SVG props можно передать явно.
+В action используйте декоративный ``,
+доступное имя задавайте кнопке. Standalone пример:
+``.
+Поддерживается также стандартный `aria-labelledby`; подпись выбирает caller.
+Card/Cards используют пакетные Add/Edit/Delete с прежними размерами/strokeWidth.
+Restore/category SVG и предметный BankOperationIcon остаются в приложении.
diff --git a/biome.json b/biome.json
index 6eff3f8..babf97e 100644
--- a/biome.json
+++ b/biome.json
@@ -7,6 +7,7 @@
"includes": [
"**",
"!dist",
+ "!packages/*/dist",
"!storybook-static",
"!src/shared/api/generated",
"!src/shared/api/spec",
@@ -38,7 +39,8 @@
"vite.config.ts",
"vitest.config.ts",
".storybook/main.ts",
- ".storybook/preview.tsx"
+ ".storybook/preview.tsx",
+ "packages/*/vite.config.ts"
],
"linter": {
"rules": {
diff --git a/dependency-cruiser.cjs b/dependency-cruiser.cjs
index a87fcea..a2d1217 100644
--- a/dependency-cruiser.cjs
+++ b/dependency-cruiser.cjs
@@ -1,5 +1,11 @@
module.exports = {
forbidden: [
+ {
+ name: "packages-must-not-depend-on-app",
+ severity: "error",
+ from: { path: "^packages/" },
+ to: { path: "^src/" },
+ },
{
name: "no-circular",
severity: "error",
@@ -17,6 +23,12 @@ module.exports = {
pathNot: "^src/$1/$2/",
},
},
+ {
+ name: "no-same-layer-cross-slice-imports",
+ severity: "error",
+ from: { path: "^src/(pages|features|entities)/([^/]+)/" },
+ to: { path: "^src/$1/", pathNot: "^src/$1/$2/" },
+ },
{
name: "shared-must-not-depend-on-layers",
severity: "error",
@@ -43,6 +55,7 @@ module.exports = {
},
],
options: {
+ exclude: "^packages/[^/]+/dist/",
doNotFollow: { path: "node_modules" },
tsConfig: { fileName: "tsconfig.app.json" },
tsPreCompilationDeps: true,
diff --git a/knip.json b/knip.json
index f2b3ac7..5f5b315 100644
--- a/knip.json
+++ b/knip.json
@@ -1,4 +1,17 @@
{
- "project": ["src/**/*.{ts,tsx}", "!src/shared/api/generated/**"],
- "ignoreDependencies": ["conventional-changelog-conventionalcommits"]
+ "ignoreDependencies": ["conventional-changelog-conventionalcommits"],
+ "workspaces": {
+ ".": {
+ "project": ["src/**/*.{ts,tsx}", "!src/shared/api/generated/**"]
+ },
+ "packages/tokens": {
+ "entry": ["tokens.json"]
+ },
+ "packages/ui": {
+ "project": ["src/**/*.{ts,tsx}"]
+ },
+ "packages/icons": {
+ "project": ["src/**/*.{ts,tsx}"]
+ }
+ }
}
diff --git a/package-lock.json b/package-lock.json
index 0caad7d..3205fa6 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -7,6 +7,9 @@
"": {
"name": "wealth-ledger-frontend",
"version": "0.3.0",
+ "workspaces": [
+ "packages/*"
+ ],
"dependencies": {
"@hookform/resolvers": "5.9.1",
"@mantine/charts": "9.6.1",
@@ -14,6 +17,9 @@
"@mantine/dates": "9.6.1",
"@mantine/hooks": "9.6.1",
"@tanstack/react-query": "5.102.8",
+ "@wealthledger/icons": "file:packages/icons",
+ "@wealthledger/tokens": "file:packages/tokens",
+ "@wealthledger/ui": "file:packages/ui",
"dayjs": "1.11.23",
"i18next": "26.4.2",
"motion": "13.2.0",
@@ -5460,6 +5466,18 @@
"url": "https://opencollective.com/vitest"
}
},
+ "node_modules/@wealthledger/icons": {
+ "resolved": "packages/icons",
+ "link": true
+ },
+ "node_modules/@wealthledger/tokens": {
+ "resolved": "packages/tokens",
+ "link": true
+ },
+ "node_modules/@wealthledger/ui": {
+ "resolved": "packages/ui",
+ "link": true
+ },
"node_modules/@webcontainer/env": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@webcontainer/env/-/env-1.1.1.tgz",
@@ -16399,6 +16417,47 @@
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
+ },
+ "packages/icons": {
+ "name": "@wealthledger/icons",
+ "version": "0.3.0",
+ "devDependencies": {
+ "@storybook/react-vite": "10.6.0",
+ "@testing-library/react": "16.3.3",
+ "@types/react": "19.3.0",
+ "typescript": "5.9.3",
+ "vite": "8.3.0",
+ "vitest": "5.0.0"
+ },
+ "peerDependencies": {
+ "react": "19.3.0"
+ }
+ },
+ "packages/tokens": {
+ "name": "@wealthledger/tokens",
+ "version": "0.3.0"
+ },
+ "packages/ui": {
+ "name": "@wealthledger/ui",
+ "version": "0.3.0",
+ "dependencies": {
+ "@wealthledger/tokens": "0.3.0"
+ },
+ "devDependencies": {
+ "@storybook/react-vite": "10.6.0",
+ "@testing-library/react": "16.3.3",
+ "@types/react": "19.3.0",
+ "storybook": "10.6.0",
+ "typescript": "5.9.3",
+ "vite": "8.3.0",
+ "vitest": "5.0.0"
+ },
+ "peerDependencies": {
+ "@mantine/core": "9.6.1",
+ "@mantine/hooks": "9.6.1",
+ "react": "19.3.0",
+ "react-dom": "19.3.0"
+ }
}
}
}
diff --git a/package.json b/package.json
index 9f5ecc7..d0a529b 100644
--- a/package.json
+++ b/package.json
@@ -7,27 +7,28 @@
"node": "24.21.0"
},
"scripts": {
- "dev": "vite",
- "dev:catalog": "storybook dev -p 6006 --no-open --disable-telemetry",
- "build:catalog": "storybook build --output-dir storybook-static --disable-telemetry",
+ "dev": "npm run build:packages && vite",
+ "dev:catalog": "npm run build:packages && storybook dev -p 6006 --no-open --disable-telemetry",
+ "build:catalog": "npm run build:packages && storybook build --output-dir storybook-static --disable-telemetry",
"preview:catalog": "vite preview --outDir storybook-static",
- "typecheck": "tsc -b --noEmit",
- "build": "tsc -b && vite build",
+ "typecheck": "npm run build:packages && tsc -b --noEmit",
+ "build": "npm run build:packages && tsc -b && vite build",
"lint": "biome check .",
- "test": "vitest",
- "test:run": "vitest run",
- "test:coverage": "vitest run --coverage",
+ "test": "npm run build:packages && vitest",
+ "test:run": "npm run build:packages && vitest run",
+ "test:coverage": "npm run build:packages && vitest run --coverage",
"openapi:generate": "openapi-typescript src/shared/api/spec/openapi.json -o src/shared/api/generated/schema.d.ts",
"openapi:check": "node scripts/check-openapi.mjs",
- "knip": "knip",
- "deps": "depcruise src --config dependency-cruiser.cjs --output-type err-long",
- "verify": "npm run openapi:check && npm run typecheck && npm run lint && npm run test:coverage && npm run knip && npm run deps && npm run deps:storybook && npm run build && npm run build:catalog",
+ "knip": "npm run build:packages && knip",
+ "deps": "npm run build:packages && depcruise src packages --config dependency-cruiser.cjs --output-type err-long",
+ "verify": "npm run build:packages && npm run openapi:check && npm run typecheck && npm run lint && npm run test:coverage && npm run knip && npm run deps && npm run deps:storybook && npm run build && npm run build:catalog",
"prepare": "husky",
"preview": "vite preview",
- "deps:storybook": "depcruise src .storybook/preview.tsx --config .storybook/dependency-cruiser.cjs --output-type err-long",
+ "deps:storybook": "npm run build:packages && depcruise src packages .storybook/preview.tsx --config .storybook/dependency-cruiser.cjs --output-type err-long",
"release:preview": "release-it --dry-run --ci",
"release:prepare": "release-it --ci",
- "commitlint": "commitlint"
+ "commitlint": "commitlint",
+ "build:packages": "npm run build --workspaces --if-present"
},
"lint-staged": {
"*.{css,html,json,md,ts,tsx}": "biome format --write --no-errors-on-unmatched"
@@ -39,6 +40,9 @@
"@mantine/dates": "9.6.1",
"@mantine/hooks": "9.6.1",
"@tanstack/react-query": "5.102.8",
+ "@wealthledger/icons": "file:packages/icons",
+ "@wealthledger/tokens": "file:packages/tokens",
+ "@wealthledger/ui": "file:packages/ui",
"dayjs": "1.11.23",
"i18next": "26.4.2",
"motion": "13.2.0",
@@ -88,5 +92,8 @@
"allowScripts": {
"esbuild@0.28.2": true,
"fsevents@2.3.3": true
- }
+ },
+ "workspaces": [
+ "packages/*"
+ ]
}
diff --git a/packages/icons/package.json b/packages/icons/package.json
new file mode 100644
index 0000000..e3f8c13
--- /dev/null
+++ b/packages/icons/package.json
@@ -0,0 +1,30 @@
+{
+ "name": "@wealthledger/icons",
+ "version": "0.3.0",
+ "private": true,
+ "type": "module",
+ "files": [
+ "dist"
+ ],
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "import": "./dist/index.js"
+ }
+ },
+ "sideEffects": false,
+ "scripts": {
+ "build": "vite build && tsc -p tsconfig.json"
+ },
+ "peerDependencies": {
+ "react": "19.3.0"
+ },
+ "devDependencies": {
+ "@storybook/react-vite": "10.6.0",
+ "@testing-library/react": "16.3.3",
+ "@types/react": "19.3.0",
+ "typescript": "5.9.3",
+ "vite": "8.3.0",
+ "vitest": "5.0.0"
+ }
+}
diff --git a/packages/icons/src/Icons.stories.tsx b/packages/icons/src/Icons.stories.tsx
new file mode 100644
index 0000000..f9b21e2
--- /dev/null
+++ b/packages/icons/src/Icons.stories.tsx
@@ -0,0 +1,45 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { AddIcon, DeleteIcon, EditIcon } from "./index";
+
+const meta = {
+ title: "Design System/Icons",
+ component: AddIcon,
+ tags: ["autodocs"],
+ parameters: {
+ docs: {
+ description: {
+ component:
+ "Статичные SVG: size по умолчанию 24, strokeWidth 1.5, цвет currentColor. В кнопке иконка декоративная, имя принадлежит действию. Для самостоятельного изображения задайте aria-hidden={false}, role=img и aria-label либо aria-labelledby. Геометрия взята из Card/Cards; подписи и действия остаются у приложения.",
+ },
+ },
+ },
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const Decorative: Story = {
+ render: () => (
+
+ ),
+};
+
+export const Meaningful: Story = {
+ render: () => (
+
+ ),
+};
diff --git a/packages/icons/src/Icons.test.tsx b/packages/icons/src/Icons.test.tsx
new file mode 100644
index 0000000..31c48ef
--- /dev/null
+++ b/packages/icons/src/Icons.test.tsx
@@ -0,0 +1,34 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+import { AddIcon, DeleteIcon, EditIcon } from "./index";
+
+describe("icon accessibility", () => {
+ it.each([AddIcon, EditIcon, DeleteIcon])(
+ "keeps decoration hidden and exposes explicitly named images: %s",
+ (Icon) => {
+ const { rerender } = render(
+ ,
+ );
+ expect(screen.queryByRole("img")).not.toBeInTheDocument();
+ expect(
+ screen.getByRole("button", { name: "Действие" }).querySelector("svg"),
+ ).toHaveAttribute("focusable", "false");
+ rerender(
+ ,
+ );
+ const icon = screen.getByRole("img", { name: "Пример" });
+ expect(icon).toHaveAttribute("width", "18");
+ expect(icon).toHaveAttribute("height", "18");
+ expect(icon).toHaveAttribute("stroke-width", "2");
+ expect(icon).toHaveAttribute("stroke", "currentColor");
+ },
+ );
+});
diff --git a/packages/icons/src/index.ts b/packages/icons/src/index.ts
new file mode 100644
index 0000000..296d458
--- /dev/null
+++ b/packages/icons/src/index.ts
@@ -0,0 +1,4 @@
+export { AddIcon } from "./svg/AddIcon";
+export { DeleteIcon } from "./svg/DeleteIcon";
+export { EditIcon } from "./svg/EditIcon";
+export type { IconProps } from "./types";
diff --git a/packages/icons/src/svg/AddIcon.tsx b/packages/icons/src/svg/AddIcon.tsx
new file mode 100644
index 0000000..cbd7ff5
--- /dev/null
+++ b/packages/icons/src/svg/AddIcon.tsx
@@ -0,0 +1,24 @@
+import type { FC } from "react";
+import type { IconProps } from "../types";
+
+const AddIcon: FC = (props) => {
+ const { size = "24", ...svgProps } = props;
+ return (
+
+ );
+};
+
+export { AddIcon };
diff --git a/packages/icons/src/svg/DeleteIcon.tsx b/packages/icons/src/svg/DeleteIcon.tsx
new file mode 100644
index 0000000..6e6321b
--- /dev/null
+++ b/packages/icons/src/svg/DeleteIcon.tsx
@@ -0,0 +1,25 @@
+import type { FC } from "react";
+import type { IconProps } from "../types";
+
+const DeleteIcon: FC = (props) => {
+ const { size = "24", ...svgProps } = props;
+ return (
+
+ );
+};
+
+export { DeleteIcon };
diff --git a/packages/icons/src/svg/EditIcon.tsx b/packages/icons/src/svg/EditIcon.tsx
new file mode 100644
index 0000000..d2c4940
--- /dev/null
+++ b/packages/icons/src/svg/EditIcon.tsx
@@ -0,0 +1,25 @@
+import type { FC } from "react";
+import type { IconProps } from "../types";
+
+const EditIcon: FC = (props) => {
+ const { size = "24", ...svgProps } = props;
+ return (
+
+ );
+};
+
+export { EditIcon };
diff --git a/packages/icons/src/types.ts b/packages/icons/src/types.ts
new file mode 100644
index 0000000..7f106f5
--- /dev/null
+++ b/packages/icons/src/types.ts
@@ -0,0 +1,10 @@
+import type { ComponentPropsWithoutRef } from "react";
+
+type IconProps = Omit<
+ ComponentPropsWithoutRef<"svg">,
+ "children" | "dangerouslySetInnerHTML"
+> & {
+ size?: number | string;
+};
+
+export type { IconProps };
diff --git a/packages/icons/tsconfig.json b/packages/icons/tsconfig.json
new file mode 100644
index 0000000..96b5e87
--- /dev/null
+++ b/packages/icons/tsconfig.json
@@ -0,0 +1,17 @@
+{
+ "compilerOptions": {
+ "target": "ES2023",
+ "lib": ["ES2023", "DOM"],
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "strict": true,
+ "skipLibCheck": true,
+ "verbatimModuleSyntax": true,
+ "declaration": true,
+ "emitDeclarationOnly": true,
+ "rootDir": "src",
+ "outDir": "dist",
+ "jsx": "react-jsx"
+ },
+ "include": ["src/index.ts"]
+}
diff --git a/packages/icons/vite.config.ts b/packages/icons/vite.config.ts
new file mode 100644
index 0000000..9dd7242
--- /dev/null
+++ b/packages/icons/vite.config.ts
@@ -0,0 +1,8 @@
+import { defineConfig } from "vite";
+
+export default defineConfig({
+ build: {
+ lib: { entry: "src/index.ts", formats: ["es"], fileName: "index" },
+ rolldownOptions: { external: /^react(\/|$)/ },
+ },
+});
diff --git a/packages/tokens/package.json b/packages/tokens/package.json
new file mode 100644
index 0000000..67b3554
--- /dev/null
+++ b/packages/tokens/package.json
@@ -0,0 +1,12 @@
+{
+ "name": "@wealthledger/tokens",
+ "version": "0.3.0",
+ "private": true,
+ "type": "module",
+ "files": [
+ "tokens.json"
+ ],
+ "exports": {
+ "./tokens.json": "./tokens.json"
+ }
+}
diff --git a/src/shared/theme/tokens.json b/packages/tokens/tokens.json
similarity index 99%
rename from src/shared/theme/tokens.json
rename to packages/tokens/tokens.json
index 32cab6d..4f5d284 100644
--- a/src/shared/theme/tokens.json
+++ b/packages/tokens/tokens.json
@@ -53,7 +53,7 @@
"#40c057",
"#37b24d",
"#2f9e44",
- "#2b8a3e"
+ "#237032"
],
"red": [
"#fff5f5",
@@ -151,12 +151,12 @@
},
"positive": {
"color": "green",
- "shade": 6,
+ "shade": 9,
"description": "Положительный результат; дополните знаком и текстом"
},
"negative": {
"color": "red",
- "shade": 6,
+ "shade": 9,
"description": "Ошибка / отрицательный результат; дополните текстом"
},
"warning": {
diff --git a/packages/ui/package.json b/packages/ui/package.json
new file mode 100644
index 0000000..7f998bc
--- /dev/null
+++ b/packages/ui/package.json
@@ -0,0 +1,44 @@
+{
+ "name": "@wealthledger/ui",
+ "version": "0.3.0",
+ "private": true,
+ "type": "module",
+ "files": [
+ "dist"
+ ],
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "import": "./dist/index.js"
+ },
+ "./theme": {
+ "types": "./dist/theme.d.ts",
+ "import": "./dist/theme.js"
+ },
+ "./styles.css": "./dist/styles.css"
+ },
+ "sideEffects": [
+ "**/*.css"
+ ],
+ "scripts": {
+ "build": "vite build && tsc -p tsconfig.json"
+ },
+ "dependencies": {
+ "@wealthledger/tokens": "0.3.0"
+ },
+ "peerDependencies": {
+ "@mantine/core": "9.6.1",
+ "@mantine/hooks": "9.6.1",
+ "react": "19.3.0",
+ "react-dom": "19.3.0"
+ },
+ "devDependencies": {
+ "@storybook/react-vite": "10.6.0",
+ "@testing-library/react": "16.3.3",
+ "@types/react": "19.3.0",
+ "storybook": "10.6.0",
+ "typescript": "5.9.3",
+ "vite": "8.3.0",
+ "vitest": "5.0.0"
+ }
+}
diff --git a/src/shared/ui/EntryCard.module.css b/packages/ui/src/EntryCard.module.css
similarity index 86%
rename from src/shared/ui/EntryCard.module.css
rename to packages/ui/src/EntryCard.module.css
index efdcb3e..2aca6de 100644
--- a/src/shared/ui/EntryCard.module.css
+++ b/packages/ui/src/EntryCard.module.css
@@ -4,8 +4,8 @@
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);
+ border-color: var(--wl-color-border);
+ background: var(--wl-color-subtle);
}
.identity,
@@ -19,15 +19,15 @@
}
.positive {
- color: var(--mantine-color-green-6);
+ color: var(--wl-color-positive);
}
.negative {
- color: var(--mantine-color-red-6);
+ color: var(--wl-color-negative);
}
.neutral {
- color: var(--mantine-color-dimmed);
+ color: var(--mantine-color-gray-7);
}
@media (max-width: 36em) {
diff --git a/packages/ui/src/EntryCard.stories.tsx b/packages/ui/src/EntryCard.stories.tsx
new file mode 100644
index 0000000..d4a2fd3
--- /dev/null
+++ b/packages/ui/src/EntryCard.stories.tsx
@@ -0,0 +1,37 @@
+import { Button } from "@mantine/core";
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { fn } from "storybook/test";
+import { EntryCard } from "./EntryCard";
+
+const meta = {
+ title: "Design System/Components/EntryCard",
+ component: EntryCard,
+ tags: ["autodocs"],
+ args: {
+ title:
+ "Учебная запись с очень длинным названием для проверки узкого экрана",
+ metadata: "22.09.2026 · Синтетические данные",
+ initial: "У",
+ imageSrc: "data:image/png;base64,invalid",
+ value: "−1 250,00 ₽",
+ valueLabel: "Тестовое значение",
+ tone: "negative",
+ actions: (
+
+ ),
+ },
+ parameters: {
+ docs: {
+ description: {
+ component:
+ "Готовые строки и slots без финансовых вычислений. Невалидное локальное изображение показывает fallback; действие доступно с клавиатуры и на узком экране. Данные вымышлены.",
+ },
+ },
+ },
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+export const LongContentAndFallback: Story = {};
diff --git a/packages/ui/src/EntryCard.test.tsx b/packages/ui/src/EntryCard.test.tsx
new file mode 100644
index 0000000..213e3d1
--- /dev/null
+++ b/packages/ui/src/EntryCard.test.tsx
@@ -0,0 +1,35 @@
+import { MantineProvider } from "@mantine/core";
+import { fireEvent, render, screen } from "@testing-library/react";
+import { assert, describe, expect, it } from "vitest";
+import { EntryCard } from "./EntryCard";
+
+describe("EntryCard", () => {
+ it("keeps the fallback after an image fails", () => {
+ const props = {
+ title: "Пример",
+ metadata: "Описание",
+ initial: "У",
+ value: "—",
+ valueLabel: "Неизвестно",
+ tone: "neutral" as const,
+ imageSrc: "data:image/png;base64,invalid",
+ };
+ const { container, rerender } = render(
+
+
+ ,
+ );
+ // Decorative image has no accessible name; trigger its actual failure event.
+ const image = container.querySelector("img");
+ assert(image);
+ fireEvent.error(image);
+ rerender(
+
+
+ ,
+ );
+ expect(container.querySelector("img")).toBeNull();
+ expect(screen.getByText("У")).toBeInTheDocument();
+ expect(screen.getByText("Обновлённый пример")).toBeInTheDocument();
+ });
+});
diff --git a/src/shared/ui/EntryCard.tsx b/packages/ui/src/EntryCard.tsx
similarity index 97%
rename from src/shared/ui/EntryCard.tsx
rename to packages/ui/src/EntryCard.tsx
index 7c2ad0c..455e2c4 100644
--- a/src/shared/ui/EntryCard.tsx
+++ b/packages/ui/src/EntryCard.tsx
@@ -25,7 +25,7 @@ const EntryCard: FC = (props) => {
style={
props.color
? {
- background: `color-mix(in srgb, var(--mantine-color-${props.color}-0) 45%, white)`,
+ background: `color-mix(in srgb, var(--mantine-color-${props.color}-0) 45%, var(--wl-color-surface))`,
}
: undefined
}
diff --git a/src/shared/ui/LoadingSkeleton.stories.tsx b/packages/ui/src/LoadingSkeleton.stories.tsx
similarity index 100%
rename from src/shared/ui/LoadingSkeleton.stories.tsx
rename to packages/ui/src/LoadingSkeleton.stories.tsx
diff --git a/src/shared/ui/LoadingSkeleton.tsx b/packages/ui/src/LoadingSkeleton.tsx
similarity index 100%
rename from src/shared/ui/LoadingSkeleton.tsx
rename to packages/ui/src/LoadingSkeleton.tsx
diff --git a/src/shared/ui/StateView.stories.tsx b/packages/ui/src/StateView.stories.tsx
similarity index 100%
rename from src/shared/ui/StateView.stories.tsx
rename to packages/ui/src/StateView.stories.tsx
diff --git a/src/shared/ui/StateView.test.tsx b/packages/ui/src/StateView.test.tsx
similarity index 100%
rename from src/shared/ui/StateView.test.tsx
rename to packages/ui/src/StateView.test.tsx
diff --git a/src/shared/ui/StateView.tsx b/packages/ui/src/StateView.tsx
similarity index 100%
rename from src/shared/ui/StateView.tsx
rename to packages/ui/src/StateView.tsx
diff --git a/src/shared/ui/index.ts b/packages/ui/src/index.ts
similarity index 77%
rename from src/shared/ui/index.ts
rename to packages/ui/src/index.ts
index db527c2..cb0030f 100644
--- a/src/shared/ui/index.ts
+++ b/packages/ui/src/index.ts
@@ -1,4 +1,3 @@
-export { AppShell } from "./AppShell";
export { EntryCard } from "./EntryCard";
export { LoadingSkeleton } from "./LoadingSkeleton";
export { StateView } from "./StateView";
diff --git a/src/shared/theme/styles.css b/packages/ui/src/styles.css
similarity index 100%
rename from src/shared/theme/styles.css
rename to packages/ui/src/styles.css
diff --git a/src/shared/theme/theme.test.ts b/packages/ui/src/theme.test.ts
similarity index 95%
rename from src/shared/theme/theme.test.ts
rename to packages/ui/src/theme.test.ts
index cd1dc79..e689f76 100644
--- a/src/shared/theme/theme.test.ts
+++ b/packages/ui/src/theme.test.ts
@@ -1,7 +1,7 @@
import { DEFAULT_THEME, mergeMantineTheme } from "@mantine/core";
+import source from "@wealthledger/tokens/tokens.json";
import { describe, expect, it } from "vitest";
import { colorRoles, cssVariablesResolver, theme } from "./theme";
-import source from "./tokens.json";
describe("design theme adapter", () => {
it("preserves scale-aware sizes, unscaled breakpoints and shadow layers", () => {
diff --git a/src/shared/theme/theme.ts b/packages/ui/src/theme.ts
similarity index 96%
rename from src/shared/theme/theme.ts
rename to packages/ui/src/theme.ts
index 07737b3..ba8504b 100644
--- a/src/shared/theme/theme.ts
+++ b/packages/ui/src/theme.ts
@@ -1,3 +1,4 @@
+import "./styles.css";
import {
type CSSVariablesResolver,
colorsTuple,
@@ -8,7 +9,7 @@ import {
rem,
rgba,
} from "@mantine/core";
-import designTokens from "./tokens.json";
+import designTokens from "@wealthledger/tokens/tokens.json";
const { color, typography, spacing, radius, shadows, breakpoints } =
designTokens;
diff --git a/packages/ui/tsconfig.json b/packages/ui/tsconfig.json
new file mode 100644
index 0000000..b287378
--- /dev/null
+++ b/packages/ui/tsconfig.json
@@ -0,0 +1,19 @@
+{
+ "compilerOptions": {
+ "target": "ES2023",
+ "lib": ["ES2023", "DOM"],
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "resolveJsonModule": true,
+ "strict": true,
+ "skipLibCheck": true,
+ "verbatimModuleSyntax": true,
+ "declaration": true,
+ "emitDeclarationOnly": true,
+ "rootDir": "src",
+ "outDir": "dist",
+ "jsx": "react-jsx",
+ "types": ["vite/client"]
+ },
+ "include": ["src/index.ts", "src/theme.ts"]
+}
diff --git a/packages/ui/vite.config.ts b/packages/ui/vite.config.ts
new file mode 100644
index 0000000..5057d03
--- /dev/null
+++ b/packages/ui/vite.config.ts
@@ -0,0 +1,14 @@
+import { defineConfig } from "vite";
+
+export default defineConfig({
+ build: {
+ lib: {
+ entry: { index: "src/index.ts", theme: "src/theme.ts" },
+ formats: ["es"],
+ cssFileName: "styles",
+ },
+ rolldownOptions: {
+ external: /^(react(?:-dom)?|@mantine\/[^/]+|@wealthledger\/tokens)(\/|$)/,
+ },
+ },
+});
diff --git a/scripts/check-image-publication.cjs b/scripts/check-image-publication.cjs
new file mode 100644
index 0000000..d0fda81
--- /dev/null
+++ b/scripts/check-image-publication.cjs
@@ -0,0 +1,75 @@
+const { test } = require("node:test");
+const assert = require("node:assert/strict");
+const {
+ mkdtempSync,
+ readFileSync,
+ writeFileSync,
+ chmodSync,
+ rmSync,
+} = require("node:fs");
+const { tmpdir } = require("node:os");
+const { join, resolve } = require("node:path");
+const EXECUTABLE_MODE = 0o755;
+const { spawnSync } = require("node:child_process");
+
+test("immutable publication resumes exact digest checks after post-push failure", () => {
+ const directory = mkdtempSync(join(tmpdir(), "wl-image-retry-"));
+ const workflow = readFileSync(
+ resolve(__dirname, "../.gitea/workflows/image.yml"),
+ "utf8",
+ );
+ const block = workflow
+ .split(" - name: Publish immutable release tag\n run: |\n")[1]
+ .split(" - name:")[0]
+ .replace(/^ {10}/gm, "");
+ const double = `#!${process.execPath}
+const fs = require('node:fs');
+const args = process.argv.slice(2).join(' ');
+fs.appendFileSync('calls', args + '\\n');
+if (args.startsWith('manifest inspect')) {
+ if (!fs.existsSync('published')) { console.error('manifest unknown'); process.exit(1); }
+} else if (args.startsWith('buildx imagetools inspect')) {
+ console.log('sha256:exact');
+} else if (args.startsWith('buildx build')) {
+ fs.writeFileSync('published', 'yes');
+ fs.writeFileSync('image-metadata.json', JSON.stringify({'containerimage.digest': 'sha256:exact'}));
+} else if (args.startsWith('pull')) {
+ if (!fs.existsSync('pull-failed')) { fs.writeFileSync('pull-failed', 'yes'); process.exit(1); }
+} else if (args.startsWith('image inspect')) {
+ console.log(args.includes('image.version') ? '1.2.3' : process.env.BAD_REVISION ? 'other' : 'commit');
+} else if (args.startsWith('run') && process.env.BAD_SMOKE) process.exit(1);
+`;
+ try {
+ for (const name of ["docker", "npm"]) {
+ writeFileSync(join(directory, name), double);
+ chmodSync(join(directory, name), EXECUTABLE_MODE);
+ }
+ const run = (extra = {}) =>
+ spawnSync("/bin/sh", ["-ec", block], {
+ cwd: directory,
+ encoding: "utf8",
+ env: {
+ ...process.env,
+ PATH: `${directory}:${process.env.PATH}`,
+ IMAGE: "synthetic/image",
+ RELEASE_TAG: "v1.2.3",
+ REVISION: "commit",
+ ...extra,
+ },
+ });
+ assert.notEqual(run().status, 0);
+ const retry = run();
+ assert.equal(retry.status, 0, retry.stdout + retry.stderr);
+ assert.notEqual(run({ BAD_REVISION: "1" }).status, 0);
+ assert.notEqual(run({ BAD_SMOKE: "1" }).status, 0);
+ const calls = readFileSync(join(directory, "calls"), "utf8");
+ assert.equal(
+ calls.split("\n").filter((line) => line.startsWith("buildx build"))
+ .length,
+ 1,
+ );
+ assert.ok(calls.includes("synthetic/image@sha256:exact"));
+ } finally {
+ rmSync(directory, { recursive: true, force: true });
+ }
+});
diff --git a/src/app/routes/AppRoutes.test.tsx b/src/app/routes/AppRoutes.test.tsx
index fd796f1..6900830 100644
--- a/src/app/routes/AppRoutes.test.tsx
+++ b/src/app/routes/AppRoutes.test.tsx
@@ -1,12 +1,11 @@
import { MantineProvider } from "@mantine/core";
import { QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { cssVariablesResolver, theme } from "@wealthledger/ui/theme";
import { HttpResponse, http } from "msw";
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 { AppRoutes } from "./AppRoutes";
diff --git a/src/app/routes/ProtectedLayout.tsx b/src/app/routes/ProtectedLayout.tsx
index 895b5b7..1aa9176 100644
--- a/src/app/routes/ProtectedLayout.tsx
+++ b/src/app/routes/ProtectedLayout.tsx
@@ -1,6 +1,5 @@
import type { FC } from "react";
-import { AppBreadcrumbs } from "@/app/ui";
-import { AppShell } from "@/shared/ui";
+import { AppBreadcrumbs, AppShell } from "@/app/ui";
import { ProtectedDocumentTitle } from "./DocumentTitle";
const ProtectedLayout: FC = () => {
diff --git a/src/app/ui/App.tsx b/src/app/ui/App.tsx
index 5621e02..74953f3 100644
--- a/src/app/ui/App.tsx
+++ b/src/app/ui/App.tsx
@@ -1,11 +1,11 @@
import { MantineProvider } from "@mantine/core";
import { DatesProvider } from "@mantine/dates";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { cssVariablesResolver, theme } from "@wealthledger/ui/theme";
import type { FC } from "react";
import { BrowserRouter } from "react-router";
import { AppRoutes, RouteErrorBoundary } from "@/app/routes";
import { AuthProvider } from "@/features/auth";
-import { cssVariablesResolver, theme } from "@/shared/theme";
const queryClient = new QueryClient({
defaultOptions: {
diff --git a/src/shared/ui/AppShell.module.css b/src/app/ui/AppShell.module.css
similarity index 100%
rename from src/shared/ui/AppShell.module.css
rename to src/app/ui/AppShell.module.css
diff --git a/src/shared/ui/AppShell.tsx b/src/app/ui/AppShell.tsx
similarity index 98%
rename from src/shared/ui/AppShell.tsx
rename to src/app/ui/AppShell.tsx
index cc2e63f..855cd29 100644
--- a/src/shared/ui/AppShell.tsx
+++ b/src/app/ui/AppShell.tsx
@@ -2,7 +2,7 @@ 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 { bindIconAnimation } from "@/shared/lib";
import classes from "./AppShell.module.css";
type AppShellProps = {
diff --git a/src/app/ui/index.ts b/src/app/ui/index.ts
index 0f97dc8..256c689 100644
--- a/src/app/ui/index.ts
+++ b/src/app/ui/index.ts
@@ -1 +1,2 @@
export { AppBreadcrumbs } from "./AppBreadcrumbs";
+export { AppShell } from "./AppShell";
diff --git a/src/entities/bank-card/index.ts b/src/entities/bank-card/index.ts
index ba1c14f..d186bd9 100644
--- a/src/entities/bank-card/index.ts
+++ b/src/entities/bank-card/index.ts
@@ -18,6 +18,7 @@ export {
moneyCents,
positiveMoneySchema,
} from "./model/form-validation";
+export { bankOperationKinds } from "./model/operation-kinds";
export { bankPeriod } from "./model/period";
export { BankCardFace } from "./ui/BankCardFace";
export { BankCardForm } from "./ui/BankCardForm";
diff --git a/src/entities/bank-card/model/operation-kinds.ts b/src/entities/bank-card/model/operation-kinds.ts
new file mode 100644
index 0000000..e849652
--- /dev/null
+++ b/src/entities/bank-card/model/operation-kinds.ts
@@ -0,0 +1,10 @@
+import type { BankOperation } from "../api/queries";
+
+const bankOperationKinds = [
+ "expense",
+ "topup",
+ "transfer",
+ "refund",
+] as const satisfies readonly BankOperation["kind"][];
+
+export { bankOperationKinds };
diff --git a/src/entities/bank-card/ui/BankOperationIcon.stories.tsx b/src/entities/bank-card/ui/BankOperationIcon.stories.tsx
index 4929acc..377c320 100644
--- a/src/entities/bank-card/ui/BankOperationIcon.stories.tsx
+++ b/src/entities/bank-card/ui/BankOperationIcon.stories.tsx
@@ -1,6 +1,6 @@
import { Stack } from "@mantine/core";
import type { Meta, StoryObj } from "@storybook/react-vite";
-import { EntryCard } from "@/shared/ui";
+import { EntryCard } from "@wealthledger/ui";
import { BankOperationIcon } from "./BankOperationIcon";
const meta = {
diff --git a/src/entities/bank-card/ui/BankSummary.view.tsx b/src/entities/bank-card/ui/BankSummary.view.tsx
index 6fc9598..bd4ac37 100644
--- a/src/entities/bank-card/ui/BankSummary.view.tsx
+++ b/src/entities/bank-card/ui/BankSummary.view.tsx
@@ -1,8 +1,8 @@
import { Button, Group, Skeleton, Stack, Text, Title } from "@mantine/core";
+import { LoadingSkeleton } from "@wealthledger/ui";
import type { CSSProperties, FC } from "react";
import { useTranslation } from "react-i18next";
import { formatMoney } from "@/shared/lib";
-import { LoadingSkeleton } from "@/shared/ui";
import type { BankSummary } from "../api/queries";
import { BankOperationIcon } from "./BankOperationIcon";
import classes from "./BankSummary.module.css";
diff --git a/src/entities/broker-operation/ui/LedgerEntryRow.tsx b/src/entities/broker-operation/ui/LedgerEntryRow.tsx
index 8944e50..8aaab15 100644
--- a/src/entities/broker-operation/ui/LedgerEntryRow.tsx
+++ b/src/entities/broker-operation/ui/LedgerEntryRow.tsx
@@ -1,4 +1,5 @@
import { Anchor, Group, Text } from "@mantine/core";
+import { EntryCard } from "@wealthledger/ui";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
@@ -8,7 +9,6 @@ import {
formatSnapshotTime,
instrumentLogoUrl,
} from "@/shared/lib";
-import { EntryCard } from "@/shared/ui";
import type { BrokerageOperation } from "../api/useBrokerageOperations";
import { operationLabelKey, paymentPresentation } from "../model/display";
diff --git a/src/entities/brokerage-event/ui/BrokerageEventCard.tsx b/src/entities/brokerage-event/ui/BrokerageEventCard.tsx
index feb11bb..18f61f7 100644
--- a/src/entities/brokerage-event/ui/BrokerageEventCard.tsx
+++ b/src/entities/brokerage-event/ui/BrokerageEventCard.tsx
@@ -1,7 +1,7 @@
+import { EntryCard } from "@wealthledger/ui";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { formatDate, formatMoney, instrumentLogoUrl } from "@/shared/lib";
-import { EntryCard } from "@/shared/ui";
import type { BrokerageEvent } from "../api/useBrokerageEvents";
type BrokerageEventCardProps = { event: BrokerageEvent };
diff --git a/src/entities/brokerage-event/ui/BrokerageEventsPreview.tsx b/src/entities/brokerage-event/ui/BrokerageEventsPreview.tsx
index 36fff05..a365439 100644
--- a/src/entities/brokerage-event/ui/BrokerageEventsPreview.tsx
+++ b/src/entities/brokerage-event/ui/BrokerageEventsPreview.tsx
@@ -1,8 +1,8 @@
import { Alert, Button, Skeleton, Stack, Text } from "@mantine/core";
+import { LoadingSkeleton } from "@wealthledger/ui";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
-import { LoadingSkeleton } from "@/shared/ui";
import type { BrokerageEvent } from "../api/useBrokerageEvents";
import { BrokerageEventCard } from "./BrokerageEventCard";
diff --git a/src/features/auth/model/auth.test.tsx b/src/features/auth/model/auth.test.tsx
index ecc997d..e349958 100644
--- a/src/features/auth/model/auth.test.tsx
+++ b/src/features/auth/model/auth.test.tsx
@@ -3,7 +3,7 @@ import { act, renderHook, waitFor } from "@testing-library/react";
import { HttpResponse, http } from "msw";
import type { FC, PropsWithChildren } from "react";
import { describe, expect, it } from "vitest";
-import { apiClient, apiQueryClient } from "@/shared/api";
+import { apiClient, apiQueryClient, useAuthenticatedQuery } from "@/shared/api";
import { createTestQueryClient } from "@/test/query";
import { server } from "@/test/server";
import { AuthProvider, useAuth } from "./auth";
@@ -167,4 +167,164 @@ describe("AuthProvider", () => {
expect(queryClient.getQueryData(portfolioKey)).toBeUndefined();
expect(queryClient.getQueryData(bankCardsKey)).toBeUndefined();
});
+ it.each([
+ [false, false],
+ [true, false],
+ [false, true],
+ [true, true],
+ ])(
+ "serializes late refresh (failure=%s, login=%s)",
+ async (failure, loginAfterLogout) => {
+ let release!: () => void;
+ const pending = new Promise((resolve) => {
+ release = resolve;
+ });
+ let calls = 0;
+ const order: string[] = [];
+ server.use(
+ http.post("*/api/v1/auth/refresh", async () => {
+ if (++calls > 1) {
+ order.push("refresh-start");
+ await pending;
+ order.push("refresh-cookie");
+ }
+ return failure && calls > 1
+ ? HttpResponse.error()
+ : HttpResponse.json({
+ accessToken: "a",
+ user: { id: "a", email: "a@example.com" },
+ });
+ }),
+ http.post("*/api/v1/auth/logout", () => {
+ order.push("logout-cookie");
+ return new HttpResponse(null, { status: 204 });
+ }),
+ http.post("*/api/v1/auth/login", () => {
+ order.push("login-cookie");
+ return HttpResponse.json({
+ accessToken: "b",
+ user: { id: "b", email: "b@example.com" },
+ });
+ }),
+ http.get("*/api/v1/brokerage-accounts", () =>
+ HttpResponse.json({ code: "UNAUTHORIZED" }, { status: 401 }),
+ ),
+ );
+ const queryClient = createTestQueryClient();
+ const wrapper: FC = ({ children }) => (
+
+ {children}
+
+ );
+ const { result } = renderHook(() => useAuth(), { wrapper });
+ await waitFor(() => expect(result.current.ready).toBe(true));
+ const oldRequest = apiClient
+ .GET("/api/v1/brokerage-accounts")
+ .catch(() => null);
+ await waitFor(() => expect(order).toContain("refresh-start"));
+ let logout!: Promise;
+ act(() => {
+ logout = result.current.logout();
+ });
+ await waitFor(() => expect(result.current.user).toBeNull());
+ let login!: Promise;
+ act(() => {
+ login = loginAfterLogout
+ ? result.current.login("b@example.com", "password")
+ : Promise.resolve();
+ });
+ await act(async () => {
+ release();
+ await Promise.all([oldRequest, logout, login]);
+ });
+ expect(result.current.user?.id ?? null).toBe(
+ loginAfterLogout ? "b" : null,
+ );
+ expect(order).toEqual([
+ "refresh-start",
+ "refresh-cookie",
+ "logout-cookie",
+ ...(loginAfterLogout ? ["login-cookie"] : []),
+ ]);
+ },
+ );
+
+ it("preserves same-user cache and removes inactive and pending data on refresh identity change", async () => {
+ let identity = "a";
+ server.use(
+ http.post("*/api/v1/auth/refresh", () =>
+ HttpResponse.json({
+ accessToken: identity,
+ user: { id: identity, email: `${identity}@example.com` },
+ }),
+ ),
+ );
+ const queryClient = createTestQueryClient();
+ const wrapper: FC = ({ children }) => (
+
+ {children}
+
+ );
+ const { result } = renderHook(() => useAuth(), { wrapper });
+ await waitFor(() => expect(result.current.ready).toBe(true));
+ queryClient.setQueryData(bankCardsKey, [{ id: "a-card" }]);
+ await act(() => result.current.refresh());
+ expect(queryClient.getQueryData(bankCardsKey)).toEqual([{ id: "a-card" }]);
+ let release!: (value: string) => void;
+ const pending = queryClient
+ .fetchQuery({
+ queryKey: ["pending-finance"],
+ queryFn: () =>
+ new Promise((resolve) => {
+ release = resolve;
+ }),
+ })
+ .catch(() => null);
+ identity = "b";
+ await act(() => result.current.refresh());
+ release("a-data");
+ await pending;
+ expect(result.current.user?.id).toBe("b");
+ expect(queryClient.getQueryData(bankCardsKey)).toBeUndefined();
+ expect(queryClient.getQueryData(["pending-finance"])).toBeUndefined();
+ });
+ it("replaces an observed financial query when refresh changes identity", async () => {
+ let identity = "a";
+ server.use(
+ http.post("*/api/v1/auth/refresh", () =>
+ HttpResponse.json({
+ accessToken: identity,
+ user: { id: identity, email: `${identity}@example.com` },
+ }),
+ ),
+ http.get("*/api/v1/bank-cards", () =>
+ HttpResponse.json([{ id: `${identity}-card` }]),
+ ),
+ );
+ const queryClient = createTestQueryClient();
+ const wrapper: FC = (props) => (
+
+ {props.children}
+
+ );
+ const { result } = renderHook(
+ () => ({
+ auth: useAuth(),
+ cards: useAuthenticatedQuery(
+ apiQueryClient.queryOptions("get", "/api/v1/bank-cards", {
+ params: { query: { includeArchived: "false" } },
+ }),
+ ),
+ }),
+ { wrapper },
+ );
+ await waitFor(() =>
+ expect(result.current.cards.data?.[0].id).toBe("a-card"),
+ );
+ identity = "b";
+ await act(() => result.current.auth.refresh());
+ await waitFor(() =>
+ expect(result.current.cards.data?.[0].id).toBe("b-card"),
+ );
+ });
});
diff --git a/src/features/auth/model/auth.tsx b/src/features/auth/model/auth.tsx
index 6bd700c..16d13ca 100644
--- a/src/features/auth/model/auth.tsx
+++ b/src/features/auth/model/auth.tsx
@@ -47,49 +47,70 @@ const AuthProvider: FC = (props) => {
const accessTokenRef = useRef(null);
const [ready, setReady] = useState(false);
+ const generation = useRef(0);
+ const userRef = useRef(null);
+ const authQueue = useRef>(Promise.resolve());
const refreshPromise = useRef | null>(null);
- const applyAuthentication = useCallback((authentication: Authentication) => {
- setUser(authentication.user);
- setAccessToken(authentication.accessToken);
- accessTokenRef.current = authentication.accessToken;
+ // Cookie-writing requests share ordering, including across tabs with Web Locks.
+ const serialize = useCallback((run: () => Promise): Promise => {
+ const next = authQueue.current.then(async () =>
+ "locks" in navigator
+ ? navigator.locks.request("wealth-ledger-refresh", run)
+ : run(),
+ );
+ authQueue.current = next.catch(() => undefined);
+ return next;
}, []);
+ const applyAuthentication = useCallback(
+ (authentication: Authentication) => {
+ const changedIdentity =
+ userRef.current && userRef.current.id !== authentication.user.id;
+ accessTokenRef.current = authentication.accessToken;
+ if (changedIdentity) {
+ generation.current += 1;
+ queryClient.removeQueries({ type: "inactive" });
+ queryClient.getMutationCache().clear();
+ void queryClient.resetQueries();
+ }
+ userRef.current = authentication.user;
+ setUser(authentication.user);
+ setAccessToken(authentication.accessToken);
+ },
+ [queryClient],
+ );
const clearSession = useCallback(() => {
+ generation.current += 1;
+ userRef.current = null;
setUser(null);
setAccessToken(null);
accessTokenRef.current = null;
queryClient.clear();
}, [queryClient]);
- const refresh = useCallback(async (): Promise => {
+ const refresh = useCallback((): Promise => {
if (refreshPromise.current) return refreshPromise.current;
- refreshPromise.current = (async () => {
- const run = async () => {
- try {
- const { data } = await apiClient.POST("/api/v1/auth/refresh");
- if (!data) {
- clearSession();
- return false;
- }
- applyAuthentication(data as Authentication);
- return true;
- } catch {
+ const started = generation.current;
+ const pending = serialize(async () => {
+ if (started !== generation.current) return false;
+ try {
+ const { data } = await apiClient.POST("/api/v1/auth/refresh");
+ if (started !== generation.current) return false;
+ if (!data) {
clearSession();
return false;
}
- };
- try {
- return "locks" in navigator
- ? navigator.locks.request("wealth-ledger-refresh", run)
- : run();
+ applyAuthentication(data);
+ return true;
} catch {
- clearSession();
+ if (started === generation.current) clearSession();
return false;
}
- })().finally(() => {
- refreshPromise.current = null;
+ }).finally(() => {
+ if (refreshPromise.current === pending) refreshPromise.current = null;
});
- return refreshPromise.current;
- }, [applyAuthentication, clearSession]);
+ refreshPromise.current = pending;
+ return pending;
+ }, [applyAuthentication, clearSession, serialize]);
useEffect(() => {
void refresh().finally(() => setReady(true));
@@ -100,21 +121,25 @@ const AuthProvider: FC = (props) => {
email: string,
password: string,
) {
- const authentication = await request(path, email, password);
- applyAuthentication(authentication);
+ clearSession();
+ const started = generation.current;
+ await serialize(async () => {
+ const authentication = await request(path, email, password);
+ if (started === generation.current) applyAuthentication(authentication);
+ });
}
async function logout() {
- try {
+ clearSession();
+ await serialize(async () => {
await apiClient.POST("/api/v1/auth/logout");
- } finally {
- clearSession();
- }
+ });
}
useEffect(() => {
configureAuthClient({
accessToken: () => accessTokenRef.current,
+ generation: () => generation.current,
refresh,
clear: clearSession,
});
diff --git a/src/features/instrument-details/ui/InstrumentSection.view.tsx b/src/features/instrument-details/ui/InstrumentSection.view.tsx
index f106845..5534f1e 100644
--- a/src/features/instrument-details/ui/InstrumentSection.view.tsx
+++ b/src/features/instrument-details/ui/InstrumentSection.view.tsx
@@ -8,9 +8,9 @@ import {
Text,
Title,
} from "@mantine/core";
+import { LoadingSkeleton } from "@wealthledger/ui";
import type { FC, PropsWithChildren, ReactNode } from "react";
import { useTranslation } from "react-i18next";
-import { LoadingSkeleton } from "@/shared/ui";
import type { SectionState } from "../model/section";
import classes from "./InstrumentDetails.module.css";
diff --git a/src/features/tbank-connection/ui/TbankConnectionSection.view.tsx b/src/features/tbank-connection/ui/TbankConnectionSection.view.tsx
index dd5c9cc..d090d3c 100644
--- a/src/features/tbank-connection/ui/TbankConnectionSection.view.tsx
+++ b/src/features/tbank-connection/ui/TbankConnectionSection.view.tsx
@@ -1,7 +1,7 @@
import { Alert, Button, Skeleton } from "@mantine/core";
+import { LoadingSkeleton } from "@wealthledger/ui";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
-import { LoadingSkeleton } from "@/shared/ui";
type TbankConnectionErrorProps = {
retry: () => void;
diff --git a/src/main.tsx b/src/main.tsx
index 1c7e32f..3a2c079 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -3,6 +3,7 @@ import { createRoot } from "react-dom/client";
import "@mantine/core/styles.css";
import "@mantine/charts/styles.css";
import "@mantine/dates/styles.css";
+import "@wealthledger/ui/styles.css";
import "dayjs/locale/ru";
import "./shared/i18n";
import { App } from "./app";
diff --git a/src/pages/account-events/ui/components/AccountEventsPageEmpty.view.tsx b/src/pages/account-events/ui/components/AccountEventsPageEmpty.view.tsx
index 5e09eb9..f3cec28 100644
--- a/src/pages/account-events/ui/components/AccountEventsPageEmpty.view.tsx
+++ b/src/pages/account-events/ui/components/AccountEventsPageEmpty.view.tsx
@@ -1,6 +1,6 @@
+import { StateView } from "@wealthledger/ui";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
-import { StateView } from "@/shared/ui";
const AccountEventsPageEmpty: FC = () => {
const { t } = useTranslation();
diff --git a/src/pages/account-events/ui/components/AccountEventsPageError.view.tsx b/src/pages/account-events/ui/components/AccountEventsPageError.view.tsx
index ae03f49..95d463e 100644
--- a/src/pages/account-events/ui/components/AccountEventsPageError.view.tsx
+++ b/src/pages/account-events/ui/components/AccountEventsPageError.view.tsx
@@ -1,7 +1,7 @@
import { Button } from "@mantine/core";
+import { StateView } from "@wealthledger/ui";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
-import { StateView } from "@/shared/ui";
type AccountEventsPageErrorProps = { retry: () => void };
diff --git a/src/pages/account-events/ui/components/AccountEventsPageLoading.view.tsx b/src/pages/account-events/ui/components/AccountEventsPageLoading.view.tsx
index 0370db8..0f04cca 100644
--- a/src/pages/account-events/ui/components/AccountEventsPageLoading.view.tsx
+++ b/src/pages/account-events/ui/components/AccountEventsPageLoading.view.tsx
@@ -1,7 +1,7 @@
import { Group, Skeleton } from "@mantine/core";
+import { LoadingSkeleton } from "@wealthledger/ui";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
-import { LoadingSkeleton } from "@/shared/ui";
const skeletonRowIds = ["first", "second", "third", "fourth"];
diff --git a/src/pages/account-income/model/period.ts b/src/pages/account-income/model/period.ts
new file mode 100644
index 0000000..1513a50
--- /dev/null
+++ b/src/pages/account-income/model/period.ts
@@ -0,0 +1,46 @@
+type IncomePeriod = { from: string; to: string };
+type IncomeForm = IncomePeriod;
+
+const datePattern = /^\d{4}-\d{2}-\d{2}$/;
+
+function formatDraftDate(value: string): string {
+ const [year, month, day] = value.split("-");
+ return `${day}.${month}.${year}`;
+}
+
+function parseDraftDate(value: string): string | null {
+ if (!/^\d{2}\.\d{2}\.\d{4}$/.test(value)) return null;
+ const [day, month, year] = value.split(".");
+ const date = `${year}-${month}-${day}`;
+ return isDate(date) ? date : null;
+}
+
+function isDate(value: string): boolean {
+ if (!datePattern.test(value)) return false;
+ const parsed = new Date(`${value}T00:00:00Z`);
+ return (
+ Number.isFinite(parsed.valueOf()) &&
+ parsed.toISOString().slice(0, 10) === value
+ );
+}
+
+function moscowDate(value: Date): string {
+ const parts = new Intl.DateTimeFormat("en-CA", {
+ timeZone: "Europe/Moscow",
+ year: "numeric",
+ month: "2-digit",
+ day: "2-digit",
+ }).formatToParts(value);
+ const part = (type: string) =>
+ parts.find((item) => item.type === type)?.value;
+ return `${part("year")}-${part("month")}-${part("day")}`;
+}
+
+export {
+ formatDraftDate,
+ type IncomeForm,
+ type IncomePeriod,
+ isDate,
+ moscowDate,
+ parseDraftDate,
+};
diff --git a/src/pages/account-income/ui/AccountIncomePage.test.tsx b/src/pages/account-income/ui/AccountIncomePage.test.tsx
index 3a587f3..2416043 100644
--- a/src/pages/account-income/ui/AccountIncomePage.test.tsx
+++ b/src/pages/account-income/ui/AccountIncomePage.test.tsx
@@ -23,8 +23,9 @@ import type { IncomeAnalytics } from "@/entities/income";
import { configureAuthClient } from "@/shared/api";
import { createQueryWrapper } from "@/test/query";
import { server } from "@/test/server";
+import { isDate } from "../model/period";
import { AccountIncomePage } from "./AccountIncomePage";
-import { isDate, toIncomeDisplayModel } from "./AccountIncomePage.vm";
+import { toIncomeDisplayModel } from "./AccountIncomePage.vm";
import { IncomePayments } from "./components/IncomePayments.view";
const analytics = {
diff --git a/src/pages/account-income/ui/AccountIncomePage.vm.ts b/src/pages/account-income/ui/AccountIncomePage.vm.ts
index 9237330..f3eaf64 100644
--- a/src/pages/account-income/ui/AccountIncomePage.vm.ts
+++ b/src/pages/account-income/ui/AccountIncomePage.vm.ts
@@ -6,12 +6,17 @@ import { z } from "zod";
import { type IncomeAnalytics, useIncomeAnalytics } from "@/entities/income";
import { validationMessage } from "@/shared/i18n";
import { formatDate } from "@/shared/lib";
+import {
+ formatDraftDate,
+ type IncomeForm,
+ type IncomePeriod,
+ isDate,
+ moscowDate,
+ parseDraftDate,
+} from "../model/period";
const ISO_MONTH_LENGTH = 7;
-type IncomePeriod = { from: string; to: string };
-type IncomeForm = IncomePeriod;
-
type IncomeDisplayModel = {
entries: { key: string; operation: IncomeAnalytics["entries"][number] }[];
comparison: {
@@ -41,7 +46,6 @@ type AccountIncomePageViewModel =
onSubmit: (values: IncomeForm) => void;
};
-const datePattern = /^\d{4}-\d{2}-\d{2}$/;
const draftDate = z
.string()
.refine((value) => parseDraftDate(value) !== null, {
@@ -129,18 +133,6 @@ const useAccountIncomePageViewModel = (
};
};
-function formatDraftDate(value: string): string {
- const [year, month, day] = value.split("-");
- return `${day}.${month}.${year}`;
-}
-
-function parseDraftDate(value: string): string | null {
- if (!/^\d{2}\.\d{2}\.\d{4}$/.test(value)) return null;
- const [day, month, year] = value.split(".");
- const date = `${year}-${month}-${day}`;
- return isDate(date) ? date : null;
-}
-
function draftPeriod(period: IncomePeriod): IncomeForm {
return { from: formatDraftDate(period.from), to: formatDraftDate(period.to) };
}
@@ -163,15 +155,6 @@ function validPeriod(period: IncomePeriod): period is IncomePeriod {
);
}
-function isDate(value: string): boolean {
- if (!datePattern.test(value)) return false;
- const parsed = new Date(`${value}T00:00:00Z`);
- return (
- Number.isFinite(parsed.valueOf()) &&
- parsed.toISOString().slice(0, 10) === value
- );
-}
-
function toIncomeDisplayModel(
analytics: IncomeAnalytics,
): IncomeDisplayModel | null {
@@ -276,26 +259,9 @@ function formatIncomeChange(
}).format(ratio);
}
-function moscowDate(value: Date): string {
- const parts = new Intl.DateTimeFormat("en-CA", {
- timeZone: "Europe/Moscow",
- year: "numeric",
- month: "2-digit",
- day: "2-digit",
- }).formatToParts(value);
- const part = (type: string) =>
- parts.find((item) => item.type === type)?.value;
- return `${part("year")}-${part("month")}-${part("day")}`;
-}
-
export {
type AccountIncomePageViewModel,
- formatDraftDate,
type IncomeDisplayModel,
- type IncomeForm,
- isDate,
- moscowDate,
- parseDraftDate,
toIncomeDisplayModel,
useAccountIncomePageViewModel,
};
diff --git a/src/pages/account-income/ui/components/AccountIncomePageError.view.tsx b/src/pages/account-income/ui/components/AccountIncomePageError.view.tsx
index 3bf8094..e125d81 100644
--- a/src/pages/account-income/ui/components/AccountIncomePageError.view.tsx
+++ b/src/pages/account-income/ui/components/AccountIncomePageError.view.tsx
@@ -1,7 +1,7 @@
import { Button } from "@mantine/core";
+import { StateView } from "@wealthledger/ui";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
-import { StateView } from "@/shared/ui";
import type { AccountIncomePageViewModel } from "../AccountIncomePage.vm";
type ErrorProps = Extract;
diff --git a/src/pages/account-income/ui/components/AccountIncomePageLoaded.view.tsx b/src/pages/account-income/ui/components/AccountIncomePageLoaded.view.tsx
index 4e36b1e..87f003e 100644
--- a/src/pages/account-income/ui/components/AccountIncomePageLoaded.view.tsx
+++ b/src/pages/account-income/ui/components/AccountIncomePageLoaded.view.tsx
@@ -7,10 +7,10 @@ import {
Text,
Title,
} from "@mantine/core";
+import { StateView } from "@wealthledger/ui";
import type { FC } from "react";
import { FormProvider } from "react-hook-form";
import { useTranslation } from "react-i18next";
-import { StateView } from "@/shared/ui";
import classes from "../AccountIncomePage.module.css";
import type { AccountIncomePageViewModel } from "../AccountIncomePage.vm";
import { IncomeComparisons } from "./IncomeComparisons.view";
diff --git a/src/pages/account-income/ui/components/AccountIncomePageLoading.view.tsx b/src/pages/account-income/ui/components/AccountIncomePageLoading.view.tsx
index bf93469..f465dbb 100644
--- a/src/pages/account-income/ui/components/AccountIncomePageLoading.view.tsx
+++ b/src/pages/account-income/ui/components/AccountIncomePageLoading.view.tsx
@@ -1,7 +1,7 @@
import { Paper, Skeleton, Stack } from "@mantine/core";
+import { LoadingSkeleton } from "@wealthledger/ui";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
-import { LoadingSkeleton } from "@/shared/ui";
import classes from "../AccountIncomePage.module.css";
const AccountIncomePageLoading: FC = () => {
diff --git a/src/pages/account-income/ui/components/IncomePeriodForm.stories.tsx b/src/pages/account-income/ui/components/IncomePeriodForm.stories.tsx
new file mode 100644
index 0000000..29f735c
--- /dev/null
+++ b/src/pages/account-income/ui/components/IncomePeriodForm.stories.tsx
@@ -0,0 +1,58 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import type { FC } from "react";
+import { FormProvider, useForm, useFormState } from "react-hook-form";
+import type { IncomeForm } from "../../model/period";
+import { IncomePeriodForm } from "./IncomePeriodForm.view";
+
+const period = { from: "01.08.2026", to: "27.08.2026" };
+const errors = {
+ to: { type: "manual", message: "Синтетическая ошибка периода" },
+};
+
+type PeriodExampleProps = { invalid?: boolean };
+
+const PeriodExample: FC = (props) => {
+ const methods = useForm({
+ defaultValues: period,
+ errors: props.invalid ? errors : undefined,
+ });
+ const { isSubmitSuccessful } = useFormState({ control: methods.control });
+ const submitted = isSubmitSuccessful ? methods.getValues() : null;
+ return (
+
+ undefined)}
+ onReset={() => {
+ methods.reset(period);
+ }}
+ />
+
+
+ );
+};
+
+const meta = {
+ title: "Product/IncomePeriodForm",
+ component: PeriodExample,
+ tags: ["autodocs"],
+ parameters: {
+ docs: {
+ description: {
+ component:
+ "Изолированная форма с синтетическим периодом. Submit показывает значения локально; reset возвращает fixture. Ошибка задана явно: query, URL и production validation остаются у page VM, API и router providers не используются.",
+ },
+ },
+ },
+ render: () => ,
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+export const Default: Story = {};
+export const ValidationError: Story = {
+ render: () => ,
+};
diff --git a/src/pages/account-income/ui/components/IncomePeriodForm.view.tsx b/src/pages/account-income/ui/components/IncomePeriodForm.view.tsx
index ae78c66..7b1be80 100644
--- a/src/pages/account-income/ui/components/IncomePeriodForm.view.tsx
+++ b/src/pages/account-income/ui/components/IncomePeriodForm.view.tsx
@@ -3,13 +3,13 @@ import { DatePickerInput } from "@mantine/dates";
import type { FC, FormEventHandler } from "react";
import { useController, useFormContext, useWatch } from "react-hook-form";
import { useTranslation } from "react-i18next";
-import classes from "../AccountIncomePage.module.css";
import {
formatDraftDate,
type IncomeForm,
moscowDate,
parseDraftDate,
-} from "../AccountIncomePage.vm";
+} from "../../model/period";
+import classes from "../AccountIncomePage.module.css";
type IncomePeriodFormProps = {
onSubmit: FormEventHandler;
diff --git a/src/pages/account-operations/ui/components/AccountOperationsPageError.view.tsx b/src/pages/account-operations/ui/components/AccountOperationsPageError.view.tsx
index c5987d6..ace026c 100644
--- a/src/pages/account-operations/ui/components/AccountOperationsPageError.view.tsx
+++ b/src/pages/account-operations/ui/components/AccountOperationsPageError.view.tsx
@@ -1,7 +1,7 @@
import { Button } from "@mantine/core";
+import { StateView } from "@wealthledger/ui";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
-import { StateView } from "@/shared/ui";
import type { AccountOperationsPageViewModel } from "../AccountOperationsPage.vm";
type AccountOperationsPageErrorProps = Extract<
diff --git a/src/pages/account-operations/ui/components/AccountOperationsPageLoaded.view.tsx b/src/pages/account-operations/ui/components/AccountOperationsPageLoaded.view.tsx
index 2f02397..019d6ba 100644
--- a/src/pages/account-operations/ui/components/AccountOperationsPageLoaded.view.tsx
+++ b/src/pages/account-operations/ui/components/AccountOperationsPageLoaded.view.tsx
@@ -8,6 +8,7 @@ import {
Title,
} from "@mantine/core";
import { DatePickerInput } from "@mantine/dates";
+import { StateView } from "@wealthledger/ui";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import {
@@ -15,7 +16,6 @@ import {
type OperationCategory,
operationCategories,
} from "@/entities/broker-operation";
-import { StateView } from "@/shared/ui";
import type { AccountOperationsPageViewModel } from "../AccountOperationsPage.vm";
type AccountOperationsPageLoadedProps = Extract<
diff --git a/src/pages/account-operations/ui/components/AccountOperationsPageLoading.view.tsx b/src/pages/account-operations/ui/components/AccountOperationsPageLoading.view.tsx
index df54e8e..63b8310 100644
--- a/src/pages/account-operations/ui/components/AccountOperationsPageLoading.view.tsx
+++ b/src/pages/account-operations/ui/components/AccountOperationsPageLoading.view.tsx
@@ -1,7 +1,7 @@
import { Group, Skeleton, Stack } from "@mantine/core";
+import { LoadingSkeleton } from "@wealthledger/ui";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
-import { LoadingSkeleton } from "@/shared/ui";
const skeletonRowIds = ["first", "second", "third", "fourth"];
diff --git a/src/pages/account/ui/AccountPage.module.css b/src/pages/account/ui/AccountPage.module.css
index f5aa21c..c097c31 100644
--- a/src/pages/account/ui/AccountPage.module.css
+++ b/src/pages/account/ui/AccountPage.module.css
@@ -63,11 +63,11 @@
}
.positive {
- color: var(--mantine-color-green-6);
+ color: var(--wl-color-positive);
}
.negative {
- color: var(--mantine-color-red-6);
+ color: var(--wl-color-negative);
}
.expandedCard {
diff --git a/src/pages/account/ui/components/AccountPageEmpty.view.tsx b/src/pages/account/ui/components/AccountPageEmpty.view.tsx
index 8e8ca97..0292eb7 100644
--- a/src/pages/account/ui/components/AccountPageEmpty.view.tsx
+++ b/src/pages/account/ui/components/AccountPageEmpty.view.tsx
@@ -1,9 +1,9 @@
import { Stack } from "@mantine/core";
+import { StateView } from "@wealthledger/ui";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import type { BrokerageAccount } from "@/entities/brokerage-account";
import { BrokerageEventsPreview } from "@/entities/brokerage-event";
-import { StateView } from "@/shared/ui";
import type {
AccountPageIncomeSummary,
AccountPageRecentEvents,
diff --git a/src/pages/account/ui/components/AccountPageError.view.tsx b/src/pages/account/ui/components/AccountPageError.view.tsx
index f10b739..4d85c56 100644
--- a/src/pages/account/ui/components/AccountPageError.view.tsx
+++ b/src/pages/account/ui/components/AccountPageError.view.tsx
@@ -1,8 +1,8 @@
import { Button } from "@mantine/core";
+import { StateView } from "@wealthledger/ui";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
-import { StateView } from "@/shared/ui";
type AccountPageErrorProps = { code?: string; retry: () => void };
diff --git a/src/pages/account/ui/components/AccountPageLoading.view.tsx b/src/pages/account/ui/components/AccountPageLoading.view.tsx
index ac07237..47a6a97 100644
--- a/src/pages/account/ui/components/AccountPageLoading.view.tsx
+++ b/src/pages/account/ui/components/AccountPageLoading.view.tsx
@@ -1,7 +1,7 @@
import { Paper, SimpleGrid, Skeleton } from "@mantine/core";
+import { LoadingSkeleton } from "@wealthledger/ui";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
-import { LoadingSkeleton } from "@/shared/ui";
import classes from "../AccountPage.module.css";
const AccountPageLoading: FC = () => {
diff --git a/src/pages/account/ui/components/IncomeSummary.view.tsx b/src/pages/account/ui/components/IncomeSummary.view.tsx
index 6b86d66..c789b98 100644
--- a/src/pages/account/ui/components/IncomeSummary.view.tsx
+++ b/src/pages/account/ui/components/IncomeSummary.view.tsx
@@ -1,10 +1,10 @@
import { Button, Paper, Stack, Text } from "@mantine/core";
+import { StateView } from "@wealthledger/ui";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import type { IncomeSummary as IncomeSummaryData } from "@/entities/income";
import { formatMoney } from "@/shared/lib";
-import { StateView } from "@/shared/ui";
type IncomeSummaryProps =
| { state: "loading"; to: string }
diff --git a/src/pages/account/ui/components/RecentOperations.view.tsx b/src/pages/account/ui/components/RecentOperations.view.tsx
index 5829887..4b4c69a 100644
--- a/src/pages/account/ui/components/RecentOperations.view.tsx
+++ b/src/pages/account/ui/components/RecentOperations.view.tsx
@@ -1,9 +1,9 @@
import { Alert, Button, Skeleton, Stack, Text } from "@mantine/core";
+import { LoadingSkeleton } from "@wealthledger/ui";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import { LedgerEntryRow } from "@/entities/broker-operation";
-import { LoadingSkeleton } from "@/shared/ui";
import type { AccountPageRecentOperations } from "../AccountPage.vm";
type RecentOperationsProps = {
diff --git a/src/pages/accounts/ui/components/AccountsPageEmpty.view.tsx b/src/pages/accounts/ui/components/AccountsPageEmpty.view.tsx
index c7e63b8..5e16dc4 100644
--- a/src/pages/accounts/ui/components/AccountsPageEmpty.view.tsx
+++ b/src/pages/accounts/ui/components/AccountsPageEmpty.view.tsx
@@ -1,7 +1,7 @@
import { Stack } from "@mantine/core";
+import { StateView } from "@wealthledger/ui";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
-import { StateView } from "@/shared/ui";
import {
AccountsRefreshState,
type AccountsRefreshStateProps,
diff --git a/src/pages/accounts/ui/components/AccountsPageError.view.tsx b/src/pages/accounts/ui/components/AccountsPageError.view.tsx
index 46faf78..a808eec 100644
--- a/src/pages/accounts/ui/components/AccountsPageError.view.tsx
+++ b/src/pages/accounts/ui/components/AccountsPageError.view.tsx
@@ -1,8 +1,8 @@
import { Button, Stack } from "@mantine/core";
+import { StateView } from "@wealthledger/ui";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
-import { StateView } from "@/shared/ui";
type AccountsPageErrorProps = { code?: string; retry?: () => void };
diff --git a/src/pages/accounts/ui/components/AccountsPageLoading.view.tsx b/src/pages/accounts/ui/components/AccountsPageLoading.view.tsx
index 5248e56..da72c3b 100644
--- a/src/pages/accounts/ui/components/AccountsPageLoading.view.tsx
+++ b/src/pages/accounts/ui/components/AccountsPageLoading.view.tsx
@@ -1,7 +1,7 @@
import { Card, Group, SimpleGrid, Skeleton, Stack } from "@mantine/core";
+import { LoadingSkeleton } from "@wealthledger/ui";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
-import { LoadingSkeleton } from "@/shared/ui";
const AccountsPageLoading: FC = () => {
const { t } = useTranslation();
diff --git a/src/pages/bond/ui/BondPage.tsx b/src/pages/bond/ui/BondPage.tsx
index 63e0047..906dab0 100644
--- a/src/pages/bond/ui/BondPage.tsx
+++ b/src/pages/bond/ui/BondPage.tsx
@@ -1,8 +1,8 @@
import { Anchor, Button, Skeleton, Stack, Text, Title } from "@mantine/core";
+import { LoadingSkeleton } from "@wealthledger/ui";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { Link, Navigate } from "react-router";
-import { LoadingSkeleton } from "@/shared/ui";
import { BondPageView } from "./BondPage.view";
import { useBondPageVm } from "./BondPage.vm";
diff --git a/src/pages/bond/ui/BondPage.view.tsx b/src/pages/bond/ui/BondPage.view.tsx
index d194c8f..ee92b95 100644
--- a/src/pages/bond/ui/BondPage.view.tsx
+++ b/src/pages/bond/ui/BondPage.view.tsx
@@ -12,6 +12,7 @@ import {
Text,
Title,
} from "@mantine/core";
+import { LoadingSkeleton } from "@wealthledger/ui";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import {
@@ -26,7 +27,6 @@ import {
formatMoney,
formatSnapshotTime,
} from "@/shared/lib";
-import { LoadingSkeleton } from "@/shared/ui";
import classes from "./BondPage.module.css";
import type { BondPageVm } from "./BondPage.vm";
diff --git a/src/pages/bonds/ui/BondsPage.view.tsx b/src/pages/bonds/ui/BondsPage.view.tsx
index 66c42bf..e8e9ab3 100644
--- a/src/pages/bonds/ui/BondsPage.view.tsx
+++ b/src/pages/bonds/ui/BondsPage.view.tsx
@@ -12,6 +12,7 @@ import {
TextInput,
Title,
} from "@mantine/core";
+import { LoadingSkeleton } from "@wealthledger/ui";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
@@ -21,7 +22,6 @@ import {
formatMoney,
formatSnapshotTime,
} from "@/shared/lib";
-import { LoadingSkeleton } from "@/shared/ui";
import classes from "./BondsPage.module.css";
import type { BondsPageVm } from "./BondsPage.vm";
diff --git a/src/pages/card/ui/CardPage.view.tsx b/src/pages/card/ui/CardPage.view.tsx
index b04d6c1..50698e9 100644
--- a/src/pages/card/ui/CardPage.view.tsx
+++ b/src/pages/card/ui/CardPage.view.tsx
@@ -10,6 +10,8 @@ import {
Text,
Title,
} from "@mantine/core";
+import { AddIcon, DeleteIcon, EditIcon } from "@wealthledger/icons";
+import { EntryCard, LoadingSkeleton, StateView } from "@wealthledger/ui";
import { type FC, useLayoutEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
@@ -20,7 +22,6 @@ import {
BankPeriodPicker,
BankSummaryView,
} from "@/entities/bank-card";
-import { EntryCard, LoadingSkeleton, StateView } from "@/shared/ui";
import classes from "./CardPage.module.css";
import type { CardPageVm } from "./CardPage.vm";
@@ -106,19 +107,7 @@ const CardPageView: FC = (props) => {
title={t("bank.editCard")}
onClick={() => vm.setEditingCard(true)}
>
-
+
= (props) => {
loading={vm.archivePending}
onClick={() => void vm.toggleArchive()}
>
-
+ {card.archivedAt ? (
+
+ ) : (
+
+ )}
@@ -196,18 +183,7 @@ const CardPageView: FC = (props) => {
disabled={Boolean(card.archivedAt) || !vm.cards.data}
onClick={() => vm.setOperation("new")}
>
-
+