From c02448f7d930eebc6b4fcef2f1b1a225d69b955f Mon Sep 17 00:00:00 2001 From: robonen Date: Fri, 7 Aug 2026 13:33:17 +0700 Subject: [PATCH] feat: add kcal app --- kcal/.gitignore | 4 + kcal/README.md | 86 + kcal/eslint.config.ts | 16 + kcal/index.html | 13 + kcal/package.json | 41 + kcal/pnpm-lock.yaml | 3260 ++++++++++++++++++++++++ kcal/pnpm-workspace.yaml | 8 + kcal/src/App.tsx | 117 + kcal/src/app.css | 134 + kcal/src/components/BarcodeScanner.tsx | 94 + kcal/src/components/MacroBar.tsx | 37 + kcal/src/components/ProgressRing.tsx | 64 + kcal/src/data/backup.ts | 57 + kcal/src/data/composables.ts | 25 + kcal/src/data/defs.ts | 232 ++ kcal/src/data/engine.ts | 29 + kcal/src/data/off.test.ts | 71 + kcal/src/data/off.ts | 89 + kcal/src/data/seed.ts | 100 + kcal/src/domain/calc.test.ts | 76 + kcal/src/domain/calc.ts | 86 + kcal/src/domain/dates.test.ts | 41 + kcal/src/domain/dates.ts | 41 + kcal/src/domain/format.ts | 22 + kcal/src/domain/types.ts | 102 + kcal/src/env.d.ts | 13 + kcal/src/main.ts | 20 + kcal/src/screens/AddSheet.tsx | 607 +++++ kcal/src/screens/DiaryScreen.tsx | 133 + kcal/src/screens/EditEntrySheet.tsx | 181 ++ kcal/src/screens/FoodFormSheet.tsx | 188 ++ kcal/src/screens/FoodsScreen.tsx | 99 + kcal/src/screens/HelpSection.tsx | 88 + kcal/src/screens/ProfileScreen.tsx | 351 +++ kcal/src/screens/StatsScreen.tsx | 303 +++ kcal/src/ui/icons.tsx | 69 + kcal/src/ui/state.ts | 28 + kcal/tsconfig.json | 12 + kcal/vite.config.ts | 29 + 39 files changed, 6966 insertions(+) create mode 100644 kcal/.gitignore create mode 100644 kcal/README.md create mode 100644 kcal/eslint.config.ts create mode 100644 kcal/index.html create mode 100644 kcal/package.json create mode 100644 kcal/pnpm-lock.yaml create mode 100644 kcal/pnpm-workspace.yaml create mode 100644 kcal/src/App.tsx create mode 100644 kcal/src/app.css create mode 100644 kcal/src/components/BarcodeScanner.tsx create mode 100644 kcal/src/components/MacroBar.tsx create mode 100644 kcal/src/components/ProgressRing.tsx create mode 100644 kcal/src/data/backup.ts create mode 100644 kcal/src/data/composables.ts create mode 100644 kcal/src/data/defs.ts create mode 100644 kcal/src/data/engine.ts create mode 100644 kcal/src/data/off.test.ts create mode 100644 kcal/src/data/off.ts create mode 100644 kcal/src/data/seed.ts create mode 100644 kcal/src/domain/calc.test.ts create mode 100644 kcal/src/domain/calc.ts create mode 100644 kcal/src/domain/dates.test.ts create mode 100644 kcal/src/domain/dates.ts create mode 100644 kcal/src/domain/format.ts create mode 100644 kcal/src/domain/types.ts create mode 100644 kcal/src/env.d.ts create mode 100644 kcal/src/main.ts create mode 100644 kcal/src/screens/AddSheet.tsx create mode 100644 kcal/src/screens/DiaryScreen.tsx create mode 100644 kcal/src/screens/EditEntrySheet.tsx create mode 100644 kcal/src/screens/FoodFormSheet.tsx create mode 100644 kcal/src/screens/FoodsScreen.tsx create mode 100644 kcal/src/screens/HelpSection.tsx create mode 100644 kcal/src/screens/ProfileScreen.tsx create mode 100644 kcal/src/screens/StatsScreen.tsx create mode 100644 kcal/src/ui/icons.tsx create mode 100644 kcal/src/ui/state.ts create mode 100644 kcal/tsconfig.json create mode 100644 kcal/vite.config.ts diff --git a/kcal/.gitignore b/kcal/.gitignore new file mode 100644 index 0000000..a4d699a --- /dev/null +++ b/kcal/.gitignore @@ -0,0 +1,4 @@ +node_modules +dist +*.local +.DS_Store diff --git a/kcal/README.md b/kcal/README.md new file mode 100644 index 0000000..f4a259d --- /dev/null +++ b/kcal/README.md @@ -0,0 +1,86 @@ +# Ккал — локальный дневник питания + +Считаете калории — приложение делает это быстрым: запись за 2–3 касания, +остаток на день всегда перед глазами, все данные только в вашем браузере +(IndexedDB, без бэкенда и аккаунтов). + +## Стек + +- **Vue 3.6.0-rc.2, Vapor mode** — без virtual DOM; компоненты написаны на TSX + через **vue-jsx-vapor** (компилятор на Rust/Oxc), `createVaporApp` в `main.ts`. +- **vue-sync-engine** (`../vue-sync-engine/lib`, подключён как `file:`-зависимость) — + нормализованный entity-кэш: запросы, мутации с optimistic-патчами, + персистентность в IndexedDB. +- **Tailwind CSS 4** как Vite-плагин, дизайн-токены в `src/app.css` (`@theme`). +- **@robonen/stdlib** (`clamp`, `groupBy`), **@robonen/vue** (`useCloseWatcher` — + закрытие шторок по Esc и жесту «назад» на Android, с фолбэком на keydown), + **@robonen/platform** (`focus`), **@robonen/tsconfig**, **@robonen/eslint**. +- **date-fns** (+ `ru`-локаль: родительный падеж месяцев из коробки). +- **lucide** — данные иконок (пары «тег + атрибуты»); рендер своим Vapor-компонентом + через `v-html`, т.к. `lucide-vue-next` построен на vdom и в чистом Vapor не работает. +- Шрифты: Golos Text (UI, кириллица) + Spectral (display-цифры и заголовки). + +```bash +pnpm install +pnpm dev # vite +pnpm test # vitest, доменные расчёты +pnpm typecheck # tsc --noEmit +pnpm lint # eslint (@robonen/eslint, eslint 10) +pnpm build # tsc + vite build +``` + +> vue-sync-engine должен быть собран: `cd ../vue-sync-engine && pnpm --filter vue-sync-engine build`. + +## Как устроен local-first слой + +Бэкенда нет, источник истины — IndexedDB (`kcal`): + +- `defineEntity({ storage: idbStore(...) })` — каждая сущность в своём object store; + `EntityDef.storage` используется и как прямой доступ к idb. +- **Запросы** читают из этих же сторов (`readAll` + фильтр), нормализуют сущности + в Mirror и возвращают списки id. Снапшоты запросов персистятся через + `indexedDBAdapter` — после перезагрузки данные всплывают мгновенно. +- **Мутации** пишут в idb внутри `fetch` (запись await-ится до `invalidate`, + поэтому рефетч всегда видит свежие данные), `optimistic` даёт мгновенный UI, + `invalidate` по тегам (`entries`, `foods`, `weights`, `profile`) обновляет + списки и статистику. +- Записи дневника хранят **снапшот** имени и нутриентов — правка или удаление + продукта не переписывает историю. + +Структура: `src/domain` (типы, расчёты Миффлина—Сан Жеора, даты — без Vue), +`src/data` (дефы движка, сид-каталог ~60 продуктов, бэкап), `src/screens` + +`src/components` (TSX Vapor), `src/ui` (состояние навигации, иконки). + +## Что уже умеет + +- Онбординг: BMR/TDEE по Миффлину—Сан Жеору, цель (похудение −15% / поддержание / + набор +10%), белок 1.6–1.8 г/кг, предупреждение о слишком низкой цели. +- Дневник: кольцо остатка, полосы Б/Ж/У, приёмы пищи, листание дней. +- Добавление: поиск по каталогу, «Недавние» (частота + последняя порция), + граммы/штуки, живой пересчёт, быстрая запись «только калории», свой продукт. +- Штрихкоды: сканер камерой (BarcodeDetector, Chrome/Android) и ручной ввод + цифр — КБЖУ подтягиваются из Open Food Facts (barcode-API отдаёт CORS `*`, + запрос идёт прямо из браузера). Повторный скан того же товара сразу открывает + выбор порции (дедупликация по `Food.barcode`). Текстовый поиск OFF из + браузера невозможен: legacy-эндпоинт отключён, у search-a-licious нет CORS. +- Справка в профиле: 7 практических вопросов — откуда брать КБЖУ, как писать + ресторанную еду, сколько точности достаточно, как считаются цели. +- Статистика: калории по дням (7/14/30) с целью-линией и выбором дня, средние, + «дней в цели», журнал веса с трендом за неделю. +- Каталог: поиск, категории, правка/удаление (сид-набор редактируется как свой). +- Профиль: пересчёт целей, ручная правка, экспорт/импорт JSON, полный сброс. + +## Дорожная карта + +- **Кросс-таб синхронизация** — перевести движок в режим SharedWorker + (`bootstrapWorker` + `createSharedWorkerClientTransport`), дефы уже + собираются плагином в `virtual:sync-engine-registry`. +- **PWA** — manifest + service worker, чтобы поставить на домашний экран. +- Порции-пресеты у продукта («стакан», «ложка»), копирование вчерашнего дня, + конструктор рецептов (сумма ингредиентов ÷ готовый вес = свой продукт + «на 100 г») — главный недостающий кусок для домашней готовки. +- Недельный отчёт: средний дефицит vs фактическое изменение веса + (замыкает петлю «оценка калорий → реальность»). +- A11y: связать `label`/`id` у полей, `aria-live` на кольце, фокус-ловушка в шторках. +- Индекс по дате для записей (`IDBKeyRange` вместо `readAll`+фильтра), когда + дневник разрастётся. diff --git a/kcal/eslint.config.ts b/kcal/eslint.config.ts new file mode 100644 index 0000000..60fa8f0 --- /dev/null +++ b/kcal/eslint.config.ts @@ -0,0 +1,16 @@ +import { base, compose, imports, stylistic, typescript, vitest } from '@robonen/eslint'; + +export default compose( + base, + typescript, + imports, + stylistic, + vitest, + { + name: 'kcal/overrides', + rules: { + // Дефы движка и доменные расчёты плотно работают с числовыми литералами. + 'unicorn/no-zero-fractions': 'off', + }, + }, +); diff --git a/kcal/index.html b/kcal/index.html new file mode 100644 index 0000000..3dfabb1 --- /dev/null +++ b/kcal/index.html @@ -0,0 +1,13 @@ + + + + + + + Ккал — дневник питания + + +
+ + + diff --git a/kcal/package.json b/kcal/package.json new file mode 100644 index 0000000..584cd83 --- /dev/null +++ b/kcal/package.json @@ -0,0 +1,41 @@ +{ + "name": "kcal", + "private": true, + "version": "0.1.0", + "type": "module", + "description": "Локальный дневник питания: калории, БЖУ, вес. Vue Vapor + vue-sync-engine + IndexedDB, без бэкенда.", + "scripts": { + "dev": "vite", + "build": "tsc --noEmit && vite build", + "preview": "vite preview", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest", + "lint": "eslint .", + "lint:fix": "eslint . --fix" + }, + "dependencies": { + "@fontsource-variable/golos-text": "^5.3.0", + "@fontsource/spectral": "^5.3.0", + "@robonen/platform": "^0.0.5", + "@robonen/stdlib": "^0.0.12", + "@robonen/vue": "^0.2.0", + "date-fns": "^4.4.0", + "lucide": "^1.28.0", + "vue": "3.6.0-rc.2", + "vue-sync-engine": "file:../vue-sync-engine/lib" + }, + "devDependencies": { + "@robonen/eslint": "^0.0.1", + "@robonen/tsconfig": "^0.1.0", + "@tailwindcss/vite": "^4.3.3", + "@types/node": "^25.9.1", + "eslint": "^10.8.0", + "jiti": "^2.7.0", + "tailwindcss": "^4.3.3", + "typescript": "~6.0.3", + "vite": "^8.2.1", + "vitest": "^4.1.8", + "vue-jsx-vapor": "^3.2.19" + } +} diff --git a/kcal/pnpm-lock.yaml b/kcal/pnpm-lock.yaml new file mode 100644 index 0000000..8717715 --- /dev/null +++ b/kcal/pnpm-lock.yaml @@ -0,0 +1,3260 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@fontsource-variable/golos-text': + specifier: ^5.3.0 + version: 5.3.0 + '@fontsource/spectral': + specifier: ^5.3.0 + version: 5.3.0 + '@robonen/platform': + specifier: ^0.0.5 + version: 0.0.5 + '@robonen/stdlib': + specifier: ^0.0.12 + version: 0.0.12 + '@robonen/vue': + specifier: ^0.2.0 + version: 0.2.0(vue@3.6.0-rc.2(typescript@6.0.3)) + date-fns: + specifier: ^4.4.0 + version: 4.4.0 + lucide: + specifier: ^1.28.0 + version: 1.28.0 + vue: + specifier: 3.6.0-rc.2 + version: 3.6.0-rc.2(typescript@6.0.3) + vue-sync-engine: + specifier: file:../vue-sync-engine/lib + version: file:../vue-sync-engine/lib(vite@8.2.1(@types/node@25.9.5)(jiti@2.7.0))(vue@3.6.0-rc.2(typescript@6.0.3)) + devDependencies: + '@robonen/eslint': + specifier: ^0.0.1 + version: 0.0.1(@typescript-eslint/eslint-plugin@8.66.0(@typescript-eslint/parser@8.66.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(@typescript-eslint/parser@8.66.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(@typescript-eslint/utils@8.66.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)(vitest@4.1.10(@types/node@25.9.5)(vite@8.2.1(@types/node@25.9.5)(jiti@2.7.0))) + '@robonen/tsconfig': + specifier: ^0.1.0 + version: 0.1.0 + '@tailwindcss/vite': + specifier: ^4.3.3 + version: 4.3.3(vite@8.2.1(@types/node@25.9.5)(jiti@2.7.0)) + '@types/node': + specifier: ^25.9.1 + version: 25.9.5 + eslint: + specifier: ^10.8.0 + version: 10.8.0(jiti@2.7.0)(supports-color@7.2.0) + jiti: + specifier: ^2.7.0 + version: 2.7.0 + tailwindcss: + specifier: ^4.3.3 + version: 4.3.3 + typescript: + specifier: ~6.0.3 + version: 6.0.3 + vite: + specifier: ^8.2.1 + version: 8.2.1(@types/node@25.9.5)(jiti@2.7.0) + vitest: + specifier: ^4.1.8 + version: 4.1.10(@types/node@25.9.5)(vite@8.2.1(@types/node@25.9.5)(jiti@2.7.0)) + vue-jsx-vapor: + specifier: ^3.2.19 + version: 3.2.19(csstype@3.2.3)(rolldown@1.2.3)(vite@8.2.1(@types/node@25.9.5)(jiti@2.7.0))(vue@3.6.0-rc.2(typescript@6.0.3)) + +packages: + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.8': + resolution: {integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + engines: {node: '>=6.9.0'} + + '@emnapi/core@1.10.0': + resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} + + '@emnapi/runtime@1.10.0': + resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} + + '@emnapi/wasi-threads@1.2.1': + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + + '@eslint-community/eslint-utils@4.10.1': + resolution: {integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.23.5': + resolution: {integrity: sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/config-helpers@0.7.0': + resolution: {integrity: sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/core@1.2.1': + resolution: {integrity: sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/js@10.0.1': + resolution: {integrity: sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + peerDependencies: + eslint: ^10.0.0 + peerDependenciesMeta: + eslint: + optional: true + + '@eslint/object-schema@3.0.5': + resolution: {integrity: sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/plugin-kit@0.7.2': + resolution: {integrity: sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@fontsource-variable/golos-text@5.3.0': + resolution: {integrity: sha512-D+Evz5yqXYh6tsalc537FS+cPDWn2FTdO8j/K1ZZuFAvA7ib6WnusTiihciq7mMnm6GBo6XTlh0JXPrhBsam2g==} + + '@fontsource/spectral@5.3.0': + resolution: {integrity: sha512-2TL5WWGOZj4SIsBmPFEzKeA60tXXd1Qp1OqaaVEWkJL8IfSGLk6YmiusmC441iFkdJDIuFelu1r8lhCmQUU3mw==} + + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} + engines: {node: '>=18.18.0'} + + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@napi-rs/wasm-runtime@1.2.2': + resolution: {integrity: sha512-JfB4kuJQjaoHuCTseIINHtHWeJnvgEcxjwA5t/Y00ZgaOO1Crz3fjT/p8kT28zA/Caz7oiUMn3d6H2yOVCVwuw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} + peerDependencies: + '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.3 + '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.3 + + '@oxc-project/types@0.143.0': + resolution: {integrity: sha512-u6JZdLBTLotrNC9Vd6vPssINdzcCzleKAH6EJKImQb7GtYvX5keN2dxkoK44stCc4tffE6QQRtZTXVSzsLUlWA==} + + '@robonen/eslint@0.0.1': + resolution: {integrity: sha512-vLdPIRurtXOnhPl/1AOepvJ5qDoPM5meUuyEDPb3Bo97F5Kl1PsdQoBiWrcdtHKN9f+HmreHcaP8C7UQkC7Rsw==} + engines: {node: '>=24.16.0'} + peerDependencies: + eslint: '>=9.39.4' + + '@robonen/platform@0.0.5': + resolution: {integrity: sha512-RrcW5V1oAUqCDVjjqQsc8ov0L+XUfcaaVKkD5USonDWE2M6VG4dQP+r1+S+OQOD6zKCrrCsH4Sat6Ye94D/RVQ==} + engines: {node: '>=24.16.0'} + + '@robonen/stdlib@0.0.12': + resolution: {integrity: sha512-eGL24DggdqCKXpU8dGKhos2RVOIGGbZb9livCuryk2F2ufMfBx7O0ApkQ9f7kwNYPV8pGxg4tomPxXgEDoKVog==} + engines: {node: '>=24.16.0'} + + '@robonen/tsconfig@0.1.0': + resolution: {integrity: sha512-wM8KkVYvuELxBiruiUs1lbZAckp85PdxQrrAcGWo4CUpK6fO5M2+G+MC9BB2sVnOv+hBLiUvfd88GPx3viKUMw==} + engines: {node: '>=24.16.0'} + + '@robonen/vue@0.2.0': + resolution: {integrity: sha512-f/JFdsE1wlY+Uw0r7azTbGB7xtHt5hrCevGIp7ccvx7RXQaaGjM2PMQcTh11O+kqFOo9NW/BjqHqOYgM9YvshA==} + engines: {node: '>=24.16.0'} + peerDependencies: + vue: ^3.5 + + '@rolldown/binding-android-arm64@1.2.3': + resolution: {integrity: sha512-zrJtHDcaZJ1Fp7xf4hNl+7seH9Cn/N5TwLYkhgXREtBwAd/jaqW3uqeHxpDugJLVICWg4eW44kOQEGJ1r6jCGw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.2.3': + resolution: {integrity: sha512-ieIiibVCp0tX7TLu2cafoNPv8wJyYi01ekXpbf8q2j7F4rGAhhXb/eQh7ge9DRBY78GwmRQtvjZDux7EDbA8kA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.2.3': + resolution: {integrity: sha512-Zh9tCon19eDXJoihx0rqKhMUlMYqzwj3aPsSuHmI4RWZh62dWUL+DJN4C5YQya5TcQBJU/Fe8+rY0jhXTQITqA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.2.3': + resolution: {integrity: sha512-nGbJWewA1wrXXZiQhjAT5rhibGfns5ZNkDVqxsO6zJ3f3YvpoDNNmGMSbbhLuXKjNScaBJVOAboztAWVespQMg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.2.3': + resolution: {integrity: sha512-QNniJr5Kml0kDEB98jiDOJjXNroxIIi0IXIbdYzY26Xt1pVbeP62+KnoIZLwirOymX/0jDk/2gI/bNUv7A7OIw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.2.3': + resolution: {integrity: sha512-TkqEAcmmvH3I/q4114NB4RVt6241Dao48pF45uLcFGrwAaIn0iITgTAKP/dLjbN0R4buJjGb91+UHSoFmpgIWw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.2.3': + resolution: {integrity: sha512-NHqjnxpsndf4MPymxteFAWHHfkTL8HjWh1KB7z23ofZ6QO2euONuxDXjat69dKZRALnGypg8k8SsK8vZJoXv1Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.2.3': + resolution: {integrity: sha512-6tbrbwfz5GB9DQ4Jwo6hy9v+vR31xZlvzZ6n5Xut6Hhx5PvrA9q/HsK8KMaYQp063iqZGXwNvZtYNLD7EM/x0w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.2.3': + resolution: {integrity: sha512-oyuXxXmoZHjXC917IAPFAAv4wWAa0cM9afk8nx1+9/jNNOX1uPf8yDA6p7G0RypOfw/X0PQt5IfoquY1um+zSg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.2.3': + resolution: {integrity: sha512-TytMwF2KVGqP2tgd0I1OY0PAv78dZRAYcF5ssDzjM34SUXCED3uXvSd5+lHoC0bTD6eEdFz7LdQNCO1y0oVk9w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.2.3': + resolution: {integrity: sha512-/E9m3qstrJFVPoULV25mVQblSNExY2+kBsYe4sy0Tn0yOOgJ8wZbZt3KnRbF/XeU2Gl1STKUQnDNTqhIE5MD4A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.2.3': + resolution: {integrity: sha512-Kr0OcsoQI816i6HOl3vFHpd1K0eZyh76zgfj4c1nTyaTsd5r2Mj1lwM4R90y/qaCfmTn9eHy0SKwi98eitRxug==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-win32-arm64-msvc@1.2.3': + resolution: {integrity: sha512-hOtMwTqnME+/gJcH/PCZ0wn0zPUjiWOgkHpxbSJpfGKMezHltx1S7/k1SitzVa7Ww2cqrDDaFbZEhcJZO8o+Jw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.2.3': + resolution: {integrity: sha512-ekcqMMkI2PlhYnfzQnB/cEdYUVVJViWvoUyLrbzgDoi3Snfc1mVBwdnc306ufA5ejy8JSPjT2RlW1nQSjW7efg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@stylistic/eslint-plugin@5.10.0': + resolution: {integrity: sha512-nPK52ZHvot8Ju/0A4ucSX1dcPV2/1clx0kLcH5wDmrE4naKso7TUC/voUyU1O9OTKTrR6MYip6LP0ogEMQ9jPQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^9.0.0 || ^10.0.0 + + '@tailwindcss/node@4.3.3': + resolution: {integrity: sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==} + + '@tailwindcss/oxide-android-arm64@4.3.3': + resolution: {integrity: sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-darwin-arm64@4.3.3': + resolution: {integrity: sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.3.3': + resolution: {integrity: sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [darwin] + + '@tailwindcss/oxide-freebsd-x64@4.3.3': + resolution: {integrity: sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3': + resolution: {integrity: sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==} + engines: {node: '>= 20'} + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.3': + resolution: {integrity: sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-arm64-musl@4.3.3': + resolution: {integrity: sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-linux-x64-gnu@4.3.3': + resolution: {integrity: sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-x64-musl@4.3.3': + resolution: {integrity: sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-wasm32-wasi@4.3.3': + resolution: {integrity: sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.3': + resolution: {integrity: sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.3.3': + resolution: {integrity: sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.3.3': + resolution: {integrity: sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==} + engines: {node: '>= 20'} + + '@tailwindcss/vite@4.3.3': + resolution: {integrity: sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==} + peerDependencies: + vite: ^5.2.0 || ^6 || ^7 || ^8 + + '@tybys/wasm-util@0.10.3': + resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/esrecurse@4.3.1': + resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/node@25.9.5': + resolution: {integrity: sha512-OScDchr2fwuUmWdf4kZ9h7PcJiYDVInhJizG/biAq3cAvqwYktuy/TYGGdZNMtNTFUP7rnb0NU4TUdm82kt4Rg==} + + '@typescript-eslint/eslint-plugin@8.66.0': + resolution: {integrity: sha512-p088eaGrzYz1s+7cov0aMOCkNGTJlVxF4jgubf28c8L0Cv9Rloj8YBHnv4hXLq6IIEE1AsjNWavO+k+8kP2Y0A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.66.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/parser@8.66.0': + resolution: {integrity: sha512-X6ypGChaWYk6PBtUg2BwuTZEFFcHJAtGTVJ9/lCTOufhZ4i9fNolQNnktq+kkMCwMj7V8Svsq7+TxSDslmhE0g==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/project-service@8.66.0': + resolution: {integrity: sha512-7MthGPTt4BP69lSryqpqq8HQqxuzynssckL/jyDyk3+TNMQ3y2jFWkptCrktWvBrP+EH787Nl5N5Qpw7WZg+5g==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/scope-manager@8.66.0': + resolution: {integrity: sha512-8TGcH25j9zqJ/IULB/ppyhRvxA8QYfFEZ7nfbg6/BN9spDgb8fPWQXlE5l8TWBL50EtUx007uZ1o9VOwrq2/9g==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.66.0': + resolution: {integrity: sha512-9D5gLYZG4rOjcoag8MQ/fWI8WqA9wcPDyOGyWtWFhvM1lHRbliqUSPIY5J3zqCU1tvSwzXxnnjhQhz5Ne7mJ4g==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/type-utils@8.66.0': + resolution: {integrity: sha512-LG2dWfjZQQp0ADtAu/EWJVayefGL2UEZ3CDeI44D9v3rXB/WYUqE/jpO28KrEKul5AySrmI+Zh1v6v+xW2U9+g==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/types@8.66.0': + resolution: {integrity: sha512-H6gcYaSDOyvL3AD/jHUtUFo2jqGgn/F6nuyuZSu0QTesxL+cP4dQoIMrODRofuJC09g64+WgZ6tE19Y1N2YIFQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.66.0': + resolution: {integrity: sha512-8/x4INiiQb10jGgXYD7116/zQ+OL84ZIFn0za68wwFHCanT/VLbBEroWht8RV8fn0/ZCAoazHLQgwUC0UQcDfg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/utils@8.66.0': + resolution: {integrity: sha512-jasearZPolBw5NJNYGMwxzHMF83niVWmMU1VdHzG1CyfI2VS7f7nZltnKtHcg20hW+7Uo5GfK4MeDPoU3qI8EA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/visitor-keys@8.66.0': + resolution: {integrity: sha512-dkKR8q+lKciskj1Y3vthHktl+3cMLWGyVUP23bRiPZ5O9BRT++4EqDDV+TVeIKBL1VXVEqrJlz8MYbcnvJcAlg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@unrs/resolver-binding-android-arm-eabi@1.12.2': + resolution: {integrity: sha512-g5T90pqg1bo/7mytQx6F4iBNC0Wsh9cu+z9veDbFjc7HjpesJFWD7QMS0NGStXM075+7dJPPVvBbpZlnrdpi/w==} + cpu: [arm] + os: [android] + + '@unrs/resolver-binding-android-arm64@1.12.2': + resolution: {integrity: sha512-YGCRZv/9GLhwmz6mYDeTsm/92BAyR28l6c2ReweVW5pWgfsitWLY8upvfRlGdoyD8HjeTHSYJWyZGD4KJA/nFQ==} + cpu: [arm64] + os: [android] + + '@unrs/resolver-binding-darwin-arm64@1.12.2': + resolution: {integrity: sha512-u9DiNT1auQMO20A9SyTuG3wUgQWB9Z7KjAg0uFuCDR1FsAY8A0CG2S6JpHS1xwm/w1G08bjXZDcyOCjv1WAm2w==} + cpu: [arm64] + os: [darwin] + + '@unrs/resolver-binding-darwin-x64@1.12.2': + resolution: {integrity: sha512-f7rPLi/T1HVKZu/u6t87lroib16n8vrSzcyxI7lg4BGO9UF26KhQL44sd9eOUgrTYhvRXtWOIZT5PejdPyJfUA==} + cpu: [x64] + os: [darwin] + + '@unrs/resolver-binding-freebsd-x64@1.12.2': + resolution: {integrity: sha512-BpcOjWCJub6nRZUS2zA20pmLvjtqAtGejETaIyRLiZiQf++cbrjltLA5NN/xaXfqeOBOSlMFbemIl5/S5tljmg==} + cpu: [x64] + os: [freebsd] + + '@unrs/resolver-binding-linux-arm-gnueabihf@1.12.2': + resolution: {integrity: sha512-vZTDvdSISZjJx66OzJqtsOhzifbqRjbmI1Mnu49fQDwog5GtDI4QidRiEAYbZCRj9C8YZEW+3ZjqsyS9GR4k2A==} + cpu: [arm] + os: [linux] + + '@unrs/resolver-binding-linux-arm-musleabihf@1.12.2': + resolution: {integrity: sha512-BiPI+IrIlwcW4nLLMM21+B1dFPzd55yAVgVGrdgDjNef+ch03GdxrcyaIz8X9SsQirh/kCQ7mviyWlMxdh2D7g==} + cpu: [arm] + os: [linux] + + '@unrs/resolver-binding-linux-arm64-gnu@1.12.2': + resolution: {integrity: sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@unrs/resolver-binding-linux-arm64-musl@1.12.2': + resolution: {integrity: sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@unrs/resolver-binding-linux-loong64-gnu@1.12.2': + resolution: {integrity: sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@unrs/resolver-binding-linux-loong64-musl@1.12.2': + resolution: {integrity: sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@unrs/resolver-binding-linux-ppc64-gnu@1.12.2': + resolution: {integrity: sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@unrs/resolver-binding-linux-riscv64-gnu@1.12.2': + resolution: {integrity: sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@unrs/resolver-binding-linux-riscv64-musl@1.12.2': + resolution: {integrity: sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@unrs/resolver-binding-linux-s390x-gnu@1.12.2': + resolution: {integrity: sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@unrs/resolver-binding-linux-x64-gnu@1.12.2': + resolution: {integrity: sha512-mPsUhunKKDih5O96Y6enDQyHc1SqBPlY1E/SfMWDM3EdJ95Z9CArPeCVwCCqbP45ljvivdEk8Fxn+SIb1rDAJQ==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@unrs/resolver-binding-linux-x64-musl@1.12.2': + resolution: {integrity: sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@unrs/resolver-binding-openharmony-arm64@1.12.2': + resolution: {integrity: sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ==} + cpu: [arm64] + os: [openharmony] + + '@unrs/resolver-binding-wasm32-wasi@1.12.2': + resolution: {integrity: sha512-tYFDIkMxSflfEc/h92ZWNsZlHSwgimbNHSO3PL2JWQHfCuC2q316jMyYU9TIWZsFK2bQwyK5VAdYgn8ygPj69A==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@unrs/resolver-binding-win32-arm64-msvc@1.12.2': + resolution: {integrity: sha512-qzNyg3xL0VPQmCaUh+N5jSitce6k+uCBfMDesWRnlULOZaqUkaJ0ybdT+UqlAWJoQjuqfIU/0Ptx9bteN4D82g==} + cpu: [arm64] + os: [win32] + + '@unrs/resolver-binding-win32-ia32-msvc@1.12.2': + resolution: {integrity: sha512-WD9sY00OfpHVGfsnHZoA8jVT+esS/Bg8z8jzxp5BnDCjjwsuKsPQrzswwpFy4J1AUJbXPRfkpcX0mXrzeXW79g==} + cpu: [ia32] + os: [win32] + + '@unrs/resolver-binding-win32-x64-msvc@1.12.2': + resolution: {integrity: sha512-nAB74NfSNKknqQ1RrYj6uz8FcXEomu/MATJZxh/x+BArzN2U3JbOYC0APYzUIGhVY3m5hRxA8VPNdPBoG8txlA==} + cpu: [x64] + os: [win32] + + '@vitest/eslint-plugin@1.6.26': + resolution: {integrity: sha512-2eawZk4MZvkEtMZFdScmCE0R1Rti85uRZpmbe9eAv5Q3blDZ5OxlrC9IHlSnxhDCqOO2xKNaD3oufl9BUlI0yA==} + engines: {node: '>=18'} + peerDependencies: + '@typescript-eslint/eslint-plugin': '*' + eslint: '>=8.57.0' + typescript: '>=5.0.0' + vitest: '*' + peerDependenciesMeta: + '@typescript-eslint/eslint-plugin': + optional: true + typescript: + optional: true + vitest: + optional: true + + '@vitest/expect@4.1.10': + resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==} + + '@vitest/mocker@4.1.10': + resolution: {integrity: sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.10': + resolution: {integrity: sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==} + + '@vitest/runner@4.1.10': + resolution: {integrity: sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==} + + '@vitest/snapshot@4.1.10': + resolution: {integrity: sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==} + + '@vitest/spy@4.1.10': + resolution: {integrity: sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==} + + '@vitest/utils@4.1.10': + resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==} + + '@vue-jsx-vapor/compiler-rs-android-arm-eabi@3.2.19': + resolution: {integrity: sha512-HefhdfW2cqQgmstyFySUtsf0ClWvfJacRto0nW0DzrNxojNmLlYKVoa4GnfcR5AitHlbPOnVorZT25rVCJ/MSA==} + cpu: [arm] + os: [android] + + '@vue-jsx-vapor/compiler-rs-android-arm64@3.2.19': + resolution: {integrity: sha512-Nqz1Fbv9+MxisXhC7Do5zbiB8LQ5kwvLPyqqVpc/KVsKbaitjYGVjuve455wik6kNpfNXGPhLQNywAf9SjMbeg==} + cpu: [arm64] + os: [android] + + '@vue-jsx-vapor/compiler-rs-darwin-arm64@3.2.19': + resolution: {integrity: sha512-MXZdt87BptqSCGT6EVXwyOMUauf13XCHE2ZoHc4TFUdbaKtFSEXO5By2SIr7Tzix+PwKEoup/mEYdUCdEaz/Bw==} + cpu: [arm64] + os: [darwin] + + '@vue-jsx-vapor/compiler-rs-darwin-x64@3.2.19': + resolution: {integrity: sha512-Ctvxsl52Vjttc28pJf3J14+TuHXoVaPd5mB0w52CPChsKYmBolDat5GXCP82Li8OOAOzX4o7OcMT6UbFcYxdug==} + cpu: [x64] + os: [darwin] + + '@vue-jsx-vapor/compiler-rs-freebsd-x64@3.2.19': + resolution: {integrity: sha512-pEqJMObI4zHdPiaXI6/4gVcO2kmM0Ay63gMsW3IUOZhIv4OVwmBpLYaA7a3Mwjyk9TvHK+oze0ohTm9wWQuuNw==} + cpu: [x64] + os: [freebsd] + + '@vue-jsx-vapor/compiler-rs-linux-arm-gnueabihf@3.2.19': + resolution: {integrity: sha512-bwpP8QAcdSmFVv8s+79nSdcYZcm8gwehAtYzAT78Iun/FT9pniSzeJpeHi7lccMKgkmhrAGGat7moADY4QrfYw==} + cpu: [arm] + os: [linux] + + '@vue-jsx-vapor/compiler-rs-linux-arm64-gnu@3.2.19': + resolution: {integrity: sha512-omJ7FlyqchUqpPESe673vjHufzYy7rrOQ/gRGcCBWGlQfTg2tzk8YBxvdFgmqSeCHpIPev7ZdZyWKsjrQGOMyw==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@vue-jsx-vapor/compiler-rs-linux-arm64-musl@3.2.19': + resolution: {integrity: sha512-CCzyY/LX6vmhsFhOh/bqaVzIot/Mky9lz3CMgvbzUktUebxG/Rfh9oo4jFcjOb1QFWfNpDPRUZGsBaBXQ+gP2w==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@vue-jsx-vapor/compiler-rs-linux-x64-gnu@3.2.19': + resolution: {integrity: sha512-BA/KIwyzkT+jUSYA+t7pZMhZe8zShEDgIgjw94WwBbPLtzWMof+YLKY8FqNjN4nTXTwJn5yBuVtWD2LCg/6sWg==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@vue-jsx-vapor/compiler-rs-linux-x64-musl@3.2.19': + resolution: {integrity: sha512-iYX0o9lnrTk19g2NAh0+EGflWk02LmKhNB5lwOXzXRVItyBuwqSsyy7fxad2rDHMdUT+gvp5HVA0rS28A11O2g==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@vue-jsx-vapor/compiler-rs-wasm32-wasi@3.2.19': + resolution: {integrity: sha512-0/maDIH/KMChDeUJhNik5HFahtJo8fXfYZT2gRtavTqhjAFC9f4WkVSwC87SC8zCi/mVT1cy5f6COCLwN3xTEg==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@vue-jsx-vapor/compiler-rs-win32-arm64-msvc@3.2.19': + resolution: {integrity: sha512-sDpi5f+b3qFx4zDOHZdsujPkd1FDT53ALdaPdgfYKwA1DNkR4ovY/TI3SsvSmPmEAmf6GsB7jGinShpX5HyaDg==} + cpu: [arm64] + os: [win32] + + '@vue-jsx-vapor/compiler-rs-win32-ia32-msvc@3.2.19': + resolution: {integrity: sha512-d4dxiaLsWDyM2zZjsP/LRDeJHoOy8/etrX1723Hlh2k8gzIITn77yzespEOK73dvdfusGbfOV0S/dkzqM0k09w==} + cpu: [ia32] + os: [win32] + + '@vue-jsx-vapor/compiler-rs-win32-x64-msvc@3.2.19': + resolution: {integrity: sha512-puiHMm14vhtar1ozcbQxSSJzNiuzcPT7dZQbY+ibWYuw2vchSHRXFS30gSvaIolz8RuHflrZ4uGHMTRdya+Pcw==} + cpu: [x64] + os: [win32] + + '@vue-jsx-vapor/compiler-rs@3.2.19': + resolution: {integrity: sha512-T1uviby19i2LH0T3D/HfpRTkS6xOpsvenknLnUU8qziZ71SLaLWqipbG4mCV1U8gxWvPEEkcgTij1eSXLR7M+g==} + + '@vue-jsx-vapor/macros@3.2.19': + resolution: {integrity: sha512-4xEjB8uQ+MnD4bQwdchRumm0N1Ru2Q7nkz06DFsOXcCtx6oJdWnI4WEkOqXuXDdGHGcLl0pffcemtCMNhWmxXA==} + peerDependencies: + '@nuxt/kit': ^3 + '@nuxt/schema': ^3 + esbuild: '*' + rollup: ^3 + vite: '>=3' + vue: '>=3' + webpack: ^4 || ^5 + peerDependenciesMeta: + '@nuxt/kit': + optional: true + '@nuxt/schema': + optional: true + esbuild: + optional: true + rollup: + optional: true + vite: + optional: true + webpack: + optional: true + + '@vue-jsx-vapor/runtime@3.2.19': + resolution: {integrity: sha512-pnULs33aW5G7tmKbveZjUorqChgP4HKrralQFIus//BPR5uZKJr2NVJtPxERzkCuc2BdHMG1SmN0TJtuFbACAQ==} + peerDependencies: + csstype: ^3.2.3 + vue: 3.6.0-rc.1 + + '@vue/compiler-core@3.6.0-rc.2': + resolution: {integrity: sha512-Xx79h4EpJktQBShelXNKaRuvn9N6YsYL1j+RXdvPKzI+8tohYziBJbxOFGAkdcOKBwKc2SIWV4DOlTAMhE96yQ==} + + '@vue/compiler-dom@3.6.0-rc.2': + resolution: {integrity: sha512-dRTdTdi/KXzHYIkHtyjGxepTIxnsZ/hB3v1osnS8c4MeycAyzkSoZW6OZkXXN4oVVVU6NKXds1XtKAEKVVw7nA==} + + '@vue/compiler-sfc@3.6.0-rc.2': + resolution: {integrity: sha512-EZWTNh0UwdxvrBJkFc6+dqfwuUSdTVHDIohQcGDPCqGaYTHOfUL20b+grEAzlyxB741KrVBUb7qqpes92hAh2w==} + + '@vue/compiler-ssr@3.6.0-rc.2': + resolution: {integrity: sha512-lqHSdQTmlhKX3dNEvR1TcspUzDlTR82T7BYLDzfvTwAqGXxAI2WJfyGJPcNpPjPKYEMI9JRGF32DU8TlHLx1AA==} + + '@vue/compiler-vapor@3.6.0-rc.2': + resolution: {integrity: sha512-QbrA01pbj9xemF2McgBopyaOwj3kQGN0W1GW5EdFXORIT4fQQJpmUOj40h99S3bk09gLs5bFTRS4YE+Oyw/F9w==} + + '@vue/devtools-api@8.2.1': + resolution: {integrity: sha512-6u4vXBlIBAC1wMplIZgpyPn7uh/s4Bf6F5bMzvLv+EdJ0aHs/+4B7Ygv864EStQSjRbsRzTko/kUG1A1IejQ3A==} + + '@vue/devtools-kit@8.2.1': + resolution: {integrity: sha512-FIGIuq3AWReEpbAHY/cRGeHDfI0qOb8OCQ3YjbEAX04uaxIDbGc9rhkbVcG7rnfHPXE3RsU5KrWOu9V/okd8AQ==} + + '@vue/devtools-shared@8.2.1': + resolution: {integrity: sha512-Fkac7lUdGReh6pVOi3AYPRGe82LQqRmAfThW7RRligOAP0ZA/Z1z9XLHDM9dv34pV2HRc79DK8uKPeG2fLnA/g==} + + '@vue/reactivity@3.6.0-rc.2': + resolution: {integrity: sha512-B01EoSTcaVQiJwZDoSoeKYClVgj60BHN43oHEcxXhPVLdxsXI0SOiQuskUskQDZn2ma08llICKJohL60o+UamA==} + + '@vue/runtime-core@3.6.0-rc.2': + resolution: {integrity: sha512-qFV61Hbg6LyEGxJiVRJYuiIfryxrfO4+cgW1esxtkR1/pvemULwG6KpFImsJDwcC+YxrtvndXGM5PRVwUCGuiA==} + + '@vue/runtime-dom@3.6.0-rc.2': + resolution: {integrity: sha512-clIE/xk0b61t30BPKoOXZep253nhWdjyw7iac53yy2KN0nxAupkb6x3ImOI9iljjXqOW5lYdNVHNxNlcx3iA1Q==} + + '@vue/runtime-vapor@3.6.0-rc.2': + resolution: {integrity: sha512-8EpxbNyT629k/52el4D7PxXmZtiYhJRBHPXJR7CZw0OluiADtT9W8J+XB83EVlzHmFUysXcd+mGWmuHwIhuHZA==} + peerDependencies: + '@vue/runtime-dom': 3.6.0-rc.2 + + '@vue/server-renderer@3.6.0-rc.2': + resolution: {integrity: sha512-TIsqoYljs6b0wfoCDhv4EeU3Du2a8gTadkaTGMjIYLTLlO4p8r4VdDqfNp/ZMAm87bW4ST1naBMRe+bXQBpAeQ==} + + '@vue/shared@3.6.0-rc.1': + resolution: {integrity: sha512-G2Y0ePuu7QIZ7eX7I8r0Q6mwO6rwhMXyYY8UAlPvkXsu7IxT5i8wDxiZK8EI5FnYYJKdSWInDnZREDX9w9zdCg==} + + '@vue/shared@3.6.0-rc.2': + resolution: {integrity: sha512-Tz44lKLjxhWRu3GJ38768dc2hW4XhDtTB5aUo5fCMis6YDyFzwQYkrD/B9SuQ1v/ailv6UVR3wSHiujNXSjMCQ==} + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.18.0: + resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} + engines: {node: '>=0.4.0'} + hasBin: true + + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + baseline-browser-mapping@2.11.12: + resolution: {integrity: sha512-r7WnVImvVCeFpf2DOXfy41aPWzeNg3H/A2X4dKmy1QL0MSyyk/e7z8ihJ3N6Nn2PsdhkVlqnEfnUE4a05P2aTA==} + engines: {node: '>=6.0.0'} + hasBin: true + + birpc@2.9.0: + resolution: {integrity: sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==} + + boolbase@1.0.0: + resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + + brace-expansion@5.0.9: + resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} + engines: {node: 20 || >=22} + + browserslist@4.28.7: + resolution: {integrity: sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + builtin-modules@5.3.0: + resolution: {integrity: sha512-hMQUl2bUFG339QygPM97E+mc8OY1IAchORZxm4a/frcYwKzozMzRVDBwHW0NjOqGElLm2O37AVQE8ikxlZHrMQ==} + engines: {node: '>=18.20'} + + caniuse-lite@1.0.30001806: + resolution: {integrity: sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==} + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + change-case@5.4.4: + resolution: {integrity: sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==} + + ci-info@4.4.0: + resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==} + engines: {node: '>=8'} + + comment-parser@1.4.7: + resolution: {integrity: sha512-0h+uSNtQGW3D98eQt3jJ8L06Fves8hncB4V/PKdw/Qb8Hnk19VaKuTr55UNRYiSoVa7WwrFls+rh3ux9agmkeQ==} + engines: {node: '>= 12.0.0'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + core-js-compat@3.50.0: + resolution: {integrity: sha512-XGpFGbMLHwSt74YLTKho7Ib242qi6O8MSX+sRokV4oz7iKXvQWGYZthjIhjRGMxjzVkAubBO512dKGYcefmX3Q==} + engines: {node: '>=6.4.0'} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + date-fns@4.4.0: + resolution: {integrity: sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + detect-indent@7.0.2: + resolution: {integrity: sha512-y+8xyqdGLL+6sh0tVeHcfP/QDd8gUgbasolJJpY7NgeQGSZ739bDtSiaiDgtoicy+mtYB81dKLxO9xRhCyIB3A==} + engines: {node: '>=12.20'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + electron-to-chromium@1.5.401: + resolution: {integrity: sha512-H6ViHN68nGYlChEvlIU67fn8O2/tpbWQPwck98yaJmh+08LSvHiydzDQ6oXNccLU3kNRVIRS9A4mA7CG+i6fLQ==} + + enhanced-resolve@5.24.5: + resolution: {integrity: sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==} + engines: {node: '>=10.13.0'} + + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + + es-module-lexer@2.3.1: + resolution: {integrity: sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==} + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-compat-utils@0.5.1: + resolution: {integrity: sha512-3z3vFexKIEnjHE3zCMRo6fn/e44U7T1khUjg+Hp0ZQMCigh28rALD0nPFBcGZuiLC5rLZa2ubQHDRln09JfU2Q==} + engines: {node: '>=12'} + peerDependencies: + eslint: '>=6.0.0' + + eslint-import-context@0.1.9: + resolution: {integrity: sha512-K9Hb+yRaGAGUbwjhFNHvSmmkZs9+zbuoe3kFQ4V1wYjrepUFYM2dZAfNtjbbj3qsPfUfsA68Bx/ICWQMi+C8Eg==} + engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + peerDependencies: + unrs-resolver: ^1.0.0 + peerDependenciesMeta: + unrs-resolver: + optional: true + + eslint-plugin-es-x@7.8.0: + resolution: {integrity: sha512-7Ds8+wAAoV3T+LAKeu39Y5BzXCrGKrcISfgKEqTS4BDN8SFEDQd0S43jiQ8vIa3wUKD07qitZdfzlenSi8/0qQ==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + eslint: '>=8' + + eslint-plugin-import-x@4.17.1: + resolution: {integrity: sha512-4cdstYkKCyjumM2Q9NSI03K8D2a9F4Ssz33K2lv2hQa4KmR9jPLwk3uWGtNvclfqBrPGfGuMBwsGMbe6dMRbfg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/utils': ^8.56.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + eslint-import-resolver-node: '*' + peerDependenciesMeta: + '@typescript-eslint/utils': + optional: true + eslint-import-resolver-node: + optional: true + + eslint-plugin-n@18.2.2: + resolution: {integrity: sha512-gOO0lIqwEjZ750kv9/SptCWArUoAZXJoBr0vYWTO2dCBxctHUXlBIigiC8xuxxr/NKqgIT6Ehz1xRcilj8a5cA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + peerDependencies: + eslint: '>=8.57.1' + ts-declaration-location: ^1.0.6 + typescript: '>=5.0.0' + peerDependenciesMeta: + ts-declaration-location: + optional: true + typescript: + optional: true + + eslint-plugin-regexp@3.1.1: + resolution: {integrity: sha512-MxR5nqoQCtVWmJwia0D2+NlXX1xzdpkslsVOZLEYQ4PQWEaL65PCZXURxaBc3lPnkNFpNxzMIRmYVxdl8giXRA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + peerDependencies: + eslint: '>=9.38.0' + + eslint-plugin-unicorn@67.0.0: + resolution: {integrity: sha512-XEYhNfAFFFYQLq++53iqpgIPFqvjasf5lsUJuCdmd/QvbRfcLbmPTjcTyVEXc5yBudWIg08SSEGgqJtEojk1ug==} + engines: {node: '>=22'} + peerDependencies: + eslint: '>=10.4' + + eslint-plugin-vue@10.10.0: + resolution: {integrity: sha512-dL9x9rBHqqNcByWiLOHK6L0SB97V82/NC0cZRn9cXPjM7pCuWlpQQP9bFH4vjBv80ej1ZpzAkuD8zWH1o9bZbA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@stylistic/eslint-plugin': ^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0 + '@typescript-eslint/parser': ^7.0.0 || ^8.0.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + vue-eslint-parser: ^10.3.0 + peerDependenciesMeta: + '@stylistic/eslint-plugin': + optional: true + '@typescript-eslint/parser': + optional: true + + eslint-scope@9.1.2: + resolution: {integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@4.2.1: + resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@10.8.0: + resolution: {integrity: sha512-nuKKvN+oIBO0koN7Tm7dlkmnkc21mtt0QJLwAKzjLq14y6lRTdVG36MZHJ8eQHwdJMwZbQNMlPOYedMq/oVJvQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@10.4.0: + resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + espree@11.2.0: + resolution: {integrity: sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + find-up-simple@1.0.1: + resolution: {integrity: sha512-afd4O7zpqHeRyg4PfDQsXmlDe2PfdHtJt6Akt8jOWaApLOZk5JXs6VMR29lz03pRe9mpykrRCYIYxaJYcfpncQ==} + engines: {node: '>=18'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flatted@3.4.4: + resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + get-tsconfig@4.14.1: + resolution: {integrity: sha512-Dz/6HxkrxgNehhxLVeyv8sad9UzF2xBVeaKBQNDfJ5XiSXmp2gTR0eO0RWiT2NCKS5aGP9jjkOMggTN90qU50A==} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + globals@15.15.0: + resolution: {integrity: sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==} + engines: {node: '>=18'} + + globals@17.9.0: + resolution: {integrity: sha512-m/MvAW61QVU5VDNF1Vj8axt016h8w7L5TU1e9zlab7XIttAT2YAlCwl75K1fOqvMM9apmD7lbCIRhpfkhmxhCg==} + engines: {node: '>=18'} + + globrex@0.1.2: + resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + hash-sum@2.0.0: + resolution: {integrity: sha512-WdZTbAByD+pHfl/g9QSsBIIwy8IT+EsPiKDs0KNX+zSHhdDLFKdZu0BQHljvO+0QI/BasbMSUa8wYNCZTvhslg==} + + hookable@5.5.3: + resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.6: + resolution: {integrity: sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==} + engines: {node: '>= 4'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + indent-string@5.0.0: + resolution: {integrity: sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==} + engines: {node: '>=12'} + + is-builtin-module@5.0.0: + resolution: {integrity: sha512-f4RqJKBUe5rQkJ2eJEJBXSticB3hGbN9j0yxxMQFqIW89Jp9WYFtzfTcRlstDKVUTRzSOTLKRfO9vIztenwtxA==} + engines: {node: '>=18.20'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + + jsdoc-type-pratt-parser@7.3.0: + resolution: {integrity: sha512-DoyJXo7x/n48M3NsGOs9QnEws0ft0tV3YsSgvWMNxz2hZtz+Q6fpqUD96lVYX4a+jcEkzHeFNDJOn68TzXfbdA==} + engines: {node: '>=20.0.0'} + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lucide@1.28.0: + resolution: {integrity: sha512-sxVxWEXfq7rRNzmffDBRohYBOFjfoOhrO7jGGCLNNRoY2fPjeuqi2boXR2UAROEyQUm+xe13DGOQNtnmwv6Y3w==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + minimatch@10.2.6: + resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} + engines: {node: 18 || 20 || >=22} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + muggle-string@0.4.1: + resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} + + nanoid@3.3.17: + resolution: {integrity: sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + napi-postinstall@0.3.4: + resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==} + engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + node-releases@2.0.52: + resolution: {integrity: sha512-MRlTqhAfoMx/4mhEbPo3Hi02g9LJZaJkka69V6h67Cb1gjrAG0jsTE4CZX1eptNx+VCAwJmfpnDIF4P0Nh1A7A==} + engines: {node: '>=18'} + + nth-check@2.1.1: + resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + + obug@2.1.4: + resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} + engines: {node: '>=12.20.0'} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + perfect-debounce@2.1.0: + resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + + pluralize@8.0.0: + resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} + engines: {node: '>=4'} + + postcss-selector-parser@7.1.4: + resolution: {integrity: sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg==} + engines: {node: '>=4'} + + postcss@8.5.25: + resolution: {integrity: sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==} + engines: {node: ^10 || ^12 || >=14} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + refa@0.12.1: + resolution: {integrity: sha512-J8rn6v4DBb2nnFqkqwy6/NnTYMcgLA+sLr0iIO41qpv0n+ngb7ksag2tMRl0inb1bbO/esUwzW1vbJi7K0sI0g==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + regexp-ast-analysis@0.7.1: + resolution: {integrity: sha512-sZuz1dYW/ZsfG17WSAG7eS85r5a0dDsvg+7BiiYR5o6lKCAtUrEwdmRmaGF6rwVj3LcmAeYkOWKEPlbPzN3Y3A==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + regjsparser@0.13.2: + resolution: {integrity: sha512-NgRBy2Nx/bE+9F27nVHnqcN5HjyLmecqsqx2PJHu3/IEtADD4WuxuXIVExD5PoSDFVrl78dOonfcOe5O+5nbzQ==} + hasBin: true + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + rolldown@1.2.3: + resolution: {integrity: sha512-rn9wpmxplLf7NLNyCk9FyWh3FM43DbY8jOzCdEPzH7uflhTftRbCEpqi6Ly2osgoU8OwObtmavMbWLaWy4LX7A==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + scslre@0.3.0: + resolution: {integrity: sha512-3A6sD0WYP7+QrjbfNA2FN3FsOaGGFoekCVgTyypy53gPxhbkCIjtO6YWgdrfM+n/8sI8JeXZOIxsHjMTNxQ4nQ==} + engines: {node: ^14.0.0 || >=16.0.0} + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stable-hash-x@0.2.0: + resolution: {integrity: sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ==} + engines: {node: '>=12.0.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.2.0: + resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + + strip-indent@4.1.1: + resolution: {integrity: sha512-SlyRoSkdh1dYP0PzclLE7r0M9sgbFKKMFXpFRUMNuKhQSbC6VQIGzq3E0qsfvGJaUFJPGv6Ws1NZ/haTAjfbMA==} + engines: {node: '>=12'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + tailwindcss@4.3.3: + resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==} + + tapable@2.3.3: + resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} + engines: {node: '>=6'} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.3.0: + resolution: {integrity: sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.1.1: + resolution: {integrity: sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==} + engines: {node: '>=14.0.0'} + + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + + ts-macro@0.3.7: + resolution: {integrity: sha512-5BinbTKn5WXRslotv5/VWTn3catfK0thWoB4BZI5VNEdxMu6r9AUPFalut4wLEK3nGvxhywA9aAdZnJ+Fnug9A==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + typescript-eslint@8.66.0: + resolution: {integrity: sha512-QlEbBPz/RuJ1XUHj29nm3t0F/O/cSlEnntozqPOYHnnTGAXFamnMBu5i9Vn6vhUPHGAjR+Vl+5J8vPN/BMUrJw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@7.24.6: + resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + + unplugin-utils@0.3.2: + resolution: {integrity: sha512-xVToRh2CTmLk2HnEG7ac4rl1MJTT3RFkpS8B++/SnB0kXvuaavD+n3m/vrzyWQOdJNSZQACnbz01pnppbwV5BA==} + engines: {node: '>=20.19.0'} + + unplugin@3.3.0: + resolution: {integrity: sha512-qa66K+crbfyE6JK10GjvbJeRrOsuC/JpbnHctfyp/i4oBTxWOzJfRZyDiOk1PtErMFRu8JhsU/wPvOdBNWe5Rg==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + '@farmfe/core': '*' + '@rspack/core': '*' + bun-types-no-globals: '*' + esbuild: '*' + rolldown: '*' + rollup: '*' + unloader: '*' + vite: '*' + webpack: '*' + peerDependenciesMeta: + '@farmfe/core': + optional: true + '@rspack/core': + optional: true + bun-types-no-globals: + optional: true + esbuild: + optional: true + rolldown: + optional: true + rollup: + optional: true + unloader: + optional: true + vite: + optional: true + webpack: + optional: true + + unrs-resolver@1.12.2: + resolution: {integrity: sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==} + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + vite@8.2.1: + resolution: {integrity: sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.4.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.1.10: + resolution: {integrity: sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.10 + '@vitest/browser-preview': 4.1.10 + '@vitest/browser-webdriverio': 4.1.10 + '@vitest/coverage-istanbul': 4.1.10 + '@vitest/coverage-v8': 4.1.10 + '@vitest/ui': 4.1.10 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + vue-eslint-parser@10.4.1: + resolution: {integrity: sha512-Gk6gRDj0n/fkRa3C3l0bBheoBckUq/Rs0F/TvMWIS6nzzx67amAViMe9CkNgsP2tXyQONvGiHQESHwFtZ3aYDA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + + vue-jsx-vapor@3.2.19: + resolution: {integrity: sha512-jigvOolTWt7LrQzBp+JDSYuv64yH3u4Ymo8Vw4QBLXtzcv8Jua7HU37JMfi24lN1qC0n7WgyBPZox0RzNKWqJA==} + peerDependencies: + '@nuxt/kit': ^3 + '@nuxt/schema': ^3 + esbuild: '*' + rollup: ^3 + vite: '>=3' + vue: ^3 + webpack: ^4 || ^5 + peerDependenciesMeta: + '@nuxt/kit': + optional: true + '@nuxt/schema': + optional: true + esbuild: + optional: true + rollup: + optional: true + vite: + optional: true + webpack: + optional: true + + vue-sync-engine@file:../vue-sync-engine/lib: + resolution: {directory: ../vue-sync-engine/lib, type: directory} + peerDependencies: + vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + vue: ^3.5.0 + peerDependenciesMeta: + vite: + optional: true + + vue@3.6.0-rc.2: + resolution: {integrity: sha512-pBgoISqETcufJ1e92vVW2/D5MEbCm2XHlC/5dq3hWq8s17tx9v5BEdYv4hojJ0yEEDidYxUL7itS+vV32wfZrw==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + webpack-virtual-modules@0.6.2: + resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + +snapshots: + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/parser@7.29.8': + dependencies: + '@babel/types': 7.29.8 + + '@babel/types@7.29.8': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@emnapi/core@1.10.0': + dependencies: + '@emnapi/wasi-threads': 1.2.1 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.10.0': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@eslint-community/eslint-utils@4.10.1(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))': + dependencies: + eslint: 10.8.0(jiti@2.7.0)(supports-color@7.2.0) + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.23.5(supports-color@7.2.0)': + dependencies: + '@eslint/object-schema': 3.0.5 + debug: 4.4.3(supports-color@7.2.0) + minimatch: 10.2.6 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.7.0': + dependencies: + '@eslint/core': 1.2.1 + + '@eslint/core@1.2.1': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/js@10.0.1(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))': + optionalDependencies: + eslint: 10.8.0(jiti@2.7.0)(supports-color@7.2.0) + + '@eslint/object-schema@3.0.5': {} + + '@eslint/plugin-kit@0.7.2': + dependencies: + '@eslint/core': 1.2.1 + levn: 0.4.1 + + '@fontsource-variable/golos-text@5.3.0': {} + + '@fontsource/spectral@5.3.0': {} + + '@humanfs/core@0.19.2': + dependencies: + '@humanfs/types': 0.15.0 + + '@humanfs/node@0.16.8': + dependencies: + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 + '@humanwhocodes/retry': 0.4.3 + + '@humanfs/types@0.15.0': {} + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@napi-rs/wasm-runtime@1.2.2(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@tybys/wasm-util': 0.10.3 + optional: true + + '@oxc-project/types@0.143.0': {} + + '@robonen/eslint@0.0.1(@typescript-eslint/eslint-plugin@8.66.0(@typescript-eslint/parser@8.66.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(@typescript-eslint/parser@8.66.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(@typescript-eslint/utils@8.66.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)(vitest@4.1.10(@types/node@25.9.5)(vite@8.2.1(@types/node@25.9.5)(jiti@2.7.0)))': + dependencies: + '@eslint/js': 10.0.1(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0)) + '@stylistic/eslint-plugin': 5.10.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0)) + '@vitest/eslint-plugin': 1.6.26(@typescript-eslint/eslint-plugin@8.66.0(@typescript-eslint/parser@8.66.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)(vitest@4.1.10(@types/node@25.9.5)(vite@8.2.1(@types/node@25.9.5)(jiti@2.7.0))) + eslint: 10.8.0(jiti@2.7.0)(supports-color@7.2.0) + eslint-plugin-import-x: 4.17.1(@typescript-eslint/utils@8.66.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0) + eslint-plugin-n: 18.2.2(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(typescript@6.0.3) + eslint-plugin-regexp: 3.1.1(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0)) + eslint-plugin-unicorn: 67.0.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0)) + eslint-plugin-vue: 10.10.0(@stylistic/eslint-plugin@5.10.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0)))(@typescript-eslint/parser@8.66.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(vue-eslint-parser@10.4.1(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)) + globals: 17.9.0 + jiti: 2.7.0 + typescript-eslint: 8.66.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + vue-eslint-parser: 10.4.1(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0) + transitivePeerDependencies: + - '@typescript-eslint/eslint-plugin' + - '@typescript-eslint/parser' + - '@typescript-eslint/utils' + - eslint-import-resolver-node + - supports-color + - ts-declaration-location + - typescript + - vitest + + '@robonen/platform@0.0.5': {} + + '@robonen/stdlib@0.0.12': {} + + '@robonen/tsconfig@0.1.0': {} + + '@robonen/vue@0.2.0(vue@3.6.0-rc.2(typescript@6.0.3))': + dependencies: + '@robonen/platform': 0.0.5 + '@robonen/stdlib': 0.0.12 + vue: 3.6.0-rc.2(typescript@6.0.3) + + '@rolldown/binding-android-arm64@1.2.3': + optional: true + + '@rolldown/binding-darwin-arm64@1.2.3': + optional: true + + '@rolldown/binding-darwin-x64@1.2.3': + optional: true + + '@rolldown/binding-freebsd-x64@1.2.3': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.2.3': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.2.3': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.2.3': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.2.3': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.2.3': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.2.3': + optional: true + + '@rolldown/binding-linux-x64-musl@1.2.3': + optional: true + + '@rolldown/binding-openharmony-arm64@1.2.3': + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.2.3': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.2.3': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@standard-schema/spec@1.1.0': {} + + '@stylistic/eslint-plugin@5.10.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))': + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0)) + '@typescript-eslint/types': 8.66.0 + eslint: 10.8.0(jiti@2.7.0)(supports-color@7.2.0) + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + estraverse: 5.3.0 + picomatch: 4.0.5 + + '@tailwindcss/node@4.3.3': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.24.5 + jiti: 2.7.0 + lightningcss: 1.32.0 + magic-string: 0.30.21 + source-map-js: 1.2.1 + tailwindcss: 4.3.3 + + '@tailwindcss/oxide-android-arm64@4.3.3': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.3.3': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.3.3': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.3.3': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.3.3': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.3': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.3.3': + optional: true + + '@tailwindcss/oxide@4.3.3': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.3.3 + '@tailwindcss/oxide-darwin-arm64': 4.3.3 + '@tailwindcss/oxide-darwin-x64': 4.3.3 + '@tailwindcss/oxide-freebsd-x64': 4.3.3 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.3 + '@tailwindcss/oxide-linux-arm64-gnu': 4.3.3 + '@tailwindcss/oxide-linux-arm64-musl': 4.3.3 + '@tailwindcss/oxide-linux-x64-gnu': 4.3.3 + '@tailwindcss/oxide-linux-x64-musl': 4.3.3 + '@tailwindcss/oxide-wasm32-wasi': 4.3.3 + '@tailwindcss/oxide-win32-arm64-msvc': 4.3.3 + '@tailwindcss/oxide-win32-x64-msvc': 4.3.3 + + '@tailwindcss/vite@4.3.3(vite@8.2.1(@types/node@25.9.5)(jiti@2.7.0))': + dependencies: + '@tailwindcss/node': 4.3.3 + '@tailwindcss/oxide': 4.3.3 + tailwindcss: 4.3.3 + vite: 8.2.1(@types/node@25.9.5)(jiti@2.7.0) + + '@tybys/wasm-util@0.10.3': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/esrecurse@4.3.1': {} + + '@types/estree@1.0.9': {} + + '@types/json-schema@7.0.15': {} + + '@types/node@25.9.5': + dependencies: + undici-types: 7.24.6 + + '@typescript-eslint/eslint-plugin@8.66.0(@typescript-eslint/parser@8.66.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.66.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + '@typescript-eslint/scope-manager': 8.66.0 + '@typescript-eslint/type-utils': 8.66.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + '@typescript-eslint/utils': 8.66.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.66.0 + eslint: 10.8.0(jiti@2.7.0)(supports-color@7.2.0) + ignore: 7.0.6 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.66.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.66.0 + '@typescript-eslint/types': 8.66.0 + '@typescript-eslint/typescript-estree': 8.66.0(supports-color@7.2.0)(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.66.0 + debug: 4.4.3(supports-color@7.2.0) + eslint: 10.8.0(jiti@2.7.0)(supports-color@7.2.0) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.66.0(supports-color@7.2.0)(typescript@6.0.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.66.0(typescript@6.0.3) + '@typescript-eslint/types': 8.66.0 + debug: 4.4.3(supports-color@7.2.0) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.66.0': + dependencies: + '@typescript-eslint/types': 8.66.0 + '@typescript-eslint/visitor-keys': 8.66.0 + + '@typescript-eslint/tsconfig-utils@8.66.0(typescript@6.0.3)': + dependencies: + typescript: 6.0.3 + + '@typescript-eslint/type-utils@8.66.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)': + dependencies: + '@typescript-eslint/types': 8.66.0 + '@typescript-eslint/typescript-estree': 8.66.0(supports-color@7.2.0)(typescript@6.0.3) + '@typescript-eslint/utils': 8.66.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + debug: 4.4.3(supports-color@7.2.0) + eslint: 10.8.0(jiti@2.7.0)(supports-color@7.2.0) + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.66.0': {} + + '@typescript-eslint/typescript-estree@8.66.0(supports-color@7.2.0)(typescript@6.0.3)': + dependencies: + '@typescript-eslint/project-service': 8.66.0(supports-color@7.2.0)(typescript@6.0.3) + '@typescript-eslint/tsconfig-utils': 8.66.0(typescript@6.0.3) + '@typescript-eslint/types': 8.66.0 + '@typescript-eslint/visitor-keys': 8.66.0 + debug: 4.4.3(supports-color@7.2.0) + minimatch: 10.2.6 + semver: 7.8.5 + tinyglobby: 0.2.17 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.66.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)': + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0)) + '@typescript-eslint/scope-manager': 8.66.0 + '@typescript-eslint/types': 8.66.0 + '@typescript-eslint/typescript-estree': 8.66.0(supports-color@7.2.0)(typescript@6.0.3) + eslint: 10.8.0(jiti@2.7.0)(supports-color@7.2.0) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.66.0': + dependencies: + '@typescript-eslint/types': 8.66.0 + eslint-visitor-keys: 5.0.1 + + '@unrs/resolver-binding-android-arm-eabi@1.12.2': + optional: true + + '@unrs/resolver-binding-android-arm64@1.12.2': + optional: true + + '@unrs/resolver-binding-darwin-arm64@1.12.2': + optional: true + + '@unrs/resolver-binding-darwin-x64@1.12.2': + optional: true + + '@unrs/resolver-binding-freebsd-x64@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-arm-gnueabihf@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-arm-musleabihf@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-arm64-gnu@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-arm64-musl@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-loong64-gnu@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-loong64-musl@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-ppc64-gnu@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-riscv64-gnu@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-riscv64-musl@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-s390x-gnu@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-x64-gnu@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-x64-musl@1.12.2': + optional: true + + '@unrs/resolver-binding-openharmony-arm64@1.12.2': + optional: true + + '@unrs/resolver-binding-wasm32-wasi@1.12.2': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@napi-rs/wasm-runtime': 1.2.2(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + optional: true + + '@unrs/resolver-binding-win32-arm64-msvc@1.12.2': + optional: true + + '@unrs/resolver-binding-win32-ia32-msvc@1.12.2': + optional: true + + '@unrs/resolver-binding-win32-x64-msvc@1.12.2': + optional: true + + '@vitest/eslint-plugin@1.6.26(@typescript-eslint/eslint-plugin@8.66.0(@typescript-eslint/parser@8.66.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)(vitest@4.1.10(@types/node@25.9.5)(vite@8.2.1(@types/node@25.9.5)(jiti@2.7.0)))': + dependencies: + '@typescript-eslint/scope-manager': 8.66.0 + '@typescript-eslint/utils': 8.66.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + eslint: 10.8.0(jiti@2.7.0)(supports-color@7.2.0) + optionalDependencies: + '@typescript-eslint/eslint-plugin': 8.66.0(@typescript-eslint/parser@8.66.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + typescript: 6.0.3 + vitest: 4.1.10(@types/node@25.9.5)(vite@8.2.1(@types/node@25.9.5)(jiti@2.7.0)) + transitivePeerDependencies: + - supports-color + + '@vitest/expect@4.1.10': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + chai: 6.2.2 + tinyrainbow: 3.1.1 + + '@vitest/mocker@4.1.10(vite@8.2.1(@types/node@25.9.5)(jiti@2.7.0))': + dependencies: + '@vitest/spy': 4.1.10 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.2.1(@types/node@25.9.5)(jiti@2.7.0) + + '@vitest/pretty-format@4.1.10': + dependencies: + tinyrainbow: 3.1.1 + + '@vitest/runner@4.1.10': + dependencies: + '@vitest/utils': 4.1.10 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + '@vitest/utils': 4.1.10 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.10': {} + + '@vitest/utils@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.1 + + '@vue-jsx-vapor/compiler-rs-android-arm-eabi@3.2.19': + optional: true + + '@vue-jsx-vapor/compiler-rs-android-arm64@3.2.19': + optional: true + + '@vue-jsx-vapor/compiler-rs-darwin-arm64@3.2.19': + optional: true + + '@vue-jsx-vapor/compiler-rs-darwin-x64@3.2.19': + optional: true + + '@vue-jsx-vapor/compiler-rs-freebsd-x64@3.2.19': + optional: true + + '@vue-jsx-vapor/compiler-rs-linux-arm-gnueabihf@3.2.19': + optional: true + + '@vue-jsx-vapor/compiler-rs-linux-arm64-gnu@3.2.19': + optional: true + + '@vue-jsx-vapor/compiler-rs-linux-arm64-musl@3.2.19': + optional: true + + '@vue-jsx-vapor/compiler-rs-linux-x64-gnu@3.2.19': + optional: true + + '@vue-jsx-vapor/compiler-rs-linux-x64-musl@3.2.19': + optional: true + + '@vue-jsx-vapor/compiler-rs-wasm32-wasi@3.2.19': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@napi-rs/wasm-runtime': 1.2.2(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + optional: true + + '@vue-jsx-vapor/compiler-rs-win32-arm64-msvc@3.2.19': + optional: true + + '@vue-jsx-vapor/compiler-rs-win32-ia32-msvc@3.2.19': + optional: true + + '@vue-jsx-vapor/compiler-rs-win32-x64-msvc@3.2.19': + optional: true + + '@vue-jsx-vapor/compiler-rs@3.2.19': + optionalDependencies: + '@vue-jsx-vapor/compiler-rs-android-arm-eabi': 3.2.19 + '@vue-jsx-vapor/compiler-rs-android-arm64': 3.2.19 + '@vue-jsx-vapor/compiler-rs-darwin-arm64': 3.2.19 + '@vue-jsx-vapor/compiler-rs-darwin-x64': 3.2.19 + '@vue-jsx-vapor/compiler-rs-freebsd-x64': 3.2.19 + '@vue-jsx-vapor/compiler-rs-linux-arm-gnueabihf': 3.2.19 + '@vue-jsx-vapor/compiler-rs-linux-arm64-gnu': 3.2.19 + '@vue-jsx-vapor/compiler-rs-linux-arm64-musl': 3.2.19 + '@vue-jsx-vapor/compiler-rs-linux-x64-gnu': 3.2.19 + '@vue-jsx-vapor/compiler-rs-linux-x64-musl': 3.2.19 + '@vue-jsx-vapor/compiler-rs-wasm32-wasi': 3.2.19 + '@vue-jsx-vapor/compiler-rs-win32-arm64-msvc': 3.2.19 + '@vue-jsx-vapor/compiler-rs-win32-ia32-msvc': 3.2.19 + '@vue-jsx-vapor/compiler-rs-win32-x64-msvc': 3.2.19 + + '@vue-jsx-vapor/macros@3.2.19(rolldown@1.2.3)(vite@8.2.1(@types/node@25.9.5)(jiti@2.7.0))(vue@3.6.0-rc.2(typescript@6.0.3))': + dependencies: + hash-sum: 2.0.0 + magic-string: 0.30.21 + ts-macro: 0.3.7 + unplugin: 3.3.0(rolldown@1.2.3)(vite@8.2.1(@types/node@25.9.5)(jiti@2.7.0)) + unplugin-utils: 0.3.2 + vue: 3.6.0-rc.2(typescript@6.0.3) + optionalDependencies: + vite: 8.2.1(@types/node@25.9.5)(jiti@2.7.0) + transitivePeerDependencies: + - '@farmfe/core' + - '@rspack/core' + - bun-types-no-globals + - rolldown + - unloader + + '@vue-jsx-vapor/runtime@3.2.19(csstype@3.2.3)(vue@3.6.0-rc.2(typescript@6.0.3))': + dependencies: + csstype: 3.2.3 + vue: 3.6.0-rc.2(typescript@6.0.3) + + '@vue/compiler-core@3.6.0-rc.2': + dependencies: + '@babel/parser': 7.29.8 + '@vue/shared': 3.6.0-rc.2 + entities: 7.0.1 + estree-walker: 2.0.2 + source-map-js: 1.2.1 + + '@vue/compiler-dom@3.6.0-rc.2': + dependencies: + '@vue/compiler-core': 3.6.0-rc.2 + '@vue/shared': 3.6.0-rc.2 + + '@vue/compiler-sfc@3.6.0-rc.2': + dependencies: + '@babel/parser': 7.29.8 + '@vue/compiler-core': 3.6.0-rc.2 + '@vue/compiler-dom': 3.6.0-rc.2 + '@vue/compiler-ssr': 3.6.0-rc.2 + '@vue/compiler-vapor': 3.6.0-rc.2 + '@vue/shared': 3.6.0-rc.2 + estree-walker: 2.0.2 + magic-string: 0.30.21 + postcss: 8.5.25 + source-map-js: 1.2.1 + + '@vue/compiler-ssr@3.6.0-rc.2': + dependencies: + '@vue/compiler-dom': 3.6.0-rc.2 + '@vue/shared': 3.6.0-rc.2 + + '@vue/compiler-vapor@3.6.0-rc.2': + dependencies: + '@babel/parser': 7.29.8 + '@vue/compiler-dom': 3.6.0-rc.2 + '@vue/shared': 3.6.0-rc.2 + estree-walker: 2.0.2 + source-map-js: 1.2.1 + + '@vue/devtools-api@8.2.1': + dependencies: + '@vue/devtools-kit': 8.2.1 + + '@vue/devtools-kit@8.2.1': + dependencies: + '@vue/devtools-shared': 8.2.1 + birpc: 2.9.0 + hookable: 5.5.3 + perfect-debounce: 2.1.0 + + '@vue/devtools-shared@8.2.1': {} + + '@vue/reactivity@3.6.0-rc.2': + dependencies: + '@vue/shared': 3.6.0-rc.2 + + '@vue/runtime-core@3.6.0-rc.2': + dependencies: + '@vue/reactivity': 3.6.0-rc.2 + '@vue/shared': 3.6.0-rc.2 + + '@vue/runtime-dom@3.6.0-rc.2': + dependencies: + '@vue/reactivity': 3.6.0-rc.2 + '@vue/runtime-core': 3.6.0-rc.2 + '@vue/shared': 3.6.0-rc.2 + csstype: 3.2.3 + + '@vue/runtime-vapor@3.6.0-rc.2(@vue/runtime-dom@3.6.0-rc.2)': + dependencies: + '@vue/reactivity': 3.6.0-rc.2 + '@vue/runtime-dom': 3.6.0-rc.2 + '@vue/shared': 3.6.0-rc.2 + + '@vue/server-renderer@3.6.0-rc.2': + dependencies: + '@vue/compiler-ssr': 3.6.0-rc.2 + '@vue/runtime-dom': 3.6.0-rc.2 + '@vue/shared': 3.6.0-rc.2 + + '@vue/shared@3.6.0-rc.1': {} + + '@vue/shared@3.6.0-rc.2': {} + + acorn-jsx@5.3.2(acorn@8.18.0): + dependencies: + acorn: 8.18.0 + + acorn@8.18.0: {} + + ajv@6.15.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + assertion-error@2.0.1: {} + + balanced-match@4.0.4: {} + + baseline-browser-mapping@2.11.12: {} + + birpc@2.9.0: {} + + boolbase@1.0.0: {} + + brace-expansion@5.0.9: + dependencies: + balanced-match: 4.0.4 + + browserslist@4.28.7: + dependencies: + baseline-browser-mapping: 2.11.12 + caniuse-lite: 1.0.30001806 + electron-to-chromium: 1.5.401 + node-releases: 2.0.52 + update-browserslist-db: 1.2.3(browserslist@4.28.7) + + builtin-modules@5.3.0: {} + + caniuse-lite@1.0.30001806: {} + + chai@6.2.2: {} + + change-case@5.4.4: {} + + ci-info@4.4.0: {} + + comment-parser@1.4.7: {} + + convert-source-map@2.0.0: {} + + core-js-compat@3.50.0: + dependencies: + browserslist: 4.28.7 + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + cssesc@3.0.0: {} + + csstype@3.2.3: {} + + date-fns@4.4.0: {} + + debug@4.4.3(supports-color@7.2.0): + dependencies: + ms: 2.1.3 + optionalDependencies: + supports-color: 7.2.0 + + deep-is@0.1.4: {} + + detect-indent@7.0.2: {} + + detect-libc@2.1.2: {} + + electron-to-chromium@1.5.401: {} + + enhanced-resolve@5.24.5: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.3 + + entities@7.0.1: {} + + es-module-lexer@2.3.1: {} + + escalade@3.2.0: {} + + escape-string-regexp@4.0.0: {} + + eslint-compat-utils@0.5.1(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0)): + dependencies: + eslint: 10.8.0(jiti@2.7.0)(supports-color@7.2.0) + semver: 7.8.5 + + eslint-import-context@0.1.9(unrs-resolver@1.12.2): + dependencies: + get-tsconfig: 4.14.1 + stable-hash-x: 0.2.0 + optionalDependencies: + unrs-resolver: 1.12.2 + + eslint-plugin-es-x@7.8.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0)): + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0)) + '@eslint-community/regexpp': 4.12.2 + eslint: 10.8.0(jiti@2.7.0)(supports-color@7.2.0) + eslint-compat-utils: 0.5.1(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0)) + + eslint-plugin-import-x@4.17.1(@typescript-eslint/utils@8.66.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0): + dependencies: + '@typescript-eslint/types': 8.66.0 + comment-parser: 1.4.7 + debug: 4.4.3(supports-color@7.2.0) + eslint: 10.8.0(jiti@2.7.0)(supports-color@7.2.0) + eslint-import-context: 0.1.9(unrs-resolver@1.12.2) + is-glob: 4.0.3 + minimatch: 10.2.6 + semver: 7.8.5 + stable-hash-x: 0.2.0 + unrs-resolver: 1.12.2 + optionalDependencies: + '@typescript-eslint/utils': 8.66.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + transitivePeerDependencies: + - supports-color + + eslint-plugin-n@18.2.2(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(typescript@6.0.3): + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0)) + enhanced-resolve: 5.24.5 + eslint: 10.8.0(jiti@2.7.0)(supports-color@7.2.0) + eslint-plugin-es-x: 7.8.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0)) + get-tsconfig: 4.14.1 + globals: 15.15.0 + globrex: 0.1.2 + ignore: 5.3.2 + semver: 7.8.5 + optionalDependencies: + typescript: 6.0.3 + + eslint-plugin-regexp@3.1.1(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0)): + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0)) + '@eslint-community/regexpp': 4.12.2 + comment-parser: 1.4.7 + eslint: 10.8.0(jiti@2.7.0)(supports-color@7.2.0) + jsdoc-type-pratt-parser: 7.3.0 + refa: 0.12.1 + regexp-ast-analysis: 0.7.1 + scslre: 0.3.0 + + eslint-plugin-unicorn@67.0.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0)): + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + '@eslint-community/eslint-utils': 4.10.1(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0)) + browserslist: 4.28.7 + change-case: 5.4.4 + ci-info: 4.4.0 + core-js-compat: 3.50.0 + detect-indent: 7.0.2 + eslint: 10.8.0(jiti@2.7.0)(supports-color@7.2.0) + find-up-simple: 1.0.1 + globals: 17.9.0 + indent-string: 5.0.0 + is-builtin-module: 5.0.0 + jsesc: 3.1.0 + pluralize: 8.0.0 + regjsparser: 0.13.2 + semver: 7.8.5 + strip-indent: 4.1.1 + + eslint-plugin-vue@10.10.0(@stylistic/eslint-plugin@5.10.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0)))(@typescript-eslint/parser@8.66.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(vue-eslint-parser@10.4.1(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)): + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0)) + eslint: 10.8.0(jiti@2.7.0)(supports-color@7.2.0) + natural-compare: 1.4.0 + nth-check: 2.1.1 + postcss-selector-parser: 7.1.4 + semver: 7.8.5 + vue-eslint-parser: 10.4.1(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0) + xml-name-validator: 5.0.0 + optionalDependencies: + '@stylistic/eslint-plugin': 5.10.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0)) + '@typescript-eslint/parser': 8.66.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + + eslint-scope@9.1.2: + dependencies: + '@types/esrecurse': 4.3.1 + '@types/estree': 1.0.9 + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@4.2.1: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0): + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0)) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.23.5(supports-color@7.2.0) + '@eslint/config-helpers': 0.7.0 + '@eslint/core': 1.2.1 + '@eslint/plugin-kit': 0.7.2 + '@humanfs/node': 0.16.8 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.9 + ajv: 6.15.0 + cross-spawn: 7.0.6 + debug: 4.4.3(supports-color@7.2.0) + escape-string-regexp: 4.0.0 + eslint-scope: 9.1.2 + eslint-visitor-keys: 5.0.1 + espree: 11.2.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + minimatch: 10.2.6 + natural-compare: 1.4.0 + optionator: 0.9.4 + optionalDependencies: + jiti: 2.7.0 + transitivePeerDependencies: + - supports-color + + espree@10.4.0: + dependencies: + acorn: 8.18.0 + acorn-jsx: 5.3.2(acorn@8.18.0) + eslint-visitor-keys: 4.2.1 + + espree@11.2.0: + dependencies: + acorn: 8.18.0 + acorn-jsx: 5.3.2(acorn@8.18.0) + eslint-visitor-keys: 5.0.1 + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + estree-walker@2.0.2: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + esutils@2.0.3: {} + + expect-type@1.4.0: {} + + fast-deep-equal@3.1.3: {} + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + find-up-simple@1.0.1: {} + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.4.4 + keyv: 4.5.4 + + flatted@3.4.4: {} + + fsevents@2.3.3: + optional: true + + get-tsconfig@4.14.1: + dependencies: + resolve-pkg-maps: 1.0.0 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + globals@15.15.0: {} + + globals@17.9.0: {} + + globrex@0.1.2: {} + + graceful-fs@4.2.11: {} + + has-flag@4.0.0: + optional: true + + hash-sum@2.0.0: {} + + hookable@5.5.3: {} + + ignore@5.3.2: {} + + ignore@7.0.6: {} + + imurmurhash@0.1.4: {} + + indent-string@5.0.0: {} + + is-builtin-module@5.0.0: + dependencies: + builtin-modules: 5.3.0 + + is-extglob@2.1.1: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + isexe@2.0.0: {} + + jiti@2.7.0: {} + + jsdoc-type-pratt-parser@7.3.0: {} + + jsesc@3.1.0: {} + + json-buffer@3.0.1: {} + + json-schema-traverse@0.4.1: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-android-arm64@1.33.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.33.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.33.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.33.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.33.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.33.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.33.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.33.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lucide@1.28.0: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + minimatch@10.2.6: + dependencies: + brace-expansion: 5.0.9 + + ms@2.1.3: {} + + muggle-string@0.4.1: {} + + nanoid@3.3.17: {} + + napi-postinstall@0.3.4: {} + + natural-compare@1.4.0: {} + + node-releases@2.0.52: {} + + nth-check@2.1.1: + dependencies: + boolbase: 1.0.0 + + obug@2.1.4: {} + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + pathe@2.0.3: {} + + perfect-debounce@2.1.0: {} + + picocolors@1.1.1: {} + + picomatch@4.0.5: {} + + pluralize@8.0.0: {} + + postcss-selector-parser@7.1.4: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss@8.5.25: + dependencies: + nanoid: 3.3.17 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prelude-ls@1.2.1: {} + + punycode@2.3.1: {} + + refa@0.12.1: + dependencies: + '@eslint-community/regexpp': 4.12.2 + + regexp-ast-analysis@0.7.1: + dependencies: + '@eslint-community/regexpp': 4.12.2 + refa: 0.12.1 + + regjsparser@0.13.2: + dependencies: + jsesc: 3.1.0 + + resolve-pkg-maps@1.0.0: {} + + rolldown@1.2.3: + dependencies: + '@oxc-project/types': 0.143.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.2.3 + '@rolldown/binding-darwin-arm64': 1.2.3 + '@rolldown/binding-darwin-x64': 1.2.3 + '@rolldown/binding-freebsd-x64': 1.2.3 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.3 + '@rolldown/binding-linux-arm64-gnu': 1.2.3 + '@rolldown/binding-linux-arm64-musl': 1.2.3 + '@rolldown/binding-linux-ppc64-gnu': 1.2.3 + '@rolldown/binding-linux-s390x-gnu': 1.2.3 + '@rolldown/binding-linux-x64-gnu': 1.2.3 + '@rolldown/binding-linux-x64-musl': 1.2.3 + '@rolldown/binding-openharmony-arm64': 1.2.3 + '@rolldown/binding-win32-arm64-msvc': 1.2.3 + '@rolldown/binding-win32-x64-msvc': 1.2.3 + + scslre@0.3.0: + dependencies: + '@eslint-community/regexpp': 4.12.2 + refa: 0.12.1 + regexp-ast-analysis: 0.7.1 + + semver@7.8.5: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + siginfo@2.0.0: {} + + source-map-js@1.2.1: {} + + stable-hash-x@0.2.0: {} + + stackback@0.0.2: {} + + std-env@4.2.0: {} + + strip-indent@4.1.1: {} + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + optional: true + + tailwindcss@4.3.3: {} + + tapable@2.3.3: {} + + tinybench@2.9.0: {} + + tinyexec@1.3.0: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + tinyrainbow@3.1.1: {} + + ts-api-utils@2.5.0(typescript@6.0.3): + dependencies: + typescript: 6.0.3 + + ts-macro@0.3.7: + dependencies: + muggle-string: 0.4.1 + + tslib@2.8.1: + optional: true + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + typescript-eslint@8.66.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.66.0(@typescript-eslint/parser@8.66.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + '@typescript-eslint/parser': 8.66.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + '@typescript-eslint/typescript-estree': 8.66.0(supports-color@7.2.0)(typescript@6.0.3) + '@typescript-eslint/utils': 8.66.0(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + eslint: 10.8.0(jiti@2.7.0)(supports-color@7.2.0) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + typescript@6.0.3: {} + + undici-types@7.24.6: {} + + unplugin-utils@0.3.2: + dependencies: + pathe: 2.0.3 + picomatch: 4.0.5 + + unplugin@3.3.0(rolldown@1.2.3)(vite@8.2.1(@types/node@25.9.5)(jiti@2.7.0)): + dependencies: + '@jridgewell/remapping': 2.3.5 + picomatch: 4.0.5 + webpack-virtual-modules: 0.6.2 + optionalDependencies: + rolldown: 1.2.3 + vite: 8.2.1(@types/node@25.9.5)(jiti@2.7.0) + + unrs-resolver@1.12.2: + dependencies: + napi-postinstall: 0.3.4 + optionalDependencies: + '@unrs/resolver-binding-android-arm-eabi': 1.12.2 + '@unrs/resolver-binding-android-arm64': 1.12.2 + '@unrs/resolver-binding-darwin-arm64': 1.12.2 + '@unrs/resolver-binding-darwin-x64': 1.12.2 + '@unrs/resolver-binding-freebsd-x64': 1.12.2 + '@unrs/resolver-binding-linux-arm-gnueabihf': 1.12.2 + '@unrs/resolver-binding-linux-arm-musleabihf': 1.12.2 + '@unrs/resolver-binding-linux-arm64-gnu': 1.12.2 + '@unrs/resolver-binding-linux-arm64-musl': 1.12.2 + '@unrs/resolver-binding-linux-loong64-gnu': 1.12.2 + '@unrs/resolver-binding-linux-loong64-musl': 1.12.2 + '@unrs/resolver-binding-linux-ppc64-gnu': 1.12.2 + '@unrs/resolver-binding-linux-riscv64-gnu': 1.12.2 + '@unrs/resolver-binding-linux-riscv64-musl': 1.12.2 + '@unrs/resolver-binding-linux-s390x-gnu': 1.12.2 + '@unrs/resolver-binding-linux-x64-gnu': 1.12.2 + '@unrs/resolver-binding-linux-x64-musl': 1.12.2 + '@unrs/resolver-binding-openharmony-arm64': 1.12.2 + '@unrs/resolver-binding-wasm32-wasi': 1.12.2 + '@unrs/resolver-binding-win32-arm64-msvc': 1.12.2 + '@unrs/resolver-binding-win32-ia32-msvc': 1.12.2 + '@unrs/resolver-binding-win32-x64-msvc': 1.12.2 + + update-browserslist-db@1.2.3(browserslist@4.28.7): + dependencies: + browserslist: 4.28.7 + escalade: 3.2.0 + picocolors: 1.1.1 + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + util-deprecate@1.0.2: {} + + vite@8.2.1(@types/node@25.9.5)(jiti@2.7.0): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.5 + postcss: 8.5.25 + rolldown: 1.2.3 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 25.9.5 + fsevents: 2.3.3 + jiti: 2.7.0 + + vitest@4.1.10(@types/node@25.9.5)(vite@8.2.1(@types/node@25.9.5)(jiti@2.7.0)): + dependencies: + '@vitest/expect': 4.1.10 + '@vitest/mocker': 4.1.10(vite@8.2.1(@types/node@25.9.5)(jiti@2.7.0)) + '@vitest/pretty-format': 4.1.10 + '@vitest/runner': 4.1.10 + '@vitest/snapshot': 4.1.10 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + es-module-lexer: 2.3.1 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.1.4 + pathe: 2.0.3 + picomatch: 4.0.5 + std-env: 4.2.0 + tinybench: 2.9.0 + tinyexec: 1.3.0 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.1 + vite: 8.2.1(@types/node@25.9.5)(jiti@2.7.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 25.9.5 + transitivePeerDependencies: + - msw + + vue-eslint-parser@10.4.1(eslint@10.8.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0): + dependencies: + debug: 4.4.3(supports-color@7.2.0) + eslint: 10.8.0(jiti@2.7.0)(supports-color@7.2.0) + eslint-scope: 9.1.2 + eslint-visitor-keys: 5.0.1 + espree: 11.2.0 + esquery: 1.7.0 + semver: 7.8.5 + transitivePeerDependencies: + - supports-color + + vue-jsx-vapor@3.2.19(csstype@3.2.3)(rolldown@1.2.3)(vite@8.2.1(@types/node@25.9.5)(jiti@2.7.0))(vue@3.6.0-rc.2(typescript@6.0.3)): + dependencies: + '@vue-jsx-vapor/compiler-rs': 3.2.19 + '@vue-jsx-vapor/macros': 3.2.19(rolldown@1.2.3)(vite@8.2.1(@types/node@25.9.5)(jiti@2.7.0))(vue@3.6.0-rc.2(typescript@6.0.3)) + '@vue-jsx-vapor/runtime': 3.2.19(csstype@3.2.3)(vue@3.6.0-rc.2(typescript@6.0.3)) + '@vue/shared': 3.6.0-rc.1 + pathe: 2.0.3 + ts-macro: 0.3.7 + unplugin: 3.3.0(rolldown@1.2.3)(vite@8.2.1(@types/node@25.9.5)(jiti@2.7.0)) + unplugin-utils: 0.3.2 + vue: 3.6.0-rc.2(typescript@6.0.3) + optionalDependencies: + vite: 8.2.1(@types/node@25.9.5)(jiti@2.7.0) + transitivePeerDependencies: + - '@farmfe/core' + - '@rspack/core' + - bun-types-no-globals + - csstype + - rolldown + - unloader + + vue-sync-engine@file:../vue-sync-engine/lib(vite@8.2.1(@types/node@25.9.5)(jiti@2.7.0))(vue@3.6.0-rc.2(typescript@6.0.3)): + dependencies: + '@vue/devtools-api': 8.2.1 + vue: 3.6.0-rc.2(typescript@6.0.3) + optionalDependencies: + vite: 8.2.1(@types/node@25.9.5)(jiti@2.7.0) + + vue@3.6.0-rc.2(typescript@6.0.3): + dependencies: + '@vue/compiler-dom': 3.6.0-rc.2 + '@vue/compiler-sfc': 3.6.0-rc.2 + '@vue/runtime-dom': 3.6.0-rc.2 + '@vue/runtime-vapor': 3.6.0-rc.2(@vue/runtime-dom@3.6.0-rc.2) + '@vue/server-renderer': 3.6.0-rc.2 + '@vue/shared': 3.6.0-rc.2 + optionalDependencies: + typescript: 6.0.3 + + webpack-virtual-modules@0.6.2: {} + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + word-wrap@1.2.5: {} + + xml-name-validator@5.0.0: {} + + yocto-queue@0.1.0: {} diff --git a/kcal/pnpm-workspace.yaml b/kcal/pnpm-workspace.yaml new file mode 100644 index 0000000..ed33aa1 --- /dev/null +++ b/kcal/pnpm-workspace.yaml @@ -0,0 +1,8 @@ +allowBuilds: + unrs-resolver: true +minimumReleaseAgeExclude: + - vite@8.2.1 +peerDependencyRules: + allowAny: + - vue + - rollup diff --git a/kcal/src/App.tsx b/kcal/src/App.tsx new file mode 100644 index 0000000..3a5ee4d --- /dev/null +++ b/kcal/src/App.tsx @@ -0,0 +1,117 @@ +import { computed, watchEffect } from 'vue'; +import { useQuery } from 'vue-sync-engine'; +import { profileQuery } from './data/defs'; +import type { Meal } from './domain/types'; +import { activeTab, addSheet, editEntryId, foodForm, openAddSheet } from './ui/state'; +import type { Tab } from './ui/state'; +import { IconApple, IconBook, IconChart, IconPlus, IconUser } from './ui/icons'; +import DiaryScreen from './screens/DiaryScreen'; +import StatsScreen from './screens/StatsScreen'; +import FoodsScreen from './screens/FoodsScreen'; +import ProfileScreen from './screens/ProfileScreen'; +import AddSheet from './screens/AddSheet'; +import EditEntrySheet from './screens/EditEntrySheet'; +import FoodFormSheet from './screens/FoodFormSheet'; + +const TABS: ReadonlyArray<{ id: Tab; label: string }> = [ + { id: 'diary', label: 'Дневник' }, + { id: 'stats', label: 'Статистика' }, + { id: 'foods', label: 'Продукты' }, + { id: 'profile', label: 'Профиль' }, +]; + +/** Приём пищи по времени суток — разумный дефолт для кнопки «+». */ +function mealByHour(hour: number): Meal { + if (hour < 11) return 'breakfast'; + if (hour < 16) return 'lunch'; + if (hour < 21) return 'dinner'; + return 'snack'; +} + +function tabIcon(tab: Tab, active: boolean) { + const cls = `size-5.5 ${active ? 'text-ember-bright' : 'text-ink-faint'}`; + if (tab === 'diary') return ; + if (tab === 'stats') return ; + if (tab === 'foods') return ; + return ; +} + +export default function App() { + const profileQ = useQuery(profileQuery, () => undefined); + const ready = computed(() => profileQ.data.value !== undefined); + const hasProfile = computed(() => profileQ.data.value?.exists === true); + + const sheetOpen = computed(() => addSheet.open || editEntryId.value !== null || foodForm.open); + watchEffect(() => { + document.documentElement.classList.toggle('overflow-hidden', sheetOpen.value); + }); + + return ( +
+ {!ready.value && ( +
+ Ккал +
+ )} + + {ready.value && !hasProfile.value && ( +
+ +
+ )} + + {ready.value && hasProfile.value && ( + <> +
+ {activeTab.value === 'diary' && } + {activeTab.value === 'stats' && } + {activeTab.value === 'foods' && } + {activeTab.value === 'profile' && } +
+ + {/* Нижняя навигация */} + + + )} + + {addSheet.open && } + {editEntryId.value !== null && } + {foodForm.open && } +
+ ); +} diff --git a/kcal/src/app.css b/kcal/src/app.css new file mode 100644 index 0000000..078d6eb --- /dev/null +++ b/kcal/src/app.css @@ -0,0 +1,134 @@ +@import 'tailwindcss'; + +/* + * «Вечерний гроссбух»: тёплый угольный фон, кремовые чернила, янтарный акцент. + * Цвета данных (ember/protein/fat/carbs) проверены на CVD-разделимость и + * контраст к поверхности (OKLCH L 0.48–0.67, C ≥ 0.1, ΔE пар ≥ 8). + */ +@theme { + --font-sans: 'Golos Text Variable', 'Golos Text', system-ui, sans-serif; + --font-display: 'Spectral', 'Iowan Old Style', Georgia, serif; + + --color-bg: #12100d; + --color-surface: #1b1815; + --color-raised: #262119; + --color-ink: #ece4d6; + --color-ink-soft: #b3a894; + --color-ink-faint: #80766a; + + --color-ember: #cf7728; + --color-ember-bright: #eda05b; + --color-protein: #2aa08b; + --color-fat: #ae8e2c; + --color-carbs: #6284d0; + --color-over: #cf5f4d; + --color-over-bright: #e8836f; +} + +html { + background: var(--color-bg); + color-scheme: dark; +} + +body { + font-family: var(--font-sans); + color: var(--color-ink); + background: + radial-gradient(120% 70% at 50% 0%, #201b14 0%, var(--color-bg) 55%), + var(--color-bg); + min-height: 100dvh; +} + +/* Зерно поверх фона — атмосфера бумаги при свете лампы. */ +body::after { + content: ''; + position: fixed; + inset: 0; + pointer-events: none; + z-index: 70; + opacity: 0.05; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='140' height='140'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2'/%3E%3C/filter%3E%3Crect width='140' height='140' filter='url(%23n)'/%3E%3C/svg%3E"); +} + +@utility text-display { + font-family: var(--font-display); + font-variant-numeric: lining-nums; + letter-spacing: -0.01em; +} + +@utility tnum { + font-variant-numeric: tabular-nums lining-nums; +} + +@utility hairline { + border-color: color-mix(in oklab, var(--color-ink) 10%, transparent); +} + +@utility scrollbar-none { + scrollbar-width: none; + &::-webkit-scrollbar { + display: none; + } +} + +/* Появление контента: мягкий подъём, каскад через animation-delay. */ +@keyframes rise { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@utility animate-rise { + animation: rise 0.45s cubic-bezier(0.22, 1, 0.36, 1) both; +} + +@keyframes sheet-up { + from { + transform: translateY(100%); + } + to { + transform: translateY(0); + } +} + +@utility animate-sheet-up { + animation: sheet-up 0.38s cubic-bezier(0.22, 1, 0.36, 1) both; +} + +@keyframes fade-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@utility animate-fade-in { + animation: fade-in 0.25s ease-out both; +} + +/* Кольцо калорий: плавное перетекание дуги при изменении данных. */ +.ring-arc { + transition: stroke-dashoffset 0.7s cubic-bezier(0.22, 1, 0.36, 1), stroke 0.4s ease; +} + +.bar-fill { + transition: width 0.5s cubic-bezier(0.22, 1, 0.36, 1), background-color 0.3s ease; +} + +/* Прячем спиннеры числовых инпутов — порции вводятся с клавиатуры и чипсами. */ +input[type='number']::-webkit-outer-spin-button, +input[type='number']::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; +} +input[type='number'] { + -moz-appearance: textfield; + appearance: textfield; +} diff --git a/kcal/src/components/BarcodeScanner.tsx b/kcal/src/components/BarcodeScanner.tsx new file mode 100644 index 0000000..c039a7f --- /dev/null +++ b/kcal/src/components/BarcodeScanner.tsx @@ -0,0 +1,94 @@ +import { onMounted, onUnmounted, shallowRef } from 'vue'; +import { useRef } from 'vue-jsx-vapor'; + +/** Есть ли в этом браузере нативное распознавание штрихкодов (Chromium). */ +export function isBarcodeScanSupported(): boolean { + return typeof BarcodeDetector !== 'undefined' && !!navigator.mediaDevices?.getUserMedia; +} + +const SCAN_INTERVAL_MS = 300; + +/** + * Камера + BarcodeDetector: распознали EAN/UPC — отдали наверх и погасили + * камеру. Компонент монтируется только после isBarcodeScanSupported(). + */ +export default function BarcodeScanner(props: { onDetected: (code: string) => void; onCancel: () => void }) { + const videoEl = useRef(); + const error = shallowRef(''); + + let stream: MediaStream | null = null; + let timer: ReturnType | null = null; + let finished = false; + + const stop = () => { + if (timer !== null) clearInterval(timer); + timer = null; + stream?.getTracks().forEach(track => track.stop()); + stream = null; + }; + + onMounted(async () => { + const video = videoEl.value as HTMLVideoElement | null; + if (!video) return; + try { + stream = await navigator.mediaDevices.getUserMedia({ + video: { facingMode: 'environment' }, + audio: false, + }); + video.srcObject = stream; + await video.play(); + + const detector = new BarcodeDetector({ + formats: ['ean_13', 'ean_8', 'upc_a', 'upc_e', 'code_128'], + }); + timer = setInterval(async () => { + if (finished || video.readyState < 2) return; + try { + const barcodes = await detector.detect(video); + const code = barcodes[0]?.rawValue; + if (code) { + finished = true; + stop(); + props.onDetected(code); + } + } + catch { + // Единичный сбой кадра — просто ждём следующий тик. + } + }, SCAN_INTERVAL_MS); + } + catch (cause) { + error.value = cause instanceof DOMException && cause.name === 'NotAllowedError' + ? 'Нет доступа к камере — разрешите его в настройках браузера.' + : 'Не удалось включить камеру.'; + } + }); + + onUnmounted(stop); + + return ( +
+ {error.value === '' + ? ( +
+
+ ); +} diff --git a/kcal/src/components/MacroBar.tsx b/kcal/src/components/MacroBar.tsx new file mode 100644 index 0000000..ae61d86 --- /dev/null +++ b/kcal/src/components/MacroBar.tsx @@ -0,0 +1,37 @@ +import { computed } from 'vue'; +import { clamp } from '@robonen/stdlib'; +import { fmtG } from '../domain/format'; + +const COLORS = { + protein: 'var(--color-protein)', + fat: 'var(--color-fat)', + carbs: 'var(--color-carbs)', +} as const; + +/** Полоса «съедено/цель» по одному макронутриенту, в граммах. */ +export default function MacroBar(props: { + label: string; + value: number; + target: number; + color: keyof typeof COLORS; +}) { + const percent = computed(() => (props.target > 0 ? clamp((props.value / props.target) * 100, 0, 100) : 0)); + + return ( +
+
+ + {props.label} +
+
+
+
+
+ {`${fmtG(props.value)} / ${fmtG(props.target)} г`} +
+
+ ); +} diff --git a/kcal/src/components/ProgressRing.tsx b/kcal/src/components/ProgressRing.tsx new file mode 100644 index 0000000..30c768e --- /dev/null +++ b/kcal/src/components/ProgressRing.tsx @@ -0,0 +1,64 @@ +import { computed } from 'vue'; +import { clamp } from '@robonen/stdlib'; +import { fmtKcal } from '../domain/format'; + +const SIZE = 216; +const RADIUS = 92; +const STROKE = 11; +const CIRCUMFERENCE = 2 * Math.PI * RADIUS; + +/** + * Главный индикатор дня: съедено против цели, в центре — остаток. + * При переборе дуга замыкается и меняет цвет, остаток становится «сверх цели». + */ +export default function ProgressRing(props: { eaten: number; target: number }) { + const over = computed(() => props.eaten > props.target); + const fraction = computed(() => (props.target > 0 ? clamp(props.eaten / props.target, 0, 1) : 0)); + const dashOffset = computed(() => CIRCUMFERENCE * (1 - fraction.value)); + const remaining = computed(() => Math.abs(props.target - props.eaten)); + + return ( +
+ + + + + + + + + + + +
+
+ {fmtKcal(remaining.value)} +
+
+ {over.value ? 'ккал сверх цели' : 'ккал осталось'} +
+
+ {`${fmtKcal(props.eaten)} из ${fmtKcal(props.target)}`} +
+
+
+ ); +} diff --git a/kcal/src/data/backup.ts b/kcal/src/data/backup.ts new file mode 100644 index 0000000..9dfe677 --- /dev/null +++ b/kcal/src/data/backup.ts @@ -0,0 +1,57 @@ +import { entryStore, foodStore, profileStore, weightStore } from './defs'; +import type { Entry, Food, Profile, WeightLog } from '../domain/types'; + +export interface BackupPayload { + app: 'kcal'; + version: 1; + exportedAt: string; + foods: Food[]; + entries: Entry[]; + weights: WeightLog[]; + profile: Profile | null; +} + +/** Полный снимок данных для файла-бэкапа. */ +export async function exportBackup(): Promise { + const [foods, entries, weights, profiles] = await Promise.all([ + foodStore.readAll(), + entryStore.readAll(), + weightStore.readAll(), + profileStore.readAll(), + ]); + return { + app: 'kcal', + version: 1, + exportedAt: new Date().toISOString(), + foods, + entries, + weights, + profile: profiles[0] ?? null, + }; +} + +/** + * Восстановление из бэкапа: данные дописываются поверх текущих (merge по id). + * После импорта проще всего перезагрузить страницу — кэш запросов пересоберётся. + */ +export async function importBackup(payload: BackupPayload): Promise { + if (payload.app !== 'kcal' || payload.version !== 1) { + throw new Error('Файл не похож на бэкап приложения «Ккал»'); + } + await Promise.all([ + payload.foods.length > 0 ? foodStore.write(payload.foods.map(f => ({ key: f.id, value: f }))) : Promise.resolve(), + payload.entries.length > 0 ? entryStore.write(payload.entries.map(e => ({ key: e.id, value: e }))) : Promise.resolve(), + payload.weights.length > 0 ? weightStore.write(payload.weights.map(w => ({ key: w.id, value: w }))) : Promise.resolve(), + payload.profile ? profileStore.write([{ key: payload.profile.id, value: payload.profile }]) : Promise.resolve(), + ]); +} + +export function downloadBackupFile(payload: BackupPayload): void { + const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = `kcal-backup-${payload.exportedAt.slice(0, 10)}.json`; + link.click(); + URL.revokeObjectURL(url); +} diff --git a/kcal/src/data/composables.ts b/kcal/src/data/composables.ts new file mode 100644 index 0000000..3f3d1b9 --- /dev/null +++ b/kcal/src/data/composables.ts @@ -0,0 +1,25 @@ +import { computed, toValue } from 'vue'; +import type { ComputedRef, MaybeRefOrGetter } from 'vue'; +import { useEngine } from 'vue-sync-engine'; +import type { EntityDef } from 'vue-sync-engine'; + +/** + * Реактивный список сущностей по массиву id из нормализованного результата + * запроса. Mirror отдаёт per-entity версии, поэтому optimistic-патч одной + * записи не пересобирает соседей. + */ +export function useEntities( + def: EntityDef, + ids: MaybeRefOrGetter, +): ComputedRef { + const engine = useEngine(); + return computed(() => { + const list = toValue(ids) ?? []; + const items: T[] = []; + for (const id of list) { + const item = engine.mirror.getEntity(def.name, id); + if (item !== undefined) items.push(item); + } + return items; + }); +} diff --git a/kcal/src/data/defs.ts b/kcal/src/data/defs.ts new file mode 100644 index 0000000..2134801 --- /dev/null +++ b/kcal/src/data/defs.ts @@ -0,0 +1,232 @@ +import { defineEntity, defineMutation, defineQuery, idbStore } from 'vue-sync-engine'; +import type { Entry, Food, Profile, WeightLog } from '../domain/types'; +import { PROFILE_ID } from '../domain/types'; + +/** + * Бэкенда нет: источник истины — IndexedDB. Каждая сущность персистится в свой + * object store базы `kcal`; query.fetch читает оттуда же, mutation.fetch туда же + * пишет (и await'ит запись — поэтому рефетч после invalidate гарантированно + * видит свежие данные). Optimistic-патчи дают мгновенный UI поверх этого. + */ +export const DB_NAME = 'kcal'; + +export const FoodEntity = defineEntity({ + name: 'food', + id: food => food.id, + storage: idbStore({ dbName: DB_NAME }), +}); + +export const EntryEntity = defineEntity({ + name: 'entry', + id: entry => entry.id, + storage: idbStore({ dbName: DB_NAME }), +}); + +export const WeightEntity = defineEntity({ + name: 'weight', + id: weight => weight.id, + storage: idbStore({ dbName: DB_NAME }), +}); + +export const ProfileEntity = defineEntity({ + name: 'profile', + id: () => PROFILE_ID, + storage: idbStore({ dbName: DB_NAME }), +}); + +// defineEntity уже инстанцировал KeyedStore-ы — используем их как прямой доступ к idb. +export const foodStore = FoodEntity.storage!; +export const entryStore = EntryEntity.storage!; +export const weightStore = WeightEntity.storage!; +export const profileStore = ProfileEntity.storage!; + +// ── Queries ────────────────────────────────────────────────────────────────── + +export const foodsQuery = defineQuery({ + name: 'foods.all', + key: () => ['foods'], + fetch: () => foodStore.readAll(), + normalize: items => ({ + entities: { food: items }, + result: { ids: [...items].sort((a, b) => a.name.localeCompare(b.name, 'ru')).map(f => f.id) }, + }), + tags: () => ['foods'], +}); + +export const entriesByDayQuery = defineQuery<{ date: string }, Entry[], { ids: string[] }>({ + name: 'entries.byDay', + key: args => ['entries', args.date], + fetch: async ({ date }) => (await entryStore.readAll()).filter(e => e.date === date), + normalize: items => ({ + entities: { entry: items }, + result: { ids: [...items].sort((a, b) => a.createdAt - b.createdAt).map(e => e.id) }, + }), + tags: () => ['entries'], +}); + +export interface DaySummary { + date: string; + kcal: number; + protein: number; + fat: number; + carbs: number; + entries: number; +} + +/** Агрегаты по дням для статистики; сущности в кэш не тянем. */ +export const daySummariesQuery = defineQuery({ + name: 'stats.daySummaries', + key: () => ['stats', 'days'], + fetch: () => entryStore.readAll(), + normalize: (items) => { + const byDate = new Map(); + for (const entry of items) { + let day = byDate.get(entry.date); + if (!day) { + day = { date: entry.date, kcal: 0, protein: 0, fat: 0, carbs: 0, entries: 0 }; + byDate.set(entry.date, day); + } + day.kcal += entry.kcal; + day.protein += entry.protein; + day.fat += entry.fat; + day.carbs += entry.carbs; + day.entries += 1; + } + return { result: [...byDate.values()].sort((a, b) => a.date.localeCompare(b.date)) }; + }, + tags: () => ['entries'], +}); + +export const weightsQuery = defineQuery({ + name: 'weights.all', + key: () => ['weights'], + fetch: () => weightStore.readAll(), + normalize: items => ({ + entities: { weight: items }, + result: { ids: [...items].sort((a, b) => a.date.localeCompare(b.date)).map(w => w.id) }, + }), + tags: () => ['weights'], +}); + +export const profileQuery = defineQuery({ + name: 'profile.get', + key: () => ['profile'], + fetch: () => profileStore.read(PROFILE_ID), + normalize: profile => ({ + entities: { profile: profile ? [profile] : [] }, + result: { exists: profile !== undefined }, + }), + tags: () => ['profile'], +}); + +// ── Mutations ──────────────────────────────────────────────────────────────── + +export const addEntryMutation = defineMutation<{ entry: Entry }, Entry>({ + name: 'entry.add', + fetch: async ({ entry }) => { + await entryStore.write([{ key: entry.id, value: entry }]); + if (entry.foodId) { + const food = await foodStore.read(entry.foodId); + if (food) { + await foodStore.write([{ + key: food.id, + value: { + ...food, + usedCount: food.usedCount + 1, + lastUsedAt: entry.createdAt, + lastAmountG: entry.amountG ?? food.lastAmountG, + }, + }]); + } + } + return entry; + }, + optimistic: ({ entry }, ctx) => ctx.upsertEntity(EntryEntity, entry), + invalidate: () => ['entries', 'foods'], +}); + +export const updateEntryMutation = defineMutation<{ id: string; patch: Partial }, Entry | null>({ + name: 'entry.update', + fetch: async ({ id, patch }) => { + const current = await entryStore.read(id); + if (!current) return null; + const next = { ...current, ...patch }; + await entryStore.write([{ key: id, value: next }]); + return next; + }, + optimistic: ({ id, patch }, ctx) => ctx.patchEntity(EntryEntity, id, patch), + invalidate: () => ['entries'], +}); + +export const removeEntryMutation = defineMutation<{ id: string }, string>({ + name: 'entry.remove', + fetch: async ({ id }) => { + await entryStore.delete(id); + return id; + }, + optimistic: ({ id }, ctx) => ctx.removeEntity(EntryEntity, id), + invalidate: () => ['entries'], +}); + +export const upsertFoodMutation = defineMutation<{ food: Food }, Food>({ + name: 'food.upsert', + fetch: async ({ food }) => { + await foodStore.write([{ key: food.id, value: food }]); + return food; + }, + optimistic: ({ food }, ctx) => ctx.upsertEntity(FoodEntity, food), + invalidate: () => ['foods'], +}); + +export const removeFoodMutation = defineMutation<{ id: string }, string>({ + name: 'food.remove', + fetch: async ({ id }) => { + await foodStore.delete(id); + return id; + }, + optimistic: ({ id }, ctx) => ctx.removeEntity(FoodEntity, id), + invalidate: () => ['foods'], +}); + +export const saveProfileMutation = defineMutation<{ profile: Profile }, Profile>({ + name: 'profile.save', + fetch: async ({ profile }) => { + await profileStore.write([{ key: PROFILE_ID, value: profile }]); + return profile; + }, + optimistic: ({ profile }, ctx) => ctx.upsertEntity(ProfileEntity, profile), + invalidate: () => ['profile'], +}); + +export const logWeightMutation = defineMutation<{ weight: WeightLog }, WeightLog>({ + name: 'weight.log', + fetch: async ({ weight }) => { + await weightStore.write([{ key: weight.id, value: weight }]); + return weight; + }, + optimistic: ({ weight }, ctx) => ctx.upsertEntity(WeightEntity, weight), + invalidate: () => ['weights'], +}); + +export const removeWeightMutation = defineMutation<{ id: string }, string>({ + name: 'weight.remove', + fetch: async ({ id }) => { + await weightStore.delete(id); + return id; + }, + optimistic: ({ id }, ctx) => ctx.removeEntity(WeightEntity, id), + invalidate: () => ['weights'], +}); + +export const allEntities = [FoodEntity, EntryEntity, WeightEntity, ProfileEntity]; +export const allQueries = [foodsQuery, entriesByDayQuery, daySummariesQuery, weightsQuery, profileQuery]; +export const allMutations = [ + addEntryMutation, + updateEntryMutation, + removeEntryMutation, + upsertFoodMutation, + removeFoodMutation, + saveProfileMutation, + logWeightMutation, + removeWeightMutation, +]; diff --git a/kcal/src/data/engine.ts b/kcal/src/data/engine.ts new file mode 100644 index 0000000..a109923 --- /dev/null +++ b/kcal/src/data/engine.ts @@ -0,0 +1,29 @@ +import { createEngine, indexedDBAdapter } from 'vue-sync-engine'; +import { DB_NAME, allEntities, allMutations, allQueries, foodStore } from './defs'; +import { SEED_FOODS } from './seed'; + +export const CACHE_DEFAULTS = { + // Локальные чтения стоят миллисекунды — рефетчим при каждой подписке, + // консистентность важнее экономии на readAll. + staleTime: 0, + gcTime: 10 * 60_000, +}; + +/** Наполняет каталог стартовым набором при первом запуске. */ +export async function seedIfEmpty(): Promise { + const existing = await foodStore.readAll(); + if (existing.length > 0) return; + await foodStore.write(SEED_FOODS.map(food => ({ key: food.id, value: food }))); +} + +/** Inline-движок: QueryGraph и Mirror в одном треде, снапшоты запросов — в idb. */ +export function createKcalEngine() { + return createEngine({ + entities: allEntities, + queries: allQueries, + mutations: allMutations, + storage: indexedDBAdapter({ dbName: DB_NAME }), + defaultStaleTime: CACHE_DEFAULTS.staleTime, + defaultGcTime: CACHE_DEFAULTS.gcTime, + }); +} diff --git a/kcal/src/data/off.test.ts b/kcal/src/data/off.test.ts new file mode 100644 index 0000000..dadbaa1 --- /dev/null +++ b/kcal/src/data/off.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest'; +import { normalizeOffProduct } from './off'; + +// Фикстуры — реальные ответы API от 2026-08-07 (curl, поля обрезаны до запрошенных). + +describe(normalizeOffProduct, () => { + it('barcode-API: Coca-Cola 5449000000996', () => { + const product = normalizeOffProduct({ + code: '5449000000996', + brands: 'Coca-Cola', + product_name: 'coca-cola', + product_name_ru: 'Coca Cola', + serving_quantity: 330, + nutriments: { + 'energy-kcal_100g': 42, + energy_100g: 180, + carbohydrates_100g: 10.6, + fat_100g: 0, + }, + }); + expect(product).toEqual({ + code: '5449000000996', + name: 'Coca Cola', + brand: 'Coca-Cola', + kcal: 42, + protein: 0, + fat: 0, + carbs: 10.6, + servingGrams: 330, + }); + }); + + it('brands-массив и строковые числа', () => { + const product = normalizeOffProduct({ + code: '5900951310935', + brands: ['Snickers'], + product_name_ru: 'Сникерс Тройной', + nutriments: { 'energy-kcal_100g': '435', proteins_100g: '0', fat_100g: 20.4, carbohydrates_100g: 53.3 }, + }); + expect(product?.brand).toBe('Snickers'); + expect(product?.kcal).toBe(435); + expect(product?.fat).toBe(20.4); + }); + + it('без kcal, но с кДж — пересчитывает', () => { + const product = normalizeOffProduct({ + code: '123', + product_name: 'Test', + nutriments: { energy_100g: 180 }, + }); + expect(product?.kcal).toBe(43); // 180 кДж / 4.184 + }); + + it('запись без калорийности отбрасывается', () => { + expect(normalizeOffProduct({ code: '1', product_name: 'X', nutriments: {} })).toBeNull(); + }); + + it('запись без имени отбрасывается', () => { + expect(normalizeOffProduct({ code: '1', nutriments: { 'energy-kcal_100g': 100 } })).toBeNull(); + }); + + it('нереалистичный вес порции не попадает в servingGrams', () => { + const product = normalizeOffProduct({ + code: '1', + product_name: 'X', + serving_quantity: 5000, + nutriments: { 'energy-kcal_100g': 100 }, + }); + expect(product?.servingGrams).toBeUndefined(); + }); +}); diff --git a/kcal/src/data/off.ts b/kcal/src/data/off.ts new file mode 100644 index 0000000..663c8a6 --- /dev/null +++ b/kcal/src/data/off.ts @@ -0,0 +1,89 @@ +import { round1 } from '../domain/calc'; + +/** + * Open Food Facts — открытая база упакованных продуктов. Barcode-API отдаёт + * `Access-Control-Allow-Origin: *`, поэтому работает прямо из браузера без + * бэкенда. Текстовый поиск у OFF из браузера недоступен (legacy-эндпоинт + * отключён, у search-a-licious нет CORS), поэтому фича — только штрихкоды: + * сканер или цифры с упаковки. + */ +export interface OffProduct { + /** EAN/UPC штрихкод. */ + code: string; + name: string; + brand?: string; + /** На 100 г. */ + kcal: number; + protein: number; + fat: number; + carbs: number; + /** Вес порции/упаковки с этикетки, если указан. */ + servingGrams?: number; +} + +const FIELDS = 'code,product_name,product_name_ru,brands,nutriments,serving_quantity'; + +export interface OffApiProduct { + code?: string; + product_name?: string; + product_name_ru?: string; + /** Barcode-API отдаёт строку через запятую, search-a-licious — массив. */ + brands?: string | string[]; + serving_quantity?: string | number; + nutriments?: Record; +} + +function toNumber(value: string | number | undefined): number | null { + if (value === undefined) return null; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; +} + +/** kcal/100 г: предпочитаем готовое поле, иначе пересчёт из кДж. */ +function kcalPer100(nutriments: Record): number | null { + const direct = toNumber(nutriments['energy-kcal_100g']); + if (direct !== null && direct > 0) return direct; + const kj = toNumber(nutriments.energy_100g); + if (kj !== null && kj > 0) return kj / 4.184; + return null; +} + +/** null — в записи нет калорийности или имени, для дневника она бесполезна. */ +export function normalizeOffProduct(raw: OffApiProduct): OffProduct | null { + const nutriments = raw.nutriments ?? {}; + const kcal = kcalPer100(nutriments); + if (kcal === null || !raw.code) return null; + const name = (raw.product_name_ru || raw.product_name || '').trim(); + if (name === '') return null; + + const serving = toNumber(raw.serving_quantity); + const brandsRaw = Array.isArray(raw.brands) ? raw.brands[0] : raw.brands?.split(',')[0]; + const brand = brandsRaw?.trim(); + return { + code: raw.code, + name, + ...(brand ? { brand } : {}), + kcal: Math.round(kcal), + protein: round1(toNumber(nutriments.proteins_100g) ?? 0), + fat: round1(toNumber(nutriments.fat_100g) ?? 0), + carbs: round1(toNumber(nutriments.carbohydrates_100g) ?? 0), + ...(serving !== null && serving >= 1 && serving <= 1500 ? { servingGrams: serving } : {}), + }; +} + +/** Продукт по штрихкоду; null — штрихкода нет в базе. */ +export async function fetchOffByBarcode(barcode: string): Promise { + const url = `https://world.openfoodfacts.org/api/v2/product/${encodeURIComponent(barcode)}.json?fields=${FIELDS}`; + let response: Response; + try { + response = await fetch(url); + } + catch { + throw new Error('Нет соединения с базой — проверьте интернет.'); + } + if (response.status === 404) return null; + if (!response.ok) throw new Error(`База недоступна (${response.status})`); + const data = await response.json() as { status?: number; product?: OffApiProduct }; + if (!data.product || data.status === 0) return null; + return normalizeOffProduct({ ...data.product, code: data.product.code ?? barcode }); +} diff --git a/kcal/src/data/seed.ts b/kcal/src/data/seed.ts new file mode 100644 index 0000000..050ed37 --- /dev/null +++ b/kcal/src/data/seed.ts @@ -0,0 +1,100 @@ +import type { Food } from '../domain/types'; + +type SeedFood = Pick; + +/** + * Стартовый каталог: типовые продукты по таблицам пищевой ценности, на 100 г. + * Значения округлены — для дневника важна стабильность оценки, а не лабораторная + * точность. Всё редактируется как свой продукт. + */ +const SEED: readonly SeedFood[] = [ + // Крупы и гарниры (в готовом виде — так, как еда попадает на весы) + { category: 'Крупы и гарниры', name: 'Гречка варёная', kcal: 110, protein: 4.2, fat: 1.1, carbs: 21.3 }, + { category: 'Крупы и гарниры', name: 'Рис варёный', kcal: 130, protein: 2.4, fat: 0.4, carbs: 28.6 }, + { category: 'Крупы и гарниры', name: 'Булгур варёный', kcal: 83, protein: 3.1, fat: 0.2, carbs: 18.6 }, + { category: 'Крупы и гарниры', name: 'Овсянка на воде', kcal: 88, protein: 3, fat: 1.7, carbs: 15 }, + { category: 'Крупы и гарниры', name: 'Овсянка на молоке', kcal: 102, protein: 4.1, fat: 3.2, carbs: 14.2 }, + { category: 'Крупы и гарниры', name: 'Макароны варёные', kcal: 155, protein: 5.3, fat: 0.9, carbs: 30.9 }, + { category: 'Крупы и гарниры', name: 'Картофель варёный', kcal: 82, protein: 2, fat: 0.4, carbs: 17 }, + { category: 'Крупы и гарниры', name: 'Картофельное пюре', kcal: 106, protein: 2.2, fat: 3.3, carbs: 16.7 }, + { category: 'Крупы и гарниры', name: 'Картофель жареный', kcal: 192, protein: 2.8, fat: 9.5, carbs: 23.4 }, + + // Мясо, птица, рыба (приготовленные) + { category: 'Мясо и рыба', name: 'Куриная грудка запечённая', kcal: 165, protein: 31, fat: 3.6, carbs: 0 }, + { category: 'Мясо и рыба', name: 'Куриное бедро без кожи', kcal: 195, protein: 24.4, fat: 10.9, carbs: 0 }, + { category: 'Мясо и рыба', name: 'Котлета куриная жареная', kcal: 222, protein: 18, fat: 14, carbs: 6.5 }, + { category: 'Мясо и рыба', name: 'Говядина тушёная', kcal: 232, protein: 25.8, fat: 14.2, carbs: 0 }, + { category: 'Мясо и рыба', name: 'Свинина запечённая', kcal: 271, protein: 25, fat: 19, carbs: 0 }, + { category: 'Мясо и рыба', name: 'Лосось запечённый', kcal: 208, protein: 22, fat: 13, carbs: 0 }, + { category: 'Мясо и рыба', name: 'Треска запечённая', kcal: 90, protein: 20, fat: 0.9, carbs: 0 }, + { category: 'Мясо и рыба', name: 'Тунец консервированный', kcal: 116, protein: 25.5, fat: 1, carbs: 0.2 }, + { category: 'Мясо и рыба', name: 'Креветки варёные', kcal: 99, protein: 21, fat: 1.2, carbs: 0.2 }, + { category: 'Мясо и рыба', name: 'Сосиска молочная', kcal: 261, protein: 11, fat: 23.9, carbs: 1.6, pieceGrams: 50 }, + { category: 'Мясо и рыба', name: 'Колбаса докторская', kcal: 257, protein: 13.7, fat: 22.8, carbs: 0 }, + + // Молочное и яйца + { category: 'Молочное и яйца', name: 'Яйцо куриное', kcal: 157, protein: 12.7, fat: 11.5, carbs: 0.7, pieceGrams: 55 }, + { category: 'Молочное и яйца', name: 'Творог 5%', kcal: 121, protein: 17.2, fat: 5, carbs: 1.8 }, + { category: 'Молочное и яйца', name: 'Творог 9%', kcal: 159, protein: 16.7, fat: 9, carbs: 2 }, + { category: 'Молочное и яйца', name: 'Сыр твёрдый', kcal: 364, protein: 23.2, fat: 29.5, carbs: 0.3 }, + { category: 'Молочное и яйца', name: 'Молоко 2,5%', kcal: 52, protein: 2.8, fat: 2.5, carbs: 4.7 }, + { category: 'Молочное и яйца', name: 'Кефир 2,5%', kcal: 50, protein: 2.8, fat: 2.5, carbs: 3.9 }, + { category: 'Молочное и яйца', name: 'Йогурт греческий 2%', kcal: 66, protein: 8, fat: 2, carbs: 3.5 }, + { category: 'Молочное и яйца', name: 'Сметана 15%', kcal: 158, protein: 2.6, fat: 15, carbs: 3 }, + { category: 'Молочное и яйца', name: 'Масло сливочное', kcal: 748, protein: 0.5, fat: 82.5, carbs: 0.8 }, + + // Овощи и фрукты + { category: 'Овощи и фрукты', name: 'Огурец', kcal: 15, protein: 0.8, fat: 0.1, carbs: 2.8 }, + { category: 'Овощи и фрукты', name: 'Помидор', kcal: 20, protein: 1.1, fat: 0.2, carbs: 3.7 }, + { category: 'Овощи и фрукты', name: 'Капуста белокочанная', kcal: 28, protein: 1.8, fat: 0.2, carbs: 4.7 }, + { category: 'Овощи и фрукты', name: 'Морковь', kcal: 35, protein: 1.3, fat: 0.1, carbs: 6.9 }, + { category: 'Овощи и фрукты', name: 'Салат овощной с маслом', kcal: 90, protein: 1.2, fat: 7, carbs: 5.5 }, + { category: 'Овощи и фрукты', name: 'Банан', kcal: 96, protein: 1.5, fat: 0.2, carbs: 21.8, pieceGrams: 120 }, + { category: 'Овощи и фрукты', name: 'Яблоко', kcal: 47, protein: 0.4, fat: 0.4, carbs: 9.8, pieceGrams: 180 }, + { category: 'Овощи и фрукты', name: 'Апельсин', kcal: 43, protein: 0.9, fat: 0.2, carbs: 8.1, pieceGrams: 150 }, + { category: 'Овощи и фрукты', name: 'Авокадо', kcal: 160, protein: 2, fat: 14.7, carbs: 8.5 }, + { category: 'Овощи и фрукты', name: 'Виноград', kcal: 72, protein: 0.6, fat: 0.6, carbs: 15.4 }, + + // Хлеб и выпечка + { category: 'Хлеб и выпечка', name: 'Хлеб белый', kcal: 265, protein: 8.1, fat: 3.2, carbs: 50.1, pieceGrams: 25 }, + { category: 'Хлеб и выпечка', name: 'Хлеб ржаной', kcal: 210, protein: 6.6, fat: 1.2, carbs: 41.4, pieceGrams: 30 }, + { category: 'Хлеб и выпечка', name: 'Лаваш тонкий', kcal: 275, protein: 9.1, fat: 1.1, carbs: 56 }, + { category: 'Хлеб и выпечка', name: 'Печенье овсяное', kcal: 437, protein: 6.5, fat: 14.4, carbs: 71.8, pieceGrams: 20 }, + + // Готовые блюда + { category: 'Готовые блюда', name: 'Борщ', kcal: 49, protein: 1.6, fat: 2.2, carbs: 5.5 }, + { category: 'Готовые блюда', name: 'Суп куриный с лапшой', kcal: 68, protein: 3.9, fat: 2.1, carbs: 8.2 }, + { category: 'Готовые блюда', name: 'Плов с курицей', kcal: 190, protein: 9.5, fat: 7.5, carbs: 21 }, + { category: 'Готовые блюда', name: 'Пельмени варёные', kcal: 275, protein: 11.9, fat: 12.4, carbs: 29 }, + { category: 'Готовые блюда', name: 'Пицца', kcal: 266, protein: 11, fat: 10.4, carbs: 32.9, pieceGrams: 120 }, + { category: 'Готовые блюда', name: 'Сырники жареные', kcal: 220, protein: 15.5, fat: 9.5, carbs: 18.2, pieceGrams: 75 }, + { category: 'Готовые блюда', name: 'Блины', kcal: 233, protein: 6.1, fat: 12.3, carbs: 26, pieceGrams: 50 }, + + // Орехи и сладкое + { category: 'Орехи и сладкое', name: 'Грецкий орех', kcal: 654, protein: 15.2, fat: 65.2, carbs: 7 }, + { category: 'Орехи и сладкое', name: 'Миндаль', kcal: 609, protein: 18.6, fat: 53.7, carbs: 13 }, + { category: 'Орехи и сладкое', name: 'Арахисовая паста', kcal: 588, protein: 25, fat: 50, carbs: 20 }, + { category: 'Орехи и сладкое', name: 'Шоколад молочный', kcal: 554, protein: 9.8, fat: 34.7, carbs: 50.4, pieceGrams: 6 }, + { category: 'Орехи и сладкое', name: 'Мёд', kcal: 329, protein: 0.8, fat: 0, carbs: 81.5 }, + { category: 'Орехи и сладкое', name: 'Сахар', kcal: 398, protein: 0, fat: 0, carbs: 99.7, pieceGrams: 5 }, + { category: 'Орехи и сладкое', name: 'Мороженое пломбир', kcal: 227, protein: 3.2, fat: 15, carbs: 20.8 }, + { category: 'Орехи и сладкое', name: 'Чипсы картофельные', kcal: 536, protein: 5.5, fat: 34, carbs: 51 }, + + // Напитки + { category: 'Напитки', name: 'Кола', kcal: 42, protein: 0, fat: 0, carbs: 10.6 }, + { category: 'Напитки', name: 'Сок яблочный', kcal: 46, protein: 0.5, fat: 0.1, carbs: 10.1 }, + { category: 'Напитки', name: 'Капучино', kcal: 45, protein: 2.1, fat: 2.3, carbs: 4, pieceGrams: 200 }, + { category: 'Напитки', name: 'Латте', kcal: 47, protein: 2.4, fat: 2.4, carbs: 4.2, pieceGrams: 300 }, + { category: 'Напитки', name: 'Пиво светлое', kcal: 42, protein: 0.5, fat: 0, carbs: 3.5 }, + { category: 'Напитки', name: 'Вино сухое', kcal: 68, protein: 0.2, fat: 0, carbs: 2.6 }, +]; + +/** Стабильные id — сидинг идемпотентен и не плодит дубликатов. */ +export const SEED_FOODS: readonly Food[] = SEED.map((item, index) => ({ + ...item, + id: `seed-${String(index + 1).padStart(3, '0')}`, + builtin: true, + usedCount: 0, + lastUsedAt: 0, + createdAt: 0, +})); diff --git a/kcal/src/domain/calc.test.ts b/kcal/src/domain/calc.test.ts new file mode 100644 index 0000000..f34648f --- /dev/null +++ b/kcal/src/domain/calc.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest'; +import { bmr, computeTargets, portionNutrients, safeKcalFloor, sumNutrients, tdee } from './calc'; + +describe('bmr (Миффлин—Сан Жеор)', () => { + it('мужчина 30 лет, 180 см, 80 кг', () => { + // 10*80 + 6.25*180 - 5*30 + 5 = 800 + 1125 - 150 + 5 + expect(bmr('male', 30, 180, 80)).toBe(1780); + }); + + it('женщина 25 лет, 165 см, 60 кг', () => { + // 10*60 + 6.25*165 - 5*25 - 161 = 600 + 1031.25 - 125 - 161 + expect(bmr('female', 25, 165, 60)).toBeCloseTo(1345.25, 2); + }); +}); + +describe(tdee, () => { + it('умножает BMR на коэффициент активности', () => { + expect(tdee('male', 30, 180, 80, 1.55)).toBeCloseTo(1780 * 1.55, 5); + }); +}); + +describe(computeTargets, () => { + const params = { sex: 'male' as const, age: 30, heightCm: 180, weightKg: 80, activity: 1.55 }; + + it('похудение: дефицит 15%, белок 1.8 г/кг, жир 0.9 г/кг', () => { + const t = computeTargets({ ...params, goal: 'lose' }); + expect(t.kcal).toBe(2350); // 2759 * 0.85 = 2345.15 → к ближайшим 10 + expect(t.protein).toBe(144); + expect(t.fat).toBe(72); + // Углеводы добираются из остатка: (2350 - 144*4 - 72*9) / 4 + expect(t.carbs).toBe(Math.round((2350 - 144 * 4 - 72 * 9) / 4)); + }); + + it('поддержание: без дефицита', () => { + const t = computeTargets({ ...params, goal: 'maintain' }); + expect(t.kcal).toBe(2760); + expect(t.protein).toBe(128); + }); + + it('набор: профицит 10%', () => { + const t = computeTargets({ ...params, goal: 'gain' }); + expect(t.kcal).toBe(3030); // 2759 * 1.1 = 3034.9 → к ближайшим 10 + }); + + it('углеводы не уходят в минус на экстремальных входных', () => { + const t = computeTargets({ sex: 'female', age: 70, heightCm: 150, weightKg: 45, activity: 1.2, goal: 'lose' }); + expect(t.carbs).toBeGreaterThanOrEqual(0); + expect(t.kcal).toBeLessThan(safeKcalFloor('female') + 500); + }); +}); + +describe(portionNutrients, () => { + const buckwheat = { kcal: 110, protein: 4.2, fat: 1.1, carbs: 21.3 }; + + it('масштабирует значения на 100 г к порции', () => { + expect(portionNutrients(buckwheat, 250)).toEqual({ kcal: 275, protein: 10.5, fat: 2.8, carbs: 53.3 }); + }); + + it('порция 0 г — нули', () => { + expect(portionNutrients(buckwheat, 0)).toEqual({ kcal: 0, protein: 0, fat: 0, carbs: 0 }); + }); +}); + +describe(sumNutrients, () => { + it('складывает записи и округляет макросы до десятых', () => { + const total = sumNutrients([ + { kcal: 275, protein: 10.5, fat: 2.8, carbs: 53.3 }, + { kcal: 157, protein: 12.7, fat: 11.5, carbs: 0.7 }, + ]); + expect(total).toEqual({ kcal: 432, protein: 23.2, fat: 14.3, carbs: 54 }); + }); + + it('пустой список — нули', () => { + expect(sumNutrients([])).toEqual({ kcal: 0, protein: 0, fat: 0, carbs: 0 }); + }); +}); diff --git a/kcal/src/domain/calc.ts b/kcal/src/domain/calc.ts new file mode 100644 index 0000000..f6691a1 --- /dev/null +++ b/kcal/src/domain/calc.ts @@ -0,0 +1,86 @@ +import type { Food, Goal, Nutrients, Sex } from './types'; + +/** Базовый обмен по Миффлину—Сан Жеору, ккал/сутки. */ +export function bmr(sex: Sex, age: number, heightCm: number, weightKg: number): number { + const base = 10 * weightKg + 6.25 * heightCm - 5 * age; + return sex === 'male' ? base + 5 : base - 161; +} + +/** Суточный расход с учётом активности. */ +export function tdee(sex: Sex, age: number, heightCm: number, weightKg: number, activity: number): number { + return bmr(sex, age, heightCm, weightKg) * activity; +} + +/** + * Пресеты целей: множитель калорийности от TDEE и нормы белка/жира на кг веса. + * Похудение — умеренный дефицит 15%, белок повышен для сохранения мышц; + * набор — профицит 10%. Углеводы добираются из остатка калорий. + */ +const GOAL_PRESETS: Record = { + lose: { kcalFactor: 0.85, proteinPerKg: 1.8, fatPerKg: 0.9 }, + maintain: { kcalFactor: 1.0, proteinPerKg: 1.6, fatPerKg: 1.0 }, + gain: { kcalFactor: 1.1, proteinPerKg: 1.8, fatPerKg: 1.0 }, +}; + +/** Безопасный минимум калорийности; ниже — предупреждаем, но не запрещаем. */ +export function safeKcalFloor(sex: Sex): number { + return sex === 'male' ? 1500 : 1200; +} + +export interface TargetInput { + sex: Sex; + age: number; + heightCm: number; + weightKg: number; + activity: number; + goal: Goal; +} + +/** Дневные цели по калориям и БЖУ из параметров тела и цели. */ +export function computeTargets(input: TargetInput): Nutrients { + const preset = GOAL_PRESETS[input.goal]; + const kcal = roundTo(tdee(input.sex, input.age, input.heightCm, input.weightKg, input.activity) * preset.kcalFactor, 10); + const protein = Math.round(input.weightKg * preset.proteinPerKg); + const fat = Math.round(input.weightKg * preset.fatPerKg); + const carbs = Math.max(0, Math.round((kcal - protein * 4 - fat * 9) / 4)); + return { kcal, protein, fat, carbs }; +} + +/** Нутриенты порции продукта: значения каталога даны на 100 г. */ +export function portionNutrients(food: Nutrients, amountG: number): Nutrients { + const k = amountG / 100; + return { + kcal: Math.round(food.kcal * k), + protein: round1(food.protein * k), + fat: round1(food.fat * k), + carbs: round1(food.carbs * k), + }; +} + +/** Сумма нутриентов по записям. */ +export function sumNutrients(items: readonly Nutrients[]): Nutrients { + const total = { kcal: 0, protein: 0, fat: 0, carbs: 0 }; + for (const item of items) { + total.kcal += item.kcal; + total.protein += item.protein; + total.fat += item.fat; + total.carbs += item.carbs; + } + total.protein = round1(total.protein); + total.fat = round1(total.fat); + total.carbs = round1(total.carbs); + return total; +} + +/** Порция по умолчанию для продукта: прошлая → одна штука → 100 г. */ +export function defaultAmount(food: Food): number { + return food.lastAmountG ?? food.pieceGrams ?? 100; +} + +export function round1(value: number): number { + return Math.round(value * 10) / 10; +} + +export function roundTo(value: number, step: number): number { + return Math.round(value / step) * step; +} diff --git a/kcal/src/domain/dates.test.ts b/kcal/src/domain/dates.test.ts new file mode 100644 index 0000000..f8b13c6 --- /dev/null +++ b/kcal/src/domain/dates.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest'; +import { dayTitle, lastDays, shiftISODate, toISODate } from './dates'; + +describe(toISODate, () => { + it('локальная дата без сдвига в UTC', () => { + expect(toISODate(new Date(2026, 7, 7))).toBe('2026-08-07'); + expect(toISODate(new Date(2026, 0, 1, 0, 30))).toBe('2026-01-01'); + }); +}); + +describe(shiftISODate, () => { + it('переход через границу месяца и года', () => { + expect(shiftISODate('2026-08-01', -1)).toBe('2026-07-31'); + expect(shiftISODate('2025-12-31', 1)).toBe('2026-01-01'); + expect(shiftISODate('2026-08-07', 0)).toBe('2026-08-07'); + }); + + it('високальный февраль', () => { + expect(shiftISODate('2028-02-28', 1)).toBe('2028-02-29'); + }); +}); + +describe(dayTitle, () => { + const today = '2026-08-07'; + + it('относительные дни', () => { + expect(dayTitle('2026-08-07', today)).toBe('Сегодня'); + expect(dayTitle('2026-08-06', today)).toBe('Вчера'); + expect(dayTitle('2026-08-08', today)).toBe('Завтра'); + }); + + it('дальние дни — день недели и число по-русски', () => { + expect(dayTitle('2026-08-01', today)).toMatch(/1 августа/); + }); +}); + +describe(lastDays, () => { + it('последние N дней по возрастанию, включая сегодня', () => { + expect(lastDays(3, '2026-08-07')).toEqual(['2026-08-05', '2026-08-06', '2026-08-07']); + }); +}); diff --git a/kcal/src/domain/dates.ts b/kcal/src/domain/dates.ts new file mode 100644 index 0000000..813a92c --- /dev/null +++ b/kcal/src/domain/dates.ts @@ -0,0 +1,41 @@ +import { addDays, format, parseISO } from 'date-fns'; +import { ru } from 'date-fns/locale'; + +/** Локальная дата в формате YYYY-MM-DD (не UTC — день дневника «человеческий»). */ +export function toISODate(date: Date): string { + return format(date, 'yyyy-MM-dd'); +} + +export function todayISO(): string { + return toISODate(new Date()); +} + +/** Локальная полночь указанного дня (date-fns трактует date-only строки как локальные). */ +export function parseISODate(iso: string): Date { + return parseISO(iso); +} + +export function shiftISODate(iso: string, days: number): string { + return toISODate(addDays(parseISODate(iso), days)); +} + +/** «Сегодня» / «Вчера» / «сб, 1 августа» — ru-локаль даёт родительный падеж месяца. */ +export function dayTitle(iso: string, today: string = todayISO()): string { + if (iso === today) return 'Сегодня'; + if (iso === shiftISODate(today, -1)) return 'Вчера'; + if (iso === shiftISODate(today, 1)) return 'Завтра'; + return format(parseISODate(iso), 'EEEEEE, d MMMM', { locale: ru }); +} + +/** Короткая подпись дня для графиков: «5.08». */ +export function dayShort(iso: string): string { + return format(parseISODate(iso), 'd.MM'); +} + +/** Последние `count` дней, включая сегодняшний, по возрастанию. */ +export function lastDays(count: number, today: string = todayISO()): string[] { + const end = parseISODate(today); + const days: string[] = []; + for (let i = count - 1; i >= 0; i--) days.push(toISODate(addDays(end, -i))); + return days; +} diff --git a/kcal/src/domain/format.ts b/kcal/src/domain/format.ts new file mode 100644 index 0000000..a99a0e5 --- /dev/null +++ b/kcal/src/domain/format.ts @@ -0,0 +1,22 @@ +const kcalFormat = new Intl.NumberFormat('ru-RU', { maximumFractionDigits: 0 }); +const gramFormat = new Intl.NumberFormat('ru-RU', { maximumFractionDigits: 1 }); + +/** «1 840» — целые ккал с неразрывной группировкой. */ +export function fmtKcal(value: number): string { + return kcalFormat.format(Math.round(value)); +} + +/** «82,5» — граммы с одним знаком, без хвоста «,0». */ +export function fmtG(value: number): string { + return gramFormat.format(value); +} + +/** Подпись порции записи: «150 г» или «2 шт · 110 г». */ +export function fmtAmount(amountG: number | undefined, pieceGrams: number | undefined): string { + if (amountG === undefined) return 'порция'; + if (pieceGrams && amountG % pieceGrams === 0) { + const pieces = amountG / pieceGrams; + return pieces === 1 ? `1 шт · ${fmtG(amountG)} г` : `${pieces} шт · ${fmtG(amountG)} г`; + } + return `${fmtG(amountG)} г`; +} diff --git a/kcal/src/domain/types.ts b/kcal/src/domain/types.ts new file mode 100644 index 0000000..213fa0e --- /dev/null +++ b/kcal/src/domain/types.ts @@ -0,0 +1,102 @@ +/** Приём пищи, к которому привязана запись дневника. */ +export type Meal = 'breakfast' | 'lunch' | 'dinner' | 'snack'; + +export const MEALS: readonly Meal[] = ['breakfast', 'lunch', 'dinner', 'snack']; + +export const MEAL_LABELS: Record = { + breakfast: 'Завтрак', + lunch: 'Обед', + dinner: 'Ужин', + snack: 'Перекус', +}; + +/** Пищевая ценность. Для Food — на 100 г, для Entry — на порцию целиком. */ +export interface Nutrients { + kcal: number; + protein: number; + fat: number; + carbs: number; +} + +/** Продукт личного каталога. Все значения — на 100 г. */ +export interface Food extends Nutrients { + id: string; + name: string; + category: string; + /** Вес одной штуки в граммах — включает режим «в штуках» при вводе порции. */ + pieceGrams?: number; + /** EAN/UPC с упаковки — для дедупликации при повторном сканировании. */ + barcode?: string; + /** Продукт из стартового набора (можно редактировать как свой). */ + builtin?: boolean; + /** Сколько раз добавляли в дневник — для сортировки «недавних». */ + usedCount: number; + /** Момент последнего добавления в дневник. */ + lastUsedAt: number; + /** Последняя введённая порция в граммах — подставляется по умолчанию. */ + lastAmountG?: number; + createdAt: number; +} + +/** + * Запись дневника. Хранит снапшот имени и итоговых нутриентов на порцию: + * правка или удаление продукта из каталога не меняет историю. + */ +export interface Entry extends Nutrients { + id: string; + /** Локальная дата дня дневника в формате YYYY-MM-DD. */ + date: string; + meal: Meal; + /** Ссылка на продукт каталога; отсутствует у быстрых записей «только ккал». */ + foodId?: string; + name: string; + /** Размер порции в граммах; отсутствует у быстрых записей. */ + amountG?: number; + createdAt: number; +} + +/** Замер веса; один на день, id совпадает с датой. */ +export interface WeightLog { + id: string; + date: string; + kg: number; + createdAt: number; +} + +export type Sex = 'male' | 'female'; +export type Goal = 'lose' | 'maintain' | 'gain'; + +/** Коэффициенты активности к BMR (множители Харриса—Бенедикта). */ +export const ACTIVITY_LEVELS = [ + { value: 1.2, label: 'Минимальная', hint: 'сидячая работа, без тренировок' }, + { value: 1.375, label: 'Лёгкая', hint: '1–3 тренировки в неделю' }, + { value: 1.55, label: 'Средняя', hint: '3–5 тренировок в неделю' }, + { value: 1.725, label: 'Высокая', hint: '6–7 тренировок в неделю' }, + { value: 1.9, label: 'Экстремальная', hint: 'физический труд + тренировки' }, +] as const; + +export const GOAL_LABELS: Record = { + lose: 'Похудение', + maintain: 'Поддержание', + gain: 'Набор массы', +}; + +/** Профиль пользователя — единственная запись с id = 'profile'. */ +export interface Profile { + id: 'profile'; + sex: Sex; + age: number; + heightCm: number; + weightKg: number; + activity: number; + goal: Goal; + /** Дневные цели. Пересчитываются из параметров, но правятся и вручную. */ + targetKcal: number; + targetProtein: number; + targetFat: number; + targetCarbs: number; + createdAt: number; + updatedAt: number; +} + +export const PROFILE_ID = 'profile' as const; diff --git a/kcal/src/env.d.ts b/kcal/src/env.d.ts new file mode 100644 index 0000000..38706d6 --- /dev/null +++ b/kcal/src/env.d.ts @@ -0,0 +1,13 @@ +/// + +// BarcodeDetector (Chromium): в lib.dom его ещё нет — минимальная декларация. +interface DetectedBarcode { + rawValue: string; + format: string; +} + +declare class BarcodeDetector { + constructor(options?: { formats?: string[] }); + detect(source: CanvasImageSource): Promise; + static getSupportedFormats(): Promise; +} diff --git a/kcal/src/main.ts b/kcal/src/main.ts new file mode 100644 index 0000000..a3f23fa --- /dev/null +++ b/kcal/src/main.ts @@ -0,0 +1,20 @@ +import '@fontsource-variable/golos-text'; +import '@fontsource/spectral/300.css'; +import '@fontsource/spectral/500.css'; +import './app.css'; +import { createVaporApp } from 'vue'; +import type { App as VueApp } from 'vue'; +import { installEngine } from 'vue-sync-engine'; +import App from './App'; +import { CACHE_DEFAULTS, createKcalEngine, seedIfEmpty } from './data/engine'; + +// Стартовый каталог должен лечь в idb до первой подписки на foods-запрос. +await seedIfEmpty(); + +const engine = createKcalEngine(); +const app = createVaporApp(App); + +// installEngine типизирован под vdom-App; VaporApp имеет совместимые provide/config. +installEngine(app as unknown as VueApp, engine, { defaults: CACHE_DEFAULTS }); + +app.mount('#app'); diff --git a/kcal/src/screens/AddSheet.tsx b/kcal/src/screens/AddSheet.tsx new file mode 100644 index 0000000..83811d5 --- /dev/null +++ b/kcal/src/screens/AddSheet.tsx @@ -0,0 +1,607 @@ +import { computed, onMounted, shallowRef } from 'vue'; +import { useRef } from 'vue-jsx-vapor'; +import { useMutation, useQuery } from 'vue-sync-engine'; +import { useCloseWatcher } from '@robonen/vue'; +import { groupBy } from '@robonen/stdlib'; +import { focus } from '@robonen/platform/browsers'; +import { FoodEntity, addEntryMutation, foodsQuery, upsertFoodMutation } from '../data/defs'; +import { useEntities } from '../data/composables'; +import { fetchOffByBarcode } from '../data/off'; +import type { OffProduct } from '../data/off'; +import { defaultAmount, portionNutrients } from '../domain/calc'; +import { fmtG, fmtKcal } from '../domain/format'; +import { MEALS, MEAL_LABELS } from '../domain/types'; +import type { Entry, Food, Meal } from '../domain/types'; +import { addSheet, selectedDate } from '../ui/state'; +import { IconBarcode, IconChevronLeft, IconClose, IconSearch } from '../ui/icons'; +import BarcodeScanner, { isBarcodeScanSupported } from '../components/BarcodeScanner'; + +type Step = 'pick' | 'amount' | 'quick' | 'food'; + +const GRAM_PRESETS = [50, 100, 150, 200, 300]; + +export default function AddSheet() { + const step = shallowRef('pick'); + const query = shallowRef(''); + const meal = shallowRef(addSheet.meal); + const searchEl = useRef(); + + const foodsQ = useQuery(foodsQuery, () => undefined); + const foods = useEntities(FoodEntity, () => foodsQ.data.value?.ids); + + const addEntry = useMutation(addEntryMutation); + const upsertFood = useMutation(upsertFoodMutation); + + const close = () => (addSheet.open = false); + useCloseWatcher().onClose(close); + onMounted(() => focus(searchEl.value as HTMLElement | null)); + + // ── выбор продукта ──────────────────────────────────────────────────────── + const trimmed = computed(() => query.value.trim().toLowerCase()); + const filtered = computed(() => + trimmed.value === '' + ? foods.value + : foods.value.filter(food => food.name.toLowerCase().includes(trimmed.value)), + ); + const recents = computed(() => + [...foods.value] + .filter(food => food.usedCount > 0) + .sort((a, b) => b.lastUsedAt - a.lastUsedAt) + .slice(0, 8), + ); + const groups = computed(() => { + const grouped = groupBy(filtered.value, food => food.category); + return Object.entries(grouped).sort((a, b) => a[0].localeCompare(b[0], 'ru')); + }); + + // ── порция ──────────────────────────────────────────────────────────────── + const selectedFood = shallowRef(null); + const amountG = shallowRef(100); + + const pickFood = (food: Food) => { + selectedFood.value = food; + amountG.value = defaultAmount(food); + step.value = 'amount'; + }; + + const preview = computed(() => + selectedFood.value ? portionNutrients(selectedFood.value, amountG.value) : null, + ); + + const submitAmount = () => { + const food = selectedFood.value; + if (!food || amountG.value <= 0) return; + const entry: Entry = { + id: crypto.randomUUID(), + date: selectedDate.value, + meal: meal.value, + foodId: food.id, + name: food.name, + amountG: amountG.value, + ...portionNutrients(food, amountG.value), + createdAt: Date.now(), + }; + addEntry.mutate({ entry }); + close(); + }; + + // ── быстрая запись ──────────────────────────────────────────────────────── + const quickName = shallowRef(''); + const quickKcal = shallowRef(0); + const quickProtein = shallowRef(0); + const quickFat = shallowRef(0); + const quickCarbs = shallowRef(0); + + const submitQuick = () => { + if (quickKcal.value <= 0) return; + const entry: Entry = { + id: crypto.randomUUID(), + date: selectedDate.value, + meal: meal.value, + name: quickName.value.trim() || 'Быстрая запись', + kcal: Math.round(quickKcal.value), + protein: quickProtein.value, + fat: quickFat.value, + carbs: quickCarbs.value, + createdAt: Date.now(), + }; + addEntry.mutate({ entry }); + close(); + }; + + // ── новый продукт ───────────────────────────────────────────────────────── + const foodName = shallowRef(''); + const foodCategory = shallowRef('Моё'); + const foodKcal = shallowRef(0); + const foodProtein = shallowRef(0); + const foodFat = shallowRef(0); + const foodCarbs = shallowRef(0); + const foodPiece = shallowRef(0); + const foodBarcode = shallowRef(''); + const categories = computed(() => [...new Set(foods.value.map(food => food.category))].sort((a, b) => a.localeCompare(b, 'ru'))); + + const submitFood = async () => { + if (foodName.value.trim() === '' || foodKcal.value <= 0) return; + const food: Food = { + id: crypto.randomUUID(), + name: foodName.value.trim(), + category: foodCategory.value.trim() || 'Моё', + kcal: foodKcal.value, + protein: foodProtein.value, + fat: foodFat.value, + carbs: foodCarbs.value, + ...(foodPiece.value > 0 ? { pieceGrams: foodPiece.value } : {}), + ...(foodBarcode.value !== '' ? { barcode: foodBarcode.value } : {}), + usedCount: 0, + lastUsedAt: 0, + createdAt: Date.now(), + }; + await upsertFood.mutateAsync({ food }); + pickFood(food); + }; + + // ── база упаковок (Open Food Facts, только штрихкоды) ───────────────────── + const offBusy = shallowRef(false); + const offError = shallowRef(''); + const scannerOpen = shallowRef(false); + const barcodeInput = shallowRef(''); + const scanSupported = isBarcodeScanSupported(); + + /** Продукт из базы: уже сканировали раньше — сразу к порции, иначе на проверку формы. */ + const applyOffProduct = (product: OffProduct) => { + const existing = foods.value.find(food => food.barcode === product.code); + if (existing) { + pickFood(existing); + return; + } + foodName.value = product.brand && !product.name.toLowerCase().includes(product.brand.toLowerCase()) + ? `${product.name} (${product.brand})` + : product.name; + foodCategory.value = 'Упакованное'; + foodKcal.value = product.kcal; + foodProtein.value = product.protein; + foodFat.value = product.fat; + foodCarbs.value = product.carbs; + foodPiece.value = product.servingGrams ?? 0; + foodBarcode.value = product.code; + step.value = 'food'; + }; + + const lookupBarcode = async (code: string) => { + const digits = code.trim(); + if (digits === '' || offBusy.value) return; + scannerOpen.value = false; + offBusy.value = true; + offError.value = ''; + try { + const product = await fetchOffByBarcode(digits); + if (product) { + barcodeInput.value = ''; + applyOffProduct(product); + } + else { + offError.value = `Штрихкод ${digits} не найден в базе — заведите продукт вручную с упаковки.`; + } + } + catch (cause) { + offError.value = cause instanceof Error ? cause.message : 'База недоступна'; + } + finally { + offBusy.value = false; + } + }; + + const numeric = (raw: string): number => { + const value = Number(raw.replace(',', '.')); + return Number.isFinite(value) && value >= 0 ? value : 0; + }; + + const fieldClass = 'w-full rounded-xl border hairline bg-raised/70 px-3.5 py-2.5 text-[15px] text-ink outline-none transition focus:border-ember/50 placeholder:text-ink-faint'; + const chipClass = (active: boolean) => + `rounded-full border px-3 py-1.5 text-[13px] transition ${active + ? 'border-ember/60 bg-ember/15 text-ember-bright' + : 'border-white/10 text-ink-soft hover:border-white/20 hover:text-ink'}`; + + return ( +
+
+ +
+ {/* Шапка */} +
+ {step.value !== 'pick' && ( + + )} +

+ {step.value === 'pick' && `${MEAL_LABELS[meal.value]} · добавить`} + {step.value === 'amount' && (selectedFood.value?.name ?? '')} + {step.value === 'quick' && 'Быстрая запись'} + {step.value === 'food' && 'Новый продукт'} +

+ +
+ + {/* Приём пищи */} +
+ {MEALS.map(m => ( + + ))} +
+ +
+ {/* ── Шаг: выбор ── */} + {step.value === 'pick' && ( +
+
+ + (query.value = event.currentTarget.value)} + class={`${fieldClass} pl-10`} + /> +
+ +
+ + +
+ + {trimmed.value === '' && recents.value.length > 0 && ( +
+

Недавние

+
+ {recents.value.map(food => ( + + ))} +
+
+ )} + + {groups.value.map(([category, list]) => ( +
+

{category}

+
+ {list.map(food => ( + + ))} +
+
+ ))} + + {filtered.value.length === 0 && ( +

+ В каталоге не нашлось — отсканируйте штрихкод упаковки ниже + или создайте «Новый продукт». +

+ )} + + {/* ── База упаковок: только штрихкоды (Open Food Facts) ── */} +
+

Штрихкод упаковки

+ + {scanSupported && !scannerOpen.value && ( + + )} + + {scannerOpen.value && ( + void lookupBarcode(code)} + onCancel={() => (scannerOpen.value = false)} + /> + )} + +
+ (barcodeInput.value = event.currentTarget.value.replaceAll(/\D/g, ''))} + onKeydown={(event) => { + if (event.key === 'Enter') void lookupBarcode(barcodeInput.value); + }} + class={`${fieldClass} tnum`} + /> + +
+ + {offError.value !== '' && ( +

+ {offError.value} +

+ )} + +

