113 Commits
Author SHA1 Message Date
codex-bot 2b94a16ae5 refactor!: support RUB only (WealthLedger/main#55) (#168)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 11s
Frontend verify / verify (push) Failing after 9m0s
2026-11-11 09:36:29 +03:00
codex-bot e2062ce837 feat(account): add performance tab vs Moscow Exchange index (WealthLedger/main#54) (#167)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 10s
Frontend verify / verify (push) Failing after 8m17s
2026-11-08 16:49:39 +03:00
codex-bot d910d624d6 test: remove timing races from screen-sync and route transition tests (#138) (#166)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 13s
Frontend verify / verify (push) Failing after 9m3s
2026-11-07 18:44:44 +03:00
codex-bot cc45352243 feat: clarify grouped coupon payouts (#162) (#165)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 11s
Frontend verify / verify (push) Failing after 7m26s
2026-11-07 11:18:07 +03:00
codex-bot ed0b5388ab fix: unify history and upcoming events labels (#161) (#163)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 12s
Frontend verify / verify (push) Failing after 7m18s
2026-11-07 10:50:51 +03:00
codex-bot 134c951178 fix: polish accounts portfolio summary (#159)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 10s
Frontend verify / verify (push) Failing after 7m26s
2026-11-06 15:17:27 +03:00
ksv741 2fd7d59e65 chore(release): 1.4.0
Frontend verify / commits (pull_request) Successful in 30s
Frontend verify / security (pull_request) Successful in 9s
Frontend verify / verify (pull_request) Successful in 5m31s
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 9s
Frontend verify / verify (push) Successful in 5m38s
Frontend image / publish (push) Successful in 7m13s
2026-11-06 07:45:29 +03:00
codex-bot 668f2ef70e fix: align audit findings for Events, currency and auth (WealthLedger/frontend#155)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 10s
Frontend verify / verify (push) Failing after 7m7s
2026-11-05 07:23:07 +03:00
codex-bot cadba8d075 refactor: share path-root query predicate (WealthLedger/frontend#142) (#154)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 10s
Frontend verify / verify (push) Failing after 7m42s
2026-11-04 22:58:58 +03:00
codex-bot 2db39f25d9 refactor: normalize view models and share breadcrumbs and titles (WealthLedger/frontend#143) (#153)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 11s
Frontend verify / verify (push) Failing after 8m19s
2026-11-04 21:14:29 +03:00
codex-bot 2d070ce2b5 refactor(api): consume bank operation oneOf contract (WealthLedger/frontend#144) (#152)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 10s
Frontend verify / verify (push) Failing after 8m20s
2026-11-04 20:47:51 +03:00
codex-bot 5077bd4f0b refactor: drop dead query helpers and simplify read deadline (WealthLedger/frontend#141) (#149)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 11s
Frontend verify / verify (push) Failing after 7m35s
2026-11-04 18:50:27 +03:00
codex-bot e2d143df46 refactor: move portfolio structure into account page, drop widgets (WealthLedger/frontend#139) (#140)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 12s
Frontend verify / verify (push) Failing after 7m51s
2026-11-04 18:01:00 +03:00
codex-bot ad93725a84 chore(deps): set Renovate release age, PR limit and update groups (WealthLedger/main#6) (#137)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 10s
Frontend verify / verify (push) Failing after 6m56s
2026-11-03 14:07:40 +03:00
codex-bot 8ce1e8e444 feat(investments): warn and offer retry after exhausted read retries (WealthLedger/frontend#55) (#136)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 9s
Frontend verify / verify (push) Failing after 7m5s
2026-11-03 09:49:08 +03:00
codex-bot aaee9591d7 chore(api): update OpenAPI snapshot with UpcomingEventsDto.window (WealthLedger/frontend#134) (#135)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 9s
Frontend verify / verify (push) Failing after 6m24s
2026-11-03 07:21:35 +03:00
codex-bot 9619dc9791 fix(account): show source metadata on empty account and income (WealthLedger/frontend#55) (#133)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 10s
Frontend verify / verify (push) Failing after 6m34s
2026-11-03 06:44:13 +03:00
codex-bot 71fbe7774d refactor: replace repeated lazy page wrappers with lazyPage helper (#131) (#132)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 8s
Frontend verify / verify (push) Failing after 6m34s
2026-11-02 07:31:25 +03:00
ksv741 870b7e6327 chore(release): 1.3.0
Frontend verify / commits (pull_request) Successful in 32s
Frontend verify / security (pull_request) Successful in 10s
Frontend verify / verify (pull_request) Successful in 5m56s
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 9s
Frontend verify / verify (push) Failing after 5m22s
Frontend image / publish (push) Successful in 6m22s
2026-11-01 19:33:21 +03:00
codex-bot 70a2c335fd feat(account): add concentration tab with top-5 slices and chart (WealthLedger/main#48) (#127)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 10s
Frontend verify / verify (push) Failing after 9m31s
2026-11-01 19:28:52 +03:00
codex-bot 7242240b21 chore(deps): resolve minimatch and glob audit advisories (WealthLedger/main#49) (#128)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 9s
Frontend verify / verify (push) Failing after 7m6s
2026-11-01 19:13:02 +03:00
codex-bot 71126c91f0 feat: group all-account rows by instrument and day (WealthLedger/main#47) (#126)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 9s
Frontend verify / verify (push) Failing after 6m1s
2026-10-30 07:28:19 +03:00
codex-bot 6792f5b70a fix: show history loading alert only while loading (WealthLedger/frontend#124) (#125)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 11s
Frontend verify / verify (push) Failing after 6m57s
2026-10-28 21:00:45 +03:00
codex-bot 32e8fe9402 refactor: derive api error codes from contract (WealthLedger/backend#117) (#123)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 9s
Frontend verify / verify (push) Failing after 6m30s
2026-10-28 17:31:22 +03:00
codex-bot 5796d2d789 fix: stabilize auth client registration (WealthLedger/frontend#95) (#122)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 10s
Frontend verify / verify (push) Failing after 6m58s
2026-10-28 16:59:09 +03:00
codex-bot a56edf2b37 fix: show account source sync status (WealthLedger/frontend#120) (#121)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 9s
Frontend verify / verify (push) Failing after 7m0s
2026-10-28 16:34:19 +03:00
codex-bot 1d4990491f fix: round money to currency minor units without negative zero (WealthLedger/frontend#118) (#119)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 9s
Frontend verify / verify (push) Failing after 5m26s
2026-10-28 08:45:55 +03:00
ksv741 431559be0f feat: show return, cash and structure on account cards (#114)
Frontend verify / commits (pull_request) Successful in 29s
Frontend verify / security (pull_request) Successful in 8s
Frontend verify / verify (pull_request) Successful in 5m25s
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 9s
Frontend verify / verify (push) Failing after 5m20s
2026-10-27 05:29:11 +03:00
ksv741 0e4bbec2a7 chore: sync brokerage accounts contract (WealthLedger/backend#138) 2026-10-27 05:29:11 +03:00
ksv741 c24704affa feat: make brokerage account list compact (#111) 2026-10-27 05:29:11 +03:00
codex-bot 58b4e12633 test: pin date in all accounts events test (#115)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 9s
Frontend verify / verify (push) Failing after 5m7s
2026-10-27 05:27:14 +03:00
codex-bot 3075081df1 feat: replace account page accordion with tabs and show open position result (#112)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 20s
Frontend verify / verify (push) Failing after 6m36s
2026-10-26 20:30:35 +03:00
codex-bot 1a92f023c1 feat: remove top header and rework mobile bottom navigation (#109)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 12s
Frontend verify / verify (push) Failing after 7m7s
2026-10-25 18:25:10 +03:00
ksv741 df86b8450f chore(release): 1.2.1
Frontend verify / commits (pull_request) Successful in 40s
Frontend verify / security (pull_request) Successful in 11s
Frontend verify / verify (pull_request) Successful in 7m15s
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 13s
Frontend verify / verify (push) Successful in 7m48s
Frontend image / publish (push) Successful in 9m17s
2026-10-24 19:15:04 +03:00
ksv741 b5ceb77208 chore(deps): update non-major dependencies #9
Frontend verify / commits (pull_request) Successful in 35s
Frontend verify / security (pull_request) Successful in 10s
Frontend verify / verify (pull_request) Successful in 6m37s
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 11s
Frontend verify / verify (push) Failing after 6m29s
2026-10-24 18:20:20 +03:00
codex-bot b009101b7b fix: show snapshot time in browser time zone (#101)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 13s
Frontend verify / verify (push) Failing after 6m33s
2026-10-24 11:04:50 +03:00
codex-bot 7e6fd95cfa chore: bump test stack and fix npm audit findings (#105)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 10s
Frontend verify / verify (push) Failing after 5m38s
2026-10-24 10:32:11 +03:00
codex-bot 2cd6b1e72d chore: bump Node to 24.22.1 (WealthLedger/backend#122)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 13s
Frontend verify / verify (push) Failing after 7m25s
2026-10-23 16:10:45 +03:00
codex-bot 3af9df0ae2 fix: keep session when refresh fails on network error or 5xx (WealthLedger/frontend#97)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 1m3s
Frontend verify / verify (push) Failing after 7m11s
2026-10-23 15:06:20 +03:00
codex-bot 7b6f34c776 chore: resolve dev dependency audit findings (WealthLedger/frontend#103)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 11s
Frontend verify / verify (push) Failing after 7m33s
2026-10-23 14:13:08 +03:00
ksv741 ed811e88fa chore(release): 1.2.0
Frontend verify / commits (pull_request) Successful in 39s
Frontend verify / security (pull_request) Successful in 10s
Frontend verify / verify (pull_request) Successful in 6m48s
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 10s
Frontend verify / verify (push) Successful in 6m45s
Frontend image / publish (push) Successful in 7m55s
2026-10-18 16:24:32 +03:00
codex-bot eefdde29f8 chore: adopt linear git model in frontend docs (#91)
Frontend verify / commits (push) Skipped
Frontend verify / commits (pull_request) Failing after 39s
Frontend verify / security (pull_request) Skipped
Frontend verify / verify (pull_request) Skipped
Frontend verify / security (push) Successful in 10s
Frontend verify / verify (push) Failing after 7m24s
2026-10-17 14:25:51 +03:00
codex-bot 5134106696 test: cover non-retry of INVALID_TBANK_TOKEN reads (WealthLedger/backend#59) (#90)
Frontend verify / commits (push) Skipped
Frontend verify / commits (pull_request) Failing after 44s
Frontend verify / security (pull_request) Skipped
Frontend verify / verify (pull_request) Skipped
Frontend verify / security (push) Successful in 11s
Frontend verify / verify (push) Failing after 7m45s
2026-10-16 23:23:12 +03:00
codex-bot ebe92684a7 fix: align password validation with backend ASCII policy (WealthLedger/backend#60) (#89)
Frontend verify / commits (push) Skipped
Frontend verify / commits (pull_request) Failing after 40s
Frontend verify / security (pull_request) Skipped
Frontend verify / verify (pull_request) Skipped
Frontend verify / security (push) Successful in 11s
Frontend verify / verify (push) Failing after 7m35s
2026-10-16 10:00:54 +03:00
codex-bot 6f1226b668 Merge pull request 'chore: synchronize release 1.1.0 into develop (WealthLedger/main#30)' (#68) from main into develop
Frontend verify / commits (push) Skipped
Frontend verify / commits (pull_request) Failing after 35s
Frontend verify / security (pull_request) Skipped
Frontend verify / verify (pull_request) Skipped
Frontend verify / security (push) Successful in 9s
Frontend verify / verify (push) Successful in 6m57s
2026-10-16 08:04:34 +03:00
codex-bot d409a297e5 ci: keep pre-commit proportionate and avoid tool downloads (WealthLedger/frontend#62) (#86)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 9s
Frontend verify / verify (push) Successful in 6m21s
Frontend verify / commits (pull_request) Failing after 34s
Frontend verify / security (pull_request) Skipped
Frontend verify / verify (pull_request) Skipped
2026-10-15 06:34:36 +03:00
codex-bot ed63adad94 fix: deliver screener OpenAPI constraints (WealthLedger/backend#70) (#85)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 9s
Frontend verify / verify (push) Successful in 6m25s
2026-10-15 06:15:53 +03:00
codex-bot bc28973d1a ci: recheck PR title on edited without the full pipeline (WealthLedger/frontend#64)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 11s
Frontend verify / verify (push) Successful in 8m12s
2026-10-14 22:46:51 +03:00
codex-bot 20618fb033 Merge pull request 'fix(events): seven-date default period and picker from today (WealthLedger/frontend#63)' (#83) from fix/64-upcoming-events-date-filter into develop
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 13s
Frontend verify / verify (push) Successful in 8m18s
2026-10-14 21:34:59 +03:00
ksv741 612ab62509 fix(events): seven-date default period and picker from today (WealthLedger/frontend#63)
Frontend verify / commits (pull_request) Successful in 50s
Frontend verify / security (pull_request) Successful in 10s
Frontend verify / verify (pull_request) Successful in 7m7s
2026-10-14 21:22:09 +03:00
codex-bot f295dea6a6 fix: respect query eligibility in screen sync (WealthLedger/frontend#54)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 13s
Frontend verify / verify (push) Successful in 8m22s
2026-10-14 17:01:53 +03:00
codex-bot f3725c202b test: await client settlement in late-response cases (WealthLedger/frontend#61) (#81)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 10s
Frontend verify / verify (push) Successful in 7m35s
2026-10-14 14:13:41 +03:00
ksv741 edc7097bd7 Merge pull request 'fix(auth,bank): guard switch-auth nav and surface banking retry (#56, #57)' (#80) from feature/56-57-auth-pending-banking-feedback into develop
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 10s
Frontend verify / verify (push) Successful in 7m50s
Reviewed-on: #80
2026-10-14 12:01:28 +03:00
ksv741 233680448f fix(bank): break BankFeedback's shared/api edge for stories (#80)
Frontend verify / commits (pull_request) Successful in 42s
Frontend verify / security (pull_request) Successful in 10s
Frontend verify / verify (pull_request) Successful in 6m36s
BankFeedback lived under entities/bank-card/ui but pulled apiErrorCode
from @/shared/api, which dragged the entire api layer (openapi-fetch,
openapi-react-query, @tanstack/react-query, client, problem-details,
resource-query) into any Storybook story that imported a sibling
view, breaking stories-must-not-reach-application-data.

Move the local 'extract code from unknown' helper into the entity
and consume it from BankFeedback. The shared helper stays in
shared/api for the rest of the app.
2026-10-14 11:25:20 +03:00
ksv741 e38ecd87d6 fix(auth,bank): guard switch-auth nav and surface banking retry
Frontend verify / commits (pull_request) Successful in 39s
Frontend verify / security (pull_request) Successful in 10s
Frontend verify / verify (pull_request) Failing after 5m50s
AuthForm disables the switch-auth Link while submit is pending so pointer
click and Enter can no longer leave the auth route and lose the draft/error
context. The form restores a true Link after the request settles.

CardsPage/CardPage distinguish cold vs cached banking failures:
- cold card/section errors expose accessible alert + retry
- cached failures keep the previous data and offer a section GET retry
- summary-only/history-only outages do not hide successful sections
- notFound still skips the retry CTA

Tests cover the pending link guard, cold/cached cards error with retry,
summary failure with retry, and the prerequisite card list recovery.

Closes #56, #57
2026-10-14 10:43:34 +03:00
codex-bot e5f8bbe33c test: cover null events and income envelopes on account pages (#72) (#79)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 12s
Frontend verify / verify (push) Successful in 7m11s
2026-10-13 23:15:56 +03:00
codex-bot f7f53ff8e4 test: cover null resource envelopes for account pages (#70) (#71) (#78)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 11s
Frontend verify / verify (push) Successful in 7m21s
2026-10-13 22:53:28 +03:00
codex-bot df05c8c7db build: use native openapi-typescript --check (#60) (#77)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 11s
Frontend verify / verify (push) Successful in 7m33s
2026-10-13 21:32:33 +03:00
codex-bot e67c47b102 refactor: derive payment-system options from schema tuple (#58) (#75)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 12s
Frontend verify / verify (push) Successful in 7m56s
2026-10-13 21:05:07 +03:00
codex-bot 43b0f5574a feat: unify income payment cards and remove comparisons (WealthLedger/main#33) (#74)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 10s
Frontend verify / verify (push) Successful in 7m18s
feat: unify income payment cards and remove comparisons (WealthLedger/main#33)
2026-10-13 18:17:35 +03:00
ksv741 c7d8d2c8f9 ci: validate release PR commits independently (WealthLedger/main#30)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 10s
Frontend image / publish (push) Successful in 6m45s
Frontend verify / verify (push) Successful in 6m7s
Frontend verify / commits (pull_request) Successful in 37s
Frontend verify / security (pull_request) Successful in 9s
Frontend verify / verify (pull_request) Successful in 5m58s
2026-10-12 23:39:30 +03:00
ksv741 9d900b331a chore(release): 1.1.0
Frontend verify / commits (pull_request) Failing after 42s
Frontend verify / security (pull_request) Skipped
Frontend verify / verify (pull_request) Skipped
2026-10-12 23:19:12 +03:00
codex-bot b357743c10 Merge pull request 'chore: align frontend engineering workflow (WealthLedger/main#27)' (#66) from chore/27-engineering-workflow into develop
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 10s
Frontend verify / verify (push) Successful in 7m9s
2026-10-12 11:00:35 +03:00
ksv741 0d747cc93e docs: pin frontend workflow reference (WealthLedger/main#27)
Frontend verify / commits (pull_request) Successful in 39s
Frontend verify / security (pull_request) Successful in 9s
Frontend verify / verify (pull_request) Successful in 6m7s
2026-10-12 10:22:13 +03:00
ksv741 c315f80b1b fix: align frontend workflow rules (WealthLedger/main#27)
Frontend verify / commits (pull_request) Successful in 48s
Frontend verify / security (pull_request) Successful in 11s
Frontend verify / verify (pull_request) Canceled after 1m36s
2026-10-12 10:19:36 +03:00
ksv741 617edfd489 chore: align frontend engineering workflow (WealthLedger/main#27)
Frontend verify / commits (pull_request) Successful in 35s
Frontend verify / security (pull_request) Successful in 9s
Frontend verify / verify (pull_request) Failing after 3m11s
2026-10-12 09:20:13 +03:00
codex-bot 9ad3245efe Merge pull request 'docs: clarify frontend verification policy' (#65) from codex/issue-24-process into develop
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 10s
Frontend verify / verify (push) Successful in 6m56s
2026-10-11 23:23:10 +03:00
ksv741 cac4351bc4 docs: clarify frontend verification policy
Frontend verify / commits (pull_request) Successful in 41s
Frontend verify / security (pull_request) Successful in 11s
Frontend verify / verify (pull_request) Successful in 6m34s
2026-10-11 23:06:51 +03:00
codex-bot 17f5bae69b feat: add stock screener
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 12s
Frontend verify / verify (push) Successful in 6m39s
2026-10-11 11:50:39 +03:00
ksv741 c8821200ce ci: skip checks on PR metadata edits
Frontend verify / commits (pull_request) Successful in 34s
Frontend verify / security (pull_request) Successful in 9s
Frontend verify / verify (pull_request) Successful in 5m37s
2026-10-11 11:36:51 +03:00
ksv741 cec9c6348a fix: preserve latest stock screener result
Frontend verify / commits (pull_request) Successful in 35s
Frontend verify / security (pull_request) Successful in 9s
Frontend verify / verify (pull_request) Successful in 5m47s
2026-10-11 11:11:58 +03:00
ksv741 a84411df39 feat: add stock screener 2026-10-11 11:11:58 +03:00
ksv741 3c226f8c29 feat: render hourly investment states
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 12s
Frontend verify / verify (push) Successful in 7m41s
2026-10-09 21:13:34 +03:00
ksv741 20b2cdfd43 fix: show bank summary totals 2026-10-09 21:13:34 +03:00
codex-bot dcd1e3c278 Merge pull request 'feat: align investment client with hourly sync' (#50) from release/1.0.0 into develop
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 11s
Frontend verify / verify (push) Successful in 7m32s
Merge hourly sync frontend client
2026-10-09 09:19:49 +03:00
ksv741 432cca299f feat: align investment client with hourly sync
Frontend verify / commits (pull_request) Successful in 40s
Frontend verify / security (pull_request) Successful in 9s
Frontend verify / verify (pull_request) Successful in 6m43s
2026-10-09 09:04:45 +03:00
ksv741 54deae57b3 chore(release): 1.0.0
Frontend image / publish (push) Successful in 7m39s
Frontend verify / commits (pull_request) Successful in 39s
Frontend verify / security (pull_request) Successful in 12s
Frontend verify / verify (pull_request) Successful in 6m39s
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 11s
Frontend verify / verify (push) Successful in 7m34s
2026-10-07 18:14:21 +03:00
ksv741 74f8a72ed5 perf(tbank-connection): poll screen sync every five seconds
Frontend verify / commits (pull_request) Successful in 48s
Frontend verify / security (pull_request) Successful in 10s
Frontend verify / verify (pull_request) Successful in 6m57s
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 11s
Frontend verify / verify (push) Successful in 8m21s
During active screen sync, status checks and observe reads run every 5s
instead of 1s; terminal state observes resources immediately.
OpenSpec change: WealthLedger/specs#21
2026-10-07 14:03:55 +03:00
ksv741 fcb938b5a2 chore: remove unused exports
Frontend verify / commits (pull_request) Successful in 42s
Frontend verify / security (pull_request) Successful in 11s
Frontend verify / verify (pull_request) Successful in 7m16s
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 14s
Frontend verify / verify (push) Failing after 6m28s
2026-10-07 09:10:03 +03:00
ksv741 f873db7b02 fix(deps): resolve npm audit advisories
Frontend verify / commits (pull_request) Successful in 36s
Frontend verify / security (pull_request) Successful in 10s
Frontend verify / verify (pull_request) Failing after 32m34s
2026-10-07 07:27:16 +03:00
ksv741 0ea8d7b62d feat: default journal period to last 7 days
Frontend verify / commits (pull_request) Successful in 50s
Frontend verify / security (pull_request) Failing after 13s
Frontend verify / verify (pull_request) Skipped
2026-10-06 22:05:41 +03:00
ksv741 c25f6bd85b refactor: move PortfolioStructure to widgets layer 2026-10-06 22:05:24 +03:00
ksv741 5b30bb802a feat: add quiet reading with rare confirmations
Part of the quiet-user-feedback change; docs and specs land in their own repositories.
2026-10-06 07:01:58 +03:00
ksv741 c096fc70ef fix(deps): remove npm dependency on Nexus
Frontend verify / commits (pull_request) Successful in 42s
Frontend verify / security (pull_request) Successful in 10s
Frontend verify / verify (pull_request) Failing after 36m0s
Nexus hosts release Docker images, not npm dependencies.

Omit registry URLs using npm's native lockfile option.

Versions and integrity hashes remain unchanged.
2026-10-03 22:09:33 +03:00
ksv741 69997075e5 refactor!: simplify query and session ownership
Frontend verify / commits (pull_request) Failing after 27s
Frontend verify / security (pull_request) Skipped
Frontend verify / verify (pull_request) Skipped
Use Query cancellation and native mutation results instead of HTTP
registries, access epochs and copied scope state.
Keep local auth ordering and mutation identity fences.
Allow staged generated files to remain excluded from ESLint.

Backend contract: WealthLedger/backend@d698ed9

BREAKING CHANGE: 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.
2026-10-03 11:45:04 +03:00
ksv741 6a89b08ee5 chore: guard presentation data boundaries
Frontend verify / commits (pull_request) Successful in 31s
Frontend verify / security (pull_request) Successful in 10s
Frontend verify / verify (pull_request) Successful in 10m18s
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 59s
Frontend verify / verify (push) Successful in 10m53s
2026-10-01 20:53:23 +03:00
ksv741 2844b22dba refactor: simplify finance view models 2026-10-01 20:53:13 +03:00
ksv741 ff67a768ad refactor: unify bank form types and actions 2026-10-01 20:53:00 +03:00
ksv741 10baefda79 fix(deps): resolve npm audit advisories
Refresh vulnerable transitive patch versions.
Scope patched undici and basic-ftp to release-it.
Retain the low audit threshold and release tooling version.
2026-10-01 20:51:32 +03:00
ksv741 f5f4447d60 fix: sort stock and bond events by payment date 2026-09-30 07:40:43 +03:00
ksv741 0bc8bc36b7 refactor: share source sync status presentation 2026-09-30 07:40:23 +03:00
ksv741 f9f0f6921e refactor: clarify frontend module ownership
Frontend verify / commits (pull_request) Failing after 1m19s
Frontend verify / security (pull_request) Skipped
Frontend verify / verify (pull_request) Skipped
2026-09-29 07:07:36 +03:00
ksv741 7c28a423dd refactor: tighten types and reuse instrument UI
Frontend verify / commits (pull_request) Successful in 24s
Frontend verify / security (pull_request) Failing after 9s
Frontend verify / verify (pull_request) Skipped
2026-09-29 06:15:31 +03:00
ksv741 b404c1802a refactor: simplify auth and instrument pages
Frontend verify / commits (pull_request) Successful in 24s
Frontend verify / security (pull_request) Failing after 9s
Frontend verify / verify (pull_request) Skipped
2026-09-29 05:45:08 +03:00
ksv741 32a77860c8 refactor: share instrument search presentation
Frontend verify / commits (pull_request) Successful in 24s
Frontend verify / security (pull_request) Failing after 9s
Frontend verify / verify (pull_request) Skipped
2026-09-29 05:14:28 +03:00
ksv741 0865b35f93 refactor: clarify frontend model ownership
Frontend verify / commits (pull_request) Successful in 27s
Frontend verify / security (pull_request) Successful in 9s
Frontend verify / verify (pull_request) Successful in 8m20s
2026-09-28 21:44:53 +03:00
ksv741 7e976c330c refactor: simplify errors and focus tests
Frontend verify / commits (pull_request) Successful in 30s
Frontend verify / security (pull_request) Successful in 11s
Frontend verify / verify (pull_request) Successful in 8m25s
2026-09-28 21:25:28 +03:00
ksv741 320ea5922a test: remove redundant frontend checks
Frontend verify / commits (pull_request) Successful in 28s
Frontend verify / security (pull_request) Successful in 12s
Frontend verify / verify (pull_request) Successful in 8m31s
2026-09-28 21:04:27 +03:00
ksv741 64fe902192 refactor: encapsulate instrument chart data
Frontend verify / commits (pull_request) Successful in 27s
Frontend verify / security (pull_request) Successful in 10s
Frontend verify / verify (pull_request) Successful in 8m45s
2026-09-28 20:46:06 +03:00
ksv741 fb3a5a6eaa refactor: remove redundant page view models
Frontend verify / commits (pull_request) Successful in 29s
Frontend verify / security (pull_request) Successful in 10s
Frontend verify / verify (pull_request) Successful in 8m35s
2026-09-28 20:34:33 +03:00
ksv741 94adbafa34 refactor: simplify calendar and form state
Frontend verify / commits (pull_request) Successful in 29s
Frontend verify / security (pull_request) Successful in 11s
Frontend verify / verify (pull_request) Successful in 8m43s
2026-09-28 20:23:02 +03:00
ksv741 a7537da344 refactor: simplify frontend state and mutation boundaries
Frontend verify / commits (pull_request) Successful in 31s
Frontend verify / security (pull_request) Successful in 11s
Frontend verify / verify (pull_request) Successful in 9m1s
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 10s
Frontend verify / verify (push) Successful in 9m59s
2026-09-28 20:01:37 +03:00
ksv741 a4046dbe87 refactor: address frontend review feedback
Frontend verify / commits (pull_request) Successful in 29s
Frontend verify / security (pull_request) Successful in 9s
Frontend verify / verify (pull_request) Successful in 8m54s
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 12s
Frontend verify / verify (push) Successful in 9m59s
2026-09-28 19:20:32 +03:00
ksv741 ca0ad4f824 refactor: enforce frontend view model boundaries
Frontend verify / commits (pull_request) Successful in 28s
Frontend verify / security (pull_request) Successful in 10s
Frontend verify / verify (pull_request) Successful in 8m19s
2026-09-28 18:48:23 +03:00
ksv741 5b69003516 refactor: enforce enum machine codes #18
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 11s
Frontend verify / verify (push) Successful in 9m31s
Use owner enums for known codes; preserve wire values and unknown fallbacks.

Verified npm ci and verify: 379 tests, 5 lint checks, app and Storybook builds.
2026-09-28 16:36:40 +03:00
ksv741 12c3b72bfb style: pin ternary operator formatting #18 2026-09-28 16:36:40 +03:00
ksv741 061ec708c6 chore: guard unsafe DOM HTML with ESLint #18
Enable no-unsanitized recommended rules and verify TypeScript DOM sink diagnostics.
2026-09-28 16:36:40 +03:00
ksv741 72d7835227 chore: finish frontend ESLint migration #18
Enable every plugin recommended preset and replace Biome with ESLint formatting.

Preserve API contracts and JSX whitespace; verify source, tests, builds and tooling.
2026-09-28 16:36:40 +03:00
ksv741 57630102e4 chore: add typed ESLint checks #18
Refs WealthLedger/main#18. Keep Biome; add no Prettier dependencies.
2026-09-28 16:36:40 +03:00
ksv741 95fdc8759e ci: reduce duplicate actions and serialize checks
Frontend verify / commits (pull_request) Successful in 28s
Frontend verify / security (pull_request) Successful in 10s
Frontend verify / verify (pull_request) Successful in 6m1s
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 12s
Frontend verify / verify (push) Successful in 7m2s
2026-09-28 14:18:16 +03:00
codex-bot c1b08735e3 refactor: simplify resource queries and layout tests
Frontend security / security (push) Successful in 10s
Frontend verify / verify (push) Successful in 5m51s
2026-09-27 18:53:12 +03:00
codex-bot 5e076470aa fix: resolve frontend audit findings (#39)
Frontend security / security (push) Successful in 10s
Frontend verify / verify (push) Successful in 6m27s
2026-09-27 12:11:44 +03:00
ksv741 f6db28defa fix: resolve frontend audit findings
Frontend security / security (push) Successful in 10s
Frontend verify / verify (push) Successful in 4m57s
Conventional commits / commits (pull_request) Successful in 28s
Frontend security / security (pull_request) Successful in 10s
Frontend verify / verify (pull_request) Successful in 5m10s
Repair investment recovery, draft preservation, date validation,
currency presentation, accessibility, and route error recovery.
Correct Docker and dependency checks and consume explicit income roles.
2026-09-27 11:38:20 +03:00
478 changed files with 33997 additions and 18292 deletions
+9 -7
View File
@@ -34,9 +34,10 @@ contract; frontend не читает его checkout для генерации
| Представление/поведение бизнес-сущности | `entities/<entity>` | | Представление/поведение бизнес-сущности | `entities/<entity>` |
| Переиспользуемый код без WealthLedger domain meaning | `shared` | | Переиспользуемый код без WealthLedger domain meaning | `shared` |
Локальный FSD profile: `app → pages → features → entities → shared`, без `widgets`. Локальный FSD profile: `app → pages → features → entities → shared`.
Крупный уникальный блок остаётся в своей page. Form, mutation или click handler Слой `widgets` не используется: композиция экрана и компонент для одной page
сами по себе не требуют feature. Два callers не делают domain helper generic shared. остаются у page. Form, mutation или click handler сами по себе не
требуют feature. Два callers не делают domain helper generic shared.
Slice именуется по ответственности, а не `helpers`, `hooks` или `types`. Slice именуется по ответственности, а не `helpers`, `hooks` или `types`.
**Безопасный путь:** при сомнении оставь код у ближайшего допустимого owner. **Безопасный путь:** при сомнении оставь код у ближайшего допустимого owner.
@@ -69,10 +70,11 @@ 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).
Статичные Add/Edit/Delete и публичный `IconProps` принадлежат private Все SVG, их геометрия и анимации принадлежат private `packages/icons`.
`packages/icons`; package не зависит от UI, domain или motion. Приложение импортирует компоненты из `@wealthledger/icons` и подключает
`BankOperationIcon` и mappings остаются у entity. Card/Cards используют package `@wealthledger/icons/styles.css`. В `src` нет SVG-файлов и компонентов,
Add/Edit/Delete; restore/category SVG остаются локальными. единственная задача которых — рисовать иконку. Domain mapping категории в
параметры `OperationIcon` остаётся в `entities/bank-card/model`.
## Примеры ## Примеры
+21 -2
View File
@@ -30,7 +30,8 @@ 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` генерирует во временный каталог и сравнивает, не меняя tracked files. `openapi:check` запускает native `--check` того же CLI и падает при устаревших
generated types, не меняя tracked files.
## Auth и invalidation ## Auth и invalidation
@@ -39,6 +40,15 @@ 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,
@@ -50,6 +60,15 @@ 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.
@@ -70,7 +89,7 @@ Network/unknown error получает безопасное локальное
Если одна группа API codes требует одинакового recovery в нескольких views, Если одна группа API codes требует одинакового recovery в нескольких views,
сведи классификацию у ближайшего общего owner. Не размножай magic comparisons сведи классификацию у ближайшего общего owner. Не размножай magic comparisons
и не объявляй закрытый frontend error enum: generated `code` пока `string`. и используй `ApiErrorCode` для известных значений: generated `code` остаётся `string`.
Неизвестный новый code должен сохранять fallback. Рефакторинг существующих Неизвестный новый code должен сохранять fallback. Рефакторинг существующих
разных recovery paths требует behavior proof, а не общей обработки всех ошибок. разных recovery paths требует behavior proof, а не общей обработки всех ошибок.
+37 -16
View File
@@ -7,11 +7,19 @@
checkout. Внешний API задаётся предоставленным публичным контрактом; checkout. Внешний API задаётся предоставленным публичным контрактом;
исходники или инструкции его реализации не являются зависимостью разработки. исходники или инструкции его реализации не являются зависимостью разработки.
При нехватке требования сообщи, какое решение не определено, до его реализации. При нехватке требования сообщи, какое решение не определено, до его реализации.
При конфликте с общим шаблоном следуй местным правилам и явному запросу
пользователя. Выбирай реализацию самостоятельно, но изменение финансовой
логики, публичного контракта или утверждённой семантики возвращай владельцу
на согласование до зависимой работы.
Начни с результата и affected areas. До edits проследи flow/invariants, найди Начни с результата и affected areas. До edits проследи flow/invariants, найди
owner и 1–3 примера. Код показывает механизм; случайный аналог не меняет контракт. owner и 1–3 примера. Код показывает механизм; случайный аналог не меняет контракт.
Работай напрямую; OpenSpec/SDD lifecycle — только по явному запросу. Выбери lightweight или SDD по [Engineering Workflow](https://git.personal.ksv741.ru/WealthLedger/main/src/commit/03b3b59e6ff2ba391f9dfc42a039a96d0d84c1c9/.ai/conventions/engineering-workflow.md);
Перед Node-командами активируй местную `.nvmrc`: `nvm use --silent`. OpenSpec lifecycle начинай после явного решения Change Owner.
Перед 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
@@ -29,7 +37,8 @@ owner и 1–3 примера. Код показывает механизм; с
## Domain values ## Domain values
Finite business vocabulary принадлежит одному domain/module/entity owner. Finite business vocabulary принадлежит одному domain/module/entity owner.
Переиспользуй его symbols; для нового словаря без аналога — `as const` + union. Переиспользуй его symbols; для machine codes и protocol values используй
строковые `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.
@@ -56,21 +65,33 @@ Peer-конфликт решай совместимыми версиями бе
## Проверки и завершение ## Проверки и завершение
Code/dependency change: `npm ci`, полный `npm run verify` и дополнительные | Когда | Что проверить |
risk checks из местной testing convention. Targeted tests ускоряют итерацию, | --- | --- |
но не заменяют финальный gate. `lint:fix`/format — edits, не proof. | Окружение новое, изменились manifest/lockfile или Node, установка сомнительна | `npm ci` до Node-проверок; одна правка кода переустановки не требует. |
Markdown-only: проверь новые/изменённые тексты, локальные ссылки/якоря, маршруты | Итерация | Адресные тесты, typecheck и lint по затронутому поведению и риску. При изменении поведения добавь/обнови содержательную регрессию, если существующая проверка не ловит её; для косметики не создавай искусственный тест. |
чтения и `git diff --check`; application build ради текста не нужен. | Итоговый code/dependency diff | Перед объявлением готовности и передачей к интеграции выполни один полный `npm run verify` на итоговых входах. Дополнительные risk checks из testing convention и явно утверждённые task checks обязательны; CI проверяет независимо. |
Повтори routing по staged/unstaged/new files. Запиши краткие команды, результаты | Только Markdown | Проверь тексты, ссылки/якоря, маршруты чтения и `git diff --check`; application build не нужен. При изменении verification-контура проверь его работу. |
и ограничения. Зелёный lint не заменяет semantic/security review. | Pre-commit | Hook не скачивает tools: вызывай установленные бинарники из `node_modules/.bin` (не `npx`). Full graph не входит в hook; он выполняется в `npm run verify` и CI. |
Повтори routing по staged/unstaged/new files. В отчёте по задаче/PR запиши
проверенные входы, команду, exit code, результат и ограничения. Повторяй
проверку при изменении значимых входов (включая config, dependencies и
generated artifacts), сомнении в evidence или явном запросе пользователя.
`lint:fix`/format — edits, не proof; lint не заменяет semantic/security review.
## Git и релизы ## Git и релизы
Работа — в `codex/<task>` от актуального `develop`, затем PR в `develop`. Работа — в ветке `<type>/<issue-number>-<slug>` от актуального `develop`,
затем PR в `develop`; для cross-repo ссылки укажи owner/repo#number.
Сохраняй чужие изменения; перед commit просмотри staged paths и diff. Сохраняй чужие изменения; перед commit просмотри staged paths и diff.
Commits и PR titles — Conventional Commits; допустим `spec:`. Каждый commit Commits и PR titles — Conventional Commits с parser-recognized Issue reference (`fix: … (#12)`
содержит одну связанную задачу. При связанной issue добавь её номер в конец или `chore: … (WealthLedger/main#27)`); допустим `spec:`. Каждый commit
заголовка (`fix: … #12`); без issue номер не нужен. Root другого checkout содержит одну связанную задачу. Исключения release и правила ссылки — в
не управляет этим repo. Engineering Workflow из AGENTS. Root другого checkout не управляет этим repo.
Прямой push в `main`, force-push и перенос опубликованных тегов не входят в Прямой push в `main`, force-push и перенос опубликованных тегов не входят в
обычную работу. Перед release/hotfix прочитай местный README, раздел «Релизы». обычную работу. PR сливается squash или rebase-merge; merge-коммит не
используется. `main` обновляется только fast-forward из `develop`, поэтому
`origin/main` всегда предок `origin/develop`:
`git merge-base --is-ancestor origin/main origin/develop`. Срочный fix —
обычная ветка `fix/*` от `develop`; пути hotfix от `main` нет.
Перед релизом прочитай местный README, раздел «Релизы».
+3 -1
View File
@@ -19,7 +19,9 @@ 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, VM и query providers даже транзитивно; Stories не достигают application API, auth и 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 иллюстрирует собственный
+1 -1
View File
@@ -47,7 +47,7 @@ throwing route, безопасная ссылка и reset после navigation
Примеры: [AuthForm test](../../src/features/auth/ui/AuthForm/AuthForm.test.tsx), Примеры: [AuthForm test](../../src/features/auth/ui/AuthForm/AuthForm.test.tsx),
[client test](../../src/shared/api/client.test.ts), [client test](../../src/shared/api/client.test.ts),
[usePortfolio test](../../src/entities/portfolio/api/usePortfolio.test.tsx). [resource lifecycle test](../../src/shared/api/resource-query.test.tsx).
Не копируй fixtures/сценарии целиком, если задача затрагивает один исход. Не копируй fixtures/сценарии целиком, если задача затрагивает один исход.
## Команды ## Команды
+50 -10
View File
@@ -5,7 +5,8 @@
- Сохраняй strict TS profile и `import type`. Для недоверенного значения используй - Сохраняй strict TS profile и `import type`. Для недоверенного значения используй
`unknown` с narrowing; `any` не заменяет DTO/validation. Локальное исключение `unknown` с narrowing; `any` не заменяет DTO/validation. Локальное исключение
на нетипизированной boundary должно быть узким и объяснённым. на нетипизированной boundary должно быть узким и объяснённым.
- `enum` и `namespace` не используются: frontend имеет `erasableSyntaxOnly`. - Строковые `enum` используются для machine codes и protocol values.
`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 находятся рядом или
@@ -14,7 +15,42 @@
на основе `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
заданы в [Biome](../../biome.json). Не распространяй их на application code. заданы в [ESLint](../../eslint.config.mjs). Не распространяй их на 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
@@ -33,19 +69,23 @@ 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`, не создавай ручную копию enum. Backend vocabulary бери из generated `paths`; generated artifacts вручную не меняй.
Текущий `schema.d.ts` содержит только types: typed wire literals допустимы, Для открытых `string` полей допускается локальный enum известных значений,
когда generator не предоставляет runtime values. используемых UI. Он не закрывает wire contract и сохраняет unknown fallback.
Если generator предоставляет runtime enum, используй его.
`ProblemDetailsDto.code` сейчас `string`. Не объявляй закрытый frontend error `ProblemDetailsDto.code` сейчас `string`. `ApiErrorCode` перечисляет известные UI коды, а не закрывает wire contract.
enum и не превращай неизвестный code в известный через cast. Повторяющуюся `apiErrorCode` сохраняет произвольную строку; `isApiErrorCode` проверяет известное
значение без cast и сохраняет fallback для неизвестного кода. Повторяющуюся
обработку своди у ответственного owner; [errors](data.md#errors). обработку своди у ответственного owner; [errors](data.md#errors).
`noMagicNumbers` в Biome — error; fixtures/tests/stories исключены только `@typescript-eslint/no-magic-numbers` — error; fixtures/tests/stories исключены
из этого правила. Policy получает имя и единицы; подавление допустимо для из этой дополнительной проверки. Policy получает имя и единицы; подавление допустимо для
incidental value с объяснением. Обычный UI-текст не превращай в constant: incidental value с объяснением. Обычный UI-текст не превращай в constant:
его owner — [i18n](ui.md#строки-и-валидация). его owner — [i18n](ui.md#строки-и-валидация).
Semantic magic strings и props profile требуют review. `no-restricted-syntax` запрещает inline machine codes и protocol values в production;
`no-unsafe-enum-comparison` проверяет сравнения enum. Fixtures/stories сохраняют
literal wire values. Новые vocabularies и props profile требуют review.
## Проверка ## Проверка
+52 -14
View File
@@ -11,13 +11,17 @@ Mantine component и общую тему. Для выпадающего выбо
ограничения JSDOM решай в test setup, сохраняя production UI. ограничения JSDOM решай в test setup, сохраняя production UI.
Семантический HTML для структуры страницы остаётся уместным. Семантический HTML для структуры страницы остаётся уместным.
Icons ищи у существующего owner (например, Icons импортируй из `@wealthledger/icons`; геометрия, SVG assets и animation
[BankOperationIcon](../../src/entities/bank-card/ui/BankOperationIcon/BankOperationIcon.tsx)); lifecycle принадлежат этому пакету. Domain/category lookup возвращает параметры
статичные Add/Edit/Delete доступны в `@wealthledger/icons`. Их SVG декоративны иконки из model сущности. Каждый SVG хранится цельным декларативным
по умолчанию; имя action принадлежит кнопке. Standalone meaningful icon получает компонентом: не разбивай path-строки и не собирай геометрию в runtime.
`aria-hidden={false}`, `role="img"` и доступное имя. Category/kind mappings Русские category names из API — данные для нормализованного сопоставления,
и анимации остаются в entity. Не добавляй пакет ради одного символа. Business/domain не ключи реестра иконок; неизвестные/переименованные категории имеют fallback.
components живут у feature/entity/page, даже при повторном использовании. Известные названия банков принадлежат enum `BankName` в bank-card model;
свободный ввод пользовательского названия остаётся строкой.
SVG декоративны по умолчанию; доступное имя action
принадлежит кнопке. Meaningful icon получает `aria-hidden={false}`, `role="img"`
и доступное имя. Приложение и Storybook подключают CSS пакета.
Wrapper нужен для проектной ответственности, а не ради нового имени Wrapper нужен для проектной ответственности, а не ради нового имени
для Button. Локальный CSS module принадлежит component; общий token вводи для для Button. Локальный CSS module принадлежит component; общий token вводи для
@@ -51,10 +55,40 @@ 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 нет data/auth/router/navigation/VM hooks и вычислений по domain collection. View получает готовые данные и именованные действия. Container вызывает VM;
Допустимы `useTranslation`, form-context hooks, UI-only state (например failed VM владеет запросами, формой, состоянием, обработчиками и derived values.
image fallback) и formatter одного переданного значения. Группировка/доли Во view нет data/auth/router/navigation hooks и domain grouping. Data VM
коллекции готовятся в VM. Перенос fetch во view ради меньшего container нарушает границу. вызывает container; presentation hook не должен скрывать fetching во view.
Presentation VM для form-context/UI-only state может использоваться автономно
в каталоге: её граф зависимостей не достигает application data/API.
`useTranslation` и декларативный вывод коллекции допустимы во view.
ESLint ограничивает прямые data/auth/router hook imports в `*.view.tsx`;
type imports, error helpers, router components и form context разрешены.
Транзитивный fetching через local presentation hook проверяется review;
Storybook дополнительно проверяет reachable graph. Новое исключение должно
сохранять presentation-only responsibility.
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 выноси в Отдельный state/section с самостоятельной responsibility выноси в
`components/` своего component. Небольшой tightly-coupled renderer оставляй `components/` своего component. Небольшой tightly-coupled renderer оставляй
@@ -82,11 +116,15 @@ image fallback) и formatter одного переданного значени
При переносе обновляй imports, ссылки документации и проверки, зависящие от пути. При переносе обновляй imports, ссылки документации и проверки, зависящие от пути.
Пример: [BankCardForm](../../src/entities/bank-card/ui/BankCardForm/BankCardForm.tsx) Пример: [BankCardForm](../../src/entities/bank-card/ui/BankCardForm/BankCardForm.tsx)
лежит рядом со своим view, тестом и story; общий `BankForms.module.css` лежит рядом со своим view, тестом и story. Формы используют Mantine
остаётся в `bank-card/ui`. `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 показывай у поля;
@@ -121,7 +159,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-строк в Biome/TypeScript нет. Synthetic story fixtures и непереведённых JSX-строк в ESLint/TypeScript нет. Synthetic story fixtures и
описания историй допустимы; они не заменяют продуктовую локализацию. описания историй допустимы; они не заменяют продуктовую локализацию.
## Проверка ## Проверка
+13 -7
View File
@@ -13,6 +13,9 @@
!public/** !public/**
!docker/ !docker/
!docker/** !docker/**
!scripts/
!scripts/minimatch-compat/
!scripts/minimatch-compat/**
!packages/ !packages/
!packages/tokens/ !packages/tokens/
!packages/tokens/package.json !packages/tokens/package.json
@@ -22,13 +25,7 @@
!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/theme.ts !packages/ui/src/**
!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
@@ -40,3 +37,12 @@
!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
@@ -0,0 +1,12 @@
---
name: Bug Report
about: Неверное поведение
---
## Проблема и шаги воспроизведения
## Факт / ожидание
## Acceptance Criteria
- [ ]
## Окружение / ссылки (если применимо)
+10
View File
@@ -0,0 +1,10 @@
---
name: Engineering Task
about: CI, тесты, документация, процесс и прочее
---
## Контекст и результат
## Scope / связанные Issues (если есть)
## Acceptance Criteria
- [ ]
+12
View File
@@ -0,0 +1,12 @@
---
name: Feature Request
about: Новое наблюдаемое поведение
---
## Зачем и какой результат
## Ожидаемое поведение
## Acceptance Criteria
- [ ]
## Зависимости / связанные Issues (если есть)
+12
View File
@@ -0,0 +1,12 @@
---
name: Refactoring / Technical Debt
about: Улучшение без нового поведения
---
## Проблема и результат
## Сохраняемые инварианты / границы
## Acceptance Criteria
- [ ]
## Зависимости / ссылки (если есть)
+11
View File
@@ -0,0 +1,11 @@
## Summary
## Linked Issue
Refs
## Changes
## Verification
- Команда / результат / ограничения:
## Breaking Changes (если есть)
+2
View File
@@ -30,6 +30,8 @@ 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'
@@ -1,11 +1,19 @@
name: Conventional commits name: Frontend verify
# 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: [opened, synchronize, reopened, edited] types: [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
-33
View File
@@ -1,33 +0,0 @@
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
+70 -2
View File
@@ -2,12 +2,78 @@ name: Frontend verify
on: on:
push: push:
branches: branches: [develop, main]
- '**'
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=moderate
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
@@ -21,6 +87,8 @@ 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,6 +12,7 @@ dist
storybook-static storybook-static
dist-ssr dist-ssr
coverage coverage
.vitest
.scannerwork .scannerwork
*.local *.local
+5 -2
View File
@@ -1,3 +1,6 @@
gitleaks git --pre-commit --staged --redact=100 --no-banner || exit $? gitleaks git --pre-commit --staged --redact=100 --no-banner || exit $?
npx lint-staged if [ ! -x node_modules/.bin/lint-staged ]; then
npm run deps echo "lint-staged is not installed; run npm ci" >&2
exit 1
fi
node_modules/.bin/lint-staged
+1 -1
View File
@@ -1 +1 @@
24.21.0 24.22.1
+7 -2
View File
@@ -5,11 +5,16 @@ 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: "^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)/", path: [
"^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,
}, },
}, },
+2 -2
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 = {
stories: ["../src/**/*.stories.tsx", "../packages/*/src/**/*.stories.tsx"],
addons: ["@storybook/addon-docs"], addons: ["@storybook/addon-docs"],
framework: "@storybook/react-vite",
core: { disableTelemetry: true }, core: { disableTelemetry: true },
framework: "@storybook/react-vite",
stories: ["../src/**/*.stories.tsx", "../packages/*/src/**/*.stories.tsx"],
}; };
export default config; export default config;
+20 -16
View File
@@ -1,39 +1,34 @@
import "@mantine/core/styles.css";
import "@mantine/charts/styles.css"; import "@mantine/charts/styles.css";
import "@mantine/core/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
cssVariablesResolver={cssVariablesResolver}
theme={theme}
forceColorScheme="light"
colorSchemeManager={colorSchemeManager} colorSchemeManager={colorSchemeManager}
cssVariablesResolver={cssVariablesResolver}
forceColorScheme="light"
theme={theme}
> >
<DatesProvider settings={{ locale: "ru" }}> <DatesProvider settings={{ locale: "ru" }}>
<Story /> <Story />
@@ -41,6 +36,15 @@ 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;
+4 -1
View File
@@ -4,6 +4,9 @@
только его файлы и предоставленные публичные контракты. Перед работой прочитай только его файлы и предоставленные публичные контракты. Перед работой прочитай
[порядок работы](.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`).
| Решение / сигнал | Читать | | Решение / сигнал | Читать |
| --- | --- | | --- | --- |
@@ -34,4 +37,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, Biome/TypeScript/dependency configs | | Versions/scripts/check scope | Manifest/lockfile, ESLint/TypeScript/dependency configs |
+203
View File
@@ -1,5 +1,208 @@
# Changelog # Changelog
## [1.4.0](https://git.personal.ksv741.ru/WealthLedger/frontend/compare/v1.3.0...v1.4.0) (2026-11-06)
### Features
* **investments:** warn and offer retry after exhausted read retries (WealthLedger/frontend[#55](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/55)) ([#136](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/136)) ([8ce1e8e](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/8ce1e8e44486dcc23d4c074559ca2bb517880b60))
### Bug Fixes
* **account:** show source metadata on empty account and income (WealthLedger/frontend[#55](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/55)) ([#133](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/133)) ([9619dc9](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/9619dc979172dbc16391206e801ed67862d20116))
* align audit findings for Events, currency and auth (WealthLedger/frontend[#155](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/155)) ([668f2ef](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/668f2ef70e535a1222e7f600b1be4a31fefc6f8b))
### Refactoring
* **api:** consume bank operation oneOf contract (WealthLedger/frontend[#144](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/144)) ([#152](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/152)) ([2d070ce](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/2d070ce2b54bbee466ecf6cc18b437910f0c07f8))
* drop dead query helpers and simplify read deadline (WealthLedger/frontend[#141](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/141)) ([#149](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/149)) ([5077bd4](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/5077bd4f0baa4a59643e875485c2f08253cc198d))
* move portfolio structure into account page, drop widgets (WealthLedger/frontend[#139](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/139)) ([#140](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/140)) ([e2d143d](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/e2d143df46e650139e49b7c80a886008ede86d2a))
* normalize view models and share breadcrumbs and titles (WealthLedger/frontend[#143](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/143)) ([#153](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/153)) ([2db39f2](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/2db39f25d9478350ad56a1b2e2ca6496068257c5))
* replace repeated lazy page wrappers with lazyPage helper ([#131](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/131)) ([#132](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/132)) ([71fbe77](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/71fbe7774d3ed63fcb84bc644da997921bc049b2))
* share path-root query predicate (WealthLedger/frontend[#142](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/142)) ([#154](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/154)) ([cadba8d](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/cadba8d075daf9b20c0c9de97ad5bd8f5a3e74fc))
### Maintenance
* **api:** update OpenAPI snapshot with UpcomingEventsDto.window (WealthLedger/frontend[#134](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/134)) ([#135](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/135)) ([aaee959](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/aaee9591d7880193ee9b0c14339e8477264e4a94))
* **deps:** set Renovate release age, PR limit and update groups (WealthLedger/main[#6](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/6)) ([#137](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/137)) ([ad93725](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/ad93725a84615ec329631b71ed8db33337085740))
## [1.3.0](https://git.personal.ksv741.ru/WealthLedger/frontend/compare/v1.2.1...v1.3.0) (2026-11-01)
### Features
* **account:** add concentration tab with top-5 slices and chart (WealthLedger/main[#48](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/48)) ([#127](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/127)) ([70a2c33](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/70a2c335fdddb8b6a99e3f609befe06aeb8cc45d))
* group all-account rows by instrument and day (WealthLedger/main[#47](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/47)) ([#126](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/126)) ([71126c9](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/71126c91f0b30f4d65a313ddc7d7f86963340455))
* make brokerage account list compact ([#111](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/111)) ([c24704a](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/c24704affacbe7fbc08f5e2b61bb6e7ffa6053b9))
* remove top header and rework mobile bottom navigation ([#109](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/109)) ([1a92f02](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/1a92f023c1c79f646424f72710ab74de63386a11))
* replace account page accordion with tabs and show open position result ([#112](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/112)) ([3075081](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/3075081df188de0630d314ba15a34fe6efd1eea9))
* show return, cash and structure on account cards ([#114](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/114)) ([431559b](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/431559be0fca9eb37f6a09d930d502f939884e39))
### Bug Fixes
* round money to currency minor units without negative zero (WealthLedger/frontend[#118](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/118)) ([#119](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/119)) ([1d49904](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/1d4990491f254e1ec6bafad8204872eaae2982d0))
* show account source sync status (WealthLedger/frontend[#120](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/120)) ([#121](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/121)) ([a56edf2](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/a56edf2b3757ef0b0f6c42ecbd422a1a563f6505))
* show history loading alert only while loading (WealthLedger/frontend[#124](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/124)) ([#125](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/125)) ([6792f5b](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/6792f5b70aa3ba90b2eb2df7e63d3ae2f669e295))
* stabilize auth client registration (WealthLedger/frontend[#95](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/95)) ([#122](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/122)) ([5796d2d](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/5796d2d789963e66a3a4bcd79eab44700f16bd31))
### Refactoring
* derive api error codes from contract (WealthLedger/backend[#117](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/117)) ([#123](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/123)) ([32e8fe9](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/32e8fe9402665ea0c350f5f247d87e328ffd44c8))
### Tests
* pin date in all accounts events test ([#115](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/115)) ([58b4e12](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/58b4e126330d01c1d6865bd0e9dd066a028fba08))
### Maintenance
* **deps:** resolve minimatch and glob audit advisories (WealthLedger/main[#49](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/49)) ([#128](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/128)) ([7242240](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/7242240b2188e9d1ca1325e65d0e1a0a3655d882))
* sync brokerage accounts contract (WealthLedger/backend[#138](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/138)) ([0e4bbec](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/0e4bbec2a7b814579095ea7ef6db68a70508fa29))
## [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) ## [0.4.0](https://git.personal.ksv741.ru/WealthLedger/frontend/compare/v0.3.0...v0.4.0) (2026-09-26)
### Features ### Features
+3 -2
View File
@@ -1,10 +1,11 @@
FROM node:24.21.0-bookworm-slim AS dependencies FROM node:24.22.1-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 ./
COPY packages/tokens/package.json ./packages/tokens/ COPY packages/tokens/package.json ./packages/tokens/
COPY packages/ui/package.json ./packages/ui/ COPY packages/ui/package.json ./packages/ui/
COPY packages/icons/package.json ./packages/icons/ COPY packages/icons/package.json ./packages/icons/
COPY scripts/minimatch-compat ./scripts/minimatch-compat
RUN npm ci RUN npm ci
FROM dependencies AS development FROM dependencies AS development
@@ -19,7 +20,7 @@ COPY . .
ENV VITE_API_BASE_URL="" ENV VITE_API_BASE_URL=""
RUN npm run build RUN npm run build
FROM nginx:1.28.0-alpine AS production FROM nginx:1.31.8-alpine AS production
ARG VERSION ARG VERSION
ARG REVISION ARG REVISION
LABEL org.opencontainers.image.version=$VERSION \ LABEL org.opencontainers.image.version=$VERSION \
+115 -36
View File
@@ -19,8 +19,10 @@ 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 с полной редакцией секретов, затем запускает formatting и FSD checks. staged diff с полной редакцией секретов, затем запускает установленный
Находка или отсутствие Gitleaks блокирует commit. `node_modules/.bin/lint-staged` только для staged JS/TS. Hook не скачивает tools и
не запускает 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
@@ -75,20 +77,71 @@ 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, Biome, один coverage-backed запуск Vitest, затем запускает strict TypeScript, ESLint и проверку его конфигурации,
один coverage-backed запуск Vitest,
Knip, dependency rules приложения и stories, сборки приложения и Storybook. Knip, dependency rules приложения и stories, сборки приложения и Storybook.
Отдельный CI workflow `Frontend security` на push, pull request и ежедневно
проверяет полную доступную историю Gitleaks 8.30.1 и выполняет `npm run lint` запускает ESLint с `--max-warnings=0`; `npm run lint:fix`
`npm audit --include=dev --audit-level=low`. Любой advisory блокирует security job; применяет автофиксы. [Конфигурация](eslint.config.mjs) подключает рекомендованные
исключений frontend нет. Локально можно выполнить те же команды: наборы всех установленных плагинов: 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=moderate`. Advisory уровня moderate и выше
блокирует security job; low не блокирует, т.к. для них часто нет исправленной версии
(например, GHSA-gcqx-w48m-w2h4 в build-only deepmerge). Других исключений нет.
Локально можно выполнить те же команды:
`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=moderate`. 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` для обхода этого ограничения
@@ -155,7 +208,8 @@ Checkout frontend содержит все входы сборки и прове
локальную копию и не обращаются к исходникам или CI сервера. Доставка новой локальную копию и не обращаются к исходникам или CI сервера. Доставка новой
схемы — явное обновление snapshot с review и `npm run openapi:generate`. схемы — явное обновление snapshot с review и `npm run openapi:generate`.
Runtime URL API задаётся окружением, а не расположением другого репозитория. Runtime URL API задаётся окружением, а не расположением другого репозитория.
Разработка идёт напрямую по задаче пользователя; OpenSpec/SDD — по явному запросу. Разработка начинается с Issue; lightweight или 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 закреплена напрямую.
@@ -163,36 +217,61 @@ preset по имени из `.release-it.json`. Версия preset закреп
## Релизы ## Релизы
Репозиторий выпускается независимо. `develop` — интеграция, `main` — принятые Репозиторий выпускается независимо. `develop` — интеграция, `main` — принятые
релизы. Работа: `codex/<task>` от актуального `develop`, PR в `develop`. релизы. Обычная работа: issue-linked ветка от актуального `develop`, PR в `develop`;
Обычные PR допускают squash; release и обратная синхронизация сохраняют историю. общие правила — в Engineering Workflow по ссылке из AGENTS.
Commits и PR titles — Conventional Commits, включая `spec:`. Версии вручную не повышай. PR сливается squash или rebase-merge; merge-коммит не используется. `main`
обновляется только fast-forward из `develop`, поэтому история линейна:
1. Получи `main`, `develop` и теги: `git fetch origin --tags`. Убедись, что ```text
`develop` содержит последний релиз. Создай `release/<name>` от проверенного <type>/<issue>-<slug> --squash--> develop --ff-only--> main --> tag vX.Y.Z
`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 в `main` с заголовком `chore(release): X.Y.Z`. После зелёных открой PR в **`develop`** с заголовком `chore(release): X.Y.Z`. После зелёных
`verify`, `security`, `commits` выполни fast-forward-only merge. Если `main` `verify`, `security`, `commits` выполни fast-forward-only merge. Если
изменился, согласуй историю и повтори проверки кандидата. `develop` изменился, обнови release-ветку и повтори проверки кандидата.
4. Получи удалённый `main`, сравни его HEAD с проверенным release SHA и версией 4. Открой PR `develop → main` с тем же заголовком и слей fast-forward-only.
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; проверь,
что `develop` содержит тег. Опубликованные теги не перемещай. При сбое после Обратная синхронизация не нужна: release-коммит попал в `develop` на шаге 3.
merge создай отсутствующий тег без нового bump; до merge исправь и проверь Опубликованные теги не перемещай. При сбое после 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`, `security`, `commits`; теги `v*` защищены. администратором, включена блокировка устаревшей ветки, обязательны `verify`,
`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 не включены.
@@ -214,14 +293,14 @@ Library build использует отдельный Vite config без app plu
Начальная версия manifest взята из приложения; отдельный release-процесс Начальная версия manifest взята из приложения; отдельный release-процесс
и публикация не настроены. и публикация не настроены.
Private `@wealthledger/icons` экспортирует `AddIcon`, `EditIcon`, `DeleteIcon` Private `@wealthledger/icons` содержит все SVG приложения: действия,
и type `IconProps`. Единственный source рисунка — TSX в `packages/icons/src/svg`; навигацию, contactless/wallet и геометрию `OperationIcon`. Пакет поставляет
поставка содержит ESM/declarations, без raw SVG, CSS, Mantine и motion. ESM, declarations и `@wealthledger/icons/styles.css`; приложение и Storybook
React — peer/external. Размер `size` по умолчанию 24, `strokeWidth` — 1.5; подключают CSS явно. В `src` нет raw SVG и компонентов только для рисования
цвет наследуется через `currentColor`, SVG props можно передать явно. иконок. Category/kind mapping остаётся в model `entities/bank-card`.
В action используйте декоративный `<AddIcon size={20} strokeWidth={2} />`,
доступное имя задавайте кнопке. Standalone пример: Статичные иконки принимают `IconProps`: `size` по умолчанию 24,
`<EditIcon aria-hidden={false} role="img" aria-label="Редактирование" />`. `strokeWidth` 1.5, цвет через `currentColor`. В кнопке иконка декоративная,
Поддерживается также стандартный `aria-labelledby`; подпись выбирает caller. имя принадлежит действию. Для самостоятельного изображения передайте
Card/Cards используют пакетные Add/Edit/Delete с прежними размерами/strokeWidth. `aria-hidden={false}`, `role="img"` и `aria-label` либо `aria-labelledby`.
Restore/category SVG и предметный BankOperationIcon остаются в приложении. Анимации сохраняют hover/focus lifecycle и `prefers-reduced-motion`.
+70
View File
@@ -0,0 +1,70 @@
# 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
@@ -1,80 +0,0 @@
{
"$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,6 +1,12 @@
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",
+31 -12
View File
@@ -1,63 +1,82 @@
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 },
}, },
{ {
name: "slices-must-use-public-api",
severity: "error",
from: { from: {
path: "^src/(app|pages|features|entities|shared)/([^/]+)/", path: "^src/(app|pages|features|entities|shared)/([^/]+)/",
}, },
name: "slices-must-use-public-api",
severity: "error",
to: { to: {
path: "^src/(app|pages|features|entities|shared)/([^/]+)/(?!index\\.ts$)", path: String.raw`^src/(app|pages|features|entities|shared)/([^/]+)/(?!index\.ts$)`,
pathNot: "^src/$1/$2/", pathNot: "^src/$1/$2/",
}, },
}, },
{ {
from: { path: "^src/(pages|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",
from: { path: "^src/shared/" },
to: { path: "^src/(entities|features|pages|app)/" }, 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",
from: { path: "^src/entities/" },
to: { path: "^src/(features|pages|app)/" }, 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",
from: { path: "^src/features/" },
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: {
exclude: "^packages/[^/]+/dist/",
doNotFollow: { path: "node_modules" }, doNotFollow: { path: "node_modules" },
tsConfig: { fileName: "tsconfig.app.json" }, enhancedResolveOptions: {
conditionNames: ["import", "require", "node", "default"],
exportsFields: ["exports"],
},
exclude: "^packages/[^/]+/dist/",
tsConfig: { fileName: "tsconfig.dependency-cruiser.json" },
tsPreCompilationDeps: true, tsPreCompilationDeps: true,
}, },
}; };
+9
View File
@@ -17,12 +17,21 @@ http {
default $http_x_forwarded_proto; default $http_x_forwarded_proto;
'' $scheme; '' $scheme;
} }
# One shared budget: the external proxy's client-IP forwarding is not verified.
map "$request_method:$uri" $auth_admission_key {
default "";
POST:/api/v1/auth/login auth;
POST:/api/v1/auth/register auth;
}
limit_req_zone $auth_admission_key zone=auth_admission:1m rate=2r/s;
limit_req_status 429;
server { server {
listen 8080; listen 8080;
server_name _; server_name _;
root /usr/share/nginx/html; root /usr/share/nginx/html;
resolver 127.0.0.11 valid=5s ipv6=off; resolver 127.0.0.11 valid=5s ipv6=off;
location /api/ { location /api/ {
limit_req zone=auth_admission burst=5 nodelay;
set $backend http://backend:3000; set $backend http://backend:3000;
proxy_pass $backend$request_uri; proxy_pass $backend$request_uri;
proxy_set_header Host $http_host; proxy_set_header Host $http_host;
+183
View File
@@ -0,0 +1,183 @@
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(node) {
checkParameters(node);
if (node.id?.name?.endsWith?.("ViewModel"))
context.report({ messageId: "vmName", node: node.id });
},
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.",
vmName: "Name custom hooks use*Vm, not use*ViewModel.",
},
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
@@ -0,0 +1,323 @@
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)]",
},
],
},
},
);
+1 -1
View File
@@ -5,7 +5,7 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" /> <link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<meta name="theme-color" content="#ffffff" /> <meta name="theme-color" content="#ffffff" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>WealthLedger</title> <title>WealthLedger</title>
</head> </head>
<body> <body>
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"ignoreDependencies": ["conventional-changelog-conventionalcommits"], "ignoreDependencies": ["conventional-changelog-conventionalcommits", "minimatch"],
"workspaces": { "workspaces": {
".": { ".": {
"project": ["src/**/*.{ts,tsx}", "!src/shared/api/generated/**"] "project": ["src/**/*.{ts,tsx}", "!src/shared/api/generated/**"]
+3899 -2377
View File
File diff suppressed because it is too large Load Diff
+82 -42
View File
@@ -1,10 +1,10 @@
{ {
"name": "wealth-ledger-frontend", "name": "wealth-ledger-frontend",
"private": true, "private": true,
"version": "0.4.0", "version": "1.4.0",
"type": "module", "type": "module",
"engines": { "engines": {
"node": "24.21.0" "node": "24.22.1"
}, },
"scripts": { "scripts": {
"dev": "npm run build:packages && vite", "dev": "npm run build:packages && vite",
@@ -13,81 +13,121 @@
"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": "biome check .", "lint": "npm run lint:eslint",
"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": "node scripts/check-openapi.mjs", "openapi:check": "openapi-typescript src/shared/api/spec/openapi.json -o src/shared/api/generated/schema.d.ts --check",
"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 && 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", "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",
"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": {
"*.{css,html,json,md,ts,tsx}": "biome format --write --no-errors-on-unmatched" "*.{js,mjs,cjs,ts,tsx}": "eslint --fix --max-warnings=0 --no-warn-ignored"
}, },
"dependencies": { "dependencies": {
"@hookform/resolvers": "5.9.1", "@hookform/resolvers": "5.9.2",
"@mantine/charts": "9.6.1", "@mantine/charts": "9.7.3",
"@mantine/core": "9.6.1", "@mantine/core": "9.7.3",
"@mantine/dates": "9.6.1", "@mantine/dates": "9.7.3",
"@mantine/hooks": "9.6.1", "@mantine/hooks": "9.7.3",
"@tanstack/react-query": "5.102.8", "@mantine/notifications": "9.7.3",
"@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.4.2", "i18next": "26.5.10",
"motion": "13.2.0", "motion": "13.5.1",
"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.0", "react": "19.3.1",
"react-dom": "19.3.0", "react-dom": "19.3.1",
"react-hook-form": "7.88.0", "react-hook-form": "7.91.0",
"react-i18next": "17.0.14", "react-i18next": "17.1.8",
"react-is": "19.3.0", "react-is": "19.3.1",
"react-router": "8.3.1", "react-router": "8.5.0",
"recharts": "3.10.1", "recharts": "3.10.1",
"zod": "4.6.4" "zod": "4.6.7"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "8.0.5", "@babel/core": "8.0.9",
"@biomejs/biome": "2.5.13", "@commitlint/cli": "21.2.4",
"@commitlint/cli": "21.2.2", "@commitlint/config-conventional": "21.2.4",
"@commitlint/config-conventional": "21.2.2", "@eslint-react/eslint-plugin": "5.28.8",
"@release-it/conventional-changelog": "12.0.0", "@eslint/js": "10.0.1",
"@release-it/conventional-changelog": "12.0.2",
"@rolldown/plugin-babel": "0.2.4", "@rolldown/plugin-babel": "0.2.4",
"@storybook/addon-docs": "10.6.0", "@storybook/addon-docs": "10.6.3",
"@storybook/react-vite": "10.6.0", "@storybook/react-vite": "10.6.3",
"@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.13.4", "@types/node": "24.19.2",
"@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.1", "@vitejs/plugin-react": "6.1.2",
"@vitest/coverage-v8": "5.0.0", "@vitest/browser": "5.0.5",
"@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.0", "conventional-changelog-conventionalcommits": "10.4.1",
"dependency-cruiser": "18.2.0", "dependency-cruiser": "18.7.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.0.1", "jsdom": "30.1.3",
"knip": "6.35.1", "knip": "6.43.0",
"lint-staged": "17.5.1", "lint-staged": "17.6.0",
"minimatch": "file:scripts/minimatch-compat",
"msw": "2.15.0", "msw": "2.15.0",
"openapi-typescript": "7.13.0", "openapi-typescript": "7.13.0",
"release-it": "21.0.2", "playwright": "1.64.1",
"storybook": "10.6.0", "release-it": "21.1.1",
"storybook": "10.6.3",
"typescript": "5.9.3", "typescript": "5.9.3",
"vite": "8.3.0", "typescript-eslint": "8.72.0",
"vite-plugin-pwa": "1.3.0", "vite": "8.3.5",
"vitest": "5.0.0" "vite-plugin-pwa": "2.0.0",
"vitest": "5.0.5"
},
"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",
"minimatch": "$minimatch",
"glob": "14.0.3"
}, },
"allowScripts": { "allowScripts": {
"esbuild@0.28.2": true, "esbuild@0.28.2": true,
+9 -6
View File
@@ -10,21 +10,24 @@
".": { ".": {
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
"import": "./dist/index.js" "import": "./dist/index.js"
}
}, },
"sideEffects": false, "./styles.css": "./dist/index.css"
},
"sideEffects": [
"**/*.css"
],
"scripts": { "scripts": {
"build": "vite build && tsc -p tsconfig.json" "build": "vite build && tsc -p tsconfig.json"
}, },
"peerDependencies": { "peerDependencies": {
"react": "19.3.0" "react": "19.3.1"
}, },
"devDependencies": { "devDependencies": {
"@storybook/react-vite": "10.6.0", "@storybook/react-vite": "10.6.3",
"@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.0", "vite": "8.3.5",
"vitest": "5.0.0" "vitest": "5.0.5"
} }
} }
+16 -10
View File
@@ -1,10 +1,9 @@
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: {
@@ -13,21 +12,26 @@ 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={{ display: "flex", gap: 16, color: "#2563eb" }}> <div style={{ color: "#2563eb", display: "flex", gap: 16 }}>
<button type="button" aria-label="Добавить пример"> <button aria-label="Добавить пример" type="button">
<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>
@@ -36,10 +40,12 @@ export const Decorative: Story = {
export const Meaningful: Story = { export const Meaningful: Story = {
render: () => ( render: () => (
<div style={{ display: "flex", gap: 16, color: "#2563eb" }}> <div style={{ color: "#2563eb", display: "flex", gap: 16 }}>
<AddIcon aria-hidden={false} role="img" aria-label="Добавление" /> <AddIcon aria-hidden={false} aria-label="Добавление" role="img" />
<EditIcon aria-hidden={false} role="img" aria-label="Редактирование" />
<DeleteIcon aria-hidden={false} role="img" aria-label="Удаление" /> <EditIcon aria-hidden={false} aria-label="Редактирование" role="img" />
<DeleteIcon aria-hidden={false} aria-label="Удаление" role="img" />
</div> </div>
), ),
}; };
+18 -3
View File
@@ -1,25 +1,28 @@
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 type="button" aria-label="Действие"> <button aria-label="Действие" type="button">
<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}
role="img"
aria-label="Пример" aria-label="Пример"
role="img"
size={18} size={18}
strokeWidth={2} strokeWidth={2}
/>, />,
@@ -31,4 +34,16 @@ 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,4 +1,5 @@
.icon { .icon {
flex-shrink: 0;
overflow: visible; overflow: visible;
& path { & path {
@@ -0,0 +1,72 @@
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 };
@@ -0,0 +1,40 @@
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 };
@@ -0,0 +1,40 @@
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 };
@@ -0,0 +1,38 @@
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 };
@@ -0,0 +1,40 @@
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 };
@@ -0,0 +1,38 @@
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 };
@@ -0,0 +1,42 @@
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 };
@@ -0,0 +1,40 @@
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 };
@@ -0,0 +1,36 @@
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 };
@@ -0,0 +1,38 @@
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 };
@@ -0,0 +1,36 @@
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 };
@@ -0,0 +1,36 @@
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 };
@@ -0,0 +1,32 @@
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 };
@@ -0,0 +1,36 @@
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 };
@@ -0,0 +1,34 @@
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 };
@@ -0,0 +1,36 @@
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 };
@@ -0,0 +1,42 @@
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 };
@@ -0,0 +1,34 @@
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 };
@@ -0,0 +1,34 @@
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 };
@@ -0,0 +1,38 @@
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 };
@@ -0,0 +1,38 @@
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 };
@@ -0,0 +1,44 @@
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 };
@@ -0,0 +1,36 @@
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 };
@@ -0,0 +1,34 @@
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 };
@@ -0,0 +1,32 @@
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 };
@@ -0,0 +1,38 @@
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 };
@@ -0,0 +1,38 @@
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 };

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

Before

Width:  |  Height:  |  Size: 8.5 KiB

After

Width:  |  Height:  |  Size: 8.5 KiB

@@ -1,36 +1,38 @@
import { fireEvent, render, waitFor } from "@testing-library/react"; import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, expect, it, vi } from "vitest"; import { afterEach, expect, it, vi } from "vitest";
import { bindIconAnimation } from "./bind-icon-animation"; import { bindIconAnimation } from "./bind-icon-animation";
afterEach(() => vi.restoreAllMocks()); afterEach(() => vi.restoreAllMocks());
it("finishes every delayed part after leaving and does not restart on quick re-entry", async () => { it("finishes every delayed part after leaving and does not restart on quick re-entry", async () => {
vi.spyOn(window, "matchMedia").mockReturnValue({ vi.spyOn(globalThis, "matchMedia").mockReturnValue({
matches: true, matches: true,
} as MediaQueryList); } as MediaQueryList);
const { container, getByRole } = render(
const { container } = render(
<button type="button"> <button type="button">
<svg ref={bindIconAnimation} aria-hidden="true" /> <svg aria-hidden="true" ref={bindIconAnimation} />
Category Category
</button>, </button>,
); );
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- Bind browser animation doubles to the decorative SVG, which has no accessible role.
const icon = container.querySelector("svg"); const icon = container.querySelector("svg");
if (!icon) throw new Error("Missing icon"); if (!icon) throw new Error("Missing icon");
const target = getByRole("button"); const target = screen.getByRole("button");
let finishFirst!: () => void; let finishFirst!: () => void;
let finishLast!: () => void; let finishLast!: () => void;
const getAnimations = vi.fn(() => [
{ const getAnimations = vi.fn(() => {
finished: new Promise<void>((resolve) => { const first = Promise.withResolvers<void>();
finishFirst = resolve; const last = Promise.withResolvers<void>();
}), finishFirst = first.resolve;
}, finishLast = last.resolve;
{
finished: new Promise<void>((resolve) => { return [{ finished: first.promise }, { finished: last.promise }];
finishLast = resolve; });
}),
},
]);
Object.defineProperty(icon, "getAnimations", { value: getAnimations }); Object.defineProperty(icon, "getAnimations", { value: getAnimations });
fireEvent.mouseEnter(target); fireEvent.mouseEnter(target);
fireEvent.mouseLeave(target); fireEvent.mouseLeave(target);
@@ -50,18 +52,20 @@ it("finishes every delayed part after leaving and does not restart on quick re-e
}); });
it("keeps playing after keyboard blur and releases cancelled animations", async () => { it("keeps playing after keyboard blur and releases cancelled animations", async () => {
const { container, getByRole, unmount } = render( const { container, unmount } = render(
<button type="button"> <button type="button">
<svg ref={bindIconAnimation} aria-hidden="true" /> <svg aria-hidden="true" ref={bindIconAnimation} />
Category Category
</button>, </button>,
); );
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- Bind browser animation doubles to the decorative SVG, which has no accessible role.
const icon = container.querySelector("svg"); const icon = container.querySelector("svg");
if (!icon) throw new Error("Missing icon"); if (!icon) throw new Error("Missing icon");
const target = getByRole("button"); const target = screen.getByRole("button");
// JSDOM does not model the browser keyboard-focus modality. // JSDOM does not model the browser keyboard-focus modality.
vi.spyOn(target, "matches").mockImplementation( vi.spyOn(target, "matches").mockImplementation(
(selector) => selector === ":focus-visible", selector => selector === ":focus-visible",
); );
let cancel!: () => void; let cancel!: () => void;
Object.defineProperty(icon, "getAnimations", { Object.defineProperty(icon, "getAnimations", {
@@ -1,67 +1,74 @@
// React ref cleanup keeps native animation lifetimes tied to the mounted icon. // React ref cleanup keeps native animation lifetimes tied to the mounted icon.
const bindIconAnimation = (icon: SVGSVGElement | null) => { const bindIconAnimation = (icon: null | SVGSVGElement) => {
if (!icon) return; if (!icon) return;
const target = icon.closest<HTMLElement>("a, button") ?? icon.parentElement; const target = icon.closest<HTMLElement>("a, button") ?? icon.parentElement;
if (!target) return; if (!target) return;
let hovered = false; let isHovered = false;
let focused = false; let isFocused = false;
let running = false; let isRunning = false;
let disposed = false; let isDisposed = false;
const release = () => { const release = () => {
if (!running && !hovered && !focused) { if (!isRunning && !isHovered && !isFocused) {
icon.removeAttribute("data-icon-animating"); delete icon.dataset.iconAnimating;
} }
}; };
const start = () => { const start = () => {
if (icon.hasAttribute("data-icon-animating")) return; if (Object.hasOwn(icon.dataset, "iconAnimating")) return;
icon.setAttribute("data-icon-animating", ""); icon.dataset.iconAnimating = "";
running = true; isRunning = true;
// Includes staggered/delayed children and finite iterations, without timers. // Includes staggered/delayed children and finite iterations, without timers.
const animations = icon.getAnimations({ subtree: true }); const animations = icon.getAnimations({ subtree: true });
void Promise.allSettled( void Promise.allSettled(
animations.map((animation) => animation.finished), animations.map(animation => animation.finished),
).then(() => { ).then(() => {
if (disposed) return; if (isDisposed) return;
running = false; isRunning = false;
release(); release();
}); });
}; };
const enter = () => { const enter = () => {
if (!window.matchMedia("(hover: hover)").matches) return; if (!matchMedia("(hover: hover)").matches) return;
hovered = true; isHovered = true;
start(); start();
}; };
const leave = () => { const leave = () => {
hovered = false; isHovered = false;
release(); release();
}; };
const focus = () => { const focus = () => {
focused = isFocused
target.matches(":focus-visible") || = target.matches(":focus-visible")
!!target.querySelector(":focus-visible"); || !!target.querySelector(":focus-visible");
if (focused) start(); if (isFocused) start();
}; };
const blur = (event: FocusEvent) => { const blur = (event: FocusEvent) => {
if ( if (
event.relatedTarget instanceof Node && event.relatedTarget instanceof Node
target.contains(event.relatedTarget) && target.contains(event.relatedTarget)
) )
return; return;
focused = false; isFocused = false;
release(); release();
}; };
target.addEventListener("mouseenter", enter); target.addEventListener("mouseenter", enter);
target.addEventListener("mouseleave", leave); target.addEventListener("mouseleave", leave);
target.addEventListener("focusin", focus); target.addEventListener("focusin", focus);
target.addEventListener("focusout", blur); target.addEventListener("focusout", blur);
return () => { return () => {
disposed = true; isDisposed = true;
target.removeEventListener("mouseenter", enter); target.removeEventListener("mouseenter", enter);
target.removeEventListener("mouseleave", leave); target.removeEventListener("mouseleave", leave);
target.removeEventListener("focusin", focus); target.removeEventListener("focusin", focus);
target.removeEventListener("focusout", blur); target.removeEventListener("focusout", blur);
icon.removeAttribute("data-icon-animating"); delete icon.dataset.iconAnimating;
}; };
}; };
+13
View File
@@ -1,4 +1,17 @@
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";
+179
View File
@@ -0,0 +1,179 @@
.rubles {
pointer-events: none;
color: var(--mantine-color-green-8);
stroke: currentColor;
& path {
opacity: 0;
--flight-x: -7px;
--flight-rotation: -20deg;
--flight-delay: 0ms;
}
& path:nth-child(2) {
--flight-x: 0px;
--flight-rotation: 8deg;
--flight-delay: 120ms;
}
& path:nth-child(3) {
--flight-x: 7px;
--flight-rotation: 24deg;
--flight-delay: 240ms;
}
}
.portfolioRuble {
stroke: var(--mantine-color-green-8);
opacity: 0;
}
.stockArrow {
transform-origin: 21px 5px;
}
.topSlider,
.bottomSlider {
fill: var(--mantine-color-body);
}
@media (prefers-reduced-motion: no-preference) {
.drawStroke,
.couponLines path {
stroke-dasharray: 1;
}
.topSlider {
--slider-offset: 3px;
--slider-delay: 0ms;
}
.bottomSlider {
--slider-offset: -3px;
--slider-delay: 160ms;
}
.couponLines path:nth-child(2) {
--line-delay: 140ms;
}
.couponLines path:nth-child(3) {
--line-delay: 280ms;
}
svg[data-icon-animating] {
.rubles path {
animation: ruble-fly 800ms ease-out var(--flight-delay) both;
}
.portfolioHandle {
animation: handle-lift 1200ms ease-in-out;
}
.portfolioLid {
animation: lid-open 1200ms ease-in-out;
}
.portfolioRuble {
animation: portfolio-ruble-reveal 1200ms ease-in-out;
}
.stockArrow {
animation: arrow-pop 300ms ease-out 400ms both;
}
.couponLines path {
animation: stroke-draw 300ms ease-out var(--line-delay, 0ms) both;
}
.drawStroke {
animation: stroke-draw 450ms ease-out;
}
:is(.topSlider, .bottomSlider) {
animation: slider-slide 650ms ease-in-out var(--slider-delay) both;
}
}
}
@keyframes ruble-fly {
0% {
opacity: 0;
transform: translate(0, 4px);
}
20%,
50% {
opacity: 1;
}
100% {
opacity: 0;
transform: translate(var(--flight-x), -10px) rotate(var(--flight-rotation));
}
}
@keyframes handle-lift {
0%,
100% {
transform: translateY(0);
}
20% {
transform: translateY(-3px);
}
40%,
75% {
transform: translateY(-3px);
}
}
@keyframes lid-open {
0%,
15%,
100% {
transform: translateY(0);
}
40%,
75% {
transform: translateY(-3px);
}
}
@keyframes portfolio-ruble-reveal {
0%,
25%,
100% {
opacity: 0;
transform: translateY(2px);
}
40%,
75% {
opacity: 1;
transform: translateY(0);
}
}
@keyframes arrow-pop {
0% {
opacity: 0;
transform: scale(0.7);
}
60% {
opacity: 1;
transform: scale(1.2);
}
100% {
opacity: 1;
transform: scale(1);
}
}
@keyframes stroke-draw {
0% {
stroke-dashoffset: 1;
}
100% {
stroke-dashoffset: 0;
}
}
@keyframes slider-slide {
0%,
100% {
transform: translateX(0);
}
50% {
transform: translateX(var(--slider-offset));
}
}
+9 -7
View File
@@ -1,19 +1,21 @@
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
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
aria-hidden="true" aria-hidden="true"
fill="none"
focusable="false" focusable="false"
height={size}
stroke="currentColor"
strokeLinecap="round"
strokeWidth="1.5"
viewBox="0 0 24 24"
width={size}
{...svgProps} {...svgProps}
> >
<path d="M12 5v14 M5 12h14" /> <path d="M12 5v14 M5 12h14" />
+39
View File
@@ -0,0 +1,39 @@
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
@@ -0,0 +1,39 @@
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
@@ -0,0 +1,27 @@
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 };
@@ -0,0 +1,32 @@
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 };
+8 -6
View File
@@ -1,20 +1,22 @@
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
width={size} aria-hidden="true"
height={size}
viewBox="0 0 24 24"
fill="none" fill="none"
focusable="false"
height={size}
stroke="currentColor" stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
aria-hidden="true" strokeWidth="1.5"
focusable="false" viewBox="0 0 24 24"
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" />
+8 -6
View File
@@ -1,20 +1,22 @@
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
width={size} aria-hidden="true"
height={size}
viewBox="0 0 24 24"
fill="none" fill="none"
focusable="false"
height={size}
stroke="currentColor" stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
aria-hidden="true" strokeWidth="1.5"
focusable="false" viewBox="0 0 24 24"
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
@@ -0,0 +1,43 @@
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
@@ -0,0 +1,27 @@
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
@@ -0,0 +1,35 @@
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
@@ -0,0 +1,39 @@
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
@@ -0,0 +1,29 @@
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 };
+12 -7
View File
@@ -1,17 +1,22 @@
{ {
"extends": "../../tsconfig.base.json",
"compilerOptions": { "compilerOptions": {
"target": "ES2023", "lib": [
"lib": ["ES2023", "DOM"], "ES2023",
"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": ["src/index.ts"] "include": [
"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", formats: ["es"], fileName: "index" }, lib: { entry: "src/index.ts", fileName: "index", formats: ["es"] },
rolldownOptions: { external: /^react(\/|$)/ }, rolldownOptions: { external: /^react(\/|$)/ },
}, },
}); });
+1 -1
View File
@@ -131,7 +131,7 @@
}, },
"muted": { "muted": {
"color": "gray", "color": "gray",
"shade": 6, "shade": 7,
"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.6.1", "@mantine/core": "9.7.3",
"@mantine/hooks": "9.6.1", "@mantine/hooks": "9.7.3",
"react": "19.3.0", "react": "19.3.1",
"react-dom": "19.3.0" "react-dom": "19.3.1"
}, },
"devDependencies": { "devDependencies": {
"@storybook/react-vite": "10.6.0", "@storybook/react-vite": "10.6.3",
"@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.0", "storybook": "10.6.3",
"typescript": "5.9.3", "typescript": "5.9.3",
"vite": "8.3.0", "vite": "8.3.5",
"vitest": "5.0.0" "vitest": "5.0.5"
} }
} }
@@ -0,0 +1,38 @@
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);
});
@@ -93,3 +93,13 @@
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%);
}
}
+19 -13
View File
@@ -1,27 +1,27 @@
import { Button } from "@mantine/core";
import type { Meta, StoryObj } from "@storybook/react-vite"; import type { Meta, StoryObj } from "@storybook/react-vite";
import { Button } from "@mantine/core";
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 size="compact-sm" onClick={fn()}> <Button onClick={fn()} size="compact-sm">
Открыть пример Открыть пример
</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,8 +30,14 @@ 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 } };
+37 -8
View File
@@ -1,26 +1,45 @@
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("keeps the fallback after an image fails", () => { it("does not leak a numeric zero from an absent action", () => {
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 = {
title: "Пример", imageSrc: "data:image/png;base64,invalid",
metadata: "Описание",
initial: "У", initial: "У",
metadata: "Описание",
title: "Пример",
tone: "neutral" as const,
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 = container.querySelector("img"); const image = screen.queryByAltText("");
assert(image); assert(image);
fireEvent.error(image); fireEvent.error(image);
rerender( rerender(
@@ -28,8 +47,18 @@ describe("EntryCard", () => {
<EntryCard {...props} title="Обновлённый пример" /> <EntryCard {...props} title="Обновлённый пример" />
</MantineProvider>, </MantineProvider>,
); );
expect(container.querySelector("img")).toBeNull(); expect(screen.queryByAltText("")).not.toBeInTheDocument();
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();
}); });
}); });
+45 -28
View File
@@ -1,66 +1,83 @@
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 = {
title: string;
metadata: string;
details?: ReactNode;
actions?: ReactNode; actions?: ReactNode;
initial: string;
icon?: ReactNode;
color?: string; color?: string;
imageSrc?: string | null; details?: ReactNode;
value: string; icon?: ReactNode;
imageSrc?: null | string;
initial: string;
metadata: string;
title: string;
tone: "negative"
| "neutral"
| "positive";
value: ReactNode;
valueLabel: string; valueLabel: string;
tone: "positive" | "negative" | "neutral"; wrap?: boolean;
}; };
const EntryCard: FC<EntryCardProps> = (props) => { const EntryCard: FC<EntryCardProps> = (props) => {
const [imageFailed, setImageFailed] = useState(false); const surfaceStyle = props.color
return (
<Paper
className={classes.row}
withBorder
style={
props.color
? { ? {
background: `color-mix(in srgb, var(--mantine-color-${props.color}-0) 45%, var(--wl-color-surface))`, background: `color-mix(in srgb, var(--mantine-color-${props.color}-0) 45%, var(--wl-color-surface))`,
} }
: undefined : undefined;
}
const titleLineClamp = props.wrap ? undefined : 1;
const valueContainerClass = props.actions ? classes.valueFooter : undefined;
const valueLabelClass = `${classes[props.tone]} ${props.actions ? classes.valueLabel : ""}`;
const valueComponent = typeof props.value === "string" ? undefined : "div";
return (
<Paper
className={classes.row}
data-wrap={props.wrap || undefined}
style={surfaceStyle}
withBorder
> >
<Avatar <Avatar
alt="" alt=""
aria-hidden="true" aria-hidden="true"
radius="md" radius="md"
size={40} size={40}
src={imageFailed ? null : props.imageSrc} src={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={1}> <Text fw={700} lineClamp={titleLineClamp}>
{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]}
component={valueComponent}
fw={700}
>
{props.value} {props.value}
</Text> </Text>
<div className={props.actions ? classes.valueFooter : undefined}>
<Text <div className={valueContainerClass}>
className={`${classes[props.tone]} ${props.actions ? classes.valueLabel : ""}`} <Text className={valueLabelClass} size="sm">
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,16 +1,16 @@
import { Paper, Skeleton } from "@mantine/core";
import type { Meta, StoryObj } from "@storybook/react-vite"; import type { Meta, StoryObj } from "@storybook/react-vite";
import { Paper, Skeleton } from "@mantine/core";
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: {
label: { control: "text" },
children: { table: { disable: true } }, children: { table: { disable: true } },
label: { control: "text" },
}, },
component: LoadingSkeleton,
parameters: { parameters: {
docs: { docs: {
description: { description: {
@@ -19,17 +19,23 @@ const meta = {
}, },
}, },
}, },
render: (args) => ( render: arguments_ => (
<Paper p="md" withBorder> <Paper p="md" withBorder>
<LoadingSkeleton {...args}> <LoadingSkeleton {...arguments_}>
<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,7 @@
import { Stack } from "@mantine/core";
import type { FC, PropsWithChildren } from "react"; import type { FC, PropsWithChildren } from "react";
import { Stack } from "@mantine/core";
type LoadingSkeletonProps = PropsWithChildren<{ type LoadingSkeletonProps = PropsWithChildren<{
label: string; label: string;
}>; }>;
+29 -23
View File
@@ -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 StateStoryArgs = ComponentProps<typeof StateView> & { type StateStoryArguments = ComponentProps<typeof StateView> & {
withAction: boolean;
onAction: () => void; onAction: () => void;
withAction: boolean;
}; };
const meta = { const meta = {
title: "Design System/Components/StateView",
component: StateView,
tags: ["autodocs"],
args: { args: {
title: "Нет учебных данных",
text: "Синтетическое пустое состояние. Пользовательские данные не загружаются.",
error: false, error: false,
withAction: false,
onAction: fn(), onAction: fn(),
text: "Синтетическое пустое состояние. Пользовательские данные не загружаются.",
title: "Нет учебных данных",
withAction: false,
}, },
argTypes: { argTypes: {
title: { control: "text" },
text: { control: "text" },
error: { control: "boolean" },
action: { table: { disable: true } }, action: { table: { disable: true } },
error: { control: "boolean" },
onAction: { control: false },
text: { control: "text" },
title: { control: "text" },
withAction: { withAction: {
control: "boolean", control: "boolean",
description: "Показать необязательное действие StateView", description: "Показать необязательное действие StateView",
}, },
onAction: { control: false },
}, },
component: StateView,
parameters: { parameters: {
docs: { docs: {
description: { description: {
@@ -39,40 +39,46 @@ const meta = {
}, },
}, },
}, },
render: (args) => { render: (arguments_) => {
const { withAction, onAction, ...stateProps } = args; const { onAction, withAction, ...stateProps } = arguments_;
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
} }
/> />
); );
}, },
} satisfies Meta<StateStoryArgs>; tags: ["autodocs"],
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 = {
name: "Пустое состояние с действием",
args: { withAction: true }, args: { withAction: true },
name: "Пустое состояние с действием",
}; };
export const Failed: Story = { export const Failed: Story = {
name: "Ошибка",
args: { args: {
error: true, error: true,
title: "Учебная ошибка",
text: "Демонстрация недоступных данных без вызова API.", text: "Демонстрация недоступных данных без вызова API.",
title: "Учебная ошибка",
}, },
name: "Ошибка",
}; };
export const ErrorWithAction: Story = { export const ErrorWithAction: Story = {
name: "Ошибка с retry",
args: { ...Failed.args, withAction: true }, args: { ...Failed.args, withAction: true },
name: "Ошибка с retry",
}; };
@@ -1,6 +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/LoadingSkeleton"; import { LoadingSkeleton } from "../LoadingSkeleton/LoadingSkeleton";
import { StateView } from "./StateView"; import { StateView } from "./StateView";

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