5 Commits
Author SHA1 Message Date
ksv741 f876411a37 refactor: establish local frontend workspace boundaries
Frontend security / security (push) Successful in 9s
Frontend verify / verify (push) Successful in 5m27s
Frontend security / security (pull_request) Successful in 10s
Frontend verify / verify (pull_request) Successful in 5m24s
Conventional commits / commits (pull_request) Failing after 26s
OpenSpec change: evolve-frontend-stack
2026-09-22 22:11:02 +03:00
ksv741 5b2c6bb7cb fix(ui): improve financial text contrast
TD-010: use readable semantic text colors on financial surfaces while preserving signs and state meanings.
2026-09-21 21:10:10 +03:00
ksv741 60f9538886 fix(ci): resume verification of immutable release images
TD-009: retry verification of an existing digest without republishing. Paired backend workflow fix: 82eccc3.
2026-09-21 21:09:53 +03:00
ksv741 fd73466073 fix(api): update validation and conflict response contract
TD-003 and TD-005: explicitly deliver OpenAPI and generated types from backend commit 1667991.
2026-09-21 21:09:41 +03:00
ksv741 edd54892cd fix(auth): isolate session races and financial caches
TD-001 and TD-002: serialize cookie writes, ignore stale responses and reset financial queries on identity changes. Backend cookie lifecycle regression: ec5780d.
2026-09-21 21:09:23 +03:00
514 changed files with 18094 additions and 31355 deletions
+12 -15
View File
@@ -8,7 +8,7 @@ Vitest — отдельную JSDOM конфигурацию. Точные versi
[package.json](../package.json), реализация — source of truth. [package.json](../package.json), реализация — source of truth.
```text ```text
app → pages → widgets → features → entities → shared app → pages → features → entities → shared
page container → page VM → entity/feature query → shared/api → backend /api/v1 page container → page VM → entity/feature query → shared/api → backend /api/v1
↓ ↓
props → state/section views props → state/section views
@@ -30,15 +30,13 @@ contract; frontend не читает его checkout для генерации
| --- | --- | | --- | --- |
| Bootstrap, глобальные providers, routes | `app` | | Bootstrap, глобальные providers, routes | `app` |
| Композиция маршрута и поведение, нужное только этому экрану | `pages/<page>` | | Композиция маршрута и поведение, нужное только этому экрану | `pages/<page>` |
| Крупная переиспользуемая композиция экрана | `widgets/<widget>` |
| Самостоятельная пользовательская возможность с собственным смыслом | `features/<capability>` | | Самостоятельная пользовательская возможность с собственным смыслом | `features/<capability>` |
| Представление/поведение бизнес-сущности | `entities/<entity>` | | Представление/поведение бизнес-сущности | `entities/<entity>` |
| Переиспользуемый код без WealthLedger domain meaning | `shared` | | Переиспользуемый код без WealthLedger domain meaning | `shared` |
Локальный FSD profile: `app → pages → widgets → features → entities → shared`. Локальный FSD profile: `app → pages → features → entities → shared`, без `widgets`.
Widget собирает несколько нижних слоёв в самостоятельный UI-блок; компонент для Крупный уникальный блок остаётся в своей page. Form, mutation или click handler
одной page остаётся у page. Form, mutation или click handler сами по себе не сами по себе не требуют feature. Два callers не делают domain helper generic shared.
требуют feature. Два callers не делают domain helper generic shared.
Slice именуется по ответственности, а не `helpers`, `hooks` или `types`. Slice именуется по ответственности, а не `helpers`, `hooks` или `types`.
**Безопасный путь:** при сомнении оставь код у ближайшего допустимого owner. **Безопасный путь:** при сомнении оставь код у ближайшего допустимого owner.
@@ -49,12 +47,12 @@ Slice именуется по ответственности, а не `helpers`,
Cross-slice consumer использует `@/` и `index.ts` владельца, например Cross-slice consumer использует `@/` и `index.ts` владельца, например
`@/entities/portfolio`. Alias Vite и TypeScript согласован. Другой slice не `@/entities/portfolio`. Alias Vite и TypeScript согласован. Другой slice не
импортирует внутренний файл напрямую. Slices `pages`, `widgets`, `features`, `entities` не зависят от соседнего slice своего слоя; импортирует внутренний файл напрямую. Slices `pages`, `features`, `entities` не зависят от соседнего slice своего слоя;
композиция принадлежит вышележащему owner. Existing меж-slice исключения композиция принадлежит вышележащему owner. Existing меж-slice исключения
не являются разрешением добавлять новые. не являются разрешением добавлять новые.
Направление зависимостей, public imports и запрет соседних slices в Направление зависимостей, public imports и запрет соседних slices в
`pages`, `widgets`, `features`, `entities` проверяет `npm run deps`. Внутренние imports `pages`, `features`, `entities` проверяет `npm run deps`. Внутренние imports
одного slice и связи shared segments этот запрет не ограничивает. одного slice и связи shared segments этот запрет не ограничивает.
Knip проверяет использование exports, а не смысл placement. Knip проверяет использование exports, а не смысл placement.
@@ -71,16 +69,15 @@ Route-aware AppShell и его navigation styles принадлежат `app/ui`
`ProtectedLayout` использует public API этого владельца. При изменении foundation прочитай `ProtectedLayout` использует public API этого владельца. При изменении foundation прочитай
[UI](conventions/ui.md#визуальная-основа) и [Storybook](conventions/storybook.md). [UI](conventions/ui.md#визуальная-основа) и [Storybook](conventions/storybook.md).
Все SVG, их геометрия и анимации принадлежат private `packages/icons`. Статичные Add/Edit/Delete и публичный `IconProps` принадлежат private
Приложение импортирует компоненты из `@wealthledger/icons` и подключает `packages/icons`; package не зависит от UI, domain или motion.
`@wealthledger/icons/styles.css`. В `src` нет SVG-файлов и компонентов, `BankOperationIcon` и mappings остаются у entity. Card/Cards используют package
единственная задача которых — рисовать иконку. Domain mapping категории в Add/Edit/Delete; restore/category SVG остаются локальными.
параметры `OperationIcon` остаётся в `entities/bank-card/model`.
## Примеры ## Примеры
- Общая busy-область: существующий [LoadingSkeleton](../packages/ui/src/LoadingSkeleton/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/AccountIncomePage.tsx) и её `components/`; новый page-local filter остаётся здесь. Комплекты файлов группируются по [UI convention](conventions/ui.md#размещение-файлов-компонента). - 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 только из-за нескольких экранов. - Portfolio query: [entity public API](../src/entities/portfolio/index.ts); новая UI-сводка с бизнес-смыслом не становится shared только из-за нескольких экранов.
## Проверка ## Проверка
+2 -21
View File
@@ -30,8 +30,7 @@ backend checkout и не проверяют backend runtime OpenAPI; измен
shape/status/route сохраняет shape/status/route сохраняет
публичный контракт предоставленного snapshot. публичный контракт предоставленного snapshot.
Одна UI-модель не требует менять API. `openapi:generate` работает offline от snapshot; Одна UI-модель не требует менять API. `openapi:generate` работает offline от snapshot;
`openapi:check` запускает native `--check` того же CLI и падает при устаревших `openapi:check` генерирует во временный каталог и сравнивает, не меняя tracked files.
generated types, не меняя tracked files.
## Auth и invalidation ## Auth и invalidation
@@ -40,15 +39,6 @@ Authorization. Auth endpoints исключены из Bearer/retry middleware. R
координируется single-flight; после `401` с `UNAUTHORIZED` возможен один retry. координируется single-flight; после `401` с `UNAUTHORIZED` возможен один retry.
Не добавляй собственный refresh/retry loop в caller. Не добавляй собственный refresh/retry loop в caller.
Refresh, завершившийся `401`, очищает session. Сетевая ошибка и `5xx`
(например, деплой backend) не доказывают конец session: она сохраняется,
`unavailable` показывает retry-экран вместо login, а следующий `401` снова
запускает refresh. Retry/backoff внутри refresh не добавляется.
Cookie-writing requests упорядочены только в текущей вкладке. Cross-tab auth
synchronization не требуется; допустим reload/re-login. Web Locks и отдельные
token stores не добавляются.
Protected queries используют existing session-aware boundary; logout очищает Protected queries используют existing session-aware boundary; logout очищает
session-scoped cache. Access token хранится только в памяти, refresh передаётся session-scoped cache. Access token хранится только в памяти, refresh передаётся
httpOnly cookie с `credentials: include`. Не сохраняй tokens в localStorage, httpOnly cookie с `credentials: include`. Не сохраняй tokens в localStorage,
@@ -60,15 +50,6 @@ GET subtree; ошибка mutation не меняет generation/cache. Это о
от обычного фонового refetch. Даже повторное сохранение того же token начинает от обычного фонового refetch. Даже повторное сохранение того же token начинает
новую generation; поздний ответ прежней generation не попадает в новый кэш. новую generation; поздний ответ прежней generation не попадает в новый кэш.
Отмена/reset принадлежат TanStack Query и generated queryFn с AbortSignal,
без HTTP request registry, URL-wide latest-read counters и access epochs.
Успешный HTTP с `data: null` остаётся query success; readiness выбирается по
data/metadata, без подмены query status и отдельного transport state.
Status синхронизации содержит только состояние работы. Resources читаются
через `sync=observe` во время работы и после завершения, без hash comparison.
Согласованность income entries/groups обеспечивает backend; frontend не
дублирует её runtime validator.
Mutations используют существующий generated client и query-key owner; Mutations используют существующий generated client и query-key owner;
после успеха обновляй/invalidируй только связанные данные. Optimistic update после успеха обновляй/invalidируй только связанные данные. Optimistic update
нужен конкретному UX и обязан иметь rollback/error behavior. нужен конкретному UX и обязан иметь rollback/error behavior.
@@ -89,7 +70,7 @@ Network/unknown error получает безопасное локальное
Если одна группа API codes требует одинакового recovery в нескольких views, Если одна группа API codes требует одинакового recovery в нескольких views,
сведи классификацию у ближайшего общего owner. Не размножай magic comparisons сведи классификацию у ближайшего общего owner. Не размножай magic comparisons
и используй `ApiErrorCode` для известных значений: generated `code` остаётся `string`. и не объявляй закрытый frontend error enum: generated `code` пока `string`.
Неизвестный новый code должен сохранять fallback. Рефакторинг существующих Неизвестный новый code должен сохранять fallback. Рефакторинг существующих
разных recovery paths требует behavior proof, а не общей обработки всех ошибок. разных recovery paths требует behavior proof, а не общей обработки всех ошибок.
+16 -37
View File
@@ -7,19 +7,11 @@
checkout. Внешний API задаётся предоставленным публичным контрактом; checkout. Внешний API задаётся предоставленным публичным контрактом;
исходники или инструкции его реализации не являются зависимостью разработки. исходники или инструкции его реализации не являются зависимостью разработки.
При нехватке требования сообщи, какое решение не определено, до его реализации. При нехватке требования сообщи, какое решение не определено, до его реализации.
При конфликте с общим шаблоном следуй местным правилам и явному запросу
пользователя. Выбирай реализацию самостоятельно, но изменение финансовой
логики, публичного контракта или утверждённой семантики возвращай владельцу
на согласование до зависимой работы.
Начни с результата и affected areas. До edits проследи flow/invariants, найди Начни с результата и affected areas. До edits проследи flow/invariants, найди
owner и 1–3 примера. Код показывает механизм; случайный аналог не меняет контракт. owner и 1–3 примера. Код показывает механизм; случайный аналог не меняет контракт.
Выбери lightweight или SDD по [Engineering Workflow](https://git.personal.ksv741.ru/WealthLedger/main/src/commit/03b3b59e6ff2ba391f9dfc42a039a96d0d84c1c9/.ai/conventions/engineering-workflow.md); Работай напрямую; OpenSpec/SDD lifecycle — только по явному запросу.
OpenSpec lifecycle начинай после явного решения Change Owner. Перед Node-командами активируй местную `.nvmrc`: `nvm use --silent`.
Перед Node-командами активируй местную `.nvmrc`: если `nvm` не загружен в
non-interactive shell, загрузи установленный `$HOME/.nvm/nvm.sh`; затем
выполни `nvm use --silent` из frontend. Если `nvm.sh` недоступен, остановись,
не используй системный Node.
Сохраняй чужие staged/unstaged/untracked изменения и read-only архивы. Сохраняй чужие staged/unstaged/untracked изменения и read-only архивы.
## Change scope ## Change scope
@@ -37,8 +29,7 @@ non-interactive shell, загрузи установленный `$HOME/.nvm/nvm
## Domain values ## Domain values
Finite business vocabulary принадлежит одному domain/module/entity owner. Finite business vocabulary принадлежит одному domain/module/entity owner.
Переиспользуй его symbols; для machine codes и protocol values используй Переиспользуй его symbols; для нового словаря без аналога — `as const` + union.
строковые `enum`, для структуры с разными payload — discriminated union.
Сохраняй wire values; generated/external enums не переписывай вручную. Сохраняй wire values; generated/external enums не переписывай вручную.
Numeric policy называй по смыслу и единицам. Индексы, `Math.max(value, 0)` и Numeric policy называй по смыслу и единицам. Индексы, `Math.max(value, 0)` и
обычные labels не выноси в constants без semantic benefit. обычные labels не выноси в constants без semantic benefit.
@@ -65,33 +56,21 @@ Peer-конфликт решай совместимыми версиями бе
## Проверки и завершение ## Проверки и завершение
| Когда | Что проверить | Code/dependency change: `npm ci`, полный `npm run verify` и дополнительные
| --- | --- | risk checks из местной testing convention. Targeted tests ускоряют итерацию,
| Окружение новое, изменились manifest/lockfile или Node, установка сомнительна | `npm ci` до Node-проверок; одна правка кода переустановки не требует. | но не заменяют финальный gate. `lint:fix`/format — edits, не proof.
| Итерация | Адресные тесты, typecheck и lint по затронутому поведению и риску. При изменении поведения добавь/обнови содержательную регрессию, если существующая проверка не ловит её; для косметики не создавай искусственный тест. | Markdown-only: проверь новые/изменённые тексты, локальные ссылки/якоря, маршруты
| Итоговый code/dependency diff | Перед объявлением готовности и передачей к интеграции выполни один полный `npm run verify` на итоговых входах. Дополнительные risk checks из testing convention и явно утверждённые task checks обязательны; CI проверяет независимо. | чтения и `git diff --check`; application build ради текста не нужен.
| Только Markdown | Проверь тексты, ссылки/якоря, маршруты чтения и `git diff --check`; application build не нужен. При изменении verification-контура проверь его работу. | Повтори routing по staged/unstaged/new files. Запиши краткие команды, результаты
| Pre-commit | Hook не скачивает tools: вызывай установленные бинарники из `node_modules/.bin` (не `npx`). Full graph не входит в hook; он выполняется в `npm run verify` и CI. | и ограничения. Зелёный lint не заменяет semantic/security review.
Повтори routing по staged/unstaged/new files. В отчёте по задаче/PR запиши
проверенные входы, команду, exit code, результат и ограничения. Повторяй
проверку при изменении значимых входов (включая config, dependencies и
generated artifacts), сомнении в evidence или явном запросе пользователя.
`lint:fix`/format — edits, не proof; lint не заменяет semantic/security review.
## Git и релизы ## Git и релизы
Работа — в ветке `<type>/<issue-number>-<slug>` от актуального `develop`, Работа — в `codex/<task>` от актуального `develop`, затем PR в `develop`.
затем PR в `develop`; для cross-repo ссылки укажи owner/repo#number.
Сохраняй чужие изменения; перед commit просмотри staged paths и diff. Сохраняй чужие изменения; перед commit просмотри staged paths и diff.
Commits и PR titles — Conventional Commits с parser-recognized Issue reference (`fix: … (#12)` Commits и PR titles — Conventional Commits; допустим `spec:`. Каждый commit
или `chore: … (WealthLedger/main#27)`); допустим `spec:`. Каждый commit содержит одну связанную задачу. При связанной issue добавь её номер в конец
содержит одну связанную задачу. Исключения release и правила ссылки — в заголовка (`fix: … #12`); без issue номер не нужен. Root другого checkout
Engineering Workflow из AGENTS. Root другого checkout не управляет этим repo. не управляет этим repo.
Прямой push в `main`, force-push и перенос опубликованных тегов не входят в Прямой push в `main`, force-push и перенос опубликованных тегов не входят в
обычную работу. PR сливается squash или rebase-merge; merge-коммит не обычную работу. Перед release/hotfix прочитай местный README, раздел «Релизы».
используется. `main` обновляется только fast-forward из `develop`, поэтому
`origin/main` всегда предок `origin/develop`:
`git merge-base --is-ancestor origin/main origin/develop`. Срочный fix —
обычная ветка `fix/*` от `develop`; пути hotfix от `main` нет.
Перед релизом прочитай местный README, раздел «Релизы».
+2 -4
View File
@@ -19,16 +19,14 @@ StateView/LoadingSkeleton и их stories принадлежат `packages/ui`.
Используй CSF, `Meta`/`StoryObj`, `satisfies Meta`, локальные fixtures и actions. Используй CSF, `Meta`/`StoryObj`, `satisfies Meta`, локальные fixtures и actions.
Общие Mantine/theme/i18n/Dates providers уже есть в Общие Mantine/theme/i18n/Dates providers уже есть в
[preview](../../.storybook/preview.tsx); не создавай второй theme source. [preview](../../.storybook/preview.tsx); не создавай второй theme source.
Stories не достигают application API, auth и query providers даже транзитивно. Stories не достигают application API, auth, VM и query providers даже транзитивно;
Presentation VM с form-context/UI-only state допустима; data VM остаётся за
этой границей, которую проверяет полный reachable graph;
границу проверяет `deps:storybook`. Не добавляй MSW worker ради каталога. границу проверяет `deps:storybook`. Не добавляй MSW worker ради каталога.
`play` через `storybook/test` нужен, когда interaction иллюстрирует собственный `play` через `storybook/test` нужен, когда interaction иллюстрирует собственный
контракт: keyboard activation, доступность действия, local callback. Не повторяй контракт: keyboard activation, доступность действия, local callback. Не повторяй
RTL suite и не тестируй внутренности Mantine. Существующий RTL suite и не тестируй внутренности Mantine. Существующий
[Button](../../src/shared/ui/Button.stories.tsx) показывает themed control [Button](../../src/shared/ui/Button.stories.tsx) показывает themed control
без production wrapper; [LoadingSkeleton](../../packages/ui/src/LoadingSkeleton/LoadingSkeleton.stories.tsx) без production wrapper; [LoadingSkeleton](../../packages/ui/src/LoadingSkeleton.stories.tsx)
— значимые busy states. — значимые busy states.
`npm run typecheck`, `npm run deps:storybook`, `npm run build:catalog` проверяют `npm run typecheck`, `npm run deps:storybook`, `npm run build:catalog` проверяют
+2 -2
View File
@@ -45,9 +45,9 @@ Finance: loading/error/empty/loaded, cached snapshot при refetch failure, ret
отсутствие старых данных после session/connection switch. ErrorBoundary: test-only отсутствие старых данных после session/connection switch. ErrorBoundary: test-only
throwing route, безопасная ссылка и reset после navigation. throwing route, безопасная ссылка и reset после navigation.
Примеры: [AuthForm test](../../src/features/auth/ui/AuthForm/AuthForm.test.tsx), Примеры: [AuthForm test](../../src/features/auth/ui/AuthForm.test.tsx),
[client test](../../src/shared/api/client.test.ts), [client test](../../src/shared/api/client.test.ts),
[resource lifecycle test](../../src/shared/api/resource-query.test.tsx). [usePortfolio test](../../src/entities/portfolio/api/usePortfolio.test.tsx).
Не копируй fixtures/сценарии целиком, если задача затрагивает один исход. Не копируй fixtures/сценарии целиком, если задача затрагивает один исход.
## Команды ## Команды
+10 -50
View File
@@ -5,8 +5,7 @@
- Сохраняй strict TS profile и `import type`. Для недоверенного значения используй - Сохраняй strict TS profile и `import type`. Для недоверенного значения используй
`unknown` с narrowing; `any` не заменяет DTO/validation. Локальное исключение `unknown` с narrowing; `any` не заменяет DTO/validation. Локальное исключение
на нетипизированной boundary должно быть узким и объяснённым. на нетипизированной boundary должно быть узким и объяснённым.
- Строковые `enum` используются для machine codes и protocol values. - `enum` и `namespace` не используются: frontend имеет `erasableSyntaxOnly`.
`namespace` не используется; tooling сохраняет `erasableSyntaxOnly`.
Для состояния с разными payload выбирай discriminated union; не добавляй Для состояния с разными payload выбирай discriminated union; не добавляй
необязательные поля, допускающие невозможные комбинации. необязательные поля, допускающие невозможные комбинации.
- Named components — const-valued `FC<Props>`; named props находятся рядом или - Named components — const-valued `FC<Props>`; named props находятся рядом или
@@ -15,42 +14,7 @@
на основе `PropsWithChildren<{ … }>`; без props — `FC`. на основе `PropsWithChildren<{ … }>`; без props — `FC`.
- Обычные exports — нижний named block; barrel делает прямой re-export. - Обычные exports — нижний named block; barrel делает прямой re-export.
CSF сохраняет default meta и прямые story exports; configuration exceptions CSF сохраняет default meta и прямые story exports; configuration exceptions
заданы в [ESLint](../../eslint.config.mjs). Не распространяй их на application code. заданы в [Biome](../../biome.json). Не распространяй их на application code.
Тип значений формы выводится из её schema, когда input/output совпадают.
Общие props определяются у одного owner; DTO и presentation model сохраняют
собственные типы при смысловом отличии. Type re-export сохраняет public imports.
## Читаемость: ESLint и review
ESLint проверяет ограничение 80 символов (tab = 2), пустые строки между
JSX siblings и вокруг многострочных declarations/control-flow/return, вынос
функций и условных выражений из JSX props, отдельные Zod schema files,
отсутствие SVG в приложении, optional trailing parameters и многострочные
сложные union types. Generated artifacts не форматируй вручную.
URL, строковые/template/regex literals и комментарии исключены из `max-len`;
это исключение для содержимого, а не повод собирать длинные expressions.
Однострочный JSX не требует обрамляющих скобок; многострочный — требует.
`unicorn/no-null` отключён: API DTO, React refs и Mantine используют `null`.
Сохраняй различие `null`/`undefined` из контракта вместо массовых suppressions.
CSS и Markdown также держи в 80 колонках там, где это не ломает данные/ссылки.
Review проверяет то, чего синтаксис не доказывает:
Обоснованное config exception сопровождается причиной и явным ожиданием в
existing lint tests, включая разрешённый и запрещённый примеры. Это позволяет
настраивать профиль без случайного ослабления остальных правил.
- Пустая строка отделяет смысловые шаги: inputs, вычисления, действие, результат.
- Имена derived values описывают значение, например `projectedAmount`.
- Тернарник остаётся коротким выбором двух значений. Mapping, несколько веток
и многострочное преобразование — отдельная функция с guard и `if/else`.
- Union из object shapes оформляй несколькими строками; nullable scalar
`null | string` остаётся компактным. Для повторяемого shape используй named type.
- Последний необязательный аргумент — `accountId?: string`. Required аргумент
перед следующим required параметром может сохранять `T | undefined`.
- Helpers и действия размещай по [UI ownership](ui.md#container-vm-и-view);
перенос не меняет null/zero, validation, recovery, closures и hook ordering.
## Hooks и derived state ## Hooks и derived state
@@ -69,23 +33,19 @@ Container/VM/view и state owners — в [UI](ui.md).
Общее правило ownership и символических значений — Общее правило ownership и символических значений —
[Domain values](engineering.md#domain-values). [Domain values](engineering.md#domain-values).
Backend vocabulary бери из generated `paths`; generated artifacts вручную не меняй. Backend vocabulary бери из generated `paths`, не создавай ручную копию enum.
Для открытых `string` полей допускается локальный enum известных значений, Текущий `schema.d.ts` содержит только types: typed wire literals допустимы,
используемых UI. Он не закрывает wire contract и сохраняет unknown fallback. когда generator не предоставляет runtime values.
Если generator предоставляет runtime enum, используй его.
`ProblemDetailsDto.code` сейчас `string`. `ApiErrorCode` перечисляет известные UI коды, а не закрывает wire contract. `ProblemDetailsDto.code` сейчас `string`. Не объявляй закрытый frontend error
`apiErrorCode` сохраняет произвольную строку; `isApiErrorCode` проверяет известное enum и не превращай неизвестный code в известный через cast. Повторяющуюся
значение без cast и сохраняет fallback для неизвестного кода. Повторяющуюся
обработку своди у ответственного owner; [errors](data.md#errors). обработку своди у ответственного owner; [errors](data.md#errors).
`@typescript-eslint/no-magic-numbers` — error; fixtures/tests/stories исключены `noMagicNumbers` в Biome — error; fixtures/tests/stories исключены только
из этой дополнительной проверки. Policy получает имя и единицы; подавление допустимо для из этого правила. Policy получает имя и единицы; подавление допустимо для
incidental value с объяснением. Обычный UI-текст не превращай в constant: incidental value с объяснением. Обычный UI-текст не превращай в constant:
его owner — [i18n](ui.md#строки-и-валидация). его owner — [i18n](ui.md#строки-и-валидация).
`no-restricted-syntax` запрещает inline machine codes и protocol values в production; Semantic magic strings и props profile требуют review.
`no-unsafe-enum-comparison` проверяет сравнения enum. Fixtures/stories сохраняют
literal wire values. Новые vocabularies и props profile требуют review.
## Проверка ## Проверка
+17 -78
View File
@@ -11,17 +11,13 @@ Mantine component и общую тему. Для выпадающего выбо
ограничения JSDOM решай в test setup, сохраняя production UI. ограничения JSDOM решай в test setup, сохраняя production UI.
Семантический HTML для структуры страницы остаётся уместным. Семантический HTML для структуры страницы остаётся уместным.
Icons импортируй из `@wealthledger/icons`; геометрия, SVG assets и animation Icons ищи у существующего owner (например,
lifecycle принадлежат этому пакету. Domain/category lookup возвращает параметры [BankOperationIcon](../../src/entities/bank-card/ui/BankOperationIcon.tsx));
иконки из model сущности. Каждый SVG хранится цельным декларативным статичные Add/Edit/Delete доступны в `@wealthledger/icons`. Их SVG декоративны
компонентом: не разбивай path-строки и не собирай геометрию в runtime. по умолчанию; имя action принадлежит кнопке. Standalone meaningful icon получает
Русские category names из API — данные для нормализованного сопоставления, `aria-hidden={false}`, `role="img"` и доступное имя. Category/kind mappings
не ключи реестра иконок; неизвестные/переименованные категории имеют fallback. и анимации остаются в entity. Не добавляй пакет ради одного символа. Business/domain
Известные названия банков принадлежат enum `BankName` в bank-card model; components живут у feature/entity/page, даже при повторном использовании.
свободный ввод пользовательского названия остаётся строкой.
SVG декоративны по умолчанию; доступное имя action
принадлежит кнопке. Meaningful icon получает `aria-hidden={false}`, `role="img"`
и доступное имя. Приложение и Storybook подключают CSS пакета.
Wrapper нужен для проектной ответственности, а не ради нового имени Wrapper нужен для проектной ответственности, а не ради нового имени
для Button. Локальный CSS module принадлежит component; общий token вводи для для Button. Локальный CSS module принадлежит component; общий token вводи для
@@ -55,76 +51,19 @@ retry callbacks, domain grouping и derived presentation data. View получа
| `Component.types.ts` | Переиспользуемые props/local types, когда отдельный owner полезен | | `Component.types.ts` | Переиспользуемые props/local types, когда отдельный owner полезен |
| `Component.module.css` | Локальные стили | | `Component.module.css` | Локальные стили |
View получает готовые данные и именованные действия. Container вызывает VM; Во view нет data/auth/router/navigation/VM hooks и вычислений по domain collection.
VM владеет запросами, формой, состоянием, обработчиками и derived values. Допустимы `useTranslation`, form-context hooks, UI-only state (например failed
Во view нет data/auth/router/navigation hooks и domain grouping. Data VM image fallback) и formatter одного переданного значения. Группировка/доли
вызывает container; presentation hook не должен скрывать fetching во view. коллекции готовятся в VM. Перенос fetch во view ради меньшего container нарушает границу.
Presentation VM для form-context/UI-only state может использоваться автономно
в каталоге: её граф зависимостей не достигает application data/API.
`useTranslation` и декларативный вывод коллекции допустимы во view.
ESLint ограничивает прямые data/auth/router hook imports в `*.view.tsx`; Отдельный state/section с самостоятельной responsibility выноси в flat
type imports, error helpers, router components и form context разрешены. `components/*.view.tsx`. Небольшой tightly-coupled renderer оставляй рядом.
Транзитивный fetching через local presentation hook проверяется review; Не создавай folder/barrel/test/CSS для каждого view и не дроби по line count.
Storybook дополнительно проверяет reachable graph. Новое исключение должно Пример: [AccountIncomePage](../../src/pages/account-income/ui/AccountIncomePage.tsx)
сохранять presentation-only responsibility. и [его VM](../../src/pages/account-income/ui/AccountIncomePage.vm.ts).
JSX props получают ссылки на функции, включая `onClick`, render props,
`dateParser` и callbacks внутри объектов props. Для React Hook Form предпочитай
`useController` в VM вместо inline `Controller.render`. Значение prop с условием
сначала получает предметное имя: `projectedAmount`, `archiveActionLabel`.
Проверяй narrowing при переносе: выражение не должно читать отсутствующие данные
до ветки, которая раньше защищала доступ; обработчик сохраняет актуальный snapshot.
Внутри компонента нет именованных helpers. Чистые функции форматирования и
адаптации UI inputs выноси в `shared/lib`, передавая явные аргументы. Domain
преобразование остаётся в model своего owner; shared не импортирует entities/pages.
Closure над состоянием, mutations и refs является действием VM, а не shared helper.
VM собирает связные предметные hooks, когда внутри смешиваются независимые
сценарии. Например, `useRefundPurchase` владеет выбором исходной покупки,
её загрузкой и пагинацией. Общий адаптер `bankDateField` обслуживает поля даты;
простое подключение одного controller остаётся в VM формы.
Не группируй несвязанные функции только по префиксу `on*` и не создавай
passthrough hook над одним вызовом ради формального дробления.
Render-helper замени самостоятельным presentation component у того же owner.
Отдельный state/section с самостоятельной responsibility выноси в
`components/` своего component. Небольшой tightly-coupled renderer оставляй
рядом; размер файла сам по себе не требует разделения.
Пример: [AccountIncomePage](../../src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.tsx)
и [его VM](../../src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.vm.ts).
## Размещение файлов компонента
Правило действует в `src` и `packages`: компонент с сопутствующими файлами
(view, VM, types, CSS, tests, stories) размещай в папке `Component/`.
Сохраняй полные имена файлов: `Component.tsx`, `Component.view.tsx`,
`Component.test.tsx`. Создавай только нужные файлы; одиночный компонент
оставляй непосредственно в `ui`, `components` или source-каталоге пакета.
Внутренние части принадлежат папке родителя; для набора state/section views
используй её `components/`. Внутри него действует то же правило: отдельная
папка объединяет комплект файлов, одиночный view остаётся файлом.
Стили и хуки нескольких соседних компонентов остаются у ближайшего общего
владельца; не копируй их и не прячь внутрь одного потребителя.
Папка компонента не создаёт новую FSD-границу. Публичные exports остаются в
существующем `index.ts` slice/segment или entry пакета; внутри используй
прямые относительные imports без дополнительных barrels на каждый компонент.
При переносе обновляй imports, ссылки документации и проверки, зависящие от пути.
Пример: [BankCardForm](../../src/entities/bank-card/ui/BankCardForm/BankCardForm.tsx)
лежит рядом со своим view, тестом и story. Формы используют Mantine
`Fieldset variant="unstyled"` для групповой блокировки полей без локального CSS.
## Формы ## Формы
Схемы Zod объявляй в `*.schema.ts`, включая `.refine`/`.superRefine`.
Зависимая от текущих данных схема — фабрика с явными inputs в том же файле;
VM вызывает фабрику. Сохраняй актуальность данных и deferred validation messages.
Используй `react-hook-form`, `zodResolver`, `FormProvider`. Container/VM создаёт Используй `react-hook-form`, `zodResolver`, `FormProvider`. Container/VM создаёт
form state, view получает `onSubmit`/`disabled`, fields используют `useFormContext`. form state, view получает `onSubmit`/`disabled`, fields используют `useFormContext`.
Во время submit блокируй все действия формы. Zod errors показывай у поля; Во время submit блокируй все действия формы. Zod errors показывай у поля;
@@ -159,7 +98,7 @@ Types, props, hooks и memoization — в [TypeScript/React](typescript-react.md
Продуктовый текст хранится в `src/shared/i18n/locales/ru.ts`, читается через Продуктовый текст хранится в `src/shared/i18n/locales/ru.ts`, читается через
`useTranslation()`. В Zod используй `validationMessage(key)` из i18n/zod, без `useTranslation()`. В Zod используй `validationMessage(key)` из i18n/zod, без
пользовательских literal messages. Это mandatory semantic review: общего запрета пользовательских literal messages. Это mandatory semantic review: общего запрета
непереведённых JSX-строк в ESLint/TypeScript нет. Synthetic story fixtures и непереведённых JSX-строк в Biome/TypeScript нет. Synthetic story fixtures и
описания историй допустимы; они не заменяют продуктовую локализацию. описания историй допустимы; они не заменяют продуктовую локализацию.
## Проверка ## Проверка
+7 -10
View File
@@ -22,7 +22,13 @@
!packages/ui/vite.config.ts !packages/ui/vite.config.ts
!packages/ui/tsconfig.json !packages/ui/tsconfig.json
!packages/ui/src/ !packages/ui/src/
!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/
!packages/icons/package.json !packages/icons/package.json
!packages/icons/tsconfig.json !packages/icons/tsconfig.json
@@ -34,12 +40,3 @@
!packages/icons/src/svg/AddIcon.tsx !packages/icons/src/svg/AddIcon.tsx
!packages/icons/src/svg/EditIcon.tsx !packages/icons/src/svg/EditIcon.tsx
!packages/icons/src/svg/DeleteIcon.tsx !packages/icons/src/svg/DeleteIcon.tsx
# Keep local dependencies, outputs and secrets out of allowed source trees.
**/node_modules
**/dist
**/coverage
**/storybook-static
**/.env*
**/*.pem
**/*.key
-12
View File
@@ -1,12 +0,0 @@
---
name: Bug Report
about: Неверное поведение
---
## Проблема и шаги воспроизведения
## Факт / ожидание
## Acceptance Criteria
- [ ]
## Окружение / ссылки (если применимо)
-10
View File
@@ -1,10 +0,0 @@
---
name: Engineering Task
about: CI, тесты, документация, процесс и прочее
---
## Контекст и результат
## Scope / связанные Issues (если есть)
## Acceptance Criteria
- [ ]
-12
View File
@@ -1,12 +0,0 @@
---
name: Feature Request
about: Новое наблюдаемое поведение
---
## Зачем и какой результат
## Ожидаемое поведение
## Acceptance Criteria
- [ ]
## Зависимости / связанные Issues (если есть)
-12
View File
@@ -1,12 +0,0 @@
---
name: Refactoring / Technical Debt
about: Улучшение без нового поведения
---
## Проблема и результат
## Сохраняемые инварианты / границы
## Acceptance Criteria
- [ ]
## Зависимости / ссылки (если есть)
-11
View File
@@ -1,11 +0,0 @@
## Summary
## Linked Issue
Refs
## Changes
## Verification
- Команда / результат / ограничения:
## Breaking Changes (если есть)
@@ -1,19 +1,11 @@
name: Frontend verify name: Conventional commits
# Metadata-only: refreshes only the `commits` context after a PR title edit.
# Same workflow name keeps the required context; `security` and `verify`
# statuses of the head revision stay untouched (no skipped overwrite).
on: on:
pull_request: pull_request:
types: [edited] types: [opened, synchronize, reopened, edited]
concurrency:
group: frontend-pr-title-${{ gitea.event.pull_request.number }}
cancel-in-progress: true
jobs: jobs:
commits: commits:
timeout-minutes: 15
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: https://github.com/actions/checkout@v4 - uses: https://github.com/actions/checkout@v4
-2
View File
@@ -30,8 +30,6 @@ jobs:
- run: npm ci - run: npm ci
env: env:
HUSKY: '0' HUSKY: '0'
- name: Install Chromium and system dependencies
run: npx --no-install playwright install --with-deps chromium
- run: npm run verify - run: npm run verify
env: env:
KNIP_DISABLE_RAW_TRANSFER: '1' KNIP_DISABLE_RAW_TRANSFER: '1'
+33
View File
@@ -0,0 +1,33 @@
name: Frontend security
on:
push:
branches:
- '**'
pull_request:
schedule:
- cron: '17 5 * * *'
jobs:
security:
runs-on: ubuntu-latest
steps:
- uses: https://github.com/actions/checkout@v4
with:
fetch-depth: 0
- name: Record checked revision
run: git rev-parse HEAD
- name: Scan history with Gitleaks 8.30.1
run: |
curl --fail --location --silent --show-error https://github.com/gitleaks/gitleaks/releases/download/v8.30.1/gitleaks_8.30.1_linux_x64.tar.gz -o /tmp/gitleaks.tar.gz
echo '551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb /tmp/gitleaks.tar.gz' | sha256sum --check
mkdir -p /tmp/gitleaks-bin
tar -xzf /tmp/gitleaks.tar.gz -C /tmp/gitleaks-bin gitleaks
/tmp/gitleaks-bin/gitleaks git --log-opts=--all --redact=100 --no-banner
- uses: https://github.com/actions/setup-node@v4
with:
node-version-file: .nvmrc
- name: Verify pre-commit secret blocking and redaction
run: PATH=/tmp/gitleaks-bin:$PATH node --test scripts/test-pre-commit.mjs
- name: Audit runtime and development dependencies
run: npm audit --include=dev --audit-level=low
+2 -70
View File
@@ -2,78 +2,12 @@ name: Frontend verify
on: on:
push: push:
branches: [develop, main] branches:
- '**'
pull_request: pull_request:
types: [opened, synchronize, reopened]
schedule:
- cron: '17 5 * * *'
# Gitea >= 1.26. A new revision supersedes only the same PR/branch/event.
concurrency:
group: frontend-verify-${{ gitea.event_name }}-${{ gitea.event.pull_request.number || gitea.ref }}
cancel-in-progress: true
jobs: jobs:
commits:
if: gitea.event_name == 'pull_request'
timeout-minutes: 15
runs-on: ubuntu-latest
steps:
- uses: https://github.com/actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 0
- uses: https://github.com/actions/setup-node@v4
with:
node-version-file: .nvmrc
- run: npm ci --ignore-scripts
- name: Check PR title and commits
env:
PR_TITLE: ${{ github.event.pull_request.title }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
printf '%s\n' "$PR_TITLE" | npm run commitlint -- --strict
if [[ "$PR_TITLE" =~ ^chore\(release\):\ [0-9]+\.[0-9]+\.[0-9]+$ ]]; then
npm run commitlint -- --strict --from "$HEAD_SHA^" --to "$HEAD_SHA"
else
npm run commitlint -- --strict --from "$BASE_SHA" --to "$HEAD_SHA"
fi
security:
needs: commits
if: >-
!cancelled() &&
(needs.commits.result == 'success' || needs.commits.result == 'skipped')
timeout-minutes: 15
runs-on: ubuntu-latest
steps:
- uses: https://github.com/actions/checkout@v4
with:
fetch-depth: 0
- name: Record checked revision
run: git rev-parse HEAD
- name: Scan history with Gitleaks 8.30.1
run: |
curl --fail --location --silent --show-error https://github.com/gitleaks/gitleaks/releases/download/v8.30.1/gitleaks_8.30.1_linux_x64.tar.gz -o /tmp/gitleaks.tar.gz
echo '551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb /tmp/gitleaks.tar.gz' | sha256sum --check
mkdir -p /tmp/gitleaks-bin
tar -xzf /tmp/gitleaks.tar.gz -C /tmp/gitleaks-bin gitleaks
/tmp/gitleaks-bin/gitleaks git --log-opts=--all --redact=100 --no-banner
- uses: https://github.com/actions/setup-node@v4
with:
node-version-file: .nvmrc
- name: Verify pre-commit secret blocking and redaction
run: PATH=/tmp/gitleaks-bin:$PATH node --test scripts/test-pre-commit.mjs
- name: Audit runtime and development dependencies
run: npm audit --include=dev --audit-level=low
verify: verify:
needs: [commits, security]
if: >-
!cancelled() && needs.security.result == 'success' &&
gitea.event_name != 'schedule'
timeout-minutes: 45
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: https://github.com/actions/checkout@v4 - uses: https://github.com/actions/checkout@v4
@@ -87,8 +21,6 @@ jobs:
- run: npm ci - run: npm ci
env: env:
HUSKY: '0' HUSKY: '0'
- name: Install Chromium and system dependencies
run: npx --no-install playwright install --with-deps chromium
- name: Run all frontend gates - name: Run all frontend gates
run: npm run verify run: npm run verify
env: env:
-1
View File
@@ -12,7 +12,6 @@ dist
storybook-static storybook-static
dist-ssr dist-ssr
coverage coverage
.vitest
.scannerwork .scannerwork
*.local *.local
+2 -5
View File
@@ -1,6 +1,3 @@
gitleaks git --pre-commit --staged --redact=100 --no-banner || exit $? gitleaks git --pre-commit --staged --redact=100 --no-banner || exit $?
if [ ! -x node_modules/.bin/lint-staged ]; then npx lint-staged
echo "lint-staged is not installed; run npm ci" >&2 npm run deps
exit 1
fi
node_modules/.bin/lint-staged
+1 -1
View File
@@ -1 +1 @@
24.22.1 24.21.0
+2 -7
View File
@@ -5,16 +5,11 @@ module.exports = {
forbidden: [ forbidden: [
...base.forbidden, ...base.forbidden,
{ {
from: { path: "[.]stories[.]tsx$|^[.]storybook/preview[.]tsx$" },
name: "stories-must-not-reach-application-data", name: "stories-must-not-reach-application-data",
severity: "error", severity: "error",
from: { path: "\\.stories\\.tsx$|^\\.storybook/preview\\.tsx$" },
to: { to: {
path: [ path: "^src/(shared/api|features/auth|app/routes|app/ui)/|^src/(entities|features)/[^/]+/api/|\\.vm\\.ts$|^src/pages/[^/]+/ui/[^/]+Page\\.tsx$|node_modules/(@tanstack/react-query|openapi-fetch|openapi-react-query)/",
"^src/(shared/api|features/auth|app/routes|app/ui)/",
"^src/(entities|features)/[^/]+/api/",
"^src/pages/[^/]+/ui/.*Page[.]tsx$",
"node_modules/(@tanstack/react-query|openapi-fetch|openapi-react-query)/",
],
reachable: true, reachable: true,
}, },
}, },
+3 -3
View File
@@ -1,10 +1,10 @@
import type { StorybookConfig } from "@storybook/react-vite"; import type { StorybookConfig } from "@storybook/react-vite";
const config: StorybookConfig = { const config: StorybookConfig = {
addons: ["@storybook/addon-docs"],
core: { disableTelemetry: true },
framework: "@storybook/react-vite",
stories: ["../src/**/*.stories.tsx", "../packages/*/src/**/*.stories.tsx"], stories: ["../src/**/*.stories.tsx", "../packages/*/src/**/*.stories.tsx"],
addons: ["@storybook/addon-docs"],
framework: "@storybook/react-vite",
core: { disableTelemetry: true },
}; };
export default config; export default config;
+15 -19
View File
@@ -1,34 +1,39 @@
import "@mantine/charts/styles.css";
import "@mantine/core/styles.css"; import "@mantine/core/styles.css";
import "@mantine/charts/styles.css";
import "@mantine/dates/styles.css"; import "@mantine/dates/styles.css";
import "@wealthledger/icons/styles.css";
import "@wealthledger/ui/styles.css"; import "@wealthledger/ui/styles.css";
import "dayjs/locale/ru"; import "dayjs/locale/ru";
import type { Preview } from "@storybook/react-vite";
import "../src/shared/i18n"; import "../src/shared/i18n";
import { MantineProvider } from "@mantine/core"; import { MantineProvider } from "@mantine/core";
import { DatesProvider } from "@mantine/dates"; import { DatesProvider } from "@mantine/dates";
import type { Preview } from "@storybook/react-vite";
import { cssVariablesResolver, theme } from "@wealthledger/ui/theme"; import { cssVariablesResolver, theme } from "@wealthledger/ui/theme";
const colorSchemeManager = { const colorSchemeManager = {
clear: () => {},
get: () => "light" as const, get: () => "light" as const,
set: () => {}, set: () => {},
clear: () => {},
subscribe: () => {}, subscribe: () => {},
unsubscribe: () => {}, unsubscribe: () => {},
}; };
const preview: Preview = { const preview: Preview = {
parameters: {
layout: "padded",
options: {
storySort: {
order: ["Design System", ["Foundations", "Components"], "Product"],
},
},
controls: { expanded: true },
},
decorators: [ decorators: [
Story => ( (Story) => (
<MantineProvider <MantineProvider
colorSchemeManager={colorSchemeManager}
cssVariablesResolver={cssVariablesResolver} cssVariablesResolver={cssVariablesResolver}
forceColorScheme="light"
theme={theme} theme={theme}
forceColorScheme="light"
colorSchemeManager={colorSchemeManager}
> >
<DatesProvider settings={{ locale: "ru" }}> <DatesProvider settings={{ locale: "ru" }}>
<Story /> <Story />
@@ -36,15 +41,6 @@ const preview: Preview = {
</MantineProvider> </MantineProvider>
), ),
], ],
parameters: {
controls: { expanded: true },
layout: "padded",
options: {
storySort: {
order: ["Design System", ["Foundations", "Components"], "Product"],
},
},
},
}; };
export default preview; export default preview;
+1 -4
View File
@@ -4,9 +4,6 @@
только его файлы и предоставленные публичные контракты. Перед работой прочитай только его файлы и предоставленные публичные контракты. Перед работой прочитай
[порядок работы](.ai/conventions/engineering.md#граница-и-порядок-работы), затем [порядок работы](.ai/conventions/engineering.md#граница-и-порядок-работы), затем
объединяй triggers задачи и путей; читай активированные темы до решения. объединяй triggers задачи и путей; читай активированные темы до решения.
До edits найди/создай подходящую Gitea Issue, проверь AC и ветку; чтение
допускается без Issue. Общий маршрут — [Engineering Workflow](https://git.personal.ksv741.ru/WealthLedger/main/src/commit/03b3b59e6ff2ba391f9dfc42a039a96d0d84c1c9/.ai/conventions/engineering-workflow.md)
(main#28; после merge — актуальная версия `develop`).
| Решение / сигнал | Читать | | Решение / сигнал | Читать |
| --- | --- | | --- | --- |
@@ -37,4 +34,4 @@ Testing и без production diff. Перед завершением повто
| Money, account/position/income | Entity types, API snapshot, formatters и tests соответствующего slice | | Money, account/position/income | Entity types, API snapshot, formatters и tests соответствующего slice |
| Theme/tokens, UI-каталог | [UI](.ai/conventions/ui.md), [Storybook](README.md#ui-каталог-storybook) | | Theme/tokens, UI-каталог | [UI](.ai/conventions/ui.md), [Storybook](README.md#ui-каталог-storybook) |
| Поведение области | Source и tests её page/entity/feature; неясное ожидание уточни до изменения | | Поведение области | Source и tests её page/entity/feature; неясное ожидание уточни до изменения |
| Versions/scripts/check scope | Manifest/lockfile, ESLint/TypeScript/dependency configs | | Versions/scripts/check scope | Manifest/lockfile, Biome/TypeScript/dependency configs |
-165
View File
@@ -1,170 +1,5 @@
# Changelog # Changelog
## [1.2.1](https://git.personal.ksv741.ru/WealthLedger/frontend/compare/v1.2.0...v1.2.1) (2026-10-24)
### Bug Fixes
* keep session when refresh fails on network error or 5xx (WealthLedger/frontend[#97](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/97)) ([3af9df0](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/3af9df0ae2f0fb314c276504ef7d50a7472de680))
* show snapshot time in browser time zone ([#101](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/101)) ([b009101](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/b009101b7b954e8462672a9da859267ebb71edc4))
### Maintenance
* bump Node to 24.22.1 (WealthLedger/backend[#122](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/122)) ([2cd6b1e](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/2cd6b1e72d97117b0c37465aac3d989b0469c1c3))
* bump test stack and fix npm audit findings ([#105](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/105)) ([7e6fd95](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/7e6fd95cfaf7d288242fb46ec7091f561e6f3de5))
* **deps:** update non-major dependencies [#9](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/9) ([b5ceb77](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/b5ceb77208e35a0e389258f3d3c4b85852980042))
* resolve dev dependency audit findings (WealthLedger/frontend[#103](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/103)) ([7b6f34c](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/7b6f34c776fa3517c93b5c3468e3b277aaa6273f))
## [1.2.0](https://git.personal.ksv741.ru/WealthLedger/frontend/compare/v1.1.0...v1.2.0) (2026-10-18)
### Features
* unify income payment cards and remove comparisons (WealthLedger/main[#33](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/33)) ([#74](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/74)) ([43b0f55](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/43b0f5574a79e26de0abae35de16d8aa8700f718))
### Bug Fixes
* align password validation with backend ASCII policy (WealthLedger/backend[#60](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/60)) ([#89](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/89)) ([ebe9268](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/ebe92684a74b4e2617b04ef00d9406b8a0f674ea))
* **auth,bank:** guard switch-auth nav and surface banking retry ([e38ecd8](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/e38ecd87d6f69968bb56e6035ceeb6cfda208a3f)), closes [#56](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/56) [#57](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/57)
* **bank:** break BankFeedback's shared/api edge for stories ([#80](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/80)) ([2336804](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/233680448f97dc3ec57d7665ec14c68ac92410e5))
* deliver screener OpenAPI constraints (WealthLedger/backend[#70](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/70)) ([#85](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/85)) ([ed63ada](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/ed63adad94f5d0e20f7d0d1446a235f953d10ec2))
* **events:** seven-date default period and picker from today (WealthLedger/frontend[#63](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/63)) ([612ab62](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/612ab6250979bf8d9b57f4feb6e1476ed3d759e4))
* respect query eligibility in screen sync (WealthLedger/frontend[#54](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/54)) ([f295dea](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/f295dea6a6a33c597c753b52aff629d5382c963a))
### Refactoring
* derive payment-system options from schema tuple ([#58](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/58)) ([#75](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/75)) ([e67c47b](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/e67c47b102329877eb5d9c9d8bb4ea7c0bc70883))
### Build
* use native openapi-typescript --check ([#60](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/60)) ([#77](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/77)) ([df05c8c](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/df05c8c7dbb5d6816fb95d54ca43cd0d83eecd48))
### CI
* keep pre-commit proportionate and avoid tool downloads (WealthLedger/frontend[#62](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/62)) ([#86](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/86)) ([d409a29](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/d409a297e5f9ae5658d4efa958bda9a012a859a1))
* recheck PR title on edited without the full pipeline (WealthLedger/frontend[#64](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/64)) ([bc28973](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/bc28973d1ad5e15bfbc2e5b73b00ba63449933db))
### Tests
* await client settlement in late-response cases (WealthLedger/frontend[#61](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/61)) ([#81](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/81)) ([f3725c2](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/f3725c202bd8cef4ba711085df82a921a2da6b7d))
* cover non-retry of INVALID_TBANK_TOKEN reads (WealthLedger/backend[#59](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/59)) ([#90](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/90)) ([5134106](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/513410669680480a1f68f5720744cc85e43c8286))
* cover null events and income envelopes on account pages ([#72](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/72)) ([#79](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/79)) ([e5f8bbe](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/e5f8bbe33cd438f432281818c5276abe9a425646))
* cover null resource envelopes for account pages ([#70](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/70)) ([#71](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/71)) ([#78](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/78)) ([f7f53ff](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/f7f53ff8e4de3c1d06fac6063974ff9667145ed6))
### Maintenance
* adopt linear git model in frontend docs ([#91](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/91)) ([eefdde2](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/eefdde29f8da269581e90bd797078e10a39e472f))
## [1.1.0](https://git.personal.ksv741.ru/WealthLedger/frontend/compare/v1.0.0...v1.1.0) (2026-10-12)
### Features
* add stock screener ([a84411d](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/a84411df39dc131290e9f5b5d9313fed17cc629a))
* align investment client with hourly sync ([432cca2](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/432cca299f3a9a9e45b2697527369ee95f2d0adc))
* render hourly investment states ([3c226f8](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/3c226f8c29efceccb75af0d6424639a757534f39))
### Bug Fixes
* align frontend workflow rules (WealthLedger/main[#27](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/27)) ([c315f80](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/c315f80b1b07ae0888366512ec157b8e29c81d62))
* preserve latest stock screener result ([cec9c63](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/cec9c6348a389f16f272dac5533aebc9d12ceae8))
* show bank summary totals ([20b2cdf](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/20b2cdfd432f8b01e447f68657cf5c685a297f1e))
### Documentation
* clarify frontend verification policy ([cac4351](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/cac4351bc4abf2cec7c5b6611a74d87bf4ec2f70))
* pin frontend workflow reference (WealthLedger/main[#27](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/27)) ([0d747cc](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/0d747cc93ee08c337cf300c0367612708f45dfb9))
### CI
* skip checks on PR metadata edits ([c882120](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/c8821200ce699dab1ce65360d1968d5877a2e78b))
### Maintenance
* align frontend engineering workflow (WealthLedger/main[#27](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/27)) ([617edfd](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/617edfd489a6a84bb33b57386eca4eb54661f4f0))
## [1.0.0](https://git.personal.ksv741.ru/WealthLedger/frontend/compare/v0.4.0...v1.0.0) (2026-10-07)
### ⚠ BREAKING CHANGES
* drop investment stateVersion/progress/resources
from the client contract and use data/metadata readiness instead of
rewritten query status. Cross-tab auth may require re-login.
### Features
* add quiet reading with rare confirmations ([5b30bb8](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/5b30bb802a4ec43c036066f9a7e178f37e747a0b))
* default journal period to last 7 days ([0ea8d7b](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/0ea8d7b62d50a4b7c92e9a9934aead6246ac97f9))
### Bug Fixes
* **deps:** remove npm dependency on Nexus ([c096fc7](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/c096fc70ef442474c9b7766fdf5772b645166b92))
* **deps:** resolve npm audit advisories ([f873db7](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/f873db7b027b6dee0c7382b4bd3df5198c7a4198))
* **deps:** resolve npm audit advisories ([10baefd](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/10baefda790e7fb491c893724ddc4da7992103f0))
* resolve frontend audit findings ([f6db28d](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/f6db28defa51bd9d8fe932ceae4c3c720aba7eb3))
* sort stock and bond events by payment date ([f5f4447](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/f5f4447d60661aa92c80b50a16e8cb194dfa161f))
### Performance
* **tbank-connection:** poll screen sync every five seconds ([74f8a72](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/74f8a72ed55af2348fa3668f57312f4fdfc1e8a8)), references [WealthLedger/specs#21](https://git.personal.ksv741.ru/WealthLedger/specs/issues/21)
### Refactoring
* address frontend review feedback ([a4046db](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/a4046dbe87bd7dc949df32da3b6aaf174e6f5e8f))
* clarify frontend model ownership ([0865b35](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/0865b35f93865b4052181d918cee80c995312930))
* clarify frontend module ownership ([f9f0f69](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/f9f0f6921e86f6c81510373c680b136e4580f386))
* encapsulate instrument chart data ([64fe902](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/64fe902192b60a72440635f5610ebe02cf8543f8))
* enforce enum machine codes [#18](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/18) ([5b69003](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/5b6900351612b6ba34c694fc99612733e7901c79))
* enforce frontend view model boundaries ([ca0ad4f](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/ca0ad4f824a47fa47e32d4717e27b15c8a985f7d))
* move PortfolioStructure to widgets layer ([c25f6bd](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/c25f6bd85b88c4c7acb2f5ae6fc013aec77005b2))
* remove redundant page view models ([fb3a5a6](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/fb3a5a6eaa0223a15ea2c9f585a06459371f0a25))
* share instrument search presentation ([32a7786](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/32a77860c8eaea4a59a26e55b2d66976fd8f9484))
* share source sync status presentation ([0bc8bc3](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/0bc8bc36b7263b7f0120a08df840b290b40d8116))
* simplify auth and instrument pages ([b404c18](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/b404c1802a74f5f369173fdfcb086a3d5760e35a))
* simplify calendar and form state ([94adbaf](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/94adbafa344b23d7f95c31db975d99d990bfcb8b))
* simplify errors and focus tests ([7e976c3](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/7e976c330c54715018eda4bff0764c5d4de9c8b0))
* simplify finance view models ([2844b22](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/2844b22dbace41a02c1b5a07b3801e84c66b3528))
* simplify frontend state and mutation boundaries ([a7537da](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/a7537da34452ab1a15138ab0c1cd93d53d06c517))
* simplify query and session ownership ([6999707](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/69997075e569da3edd43e242ef36f09311262412))
* simplify resource queries and layout tests ([c1b0873](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/c1b08735e3a45d565c3836ae37ad76186bf48f26))
* tighten types and reuse instrument UI ([7c28a42](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/7c28a423dd8a3cc4a5bdcb49b8de7018979df413))
* unify bank form types and actions ([ff67a76](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/ff67a768addc0c4ba55247f5e2d9d5d5d5aec734))
### CI
* reduce duplicate actions and serialize checks ([95fdc87](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/95fdc8759e20f9baa4ac3d00e703752c79010e01))
### Tests
* remove redundant frontend checks ([320ea59](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/320ea5922a70ab81ce4f05c3c90eb7b6a664fa39))
### Styles
* pin ternary operator formatting [#18](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/18) ([12c3b72](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/12c3b72bfb32dff5f1ffdb36519c90f7ea852488))
### Maintenance
* add typed ESLint checks [#18](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/18) ([5763010](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/57630102e4bcc6b555dd546ba373a14a88256eed))
* finish frontend ESLint migration [#18](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/18) ([72d7835](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/72d7835227340e349d99f57db134a635f276f6e9))
* guard presentation data boundaries ([6a89b08](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/6a89b08ee571562fea349801169c40f1f993e9b9))
* guard unsafe DOM HTML with ESLint [#18](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/18) ([061ec70](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/061ec708c651029cd7d61d22319d11e7ac9f074e))
* remove unused exports ([fcb938b](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/fcb938b5a29fc82f00bbaa8b2ee776ca57dad010))
## [0.4.0](https://git.personal.ksv741.ru/WealthLedger/frontend/compare/v0.3.0...v0.4.0) (2026-09-26)
### Features
* show persisted investment data and sync state ([4e5f564](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/4e5f564fa1d583013fb3e7febdb3baa9737446b4))
### Bug Fixes
* **api:** update validation and conflict response contract ([db184c8](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/db184c8caec01609e8a3df3b16097ce634e5eff0))
* **auth:** isolate session races and financial caches ([a209d71](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/a209d715892862bb6d48084df0c84a10b8d56bf1))
* **ci:** resume verification of immutable release images ([6a87379](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/6a87379813ba1d4002779b23b2a65446b4d10258))
* **ui:** improve financial text contrast ([c86be7b](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/c86be7bf73d0141e0606d259ada6453c0d8e3dec))
### Refactoring
* colocate frontend component files ([b987fd8](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/b987fd8e780963dfe1dde0c714232697a52e0475))
* establish local frontend workspace boundaries ([85fcfe3](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/85fcfe3d073f80e759dafb14281c9462c1d7410d))
## [0.3.0](https://git.personal.ksv741.ru/WealthLedger/frontend/compare/v0.2.3...v0.3.0) (2026-09-20) ## [0.3.0](https://git.personal.ksv741.ru/WealthLedger/frontend/compare/v0.2.3...v0.3.0) (2026-09-20)
### Features ### Features
+2 -2
View File
@@ -1,4 +1,4 @@
FROM node:24.22.1-bookworm-slim AS dependencies FROM node:24.21.0-bookworm-slim AS dependencies
WORKDIR /app WORKDIR /app
ENV HUSKY=0 ENV HUSKY=0
COPY package.json package-lock.json ./ COPY package.json package-lock.json ./
@@ -19,7 +19,7 @@ COPY . .
ENV VITE_API_BASE_URL="" ENV VITE_API_BASE_URL=""
RUN npm run build RUN npm run build
FROM nginx:1.31.8-alpine AS production FROM nginx:1.28.0-alpine AS production
ARG VERSION ARG VERSION
ARG REVISION ARG REVISION
LABEL org.opencontainers.image.version=$VERSION \ LABEL org.opencontainers.image.version=$VERSION \
+33 -110
View File
@@ -19,10 +19,8 @@ npm run dev
Для commits установите [Gitleaks](https://github.com/gitleaks/gitleaks/releases/tag/v8.30.1) Для commits установите [Gitleaks](https://github.com/gitleaks/gitleaks/releases/tag/v8.30.1)
8.30.1 в `PATH` (macOS: `brew install gitleaks`). Pre-commit сначала проверяет 8.30.1 в `PATH` (macOS: `brew install gitleaks`). Pre-commit сначала проверяет
staged diff с полной редакцией секретов, затем запускает установленный staged diff с полной редакцией секретов, затем запускает formatting и FSD checks.
`node_modules/.bin/lint-staged` только для staged JS/TS. Hook не скачивает tools и Находка или отсутствие Gitleaks блокирует commit.
не запускает full graph: полный `npm run verify` выполняется в CI и перед передачей.
Находка или отсутствие Gitleaks либо lint-staged блокирует commit.
Проверка этого поведения: `node --test scripts/test-pre-commit.mjs`. Проверка этого поведения: `node --test scripts/test-pre-commit.mjs`.
## Контейнерный image ## Контейнерный image
@@ -77,69 +75,20 @@ Service worker кеширует только статическую оболоч
```bash ```bash
nvm use --silent nvm use --silent
npm ci
npx --no-install playwright install chromium
npm run verify npm run verify
``` ```
Команда проверяет, что generated OpenAPI types соответствуют committed snapshot, Команда проверяет, что generated OpenAPI types соответствуют committed snapshot,
затем запускает strict TypeScript, ESLint и проверку его конфигурации, затем запускает strict TypeScript, Biome, один coverage-backed запуск Vitest,
один coverage-backed запуск Vitest,
Knip, dependency rules приложения и stories, сборки приложения и Storybook. Knip, dependency rules приложения и stories, сборки приложения и Storybook.
Отдельный CI workflow `Frontend security` на push, pull request и ежедневно
`npm run lint` запускает ESLint с `--max-warnings=0`; `npm run lint:fix` проверяет полную доступную историю Gitleaks 8.30.1 и выполняет
применяет автофиксы. [Конфигурация](eslint.config.mjs) подключает рекомендованные
наборы всех установленных плагинов: TypeScript, React, Hooks, Refresh, Query,
Unicorn, import-x, SonarJS, RegExp, Security, no-unsanitized, Perfectionist, Stylistic,
Vitest, Testing Library, jest-dom и Storybook. Правила React, тестов и stories
применяются к соответствующим файлам; generated/build artifacts исключены.
Stylistic отвечает за форматирование JS/TS/TSX, Perfectionist — за сортировку.
Сохраняются табы, двойные кавычки и точки с запятой. Biome удалён; Prettier
не используется. CSS, JSON и Markdown не форматируются через ESLint.
`lint-staged` исправляет JS/TS/TSX перед commit. `lint:eslint` сначала собирает
private packages, чтобы анализ типов использовал актуальные declarations.
`npm run test:lint` проверяет действующие presets и ошибочные примеры.
`no-unsanitized` проверяет опасные DOM HTML-присваивания и вызовы;
React `dangerouslySetInnerHTML` отдельно проверяется React-плагином.
Точечные исключения в исходниках объясняют контракты и ограничения статического
анализа; изменение nullable API fields, query keys или порядка race-операций
ради линтера недопустимо. Архитектуру проверяет dependency-cruiser, поведение —
Vitest/MSW и Chromium. Линтеры не заменяют semantic/security review.
`jsx-a11y` без поддержки ESLint 10 остаётся отложенным в
[workspace issue #18](https://git.personal.ksv741.ru/WealthLedger/main/issues/18).
Vitest включает JSDOM-тесты и проверку геометрии секций позиций в headless
Chromium при ширинах 375 и 1280 px. Browser project не использует JSDOM setup
и его заглушки layout API. Только браузерная проверка:
`npm run test:run -- --project='browser-*'`. После обновления Playwright повторите
установку Chromium; CI устанавливает его с `--with-deps` для Linux.
Единый `.gitea/workflows/verify.yml` запускает `commits → security → verify`
последовательно на PR; на push в `develop`/`main` — `security → verify`.
Рабочие ветки проверяются после открытия PR, без дублирующего push-запуска.
Изменение заголовка PR запускает `.gitea/workflows/pr-title.yml` (то же имя workflow,
только job `commits`): статусы `security` и `verify` на HEAD не перезаписываются.
Новый запуск отменяет устаревший для того же PR/ветки и типа события
(требуется Gitea 1.26+). Ежедневно в 05:17 UTC выполняется только `security`.
Job `security` проверяет полную доступную историю Gitleaks 8.30.1 и выполняет
`npm audit --include=dev --audit-level=low`. Любой advisory блокирует security job; `npm audit --include=dev --audit-level=low`. Любой advisory блокирует security job;
исключений frontend нет. Локально можно выполнить те же команды: исключений frontend нет. Локально можно выполнить те же команды:
`gitleaks git --log-opts=--all --redact=100 --no-banner` и `gitleaks git --log-opts=--all --redact=100 --no-banner` и
`npm audit --include=dev --audit-level=low`. Audit требует доступа к npm registry; `npm audit --include=dev --audit-level=low`. Audit требует доступа к npm registry;
проверка секретов выполняется локально и не выводит их значения. проверка секретов выполняется локально и не выводит их значения.
Имена jobs `commits`, `security`, `verify` сохранены, но Gitea включает имя
workflow в статус. При внедрении администратор должен заменить required checks
для защищённых веток на `Frontend verify / commits (pull_request)`,
`Frontend verify / security (pull_request)` и
`Frontend verify / verify (pull_request)`, удалив старые имена и push-статусы,
если они требовались. Все три PR-проверки должны оставаться обязательными.
Публикация образов по тегам остаётся в `image.yml`.
Последовательность jobs ограничивает один запуск; для ограничения нагрузки всего сервера настройте
`runner.capacity: 1` у общего runner и учтите остальные зарегистрированные
runners. Конфигурация runner управляется вне этого репозитория.
TypeScript остаётся на 5.9.3: `openapi-typescript@7.13.0` требует peer `^5.x`. TypeScript остаётся на 5.9.3: `openapi-typescript@7.13.0` требует peer `^5.x`.
Обновление до TypeScript 7 из Renovate dashboard откладывается до совместимого Обновление до TypeScript 7 из Renovate dashboard откладывается до совместимого
релиза генератора; `--force`/`--legacy-peer-deps` для обхода этого ограничения релиза генератора; `--force`/`--legacy-peer-deps` для обхода этого ограничения
@@ -206,8 +155,7 @@ Checkout frontend содержит все входы сборки и прове
локальную копию и не обращаются к исходникам или CI сервера. Доставка новой локальную копию и не обращаются к исходникам или CI сервера. Доставка новой
схемы — явное обновление snapshot с review и `npm run openapi:generate`. схемы — явное обновление snapshot с review и `npm run openapi:generate`.
Runtime URL API задаётся окружением, а не расположением другого репозитория. Runtime URL API задаётся окружением, а не расположением другого репозитория.
Разработка начинается с Issue; lightweight или SDD выбирается по критериям Разработка идёт напрямую по задаче пользователя; OpenSpec/SDD — по явному запросу.
Engineering Workflow из AGENTS. OpenSpec — после явного решения Change Owner.
Knip исключает `conventional-changelog-conventionalcommits`: release-it загружает Knip исключает `conventional-changelog-conventionalcommits`: release-it загружает
preset по имени из `.release-it.json`. Версия preset закреплена напрямую. preset по имени из `.release-it.json`. Версия preset закреплена напрямую.
@@ -215,61 +163,36 @@ preset по имени из `.release-it.json`. Версия preset закреп
## Релизы ## Релизы
Репозиторий выпускается независимо. `develop` — интеграция, `main` — принятые Репозиторий выпускается независимо. `develop` — интеграция, `main` — принятые
релизы. Обычная работа: issue-linked ветка от актуального `develop`, PR в `develop`; релизы. Работа: `codex/<task>` от актуального `develop`, PR в `develop`.
общие правила — в Engineering Workflow по ссылке из AGENTS. Обычные PR допускают squash; release и обратная синхронизация сохраняют историю.
PR сливается squash или rebase-merge; merge-коммит не используется. `main` Commits и PR titles — Conventional Commits, включая `spec:`. Версии вручную не повышай.
обновляется только fast-forward из `develop`, поэтому история линейна:
```text 1. Получи `main`, `develop` и теги: `git fetch origin --tags`. Убедись, что
<type>/<issue>-<slug> --squash--> develop --ff-only--> main --> tag vX.Y.Z `develop` содержит последний релиз. Создай `release/<name>` от проверенного
``` `develop`; `nvm use --silent`, `npm ci`. Hotfix начинается от `main` в
`release/hotfix-<name>` и проходит тот же процесс.
Инвариант: `origin/main` всегда предок `origin/develop`. Проверка:
```bash
git fetch -q origin
git merge-base --is-ancestor origin/main origin/develop && echo ok || echo BROKEN
```
Commits и PR titles — Conventional Commits со ссылкой на Issue (кроме release),
включая `spec:`. Версии вручную не повышай.
1. Получи `main`, `develop` и теги: `git fetch origin --tags`. Проверь
инвариант. Создай `release/X.Y.Z` от проверенного `develop`;
`nvm use --silent`, `npm ci`.
2. На чистой ветке выполни `npm run release:preview`, просмотри версию/changelog, 2. На чистой ветке выполни `npm run release:preview`, просмотри версию/changelog,
затем `npm run release:prepare`. Команда создаёт `chore(release): X.Y.Z`, затем `npm run release:prepare`. Команда создаёт `chore(release): X.Y.Z`,
обновляет manifest/lockfile/CHANGELOG; тег и push не выполняет. Не обходи обновляет manifest/lockfile/CHANGELOG; тег и push не выполняет. Не обходи
guards ветки, чистого дерева, отсутствующих тегов или уже подготовленной версии. guards ветки, чистого дерева, отсутствующих тегов или уже подготовленной версии.
3. Проверь diff и штатные gates этого репозитория. Отправь только release-ветку, 3. Проверь diff и штатные gates этого репозитория. Отправь только release-ветку,
открой PR в **`develop`** с заголовком `chore(release): X.Y.Z`. После зелёных открой PR в `main` с заголовком `chore(release): X.Y.Z`. После зелёных
`verify`, `security`, `commits` выполни fast-forward-only merge. Если `verify`, `security`, `commits` выполни fast-forward-only merge. Если `main`
`develop` изменился, обнови release-ветку и повтори проверки кандидата. изменился, согласуй историю и повтори проверки кандидата.
4. Открой PR `develop → main` с тем же заголовком и слей fast-forward-only. 4. Получи удалённый `main`, сравни его HEAD с проверенным release SHA и версией
5. Получи удалённый `main`, сравни его HEAD с проверенным release SHA и версией
manifest/lockfile. Создай аннотированный `vX.Y.Z` именно на этом SHA: manifest/lockfile. Создай аннотированный `vX.Y.Z` именно на этом SHA:
`git tag -a vX.Y.Z <release-sha> -m "Release X.Y.Z"`, затем отправь только `git tag -a vX.Y.Z <release-sha> -m "Release X.Y.Z"`, затем отправь только
`git push origin refs/tags/vX.Y.Z`. Не запускай release-it повторно на `main`. `git push origin refs/tags/vX.Y.Z`. Не запускай release-it повторно на `main`.
5. Верни release-коммит через PR `main → develop` без squash/rebase; проверь,
Обратная синхронизация не нужна: release-коммит попал в `develop` на шаге 3. что `develop` содержит тег. Опубликованные теги не перемещай. При сбое после
Опубликованные теги не перемещай. При сбое после merge создай отсутствующий тег merge создай отсутствующий тег без нового bump; до merge исправь и проверь
без нового bump; до merge исправь и проверь существующий кандидат без существующий кандидат без повторного повышения версии.
повторного повышения версии.
Freeze релиза не поддерживается: пока открыт релизный PR, `develop` не двигают.
Срочный fix — обычная ветка `fix/*` от `develop` с последующим релизом;
пути от `main` нет, он сделал бы `main` не-предком `develop`. Исключение
требует явного решения: `release/hotfix-<name>` от `main`, PR в `main`, тег,
затем merge `main → develop` для восстановления инварианта.
`feat:` повышает minor, `fix:`/`perf:` и прочие разрешённые типы — patch; `feat:` повышает minor, `fix:`/`perf:` и прочие разрешённые типы — patch;
`!`/`BREAKING CHANGE:` — major, включая версии до 1.0. `chore(release):` `!`/`BREAKING CHANGE:` — major, включая версии до 1.0. `chore(release):`
исключён из рекомендации и changelog. Конфигурация — `.release-it.json`. исключён из рекомендации и changelog. Конфигурация — `.release-it.json`.
В Gitea для `main`/`develop` должны быть запрещены прямой/force push и обход В Gitea для `main`/`develop` должны быть запрещены прямой/force push и обход
администратором, включена блокировка устаревшей ветки, обязательны `verify`, администратором, обязательны `verify`, `security`, `commits`; теги `v*` защищены.
`security`, `commits`; теги `v*` защищены. Линейность обеспечивают merge styles
репозитория: merge-коммиты и rebase `--no-ff` выключены, доступны squash,
rebase-merge и fast-forward-only; стиль по умолчанию — squash.
Workflow сам не включает эти настройки. Отдельное review approval для одного Workflow сам не включает эти настройки. Отдельное review approval для одного
владельца не требуется. Npm publishing, deploy и Gitea Releases не включены. владельца не требуется. Npm publishing, deploy и Gitea Releases не включены.
@@ -291,14 +214,14 @@ Library build использует отдельный Vite config без app plu
Начальная версия manifest взята из приложения; отдельный release-процесс Начальная версия manifest взята из приложения; отдельный release-процесс
и публикация не настроены. и публикация не настроены.
Private `@wealthledger/icons` содержит все SVG приложения: действия, Private `@wealthledger/icons` экспортирует `AddIcon`, `EditIcon`, `DeleteIcon`
навигацию, contactless/wallet и геометрию `OperationIcon`. Пакет поставляет и type `IconProps`. Единственный source рисунка — TSX в `packages/icons/src/svg`;
ESM, declarations и `@wealthledger/icons/styles.css`; приложение и Storybook поставка содержит ESM/declarations, без raw SVG, CSS, Mantine и motion.
подключают CSS явно. В `src` нет raw SVG и компонентов только для рисования React — peer/external. Размер `size` по умолчанию 24, `strokeWidth` — 1.5;
иконок. Category/kind mapping остаётся в model `entities/bank-card`. цвет наследуется через `currentColor`, SVG props можно передать явно.
В action используйте декоративный `<AddIcon size={20} strokeWidth={2} />`,
Статичные иконки принимают `IconProps`: `size` по умолчанию 24, доступное имя задавайте кнопке. Standalone пример:
`strokeWidth` 1.5, цвет через `currentColor`. В кнопке иконка декоративная, `<EditIcon aria-hidden={false} role="img" aria-label="Редактирование" />`.
имя принадлежит действию. Для самостоятельного изображения передайте Поддерживается также стандартный `aria-labelledby`; подпись выбирает caller.
`aria-hidden={false}`, `role="img"` и `aria-label` либо `aria-labelledby`. Card/Cards используют пакетные Add/Edit/Delete с прежними размерами/strokeWidth.
Анимации сохраняют hover/focus lifecycle и `prefers-reduced-motion`. Restore/category SVG и предметный BankOperationIcon остаются в приложении.
-70
View File
@@ -1,70 +0,0 @@
# Security-плагины ESLint
Проверено 28 сентября 2026 года для frontend: ESLint 10.11.0,
TypeScript и React 19. Рекомендация — добавить только
`eslint-plugin-no-unsanitized@4.1.5` с полным `configs.recommended`.
Решение реализовано: пакет добавлен в devDependencies, полный recommended
подключён в ESLint. Ниже сохранены результаты исследования до установки.
## Все пакеты из раздела Security
Источник списка: [awesome-eslint / Security](https://github.com/dustinspecker/awesome-eslint#security).
Версии и даты ниже — опубликованные `latest` из npm на момент проверки.
Широкий peer range сам по себе не доказывает работоспособность.
| Пакет | Версия; публикация | Заявленный peer ESLint | Решение для frontend |
| --- | --- | --- | --- |
| [no-unsanitized](https://github.com/mozilla/eslint-plugin-no-unsanitized) | [4.1.5](https://registry.npmjs.org/eslint-plugin-no-unsanitized/4.1.5); 2026-02-19 | `^9 \|\| ^10` | Добавить: проверяет опасные присваивания и вызовы native DOM HTML API. Flat recommended включает `property` и `method` как errors. |
| [no-secrets](https://github.com/nickdeis/eslint-plugin-no-secrets) | [2.3.3](https://registry.npmjs.org/eslint-plugin-no-secrets/2.3.3); 2026-03-01 | `>=5` | Не добавлять: поиск секретов по entropy/patterns существенно пересекается с существующими Gitleaks и SonarJS. Flat config поддерживается; отсутствие рекомендации связано с дублированием. |
| [pii](https://github.com/shiva-hack/eslint-plugin-pii) | [1.0.2](https://registry.npmjs.org/eslint-plugin-pii/1.0.2); 2021-04-13 | `>=4.19.1` | Не добавлять: правило падает на ESLint 10. Проверяет email/даты рождения/IP/телефоны в литералах и комментариях, а не потоки персональных данных; потребует исключений для тестовых данных. |
| [pg](https://github.com/interlace-collie/eslint/tree/main/packages/eslint-plugin-pg) | [1.4.14](https://registry.npmjs.org/eslint-plugin-pg/1.4.14); 2026-08-06 | `^8.0.0 \|\| ^9.0.0 \|\| ^10.0.0` | Не добавлять: предназначен для Node.js с `pg`, которого во frontend нет. Пакет deprecated и переименован в `eslint-plugin-postgresql-security`; старое имя больше не обновляется. |
| [security](https://github.com/eslint-community/eslint-plugin-security) | [4.1.0](https://registry.npmjs.org/eslint-plugin-security/4.1.0); 2026-09-27 | Не указан | Уже установлен с recommended. Ориентирован главным образом на Node.js; не заменяет проверки DOM. |
| [xss](https://github.com/Rantanen/eslint-plugin-xss) | [0.1.12](https://registry.npmjs.org/eslint-plugin-xss/0.1.12); 2022-06-27 | Не указан | Не добавлять: правило падает на ESLint 10. Проверяет HTML-конкатенации/соглашения об именах и присваивания `location.href`; это не специализированная проверка React/TSX. |
## Что добавляет no-unsanitized
[Правило property](https://github.com/mozilla/eslint-plugin-no-unsanitized/blob/master/docs/rules/property.md)
проверяет присваивания `innerHTML`/`outerHTML`;
[method](https://github.com/mozilla/eslint-plugin-no-unsanitized/blob/master/docs/rules/method.md)
проверяет, в частности, `insertAdjacentHTML`, `document.write` и `document.writeln`.
В [исходнике method](https://github.com/mozilla/eslint-plugin-no-unsanitized/blob/master/lib/rules/method.js)
также есть проверки `createContextualFragment`, `setHTMLUnsafe` и dynamic import.
Следовательно, перед включением нужно прогнать весь lint, а не только HTML-примеры.
До подключения no-unsanitized [eslint.config.mjs](eslint.config.mjs) уже включал
React-проверку `dom-no-dangerously-set-innerhtml` и security-правила SonarJS.
Предварительная проверка через локальный `ESLint.lintText` показала пробел:
функция с `element.innerHTML = html` не получала security-диагностику,
тогда как JSX `dangerouslySetInnerHTML` получал
диагностику React. Это различие в покрытии, не обнаруженная уязвимость приложения:
таких production DOM-присваиваний при поиске не найдено.
Плагин понимает TS-синтаксис через TypeScript parser, включая `as`-выражения,
но не использует TypeScript type checker. Он не доказывает безопасность всех
потоков данных. У версии 4.1.5 нет runtime dependencies; Prettier указан только
в devDependencies самого плагина и при его обычной установке потребителем
не устанавливается.
## Проверка совместимости без установки
Опубликованные npm tarballs прочитаны в `/tmp`; использованы локальные
ESLint 10.11.0 и `@typescript-eslint/parser`, без lifecycle-скриптов пакетов.
Через `Linter.verify` с recommended no-unsanitized проверены:
- `innerHTML`/`outerHTML` с параметром `html: string` — обе ошибки найдены;
- `insertAdjacentHTML`/`document.write`/`document.writeln` — все три ошибки найдены;
- `innerHTML = html as string` — ошибка найдена;
- безопасный `textContent` и обычная TSX-интерполяция — без ошибок.
Отдельный запуск реальных правил `pii/no-email` и
`xss/no-location-href-assign` завершился
`TypeError: context.getSourceCode is not a function` на ESLint 10.
Это подтверждает несовместимость проверенных версий, несмотря на широкий
или отсутствующий peer range. Обходы через legacy adapters не предлагаются.
Поиск секретов уже выполняют [security workflow](.gitea/workflows/security.yml)
и pre-commit Gitleaks; SonarJS дополнительно проверяет hardcoded secrets.
Добавление ещё одного secret-scanner в ESLint сейчас не оправдано.
Активацию обоих правил и примеры опасных DOM-вставок проверяет
`npm run test:lint`; интеграцию зависимости — полный `npm run verify`.
+80
View File
@@ -0,0 +1,80 @@
{
"$schema": "./node_modules/@biomejs/biome/configuration_schema.json",
"vcs": {
"useIgnoreFile": true
},
"files": {
"includes": [
"**",
"!dist",
"!packages/*/dist",
"!storybook-static",
"!src/shared/api/generated",
"!src/shared/api/spec",
"!coverage",
"!node_modules"
]
},
"linter": {
"rules": {
"preset": "recommended",
"nursery": {
"useReactFunctionComponentDefinition": {
"level": "error",
"options": {
"namedComponents": "arrowFunction"
}
}
},
"style": {
"noDefaultExport": "error",
"useExportsLast": "error",
"noMagicNumbers": "error"
}
}
},
"overrides": [
{
"includes": [
"vite.config.ts",
"vitest.config.ts",
".storybook/main.ts",
".storybook/preview.tsx",
"packages/*/vite.config.ts"
],
"linter": {
"rules": {
"style": {
"noDefaultExport": "off"
}
}
}
},
{
"includes": ["**/*.stories.tsx"],
"linter": {
"rules": {
"style": {
"noDefaultExport": "off",
"useExportsLast": "off"
}
}
}
},
{
"includes": [
"**/*.test.ts",
"**/*.test.tsx",
"**/*.stories.tsx",
"src/test/**"
],
"linter": {
"rules": {
"style": {
"noMagicNumbers": "off"
}
}
}
}
]
}
-6
View File
@@ -1,12 +1,6 @@
module.exports = { module.exports = {
defaultIgnores: false,
extends: ["@commitlint/config-conventional"], extends: ["@commitlint/config-conventional"],
ignores: [
message => /^chore\(release\): \d+\.\d+\.\d+$/.test(message.trim()),
message => /^Merge pull request ['"].+['"] \(#\d+\) from .+ into .+$/.test(message.trim()),
],
rules: { rules: {
"references-empty": [2, "never"],
"type-enum": [ "type-enum": [
2, 2,
"always", "always",
+15 -40
View File
@@ -1,88 +1,63 @@
module.exports = { module.exports = {
forbidden: [ forbidden: [
{ {
from: {
path: "^(src|packages/[^/]+/src)/",
pathNot: "[.](test|stories|fixtures)[.]|^src/test/",
},
name: "production-must-not-depend-on-test-code",
severity: "error",
to: { path: "[.](test|stories|fixtures)[.]|^src/test/" },
},
{
from: {},
name: "no-unresolved",
severity: "error",
to: { couldNotResolve: true },
},
{
from: { path: "^packages/" },
name: "packages-must-not-depend-on-app", name: "packages-must-not-depend-on-app",
severity: "error", severity: "error",
from: { path: "^packages/" },
to: { path: "^src/" }, to: { path: "^src/" },
}, },
{ {
from: {},
name: "no-circular", name: "no-circular",
severity: "error", severity: "error",
from: {},
to: { circular: true }, to: { circular: true },
}, },
{ {
from: {
path: "^src/(app|pages|widgets|features|entities|shared)/([^/]+)/",
},
name: "slices-must-use-public-api", name: "slices-must-use-public-api",
severity: "error", severity: "error",
from: {
path: "^src/(app|pages|features|entities|shared)/([^/]+)/",
},
to: { to: {
path: String.raw`^src/(app|pages|widgets|features|entities|shared)/([^/]+)/(?!index\.ts$)`, path: "^src/(app|pages|features|entities|shared)/([^/]+)/(?!index\\.ts$)",
pathNot: "^src/$1/$2/", pathNot: "^src/$1/$2/",
}, },
}, },
{ {
from: { path: "^src/(pages|widgets|features|entities)/([^/]+)/" },
name: "no-same-layer-cross-slice-imports", name: "no-same-layer-cross-slice-imports",
severity: "error", severity: "error",
from: { path: "^src/(pages|features|entities)/([^/]+)/" },
to: { path: "^src/$1/", pathNot: "^src/$1/$2/" }, to: { path: "^src/$1/", pathNot: "^src/$1/$2/" },
}, },
{ {
from: { path: "^src/shared/" },
name: "shared-must-not-depend-on-layers", name: "shared-must-not-depend-on-layers",
severity: "error", severity: "error",
to: { path: "^src/(entities|features|widgets|pages|app)/" }, from: { path: "^src/shared/" },
to: { path: "^src/(entities|features|pages|app)/" },
}, },
{ {
from: { path: "^src/entities/" },
name: "entities-must-not-depend-on-higher-layers", name: "entities-must-not-depend-on-higher-layers",
severity: "error", severity: "error",
to: { path: "^src/(features|widgets|pages|app)/" }, from: { path: "^src/entities/" },
to: { path: "^src/(features|pages|app)/" },
}, },
{ {
from: { path: "^src/features/" },
name: "features-must-not-depend-on-higher-layers", name: "features-must-not-depend-on-higher-layers",
severity: "error", severity: "error",
to: { path: "^src/(widgets|pages|app)/" }, from: { path: "^src/features/" },
},
{
from: { path: "^src/widgets/" },
name: "widgets-must-not-depend-on-higher-layers",
severity: "error",
to: { path: "^src/(pages|app)/" }, to: { path: "^src/(pages|app)/" },
}, },
{ {
from: { path: "^src/pages/" },
name: "pages-must-not-depend-on-app", name: "pages-must-not-depend-on-app",
severity: "error", severity: "error",
from: { path: "^src/pages/" },
to: { path: "^src/app/" }, to: { path: "^src/app/" },
}, },
], ],
options: { options: {
doNotFollow: { path: "node_modules" },
enhancedResolveOptions: {
conditionNames: ["import", "require", "node", "default"],
exportsFields: ["exports"],
},
exclude: "^packages/[^/]+/dist/", exclude: "^packages/[^/]+/dist/",
tsConfig: { fileName: "tsconfig.dependency-cruiser.json" }, doNotFollow: { path: "node_modules" },
tsConfig: { fileName: "tsconfig.app.json" },
tsPreCompilationDeps: true, tsPreCompilationDeps: true,
}, },
}; };
-178
View File
@@ -1,178 +0,0 @@
const readability = {
create(context) {
const filename = context.filename;
function checkParameters(node) {
const last = node.params.at(-1);
if (
last?.type === "Identifier"
&& !last.optional
&& last.typeAnnotation?.typeAnnotation.type === "TSUnionType"
&& last.typeAnnotation.typeAnnotation.types.some(
type => type.type === "TSUndefinedKeyword",
)
)
context.report({ messageId: "optional", node: last });
}
return {
ArrowFunctionExpression: checkParameters,
CallExpression(node) {
if (
node.callee.type === "MemberExpression"
&& ["refine", "superRefine"].includes(node.callee.property.name)
&& !filename.endsWith(".schema.ts")
)
context.report({ messageId: "schema", node });
},
FunctionDeclaration: checkParameters,
FunctionExpression: checkParameters,
ImportDeclaration(node) {
if (
node.source.value === "zod"
&& node.importKind !== "type"
&& !filename.endsWith(".schema.ts")
)
context.report({ messageId: "schema", node });
},
JSXOpeningElement(node) {
if (
!filename.includes("/packages/icons/")
&& node.name.type === "JSXIdentifier"
&& /^(?:svg|path|circle|ellipse|rect|polygon|polyline|line|g|defs|use|symbol)$/.test(
node.name.name,
)
)
context.report({ messageId: "svg", node });
},
TSUnionType(node) {
const complex
= node.types.length > 2
|| node.types.some(type => type.type === "TSTypeLiteral");
if (
complex
&& node.types.some(
(type, index) =>
index > 0
&& type.loc.start.line === node.types[index - 1].loc.end.line,
)
)
context.report({ messageId: "union", node });
},
VariableDeclarator(node) {
if (
!/^[A-Z]/.test(node.id.name ?? "")
|| node.init?.type !== "ArrowFunctionExpression"
|| node.init.body.type !== "BlockStatement"
)
return;
for (const statement of node.init.body.body) {
if (statement.type === "FunctionDeclaration")
context.report({ messageId: "helper", node: statement });
else if (statement.type === "VariableDeclaration") {
for (const declaration of statement.declarations) {
if (
["ArrowFunctionExpression", "FunctionExpression"].includes(
declaration.init?.type,
)
)
context.report({ messageId: "helper", node: declaration });
}
}
}
},
};
},
meta: {
messages: {
helper:
"Move helpers outside components; actions belong to the ViewModel.",
optional:
"Use an optional trailing parameter instead of a union with undefined.",
schema: "Declare Zod schemas in a dedicated *.schema.ts file.",
svg: "Own SVG markup in packages/icons, and import its public component.",
union: "Put complex union members on separate lines.",
},
schema: [],
type: "suggestion",
},
};
function isComponent(node) {
const pending = [node];
while (pending.length > 0) {
const child = pending.pop();
if (!child) continue;
switch (child.type) {
case "JSXElement": {
return true;
}
case "JSXFragment": {
return true;
}
case "JSXExpressionContainer": {
pending.push(child.expression);
continue;
}
case "LogicalExpression": {
pending.push(child.right);
continue;
}
case "ConditionalExpression": {
pending.push(child.consequent, child.alternate);
continue;
}
}
}
return false;
}
const jsxSpacing = {
create(context) {
function check(node) {
let previous;
for (const child of node.children) {
if (child.type === "JSXText" && !child.value.trim()) continue;
if (isComponent(child)) {
if (previous) {
const gap = context.sourceCode.text.slice(
previous.range[1],
child.range[0],
);
if (!/\n\s*\n/.test(gap))
context.report({
fix: fixer => fixer.insertTextAfter(previous, "\n"),
messageId: "space",
node: child,
});
}
previous = child;
}
else previous = undefined;
}
}
return { JSXElement: check, JSXFragment: check };
},
meta: {
fixable: "whitespace",
messages: { space: "Separate sibling JSX components with a blank line." },
schema: [],
type: "layout",
},
};
export default { rules: { "jsx-spacing": jsxSpacing, readability } };
-323
View File
@@ -1,323 +0,0 @@
import react from "@eslint-react/eslint-plugin";
import js from "@eslint/js";
import stylistic from "@stylistic/eslint-plugin";
import query from "@tanstack/eslint-plugin-query";
import vitest from "@vitest/eslint-plugin";
import { createTypeScriptImportResolver } from "eslint-import-resolver-typescript";
import { importX } from "eslint-plugin-import-x";
import jestDom from "eslint-plugin-jest-dom";
import noUnsanitized from "eslint-plugin-no-unsanitized";
import perfectionist from "eslint-plugin-perfectionist";
import hooks from "eslint-plugin-react-hooks";
import refresh from "eslint-plugin-react-refresh";
import regexp from "eslint-plugin-regexp";
import security from "eslint-plugin-security";
import sonarjs from "eslint-plugin-sonarjs";
import storybook from "eslint-plugin-storybook";
import testingLibrary from "eslint-plugin-testing-library";
import unicorn from "eslint-plugin-unicorn";
import { defineConfig, globalIgnores } from "eslint/config";
import globals from "globals";
import tseslint from "typescript-eslint";
import frontend from "./eslint-rules.mjs";
export default defineConfig(
{
files: ["src/**/*.{ts,tsx}", "packages/*/src/**/*.{ts,tsx}"],
ignores: ["**/*.test.{ts,tsx}", "**/*.stories.tsx", "src/test/**"],
plugins: { frontend },
rules: { "frontend/jsx-spacing": "error", "frontend/readability": "error" },
},
globalIgnores([
"**/dist/**",
"dist-ssr/**",
"storybook-static/**",
"coverage/**",
".vitest/**",
".scannerwork/**",
"src/shared/api/generated/**",
"src/shared/api/spec/**",
]),
{
extends: [
js.configs.recommended,
importX.flatConfigs.recommended,
unicorn.configs.recommended,
sonarjs.configs.recommended,
regexp.configs.recommended,
security.configs.recommended,
noUnsanitized.configs.recommended,
perfectionist.configs["recommended-natural"],
stylistic.configs.customize({
indent: "tab",
jsx: true,
quotes: "double",
semi: true,
}),
],
files: ["**/*.{js,mjs,cjs,ts,tsx}"],
linterOptions: { reportUnusedDisableDirectives: "error" },
rules: {
// A comma disambiguates generic arrow functions from JSX in TSX.
"@stylistic/comma-dangle": [
"error",
{
arrays: "always-multiline",
enums: "always-multiline",
exports: "always-multiline",
functions: "always-multiline",
generics: "ignore",
imports: "always-multiline",
objects: "always-multiline",
tuples: "always-multiline",
},
],
// Keep text/interpolation together: splitting can change rendered whitespace.
"@stylistic/jsx-one-expression-per-line": ["error", { allow: "non-jsx" }],
"@stylistic/max-len": [
"error",
{
code: 80,
ignoreComments: true,
ignoreRegExpLiterals: true,
ignoreStrings: true,
ignoreTemplateLiterals: true,
ignoreTrailingComments: true,
ignoreUrls: true,
tabWidth: 2,
},
],
"@stylistic/multiline-ternary": ["error", "always-multiline"],
"@stylistic/no-tabs": ["error", { allowIndentationTabs: true }],
"@stylistic/operator-linebreak": ["error", "before"],
"@stylistic/padding-line-between-statements": [
"error",
{ blankLine: "always", next: "return", prev: "*" },
{
blankLine: "always",
next: "*",
prev: ["multiline-const", "multiline-let", "block-like", "type"],
},
{
blankLine: "always",
next: ["multiline-const", "multiline-let", "block-like", "type"],
prev: "*",
},
{ blankLine: "any", next: "cjs-import", prev: "cjs-import" },
],
"no-nested-ternary": "error",
"perfectionist/sort-classes": [
"error",
{
groups: ["static-method", "property", "method", "unknown"],
type: "natural",
},
],
// Preserve the repository's component, hook and config file naming conventions.
"unicorn/filename-case": [
"error",
{ cases: { camelCase: true, kebabCase: true, pascalCase: true } },
],
// React uses Props/Ref names, including the recommended React ref-name rule.
"unicorn/name-replacements": [
"error",
{ replacements: { props: false, ref: false } },
],
// API DTOs, React refs and Mantine use null as part of their contracts.
"unicorn/no-null": "off",
"unicorn/prefer-ternary": ["error", "only-single-line"],
},
settings: {
"import-x/resolver-next": [
createTypeScriptImportResolver({ project: "tsconfig.json" }),
],
},
},
{
files: ["**/*.{js,mjs,cjs}", "**/*.config.ts", ".storybook/main.ts"],
languageOptions: { globals: globals.node },
},
{
extends: [
tseslint.configs.recommendedTypeChecked,
importX.flatConfigs.typescript,
],
files: ["**/*.{ts,tsx}"],
languageOptions: {
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
rules: {
"@typescript-eslint/consistent-type-imports": "error",
// React ignores event handler return values; floating promises are still checked in bodies.
"@typescript-eslint/no-misused-promises": [
"error",
{ checksVoidReturn: { attributes: false } },
],
"@typescript-eslint/no-non-null-assertion": "error",
"@typescript-eslint/switch-exhaustiveness-check": "error",
},
},
{
extends: [
react.configs["recommended-type-checked"],
hooks.configs.flat.recommended,
query.configs["flat/recommended"],
],
files: [
"src/**/*.{ts,tsx}",
"packages/*/src/**/*.{ts,tsx}",
".storybook/preview.tsx",
],
languageOptions: { globals: globals.browser },
rules: {
"import-x/exports-last": "error",
"import-x/no-default-export": "error",
},
},
{
extends: [refresh.configs.vite],
files: ["src/**/*.tsx", "packages/*/src/**/*.tsx"],
ignores: ["**/*.test.tsx", "**/*.stories.tsx", "src/test/**"],
},
{
files: ["src/**/*.view.tsx"],
rules: {
"no-restricted-imports": ["error", {
paths: [
{
allowTypeImports: true,
message: "Query lifecycle belongs to the data VM.",
name: "@tanstack/react-query",
},
{
allowTypeImports: true,
message: "Transport belongs to shared/api and the data VM.",
name: "openapi-fetch",
},
{
allowTypeImports: true,
message: "Transport belongs to shared/api and the data VM.",
name: "openapi-react-query",
},
],
patterns: [
{
allowTypeImports: true,
group: ["react-router", "@/entities/*", "@/features/*"],
importNamePattern: "^use",
message: "Pass route, auth and entity data from the data VM.",
},
{
allowTypeImports: true,
group: ["@/shared/api", "@/shared/api/*"],
importNamePattern: "^use|^api(?:Query)?Client$",
message: "Fetching belongs to the data VM; error helpers are allowed.",
},
],
}],
},
},
{
files: ["src/**/*.{ts,tsx}", "packages/*/src/**/*.{ts,tsx}"],
ignores: ["**/*.test.{ts,tsx}", "**/*.stories.tsx", "src/test/**"],
rules: {
"@typescript-eslint/no-magic-numbers": [
"error",
{
ignore: [-1, 0, 1, "-1n", "0n", "1n"],
ignoreArrayIndexes: true,
ignoreDefaultValues: true,
ignoreNumericLiteralTypes: true,
ignoreTypeIndexes: true,
},
],
"@typescript-eslint/no-unnecessary-condition": ["error", { allowConstantLoopConditions: true }],
},
},
{
extends: [vitest.configs.recommended],
files: ["**/*.test.{ts,tsx}"],
rules: {
"vitest/no-focused-tests": "error",
"vitest/valid-expect": ["error", { maxArgs: 2 }],
},
},
{
extends: [
testingLibrary.configs["flat/react"],
jestDom.configs["flat/recommended"],
],
files: ["**/*.test.tsx"],
},
storybook.configs["flat/recommended"],
{
files: ["**/*.stories.tsx", ".storybook/preview.tsx"],
rules: {
"import-x/exports-last": "off",
"import-x/no-default-export": "off",
},
},
{
files: ["src/**/*.{ts,tsx}", "packages/*/src/**/*.{ts,tsx}"],
ignores: ["**/*.test.{ts,tsx}", "**/*.stories.tsx", "src/test/**"],
rules: {
"@typescript-eslint/no-unsafe-enum-comparison": "error",
"no-restricted-syntax": [
"error",
{
message:
"Pass a named handler from the ViewModel; prepare prop data before JSX.",
selector:
"JSXAttribute > JSXExpressionContainer :matches(ArrowFunctionExpression, FunctionExpression)",
},
{
message: "Name the derived prop value before JSX.",
selector:
"JSXAttribute > JSXExpressionContainer ConditionalExpression",
},
{
message:
"Use the owning enum member for machine codes and protocol values.",
selector:
"Literal[value=/^(?:(?:INSTRUMENT_ID_TYPE|INSTRUMENT_TYPE|OPERATION_STATE|OPERATION_TYPE|ACCOUNT_STATUS|ACCOUNT_TYPE|CANDLE_INTERVAL|COUPON_TYPE|BANK|INVALID)_[A-Z0-9]+(?:_[A-Z0-9]+)*|[A-Z][A-Z0-9_]*_(?:ERROR|UNAVAILABLE|NOT_FOUND|NOT_CONNECTED|CONFLICT|AMBIGUOUS|REGISTERED)|P[0-9]{4}|UNAUTHORIZED|FORBIDDEN|BAD_REQUEST|NOT_FOUND|UNSUPPORTED_MEDIA_TYPE)$/]:not(TSEnumMember > Literal)",
},
{
message:
"Use the owning enum member instead of a template machine code.",
selector:
"TemplateElement[value.cooked=/^(?:(?:INSTRUMENT_ID_TYPE|INSTRUMENT_TYPE|OPERATION_STATE|OPERATION_TYPE|ACCOUNT_STATUS|ACCOUNT_TYPE|CANDLE_INTERVAL|COUPON_TYPE|BANK|INVALID)_[A-Z0-9]+(?:_[A-Z0-9]+)*|[A-Z][A-Z0-9_]*_(?:ERROR|UNAVAILABLE|NOT_FOUND|NOT_CONNECTED|CONFLICT|AMBIGUOUS|REGISTERED)|P[0-9]{4}|UNAUTHORIZED|FORBIDDEN|BAD_REQUEST|NOT_FOUND|UNSUPPORTED_MEDIA_TYPE)$/]:not(TSEnumMember TemplateElement)",
},
{
message:
"Use an enum member instead of an inline code or protocol value.",
selector:
"Property:matches([key.name=/^(code|errorCode|idType|instrumentKind)$/], [key.value=/^(code|errorCode|idType|instrumentKind)$/]) > Literal.value[value=type(string)]",
},
{
message:
"Use an enum member instead of constructing a code or protocol value.",
selector:
"Property:matches([key.name=/^(code|errorCode|idType|instrumentKind)$/], [key.value=/^(code|errorCode|idType|instrumentKind)$/]) > TemplateLiteral.value",
},
{
message: "Use a computed enum member as the protocol mapping key.",
selector:
"Property[computed=false][key.name=/^(OPERATION_TYPE|ACCOUNT_TYPE)_/]",
},
{
message:
"Compare codes with their enum members, not string literals.",
selector:
"BinaryExpression:has(> :matches(MemberExpression[property.name=/^(code|errorCode)$/], Identifier[name=/^(code|errorCode)$/])) > Literal[value=type(string)]",
},
],
},
},
);
+2246 -3864
View File
File diff suppressed because it is too large Load Diff
+41 -78
View File
@@ -1,10 +1,10 @@
{ {
"name": "wealth-ledger-frontend", "name": "wealth-ledger-frontend",
"private": true, "private": true,
"version": "1.2.1", "version": "0.3.0",
"type": "module", "type": "module",
"engines": { "engines": {
"node": "24.22.1" "node": "24.21.0"
}, },
"scripts": { "scripts": {
"dev": "npm run build:packages && vite", "dev": "npm run build:packages && vite",
@@ -13,118 +13,81 @@
"preview:catalog": "vite preview --outDir storybook-static", "preview:catalog": "vite preview --outDir storybook-static",
"typecheck": "npm run build:packages && tsc -b --noEmit", "typecheck": "npm run build:packages && tsc -b --noEmit",
"build": "npm run build:packages && tsc -b && vite build", "build": "npm run build:packages && tsc -b && vite build",
"lint": "npm run lint:eslint", "lint": "biome check .",
"lint:eslint": "npm run build:packages && eslint . --max-warnings=0",
"test": "npm run build:packages && vitest", "test": "npm run build:packages && vitest",
"test:run": "npm run build:packages && vitest run", "test:run": "npm run build:packages && vitest run",
"test:coverage": "npm run build:packages && vitest run --coverage", "test:coverage": "npm run build:packages && vitest run --coverage",
"test:lint": "node --test scripts/test-eslint.mjs",
"openapi:generate": "openapi-typescript src/shared/api/spec/openapi.json -o src/shared/api/generated/schema.d.ts", "openapi:generate": "openapi-typescript src/shared/api/spec/openapi.json -o src/shared/api/generated/schema.d.ts",
"openapi:check": "openapi-typescript src/shared/api/spec/openapi.json -o src/shared/api/generated/schema.d.ts --check", "openapi:check": "node scripts/check-openapi.mjs",
"knip": "npm run build:packages && knip", "knip": "npm run build:packages && knip",
"deps": "npm run build:packages && depcruise src packages --config dependency-cruiser.cjs --output-type err-long", "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 && tsc -b --noEmit && eslint . --max-warnings=0 && npm run test:lint && vitest run --coverage && knip && depcruise src packages --config dependency-cruiser.cjs --output-type err-long && depcruise src packages .storybook/preview.tsx --config .storybook/dependency-cruiser.cjs --output-type err-long && tsc -b && vite build && storybook build --output-dir storybook-static --disable-telemetry", "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", "prepare": "husky",
"preview": "vite preview", "preview": "vite preview",
"deps:storybook": "npm run build:packages && depcruise src packages .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:preview": "release-it --dry-run --ci",
"release:prepare": "release-it --ci", "release:prepare": "release-it --ci",
"commitlint": "commitlint", "commitlint": "commitlint",
"build:packages": "npm run build --workspaces --if-present", "build:packages": "npm run build --workspaces --if-present"
"lint:fix": "npm run build:packages && eslint . --fix --max-warnings=0"
}, },
"lint-staged": { "lint-staged": {
"*.{js,mjs,cjs,ts,tsx}": "eslint --fix --max-warnings=0 --no-warn-ignored" "*.{css,html,json,md,ts,tsx}": "biome format --write --no-errors-on-unmatched"
}, },
"dependencies": { "dependencies": {
"@hookform/resolvers": "5.9.2", "@hookform/resolvers": "5.9.1",
"@mantine/charts": "9.7.3", "@mantine/charts": "9.6.1",
"@mantine/core": "9.7.3", "@mantine/core": "9.6.1",
"@mantine/dates": "9.7.3", "@mantine/dates": "9.6.1",
"@mantine/hooks": "9.7.3", "@mantine/hooks": "9.6.1",
"@mantine/notifications": "9.7.3", "@tanstack/react-query": "5.102.8",
"@tanstack/react-query": "5.104.5",
"@wealthledger/icons": "file:packages/icons", "@wealthledger/icons": "file:packages/icons",
"@wealthledger/tokens": "file:packages/tokens", "@wealthledger/tokens": "file:packages/tokens",
"@wealthledger/ui": "file:packages/ui", "@wealthledger/ui": "file:packages/ui",
"dayjs": "1.11.23", "dayjs": "1.11.23",
"i18next": "26.5.10", "i18next": "26.4.2",
"motion": "13.5.1", "motion": "13.2.0",
"openapi-fetch": "0.17.0", "openapi-fetch": "0.17.0",
"openapi-react-query": "0.5.4", "openapi-react-query": "0.5.4",
"react": "19.3.1", "react": "19.3.0",
"react-dom": "19.3.1", "react-dom": "19.3.0",
"react-hook-form": "7.91.0", "react-hook-form": "7.88.0",
"react-i18next": "17.1.8", "react-i18next": "17.0.14",
"react-is": "19.3.1", "react-is": "19.3.0",
"react-router": "8.5.0", "react-router": "8.3.1",
"recharts": "3.10.1", "recharts": "3.10.1",
"zod": "4.6.7" "zod": "4.6.4"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "8.0.9", "@babel/core": "8.0.5",
"@commitlint/cli": "21.2.4", "@biomejs/biome": "2.5.13",
"@commitlint/config-conventional": "21.2.4", "@commitlint/cli": "21.2.2",
"@eslint-react/eslint-plugin": "5.28.8", "@commitlint/config-conventional": "21.2.2",
"@eslint/js": "10.0.1", "@release-it/conventional-changelog": "12.0.0",
"@release-it/conventional-changelog": "12.0.2",
"@rolldown/plugin-babel": "0.2.4", "@rolldown/plugin-babel": "0.2.4",
"@storybook/addon-docs": "10.6.3", "@storybook/addon-docs": "10.6.0",
"@storybook/react-vite": "10.6.3", "@storybook/react-vite": "10.6.0",
"@stylistic/eslint-plugin": "5.10.0",
"@tanstack/eslint-plugin-query": "5.104.5",
"@testing-library/jest-dom": "7.0.1", "@testing-library/jest-dom": "7.0.1",
"@testing-library/react": "16.3.3", "@testing-library/react": "16.3.3",
"@types/node": "24.19.2", "@types/node": "24.13.4",
"@types/react": "19.3.0", "@types/react": "19.3.0",
"@types/react-dom": "19.3.0", "@types/react-dom": "19.3.0",
"@vitejs/plugin-react": "6.1.2", "@vitejs/plugin-react": "6.1.1",
"@vitest/browser": "5.0.5", "@vitest/coverage-v8": "5.0.0",
"@vitest/browser-playwright": "5.0.5",
"@vitest/coverage-v8": "5.0.5",
"@vitest/eslint-plugin": "1.6.27",
"@vitest/ui": "5.0.5",
"babel-plugin-react-compiler": "1.0.0", "babel-plugin-react-compiler": "1.0.0",
"conventional-changelog-conventionalcommits": "10.4.1", "conventional-changelog-conventionalcommits": "10.4.0",
"dependency-cruiser": "18.7.0", "dependency-cruiser": "18.2.0",
"eslint": "10.13.0",
"eslint-import-resolver-typescript": "4.4.5",
"eslint-plugin-import-x": "4.17.1",
"eslint-plugin-jest-dom": "5.10.1",
"eslint-plugin-no-unsanitized": "4.1.5",
"eslint-plugin-perfectionist": "5.13.0",
"eslint-plugin-react-hooks": "7.1.1",
"eslint-plugin-react-refresh": "0.5.7",
"eslint-plugin-regexp": "3.3.1",
"eslint-plugin-security": "4.2.0",
"eslint-plugin-sonarjs": "4.2.2",
"eslint-plugin-storybook": "10.6.3",
"eslint-plugin-testing-library": "7.16.2",
"eslint-plugin-unicorn": "76.0.0",
"globals": "17.13.0",
"husky": "9.1.7", "husky": "9.1.7",
"jsdom": "30.1.3", "jsdom": "30.0.1",
"knip": "6.43.0", "knip": "6.35.1",
"lint-staged": "17.6.0", "lint-staged": "17.5.1",
"msw": "2.15.0", "msw": "2.15.0",
"openapi-typescript": "7.13.0", "openapi-typescript": "7.13.0",
"playwright": "1.64.1", "release-it": "21.0.2",
"release-it": "21.1.1", "storybook": "10.6.0",
"storybook": "10.6.3",
"typescript": "5.9.3", "typescript": "5.9.3",
"typescript-eslint": "8.72.0", "vite": "8.3.0",
"vite": "8.3.5",
"vite-plugin-pwa": "1.3.0", "vite-plugin-pwa": "1.3.0",
"vitest": "5.0.5" "vitest": "5.0.0"
},
"overrides": {
"release-it": {
"basic-ftp": "6.2.3",
"undici": "8.12.3"
},
"@vitest/browser": "5.0.5",
"@vitest/ui": "5.0.5",
"@vitest/mocker": "5.0.5"
}, },
"allowScripts": { "allowScripts": {
"esbuild@0.28.2": true, "esbuild@0.28.2": true,
+6 -9
View File
@@ -10,24 +10,21 @@
".": { ".": {
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
"import": "./dist/index.js" "import": "./dist/index.js"
}, }
"./styles.css": "./dist/index.css"
}, },
"sideEffects": [ "sideEffects": false,
"**/*.css"
],
"scripts": { "scripts": {
"build": "vite build && tsc -p tsconfig.json" "build": "vite build && tsc -p tsconfig.json"
}, },
"peerDependencies": { "peerDependencies": {
"react": "19.3.1" "react": "19.3.0"
}, },
"devDependencies": { "devDependencies": {
"@storybook/react-vite": "10.6.3", "@storybook/react-vite": "10.6.0",
"@testing-library/react": "16.3.3", "@testing-library/react": "16.3.3",
"@types/react": "19.3.0", "@types/react": "19.3.0",
"typescript": "5.9.3", "typescript": "5.9.3",
"vite": "8.3.5", "vite": "8.3.0",
"vitest": "5.0.5" "vitest": "5.0.0"
} }
} }
+10 -16
View File
@@ -1,9 +1,10 @@
import type { Meta, StoryObj } from "@storybook/react-vite"; import type { Meta, StoryObj } from "@storybook/react-vite";
import { AddIcon, DeleteIcon, EditIcon } from "./index"; import { AddIcon, DeleteIcon, EditIcon } from "./index";
const meta = { const meta = {
title: "Design System/Icons",
component: AddIcon, component: AddIcon,
tags: ["autodocs"],
parameters: { parameters: {
docs: { docs: {
description: { description: {
@@ -12,26 +13,21 @@ const meta = {
}, },
}, },
}, },
tags: ["autodocs"],
title: "Design System/Icons",
} satisfies Meta<typeof AddIcon>; } satisfies Meta<typeof AddIcon>;
export default meta; export default meta;
type Story = StoryObj<typeof meta>; type Story = StoryObj<typeof meta>;
export const Decorative: Story = { export const Decorative: Story = {
render: () => ( render: () => (
<div style={{ color: "#2563eb", display: "flex", gap: 16 }}> <div style={{ display: "flex", gap: 16, color: "#2563eb" }}>
<button aria-label="Добавить пример" type="button"> <button type="button" aria-label="Добавить пример">
<AddIcon size={20} strokeWidth={2} /> <AddIcon size={20} strokeWidth={2} />
</button> </button>
<button type="button" aria-label="Изменить пример">
<button aria-label="Изменить пример" type="button">
<EditIcon size={18} /> <EditIcon size={18} />
</button> </button>
<button type="button" aria-label="Удалить пример">
<button aria-label="Удалить пример" type="button">
<DeleteIcon size={18} /> <DeleteIcon size={18} />
</button> </button>
</div> </div>
@@ -40,12 +36,10 @@ export const Decorative: Story = {
export const Meaningful: Story = { export const Meaningful: Story = {
render: () => ( render: () => (
<div style={{ color: "#2563eb", display: "flex", gap: 16 }}> <div style={{ display: "flex", gap: 16, color: "#2563eb" }}>
<AddIcon aria-hidden={false} aria-label="Добавление" role="img" /> <AddIcon aria-hidden={false} role="img" aria-label="Добавление" />
<EditIcon aria-hidden={false} role="img" aria-label="Редактирование" />
<EditIcon aria-hidden={false} aria-label="Редактирование" role="img" /> <DeleteIcon aria-hidden={false} role="img" aria-label="Удаление" />
<DeleteIcon aria-hidden={false} aria-label="Удаление" role="img" />
</div> </div>
), ),
}; };
+3 -18
View File
@@ -1,28 +1,25 @@
import { render, screen } from "@testing-library/react"; import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { AddIcon, DeleteIcon, EditIcon } from "./index";
import { AddIcon, DeleteIcon, EditIcon, OperationIcon } from "./index";
describe("icon accessibility", () => { describe("icon accessibility", () => {
it.each([AddIcon, EditIcon, DeleteIcon])( it.each([AddIcon, EditIcon, DeleteIcon])(
"keeps decoration hidden and exposes explicitly named images: %s", "keeps decoration hidden and exposes explicitly named images: %s",
(Icon) => { (Icon) => {
const { rerender } = render( const { rerender } = render(
<button aria-label="Действие" type="button"> <button type="button" aria-label="Действие">
<Icon /> <Icon />
</button>, </button>,
); );
expect(screen.queryByRole("img")).not.toBeInTheDocument(); expect(screen.queryByRole("img")).not.toBeInTheDocument();
expect( expect(
// eslint-disable-next-line testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish.
screen.getByRole("button", { name: "Действие" }).querySelector("svg"), screen.getByRole("button", { name: "Действие" }).querySelector("svg"),
).toHaveAttribute("focusable", "false"); ).toHaveAttribute("focusable", "false");
rerender( rerender(
<Icon <Icon
aria-hidden={false} aria-hidden={false}
aria-label="Пример"
role="img" role="img"
aria-label="Пример"
size={18} size={18}
strokeWidth={2} strokeWidth={2}
/>, />,
@@ -34,16 +31,4 @@ describe("icon accessibility", () => {
expect(icon).toHaveAttribute("stroke", "currentColor"); expect(icon).toHaveAttribute("stroke", "currentColor");
}, },
); );
it("keeps operation icons decorative", () => {
render(
<button aria-label="Операция" type="button">
<OperationIcon color="orange" variant="home" />
</button>,
);
expect(screen.queryByRole("img")).not.toBeInTheDocument();
// eslint-disable-next-line testing-library/no-node-access -- Decorative SVG has no accessible role.
expect(screen.getByRole("button").querySelector("svg")).toHaveAttribute(
"aria-hidden", "true",
);
});
}); });
@@ -1,72 +0,0 @@
import type { FC } from "react";
import { BasketIcon } from "./svg/BasketIcon";
import { CarIcon } from "./svg/CarIcon";
import { CashWithdrawalIcon } from "./svg/CashWithdrawalIcon";
import { CoffeeIcon } from "./svg/CoffeeIcon";
import { DepositIcon } from "./svg/DepositIcon";
import { DiceIcon } from "./svg/DiceIcon";
import { DownloadIcon } from "./svg/DownloadIcon";
import { EducationIcon } from "./svg/EducationIcon";
import { GridIcon } from "./svg/GridIcon";
import { GrowthIcon } from "./svg/GrowthIcon";
import { HomeIcon } from "./svg/HomeIcon";
import { MedicalIcon } from "./svg/MedicalIcon";
import { MusicIcon } from "./svg/MusicIcon";
import { RefundIcon } from "./svg/RefundIcon";
import { RepaymentIcon } from "./svg/RepaymentIcon";
import { ServerIcon } from "./svg/ServerIcon";
import { ShieldIcon } from "./svg/ShieldIcon";
import { ShoppingIcon } from "./svg/ShoppingIcon";
import { SmokingIcon } from "./svg/SmokingIcon";
import { SubscriptionsIcon } from "./svg/SubscriptionsIcon";
import { TaxiIcon } from "./svg/TaxiIcon";
import { TopupIcon } from "./svg/TopupIcon";
import { TransferIcon } from "./svg/TransferIcon";
import { TravelIcon } from "./svg/TravelIcon";
import { WifiIcon } from "./svg/WifiIcon";
import { WineIcon } from "./svg/WineIcon";
const icons = {
basket: BasketIcon,
car: CarIcon,
cashWithdrawal: CashWithdrawalIcon,
coffee: CoffeeIcon,
deposit: DepositIcon,
dice: DiceIcon,
download: DownloadIcon,
education: EducationIcon,
grid: GridIcon,
growth: GrowthIcon,
home: HomeIcon,
medical: MedicalIcon,
music: MusicIcon,
refund: RefundIcon,
repayment: RepaymentIcon,
server: ServerIcon,
shield: ShieldIcon,
shopping: ShoppingIcon,
smoking: SmokingIcon,
subscriptions: SubscriptionsIcon,
taxi: TaxiIcon,
topup: TopupIcon,
transfer: TransferIcon,
travel: TravelIcon,
wifi: WifiIcon,
wine: WineIcon,
};
type OperationIconProps = {
color: string;
variant: keyof typeof icons;
};
const OperationIcon: FC<OperationIconProps> = (props) => {
const { color, variant } = props;
// eslint-disable-next-line security/detect-object-injection -- Variant is a closed key of the static icon registry.
const Icon = icons[variant];
return <Icon style={{ color: `var(--mantine-color-${color}-7)` }} />;
};
export { OperationIcon, type OperationIconProps };
@@ -1,40 +0,0 @@
import type { FC } from "react";
import type { IconProps } from "../../types";
import { bindIconAnimation } from "../../bind-icon-animation";
import classes from "../OperationIcon.module.css";
const BasketIcon: FC<IconProps> = (props) => {
const { size = 24, ...svgProps } = props;
return (
<svg
aria-hidden="true"
className={`${classes.icon} ${classes.basket}`}
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.6"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M3 9h18l-2 11H5L3 9Z" pathLength={1} />
<path d="M8 9l4-6 4 6" pathLength={1} />
<path d="M9 13v3" pathLength={1} />
<path d="M15 13v3" pathLength={1} />
<path d="M10 6a2 2 0 1 0 4 0a2 2 0 1 0-4 0" pathLength={1} />
</svg>
);
};
export { BasketIcon };
@@ -1,40 +0,0 @@
import type { FC } from "react";
import type { IconProps } from "../../types";
import { bindIconAnimation } from "../../bind-icon-animation";
import classes from "../OperationIcon.module.css";
const CarIcon: FC<IconProps> = (props) => {
const { size = 24, ...svgProps } = props;
return (
<svg
aria-hidden="true"
className={`${classes.icon} ${classes.car}`}
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.6"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M4 16H2v-4l3-1 3-4h6l4 4 4 1v4h-2" pathLength={1} />
<path d="M8 16h8" pathLength={1} />
<path d="M6 11h12m-6-4v4" pathLength={1} />
<path d="M4 16a2 2 0 1 0 4 0a2 2 0 1 0-4 0m2-2v4m-2-2h4" pathLength={1} />
<path d="M16 16a2 2 0 1 0 4 0a2 2 0 1 0-4 0m2-2v4m-2-2h4" pathLength={1} />
</svg>
);
};
export { CarIcon };
@@ -1,38 +0,0 @@
import type { FC } from "react";
import type { IconProps } from "../../types";
import { bindIconAnimation } from "../../bind-icon-animation";
import classes from "../OperationIcon.module.css";
const CashWithdrawalIcon: FC<IconProps> = (props) => {
const { size = 24, ...svgProps } = props;
return (
<svg
aria-hidden="true"
className={classes.icon}
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.6"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M4 10V3h16v7" pathLength={1} />
<path d="M7 7h10v14H7V7Z" pathLength={1} />
<path d="M7 11h10" pathLength={1} />
<path d="M10 16h4" pathLength={1} />
</svg>
);
};
export { CashWithdrawalIcon };
@@ -1,40 +0,0 @@
import type { FC } from "react";
import type { IconProps } from "../../types";
import { bindIconAnimation } from "../../bind-icon-animation";
import classes from "../OperationIcon.module.css";
const CoffeeIcon: FC<IconProps> = (props) => {
const { size = 24, ...svgProps } = props;
return (
<svg
aria-hidden="true"
className={`${classes.icon} ${classes.coffee}`}
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.6"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M4 8h12v7a4 4 0 0 1-4 4H8a4 4 0 0 1-4-4V8Z" pathLength={1} />
<path d="M16 9h2a3 3 0 0 1 0 6h-2" pathLength={1} />
<path d="M8 6c-2-1 2-2 0-4" pathLength={1} />
<path d="M12 6c-2-1 2-2 0-4" pathLength={1} />
<path d="M3 22h15" pathLength={1} />
</svg>
);
};
export { CoffeeIcon };
@@ -1,38 +0,0 @@
import type { FC } from "react";
import type { IconProps } from "../../types";
import { bindIconAnimation } from "../../bind-icon-animation";
import classes from "../OperationIcon.module.css";
const DepositIcon: FC<IconProps> = (props) => {
const { size = 24, ...svgProps } = props;
return (
<svg
aria-hidden="true"
className={`${classes.icon} ${classes.deposit}`}
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.6"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M6 9a6 6 0 1 0 12 0a6 6 0 1 0-12 0" pathLength={1} />
<path d="M10.5 12V6h2a1.5 1.5 0 0 1 0 3h-3m0 1.5h3" pathLength={1} />
<path d="M4 16c0 4 16 4 16 0" pathLength={1} />
<path d="M4 19c0 4 16 4 16 0" pathLength={1} />
</svg>
);
};
export { DepositIcon };
@@ -1,42 +0,0 @@
import type { FC } from "react";
import type { IconProps } from "../../types";
import { bindIconAnimation } from "../../bind-icon-animation";
import classes from "../OperationIcon.module.css";
const DiceIcon: FC<IconProps> = (props) => {
const { size = 24, ...svgProps } = props;
return (
<svg
aria-hidden="true"
className={classes.icon}
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.6"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2Z" pathLength={1} />
<path d="M7 7h.01" pathLength={1} />
<path d="M17 7h.01" pathLength={1} />
<path d="M12 12h.01" pathLength={1} />
<path d="M7 17h.01" pathLength={1} />
<path d="M17 17h.01" pathLength={1} />
</svg>
);
};
export { DiceIcon };
@@ -1,40 +0,0 @@
import type { FC } from "react";
import type { IconProps } from "../../types";
import { bindIconAnimation } from "../../bind-icon-animation";
import classes from "../OperationIcon.module.css";
const DownloadIcon: FC<IconProps> = (props) => {
const { size = 24, ...svgProps } = props;
return (
<svg
aria-hidden="true"
className={classes.icon}
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.6"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M3 3h18v13H3V3Z" pathLength={1} />
<path d="M8 21h8" pathLength={1} />
<path d="M12 16v5" pathLength={1} />
<path d="M12 6v7" pathLength={1} />
<path d="M9 10l3 3 3-3" pathLength={1} />
</svg>
);
};
export { DownloadIcon };
@@ -1,36 +0,0 @@
import type { FC } from "react";
import type { IconProps } from "../../types";
import { bindIconAnimation } from "../../bind-icon-animation";
import classes from "../OperationIcon.module.css";
const EducationIcon: FC<IconProps> = (props) => {
const { size = 24, ...svgProps } = props;
return (
<svg
aria-hidden="true"
className={classes.icon}
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.6"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M2 8l10-5 10 5-10 5L2 8Z" pathLength={1} />
<path d="M6 10v7c4 3 8 3 12 0v-7" pathLength={1} />
<path d="M22 8v9" pathLength={1} />
</svg>
);
};
export { EducationIcon };
@@ -1,38 +0,0 @@
import type { FC } from "react";
import type { IconProps } from "../../types";
import { bindIconAnimation } from "../../bind-icon-animation";
import classes from "../OperationIcon.module.css";
const GridIcon: FC<IconProps> = (props) => {
const { size = 24, ...svgProps } = props;
return (
<svg
aria-hidden="true"
className={`${classes.icon} ${classes.grid}`}
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.6"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M4 4h6v6H4V4Z" pathLength={1} />
<path d="M14 4h6v6h-6V4Z" pathLength={1} />
<path d="M4 14h6v6H4v-6Z" pathLength={1} />
<path d="M14 14h6v6h-6v-6Z" pathLength={1} />
</svg>
);
};
export { GridIcon };
@@ -1,36 +0,0 @@
import type { FC } from "react";
import type { IconProps } from "../../types";
import { bindIconAnimation } from "../../bind-icon-animation";
import classes from "../OperationIcon.module.css";
const GrowthIcon: FC<IconProps> = (props) => {
const { size = 24, ...svgProps } = props;
return (
<svg
aria-hidden="true"
className={`${classes.icon} ${classes.growth}`}
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.6"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M3 3v18h18" pathLength={1} />
<path d="M6 15l5-5 4 3 6-8" pathLength={1} />
<path d="M16 5h5v5" pathLength={1} />
</svg>
);
};
export { GrowthIcon };
@@ -1,36 +0,0 @@
import type { FC } from "react";
import type { IconProps } from "../../types";
import { bindIconAnimation } from "../../bind-icon-animation";
import classes from "../OperationIcon.module.css";
const HomeIcon: FC<IconProps> = (props) => {
const { size = 24, ...svgProps } = props;
return (
<svg
aria-hidden="true"
className={classes.icon}
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.6"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M3 11l9-8 9 8" pathLength={1} />
<path d="M5 10v11h14V10" pathLength={1} />
<path d="M9 21v-8h6v8" pathLength={1} />
</svg>
);
};
export { HomeIcon };
@@ -1,32 +0,0 @@
import type { FC } from "react";
import type { IconProps } from "../../types";
import { bindIconAnimation } from "../../bind-icon-animation";
import classes from "../OperationIcon.module.css";
const MedicalIcon: FC<IconProps> = (props) => {
const { size = 24, ...svgProps } = props;
return (
<svg
aria-hidden="true"
className={classes.icon}
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.6"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M9 3h6v6h6v6h-6v6H9v-6H3V9h6V3Z" pathLength={1} />
</svg>
);
};
export { MedicalIcon };
@@ -1,36 +0,0 @@
import type { FC } from "react";
import type { IconProps } from "../../types";
import { bindIconAnimation } from "../../bind-icon-animation";
import classes from "../OperationIcon.module.css";
const MusicIcon: FC<IconProps> = (props) => {
const { size = 24, ...svgProps } = props;
return (
<svg
aria-hidden="true"
className={classes.icon}
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.6"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M9 5v14a3 3 0 1 1-3-3h3" pathLength={1} />
<path d="M9 5l11-2v14a3 3 0 1 1-3-3h3" pathLength={1} />
<path d="M9 9l11-2" pathLength={1} />
</svg>
);
};
export { MusicIcon };
@@ -1,34 +0,0 @@
import type { FC } from "react";
import type { IconProps } from "../../types";
import { bindIconAnimation } from "../../bind-icon-animation";
import classes from "../OperationIcon.module.css";
const RefundIcon: FC<IconProps> = (props) => {
const { size = 24, ...svgProps } = props;
return (
<svg
aria-hidden="true"
className={classes.icon}
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.6"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M8 4L3 9l5 5" pathLength={1} />
<path d="M3 9h11a7 7 0 0 1 0 14" pathLength={1} />
</svg>
);
};
export { RefundIcon };
@@ -1,36 +0,0 @@
import type { FC } from "react";
import type { IconProps } from "../../types";
import { bindIconAnimation } from "../../bind-icon-animation";
import classes from "../OperationIcon.module.css";
const RepaymentIcon: FC<IconProps> = (props) => {
const { size = 24, ...svgProps } = props;
return (
<svg
aria-hidden="true"
className={`${classes.icon} ${classes.repayment}`}
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.6"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M3 5h18v14H3V5Z" pathLength={1} />
<path d="M3 9h18" pathLength={1} />
<path d="M10 18v-7h3a2 2 0 0 1 0 4H9m0 1.5h4" pathLength={1} />
</svg>
);
};
export { RepaymentIcon };
@@ -1,42 +0,0 @@
import type { FC } from "react";
import type { IconProps } from "../../types";
import { bindIconAnimation } from "../../bind-icon-animation";
import classes from "../OperationIcon.module.css";
const ServerIcon: FC<IconProps> = (props) => {
const { size = 24, ...svgProps } = props;
return (
<svg
aria-hidden="true"
className={`${classes.icon} ${classes.server}`}
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.6"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M3 3h18v7H3V3Z" pathLength={1} />
<path d="M3 14h18v7H3v-7Z" pathLength={1} />
<path d="M7 6.5h.01" pathLength={1} />
<path d="M7 17.5h.01" pathLength={1} />
<path d="M15 6.5h3" pathLength={1} />
<path d="M15 17.5h3" pathLength={1} />
</svg>
);
};
export { ServerIcon };
@@ -1,34 +0,0 @@
import type { FC } from "react";
import type { IconProps } from "../../types";
import { bindIconAnimation } from "../../bind-icon-animation";
import classes from "../OperationIcon.module.css";
const ShieldIcon: FC<IconProps> = (props) => {
const { size = 24, ...svgProps } = props;
return (
<svg
aria-hidden="true"
className={classes.icon}
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.6"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M12 2l8 4v6c0 5-8 10-8 10S4 17 4 12V6l8-4Z" pathLength={1} />
<path d="M8 12l3 3 5-6" pathLength={1} />
</svg>
);
};
export { ShieldIcon };
@@ -1,34 +0,0 @@
import type { FC } from "react";
import type { IconProps } from "../../types";
import { bindIconAnimation } from "../../bind-icon-animation";
import classes from "../OperationIcon.module.css";
const ShoppingIcon: FC<IconProps> = (props) => {
const { size = 24, ...svgProps } = props;
return (
<svg
aria-hidden="true"
className={classes.icon}
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.6"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M5 7h14l2 14H3L5 7Z" pathLength={1} />
<path d="M8 8V6a4 4 0 0 1 8 0v2" pathLength={1} />
</svg>
);
};
export { ShoppingIcon };
@@ -1,38 +0,0 @@
import type { FC } from "react";
import type { IconProps } from "../../types";
import { bindIconAnimation } from "../../bind-icon-animation";
import classes from "../OperationIcon.module.css";
const SmokingIcon: FC<IconProps> = (props) => {
const { size = 24, ...svgProps } = props;
return (
<svg
aria-hidden="true"
className={classes.icon}
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.6"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M3 14h18v5H3v-5Z" pathLength={1} />
<path d="M16 14v5" pathLength={1} />
<path d="M8 10V8c0-2 3-2 3-4V2" pathLength={1} />
<path d="M15 10V8c0-2 3-2 3-4" pathLength={1} />
</svg>
);
};
export { SmokingIcon };
@@ -1,38 +0,0 @@
import type { FC } from "react";
import type { IconProps } from "../../types";
import { bindIconAnimation } from "../../bind-icon-animation";
import classes from "../OperationIcon.module.css";
const SubscriptionsIcon: FC<IconProps> = (props) => {
const { size = 24, ...svgProps } = props;
return (
<svg
aria-hidden="true"
className={classes.icon}
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.6"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M4 10a8 8 0 0 1 13-6l3 3" pathLength={1} />
<path d="M20 3v4h-4" pathLength={1} />
<path d="M20 14a8 8 0 0 1-13 6l-3-3" pathLength={1} />
<path d="M4 21v-4h4" pathLength={1} />
</svg>
);
};
export { SubscriptionsIcon };
@@ -1,44 +0,0 @@
import type { FC } from "react";
import type { IconProps } from "../../types";
import { bindIconAnimation } from "../../bind-icon-animation";
import classes from "../OperationIcon.module.css";
const TaxiIcon: FC<IconProps> = (props) => {
const { size = 24, ...svgProps } = props;
return (
<svg
aria-hidden="true"
className={classes.icon}
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.6"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M4 10l2-6h12l2 6" pathLength={1} />
<path d="M3 10h18v8H3v-8Z" pathLength={1} />
<path d="M6 18v3" pathLength={1} />
<path d="M18 18v3" pathLength={1} />
<path d="M6 14h2" pathLength={1} />
<path d="M16 14h2" pathLength={1} />
<path d="M10 4V2h4v2" pathLength={1} />
</svg>
);
};
export { TaxiIcon };
@@ -1,36 +0,0 @@
import type { FC } from "react";
import type { IconProps } from "../../types";
import { bindIconAnimation } from "../../bind-icon-animation";
import classes from "../OperationIcon.module.css";
const TopupIcon: FC<IconProps> = (props) => {
const { size = 24, ...svgProps } = props;
return (
<svg
aria-hidden="true"
className={`${classes.icon} ${classes.topup}`}
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.6"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M20 8V5H5a2 2 0 0 1 0-4h13v4" pathLength={1} />
<path d="M3 3v16a2 2 0 0 0 2 2h15V8H5" pathLength={1} />
<path d="M16 12v5m-2.5-2.5h5" pathLength={1} />
</svg>
);
};
export { TopupIcon };
@@ -1,34 +0,0 @@
import type { FC } from "react";
import type { IconProps } from "../../types";
import { bindIconAnimation } from "../../bind-icon-animation";
import classes from "../OperationIcon.module.css";
const TransferIcon: FC<IconProps> = (props) => {
const { size = 24, ...svgProps } = props;
return (
<svg
aria-hidden="true"
className={`${classes.icon} ${classes.transfer}`}
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.6"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M3 7h18l-4-4" pathLength={1} />
<path d="M21 17H3l4 4" pathLength={1} />
</svg>
);
};
export { TransferIcon };
@@ -1,32 +0,0 @@
import type { FC } from "react";
import type { IconProps } from "../../types";
import { bindIconAnimation } from "../../bind-icon-animation";
import classes from "../OperationIcon.module.css";
const TravelIcon: FC<IconProps> = (props) => {
const { size = 24, ...svgProps } = props;
return (
<svg
aria-hidden="true"
className={classes.icon}
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.6"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M10 3h4v7l7 5v3l-7-3v5l2 1v1l-4-1-4 1v-1l2-1v-5l-7 3v-3l7-5V3Z" pathLength={1} />
</svg>
);
};
export { TravelIcon };
@@ -1,38 +0,0 @@
import type { FC } from "react";
import type { IconProps } from "../../types";
import { bindIconAnimation } from "../../bind-icon-animation";
import classes from "../OperationIcon.module.css";
const WifiIcon: FC<IconProps> = (props) => {
const { size = 24, ...svgProps } = props;
return (
<svg
aria-hidden="true"
className={`${classes.icon} ${classes.wifi}`}
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.6"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M3 7a14 14 0 0 1 18 0" pathLength={1} />
<path d="M6 11a9 9 0 0 1 12 0" pathLength={1} />
<path d="M9 15a4 4 0 0 1 6 0" pathLength={1} />
<path d="M12 19h.01" pathLength={1} />
</svg>
);
};
export { WifiIcon };
@@ -1,38 +0,0 @@
import type { FC } from "react";
import type { IconProps } from "../../types";
import { bindIconAnimation } from "../../bind-icon-animation";
import classes from "../OperationIcon.module.css";
const WineIcon: FC<IconProps> = (props) => {
const { size = 24, ...svgProps } = props;
return (
<svg
aria-hidden="true"
className={`${classes.icon} ${classes.wine}`}
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.6"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M7 3h10v5a5 5 0 0 1-10 0V3Z" pathLength={1} />
<path d="M7 7h10v1a5 5 0 0 1-10 0V7Z" pathLength={1} />
<path d="M12 13v8" pathLength={1} />
<path d="M8 21h8" pathLength={1} />
</svg>
);
};
export { WineIcon };
-13
View File
@@ -1,17 +1,4 @@
export {
OperationIcon,
type OperationIconProps,
} from "./OperationIcon/OperationIcon";
export { AddIcon } from "./svg/AddIcon"; export { AddIcon } from "./svg/AddIcon";
export { BondsIcon } from "./svg/BondsIcon";
export { CardsIcon } from "./svg/CardsIcon";
export { CategoriesIcon } from "./svg/CategoriesIcon";
export { ContactlessIcon } from "./svg/ContactlessIcon";
export { DeleteIcon } from "./svg/DeleteIcon"; export { DeleteIcon } from "./svg/DeleteIcon";
export { EditIcon } from "./svg/EditIcon"; export { EditIcon } from "./svg/EditIcon";
export { PortfolioIcon } from "./svg/PortfolioIcon";
export { RestoreIcon } from "./svg/RestoreIcon";
export { SettingsIcon } from "./svg/SettingsIcon";
export { StocksIcon } from "./svg/StocksIcon";
export { WalletIcon } from "./svg/WalletIcon";
export type { IconProps } from "./types"; export type { IconProps } from "./types";
+8 -10
View File
@@ -1,21 +1,19 @@
import type { FC } from "react"; import type { FC } from "react";
import type { IconProps } from "../types"; import type { IconProps } from "../types";
const AddIcon: FC<IconProps> = (props) => { const AddIcon: FC<IconProps> = (props) => {
const { size = "24", ...svgProps } = props; const { size = "24", ...svgProps } = props;
return ( return (
<svg <svg
aria-hidden="true"
fill="none"
focusable="false"
height={size}
stroke="currentColor"
strokeLinecap="round"
strokeWidth="1.5"
viewBox="0 0 24 24"
width={size} width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
aria-hidden="true"
focusable="false"
{...svgProps} {...svgProps}
> >
<path d="M12 5v14 M5 12h14" /> <path d="M12 5v14 M5 12h14" />
-39
View File
@@ -1,39 +0,0 @@
import type { FC } from "react";
import type { IconProps } from "../types";
import { bindIconAnimation } from "../bind-icon-animation";
import classes from "../navigation.module.css";
const BondsIcon: FC<IconProps> = (props) => {
const { size = "24", ...svgProps } = props;
return (
<svg
aria-hidden="true"
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M6 3h12v18l-3-2-3 2-3-2-3 2V3Z" />
<g className={classes.couponLines}>
<path d="M9 7h6" pathLength={1} />
<path d="M9 11h6" pathLength={1} />
<path d="M9 15h3" pathLength={1} />
</g>
</svg>
);
};
export { BondsIcon };
-39
View File
@@ -1,39 +0,0 @@
import type { FC } from "react";
import type { IconProps } from "../types";
import { bindIconAnimation } from "../bind-icon-animation";
import classes from "../navigation.module.css";
const CardsIcon: FC<IconProps> = (props) => {
const { size = "24", ...svgProps } = props;
return (
<svg
aria-hidden="true"
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M3 5h18v14H3V5Zm0 4h18M6 15h4" />
<g className={classes.rubles}>
<path d="M10 12V4h3a2 2 0 0 1 0 4H9m0 2h4" />
<path d="M10 12V4h3a2 2 0 0 1 0 4H9m0 2h4" />
<path d="M10 12V4h3a2 2 0 0 1 0 4H9m0 2h4" />
</g>
</svg>
);
};
export { CardsIcon };
-27
View File
@@ -1,27 +0,0 @@
import type { FC } from "react";
import type { IconProps } from "../types";
const CategoriesIcon: FC<IconProps> = (props) => {
const { size = "24", ...svgProps } = props;
return (
<svg
aria-hidden="true"
fill="none"
focusable="false"
height={size}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M3 3h7v7H3zM14 3h7v7h-7zM3 14h7v7H3zM14 14h7v7h-7z" />
</svg>
);
};
export { CategoriesIcon };
@@ -1,32 +0,0 @@
import type { FC } from "react";
import type { IconProps } from "../types";
const ContactlessIcon: FC<IconProps> = (props) => {
const { size = "24", ...svgProps } = props;
return (
<svg
aria-hidden="true"
fill="none"
focusable="false"
height={size}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path
d="M7 11a8 8 0 0 1 0 8m5-12a15 15 0 0 1 0 16m5-20a23 23 0 0 1 0 24"
stroke="currentColor"
strokeLinecap="round"
strokeWidth="2"
/>
</svg>
);
};
export { ContactlessIcon };
+6 -8
View File
@@ -1,22 +1,20 @@
import type { FC } from "react"; import type { FC } from "react";
import type { IconProps } from "../types"; import type { IconProps } from "../types";
const DeleteIcon: FC<IconProps> = (props) => { const DeleteIcon: FC<IconProps> = (props) => {
const { size = "24", ...svgProps } = props; const { size = "24", ...svgProps } = props;
return ( return (
<svg <svg
aria-hidden="true" width={size}
fill="none"
focusable="false"
height={size} height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor" stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
strokeWidth="1.5" aria-hidden="true"
viewBox="0 0 24 24" focusable="false"
width={size}
{...svgProps} {...svgProps}
> >
<path d="M3 6h18 M9 6V3h6v3 M5 6l1 15h12l1-15 M10 10v7 M14 10v7" /> <path d="M3 6h18 M9 6V3h6v3 M5 6l1 15h12l1-15 M10 10v7 M14 10v7" />
+6 -8
View File
@@ -1,22 +1,20 @@
import type { FC } from "react"; import type { FC } from "react";
import type { IconProps } from "../types"; import type { IconProps } from "../types";
const EditIcon: FC<IconProps> = (props) => { const EditIcon: FC<IconProps> = (props) => {
const { size = "24", ...svgProps } = props; const { size = "24", ...svgProps } = props;
return ( return (
<svg <svg
aria-hidden="true" width={size}
fill="none"
focusable="false"
height={size} height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor" stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
strokeWidth="1.5" aria-hidden="true"
viewBox="0 0 24 24" focusable="false"
width={size}
{...svgProps} {...svgProps}
> >
<path d="m16 3 5 5-12 12-6 1 1-6L16 3Z M13 6l5 5" /> <path d="m16 3 5 5-12 12-6 1 1-6L16 3Z M13 6l5 5" />
-43
View File
@@ -1,43 +0,0 @@
import type { FC } from "react";
import type { IconProps } from "../types";
import { bindIconAnimation } from "../bind-icon-animation";
import classes from "../navigation.module.css";
const PortfolioIcon: FC<IconProps> = (props) => {
const { size = "24", ...svgProps } = props;
return (
<svg
aria-hidden="true"
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M3 10v9h18v-9" />
<path className={classes.portfolioHandle} d="M8 6V4h8v2" />
<path
className={classes.portfolioLid}
d="M3 10V8a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v2H3Zm6 0v3h6v-3"
/>
<path
className={classes.portfolioRuble}
d="M10 18v-7h3a2 2 0 0 1 0 4H9m0 1.5h4"
/>
</svg>
);
};
export { PortfolioIcon };
-27
View File
@@ -1,27 +0,0 @@
import type { FC } from "react";
import type { IconProps } from "../types";
const RestoreIcon: FC<IconProps> = (props) => {
const { size = "24", ...svgProps } = props;
return (
<svg
aria-hidden="true"
fill="none"
focusable="false"
height={size}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M3 3h18v4H3z M5 7v14h14V7 M12 17V10m-3 3 3-3 3 3" />
</svg>
);
};
export { RestoreIcon };
-35
View File
@@ -1,35 +0,0 @@
import type { FC } from "react";
import type { IconProps } from "../types";
import { bindIconAnimation } from "../bind-icon-animation";
import classes from "../navigation.module.css";
const SettingsIcon: FC<IconProps> = (props) => {
const { size = "24", ...svgProps } = props;
return (
<svg
aria-hidden="true"
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M4 7h16M4 17h16" />
<path className={classes.topSlider} d="M10 4v6h4V4h-4Z" />
<path className={classes.bottomSlider} d="M10 14v6h4v-6h-4Z" />
</svg>
);
};
export { SettingsIcon };
-39
View File
@@ -1,39 +0,0 @@
import type { FC } from "react";
import type { IconProps } from "../types";
import { bindIconAnimation } from "../bind-icon-animation";
import classes from "../navigation.module.css";
const StocksIcon: FC<IconProps> = (props) => {
const { size = "24", ...svgProps } = props;
return (
<svg
aria-hidden="true"
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M3 3v18h18" />
<path
className={classes.drawStroke}
d="M7 14l5-5 4 3 5-7"
pathLength={1}
/>
<path className={classes.stockArrow} d="M16 5h5v5" />
</svg>
);
};
export { StocksIcon };
-29
View File
@@ -1,29 +0,0 @@
import type { FC } from "react";
import type { IconProps } from "../types";
const WalletIcon: FC<IconProps> = (props) => {
const { size = "24", ...svgProps } = props;
return (
<svg
aria-hidden="true"
fill="none"
focusable="false"
height={size}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<rect height="15" rx="3" width="18" x="3" y="5" />
<path d="M3 9h18M7 15h3M14 5V3H7a4 4 0 0 0-4 4" />
</svg>
);
};
export { WalletIcon };
+7 -12
View File
@@ -1,22 +1,17 @@
{ {
"extends": "../../tsconfig.base.json",
"compilerOptions": { "compilerOptions": {
"lib": [ "target": "ES2023",
"ES2023", "lib": ["ES2023", "DOM"],
"DOM"
],
"module": "ESNext", "module": "ESNext",
"moduleResolution": "Bundler", "moduleResolution": "Bundler",
"strict": true,
"skipLibCheck": true,
"verbatimModuleSyntax": true,
"declaration": true, "declaration": true,
"emitDeclarationOnly": true, "emitDeclarationOnly": true,
"rootDir": "src", "rootDir": "src",
"outDir": "dist", "outDir": "dist",
"jsx": "react-jsx", "jsx": "react-jsx"
"types": [
"vite/client"
]
}, },
"include": [ "include": ["src/index.ts"]
"src/index.ts"
]
} }
+1 -1
View File
@@ -2,7 +2,7 @@ import { defineConfig } from "vite";
export default defineConfig({ export default defineConfig({
build: { build: {
lib: { entry: "src/index.ts", fileName: "index", formats: ["es"] }, lib: { entry: "src/index.ts", formats: ["es"], fileName: "index" },
rolldownOptions: { external: /^react(\/|$)/ }, rolldownOptions: { external: /^react(\/|$)/ },
}, },
}); });
+1 -1
View File
@@ -131,7 +131,7 @@
}, },
"muted": { "muted": {
"color": "gray", "color": "gray",
"shade": 7, "shade": 6,
"description": "Вторичный текст; проверяйте контраст при выборе фона" "description": "Вторичный текст; проверяйте контраст при выборе фона"
}, },
"border": { "border": {
+8 -8
View File
@@ -27,18 +27,18 @@
"@wealthledger/tokens": "0.3.0" "@wealthledger/tokens": "0.3.0"
}, },
"peerDependencies": { "peerDependencies": {
"@mantine/core": "9.7.3", "@mantine/core": "9.6.1",
"@mantine/hooks": "9.7.3", "@mantine/hooks": "9.6.1",
"react": "19.3.1", "react": "19.3.0",
"react-dom": "19.3.1" "react-dom": "19.3.0"
}, },
"devDependencies": { "devDependencies": {
"@storybook/react-vite": "10.6.3", "@storybook/react-vite": "10.6.0",
"@testing-library/react": "16.3.3", "@testing-library/react": "16.3.3",
"@types/react": "19.3.0", "@types/react": "19.3.0",
"storybook": "10.6.3", "storybook": "10.6.0",
"typescript": "5.9.3", "typescript": "5.9.3",
"vite": "8.3.5", "vite": "8.3.0",
"vitest": "5.0.5" "vitest": "5.0.0"
} }
} }
@@ -93,13 +93,3 @@
transition: none; transition: none;
} }
} }
.row[data-wrap] p {
white-space: normal;
overflow-wrap: anywhere;
}
@media (min-width: 36em) {
.row[data-wrap] {
grid-template-columns: auto minmax(0, 1fr) minmax(0, 40%);
}
}
@@ -1,27 +1,27 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Button } from "@mantine/core"; import { Button } from "@mantine/core";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { fn } from "storybook/test"; import { fn } from "storybook/test";
import { EntryCard } from "./EntryCard"; import { EntryCard } from "./EntryCard";
const meta = { const meta = {
title: "Design System/Components/EntryCard",
component: EntryCard,
tags: ["autodocs"],
args: { args: {
title:
"Учебная запись с очень длинным названием для проверки узкого экрана",
metadata: "22.09.2026 · Синтетические данные",
initial: "У",
imageSrc: "data:image/png;base64,invalid",
value: "−1 250,00 ₽",
valueLabel: "Тестовое значение",
tone: "negative",
actions: ( actions: (
<Button onClick={fn()} size="compact-sm"> <Button size="compact-sm" onClick={fn()}>
Открыть пример Открыть пример
</Button> </Button>
), ),
imageSrc: "data:image/png;base64,invalid",
initial: "У",
metadata: "22.09.2026 · Синтетические данные",
title:
"Учебная запись с очень длинным названием для проверки узкого экрана",
tone: "negative",
value: "−1 250,00 ₽",
valueLabel: "Тестовое значение",
}, },
component: EntryCard,
parameters: { parameters: {
docs: { docs: {
description: { description: {
@@ -30,14 +30,8 @@ const meta = {
}, },
}, },
}, },
tags: ["autodocs"],
title: "Design System/Components/EntryCard",
} satisfies Meta<typeof EntryCard>; } satisfies Meta<typeof EntryCard>;
export default meta; export default meta;
type Story = StoryObj<typeof meta>; type Story = StoryObj<typeof meta>;
export const LongContentAndFallback: Story = {}; export const LongContentAndFallback: Story = {};
export const WrappedOperation: Story = { args: { value: "−123 456 789 012 345 678,90 ₽", wrap: true } };
@@ -1,45 +1,26 @@
import { MantineProvider } from "@mantine/core"; import { MantineProvider } from "@mantine/core";
import { fireEvent, render, screen } from "@testing-library/react"; import { fireEvent, render, screen } from "@testing-library/react";
import { assert, describe, expect, it } from "vitest"; import { assert, describe, expect, it } from "vitest";
import { EntryCard } from "./EntryCard"; import { EntryCard } from "./EntryCard";
describe("EntryCard", () => { describe("EntryCard", () => {
it("does not leak a numeric zero from an absent action", () => { it("keeps the fallback after an image fails", () => {
render(
<MantineProvider>
<EntryCard
actions={0}
initial="У"
metadata="Описание"
title="Пример"
tone="neutral"
value="—"
valueLabel="Неизвестно"
/>
</MantineProvider>,
);
expect(screen.queryByText("0")).not.toBeInTheDocument();
});
it("keeps a failed image fallback until a new source is supplied", () => {
const props = { const props = {
imageSrc: "data:image/png;base64,invalid",
initial: "У",
metadata: "Описание",
title: "Пример", title: "Пример",
tone: "neutral" as const, metadata: "Описание",
initial: "У",
value: "—", value: "—",
valueLabel: "Неизвестно", valueLabel: "Неизвестно",
tone: "neutral" as const,
imageSrc: "data:image/png;base64,invalid",
}; };
const { container, rerender } = render(
const { rerender } = render(
<MantineProvider> <MantineProvider>
<EntryCard {...props} /> <EntryCard {...props} />
</MantineProvider>, </MantineProvider>,
); );
// Decorative image has no accessible name; trigger its actual failure event. // Decorative image has no accessible name; trigger its actual failure event.
const image = screen.queryByAltText(""); const image = container.querySelector("img");
assert(image); assert(image);
fireEvent.error(image); fireEvent.error(image);
rerender( rerender(
@@ -47,18 +28,8 @@ describe("EntryCard", () => {
<EntryCard {...props} title="Обновлённый пример" /> <EntryCard {...props} title="Обновлённый пример" />
</MantineProvider>, </MantineProvider>,
); );
expect(screen.queryByAltText("")).not.toBeInTheDocument(); expect(container.querySelector("img")).toBeNull();
expect(screen.getByText("У")).toBeInTheDocument(); expect(screen.getByText("У")).toBeInTheDocument();
expect(screen.getByText("Обновлённый пример")).toBeInTheDocument(); expect(screen.getByText("Обновлённый пример")).toBeInTheDocument();
rerender(
<MantineProvider>
<EntryCard {...props} imageSrc="/replacement-logo.png" />
</MantineProvider>,
);
expect(screen.queryByAltText("")).toHaveAttribute(
"src",
"/replacement-logo.png",
);
expect(screen.queryByText("У")).not.toBeInTheDocument();
}); });
}); });
@@ -1,78 +1,66 @@
import type { FC, ReactNode } from "react";
import { Avatar, Paper, Stack, Text } from "@mantine/core"; import { Avatar, Paper, Stack, Text } from "@mantine/core";
import { type FC, type ReactNode, useState } from "react";
import classes from "./EntryCard.module.css"; import classes from "./EntryCard.module.css";
type EntryCardProps = { type EntryCardProps = {
actions?: ReactNode;
color?: string;
details?: ReactNode;
icon?: ReactNode;
imageSrc?: null | string;
initial: string;
metadata: string;
title: string; title: string;
tone: "negative" metadata: string;
| "neutral" details?: ReactNode;
| "positive"; actions?: ReactNode;
initial: string;
icon?: ReactNode;
color?: string;
imageSrc?: string | null;
value: string; value: string;
valueLabel: string; valueLabel: string;
wrap?: boolean; tone: "positive" | "negative" | "neutral";
}; };
const EntryCard: FC<EntryCardProps> = (props) => { const EntryCard: FC<EntryCardProps> = (props) => {
const surfaceStyle = props.color const [imageFailed, setImageFailed] = useState(false);
? {
background: `color-mix(in srgb, var(--mantine-color-${props.color}-0) 45%, var(--wl-color-surface))`,
}
: undefined;
const titleLineClamp = props.wrap ? undefined : 1;
const valueContainerClass = props.actions ? classes.valueFooter : undefined;
const valueLabelClass = `${classes[props.tone]} ${props.actions ? classes.valueLabel : ""}`;
return ( return (
<Paper <Paper
className={classes.row} className={classes.row}
data-wrap={props.wrap || undefined}
style={surfaceStyle}
withBorder withBorder
style={
props.color
? {
background: `color-mix(in srgb, var(--mantine-color-${props.color}-0) 45%, var(--wl-color-surface))`,
}
: undefined
}
> >
<Avatar <Avatar
alt="" alt=""
aria-hidden="true" aria-hidden="true"
radius="md" radius="md"
size={40} size={40}
src={props.imageSrc} src={imageFailed ? null : props.imageSrc}
onError={() => setImageFailed(true)}
> >
{props.icon ?? props.initial} {props.icon ?? props.initial}
</Avatar> </Avatar>
<Stack className={classes.identity} gap={2}> <Stack className={classes.identity} gap={2}>
<Text fw={700} lineClamp={titleLineClamp}> <Text fw={700} lineClamp={1}>
{props.title} {props.title}
</Text> </Text>
<Text c="dimmed" size="sm"> <Text c="dimmed" size="sm">
{props.metadata} {props.metadata}
</Text> </Text>
{props.details} {props.details}
</Stack> </Stack>
<Stack className={classes.value} gap={2}> <Stack className={classes.value} gap={2}>
<Text className={classes[props.tone]} fw={700}> <Text className={classes[props.tone]} fw={700}>
{props.value} {props.value}
</Text> </Text>
<div className={props.actions ? classes.valueFooter : undefined}>
<div className={valueContainerClass}> <Text
<Text className={valueLabelClass} size="sm"> className={`${classes[props.tone]} ${props.actions ? classes.valueLabel : ""}`}
size="sm"
>
{props.valueLabel} {props.valueLabel}
</Text> </Text>
{props.actions && (
{Boolean(props.actions) && (
<div className={classes.actions}>{props.actions}</div> <div className={classes.actions}>{props.actions}</div>
)} )}
</div> </div>
@@ -1,38 +0,0 @@
import "@mantine/core/styles.css";
import "../styles.css";
import { MantineProvider } from "@mantine/core";
// eslint-disable-next-line testing-library/no-manual-cleanup -- Vitest globals are disabled, so RTL cannot register its automatic afterEach cleanup.
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, expect, it } from "vitest";
import { cssVariablesResolver, theme } from "../theme";
import { EntryCard } from "./EntryCard";
afterEach(cleanup);
it("shows the full wrapped title without horizontal overflow", () => {
const title = "Дивидендная выплата с очень длинным описанием, которое должно переноситься и оставаться доступным полностью";
render(
<MantineProvider cssVariablesResolver={cssVariablesResolver} theme={theme}>
<EntryCard
initial="Д"
metadata="29.09.2026"
title={title}
tone="positive"
value="123 456 789,90 ₽"
valueLabel="Дивиденды"
wrap
/>
</MantineProvider>,
);
const description = screen.getByText(title);
expect(description.clientHeight).toBeGreaterThan(0);
expect(description.scrollHeight)
.toBeLessThanOrEqual(description.clientHeight);
expect(document.documentElement.scrollWidth)
.toBeLessThanOrEqual(window.innerWidth);
});
@@ -1,16 +1,16 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Paper, Skeleton } from "@mantine/core"; import { Paper, Skeleton } from "@mantine/core";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { LoadingSkeleton } from "./LoadingSkeleton"; import { LoadingSkeleton } from "./LoadingSkeleton";
const meta = { const meta = {
title: "Design System/Components/LoadingSkeleton",
component: LoadingSkeleton,
tags: ["autodocs"],
args: { label: "Загрузка содержимого" }, args: { label: "Загрузка содержимого" },
argTypes: { argTypes: {
children: { table: { disable: true } },
label: { control: "text" }, label: { control: "text" },
children: { table: { disable: true } },
}, },
component: LoadingSkeleton,
parameters: { parameters: {
docs: { docs: {
description: { description: {
@@ -19,23 +19,17 @@ const meta = {
}, },
}, },
}, },
render: arguments_ => ( render: (args) => (
<Paper p="md" withBorder> <Paper p="md" withBorder>
<LoadingSkeleton {...arguments_}> <LoadingSkeleton {...args}>
<Skeleton h={24} w="60%" /> <Skeleton h={24} w="60%" />
<Skeleton h={72} /> <Skeleton h={72} />
<Skeleton h={16} w="80%" /> <Skeleton h={16} w="80%" />
</LoadingSkeleton> </LoadingSkeleton>
</Paper> </Paper>
), ),
tags: ["autodocs"],
title: "Design System/Components/LoadingSkeleton",
} satisfies Meta<typeof LoadingSkeleton>; } satisfies Meta<typeof LoadingSkeleton>;
export default meta; export default meta;
type Story = StoryObj<typeof meta>; type Story = StoryObj<typeof meta>;
export const Initial: Story = { name: "Загрузка области" }; export const Initial: Story = { name: "Загрузка области" };
@@ -1,6 +1,5 @@
import type { FC, PropsWithChildren } from "react";
import { Stack } from "@mantine/core"; import { Stack } from "@mantine/core";
import type { FC, PropsWithChildren } from "react";
type LoadingSkeletonProps = PropsWithChildren<{ type LoadingSkeletonProps = PropsWithChildren<{
label: string; label: string;
@@ -1,36 +1,36 @@
import { Button } from "@mantine/core";
import type { Meta, StoryObj } from "@storybook/react-vite"; import type { Meta, StoryObj } from "@storybook/react-vite";
import type { ComponentProps } from "react"; import type { ComponentProps } from "react";
import { Button } from "@mantine/core";
import { fn } from "storybook/test"; import { fn } from "storybook/test";
import { StateView } from "./StateView"; import { StateView } from "./StateView";
type StateStoryArguments = ComponentProps<typeof StateView> & { type StateStoryArgs = ComponentProps<typeof StateView> & {
onAction: () => void;
withAction: boolean; withAction: boolean;
onAction: () => void;
}; };
const meta = { const meta = {
title: "Design System/Components/StateView",
component: StateView,
tags: ["autodocs"],
args: { args: {
error: false,
onAction: fn(),
text: "Синтетическое пустое состояние. Пользовательские данные не загружаются.",
title: "Нет учебных данных", title: "Нет учебных данных",
text: "Синтетическое пустое состояние. Пользовательские данные не загружаются.",
error: false,
withAction: false, withAction: false,
onAction: fn(),
}, },
argTypes: { argTypes: {
action: { table: { disable: true } },
error: { control: "boolean" },
onAction: { control: false },
text: { control: "text" },
title: { control: "text" }, title: { control: "text" },
text: { control: "text" },
error: { control: "boolean" },
action: { table: { disable: true } },
withAction: { withAction: {
control: "boolean", control: "boolean",
description: "Показать необязательное действие StateView", description: "Показать необязательное действие StateView",
}, },
onAction: { control: false },
}, },
component: StateView,
parameters: { parameters: {
docs: { docs: {
description: { description: {
@@ -39,46 +39,40 @@ const meta = {
}, },
}, },
}, },
render: (arguments_) => { render: (args) => {
const { onAction, withAction, ...stateProps } = arguments_; const { withAction, onAction, ...stateProps } = args;
return ( return (
<StateView <StateView
{...stateProps} {...stateProps}
action={ action={
withAction withAction ? (
? ( <Button onClick={onAction} variant="default">
<Button onClick={onAction} variant="default"> {args.error ? "Повторить локально" : "Выполнить пример"}
{arguments_.error ? "Повторить локально" : "Выполнить пример"} </Button>
</Button> ) : undefined
)
: undefined
} }
/> />
); );
}, },
tags: ["autodocs"], } satisfies Meta<StateStoryArgs>;
title: "Design System/Components/StateView",
} satisfies Meta<StateStoryArguments>;
export default meta; export default meta;
type Story = StoryObj<typeof meta>; type Story = StoryObj<typeof meta>;
export const Empty: Story = { name: "Пустое состояние" }; export const Empty: Story = { name: "Пустое состояние" };
export const EmptyWithAction: Story = { export const EmptyWithAction: Story = {
args: { withAction: true },
name: "Пустое состояние с действием", name: "Пустое состояние с действием",
args: { withAction: true },
}; };
export const Failed: Story = { export const Failed: Story = {
name: "Ошибка",
args: { args: {
error: true, error: true,
text: "Демонстрация недоступных данных без вызова API.",
title: "Учебная ошибка", title: "Учебная ошибка",
text: "Демонстрация недоступных данных без вызова API.",
}, },
name: "Ошибка",
}; };
export const ErrorWithAction: Story = { export const ErrorWithAction: Story = {
args: { ...Failed.args, withAction: true },
name: "Ошибка с retry", name: "Ошибка с retry",
args: { ...Failed.args, withAction: true },
}; };
@@ -1,8 +1,7 @@
import { MantineProvider } from "@mantine/core"; import { MantineProvider } from "@mantine/core";
import { render, screen } from "@testing-library/react"; import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { LoadingSkeleton } from "./LoadingSkeleton";
import { LoadingSkeleton } from "../LoadingSkeleton/LoadingSkeleton";
import { StateView } from "./StateView"; import { StateView } from "./StateView";
describe("StateView", () => { describe("StateView", () => {
@@ -1,25 +1,22 @@
import { Paper, Stack, Text, Title } from "@mantine/core";
import type { FC, ReactNode } from "react"; import type { FC, ReactNode } from "react";
import { Paper, Stack, Text, Title } from "@mantine/core";
type StateViewProps = { type StateViewProps = {
title: string;
text: string;
action?: ReactNode; action?: ReactNode;
error?: boolean; error?: boolean;
text: string;
title: string;
}; };
const StateView: FC<StateViewProps> = (props) => { const StateView: FC<StateViewProps> = (props) => {
const { action, error = false, text, title } = props; const { title, text, action, error = false } = props;
const role = error ? "alert" : undefined; const role = error ? "alert" : undefined;
return ( return (
<Paper p="xl" radius="md" role={role} shadow="xs" withBorder> <Paper p="xl" radius="md" shadow="xs" withBorder role={role}>
<Stack align="flex-start" gap="sm"> <Stack align="flex-start" gap="sm">
<Title order={2}>{title}</Title> <Title order={2}>{title}</Title>
<Text c="dimmed">{text}</Text> <Text c="dimmed">{text}</Text>
{action} {action}
</Stack> </Stack>
</Paper> </Paper>
+3 -3
View File
@@ -1,3 +1,3 @@
export { EntryCard } from "./EntryCard/EntryCard"; export { EntryCard } from "./EntryCard";
export { LoadingSkeleton } from "./LoadingSkeleton/LoadingSkeleton"; export { LoadingSkeleton } from "./LoadingSkeleton";
export { StateView } from "./StateView/StateView"; export { StateView } from "./StateView";
-4
View File
@@ -1,7 +1,6 @@
import { DEFAULT_THEME, mergeMantineTheme } from "@mantine/core"; import { DEFAULT_THEME, mergeMantineTheme } from "@mantine/core";
import source from "@wealthledger/tokens/tokens.json"; import source from "@wealthledger/tokens/tokens.json";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { colorRoles, cssVariablesResolver, theme } from "./theme"; import { colorRoles, cssVariablesResolver, theme } from "./theme";
describe("design theme adapter", () => { describe("design theme adapter", () => {
@@ -26,8 +25,5 @@ describe("design theme adapter", () => {
expect(Object.keys(variables)).toHaveLength( expect(Object.keys(variables)).toHaveLength(
Object.keys(source.color.roles).length, Object.keys(source.color.roles).length,
); );
expect(cssVariablesResolver(resolved).light["--mantine-color-dimmed"]).toBe(
variables["--wl-color-muted"],
);
}); });
}); });
+27 -33
View File
@@ -1,7 +1,8 @@
import "./styles.css";
import { import {
type CSSVariablesResolver,
colorsTuple, colorsTuple,
createTheme, createTheme,
type CSSVariablesResolver,
getThemeColor, getThemeColor,
type MantinePrimaryShade, type MantinePrimaryShade,
Modal, Modal,
@@ -10,83 +11,76 @@ import {
} from "@mantine/core"; } from "@mantine/core";
import designTokens from "@wealthledger/tokens/tokens.json"; import designTokens from "@wealthledger/tokens/tokens.json";
import "./styles.css"; const { color, typography, spacing, radius, shadows, breakpoints } =
designTokens;
const { breakpoints, color, radius, shadows, spacing, typography }
= designTokens;
const mapValues = <T, U>(values: Record<string, T>, convert: (value: T) => U) => const mapValues = <T, U>(values: Record<string, T>, convert: (value: T) => U) =>
Object.fromEntries( Object.fromEntries(
Object.entries(values).map(([name, value]) => [name, convert(value)]), Object.entries(values).map(([name, value]) => [name, convert(value)]),
); );
const colorRoles = mapValues(color.roles, role => const colorRoles = mapValues(color.roles, (role) =>
role.shade === null ? role.color : `${role.color}.${role.shade}`, role.shade === null ? role.color : `${role.color}.${role.shade}`,
); );
const theme = createTheme({ const theme = createTheme({
black: color.base.black, respectReducedMotion: true,
breakpoints: mapValues(
breakpoints.values,
value => `${value}${breakpoints.unit}`,
),
colors: mapValues(color.palettes, colorsTuple),
components: { components: {
Modal: Modal.extend({ Modal: Modal.extend({
defaultProps: { defaultProps: {
overlayProps: { blur: 3 }, overlayProps: { blur: 3 },
transitionProps: { transitionProps: {
transition: "fade-down",
duration: 220, duration: 220,
exitDuration: 180, exitDuration: 180,
timingFunction: "ease-out", timingFunction: "ease-out",
transition: "fade-down",
}, },
}, },
}), }),
}, },
primaryColor: color.roles.primary.color,
primaryShade: color.primaryShade as MantinePrimaryShade,
defaultRadius: radius.default, defaultRadius: radius.default,
white: color.base.white,
black: color.base.black,
colors: mapValues(color.palettes, colorsTuple),
fontFamily: typography.fontFamily, fontFamily: typography.fontFamily,
fontFamilyMonospace: typography.fontFamilyMonospace, fontFamilyMonospace: typography.fontFamilyMonospace,
fontSizes: mapValues(typography.fontSizes, value => rem(value)), fontSizes: mapValues(typography.fontSizes, (value) => rem(value)),
fontWeights: mapValues(typography.fontWeights, String), fontWeights: mapValues(typography.fontWeights, String),
lineHeights: mapValues(typography.lineHeights, String),
headings: { headings: {
fontFamily: typography.fontFamily, fontFamily: typography.fontFamily,
fontWeight: String(typography.fontWeights.bold), fontWeight: String(typography.fontWeights.bold),
sizes: mapValues(typography.headings, value => ({ sizes: mapValues(typography.headings, (value) => ({
fontSize: rem(value.fontSize), fontSize: rem(value.fontSize),
lineHeight: String(value.lineHeight), lineHeight: String(value.lineHeight),
})), })),
}, },
lineHeights: mapValues(typography.lineHeights, String), spacing: mapValues(spacing.values, (value) => rem(value)),
primaryColor: color.roles.primary.color, radius: mapValues(radius.values, (value) => rem(value)),
primaryShade: color.primaryShade as MantinePrimaryShade, shadows: mapValues(shadows.values, (layers) =>
radius: mapValues(radius.values, value => rem(value)),
respectReducedMotion: true,
shadows: mapValues(shadows.values, layers =>
layers layers
.map( .map(
layer => (layer) =>
`${rem(layer.offsetX)} ${rem(layer.offsetY)} ${rem(layer.blur)} ${rem(layer.spread)} ${rgba(color.base[layer.color as keyof typeof color.base], layer.opacity)}`, `${rem(layer.offsetX)} ${rem(layer.offsetY)} ${rem(layer.blur)} ${rem(layer.spread)} ${rgba(color.base[layer.color as keyof typeof color.base], layer.opacity)}`,
) )
.join(", "), .join(", "),
), ),
spacing: mapValues(spacing.values, value => rem(value)), breakpoints: mapValues(
white: color.base.white, breakpoints.values,
(value) => `${value}${breakpoints.unit}`,
),
}); });
const cssVariablesResolver: CSSVariablesResolver = resolvedTheme => ({ const cssVariablesResolver: CSSVariablesResolver = (resolvedTheme) => ({
dark: {},
light: {
"--mantine-color-dimmed": getThemeColor(colorRoles.muted, resolvedTheme),
},
variables: Object.fromEntries( variables: Object.fromEntries(
Object.entries(colorRoles).map(([role, reference]) => [ Object.entries(colorRoles).map(([role, reference]) => [
`--wl-color-${role}`, `--wl-color-${role}`,
getThemeColor(reference, resolvedTheme), getThemeColor(reference, resolvedTheme),
]), ]),
), ),
light: {},
dark: {},
}); });
export { colorRoles, cssVariablesResolver, theme }; export { colorRoles, cssVariablesResolver, designTokens, theme };
export { default as designTokens } from "@wealthledger/tokens/tokens.json";
+4 -1
View File
@@ -1,10 +1,13 @@
{ {
"extends": "../../tsconfig.base.json",
"compilerOptions": { "compilerOptions": {
"target": "ES2023",
"lib": ["ES2023", "DOM"], "lib": ["ES2023", "DOM"],
"module": "ESNext", "module": "ESNext",
"moduleResolution": "Bundler", "moduleResolution": "Bundler",
"resolveJsonModule": true, "resolveJsonModule": true,
"strict": true,
"skipLibCheck": true,
"verbatimModuleSyntax": true,
"declaration": true, "declaration": true,
"emitDeclarationOnly": true, "emitDeclarationOnly": true,
"rootDir": "src", "rootDir": "src",
+1 -1
View File
@@ -3,9 +3,9 @@ import { defineConfig } from "vite";
export default defineConfig({ export default defineConfig({
build: { build: {
lib: { lib: {
cssFileName: "styles",
entry: { index: "src/index.ts", theme: "src/theme.ts" }, entry: { index: "src/index.ts", theme: "src/theme.ts" },
formats: ["es"], formats: ["es"],
cssFileName: "styles",
}, },
rolldownOptions: { rolldownOptions: {
external: /^(react(?:-dom)?|@mantine\/[^/]+|@wealthledger\/tokens)(\/|$)/, external: /^(react(?:-dom)?|@mantine\/[^/]+|@wealthledger\/tokens)(\/|$)/,
+18 -31
View File
@@ -1,37 +1,31 @@
const { test } = require("node:test");
const assert = require("node:assert/strict"); const assert = require("node:assert/strict");
const { const {
chmodSync,
mkdtempSync, mkdtempSync,
readFileSync, readFileSync,
rmSync,
writeFileSync, writeFileSync,
chmodSync,
rmSync,
} = require("node:fs"); } = require("node:fs");
const { tmpdir } = require("node:os"); const { tmpdir } = require("node:os");
const nodePath = require("node:path"); const { join, resolve } = require("node:path");
const { test } = require("node:test");
const EXECUTABLE_MODE = 0o755; const EXECUTABLE_MODE = 0o755;
const { spawnSync } = require("node:child_process"); const { spawnSync } = require("node:child_process");
test("immutable publication resumes exact digest checks after post-push failure", () => { test("immutable publication resumes exact digest checks after post-push failure", () => {
const directory = mkdtempSync(nodePath.join(tmpdir(), "wl-image-retry-")); const directory = mkdtempSync(join(tmpdir(), "wl-image-retry-"));
const workflow = readFileSync( const workflow = readFileSync(
nodePath.resolve(__dirname, "../.gitea/workflows/image.yml"), resolve(__dirname, "../.gitea/workflows/image.yml"),
"utf8", "utf8",
); );
const block = workflow const block = workflow
.split( .split(" - name: Publish immutable release tag\n run: |\n")[1]
" - name: Publish immutable release tag\n run: |\n", .split(" - name:")[0]
2, .replace(/^ {10}/gm, "");
)[1] const double = `#!${process.execPath}
.split(" - name:", 1)[0]
.replaceAll(/^ {10}/gm, "");
const double = String.raw`#!${process.execPath}
const fs = require('node:fs'); const fs = require('node:fs');
const args = process.argv.slice(2).join(' '); const args = process.argv.slice(2).join(' ');
fs.appendFileSync('calls', args + '\n'); fs.appendFileSync('calls', args + '\\n');
if (args.startsWith('manifest inspect')) { if (args.startsWith('manifest inspect')) {
if (!fs.existsSync('published')) { console.error('manifest unknown'); process.exit(1); } if (!fs.existsSync('published')) { console.error('manifest unknown'); process.exit(1); }
} else if (args.startsWith('buildx imagetools inspect')) { } else if (args.startsWith('buildx imagetools inspect')) {
@@ -45,44 +39,37 @@ if (args.startsWith('manifest inspect')) {
console.log(args.includes('image.version') ? '1.2.3' : process.env.BAD_REVISION ? 'other' : 'commit'); 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); } else if (args.startsWith('run') && process.env.BAD_SMOKE) process.exit(1);
`; `;
try { try {
for (const name of ["docker", "npm"]) { for (const name of ["docker", "npm"]) {
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames. writeFileSync(join(directory, name), double);
writeFileSync(nodePath.join(directory, name), double); chmodSync(join(directory, name), EXECUTABLE_MODE);
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames.
chmodSync(nodePath.join(directory, name), EXECUTABLE_MODE);
} }
const run = (extra = {}) => const run = (extra = {}) =>
spawnSync("/bin/sh", ["-ec", block], { spawnSync("/bin/sh", ["-ec", block], {
cwd: directory, cwd: directory,
encoding: "utf8", encoding: "utf8",
env: { env: {
...process.env, ...process.env,
IMAGE: "synthetic/image",
PATH: `${directory}:${process.env.PATH}`, PATH: `${directory}:${process.env.PATH}`,
IMAGE: "synthetic/image",
RELEASE_TAG: "v1.2.3", RELEASE_TAG: "v1.2.3",
REVISION: "commit", REVISION: "commit",
...extra, ...extra,
}, },
}); });
assert.notEqual(run().status, 0); assert.notEqual(run().status, 0);
const retry = run(); const retry = run();
assert.equal(retry.status, 0, retry.stdout + retry.stderr); assert.equal(retry.status, 0, retry.stdout + retry.stderr);
assert.notEqual(run({ BAD_REVISION: "1" }).status, 0); assert.notEqual(run({ BAD_REVISION: "1" }).status, 0);
assert.notEqual(run({ BAD_SMOKE: "1" }).status, 0); assert.notEqual(run({ BAD_SMOKE: "1" }).status, 0);
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames. const calls = readFileSync(join(directory, "calls"), "utf8");
const calls = readFileSync(nodePath.join(directory, "calls"), "utf8");
assert.equal( assert.equal(
calls.split("\n").filter(line => line.startsWith("buildx build")) calls.split("\n").filter((line) => line.startsWith("buildx build"))
.length, .length,
1, 1,
); );
assert.ok(calls.includes("synthetic/image@sha256:exact")); assert.ok(calls.includes("synthetic/image@sha256:exact"));
} } finally {
finally { rmSync(directory, { recursive: true, force: true });
rmSync(directory, { force: true, recursive: true });
} }
}); });
+40
View File
@@ -0,0 +1,40 @@
import { spawnSync } from "node:child_process";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
const temporaryDirectory = await mkdtemp(
join(tmpdir(), "wealth-ledger-openapi-"),
);
const generatedPath = join(temporaryDirectory, "schema.d.ts");
const committedPath = resolve("src/shared/api/generated/schema.d.ts");
const cliPath = resolve("node_modules/openapi-typescript/bin/cli.js");
let exitCode = 0;
try {
const generated = spawnSync(
process.execPath,
[cliPath, "src/shared/api/spec/openapi.json", "-o", generatedPath],
{ encoding: "utf8" },
);
if (generated.status !== 0) {
process.stderr.write(generated.stderr || generated.stdout);
exitCode = generated.status ?? 1;
} else {
const [committed, current] = await Promise.all([
readFile(committedPath, "utf8"),
readFile(generatedPath, "utf8"),
]);
if (committed !== current) {
console.error(
"Generated OpenAPI types are stale. Run `npm run openapi:generate` and commit the result.",
);
exitCode = 1;
}
}
} finally {
await rm(temporaryDirectory, { recursive: true, force: true });
}
process.exitCode = exitCode;
+9 -8
View File
@@ -1,12 +1,14 @@
const assert = require("node:assert/strict"); const assert = require("node:assert/strict");
const { execFileSync } = require("node:child_process"); const { execFileSync } = require("node:child_process");
const { version } = require("../package.json"); const { version } = require("../package.json");
const git = (...args) => execFileSync("git", args, { encoding: "utf8" }).trim();
const git = (...arguments_) => assert.equal(
execFileSync("git", arguments_, { encoding: "utf8" }).trim(); // eslint-disable-line sonarjs/no-os-command-from-path -- Repository checks use the developer/CI Git installation; arguments never invoke a shell. git("status", "--porcelain"),
"",
const tag_ = git( "Commit all changes before preparing a release",
);
const tags = git(
"tag", "tag",
"--merged", "--merged",
"HEAD", "HEAD",
@@ -15,10 +17,9 @@ const tag_ = git(
"--sort=-version:refname", "--sort=-version:refname",
) )
.split("\n") .split("\n")
.find(tag => /^v\d+\.\d+\.\d+$/.test(tag)); .filter((tag) => /^v\d+\.\d+\.\d+$/.test(tag));
assert.equal( assert.equal(
version, version,
tag_?.slice(1) ?? "0.0.0", tags[0]?.slice(1) ?? "0.0.0",
"Version is already prepared or release tags are missing. Fetch tags; finish the existing release before preparing another.", "Version is already prepared or release tags are missing. Fetch tags; finish the existing release before preparing another.",
); );

Some files were not shown because too many files have changed in this diff Show More