+ КБЖУ подтянутся из Open Food Facts — открытой базы упаковок. + Её заполняют люди, поэтому цифры стоит сверить с этикеткой. +

+
+
+ )} + + {/* ── Шаг: порция ── */} + {step.value === 'amount' && selectedFood.value && ( +
+
+
{preview.value ? fmtKcal(preview.value.kcal) : 0}
+
ккал в порции
+
+ + Б + {' '} + {fmtG(preview.value?.protein ?? 0)} + + + Ж + {' '} + {fmtG(preview.value?.fat ?? 0)} + + + У + {' '} + {fmtG(preview.value?.carbs ?? 0)} + +
+
+ +
+ + (amountG.value = numeric(event.currentTarget.value))} + class={`${fieldClass} text-center text-lg tnum`} + /> +
+ {GRAM_PRESETS.map(grams => ( + + ))} +
+ {selectedFood.value.pieceGrams && ( +
+ {[1, 2, 3].map(count => ( + + ))} +
+ )} +
+ + +
+ )} + + {/* ── Шаг: быстрая запись ── */} + {step.value === 'quick' && ( +
+

+ Когда некогда взвешивать — запишите оценку калорий, чтобы день остался честным. + Б/Ж/У можно не указывать. +

+ (quickName.value = event.currentTarget.value)} + class={fieldClass} + /> +
+ + (quickKcal.value = numeric(event.currentTarget.value))} + class={`${fieldClass} text-center text-lg tnum`} + /> +
+
+ {([ + ['Белки', quickProtein], + ['Жиры', quickFat], + ['Углеводы', quickCarbs], + ] as const).map(([label, model]) => ( +
+ + (model.value = numeric(event.currentTarget.value))} + class={`${fieldClass} text-center tnum`} + /> +
+ ))} +
+ +
+ )} + + {/* ── Шаг: новый продукт ── */} + {step.value === 'food' && ( +
+ (foodName.value = event.currentTarget.value)} + class={fieldClass} + /> +
+ + (foodCategory.value = event.currentTarget.value)} + class={fieldClass} + /> + + {categories.value.map(category => ( + +
+ {foodBarcode.value !== '' + ? ( +

+ Значения подставлены из Open Food Facts — сверьте с этикеткой и поправьте, если расходятся. +

+ ) + :

Значения указываются на 100 г продукта.

} +
+
+ + (foodKcal.value = numeric(event.currentTarget.value))} + class={`${fieldClass} tnum`} + /> +
+
+ + (foodPiece.value = numeric(event.currentTarget.value))} + class={`${fieldClass} tnum`} + /> +
+
+
+ {([ + ['Белки', foodProtein], + ['Жиры', foodFat], + ['Углеводы', foodCarbs], + ] as const).map(([label, model]) => ( +
+ + (model.value = numeric(event.currentTarget.value))} + class={`${fieldClass} text-center tnum`} + /> +
+ ))} +
+ +
+ )} +
+
+
+ ); +} diff --git a/kcal/src/screens/DiaryScreen.tsx b/kcal/src/screens/DiaryScreen.tsx new file mode 100644 index 0000000..c23459b --- /dev/null +++ b/kcal/src/screens/DiaryScreen.tsx @@ -0,0 +1,133 @@ +import { computed } from 'vue'; +import { useEntity, useQuery } from 'vue-sync-engine'; +import { EntryEntity, FoodEntity, ProfileEntity, entriesByDayQuery, foodsQuery } from '../data/defs'; +import { useEntities } from '../data/composables'; +import { sumNutrients } from '../domain/calc'; +import { dayTitle, shiftISODate, todayISO } from '../domain/dates'; +import { fmtAmount, fmtKcal } from '../domain/format'; +import { MEALS, MEAL_LABELS, PROFILE_ID } from '../domain/types'; +import type { Entry, Meal } from '../domain/types'; +import { editEntryId, goToday, openAddSheet, selectedDate } from '../ui/state'; +import { IconChevronLeft, IconChevronRight, IconPlus } from '../ui/icons'; +import ProgressRing from '../components/ProgressRing'; +import MacroBar from '../components/MacroBar'; + +export default function DiaryScreen() { + const profile = useEntity(ProfileEntity, () => PROFILE_ID); + const day = useQuery(entriesByDayQuery, () => ({ date: selectedDate.value })); + const entries = useEntities(EntryEntity, () => day.data.value?.ids); + // Каталог нужен только ради pieceGrams — чтобы подписывать порции «2 шт · 110 г». + const foodsQ = useQuery(foodsQuery, () => undefined); + const foods = useEntities(FoodEntity, () => foodsQ.data.value?.ids); + const pieceByFoodId = computed(() => { + const map = new Map(); + for (const food of foods.value) map.set(food.id, food.pieceGrams); + return map; + }); + const totals = computed(() => sumNutrients(entries.value)); + const isToday = computed(() => selectedDate.value === todayISO()); + + const byMeal = computed(() => { + const map = new Map(); + for (const meal of MEALS) map.set(meal, []); + for (const entry of entries.value) map.get(entry.meal)?.push(entry); + return map; + }); + + return ( +
+ {/* Навигация по дням */} +
+ +
+
{dayTitle(selectedDate.value)}
+ {!isToday.value && ( + + )} +
+ +
+ + {/* Итог дня: кольцо + макросы */} +
+ +
+ + + +
+
+ + {/* Приёмы пищи */} + {MEALS.map((meal, index) => { + const list = byMeal.value.get(meal) ?? []; + const mealKcal = list.reduce((acc, entry) => acc + entry.kcal, 0); + return ( +
+
+

{MEAL_LABELS[meal]}

+ {list.length > 0 && ( + + {fmtKcal(mealKcal)} + {' '} + ккал + + )} +
+
+ {list.map(entry => ( + + ))} + +
+
+ ); + })} + + {entries.value.length === 0 && ( +

+ Пока пусто. Нажмите «Добавить» в любом приёме пищи — + недавние продукты будут под рукой, запись занимает пару касаний. +

+ )} +
+ ); +} diff --git a/kcal/src/screens/EditEntrySheet.tsx b/kcal/src/screens/EditEntrySheet.tsx new file mode 100644 index 0000000..183854b --- /dev/null +++ b/kcal/src/screens/EditEntrySheet.tsx @@ -0,0 +1,181 @@ +import { computed, shallowRef } from 'vue'; +import { useEntity, useMutation } from 'vue-sync-engine'; +import { useCloseWatcher } from '@robonen/vue'; +import { EntryEntity, removeEntryMutation, updateEntryMutation } from '../data/defs'; +import { round1 } from '../domain/calc'; +import { fmtG, fmtKcal } from '../domain/format'; +import { MEALS, MEAL_LABELS } from '../domain/types'; +import type { Entry, Meal } from '../domain/types'; +import { editEntryId } from '../ui/state'; +import { IconClose, IconTrash } from '../ui/icons'; + +/** + * Правка записи: порция (пересчёт нутриентов пропорцией — работает и для + * записей без продукта-источника), приём пищи, удаление. + */ +export default function EditEntrySheet() { + const entry = useEntity(EntryEntity, () => editEntryId.value ?? undefined); + + const update = useMutation(updateEntryMutation); + const remove = useMutation(removeEntryMutation); + + const close = () => (editEntryId.value = null); + useCloseWatcher().onClose(close); + + // Локальные правки поверх записи; null — «не менялось». + const draftAmount = shallowRef(null); + const draftKcal = shallowRef(null); + const draftMeal = shallowRef(null); + + const amount = computed(() => draftAmount.value ?? entry.value?.amountG ?? 0); + const meal = computed(() => draftMeal.value ?? entry.value?.meal ?? 'snack'); + + /** Нутриенты после правки порции: масштабируем снапшот записи. */ + const scaled = computed(() => { + const current = entry.value; + if (!current) return null; + if (current.amountG && draftAmount.value !== null && draftAmount.value > 0) { + const factor = draftAmount.value / current.amountG; + return { + kcal: Math.round(current.kcal * factor), + protein: round1(current.protein * factor), + fat: round1(current.fat * factor), + carbs: round1(current.carbs * factor), + }; + } + if (!current.amountG && draftKcal.value !== null) { + return { kcal: Math.round(draftKcal.value), protein: current.protein, fat: current.fat, carbs: current.carbs }; + } + return { kcal: current.kcal, protein: current.protein, fat: current.fat, carbs: current.carbs }; + }); + + const save = () => { + const current = entry.value; + const nutrients = scaled.value; + if (!current || !nutrients) return; + const patch: Partial = { ...nutrients, meal: meal.value }; + if (current.amountG && draftAmount.value !== null && draftAmount.value > 0) { + patch.amountG = draftAmount.value; + } + update.mutate({ id: current.id, patch }); + close(); + }; + + const removeEntry = () => { + const current = entry.value; + if (!current) return; + remove.mutate({ id: current.id }); + close(); + }; + + const numeric = (raw: string): number => { + const value = Number(raw.replace(',', '.')); + return Number.isFinite(value) && value >= 0 ? value : 0; + }; + + const fieldClass = 'w-full rounded-xl border hairline bg-raised/70 px-3.5 py-2.5 text-[15px] text-ink outline-none transition focus:border-ember/50'; + const chipClass = (active: boolean) => + `rounded-full border px-3 py-1.5 text-[13px] transition ${active + ? 'border-ember/60 bg-ember/15 text-ember-bright' + : 'border-white/10 text-ink-soft hover:border-white/20 hover:text-ink'}`; + + return ( +
+
+ +
+
+

{entry.value?.name ?? ''}

+ +
+ + {entry.value && ( +
+
+ {fmtKcal(scaled.value?.kcal ?? 0)} + ккал +
+ + Б + {' '} + {fmtG(scaled.value?.protein ?? 0)} + + + Ж + {' '} + {fmtG(scaled.value?.fat ?? 0)} + + + У + {' '} + {fmtG(scaled.value?.carbs ?? 0)} + +
+
+ +
+ {MEALS.map(m => ( + + ))} +
+ + {entry.value.amountG + ? ( +
+ + (draftAmount.value = numeric(event.currentTarget.value))} + class={`${fieldClass} text-center text-lg tnum`} + /> +
+ ) + : ( +
+ + (draftKcal.value = numeric(event.currentTarget.value))} + class={`${fieldClass} text-center text-lg tnum`} + /> +
+ )} + +
+ + +
+
+ )} +
+
+ ); +} diff --git a/kcal/src/screens/FoodFormSheet.tsx b/kcal/src/screens/FoodFormSheet.tsx new file mode 100644 index 0000000..7c7a2a8 --- /dev/null +++ b/kcal/src/screens/FoodFormSheet.tsx @@ -0,0 +1,188 @@ +import { computed, shallowRef } from 'vue'; +import { useEntity, useMutation, useQuery } from 'vue-sync-engine'; +import { useCloseWatcher } from '@robonen/vue'; +import { FoodEntity, foodsQuery, removeFoodMutation, upsertFoodMutation } from '../data/defs'; +import { useEntities } from '../data/composables'; +import type { Food } from '../domain/types'; +import { foodForm } from '../ui/state'; +import { IconClose, IconTrash } from '../ui/icons'; + +/** Создание и правка продукта каталога. Значения нутриентов — на 100 г. */ +export default function FoodFormSheet() { + const existing = useEntity(FoodEntity, () => foodForm.foodId ?? undefined); + const foodsQ = useQuery(foodsQuery, () => undefined); + const foods = useEntities(FoodEntity, () => foodsQ.data.value?.ids); + + const upsert = useMutation(upsertFoodMutation); + const remove = useMutation(removeFoodMutation); + + const close = () => (foodForm.open = false); + useCloseWatcher().onClose(close); + + const source = existing.value; + const name = shallowRef(source?.name ?? ''); + const category = shallowRef(source?.category ?? 'Моё'); + const kcal = shallowRef(source?.kcal ?? 0); + const protein = shallowRef(source?.protein ?? 0); + const fat = shallowRef(source?.fat ?? 0); + const carbs = shallowRef(source?.carbs ?? 0); + const pieceGrams = shallowRef(source?.pieceGrams ?? 0); + + const categories = computed(() => [...new Set(foods.value.map(food => food.category))].sort((a, b) => a.localeCompare(b, 'ru'))); + + const save = () => { + if (name.value.trim() === '' || kcal.value <= 0) return; + const base = existing.value; + const food: Food = { + id: base?.id ?? crypto.randomUUID(), + name: name.value.trim(), + category: category.value.trim() || 'Моё', + kcal: kcal.value, + protein: protein.value, + fat: fat.value, + carbs: carbs.value, + ...(pieceGrams.value > 0 ? { pieceGrams: pieceGrams.value } : {}), + usedCount: base?.usedCount ?? 0, + lastUsedAt: base?.lastUsedAt ?? 0, + ...(base?.lastAmountG ? { lastAmountG: base.lastAmountG } : {}), + createdAt: base?.createdAt ?? Date.now(), + }; + upsert.mutate({ food }); + close(); + }; + + const removeFood = () => { + const base = existing.value; + if (!base) return; + remove.mutate({ id: base.id }); + close(); + }; + + const numeric = (raw: string): number => { + const value = Number(raw.replace(',', '.')); + return Number.isFinite(value) && value >= 0 ? value : 0; + }; + + const fieldClass = 'w-full rounded-xl border hairline bg-raised/70 px-3.5 py-2.5 text-[15px] text-ink outline-none transition focus:border-ember/50 placeholder:text-ink-faint'; + + return ( +
+
+ +
+
+

{existing.value ? 'Продукт' : 'Новый продукт'}

+ +
+ +
+ (name.value = event.currentTarget.value)} + class={fieldClass} + /> +
+ + (category.value = event.currentTarget.value)} + class={fieldClass} + /> + + {categories.value.map(item => ( + +
+ +

Значения указываются на 100 г продукта.

+
+
+ + (kcal.value = numeric(event.currentTarget.value))} + class={`${fieldClass} tnum`} + /> +
+
+ + (pieceGrams.value = numeric(event.currentTarget.value))} + class={`${fieldClass} tnum`} + /> +
+
+
+ {([ + ['Белки', protein], + ['Жиры', fat], + ['Углеводы', carbs], + ] as const).map(([label, model]) => ( +
+ + (model.value = numeric(event.currentTarget.value))} + class={`${fieldClass} text-center tnum`} + /> +
+ ))} +
+ +
+ {existing.value && ( + + )} + +
+ + {existing.value && ( +

+ Записи в дневнике хранят свой снимок значений — правка продукта + не меняет уже записанные дни. +

+ )} +
+
+
+ ); +} diff --git a/kcal/src/screens/FoodsScreen.tsx b/kcal/src/screens/FoodsScreen.tsx new file mode 100644 index 0000000..d2ca16e --- /dev/null +++ b/kcal/src/screens/FoodsScreen.tsx @@ -0,0 +1,99 @@ +import { computed, shallowRef } from 'vue'; +import { useQuery } from 'vue-sync-engine'; +import { groupBy } from '@robonen/stdlib'; +import { FoodEntity, foodsQuery } from '../data/defs'; +import { useEntities } from '../data/composables'; +import { fmtG, fmtKcal } from '../domain/format'; +import { foodForm } from '../ui/state'; +import { IconPlus, IconSearch } from '../ui/icons'; + +export default function FoodsScreen() { + const foodsQ = useQuery(foodsQuery, () => undefined); + const foods = useEntities(FoodEntity, () => foodsQ.data.value?.ids); + const query = shallowRef(''); + + const filtered = computed(() => { + const needle = query.value.trim().toLowerCase(); + return needle === '' ? foods.value : foods.value.filter(food => food.name.toLowerCase().includes(needle)); + }); + + const groups = computed(() => { + const grouped = groupBy(filtered.value, food => food.category); + return Object.entries(grouped).sort((a, b) => a[0].localeCompare(b[0], 'ru')); + }); + + return ( +
+
+

Продукты

+ +
+ +
+ + (query.value = event.currentTarget.value)} + class="w-full rounded-xl border hairline bg-raised/70 py-2.5 pr-3.5 pl-10 text-[15px] text-ink outline-none transition focus:border-ember/50 placeholder:text-ink-faint" + /> +
+ + {groups.value.map(([category, list], index) => ( +
+

{category}

+
+ {list.map(food => ( + + ))} +
+
+ ))} + + {filtered.value.length === 0 && ( +

Ничего не нашлось.

+ )} +
+ ); +} diff --git a/kcal/src/screens/HelpSection.tsx b/kcal/src/screens/HelpSection.tsx new file mode 100644 index 0000000..94c219b --- /dev/null +++ b/kcal/src/screens/HelpSection.tsx @@ -0,0 +1,88 @@ +import { IconChevronRight } from '../ui/icons'; + +interface FaqItem { + q: string; + a: string[]; +} + +const FAQ: FaqItem[] = [ + { + q: 'Откуда брать калории и БЖУ?', + a: [ + 'Упакованные продукты: КБЖУ на 100 г напечатаны на этикетке — заведите «Новый продукт» и перепишите 4 числа, либо отсканируйте штрихкод, и значения подставятся из открытой базы. Делается один раз: дальше продукт живёт в каталоге и всплывает в «Недавних».', + 'Обычная еда без этикетки (крупы, мясо, овощи): большая часть уже в каталоге, гарниры — в готовом виде, как на весах. Если чего-то нет — поищите «название калорийность на 100 г»: Calorizator, health-diet.ru (таблицы Скурихина), FatSecret дают рабочие цифры.', + 'Со временем ввод почти исчезает: вы едите одни и те же 20–30 продуктов, и все они окажутся в каталоге за первые пару недель.', + ], + }, + { + q: 'Как записывать ресторан и доставку?', + a: [ + 'Точных цифр тут не знает никто — используйте «Только калории» и записывайте оценку. День с примерной записью полезнее дня без записи.', + 'Ориентиры: тарелка бизнес-ланча 600–800 ккал, бургер с картошкой ~1000, кусок пиццы 250–300, салат с заправкой 150–250, сладкий кофе 150–300.', + 'У сетей (Вкусно и точка, KFC, Додо) КБЖУ опубликованы на сайте или в приложении — можно завести как продукт.', + ], + }, + { + q: 'Насколько точно нужно считать?', + a: [ + 'Не точно — консистентно. Ошибка ±20% на глаз — это нормально: она систематическая, то есть постоянная, и её выравнивает обратная связь по весу.', + 'Раз в день (утром, натощак) записывайте вес и смотрите на тренд за 1–2 недели, а не на суточные скачки. Записываете ~2000 ккал, а вес стоит при цели «похудение»? Значит, реальные калории выше — просто уменьшите цель на 200–300.', + 'Главная причина бросить дневник — перфекционизм. Лучше записать примерно, чем не записать вовсе.', + ], + }, + { + q: 'Как считать домашние блюда?', + a: [ + 'Пока — приближением: в каталоге есть типовые готовые блюда (борщ, плов, сырники), их значения усреднённые, но стабильные.', + 'Хотите точнее — заведите свой продукт: сложите КБЖУ сырых ингредиентов всей кастрюли, разделите на общий готовый вес и получите значения «на 100 г» своего блюда.', + 'Конструктор рецептов, который посчитает это сам, — в планах.', + ], + }, + { + q: 'Как считаются мои цели?', + a: [ + 'База — формула Миффлина—Сан Жеора: базовый обмен из пола, возраста, роста и веса, умноженный на коэффициент активности, даёт суточный расход.', + 'Похудение — умеренный дефицит 15% (комфортный темп ~0,5 кг в неделю), набор — профицит 10%. Белок 1,6–1,8 г на кг веса — для сытости и сохранения мышц; жиры ~1 г/кг; углеводы — остаток калорий.', + 'Это стартовая точка, а не приговор: цели можно править вручную в профиле, и стоит корректировать их по фактическому тренду веса.', + ], + }, + { + q: 'Как работает сканер штрихкодов?', + a: [ + 'Сканер и поле «Цифры под штрихкодом» ищут упаковку в Open Food Facts — открытой базе продуктов, которую заполняют люди. Запрос уходит напрямую из браузера, свой сервер для этого не нужен.', + 'Качество данных разное: перед сохранением приложение показывает форму с подставленными значениями — сверьте их с этикеткой. Повторное сканирование того же товара сразу открывает выбор порции.', + 'Камера работает в Chrome и на Android (нужно разрешение); если сканера нет — введите цифры, напечатанные под полосками штрихкода.', + ], + }, + { + q: 'Где хранятся мои данные?', + a: [ + 'Только на этом устройстве, в IndexedDB браузера. Никаких аккаунтов, серверов и синхронизации — данные не покидают браузер.', + 'Обратная сторона: браузер может удалить данные сайта (очистка хранилища, «стереть данные посещённых сайтов»). Поэтому время от времени делайте «Экспорт JSON» в разделе «Данные» — файл восстановит всё через «Импорт».', + ], + }, +]; + +/** Справка: практические ответы для первых недель ведения дневника. */ +export default function HelpSection() { + return ( +
+

Справка

+
+ {FAQ.map(item => ( +
+ + + {item.q} + +
+ {item.a.map(paragraph => ( +

{paragraph}

+ ))} +
+
+ ))} +
+
+ ); +} diff --git a/kcal/src/screens/ProfileScreen.tsx b/kcal/src/screens/ProfileScreen.tsx new file mode 100644 index 0000000..d574e29 --- /dev/null +++ b/kcal/src/screens/ProfileScreen.tsx @@ -0,0 +1,351 @@ +import { computed, shallowRef, watch } from 'vue'; +import { Status, useEntity, useMutation } from 'vue-sync-engine'; +import { DB_NAME, ProfileEntity, saveProfileMutation } from '../data/defs'; +import { downloadBackupFile, exportBackup, importBackup } from '../data/backup'; +import type { BackupPayload } from '../data/backup'; +import { bmr, computeTargets, roundTo, safeKcalFloor, tdee } from '../domain/calc'; +import { fmtKcal } from '../domain/format'; +import { ACTIVITY_LEVELS, GOAL_LABELS, PROFILE_ID } from '../domain/types'; +import type { Goal, Profile, Sex } from '../domain/types'; +import { IconDownload, IconUpload } from '../ui/icons'; +import HelpSection from './HelpSection'; + +const GOALS: readonly Goal[] = ['lose', 'maintain', 'gain']; + +/** Онбординг при первом входе и настройки в дальнейшем — одна форма. */ +export default function ProfileScreen(props: { onboarding?: boolean }) { + const profile = useEntity(ProfileEntity, () => PROFILE_ID); + const save = useMutation(saveProfileMutation); + + const source = profile.value; + const sex = shallowRef(source?.sex ?? 'male'); + const age = shallowRef(source?.age ?? 30); + const heightCm = shallowRef(source?.heightCm ?? 175); + const weightKg = shallowRef(source?.weightKg ?? 75); + const activity = shallowRef(source?.activity ?? 1.375); + const goal = shallowRef(source?.goal ?? 'lose'); + + const targetKcal = shallowRef(source?.targetKcal ?? 0); + const targetProtein = shallowRef(source?.targetProtein ?? 0); + const targetFat = shallowRef(source?.targetFat ?? 0); + const targetCarbs = shallowRef(source?.targetCarbs ?? 0); + + const valid = computed(() => + age.value >= 10 && age.value <= 100 + && heightCm.value >= 120 && heightCm.value <= 230 + && weightKg.value >= 30 && weightKg.value <= 300, + ); + + const recompute = () => { + if (!valid.value) return; + const targets = computeTargets({ + sex: sex.value, + age: age.value, + heightCm: heightCm.value, + weightKg: weightKg.value, + activity: activity.value, + goal: goal.value, + }); + targetKcal.value = targets.kcal; + targetProtein.value = targets.protein; + targetFat.value = targets.fat; + targetCarbs.value = targets.carbs; + }; + + // Цели следуют за параметрами; ручную правку ниже можно сделать перед сохранением. + watch([sex, age, heightCm, weightKg, activity, goal], recompute); + if (!source) recompute(); + + const bmrValue = computed(() => + valid.value ? Math.round(bmr(sex.value, age.value, heightCm.value, weightKg.value)) : 0, + ); + const tdeeValue = computed(() => + valid.value ? roundTo(tdee(sex.value, age.value, heightCm.value, weightKg.value, activity.value), 10) : 0, + ); + const belowFloor = computed(() => targetKcal.value > 0 && targetKcal.value < safeKcalFloor(sex.value)); + + const submit = () => { + if (!valid.value || targetKcal.value <= 0) return; + const now = Date.now(); + const next: Profile = { + id: PROFILE_ID, + sex: sex.value, + age: age.value, + heightCm: heightCm.value, + weightKg: weightKg.value, + activity: activity.value, + goal: goal.value, + targetKcal: targetKcal.value, + targetProtein: targetProtein.value, + targetFat: targetFat.value, + targetCarbs: targetCarbs.value, + createdAt: profile.value?.createdAt ?? now, + updatedAt: now, + }; + save.mutate({ profile: next }); + }; + + // ── бэкап ───────────────────────────────────────────────────────────────── + const importing = shallowRef(false); + const notice = shallowRef(''); + + const doExport = async () => { + downloadBackupFile(await exportBackup()); + }; + + const doImport = async (file: File | undefined) => { + if (!file) return; + importing.value = true; + try { + const payload = JSON.parse(await file.text()) as BackupPayload; + await importBackup(payload); + location.reload(); + } + catch (error) { + notice.value = error instanceof Error ? error.message : 'Не удалось прочитать файл'; + importing.value = false; + } + }; + + const wipeAll = () => { + if (!globalThis.confirm('Удалить все данные приложения? Действие необратимо.')) return; + indexedDB.deleteDatabase(DB_NAME); + location.reload(); + }; + + const numeric = (raw: string): number => { + const value = Number(raw.replace(',', '.')); + return Number.isFinite(value) && value >= 0 ? value : 0; + }; + + const fieldClass = 'w-full rounded-xl border hairline bg-raised/70 px-3.5 py-2.5 text-[15px] text-ink outline-none transition focus:border-ember/50 tnum'; + const chipClass = (active: boolean) => + `flex-1 rounded-xl border px-3 py-2 text-[13px] transition ${active + ? 'border-ember/60 bg-ember/15 text-ember-bright' + : 'border-white/10 text-ink-soft hover:border-white/20 hover:text-ink'}`; + + return ( +
+
+ {props.onboarding + ? ( +
+
Ккал
+

Настроим дневник

+

+ Пара параметров — и посчитаем вашу дневную норму калорий и белка. + Всё хранится только на этом устройстве. +

+
+ ) + :

Профиль

} +
+ + {/* Параметры тела */} +
+
+ + +
+ +
+
+ + (age.value = numeric(event.currentTarget.value))} + class={fieldClass} + /> +
+
+ + (heightCm.value = numeric(event.currentTarget.value))} + class={fieldClass} + /> +
+
+ + (weightKg.value = numeric(event.currentTarget.value))} + class={fieldClass} + /> +
+
+ +
+ +
+ {ACTIVITY_LEVELS.map(level => ( + + ))} +
+
+ +
+ +
+ {GOALS.map(value => ( + + ))} +
+
+
+ + {/* Расчёт и цели */} +
+
+
+
{fmtKcal(bmrValue.value)}
+
базовый обмен
+
+
+
{fmtKcal(tdeeValue.value)}
+
суточный расход
+
+
+ +
+
+ + (targetKcal.value = numeric(event.currentTarget.value))} + class={fieldClass} + /> +
+
+ + (targetProtein.value = numeric(event.currentTarget.value))} + class={fieldClass} + /> +
+
+ + (targetFat.value = numeric(event.currentTarget.value))} + class={fieldClass} + /> +
+
+ + (targetCarbs.value = numeric(event.currentTarget.value))} + class={fieldClass} + /> +
+
+ + {belowFloor.value && ( +

+ Цель ниже безопасного минимума ( + {fmtKcal(safeKcalFloor(sex.value))} + {' '} + ккал). + Долгий жёсткий дефицит вредит — лучше худеть медленнее. +

+ )} + +

