From 94a9f329695e9ff386b8860fc164b2401ba16795 Mon Sep 17 00:00:00 2001 From: Amer Agovic Date: Sat, 18 Apr 2026 10:43:52 -0500 Subject: [PATCH] Initial commit: bface library, build fixes, and refreshed docs - Externalize all @tamagui/* and tamagui subpaths so dist no longer vendors Tamagui. - Emit TypeScript declarations with vite-plugin-dts; fix package exports types for ui/*. - Align initEnv with profiles: displayName, brandLogo, api.baseURL, themeColor, uiShell. - Stabilize tests with Node localStorage file; env tests pass. - Update README and component docs for services, menus, API client, and development. --- .gitignore | 44 + README.md | 343 ++ package-lock.json | 4412 +++++++++++++++++++ package.json | 52 + src/data/DataModel.js | 62 + src/data/InMemoryDataModel.js | 220 + src/data/index.js | 2 + src/index.js | 22 + src/platform/api.js | 133 + src/platform/compat.js | 731 +++ src/platform/env.js | 487 ++ src/platform/host.js | 43 + src/platform/menu.js | 1301 ++++++ src/platform/storage.js | 220 + src/platform/sw-register.js | 233 + src/security/index.js | 6 + src/security/model/AccountProfile.js | 10 + src/security/model/Permit.js | 14 + src/security/model/Realm.js | 9 + src/security/model/Resource.js | 10 + src/security/model/Role.js | 10 + src/security/model/Session.js | 12 + src/security/model/User.js | 15 + src/security/model/index.js | 15 + src/security/model/rights.js | 57 + src/security/pages/AccountHomePage.jsx | 17 + src/security/pages/AccountProfilePage.jsx | 270 ++ src/security/pages/ErrorPage.jsx | 66 + src/security/pages/LoginPage.jsx | 165 + src/security/pages/SecurityAdminPage.jsx | 155 + src/security/pages/index.js | 5 + src/security/policy/BasicSecurityPolicy.js | 533 +++ src/security/policy/BstoreSecurityPolicy.js | 16 + src/security/policy/SecurityPolicy.js | 51 + src/security/policy/index.js | 3 + src/security/runtime/account-tabs.js | 58 + src/security/runtime/api-auth.js | 12 + src/security/runtime/route-guards.js | 42 + src/security/runtime/security-service.js | 371 ++ src/ui/App.jsx | 532 +++ src/ui/components/AppInfo.jsx | 72 + src/ui/components/DashboardShell.jsx | 29 + src/ui/components/DetView.jsx | 27 + src/ui/components/DirView.jsx | 421 ++ src/ui/components/EmptyShell.jsx | 292 ++ src/ui/components/FormField.jsx | 283 ++ src/ui/components/FormView.jsx | 145 + src/ui/components/GeneralConfig.jsx | 49 + src/ui/components/IconMapper.jsx | 324 ++ src/ui/components/IdentityConfig.jsx | 35 + src/ui/components/LandingShell.jsx | 31 + src/ui/components/MenuItemButton.jsx | 609 +++ src/ui/components/Page.jsx | 110 + src/ui/components/Panel.jsx | 198 + src/ui/components/PersonalMenuItem.jsx | 82 + src/ui/components/ProgressBar.jsx | 190 + src/ui/components/README.md | 269 ++ src/ui/components/Router.jsx | 1101 +++++ src/ui/components/SettingsPanel.jsx | 359 ++ src/ui/components/Shell.jsx | 687 +++ src/ui/components/SideBar.jsx | 495 +++ src/ui/components/SidePanelShell.jsx | 142 + src/ui/components/TopBar.jsx | 417 ++ src/ui/components/grid/GridView.jsx | 290 ++ src/ui/components/grid/README.md | 110 + src/ui/components/grid/context.js | 12 + src/ui/components/grid/index.js | 13 + src/ui/components/grid/layout.jsx | 115 + src/ui/components/grid/model.js | 123 + src/ui/components/grid/panel.jsx | 313 ++ src/ui/components/grid/presets.js | 20 + src/ui/components/grid/table.jsx | 256 ++ src/ui/components/grid/utils.js | 259 ++ src/ui/components/index.js | 30 + src/ui/pages/SettingsPage.jsx | 143 + src/ui/route-loading.js | 18 + src/ui/runtime/general-settings.js | 66 + src/ui/styles/ColorfulTheme.js | 121 + src/ui/styles/MaterialTheme.js | 123 + src/ui/styles/MinimalTheme.js | 121 + src/ui/styles/index.js | 51 + src/ui/styles/main.css | 21 + src/ui/theme-controller.js | 123 + test/env.test.js | 161 + test/grid-data-model.test.js | 57 + tsconfig.json | 28 + vite.config.js | 50 + 87 files changed, 19750 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/data/DataModel.js create mode 100644 src/data/InMemoryDataModel.js create mode 100644 src/data/index.js create mode 100644 src/index.js create mode 100644 src/platform/api.js create mode 100644 src/platform/compat.js create mode 100644 src/platform/env.js create mode 100644 src/platform/host.js create mode 100644 src/platform/menu.js create mode 100644 src/platform/storage.js create mode 100644 src/platform/sw-register.js create mode 100644 src/security/index.js create mode 100644 src/security/model/AccountProfile.js create mode 100644 src/security/model/Permit.js create mode 100644 src/security/model/Realm.js create mode 100644 src/security/model/Resource.js create mode 100644 src/security/model/Role.js create mode 100644 src/security/model/Session.js create mode 100644 src/security/model/User.js create mode 100644 src/security/model/index.js create mode 100644 src/security/model/rights.js create mode 100644 src/security/pages/AccountHomePage.jsx create mode 100644 src/security/pages/AccountProfilePage.jsx create mode 100644 src/security/pages/ErrorPage.jsx create mode 100644 src/security/pages/LoginPage.jsx create mode 100644 src/security/pages/SecurityAdminPage.jsx create mode 100644 src/security/pages/index.js create mode 100644 src/security/policy/BasicSecurityPolicy.js create mode 100644 src/security/policy/BstoreSecurityPolicy.js create mode 100644 src/security/policy/SecurityPolicy.js create mode 100644 src/security/policy/index.js create mode 100644 src/security/runtime/account-tabs.js create mode 100644 src/security/runtime/api-auth.js create mode 100644 src/security/runtime/route-guards.js create mode 100644 src/security/runtime/security-service.js create mode 100644 src/ui/App.jsx create mode 100644 src/ui/components/AppInfo.jsx create mode 100644 src/ui/components/DashboardShell.jsx create mode 100644 src/ui/components/DetView.jsx create mode 100644 src/ui/components/DirView.jsx create mode 100644 src/ui/components/EmptyShell.jsx create mode 100644 src/ui/components/FormField.jsx create mode 100644 src/ui/components/FormView.jsx create mode 100644 src/ui/components/GeneralConfig.jsx create mode 100644 src/ui/components/IconMapper.jsx create mode 100644 src/ui/components/IdentityConfig.jsx create mode 100644 src/ui/components/LandingShell.jsx create mode 100644 src/ui/components/MenuItemButton.jsx create mode 100644 src/ui/components/Page.jsx create mode 100644 src/ui/components/Panel.jsx create mode 100644 src/ui/components/PersonalMenuItem.jsx create mode 100644 src/ui/components/ProgressBar.jsx create mode 100644 src/ui/components/README.md create mode 100644 src/ui/components/Router.jsx create mode 100644 src/ui/components/SettingsPanel.jsx create mode 100644 src/ui/components/Shell.jsx create mode 100644 src/ui/components/SideBar.jsx create mode 100644 src/ui/components/SidePanelShell.jsx create mode 100644 src/ui/components/TopBar.jsx create mode 100644 src/ui/components/grid/GridView.jsx create mode 100644 src/ui/components/grid/README.md create mode 100644 src/ui/components/grid/context.js create mode 100644 src/ui/components/grid/index.js create mode 100644 src/ui/components/grid/layout.jsx create mode 100644 src/ui/components/grid/model.js create mode 100644 src/ui/components/grid/panel.jsx create mode 100644 src/ui/components/grid/presets.js create mode 100644 src/ui/components/grid/table.jsx create mode 100644 src/ui/components/grid/utils.js create mode 100644 src/ui/components/index.js create mode 100644 src/ui/pages/SettingsPage.jsx create mode 100644 src/ui/route-loading.js create mode 100644 src/ui/runtime/general-settings.js create mode 100644 src/ui/styles/ColorfulTheme.js create mode 100644 src/ui/styles/MaterialTheme.js create mode 100644 src/ui/styles/MinimalTheme.js create mode 100644 src/ui/styles/index.js create mode 100644 src/ui/styles/main.css create mode 100644 src/ui/theme-controller.js create mode 100644 test/env.test.js create mode 100644 test/grid-data-model.test.js create mode 100644 tsconfig.json create mode 100644 vite.config.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..35caa81 --- /dev/null +++ b/.gitignore @@ -0,0 +1,44 @@ +# Build output +dist/ + +# Dependencies +node_modules/ + +# Environment and secrets +.env +.env.* +!.env.example + +# Node test runner persisted localStorage (see npm test script) +.node-localstorage + +# Logs and debug +logs/ +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + +# Test / coverage +coverage/ +.nyc_output/ + +# Vite / tooling cache +.vite/ +*.tsbuildinfo + +# Tamagui (generated by build / vite plugin) +.tamagui/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db +ehthumbs.db +Desktop.ini diff --git a/README.md b/README.md new file mode 100644 index 0000000..f2f3357 --- /dev/null +++ b/README.md @@ -0,0 +1,343 @@ +# @reliancy/bface + +Base UI and platform library for building Progressive Web Applications (PWAs) with React and Tamagui: shells, routing, env/config, storage, menus, security primitives, and data helpers. + +## Installation + +```bash +npm install @reliancy/bface +``` + +If your organization hosts this package on a private registry, configure npm for that registry (see `publishConfig` in `package.json`) before installing. + +## Peer dependencies + +Install React in the consuming app (versions should match `peerDependencies` in `package.json`): + +```bash +npm install react react-dom +``` + +Tamagui packages are **dependencies** of `@reliancy/bface`, so you do not need to add `@tamagui/*` or `tamagui` separately unless you want a single shared version across packages (then align versions with this library). + +## Quick Start + +### 1. Basic App Setup + +The library provides an `App` component that manages the application shell, routing, theming, and platform services. + +```jsx +import { App } from '@reliancy/bface/ui/App'; +import { CONFIG_KEYS } from '@reliancy/bface/platform/env'; +import { registerServiceWorker } from '@reliancy/bface/platform/sw-register'; + +async function handleInit(services, { initialProfile } = {}) { + // Application profile (camelCase or snake_case field names are accepted where noted in env mapping) + const profile = { + name: 'MyApp', + displayName: 'My Application', + brandLogo: '/logo.svg', + ui_shell: 'DashboardShell', + modules: ['core'], + storage: { backend: 'localStorage' }, + api: { baseURL: '/api' } + }; + + services.env.initEnv(profile); + + for (const moduleName of profile.modules) { + // await loadModule(moduleName, services); + } + + await registerServiceWorker(); + + return profile; +} + +function MyApp() { + return ; +} +``` + +### 2. Using platform services + +`App` calls `onInit(services, { initialProfile })` once platform services exist. The `services` object includes: + +- **`services.api_client`** — HTTP client (`get`, `post`, …) +- **`services.storage`** — storage module (`getProvider`, …) +- **`services.api_router`** — placeholder for service-worker API routing (when available) +- **`services.ui_router`** — UI routing helpers +- **`services.menu`** — menu registration and queries +- **`services.env`** — `initEnv`, `getConfig`, `setConfig`, `CONFIG_KEYS`, tracing helpers, etc. + +Service worker registration is **not** on `services`; import `registerServiceWorker` from `@reliancy/bface/platform/sw-register` (or the package root) and call it from `onInit` when you are ready. + +```jsx +async function handleInit(services) { + // Example: Get configuration + const appName = await services.env.getConfig(CONFIG_KEYS.APP_NAME); + + // Example: Make API call + const data = await services.api_client.get('/users'); + + // Example: Store data (KeyValueStore via getProvider) + const kv = services.storage.getProvider('kv', 'myStore'); + await kv.set('user', { id: 1, name: 'John' }); +} +``` + +### 3. Using UI Components + +Import and use UI components directly: + +```jsx +import { Page, Panel, MenuItemButton } from '@reliancy/bface/ui/components'; +import { SideBar, TopBar } from '@reliancy/bface/ui/components'; + +function MyPage() { + return ( + + + Welcome to your dashboard + + + ); +} +``` + +### 4. Using Platform Modules + +Import platform utilities: + +```jsx +import { getConfig, setConfig, CONFIG_KEYS } from '@reliancy/bface/platform/env'; +import { api } from '@reliancy/bface/platform/api'; +import { getProvider } from '@reliancy/bface/platform/storage'; +import { queryMenuItems } from '@reliancy/bface/platform/menu'; + +// Get configuration +const theme = await getConfig('theme.mode', 'system'); + +// Set configuration +await setConfig('theme.mode', 'dark'); + +// Use storage +const storage = getProvider('kv', 'myStore'); +await storage.set('key', 'value'); +const value = await storage.get('key'); + +// HTTP (singleton; base URL comes from env when wired in App) +await api.get('/status'); + +// Query menu items +const menuItems = queryMenuItems('/primary'); +``` + +## Library structure + +### Exports (`package.json` → `exports`) + +- **`@reliancy/bface`** — main entry: platform, `App`, UI components index, general settings, security, and data helpers +- **`@reliancy/bface/platform/*`** — platform modules (`env`, `api`, `storage`, `menu`, `sw-register`, `compat`, `host`, …) +- **`@reliancy/bface/ui/*`** — UI entry points such as `App` and `components` + +Security and data types are re-exported from the root entry; there are no separate `exports` subpaths for `./security/*` or `./data/*` today—import them from `@reliancy/bface` or add deep links if your bundler resolves source. + +### Platform modules + +- **`platform/env.js`** — profile → config dictionary, `getConfig` / `setConfig`, logging and tracing helpers +- **`platform/api.js`** — API client +- **`platform/storage.js`** — storage abstraction (localStorage, IndexedDB, OPFS) +- **`platform/menu.js`** — menu model and queries +- **`platform/sw-register.js`** — service worker registration and cache helpers +- **`platform/compat.js`** — environment detection and compatibility +- **`platform/host.js`** — host detection (e.g. Electron) + +### UI + +- **`ui/App.jsx`** — Tamagui provider, theme controller, security bootstrap, shell selection, `onInit` +- **`ui/components/`** — shells (`EmptyShell`, `LandingShell`, `DashboardShell`, …), layout, grid/DirView, forms, router +- **`ui/styles/`** — Tamagui style themes (`material`, `minimal`, `colorful`) + +### Other areas + +- **`security/`** — policies, models, login and account pages, route guards, `securityService` +- **`data/`** — `DataModel`, `InMemoryDataModel` + +### Application profile and `initEnv` + +`initEnv` maps the profile object onto internal config keys. For convenience, several fields accept **either** camelCase **or** snake_case: + +| Concept | Accepted profile fields | Internal key | +|--------|-------------------------|--------------| +| Display title | `displayName`, then `short_name`, then `name` | `APP_DISPLAY_NAME` | +| Stable app id | `id`, then `name` | `APP_NAME` | +| Logo | `brandLogo` or `brand_logo`, or PWA manifest-style `icons[0].src` | `BRAND_LOGO` | +| API base | `api.baseURL` or `api.base_url` | `API_BASE_URL` | +| Shell | `uiShell` or `ui_shell` | `UI_SHELL` | + +### Shell names (`ui_shell` / `UI_SHELL`) + +Resolved case-insensitively in `App`: + +| Profile value | Component | +|---------------|-----------| +| `EmptyShell` (default) | `EmptyShell` | +| `LandingShell` | `LandingShell` | +| `TopBarShell` | Same layout as `LandingShell` (top bar shell) | +| `DashboardShell` | `DashboardShell` | + +## Complete Example + +Here's a complete example of using the library in a project: + +```jsx +// app.jsx +import { App } from '@reliancy/bface/ui/App'; +import { CONFIG_KEYS } from '@reliancy/bface/platform/env'; +import { registerServiceWorker } from '@reliancy/bface/platform/sw-register'; + +async function loadProfile() { + // Load your app profile (from JSON, API, etc.) + const response = await fetch('/profile.json'); + return await response.json(); +} + +async function loadModule(moduleName, services) { + // Dynamically import and initialize your modules + const module = await import(`./modules/${moduleName}/index.js`); + if (module.publishModule) { + module.publishModule(services); + } +} + +async function handleInit(services, { initialProfile } = {}) { + // 1. Load profile (use embedded profile from App when provided) + const profile = initialProfile ?? await loadProfile(); + + // 2. Initialize environment + services.env.initEnv(profile); + + // 3. Load modules + const modules = await services.env.getConfig(CONFIG_KEYS.MODULES, []); + for (const moduleName of modules) { + await loadModule(moduleName, services); + } + + // 4. Register service worker + await registerServiceWorker(); + + return profile; +} + +export default function MyApp() { + return ; +} +``` + +## Theming + +The library supports multiple themes. Configure the theme in your profile: + +```json +{ + "name": "MyApp", + "ui_shell": "DashboardShell", + "theme": { + "name": "material", + "mode": "system" + } +} +``` + +Available themes: +- `material` - Material Design theme +- `minimal` - Minimal theme +- `colorful` - Colorful theme + +## Menu System + +Register menu items in your modules: + +```jsx +// In your module +import { publishMenuItem, MENU_DIRS } from '@reliancy/bface/platform/menu'; + +export function publishModule(platform) { + publishMenuItem(MENU_DIRS.PRIMARY('dashboard'), { + label: 'Dashboard', + icon: 'dashboard', + invoke: () => { + platform.ui_router.navigate('/dashboard'); + } + }); +} +``` + +## Storage + +The library provides a unified storage API: + +```jsx +import { getProvider } from '@reliancy/bface/platform/storage'; + +// Get storage provider +const storage = getProvider('kv', 'myStore'); + +// Use storage +await storage.set('key', { data: 'value' }); +const value = await storage.get('key'); +const exists = await storage.hasKey('key'); +await storage.remove('key'); +await storage.clear(); +``` + +Supported backends: +- `localStorage` - Browser localStorage +- `indexedDB` - IndexedDB +- `opfs` - Origin Private File System + +## API Client + +Make HTTP requests using the API client: + +```jsx +// In your onInit callback — paths are relative to the API base URL from the profile (`api.baseURL` / `api.base_url`) +const data = await services.api_client.get('/users'); +const user = await services.api_client.post('/users', { name: 'John' }); +await services.api_client.put('/users/1', { name: 'Jane' }); +await services.api_client.delete('/users/1'); +``` + +## Development + +Clone the repository, install dependencies (`npm install`), then: + +### Build + +```bash +npm run build +``` + +Produces ESM under `dist/` with **`.d.ts` declaration files** (via `vite-plugin-dts`) for the published `exports` map. The `dist/` folder is gitignored in this repo; the npm package tarball is built from `files` in `package.json`. + +### Tests + +```bash +npm test +``` + +Uses Node’s built-in test runner. The npm script passes `--localstorage-file=.node-localstorage` so `getConfig` / storage-backed paths work under Node without noisy `SecurityError` warnings. + +### Watch mode (library) + +```bash +npm run dev +``` + +Runs `vite build --watch` for iterative work on the package. + +## License + +Copyright and licensing terms are defined by the organization that publishes this package. + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..03b989f --- /dev/null +++ b/package-lock.json @@ -0,0 +1,4412 @@ +{ + "name": "@reliancy/bface", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@reliancy/bface", + "version": "1.0.0", + "dependencies": { + "@tamagui/config": "^1.144.2", + "@tamagui/core": "^1.144.2", + "@tamagui/lucide-icons": "^1.144.2", + "tamagui": "^1.144.2" + }, + "devDependencies": { + "@tamagui/vite-plugin": "^1.144.2", + "@vitejs/plugin-react": "^4.2.1", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "vite": "^5.0.8", + "vite-plugin-dts": "^4.3.0" + }, + "peerDependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.28.6", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.28.6", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.6", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/generator": "^7.28.6", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.28.6", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-annotate-as-pure": { + "version": "7.27.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.27.3" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.6", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.28.6", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx": { + "version": "7.28.6", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/plugin-syntax-jsx": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.28.6", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.28.6", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/generator": "^7.28.6", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.6", + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emotion/is-prop-valid": { + "version": "0.8.8", + "license": "MIT", + "optional": true, + "dependencies": { + "@emotion/memoize": "0.7.4" + } + }, + "node_modules/@emotion/memoize": { + "version": "0.7.4", + "license": "MIT", + "optional": true + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.3", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.4", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.3", + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/react": { + "version": "0.27.16", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.1.6", + "@floating-ui/utils": "^0.2.10", + "tabbable": "^6.0.0" + }, + "peerDependencies": { + "react": ">=17.0.0", + "react-dom": ">=17.0.0" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.6", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.4" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/react-native": { + "version": "0.10.7", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-native": ">=0.64.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.10", + "license": "MIT" + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@microsoft/api-extractor": { + "version": "7.58.5", + "resolved": "https://registry.npmjs.org/@microsoft/api-extractor/-/api-extractor-7.58.5.tgz", + "integrity": "sha512-pKQs2MBtGCVcIs1pxkSjhb1GGxYaS3KRCoi7Ki6qCJw6S/zx5ZYvC3CX39RpexBqYcO0asphyrqdW2ifyuSX6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@microsoft/api-extractor-model": "7.33.8", + "@microsoft/tsdoc": "~0.16.0", + "@microsoft/tsdoc-config": "~0.18.1", + "@rushstack/node-core-library": "5.23.1", + "@rushstack/rig-package": "0.7.3", + "@rushstack/terminal": "0.23.0", + "@rushstack/ts-command-line": "5.3.8", + "diff": "~8.0.2", + "minimatch": "10.2.3", + "resolve": "~1.22.1", + "semver": "~7.7.4", + "source-map": "~0.6.1", + "typescript": "5.9.3" + }, + "bin": { + "api-extractor": "bin/api-extractor" + } + }, + "node_modules/@microsoft/api-extractor-model": { + "version": "7.33.8", + "resolved": "https://registry.npmjs.org/@microsoft/api-extractor-model/-/api-extractor-model-7.33.8.tgz", + "integrity": "sha512-aIcoQggPyer3B6Ze3usz0YWC/oBwUHfRH5ETUsr+oT2BRA6SfTJl7IKPcPZkX4UR+PohowzW4uMxsvjrn8vm+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@microsoft/tsdoc": "~0.16.0", + "@microsoft/tsdoc-config": "~0.18.1", + "@rushstack/node-core-library": "5.23.1" + } + }, + "node_modules/@microsoft/api-extractor/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@microsoft/api-extractor/node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@microsoft/api-extractor/node_modules/minimatch": { + "version": "10.2.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.3.tgz", + "integrity": "sha512-Rwi3pnapEqirPSbWbrZaa6N3nmqq4Xer/2XooiOKyV3q12ML06f7MOuc5DVH8ONZIFhwIYQ3yzPH4nt7iWHaTg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@microsoft/api-extractor/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@microsoft/api-extractor/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@microsoft/tsdoc": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/@microsoft/tsdoc/-/tsdoc-0.16.0.tgz", + "integrity": "sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@microsoft/tsdoc-config": { + "version": "0.18.1", + "resolved": "https://registry.npmjs.org/@microsoft/tsdoc-config/-/tsdoc-config-0.18.1.tgz", + "integrity": "sha512-9brPoVdfN9k9g0dcWkFeA7IH9bbcttzDJlXvkf8b2OBzd5MueR1V2wkKBL0abn0otvmkHJC6aapBOTJDDeMCZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@microsoft/tsdoc": "0.16.0", + "ajv": "~8.18.0", + "jju": "~1.4.0", + "resolve": "~1.22.2" + } + }, + "node_modules/@motionone/animation": { + "version": "10.18.0", + "license": "MIT", + "dependencies": { + "@motionone/easing": "^10.18.0", + "@motionone/types": "^10.17.1", + "@motionone/utils": "^10.18.0", + "tslib": "^2.3.1" + } + }, + "node_modules/@motionone/dom": { + "version": "10.12.0", + "license": "MIT", + "dependencies": { + "@motionone/animation": "^10.12.0", + "@motionone/generators": "^10.12.0", + "@motionone/types": "^10.12.0", + "@motionone/utils": "^10.12.0", + "hey-listen": "^1.0.8", + "tslib": "^2.3.1" + } + }, + "node_modules/@motionone/easing": { + "version": "10.18.0", + "license": "MIT", + "dependencies": { + "@motionone/utils": "^10.18.0", + "tslib": "^2.3.1" + } + }, + "node_modules/@motionone/generators": { + "version": "10.18.0", + "license": "MIT", + "dependencies": { + "@motionone/types": "^10.17.1", + "@motionone/utils": "^10.18.0", + "tslib": "^2.3.1" + } + }, + "node_modules/@motionone/types": { + "version": "10.17.1", + "license": "MIT" + }, + "node_modules/@motionone/utils": { + "version": "10.18.0", + "license": "MIT", + "dependencies": { + "@motionone/types": "^10.17.1", + "hey-listen": "^1.0.8", + "tslib": "^2.3.1" + } + }, + "node_modules/@napi-rs/nice": { + "version": "1.1.1", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@napi-rs/nice-android-arm-eabi": "1.1.1", + "@napi-rs/nice-android-arm64": "1.1.1", + "@napi-rs/nice-darwin-arm64": "1.1.1", + "@napi-rs/nice-darwin-x64": "1.1.1", + "@napi-rs/nice-freebsd-x64": "1.1.1", + "@napi-rs/nice-linux-arm-gnueabihf": "1.1.1", + "@napi-rs/nice-linux-arm64-gnu": "1.1.1", + "@napi-rs/nice-linux-arm64-musl": "1.1.1", + "@napi-rs/nice-linux-ppc64-gnu": "1.1.1", + "@napi-rs/nice-linux-riscv64-gnu": "1.1.1", + "@napi-rs/nice-linux-s390x-gnu": "1.1.1", + "@napi-rs/nice-linux-x64-gnu": "1.1.1", + "@napi-rs/nice-linux-x64-musl": "1.1.1", + "@napi-rs/nice-openharmony-arm64": "1.1.1", + "@napi-rs/nice-win32-arm64-msvc": "1.1.1", + "@napi-rs/nice-win32-ia32-msvc": "1.1.1", + "@napi-rs/nice-win32-x64-msvc": "1.1.1" + } + }, + "node_modules/@napi-rs/nice-win32-x64-msvc": { + "version": "1.1.1", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@react-native/normalize-color": { + "version": "2.1.0", + "license": "MIT" + }, + "node_modules/@react-native/normalize-colors": { + "version": "0.74.89", + "dev": true, + "license": "MIT" + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/pluginutils": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", + "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.55.2", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.55.2", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rushstack/node-core-library": { + "version": "5.23.1", + "resolved": "https://registry.npmjs.org/@rushstack/node-core-library/-/node-core-library-5.23.1.tgz", + "integrity": "sha512-wlKmIKIYCKuCASbITvOxLZXepPbwXvrv7S6ig6XNWFchSyhL/E2txmVXspHY49Wu2dzf7nI27a2k/yV5BA3EiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "~8.18.0", + "ajv-draft-04": "~1.0.0", + "ajv-formats": "~3.0.1", + "fs-extra": "~11.3.0", + "import-lazy": "~4.0.0", + "jju": "~1.4.0", + "resolve": "~1.22.1", + "semver": "~7.7.4" + }, + "peerDependencies": { + "@types/node": "*" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@rushstack/node-core-library/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@rushstack/problem-matcher": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@rushstack/problem-matcher/-/problem-matcher-0.2.1.tgz", + "integrity": "sha512-gulfhBs6n+I5b7DvjKRfhMGyUejtSgOHTclF/eONr8hcgF1APEDjhxIsfdUYYMzC3rvLwGluqLjbwCFZ8nxrog==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/node": "*" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@rushstack/rig-package": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/@rushstack/rig-package/-/rig-package-0.7.3.tgz", + "integrity": "sha512-aAA518n6wxxjCfnTAOjQnm7ngNE0FVHxHAw2pxKlIhxrMn0XQjGcXKF0oKWpjBgJOmsaJpVob/v+zr3zxgPWuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jju": "~1.4.0", + "resolve": "~1.22.1" + } + }, + "node_modules/@rushstack/terminal": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@rushstack/terminal/-/terminal-0.23.0.tgz", + "integrity": "sha512-7FfI9irLqnBDxCvMEXdEXLDjaouZsGbKkeGfmW8IOn8aYi7DV9aaXaEQHCcVxWMvEDOkuTf4y6RMDgIY5CFuUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rushstack/node-core-library": "5.23.1", + "@rushstack/problem-matcher": "0.2.1", + "supports-color": "~8.1.1" + }, + "peerDependencies": { + "@types/node": "*" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@rushstack/ts-command-line": { + "version": "5.3.8", + "resolved": "https://registry.npmjs.org/@rushstack/ts-command-line/-/ts-command-line-5.3.8.tgz", + "integrity": "sha512-vKL4fVR2TdnMdCmlP71nZUzbNANOYlR7NfN+J/YL9UIvicDIEB+ZtH38LcJECOZUwHia8C5Mg98kc4kPt/ev5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rushstack/terminal": "0.23.0", + "@types/argparse": "1.0.38", + "argparse": "~1.0.9", + "string-argv": "~0.3.1" + } + }, + "node_modules/@rushstack/ts-command-line/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/@tamagui/accordion": { + "version": "1.144.3", + "dependencies": { + "@tamagui/collapsible": "1.144.3", + "@tamagui/collection": "1.144.3", + "@tamagui/compose-refs": "1.144.3", + "@tamagui/constants": "1.144.3", + "@tamagui/core": "1.144.3", + "@tamagui/create-context": "1.144.3", + "@tamagui/helpers": "1.144.3", + "@tamagui/polyfill-dev": "1.144.3", + "@tamagui/stacks": "1.144.3", + "@tamagui/text": "1.144.3", + "@tamagui/use-controllable-state": "1.144.3", + "@tamagui/use-direction": "1.144.3" + }, + "peerDependencies": { + "react": "*" + } + }, + "node_modules/@tamagui/adapt": { + "version": "1.144.3", + "dependencies": { + "@tamagui/constants": "1.144.3", + "@tamagui/core": "1.144.3", + "@tamagui/helpers": "1.144.3", + "@tamagui/portal": "1.144.3", + "@tamagui/z-index-stack": "1.144.3" + }, + "peerDependencies": { + "react": "*" + } + }, + "node_modules/@tamagui/alert-dialog": { + "version": "1.144.3", + "dependencies": { + "@tamagui/animate-presence": "1.144.3", + "@tamagui/compose-refs": "1.144.3", + "@tamagui/constants": "1.144.3", + "@tamagui/core": "1.144.3", + "@tamagui/create-context": "1.144.3", + "@tamagui/dialog": "1.144.3", + "@tamagui/dismissable": "1.144.3", + "@tamagui/focus-scope": "1.144.3", + "@tamagui/helpers": "1.144.3", + "@tamagui/polyfill-dev": "1.144.3", + "@tamagui/popper": "1.144.3", + "@tamagui/portal": "1.144.3", + "@tamagui/remove-scroll": "1.144.3", + "@tamagui/stacks": "1.144.3", + "@tamagui/text": "1.144.3", + "@tamagui/use-controllable-state": "1.144.3" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/@tamagui/animate": { + "version": "1.144.3", + "license": "MIT", + "dependencies": { + "@tamagui/animate-presence": "1.144.3" + }, + "peerDependencies": { + "react": "*" + } + }, + "node_modules/@tamagui/animate-presence": { + "version": "1.144.3", + "license": "MIT", + "dependencies": { + "@tamagui/constants": "1.144.3", + "@tamagui/helpers": "1.144.3", + "@tamagui/use-constant": "1.144.3", + "@tamagui/use-force-update": "1.144.3", + "@tamagui/use-presence": "1.144.3", + "@tamagui/web": "1.144.3" + }, + "peerDependencies": { + "react": "*" + } + }, + "node_modules/@tamagui/animations-css": { + "version": "1.144.3", + "license": "MIT", + "dependencies": { + "@tamagui/constants": "1.144.3", + "@tamagui/cubic-bezier-animator": "1.144.3", + "@tamagui/use-presence": "1.144.3", + "@tamagui/web": "1.144.3" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/@tamagui/animations-moti": { + "version": "1.144.3", + "license": "MIT", + "dependencies": { + "@tamagui/core": "1.144.3", + "@tamagui/use-presence": "1.144.3", + "moti": "^0.30.0" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/@tamagui/animations-react-native": { + "version": "1.144.3", + "license": "MIT", + "dependencies": { + "@tamagui/constants": "1.144.3", + "@tamagui/use-presence": "1.144.3", + "@tamagui/web": "1.144.3" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/@tamagui/avatar": { + "version": "1.144.3", + "dependencies": { + "@tamagui/core": "1.144.3", + "@tamagui/create-context": "1.144.3", + "@tamagui/helpers": "1.144.3", + "@tamagui/image": "1.144.3", + "@tamagui/shapes": "1.144.3", + "@tamagui/stacks": "1.144.3", + "@tamagui/text": "1.144.3" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/@tamagui/button": { + "version": "1.144.3", + "dependencies": { + "@tamagui/config-default": "1.144.3", + "@tamagui/core": "1.144.3", + "@tamagui/font-size": "1.144.3", + "@tamagui/get-button-sized": "1.144.3", + "@tamagui/helpers": "1.144.3", + "@tamagui/helpers-tamagui": "1.144.3", + "@tamagui/stacks": "1.144.3", + "@tamagui/text": "1.144.3", + "@tamagui/web": "1.144.3" + }, + "peerDependencies": { + "react": "*" + } + }, + "node_modules/@tamagui/card": { + "version": "1.144.3", + "dependencies": { + "@tamagui/create-context": "1.144.3", + "@tamagui/helpers": "1.144.3", + "@tamagui/stacks": "1.144.3", + "@tamagui/web": "1.144.3" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/@tamagui/checkbox": { + "version": "1.144.3", + "dependencies": { + "@tamagui/checkbox-headless": "1.144.3", + "@tamagui/compose-refs": "1.144.3", + "@tamagui/constants": "1.144.3", + "@tamagui/core": "1.144.3", + "@tamagui/create-context": "1.144.3", + "@tamagui/focusable": "1.144.3", + "@tamagui/font-size": "1.144.3", + "@tamagui/get-token": "1.144.3", + "@tamagui/helpers": "1.144.3", + "@tamagui/helpers-tamagui": "1.144.3", + "@tamagui/label": "1.144.3", + "@tamagui/stacks": "1.144.3", + "@tamagui/use-controllable-state": "1.144.3", + "@tamagui/use-previous": "1.144.3" + }, + "peerDependencies": { + "react": "*" + } + }, + "node_modules/@tamagui/checkbox-headless": { + "version": "1.144.3", + "dependencies": { + "@tamagui/compose-refs": "1.144.3", + "@tamagui/constants": "1.144.3", + "@tamagui/create-context": "1.144.3", + "@tamagui/focusable": "1.144.3", + "@tamagui/helpers": "1.144.3", + "@tamagui/label": "1.144.3", + "@tamagui/use-controllable-state": "1.144.3", + "@tamagui/use-previous": "1.144.3", + "@tamagui/web": "1.144.3" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/@tamagui/cli-color": { + "version": "1.144.3", + "dev": true + }, + "node_modules/@tamagui/collapsible": { + "version": "1.144.3", + "dependencies": { + "@tamagui/animate-presence": "1.144.3", + "@tamagui/compose-refs": "1.144.3", + "@tamagui/core": "1.144.3", + "@tamagui/create-context": "1.144.3", + "@tamagui/helpers": "1.144.3", + "@tamagui/polyfill-dev": "1.144.3", + "@tamagui/stacks": "1.144.3", + "@tamagui/use-controllable-state": "1.144.3", + "@tamagui/web": "1.144.3" + }, + "peerDependencies": { + "react": "*" + } + }, + "node_modules/@tamagui/collection": { + "version": "1.144.3", + "dependencies": { + "@tamagui/compose-refs": "1.144.3", + "@tamagui/constants": "1.144.3", + "@tamagui/core": "1.144.3", + "@tamagui/create-context": "1.144.3", + "@tamagui/polyfill-dev": "1.144.3", + "@tamagui/stacks": "1.144.3", + "@tamagui/use-controllable-state": "1.144.3" + }, + "peerDependencies": { + "react": "*" + } + }, + "node_modules/@tamagui/colors": { + "version": "1.144.3" + }, + "node_modules/@tamagui/compose-refs": { + "version": "1.144.3", + "peerDependencies": { + "react": "*" + } + }, + "node_modules/@tamagui/config": { + "version": "1.144.3", + "dependencies": { + "@tamagui/animations-css": "1.144.3", + "@tamagui/animations-moti": "1.144.3", + "@tamagui/animations-react-native": "1.144.3", + "@tamagui/colors": "1.144.3", + "@tamagui/core": "1.144.3", + "@tamagui/font-inter": "1.144.3", + "@tamagui/font-silkscreen": "1.144.3", + "@tamagui/react-native-media-driver": "1.144.3", + "@tamagui/shorthands": "1.144.3", + "@tamagui/theme-builder": "1.144.3", + "@tamagui/themes": "1.144.3", + "@tamagui/web": "1.144.3" + } + }, + "node_modules/@tamagui/config-default": { + "version": "1.144.3", + "dependencies": { + "@tamagui/animations-css": "1.144.3", + "@tamagui/animations-react-native": "1.144.3", + "@tamagui/core": "1.144.3", + "@tamagui/shorthands": "1.144.3", + "@tamagui/web": "1.144.3" + } + }, + "node_modules/@tamagui/constants": { + "version": "1.144.3", + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/@tamagui/core": { + "version": "1.144.3", + "license": "MIT", + "dependencies": { + "@tamagui/helpers": "1.144.3", + "@tamagui/react-native-media-driver": "1.144.3", + "@tamagui/react-native-use-pressable": "1.144.3", + "@tamagui/react-native-use-responder-events": "1.144.3", + "@tamagui/use-element-layout": "1.144.3", + "@tamagui/use-event": "1.144.3", + "@tamagui/web": "1.144.3" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/@tamagui/create-context": { + "version": "1.144.3", + "peerDependencies": { + "react": "*" + } + }, + "node_modules/@tamagui/create-theme": { + "version": "1.144.3", + "dependencies": { + "@tamagui/web": "1.144.3" + } + }, + "node_modules/@tamagui/cubic-bezier-animator": { + "version": "1.144.3" + }, + "node_modules/@tamagui/dialog": { + "version": "1.144.3", + "dependencies": { + "@tamagui/adapt": "1.144.3", + "@tamagui/animate-presence": "1.144.3", + "@tamagui/compose-refs": "1.144.3", + "@tamagui/constants": "1.144.3", + "@tamagui/core": "1.144.3", + "@tamagui/create-context": "1.144.3", + "@tamagui/dismissable": "1.144.3", + "@tamagui/focus-scope": "1.144.3", + "@tamagui/helpers": "1.144.3", + "@tamagui/polyfill-dev": "1.144.3", + "@tamagui/popper": "1.144.3", + "@tamagui/portal": "1.144.3", + "@tamagui/remove-scroll": "1.144.3", + "@tamagui/sheet": "1.144.3", + "@tamagui/stacks": "1.144.3", + "@tamagui/text": "1.144.3", + "@tamagui/use-controllable-state": "1.144.3", + "@tamagui/z-index-stack": "1.144.3" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/@tamagui/dismissable": { + "version": "1.144.3", + "dependencies": { + "@tamagui/compose-refs": "1.144.3", + "@tamagui/core": "1.144.3", + "@tamagui/helpers": "1.144.3", + "@tamagui/use-escape-keydown": "1.144.3", + "@tamagui/use-event": "1.144.3" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/@tamagui/elements": { + "version": "1.144.3", + "dependencies": { + "@tamagui/core": "1.144.3" + }, + "peerDependencies": { + "react": "*" + } + }, + "node_modules/@tamagui/fake-react-native": { + "version": "1.144.3" + }, + "node_modules/@tamagui/floating": { + "version": "1.144.3", + "dependencies": { + "@floating-ui/react-dom": "^2.1.6", + "@floating-ui/react-native": "^0.10.7" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/@tamagui/focus-scope": { + "version": "1.144.3", + "dependencies": { + "@tamagui/compose-refs": "1.144.3", + "@tamagui/create-context": "1.144.3", + "@tamagui/start-transition": "1.144.3", + "@tamagui/use-async": "1.144.3", + "@tamagui/use-event": "1.144.3" + }, + "peerDependencies": { + "react": "*" + } + }, + "node_modules/@tamagui/focusable": { + "version": "1.144.3", + "license": "MIT", + "dependencies": { + "@tamagui/compose-refs": "1.144.3", + "@tamagui/web": "1.144.3" + }, + "peerDependencies": { + "react": "*" + } + }, + "node_modules/@tamagui/font-inter": { + "version": "1.144.3", + "dependencies": { + "@tamagui/core": "1.144.3" + } + }, + "node_modules/@tamagui/font-silkscreen": { + "version": "1.144.3", + "dependencies": { + "@tamagui/core": "1.144.3" + } + }, + "node_modules/@tamagui/font-size": { + "version": "1.144.3", + "dependencies": { + "@tamagui/core": "1.144.3" + }, + "peerDependencies": { + "react": "*" + } + }, + "node_modules/@tamagui/form": { + "version": "1.144.3", + "dependencies": { + "@tamagui/compose-refs": "1.144.3", + "@tamagui/core": "1.144.3", + "@tamagui/create-context": "1.144.3", + "@tamagui/focusable": "1.144.3", + "@tamagui/get-button-sized": "1.144.3", + "@tamagui/get-font-sized": "1.144.3", + "@tamagui/helpers": "1.144.3", + "@tamagui/text": "1.144.3" + }, + "peerDependencies": { + "react": "*" + } + }, + "node_modules/@tamagui/generate-themes": { + "version": "1.144.3", + "dev": true, + "dependencies": { + "@tamagui/create-theme": "1.144.3", + "@tamagui/theme-builder": "1.144.3", + "@tamagui/types": "1.144.3", + "esbuild-register": "^3.6.0", + "fs-extra": "^11.2.0" + } + }, + "node_modules/@tamagui/get-button-sized": { + "version": "1.144.3", + "dependencies": { + "@tamagui/get-token": "1.144.3", + "@tamagui/web": "1.144.3" + }, + "peerDependencies": { + "react": "*" + } + }, + "node_modules/@tamagui/get-font-sized": { + "version": "1.144.3", + "dependencies": { + "@tamagui/constants": "1.144.3", + "@tamagui/web": "1.144.3" + }, + "peerDependencies": { + "react": "*" + } + }, + "node_modules/@tamagui/get-token": { + "version": "1.144.3", + "dependencies": { + "@tamagui/web": "1.144.3" + }, + "peerDependencies": { + "react": "*" + } + }, + "node_modules/@tamagui/group": { + "version": "1.144.3", + "dependencies": { + "@tamagui/core": "1.144.3", + "@tamagui/create-context": "1.144.3", + "@tamagui/helpers": "1.144.3", + "@tamagui/stacks": "1.144.3", + "@tamagui/use-controllable-state": "1.144.3" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/@tamagui/helpers": { + "version": "1.144.3", + "dependencies": { + "@tamagui/constants": "1.144.3", + "@tamagui/simple-hash": "1.144.3" + }, + "peerDependencies": { + "react": "*" + } + }, + "node_modules/@tamagui/helpers-icon": { + "version": "1.144.3", + "license": "MIT", + "dependencies": { + "@tamagui/core": "1.144.3" + }, + "peerDependencies": { + "react": "*", + "react-native-svg": ">=12" + } + }, + "node_modules/@tamagui/helpers-node": { + "version": "1.144.3", + "dev": true, + "dependencies": { + "@tamagui/types": "1.144.3" + } + }, + "node_modules/@tamagui/helpers-tamagui": { + "version": "1.144.3", + "dependencies": { + "@tamagui/helpers": "1.144.3", + "@tamagui/web": "1.144.3" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/@tamagui/image": { + "version": "1.144.3", + "dependencies": { + "@tamagui/constants": "1.144.3", + "@tamagui/core": "1.144.3" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/@tamagui/is-equal-shallow": { + "version": "1.144.3", + "peerDependencies": { + "react": "*" + } + }, + "node_modules/@tamagui/label": { + "version": "1.144.3", + "dependencies": { + "@tamagui/compose-refs": "1.144.3", + "@tamagui/constants": "1.144.3", + "@tamagui/create-context": "1.144.3", + "@tamagui/focusable": "1.144.3", + "@tamagui/get-button-sized": "1.144.3", + "@tamagui/get-font-sized": "1.144.3", + "@tamagui/text": "1.144.3", + "@tamagui/web": "1.144.3" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/@tamagui/linear-gradient": { + "version": "1.144.3", + "dependencies": { + "@tamagui/core": "1.144.3", + "@tamagui/stacks": "1.144.3" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/@tamagui/list-item": { + "version": "1.144.3", + "dependencies": { + "@tamagui/font-size": "1.144.3", + "@tamagui/get-font-sized": "1.144.3", + "@tamagui/get-token": "1.144.3", + "@tamagui/helpers": "1.144.3", + "@tamagui/helpers-tamagui": "1.144.3", + "@tamagui/stacks": "1.144.3", + "@tamagui/text": "1.144.3", + "@tamagui/web": "1.144.3" + }, + "peerDependencies": { + "react": "*" + } + }, + "node_modules/@tamagui/lucide-icons": { + "version": "1.144.3", + "license": "MIT", + "dependencies": { + "@tamagui/core": "1.144.3", + "@tamagui/helpers-icon": "1.144.3" + }, + "peerDependencies": { + "react": "*", + "react-native-svg": ">=12" + } + }, + "node_modules/@tamagui/normalize-css-color": { + "version": "1.144.3", + "dependencies": { + "@react-native/normalize-color": "^2.1.0" + } + }, + "node_modules/@tamagui/polyfill-dev": { + "version": "1.144.3" + }, + "node_modules/@tamagui/popover": { + "version": "1.144.3", + "dependencies": { + "@floating-ui/react": "^0.27.16", + "@tamagui/adapt": "1.144.3", + "@tamagui/animate": "1.144.3", + "@tamagui/animate-presence": "1.144.3", + "@tamagui/compose-refs": "1.144.3", + "@tamagui/constants": "1.144.3", + "@tamagui/core": "1.144.3", + "@tamagui/dismissable": "1.144.3", + "@tamagui/floating": "1.144.3", + "@tamagui/focus-scope": "1.144.3", + "@tamagui/helpers": "1.144.3", + "@tamagui/polyfill-dev": "1.144.3", + "@tamagui/popper": "1.144.3", + "@tamagui/portal": "1.144.3", + "@tamagui/remove-scroll": "1.144.3", + "@tamagui/scroll-view": "1.144.3", + "@tamagui/sheet": "1.144.3", + "@tamagui/stacks": "1.144.3", + "@tamagui/use-controllable-state": "1.144.3", + "@tamagui/z-index-stack": "1.144.3", + "react-freeze": "^1.0.3" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/@tamagui/popper": { + "version": "1.144.3", + "dependencies": { + "@tamagui/compose-refs": "1.144.3", + "@tamagui/constants": "1.144.3", + "@tamagui/core": "1.144.3", + "@tamagui/floating": "1.144.3", + "@tamagui/get-token": "1.144.3", + "@tamagui/stacks": "1.144.3", + "@tamagui/start-transition": "1.144.3", + "@tamagui/use-controllable-state": "1.144.3" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/@tamagui/portal": { + "version": "1.144.3", + "dependencies": { + "@tamagui/constants": "1.144.3", + "@tamagui/core": "1.144.3", + "@tamagui/start-transition": "1.144.3", + "@tamagui/use-event": "1.144.3", + "@tamagui/web": "1.144.3", + "@tamagui/z-index-stack": "1.144.3" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*", + "react-native": "*" + } + }, + "node_modules/@tamagui/progress": { + "version": "1.144.3", + "dependencies": { + "@tamagui/compose-refs": "1.144.3", + "@tamagui/core": "1.144.3", + "@tamagui/create-context": "1.144.3", + "@tamagui/get-token": "1.144.3", + "@tamagui/helpers": "1.144.3", + "@tamagui/stacks": "1.144.3" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/@tamagui/proxy-worm": { + "version": "1.144.3", + "dev": true + }, + "node_modules/@tamagui/radio-group": { + "version": "1.144.3", + "dependencies": { + "@tamagui/compose-refs": "1.144.3", + "@tamagui/constants": "1.144.3", + "@tamagui/core": "1.144.3", + "@tamagui/create-context": "1.144.3", + "@tamagui/focusable": "1.144.3", + "@tamagui/get-token": "1.144.3", + "@tamagui/helpers": "1.144.3", + "@tamagui/label": "1.144.3", + "@tamagui/radio-headless": "1.144.3", + "@tamagui/roving-focus": "1.144.3", + "@tamagui/stacks": "1.144.3", + "@tamagui/use-controllable-state": "1.144.3", + "@tamagui/use-previous": "1.144.3" + }, + "peerDependencies": { + "react": "*" + } + }, + "node_modules/@tamagui/radio-headless": { + "version": "1.144.3", + "dependencies": { + "@tamagui/compose-refs": "1.144.3", + "@tamagui/constants": "1.144.3", + "@tamagui/create-context": "1.144.3", + "@tamagui/focusable": "1.144.3", + "@tamagui/helpers": "1.144.3", + "@tamagui/label": "1.144.3", + "@tamagui/use-controllable-state": "1.144.3", + "@tamagui/use-previous": "1.144.3", + "@tamagui/web": "1.144.3" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/@tamagui/react-native-media-driver": { + "version": "1.144.3", + "license": "MIT", + "dependencies": { + "@tamagui/web": "1.144.3" + }, + "peerDependencies": { + "react-native": "*" + } + }, + "node_modules/@tamagui/react-native-svg": { + "version": "1.144.3", + "dev": true, + "peerDependencies": { + "react-native-svg": "*" + }, + "peerDependenciesMeta": { + "react-native-svg": { + "optional": true + } + } + }, + "node_modules/@tamagui/react-native-use-pressable": { + "version": "1.144.3", + "peerDependencies": { + "react": "*" + } + }, + "node_modules/@tamagui/react-native-use-responder-events": { + "version": "1.144.3", + "peerDependencies": { + "react": "*" + } + }, + "node_modules/@tamagui/react-native-web-internals": { + "version": "1.144.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@tamagui/normalize-css-color": "1.144.3", + "@tamagui/react-native-use-pressable": "1.144.3", + "@tamagui/react-native-use-responder-events": "1.144.3", + "@tamagui/simple-hash": "1.144.3", + "@tamagui/use-element-layout": "1.144.3", + "@tamagui/web": "1.144.3" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/@tamagui/react-native-web-lite": { + "version": "1.144.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@tamagui/normalize-css-color": "1.144.3", + "@tamagui/react-native-use-pressable": "1.144.3", + "@tamagui/react-native-use-responder-events": "1.144.3", + "@tamagui/react-native-web-internals": "1.144.3", + "@tamagui/web": "1.144.3", + "invariant": "^2.2.4", + "memoize-one": "^6.0.0" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/@tamagui/remove-scroll": { + "version": "1.144.3", + "peerDependencies": { + "react": "*" + } + }, + "node_modules/@tamagui/roving-focus": { + "version": "1.144.3", + "dependencies": { + "@tamagui/collection": "1.144.3", + "@tamagui/compose-refs": "1.144.3", + "@tamagui/constants": "1.144.3", + "@tamagui/core": "1.144.3", + "@tamagui/create-context": "1.144.3", + "@tamagui/helpers": "1.144.3", + "@tamagui/use-controllable-state": "1.144.3", + "@tamagui/use-direction": "1.144.3", + "@tamagui/use-event": "1.144.3" + }, + "peerDependencies": { + "react": "*" + } + }, + "node_modules/@tamagui/scroll-view": { + "version": "1.144.3", + "dependencies": { + "@tamagui/stacks": "1.144.3", + "@tamagui/web": "1.144.3" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/@tamagui/select": { + "version": "1.144.3", + "dependencies": { + "@floating-ui/react": "^0.27.16", + "@floating-ui/react-dom": "^2.1.6", + "@floating-ui/react-native": "^0.10.7", + "@tamagui/adapt": "1.144.3", + "@tamagui/animate-presence": "1.144.3", + "@tamagui/compose-refs": "1.144.3", + "@tamagui/constants": "1.144.3", + "@tamagui/core": "1.144.3", + "@tamagui/create-context": "1.144.3", + "@tamagui/dismissable": "1.144.3", + "@tamagui/focus-scope": "1.144.3", + "@tamagui/focusable": "1.144.3", + "@tamagui/get-token": "1.144.3", + "@tamagui/helpers": "1.144.3", + "@tamagui/list-item": "1.144.3", + "@tamagui/portal": "1.144.3", + "@tamagui/remove-scroll": "1.144.3", + "@tamagui/separator": "1.144.3", + "@tamagui/sheet": "1.144.3", + "@tamagui/stacks": "1.144.3", + "@tamagui/text": "1.144.3", + "@tamagui/use-controllable-state": "1.144.3", + "@tamagui/use-debounce": "1.144.3", + "@tamagui/use-event": "1.144.3", + "@tamagui/use-previous": "1.144.3" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*", + "react-native": "*" + } + }, + "node_modules/@tamagui/separator": { + "version": "1.144.3", + "dependencies": { + "@tamagui/constants": "1.144.3", + "@tamagui/core": "1.144.3" + }, + "peerDependencies": { + "react": "*" + } + }, + "node_modules/@tamagui/shapes": { + "version": "1.144.3", + "dependencies": { + "@tamagui/stacks": "1.144.3", + "@tamagui/web": "1.144.3" + }, + "peerDependencies": { + "react": "*" + } + }, + "node_modules/@tamagui/sheet": { + "version": "1.144.3", + "license": "MIT", + "dependencies": { + "@tamagui/adapt": "1.144.3", + "@tamagui/animate-presence": "1.144.3", + "@tamagui/animations-react-native": "1.144.3", + "@tamagui/compose-refs": "1.144.3", + "@tamagui/constants": "1.144.3", + "@tamagui/core": "1.144.3", + "@tamagui/create-context": "1.144.3", + "@tamagui/helpers": "1.144.3", + "@tamagui/portal": "1.144.3", + "@tamagui/remove-scroll": "1.144.3", + "@tamagui/scroll-view": "1.144.3", + "@tamagui/stacks": "1.144.3", + "@tamagui/use-constant": "1.144.3", + "@tamagui/use-controllable-state": "1.144.3", + "@tamagui/use-did-finish-ssr": "1.144.3", + "@tamagui/use-keyboard-visible": "1.144.3", + "@tamagui/z-index-stack": "1.144.3" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/@tamagui/shorthands": { + "version": "1.144.3", + "dependencies": { + "@tamagui/web": "1.144.3" + } + }, + "node_modules/@tamagui/simple-hash": { + "version": "1.144.3" + }, + "node_modules/@tamagui/slider": { + "version": "1.144.3", + "dependencies": { + "@tamagui/compose-refs": "1.144.3", + "@tamagui/constants": "1.144.3", + "@tamagui/core": "1.144.3", + "@tamagui/create-context": "1.144.3", + "@tamagui/get-token": "1.144.3", + "@tamagui/helpers": "1.144.3", + "@tamagui/stacks": "1.144.3", + "@tamagui/use-controllable-state": "1.144.3", + "@tamagui/use-debounce": "1.144.3", + "@tamagui/use-direction": "1.144.3" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/@tamagui/stacks": { + "version": "1.144.3", + "dependencies": { + "@tamagui/core": "1.144.3", + "@tamagui/get-button-sized": "1.144.3" + }, + "peerDependencies": { + "react": "*" + } + }, + "node_modules/@tamagui/start-transition": { + "version": "1.144.3", + "license": "MIT", + "peerDependencies": { + "react": "*" + } + }, + "node_modules/@tamagui/static": { + "version": "1.144.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.25.2", + "@babel/generator": "^7.25.5", + "@babel/helper-plugin-utils": "^7.24.8", + "@babel/parser": "^7.25.4", + "@babel/plugin-transform-react-jsx": "^7.25.2", + "@babel/runtime": "^7.25.4", + "@babel/template": "^7.25.0", + "@babel/traverse": "^7.25.4", + "@babel/types": "^7.25.4", + "@tamagui/cli-color": "1.144.3", + "@tamagui/config-default": "1.144.3", + "@tamagui/core": "1.144.3", + "@tamagui/fake-react-native": "1.144.3", + "@tamagui/generate-themes": "1.144.3", + "@tamagui/helpers": "1.144.3", + "@tamagui/helpers-node": "1.144.3", + "@tamagui/proxy-worm": "1.144.3", + "@tamagui/react-native-web-internals": "1.144.3", + "@tamagui/react-native-web-lite": "1.144.3", + "@tamagui/shorthands": "1.144.3", + "@tamagui/types": "1.144.3", + "@tamagui/web": "1.144.3", + "babel-literal-to-ast": "^2.1.0", + "browserslist": "^4.22.2", + "check-dependency-version-consistency": "^4.1.0", + "esbuild": "^0.25.11", + "esbuild-register": "^3.6.0", + "fast-glob": "^3.2.11", + "find-cache-dir": "^3.3.2", + "find-root": "^1.1.0", + "fs-extra": "^11.2.0", + "invariant": "^2.2.4", + "js-yaml": "^4.1.0", + "react-native-web": "^0.21.0" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/@tamagui/static-worker": { + "version": "1.144.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@tamagui/static": "1.144.3", + "@tamagui/types": "1.144.3", + "piscina": "^4.7.0" + } + }, + "node_modules/@tamagui/switch": { + "version": "1.144.3", + "dependencies": { + "@tamagui/compose-refs": "1.144.3", + "@tamagui/constants": "1.144.3", + "@tamagui/core": "1.144.3", + "@tamagui/focusable": "1.144.3", + "@tamagui/get-token": "1.144.3", + "@tamagui/helpers": "1.144.3", + "@tamagui/label": "1.144.3", + "@tamagui/stacks": "1.144.3", + "@tamagui/switch-headless": "1.144.3", + "@tamagui/use-controllable-state": "1.144.3", + "@tamagui/use-previous": "1.144.3" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/@tamagui/switch-headless": { + "version": "1.144.3", + "dependencies": { + "@tamagui/compose-refs": "1.144.3", + "@tamagui/constants": "1.144.3", + "@tamagui/helpers": "1.144.3", + "@tamagui/label": "1.144.3", + "@tamagui/use-previous": "1.144.3" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/@tamagui/tabs": { + "version": "1.144.3", + "dependencies": { + "@tamagui/compose-refs": "1.144.3", + "@tamagui/constants": "1.144.3", + "@tamagui/core": "1.144.3", + "@tamagui/create-context": "1.144.3", + "@tamagui/get-button-sized": "1.144.3", + "@tamagui/group": "1.144.3", + "@tamagui/helpers": "1.144.3", + "@tamagui/roving-focus": "1.144.3", + "@tamagui/stacks": "1.144.3", + "@tamagui/use-controllable-state": "1.144.3", + "@tamagui/use-direction": "1.144.3", + "@tamagui/web": "1.144.3" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/@tamagui/text": { + "version": "1.144.3", + "dependencies": { + "@tamagui/get-font-sized": "1.144.3", + "@tamagui/helpers-tamagui": "1.144.3", + "@tamagui/web": "1.144.3" + }, + "peerDependencies": { + "react": "*" + } + }, + "node_modules/@tamagui/theme": { + "version": "1.144.3", + "dependencies": { + "@tamagui/constants": "1.144.3", + "@tamagui/start-transition": "1.144.3", + "@tamagui/web": "1.144.3" + }, + "peerDependencies": { + "react": "*" + } + }, + "node_modules/@tamagui/theme-builder": { + "version": "1.144.3", + "dependencies": { + "@tamagui/create-theme": "1.144.3", + "@tamagui/web": "1.144.3", + "color2k": "^2.0.2" + } + }, + "node_modules/@tamagui/themes": { + "version": "1.144.3", + "dependencies": { + "@tamagui/colors": "1.144.3", + "@tamagui/create-theme": "1.144.3", + "@tamagui/theme-builder": "1.144.3", + "@tamagui/web": "1.144.3", + "color2k": "^2.0.2" + } + }, + "node_modules/@tamagui/timer": { + "version": "1.144.3" + }, + "node_modules/@tamagui/toggle-group": { + "version": "1.144.3", + "dependencies": { + "@tamagui/constants": "1.144.3", + "@tamagui/create-context": "1.144.3", + "@tamagui/focusable": "1.144.3", + "@tamagui/font-size": "1.144.3", + "@tamagui/get-token": "1.144.3", + "@tamagui/group": "1.144.3", + "@tamagui/helpers": "1.144.3", + "@tamagui/helpers-tamagui": "1.144.3", + "@tamagui/roving-focus": "1.144.3", + "@tamagui/stacks": "1.144.3", + "@tamagui/use-controllable-state": "1.144.3", + "@tamagui/use-direction": "1.144.3", + "@tamagui/web": "1.144.3" + }, + "peerDependencies": { + "react": "*" + } + }, + "node_modules/@tamagui/tooltip": { + "version": "1.144.3", + "dependencies": { + "@floating-ui/react": "^0.27.16", + "@tamagui/compose-refs": "1.144.3", + "@tamagui/core": "1.144.3", + "@tamagui/create-context": "1.144.3", + "@tamagui/floating": "1.144.3", + "@tamagui/get-token": "1.144.3", + "@tamagui/helpers": "1.144.3", + "@tamagui/polyfill-dev": "1.144.3", + "@tamagui/popover": "1.144.3", + "@tamagui/popper": "1.144.3", + "@tamagui/stacks": "1.144.3", + "@tamagui/text": "1.144.3", + "@tamagui/use-controllable-state": "1.144.3" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/@tamagui/types": { + "version": "1.144.3" + }, + "node_modules/@tamagui/use-async": { + "version": "1.144.3", + "peerDependencies": { + "react": "*" + } + }, + "node_modules/@tamagui/use-callback-ref": { + "version": "1.144.3", + "peerDependencies": { + "react": "*" + } + }, + "node_modules/@tamagui/use-constant": { + "version": "1.144.3", + "peerDependencies": { + "react": "*" + } + }, + "node_modules/@tamagui/use-controllable-state": { + "version": "1.144.3", + "dependencies": { + "@tamagui/start-transition": "1.144.3", + "@tamagui/use-event": "1.144.3" + }, + "peerDependencies": { + "react": "*" + } + }, + "node_modules/@tamagui/use-debounce": { + "version": "1.144.3", + "peerDependencies": { + "react": "*" + } + }, + "node_modules/@tamagui/use-did-finish-ssr": { + "version": "1.144.3", + "peerDependencies": { + "react": "*" + } + }, + "node_modules/@tamagui/use-direction": { + "version": "1.144.3", + "peerDependencies": { + "react": "*" + } + }, + "node_modules/@tamagui/use-element-layout": { + "version": "1.144.3", + "dependencies": { + "@tamagui/constants": "1.144.3", + "@tamagui/is-equal-shallow": "1.144.3" + }, + "peerDependencies": { + "react": "*" + } + }, + "node_modules/@tamagui/use-escape-keydown": { + "version": "1.144.3", + "dependencies": { + "@tamagui/use-callback-ref": "1.144.3" + }, + "peerDependencies": { + "react": "*" + } + }, + "node_modules/@tamagui/use-event": { + "version": "1.144.3", + "dependencies": { + "@tamagui/constants": "1.144.3" + }, + "peerDependencies": { + "react": "*" + } + }, + "node_modules/@tamagui/use-force-update": { + "version": "1.144.3", + "peerDependencies": { + "react": "*" + } + }, + "node_modules/@tamagui/use-keyboard-visible": { + "version": "1.144.3", + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/@tamagui/use-presence": { + "version": "1.144.3", + "dependencies": { + "@tamagui/web": "1.144.3" + }, + "peerDependencies": { + "react": "*" + } + }, + "node_modules/@tamagui/use-previous": { + "version": "1.144.3", + "peerDependencies": { + "react": "*" + } + }, + "node_modules/@tamagui/use-window-dimensions": { + "version": "1.144.3", + "dependencies": { + "@tamagui/constants": "1.144.3" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/@tamagui/visually-hidden": { + "version": "1.144.3", + "dependencies": { + "@tamagui/web": "1.144.3" + }, + "peerDependencies": { + "react": "*" + } + }, + "node_modules/@tamagui/vite-plugin": { + "version": "1.144.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@tamagui/fake-react-native": "1.144.3", + "@tamagui/proxy-worm": "1.144.3", + "@tamagui/react-native-svg": "1.144.3", + "@tamagui/react-native-web-lite": "1.144.3", + "@tamagui/static-worker": "1.144.3", + "@tamagui/types": "1.144.3", + "esm-resolve": "^1.0.8", + "fs-extra": "^11.2.0", + "outdent": "^0.8.0", + "react-native-web": "^0.21.0" + }, + "peerDependencies": { + "vite": "*" + } + }, + "node_modules/@tamagui/web": { + "version": "1.144.3", + "license": "MIT", + "dependencies": { + "@tamagui/compose-refs": "1.144.3", + "@tamagui/constants": "1.144.3", + "@tamagui/helpers": "1.144.3", + "@tamagui/is-equal-shallow": "1.144.3", + "@tamagui/normalize-css-color": "1.144.3", + "@tamagui/timer": "1.144.3", + "@tamagui/types": "1.144.3", + "@tamagui/use-did-finish-ssr": "1.144.3", + "@tamagui/use-event": "1.144.3", + "@tamagui/use-force-update": "1.144.3" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*", + "react-native": "*" + } + }, + "node_modules/@tamagui/z-index-stack": { + "version": "1.144.3", + "peerDependencies": { + "react": "*" + } + }, + "node_modules/@types/argparse": { + "version": "1.0.38", + "resolved": "https://registry.npmjs.org/@types/argparse/-/argparse-1.0.38.tgz", + "integrity": "sha512-ebDJ9b0e702Yr7pWgB0jzm+CX4Srzz8RcXtLJDJB+BSccqMa36uyH/zUsSYao5+BD1ytv3k3rPYCq4mAE1hsXA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/js-yaml": { + "version": "4.0.9", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@volar/language-core": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.28.tgz", + "integrity": "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "2.4.28" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.28.tgz", + "integrity": "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@volar/typescript": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.28.tgz", + "integrity": "sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.28", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.32.tgz", + "integrity": "sha512-4x74Tbtqnda8s/NSD6e1Dr5p1c8HdMU5RWSjMSUzb8RTcUQqevDCxVAitcLBKT+ie3o0Dl9crc/S/opJM7qBGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.2", + "@vue/shared": "3.5.32", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-core/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.32.tgz", + "integrity": "sha512-ybHAu70NtiEI1fvAUz3oXZqkUYEe5J98GjMDpTGl5iHb0T15wQYLR4wE3h9xfuTNA+Cm2f4czfe8B4s+CCH57Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.32", + "@vue/shared": "3.5.32" + } + }, + "node_modules/@vue/compiler-vue2": { + "version": "2.7.16", + "resolved": "https://registry.npmjs.org/@vue/compiler-vue2/-/compiler-vue2-2.7.16.tgz", + "integrity": "sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==", + "dev": true, + "license": "MIT", + "dependencies": { + "de-indent": "^1.0.2", + "he": "^1.2.0" + } + }, + "node_modules/@vue/language-core": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-2.1.6.tgz", + "integrity": "sha512-MW569cSky9R/ooKMh6xa2g1D0AtRKbL56k83dzus/bx//RDJk24RHWkMzbAlXjMdDNyxAaagKPRquBIxkxlCkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "~2.4.1", + "@vue/compiler-dom": "^3.4.0", + "@vue/compiler-vue2": "^2.7.16", + "@vue/shared": "^3.4.0", + "computeds": "^0.0.1", + "minimatch": "^9.0.3", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@vue/language-core/node_modules/brace-expansion": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@vue/language-core/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.32.tgz", + "integrity": "sha512-ksNyrmRQzWJJ8n3cRDuSF7zNNontuJg1YHnmWRJd2AMu8Ij2bqwiiri2lH5rHtYPZjj4STkNcgcmiQqlOjiYGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-draft-04": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ajv-draft-04/-/ajv-draft-04-1.0.0.tgz", + "integrity": "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "ajv": "^8.5.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/asap": { + "version": "2.0.6", + "dev": true, + "license": "MIT" + }, + "node_modules/astral-regex": { + "version": "2.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-literal-to-ast": { + "version": "2.1.0", + "dev": true, + "license": "CC0-1.0", + "dependencies": { + "@babel/parser": "^7.1.6", + "@babel/traverse": "^7.1.6", + "@babel/types": "^7.1.6" + }, + "peerDependencies": { + "@babel/core": "^7.1.2" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.9.16", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001765", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "5.6.2", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/check-dependency-version-consistency": { + "version": "4.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/js-yaml": "^4.0.5", + "chalk": "^5.2.0", + "commander": "^11.0.0", + "edit-json-file": "^1.7.0", + "globby": "^13.1.4", + "js-yaml": "^4.1.0", + "semver": "^7.5.1", + "table": "^6.8.1", + "type-fest": "^4.30.0" + }, + "bin": { + "check-dependency-version-consistency": "dist/bin/check-dependency-version-consistency.js" + }, + "engines": { + "node": "^16.0.0 || ^18.0.0 || >=20.0.0" + } + }, + "node_modules/check-dependency-version-consistency/node_modules/semver": { + "version": "7.7.3", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "dev": true, + "license": "MIT" + }, + "node_modules/color2k": { + "version": "2.0.3", + "license": "MIT" + }, + "node_modules/commander": { + "version": "11.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/commondir": { + "version": "1.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/compare-versions": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/compare-versions/-/compare-versions-6.1.1.tgz", + "integrity": "sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg==", + "dev": true, + "license": "MIT" + }, + "node_modules/computeds": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/computeds/-/computeds-0.0.1.tgz", + "integrity": "sha512-7CEBgcMjVmitjYo5q8JTJVra6X5mQ20uTThdK+0kR7UEaDrAWEQcRiBtWJzga4eRpP6afNwwLsX2SET2JhVB1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-fetch": { + "version": "3.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "node-fetch": "^2.7.0" + } + }, + "node_modules/css-in-js-utils": { + "version": "3.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "hyphenate-style-name": "^1.0.3" + } + }, + "node_modules/de-indent": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz", + "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/diff": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", + "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/dir-glob": { + "version": "3.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/edit-json-file": { + "version": "1.8.1", + "dev": true, + "license": "MIT", + "dependencies": { + "find-value": "^1.0.12", + "iterate-object": "^1.3.4", + "r-json": "^1.2.10", + "set-value": "^4.1.0", + "w-json": "^1.3.10" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.267", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/esbuild-register": { + "version": "3.6.0", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.4" + }, + "peerDependencies": { + "esbuild": ">=0.12 <1" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/esm-resolve": { + "version": "1.0.11", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastq": { + "version": "1.20.1", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fbjs": { + "version": "3.0.5", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-fetch": "^3.1.5", + "fbjs-css-vars": "^1.0.0", + "loose-envify": "^1.0.0", + "object-assign": "^4.1.0", + "promise": "^7.1.1", + "setimmediate": "^1.0.5", + "ua-parser-js": "^1.0.35" + } + }, + "node_modules/fbjs-css-vars": { + "version": "1.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/fill-range": { + "version": "7.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-cache-dir": { + "version": "3.3.2", + "dev": true, + "license": "MIT", + "dependencies": { + "commondir": "^1.0.1", + "make-dir": "^3.0.2", + "pkg-dir": "^4.1.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/avajs/find-cache-dir?sponsor=1" + } + }, + "node_modules/find-root": { + "version": "1.1.0", + "dev": true, + "license": "MIT" + }, + "node_modules/find-up": { + "version": "4.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-value": { + "version": "1.0.13", + "dev": true, + "license": "MIT" + }, + "node_modules/framer-motion": { + "version": "6.5.1", + "license": "MIT", + "dependencies": { + "@motionone/dom": "10.12.0", + "framesync": "6.0.1", + "hey-listen": "^1.0.8", + "popmotion": "11.0.3", + "style-value-types": "5.0.0", + "tslib": "^2.1.0" + }, + "optionalDependencies": { + "@emotion/is-prop-valid": "^0.8.2" + }, + "peerDependencies": { + "react": ">=16.8 || ^17.0.0 || ^18.0.0", + "react-dom": ">=16.8 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/framesync": { + "version": "6.0.1", + "license": "MIT", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/fs-extra": { + "version": "11.3.3", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/globby": { + "version": "13.2.2", + "dev": true, + "license": "MIT", + "dependencies": { + "dir-glob": "^3.0.1", + "fast-glob": "^3.3.0", + "ignore": "^5.2.4", + "merge2": "^1.4.1", + "slash": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "dev": true, + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/hey-listen": { + "version": "1.0.8", + "license": "MIT" + }, + "node_modules/hyphenate-style-name": { + "version": "1.1.0", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/ignore": { + "version": "5.3.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-lazy": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/import-lazy/-/import-lazy-4.0.0.tgz", + "integrity": "sha512-rKtvo6a868b5Hu3heneU+L4yEQ4jYKLtjpnPeUdK7h0yzXGmyBTypknlkCvHFBqfX9YlorEiMM6Dnq/5atfHkw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/inline-style-prefixer": { + "version": "7.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "css-in-js-utils": "^3.1.0" + } + }, + "node_modules/invariant": { + "version": "2.2.4", + "dev": true, + "license": "MIT", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-plain-object": { + "version": "2.0.4", + "dev": true, + "license": "MIT", + "dependencies": { + "isobject": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-primitive": { + "version": "3.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isobject": { + "version": "3.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/iterate-object": { + "version": "1.3.5", + "dev": true, + "license": "MIT" + }, + "node_modules/jju": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/jju/-/jju-1.4.0.tgz", + "integrity": "sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonfile": { + "version": "6.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/kolorist": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/kolorist/-/kolorist-1.8.0.tgz", + "integrity": "sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/local-pkg": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.5.1.tgz", + "integrity": "sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mlly": "^1.7.3", + "pkg-types": "^1.2.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/locate-path": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/lodash.truncate": { + "version": "4.4.2", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/make-dir": { + "version": "3.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^6.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/memoize-one": { + "version": "6.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/merge2": { + "version": "1.4.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mlly": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", + "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.16.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.3" + } + }, + "node_modules/moti": { + "version": "0.30.0", + "license": "MIT", + "dependencies": { + "framer-motion": "^6.5.1" + }, + "peerDependencies": { + "react-native-reanimated": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "dev": true, + "license": "MIT" + }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-releases": { + "version": "2.0.27", + "dev": true, + "license": "MIT" + }, + "node_modules/nullthrows": { + "version": "1.1.1", + "dev": true, + "license": "MIT" + }, + "node_modules/object-assign": { + "version": "4.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/outdent": { + "version": "0.8.0", + "dev": true, + "license": "MIT" + }, + "node_modules/p-limit": { + "version": "2.3.0", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-exists": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-type": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/piscina": { + "version": "4.9.2", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "@napi-rs/nice": "^1.0.1" + } + }, + "node_modules/pkg-dir": { + "version": "4.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/popmotion": { + "version": "11.0.3", + "license": "MIT", + "dependencies": { + "framesync": "6.0.1", + "hey-listen": "^1.0.8", + "style-value-types": "5.0.0", + "tslib": "^2.1.0" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "dev": true, + "license": "MIT" + }, + "node_modules/promise": { + "version": "7.3.1", + "dev": true, + "license": "MIT", + "dependencies": { + "asap": "~2.0.3" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/r-json": { + "version": "1.3.1", + "dev": true, + "license": "MIT", + "dependencies": { + "w-json": "1.3.10" + } + }, + "node_modules/r-json/node_modules/w-json": { + "version": "1.3.10", + "dev": true, + "license": "MIT" + }, + "node_modules/react": { + "version": "18.3.1", + "dev": true, + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "dev": true, + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-freeze": { + "version": "1.0.4", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": ">=17.0.0" + } + }, + "node_modules/react-native-web": { + "version": "0.21.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.6", + "@react-native/normalize-colors": "^0.74.1", + "fbjs": "^3.0.4", + "inline-style-prefixer": "^7.0.1", + "memoize-one": "^6.0.0", + "nullthrows": "^1.1.1", + "postcss-value-parser": "^4.2.0", + "styleq": "^0.1.3" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.55.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.55.2", + "@rollup/rollup-android-arm64": "4.55.2", + "@rollup/rollup-darwin-arm64": "4.55.2", + "@rollup/rollup-darwin-x64": "4.55.2", + "@rollup/rollup-freebsd-arm64": "4.55.2", + "@rollup/rollup-freebsd-x64": "4.55.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.55.2", + "@rollup/rollup-linux-arm-musleabihf": "4.55.2", + "@rollup/rollup-linux-arm64-gnu": "4.55.2", + "@rollup/rollup-linux-arm64-musl": "4.55.2", + "@rollup/rollup-linux-loong64-gnu": "4.55.2", + "@rollup/rollup-linux-loong64-musl": "4.55.2", + "@rollup/rollup-linux-ppc64-gnu": "4.55.2", + "@rollup/rollup-linux-ppc64-musl": "4.55.2", + "@rollup/rollup-linux-riscv64-gnu": "4.55.2", + "@rollup/rollup-linux-riscv64-musl": "4.55.2", + "@rollup/rollup-linux-s390x-gnu": "4.55.2", + "@rollup/rollup-linux-x64-gnu": "4.55.2", + "@rollup/rollup-linux-x64-musl": "4.55.2", + "@rollup/rollup-openbsd-x64": "4.55.2", + "@rollup/rollup-openharmony-arm64": "4.55.2", + "@rollup/rollup-win32-arm64-msvc": "4.55.2", + "@rollup/rollup-win32-ia32-msvc": "4.55.2", + "@rollup/rollup-win32-x64-gnu": "4.55.2", + "@rollup/rollup-win32-x64-msvc": "4.55.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "dev": true, + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-value": { + "version": "4.1.0", + "dev": true, + "funding": [ + "https://github.com/sponsors/jonschlinkert", + "https://paypal.me/jonathanschlinkert", + "https://jonschlinkert.dev/sponsor" + ], + "license": "MIT", + "dependencies": { + "is-plain-object": "^2.0.4", + "is-primitive": "^3.0.1" + }, + "engines": { + "node": ">=11.0" + } + }, + "node_modules/setimmediate": { + "version": "1.0.5", + "dev": true, + "license": "MIT" + }, + "node_modules/slash": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/slice-ansi": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/string-argv": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", + "integrity": "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6.19" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/style-value-types": { + "version": "5.0.0", + "license": "MIT", + "dependencies": { + "hey-listen": "^1.0.8", + "tslib": "^2.1.0" + } + }, + "node_modules/styleq": { + "version": "0.1.3", + "dev": true, + "license": "MIT" + }, + "node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tabbable": { + "version": "6.4.0", + "license": "MIT" + }, + "node_modules/table": { + "version": "6.9.0", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "ajv": "^8.0.1", + "lodash.truncate": "^4.4.2", + "slice-ansi": "^4.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/tamagui": { + "version": "1.144.3", + "dependencies": { + "@tamagui/accordion": "1.144.3", + "@tamagui/adapt": "1.144.3", + "@tamagui/alert-dialog": "1.144.3", + "@tamagui/animate-presence": "1.144.3", + "@tamagui/avatar": "1.144.3", + "@tamagui/button": "1.144.3", + "@tamagui/card": "1.144.3", + "@tamagui/checkbox": "1.144.3", + "@tamagui/compose-refs": "1.144.3", + "@tamagui/constants": "1.144.3", + "@tamagui/core": "1.144.3", + "@tamagui/create-context": "1.144.3", + "@tamagui/dialog": "1.144.3", + "@tamagui/elements": "1.144.3", + "@tamagui/fake-react-native": "1.144.3", + "@tamagui/focusable": "1.144.3", + "@tamagui/font-size": "1.144.3", + "@tamagui/form": "1.144.3", + "@tamagui/get-button-sized": "1.144.3", + "@tamagui/get-font-sized": "1.144.3", + "@tamagui/get-token": "1.144.3", + "@tamagui/group": "1.144.3", + "@tamagui/helpers-tamagui": "1.144.3", + "@tamagui/image": "1.144.3", + "@tamagui/label": "1.144.3", + "@tamagui/linear-gradient": "1.144.3", + "@tamagui/list-item": "1.144.3", + "@tamagui/polyfill-dev": "1.144.3", + "@tamagui/popover": "1.144.3", + "@tamagui/popper": "1.144.3", + "@tamagui/portal": "1.144.3", + "@tamagui/progress": "1.144.3", + "@tamagui/radio-group": "1.144.3", + "@tamagui/react-native-media-driver": "1.144.3", + "@tamagui/scroll-view": "1.144.3", + "@tamagui/select": "1.144.3", + "@tamagui/separator": "1.144.3", + "@tamagui/shapes": "1.144.3", + "@tamagui/sheet": "1.144.3", + "@tamagui/slider": "1.144.3", + "@tamagui/stacks": "1.144.3", + "@tamagui/switch": "1.144.3", + "@tamagui/tabs": "1.144.3", + "@tamagui/text": "1.144.3", + "@tamagui/theme": "1.144.3", + "@tamagui/toggle-group": "1.144.3", + "@tamagui/tooltip": "1.144.3", + "@tamagui/use-controllable-state": "1.144.3", + "@tamagui/use-debounce": "1.144.3", + "@tamagui/use-force-update": "1.144.3", + "@tamagui/use-window-dimensions": "1.144.3", + "@tamagui/visually-hidden": "1.144.3", + "@tamagui/z-index-stack": "1.144.3" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "dev": true, + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "license": "0BSD" + }, + "node_modules/type-fest": { + "version": "4.41.0", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ua-parser-js": { + "version": "1.0.41", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/ua-parser-js" + }, + { + "type": "paypal", + "url": "https://paypal.me/faisalman" + }, + { + "type": "github", + "url": "https://github.com/sponsors/faisalman" + } + ], + "license": "MIT", + "bin": { + "ua-parser-js": "script/cli.js" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ufo": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz", + "integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/universalify": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-plugin-dts": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/vite-plugin-dts/-/vite-plugin-dts-4.3.0.tgz", + "integrity": "sha512-LkBJh9IbLwL6/rxh0C1/bOurDrIEmRE7joC+jFdOEEciAFPbpEKOLSAr5nNh5R7CJ45cMbksTrFfy52szzC5eA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@microsoft/api-extractor": "^7.47.11", + "@rollup/pluginutils": "^5.1.0", + "@volar/typescript": "^2.4.4", + "@vue/language-core": "2.1.6", + "compare-versions": "^6.1.1", + "debug": "^4.3.6", + "kolorist": "^1.8.0", + "local-pkg": "^0.5.0", + "magic-string": "^0.30.11" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "typescript": "*", + "vite": "*" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/esbuild": { + "version": "0.21.5", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/w-json": { + "version": "1.3.11", + "dev": true, + "license": "MIT" + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "dev": true, + "license": "ISC" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..e4b4cf5 --- /dev/null +++ b/package.json @@ -0,0 +1,52 @@ +{ + "name": "@reliancy/bface", + "version": "1.0.0", + "type": "module", + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + }, + "./platform/*": { + "import": "./dist/platform/*.js", + "types": "./dist/platform/*.d.ts" + }, + "./ui/*": { + "import": "./dist/ui/*.js", + "types": "./dist/ui/*.d.ts" + } + }, + "files": [ + "dist" + ], + "publishConfig": { + "registry": "https://repo.reliancy.com/repository/npm-private/" + }, + "scripts": { + "build": "vite build", + "dev": "vite build --watch", + "test": "node --localstorage-file=.node-localstorage --test test/**/*.test.js", + "test:watch": "node --localstorage-file=.node-localstorage --test --watch test/**/*.test.js" + }, + "dependencies": { + "@tamagui/config": "^1.144.2", + "@tamagui/core": "^1.144.2", + "@tamagui/lucide-icons": "^1.144.2", + "tamagui": "^1.144.2" + }, + "devDependencies": { + "@tamagui/vite-plugin": "^1.144.2", + "@vitejs/plugin-react": "^4.2.1", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "vite": "^5.0.8", + "vite-plugin-dts": "^4.3.0" + }, + "peerDependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" + } +} diff --git a/src/data/DataModel.js b/src/data/DataModel.js new file mode 100644 index 0000000..b81ae50 --- /dev/null +++ b/src/data/DataModel.js @@ -0,0 +1,62 @@ +export class DataModel { + constructor(options = {}) { + this.idField = options.idField || 'id'; + this.listeners = new Set(); + } + + subscribe(listener) { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + notifyChange(payload = {}) { + this.listeners.forEach((listener) => { + try { + listener(payload); + } catch (error) { + console.warn('[DataModel] Listener failed:', error); + } + }); + } + + getIdField() { + return this.idField; + } + + normalizeQuery(query = {}) { + return { + page: Math.max(1, Number(query.page) || 1), + pageSize: Math.max(1, Number(query.pageSize) || 10), + search: typeof query.search === 'string' ? query.search.trim() : '', + orderBy: query.orderBy || '', + order: query.order === 'desc' ? 'desc' : 'asc', + filters: query.filters && typeof query.filters === 'object' ? query.filters : {} + }; + } + + async queryRecords(_query = {}) { + throw new Error('queryRecords() not implemented'); + } + + async querySummary(_query = {}, _summaryDefinitions = []) { + throw new Error('querySummary() not implemented'); + } + + async getRecord(_id) { + throw new Error('getRecord() not implemented'); + } + + async createRecord(_values) { + throw new Error('createRecord() not implemented'); + } + + async updateRecord(_id, _patch) { + throw new Error('updateRecord() not implemented'); + } + + async deleteRecord(_id) { + throw new Error('deleteRecord() not implemented'); + } +} + +export default DataModel; diff --git a/src/data/InMemoryDataModel.js b/src/data/InMemoryDataModel.js new file mode 100644 index 0000000..ba251d0 --- /dev/null +++ b/src/data/InMemoryDataModel.js @@ -0,0 +1,220 @@ +import { DataModel } from './DataModel.js'; + +function clone(value) { + return JSON.parse(JSON.stringify(value)); +} + +function normalizeString(value) { + if (value === null || value === undefined) { + return ''; + } + return String(value).toLowerCase(); +} + +function getValueByPath(record, path) { + if (!path) { + return undefined; + } + + return path.split('.').reduce((current, key) => current?.[key], record); +} + +function compareValues(left, right) { + if (left === right) return 0; + if (left === null || left === undefined) return -1; + if (right === null || right === undefined) return 1; + if (typeof left === 'number' && typeof right === 'number') { + return left - right; + } + return String(left).localeCompare(String(right), undefined, { numeric: true, sensitivity: 'base' }); +} + +export class InMemoryDataModel extends DataModel { + constructor(options = {}) { + super(options); + this.records = Array.isArray(options.records) ? clone(options.records) : []; + this.searchFields = Array.isArray(options.searchFields) ? options.searchFields : []; + this.defaultSummaryDefinitions = Array.isArray(options.summaryDefinitions) ? options.summaryDefinitions : []; + this.nextId = options.nextId || this.computeNextId(); + } + + computeNextId() { + const idField = this.getIdField(); + const numericIds = this.records + .map((record) => Number(record?.[idField])) + .filter((value) => Number.isFinite(value)); + + if (numericIds.length === 0) { + return 1; + } + + return Math.max(...numericIds) + 1; + } + + setRecords(records = []) { + this.records = Array.isArray(records) ? clone(records) : []; + this.nextId = this.computeNextId(); + this.notifyChange({ type: 'reset' }); + } + + getAllRecords() { + return clone(this.records); + } + + applyFilters(records, filters = {}) { + const entries = Object.entries(filters).filter(([, value]) => value !== undefined && value !== null && value !== ''); + if (entries.length === 0) { + return records; + } + + return records.filter((record) => { + return entries.every(([field, expected]) => { + const actual = getValueByPath(record, field); + if (Array.isArray(expected)) { + return expected.includes(actual); + } + return actual === expected; + }); + }); + } + + applySearch(records, search = '') { + const normalizedSearch = normalizeString(search).trim(); + if (!normalizedSearch) { + return records; + } + + const fields = this.searchFields.length > 0 + ? this.searchFields + : Array.from(new Set(records.flatMap((record) => Object.keys(record || {})))); + + return records.filter((record) => { + return fields.some((field) => normalizeString(getValueByPath(record, field)).includes(normalizedSearch)); + }); + } + + applySort(records, orderBy = '', order = 'asc') { + if (!orderBy) { + return records; + } + + const sorted = [...records].sort((left, right) => compareValues(getValueByPath(left, orderBy), getValueByPath(right, orderBy))); + return order === 'desc' ? sorted.reverse() : sorted; + } + + paginate(records, page = 1, pageSize = 10) { + const startIndex = (page - 1) * pageSize; + return records.slice(startIndex, startIndex + pageSize); + } + + summarize(records, definitions = []) { + const summaryDefinitions = definitions.length > 0 ? definitions : this.defaultSummaryDefinitions; + const items = summaryDefinitions.map((definition) => { + const type = definition.type || 'count'; + const field = definition.field || ''; + let value = 0; + + if (type === 'sum') { + value = records.reduce((total, record) => total + (Number(getValueByPath(record, field)) || 0), 0); + } else if (type === 'avg') { + value = records.length > 0 + ? records.reduce((total, record) => total + (Number(getValueByPath(record, field)) || 0), 0) / records.length + : 0; + } else if (type === 'countBy') { + value = records.reduce((accumulator, record) => { + const key = getValueByPath(record, field) ?? 'unknown'; + accumulator[key] = (accumulator[key] || 0) + 1; + return accumulator; + }, {}); + } else { + value = records.length; + } + + return { + ...definition, + value + }; + }); + + return { + totalRecords: records.length, + items + }; + } + + buildFilteredRecords(query = {}) { + const normalized = this.normalizeQuery(query); + const filtered = this.applySearch(this.applyFilters([...this.records], normalized.filters), normalized.search); + const sorted = this.applySort(filtered, normalized.orderBy, normalized.order); + + return { + query: normalized, + filteredRecords: sorted + }; + } + + async queryRecords(query = {}) { + const { query: normalized, filteredRecords } = this.buildFilteredRecords(query); + return { + records: clone(this.paginate(filteredRecords, normalized.page, normalized.pageSize)), + totalRecords: filteredRecords.length, + page: normalized.page, + pageSize: normalized.pageSize + }; + } + + async querySummary(query = {}, summaryDefinitions = []) { + const { filteredRecords, query: normalized } = this.buildFilteredRecords(query); + return { + query: normalized, + ...this.summarize(filteredRecords, summaryDefinitions) + }; + } + + async getRecord(id) { + const idField = this.getIdField(); + const record = this.records.find((item) => String(item?.[idField]) === String(id)); + return record ? clone(record) : null; + } + + async createRecord(values = {}) { + const idField = this.getIdField(); + const record = { + ...clone(values), + [idField]: values?.[idField] ?? this.nextId++ + }; + this.records.unshift(record); + this.notifyChange({ type: 'create', record: clone(record) }); + return clone(record); + } + + async updateRecord(id, patch = {}) { + const idField = this.getIdField(); + const recordIndex = this.records.findIndex((item) => String(item?.[idField]) === String(id)); + if (recordIndex < 0) { + throw new Error(`Record not found: ${id}`); + } + + this.records[recordIndex] = { + ...this.records[recordIndex], + ...clone(patch), + [idField]: this.records[recordIndex][idField] + }; + this.notifyChange({ type: 'update', record: clone(this.records[recordIndex]) }); + return clone(this.records[recordIndex]); + } + + async deleteRecord(id) { + const idField = this.getIdField(); + const beforeLength = this.records.length; + this.records = this.records.filter((item) => String(item?.[idField]) !== String(id)); + if (this.records.length === beforeLength) { + throw new Error(`Record not found: ${id}`); + } + + this.notifyChange({ type: 'delete', id }); + return true; + } +} + +export default InMemoryDataModel; diff --git a/src/data/index.js b/src/data/index.js new file mode 100644 index 0000000..73f9160 --- /dev/null +++ b/src/data/index.js @@ -0,0 +1,2 @@ +export { DataModel, default as DataModelDefault } from './DataModel.js'; +export { InMemoryDataModel, default as InMemoryDataModelDefault } from './InMemoryDataModel.js'; diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..49603a9 --- /dev/null +++ b/src/index.js @@ -0,0 +1,22 @@ +/** + * BUI Library Entry Point + * Base UI and Platform library + */ + +// Re-export platform modules +export * from './platform/api.js'; +export * from './platform/compat.js'; +export * from './platform/env.js'; +export * from './platform/menu.js'; +export * from './platform/storage.js'; +export * from './platform/sw-register.js'; +export * from './data/index.js'; + +// Re-export UI components +export * from './ui/App.jsx'; +export * from './ui/components/index.js'; +export * from './ui/runtime/general-settings.js'; + +// Re-export security modules +export * from './security/index.js'; + diff --git a/src/platform/api.js b/src/platform/api.js new file mode 100644 index 0000000..a6befe6 --- /dev/null +++ b/src/platform/api.js @@ -0,0 +1,133 @@ +/** + * API Client + * Fetch wrappers for /api/* endpoints with offline support + */ + +class APIClient { + constructor(baseURL = '/api') { + this.baseURL = baseURL; + this.interceptors = { + request: [], + response: [] + }; + } + + /** + * Add request interceptor + * @param {Function} interceptor - (config) => config + */ + addRequestInterceptor(interceptor) { + this.interceptors.request.push(interceptor); + } + + /** + * Add response interceptor + * @param {Function} interceptor - (response) => response + */ + addResponseInterceptor(interceptor) { + this.interceptors.response.push(interceptor); + } + + /** + * Execute request interceptors + * @param {RequestInit} config + * @returns {RequestInit} + */ + _applyRequestInterceptors(config) { + return this.interceptors.request.reduce( + (acc, interceptor) => interceptor(acc), + config + ); + } + + /** + * Execute response interceptors + * @param {Response} response + * @returns {Response} + */ + _applyResponseInterceptors(response) { + return this.interceptors.response.reduce( + (acc, interceptor) => interceptor(acc), + response + ); + } + + /** + * Make API request + * @param {string} endpoint + * @param {RequestInit} options + * @returns {Promise} + */ + async request(endpoint, options = {}) { + const url = `${this.baseURL}${endpoint}`; + const config = this._applyRequestInterceptors({ + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } + }); + + try { + const response = await fetch(url, config); + return this._applyResponseInterceptors(response); + } catch (error) { + // TODO: Implement offline queue management + throw error; + } + } + + /** + * GET request + * @param {string} endpoint + * @param {RequestInit} options + * @returns {Promise} + */ + async get(endpoint, options = {}) { + return this.request(endpoint, { ...options, method: 'GET' }); + } + + /** + * POST request + * @param {string} endpoint + * @param {any} data + * @param {RequestInit} options + * @returns {Promise} + */ + async post(endpoint, data, options = {}) { + return this.request(endpoint, { + ...options, + method: 'POST', + body: JSON.stringify(data) + }); + } + + /** + * PUT request + * @param {string} endpoint + * @param {any} data + * @param {RequestInit} options + * @returns {Promise} + */ + async put(endpoint, data, options = {}) { + return this.request(endpoint, { + ...options, + method: 'PUT', + body: JSON.stringify(data) + }); + } + + /** + * DELETE request + * @param {string} endpoint + * @param {RequestInit} options + * @returns {Promise} + */ + async delete(endpoint, options = {}) { + return this.request(endpoint, { ...options, method: 'DELETE' }); + } +} + +// Export singleton instance +export const api = new APIClient(); + diff --git a/src/platform/compat.js b/src/platform/compat.js new file mode 100644 index 0000000..2c88d99 --- /dev/null +++ b/src/platform/compat.js @@ -0,0 +1,731 @@ +/** + * Platform Compatibility Layer + * Abstracts web-only operations that will need React Native equivalents later + * + * This module provides a compatibility layer for platform-specific operations. + * Web implementations are provided here; React Native implementations will be + * added when needed. + */ + +import { getConfig, setConfig } from './env.js'; +import { getDesktopBridgeSafe, getHostKind, isElectronHost } from './host.js'; + +// Config key for last visited route path +const LAST_PATH_CONFIG_KEY = 'router.lastPath'; + +// ============================================================================ +// Theme Detection (System Color Scheme) +// ============================================================================ + +/** + * Get system theme mode preference + * Web: Uses window.matchMedia('(prefers-color-scheme: dark)') + * React Native: Will use Appearance API or useColorScheme hook + * + * @returns {'light' | 'dark'} System theme mode preference + */ +export function getSystemThemeMode() { + if (typeof window === 'undefined') { + return 'light'; + } + + if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) { + return 'dark'; + } + + return 'light'; +} + +/** + * Subscribe to system theme mode changes + * Web: Uses MediaQueryList.addEventListener + * React Native: Will use Appearance.addChangeListener + * + * @param {Function} listener - Callback function called with ('light' | 'dark') when theme mode changes + * @returns {Function} Unsubscribe function + */ +export function subscribeToSystemThemeMode(listener) { + if (typeof window === 'undefined') { + // Return no-op unsubscribe for SSR/non-browser + return () => {}; + } + + const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); + + const handleChange = (e) => { + const scheme = e.matches ? 'dark' : 'light'; + try { + listener(scheme); + } catch (error) { + console.warn('[Compat] System color scheme listener error:', error); + } + }; + + // Modern browsers + if (mediaQuery.addEventListener) { + mediaQuery.addEventListener('change', handleChange); + return () => { + mediaQuery.removeEventListener('change', handleChange); + }; + } + // Fallback for older browsers + else if (mediaQuery.addListener) { + mediaQuery.addListener(handleChange); + return () => { + mediaQuery.removeListener(handleChange); + }; + } + + // Fallback: no-op unsubscribe + return () => {}; +} + +// ============================================================================ +// Scheduling (timers & animation frames) +// Web uses globalThis today; React Native can swap implementations here later. +// ============================================================================ + +function schedulerGlobal() { + if (typeof globalThis !== 'undefined') { + return globalThis; + } + if (typeof window !== 'undefined') { + return window; + } + return null; +} + +/** + * Repeating timer. Returns a cancel function (clears interval). + * @param {() => void} callback + * @param {number} delayMs + * @returns {() => void} + */ +export function scheduleInterval(callback, delayMs) { + const g = schedulerGlobal(); + if (!g || typeof g.setInterval !== 'function') { + return () => {}; + } + const id = g.setInterval(callback, delayMs); + return () => { + if (typeof g.clearInterval === 'function') { + g.clearInterval(id); + } + }; +} + +/** + * One-shot delayed execution. Returns a cancel function (clears timeout). + * @param {() => void} callback + * @param {number} delayMs + * @returns {() => void} + */ +export function scheduleTimeout(callback, delayMs) { + const g = schedulerGlobal(); + if (!g || typeof g.setTimeout !== 'function') { + return () => {}; + } + const id = g.setTimeout(callback, delayMs); + return () => { + if (typeof g.clearTimeout === 'function') { + g.clearTimeout(id); + } + }; +} + +/** + * Run once on the next display frame (or a short timeout fallback). + * @param {(time: number) => void} callback + * @returns {() => void} + */ +export function runOnNextFrame(callback) { + if (typeof requestAnimationFrame === 'function' && typeof cancelAnimationFrame === 'function') { + const id = requestAnimationFrame(callback); + return () => cancelAnimationFrame(id); + } + return scheduleTimeout(() => { + const t = typeof performance !== 'undefined' && typeof performance.now === 'function' + ? performance.now() + : Date.now(); + callback(t); + }, 16); +} + +/** + * Invoke callback on each animation frame until the returned unsubscribe runs. + * Prefer this over {@link scheduleInterval} when driving layout or motion from JS. + * @param {(time: number) => void} callback + * @returns {() => void} + */ +export function subscribeAnimationFrames(callback) { + if (typeof requestAnimationFrame !== 'function' || typeof cancelAnimationFrame !== 'function') { + return scheduleInterval(() => { + const t = typeof performance !== 'undefined' && typeof performance.now === 'function' + ? performance.now() + : Date.now(); + callback(t); + }, 16); + } + + let id; + let stopped = false; + const step = (time) => { + if (stopped) { + return; + } + callback(time); + if (stopped) { + return; + } + id = requestAnimationFrame(step); + }; + + id = requestAnimationFrame(step); + return () => { + stopped = true; + if (id != null) { + cancelAnimationFrame(id); + } + }; +} + +// ============================================================================ +// Element Bounds & Positioning +// ============================================================================ + +/** + * Get element's or viewport's bounding rectangle + * If element is null/undefined, returns viewport bounds (web: window.innerWidth/innerHeight) + * Otherwise returns element's bounding rectangle (web: getBoundingClientRect) + * @param {HTMLElement|React.RefObject|null|undefined} element - DOM element, ref object, or null for viewport + * @returns {{top: number, left: number, right: number, bottom: number, width: number, height: number}|null} + */ +export function getBounds(element) { + // If element is null/undefined, return viewport bounds + if (!element) { + if (typeof window !== 'undefined') { + return { + top: 0, + left: 0, + right: window.innerWidth, + bottom: window.innerHeight, + width: window.innerWidth, + height: window.innerHeight + }; + } + // Fallback for SSR or non-browser environments + return { top: 0, left: 0, right: 0, bottom: 0, width: 0, height: 0 }; + } + + // Get element bounds + const el = element.current || element; + if (!el || typeof el.getBoundingClientRect !== 'function') { + return null; + } + + const rect = el.getBoundingClientRect(); + return { + top: rect.top, + left: rect.left, + right: rect.right, + bottom: rect.bottom, + width: rect.width, + height: rect.height + }; +} + +/** + * Add event listener to a target object (web: target.addEventListener) + * Generic function for adding event listeners to any EventTarget + * @param {EventTarget} target - Target object (e.g., document, window) + * @param {string} eventType - Event type (e.g., 'mousedown', 'resize') + * @param {Function} handler - Event handler function + * @returns {Function} Cleanup function to remove the listener + */ +function addEventListener(target, eventType, handler) { + if (!target || typeof target.addEventListener !== 'function') { + // Return no-op cleanup for non-browser environments or invalid targets + return () => {}; + } + + target.addEventListener(eventType, handler); + + // Return cleanup function + return () => { + target.removeEventListener(eventType, handler); + }; +} + +/** + * Add document-level event listener (web: document.addEventListener) + * @param {string} eventType - Event type (e.g., 'mousedown', 'resize') + * @param {Function} handler - Event handler function + * @returns {Function} Cleanup function to remove the listener + */ +export function addDocumentEventListener(eventType, handler) { + return addEventListener(typeof document !== 'undefined' ? document : null, eventType, handler); +} + +/** + * Add window-level event listener (web: window.addEventListener) + * @param {string} eventType - Event type (e.g., 'resize') + * @param {Function} handler - Event handler function + * @returns {Function} Cleanup function to remove the listener + */ +export function addWindowEventListener(eventType, handler) { + return addEventListener(typeof window !== 'undefined' ? window : null, eventType, handler); +} + +/** + * Check if an element contains another element or target (web: element.contains) + * @param {HTMLElement|React.RefObject} element - Parent element or ref + * @param {EventTarget|Node} target - Child element or event target + * @returns {boolean} True if element contains target + */ +export function elementContains(element, target) { + if (!element || !target) return false; + + const el = element.current || element; + if (!el || typeof el.contains !== 'function') { + return false; + } + + return el.contains(target); +} + +/** + * Check if child bounds are fully contained within parent bounds (with margin) + * @param {{top: number, left: number, right: number, bottom: number}} parent - Parent bounds + * @param {{top: number, left: number, right: number, bottom: number}} child - Child bounds + * @param {number} margin - Margin from parent edges + * @returns {boolean} True if child is fully contained within parent + */ +function boundsContained(parent, child, margin = 0) { + return ( + child.top >= parent.top + margin && + child.left >= parent.left + margin && + child.right <= parent.right - margin && + child.bottom <= parent.bottom - margin + ); +} + +/** + * Calculate popup bounds by trying placement options in priority order + * Returns the first option that fits within the viewport + * @param {HTMLElement|React.RefObject} buttonElement - Button element + * @param {number} popupWidth - Popup width + * @param {number} popupHeight - Popup height + * @param {number} margin - Margin from screen edges + * @returns {{top: number, left: number, right: number, bottom: number, width: number, height: number, alignRight: boolean, alignRightSide: boolean, alignBottom: boolean}|null} + */ +export function getPopupBounds(buttonElement, popupWidth = 200, popupHeight = 300, margin = 8) { + const buttonBounds = getBounds(buttonElement); + if (!buttonBounds) { + return null; + } + + const viewport = getBounds(null); + const spacing = 4; // Space between button and popup + + // Calculate 4 placement options in priority order: + // 1. Below button, left-aligned + // 2. Below button, right-aligned + // 3. Right of button, bottom-aligned + // 4. Left of button, bottom-aligned + + const options = [ + // Option 1: Below button, left-aligned + { + top: buttonBounds.bottom + spacing, + left: buttonBounds.left, + right: buttonBounds.left + popupWidth, + bottom: buttonBounds.bottom + spacing + popupHeight, + width: popupWidth, + height: popupHeight, + alignRight: false, + alignRightSide: false, + alignBottom: false + }, + // Option 2: Below button, right-aligned + { + top: buttonBounds.bottom + spacing, + left: buttonBounds.right - popupWidth, + right: buttonBounds.right, + bottom: buttonBounds.bottom + spacing + popupHeight, + width: popupWidth, + height: popupHeight, + alignRight: true, + alignRightSide: false, + alignBottom: false + }, + // Option 3: Right of button, bottom-aligned + { + top: buttonBounds.bottom - popupHeight, + left: buttonBounds.right + spacing, + right: buttonBounds.right + spacing + popupWidth, + bottom: buttonBounds.bottom, + width: popupWidth, + height: popupHeight, + alignRight: false, + alignRightSide: true, + alignBottom: true + }, + // Option 4: Left of button, bottom-aligned + { + top: buttonBounds.bottom - popupHeight, + left: buttonBounds.left - popupWidth - spacing, + right: buttonBounds.left - spacing, + bottom: buttonBounds.bottom, + width: popupWidth, + height: popupHeight, + alignRight: false, + alignRightSide: false, + alignBottom: true + } + ]; + + // Find first option that fits within viewport + for (const option of options) { + if (boundsContained(viewport, option, 0)) { + return option; + } + } + + // If none fit, return the first option (below, left-aligned) as fallback + // It will be clamped by getPopupPositionStyle + return options[0]; +} + +/** + * Get popup positioning style for fixed positioning (web: position: fixed) + * In React Native, this will need to use absolute positioning with proper parent container + * Clamps popup to viewport if it doesn't fit + * @param {{top: number, left: number, right: number, bottom: number, width: number, height: number, alignRight: boolean, alignRightSide: boolean, alignBottom: boolean}|null} popupBounds - Popup bounds from getPopupBounds + * @param {HTMLElement|React.RefObject} buttonElement - Button element for fallback calculations + * @returns {Object} Style object for popup positioning + */ +export function getPopupPositionStyle(popupBounds, buttonElement = null) { + const viewport = getBounds(null); + const buttonBounds = buttonElement ? getBounds(buttonElement) : null; + + const style = {}; + + if (popupBounds) { + // Use provided popup bounds + // Handle top/bottom positioning + if (popupBounds.alignBottom && buttonBounds) { + style.bottom = `${viewport.height - buttonBounds.bottom}px`; + style.top = 'auto'; + } else { + style.top = `${popupBounds.top}px`; + style.bottom = 'auto'; + } + + // Handle left/right positioning + if (popupBounds.alignRight && buttonBounds) { + style.right = `${viewport.width - buttonBounds.right}px`; + style.left = 'auto'; + } else if (popupBounds.alignRightSide) { + style.left = `${popupBounds.left}px`; + style.right = 'auto'; + } else { + style.left = `${popupBounds.left}px`; + style.right = 'auto'; + } + } else { + // Fallback to old behavior if bounds not provided + if (buttonBounds) { + style.top = `${buttonBounds.bottom + 4}px`; + style.left = `${buttonBounds.left}px`; + style.bottom = 'auto'; + style.right = 'auto'; + } else { + style.top = 'auto'; + style.left = 'auto'; + style.bottom = 'auto'; + style.right = 'auto'; + } + } + + // Add position: fixed for web (will be handled differently in React Native) + style.position = 'fixed'; + + return style; +} + +// ============================================================================ +// External Navigation +// ============================================================================ + +/** + * Open an external URL + * Platform-agnostic wrapper for opening external links + * Web: Uses window.open() + * React Native: Will use Linking.openURL() + * + * @param {string} url - URL to open + * @param {string} [target='_blank'] - Target window (e.g., '_blank', '_self') + * @param {string} [features] - Window features (e.g., 'noopener,noreferrer') + * @returns {Window|null} Opened window reference, or null if not supported + */ +export function openExternalURL(url, target = '_blank', features = 'noopener,noreferrer') { + const desktopBridge = getDesktopBridgeSafe(); + if (desktopBridge?.openExternal && isElectronHost()) { + desktopBridge.openExternal(url); + return null; + } + + if (typeof window === 'undefined') { + console.warn('[Compat] openExternalURL: window is not available'); + return null; + } + + if (!url) { + console.warn('[Compat] openExternalURL: URL is required'); + return null; + } + + try { + return window.open(url, target, features); + } catch (error) { + console.error('[Compat] openExternalURL error:', error); + return null; + } +} + +// ============================================================================ +// File Picking +// ============================================================================ + +/** + * Pick a file from the local device. + * Web: Uses a temporary hidden file input and optional FileReader conversion. + * React Native: Will need a native document/image picker equivalent. + * + * @param {Object} [options] + * @param {string} [options.accept] - File accept filter, defaults to any file type + * @param {boolean} [options.multiple=false] - Allow multiple selection + * @param {'text'|'dataURL'|'arrayBuffer'|null} [options.readAs='dataURL'] - Optional file read mode + * @returns {Promise<{file: File, result: any}|null>} + */ +export function pickFile(options = {}) { + const desktopBridge = getDesktopBridgeSafe(); + if (desktopBridge?.pickFile && isElectronHost()) { + return desktopBridge.pickFile(options); + } + + const { + accept = '*/*', + multiple = false, + readAs = 'dataURL' + } = options; + + if (typeof document === 'undefined') { + console.warn('[Compat] pickFile: document is not available'); + return Promise.resolve(null); + } + + return new Promise((resolve) => { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = accept; + input.multiple = multiple === true; + input.style.display = 'none'; + + const cleanup = () => { + input.onchange = null; + if (input.parentNode) { + input.parentNode.removeChild(input); + } + }; + + input.onchange = () => { + const file = input.files?.[0]; + if (!file) { + cleanup(); + resolve(null); + return; + } + + if (!readAs) { + cleanup(); + resolve({ file, result: null }); + return; + } + + const reader = new FileReader(); + reader.onload = () => { + const result = reader.result; + cleanup(); + resolve({ file, result }); + }; + reader.onerror = () => { + console.warn('[Compat] pickFile: failed to read selected file'); + cleanup(); + resolve(null); + }; + + if (readAs === 'text') { + reader.readAsText(file); + } else if (readAs === 'arrayBuffer') { + reader.readAsArrayBuffer(file); + } else { + reader.readAsDataURL(file); + } + }; + + document.body.appendChild(input); + input.click(); + }); +} + +/** + * Pick an image from the local device. + * Web: Delegates to pickFile() with image accept types and reads as data URL. + * React Native: Will need a native media picker equivalent. + * + * @returns {Promise<{file: File, result: string}|null>} + */ +export async function pickImage() { + const desktopBridge = getDesktopBridgeSafe(); + if (desktopBridge?.pickImage && isElectronHost()) { + return desktopBridge.pickImage(); + } + + const selection = await pickFile({ + accept: 'image/*', + readAs: 'dataURL' + }); + + if (!selection?.file) { + return null; + } + + if (!selection.file.type?.startsWith('image/')) { + console.warn('[Compat] pickImage: selected file is not an image'); + return null; + } + + return { + file: selection.file, + result: typeof selection.result === 'string' ? selection.result : '' + }; +} + +export async function getHostInfo() { + const desktopBridge = getDesktopBridgeSafe(); + if (desktopBridge?.getHostInfo && isElectronHost()) { + return desktopBridge.getHostInfo(); + } + + return { + hostKind: getHostKind(), + isPackaged: false, + platform: typeof navigator !== 'undefined' ? navigator.platform : 'unknown', + appVersion: null, + userDataPath: null + }; +} + +// ============================================================================ +// URL/Path Management (Browser URL synchronization) +// ============================================================================ + +/** + * Get router path (current path the router should use) + * Checks browser URL first, then localStorage for last visited path, then falls back to default + * + * @param {string} defaultPath - Default path to use if no URL or stored path exists + * @returns {Promise} Router path to use + */ +export async function getRouterPath(defaultPath = '/') { + // First, check browser URL (web only) + if (typeof window !== 'undefined' && window.location) { + const urlPath = window.location.pathname; + // If URL has a meaningful path (not just '/'), use it + if (urlPath && urlPath !== '/') { + return urlPath; + } + } + + // If URL is empty or '/', check config for last visited path + try { + const lastPath = await getConfig(LAST_PATH_CONFIG_KEY, null); + if (lastPath && typeof lastPath === 'string' && lastPath !== '/') { + return lastPath; + } + } catch (error) { + console.warn('[Compat] Failed to get last path from config:', error); + } + + // Fall back to default path + return defaultPath; +} + +/** + * Update router path (browser URL and storage) + * Updates browser URL without page reload and saves path to localStorage for persistence + * Web: Uses history.pushState or history.replaceState + * React Native: Only saves to storage (no URL to update) + * + * @param {string} path - Path to navigate to (e.g., '/dashboard') + * @param {boolean} [replace=false] - Whether to replace current history entry + */ +export async function setRouterPath(path, replace = false, options = {}) { + const { notify = true, state = null } = options; + const fullPath = path.startsWith('/') ? path : '/' + path; + + // Update browser URL (web only) + if (typeof window !== 'undefined' && window.history) { + if (replace) { + window.history.replaceState(state, '', fullPath); + } else { + window.history.pushState(state, '', fullPath); + } + + if (notify) { + try { + window.dispatchEvent(new PopStateEvent('popstate', { state })); + } catch (error) { + window.dispatchEvent(new Event('popstate')); + } + } + } + + // Save to config for persistence (works on all platforms) + try { + await setConfig(LAST_PATH_CONFIG_KEY, fullPath); + } catch (error) { + console.warn('[Compat] Failed to save path to config:', error); + } +} + +/** + * Subscribe to browser URL changes (back/forward buttons) + * Web: Listens to popstate event + * React Native: Returns no-op unsubscribe (no URL changes) + * + * @param {Function} listener - Callback function called with (path: string, state: any) when URL changes + * @returns {Function} Unsubscribe function + */ +export function subscribeToPathChanges(listener) { + if (typeof window === 'undefined') { + // Return no-op unsubscribe for non-browser + return () => {}; + } + + const handlePopState = (event) => { + const path = window.location.pathname; + try { + listener(path, event.state); + } catch (error) { + console.warn('[Compat] Path change listener error:', error); + } + }; + + window.addEventListener('popstate', handlePopState); + return () => { + window.removeEventListener('popstate', handlePopState); + }; +} + diff --git a/src/platform/env.js b/src/platform/env.js new file mode 100644 index 0000000..e876eaf --- /dev/null +++ b/src/platform/env.js @@ -0,0 +1,487 @@ +/** + * Environment Configuration & Utilities + * Central config dictionary and environment discovery + */ + +import { getProvider } from './storage.js'; + +// Private storage instance for config +const storage = getProvider('kv', 'config'); + +// Config dictionary - populated by loader +let config = {}; +let consoleLoggerInstalled = false; +const lockedKeys = new Set(); + +const LOGGER_ICONS = { + debug: '.', + info: 'i', + log: '>', + warn: '!', + error: 'x' +}; + +const NATIVE_CONSOLE = { + log: console.log.bind(console), + info: (console.info || console.log).bind(console), + warn: console.warn.bind(console), + error: console.error.bind(console), + debug: (console.debug || console.log).bind(console) +}; +const perfNow = () => { + if (typeof performance !== 'undefined' && typeof performance.now === 'function') { + return performance.now(); + } + return Date.now(); +}; + +function normalizeLogLevel(level = 'log') { + if (level === 'info') return 'info'; + if (level === 'warn') return 'warn'; + if (level === 'error') return 'error'; + if (level === 'debug') return 'debug'; + return 'log'; +} + +function splitSourceAndMessage(args = []) { + if (args.length === 0) { + return { + source: 'App', + messageParts: [] + }; + } + + const [firstArg, ...restArgs] = args; + if (typeof firstArg !== 'string') { + return { + source: 'App', + messageParts: args + }; + } + + const match = firstArg.match(/^\[([^\]]+)\]\s*(.*)$/s); + if (!match) { + return { + source: 'App', + messageParts: args + }; + } + + const [, source, firstMessage] = match; + const messageParts = firstMessage ? [firstMessage, ...restArgs] : restArgs; + return { + source, + messageParts + }; +} + +// Special config keys +export const CONFIG_KEYS = { + APP_NAME: 'APP_NAME', + APP_DISPLAY_NAME: 'APP_DISPLAY_NAME', + APP_DESCRIPTION: 'APP_DESCRIPTION', + BRAND_LOGO: 'BRAND_LOGO', + THEME_COLOR: 'THEME_COLOR', + UI_SHELL: 'UI_SHELL', + STORAGE_BACKEND: 'STORAGE_BACKEND', + API_BASE_URL: 'API_BASE_URL', + MODULES: 'MODULES', + SECURITY_CONFIG: 'SECURITY_CONFIG', + LOCALE: 'LOCALE', + /** Development host: extra dev UI, SW dev behavior, etc. Layered via getConfig (storage → profile → bundler). */ + DEV_HOST: 'DEV_HOST' +}; + +// do not allow edits on these keys via setConfig - they are meant to be set from profile or env vars and not overridden at runtime +[ + CONFIG_KEYS.API_BASE_URL, + CONFIG_KEYS.MODULES, + CONFIG_KEYS.SECURITY_CONFIG +].forEach((key) => lockedKeys.add(key)); + +/** + * Resolve dev-host flag from profile (explicit) or bundler hints. + * Profile wins: `dev_host: true|false` or `runtime.dev: true|false`. + * Otherwise Vite `import.meta.env.DEV`, else false. + * @param {Object|null|undefined} appConfig + * @returns {boolean} + */ +function resolveDevHostFlag(appConfig) { + if (appConfig && typeof appConfig.dev_host === 'boolean') { + return appConfig.dev_host; + } + if (appConfig?.runtime && typeof appConfig.runtime.dev === 'boolean') { + return appConfig.runtime.dev; + } + if (typeof import.meta !== 'undefined' && import.meta.env) { + return Boolean(import.meta.env.DEV); + } + return false; +} + +/** + * Initialize environment config + * @param {Object} appConfig - Configuration from profile + */ +export function initEnv(appConfig) { + const api = appConfig.api || {}; + config = { + APP_NAME: appConfig.id || appConfig.name, + APP_DISPLAY_NAME: appConfig.displayName || appConfig.short_name || appConfig.name, + APP_DESCRIPTION: appConfig.description || '', + BRAND_LOGO: appConfig.brand_logo || appConfig.brandLogo || appConfig.icons?.[0]?.src || '/favicon.svg', + THEME_COLOR: appConfig.theme_color || appConfig.themeColor || '#000000', + UI_SHELL: appConfig.ui_shell || appConfig.uiShell || 'EmptyShell', + STORAGE_BACKEND: appConfig.storage?.backend || 'localStorage', + API_BASE_URL: api.base_url || api.baseURL || '/api', + MODULES: appConfig.modules || [], + SECURITY_CONFIG: appConfig.security || {}, + LOCALE: appConfig.locale || null, + [CONFIG_KEYS.DEV_HOST]: resolveDevHostFlag(appConfig), + // Store full profile for advanced access + _profile: appConfig + }; +} + +function resolveSystemLocale() { + if (typeof navigator !== 'undefined' && typeof navigator.language === 'string' && navigator.language.trim()) { + return navigator.language.trim(); + } + return 'en-US'; +} + +/** + * Bootstrap environment as early as possible from the selected profile. + * This gives the app a single source of truth before full boot runs. + * @param {Object} appConfig + * @param {Object} options + * @param {boolean} [options.installLoggerFirst=true] + */ +export function bootstrapEnv(appConfig, options = {}) { + const { installLoggerFirst = true } = options; + if (installLoggerFirst) { + installConsoleLogger(); + } + if (appConfig && typeof appConfig === 'object') { + initEnv(appConfig); + } + return getConfigDict(); +} + +export function log(source = 'App', level = 'log', ...messageParts) { + const normalizedLevel = normalizeLogLevel(level); + const sink = NATIVE_CONSOLE[normalizedLevel] || NATIVE_CONSOLE.log; + const timestamp = new Date().toISOString(); + const icon = LOGGER_ICONS[normalizedLevel] || LOGGER_ICONS.log; + sink(`${timestamp} [${source}] ${icon}`, ...messageParts); +} + +export function createLogger(source = 'App') { + return { + debug: (...messageParts) => log(source, 'debug', ...messageParts), + info: (...messageParts) => log(source, 'info', ...messageParts), + log: (...messageParts) => log(source, 'log', ...messageParts), + warn: (...messageParts) => log(source, 'warn', ...messageParts), + error: (...messageParts) => log(source, 'error', ...messageParts) + }; +} + +export function startTrace(source = 'App', label = 'operation', metadata = null) { + const startedAt = perfNow(); + if (metadata !== null && metadata !== undefined) { + log(source, 'debug', `${label} started`, metadata); + } else { + log(source, 'debug', `${label} started`); + } + + return { + end(extra = null, level = 'log') { + const durationMs = Math.round((perfNow() - startedAt) * 100) / 100; + if (extra !== null && extra !== undefined) { + log(source, level, `${label} finished in ${durationMs}ms`, extra); + } else { + log(source, level, `${label} finished in ${durationMs}ms`); + } + return durationMs; + }, + fail(error = null) { + const durationMs = Math.round((perfNow() - startedAt) * 100) / 100; + log(source, 'error', `${label} failed in ${durationMs}ms`, error); + return durationMs; + } + }; +} + +export async function traceAsync(source, label, fn, metadata = null) { + const trace = startTrace(source, label, metadata); + try { + const result = await fn(); + trace.end(); + return result; + } catch (error) { + trace.fail(error); + throw error; + } +} + +export function installConsoleLogger() { + if (consoleLoggerInstalled || typeof console === 'undefined') { + return; + } + + consoleLoggerInstalled = true; + + console.log = (...args) => { + const { source, messageParts } = splitSourceAndMessage(args); + log(source, 'log', ...messageParts); + }; + + console.info = (...args) => { + const { source, messageParts } = splitSourceAndMessage(args); + log(source, 'info', ...messageParts); + }; + + console.warn = (...args) => { + const { source, messageParts } = splitSourceAndMessage(args); + log(source, 'warn', ...messageParts); + }; + + console.error = (...args) => { + const { source, messageParts } = splitSourceAndMessage(args); + log(source, 'error', ...messageParts); + }; + + console.debug = (...args) => { + const { source, messageParts } = splitSourceAndMessage(args); + log(source, 'debug', ...messageParts); + }; +} + +export function lockConfigKey(key) { + if (!key) { + return false; + } + lockedKeys.add(key); + return true; +} + +export function unlockConfigKey(key) { + if (!key) { + return false; + } + return lockedKeys.delete(key); +} + +export function isConfigKeyLocked(key) { + return lockedKeys.has(key); +} + +export function getLockedConfigKeys() { + return Array.from(lockedKeys); +} + +function setMetaTag(name, content) { + if (typeof document === 'undefined') { + return; + } + + let element = document.querySelector(`meta[name="${name}"]`); + if (!element) { + element = document.createElement('meta'); + element.setAttribute('name', name); + document.head.appendChild(element); + } + + element.setAttribute('content', content); +} + +function setFavicon(href) { + if (typeof document === 'undefined' || !href) { + return; + } + + let element = document.querySelector('link[rel="icon"]'); + if (!element) { + element = document.createElement('link'); + element.setAttribute('rel', 'icon'); + document.head.appendChild(element); + } + + element.setAttribute('href', href); + if (!element.getAttribute('type')) { + element.setAttribute('type', 'image/svg+xml'); + } +} + +export async function syncDocumentHeadFromConfig(options = {}) { + if (typeof document === 'undefined') { + return null; + } + + const { + titleFallback = 'PWA Template', + descriptionFallback = '', + themeColorFallback = '#000000' + } = options; + + const [title, description, themeColor, brandLogo] = await Promise.all([ + getConfig(CONFIG_KEYS.APP_DISPLAY_NAME, titleFallback), + getConfig(CONFIG_KEYS.APP_DESCRIPTION, descriptionFallback), + getConfig(CONFIG_KEYS.THEME_COLOR, themeColorFallback), + getConfig(CONFIG_KEYS.BRAND_LOGO, '/favicon.svg') + ]); + + document.title = title || titleFallback; + setMetaTag('description', description || descriptionFallback); + setMetaTag('theme-color', themeColor || themeColorFallback); + setFavicon(brandLogo || '/favicon.svg'); + + return { + title: title || titleFallback, + description: description || descriptionFallback, + themeColor: themeColor || themeColorFallback, + brandLogo: brandLogo || '/favicon.svg' + }; +} + +/** + * Get config value by key + * Checks in order: storage (if key exists) → config dictionary → environment variables → altValue + * Uses storage.hasKey() as a guard to skip unnecessary storage.get() calls for profile config keys + * @param {string} key - Config key + * @param {any} altValue - Alternative value if not found + * @returns {Promise} Config value or altValue + */ +export async function getConfig(key, altValue = null) { + // 1. Check if key exists in storage first (quick check to avoid unnecessary get) + try { + const exists = await storage.hasKey(key); + if (exists) { + // Only do full storage.get() if key exists + const storedValue = await storage.get(key, null); + if (storedValue !== null && storedValue !== undefined) { + return storedValue; + } + } + // If key doesn't exist in storage, skip to config dictionary (faster path) + } catch (error) { + // Storage might not be available, continue to next check + console.warn(`[Env] Failed to check storage for "${key}":`, error); + } + + // 2. Check config dictionary (in-memory config from profile) + if (config.hasOwnProperty(key)) { + return config[key]; + } + + // 3. Fall back to environment variables (Vite env vars) + // Check if import.meta.env exists (available in Vite, not in Node) + if (typeof import.meta !== 'undefined' && import.meta.env) { + const envKey = `VITE_${key}`; + const envValue = import.meta.env[envKey]; + + if (envValue !== undefined) { + return envValue; + } + } + + // 4. Return alternative value + return altValue; +} + +/** + * Set config value + * If key exists in config dictionary, update it there (in-memory). + * Otherwise, save to storage (for persistence). + * @param {string} key - Config key + * @param {any} value - Value to set + * @returns {Promise} + */ +export async function setConfig(key, value) { + if (isConfigKeyLocked(key)) { + console.warn(`[Env] Refusing to set locked config key "${key}". Unlock it explicitly first if this override is intentional.`); + return false; + } + + // If key exists in config dictionary, update it there (in-memory config) + if (config.hasOwnProperty(key)) { + config[key] = value; + return true; + } else { + // Otherwise, save to storage (for state/settings persistence) + try { + await storage.set(key, value); + return true; + } catch (error) { + console.warn(`[Env] Failed to set "${key}" in storage:`, error); + // Fallback: store in config dictionary if storage fails + config[key] = value; + return true; + } + } +} + +/** + * Get full config object (read-only copy) + * @returns {Object} Config dictionary + */ +export function getConfigDict() { + return { ...config }; +} + +export async function getLocale(altValue = null) { + const savedLocale = await getConfig(CONFIG_KEYS.LOCALE, null); + if (savedLocale && typeof savedLocale === 'string') { + config[CONFIG_KEYS.LOCALE] = savedLocale; + return savedLocale; + } + + return altValue || resolveSystemLocale(); +} + +export function getLocaleSync(altValue = null) { + return config[CONFIG_KEYS.LOCALE] || altValue || resolveSystemLocale(); +} + +export async function setLocale(locale) { + const normalizedLocale = typeof locale === 'string' && locale.trim() ? locale.trim() : null; + config[CONFIG_KEYS.LOCALE] = normalizedLocale; + + try { + if (normalizedLocale) { + await storage.set(CONFIG_KEYS.LOCALE, normalizedLocale); + } else { + await storage.remove(CONFIG_KEYS.LOCALE); + } + } catch (error) { + console.warn('[Env] Failed to persist locale:', error); + } + + return normalizedLocale || resolveSystemLocale(); +} + +/** + * Whether the app is treated as a development host (dev tooling, SW policy, etc.). + * Synchronous: uses in-memory config after {@link initEnv} / {@link bootstrapEnv}. + * For persisted overrides, use {@link getConfig} with {@link CONFIG_KEYS.DEV_HOST} in async code. + */ +export function isDevelopment() { + if (config && Object.prototype.hasOwnProperty.call(config, CONFIG_KEYS.DEV_HOST)) { + return Boolean(config[CONFIG_KEYS.DEV_HOST]); + } + if (typeof import.meta !== 'undefined' && import.meta.env) { + return Boolean(import.meta.env.DEV); + } + return false; +} + +/** + * Inverse of {@link isDevelopment} when no explicit production flag exists. + */ +export function isProduction() { + if (typeof import.meta !== 'undefined' && import.meta.env && typeof import.meta.env.PROD === 'boolean') { + return import.meta.env.PROD; + } + return !isDevelopment(); +} + diff --git a/src/platform/host.js b/src/platform/host.js new file mode 100644 index 0000000..bfd9d66 --- /dev/null +++ b/src/platform/host.js @@ -0,0 +1,43 @@ +function getDesktopBridge() { + if (typeof window === 'undefined') { + return null; + } + + return window.__bfaceDesktop || null; +} + +export function getHostKind() { + const bridge = getDesktopBridge(); + if (bridge?.hostKind) { + return bridge.hostKind; + } + + if (bridge?.isElectron) { + return 'electron'; + } + + if (typeof window !== 'undefined' && (window.__TAURI__ || window.__TAURI_INTERNALS__)) { + return 'tauri'; + } + + return 'web'; +} + +export function isElectronHost() { + return getHostKind() === 'electron'; +} + +export function isTauriHost() { + return getHostKind() === 'tauri'; +} + +export function getDesktopBridgeSafe() { + return getDesktopBridge(); +} + +export default { + getHostKind, + isElectronHost, + isTauriHost, + getDesktopBridgeSafe +}; diff --git a/src/platform/menu.js b/src/platform/menu.js new file mode 100644 index 0000000..5253128 --- /dev/null +++ b/src/platform/menu.js @@ -0,0 +1,1301 @@ +/** + * Menu Library + * Central place for modules to register frontend-facing menu items + * Hierarchical menu structure with root items for each menu directory + */ + +import { normalizeRightsInput } from '../security/model/rights.js'; +import { getProvider } from './storage.js'; + +// Menu root structure: Map of root directory IDs to MenuItem instances +const menuRoot = new Map(); +const menuPreferenceStorage = getProvider('kv', 'menu.ui'); +const MENU_PREFERENCES_KEY = 'menu.preferences'; +const DEFAULT_MENU_PREFERENCES = { + visibility: {}, + expanded: {} +}; + +// Menu change listeners for reactive updates +const menuChangeListeners = new Set(); +let menuVersion = 0; +let menuPreferences = { + visibility: {}, + expanded: {} +}; +let menuPreferencesLoaded = false; +let menuPreferencePersistTimer = null; + +// ============================================================================ +// Invoke Handlers +// Global handlers for different invoke types (to be set by App) +// ============================================================================ + +/** + * Global invoke handlers for menu item actions + * These should be set early in the application (e.g., in App.jsx) + * to work together with routing and navigation + */ +export const InvokeHandlers = { + /** + * Navigate to an internal page/route + * @param {MenuItem} menuItem - The menu item being invoked + * @param {HTMLElement|null} eventSource - Source element that triggered the action + * @param {Event|null} event - Original event object + */ + goToPage: (menuItem, eventSource = null, event = null) => { + // To be implemented in App.jsx + }, + + /** + * Navigate to an external URL + * @param {MenuItem} menuItem - The menu item being invoked + * @param {HTMLElement|null} eventSource - Source element that triggered the action + * @param {Event|null} event - Original event object + */ + goToOutside: (menuItem, eventSource = null, event = null) => { + // To be implemented in App.jsx + }, + + /** + * Open a modal dialog + * @param {MenuItem} menuItem - The menu item being invoked + * @param {HTMLElement|null} eventSource - Source element that triggered the action + * @param {Event|null} event - Original event object + */ + goToModal: (menuItem, eventSource = null, event = null) => { + // To be implemented in App.jsx + } +}; + +/** + * Subscribe to menu changes + * @param {Function} listener - Callback function called when menu changes + * @returns {Function} Unsubscribe function + */ +export function subscribeToMenuChanges(listener) { + menuChangeListeners.add(listener); + return () => { + menuChangeListeners.delete(listener); + }; +} + +/** + * Notify all listeners of menu changes + */ +function notifyMenuChange() { + menuVersion++; + menuChangeListeners.forEach(listener => { + try { + listener(menuVersion); + } catch (error) { + console.warn('[Menu] Listener error:', error); + } + }); +} + +/** + * Get current menu version (for use as dependency in React hooks) + * @returns {number} + */ +export function getMenuVersion() { + return menuVersion; +} + +export function getMenuPreferencesSnapshot() { + return cloneMenuPreferences(); +} + +export function getMenuItemExpandedPreference(path, defaultValue = false) { + if (!path) { + return defaultValue; + } + + if (Object.prototype.hasOwnProperty.call(menuPreferences.expanded, path)) { + return menuPreferences.expanded[path] !== false; + } + + return defaultValue; +} + +/** + * MenuItem Class + * Well-defined structure for menu items with hierarchical children + */ +export class MenuItem { + /** + * @param {Object} config - Menu item configuration + * @param {string} [config.id] - Unique identifier within parent (auto-generated from path if not provided) + * @param {string} config.label - Display text (required) + * @param {string} [config.tag='link'] - HTML tag or component type + * @param {string} [config.icon] - Icon identifier/URL + * @param {Function} [config.invoke] - Custom invoke function: invoke(menuItem, eventSource, event) + * @param {string} [config.invoke_type] - Invoke type: 'page', 'external', 'modal', or 'action' + * @param {string} [config.invoke_target] - Target route/URL (used with invoke_type) + * @param {Function} [config.handler] - Legacy: Click/action handler (deprecated, use invoke instead) + * @param {Map|Object} [config.items] - Nested menu items as Map (id -> MenuItem) + * @param {string} [config.path] - Full path (auto-set by menu system) + * @param {boolean} [config.is_active=true] - Whether menu item is active/enabled + * @param {string} [config.style='both'] - Display style: 'both', 'label_only', or 'icon_only' + * @param {string} [config.with_permits=''] - Comma-delimited permissions required (e.g., "read,write") + * @param {Object} [config.attrs={}] - Additional attributes + */ + constructor(config = {}) { + // Required properties + if (!config.label && !config.id) { + throw new Error('MenuItem requires at least "label" or "id"'); + } + + // Core properties + this.id = config.id || null; + this.label = config.label || ''; + this.tag = config.tag || 'link'; + this.icon = config.icon || null; + this.path = config.path || null; + + // Invoke properties (new system) + this.invoke = config.invoke || null; + this.invoke_type = config.invoke_type || null; + this.invoke_target = config.invoke_target || null; + + // Legacy handler support (for backward compatibility) + if (config.handler && !config.invoke) { + console.warn('[MenuItem] "handler" is deprecated, use "invoke" instead'); + this.invoke = config.handler; + } + + // Items as Map (id -> MenuItem) + this.items = new Map(); + if (config.items) { + if (config.items instanceof Map) { + // Copy from existing Map + for (const [id, item] of config.items.entries()) { + this.items.set(id, item instanceof MenuItem ? item : new MenuItem(item)); + } + } else if (Array.isArray(config.items)) { + // Legacy support: convert array to Map + config.items.forEach(item => { + const menuItem = item instanceof MenuItem ? item : new MenuItem(item); + const itemId = menuItem.id || menuItem.label?.toLowerCase().replace(/\s+/g, '-') || `item-${this.items.size}`; + this.items.set(itemId, menuItem); + }); + } else if (typeof config.items === 'object') { + // Object with id keys + for (const [id, item] of Object.entries(config.items)) { + this.items.set(id, item instanceof MenuItem ? item : new MenuItem(item)); + } + } + } + + // New properties + this.is_active = config.is_active !== undefined ? config.is_active : true; + this.is_visible = config.is_visible !== undefined ? config.is_visible : true; + this.style = config.style || 'both'; + this.with_permits = config.with_permits || ''; + this.tags = Array.isArray(config.tags) + ? [...config.tags] + : (typeof config.tags === 'string' ? [config.tags] : []); + this.visible_when_permitted = config.visible_when_permitted || null; + + // Validate style + const validStyles = ['both', 'label_only', 'icon_only']; + if (!validStyles.includes(this.style)) { + console.warn(`[MenuItem] Invalid style "${this.style}", defaulting to "both"`); + this.style = 'both'; + } + + // Additional attributes + this.attrs = config.attrs || {}; + + // Validate invoke_type + if (this.invoke_type) { + const validTypes = ['page', 'external', 'modal', 'action']; + if (!validTypes.includes(this.invoke_type)) { + console.warn(`[MenuItem] Invalid invoke_type "${this.invoke_type}", must be one of: ${validTypes.join(', ')}`); + this.invoke_type = null; + } + } + + // Validate invoke_target is set when invoke_type requires it + if (this.invoke_type && ['page', 'external', 'modal'].includes(this.invoke_type) && !this.invoke_target) { + console.warn(`[MenuItem] invoke_type "${this.invoke_type}" requires invoke_target to be set`); + } + + // Copy any other properties + Object.keys(config).forEach(key => { + if (!['id', 'label', 'tag', 'icon', 'invoke', 'invoke_type', 'invoke_target', 'handler', 'items', 'path', 'is_active', 'is_visible', 'style', 'with_permits', 'tags', 'visible_when_permitted', 'attrs'].includes(key)) { + this[key] = config[key]; + } + }); + } + + /** + * Convert to plain object (for serialization) + * @returns {Object} Plain object representation + */ + toObject() { + const itemsObj = {}; + for (const [id, item] of this.items.entries()) { + itemsObj[id] = item instanceof MenuItem ? item.toObject() : item; + } + + return { + id: this.id, + label: this.label, + tag: this.tag, + icon: this.icon, + invoke: this.invoke, + invoke_type: this.invoke_type, + invoke_target: this.invoke_target, + items: itemsObj, + path: this.path, + is_active: this.is_active, + is_visible: this.is_visible, + style: this.style, + with_permits: this.with_permits, + tags: [...this.tags], + visible_when_permitted: this.visible_when_permitted ? { ...this.visible_when_permitted } : null, + ...this.attrs, + // Include any additional properties + ...Object.fromEntries( + Object.entries(this).filter(([key]) => + !['id', 'label', 'tag', 'icon', 'invoke', 'invoke_type', 'invoke_target', 'items', 'path', 'is_active', 'is_visible', 'style', 'with_permits', 'tags', 'visible_when_permitted', 'attrs'].includes(key) + ) + ) + }; + } + + /** + * Check if menu item has items + * @returns {boolean} + */ + hasItems() { + return this.items.size > 0; + } + + /** + * Check if menu item is actionable (has invoke function OR invoke_type + invoke_target) + * @returns {boolean} + */ + isActionable() { + if (!this.is_active) return false; + + // Has custom invoke function + if (typeof this.invoke === 'function') return true; + + // Has invoke_type with invoke_target (automatic routing) + if (this.invoke_type && this.invoke_target) { + if (['page', 'external', 'modal'].includes(this.invoke_type)) { + return true; + } + } + + return false; + } + + /** + * Check if menu item requires permissions + * @returns {boolean} + */ + requiresPermits() { + return this.with_permits && this.with_permits.trim().length > 0; + } + + /** + * Get required permissions as array + * @returns {string[]} Array of permission strings + */ + getPermits() { + if (!this.requiresPermits()) return []; + return this.with_permits.split(',').map(p => p.trim()).filter(p => p.length > 0); + } + + /** + * Check generic app-driven visibility. + * @returns {boolean} + */ + isVisible() { + return this.is_visible !== false; + } + + /** + * Check whether this item matches a tag. + * @param {string} tag + * @returns {boolean} + */ + matchesTag(tag) { + if (!tag) return false; + return this.tags.includes(tag); + } + + /** + * Set visibility for this item. + * @param {boolean} yesOrNo + * @returns {MenuItem} + */ + setVisible(yesOrNo) { + this.is_visible = yesOrNo !== false; + return this; + } + + /** + * Set visibility on items matching the given tag. + * @param {boolean} yesOrNo + * @param {string} tag + * @param {Object} [options] + * @param {boolean} [options.recursive=true] + * @returns {number} Number of affected items + */ + setVisibleByTag(yesOrNo, tag, options = {}) { + const { recursive = true } = options; + let changed = 0; + + if (this.matchesTag(tag)) { + this.setVisible(yesOrNo); + changed += 1; + } + + if (recursive) { + for (const child of this.items.values()) { + changed += child.setVisibleByTag(yesOrNo, tag, options); + } + } + + return changed; + } + + /** + * Internal helper for permission visibility metadata. + * @returns {{resource_path: string|null, rights: number}|null} + */ + getPermissionVisibilityRule() { + if (this.visible_when_permitted && typeof this.visible_when_permitted === 'object') { + return { + resource_path: this.visible_when_permitted.resource_path || this.invoke_target || this.path || null, + rights: normalizeRightsInput(this.visible_when_permitted.rights || 0) + }; + } + + if (this.requiresPermits()) { + return { + resource_path: this.invoke_target || this.path || null, + rights: normalizeRightsInput(this.getPermits()) + }; + } + + return null; + } + + /** + * Check security-driven visibility. + * @param {Object} security + * @returns {boolean} + */ + isPermitted(security = null) { + const rule = this.getPermissionVisibilityRule(); + if (!rule) { + return true; + } + + if (!security || security.enabled === false) { + return true; + } + + if (typeof security.isPermitted === 'function') { + return security.isPermitted(rule.rights, rule.resource_path, { menuItem: this }) !== false; + } + + return false; + } + + /** + * Combined renderability check. + * @param {Object} security + * @returns {boolean} + */ + isRenderable(security = null) { + return this.isVisible() && this.isPermitted(security); + } + + /** + * Execute the invoke function or automatic handler based on invoke_type + * @param {HTMLElement|null} eventSource - Source element that triggered the action + * @param {Event|null} event - Original event object + * @returns {any} Return value from invoke function or handler + */ + execute(eventSource = null, event = null) { + if (!this.isActionable()) { + return null; + } + + // If custom invoke function is provided, use it + if (typeof this.invoke === 'function') { + return this.invoke(this, eventSource, event); + } + + // Otherwise, use automatic routing based on invoke_type + if (this.invoke_type && this.invoke_target) { + switch (this.invoke_type) { + case 'page': + if (typeof InvokeHandlers.goToPage === 'function') { + return InvokeHandlers.goToPage(this, eventSource, event); + } + console.warn('[MenuItem] InvokeHandlers.goToPage is not set'); + break; + + case 'external': + if (typeof InvokeHandlers.goToOutside === 'function') { + return InvokeHandlers.goToOutside(this, eventSource, event); + } + console.warn('[MenuItem] InvokeHandlers.goToOutside is not set'); + break; + + case 'modal': + if (typeof InvokeHandlers.goToModal === 'function') { + return InvokeHandlers.goToModal(this, eventSource, event); + } + console.warn('[MenuItem] InvokeHandlers.goToModal is not set'); + break; + + case 'action': + // Action type requires custom invoke function + console.warn('[MenuItem] invoke_type "action" requires a custom invoke function'); + break; + + default: + console.warn(`[MenuItem] Unknown invoke_type: ${this.invoke_type}`); + } + } + + return null; + } + + /** + * Create a copy of this menu item + * @returns {MenuItem} + */ + clone() { + return new MenuItem(this.toObject()); + } + + /** + * Get item by ID + * @param {string} id - Item ID + * @returns {MenuItem|null} + */ + getItem(id) { + return this.items.get(id) || null; + } + + /** + * Add item to this menu item + * @param {string} id - Item ID (must be unique within this parent) + * @param {MenuItem} item - Menu item to add + * @returns {boolean} Success + */ + addItem(id, item) { + if (!id) { + console.warn('[MenuItem] Cannot add item without ID'); + return false; + } + if (this.items.has(id)) { + console.warn(`[MenuItem] Item with ID "${id}" already exists, overwriting`); + } + const menuItem = item instanceof MenuItem ? item : new MenuItem(item); + menuItem.id = id; + this.items.set(id, menuItem); + return true; + } + + /** + * Remove item by ID + * @param {string} id - Item ID + * @returns {boolean} Success + */ + removeItem(id) { + return this.items.delete(id); + } +} + +/** + * Factory function to create MenuItem instances + * @param {Object} config - Menu item configuration + * @returns {MenuItem} + */ +export function createMenuItem(config) { + return new MenuItem(config); +} + +/** + * Validate menu item structure + * @param {any} item - Item to validate + * @returns {boolean} + */ +export function isValidMenuItem(item) { + if (!item) return false; + if (item instanceof MenuItem) return true; + if (typeof item === 'object' && (item.label || item.id)) return true; + return false; +} + +/** + * Menu Directory Constants + * Helper methods to generate prefixed paths for different menu areas + */ +export const MENU_DIRS = { + /** + * Primary menu directory + * @param {string} relativePath - Relative path (e.g., "home" or "dashboard/stats") + * @returns {string} Full path starting with "/primary/" + */ + PRIMARY: (relativePath = '') => { + const cleanPath = relativePath.startsWith('/') ? relativePath.slice(1) : relativePath; + return `/primary/${cleanPath}`.replace(/\/+$/, ''); // Remove trailing slashes + }, + + /** + * Secondary menu directory + * @param {string} relativePath - Relative path + * @returns {string} Full path starting with "/secondary/" + */ + SECONDARY: (relativePath = '') => { + const cleanPath = relativePath.startsWith('/') ? relativePath.slice(1) : relativePath; + return `/secondary/${cleanPath}`.replace(/\/+$/, ''); + }, + + /** + * Settings menu directory + * @param {string} relativePath - Relative path + * @returns {string} Full path starting with "/settings/" + */ + SETTINGS: (relativePath = '') => { + const cleanPath = relativePath.startsWith('/') ? relativePath.slice(1) : relativePath; + return `/settings/${cleanPath}`.replace(/\/+$/, ''); + }, + + /** + * Personal menu directory + * @param {string} relativePath - Relative path + * @returns {string} Full path starting with "/personal/" + */ + PERSONAL: (relativePath = '') => { + const cleanPath = relativePath.startsWith('/') ? relativePath.slice(1) : relativePath; + return `/personal/${cleanPath}`.replace(/\/+$/, ''); + } +}; + +/** + * Initialize menu root with 4 top-level directory items + */ +function initializeMenuRoot() { + if (menuRoot.size > 0) { + return; // Already initialized + } + + // Create root items for each directory + const rootItems = [ + { id: 'primary', label: 'Primary', path: '/primary' }, + { id: 'secondary', label: 'Secondary', path: '/secondary' }, + { id: 'settings', label: 'Settings', path: '/settings' }, + { id: 'personal', label: 'Personal', path: '/personal' } + ]; + + for (const rootConfig of rootItems) { + const rootItem = new MenuItem(rootConfig); + menuRoot.set(rootConfig.id, rootItem); + } + + console.log('[Menu] Initialized menu root with 4 directory items'); +} + +// Initialize on module load +initializeMenuRoot(); + +/** + * Normalize path to ensure it starts with "/" + * @param {string} path - Path to normalize + * @returns {string} Normalized path + */ +function normalizePath(path) { + if (!path) return '/'; + // Ensure path starts with "/" + return path.startsWith('/') ? path : `/${path}`; +} + +/** + * Split path into parts (excluding empty parts) + * @param {string} path - Path to split + * @returns {string[]} Array of path parts + */ +function splitPath(path) { + const normalized = normalizePath(path); + return normalized.split('/').filter(part => part.length > 0); +} + +/** + * Get or create menu item at path, creating parent items as needed + * @param {string} path - Full path (e.g., "/primary/itemA/itemB") + * @returns {MenuItem|null} Menu item at path, or null if path is invalid + */ +function getOrCreateMenuItemAtPath(path) { + const parts = splitPath(path); + + if (parts.length === 0) { + return null; + } + + // First part must be a root directory + const rootId = parts[0]; + if (!['primary', 'secondary', 'settings', 'personal'].includes(rootId)) { + console.warn(`[Menu] Invalid root directory: ${rootId}`); + return null; + } + + // Get or create root item + let current = menuRoot.get(rootId); + if (!current) { + current = new MenuItem({ + id: rootId, + label: rootId.charAt(0).toUpperCase() + rootId.slice(1), + path: `/${rootId}` + }); + menuRoot.set(rootId, current); + } + + // Navigate/create through path parts + for (let i = 1; i < parts.length; i++) { + const part = parts[i]; + let child = current.getItem(part); + + if (!child) { + // Create new item + child = new MenuItem({ + id: part, + label: part.charAt(0).toUpperCase() + part.slice(1).replace(/-/g, ' '), + path: `/${parts.slice(0, i + 1).join('/')}` + }); + current.addItem(part, child); + } + + current = child; + } + + return current; +} + +/** + * Get menu item at path (does not create) + * @param {string} path - Full path + * @returns {MenuItem|null} Menu item at path, or null if not found + */ +function getMenuItemAtPath(path) { + const parts = splitPath(path); + + if (parts.length === 0) { + return null; + } + + // First part must be a root directory + const rootId = parts[0]; + if (!['primary', 'secondary', 'settings', 'personal'].includes(rootId)) { + return null; + } + + // Get root item + let current = menuRoot.get(rootId); + if (!current) { + return null; + } + + // Navigate through path parts + for (let i = 1; i < parts.length; i++) { + const part = parts[i]; + current = current.getItem(part); + if (!current) { + return null; + } + } + + return current; +} + +/** + * Publish a menu item at the given path + * Creates parent items as needed and registers item hierarchically + * @param {string} path - Path like "/primary/home" or "/primary/itemA/itemB" (must start with "/") + * @param {MenuItem|Object} item - Menu item (MenuItem instance or plain object) + */ +export function publishMenuItem(path, item) { + if (!path || !item) { + console.warn('[Menu] Invalid path or item'); + return false; + } + + // Validate menu item + if (!isValidMenuItem(item)) { + console.warn('[Menu] Invalid menu item structure', item); + return false; + } + + // Normalize path to ensure it starts with "/" + const normalizedPath = normalizePath(path); + const parts = splitPath(normalizedPath); + + if (parts.length === 0) { + console.warn('[Menu] Cannot publish to root path'); + return false; + } + + // Get or create parent item + const itemId = parts[parts.length - 1]; + const parentPath = parts.length > 1 ? `/${parts.slice(0, -1).join('/')}` : `/${parts[0]}`; + + let parent; + if (parts.length === 1) { + // Publishing directly to root directory + const rootId = parts[0]; + if (!['primary', 'secondary', 'settings', 'personal'].includes(rootId)) { + console.warn(`[Menu] Invalid root directory: ${rootId}`); + return false; + } + parent = menuRoot.get(rootId); + if (!parent) { + parent = new MenuItem({ + id: rootId, + label: rootId.charAt(0).toUpperCase() + rootId.slice(1), + path: `/${rootId}` + }); + menuRoot.set(rootId, parent); + } + } else { + // Publishing to nested path - get or create parent + parent = getOrCreateMenuItemAtPath(parentPath); + if (!parent) { + console.warn(`[Menu] Failed to get/create parent at: ${parentPath}`); + return false; + } + } + + // Convert to MenuItem if it's a plain object + let menuItem; + if (item instanceof MenuItem) { + menuItem = item; + } else { + menuItem = new MenuItem(item); + } + + // Set ID from path if not provided (last token in path) + if (!menuItem.id) { + menuItem.id = itemId; + } else if (menuItem.id !== itemId) { + // Warn if provided ID doesn't match path (this is expected when publishing same item under different paths) + console.warn(`[Menu] Item ID "${menuItem.id}" doesn't match path token "${itemId}" (expected when publishing same item under different paths). Using "${itemId}" from path.`); + menuItem.id = itemId; + } + + // Set full path on item + menuItem.path = normalizedPath; + if (menuPreferencesLoaded) { + applyStoredVisibilityPreference(menuItem); + } + + // Add to parent's items Map + parent.addItem(itemId, menuItem); + + console.log(`[Menu] Published item at: ${normalizedPath} (ID: ${itemId})`); + + // Notify listeners of menu change + notifyMenuChange(); + + return true; +} + +/** + * Retract (remove) a menu item by path + * @param {string} path - Path to remove (will be normalized to start with "/") + */ +export function retractMenuItem(path) { + if (!path) { + console.warn('[Menu] Invalid path'); + return false; + } + + // Normalize path + const normalizedPath = normalizePath(path); + const parts = splitPath(normalizedPath); + + if (parts.length === 0) { + console.warn('[Menu] Cannot retract root path'); + return false; + } + + // Get parent item + const itemId = parts[parts.length - 1]; + let parent; + + if (parts.length === 1) { + // Retracting root directory item + const rootId = parts[0]; + if (!['primary', 'secondary', 'settings', 'personal'].includes(rootId)) { + console.warn(`[Menu] Invalid root directory: ${rootId}`); + return false; + } + parent = menuRoot.get(rootId); + if (!parent) { + console.warn(`[Menu] Root item not found: ${rootId}`); + return false; + } + // Cannot remove root items + console.warn(`[Menu] Cannot remove root directory item: ${rootId}`); + return false; + } else { + // Retracting nested item + const parentPath = `/${parts.slice(0, -1).join('/')}`; + parent = getMenuItemAtPath(parentPath); + if (!parent) { + console.warn(`[Menu] Parent not found at: ${parentPath}`); + return false; + } + } + + const removed = parent.removeItem(itemId); + if (removed) { + console.log(`[Menu] Retracted item at: ${normalizedPath}`); + // Notify listeners of menu change + notifyMenuChange(); + } else { + console.warn(`[Menu] Item not found at: ${normalizedPath}`); + } + + return removed; +} + +/** + * Query menu items by path prefix + * Returns all items under the given path (including nested items) + * @param {string} pathPrefix - Path prefix to match (e.g., "/primary" matches all items under primary) + * If empty string, returns all root items + * @returns {Array} List of matching menu items (as plain objects) + */ +export function queryMenuItems(pathPrefix = '') { + const results = []; + + if (!pathPrefix || pathPrefix === '/') { + // Return all root items + for (const rootItem of menuRoot.values()) { + results.push(rootItem.toObject()); + } + return results; + } + + // Normalize prefix + const normalizedPrefix = normalizePath(pathPrefix); + const prefixParts = splitPath(normalizedPrefix); + + // Get root item + if (prefixParts.length === 0) { + return results; + } + + const rootId = prefixParts[0]; + if (!['primary', 'secondary', 'settings', 'personal'].includes(rootId)) { + return results; + } + + const rootItem = menuRoot.get(rootId); + if (!rootItem) { + return results; + } + + // If querying root, return root item + if (prefixParts.length === 1) { + results.push(rootItem.toObject()); + // Also include all nested items + function collectItems(item) { + for (const child of item.items.values()) { + results.push(child.toObject()); + if (child.hasItems()) { + collectItems(child); + } + } + } + collectItems(rootItem); + } else { + // Navigate to specific path and collect items + let current = rootItem; + for (let i = 1; i < prefixParts.length; i++) { + current = current.getItem(prefixParts[i]); + if (!current) { + return results; // Path not found + } + } + // Add current item and all its children + results.push(current.toObject()); + function collectItems(item) { + for (const child of item.items.values()) { + results.push(child.toObject()); + if (child.hasItems()) { + collectItems(child); + } + } + } + collectItems(current); + } + + // Sort by path for consistent ordering + results.sort((a, b) => (a.path || '').localeCompare(b.path || '')); + + return results; +} + +/** + * Get a specific menu item by path + * @param {string} path - Exact path (will be normalized to start with "/") + * @returns {Object|null} Menu item (as plain object) or null + */ +export function getMenuItem(path) { + if (!path) return null; + const item = getMenuItemAtPath(path); + if (!item) return null; + return item.toObject(); +} + +/** + * Get all menu items (flattened structure) + * @returns {Array} All menu items (as plain objects) + */ +export function getAllMenuItems() { + const results = []; + + function collectItems(item) { + results.push(item.toObject()); + for (const child of item.items.values()) { + collectItems(child); + } + } + + for (const rootItem of menuRoot.values()) { + collectItems(rootItem); + } + + return results; +} + +/** + * Clear all menu items (except root items) + */ +export function clearMenu() { + for (const rootItem of menuRoot.values()) { + rootItem.items.clear(); + } + console.log('[Menu] Cleared all items (root items preserved)'); +} + +export async function restoreMenuPreferences() { + try { + const storedPreferences = await menuPreferenceStorage?.get(MENU_PREFERENCES_KEY, DEFAULT_MENU_PREFERENCES); + menuPreferences = normalizeMenuPreferences(storedPreferences || DEFAULT_MENU_PREFERENCES); + } catch (error) { + console.warn('[Menu] Failed to restore menu preferences:', error); + menuPreferences = normalizeMenuPreferences(DEFAULT_MENU_PREFERENCES); + } + + menuPreferencesLoaded = true; + applyStoredVisibilityPreferencesToRegisteredItems(); + notifyMenuChange(); + return cloneMenuPreferences(); +} + +export function setMenuItemExpandedPreference(path, yesOrNo) { + if (!path) { + return false; + } + + setStoredExpandedPreference(path, yesOrNo); + notifyMenuChange(); + return true; +} + +export async function clearMenuPreferences(options = {}) { + const { + visibility = true, + expanded = true + } = options; + + if (visibility) { + menuPreferences.visibility = {}; + for (const rootItem of menuRoot.values()) { + walkMenuItems(rootItem, (item) => { + item.setVisible(true); + }); + } + } + + if (expanded) { + menuPreferences.expanded = {}; + } + + if (menuPreferencePersistTimer) { + clearTimeout(menuPreferencePersistTimer); + menuPreferencePersistTimer = null; + } + + await persistMenuPreferencesNow(); + notifyMenuChange(); + return cloneMenuPreferences(); +} + +export async function clearMenuItemPreferences(path, options = {}) { + if (!path) { + return false; + } + + const { + visibility = true, + expanded = true + } = options; + + if (visibility) { + delete menuPreferences.visibility[path]; + const item = getMenuItemAtPath(path); + if (item) { + item.setVisible(true); + } + } + + if (expanded) { + delete menuPreferences.expanded[path]; + } + + if (menuPreferencePersistTimer) { + clearTimeout(menuPreferencePersistTimer); + menuPreferencePersistTimer = null; + } + + await persistMenuPreferencesNow(); + notifyMenuChange(); + return true; +} + +function normalizePreferenceMap(value) { + if (!value || typeof value !== 'object') { + return {}; + } + + return Object.fromEntries( + Object.entries(value) + .filter(([path]) => typeof path === 'string' && path.length > 0) + .map(([path, flag]) => [path, flag !== false]) + ); +} + +function normalizeMenuPreferences(value = {}) { + return { + visibility: normalizePreferenceMap(value.visibility), + expanded: normalizePreferenceMap(value.expanded) + }; +} + +function cloneMenuPreferences() { + return { + visibility: { ...menuPreferences.visibility }, + expanded: { ...menuPreferences.expanded } + }; +} + +async function persistMenuPreferencesNow() { + if (!menuPreferenceStorage) { + return; + } + + try { + await menuPreferenceStorage.set(MENU_PREFERENCES_KEY, cloneMenuPreferences()); + } catch (error) { + console.warn('[Menu] Failed to persist menu preferences:', error); + } +} + +function scheduleMenuPreferencePersist() { + if (menuPreferencePersistTimer) { + clearTimeout(menuPreferencePersistTimer); + } + + menuPreferencePersistTimer = setTimeout(() => { + menuPreferencePersistTimer = null; + persistMenuPreferencesNow(); + }, 160); +} + +function setStoredVisibilityPreference(path, yesOrNo) { + if (!path) { + return; + } + menuPreferences.visibility[path] = yesOrNo !== false; + scheduleMenuPreferencePersist(); +} + +function setStoredExpandedPreference(path, yesOrNo) { + if (!path) { + return; + } + menuPreferences.expanded[path] = yesOrNo !== false; + scheduleMenuPreferencePersist(); +} + +function applyStoredVisibilityPreference(item) { + if (!item?.path) { + return; + } + + if (Object.prototype.hasOwnProperty.call(menuPreferences.visibility, item.path)) { + item.setVisible(menuPreferences.visibility[item.path]); + } +} + +function walkMenuItems(startItem, visitor) { + if (!startItem) { + return; + } + + visitor(startItem); + for (const child of startItem.items.values()) { + walkMenuItems(child, visitor); + } +} + +function applyStoredVisibilityPreferencesToRegisteredItems() { + for (const rootItem of menuRoot.values()) { + walkMenuItems(rootItem, (item) => { + applyStoredVisibilityPreference(item); + }); + } +} + +/** + * Get menu structure as nested tree + * @returns {Object} Nested menu structure with root items + */ +export function getMenuTree() { + const tree = {}; + + for (const [rootId, rootItem] of menuRoot.entries()) { + tree[rootId] = rootItem.toObject(); + } + + return tree; +} + +/** + * Get root menu item by directory ID + * @param {string} rootId - Root directory ID ('primary', 'secondary', 'settings', 'personal') + * @returns {MenuItem|null} + */ +export function getRootItem(rootId) { + return menuRoot.get(rootId) || null; +} + +/** + * Get all root items + * @returns {Map} Map of root items + */ +export function getRootItems() { + return new Map(menuRoot); +} + +/** + * Set visibility for one menu item by exact path. + * @param {string} path + * @param {boolean} yesOrNo + * @returns {boolean} + */ +export function setMenuItemVisibility(path, yesOrNo) { + if (!path) { + return false; + } + + const item = getMenuItemAtPath(path); + if (!item) { + return false; + } + + item.setVisible(yesOrNo); + setStoredVisibilityPreference(item.path, yesOrNo); + notifyMenuChange(); + return true; +} + +/** + * Set visibility for all items matching a tag. + * @param {string} tag + * @param {boolean} yesOrNo + * @param {Object} [options] + * @param {boolean} [options.recursive=true] + * @returns {number} + */ +export function setMenuItemsVisibilityByTag(tag, yesOrNo, options = {}) { + if (!tag) { + return 0; + } + + let changed = 0; + for (const rootItem of menuRoot.values()) { + changed += rootItem.setVisibleByTag(yesOrNo, tag, options); + } + + if (changed > 0) { + for (const rootItem of menuRoot.values()) { + walkMenuItems(rootItem, (item) => { + if (item.matchesTag(tag)) { + setStoredVisibilityPreference(item.path, yesOrNo); + } + }); + } + notifyMenuChange(); + } + + return changed; +} + +/** + * Set visibility for all items under a path prefix. + * @param {string} pathPrefix + * @param {boolean} yesOrNo + * @param {Object} [options] + * @param {boolean} [options.includeRoot=true] + * @param {boolean} [options.recursive=false] + * @returns {number} + */ +export function setMenuItemsVisibilityUnderPath(pathPrefix, yesOrNo, options = {}) { + const { includeRoot = true, recursive = false } = options; + if (!pathPrefix) { + return 0; + } + + const rootItem = getMenuItemAtPath(pathPrefix); + if (!rootItem) { + return 0; + } + + let changed = 0; + if (includeRoot) { + rootItem.setVisible(yesOrNo); + setStoredVisibilityPreference(rootItem.path, yesOrNo); + changed += 1; + } + + if (recursive) { + for (const child of rootItem.items.values()) { + walkMenuItems(child, (item) => { + item.setVisible(yesOrNo); + setStoredVisibilityPreference(item.path, yesOrNo); + changed += 1; + }); + } + } + + if (changed > 0) { + notifyMenuChange(); + } + + return changed; +} diff --git a/src/platform/storage.js b/src/platform/storage.js new file mode 100644 index 0000000..7c49805 --- /dev/null +++ b/src/platform/storage.js @@ -0,0 +1,220 @@ +/** + * Storage Provider System + * Provides abstraction over different storage types (KeyValueStore, etc.) + */ + +// Private providers map: type.uri -> provider instance +const providers = new Map(); + +/** + * KeyValueStore Class + * Provides key-value storage abstraction over localStorage, IndexedDB, and OPFS + */ +class KeyValueStore { + constructor(name, backend = 'localStorage') { + this.name = name; + this.backend = backend; // 'localStorage' | 'indexedDB' | 'opfs' + } + + /** + * Get item from storage + * @param {string} key + * @param {any} altValue - Alternative value to return if key not found (default: null) + * @returns {Promise} + */ + async get(key, altValue = null) { + let value; + switch (this.backend) { + case 'localStorage': + value = await this._getLocalStorage(key); + break; + case 'indexedDB': + value = await this._getIndexedDB(key); + break; + case 'opfs': + value = await this._getOPFS(key); + break; + default: + throw new Error(`Unknown storage backend: ${this.backend}`); + } + // Return altValue if key not found (value is null or undefined) + return value !== null && value !== undefined ? value : altValue; + } + + /** + * Set item in storage + * @param {string} key + * @param {any} value + * @returns {Promise} + */ + async set(key, value) { + switch (this.backend) { + case 'localStorage': + return this._setLocalStorage(key, value); + case 'indexedDB': + return this._setIndexedDB(key, value); + case 'opfs': + return this._setOPFS(key, value); + default: + throw new Error(`Unknown storage backend: ${this.backend}`); + } + } + + /** + * Remove item from storage + * @param {string} key + * @returns {Promise} + */ + async remove(key) { + switch (this.backend) { + case 'localStorage': + return this._removeLocalStorage(key); + case 'indexedDB': + return this._removeIndexedDB(key); + case 'opfs': + return this._removeOPFS(key); + default: + throw new Error(`Unknown storage backend: ${this.backend}`); + } + } + + /** + * Clear all storage + * @returns {Promise} + */ + async clear() { + switch (this.backend) { + case 'localStorage': + return this._clearLocalStorage(); + case 'indexedDB': + return this._clearIndexedDB(); + case 'opfs': + return this._clearOPFS(); + default: + throw new Error(`Unknown storage backend: ${this.backend}`); + } + } + + /** + * Check if a key exists in storage + * @param {string} key + * @returns {Promise} + */ + async hasKey(key) { + switch (this.backend) { + case 'localStorage': + return this._hasKeyLocalStorage(key); + case 'indexedDB': + return this._hasKeyIndexedDB(key); + case 'opfs': + return this._hasKeyOPFS(key); + default: + throw new Error(`Unknown storage backend: ${this.backend}`); + } + } + + // LocalStorage implementations + _getLocalStorage(key) { + const value = localStorage.getItem(key); + return Promise.resolve(value ? JSON.parse(value) : null); + } + + _hasKeyLocalStorage(key) { + return Promise.resolve(localStorage.getItem(key) !== null); + } + + _setLocalStorage(key, value) { + localStorage.setItem(key, JSON.stringify(value)); + return Promise.resolve(); + } + + _removeLocalStorage(key) { + localStorage.removeItem(key); + return Promise.resolve(); + } + + _clearLocalStorage() { + localStorage.clear(); + return Promise.resolve(); + } + + // IndexedDB implementations (placeholder - to be expanded) + async _getIndexedDB(key) { + // TODO: Implement IndexedDB access + throw new Error('IndexedDB not yet implemented'); + } + + async _hasKeyIndexedDB(key) { + // TODO: Implement IndexedDB access + throw new Error('IndexedDB not yet implemented'); + } + + async _setIndexedDB(key, value) { + // TODO: Implement IndexedDB access + throw new Error('IndexedDB not yet implemented'); + } + + async _removeIndexedDB(key) { + // TODO: Implement IndexedDB access + throw new Error('IndexedDB not yet implemented'); + } + + async _clearIndexedDB() { + // TODO: Implement IndexedDB access + throw new Error('IndexedDB not yet implemented'); + } + + // OPFS implementations (placeholder - to be expanded) + async _getOPFS(key) { + // TODO: Implement OPFS access + throw new Error('OPFS not yet implemented'); + } + + async _hasKeyOPFS(key) { + // TODO: Implement OPFS access + throw new Error('OPFS not yet implemented'); + } + + async _setOPFS(key, value) { + // TODO: Implement OPFS access + throw new Error('OPFS not yet implemented'); + } + + async _removeOPFS(key) { + // TODO: Implement OPFS access + throw new Error('OPFS not yet implemented'); + } + + async _clearOPFS() { + // TODO: Implement OPFS access + throw new Error('OPFS not yet implemented'); + } +} + +/** + * Get or create a storage provider + * @param {string} type - Provider type (e.g., "kv" for KeyValueStore) + * @param {string} uri - Provider URI/identifier (e.g., "config", "cache") + * @returns {KeyValueStore|null} Provider instance or null if type not supported + */ +export function getProvider(type, uri) { + const key = `${type}.${uri}`; + + // Check if provider already exists + if (providers.has(key)) { + return providers.get(key); + } + + // Create new provider based on type + if (type === 'kv') { + const provider = new KeyValueStore(uri, 'localStorage'); + providers.set(key, provider); + return provider; + } + + // Type not supported + return null; +} + +// Export KeyValueStore class for direct instantiation if needed +export { KeyValueStore }; diff --git a/src/platform/sw-register.js b/src/platform/sw-register.js new file mode 100644 index 0000000..f297880 --- /dev/null +++ b/src/platform/sw-register.js @@ -0,0 +1,233 @@ +/** + * Service Worker Registration + */ + +import { isElectronHost } from './host.js'; +import { getConfig, isDevelopment, CONFIG_KEYS } from './env.js'; + +const SW_PATH = '/sw.js'; +const SW_SCOPE = '/'; +const DEV_SW_RESET_KEY = '__bface_dev_sw_reset__'; + +/** + * Clear all caches + */ +export async function clearAllCaches() { + if ('caches' in window) { + try { + const cacheNames = await caches.keys(); + await Promise.all(cacheNames.map(name => { + console.log('[SW] Clearing cache:', name); + return caches.delete(name); + })); + console.log('[SW] All caches cleared'); + return cacheNames.length; + } catch (error) { + console.error('[SW] Failed to clear caches:', error); + throw error; + } + } + return 0; +} + +/** + * Unregister all service workers + */ +export async function unregisterAllServiceWorkers() { + if ('serviceWorker' in navigator) { + try { + const registrations = await navigator.serviceWorker.getRegistrations(); + await Promise.all(registrations.map(reg => { + console.log('[SW] Unregistering service worker:', reg.scope); + return reg.unregister(); + })); + console.log(`[SW] Unregistered ${registrations.length} service worker(s)`); + return registrations.length; + } catch (error) { + console.error('[SW] Failed to unregister service workers:', error); + throw error; + } + } + return 0; +} + +/** + * Clear all storage (localStorage, sessionStorage, IndexedDB) + */ +export async function clearAllStorage() { + try { + // Clear localStorage + localStorage.clear(); + console.log('[Storage] localStorage cleared'); + + // Clear sessionStorage + sessionStorage.clear(); + console.log('[Storage] sessionStorage cleared'); + + // Clear IndexedDB databases + if ('indexedDB' in window) { + const databases = await indexedDB.databases(); + await Promise.all(databases.map(db => { + if (db.name) { + return new Promise((resolve, reject) => { + const deleteReq = indexedDB.deleteDatabase(db.name); + deleteReq.onsuccess = () => { + console.log(`[Storage] IndexedDB database "${db.name}" deleted`); + resolve(); + }; + deleteReq.onerror = () => { + console.warn(`[Storage] Failed to delete IndexedDB database "${db.name}"`); + resolve(); // Continue even if one fails + }; + }); + } + })); + } + + console.log('[Storage] All storage cleared'); + return true; + } catch (error) { + console.error('[Storage] Failed to clear storage:', error); + throw error; + } +} + +/** + * Clear everything: caches, service workers, and storage + * This is the main utility function for clearing all PWA data + */ +export async function clearPWACache() { + console.log('🧹 Clearing all PWA caches and storage...'); + + try { + // 1. Unregister service workers + const swCount = await unregisterAllServiceWorkers(); + + // 2. Clear all caches + const cacheCount = await clearAllCaches(); + + // 3. Clear all storage + await clearAllStorage(); + + console.log(`✅ PWA cache cleared: ${swCount} service worker(s), ${cacheCount} cache(s), and all storage`); + console.log('💡 Reload the page to re-register service workers'); + + return { + serviceWorkers: swCount, + caches: cacheCount, + storage: true + }; + } catch (error) { + console.error('❌ Failed to clear PWA cache:', error); + throw error; + } +} + +/** + * Register service worker + */ +export async function registerServiceWorker() { + if (isElectronHost()) { + console.log('[SW] Skipping service worker registration in Electron host'); + return null; + } + + const devHost = await getConfig(CONFIG_KEYS.DEV_HOST, isDevelopment()); + if (devHost) { + console.log('[SW] Skipping service worker registration in development'); + return null; + } + + if ('serviceWorker' in navigator) { + try { + + // Register or get existing service worker + const registration = await navigator.serviceWorker.register(SW_PATH, { + scope: SW_SCOPE, + updateViaCache: 'none' // Always fetch fresh service worker + }); + + console.log('Service Worker registered:', registration); + + // Force immediate update check to get latest version + await registration.update(); + + // Handle updates + registration.addEventListener('updatefound', () => { + const newWorker = registration.installing; + if (newWorker) { + newWorker.addEventListener('statechange', () => { + if (newWorker.state === 'installed') { + if (navigator.serviceWorker.controller) { + console.log('[SW] New service worker available, reloading...'); + // Force reload to activate new worker + window.location.reload(); + } else { + console.log('[SW] Service worker installed for the first time'); + } + } + }); + } + }); + + return registration; + } catch (error) { + console.error('Service Worker registration failed:', error); + throw error; + } + } else { + console.warn('Service Workers are not supported'); + return null; + } +} + +/** + * In development, stale service workers can break Vite module loading by + * intercepting /@fs and related requests. Clear them once and reload cleanly. + */ +export async function ensureDevelopmentServiceWorkerState() { + if (isElectronHost()) { + return false; + } + + const devHost = await getConfig(CONFIG_KEYS.DEV_HOST, isDevelopment()); + if (!devHost || typeof window === 'undefined' || !('serviceWorker' in navigator)) { + return false; + } + + const hasController = Boolean(navigator.serviceWorker.controller); + const alreadyReset = sessionStorage.getItem(DEV_SW_RESET_KEY) === '1'; + + if (!hasController || alreadyReset) { + return false; + } + + try { + await unregisterAllServiceWorkers(); + await clearAllCaches(); + } catch (error) { + console.warn('[SW] Failed to reset dev service workers:', error); + } + + sessionStorage.setItem(DEV_SW_RESET_KEY, '1'); + window.location.reload(); + return true; +} + +/** + * Unregister service worker + */ +export async function unregisterServiceWorker() { + if (isElectronHost()) { + return; + } + + if ('serviceWorker' in navigator) { + const registration = await navigator.serviceWorker.getRegistration(); + if (registration) { + await registration.unregister(); + console.log('Service Worker unregistered'); + } + } +} + diff --git a/src/security/index.js b/src/security/index.js new file mode 100644 index 0000000..5b25ffc --- /dev/null +++ b/src/security/index.js @@ -0,0 +1,6 @@ +export * from './model/index.js'; +export * from './policy/index.js'; +export * from './runtime/security-service.js'; +export * from './runtime/route-guards.js'; +export * from './runtime/account-tabs.js'; +export * from './pages/index.js'; diff --git a/src/security/model/AccountProfile.js b/src/security/model/AccountProfile.js new file mode 100644 index 0000000..4f500f3 --- /dev/null +++ b/src/security/model/AccountProfile.js @@ -0,0 +1,10 @@ +export class AccountProfile { + constructor(data = {}) { + this.user_id = data.user_id || null; + this.display_name = data.display_name || ''; + this.email = data.email || ''; + this.image_url = data.image_url || ''; + this.preferences = data.preferences || {}; + this.updated_on = data.updated_on || new Date().toISOString(); + } +} diff --git a/src/security/model/Permit.js b/src/security/model/Permit.js new file mode 100644 index 0000000..3951a5f --- /dev/null +++ b/src/security/model/Permit.js @@ -0,0 +1,14 @@ +import { normalizeRightsInput } from './rights.js'; + +export class Permit { + constructor(data = {}) { + this.id = data.id || null; + this.principal_type = data.principal_type || 'role'; + this.principal_id = data.principal_id || ''; + this.resource_path = data.resource_path || '*'; + this.rights = normalizeRightsInput(data.rights || 0); + this.effect = data.effect || 'allow'; + this.created_on = data.created_on || new Date().toISOString(); + this.updated_on = data.updated_on || this.created_on; + } +} diff --git a/src/security/model/Realm.js b/src/security/model/Realm.js new file mode 100644 index 0000000..4f8d99e --- /dev/null +++ b/src/security/model/Realm.js @@ -0,0 +1,9 @@ +export class Realm { + constructor(data = {}) { + this.id = data.id || null; + this.name = data.name || ''; + this.description = data.description || ''; + this.created_on = data.created_on || new Date().toISOString(); + this.updated_on = data.updated_on || this.created_on; + } +} diff --git a/src/security/model/Resource.js b/src/security/model/Resource.js new file mode 100644 index 0000000..0a4432f --- /dev/null +++ b/src/security/model/Resource.js @@ -0,0 +1,10 @@ +export class Resource { + constructor(data = {}) { + this.path = data.path || '/'; + this.realm_id = data.realm_id || 'local'; + this.type = data.type || 'ui-route'; + this.metadata = data.metadata || {}; + this.created_on = data.created_on || new Date().toISOString(); + this.updated_on = data.updated_on || this.created_on; + } +} diff --git a/src/security/model/Role.js b/src/security/model/Role.js new file mode 100644 index 0000000..e688fe3 --- /dev/null +++ b/src/security/model/Role.js @@ -0,0 +1,10 @@ +export class Role { + constructor(data = {}) { + this.id = data.id || null; + this.name = data.name || ''; + this.realm_id = data.realm_id || 'local'; + this.description = data.description || ''; + this.created_on = data.created_on || new Date().toISOString(); + this.updated_on = data.updated_on || this.created_on; + } +} diff --git a/src/security/model/Session.js b/src/security/model/Session.js new file mode 100644 index 0000000..46f001f --- /dev/null +++ b/src/security/model/Session.js @@ -0,0 +1,12 @@ +export class Session { + constructor(data = {}) { + this.user_id = data.user_id || null; + this.realm_id = data.realm_id || 'local'; + this.jwt_token = data.jwt_token || ''; + this.refresh_token = data.refresh_token || ''; + this.issued_on = data.issued_on || new Date().toISOString(); + this.expires_on = data.expires_on || null; + this.auth_provider = data.auth_provider || 'basic'; + this.status = data.status || 'active'; + } +} diff --git a/src/security/model/User.js b/src/security/model/User.js new file mode 100644 index 0000000..8f106cd --- /dev/null +++ b/src/security/model/User.js @@ -0,0 +1,15 @@ +export class User { + constructor(data = {}) { + this.id = data.id || null; + this.username = data.username || ''; + this.display_name = data.display_name || data.displayName || ''; + this.email = data.email || ''; + this.image_url = data.image_url || ''; + this.realm_id = data.realm_id || 'local'; + this.role_ids = Array.isArray(data.role_ids) ? [...data.role_ids] : []; + this.status = data.status || 'active'; + this.password_hash = data.password_hash || ''; + this.created_on = data.created_on || new Date().toISOString(); + this.updated_on = data.updated_on || this.created_on; + } +} diff --git a/src/security/model/index.js b/src/security/model/index.js new file mode 100644 index 0000000..e394525 --- /dev/null +++ b/src/security/model/index.js @@ -0,0 +1,15 @@ +export { AccountProfile } from './AccountProfile.js'; +export { Permit } from './Permit.js'; +export { Realm } from './Realm.js'; +export { Resource } from './Resource.js'; +export { Role } from './Role.js'; +export { Session } from './Session.js'; +export { User } from './User.js'; +export { + SECURITY_RIGHTS, + SECURITY_RIGHT_NAMES, + normalizeRightsInput, + rightsToArray, + rightsToObject, + hasRequiredRights +} from './rights.js'; diff --git a/src/security/model/rights.js b/src/security/model/rights.js new file mode 100644 index 0000000..82eba40 --- /dev/null +++ b/src/security/model/rights.js @@ -0,0 +1,57 @@ +export const SECURITY_RIGHTS = { + read: 1 << 0, + write: 1 << 1, + delete: 1 << 2, + execute: 1 << 3, + secure: 1 << 4 +}; + +export const SECURITY_RIGHT_NAMES = Object.keys(SECURITY_RIGHTS); + +export function normalizeRightsInput(rights = 0) { + if (typeof rights === 'number' && Number.isFinite(rights)) { + return rights; + } + + if (typeof rights === 'string') { + return rights + .split(',') + .map((value) => value.trim()) + .filter(Boolean) + .reduce((mask, right) => mask | (SECURITY_RIGHTS[right] || 0), 0); + } + + if (Array.isArray(rights)) { + return rights.reduce((mask, right) => mask | (SECURITY_RIGHTS[right] || 0), 0); + } + + if (rights && typeof rights === 'object') { + return SECURITY_RIGHT_NAMES.reduce((mask, right) => { + return rights[right] ? mask | SECURITY_RIGHTS[right] : mask; + }, 0); + } + + return 0; +} + +export function rightsToObject(rights = 0) { + const mask = normalizeRightsInput(rights); + return SECURITY_RIGHT_NAMES.reduce((result, right) => { + result[right] = (mask & SECURITY_RIGHTS[right]) !== 0; + return result; + }, {}); +} + +export function rightsToArray(rights = 0) { + const mask = normalizeRightsInput(rights); + return SECURITY_RIGHT_NAMES.filter((right) => (mask & SECURITY_RIGHTS[right]) !== 0); +} + +export function hasRequiredRights(grantedRights = 0, requestedRights = 0) { + const grantedMask = normalizeRightsInput(grantedRights); + const requestedMask = normalizeRightsInput(requestedRights); + if (requestedMask === 0) { + return true; + } + return (grantedMask & requestedMask) === requestedMask; +} diff --git a/src/security/pages/AccountHomePage.jsx b/src/security/pages/AccountHomePage.jsx new file mode 100644 index 0000000..06f558e --- /dev/null +++ b/src/security/pages/AccountHomePage.jsx @@ -0,0 +1,17 @@ +import React from 'react'; +import { Page } from '../../ui/components/Page.jsx'; +import { AccountProfilePage } from './AccountProfilePage.jsx'; + +export function AccountHomePage({ title = 'Account', icon = 'account' }) { + return ( + + + + ); +} + +export function ProfileHomePage() { + return ; +} + +export default AccountHomePage; diff --git a/src/security/pages/AccountProfilePage.jsx b/src/security/pages/AccountProfilePage.jsx new file mode 100644 index 0000000..3d7e4d3 --- /dev/null +++ b/src/security/pages/AccountProfilePage.jsx @@ -0,0 +1,270 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { Avatar, Button, Input, Label, Paragraph, ScrollView, Text, XStack, YStack } from 'tamagui'; +import { useApp } from '../../ui/App.jsx'; +import { Panel } from '../../ui/components/Panel.jsx'; +import { pickImage } from '../../platform/compat.js'; +import { useAccountTabs } from '../runtime/account-tabs.js'; + +function AccountExtensionTabs() { + const tabs = useAccountTabs(); + + if (tabs.length === 0) { + return ( + + No additional account extensions are registered yet. + + ); + } + + return ( + + {tabs.map((tab) => { + const TabComponent = tab.component; + return ( + + + + ); + })} + + ); +} + +export function AccountProfilePage() { + const { security } = useApp(); + const profile = security.profile || {}; + const user = security.user || {}; + const [form, setForm] = useState({ + display_name: '', + email: '', + image_url: '' + }); + const [passwords, setPasswords] = useState({ + currentPassword: '', + newPassword: '' + }); + const [message, setMessage] = useState(''); + const [uploadMessage, setUploadMessage] = useState(''); + const initials = useMemo(() => { + const source = form.display_name || user.username || 'U'; + return source + .split(/\s+/) + .filter(Boolean) + .slice(0, 2) + .map((part) => part[0]?.toUpperCase() || '') + .join(''); + }, [form.display_name, user.username]); + + useEffect(() => { + setForm({ + display_name: profile.display_name || user.display_name || '', + email: profile.email || user.email || '', + image_url: profile.image_url || user.image_url || '' + }); + }, [profile, user]); + + const saveProfile = async () => { + setMessage(''); + try { + await security.updateAccountProfile(form); + setMessage('Profile updated'); + } catch (error) { + setMessage(error.message || 'Failed to update profile'); + } + }; + + const savePassword = async () => { + setMessage(''); + try { + await security.changePassword(passwords); + setPasswords({ + currentPassword: '', + newPassword: '' + }); + setMessage('Password updated'); + } catch (error) { + setMessage(error.message || 'Failed to update password'); + } + }; + + const handlePasswordSubmit = (event) => { + event?.preventDefault?.(); + savePassword(); + }; + + const pickProfileImage = async () => { + const selection = await pickImage(); + if (!selection?.file) { + return; + } + + setForm((state) => ({ ...state, image_url: selection.result || '' })); + setUploadMessage(`Selected ${selection.file.name}`); + }; + + const clearProfileImage = () => { + setUploadMessage(''); + setForm((state) => ({ ...state, image_url: '' })); + }; + + return ( + + + + + + + + setForm((state) => ({ ...state, display_name: value }))} + /> + + + + + setForm((state) => ({ ...state, email: value }))} + /> + + + + + + + + + {form.image_url ? : null} + + {initials} + + + + Change photo + + + + + + {form.display_name || user.username || 'Anonymous'} + + + {user.username ? `Username: ${user.username}` : 'Not authenticated'} + + + Realm: {security.realm?.name || security.realm?.id || 'local'} + + + + + + {form.image_url ? ( + + ) : null} + + + + JPG, PNG, GIF, or WebP. The selected image is stored with your account profile. + + + {uploadMessage ? ( + + {uploadMessage} + + ) : null} + + + + + + + + + setPasswords((state) => ({ ...state, currentPassword: value }))} + autoCapitalize="none" + autoCorrect={false} + spellCheck={false} + autoComplete="current-password" + textContentType="password" + secureTextEntry + /> + setPasswords((state) => ({ ...state, newPassword: value }))} + autoCapitalize="none" + autoCorrect={false} + spellCheck={false} + autoComplete="new-password" + textContentType="newPassword" + returnKeyType="go" + onSubmitEditing={savePassword} + secureTextEntry + /> + + {message ? ( + + {message} + + ) : null} + + + + + + + + + + ); +} + +export default AccountProfilePage; diff --git a/src/security/pages/ErrorPage.jsx b/src/security/pages/ErrorPage.jsx new file mode 100644 index 0000000..48791b4 --- /dev/null +++ b/src/security/pages/ErrorPage.jsx @@ -0,0 +1,66 @@ +import React from 'react'; +import { ScrollView, Text, YStack } from 'tamagui'; +import { getIcon } from '../../ui/components/IconMapper.jsx'; + +export function ErrorPage({ + title = 'Something went wrong', + message = 'The application could not complete that request.', + icon = 'error', + error = null, + debug = false +}) { + const IconComponent = getIcon(icon) || getIcon('error'); + + return ( + + + + {IconComponent ? : null} + + {title} + + + {message} + + + + {debug && error ? ( + + + {error.stack || error.message || String(error)} + + + ) : null} + + + ); +} + +export default ErrorPage; diff --git a/src/security/pages/LoginPage.jsx b/src/security/pages/LoginPage.jsx new file mode 100644 index 0000000..a002c46 --- /dev/null +++ b/src/security/pages/LoginPage.jsx @@ -0,0 +1,165 @@ +import React, { forwardRef, useRef, useState } from 'react'; +import { Button, Input, Label, Paragraph, Text, YStack } from 'tamagui'; +import { getRouterPath, setRouterPath } from '../../platform/compat.js'; +import { securityService, useSecurityState } from '../runtime/security-service.js'; +import { Panel } from '../../ui/components/Panel.jsx'; + +const LoginField = forwardRef(function LoginField({ id, label, ...props }, ref) { + return ( + + + + + ); +}); + +export function LoginPage({ compact = false, title = 'Login', subtitle = 'Sign in to continue' }) { + const security = useSecurityState(); + const passwordInputRef = useRef(null); + const [identifier, setIdentifier] = useState(''); + const [password, setPassword] = useState(''); + const [errorMessage, setErrorMessage] = useState(''); + + const handleSubmit = async () => { + setErrorMessage(''); + if (!security.enabled) { + setErrorMessage('Identity is disabled in the app profile.'); + return; + } + if (!security.initialized || !security.policy) { + setErrorMessage('Security is still initializing. Reload once if this persists.'); + return; + } + try { + await securityService.login({ + username: identifier, + password + }); + const currentPath = await getRouterPath('/home'); + if (currentPath === (security.config.login_route || '/login')) { + await setRouterPath('/home', true); + } + } catch (error) { + setErrorMessage(error.message || 'Login failed'); + } + }; + + const handlePasswordKeyDown = (event) => { + if (event?.key !== 'Enter') { + return; + } + event.preventDefault?.(); + handleSubmit(); + }; + + const handleFormSubmit = (event) => { + event?.preventDefault?.(); + handleSubmit(); + }; + + const content = ( + + + + {subtitle} + + passwordInputRef.current?.focus?.()} + /> + + {errorMessage ? ( + + {errorMessage} + + ) : null} + + {!security.enabled ? ( + + Identity is currently disabled in the active app profile. + + ) : null} + {security.enabled && !security.initialized ? ( + + Security is still initializing. + + ) : null} + + Demo credentials: admin / admin or demo / demo + + + + ); + + if (compact) { + return content; + } + + return ( + + + {content} + + + ); +} + +export default LoginPage; diff --git a/src/security/pages/SecurityAdminPage.jsx b/src/security/pages/SecurityAdminPage.jsx new file mode 100644 index 0000000..67051d2 --- /dev/null +++ b/src/security/pages/SecurityAdminPage.jsx @@ -0,0 +1,155 @@ +import React, { useEffect, useState } from 'react'; +import { Paragraph, Text, XStack, YStack } from 'tamagui'; +import { SettingsPanel } from '../../ui/components/SettingsPanel.jsx'; +import { rightsToArray } from '../model/rights.js'; +import { useApp } from '../../ui/App.jsx'; + +function renderSectionBody(items, renderItem, emptyText) { + return ( + + {items.length > 0 ? items.map(renderItem) : ( + + {emptyText} + + )} + + ); +} + +export function SecurityAdminPage() { + const { security } = useApp(); + const [users, setUsers] = useState([]); + const [roles, setRoles] = useState([]); + const [realms, setRealms] = useState([]); + const [resources, setResources] = useState([]); + const [permits, setPermits] = useState([]); + + useEffect(() => { + let active = true; + + async function loadSecurityData() { + try { + const [nextUsers, nextRoles, nextRealms, nextResources, nextPermits] = await Promise.all([ + security.listUsers(), + security.listRoles(), + security.listRealms(), + security.listResources(), + security.listPermits() + ]); + + if (!active) { + return; + } + + setUsers(nextUsers); + setRoles(nextRoles); + setRealms(nextRealms); + setResources(nextResources); + setPermits(nextPermits); + } catch (error) { + console.warn('[SecurityAdminPage] Failed to load security data:', error); + } + } + + if (security.enabled && security.isAuthenticated) { + loadSecurityData(); + } + + return () => { + active = false; + }; + }, [security.enabled, security.isAuthenticated, security.user?.id]); + + const content = [ + { + id: 'users', + label: 'Users', + icon: 'group', + content: renderSectionBody(users, (user) => ( + + + {user.display_name || user.username} + {user.email} + + {(user.role_ids || []).join(', ') || 'no roles'} + + ), 'No users available.') + }, + { + id: 'roles', + label: 'Roles', + icon: 'lock', + content: renderSectionBody(roles, (role) => ( + + {role.name} + {role.description || role.id} + + ), 'No roles available.') + }, + { + id: 'realms', + label: 'Realms', + icon: 'network', + content: renderSectionBody(realms, (realm) => ( + + {realm.name} + {realm.description || realm.id} + + ), 'No realms available.') + }, + { + id: 'resources', + label: 'Resources', + icon: 'library', + content: renderSectionBody(resources, (resource) => ( + + {resource.path} + {resource.type} in realm {resource.realm_id} + + ), 'No resources registered.') + }, + { + id: 'permits', + label: 'Permits', + icon: 'lock', + content: renderSectionBody(permits, (permit) => ( + + + {permit.effect.toUpperCase()} {permit.principal_type}:{permit.principal_id} + + + {permit.resource_path} {'->'} {rightsToArray(permit.rights).join(', ') || 'none'} + + + ), 'No permits registered.') + } + ]; + + return ( + + + + + Provider: {security.config.provider} + + + Security is {security.enabled ? 'enabled' : 'disabled'}. + + + Authenticated user: {security.user?.display_name || security.user?.username || 'none'} + + + + + ); +} + +export default SecurityAdminPage; diff --git a/src/security/pages/index.js b/src/security/pages/index.js new file mode 100644 index 0000000..361ec56 --- /dev/null +++ b/src/security/pages/index.js @@ -0,0 +1,5 @@ +export { LoginPage } from './LoginPage.jsx'; +export { AccountProfilePage } from './AccountProfilePage.jsx'; +export { AccountHomePage, ProfileHomePage } from './AccountHomePage.jsx'; +export { SecurityAdminPage } from './SecurityAdminPage.jsx'; +export { ErrorPage } from './ErrorPage.jsx'; diff --git a/src/security/policy/BasicSecurityPolicy.js b/src/security/policy/BasicSecurityPolicy.js new file mode 100644 index 0000000..1905df0 --- /dev/null +++ b/src/security/policy/BasicSecurityPolicy.js @@ -0,0 +1,533 @@ +import { getProvider } from '../../platform/storage.js'; +import { SecurityPolicy } from './SecurityPolicy.js'; +import { AccountProfile, Permit, Realm, Resource, Role, Session, User, SECURITY_RIGHTS, hasRequiredRights, normalizeRightsInput } from '../model/index.js'; + +const STORAGE_KEY = 'security.basic.dataset'; +const SESSION_KEY = 'security.basic.session'; + +function clone(value) { + return JSON.parse(JSON.stringify(value)); +} + +function nowISO() { + return new Date().toISOString(); +} + +function createId(prefix) { + return `${prefix}_${Math.random().toString(36).slice(2, 10)}`; +} + +function pathMatches(resourcePath, targetPath) { + if (!resourcePath || resourcePath === '*') { + return true; + } + + if (resourcePath.endsWith('*')) { + const prefix = resourcePath.slice(0, -1); + return targetPath.startsWith(prefix); + } + + return targetPath === resourcePath || targetPath.startsWith(`${resourcePath}/`); +} + +async function hashPassword(password) { + if (typeof crypto === 'undefined' || !crypto.subtle) { + return `plain:${password}`; + } + + const encoded = new TextEncoder().encode(password); + const digest = await crypto.subtle.digest('SHA-256', encoded); + const hashArray = Array.from(new Uint8Array(digest)); + const hashHex = hashArray.map((byte) => byte.toString(16).padStart(2, '0')).join(''); + return `sha256:${hashHex}`; +} + +async function verifyPassword(password, storedHash) { + if (!storedHash) { + return false; + } + + if (storedHash.startsWith('plain:')) { + return storedHash === `plain:${password}`; + } + + return (await hashPassword(password)) === storedHash; +} + +function evaluateAgainstDataset(dataset, userId, rights, resourcePath, context = {}) { + const targetRights = normalizeRightsInput(rights); + const targetPath = resourcePath || context.resource_path || '/'; + + if (!userId) { + return { + allowed: false, + requires_login: true, + reason: 'Authentication required', + matched_permits: [] + }; + } + + const user = dataset.users.find((item) => item.id === userId); + if (!user) { + return { + allowed: false, + requires_login: true, + reason: 'Session user not found', + matched_permits: [] + }; + } + + const principals = [ + { principal_type: 'user', principal_id: user.id } + ]; + user.role_ids.forEach((roleId) => principals.push({ principal_type: 'role', principal_id: roleId })); + + const matchingPermits = dataset.permits.filter((permit) => { + const principalMatch = principals.some((principal) => { + return principal.principal_type === permit.principal_type && principal.principal_id === permit.principal_id; + }); + return principalMatch && pathMatches(permit.resource_path, targetPath); + }); + + const denyMatch = matchingPermits.find((permit) => permit.effect === 'deny' && hasRequiredRights(permit.rights, targetRights)); + if (denyMatch) { + return { + allowed: false, + requires_login: false, + reason: 'Denied by explicit policy', + matched_permits: clone([denyMatch]) + }; + } + + const allowMatches = matchingPermits.filter((permit) => permit.effect === 'allow' && hasRequiredRights(permit.rights, targetRights)); + if (allowMatches.length > 0 || targetRights === 0) { + return { + allowed: true, + requires_login: false, + reason: allowMatches.length > 0 ? 'Permit granted' : 'No specific rights requested', + matched_permits: clone(allowMatches) + }; + } + + return { + allowed: false, + requires_login: false, + reason: `Missing required rights on ${targetPath}`, + matched_permits: clone(matchingPermits) + }; +} + +export class BasicSecurityPolicy extends SecurityPolicy { + constructor(config = {}) { + super(config); + this.storage = getProvider('kv', 'security.basic'); + this.dataset = null; + } + + async init() { + const dataset = await this.storage.get(STORAGE_KEY, null); + + if (dataset) { + this.dataset = dataset; + return; + } + + const adminRole = new Role({ + id: 'role_admin', + name: 'Administrators', + description: 'Full access to all registered resources' + }); + + const memberRole = new Role({ + id: 'role_member', + name: 'Members', + description: 'Standard authenticated user role' + }); + + const realm = new Realm({ + id: 'local', + name: 'Local Realm', + description: 'Local skeleton application realm' + }); + + const adminUser = new User({ + id: 'user_admin', + username: 'admin', + display_name: 'Local Administrator', + email: 'admin@local.realm', + realm_id: realm.id, + role_ids: [adminRole.id], + password_hash: await hashPassword('admin') + }); + + const demoUser = new User({ + id: 'user_demo', + username: 'demo', + display_name: 'Demo User', + email: 'demo@local.realm', + realm_id: realm.id, + role_ids: [memberRole.id], + password_hash: await hashPassword('demo') + }); + + const adminProfile = new AccountProfile({ + user_id: adminUser.id, + display_name: adminUser.display_name, + email: adminUser.email + }); + + const demoProfile = new AccountProfile({ + user_id: demoUser.id, + display_name: demoUser.display_name, + email: demoUser.email + }); + + this.dataset = { + version: 1, + realms: [realm], + roles: [adminRole, memberRole], + users: [adminUser, demoUser], + resources: [ + new Resource({ path: '/login', realm_id: realm.id, type: 'ui-route' }), + new Resource({ path: '/settings/account', realm_id: realm.id, type: 'ui-route' }), + new Resource({ path: '/settings/security', realm_id: realm.id, type: 'ui-route' }) + ], + permits: [ + new Permit({ + id: 'permit_admin_all', + principal_type: 'role', + principal_id: adminRole.id, + resource_path: '*', + rights: Object.values(SECURITY_RIGHTS).reduce((mask, value) => mask | value, 0), + effect: 'allow' + }), + new Permit({ + id: 'permit_member_account', + principal_type: 'role', + principal_id: memberRole.id, + resource_path: '/settings/account', + rights: SECURITY_RIGHTS.read | SECURITY_RIGHTS.write, + effect: 'allow' + }), + new Permit({ + id: 'permit_member_home', + principal_type: 'role', + principal_id: memberRole.id, + resource_path: '/home', + rights: SECURITY_RIGHTS.read | SECURITY_RIGHTS.execute, + effect: 'allow' + }) + ], + profiles: [adminProfile, demoProfile] + }; + + await this._persistDataset(); + } + + async _persistDataset() { + await this.storage.set(STORAGE_KEY, clone(this.dataset)); + } + + async _loadDataset() { + if (!this.dataset) { + await this.init(); + } + return this.dataset; + } + + async authenticate(credentials = {}) { + const dataset = await this._loadDataset(); + const identifier = (credentials.username || credentials.email || '').trim().toLowerCase(); + const password = credentials.password || ''; + + const user = dataset.users.find((candidate) => { + return candidate.username.toLowerCase() === identifier || candidate.email.toLowerCase() === identifier; + }); + + if (!user) { + throw new Error('Unknown user'); + } + + const valid = await verifyPassword(password, user.password_hash); + if (!valid) { + throw new Error('Invalid password'); + } + + const session = new Session({ + user_id: user.id, + realm_id: user.realm_id, + jwt_token: `local.${user.id}.${Date.now()}`, + issued_on: nowISO(), + auth_provider: 'basic', + status: 'active' + }); + + await this.saveSession(session); + return { + session, + user: clone(user), + profile: await this.getAccountProfile(user.id) + }; + } + + async logout() { + await this.clearSession(); + } + + async getCurrentSession() { + const session = await this.storage.get(SESSION_KEY, null); + return session ? new Session(session) : null; + } + + async saveSession(session) { + await this.storage.set(SESSION_KEY, clone(session)); + } + + async clearSession() { + await this.storage.remove(SESSION_KEY); + } + + async listUsers() { + const dataset = await this._loadDataset(); + return clone(dataset.users); + } + + async getUser(userId) { + const dataset = await this._loadDataset(); + const user = dataset.users.find((item) => item.id === userId); + return user ? clone(user) : null; + } + + async createUser(userData) { + const dataset = await this._loadDataset(); + const createdOn = nowISO(); + const user = new User({ + ...userData, + id: userData.id || createId('user'), + created_on: createdOn, + updated_on: createdOn, + password_hash: await hashPassword(userData.password || 'changeme') + }); + dataset.users.push(user); + dataset.profiles.push(new AccountProfile({ + user_id: user.id, + display_name: user.display_name, + email: user.email, + image_url: user.image_url || '' + })); + await this._persistDataset(); + return clone(user); + } + + async updateUser(userId, patch) { + const dataset = await this._loadDataset(); + const user = dataset.users.find((item) => item.id === userId); + if (!user) { + throw new Error(`User not found: ${userId}`); + } + Object.assign(user, patch, { updated_on: nowISO() }); + await this._persistDataset(); + return clone(user); + } + + async deleteUser(userId) { + const dataset = await this._loadDataset(); + dataset.users = dataset.users.filter((item) => item.id !== userId); + dataset.profiles = dataset.profiles.filter((item) => item.user_id !== userId); + await this._persistDataset(); + } + + async listRoles(realmId = null) { + const dataset = await this._loadDataset(); + return clone(realmId ? dataset.roles.filter((role) => role.realm_id === realmId) : dataset.roles); + } + + async getRole(roleId) { + const dataset = await this._loadDataset(); + const role = dataset.roles.find((item) => item.id === roleId); + return role ? clone(role) : null; + } + + async createRole(roleData) { + const dataset = await this._loadDataset(); + const role = new Role({ + ...roleData, + id: roleData.id || createId('role'), + created_on: nowISO(), + updated_on: nowISO() + }); + dataset.roles.push(role); + await this._persistDataset(); + return clone(role); + } + + async updateRole(roleId, patch) { + const dataset = await this._loadDataset(); + const role = dataset.roles.find((item) => item.id === roleId); + if (!role) { + throw new Error(`Role not found: ${roleId}`); + } + Object.assign(role, patch, { updated_on: nowISO() }); + await this._persistDataset(); + return clone(role); + } + + async deleteRole(roleId) { + const dataset = await this._loadDataset(); + dataset.roles = dataset.roles.filter((item) => item.id !== roleId); + dataset.users.forEach((user) => { + user.role_ids = user.role_ids.filter((item) => item !== roleId); + }); + dataset.permits = dataset.permits.filter((permit) => !(permit.principal_type === 'role' && permit.principal_id === roleId)); + await this._persistDataset(); + } + + async listRealms() { + const dataset = await this._loadDataset(); + return clone(dataset.realms); + } + + async getRealm(realmId) { + const dataset = await this._loadDataset(); + const realm = dataset.realms.find((item) => item.id === realmId); + return realm ? clone(realm) : null; + } + + async createRealm(realmData) { + const dataset = await this._loadDataset(); + const realm = new Realm({ + ...realmData, + id: realmData.id || createId('realm'), + created_on: nowISO(), + updated_on: nowISO() + }); + dataset.realms.push(realm); + await this._persistDataset(); + return clone(realm); + } + + async updateRealm(realmId, patch) { + const dataset = await this._loadDataset(); + const realm = dataset.realms.find((item) => item.id === realmId); + if (!realm) { + throw new Error(`Realm not found: ${realmId}`); + } + Object.assign(realm, patch, { updated_on: nowISO() }); + await this._persistDataset(); + return clone(realm); + } + + async registerResource(resourceData) { + const dataset = await this._loadDataset(); + const existing = dataset.resources.find((item) => item.path === resourceData.path); + if (existing) { + Object.assign(existing, resourceData, { updated_on: nowISO() }); + await this._persistDataset(); + return clone(existing); + } + const resource = new Resource({ + ...resourceData, + created_on: nowISO(), + updated_on: nowISO() + }); + dataset.resources.push(resource); + await this._persistDataset(); + return clone(resource); + } + + async listResources(realmId = null) { + const dataset = await this._loadDataset(); + return clone(realmId ? dataset.resources.filter((item) => item.realm_id === realmId) : dataset.resources); + } + + async listPermits(filters = {}) { + const dataset = await this._loadDataset(); + let items = dataset.permits; + if (filters.principal_type) { + items = items.filter((permit) => permit.principal_type === filters.principal_type); + } + if (filters.principal_id) { + items = items.filter((permit) => permit.principal_id === filters.principal_id); + } + return clone(items); + } + + async grantPermit(permitData) { + const dataset = await this._loadDataset(); + const permit = new Permit({ + ...permitData, + id: permitData.id || createId('permit'), + created_on: nowISO(), + updated_on: nowISO() + }); + dataset.permits.push(permit); + await this._persistDataset(); + return clone(permit); + } + + async revokePermit(permitId) { + const dataset = await this._loadDataset(); + dataset.permits = dataset.permits.filter((item) => item.id !== permitId); + await this._persistDataset(); + } + + async getAccountProfile(userId) { + const dataset = await this._loadDataset(); + const profile = dataset.profiles.find((item) => item.user_id === userId); + return profile ? clone(profile) : null; + } + + async updateAccountProfile(userId, patch) { + const dataset = await this._loadDataset(); + const profile = dataset.profiles.find((item) => item.user_id === userId); + const user = dataset.users.find((item) => item.id === userId); + if (!profile || !user) { + throw new Error(`Profile not found for user: ${userId}`); + } + Object.assign(profile, patch, { updated_on: nowISO() }); + if (patch.display_name !== undefined) user.display_name = patch.display_name; + if (patch.email !== undefined) user.email = patch.email; + if (patch.image_url !== undefined) user.image_url = patch.image_url; + user.updated_on = nowISO(); + await this._persistDataset(); + return clone(profile); + } + + async changePassword(userId, passwordInput = {}) { + const dataset = await this._loadDataset(); + const user = dataset.users.find((item) => item.id === userId); + if (!user) { + throw new Error(`User not found: ${userId}`); + } + if (passwordInput.currentPassword) { + const valid = await verifyPassword(passwordInput.currentPassword, user.password_hash); + if (!valid) { + throw new Error('Current password is invalid'); + } + } + if (!passwordInput.newPassword) { + throw new Error('New password is required'); + } + user.password_hash = await hashPassword(passwordInput.newPassword); + user.updated_on = nowISO(); + await this._persistDataset(); + } + + async evaluate(userId, rights, resourcePath, context = {}) { + const dataset = await this._loadDataset(); + return evaluateAgainstDataset(dataset, userId, rights, resourcePath, context); + } + + evaluateSync(userId, rights, resourcePath, context = {}) { + if (!this.dataset) { + return { + allowed: false, + requires_login: false, + reason: 'Security dataset is not initialized', + matched_permits: [] + }; + } + + return evaluateAgainstDataset(this.dataset, userId, rights, resourcePath, context); + } +} diff --git a/src/security/policy/BstoreSecurityPolicy.js b/src/security/policy/BstoreSecurityPolicy.js new file mode 100644 index 0000000..9c19bee --- /dev/null +++ b/src/security/policy/BstoreSecurityPolicy.js @@ -0,0 +1,16 @@ +import { SecurityPolicy } from './SecurityPolicy.js'; + +export class BstoreSecurityPolicy extends SecurityPolicy { + async init() { + throw new Error('BstoreSecurityPolicy is not implemented yet'); + } + + evaluateSync() { + return { + allowed: false, + requires_login: false, + reason: 'BstoreSecurityPolicy sync evaluation is not implemented', + matched_permits: [] + }; + } +} diff --git a/src/security/policy/SecurityPolicy.js b/src/security/policy/SecurityPolicy.js new file mode 100644 index 0000000..7f6d69e --- /dev/null +++ b/src/security/policy/SecurityPolicy.js @@ -0,0 +1,51 @@ +export class SecurityPolicy { + constructor(config = {}) { + this.config = config; + } + + async init() {} + async authenticate(_credentials) { throw new Error('authenticate() not implemented'); } + async logout(_session) {} + async getCurrentSession() { return null; } + async saveSession(_session) {} + async clearSession() {} + async listUsers(_filters = {}) { return []; } + async getUser(_userId) { return null; } + async createUser(_userData) { throw new Error('createUser() not implemented'); } + async updateUser(_userId, _patch) { throw new Error('updateUser() not implemented'); } + async deleteUser(_userId) { throw new Error('deleteUser() not implemented'); } + async listRoles(_realmId = null) { return []; } + async getRole(_roleId) { return null; } + async createRole(_roleData) { throw new Error('createRole() not implemented'); } + async updateRole(_roleId, _patch) { throw new Error('updateRole() not implemented'); } + async deleteRole(_roleId) { throw new Error('deleteRole() not implemented'); } + async listRealms() { return []; } + async getRealm(_realmId) { return null; } + async createRealm(_realmData) { throw new Error('createRealm() not implemented'); } + async updateRealm(_realmId, _patch) { throw new Error('updateRealm() not implemented'); } + async registerResource(_resource) { throw new Error('registerResource() not implemented'); } + async listResources(_realmId = null) { return []; } + async listPermits(_filters = {}) { return []; } + async grantPermit(_permit) { throw new Error('grantPermit() not implemented'); } + async revokePermit(_permitId) { throw new Error('revokePermit() not implemented'); } + async getAccountProfile(_userId) { return null; } + async updateAccountProfile(_userId, _patch) { throw new Error('updateAccountProfile() not implemented'); } + async changePassword(_userId, _passwordInput) { throw new Error('changePassword() not implemented'); } + async evaluate(_userId, _rights, _resourcePath, _context = {}) { + return { + allowed: true, + requires_login: false, + reason: 'Security policy not enforced', + matched_permits: [] + }; + } + + evaluateSync(_userId, _rights, _resourcePath, _context = {}) { + return { + allowed: true, + requires_login: false, + reason: 'Security policy not enforced', + matched_permits: [] + }; + } +} diff --git a/src/security/policy/index.js b/src/security/policy/index.js new file mode 100644 index 0000000..40b4119 --- /dev/null +++ b/src/security/policy/index.js @@ -0,0 +1,3 @@ +export { SecurityPolicy } from './SecurityPolicy.js'; +export { BasicSecurityPolicy } from './BasicSecurityPolicy.js'; +export { BstoreSecurityPolicy } from './BstoreSecurityPolicy.js'; diff --git a/src/security/runtime/account-tabs.js b/src/security/runtime/account-tabs.js new file mode 100644 index 0000000..ca88a1d --- /dev/null +++ b/src/security/runtime/account-tabs.js @@ -0,0 +1,58 @@ +import { useSyncExternalStore } from 'react'; + +const accountTabs = []; +const listeners = new Set(); +let cachedTabs = []; + +function rebuildSnapshot() { + cachedTabs = [...accountTabs].sort((a, b) => (a.order || 0) - (b.order || 0)); +} + +function emit() { + listeners.forEach((listener) => { + try { + listener(); + } catch (error) { + console.warn('[Security] Account tab listener failed:', error); + } + }); +} + +export function publishAccountTab(tab) { + if (!tab || !tab.id || !tab.label || !tab.component) { + console.warn('[Security] publishAccountTab() requires id, label, and component'); + return; + } + + const existingIndex = accountTabs.findIndex((item) => item.id === tab.id); + if (existingIndex >= 0) { + accountTabs[existingIndex] = tab; + } else { + accountTabs.push(tab); + } + rebuildSnapshot(); + emit(); +} + +export function retractAccountTab(tabId) { + const nextTabs = accountTabs.filter((tab) => tab.id !== tabId); + if (nextTabs.length !== accountTabs.length) { + accountTabs.length = 0; + accountTabs.push(...nextTabs); + rebuildSnapshot(); + emit(); + } +} + +export function getAccountTabs() { + return cachedTabs; +} + +export function subscribeToAccountTabs(listener) { + listeners.add(listener); + return () => listeners.delete(listener); +} + +export function useAccountTabs() { + return useSyncExternalStore(subscribeToAccountTabs, getAccountTabs, getAccountTabs); +} diff --git a/src/security/runtime/api-auth.js b/src/security/runtime/api-auth.js new file mode 100644 index 0000000..ca5e1eb --- /dev/null +++ b/src/security/runtime/api-auth.js @@ -0,0 +1,12 @@ +export function createSecurityRequestInterceptor(securityService) { + return (config = {}) => securityService.injectRequestConfig(config); +} + +export function createSecurityResponseInterceptor(securityService) { + return (response) => { + if (response && response.status === 401) { + securityService.handleUnauthorizedResponse(); + } + return response; + }; +} diff --git a/src/security/runtime/route-guards.js b/src/security/runtime/route-guards.js new file mode 100644 index 0000000..478493d --- /dev/null +++ b/src/security/runtime/route-guards.js @@ -0,0 +1,42 @@ +import { normalizeRightsInput } from '../model/rights.js'; + +export async function evaluateRouteAccess(route = {}, securityService) { + const securityState = securityService.getState(); + const options = route?.options || {}; + const resourcePath = options.resource_path || route?.path || '/'; + const requestedRights = normalizeRightsInput(options.required_rights || 0); + + if (!securityState.enabled) { + return { + allowed: true, + requires_login: false, + reason: 'Security disabled' + }; + } + + if (!options.require_user && requestedRights === 0) { + return { + allowed: true, + requires_login: false, + reason: 'Route has no security requirements' + }; + } + + if (options.require_user && !securityState.isAuthenticated) { + return { + allowed: false, + requires_login: true, + reason: 'Login required for route' + }; + } + + if (requestedRights !== 0) { + return securityService.userPermitted(requestedRights, resourcePath, { redirectOnFail: false }); + } + + return { + allowed: true, + requires_login: false, + reason: 'Authenticated route' + }; +} diff --git a/src/security/runtime/security-service.js b/src/security/runtime/security-service.js new file mode 100644 index 0000000..06fcfb8 --- /dev/null +++ b/src/security/runtime/security-service.js @@ -0,0 +1,371 @@ +import { useSyncExternalStore } from 'react'; +import { setRouterPath } from '../../platform/compat.js'; +import { normalizeRightsInput } from '../model/rights.js'; +import { BasicSecurityPolicy, BstoreSecurityPolicy } from '../policy/index.js'; +import { createSecurityRequestInterceptor, createSecurityResponseInterceptor } from './api-auth.js'; + +const DEFAULT_SECURITY_CONFIG = { + enabled: false, + provider: 'basic', + require_login: false, + login_route: '/login', + logout_route: '/login', + default_realm: 'local', + debug_errors: false +}; + +function normalizeSecurityConfig(config = {}) { + return { + ...DEFAULT_SECURITY_CONFIG, + ...(config || {}) + }; +} + +function createInitialState() { + return { + initialized: false, + loading: false, + enabled: false, + provider: 'basic', + requireLogin: false, + config: normalizeSecurityConfig(), + policy: null, + session: null, + user: null, + profile: null, + realm: null, + isAuthenticated: false, + error: null + }; +} + +class SecurityService { + constructor() { + this.state = createInitialState(); + this.listeners = new Set(); + this.apiHooksInstalled = false; + } + + subscribe(listener) { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + emit() { + this.listeners.forEach((listener) => { + try { + listener(); + } catch (error) { + console.warn('[Security] Subscriber failed:', error); + } + }); + } + + getSnapshot = () => this.state; + getState() { return this.state; } + + setState(patch) { + this.state = { + ...this.state, + ...patch + }; + this.emit(); + } + + _resolvePolicy(config) { + if (config.provider === 'bstore') { + return new BstoreSecurityPolicy(config); + } + return new BasicSecurityPolicy(config); + } + + async init(config = {}) { + const normalizedConfig = normalizeSecurityConfig(config); + + if (!normalizedConfig.enabled) { + this.setState({ + ...createInitialState(), + initialized: true, + config: normalizedConfig, + enabled: false, + provider: normalizedConfig.provider, + requireLogin: normalizedConfig.require_login + }); + return this.state; + } + + this.setState({ + loading: true, + error: null, + enabled: true, + provider: normalizedConfig.provider, + requireLogin: normalizedConfig.require_login, + config: normalizedConfig + }); + + try { + const policy = this._resolvePolicy(normalizedConfig); + await policy.init(); + const session = await policy.getCurrentSession(); + const user = session?.user_id ? await policy.getUser(session.user_id) : null; + const realm = user?.realm_id ? await policy.getRealm(user.realm_id) : null; + const profile = user ? await policy.getAccountProfile(user.id) : null; + + this.setState({ + initialized: true, + loading: false, + enabled: true, + provider: normalizedConfig.provider, + requireLogin: normalizedConfig.require_login, + config: normalizedConfig, + policy, + session, + user, + profile, + realm, + isAuthenticated: Boolean(session && user), + error: null + }); + } catch (error) { + console.error('[Security] Failed to initialize security service:', error); + this.setState({ + initialized: true, + loading: false, + enabled: normalizedConfig.enabled, + provider: normalizedConfig.provider, + requireLogin: normalizedConfig.require_login, + config: normalizedConfig, + policy: null, + session: null, + user: null, + profile: null, + realm: null, + isAuthenticated: false, + error + }); + } + + return this.state; + } + + installAPIClient(apiClient) { + if (!apiClient || this.apiHooksInstalled) { + return; + } + apiClient.addRequestInterceptor(createSecurityRequestInterceptor(this)); + apiClient.addResponseInterceptor(createSecurityResponseInterceptor(this)); + this.apiHooksInstalled = true; + } + + injectRequestConfig(config = {}) { + if (!this.state.session?.jwt_token) { + return config; + } + return { + ...config, + headers: { + ...(config.headers || {}), + Authorization: `Bearer ${this.state.session.jwt_token}` + } + }; + } + + async injectAuthHeaders(headers = {}) { + return this.injectRequestConfig({ headers }).headers; + } + + async handleUnauthorizedResponse() { + if (this.state.isAuthenticated) { + await this.logout({ redirect: true }); + } + } + + async login(credentials = {}) { + if (!this.state.policy) { + throw new Error('Security policy is not initialized'); + } + + this.setState({ loading: true, error: null }); + try { + const result = await this.state.policy.authenticate(credentials); + const realm = result.user?.realm_id ? await this.state.policy.getRealm(result.user.realm_id) : null; + this.setState({ + loading: false, + session: result.session, + user: result.user, + profile: result.profile || null, + realm, + isAuthenticated: true, + error: null + }); + return result; + } catch (error) { + this.setState({ + loading: false, + error + }); + throw error; + } + } + + async logout(options = {}) { + const { redirect = true } = options; + + if (this.state.policy && this.state.session) { + try { + await this.state.policy.logout(this.state.session); + } catch (error) { + console.warn('[Security] Policy logout failed:', error); + } + } + + if (this.state.policy) { + await this.state.policy.clearSession(); + } + + this.setState({ + session: null, + user: null, + profile: null, + realm: null, + isAuthenticated: false, + error: null + }); + + if (redirect) { + await setRouterPath(this.state.config.logout_route || this.state.config.login_route || '/login', true); + } + } + + async refreshSession() { + if (!this.state.policy) { + return this.state; + } + + const session = await this.state.policy.getCurrentSession(); + const user = session?.user_id ? await this.state.policy.getUser(session.user_id) : null; + const realm = user?.realm_id ? await this.state.policy.getRealm(user.realm_id) : null; + const profile = user ? await this.state.policy.getAccountProfile(user.id) : null; + + this.setState({ + session, + user, + profile, + realm, + isAuthenticated: Boolean(session && user) + }); + + return this.state; + } + + async registerResource(resource) { + if (!this.state.policy) { + return null; + } + return this.state.policy.registerResource(resource); + } + + async userRequired(options = {}) { + if (!this.state.enabled) { + return { allowed: true, requires_login: false, reason: 'Security disabled' }; + } + + if (this.state.isAuthenticated) { + return { allowed: true, requires_login: false, reason: 'User authenticated' }; + } + + if (options.redirect !== false) { + await setRouterPath(this.state.config.login_route || '/login', true); + } + + return { allowed: false, requires_login: true, reason: 'User login required' }; + } + + async userPermitted(rights, resourcePath, options = {}) { + if (!this.state.enabled || !this.state.policy) { + return { allowed: true, requires_login: false, reason: 'Security disabled', matched_permits: [] }; + } + + if (!this.state.isAuthenticated) { + const response = { allowed: false, requires_login: true, reason: 'User login required', matched_permits: [] }; + if (options.redirectOnFail) { + await setRouterPath(this.state.config.login_route || '/login', true); + } + return response; + } + + const result = await this.state.policy.evaluate( + this.state.user.id, + normalizeRightsInput(rights), + resourcePath, + options + ); + + if (!result.allowed && result.requires_login && options.redirectOnFail) { + await setRouterPath(this.state.config.login_route || '/login', true); + } + + return result; + } + + isPermitted(rights, resourcePath, options = {}) { + if (!this.state.enabled || !this.state.policy) { + return true; + } + + if (!this.state.isAuthenticated || !this.state.user?.id) { + return false; + } + + if (typeof this.state.policy.evaluateSync !== 'function') { + return false; + } + + const result = this.state.policy.evaluateSync( + this.state.user.id, + normalizeRightsInput(rights), + resourcePath, + options + ); + + return result?.allowed === true; + } + + async updateAccountProfile(patch) { + if (!this.state.policy || !this.state.user) { + throw new Error('No authenticated user to update'); + } + const profile = await this.state.policy.updateAccountProfile(this.state.user.id, patch); + const user = await this.state.policy.getUser(this.state.user.id); + this.setState({ + profile, + user + }); + return profile; + } + + async changePassword(passwordInput) { + if (!this.state.policy || !this.state.user) { + throw new Error('No authenticated user to update'); + } + await this.state.policy.changePassword(this.state.user.id, passwordInput); + } + + async listUsers() { return this.state.policy ? this.state.policy.listUsers() : []; } + async listRoles() { return this.state.policy ? this.state.policy.listRoles() : []; } + async listRealms() { return this.state.policy ? this.state.policy.listRealms() : []; } + async listResources() { return this.state.policy ? this.state.policy.listResources() : []; } + async listPermits() { return this.state.policy ? this.state.policy.listPermits() : []; } +} + +export const securityService = new SecurityService(); + +export function useSecurityState() { + return useSyncExternalStore( + (listener) => securityService.subscribe(listener), + securityService.getSnapshot, + securityService.getSnapshot + ); +} + +export { normalizeSecurityConfig, DEFAULT_SECURITY_CONFIG }; diff --git a/src/ui/App.jsx b/src/ui/App.jsx new file mode 100644 index 0000000..372e719 --- /dev/null +++ b/src/ui/App.jsx @@ -0,0 +1,532 @@ +/** + * App Component + * Main application component that manages all contexts and app state + * Provides extensible structure for multiple context managers (Theme, Auth, etc.) + */ + +import React, { createContext, useContext, useState, useEffect, useCallback, useRef, useMemo } from 'react'; +import { TamaguiProvider, Theme, createTamagui, YStack } from 'tamagui'; +import { registerServiceWorker, clearPWACache } from '../platform/sw-register.js'; +import { getProvider } from '../platform/storage.js'; +import * as apiClient from '../platform/api.js'; +import * as storageModuleRef from '../platform/storage.js'; +import * as menuRef from '../platform/menu.js'; +import * as envModuleRef from '../platform/env.js'; +import { getConfig, setConfig, CONFIG_KEYS, createLogger, startTrace, isDevelopment } from '../platform/env.js'; +import { EmptyShell, LandingShell, DashboardShell, AppInfo, Router } from './components/index.js'; +import { LoginPage } from '../security/pages/LoginPage.jsx'; +import { getStyleTheme, DEFAULT_STYLE_THEME, normalizeStyleThemeName } from './styles/index.js'; +import { THEME_MODE_CONFIG_KEY, THEME_NAME_CONFIG_KEY, THEME_MODES, themeManager } from './theme-controller.js'; +import { securityService, useSecurityState } from '../security/runtime/security-service.js'; +// ============================================================================ +// Theme Manager +// ============================================================================ + +// Import platform-agnostic theme detection +import { getSystemThemeMode, subscribeToSystemThemeMode } from '../platform/compat.js'; + +// ============================================================================ +// App Context +// ============================================================================ + +/** + * App Context + * Consolidated context providing access to all app managers (theme, etc.) + */ +const AppContext = createContext(null); +const appLogger = createLogger('App'); + +function resolveShellComponent(shellName = 'EmptyShell') { + const key = String(shellName ?? 'EmptyShell').trim().toLowerCase(); + switch (key) { + case 'landingshell': + return LandingShell; + // Same layout as LandingShell: TopBar in shell header with primary/secondary/personal menus. + case 'topbarshell': + return LandingShell; + case 'dashboardshell': + return DashboardShell; + case 'emptyshell': + default: + return EmptyShell; + } +} + +// ============================================================================ +// App Component +// ============================================================================ + +function App({ onInit, renderBootScreen = null, showInitialBootSplash = false, initialProfile = null }) { + // App state + const [swStatus, setSwStatus] = useState('Checking...'); + const [storageBackend, setStorageBackend] = useState('localStorage'); + const [appName, setAppName] = useState(initialProfile?.id ?? ''); + const [menuItems, setMenuItems] = useState([]); + const [initialized, setInitialized] = useState(false); + const [ShellComponent, setShellComponent] = useState(() => resolveShellComponent(initialProfile?.ui_shell ?? 'EmptyShell')); + const [bootResult, setBootResult] = useState(null); + const [bootModeOverride, setBootModeOverride] = useState(null); + + // Theme state + const [themeMode, setThemeModeState] = useState(THEME_MODES.SYSTEM); + const [systemScheme, setSystemScheme] = useState(getSystemThemeMode()); + const [styleThemeName, setStyleThemeName] = useState(DEFAULT_STYLE_THEME); + const securityState = useSecurityState(); + + // Initialize theme manager + useEffect(() => { + themeManager.init(setThemeModeState, setSystemScheme, setStyleThemeName); + }, []); + + // Get style theme configuration + const styleTheme = useMemo(() => getStyleTheme(styleThemeName), [styleThemeName]); + + // Create Tamagui config from style theme + const tamaguiConfig = useMemo(() => { + return createTamagui(styleTheme); + }, [styleTheme]); + + // Calculate active theme (light/dark variant) + const activeTheme = themeMode === THEME_MODES.SYSTEM ? systemScheme : themeMode; + + // Update theme manager state + useEffect(() => { + themeManager.updateState(themeMode, systemScheme, activeTheme, styleThemeName); + }, [themeMode, systemScheme, activeTheme, styleThemeName]); + + // Load theme preferences from storage on mount + useEffect(() => { + async function loadThemePreferences() { + try { + // Load theme mode (light/dark/system) + const savedMode = await getConfig(THEME_MODE_CONFIG_KEY, null); + if (savedMode && Object.values(THEME_MODES).includes(savedMode)) { + setThemeModeState(savedMode); + } + + // Load style theme (material/minimal/colorful) + const savedStyleTheme = await getConfig(THEME_NAME_CONFIG_KEY, null); + if (savedStyleTheme) { + setStyleThemeName(normalizeStyleThemeName(savedStyleTheme)); + } + } catch (error) { + console.warn('[App] Failed to load theme preferences:', error); + } + } + + loadThemePreferences(); + }, []); + + // Listen for system theme changes using platform-agnostic compat layer + useEffect(() => { + const unsubscribe = subscribeToSystemThemeMode((scheme) => { + setSystemScheme(scheme); + }); + + return unsubscribe; + }, []); + + // Save theme mode preference to storage when it changes + useEffect(() => { + async function saveThemePreference() { + try { + await setConfig(THEME_MODE_CONFIG_KEY, themeMode); + } catch (error) { + console.warn('[App] Failed to save theme preference:', error); + } + } + + if (themeMode !== THEME_MODES.SYSTEM || systemScheme) { + saveThemePreference(); + } + }, [themeMode]); + + // Save style theme preference to storage when it changes + useEffect(() => { + async function saveStyleThemePreference() { + try { + await setConfig(THEME_NAME_CONFIG_KEY, styleThemeName); + } catch (error) { + console.warn('[App] Failed to save style theme preference:', error); + } + } + + saveStyleThemePreference(); + }, [styleThemeName]); + + /** + * Get platform services for module injection + * Returns services with renamed keys and includes env + */ + async function getPlatformServices() { + // Get UI Router (for route declarations - Router will collect on mount) + let ui_router = null; + try { + if (Router && Router.publishRoutes) { + ui_router = { + publishRoutes: Router.publishRoutes + }; + } + } catch (error) { + console.warn('[App] Failed to import Router module:', error); + } + + // Get API router from service worker (if available) + let api_router = null; + if (typeof window !== 'undefined' && 'serviceWorker' in navigator) { + // API Router is in service worker, modules will register via message passing + // or we expose a registration function + api_router = { + register: (path, handler) => { + // Register endpoint in SW router + // Note: For now, we'll need to handle this differently since + // functions can't be serialized. Modules should register routes + // in their routes.js files which get loaded into the SW. + console.log(`[API Router] Register endpoint: ${path}`); + // TODO: Implement proper SW router registration + } + }; + } + + return { + api_client: apiClient.api, // Renamed from api + storage: storageModuleRef, + api_router, + ui_router, + menu: menuRef, + env: envModuleRef // New service + }; + } + + const initializeApp = useCallback(async () => { + const initTrace = startTrace('App', 'initializeApp'); + appLogger.log('Initializing PWA...'); + + try { + // Do not clear `initialized` here: it caused a blank / half-ready shell flash while re-running init. + setBootModeOverride(null); + + // Get platform services + const servicesTrace = startTrace('App', 'getPlatformServices'); + const services = await getPlatformServices(); + servicesTrace.end(); + const initialSecurityConfig = initialProfile?.security || {}; + const securityTrace = startTrace('Security', 'init', { provider: initialSecurityConfig.provider ?? 'basic' }); + await securityService.init(initialSecurityConfig); + securityTrace.end({ enabled: initialSecurityConfig.enabled === true }); + securityService.installAPIClient(services.api_client); + + // Call onInit callback if provided (handles profile, env, modules, SW) + let selectedProfile = null; + if (onInit) { + const onInitTrace = startTrace('App', 'onInit'); + selectedProfile = await onInit(services, { initialProfile }); + onInitTrace.end({ profile: selectedProfile?.id ?? initialProfile?.id ?? 'default' }); + } + + const selectedSecurityConfig = selectedProfile?.security || initialSecurityConfig; + if (JSON.stringify(selectedSecurityConfig) !== JSON.stringify(initialSecurityConfig)) { + const selectedSecurityTrace = startTrace('Security', 're-init from selected profile', { provider: selectedSecurityConfig.provider ?? 'basic' }); + await securityService.init(selectedSecurityConfig); + selectedSecurityTrace.end({ enabled: selectedSecurityConfig.enabled === true }); + } + + if (selectedProfile?.__boot) { + setBootResult(selectedProfile.__boot); + } + + if (services.menu?.restoreMenuPreferences) { + await services.menu.restoreMenuPreferences(); + } + + // Use services to set up app state + const name = await services.env.getConfig(CONFIG_KEYS.APP_NAME, 'default'); + setAppName(name); + + const backend = await services.env.getConfig(CONFIG_KEYS.STORAGE_BACKEND, 'localStorage'); + setStorageBackend(backend); + + // Load UI shell from config + const shellName = await services.env.getConfig(CONFIG_KEYS.UI_SHELL, 'EmptyShell'); + const Shell = resolveShellComponent(shellName); + setShellComponent(() => Shell); + appLogger.log(`Using shell: ${shellName}`); + + // Get menu items from primary menu + if (services.menu) { + // Query primary menu (returns root + all nested items) + const allPrimaryItems = services.menu.queryMenuItems('/primary'); + appLogger.debug('Primary menu items found:', allPrimaryItems); + + // Filter out the root "Primary" item, keep only actual menu items + const items = allPrimaryItems.filter(item => item.path !== '/primary'); + appLogger.debug('Filtered menu items (excluding root):', items); + setMenuItems(items); + } else { + appLogger.warn('Menu service not available'); + } + + // Update service worker status if available + const devHost = await getConfig(CONFIG_KEYS.DEV_HOST, isDevelopment()); + if ('serviceWorker' in navigator) { + try { + if (devHost) { + const registration = await navigator.serviceWorker.getRegistration(); + setSwStatus(registration ? 'Active' : 'Development Disabled'); + if (registration) { + registration.update(); + } + } else { + const registration = await navigator.serviceWorker.ready; + setSwStatus('Active'); + } + } catch (error) { + setSwStatus('Not Supported'); + } + } else { + setSwStatus('Not Supported'); + } + + setInitialized(true); + initTrace.end({ + shell: shellName, + bootMode: selectedProfile?.__boot?.uiMode ?? 'runtime' + }); + } catch (error) { + initTrace.fail(error); + appLogger.error('Failed to initialize app:', error); + setInitialized(true); + } + }, [initialProfile, onInit]); + + // Initialize app + useEffect(() => { + initializeApp(); + }, [initializeApp]); + + // Consolidated app context value + const appContextValue = { + theme: { + themeMode, + activeTheme, + systemScheme, + styleThemeName, + styleTheme, + setThemeMode: async (mode) => { + if (!Object.values(THEME_MODES).includes(mode)) { + console.warn('[App] Invalid theme mode:', mode); + return; + } + setThemeModeState(mode); + }, + setStyleTheme: async (themeName) => { + setStyleThemeName(normalizeStyleThemeName(themeName)); + }, + toggleTheme: () => { + const nextMode = + themeMode === THEME_MODES.LIGHT ? THEME_MODES.DARK : + themeMode === THEME_MODES.DARK ? THEME_MODES.SYSTEM : + THEME_MODES.LIGHT; + setThemeModeState(nextMode); + }, + THEME_MODES + }, + security: { + ...securityState, + login: (credentials) => securityService.login(credentials), + logout: (options) => securityService.logout(options), + refreshSession: () => securityService.refreshSession(), + injectAuthHeaders: (headers) => securityService.injectAuthHeaders(headers), + userRequired: (options) => securityService.userRequired(options), + userPermitted: (rights, resourcePath, options) => securityService.userPermitted(rights, resourcePath, options), + isPermitted: (rights, resourcePath, options) => securityService.isPermitted(rights, resourcePath, options), + registerResource: (resource) => securityService.registerResource(resource), + updateAccountProfile: (patch) => securityService.updateAccountProfile(patch), + changePassword: (passwordInput) => securityService.changePassword(passwordInput), + listUsers: () => securityService.listUsers(), + listRoles: () => securityService.listRoles(), + listRealms: () => securityService.listRealms(), + listResources: () => securityService.listResources(), + listPermits: () => securityService.listPermits() + }, + system: { + locale: envModuleRef.getLocaleSync(), + getLocale: (altValue) => envModuleRef.getLocale(altValue), + setLocale: (locale) => envModuleRef.setLocale(locale) + } + // Future managers can be added here: auth, etc. + }; + + const effectiveBootMode = bootModeOverride ?? bootResult?.uiMode ?? 'runtime'; + const shouldRenderBootScreen = effectiveBootMode !== 'runtime' || (!initialized && showInitialBootSplash); + const shouldHoldDuringInit = !initialized && !showInitialBootSplash; + const shouldRenderLoginGate = initialized && !shouldRenderBootScreen && securityState.enabled && securityState.requireLogin && !securityState.isAuthenticated; + let bootScreenContent = null; + let appContent = null; + + if (shouldRenderBootScreen) { + if (typeof renderBootScreen === 'function') { + bootScreenContent = renderBootScreen({ + mode: initialized ? effectiveBootMode : 'splash', + bootResult, + continueToRuntime: () => setBootModeOverride('runtime'), + completeSetup: async () => { + if (bootResult?.runtimeStorage?.markSetupComplete) { + await bootResult.runtimeStorage.markSetupComplete(); + } + await initializeApp(); + }, + completeModuleSetup: async (moduleId) => { + if (bootResult?.runtimeStorage?.markModuleSetupComplete) { + await bootResult.runtimeStorage.markModuleSetupComplete(moduleId); + } + await initializeApp(); + }, + retryBoot: async () => { + await initializeApp(); + } + }); + } + } + + if (shouldRenderLoginGate) { + appContent = ; + } else if (!shouldRenderBootScreen && !shouldHoldDuringInit) { + appContent = ( + + {/* Declarative route registration (commented out - routes now registered programmatically via modules) + + + + + + + + */} + + + + + + ); + } else if (shouldHoldDuringInit) { + appContent = ( + + + + + + ); + } + + return ( + + + + {shouldRenderBootScreen ? ( + bootScreenContent + ) : appContent} + + + + ); +} + +// ============================================================================ +// App Hooks +// ============================================================================ + +/** + * useApp Hook + * Access all app managers (theme, etc.) within React components + * + * @returns {{ + * theme: { + * themeMode: 'light' | 'dark' | 'system', + * activeTheme: 'light' | 'dark', + * systemScheme: 'light' | 'dark', + * styleThemeName: string, + * styleTheme: object, + * setThemeMode: (mode: string) => Promise, + * setStyleTheme: (themeName: string) => Promise, + * toggleTheme: () => void, + * THEME_MODES: object + * }, + * }} + */ +function useApp() { + const context = useContext(AppContext); + if (!context) { + throw new Error('useApp must be used within App component'); + } + return context; +} + +/** + * useTheme Hook (Backward Compatibility) + * Access theme state and controls within React components + * @deprecated Use useApp().theme instead + * + * @returns {{ + * themeMode: 'light' | 'dark' | 'system', + * activeTheme: 'light' | 'dark', + * systemScheme: 'light' | 'dark', + * styleThemeName: string, + * styleTheme: object, + * setThemeMode: (mode: string) => Promise, + * setStyleTheme: (themeName: string) => Promise, + * toggleTheme: () => void, + * THEME_MODES: object + * }} + */ +function useTheme() { + const app = useApp(); + return app.theme; +} + +// ============================================================================ +// App Static Properties (Global Access) +// ============================================================================ + +// Attach static properties to App +App.ThemeManager = themeManager; +App.useApp = useApp; +App.useTheme = useTheme; // Backward compatibility +App.THEME_MODES = THEME_MODES; + +// ============================================================================ +// Export and Initialize +// ============================================================================ + +// Export App as both default and named for flexibility +export default App; +export { App }; +export { useApp, useTheme, THEME_MODES, themeManager as ThemeManager }; + +// Expose PWA utilities globally for console access +if (typeof window !== 'undefined') { + window.clearPWACache = clearPWACache; + window.__PWA_UTILS__ = { + clearPWACache, + clearAllCaches: async () => { + const { clearAllCaches } = await import('../platform/sw-register.js'); + return clearAllCaches(); + }, + unregisterAllServiceWorkers: async () => { + const { unregisterAllServiceWorkers } = await import('../platform/sw-register.js'); + return unregisterAllServiceWorkers(); + }, + clearAllStorage: async () => { + const { clearAllStorage } = await import('../platform/sw-register.js'); + return clearAllStorage(); + } + }; + + console.log('💡 PWA Utilities available:'); + console.log(' - clearPWACache() - Clear everything (caches, SW, storage)'); + console.log(' - __PWA_UTILS__.clearAllCaches() - Clear caches only'); + console.log(' - __PWA_UTILS__.unregisterAllServiceWorkers() - Unregister SW only'); + console.log(' - __PWA_UTILS__.clearAllStorage() - Clear storage only'); +} + +// Note: App initialization is handled by the consuming project (e.g., app-react.jsx) diff --git a/src/ui/components/AppInfo.jsx b/src/ui/components/AppInfo.jsx new file mode 100644 index 0000000..e42c698 --- /dev/null +++ b/src/ui/components/AppInfo.jsx @@ -0,0 +1,72 @@ +/** + * AppInfo Component + * Displays application information and status + */ + +import React, { useState, useEffect } from 'react'; +import { YStack, XStack, Text, Heading } from 'tamagui'; +import { getConfig, CONFIG_KEYS } from '../../platform/env.js'; + +/** + * AppInfo Component + * + * @param {Object} props + * @param {string} props.appName - Application name + * @param {string} props.swStatus - Service worker status + * @param {string} props.storageBackend - Storage backend name + * @param {Array} props.menuItems - Menu items to display + * @param {boolean} props.initialized - Whether app is initialized + */ +export function AppInfo({ appName, swStatus, storageBackend, menuItems = [], initialized = false }) { + const [displayName, setDisplayName] = useState('PWA Template'); + + useEffect(() => { + async function loadConfig() { + const name = await getConfig(CONFIG_KEYS.APP_DISPLAY_NAME, 'PWA Template'); + setDisplayName(name); + } + loadConfig(); + }, []); + + return ( + + + {displayName} + + + + App: + {appName || 'Loading...'} + + + + Service Worker: + {swStatus} + + + + Storage Backend: + {storageBackend} + + + {initialized && menuItems.length > 0 && ( + + Menu Items: + {menuItems.map((item) => ( + + • {item.label} + {item.icon && ({item.icon})} + + ))} + + )} + + + Ready for development! + + + ); +} + +export default AppInfo; + diff --git a/src/ui/components/DashboardShell.jsx b/src/ui/components/DashboardShell.jsx new file mode 100644 index 0000000..b1b7d8e --- /dev/null +++ b/src/ui/components/DashboardShell.jsx @@ -0,0 +1,29 @@ +/** + * DashboardShell - Dashboard shell + * Derived from EmptyShell, designed for dashboard/application pages + */ + +import React from 'react'; +import EmptyShell from './EmptyShell.jsx'; +import { SideBar } from './SideBar.jsx'; + +/** + * DashboardShell Component + * Shell component for dashboard pages with SideBar in left side + * + * @param {Object} props - Component props + * @param {React.ReactNode} props.children - Content to render inside shell + */ +export function DashboardShell(props) { + return ( + + + {/* Menu items will be placed here via placement */} + + {props.children} + + ); +} + +export default DashboardShell; + diff --git a/src/ui/components/DetView.jsx b/src/ui/components/DetView.jsx new file mode 100644 index 0000000..34b341a --- /dev/null +++ b/src/ui/components/DetView.jsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { SidePanelShell } from './SidePanelShell.jsx'; + +export function DetView({ + open = false, + onClose = null, + title = 'Detail View', + toolbar = [], + footerActions = [], + width = 420, + children = null +}) { + return ( + + {children} + + ); +} + +export default DetView; diff --git a/src/ui/components/DirView.jsx b/src/ui/components/DirView.jsx new file mode 100644 index 0000000..d3c5caf --- /dev/null +++ b/src/ui/components/DirView.jsx @@ -0,0 +1,421 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { Button, Input, Paragraph, ScrollView, Separator, Spinner, Text, XStack, YStack } from 'tamagui'; +import { getIcon } from './IconMapper.jsx'; +import { normalizeColumnsArray } from './grid/utils.js'; + +const EMPTY_COLUMNS = []; +const EMPTY_ACTIONS = []; +const DEFAULT_SEARCH_CONFIG = { enabled: true, placeholder: 'Search records...' }; +const EMPTY_SUMMARY_DEFINITIONS = []; + +function getColumnJustify(align) { + if (align === 'right') { + return 'flex-end'; + } + if (align === 'center') { + return 'center'; + } + return 'flex-start'; +} + +function normalizeSummaryValue(value) { + if (typeof value === 'number') { + return Number.isInteger(value) ? String(value) : value.toFixed(2); + } + if (value && typeof value === 'object') { + return Object.entries(value) + .map(([key, count]) => `${key}: ${count}`) + .join(' · '); + } + return String(value ?? ''); +} + +function SummaryCards({ summary }) { + if (!summary?.items?.length) { + return null; + } + + return ( + + {summary.items.map((item) => ( + + + {item.label} + + + {normalizeSummaryValue(item.value)} + + + ))} + + ); +} + +function HeaderCell({ column, orderBy, order, onSort }) { + const sortable = column.sortable !== false; + const isActive = orderBy === column.id; + const arrow = isActive ? (order === 'asc' ? '↑' : '↓') : ''; + const justifyContent = getColumnJustify(column.align); + + return ( + + + + ); +} + +function RowCell({ column, record }) { + const value = record?.[column.id]; + const content = column.render ? column.render(value, record, column) : (value ?? ''); + const justifyContent = getColumnJustify(column.align); + return ( + + + {React.isValidElement(content) ? content : ( + + {String(content)} + + )} + + + ); +} + +export function DirView({ + dataModel, + columns = EMPTY_COLUMNS, + title = 'Directory', + toolbarActions = EMPTY_ACTIONS, + toolbarItems = undefined, + actions = undefined, + topLeftContent = null, + topRightContent = null, + bodyHeaderContent = null, + bodyFooterContent = null, + searchConfig = DEFAULT_SEARCH_CONFIG, + searchValue = undefined, + onSearchChange = null, + initialSearchValue = '', + summaryDefinitions = EMPTY_SUMMARY_DEFINITIONS, + pageSize = 10, + bodyMaxHeight = 480, + onRowClick = null, + onRowPress = null, + onRefresh = null +}) { + const [dataVersion, setDataVersion] = useState(0); + const [records, setRecords] = useState([]); + const [summary, setSummary] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [internalSearchTerm, setInternalSearchTerm] = useState(initialSearchValue); + const resolvedColumns = useMemo(() => normalizeColumnsArray(columns), [columns]); + const effectiveToolbarItems = actions ?? toolbarItems ?? toolbarActions; + const effectiveSearchTerm = searchValue ?? internalSearchTerm; + const effectiveRowPress = onRowPress ?? onRowClick; + const [orderBy, setOrderBy] = useState(resolvedColumns.find((column) => column.sortable !== false)?.id || ''); + const [order, setOrder] = useState('asc'); + const [currentPage, setCurrentPage] = useState(1); + const [totalRecords, setTotalRecords] = useState(0); + const resolvedSummaryDefinitions = summaryDefinitions || EMPTY_SUMMARY_DEFINITIONS; + const RefreshIcon = getIcon('refresh'); + const FirstPageIcon = getIcon('first-page'); + const PreviousPageIcon = getIcon('chevron-left'); + const NextPageIcon = getIcon('chevron-right'); + const LastPageIcon = getIcon('last-page'); + + useEffect(() => { + if (!dataModel?.subscribe) { + return undefined; + } + + return dataModel.subscribe(() => { + setDataVersion((value) => value + 1); + }); + }, [dataModel]); + + const totalPages = useMemo(() => Math.max(1, Math.ceil(totalRecords / pageSize)), [totalRecords, pageSize]); + + useEffect(() => { + let cancelled = false; + + async function loadData() { + if (!dataModel) { + return; + } + + setLoading(true); + setError(''); + try { + const query = { + page: currentPage, + pageSize, + search: effectiveSearchTerm, + orderBy, + order + }; + + const [recordResult, summaryResult] = await Promise.all([ + dataModel.queryRecords(query), + dataModel.querySummary(query, resolvedSummaryDefinitions) + ]); + + if (!cancelled) { + setRecords(recordResult.records || []); + setTotalRecords(recordResult.totalRecords || 0); + setSummary(summaryResult || null); + } + } catch (loadError) { + if (!cancelled) { + setError(loadError.message || 'Failed to load records'); + } + } finally { + if (!cancelled) { + setLoading(false); + } + } + } + + loadData(); + return () => { + cancelled = true; + }; + }, [currentPage, dataModel, dataVersion, effectiveSearchTerm, order, orderBy, pageSize, resolvedSummaryDefinitions]); + + useEffect(() => { + const nextOrderBy = resolvedColumns.find((column) => column.sortable !== false)?.id || ''; + setOrderBy((current) => { + if (current && resolvedColumns.some((column) => column.id === current)) { + return current; + } + return nextOrderBy; + }); + }, [resolvedColumns]); + + const handleRefresh = async () => { + if (onRefresh) { + await onRefresh(); + } + setDataVersion((value) => value + 1); + }; + + const updateSearchTerm = (value) => { + if (searchValue === undefined) { + setInternalSearchTerm(value); + } + onSearchChange?.(value); + searchConfig?.onChange?.(value); + setCurrentPage(1); + }; + + const renderToolbarButton = (action, index) => { + if (React.isValidElement(action)) { + return React.cloneElement(action, { key: action.key || `toolbar-${index}` }); + } + + if (action?.kind === 'text') { + return ( + + {action?.text} + + ); + } + + if (action?.kind === 'node') { + return {action?.node}; + } + + const IconComponent = action?.icon ? getIcon(action.icon) : null; + return ( + + ); + }; + + return ( + + + + + {title} + + {topLeftContent} + + + + {searchConfig?.enabled ? ( + + ) : null} + {effectiveToolbarItems.map(renderToolbarButton)} + + ); + })} + + + ); + } + + if (type === 'checkbox') { + return ( + + + + ); + } + + if (type === 'radio') { + return ( + + + {options.map((option) => { + const selected = String(fieldValue) === String(option.value); + return ( + + ); + })} + + + ); + } + + if (type === 'file') { + return ( + + + + + {fieldValue?.name || 'No file selected'} + + + + ); + } + + if (type === 'textarea') { + return ( + +