+ Цели пересчитываются из параметров выше, но перед сохранением их можно + поправить вручную. Формула — Миффлина—Сан Жеора. +

+ + + {!props.onboarding && save.status.value === Status.Success && ( +

Сохранено

+ )} +
+ + {/* Данные */} + {!props.onboarding && ( +
+

Данные

+
+ + +
+ {notice.value &&

{notice.value}

} +

+ Всё хранится в IndexedDB этого браузера. Делайте экспорт время от + времени — браузер может очистить данные сайта. +

+ +
+ )} + + {!props.onboarding && } +
+ ); +} diff --git a/kcal/src/screens/StatsScreen.tsx b/kcal/src/screens/StatsScreen.tsx new file mode 100644 index 0000000..b61ac8f --- /dev/null +++ b/kcal/src/screens/StatsScreen.tsx @@ -0,0 +1,303 @@ +import { computed, shallowRef } from 'vue'; +import { useEntity, useMutation, useQuery } from 'vue-sync-engine'; +import { + ProfileEntity, + WeightEntity, + daySummariesQuery, + logWeightMutation, + removeWeightMutation, + weightsQuery, +} from '../data/defs'; +import type { DaySummary } from '../data/defs'; +import { useEntities } from '../data/composables'; +import { dayShort, dayTitle, lastDays, todayISO } from '../domain/dates'; +import { fmtG, fmtKcal } from '../domain/format'; +import { PROFILE_ID } from '../domain/types'; +import type { WeightLog } from '../domain/types'; +import { activeTab, selectedDate } from '../ui/state'; +import { IconScale, IconTrash } from '../ui/icons'; + +const PERIODS = [7, 14, 30] as const; + +export default function StatsScreen() { + const profile = useEntity(ProfileEntity, () => PROFILE_ID); + const summariesQ = useQuery(daySummariesQuery, () => undefined); + const weightsQ = useQuery(weightsQuery, () => undefined); + const weights = useEntities(WeightEntity, () => weightsQ.data.value?.ids); + + const logWeight = useMutation(logWeightMutation); + const removeWeight = useMutation(removeWeightMutation); + + const period = shallowRef<7 | 14 | 30>(14); + const selectedBar = shallowRef(todayISO()); + + const target = computed(() => profile.value?.targetKcal ?? 2000); + + const days = computed(() => { + const byDate = new Map((summariesQ.data.value ?? []).map(day => [day.date, day])); + return lastDays(period.value).map(date => + byDate.get(date) ?? { date, kcal: 0, protein: 0, fat: 0, carbs: 0, entries: 0 }, + ); + }); + + const chartMax = computed(() => { + const peak = Math.max(target.value, ...days.value.map(day => day.kcal)); + return peak * 1.08; + }); + + const selectedDay = computed(() => + days.value.find(day => day.date === selectedBar.value) ?? null, + ); + + const tracked = computed(() => days.value.filter(day => day.entries > 0)); + const averages = computed(() => { + const list = tracked.value; + if (list.length === 0) return null; + const total = list.reduce( + (acc, day) => ({ kcal: acc.kcal + day.kcal, protein: acc.protein + day.protein }), + { kcal: 0, protein: 0 }, + ); + const onTarget = list.filter(day => day.kcal <= target.value).length; + return { + kcal: total.kcal / list.length, + protein: total.protein / list.length, + onTargetShare: Math.round((onTarget / list.length) * 100), + }; + }); + + // ── вес ─────────────────────────────────────────────────────────────────── + const weightInput = shallowRef(0); + const latestWeight = computed(() => weights.value.at(-1) ?? null); + const weightDelta = computed(() => { + const list = weights.value; + const latest = list.at(-1); + if (!latest || list.length < 2) return null; + const weekAgoDate = lastDays(8)[0] ?? latest.date; + const reference = [...list].reverse().find(item => item.date <= weekAgoDate) ?? list[0]; + if (!reference || reference.id === latest.id) return null; + return latest.kg - reference.kg; + }); + + const sparkPoints = computed(() => { + const list = weights.value.slice(-30); + if (list.length < 2) return ''; + const min = Math.min(...list.map(item => item.kg)); + const max = Math.max(...list.map(item => item.kg)); + const span = Math.max(max - min, 0.5); + return list + .map((item, index) => { + const x = (index / (list.length - 1)) * 100; + const y = 26 - ((item.kg - min) / span) * 22 + 2; + return `${x.toFixed(1)},${y.toFixed(1)}`; + }) + .join(' '); + }); + + const submitWeight = () => { + if (weightInput.value <= 0) return; + const date = todayISO(); + const weight: WeightLog = { id: date, date, kg: weightInput.value, createdAt: Date.now() }; + logWeight.mutate({ weight }); + weightInput.value = 0; + }; + + const openInDiary = (day: DaySummary) => { + selectedDate.value = day.date; + activeTab.value = 'diary'; + }; + + const numeric = (raw: string): number => { + const value = Number(raw.replace(',', '.')); + return Number.isFinite(value) && value >= 0 ? value : 0; + }; + + return ( +
+
+

Статистика

+
+ {PERIODS.map(value => ( + + ))} +
+
+ + {/* График калорий по дням */} +
+
+

Калории по дням

+ + {`цель ${fmtKcal(target.value)}`} + +
+ + {selectedDay.value && ( + + )} + +
+ {/* Линия цели */} +
+
+ {days.value.map(day => ( + + ))} +
+
+
+ {dayShort(days.value[0]?.date ?? todayISO())} + {dayShort(days.value.at(-1)?.date ?? todayISO())} +
+
+ + {/* Средние за период */} + {averages.value && ( +
+
+
{fmtKcal(averages.value.kcal)}
+
ккал в среднем
+
+
+
{fmtG(averages.value.protein)}
+
белка в день, г
+
+
+
+ {averages.value.onTargetShare} + % +
+
дней в цели
+
+
+ )} + + {/* Вес */} +
+
+ +

Вес

+ {weightDelta.value !== null && ( + + {weightDelta.value > 0 ? '+' : ''} + {fmtG(weightDelta.value)} + {' '} + кг за неделю + + )} +
+ + {latestWeight.value && ( +
+
+ {fmtG(latestWeight.value.kg)} + кг +
+ {sparkPoints.value && ( + + + + )} +
+ )} + +
+ (weightInput.value = numeric(event.currentTarget.value))} + class="w-full flex-1 rounded-xl border hairline bg-raised/70 px-3.5 py-2.5 text-[15px] text-ink outline-none transition focus:border-ember/50 placeholder:text-ink-faint tnum" + /> + +
+ + {weights.value.length > 0 && ( +
+ {[...weights.value].reverse().slice(0, 5).map(item => ( +
+ {dayTitle(item.date)} + + {fmtG(item.kg)} + {' '} + кг + + +
+ ))} +
+ )} + +

+ Взвешивайтесь утром натощак. Смотрите на тренд за неделю, а не на + ежедневные колебания — вода и еда в желудке шумят на ±1 кг. +

+
+
+ ); +} diff --git a/kcal/src/ui/icons.tsx b/kcal/src/ui/icons.tsx new file mode 100644 index 0000000..f19d3d2 --- /dev/null +++ b/kcal/src/ui/icons.tsx @@ -0,0 +1,69 @@ +import { + Apple, + BookOpen, + ChartColumn, + ChevronLeft, + ChevronRight, + Download, + Plus, + ScanBarcode, + Search, + Trash2, + Upload, + UserRound, + Weight, + X, +} from 'lucide'; +import type { IconNode } from 'lucide'; + +interface IconProps { + class?: string; +} + +/** + * Иконки — данные из `lucide` (пары [тег, атрибуты] без обёртки svg). + * Компоненты lucide-vue-next построены на vdom и в чистом Vapor не работают, + * поэтому рендерим содержимое сами: данные статичны и доверенны — сериализуем + * их в разметку один раз на модуль и вставляем через v-html. + */ +function toMarkup(node: IconNode): string { + return node + .map(([tag, attrs]) => { + const serialized = Object.entries(attrs) + .map(([key, value]) => `${key}="${String(value)}"`) + .join(' '); + return `<${tag} ${serialized}/>`; + }) + .join(''); +} + +function createIcon(node: IconNode) { + const markup = toMarkup(node); + return (props: IconProps) => ( + + ); +} + +export const IconBook = createIcon(BookOpen); +export const IconChart = createIcon(ChartColumn); +export const IconApple = createIcon(Apple); +export const IconUser = createIcon(UserRound); +export const IconPlus = createIcon(Plus); +export const IconChevronLeft = createIcon(ChevronLeft); +export const IconChevronRight = createIcon(ChevronRight); +export const IconClose = createIcon(X); +export const IconTrash = createIcon(Trash2); +export const IconSearch = createIcon(Search); +export const IconScale = createIcon(Weight); +export const IconBarcode = createIcon(ScanBarcode); +export const IconDownload = createIcon(Download); +export const IconUpload = createIcon(Upload); diff --git a/kcal/src/ui/state.ts b/kcal/src/ui/state.ts new file mode 100644 index 0000000..29372ea --- /dev/null +++ b/kcal/src/ui/state.ts @@ -0,0 +1,28 @@ +import { reactive, shallowRef } from 'vue'; +import type { Meal } from '../domain/types'; +import { todayISO } from '../domain/dates'; + +export type Tab = 'diary' | 'stats' | 'foods' | 'profile'; + +export const activeTab = shallowRef('diary'); + +/** День, открытый в дневнике. Записи добавляются именно в него. */ +export const selectedDate = shallowRef(todayISO()); + +/** Шторка добавления записи. */ +export const addSheet = reactive({ open: false, meal: 'breakfast' as Meal }); + +/** Шторка редактирования записи дневника. */ +export const editEntryId = shallowRef(null); + +/** Шторка формы продукта; null в foodId — создание нового. */ +export const foodForm = reactive({ open: false, foodId: null as string | null }); + +export function openAddSheet(meal: Meal): void { + addSheet.meal = meal; + addSheet.open = true; +} + +export function goToday(): void { + selectedDate.value = todayISO(); +} diff --git a/kcal/tsconfig.json b/kcal/tsconfig.json new file mode 100644 index 0000000..413f6b7 --- /dev/null +++ b/kcal/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "@robonen/tsconfig/tsconfig.dom.json", + "compilerOptions": { + "jsx": "preserve", + "jsxImportSource": "vue-jsx-vapor", + "types": ["node", "vite/client"], + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src", "vite.config.ts", "eslint.config.ts"] +} diff --git a/kcal/vite.config.ts b/kcal/vite.config.ts new file mode 100644 index 0000000..dda48fc --- /dev/null +++ b/kcal/vite.config.ts @@ -0,0 +1,29 @@ +/// +import { defineConfig } from 'vitest/config'; +import vueJsxVapor from 'vue-jsx-vapor/vite'; +import tailwindcss from '@tailwindcss/vite'; +import { syncEnginePlugin } from 'vue-sync-engine/plugin'; + +// JSX компилируется сразу в Vapor-код (без interop и virtual DOM). +// Tailwind v4 подключён как Vite-плагин, конфиг живёт в src/app.css (@theme). +// syncEnginePlugin собирает дефы в virtual:sync-engine-registry — его требует +// DevTools-ветка движка в dev-режиме. +export default defineConfig({ + plugins: [vueJsxVapor(), tailwindcss(), syncEnginePlugin({ definitions: ['/src/data/defs.ts'] })], + optimizeDeps: { + // Движок ходит в virtual-модуль — пребандл прятал бы его от плагина. + exclude: ['vue-sync-engine'], + }, + define: { + __VUE_OPTIONS_API__: 'false', + __VUE_PROD_DEVTOOLS__: 'false', + __VUE_PROD_HYDRATION_MISMATCH_DETAILS__: 'false', + // Dev-ветки vue-sync-engine (DevTools-панель) остаются в `vite dev`, + // вырезаются из прод-сборки. + __SYNC_ENGINE_DEV__: JSON.stringify(process.env.NODE_ENV !== 'production'), + }, + test: { + environment: 'node', + include: ['src/**/*.test.ts'], + }, +});