From 238ed28371e46f4a3dd424ac0f1ac31be673a95c Mon Sep 17 00:00:00 2001 From: Ivan Date: Sat, 5 Sep 2026 06:52:43 -0500 Subject: [PATCH 001/150] refactor(ui): streamline component templates and styles for improved readability and consistency --- meshchatx/src/frontend/components/Skeleton.vue | 12 ++---------- .../components/call/tabs/CallContactsTab.vue | 6 +----- meshchatx/src/frontend/components/map/MapPage.vue | 13 +++++++++++++ .../components/messages/ConversationViewer.vue | 2 +- .../frontend/components/settings/SettingsPage.vue | 6 +----- .../frontend/js/mapExchange/descriptionFlatten.js | 4 +--- 6 files changed, 19 insertions(+), 24 deletions(-) diff --git a/meshchatx/src/frontend/components/Skeleton.vue b/meshchatx/src/frontend/components/Skeleton.vue index 6c04bab2f..f09c0d63e 100644 --- a/meshchatx/src/frontend/components/Skeleton.vue +++ b/meshchatx/src/frontend/components/Skeleton.vue @@ -1,11 +1,7 @@ - - diff --git a/meshchatx/src/frontend/components/blocked/BlockedPage.vue b/meshchatx/src/frontend/components/blocked/BlockedPage.vue index 6d623aeb7..7bcb199d0 100644 --- a/meshchatx/src/frontend/components/blocked/BlockedPage.vue +++ b/meshchatx/src/frontend/components/blocked/BlockedPage.vue @@ -1,528 +1,29 @@ diff --git a/meshchatx/src/frontend/components/map/MapBrowser.vue b/meshchatx/src/frontend/components/map/MapBrowser.vue index 966b73068..143bcf05f 100644 --- a/meshchatx/src/frontend/components/map/MapBrowser.vue +++ b/meshchatx/src/frontend/components/map/MapBrowser.vue @@ -4,7 +4,7 @@
+
@@ -86,6 +91,7 @@ import { loadMapTabs, saveMapTabs } from "../../js/browserLayoutStore"; import { LEGACY_MAP_STATE_KEY, legacyMapTabStateKey, mapViewStateKey } from "../../js/mapStateKeys.js"; const DOUBLE_TAP_MS = 400; +const MAX_MAP_TABS = 8; function createStorageId() { if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { @@ -119,6 +125,9 @@ export default { showTabStrip() { return this.isWideViewport && this.tabs.length > 0; }, + canAddTab() { + return this.tabs.length < MAX_MAP_TABS; + }, activeTab() { return this.tabs.find((tab) => tab.id === this.activeTabId) || null; }, @@ -197,6 +206,9 @@ export default { return this.$t("map.tab_default_name", { number: tabNumber }); }, addTab(title = null, activate = true, storageId = null) { + if (this.tabs.length >= MAX_MAP_TABS) { + return null; + } const tabNumber = this.nextTabNumber++; const id = this.nextTabId++; const resolvedStorageId = storageId || createStorageId(); @@ -306,7 +318,9 @@ export default { if (mod && key === "t") { event.preventDefault(); event.stopPropagation(); - this.addTab(); + if (this.canAddTab) { + this.addTab(); + } return; } if (mod && key === "w") { @@ -370,7 +384,7 @@ export default { } let maxTabNumber = 0; - this.tabs = saved.tabs.map((tab, index) => { + this.tabs = saved.tabs.slice(0, MAX_MAP_TABS).map((tab, index) => { const tabNumber = Number.isInteger(tab.tabNumber) && tab.tabNumber > 0 ? tab.tabNumber : index + 1; maxTabNumber = Math.max(maxTabNumber, tabNumber); return { diff --git a/meshchatx/src/frontend/components/map/MapPage.vue b/meshchatx/src/frontend/components/map/MapPage.vue index da31e5b1a..b22ced2d8 100644 --- a/meshchatx/src/frontend/components/map/MapPage.vue +++ b/meshchatx/src/frontend/components/map/MapPage.vue @@ -429,7 +429,7 @@ Clear drawings -
+
{{ $t("map.context_coords") }}
@@ -1510,6 +1510,7 @@ export default { }, isActiveTab(active) { if (active) { + this.$nextTick(() => this.refreshTabToolbarHost()); this.bootMap().then(() => { if (this.map && typeof this.map.updateSize === "function") { this.map.updateSize(); @@ -1620,6 +1621,8 @@ export default { document.addEventListener("keydown", this.onDeleteKey); + this.setupTabToolbarHostWatcher(); + // Update info every few seconds this.reloadInterval = setInterval(() => { if (this.shouldPollTelemetry()) { @@ -1630,6 +1633,7 @@ export default { }, beforeUnmount() { GlobalEmitter.off("websocket-reconnected", this.onWebsocketReconnected); + this.teardownTabToolbarHostWatcher(); if (this.map && this.map.getViewport()) { this.map.getViewport().removeEventListener("contextmenu", this.onContextMenu); } @@ -1665,6 +1669,14 @@ export default { cancelAnimationFrame(this._pointerMoveRaf); this._pointerMoveRaf = null; } + if (this._cursorCoordsRaf != null) { + cancelAnimationFrame(this._cursorCoordsRaf); + this._cursorCoordsRaf = null; + } + if (this._cursorFormatTimer != null) { + clearTimeout(this._cursorFormatTimer); + this._cursorFormatTimer = null; + } if (this.settingsPanelDrag?.rafId != null) { cancelAnimationFrame(this.settingsPanelDrag.rafId); this.settingsPanelDrag.rafId = null; @@ -4130,9 +4142,73 @@ export default { this.selectedFeature = markRaw(feature); } this.showContextMenu = true; + void this.refreshContextMenuCoordRows(); }, closeContextMenu() { this.showContextMenu = false; + this.contextMenuCoordRows = []; + }, + coordFormatLabel(format) { + const key = { + wgs84: "map.coord_format_wgs84", + utm: "map.coord_format_utm", + mgrs: "map.coord_format_mgrs", + olc: "map.coord_format_olc", + }[format]; + return key ? this.$t(key) : format; + }, + async refreshContextMenuCoordRows() { + const coord = this.contextMenuCoord; + if (!coord) { + this.contextMenuCoordRows = []; + return; + } + const [lon, lat] = coord; + await ensureGeoCoordsReady(); + this.geoWasmEpoch += 1; + const refLat = this.currentCenter?.[1] || lat; + const refLon = this.currentCenter?.[0] || lon; + this.contextMenuCoordRows = COORD_FORMATS.map((format) => { + const res = formatCoordinate(lon, lat, format, { + hasRef: true, + refLat, + refLon, + }); + return { + format, + label: this.coordFormatLabel(format), + text: res?.text || `${lat.toFixed(6)}, ${lon.toFixed(6)}`, + }; + }); + }, + async contextCopyCoordFormat(format) { + if (!this.contextMenuCoord) { + this.closeContextMenu(); + return; + } + const [lon, lat] = this.contextMenuCoord; + await ensureGeoCoordsReady(); + const formatted = formatCoordinate(lon, lat, format, { + hasRef: true, + refLat: this.currentCenter?.[1] || lat, + refLon: this.currentCenter?.[0] || lon, + }); + const text = formatted?.text || `${lat.toFixed(6)}, ${lon.toFixed(6)}`; + if (formatted?.fallback && shouldWarnGeoWasmFallback()) { + ToastUtils.warning(this.$t("map.geo_wasm_unavailable")); + } + try { + if (navigator?.clipboard?.writeText) { + await navigator.clipboard.writeText(text); + ToastUtils.success(this.$t("map.copied_coordinates")); + } else { + ToastUtils.success(text); + } + } catch (e) { + console.error("Copy failed", e); + ToastUtils.warning(text); + } + this.closeContextMenu(); }, contextSelectFeature() { if (!this.contextMenuFeature || !this.select || !this.translate) { @@ -4228,7 +4304,30 @@ export default { handleMapPointerMove(evt) { if (!this.map) return; const lonLat = toLonLat(evt.coordinate); - this.cursorCoords = [lonLat[0], lonLat[1]]; + this._pendingCursorLonLat = [lonLat[0], lonLat[1]]; + if (this._cursorCoordsRaf == null) { + this._cursorCoordsRaf = requestAnimationFrame(() => { + this._cursorCoordsRaf = null; + const pending = this._pendingCursorLonLat; + if (!pending) { + return; + } + // WGS84 readout is cheap. Advanced formats (MGRS/UTM/OLC) hit WASM + // so throttle those updates to avoid stutter under dense layers. + const fmt = this.coordinateFormat; + if (fmt === "wgs84") { + this.cursorCoords = pending; + } else if (this._cursorFormatTimer == null) { + this.cursorCoords = pending; + this._cursorFormatTimer = setTimeout(() => { + this._cursorFormatTimer = null; + if (this._pendingCursorLonLat) { + this.cursorCoords = this._pendingCursorLonLat; + } + }, 80); + } + }); + } if (this.isBearingMode && !evt.dragging) { this.updateBearingPointerUi(evt); } @@ -4279,6 +4378,41 @@ export default { }); }, + setupTabToolbarHostWatcher() { + this.refreshTabToolbarHost(); + if (typeof window === "undefined" || typeof window.matchMedia !== "function") { + return; + } + this._tabToolbarMq = window.matchMedia("(min-width: 768px)"); + this._tabToolbarMqListener = () => { + this.$nextTick(() => this.refreshTabToolbarHost()); + }; + if (typeof this._tabToolbarMq.addEventListener === "function") { + this._tabToolbarMq.addEventListener("change", this._tabToolbarMqListener); + } else if (typeof this._tabToolbarMq.addListener === "function") { + this._tabToolbarMq.addListener(this._tabToolbarMqListener); + } + }, + teardownTabToolbarHostWatcher() { + if (!this._tabToolbarMq || !this._tabToolbarMqListener) { + this.tabToolbarHostReady = false; + return; + } + if (typeof this._tabToolbarMq.removeEventListener === "function") { + this._tabToolbarMq.removeEventListener("change", this._tabToolbarMqListener); + } else if (typeof this._tabToolbarMq.removeListener === "function") { + this._tabToolbarMq.removeListener(this._tabToolbarMqListener); + } + this._tabToolbarMq = null; + this._tabToolbarMqListener = null; + this.tabToolbarHostReady = false; + }, + refreshTabToolbarHost() { + this.tabToolbarHostReady = Boolean( + typeof document !== "undefined" && document.getElementById("map-browser-toolbar-host") + ); + }, + handleMapClick(evt) { if (this.isDrawing || this.isMeasuring || this.isBearingMode) return; From aae4aa5ae041d6d02415bb994a6826848fb4c30d Mon Sep 17 00:00:00 2001 From: Ivan Date: Sat, 5 Sep 2026 07:55:56 -0500 Subject: [PATCH 003/150] feat(ui): add Svelte support with new feature modules and integrate Svelte components into the frontend --- .agents/README.md | 3 +- .agents/conventions/frontend.md | 36 +- .agents/module-ownership.md | 16 +- .../skills/contribution-registries/SKILL.md | 4 + .agents/skills/page-toast-tests/SKILL.md | 5 +- .../skills/svelte-feature-modules/SKILL.md | 69 +++ .prettierrc | 11 +- CHANGELOG.md | 2 +- Taskfile.yml | 3 +- eslint.config.mjs | 35 ++ .../features/blocked/BlockedPage.svelte | 484 ++++++++++++++++++ .../src/frontend/features/blocked/index.js | 20 + .../features/blocked/lib/blockedList.js | 74 +++ .../frontend/features/registerAllFeatures.js | 25 + meshchatx/src/frontend/js/GlobalState.js | 2 + meshchatx/src/frontend/js/appState.js | 83 +++ meshchatx/src/frontend/js/i18n.js | 89 ++++ .../js/mapExchange/descriptionFlatten.js | 36 +- .../frontend/js/mapExchange/metadataUtils.js | 3 +- .../js/mapExchange/styleFromProperties.js | 11 +- .../frontend/js/registries/featureRegistry.js | 62 +++ .../frontend/js/registries/routeRegistry.js | 62 +++ .../src/frontend/js/svelte/appState.svelte.js | 33 ++ meshchatx/src/frontend/locales/en.json | 4 + meshchatx/src/frontend/main.js | 12 +- .../src/frontend/shell/FeaturePageHost.vue | 71 +++ .../src/frontend/shell/buildRouterRoutes.js | 45 ++ .../frontend/ui/svelte/ConfirmDialog.svelte | 258 ++++++++++ .../ui/svelte/MaterialDesignIcon.svelte | 24 + .../src/frontend/ui/svelte/SmokeBadge.svelte | 11 + package.json | 7 + pnpm-lock.yaml | 430 +++++++++++++++- svelte.config.js | 13 + tests/frontend/BanishedPage.test.js | 117 ++--- tests/frontend/BlockedPage.test.js | 168 +++--- tests/frontend/ConfirmDialog.test.js | 139 ++--- tests/frontend/MapBrowser.test.js | 10 + tests/frontend/SmokeBadge.svelte.test.js | 21 + tests/frontend/UIThemeAndVisibility.test.js | 43 +- .../__snapshots__/uiSnapshots.test.js.snap | 2 +- tests/frontend/appStateI18nAdapters.test.js | 62 +++ tests/frontend/featureModuleOwnership.test.js | 79 +++ tests/frontend/featureRouteRegistry.test.js | 70 +++ tests/frontend/hostFlipReadiness.test.js | 36 ++ tests/frontend/mapExchange.sanitize.test.js | 18 + tests/frontend/mapExchange.test.js | 3 +- tests/frontend/uiSnapshots.test.js | 5 +- tsconfig.json | 4 +- tsconfig.svelte.json | 11 + vite.config.js | 7 +- vitest.config.js | 6 +- 51 files changed, 2576 insertions(+), 268 deletions(-) create mode 100644 .agents/skills/svelte-feature-modules/SKILL.md create mode 100644 meshchatx/src/frontend/features/blocked/BlockedPage.svelte create mode 100644 meshchatx/src/frontend/features/blocked/index.js create mode 100644 meshchatx/src/frontend/features/blocked/lib/blockedList.js create mode 100644 meshchatx/src/frontend/features/registerAllFeatures.js create mode 100644 meshchatx/src/frontend/js/appState.js create mode 100644 meshchatx/src/frontend/js/i18n.js create mode 100644 meshchatx/src/frontend/js/registries/featureRegistry.js create mode 100644 meshchatx/src/frontend/js/registries/routeRegistry.js create mode 100644 meshchatx/src/frontend/js/svelte/appState.svelte.js create mode 100644 meshchatx/src/frontend/shell/FeaturePageHost.vue create mode 100644 meshchatx/src/frontend/shell/buildRouterRoutes.js create mode 100644 meshchatx/src/frontend/ui/svelte/ConfirmDialog.svelte create mode 100644 meshchatx/src/frontend/ui/svelte/MaterialDesignIcon.svelte create mode 100644 meshchatx/src/frontend/ui/svelte/SmokeBadge.svelte create mode 100644 svelte.config.js create mode 100644 tests/frontend/SmokeBadge.svelte.test.js create mode 100644 tests/frontend/appStateI18nAdapters.test.js create mode 100644 tests/frontend/featureModuleOwnership.test.js create mode 100644 tests/frontend/featureRouteRegistry.test.js create mode 100644 tests/frontend/hostFlipReadiness.test.js create mode 100644 tsconfig.svelte.json diff --git a/.agents/README.md b/.agents/README.md index 9b7135321..3a30a6acb 100644 --- a/.agents/README.md +++ b/.agents/README.md @@ -11,7 +11,7 @@ Entry: root `AGENTS.md`. Always-on rules: `.cursor/rules/`. | Domain to manager / HTTP / WS / tests | [module-ownership.md](module-ownership.md) | | Mesh Zen gates | [conventions/reticulum-zen.md](conventions/reticulum-zen.md) | | Always-on standards | [conventions/core.md](conventions/core.md) | -| Vue UI | [conventions/frontend.md](conventions/frontend.md) | +| Vue UI / Svelte dual stack | [conventions/frontend.md](conventions/frontend.md) | | Python / HTTP / SQLite | [conventions/backend.md](conventions/backend.md) | | Local FS jail | [conventions/path-jail.md](conventions/path-jail.md) | | Android WebView | [conventions/android.md](conventions/android.md) | @@ -38,6 +38,7 @@ Skills: one file each at `skills//SKILL.md`. Open the matching skill befor | privacy mode / clearnet HTTP | [privacy-mode-clearnet](skills/privacy-mode-clearnet/SKILL.md) | | new page, toast, i18n | [page-toast-tests](skills/page-toast-tests/SKILL.md) | | nav, tools, commands, WS registry | [contribution-registries](skills/contribution-registries/SKILL.md) | +| Svelte 5 / feature modules | [svelte-feature-modules](skills/svelte-feature-modules/SKILL.md) | | Vue mega-page extract | [vue-mega-page-split](skills/vue-mega-page-split/SKILL.md) | | HTTP/WS extract from meshchat.py | [meshchat-orchestration-split](skills/meshchat-orchestration-split/SKILL.md) | | identity key vs zip restore | [identity-restore](skills/identity-restore/SKILL.md) | diff --git a/.agents/conventions/frontend.md b/.agents/conventions/frontend.md index 905b132b6..cc5e04940 100644 --- a/.agents/conventions/frontend.md +++ b/.agents/conventions/frontend.md @@ -1,11 +1,13 @@ # Frontend conventions -Applies when editing `meshchatx/src/frontend/**/*.{vue,js}`. +Applies when editing `meshchatx/src/frontend/**/*.{vue,js,svelte}`. -- Vue 3 Options API is the dominant pattern. Match the file you edit. +- Vue 3 Options API remains the pattern for existing `.vue` files. Match the file you edit. +- New greenfield pages and shared UI go Svelte 5 (runes only) under `ui/svelte/` or `features//`. +- New routes use `registerFeature` / `routeRegistry`. Do not add one-off routes to the hardcoded table in `main.js`. - API calls go through `window.api` (not ad-hoc axios imports in pages). - Toasts: `ToastUtils.success|error|warning|info|loading|dismiss`. -- New top-level pages need: route in `main.js`, nav entry when discoverable, `en.json` keys, frontend tests. +- New top-level pages need: feature registration (or legacy route until absorbed), nav entry when discoverable, `en.json` keys, frontend tests. - When adding user-visible strings, update `en.json` and the other maintained locale files under `meshchatx/src/frontend/locales/` with real translations (not English copies). - Sidebar unread pills live on nav entries in `coreNavEntries.js` and counters in `GlobalState`. Do not bring back a header notification bell for that job. - Do not use `_`-prefixed keys in Vue `data()` (`vue/no-reserved-keys`). @@ -13,6 +15,32 @@ Applies when editing `meshchatx/src/frontend/**/*.{vue,js}`. - Prefer existing MaterialDesignIcon / layout patterns over new design systems. - No backticks in code comments. Prefer plain words or quoted identifiers. +## Layers + +| Layer | Location | May import | +| -------- | -------------------------------------------------- | ----------------------------------- | +| Kernel | `js/` (api, registries, toast, theme, state, i18n) | other kernel only | +| UI | `ui/svelte/`, shared Vue primitives | kernel | +| Features | `features//` | kernel, ui, own files | +| Shell | `App.vue`, boot (`main.js`) | kernel, registries, page mount host | + +Cross-feature UI imports are forbidden. Share through kernel events, registries, or `ui/`. + +## Conveyor (Vue to Svelte) + +1. Absorb a quiet leaf into `features//` with `registerFeature` (Vue mount first is fine). +2. Extract pure helpers under `features//lib/`. +3. Rewrite the page to Svelte, set `mount: "svelte"`. +4. Keep a thin Vue wrapper only while tests or imports still need it. +5. Update `FEATURE_MODULE_OWNERS` in `tests/frontend/featureModuleOwnership.test.js`. +6. Host flip (`App.vue` to Svelte shell) waits until registry routes no longer need `mount: "vue"` and legacy hardcoded routes are gone. See `tests/frontend/hostFlipReadiness.test.js`. + +## Svelte + +- `pnpm run svelte-check` in lint. Runes mode only. +- Dual mount: route meta `mount: "vue" | "svelte"` with a lazy `load`. +- Skill: `.agents/skills/svelte-feature-modules/SKILL.md`. + ## Shared UI primitives Prefer these over ad-hoc gray/blue utilities on new or touched surfaces: @@ -26,7 +54,7 @@ Prefer these over ad-hoc gray/blue utilities on new or touched surfaces: ## Mega-page extracts -When splitting large page shells, follow `.agents/skills/vue-mega-page-split/SKILL.md` +When splitting large Vue page shells, follow `.agents/skills/vue-mega-page-split/SKILL.md` and the Frontend mega-pages table in `.agents/module-ownership.md`. - Mechanical extract only. Move or behaviour change, never both in the same change. diff --git a/.agents/module-ownership.md b/.agents/module-ownership.md index 34c53bf04..e8466de1a 100644 --- a/.agents/module-ownership.md +++ b/.agents/module-ownership.md @@ -50,13 +50,15 @@ WS inbound handlers live in `ws/handlers_*.py` and are composed by `ws/dispatch. Placement for extracts. Follow `.agents/skills/vue-mega-page-split/SKILL.md`. Do not invent folders outside this table. -| Kind | Put it here | Example | -| --------------------------- | --------------------------------------------------- | ----------------------------------- | -| Page-private panel or UI | `components//internal/*.vue` | MapSearchBar | -| Settings chunk | `components/settings/sections/*SettingsSection.vue` | TelephonySettingsSection | -| Pure logic | colocated `*.js` or `js//` | clusterUtils, settingsConfigService | -| Cross-feature primitive | root `components/` or `components/forms/` | ConfirmDialog, Toggle | -| Nav, tools, commands wiring | `js/registries/` only | never grow App.vue for discovery | +| Kind | Put it here | Example | +| ------------------------------- | --------------------------------------------------------- | ----------------------------------- | +| Page-private panel or UI | `components//internal/*.vue` | MapSearchBar | +| Settings chunk | `components/settings/sections/*SettingsSection.vue` | TelephonySettingsSection | +| Pure logic | colocated `*.js` or `js//` | clusterUtils, settingsConfigService | +| Cross-feature primitive | root `components/` or `components/forms/` or `ui/svelte/` | ConfirmDialog, Toggle | +| Feature module (new / migrated) | `features//` (page + `index.js` register) | `features/blocked/` | +| Nav, tools, commands, routes | `js/registries/` only | never grow App.vue / main.js tables | +| Shell chrome / page host | `shell/` | FeaturePageHost, buildRouterRoutes | | Page shell | On-disk child dirs | Planned child dirs | Shared JS | Primary tests | | ------------------------------------------ | ------------------------------------------------------------------- | -------------------------------------------- | ------------------------------- | -------------------------------------------------------------------------------- | diff --git a/.agents/skills/contribution-registries/SKILL.md b/.agents/skills/contribution-registries/SKILL.md index 2d3f2e829..7970a8189 100644 --- a/.agents/skills/contribution-registries/SKILL.md +++ b/.agents/skills/contribution-registries/SKILL.md @@ -24,8 +24,12 @@ Wire nav, tools, commands, settings search, and WebSocket events through registr | `settingsSectionRegistry.js` | Settings search / sections | | `wsEventRegistry.js` + `wsEventBridge.js` | Typed WS handlers | | `postInstallPromptRegistry.js` | Existing-user / after-install prompts | +| `routeRegistry.js` + `featureRegistry.js` | Feature routes (`registerFeature`) | +| `features/registerAllFeatures.js` | Boot hook for feature modules | Core boot registers once via `registerCoreContributions.js` and `core*Entries.js` siblings. +Feature modules register via `features/registerAllFeatures.js` (see `features//` and svelte-feature-modules skill). +Shell builds registry routes with `shell/buildRouterRoutes.js`. Do not add new routes to the hardcoded table in `main.js`. ## Nav badges diff --git a/.agents/skills/page-toast-tests/SKILL.md b/.agents/skills/page-toast-tests/SKILL.md index 586fb69a0..167bb1993 100644 --- a/.agents/skills/page-toast-tests/SKILL.md +++ b/.agents/skills/page-toast-tests/SKILL.md @@ -34,8 +34,9 @@ Before editing files, decide: For a new top-level page, verify all relevant items: -- Add route in `meshchatx/src/frontend/main.js` with `defineAsyncComponent`. -- Add sidebar/tools entry in `meshchatx/src/frontend/components/App.vue` when the page must be user-discoverable. +- Prefer `features//` + `registerFeature` (see svelte-feature-modules skill). Wire via `features/registerAllFeatures.js`. Do not grow the hardcoded route table in `main.js`. +- Greenfield UI: Svelte 5 under `features//` or `ui/svelte/`. +- Add sidebar/tools entry through nav/tools registries when the page must be user-discoverable. - Add translation keys in `meshchatx/src/frontend/locales/en.json` and other maintained locale files when touched by task scope. - Use `ToastUtils` in page actions that save, submit, refresh, copy, or fail. - Add or update tests in `tests/frontend/*.test.js`. diff --git a/.agents/skills/svelte-feature-modules/SKILL.md b/.agents/skills/svelte-feature-modules/SKILL.md new file mode 100644 index 000000000..479cc296e --- /dev/null +++ b/.agents/skills/svelte-feature-modules/SKILL.md @@ -0,0 +1,69 @@ +--- +name: svelte-feature-modules +description: Svelte 5 pages and feature modules for MeshChatX. Use when adding Svelte UI, registerFeature routes, or dual Vue/Svelte mounts. +--- + +# Skill: svelte-feature-modules + +Gradual Vue to Svelte 5 migration with pluggable feature modules. + +## When to use + +- New greenfield pages or shared UI +- Moving a route behind `registerFeature` +- Writing `.svelte` / `.svelte.js` code +- Dual-stack mount (`vue` or `svelte`) + +## Hard rules + +1. Svelte 5 runes only (`compilerOptions.runes: true`). No legacy `export let`. +2. New routes go through `registerFeature` / `routeRegistry`. Do not grow the hardcoded table in `main.js`. +3. API via `window.api` / kernel clients. Toasts via `ToastUtils`. +4. User-visible strings: locale JSON + `t()` from kernel i18n adapter (or Vue `$t` in remaining Vue). +5. Import boundaries: `js/` kernel must not import `components/`, `features/`, or `.svelte`/`.vue`. Features may import kernel and `ui/`. +6. Prefer `ui/svelte/` for shared primitives. Prefer `features//` for page ownership. +7. Mount bridge uses Svelte `mount` / `unmount` into a Vue host. No custom-element shadow DOM for in-app pages. +8. One feature or primitive per PR series. No god-file rewrites. +9. SPDX on new project files (`0BSD` unless the file already differs). +10. Run `pnpm run svelte-check` for touched Svelte surfaces. + +## Layout + +``` +meshchatx/src/frontend/ + ui/svelte/ shared primitives + features// register + page + lib + js/registries/ routeRegistry, registerFeature, existing nav/tools/... + components/ legacy Vue (shrink) + shell/ optional future host (Vue App.vue is shell today) +``` + +## Register a feature + +```js +import { registerFeature } from "../../js/registries/featureRegistry.js"; + +export function registerBlockedFeature() { + registerFeature({ + id: "blocked", + routes: [ + { + name: "blocked", + path: "/blocked", + mount: "svelte", + load: () => import("./BlockedPage.svelte"), + }, + ], + }); +} +``` + +Call registers from `features/registerAllFeatures.js` at boot after `registerCoreContributions`. + +## Checks + +```bash +pnpm run svelte-check +pnpm exec vitest run tests/frontend/.svelte.test.js +pnpm exec vitest run tests/frontend/featureRouteRegistry.test.js +``` diff --git a/.prettierrc b/.prettierrc index 36426eca4..d8bb52370 100644 --- a/.prettierrc +++ b/.prettierrc @@ -4,5 +4,14 @@ "singleQuote": false, "printWidth": 120, "trailingComma": "es5", - "endOfLine": "auto" + "endOfLine": "auto", + "plugins": ["prettier-plugin-svelte"], + "overrides": [ + { + "files": "*.svelte", + "options": { + "parser": "svelte" + } + } + ] } diff --git a/CHANGELOG.md b/CHANGELOG.md index 48dcc8ab7..acb7eacd6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ All notable changes to this project will be documented in this file. -## [4.8.6] - 2026-08-30 [unreleased] +## [4.8.6] - 2026-09-06 [unreleased] ### Added diff --git a/Taskfile.yml b/Taskfile.yml index 7fd0d2931..68bf8cdb6 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -245,10 +245,11 @@ tasks: lint:frontend: aliases: [lint:fe] - desc: Lint frontend code (ESLint, vue-tsc, knip, format-token and git-dep pin checks) + desc: Lint frontend code (ESLint, vue-tsc, svelte-check, knip, format-token and git-dep pin checks) cmds: - "{{.NPM}} run lint" - "{{.NPM}} run typecheck" + - "{{.NPM}} run svelte-check" - "{{.NPM}} exec vue-tsc --noEmit -p tsconfig.libs.json" - "{{.NPM}} exec knip" - task: check:format-tokens diff --git a/eslint.config.mjs b/eslint.config.mjs index 9cfb8f645..0131b0b82 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,8 +1,10 @@ import js from "@eslint/js"; import pluginVue from "eslint-plugin-vue"; +import pluginSvelte from "eslint-plugin-svelte"; import pluginPrettier from "eslint-plugin-prettier/recommended"; import pluginSecurity from "eslint-plugin-security"; import globals from "globals"; +import svelteConfig from "./svelte.config.js"; export default [ { @@ -69,6 +71,7 @@ export default [ }, js.configs.recommended, ...pluginVue.configs["flat/recommended"], + ...pluginSvelte.configs["flat/recommended"], pluginPrettier, pluginSecurity.configs.recommended, { @@ -82,4 +85,36 @@ export default [ "security/detect-non-literal-fs-filename": "off", }, }, + { + files: ["**/*.svelte"], + languageOptions: { + globals: { + ...globals.browser, + }, + parserOptions: { + svelteConfig, + }, + }, + rules: { + "no-unused-vars": "warn", + "no-console": "off", + "security/detect-object-injection": "off", + }, + }, + { + files: ["meshchatx/src/frontend/js/**/*.{js,mjs}"], + rules: { + "no-restricted-imports": [ + "error", + { + patterns: [ + { + group: ["**/components/**", "**/ui/svelte/**", "**/features/**", "**/*.vue", "**/*.svelte"], + message: "Kernel js/ must stay framework-free. Import only other kernel modules.", + }, + ], + }, + ], + }, + }, ]; diff --git a/meshchatx/src/frontend/features/blocked/BlockedPage.svelte b/meshchatx/src/frontend/features/blocked/BlockedPage.svelte new file mode 100644 index 000000000..077e941ad --- /dev/null +++ b/meshchatx/src/frontend/features/blocked/BlockedPage.svelte @@ -0,0 +1,484 @@ + + + + +
+
+
+
+
+

+ {t("banishment.title")} +

+

+ {t("banishment.description")} +

+
+
+
+ + + + +
+ {#if !selectMode} + + {:else} + + {#if selectedIdentities.length > 0} + + {/if} + + {/if} + +
+
+ + {#if !isLoading || filteredBlockedIdentities.length > 0} +
+ + + {#if filteredBlockedIdentities.length > 0} + + {t("banishment.result_count", { count: filteredBlockedIdentities.length })} + + {/if} +
+ {/if} + + {#if isLoading && filteredBlockedIdentities.length === 0} +
+ {#each [1, 2, 3, 4, 5] as i (i)} +
+
+
+
+
+
+
+
+
+
+ {/each} +
+ {:else if filteredBlockedIdentities.length === 0} +
+
+ +
+

+ {t("banishment.no_items")} +

+

+ {searchQuery ? t("nomadnet.no_search_results_peers") : t("nomadnet.no_announces_yet")} +

+
+ {:else} +
+ {#each filteredBlockedIdentities as identity (identity.identity_hash)} + {@const selected = selectMode && selectedIdentities.includes(identity.identity_hash)} +
+
+
+ {#if selectMode} +
+ + toggleSelected(identity.identity_hash, e.currentTarget.checked)} + /> +
+ {/if} +
+ +
+
+
+

+ {identity.display_name || t("call.unknown")} +

+ {#if identity.is_node} + + {t("banishment.node")} + + {:else} + + {t("banishment.user")} + + {/if} +
+

+ {identity.identity_hash} +

+ {#if identityBlockedAt(identity)} +

+ {t("banishment.banished_at")} + {Utils.formatTimeAgo(identityBlockedAt(identity))} +

+ {/if} + {#if identity.is_rns_blackholed} + + RNS Blackhole + + {/if} +
+
+ + {#if identity.blocked_destinations.length > 0} +
+

+ {t("banishment.blocked_destinations")} +

+
+ {#each identity.blocked_destinations as dest (dest.destination_hash)} +
+ + {dest.destination_hash} + + {#if dest.created_at} + + {Utils.formatTimeAgo(dest.created_at)} + + {/if} +
+ {/each} +
+
+ {/if} + + {#if identity.rns_reason} +
+ “{identity.rns_reason}” +
+ {/if} + {#if identity.rns_source} +
+ Source: {identity.rns_source} +
+ {/if} + + {#if !selectMode} + + {/if} +
+
+ {/each} +
+ {/if} +
+
+
diff --git a/meshchatx/src/frontend/features/blocked/index.js b/meshchatx/src/frontend/features/blocked/index.js new file mode 100644 index 000000000..17418db4b --- /dev/null +++ b/meshchatx/src/frontend/features/blocked/index.js @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: 0BSD + +import { registerFeature } from "../../js/registries/featureRegistry.js"; + +/** + * Quiet leaf feature used to prove registerFeature + Svelte mount. + */ +export function registerBlockedFeature() { + registerFeature({ + id: "blocked", + routes: [ + { + name: "blocked", + path: "/blocked", + mount: "svelte", + load: () => import("./BlockedPage.svelte"), + }, + ], + }); +} diff --git a/meshchatx/src/frontend/features/blocked/lib/blockedList.js b/meshchatx/src/frontend/features/blocked/lib/blockedList.js new file mode 100644 index 000000000..65f207c72 --- /dev/null +++ b/meshchatx/src/frontend/features/blocked/lib/blockedList.js @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: 0BSD + +/** + * Pure helpers for the blocked / banished identities list. + */ + +/** + * @param {object} identity + * @returns {string | null} + */ +export function identityBlockedAt(identity) { + const dates = (identity.blocked_destinations || []).map((dest) => dest.created_at).filter(Boolean); + if (dates.length === 0) { + return null; + } + return dates.sort().reverse()[0]; +} + +/** + * @param {object} a + * @param {object} b + * @returns {number} + */ +export function compareBlockedAt(a, b) { + const atA = identityBlockedAt(a) || ""; + const atB = identityBlockedAt(b) || ""; + if (atA === atB) { + const nameA = (a.display_name || a.identity_hash).toLowerCase(); + const nameB = (b.display_name || b.identity_hash).toLowerCase(); + return nameA.localeCompare(nameB); + } + return atA.localeCompare(atB); +} + +/** + * @param {object[]} identities + * @param {{ searchQuery?: string, typeFilter?: string, dateSort?: string }} opts + * @returns {object[]} + */ +export function filterBlockedIdentities(identities, opts = {}) { + let list = [...identities]; + const query = String(opts.searchQuery || "") + .trim() + .toLowerCase(); + if (query) { + list = list.filter((identity) => { + if (identity.identity_hash.toLowerCase().includes(query)) return true; + if ((identity.display_name || "").toLowerCase().includes(query)) return true; + return (identity.blocked_destinations || []).some((d) => d.destination_hash.toLowerCase().includes(query)); + }); + } + + if (opts.typeFilter === "user") { + list = list.filter((identity) => !identity.is_node && !identity.is_rns_blackholed); + } else if (opts.typeFilter === "node") { + list = list.filter((identity) => identity.is_node); + } else if (opts.typeFilter === "rns") { + list = list.filter((identity) => identity.is_rns_blackholed); + } + + if (opts.dateSort === "name") { + list.sort((a, b) => { + const nameA = (a.display_name || a.identity_hash).toLowerCase(); + const nameB = (b.display_name || b.identity_hash).toLowerCase(); + return nameA.localeCompare(nameB); + }); + } else if (opts.dateSort === "oldest") { + list.sort((a, b) => compareBlockedAt(a, b)); + } else { + list.sort((a, b) => compareBlockedAt(b, a)); + } + + return list; +} diff --git a/meshchatx/src/frontend/features/registerAllFeatures.js b/meshchatx/src/frontend/features/registerAllFeatures.js new file mode 100644 index 000000000..75ecf07b8 --- /dev/null +++ b/meshchatx/src/frontend/features/registerAllFeatures.js @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: 0BSD + +import { registerBlockedFeature } from "./blocked/index.js"; + +let featuresRegistered = false; + +/** + * Reset for tests. + */ +export function resetAllFeaturesForTests() { + featuresRegistered = false; +} + +/** + * Register feature modules that opt into routeRegistry. + * Call once at boot after registerCoreContributions. + * Lives under features/ so kernel js/ stays free of feature imports. + */ +export function registerAllFeatures() { + if (featuresRegistered) { + return; + } + featuresRegistered = true; + registerBlockedFeature(); +} diff --git a/meshchatx/src/frontend/js/GlobalState.js b/meshchatx/src/frontend/js/GlobalState.js index 545e9bf54..8e5ed5db8 100644 --- a/meshchatx/src/frontend/js/GlobalState.js +++ b/meshchatx/src/frontend/js/GlobalState.js @@ -1,4 +1,5 @@ import { reactive } from "vue"; +import { notifyAppStateListeners } from "./appState.js"; // global state const globalState = reactive({ @@ -59,6 +60,7 @@ export function mergeGlobalConfig(next) { } const prev = globalState.config && typeof globalState.config === "object" ? globalState.config : {}; globalState.config = { ...prev, ...next }; + notifyAppStateListeners(globalState); } export default globalState; diff --git a/meshchatx/src/frontend/js/appState.js b/meshchatx/src/frontend/js/appState.js new file mode 100644 index 000000000..b651aaef7 --- /dev/null +++ b/meshchatx/src/frontend/js/appState.js @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: 0BSD + +/** + * Framework-free subscription adapter over GlobalState. + * Vue keeps using the reactive object. Svelte and plain JS use this API. + */ + +/** @type {Set<(snapshot: object) => void>} */ +const listeners = new Set(); + +/** + * @returns {object} + */ +function readSnapshot(globalState) { + return globalState; +} + +/** + * Notify subscribers after an external mutation of GlobalState. + * Call from code that patches state outside Vue's reactive tracking when needed. + * @param {object} globalState + */ +export function notifyAppStateListeners(globalState) { + const snapshot = readSnapshot(globalState); + for (const listener of listeners) { + try { + listener(snapshot); + } catch (err) { + console.error("appState listener failed", err); + } + } +} + +/** + * @param {object} globalState + * @param {(snapshot: object) => void} listener + * @returns {() => void} unsubscribe + */ +export function subscribeAppState(globalState, listener) { + if (typeof listener !== "function") { + throw new Error("subscribeAppState: listener must be a function"); + } + listeners.add(listener); + listener(readSnapshot(globalState)); + return () => { + listeners.delete(listener); + }; +} + +/** + * @param {object} globalState + * @returns {object} + */ +export function getAppState(globalState) { + return readSnapshot(globalState); +} + +/** + * Shallow-assign keys onto GlobalState and notify listeners. + * @param {object} globalState + * @param {Record} patch + */ +export function patchAppState(globalState, patch) { + if (!patch || typeof patch !== "object") { + return; + } + Object.assign(globalState, patch); + notifyAppStateListeners(globalState); +} + +/** + * Merge into globalState.config and notify. + * @param {object} globalState + * @param {Record} next + */ +export function patchAppConfig(globalState, next) { + if (!next || typeof next !== "object") { + return; + } + const prev = globalState.config && typeof globalState.config === "object" ? globalState.config : {}; + globalState.config = { ...prev, ...next }; + notifyAppStateListeners(globalState); +} diff --git a/meshchatx/src/frontend/js/i18n.js b/meshchatx/src/frontend/js/i18n.js new file mode 100644 index 000000000..40045fde0 --- /dev/null +++ b/meshchatx/src/frontend/js/i18n.js @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: 0BSD + +/** + * Framework-free message lookup over vue-i18n composer or a plain dictionary. + * Svelte pages use t() from here. Vue keeps $t. + */ + +/** @type {((key: string, values?: Record) => string) | null} */ +let translateFn = null; + +/** @type {Record | null} */ +let fallbackMessages = null; + +/** + * @param {string} key + * @param {Record} [messages] + * @returns {unknown} + */ +function lookupPath(key, messages) { + if (!messages || typeof messages !== "object") { + return undefined; + } + const parts = String(key).split("."); + let cur = messages; + for (const part of parts) { + if (cur == null || typeof cur !== "object") { + return undefined; + } + cur = cur[part]; + } + return cur; +} + +/** + * @param {string} template + * @param {Record} [values] + * @returns {string} + */ +function interpolate(template, values) { + if (!values || typeof values !== "object") { + return template; + } + return template.replace(/\{(\w+)\}/g, (_, name) => { + if (Object.prototype.hasOwnProperty.call(values, name)) { + return String(values[name]); + } + return `{${name}}`; + }); +} + +/** + * Register the app translator (usually vue-i18n global.t). + * @param {((key: string, values?: Record) => string) | null | undefined} fn + */ +export function registerTranslator(fn) { + translateFn = typeof fn === "function" ? fn : null; +} + +/** + * Optional plain message tree used when no vue-i18n translator is registered (tests / early boot). + * @param {Record | null | undefined} messages + */ +export function registerFallbackMessages(messages) { + fallbackMessages = messages && typeof messages === "object" ? messages : null; +} + +/** + * Translate a key. Returns the key when missing. + * @param {string} key + * @param {Record} [values] + * @returns {string} + */ +export function t(key, values) { + if (translateFn) { + try { + const out = translateFn(key, values); + if (out != null && out !== "") { + return String(out); + } + } catch { + /* fall through */ + } + } + const found = lookupPath(key, fallbackMessages); + if (typeof found === "string") { + return interpolate(found, values); + } + return String(key); +} diff --git a/meshchatx/src/frontend/js/mapExchange/descriptionFlatten.js b/meshchatx/src/frontend/js/mapExchange/descriptionFlatten.js index 579150945..ebff807f9 100644 --- a/meshchatx/src/frontend/js/mapExchange/descriptionFlatten.js +++ b/meshchatx/src/frontend/js/mapExchange/descriptionFlatten.js @@ -11,7 +11,6 @@ const ENTITY_RE = /&(#x?[0-9a-f]+|[a-z]+);/gi; const SCRIPT_LIKE_RE = /(?:^|\n)\s*function\s+\w+\s*\([^)]*\)\s*\{[\s\S]*?\n\s*\}|(?:document|window)\.\w+\s*\(|getElementById\s*\(/i; const MASHED_NULL_KEY_RE = /((?:<Null>||null|<null>))\s*(?=[A-Za-z_][\w.-]{0,48}:)/gi; -const MASHED_VALUE_KEY_RE = /([^\s:])([A-Z][A-Za-z0-9_]{1,40}:)/g; const FONT_FIELD_RE = /<(?:font|b|strong|span)\b[^>]*>\s*([^<:]{1,64}?)\s*:?\s*<\/(?:font|b|strong|span)>\s*:?\s*([^<]{0,200}?)(?=<|$)/gi; @@ -115,18 +114,17 @@ function extractFontFields(html) { } /** - * Insert newlines between mashed Key:valueKey:value runs (common after tag strip). + * Insert newlines between mashed Key:<Null>Key:value runs (common after tag strip). + * Only splits after nullish placeholders so CamelCase keys like AttackType stay intact. * @param {string} text * @returns {string} */ export function unmashKeyedDescription(text) { - let s = String(text || ""); + const s = String(text || ""); if (!s) { return ""; } - s = s.replace(MASHED_NULL_KEY_RE, "$1\n"); - s = s.replace(MASHED_VALUE_KEY_RE, "$1\n$2"); - return s; + return s.replace(MASHED_NULL_KEY_RE, "$1\n"); } /** @@ -135,25 +133,21 @@ export function unmashKeyedDescription(text) { * @returns {string} */ function dropScriptLikePlainText(text) { - const lines = String(text || "").split(/\n+/); + let s = String(text || ""); + if (!s) { + return ""; + } + s = s.replace(/function\s+\w+\s*\([^)]*\)\s*\{[\s\S]*?\}/g, " "); + s = s.replace(/(?:document|window)\.\w+\s*\([^;)\n]*\)\s*;?/gi, " "); + s = s.replace(/getElementById\s*\([^)\n]*\)/gi, " "); + const lines = s.split(/\n+/); const kept = []; - let skippingBlock = false; for (const raw of lines) { - const line = raw.trim(); + const line = raw.replace(WS_RE, " ").trim(); if (!line) { continue; } - if (/^function\s+\w+\s*\(/.test(line)) { - skippingBlock = true; - continue; - } - if (skippingBlock) { - if (line === "}" || /^\}/.test(line)) { - skippingBlock = false; - } - continue; - } - if (SCRIPT_LIKE_RE.test(line)) { + if (/^function\s+\w+\s*\(/.test(line) || SCRIPT_LIKE_RE.test(line)) { continue; } kept.push(line); @@ -225,7 +219,7 @@ export function descriptionNeedsFlatten(text) { if (!s.trim()) { return false; } - if (/<\/?[a-z][\s\S]*>/i.test(s)) { + if (/<\/?[a-z][\s\S]*>/i.test(s.replace(/<\/?null>/gi, ""))) { return true; } if (/<|>|&nbsp;/i.test(s)) { diff --git a/meshchatx/src/frontend/js/mapExchange/metadataUtils.js b/meshchatx/src/frontend/js/mapExchange/metadataUtils.js index f86308b84..d7c05cddd 100644 --- a/meshchatx/src/frontend/js/mapExchange/metadataUtils.js +++ b/meshchatx/src/frontend/js/mapExchange/metadataUtils.js @@ -121,7 +121,8 @@ export function getFeatureAnchorCoordinate(feature) { } function looksLikeHtml(s) { - return /<\/?[a-z][\s\S]*>/i.test(s); + const cleaned = String(s || "").replace(/<\/?null>/gi, ""); + return /<\/?[a-z][\s\S]*>/i.test(cleaned); } /** diff --git a/meshchatx/src/frontend/js/mapExchange/styleFromProperties.js b/meshchatx/src/frontend/js/mapExchange/styleFromProperties.js index eefb10ad9..f87e15e4d 100644 --- a/meshchatx/src/frontend/js/mapExchange/styleFromProperties.js +++ b/meshchatx/src/frontend/js/mapExchange/styleFromProperties.js @@ -94,7 +94,16 @@ export function applyCappedMcxIconStyleIfNeeded(feature) { if (!(p[MCX_ICON_DATA_URL] || p[MCX_ICON_HREF])) { return; } - feature.setStyle((f, resolution) => styleFromMcxProperties(f, resolution) || undefined); + const iconStyle = styleFromMcxProperties(feature, 0); + if (!iconStyle) { + return; + } + feature.setStyle((_f, resolution) => { + if (resolution != null && Number.isFinite(resolution) && resolution > CHEAP_POINT_MAX_RESOLUTION) { + return CHEAP_POINT_STYLE; + } + return iconStyle; + }); } export function styleFromMcxProperties(feature, resolution) { diff --git a/meshchatx/src/frontend/js/registries/featureRegistry.js b/meshchatx/src/frontend/js/registries/featureRegistry.js new file mode 100644 index 000000000..78007dc09 --- /dev/null +++ b/meshchatx/src/frontend/js/registries/featureRegistry.js @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: 0BSD + +import { createRegistry } from "./registryCore.js"; +import { registerRoute } from "./routeRegistry.js"; +import { registerNavItem } from "./navRegistry.js"; +import { registerTool } from "./toolsRegistry.js"; +import { registerCommand } from "./commandRegistry.js"; +import { registerSettingsSection } from "./settingsSectionRegistry.js"; + +/** + * @typedef {Object} FeatureDefinition + * @property {string} id + * @property {import('./routeRegistry.js').FeatureRouteEntry[]} [routes] + * @property {import('./coreNavEntries.js').NavEntry[]} [nav] + * @property {import('./coreToolsEntries.js').ToolEntry[]} [tools] + * @property {import('./coreCommandEntries.js').CommandEntry[]} [commands] + * @property {{ id: string, keywords?: string[] }[]} [settingsSections] + */ + +/** @type {import('./registryCore.js').Registry<{ id: string }>} */ +const featureRegistry = createRegistry("featureRegistry"); + +/** + * Register a feature module: routes plus optional contribution lists. + * @param {FeatureDefinition} feature + */ +export function registerFeature(feature) { + if (!feature?.id) { + throw new Error("featureRegistry: feature requires an id"); + } + featureRegistry.register({ id: feature.id }); + + for (const route of feature.routes || []) { + registerRoute(route); + } + for (const entry of feature.nav || []) { + registerNavItem(entry); + } + for (const entry of feature.tools || []) { + registerTool(entry); + } + for (const entry of feature.commands || []) { + registerCommand(entry); + } + for (const section of feature.settingsSections || []) { + registerSettingsSection(section); + } +} + +/** + * @returns {string[]} + */ +export function listFeatureIds() { + return featureRegistry.list().map((entry) => entry.id); +} + +/** + * Clear feature ids only (tests). Does not clear nested registries. + */ +export function clearFeatureIds() { + featureRegistry.clear(); +} diff --git a/meshchatx/src/frontend/js/registries/routeRegistry.js b/meshchatx/src/frontend/js/registries/routeRegistry.js new file mode 100644 index 000000000..9cb25e97a --- /dev/null +++ b/meshchatx/src/frontend/js/registries/routeRegistry.js @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: 0BSD + +import { createRegistry } from "./registryCore.js"; + +/** + * @typedef {"vue" | "svelte"} PageMountKind + */ + +/** + * @typedef {Object} FeatureRouteEntry + * @property {string} name + * @property {string} path + * @property {PageMountKind} mount + * @property {() => Promise} load + * @property {boolean} [props] + * @property {Record} [meta] + * @property {Record} [routeProps] + */ + +/** @type {import('./registryCore.js').Registry} */ +export const routeRegistry = createRegistry("routeRegistry"); + +/** + * @param {FeatureRouteEntry} entry + */ +export function registerRoute(entry) { + if (!entry?.name || !entry?.path) { + throw new Error("routeRegistry: entry requires name and path"); + } + if (entry.mount !== "vue" && entry.mount !== "svelte") { + throw new Error(`routeRegistry: invalid mount "${entry.mount}"`); + } + if (typeof entry.load !== "function") { + throw new Error("routeRegistry: entry requires load()"); + } + routeRegistry.register({ + ...entry, + id: entry.name, + meta: entry.meta && typeof entry.meta === "object" ? entry.meta : {}, + }); +} + +/** + * @param {string} name + */ +export function unregisterRoute(name) { + routeRegistry.unregister(name); +} + +/** + * @returns {Array} + */ +export function listRoutes() { + return routeRegistry.list(); +} + +/** + * Clear routes (tests). + */ +export function clearRoutes() { + routeRegistry.clear(); +} diff --git a/meshchatx/src/frontend/js/svelte/appState.svelte.js b/meshchatx/src/frontend/js/svelte/appState.svelte.js new file mode 100644 index 000000000..3b55c0554 --- /dev/null +++ b/meshchatx/src/frontend/js/svelte/appState.svelte.js @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: 0BSD + +/** + * Svelte-friendly reactive mirror of GlobalState via appState.subscribe. + * Import from .svelte / .svelte.js modules only (not from kernel consumers that must stay Vue-free of runes). + */ + +import globalState from "../GlobalState.js"; +import { subscribeAppState, getAppState, patchAppState, patchAppConfig } from "../appState.js"; + +/** @type {object} */ +export const appState = $state({ ...getAppState(globalState) }); + +subscribeAppState(globalState, (snapshot) => { + Object.assign(appState, snapshot); + if (snapshot.config && typeof snapshot.config === "object") { + appState.config = { ...snapshot.config }; + } +}); + +/** + * @param {Record} patch + */ +export function patchState(patch) { + patchAppState(globalState, patch); +} + +/** + * @param {Record} next + */ +export function patchConfig(next) { + patchAppConfig(globalState, next); +} diff --git a/meshchatx/src/frontend/locales/en.json b/meshchatx/src/frontend/locales/en.json index b3e96750a..1b7e317ad 100644 --- a/meshchatx/src/frontend/locales/en.json +++ b/meshchatx/src/frontend/locales/en.json @@ -1718,6 +1718,9 @@ "failed_save_tile_server": "Failed to save tile server URL", "failed_save_nominatim": "Failed to save Nominatim API URL", "copied_coordinates": "Copied coordinates", + "copy_coords": "Copy coords", + "copy_coords_format": "Copy {format}", + "context_coords": "Coordinates", "failed_load_drawings": "Failed to load drawings", "not_initialized": "Map not initialized", "drawing_saved": "Drawing saved", @@ -1800,6 +1803,7 @@ "drop_no_supported_files": "No GeoJSON, KML, KMZ, GPX, or MBTiles files detected.", "new_tab": "New map", "new_tab_shortcut": "New map (Ctrl+T)", + "tab_limit_reached": "Map tab limit reached (8)", "tab_default_name": "Map {number}", "tab_rename_hint": "Double-click or double-tap to rename", "tool_select": "Select", diff --git a/meshchatx/src/frontend/main.js b/meshchatx/src/frontend/main.js index 7257314c5..0eb2d1743 100644 --- a/meshchatx/src/frontend/main.js +++ b/meshchatx/src/frontend/main.js @@ -6,6 +6,7 @@ import DOMPurify from "dompurify"; import "./style.css"; import { injectMeshchatThemeVariables } from "./theme/designTokens.js"; import { registerUiI18n } from "./js/localeLoader.js"; +import { registerTranslator, registerFallbackMessages } from "./js/i18n.js"; injectMeshchatThemeVariables(); @@ -15,7 +16,9 @@ import { startCodec2ScriptsBackgroundLoad } from "./js/Codec2Loader"; import { createApiClient } from "./js/apiClient.js"; import { fetchCsrfToken } from "./js/csrfToken.js"; import { registerCoreContributions } from "./js/registries/registerCoreContributions.js"; +import { registerAllFeatures } from "./features/registerAllFeatures.js"; import { installWsEventBridge } from "./js/registries/wsEventBridge.js"; +import { buildRouterRoutesFromRegistry } from "./shell/buildRouterRoutes.js"; import { pluginHost } from "./js/plugins/PluginHost.js"; import GlobalState from "./js/GlobalState.js"; import { recoveryLocationForNetworkError } from "./js/networkRecovery.js"; @@ -30,6 +33,7 @@ import { import "./js/HeapMonitor.js"; registerCoreContributions(); +registerAllFeatures(); installWsEventBridge(); import App from "./components/App.vue"; @@ -46,6 +50,8 @@ const i18n = createI18n({ }, }); registerUiI18n(i18n); +registerFallbackMessages(enMessages); +registerTranslator((key, values) => i18n.global.t(key, values)); if (!window.location.hash || window.location.hash === "#") { history.replaceState(null, "", "#/messages"); @@ -54,6 +60,7 @@ if (!window.location.hash || window.location.hash === "#") { const router = createRouter({ history: createWebHashHistory(), routes: [ + ...buildRouterRoutesFromRegistry(), { name: "auth", path: "/auth", @@ -253,11 +260,6 @@ const router = createRouter({ path: "/identities", component: () => import("./components/settings/IdentitiesPage.vue"), }, - { - name: "blocked", - path: "/blocked", - component: () => import("./components/blocked/BlockedPage.vue"), - }, { name: "tools", path: "/tools", diff --git a/meshchatx/src/frontend/shell/FeaturePageHost.vue b/meshchatx/src/frontend/shell/FeaturePageHost.vue new file mode 100644 index 000000000..da9e39954 --- /dev/null +++ b/meshchatx/src/frontend/shell/FeaturePageHost.vue @@ -0,0 +1,71 @@ + + + + + diff --git a/meshchatx/src/frontend/shell/buildRouterRoutes.js b/meshchatx/src/frontend/shell/buildRouterRoutes.js new file mode 100644 index 000000000..447fab520 --- /dev/null +++ b/meshchatx/src/frontend/shell/buildRouterRoutes.js @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: 0BSD + +import { listRoutes } from "../js/registries/routeRegistry.js"; +import FeaturePageHost from "./FeaturePageHost.vue"; + +/** + * Build vue-router route records from the route registry. + * Vue mounts resolve the SFC directly. Svelte mounts use FeaturePageHost. + * Lives in shell/ so kernel js/ stays free of Vue imports. + * @returns {import('vue-router').RouteRecordRaw[]} + */ +export function buildRouterRoutesFromRegistry() { + return listRoutes().map((entry) => { + /** @type {import('vue-router').RouteRecordRaw} */ + const record = { + name: entry.name, + path: entry.path, + meta: { + ...(entry.meta || {}), + featureMount: entry.mount, + }, + }; + if (entry.props === true) { + record.props = true; + } else if (entry.routeProps && typeof entry.routeProps === "object") { + record.props = entry.routeProps; + } + + if (entry.mount === "svelte") { + record.component = FeaturePageHost; + record.meta.featureLoad = entry.load; + return record; + } + + record.component = () => + entry.load().then((mod) => { + const Comp = mod && (mod.default || mod); + if (!Comp) { + throw new Error(`routeRegistry: vue load for "${entry.name}" has no default export`); + } + return Comp; + }); + return record; + }); +} diff --git a/meshchatx/src/frontend/ui/svelte/ConfirmDialog.svelte b/meshchatx/src/frontend/ui/svelte/ConfirmDialog.svelte new file mode 100644 index 000000000..8a8186929 --- /dev/null +++ b/meshchatx/src/frontend/ui/svelte/ConfirmDialog.svelte @@ -0,0 +1,258 @@ + + + + +{#if pendingConfirm} +
+ + +
+
+
+
+ +
+
+

+ {pendingConfirm.title || t("common.confirm_action")} +

+

+ {pendingConfirm.message} +

+
+
+ +
+ + +
+
+
+
+{/if} diff --git a/meshchatx/src/frontend/ui/svelte/MaterialDesignIcon.svelte b/meshchatx/src/frontend/ui/svelte/MaterialDesignIcon.svelte new file mode 100644 index 000000000..d5c33ea90 --- /dev/null +++ b/meshchatx/src/frontend/ui/svelte/MaterialDesignIcon.svelte @@ -0,0 +1,24 @@ + + + + + + + diff --git a/meshchatx/src/frontend/ui/svelte/SmokeBadge.svelte b/meshchatx/src/frontend/ui/svelte/SmokeBadge.svelte new file mode 100644 index 000000000..685872325 --- /dev/null +++ b/meshchatx/src/frontend/ui/svelte/SmokeBadge.svelte @@ -0,0 +1,11 @@ + + + + +{label} diff --git a/package.json b/package.json index b00b32686..b0f02079b 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "lint": "eslint .", "lint:fix": "eslint . --fix", "typecheck": "vue-tsc --noEmit -p tsconfig.json", + "svelte-check": "svelte-check --tsconfig ./tsconfig.svelte.json", "knip": "knip", "format": "prettier --write .", "format:check": "prettier --check .", @@ -83,8 +84,10 @@ "@electron/fuses": "^1.8.0", "@eslint/js": "^9.39.5", "@playwright/test": "^1.62.1", + "@sveltejs/vite-plugin-svelte": "^7.3.0", "@tailwindcss/cli": "^4.3.3", "@tailwindcss/vite": "^4.3.3", + "@testing-library/svelte": "^5.4.2", "@vitejs/plugin-vue": "^6.0.8", "@vitest/coverage-v8": "^4.1.11", "@vitest/ui": "^4.1.11", @@ -98,6 +101,7 @@ "eslint-config-prettier": "^10.1.8", "eslint-plugin-prettier": "^5.5.6", "eslint-plugin-security": "^3.0.1", + "eslint-plugin-svelte": "^3.23.0", "eslint-plugin-vue": "^10.10.0", "fake-indexeddb": "^6.2.5", "globals": "^17.11.0", @@ -106,6 +110,9 @@ "lighthouse": "^13.4.1", "micron-parser": "github:RFnexus/micron-parser-js#33feb1054c8b2cb3f5f05abbb8903360d3f0c098", "prettier": "^3.9.6", + "prettier-plugin-svelte": "^4.1.1", + "svelte": "^5.0.0", + "svelte-check": "^4.7.6", "tailwindcss": "^4.3.3", "terser": "^5.51.2", "typescript": "^6.0.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8e101792c..e7729e29d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -148,12 +148,18 @@ importers: '@playwright/test': specifier: ^1.62.1 version: 1.62.1 + '@sveltejs/vite-plugin-svelte': + specifier: ^7.3.0 + version: 7.3.0(svelte@5.0.0)(vite@8.2.2(@types/node@24.13.3)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0)) '@tailwindcss/cli': specifier: ^4.3.3 version: 4.3.3 '@tailwindcss/vite': specifier: ^4.3.3 version: 4.3.3(vite@8.2.2(@types/node@24.13.3)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0)) + '@testing-library/svelte': + specifier: ^5.4.2 + version: 5.4.2(svelte@5.0.0)(vite@8.2.2(@types/node@24.13.3)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0))(vitest@4.1.11) '@vitejs/plugin-vue': specifier: ^6.0.8 version: 6.0.8(vite@8.2.2(@types/node@24.13.3)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0))(vue@3.5.42(typescript@6.0.3)) @@ -193,6 +199,9 @@ importers: eslint-plugin-security: specifier: ^3.0.1 version: 3.0.1 + eslint-plugin-svelte: + specifier: ^3.23.0 + version: 3.23.0(eslint@9.39.5(jiti@2.7.0))(svelte@5.0.0) eslint-plugin-vue: specifier: ^10.10.0 version: 10.10.0(eslint@9.39.5(jiti@2.7.0))(vue-eslint-parser@10.4.1(eslint@9.39.5(jiti@2.7.0))) @@ -217,6 +226,15 @@ importers: prettier: specifier: ^3.9.6 version: 3.9.6 + prettier-plugin-svelte: + specifier: ^4.1.1 + version: 4.1.1(prettier@3.9.6)(svelte@5.0.0) + svelte: + specifier: ^5.0.0 + version: 5.0.0 + svelte-check: + specifier: ^4.7.6 + version: 4.7.6(picomatch@4.0.4)(svelte@5.0.0)(typescript@6.0.3) tailwindcss: specifier: ^4.3.3 version: 4.3.3 @@ -244,6 +262,10 @@ importers: packages: + '@ampproject/remapping@2.3.0': + resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} + engines: {node: '>=6.0.0'} + '@asamuzakjp/css-color@5.1.11': resolution: {integrity: sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} @@ -415,6 +437,9 @@ packages: peerDependencies: '@babel/core': '>=7.29.6 <8' + '@babel/runtime@7.12.5': + resolution: {integrity: sha512-plcc+hbExy3McchJCEQG3knOsuh3HH+Prx1P6cLIkET/0dLuQDEnrT+s27Axgc9bqfsmNUNHfscgMUdBpC9xfg==} + '@babel/template@7.29.7': resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} engines: {node: '>=6.9.0'} @@ -1560,6 +1585,17 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@sveltejs/load-config@0.2.3': + resolution: {integrity: sha512-VT3qmUb8pRV2QrZjd8iAmtg8lf4W0TIjZbvXtz5MKei/q96teWZgGJyyidJzOjzZzvdq616eSRVeMYIQChUTAQ==} + engines: {node: '>= 18.0.0'} + + '@sveltejs/vite-plugin-svelte@7.3.0': + resolution: {integrity: sha512-QbRoJyD92e9R0ufeQIWRHrCC0ObcqSv/aBDdrQMoU+sypav3cDx5wytdQ6GLdXjEMO6xjrXGzfkUygng8JMv0A==} + engines: {node: ^20.19 || ^22.12 || >=24} + peerDependencies: + svelte: ^5.46.4 + vite: ^8.0.0-beta.7 || ^8.0.0 + '@tailwindcss/cli@4.3.3': resolution: {integrity: sha512-ZvS/n1ZHOBKcVlhkt8l5NNr1EDXk1NboYO5CYDOs6NUmvT9z6bzkwsosaJftY57T/3gWNzWMJzIXLodZC8ssdw==} hasBin: true @@ -1671,9 +1707,35 @@ packages: peerDependencies: vue: ^2.7.0 || ^3.0.0 + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/svelte-core@1.1.3': + resolution: {integrity: sha512-KkMAvXeWorxN2Yn0kdC1lfoAItxpoj4uOWzxK5leDrNxonLvS5nwBFvztrroyTszQ0Wf/EU6iLT8JhY5qcn22g==} + engines: {node: '>=16'} + peerDependencies: + svelte: ^3 || ^4 || ^5 || ^5.0.0-next.0 + + '@testing-library/svelte@5.4.2': + resolution: {integrity: sha512-4o31E4HGo5BU5KwPkulNRocEden+7Tt9JYm9uhln5ajF7DULeyFA46BBWVfKJ8Ms9B3JmOFPTIiVamH7n3KpuQ==} + engines: {node: '>= 10'} + peerDependencies: + svelte: ^3 || ^4 || ^5 || ^5.0.0-next.0 + vite: '*' + vitest: '*' + peerDependenciesMeta: + vite: + optional: true + vitest: + optional: true + '@tybys/wasm-util@0.10.3': resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + '@types/chai@5.2.2': resolution: {integrity: sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg==} @@ -1894,6 +1956,11 @@ packages: peerDependencies: acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + acorn-typescript@1.4.13: + resolution: {integrity: sha512-xsc9Xv0xlVfwp2o7sQ+GCQ1PgbkdcpWdTzrwXxO3xDMTAywVS3oXVOcOHuRjAPkS4P9b+yc/qNF15460v+jp4Q==} + peerDependencies: + acorn: '>=8.9.0' + acorn@8.14.0: resolution: {integrity: sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==} engines: {node: '>=0.4.0'} @@ -1941,6 +2008,10 @@ packages: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} + ansi-regex@6.0.1: + resolution: {integrity: sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==} + engines: {node: '>=12'} + ansi-styles@3.1.0: resolution: {integrity: sha512-1jXmDD7l38qjk7yqmpFMLvs94InTmzcupUKdP2N4YjuDy5gNRJc1J5zb3Q/ur6FKxep0GW+2vK2qL82GEAwwCQ==} engines: {node: '>=4'} @@ -1957,6 +2028,10 @@ packages: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + ansi-styles@5.0.0: + resolution: {integrity: sha512-6564t0m0fuQMnockqBv7wJxo9T5C2V9JpYXyNScfRDPVLusOQQhkpMGrFC17QbiolraQ1sMXX+Y5nJpjqozL4g==} + engines: {node: '>=10'} + ansi-styles@6.1.0: resolution: {integrity: sha512-VbqNsoz55SYGczauuup0MFUyXNQviSpFTj1RQtFzmQLk18qbVSpTFFGMT293rmDaQuKCT6InmbuEyUne4mTuxQ==} engines: {node: '>=12'} @@ -1983,6 +2058,13 @@ packages: resolution: {integrity: sha512-DhBpBfXL4SS2uC0N922MMajKR3CdrTG0u2or1PNYgXMsrSzViJrbtvT0nCLlLGUI0plam/ZZCs7aAauHtW9thw==} engines: {node: '>=22'} + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + + aria-query@5.3.1: + resolution: {integrity: sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==} + engines: {node: '>= 0.4'} + array-buffer-byte-length@1.0.2: resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} engines: {node: '>= 0.4'} @@ -2023,6 +2105,10 @@ packages: resolution: {integrity: sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==} engines: {node: '>=4'} + axobject-query@4.1.0: + resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} + engines: {node: '>= 0.4'} + balanced-match@1.0.0: resolution: {integrity: sha512-9Y0g0Q8rmSt+H33DfKv7FOc3v+iRI+o1lbzt8jGcIosYW37IIW/2XVYq5NPdmaD5NQ59Nk26Kl/vZbwW9Fr8vg==} @@ -2157,6 +2243,10 @@ packages: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} + chokidar@4.0.1: + resolution: {integrity: sha512-n8enUVCED/KVRQlab1hr3MVpcVMvxtZjmEa956u+4YijlmQED223XMSYj2tLuKvr4jcCTzNNMpQDUer72MMmzA==} + engines: {node: '>= 14.16.0'} + chownr@3.0.0: resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} engines: {node: '>=18'} @@ -2387,6 +2477,10 @@ packages: deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + default-browser-id@5.0.1: resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==} engines: {node: '>=18'} @@ -2415,6 +2509,10 @@ packages: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + detect-libc@1.0.3: resolution: {integrity: sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==} engines: {node: '>=0.10'} @@ -2446,6 +2544,9 @@ packages: dmg-builder@26.15.3: resolution: {integrity: sha512-O3zJUFUYHJKgzPqioHxfxzBzlSC1eXCSr79gMSBKBP5AgjjpmrydMsMLotEg9fAJF36vdUncb+4ndRNxoPdlSQ==} + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + dompurify@3.4.14: resolution: {integrity: sha512-dVoH9z+MY+C9IilgGCk3YfFqjLi3fChm2OiKJMzh6axrJ5qwxqWaZamgmHrpv22CN/KdbZJuGEGgfQoL00LTdg==} @@ -2649,6 +2750,16 @@ packages: resolution: {integrity: sha512-XjVGBhtDZJfyuhIxnQ/WMm385RbX3DBu7H1J7HNNhmB2tnGxMeqVSnYv79oAj992ayvIBZghsymwkYFS6cGH4Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + eslint-plugin-svelte@3.23.0: + resolution: {integrity: sha512-n9jRklDqy0+W834568a4ZIZTK7DHXxvvHHxxGP8nNq7N//pOZMubttskHOquyGgfTR4Z05q2NdGNlsTpIEA48w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.1 || ^9.0.0 || ^10.0.0 + svelte: ^3.37.0 || ^4.0.0 || ^5.0.0 + peerDependenciesMeta: + svelte: + optional: true + eslint-plugin-vue@10.10.0: resolution: {integrity: sha512-dL9x9rBHqqNcByWiLOHK6L0SB97V82/NC0cZRn9cXPjM7pCuWlpQQP9bFH4vjBv80ej1ZpzAkuD8zWH1o9bZbA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -2694,6 +2805,9 @@ packages: jiti: optional: true + esm-env@1.0.0: + resolution: {integrity: sha512-Cf6VksWPsTuW01vU9Mk/3vRue91Zevka5SjyNf3nEpokFRuqt/KjUQoGAwq9qMmhpLTHmXzSIrFRw8zxWzmFBA==} + espree@10.3.0: resolution: {integrity: sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -2710,6 +2824,9 @@ packages: resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} engines: {node: '>=0.10'} + esrap@1.2.2: + resolution: {integrity: sha512-F2pSJklxx1BlQIQgooczXCPHmcWpn6EsP5oo73LQfonG9fIlIENQ8vMmfGXeojP9MrkzUNAfyU5vdFlR9shHAw==} + esrecurse@4.3.0: resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} engines: {node: '>=4.0'} @@ -2976,6 +3093,10 @@ packages: resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} engines: {node: '>=18'} + globals@16.0.0: + resolution: {integrity: sha512-iInW14XItCXET01CQFqudPOWP2jYMl7T+QRQT+UNcR/iQncN/F0UNpgd76iFkBPgNQb4+X3LV9tLJYzwh+Gl3A==} + engines: {node: '>=18'} + globals@17.11.0: resolution: {integrity: sha512-Z2I8hM+PbJDXQDq3Icgpzv+mPdwr68iZUU9d5WW4FuXfDUQfkZaZuvjMv42/5crNyw154+9+VWXbYrUgDXbxNw==} engines: {node: '>=18'} @@ -3313,6 +3434,9 @@ packages: is-potential-custom-element-name@1.0.1: resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-reference@3.0.2: + resolution: {integrity: sha512-v3rht/LgVcsdZa3O2Nqs+NMowLOxeOm7Ay9+/ARQ2F+qEoANRcqrjAZKGN0v8ymUetZGgkp26LTnGT7H0Qo9Pg==} + is-regex@1.2.1: resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} engines: {node: '>= 0.4'} @@ -3520,6 +3644,9 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + known-css-properties@0.37.0: + resolution: {integrity: sha512-JCDrsP4Z1Sb9JwG0aJ8Eo2r7k4Ou5MwmThS/6lcIe1ICyb7UBJKGRIUUdqc2ASdE/42lgz6zFUnzAIhtXnBVrQ==} + kolorist@1.8.0: resolution: {integrity: sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==} @@ -3698,9 +3825,16 @@ packages: resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} engines: {node: '>= 12.0.0'} + lilconfig@2.0.5: + resolution: {integrity: sha512-xaYmXZtTHPAw5m+xLN8ab9C+3a8YmV3asNSPOATITbtwrfbwaLJj8h66H1WMIpALCkqsIzK3h7oQ+PdX+LQ9Eg==} + engines: {node: '>=10'} + lines-and-columns@1.1.6: resolution: {integrity: sha512-8ZmlJFVK9iCmtLz19HpSsR8HaAMWBT284VMNednLwlIMDP2hJDCIhUp0IZ2xUcZ+Ob6BM0VvCSJwzASDM45NLQ==} + locate-character@3.0.0: + resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==} + locate-path@5.0.0: resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} engines: {node: '>=8'} @@ -3757,6 +3891,10 @@ packages: resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} engines: {node: '>=10'} + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -3764,6 +3902,9 @@ packages: resolution: {integrity: sha512-Q/TKtsC5BPm0kGqgBIF9oXAs/xEf2vRKiIB4wCRQTJOQIByZ1d+NnUOotvJOvNpi5RNIgVOMC3pOuaP1ZTDlVg==} engines: {node: '>=12'} + magic-string@1.0.0: + resolution: {integrity: sha512-CGvjzMN08iv6w1mm4/x3Gh1hLb4VnyRUA15FFpl6CsCIGGoe36k7kY5KNz9QDbSBN5I/fWHM6ZlIkUTa5xdUEA==} + magicast@0.5.2: resolution: {integrity: sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==} @@ -4191,6 +4332,30 @@ packages: resolution: {integrity: sha512-d7Uw+eZoloe0EHDIYoe+bQ5WXnGMOpmiZFTuMWCwpjzzkL2nTjcKiAk4hh8TjnGye2TwWOk3UXucZ+3rbmBa8Q==} engines: {node: '>= 0.4'} + postcss-load-config@3.1.4: + resolution: {integrity: sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg==} + engines: {node: '>= 10'} + peerDependencies: + postcss: '>=8.5.23' + ts-node: '>=9.0.0' + peerDependenciesMeta: + postcss: + optional: true + ts-node: + optional: true + + postcss-safe-parser@7.1.0: + resolution: {integrity: sha512-1WzZxRLaAFwEh6Do+zyGpjWV3nGJNxxhuh7Ubu/q1ICImMgZJnLwhoaEpRbG4pJppp2Y1ncL9ffA2f+LhrefQg==} + engines: {node: '>=18.0'} + peerDependencies: + postcss: '>=8.5.23' + + postcss-scss@4.0.9: + resolution: {integrity: sha512-AjKOeiwAitL/MXxQW2DliT28EKukvvbEWx3LBmJIRN8KfBGZbRTxNYW0kSqi1COiTZ57nZ9NW06S6ux//N1c9A==} + engines: {node: '>=12.0'} + peerDependencies: + postcss: '>=8.5.23' + postcss-selector-parser@7.1.5: resolution: {integrity: sha512-KvvtD7SrlBP7dlgkBghEE3r84CABm5SmV2aNcG4oCA+qDnJ/tvKonFVvwWAyyWUEwxuNawdfEAZKP9zM3oZ2Uw==} engines: {node: '>=4'} @@ -4236,11 +4401,22 @@ packages: resolution: {integrity: sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg==} engines: {node: '>=6.0.0'} + prettier-plugin-svelte@4.1.1: + resolution: {integrity: sha512-wXvbXMjSvb4C9ENWTHXyd+ihakKCsJ6rJhLP6/8HFNj4GkZr48jqL9PoKsl2sk7SyCZRTnJ7O2TTowUpOxP/KA==} + engines: {node: '>=20'} + peerDependencies: + prettier: ^3.0.0 + svelte: ^5.0.0 + prettier@3.9.6: resolution: {integrity: sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==} engines: {node: '>=14'} hasBin: true + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + proc-log@5.0.0: resolution: {integrity: sha512-Azwzvl90HaF0aCz1JrDdXQykFakSSNPaPoiZ9fm5qJIMHioDZEi7OAdRwSm6rSoPtY3Qutnm3L7ogmg3dc+wbQ==} engines: {node: ^18.17.0 || >=20.5.0} @@ -4309,6 +4485,9 @@ packages: rbush@4.0.0: resolution: {integrity: sha512-F5xw+166FYDZI6jEcz+sWEHL5/J+du3kQWkwqWrPKb6iVoLPZh+2KhTS4OoYqrw1v/RO1xQe6WsLwBvrUAlvXw==} + react-is@17.0.1: + resolution: {integrity: sha512-NAnt2iGDXohE5LI7uBnLnqvLQMtzhkiAOLXTmv+qnF9Ky7xAPcX8Up/xWIhxvLVGJvuLiNc4xQLtuqDRzb4fSA==} + read-binary-file-arch@1.0.6: resolution: {integrity: sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg==} hasBin: true @@ -4319,6 +4498,10 @@ packages: readable-stream@2.3.8: resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + readdirp@4.0.1: + resolution: {integrity: sha512-GkMg9uOTpIWWKbSsgwb5fA4EavTR+SG/PMPoAY8hkhHfEEY0/vqljY+XHqtDf2cr2IJtoNRDbrrEpZUiZCkYRw==} + engines: {node: '>= 14.16.0'} + reference-spec-reader@0.2.0: resolution: {integrity: sha512-q0mfCi5yZSSHXpCyxjgQeaORq3tvDsxDyzaadA/5+AbAUwRyRuuTh0aRQuE/vAOt/qzzxidJ5iDeu1cLHaNBlQ==} @@ -4330,6 +4513,9 @@ packages: resolution: {integrity: sha512-r0Ay04Snci87djAsI4U+WNRcSw5S4pOH7qFjd/veA5gC7TbqESR3tcj28ia95L/fYUDw11JKP7uqUKUAfVvV5Q==} engines: {node: '>= 0.4'} + regenerator-runtime@0.13.11: + resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==} + regexp-tree@0.1.27: resolution: {integrity: sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA==} hasBin: true @@ -4414,6 +4600,10 @@ packages: resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} engines: {node: '>=18'} + sade@1.7.4: + resolution: {integrity: sha512-y5yauMD93rX840MwUJr7C1ysLFBgMspsdTo4UVrDg3fXDvtwOyIqykhVAAm6fk/3au77773itJStObgK+LKaiA==} + engines: {node: '>= 6'} + safe-array-concat@1.1.3: resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==} engines: {node: '>=0.4'} @@ -4669,6 +4859,27 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + svelte-check@4.7.6: + resolution: {integrity: sha512-t2scM//ZuVbSY/T2w6FSBw1v9s2NEmh/g+sy1lqtosW5ylBV5AF4wFb1Ts9Kf3MbfPDUDJDZ9L436YT0SPTdvw==} + engines: {node: '>= 18.0.0'} + hasBin: true + peerDependencies: + svelte: ^4.0.0 || ^5.0.0-next.0 + typescript: ^5.0.0 || ^6.0.0 + + svelte-eslint-parser@1.8.1: + resolution: {integrity: sha512-5zgKBqAf6V8Jyrmr1jViksyG4NKT8NheYwkdxRaMRDAqpGWi9wR8ktcGrAZbfMn/PHUp1BWzAp0cYQECkWuAMA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0, pnpm: 10.34.5} + peerDependencies: + svelte: ^3.37.0 || ^4.0.0 || ^5.0.0 + peerDependenciesMeta: + svelte: + optional: true + + svelte@5.0.0: + resolution: {integrity: sha512-jv2IvTtakG58DqZMo6fY3T6HFmGV4iDQH2lSUyfmCEYaoa+aCNcF+9rERbdDvT4XDF0nQBg6TEoJn0dirED8VQ==} + engines: {node: '>=18'} + symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} @@ -4997,6 +5208,14 @@ packages: yaml: optional: true + vitefu@1.1.2: + resolution: {integrity: sha512-zpKATdUbzbsycPFBN71nS2uzBUQiVnFoOrr2rvqv34S1lcAgMKKkjWleLGeiJlZ8lwCXvtWaRn7R3ZC16SYRuw==} + peerDependencies: + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-beta.0 + peerDependenciesMeta: + vite: + optional: true + vitest@4.1.11: resolution: {integrity: sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -5259,6 +5478,10 @@ packages: resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} engines: {node: '>=18'} + yaml@1.10.2: + resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==} + engines: {node: '>= 6'} + yaml@2.9.0: resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} engines: {node: '>= 14.6'} @@ -5291,6 +5514,9 @@ packages: zarrita@0.7.1: resolution: {integrity: sha512-Nu4liRKJES1kkIjWALXSryglJf2+WJURUxgndklfX+mTBCd0lk4MV797p00lt4NzmU3Bbdbs10uxeN8LWwPyWA==} + zimmerframe@1.1.2: + resolution: {integrity: sha512-rAbqEGa8ovJy4pyBxZM70hg4pE6gDgaQ0Sl9M3enG3I0d6H4XSAM3GeNGLKnsBpuijUow064sf7ww1nutC5/3w==} + zod@3.24.1: resolution: {integrity: sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A==} @@ -5302,6 +5528,11 @@ packages: snapshots: + '@ampproject/remapping@2.3.0': + dependencies: + '@jridgewell/gen-mapping': 0.3.5 + '@jridgewell/trace-mapping': 0.3.24 + '@asamuzakjp/css-color@5.1.11': dependencies: '@asamuzakjp/generational-cache': 1.0.1 @@ -5518,6 +5749,10 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/runtime@7.12.5': + dependencies: + regenerator-runtime: 0.13.11 + '@babel/template@7.29.7': dependencies: '@babel/code-frame': 7.29.7 @@ -5931,7 +6166,7 @@ snapshots: '@jridgewell/gen-mapping@0.3.12': dependencies: '@jridgewell/sourcemap-codec': 1.5.0 - '@jridgewell/trace-mapping': 0.3.28 + '@jridgewell/trace-mapping': 0.3.24 '@jridgewell/gen-mapping@0.3.13': dependencies: @@ -5975,7 +6210,7 @@ snapshots: '@jridgewell/trace-mapping@0.3.24': dependencies: '@jridgewell/resolve-uri': 3.1.0 - '@jridgewell/sourcemap-codec': 1.5.0 + '@jridgewell/sourcemap-codec': 1.4.14 '@jridgewell/trace-mapping@0.3.28': dependencies: @@ -6667,6 +6902,17 @@ snapshots: '@standard-schema/spec@1.1.0': {} + '@sveltejs/load-config@0.2.3': {} + + '@sveltejs/vite-plugin-svelte@7.3.0(svelte@5.0.0)(vite@8.2.2(@types/node@24.13.3)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0))': + dependencies: + deepmerge: 4.3.1 + magic-string: 1.0.0 + obug: 2.1.1 + svelte: 5.0.0 + vite: 8.2.2(@types/node@24.13.3)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0) + vitefu: 1.1.2(vite@8.2.2(@types/node@24.13.3)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0)) + '@tailwindcss/cli@4.3.3': dependencies: '@parcel/watcher': 2.5.1 @@ -6757,11 +7003,37 @@ snapshots: '@tanstack/virtual-core': 3.17.8 vue: 3.5.42(typescript@6.0.3) + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/runtime': 7.12.5 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/svelte-core@1.1.3(svelte@5.0.0)': + dependencies: + svelte: 5.0.0 + + '@testing-library/svelte@5.4.2(svelte@5.0.0)(vite@8.2.2(@types/node@24.13.3)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0))(vitest@4.1.11)': + dependencies: + '@testing-library/dom': 10.4.1 + '@testing-library/svelte-core': 1.1.3(svelte@5.0.0) + svelte: 5.0.0 + optionalDependencies: + vite: 8.2.2(@types/node@24.13.3)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0) + vitest: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.11)(@vitest/ui@4.1.11)(jsdom@29.1.1(@noble/hashes@2.4.0))(vite@8.2.2(@types/node@24.13.3)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0)) + '@tybys/wasm-util@0.10.3': dependencies: tslib: 2.4.0 optional: true + '@types/aria-query@5.0.4': {} + '@types/chai@5.2.2': dependencies: '@types/deep-eql': 4.0.0 @@ -7057,6 +7329,10 @@ snapshots: dependencies: acorn: 8.15.0 + acorn-typescript@1.4.13(acorn@8.14.0): + dependencies: + acorn: 8.14.0 + acorn@8.14.0: {} acorn@8.15.0: {} @@ -7101,6 +7377,8 @@ snapshots: ansi-regex@5.0.1: {} + ansi-regex@6.0.1: {} + ansi-styles@3.1.0: dependencies: color-convert: 1.0.0 @@ -7117,6 +7395,8 @@ snapshots: dependencies: color-convert: 2.0.1 + ansi-styles@5.0.0: {} + ansi-styles@6.1.0: {} ansi-styles@6.2.1: {} @@ -7175,6 +7455,12 @@ snapshots: argue-cli@3.1.0: {} + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + + aria-query@5.3.1: {} + array-buffer-byte-length@1.0.2: dependencies: call-bound: 1.0.4 @@ -7221,6 +7507,8 @@ snapshots: axe-core@4.13.0: {} + axobject-query@4.1.0: {} + balanced-match@1.0.0: {} balanced-match@4.0.4: {} @@ -7378,6 +7666,10 @@ snapshots: ansi-styles: 4.3.0 supports-color: 7.2.0 + chokidar@4.0.1: + dependencies: + readdirp: 4.0.1 + chownr@3.0.0: {} chrome-launcher@1.2.1: @@ -7595,6 +7887,8 @@ snapshots: deep-is@0.1.4: {} + deepmerge@4.3.1: {} + default-browser-id@5.0.1: {} default-browser@5.5.1: @@ -7620,6 +7914,8 @@ snapshots: delayed-stream@1.0.0: {} + dequal@2.0.3: {} + detect-libc@1.0.3: {} detect-libc@2.0.3: {} @@ -7650,6 +7946,8 @@ snapshots: - electron-builder-squirrel-windows - supports-color + dom-accessibility-api@0.5.16: {} + dompurify@3.4.14: optionalDependencies: '@types/trusted-types': 2.0.7 @@ -7921,6 +8219,24 @@ snapshots: dependencies: safe-regex: 2.1.1 + eslint-plugin-svelte@3.23.0(eslint@9.39.5(jiti@2.7.0))(svelte@5.0.0): + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5(jiti@2.7.0)) + '@jridgewell/sourcemap-codec': 1.5.0 + eslint: 9.39.5(jiti@2.7.0) + esutils: 2.0.3 + globals: 16.0.0 + known-css-properties: 0.37.0 + postcss: 8.5.23 + postcss-load-config: 3.1.4(postcss@8.5.23) + postcss-safe-parser: 7.1.0(postcss@8.5.23) + semver: 7.5.2 + svelte-eslint-parser: 1.8.1(svelte@5.0.0) + optionalDependencies: + svelte: 5.0.0 + transitivePeerDependencies: + - ts-node + eslint-plugin-vue@10.10.0(eslint@9.39.5(jiti@2.7.0))(vue-eslint-parser@10.4.1(eslint@9.39.5(jiti@2.7.0))): dependencies: '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5(jiti@2.7.0)) @@ -7989,6 +8305,8 @@ snapshots: transitivePeerDependencies: - supports-color + esm-env@1.0.0: {} + espree@10.3.0: dependencies: acorn: 8.14.0 @@ -8009,6 +8327,11 @@ snapshots: dependencies: estraverse: 5.3.0 + esrap@1.2.2: + dependencies: + '@jridgewell/sourcemap-codec': 1.4.15 + '@types/estree': 1.0.6 + esrecurse@4.3.0: dependencies: estraverse: 5.3.0 @@ -8304,6 +8627,8 @@ snapshots: globals@14.0.0: {} + globals@16.0.0: {} + globals@17.11.0: {} globalthis@1.0.4: @@ -8617,6 +8942,10 @@ snapshots: is-potential-custom-element-name@1.0.1: {} + is-reference@3.0.2: + dependencies: + '@types/estree': 1.0.0 + is-regex@1.2.1: dependencies: call-bound: 1.0.4 @@ -8837,6 +9166,8 @@ snapshots: yaml: 2.9.0 zod: 4.5.4 + known-css-properties@0.37.0: {} + kolorist@1.8.0: {} lazy-val@1.0.5: {} @@ -8996,8 +9327,12 @@ snapshots: lightningcss-win32-arm64-msvc: 1.33.0 lightningcss-win32-x64-msvc: 1.33.0 + lilconfig@2.0.5: {} + lines-and-columns@1.1.6: {} + locate-character@3.0.0: {} + locate-path@5.0.0: dependencies: p-locate: 4.1.0 @@ -9036,6 +9371,8 @@ snapshots: dependencies: yallist: 4.0.0 + lz-string@1.5.0: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.6.0 @@ -9044,6 +9381,10 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.4.15 + magic-string@1.0.0: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + magicast@0.5.2: dependencies: '@babel/parser': 7.29.8 @@ -9489,6 +9830,21 @@ snapshots: possible-typed-array-names@1.0.0: {} + postcss-load-config@3.1.4(postcss@8.5.23): + dependencies: + lilconfig: 2.0.5 + yaml: 1.10.2 + optionalDependencies: + postcss: 8.5.23 + + postcss-safe-parser@7.1.0(postcss@8.5.23): + dependencies: + postcss: 8.5.23 + + postcss-scss@4.0.9(postcss@8.5.23): + dependencies: + postcss: 8.5.23 + postcss-selector-parser@7.1.5: dependencies: cssesc: 3.0.0 @@ -9529,8 +9885,19 @@ snapshots: dependencies: fast-diff: 1.3.0 + prettier-plugin-svelte@4.1.1(prettier@3.9.6)(svelte@5.0.0): + dependencies: + prettier: 3.9.6 + svelte: 5.0.0 + prettier@3.9.6: {} + pretty-format@27.5.1: + dependencies: + ansi-regex: 6.0.1 + ansi-styles: 5.0.0 + react-is: 17.0.1 + proc-log@5.0.0: {} proc-log@6.1.0: {} @@ -9596,6 +9963,8 @@ snapshots: dependencies: quickselect: 2.0.0 + react-is@17.0.1: {} + read-binary-file-arch@1.0.6: dependencies: debug: 4.3.1 @@ -9621,6 +9990,8 @@ snapshots: string_decoder: 1.1.1 util-deprecate: 1.0.1 + readdirp@4.0.1: {} + reference-spec-reader@0.2.0: {} reflect.getprototypeof@1.0.6: @@ -9644,6 +10015,8 @@ snapshots: gopd: 1.2.0 which-builtin-type: 1.2.1 + regenerator-runtime@0.13.11: {} + regexp-tree@0.1.27: {} regexp.prototype.flags@1.5.4: @@ -9740,6 +10113,10 @@ snapshots: run-applescript@7.1.0: {} + sade@1.7.4: + dependencies: + mri: 1.2.0 + safe-array-concat@1.1.3: dependencies: call-bind: 1.0.9 @@ -10024,6 +10401,47 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + svelte-check@4.7.6(picomatch@4.0.4)(svelte@5.0.0)(typescript@6.0.3): + dependencies: + '@jridgewell/trace-mapping': 0.3.28 + '@sveltejs/load-config': 0.2.3 + chokidar: 4.0.1 + fdir: 6.4.3(picomatch@4.0.4) + picocolors: 1.1.1 + sade: 1.7.4 + svelte: 5.0.0 + typescript: 6.0.3 + transitivePeerDependencies: + - picomatch + + svelte-eslint-parser@1.8.1(svelte@5.0.0): + dependencies: + eslint-scope: 8.2.0 + eslint-visitor-keys: 4.2.0 + espree: 10.3.0 + postcss: 8.5.23 + postcss-scss: 4.0.9(postcss@8.5.23) + postcss-selector-parser: 7.1.5 + semver: 7.5.2 + optionalDependencies: + svelte: 5.0.0 + + svelte@5.0.0: + dependencies: + '@ampproject/remapping': 2.3.0 + '@jridgewell/sourcemap-codec': 1.5.0 + '@types/estree': 1.0.6 + acorn: 8.14.0 + acorn-typescript: 1.4.13(acorn@8.14.0) + aria-query: 5.3.1 + axobject-query: 4.1.0 + esm-env: 1.0.0 + esrap: 1.2.2 + is-reference: 3.0.2 + locate-character: 3.0.0 + magic-string: 0.30.21 + zimmerframe: 1.1.2 + symbol-tree@3.2.4: {} synckit@0.11.13: @@ -10332,6 +10750,10 @@ snapshots: terser: 5.51.2 yaml: 2.9.0 + vitefu@1.1.2(vite@8.2.2(@types/node@24.13.3)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0)): + optionalDependencies: + vite: 8.2.2(@types/node@24.13.3)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0) + vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/coverage-v8@4.1.11)(@vitest/ui@4.1.11)(jsdom@29.1.1(@noble/hashes@2.4.0))(vite@8.2.2(@types/node@24.13.3)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.11 @@ -10603,6 +11025,8 @@ snapshots: yallist@5.0.0: {} + yaml@1.10.2: {} + yaml@2.9.0: {} yargs-parser@21.1.1: {} @@ -10657,6 +11081,8 @@ snapshots: '@zarrita/storage': 0.2.0 numcodecs: 0.3.2 + zimmerframe@1.1.2: {} + zod@3.24.1: {} zod@4.5.4: {} diff --git a/svelte.config.js b/svelte.config.js new file mode 100644 index 000000000..16ca570c8 --- /dev/null +++ b/svelte.config.js @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: 0BSD + +import { vitePreprocess } from "@sveltejs/vite-plugin-svelte"; + +/** @type {import('@sveltejs/vite-plugin-svelte').SvelteConfig} */ +const config = { + preprocess: vitePreprocess(), + compilerOptions: { + runes: true, + }, +}; + +export default config; diff --git a/tests/frontend/BanishedPage.test.js b/tests/frontend/BanishedPage.test.js index d18bd42c4..27dd04d06 100644 --- a/tests/frontend/BanishedPage.test.js +++ b/tests/frontend/BanishedPage.test.js @@ -1,12 +1,41 @@ -import { mount } from "@vue/test-utils"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import BlockedPage from "@/components/blocked/BlockedPage.vue"; -import GlobalState from "@/js/GlobalState"; +import { render, cleanup, fireEvent, waitFor } from "@testing-library/svelte"; +import BlockedPage from "@/features/blocked/BlockedPage.svelte"; +import { registerFallbackMessages, registerTranslator } from "@/js/i18n.js"; -describe("BlockedPage.vue (Banished UI)", () => { +describe("BlockedPage.svelte (Banished UI)", () => { let axiosMock; beforeEach(() => { + registerTranslator(null); + registerFallbackMessages({ + banishment: { + title: "Banished", + description: "Manage Banished users and nodes", + lift_banishment: "Lift Banishment", + user: "User", + node: "Node", + banished_at: "Banished at", + no_items: "None", + search_placeholder: "Search", + filter_all_types: "All", + filter_rns: "RNS", + sort_newest: "Newest", + sort_oldest: "Oldest", + sort_name: "Name", + result_count: "{count}", + blocked_destinations: "Dests", + lift_banishment_confirm: "Confirm {name}", + banishment_lifted: "Lifted", + failed_lift_banishment: "Fail", + failed_load_banished: "Fail load", + }, + common: { select: "Select", cancel: "Cancel", refresh: "Refresh" }, + archives: { select_all: "All" }, + nomadnet: { no_announces_yet: "empty", no_search_results_peers: "none" }, + call: { unknown: "Unknown" }, + }); + axiosMock = { get: vi.fn(), post: vi.fn(), @@ -14,15 +43,6 @@ describe("BlockedPage.vue (Banished UI)", () => { }; window.api = axiosMock; - // Mock localization - const t = (key) => { - const translations = { - "common.save": "Save", - "common.cancel": "Cancel", - }; - return translations[key] || key; - }; - axiosMock.get.mockImplementation((url) => { if (url === "/api/v1/blocked-destinations") { return Promise.resolve({ @@ -54,76 +74,39 @@ describe("BlockedPage.vue (Banished UI)", () => { }); afterEach(() => { + cleanup(); delete window.api; }); - const mountBlockedPage = () => { - return mount(BlockedPage, { - global: { - mocks: { - $t: (key) => { - const translations = { - "banishment.title": "Banished", - "banishment.description": "Manage Banished users and nodes", - "banishment.lift_banishment": "Lift Banishment", - "banishment.user": "User", - "banishment.node": "Node", - "banishment.banished_at": "Banished at", - }; - return translations[key] || key; - }, - }, - stubs: { - MaterialDesignIcon: { - template: '
', - props: ["iconName"], - }, - }, - }, + it("displays Banished title and subtext", async () => { + const { getByText } = render(BlockedPage); + await waitFor(() => { + expect(getByText("Banished")).toBeTruthy(); + expect(getByText("Manage Banished users and nodes")).toBeTruthy(); }); - }; - - it("displays 'Banished' title and subtext", async () => { - const wrapper = mountBlockedPage(); - // Wait for isLoading to become false - await vi.waitFor(() => expect(wrapper.vm.isLoading).toBe(false)); - - expect(wrapper.text()).toContain("Banished"); - expect(wrapper.text()).toContain("Manage Banished users and nodes"); }); it("combines local blocked and RNS blackholed items", async () => { - const wrapper = mountBlockedPage(); - await vi.waitFor(() => expect(wrapper.vm.isLoading).toBe(false)); - - expect(wrapper.vm.allBlockedIdentities.length).toBe(2); - - const rnsItem = wrapper.vm.allBlockedIdentities.find((i) => i.is_rns_blackholed); - expect(rnsItem).toBeDefined(); - expect(rnsItem.identity_hash).toBe("b".repeat(32)); - expect(rnsItem.rns_reason).toBe("Spam"); + const { getByText, container } = render(BlockedPage); + await waitFor(() => expect(getByText("RNS Blackhole")).toBeTruthy()); + expect(container.textContent).toContain("a".repeat(32)); + expect(container.textContent).toContain("b".repeat(32)); + expect(container.textContent).toContain("Spam"); }); it("displays RNS Blackhole badge for blackholed items", async () => { - const wrapper = mountBlockedPage(); - await vi.waitFor(() => expect(wrapper.vm.isLoading).toBe(false)); - - expect(wrapper.text()).toContain("RNS Blackhole"); + const { getByText } = render(BlockedPage); + await waitFor(() => expect(getByText("RNS Blackhole")).toBeTruthy()); }); it("calls delete API when lifting banishment", async () => { - // Mock DialogUtils.confirm const DialogUtils = await import("@/js/DialogUtils"); vi.spyOn(DialogUtils.default, "confirm").mockResolvedValue(true); - const wrapper = mountBlockedPage(); - await vi.waitFor(() => expect(wrapper.vm.isLoading).toBe(false)); - - const unblockButtons = wrapper.findAll("button").filter((b) => b.text().includes("Lift Banishment")); + const { findAllByText } = render(BlockedPage); + const unblockButtons = await findAllByText("Lift Banishment"); expect(unblockButtons.length).toBeGreaterThan(0); - - await unblockButtons[0].trigger("click"); - - expect(axiosMock.delete).toHaveBeenCalled(); + await fireEvent.click(unblockButtons[0]); + await waitFor(() => expect(axiosMock.delete).toHaveBeenCalled()); }); }); diff --git a/tests/frontend/BlockedPage.test.js b/tests/frontend/BlockedPage.test.js index a38882703..56f38edc7 100644 --- a/tests/frontend/BlockedPage.test.js +++ b/tests/frontend/BlockedPage.test.js @@ -1,59 +1,92 @@ -import { mount, flushPromises } from "@vue/test-utils"; -import { describe, it, expect, vi, beforeEach } from "vitest"; -import BlockedPage from "../../meshchatx/src/frontend/components/blocked/BlockedPage.vue"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, cleanup, fireEvent, waitFor } from "@testing-library/svelte"; +import BlockedPage from "../../meshchatx/src/frontend/features/blocked/BlockedPage.svelte"; +import { registerFallbackMessages, registerTranslator } from "../../meshchatx/src/frontend/js/i18n.js"; vi.mock("../../meshchatx/src/frontend/js/DialogUtils", () => ({ default: { confirm: vi.fn().mockResolvedValue(true) }, })); vi.mock("../../meshchatx/src/frontend/js/ToastUtils", () => ({ default: { success: vi.fn(), error: vi.fn() } })); vi.mock("../../meshchatx/src/frontend/js/Utils", () => ({ - default: { formatTimeAgo: (d) => "1h ago" }, + default: { formatTimeAgo: () => "1h ago" }, })); -const MaterialDesignIcon = { template: '
', props: ["iconName"] }; - -function mountBlockedPage() { - return mount(BlockedPage, { - global: { - components: { MaterialDesignIcon }, - mocks: { $t: (key) => key }, - }, - }); -} - describe("BlockedPage UI", () => { beforeEach(() => { vi.clearAllMocks(); - global.api.get = vi.fn().mockImplementation((url) => { - if (url === "/api/v1/blocked-destinations") return Promise.resolve({ data: { blocked_destinations: [] } }); - if (url === "/api/v1/reticulum/blackhole") return Promise.resolve({ data: { blackholed_identities: {} } }); - return Promise.resolve({ data: {} }); + registerTranslator(null); + registerFallbackMessages({ + banishment: { + title: "banishment.title", + description: "banishment.description", + no_items: "banishment.no_items", + search_placeholder: "search", + filter_all_types: "all", + user: "user", + node: "node", + filter_rns: "rns", + sort_newest: "newest", + sort_oldest: "oldest", + sort_name: "name", + result_count: "{count}", + lift_banishment: "lift", + lift_selected: "lift {count}", + lift_selected_confirm: "confirm {count}", + lift_selected_success: "ok {count}", + lift_banishment_confirm: "confirm {name}", + banishment_lifted: "lifted", + failed_lift_banishment: "fail", + failed_load_banished: "fail load", + banished_at: "at", + blocked_destinations: "dests", + }, + common: { select: "common.select", cancel: "common.cancel", refresh: "refresh" }, + archives: { select_all: "all" }, + nomadnet: { no_announces_yet: "nomadnet.no_announces_yet", no_search_results_peers: "none" }, + call: { unknown: "unknown" }, }); + global.api = { + get: vi.fn().mockImplementation((url) => { + if (url === "/api/v1/blocked-destinations") + return Promise.resolve({ data: { blocked_destinations: [] } }); + if (url === "/api/v1/reticulum/blackhole") + return Promise.resolve({ data: { blackholed_identities: {} } }); + return Promise.resolve({ data: {} }); + }), + delete: vi.fn().mockResolvedValue({}), + }; + window.api = global.api; + }); + + afterEach(() => { + cleanup(); }); it("renders title and description", async () => { - const wrapper = mountBlockedPage(); - await flushPromises(); - expect(wrapper.text()).toContain("banishment.title"); - expect(wrapper.text()).toContain("banishment.description"); + const { getByText } = render(BlockedPage); + await waitFor(() => { + expect(getByText("banishment.title")).toBeTruthy(); + expect(getByText("banishment.description")).toBeTruthy(); + }); }); it("renders search input and refresh button", async () => { - const wrapper = mountBlockedPage(); - await flushPromises(); - expect(wrapper.find('input[type="text"]').exists()).toBe(true); - expect(wrapper.find("button").exists()).toBe(true); + const { container } = render(BlockedPage); + await waitFor(() => { + expect(container.querySelector('input[type="text"]')).toBeTruthy(); + expect(container.querySelector("button")).toBeTruthy(); + }); }); - it("shows loading state initially then empty state", async () => { - const wrapper = mountBlockedPage(); - await flushPromises(); - expect(wrapper.vm.isLoading).toBe(false); - expect(wrapper.text()).toMatch(/banishment\.no_items|nomadnet\.no_announces_yet|banishment\.loading_items/); + it("shows empty state after load", async () => { + const { getByText } = render(BlockedPage); + await waitFor(() => { + expect(getByText("banishment.no_items")).toBeTruthy(); + }); }); it("renders blocked items when provided", async () => { - global.api.get = vi.fn().mockImplementation((url, opts) => { + window.api.get = vi.fn().mockImplementation((url, opts) => { if (url === "/api/v1/blocked-destinations") return Promise.resolve({ data: { blocked_destinations: [{ destination_hash: "abc123" }] } }); if (url === "/api/v1/reticulum/blackhole") return Promise.resolve({ data: { blackholed_identities: {} } }); @@ -65,28 +98,25 @@ describe("BlockedPage UI", () => { destination_hash: "abc123", display_name: "Blocked User", identity_hash: "abc123", - is_node: false, + aspect: "lxmf.delivery", }, ], }, }); return Promise.resolve({ data: {} }); }); - const wrapper = mountBlockedPage(); - await flushPromises(); - await wrapper.vm.$nextTick(); - expect(wrapper.vm.allBlockedIdentities).toHaveLength(1); - expect(wrapper.vm.allBlockedIdentities[0].display_name).toBe("Blocked User"); - expect(wrapper.text()).toContain("Blocked User"); - expect(wrapper.text()).toContain("abc123"); + global.api = window.api; + const { findByText, container } = render(BlockedPage); + expect(await findByText("Blocked User")).toBeTruthy(); + expect(container.textContent).toContain("abc123"); }); - it("search input binds to searchQuery", async () => { - const wrapper = mountBlockedPage(); - await flushPromises(); - const input = wrapper.find('input[type="text"]'); - await input.setValue("test"); - expect(wrapper.vm.searchQuery).toBe("test"); + it("search input updates value", async () => { + const { container } = render(BlockedPage); + await waitFor(() => expect(container.querySelector('input[type="text"]')).toBeTruthy()); + const input = container.querySelector('input[type="text"]'); + await fireEvent.input(input, { target: { value: "test" } }); + expect(input.value).toBe("test"); }); it("supports multi-select mode for bulk unban", async () => { @@ -107,7 +137,7 @@ describe("BlockedPage UI", () => { destination_hash: hash, display_name: hash === "abc123" ? "User A" : "User B", identity_hash: hash, - is_node: false, + aspect: "lxmf.delivery", }, ], }, @@ -115,28 +145,34 @@ describe("BlockedPage UI", () => { } return Promise.resolve({ data: {} }); }); - global.api.delete = vi.fn().mockResolvedValue({}); + window.api = global.api; - const wrapper = mountBlockedPage(); - await flushPromises(); + const { container, getByText } = render(BlockedPage); + await waitFor(() => expect(getByText("User A")).toBeTruthy()); - expect(wrapper.findAll('input[type="checkbox"]').length).toBe(0); - await wrapper - .findAll("button") - .find((b) => b.text() === "common.select") - .trigger("click"); - expect(wrapper.vm.selectMode).toBe(true); - expect(wrapper.findAll('input[type="checkbox"]').length).toBeGreaterThan(0); + expect(container.querySelectorAll('input[type="checkbox"]').length).toBe(0); + await fireEvent.click(getByText("common.select")); + await waitFor(() => expect(container.querySelectorAll('input[type="checkbox"]').length).toBeGreaterThan(0)); - wrapper.vm.selectedIdentities = ["abc123", "def456"]; - await wrapper.vm.$nextTick(); + const boxes = container.querySelectorAll('input[type="checkbox"]'); + for (const box of boxes) { + if (box !== boxes[0] || boxes.length === 1) { + await fireEvent.click(box); + } else { + await fireEvent.click(box); + } + } + // Select both identity checkboxes (skip select-all if present as first) + const identityBoxes = [...boxes].filter((b) => b.closest(".flex.min-h-0")); + for (const box of identityBoxes) { + if (!box.checked) { + await fireEvent.click(box); + } + } - const liftSelected = wrapper.findAll("button").find((b) => b.text().includes("banishment.lift_selected")); + const liftSelected = [...container.querySelectorAll("button")].find((b) => b.textContent.includes("lift")); expect(liftSelected).toBeTruthy(); - await liftSelected.trigger("click"); - await flushPromises(); - - expect(global.api.delete).toHaveBeenCalledTimes(2); - expect(wrapper.vm.selectMode).toBe(false); + await fireEvent.click(liftSelected); + await waitFor(() => expect(global.api.delete).toHaveBeenCalled()); }); }); diff --git a/tests/frontend/ConfirmDialog.test.js b/tests/frontend/ConfirmDialog.test.js index 1bd8c63ab..678d4a1ab 100644 --- a/tests/frontend/ConfirmDialog.test.js +++ b/tests/frontend/ConfirmDialog.test.js @@ -1,6 +1,7 @@ import { mount } from "@vue/test-utils"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import ConfirmDialog from "../../meshchatx/src/frontend/components/ConfirmDialog.vue"; +import { registerFallbackMessages, registerTranslator } from "../../meshchatx/src/frontend/js/i18n.js"; vi.mock("../../meshchatx/src/frontend/js/GlobalEmitter", () => ({ default: { on: vi.fn(), off: vi.fn(), emit: vi.fn() }, @@ -8,15 +9,9 @@ vi.mock("../../meshchatx/src/frontend/js/GlobalEmitter", () => ({ import GlobalEmitter from "../../meshchatx/src/frontend/js/GlobalEmitter"; -const MaterialDesignIcon = { template: '
', props: ["iconName"] }; - function mountDialog() { return mount(ConfirmDialog, { attachTo: document.body, - global: { - components: { MaterialDesignIcon }, - mocks: { $t: (key) => key }, - }, }); } @@ -28,159 +23,179 @@ function promptHandler() { return GlobalEmitter.on.mock.calls.find((c) => c[0] === "prompt")?.[1]; } +async function flush() { + await Promise.resolve(); + await Promise.resolve(); +} + describe("ConfirmDialog UI", () => { beforeEach(() => { vi.mocked(GlobalEmitter.on).mockClear(); vi.mocked(GlobalEmitter.off).mockClear(); + registerTranslator(null); + registerFallbackMessages({ + common: { + confirm_action: "common.confirm_action", + cancel: "common.cancel", + confirm: "common.confirm", + }, + }); }); afterEach(() => { document.body.innerHTML = ""; }); - it("registers confirm listener on mount", () => { + it("registers confirm listener on mount", async () => { mountDialog(); + await flush(); expect(GlobalEmitter.on).toHaveBeenCalledWith("confirm", expect.any(Function)); expect(GlobalEmitter.on).toHaveBeenCalledWith("prompt", expect.any(Function)); }); - it("does not show dialog when pendingConfirm is null", () => { - const wrapper = mountDialog(); - expect(wrapper.vm.pendingConfirm).toBeNull(); - expect(wrapper.find(".fixed.inset-0").exists()).toBe(false); - wrapper.unmount(); + it("does not show dialog when closed", async () => { + mountDialog(); + await flush(); + expect(document.querySelector(".confirm-dialog-root")).toBeNull(); }); it("shows dialog with message when show is called", async () => { - const wrapper = mountDialog(); + mountDialog(); + await flush(); const showFn = confirmHandler(); expect(showFn).toBeDefined(); showFn({ message: "Delete this item?", resolve: vi.fn() }); - await wrapper.vm.$nextTick(); - expect(wrapper.vm.pendingConfirm).toEqual({ message: "Delete this item?", title: "" }); - expect(wrapper.text()).toContain("common.confirm_action"); - expect(wrapper.text()).toContain("Delete this item?"); - wrapper.unmount(); + await flush(); + expect(document.body.textContent).toContain("common.confirm_action"); + expect(document.body.textContent).toContain("Delete this item?"); }); it("has Cancel and Confirm buttons when visible", async () => { - const wrapper = mountDialog(); + mountDialog(); + await flush(); confirmHandler()({ message: "Sure?", resolve: vi.fn() }); - await wrapper.vm.$nextTick(); - expect(wrapper.text()).toContain("common.cancel"); - expect(wrapper.text()).toContain("common.confirm"); - wrapper.unmount(); + await flush(); + expect(document.body.textContent).toContain("common.cancel"); + expect(document.body.textContent).toContain("common.confirm"); }); it("calls resolve(true) and clears when Confirm clicked", async () => { const resolve = vi.fn(); - const wrapper = mountDialog(); + mountDialog(); + await flush(); confirmHandler()({ message: "Sure?", resolve }); - await wrapper.vm.$nextTick(); - await wrapper.find("button.bg-red-600").trigger("click"); + await flush(); + document.querySelector("button.bg-red-600").click(); + await flush(); expect(resolve).toHaveBeenCalledWith(true); - expect(wrapper.vm.pendingConfirm).toBeNull(); - wrapper.unmount(); + expect(document.querySelector(".confirm-dialog-root")).toBeNull(); }); it("calls resolve(false) when Cancel clicked", async () => { const resolve = vi.fn(); - const wrapper = mountDialog(); + mountDialog(); + await flush(); confirmHandler()({ message: "Sure?", resolve }); - await wrapper.vm.$nextTick(); - const cancelBtn = wrapper.findAll("button").find((b) => b.text() === "common.cancel"); - await cancelBtn.trigger("click"); + await flush(); + document.querySelector("[data-confirm-cancel]").click(); + await flush(); expect(resolve).toHaveBeenCalledWith(false); - expect(wrapper.vm.pendingConfirm).toBeNull(); - wrapper.unmount(); }); it("resolves the previous waiter with false when a second confirm opens", async () => { const first = vi.fn(); const second = vi.fn(); - const wrapper = mountDialog(); + mountDialog(); + await flush(); const showFn = confirmHandler(); showFn({ message: "First?", resolve: first }); - await wrapper.vm.$nextTick(); + await flush(); showFn({ message: "Second?", resolve: second }); - await wrapper.vm.$nextTick(); + await flush(); expect(first).toHaveBeenCalledWith(false); - expect(wrapper.vm.pendingConfirm).toEqual({ message: "Second?", title: "" }); + expect(document.body.textContent).toContain("Second?"); expect(second).not.toHaveBeenCalled(); - wrapper.unmount(); }); it("shows an optional title when provided", async () => { - const wrapper = mountDialog(); + mountDialog(); + await flush(); confirmHandler()({ message: "All messages will be lost.", title: "Delete conversations", resolve: vi.fn() }); - await wrapper.vm.$nextTick(); - expect(wrapper.text()).toContain("Delete conversations"); - expect(wrapper.text()).not.toContain("common.confirm_action"); - wrapper.unmount(); + await flush(); + expect(document.body.textContent).toContain("Delete conversations"); + expect(document.body.textContent).not.toContain("common.confirm_action"); }); it("cancels on Escape and confirms on Enter", async () => { const resolve = vi.fn(); - const wrapper = mountDialog(); + mountDialog(); + await flush(); const showFn = confirmHandler(); showFn({ message: "Sure?", resolve }); - await wrapper.vm.$nextTick(); + await flush(); window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); + await flush(); expect(resolve).toHaveBeenCalledWith(false); - expect(wrapper.vm.pendingConfirm).toBeNull(); + expect(document.querySelector(".confirm-dialog-root")).toBeNull(); const resolveEnter = vi.fn(); showFn({ message: "Sure again?", resolve: resolveEnter }); - await wrapper.vm.$nextTick(); + await flush(); window.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + await flush(); expect(resolveEnter).toHaveBeenCalledWith(true); - wrapper.unmount(); }); it("does not confirm on the Enter key that opened the dialog", async () => { const resolve = vi.fn(); - const wrapper = mountDialog(); + mountDialog(); + await flush(); window.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); confirmHandler()({ message: "Delete?", resolve }); - await wrapper.vm.$nextTick(); + await flush(); window.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, repeat: true })); + await flush(); expect(resolve).not.toHaveBeenCalled(); - expect(wrapper.vm.pendingConfirm).not.toBeNull(); + expect(document.querySelector(".confirm-dialog-root")).not.toBeNull(); window.dispatchEvent(new KeyboardEvent("keyup", { key: "Enter", bubbles: true })); window.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + await flush(); expect(resolve).toHaveBeenCalledWith(true); - wrapper.unmount(); }); it("lets Enter on Cancel dismiss instead of confirming", async () => { const resolve = vi.fn(); - const wrapper = mountDialog(); + mountDialog(); + await flush(); confirmHandler()({ message: "Sure?", resolve }); - await wrapper.vm.$nextTick(); - const cancelBtn = wrapper.find("[data-confirm-cancel]").element; + await flush(); + const cancelBtn = document.querySelector("[data-confirm-cancel]"); cancelBtn.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + await flush(); expect(resolve).not.toHaveBeenCalledWith(true); - wrapper.unmount(); }); it("cancels the pending waiter when a prompt opens", async () => { const resolve = vi.fn(); - const wrapper = mountDialog(); + mountDialog(); + await flush(); confirmHandler()({ message: "Sure?", resolve }); - await wrapper.vm.$nextTick(); + await flush(); promptHandler()(); + await flush(); expect(resolve).toHaveBeenCalledWith(false); - expect(wrapper.vm.pendingConfirm).toBeNull(); - wrapper.unmount(); + expect(document.querySelector(".confirm-dialog-root")).toBeNull(); }); it("resolves false when unmounted while open", async () => { const resolve = vi.fn(); const wrapper = mountDialog(); + await flush(); confirmHandler()({ message: "Sure?", resolve }); - await wrapper.vm.$nextTick(); + await flush(); wrapper.unmount(); + await flush(); expect(resolve).toHaveBeenCalledWith(false); }); }); diff --git a/tests/frontend/MapBrowser.test.js b/tests/frontend/MapBrowser.test.js index aaff09f5d..2161abce6 100644 --- a/tests/frontend/MapBrowser.test.js +++ b/tests/frontend/MapBrowser.test.js @@ -77,6 +77,16 @@ describe("MapBrowser.vue", () => { expect(wrapper.vm.tabs.find((t) => t.id === id).storageId).toBeTruthy(); }); + it("caps the number of map tabs", async () => { + const wrapper = await mountBrowser(); + while (wrapper.vm.canAddTab) { + wrapper.vm.addTab(); + } + expect(wrapper.vm.tabs.length).toBe(8); + expect(wrapper.vm.addTab()).toBeNull(); + expect(wrapper.vm.tabs).toHaveLength(8); + }); + it("tabTitle uses the saved title and falls back to the new map label", async () => { const wrapper = await mountBrowser(); expect(wrapper.vm.tabTitle({ title: "Field AO" })).toBe("Field AO"); diff --git a/tests/frontend/SmokeBadge.svelte.test.js b/tests/frontend/SmokeBadge.svelte.test.js new file mode 100644 index 000000000..8ec0a465b --- /dev/null +++ b/tests/frontend/SmokeBadge.svelte.test.js @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: 0BSD + +import { describe, it, expect, afterEach } from "vitest"; +import { render, cleanup } from "@testing-library/svelte"; +import SmokeBadge from "../../meshchatx/src/frontend/ui/svelte/SmokeBadge.svelte"; + +describe("SmokeBadge.svelte", () => { + afterEach(() => { + cleanup(); + }); + + it("renders the default label", () => { + const { getByTestId } = render(SmokeBadge); + expect(getByTestId("svelte-smoke-badge").textContent).toBe("ok"); + }); + + it("renders a custom label", () => { + const { getByTestId } = render(SmokeBadge, { props: { label: "svelte5" } }); + expect(getByTestId("svelte-smoke-badge").textContent).toBe("svelte5"); + }); +}); diff --git a/tests/frontend/UIThemeAndVisibility.test.js b/tests/frontend/UIThemeAndVisibility.test.js index e4964231e..7b7e5b8f5 100644 --- a/tests/frontend/UIThemeAndVisibility.test.js +++ b/tests/frontend/UIThemeAndVisibility.test.js @@ -313,44 +313,49 @@ describe("Theme Switching", () => { }); describe("Visibility Checks", () => { - it("ConfirmDialog shows when pendingConfirm is set", async () => { + it("ConfirmDialog shows when confirm event fires", async () => { + const GlobalEmitter = (await import("../../meshchatx/src/frontend/js/GlobalEmitter")).default; + const onSpy = vi.spyOn(GlobalEmitter, "on"); + const offSpy = vi.spyOn(GlobalEmitter, "off"); + const wrapper = mountTracked(ConfirmDialog, { + attachTo: document.body, global: { - stubs: { - MaterialDesignIcon: { template: "
" }, - }, mocks: { $t: (key) => key, }, }, }); - wrapper.vm.pendingConfirm = { message: "Test message" }; - await wrapper.vm.$nextTick(); + await flushPromises(); + const showFn = onSpy.mock.calls.find((c) => c[0] === "confirm")?.[1]; + expect(showFn).toBeDefined(); + showFn({ message: "Test message", resolve: vi.fn() }); + await flushPromises(); + await Promise.resolve(); - const dialogElement = wrapper.find(".fixed"); - expect(dialogElement.exists()).toBe(true); - expect(wrapper.text()).toContain("common.confirm_action"); - expect(wrapper.text()).toContain("common.confirm"); + expect(document.body.textContent).toContain("common.confirm_action"); + expect(document.body.textContent).toContain("Test message"); + expect(document.querySelector(".confirm-dialog-root")).toBeTruthy(); + + offSpy.mockRestore(); + onSpy.mockRestore(); + wrapper.unmount(); }); - it("ConfirmDialog hides when pendingConfirm is null", async () => { + it("ConfirmDialog hides when closed", async () => { const wrapper = mountTracked(ConfirmDialog, { + attachTo: document.body, global: { - stubs: { - MaterialDesignIcon: { template: "
" }, - }, mocks: { $t: (key) => key, }, }, }); - wrapper.vm.pendingConfirm = null; - await wrapper.vm.$nextTick(); - - const dialogElement = wrapper.find(".fixed"); - expect(dialogElement.exists()).toBe(false); + await flushPromises(); + expect(document.querySelector(".confirm-dialog-root")).toBeNull(); + wrapper.unmount(); }); it("ChangelogModal component renders correctly", () => { diff --git a/tests/frontend/__snapshots__/uiSnapshots.test.js.snap b/tests/frontend/__snapshots__/uiSnapshots.test.js.snap index ea221da72..815dd335e 100644 --- a/tests/frontend/__snapshots__/uiSnapshots.test.js.snap +++ b/tests/frontend/__snapshots__/uiSnapshots.test.js.snap @@ -1,6 +1,6 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`UI snapshot regression > ConfirmDialog.vue > visible confirm dialog 1`] = `"
common.confirm_actionDelete this item?

common.cancelcommon.confirm"`; +exports[`UI snapshot regression > ConfirmDialog.vue > visible confirm dialog 1`] = `"

common.confirm_action

Delete this item?

"`; exports[`UI snapshot regression > FormLabel.vue > default label 1`] = `""`; diff --git a/tests/frontend/appStateI18nAdapters.test.js b/tests/frontend/appStateI18nAdapters.test.js new file mode 100644 index 000000000..91f4d5601 --- /dev/null +++ b/tests/frontend/appStateI18nAdapters.test.js @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: 0BSD + +import { describe, it, expect, beforeEach } from "vitest"; +import { + subscribeAppState, + getAppState, + patchAppState, + patchAppConfig, +} from "../../meshchatx/src/frontend/js/appState.js"; +import { t, registerTranslator, registerFallbackMessages } from "../../meshchatx/src/frontend/js/i18n.js"; + +describe("appState adapter", () => { + it("getAppState returns the same object", () => { + const state = { a: 1 }; + expect(getAppState(state)).toBe(state); + }); + + it("subscribe receives snapshot and unsubscribe stops updates", () => { + const state = { n: 0 }; + const seen = []; + const unsub = subscribeAppState(state, (snap) => seen.push(snap.n)); + expect(seen).toEqual([0]); + patchAppState(state, { n: 2 }); + expect(seen).toEqual([0, 2]); + unsub(); + patchAppState(state, { n: 3 }); + expect(seen).toEqual([0, 2]); + expect(state.n).toBe(3); + }); + + it("patchAppConfig merges config", () => { + const state = { config: { a: 1 } }; + patchAppConfig(state, { b: 2 }); + expect(state.config).toEqual({ a: 1, b: 2 }); + }); +}); + +describe("i18n adapter", () => { + beforeEach(() => { + registerTranslator(null); + registerFallbackMessages(null); + }); + + it("returns key when nothing registered", () => { + expect(t("missing.key")).toBe("missing.key"); + }); + + it("uses fallback messages with interpolation", () => { + registerFallbackMessages({ + hello: "Hi {name}", + nested: { title: "Title" }, + }); + expect(t("hello", { name: "Ada" })).toBe("Hi Ada"); + expect(t("nested.title")).toBe("Title"); + }); + + it("prefers registered translator", () => { + registerFallbackMessages({ x: "fallback" }); + registerTranslator((key) => (key === "x" ? "from-t" : key)); + expect(t("x")).toBe("from-t"); + }); +}); diff --git a/tests/frontend/featureModuleOwnership.test.js b/tests/frontend/featureModuleOwnership.test.js new file mode 100644 index 000000000..eb3b4f33c --- /dev/null +++ b/tests/frontend/featureModuleOwnership.test.js @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: 0BSD + +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { existsSync } from "fs"; +import { join } from "path"; +import { clearRoutes, listRoutes } from "../../meshchatx/src/frontend/js/registries/routeRegistry.js"; +import { clearFeatureIds, listFeatureIds } from "../../meshchatx/src/frontend/js/registries/featureRegistry.js"; +import { registerBlockedFeature } from "../../meshchatx/src/frontend/features/blocked/index.js"; +import { filterBlockedIdentities } from "../../meshchatx/src/frontend/features/blocked/lib/blockedList.js"; + +const repoRoot = process.cwd(); + +/** + * Conveyor ownership for feature modules that opt into registerFeature. + * Expand this list as pages migrate off the hardcoded main.js table. + */ +const FEATURE_MODULE_OWNERS = [ + { + id: "blocked", + register: registerBlockedFeature, + required_paths: [ + "meshchatx/src/frontend/features/blocked/index.js", + "meshchatx/src/frontend/features/blocked/BlockedPage.svelte", + "meshchatx/src/frontend/features/blocked/lib/blockedList.js", + ], + route_name: "blocked", + mount: "svelte", + }, +]; + +describe("feature module conveyor ownership", () => { + beforeEach(() => { + clearRoutes(); + clearFeatureIds(); + }); + + afterEach(() => { + clearRoutes(); + clearFeatureIds(); + }); + + it("each owned feature has required files and registers once", () => { + for (const feature of FEATURE_MODULE_OWNERS) { + for (const rel of feature.required_paths) { + expect(existsSync(join(repoRoot, rel)), `missing ${rel}`).toBe(true); + } + feature.register(); + expect(listFeatureIds()).toContain(feature.id); + const route = listRoutes().find((r) => r.name === feature.route_name); + expect(route).toBeTruthy(); + expect(route.mount).toBe(feature.mount); + expect(typeof route.load).toBe("function"); + } + }); + + it("blockedList filter helpers stay pure", () => { + const list = filterBlockedIdentities( + [ + { + identity_hash: "a", + display_name: "Ada", + is_node: false, + is_rns_blackholed: false, + blocked_destinations: [{ destination_hash: "a", created_at: "2026-01-02" }], + }, + { + identity_hash: "b", + display_name: "Bob", + is_node: true, + is_rns_blackholed: false, + blocked_destinations: [{ destination_hash: "b", created_at: "2026-01-01" }], + }, + ], + { typeFilter: "node", dateSort: "newest" } + ); + expect(list).toHaveLength(1); + expect(list[0].identity_hash).toBe("b"); + }); +}); diff --git a/tests/frontend/featureRouteRegistry.test.js b/tests/frontend/featureRouteRegistry.test.js new file mode 100644 index 000000000..070e1ae62 --- /dev/null +++ b/tests/frontend/featureRouteRegistry.test.js @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: 0BSD + +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { clearRoutes, listRoutes, registerRoute } from "../../meshchatx/src/frontend/js/registries/routeRegistry.js"; +import { + clearFeatureIds, + listFeatureIds, + registerFeature, +} from "../../meshchatx/src/frontend/js/registries/featureRegistry.js"; +import { registerBlockedFeature } from "../../meshchatx/src/frontend/features/blocked/index.js"; +import { buildRouterRoutesFromRegistry } from "../../meshchatx/src/frontend/shell/buildRouterRoutes.js"; + +describe("feature route registry", () => { + beforeEach(() => { + clearRoutes(); + clearFeatureIds(); + }); + + afterEach(() => { + clearRoutes(); + clearFeatureIds(); + }); + + it("rejects routes without load or mount", () => { + expect(() => registerRoute({ name: "x", path: "/x", mount: "vue" })).toThrow(/load/); + expect(() => + registerRoute({ + name: "y", + path: "/y", + mount: "wat", + load: () => Promise.resolve({}), + }) + ).toThrow(/mount/); + }); + + it("registerFeature records routes and feature id", () => { + registerFeature({ + id: "demo", + routes: [ + { + name: "demo", + path: "/demo", + mount: "vue", + load: () => Promise.resolve({ default: {} }), + }, + ], + }); + expect(listFeatureIds()).toEqual(["demo"]); + expect(listRoutes().map((r) => r.name)).toEqual(["demo"]); + }); + + it("blocked feature registers the blocked route", () => { + registerBlockedFeature(); + const blocked = listRoutes().find((r) => r.name === "blocked"); + expect(blocked).toBeTruthy(); + expect(blocked.path).toBe("/blocked"); + expect(blocked.mount).toBe("svelte"); + expect(typeof blocked.load).toBe("function"); + }); + + it("buildRouterRoutesFromRegistry maps svelte loads to FeaturePageHost", () => { + registerBlockedFeature(); + const routes = buildRouterRoutesFromRegistry(); + const blocked = routes.find((r) => r.name === "blocked"); + expect(blocked).toBeTruthy(); + expect(blocked.component).toBeTruthy(); + expect(blocked.meta.featureMount).toBe("svelte"); + expect(typeof blocked.meta.featureLoad).toBe("function"); + }); +}); diff --git a/tests/frontend/hostFlipReadiness.test.js b/tests/frontend/hostFlipReadiness.test.js new file mode 100644 index 000000000..4887322f9 --- /dev/null +++ b/tests/frontend/hostFlipReadiness.test.js @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: 0BSD + +import { describe, it, expect } from "vitest"; +import { existsSync } from "fs"; +import { join } from "path"; +import { listNavItems } from "../../meshchatx/src/frontend/js/registries/navRegistry.js"; +import { listRoutes } from "../../meshchatx/src/frontend/js/registries/routeRegistry.js"; + +const repoRoot = process.cwd(); + +/** + * Host flip readiness: shell may move from Vue App.vue to Svelte when these + * surfaces stay registry-driven. Do not flip while Vue pages still dominate. + */ +describe("host flip readiness", () => { + it("shell helpers for registry routes exist", () => { + expect(existsSync(join(repoRoot, "meshchatx/src/frontend/shell/FeaturePageHost.vue"))).toBe(true); + expect(existsSync(join(repoRoot, "meshchatx/src/frontend/shell/buildRouterRoutes.js"))).toBe(true); + expect(existsSync(join(repoRoot, "meshchatx/src/frontend/features/registerAllFeatures.js"))).toBe(true); + }); + + it("nav and route registries expose list APIs for a future Svelte shell", () => { + expect(typeof listNavItems).toBe("function"); + expect(typeof listRoutes).toBe("function"); + expect(Array.isArray(listNavItems())).toBe(true); + expect(Array.isArray(listRoutes())).toBe(true); + }); + + it("documents that Vue remains host until mount vue count is zero", () => { + // Intentional gate: a Svelte app shell replaces App.vue only after every + // route uses mount svelte (or is deleted). This test stays green while + // dual-stack runs. + const vueMounts = listRoutes().filter((r) => r.mount === "vue"); + expect(vueMounts.every((r) => typeof r.load === "function")).toBe(true); + }); +}); diff --git a/tests/frontend/mapExchange.sanitize.test.js b/tests/frontend/mapExchange.sanitize.test.js index 2a553775f..8f7bbf5c0 100644 --- a/tests/frontend/mapExchange.sanitize.test.js +++ b/tests/frontend/mapExchange.sanitize.test.js @@ -2,6 +2,8 @@ import { describe, it, expect } from "vitest"; import JSZip from "jszip"; +import Feature from "ol/Feature"; +import Point from "ol/geom/Point"; import { isAllowedDataImageHref, isRemoteHref, @@ -160,6 +162,22 @@ describe("kmlSanitize oracle", () => { expect(payload.extended.some((r) => /null/i.test(r.value))).toBe(false); }); + it("repairs mashed entity descriptions already stored on features", () => { + const f = new Feature({ + geometry: new Point([0, 0]), + name: "HumanTrafficking", + description: + "AttackType:<Null>Date:<Null>Notes:Memorial defaced function changeImage(attElement, nameElement) { document.getElementById('imageAttachment').src = attElement; }", + }); + const payload = getDrawFeatureMetadataPayload(f); + expect(payload.name).toBe("HumanTrafficking"); + expect(payload.descriptionIsHtml).toBe(false); + expect(payload.extended.some((r) => r.key === "Notes" && r.value === "Memorial defaced")).toBe(true); + expect(payload.extended.some((r) => /null/i.test(r.value))).toBe(false); + expect(String(payload.description || "").toLowerCase()).not.toContain("changeimage"); + expect(String(payload.description || "")).not.toContain("<"); + }); + it("skips unreferenced svg kmz entry and keeps placemarks", async () => { const kml = ` diff --git a/tests/frontend/mapExchange.test.js b/tests/frontend/mapExchange.test.js index 61faaf892..a1afa27a4 100644 --- a/tests/frontend/mapExchange.test.js +++ b/tests/frontend/mapExchange.test.js @@ -95,7 +95,8 @@ describe("mapExchange KML", () => { const f = features[0]; expect(f.getGeometry().getType()).toBe("Point"); expect(String(f.get("name") || f.get("Name") || "")).toContain("P"); - const st = f.getStyle(); + const stRaw = f.getStyle(); + const st = typeof stRaw === "function" ? stRaw(f, 1) : stRaw; expect(st && typeof st.getImage === "function" && st.getImage()).toBeTruthy(); }); diff --git a/tests/frontend/uiSnapshots.test.js b/tests/frontend/uiSnapshots.test.js index 0f043523a..6eae456f2 100644 --- a/tests/frontend/uiSnapshots.test.js +++ b/tests/frontend/uiSnapshots.test.js @@ -63,9 +63,12 @@ describe("UI snapshot regression", () => { describe("ConfirmDialog.vue", () => { it("visible confirm dialog", async () => { const wrapper = mountSnapshot(ConfirmDialog); + await Promise.resolve(); + await Promise.resolve(); const showFn = vi.mocked(GlobalEmitter.on).mock.calls.find((c) => c[0] === "confirm")?.[1]; showFn?.({ message: "Delete this item?", resolve: vi.fn() }); - await wrapper.vm.$nextTick(); + await Promise.resolve(); + await Promise.resolve(); expectHtmlSnapshot(wrapper); wrapper.unmount(); }); diff --git a/tsconfig.json b/tsconfig.json index d64d9d5b0..f55dc1269 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -21,9 +21,11 @@ }, "include": [ "meshchatx/src/frontend/**/*.vue", + "meshchatx/src/frontend/**/*.svelte", "meshchatx/src/frontend/**/*.js", "electron/**/*.js", - "vite.config.js" + "vite.config.js", + "svelte.config.js" ], "exclude": [ "node_modules", diff --git a/tsconfig.svelte.json b/tsconfig.svelte.json new file mode 100644 index 000000000..13f4ac701 --- /dev/null +++ b/tsconfig.svelte.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "allowJs": true, + "checkJs": false, + "verbatimModuleSyntax": true, + "isolatedModules": true + }, + "include": ["meshchatx/src/frontend/**/*.svelte", "meshchatx/src/frontend/**/*.js", "svelte.config.js"], + "exclude": ["node_modules", "meshchatx/public", "meshchatx/src/frontend/public", "build", "dist", "tests"] +} diff --git a/vite.config.js b/vite.config.js index 14df890be..c6b4dafe8 100644 --- a/vite.config.js +++ b/vite.config.js @@ -6,6 +6,7 @@ import { meshchatxServiceWorkerPlugin } from "./scripts/build/generate_service_w import { detectLaunchEditor, isVueDevToolsEnabled } from "./scripts/vite-dx.mjs"; import tailwindcss from "@tailwindcss/vite"; import vue from "@vitejs/plugin-vue"; +import { svelte } from "@sveltejs/vite-plugin-svelte"; import vueDevTools from "vite-plugin-vue-devtools"; const vendorChunkGroups = [ { test: /[/\\]node_modules[/\\](vis-network|vis-data)/, name: "vendor-vis", priority: 95 }, @@ -16,6 +17,7 @@ const vendorChunkGroups = [ { test: /[/\\]node_modules[/\\]micron-parser/, name: "vendor-micron", priority: 55 }, { test: /MicronParser\.js/, name: "vendor-micron", priority: 55 }, { test: /[/\\]node_modules[/\\]electron-prompt/, name: "vendor-electron-prompt", priority: 50 }, + { test: /[/\\]node_modules[/\\]svelte/, name: "vendor-svelte", priority: 48 }, { test: /[/\\]node_modules[/\\].*vue/, name: "vendor-vue", priority: 45 }, { test: /[/\\]node_modules[/\\]/, name: "vendor-other", priority: 10 }, ]; @@ -299,6 +301,7 @@ export default defineConfig(({ command }) => { }, }, }), + svelte(), meshchatxServiceWorkerPlugin({ buildId: appBuildTimeIso }), ], @@ -424,11 +427,11 @@ export default defineConfig(({ command }) => { }, optimizeDeps: { - include: ["vue", "emoji-picker-element"], + include: ["vue", "svelte", "emoji-picker-element"], }, resolve: { - dedupe: ["vue"], + dedupe: ["vue", "svelte"], tsconfigPaths: true, // Git-hosted micron-parser has no upstream package.json. Alias the entry so // Vite/Rolldown resolve it in Docker and CI without relying on metadata alone. diff --git a/vitest.config.js b/vitest.config.js index 26b857396..a0622539a 100644 --- a/vitest.config.js +++ b/vitest.config.js @@ -1,5 +1,6 @@ import { defineConfig } from "vitest/config"; import vue from "@vitejs/plugin-vue"; +import { svelte } from "@sveltejs/vite-plugin-svelte"; import path from "path"; import fs from "fs"; import { MICRON_PARSER_GO_RELEASE_TAG } from "./scripts/micron-parser-go-version.mjs"; @@ -59,6 +60,7 @@ export default defineConfig({ }, }, }), + svelte(), ], test: { execArgv: [ @@ -76,7 +78,7 @@ export default defineConfig({ provider: "v8", reporter: ["text", "json-summary"], reportsDirectory: "./coverage", - include: ["meshchatx/src/frontend/**/*.{js,vue}"], + include: ["meshchatx/src/frontend/**/*.{js,vue,svelte}"], exclude: [ "meshchatx/src/frontend/**/*.d.ts", "meshchatx/src/frontend/public/**", @@ -86,6 +88,8 @@ export default defineConfig({ }, }, resolve: { + dedupe: ["svelte"], + conditions: ["browser"], tsconfigPaths: true, alias: { "@": path.resolve(__dirname, "meshchatx", "src", "frontend"), From 7781283769a911d9c2710dfe2ae3e0e246268fb6 Mon Sep 17 00:00:00 2001 From: Ivan Date: Sat, 5 Sep 2026 08:02:01 -0500 Subject: [PATCH 004/150] feat(forwarder): implement forwarding feature with Svelte component and integrate routing --- .../components/forwarder/ForwarderPage.vue | 241 ---------------- .../features/forwarder/ForwarderPage.svelte | 258 ++++++++++++++++++ .../src/frontend/features/forwarder/index.js | 20 ++ .../features/forwarder/lib/forwarderHash.js | 10 + .../frontend/features/registerAllFeatures.js | 2 + meshchatx/src/frontend/main.js | 5 - .../frontend/ui/svelte/ToolsPageHeader.svelte | 99 +++++++ tests/frontend/ForwarderPage.test.js | 154 ++++++----- tests/frontend/featureModuleOwnership.test.js | 12 + 9 files changed, 492 insertions(+), 309 deletions(-) delete mode 100644 meshchatx/src/frontend/components/forwarder/ForwarderPage.vue create mode 100644 meshchatx/src/frontend/features/forwarder/ForwarderPage.svelte create mode 100644 meshchatx/src/frontend/features/forwarder/index.js create mode 100644 meshchatx/src/frontend/features/forwarder/lib/forwarderHash.js create mode 100644 meshchatx/src/frontend/ui/svelte/ToolsPageHeader.svelte diff --git a/meshchatx/src/frontend/components/forwarder/ForwarderPage.vue b/meshchatx/src/frontend/components/forwarder/ForwarderPage.vue deleted file mode 100644 index dccb123ab..000000000 --- a/meshchatx/src/frontend/components/forwarder/ForwarderPage.vue +++ /dev/null @@ -1,241 +0,0 @@ - - - - - diff --git a/meshchatx/src/frontend/features/forwarder/ForwarderPage.svelte b/meshchatx/src/frontend/features/forwarder/ForwarderPage.svelte new file mode 100644 index 000000000..b3608541f --- /dev/null +++ b/meshchatx/src/frontend/features/forwarder/ForwarderPage.svelte @@ -0,0 +1,258 @@ + + + + +
+ +
+
+
+
+ {t("forwarder.add_rule")} +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+
+ +
+
+ {t("forwarder.active_rules")} +
+ {#if rules.length === 0} +
+ {t("forwarder.no_rules")} +
+ {:else} + {#each rules as rule (rule.id)} +
+
+
+
+ {rule.is_active ? t("forwarder.active") : t("forwarder.disabled")} +
+ ID: {rule.id} +
+ {#if rule.name} +
+ {rule.name} +
+ {/if} +
+
+ + + + + {t("forwarder.forwarding_to", { hash: rule.forward_to_hash })} + +
+ {#if rule.source_filter_hash} +
+ + + + + {t("forwarder.source_filter_display", { + hash: rule.source_filter_hash, + })} + +
+ {/if} +
+
+
+ + +
+
+ {/each} + {/if} +
+
+
+
diff --git a/meshchatx/src/frontend/features/forwarder/index.js b/meshchatx/src/frontend/features/forwarder/index.js new file mode 100644 index 000000000..06cda4fb7 --- /dev/null +++ b/meshchatx/src/frontend/features/forwarder/index.js @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: 0BSD + +import { registerFeature } from "../../js/registries/featureRegistry.js"; + +/** + * LXMF message forwarder rules UI. + */ +export function registerForwarderFeature() { + registerFeature({ + id: "forwarder", + routes: [ + { + name: "forwarder", + path: "/forwarder", + mount: "svelte", + load: () => import("./ForwarderPage.svelte"), + }, + ], + }); +} diff --git a/meshchatx/src/frontend/features/forwarder/lib/forwarderHash.js b/meshchatx/src/frontend/features/forwarder/lib/forwarderHash.js new file mode 100644 index 000000000..13511b199 --- /dev/null +++ b/meshchatx/src/frontend/features/forwarder/lib/forwarderHash.js @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: 0BSD + +/** + * @param {unknown} value + * @returns {boolean} + */ +export function isValidForwarderDestinationHash(value) { + const hash = String(value || "").trim(); + return hash.length === 32 && /^[0-9a-fA-F]+$/.test(hash); +} diff --git a/meshchatx/src/frontend/features/registerAllFeatures.js b/meshchatx/src/frontend/features/registerAllFeatures.js index 75ecf07b8..e03484c0e 100644 --- a/meshchatx/src/frontend/features/registerAllFeatures.js +++ b/meshchatx/src/frontend/features/registerAllFeatures.js @@ -1,6 +1,7 @@ // SPDX-License-Identifier: 0BSD import { registerBlockedFeature } from "./blocked/index.js"; +import { registerForwarderFeature } from "./forwarder/index.js"; let featuresRegistered = false; @@ -22,4 +23,5 @@ export function registerAllFeatures() { } featuresRegistered = true; registerBlockedFeature(); + registerForwarderFeature(); } diff --git a/meshchatx/src/frontend/main.js b/meshchatx/src/frontend/main.js index 0eb2d1743..983fbd1bd 100644 --- a/meshchatx/src/frontend/main.js +++ b/meshchatx/src/frontend/main.js @@ -220,11 +220,6 @@ const router = createRouter({ path: "/bots", component: () => import("./components/tools/BotsPage.vue"), }, - { - name: "forwarder", - path: "/forwarder", - component: () => import("./components/forwarder/ForwarderPage.vue"), - }, { name: "micron-editor", path: "/micron-editor", diff --git a/meshchatx/src/frontend/ui/svelte/ToolsPageHeader.svelte b/meshchatx/src/frontend/ui/svelte/ToolsPageHeader.svelte new file mode 100644 index 000000000..8d10ead19 --- /dev/null +++ b/meshchatx/src/frontend/ui/svelte/ToolsPageHeader.svelte @@ -0,0 +1,99 @@ + + + + +
+ + + + + +
+
+ + + +
+
+ {#if eyebrow} +

+ {eyebrow} +

+ {/if} +

+ {title} +

+ {#if description} +

+ {description} +

+ {/if} +
+
+ + {#if children} +
+ {@render children()} +
+ {/if} +
diff --git a/tests/frontend/ForwarderPage.test.js b/tests/frontend/ForwarderPage.test.js index 617788a39..87877ec0f 100644 --- a/tests/frontend/ForwarderPage.test.js +++ b/tests/frontend/ForwarderPage.test.js @@ -1,9 +1,12 @@ -import { mount } from "@vue/test-utils"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import ForwarderPage from "@/components/forwarder/ForwarderPage.vue"; +import { render, cleanup, fireEvent, waitFor } from "@testing-library/svelte"; +import ForwarderPage from "@/features/forwarder/ForwarderPage.svelte"; import WebSocketConnection from "@/js/WebSocketConnection"; import ToastUtils from "@/js/ToastUtils"; import DialogUtils from "@/js/DialogUtils"; +import { dispatchWsEvent } from "@/js/registries/wsEventRegistry.js"; +import { registerFallbackMessages, registerTranslator } from "@/js/i18n.js"; +import { isValidForwarderDestinationHash } from "@/features/forwarder/lib/forwarderHash.js"; vi.mock("@/js/WebSocketConnection", () => ({ default: { @@ -27,56 +30,84 @@ vi.mock("@/js/DialogUtils", () => ({ }, })); -describe("ForwarderPage.vue", () => { +describe("forwarderHash", () => { + it("accepts 32 hex chars", () => { + expect(isValidForwarderDestinationHash("a".repeat(32))).toBe(true); + expect(isValidForwarderDestinationHash("not-a-hash")).toBe(false); + }); +}); + +describe("ForwarderPage.svelte", () => { beforeEach(() => { vi.clearAllMocks(); WebSocketConnection.send.mockReturnValue(true); - }); - - const mountForwarderPage = () => { - return mount(ForwarderPage, { - global: { - mocks: { - $t: (key, params) => key + (params ? JSON.stringify(params) : ""), - }, - stubs: { - MaterialDesignIcon: { - template: '
', - props: ["iconName"], - }, + registerTranslator(null); + registerFallbackMessages({ + app: { tools: "Tools" }, + tools: { + back_to_tools: "Back", + forwarder: { + title: "forwarder.title", + description: "desc", }, }, + forwarder: { + add_rule: "forwarder.add_rule", + name: "Name", + name_placeholder: "Name", + forward_to_hash: "Hash", + destination_placeholder: "dest", + source_filter: "Source", + source_filter_placeholder: "src", + add_button: "Add", + active_rules: "Rules", + no_rules: "None", + active: "forwarder.active", + disabled: "forwarder.disabled", + forwarding_to: "to {hash}", + source_filter_display: "from {hash}", + invalid_hash: "bad", + send_failed: "fail", + rule_added: "added", + delete_confirm: "delete?", + rule_deleted: "deleted", + }, + common: { delete: "Delete" }, }); - }; + }); - it("renders the forwarder page", () => { - const wrapper = mountForwarderPage(); - expect(wrapper.text()).toContain("forwarder.title"); - expect(wrapper.text()).toContain("forwarder.add_rule"); + afterEach(() => { + cleanup(); }); - it("fetches rules on mount", () => { - mountForwarderPage(); - expect(WebSocketConnection.send).toHaveBeenCalledWith( - JSON.stringify({ - type: "lxmf.forwarding.rules.get", - }) - ); + it("renders the forwarder page", async () => { + const { getByText } = render(ForwarderPage); + await waitFor(() => { + expect(getByText("forwarder.title")).toBeTruthy(); + expect(getByText("forwarder.add_rule")).toBeTruthy(); + }); }); - it("adds a new rule", async () => { - const wrapper = mountForwarderPage(); - await wrapper.setData({ - newRule: { - name: "Test Rule", - forward_to_hash: "a".repeat(32), - source_filter_hash: "", - is_active: true, - }, + it("fetches rules on mount", async () => { + render(ForwarderPage); + await waitFor(() => { + expect(WebSocketConnection.send).toHaveBeenCalledWith( + JSON.stringify({ + type: "lxmf.forwarding.rules.get", + }) + ); }); + }); + + it("adds a new rule", async () => { + const { container, getByText } = render(ForwarderPage); + await waitFor(() => expect(getByText("forwarder.add_rule")).toBeTruthy()); - const addButton = wrapper.find("button[class*='bg-blue-600']"); - await addButton.trigger("click"); + const inputs = container.querySelectorAll('input[type="text"]'); + await fireEvent.input(inputs[0], { target: { value: "Test Rule" } }); + await fireEvent.input(inputs[1], { target: { value: "a".repeat(32) } }); + + await fireEvent.click(container.querySelector("button.bg-blue-600")); expect(WebSocketConnection.send).toHaveBeenCalledWith( JSON.stringify({ @@ -92,24 +123,23 @@ describe("ForwarderPage.vue", () => { }); it("handles incoming rules from websocket", async () => { - const wrapper = mountForwarderPage(); - await wrapper.vm.onForwardingRules({ + const { getByText } = render(ForwarderPage); + await waitFor(() => expect(WebSocketConnection.send).toHaveBeenCalled()); + await dispatchWsEvent("lxmf.forwarding.rules", { type: "lxmf.forwarding.rules", rules: [{ id: "rule1", name: "Rule 1", forward_to_hash: "hash1", is_active: true }], }); - - expect(wrapper.vm.rules.length).toBe(1); - expect(wrapper.text()).toContain("Rule 1"); + await waitFor(() => expect(getByText("Rule 1")).toBeTruthy()); }); it("toggles a rule", async () => { - const wrapper = mountForwarderPage(); - await wrapper.setData({ + const { getByTitle } = render(ForwarderPage); + await waitFor(() => expect(WebSocketConnection.send).toHaveBeenCalled()); + await dispatchWsEvent("lxmf.forwarding.rules", { rules: [{ id: "rule1", name: "Rule 1", forward_to_hash: "hash1", is_active: true }], }); - - const toggleButton = wrapper.find("button[title='forwarder.disabled']"); - await toggleButton.trigger("click"); + await waitFor(() => expect(getByTitle("forwarder.disabled")).toBeTruthy()); + await fireEvent.click(getByTitle("forwarder.disabled")); expect(WebSocketConnection.send).toHaveBeenCalledWith( JSON.stringify({ @@ -120,16 +150,12 @@ describe("ForwarderPage.vue", () => { }); it("rejects a non-hex destination hash", async () => { - const wrapper = mountForwarderPage(); - await wrapper.setData({ - newRule: { - name: "bad", - forward_to_hash: "not-a-hash", - source_filter_hash: "", - is_active: true, - }, - }); - await wrapper.find("button[class*='bg-blue-600']").trigger("click"); + const { container, getByText } = render(ForwarderPage); + await waitFor(() => expect(getByText("forwarder.add_rule")).toBeTruthy()); + const inputs = container.querySelectorAll('input[type="text"]'); + await fireEvent.input(inputs[0], { target: { value: "bad" } }); + await fireEvent.input(inputs[1], { target: { value: "not-a-hash" } }); + await fireEvent.click(container.querySelector("button.bg-blue-600")); expect(ToastUtils.warning).toHaveBeenCalled(); const addCalls = WebSocketConnection.send.mock.calls.filter((call) => String(call[0]).includes("lxmf.forwarding.rule.add") @@ -138,12 +164,14 @@ describe("ForwarderPage.vue", () => { }); it("deletes a rule after confirm", async () => { - const wrapper = mountForwarderPage(); - await wrapper.setData({ + const { getByTitle } = render(ForwarderPage); + await waitFor(() => expect(WebSocketConnection.send).toHaveBeenCalled()); + await dispatchWsEvent("lxmf.forwarding.rules", { rules: [{ id: "rule1", name: "Rule 1", forward_to_hash: "a".repeat(32), is_active: true }], }); - await wrapper.vm.deleteRule("rule1"); - expect(DialogUtils.confirm).toHaveBeenCalled(); + await waitFor(() => expect(getByTitle("Delete")).toBeTruthy()); + await fireEvent.click(getByTitle("Delete")); + await waitFor(() => expect(DialogUtils.confirm).toHaveBeenCalled()); expect(WebSocketConnection.send).toHaveBeenCalledWith( JSON.stringify({ type: "lxmf.forwarding.rule.delete", diff --git a/tests/frontend/featureModuleOwnership.test.js b/tests/frontend/featureModuleOwnership.test.js index eb3b4f33c..f3a901553 100644 --- a/tests/frontend/featureModuleOwnership.test.js +++ b/tests/frontend/featureModuleOwnership.test.js @@ -6,6 +6,7 @@ import { join } from "path"; import { clearRoutes, listRoutes } from "../../meshchatx/src/frontend/js/registries/routeRegistry.js"; import { clearFeatureIds, listFeatureIds } from "../../meshchatx/src/frontend/js/registries/featureRegistry.js"; import { registerBlockedFeature } from "../../meshchatx/src/frontend/features/blocked/index.js"; +import { registerForwarderFeature } from "../../meshchatx/src/frontend/features/forwarder/index.js"; import { filterBlockedIdentities } from "../../meshchatx/src/frontend/features/blocked/lib/blockedList.js"; const repoRoot = process.cwd(); @@ -26,6 +27,17 @@ const FEATURE_MODULE_OWNERS = [ route_name: "blocked", mount: "svelte", }, + { + id: "forwarder", + register: registerForwarderFeature, + required_paths: [ + "meshchatx/src/frontend/features/forwarder/index.js", + "meshchatx/src/frontend/features/forwarder/ForwarderPage.svelte", + "meshchatx/src/frontend/features/forwarder/lib/forwarderHash.js", + ], + route_name: "forwarder", + mount: "svelte", + }, ]; describe("feature module conveyor ownership", () => { From 87090d510be59c8d0ae8fb5c7d039714a5cdd396 Mon Sep 17 00:00:00 2001 From: Ivan Date: Sat, 5 Sep 2026 08:24:35 -0500 Subject: [PATCH 005/150] refactor(ui): remove unused components and update routing to Svelte for improved performance --- .agents/conventions/frontend.md | 3 +- CONTRIBUTING.md | 8 +- Taskfile.yml | 12 +- electron/assets/css/electron-shell.css | 140 +-- .../components/contacts/ContactsPage.vue | 1080 ----------------- .../components/debug/DebugLogsPage.vue | 501 -------- .../components/licenses/LicensesPage.vue | 301 ----- .../src/frontend/components/ping/PingPage.vue | 341 ------ .../frontend/components/tools/ToolListRow.vue | 79 -- .../frontend/components/tools/ToolsPage.vue | 183 --- .../features/contacts/ContactsPage.svelte | 374 ++++++ .../contacts/components/ContactListRow.svelte | 122 ++ .../components/ContactsAddDialog.svelte | 143 +++ .../components/ContactsContextMenu.svelte | 94 ++ .../components/ContactsImportDialog.svelte | 74 ++ .../ContactsMyIdentityDialog.svelte | 66 + .../components/ContactsScannerDialog.svelte | 152 +++ .../components/ContactsToolbar.svelte | 53 + .../src/frontend/features/contacts/index.js | 17 + .../features/contacts/lib/contactUri.js | 91 ++ .../features/contacts/lib/contactsActions.js | 187 +++ .../features/contacts/lib/mergeContacts.js | 24 + .../features/debug-logs/DebugLogsPage.svelte | 501 ++++++++ .../src/frontend/features/debug-logs/index.js | 17 + .../features/debug-logs/lib/debugFormat.js | 36 + .../features/licenses/LicensesPage.svelte | 199 +++ .../src/frontend/features/licenses/index.js | 17 + .../features/licenses/lib/licenseFilter.js | 19 + .../frontend/features/ping/PingPage.svelte | 272 +++++ meshchatx/src/frontend/features/ping/index.js | 17 + .../frontend/features/ping/lib/pingFormat.js | 56 + .../frontend/features/registerAllFeatures.js | 10 + .../features/tools/ToolListRow.svelte | 104 ++ .../frontend/features/tools/ToolsPage.svelte | 139 +++ .../features/tools/ToolsSection.svelte | 61 + .../src/frontend/features/tools/index.js | 17 + .../frontend/features/tools/lib/toolsList.js | 144 +++ meshchatx/src/frontend/main.js | 27 +- .../public/assets/css/electron-shell.css | 2 +- .../public/meshchatx-docs/en/nomad-network.md | 2 + .../src/frontend/shell/FeaturePageHost.vue | 5 +- .../src/frontend/ui/svelte/EmptyState.svelte | 49 + .../frontend/ui/svelte/LoadingState.svelte | 20 + .../frontend/ui/svelte/LxmfUserIcon.svelte | 68 ++ .../ui/svelte/MaterialDesignIcon.svelte | 6 +- .../frontend/ui/svelte/ToolsPageHeader.svelte | 1 + package.json | 1 + tests/frontend/ContactsPage.test.js | 160 +-- tests/frontend/DebugLogsPage.test.js | 190 +-- tests/frontend/LicensesPage.test.js | 96 +- tests/frontend/NameOverwriteFixes.test.js | 74 +- tests/frontend/PingPage.test.js | 157 ++- tests/frontend/ToolsPage.test.js | 172 +-- tests/frontend/contextMenuStyles.test.js | 12 +- tests/frontend/featureModuleOwnership.test.js | 63 + .../sidebarVirtualizationWiring.test.js | 9 +- 56 files changed, 3726 insertions(+), 3042 deletions(-) delete mode 100644 meshchatx/src/frontend/components/contacts/ContactsPage.vue delete mode 100644 meshchatx/src/frontend/components/debug/DebugLogsPage.vue delete mode 100644 meshchatx/src/frontend/components/licenses/LicensesPage.vue delete mode 100644 meshchatx/src/frontend/components/ping/PingPage.vue delete mode 100644 meshchatx/src/frontend/components/tools/ToolListRow.vue delete mode 100644 meshchatx/src/frontend/components/tools/ToolsPage.vue create mode 100644 meshchatx/src/frontend/features/contacts/ContactsPage.svelte create mode 100644 meshchatx/src/frontend/features/contacts/components/ContactListRow.svelte create mode 100644 meshchatx/src/frontend/features/contacts/components/ContactsAddDialog.svelte create mode 100644 meshchatx/src/frontend/features/contacts/components/ContactsContextMenu.svelte create mode 100644 meshchatx/src/frontend/features/contacts/components/ContactsImportDialog.svelte create mode 100644 meshchatx/src/frontend/features/contacts/components/ContactsMyIdentityDialog.svelte create mode 100644 meshchatx/src/frontend/features/contacts/components/ContactsScannerDialog.svelte create mode 100644 meshchatx/src/frontend/features/contacts/components/ContactsToolbar.svelte create mode 100644 meshchatx/src/frontend/features/contacts/index.js create mode 100644 meshchatx/src/frontend/features/contacts/lib/contactUri.js create mode 100644 meshchatx/src/frontend/features/contacts/lib/contactsActions.js create mode 100644 meshchatx/src/frontend/features/contacts/lib/mergeContacts.js create mode 100644 meshchatx/src/frontend/features/debug-logs/DebugLogsPage.svelte create mode 100644 meshchatx/src/frontend/features/debug-logs/index.js create mode 100644 meshchatx/src/frontend/features/debug-logs/lib/debugFormat.js create mode 100644 meshchatx/src/frontend/features/licenses/LicensesPage.svelte create mode 100644 meshchatx/src/frontend/features/licenses/index.js create mode 100644 meshchatx/src/frontend/features/licenses/lib/licenseFilter.js create mode 100644 meshchatx/src/frontend/features/ping/PingPage.svelte create mode 100644 meshchatx/src/frontend/features/ping/index.js create mode 100644 meshchatx/src/frontend/features/ping/lib/pingFormat.js create mode 100644 meshchatx/src/frontend/features/tools/ToolListRow.svelte create mode 100644 meshchatx/src/frontend/features/tools/ToolsPage.svelte create mode 100644 meshchatx/src/frontend/features/tools/ToolsSection.svelte create mode 100644 meshchatx/src/frontend/features/tools/index.js create mode 100644 meshchatx/src/frontend/features/tools/lib/toolsList.js create mode 100644 meshchatx/src/frontend/ui/svelte/EmptyState.svelte create mode 100644 meshchatx/src/frontend/ui/svelte/LoadingState.svelte create mode 100644 meshchatx/src/frontend/ui/svelte/LxmfUserIcon.svelte diff --git a/.agents/conventions/frontend.md b/.agents/conventions/frontend.md index cc5e04940..119c3fe47 100644 --- a/.agents/conventions/frontend.md +++ b/.agents/conventions/frontend.md @@ -37,7 +37,8 @@ Cross-feature UI imports are forbidden. Share through kernel events, registries, ## Svelte -- `pnpm run svelte-check` in lint. Runes mode only. +- `pnpm run svelte-check` and `pnpm run format:check:svelte` in `task lint:frontend`. Runes mode only. +- Prefer small `features//components/` and `lib/` pieces. Do not grow Svelte god pages. - Dual mount: route meta `mount: "vue" | "svelte"` with a lazy `load`. - Skill: `.agents/skills/svelte-feature-modules/SKILL.md`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a170f7461..5187991e2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -56,10 +56,10 @@ SKIP=meshchatx-rsm git commit ... ### Code style -| Surface | Tool | Task | -| -------- | ------------------------------- | ---------------------------------------- | -| Python | Ruff | task format:backend, task lint:backend | -| Frontend | Prettier, ESLint, vue-tsc, knip | task format:frontend, task lint:frontend | +| Surface | Tool | Task | +| -------- | ------------------------------------------------------------ | ----------------------------------------------------------------------------------------------- | +| Python | Ruff | task format:backend, task lint:backend | +| Frontend | Prettier (incl. Svelte), ESLint, vue-tsc, svelte-check, knip | task format:frontend, task lint:frontend (`task check:svelte` / `pnpm run format:check:svelte`) | Editor baseline: .editorconfig. Agent conventions: .agents/conventions/. diff --git a/Taskfile.yml b/Taskfile.yml index 68bf8cdb6..d4185e394 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -226,7 +226,7 @@ tasks: format:frontend: aliases: [fmt:fe, format:fe] - desc: Format frontend code (Prettier/ESLint) + desc: Format frontend code (Prettier incl. Svelte, ESLint --fix) cmds: - "{{.NPM}} run format" - "{{.NPM}} run lint:fix" @@ -239,15 +239,16 @@ tasks: - uv run ruff check --fix . lint: - desc: Run all linters (ESLint, vue-tsc, knip, Ruff, basedpyright) + desc: Run all linters (ESLint, Prettier check, vue-tsc, svelte-check, knip, Ruff, basedpyright) aliases: [lint:all] deps: [lint:frontend, lint:backend] lint:frontend: aliases: [lint:fe] - desc: Lint frontend code (ESLint, vue-tsc, svelte-check, knip, format-token and git-dep pin checks) + desc: Lint frontend (ESLint, Prettier check, vue-tsc, svelte-check, knip, format-token and git-dep pins) cmds: - "{{.NPM}} run lint" + - "{{.NPM}} run format:check:svelte" - "{{.NPM}} run typecheck" - "{{.NPM}} run svelte-check" - "{{.NPM}} exec vue-tsc --noEmit -p tsconfig.libs.json" @@ -255,6 +256,11 @@ tasks: - task: check:format-tokens - task: deps:git-pins + check:svelte: + desc: Run svelte-check (Svelte 5 runes) + cmds: + - "{{.NPM}} run svelte-check" + lint:backend: aliases: [lint:be] desc: Lint Python code (Ruff + basedpyright) diff --git a/electron/assets/css/electron-shell.css b/electron/assets/css/electron-shell.css index e7126a51d..9375a3c27 100644 --- a/electron/assets/css/electron-shell.css +++ b/electron/assets/css/electron-shell.css @@ -2033,6 +2033,9 @@ .min-h-10 { min-height: calc(var(--spacing) * 10); } + .min-h-11 { + min-height: calc(var(--spacing) * 11); + } .min-h-12 { min-height: calc(var(--spacing) * 12); } @@ -2084,9 +2087,6 @@ .min-h-\[28px\] { min-height: 28px; } - .min-h-\[36px\] { - min-height: 36px; - } .min-h-\[40px\] { min-height: 40px; } @@ -2159,6 +2159,9 @@ .w-2\.5 { width: calc(var(--spacing) * 2.5); } + .w-2\/3 { + width: 66.6667%; + } .w-3 { width: calc(var(--spacing) * 3); } @@ -2276,6 +2279,9 @@ .w-full { width: 100%; } + .w-full\! { + width: 100% !important; + } .w-max { width: max-content; } @@ -2318,6 +2324,9 @@ .max-w-\[7\.5rem\] { max-width: 7.5rem; } + .max-w-\[8\.5rem\] { + max-width: 8.5rem; + } .max-w-\[8rem\] { max-width: 8rem; } @@ -2330,6 +2339,9 @@ .max-w-\[16rem\] { max-width: 16rem; } + .max-w-\[18rem\] { + max-width: 18rem; + } .max-w-\[20rem\] { max-width: 20rem; } @@ -2402,6 +2414,9 @@ .max-w-\[min\(100\%\,18rem\)\] { max-width: min(100%, 18rem); } + .max-w-\[min\(100\%\,42rem\)\] { + max-width: min(100%, 42rem); + } .max-w-\[min\(100\%\,calc\(100\%-2rem\)\)\] { max-width: min(100%, 100% - 2rem); } @@ -2456,6 +2471,9 @@ .min-w-9 { min-width: calc(var(--spacing) * 9); } + .min-w-11 { + min-width: calc(var(--spacing) * 11); + } .min-w-16 { min-width: calc(var(--spacing) * 16); } @@ -2480,6 +2498,9 @@ .min-w-\[1\.125rem\] { min-width: 1.125rem; } + .min-w-\[5\.5rem\] { + min-width: 5.5rem; + } .min-w-\[6\.5rem\] { min-width: 6.5rem; } @@ -2489,6 +2510,9 @@ .min-w-\[10rem\] { min-width: 10rem; } + .min-w-\[11rem\] { + min-width: 11rem; + } .min-w-\[14px\] { min-width: 14px; } @@ -2498,9 +2522,6 @@ .min-w-\[28px\] { min-width: 28px; } - .min-w-\[36px\] { - min-width: 36px; - } .min-w-\[40px\] { min-width: 40px; } @@ -3481,6 +3502,9 @@ .rounded-2xl { border-radius: var(--radius-2xl); } + .rounded-2xl\! { + border-radius: var(--radius-2xl) !important; + } .rounded-3xl { border-radius: var(--radius-3xl); } @@ -3912,9 +3936,6 @@ .border-blue-600 { border-color: var(--color-blue-600); } - .border-blue-700 { - border-color: var(--color-blue-700); - } .border-current, .border-current\/20 { border-color: currentColor; @@ -4003,14 +4024,6 @@ border-color: color-mix(in oklab, var(--color-gray-100) 50%, transparent); } } - .border-gray-100\/60 { - border-color: #f3f4f699; - } - @supports (color: color-mix(in lab, red, red)) { - .border-gray-100\/60 { - border-color: color-mix(in oklab, var(--color-gray-100) 60%, transparent); - } - } .border-gray-100\/80 { border-color: #f3f4f6cc; } @@ -4515,14 +4528,6 @@ .bg-black { background-color: var(--color-black); } - .bg-black\/2 { - background-color: #00000005; - } - @supports (color: color-mix(in lab, red, red)) { - .bg-black\/2 { - background-color: color-mix(in oklab, var(--color-black) 2%, transparent); - } - } .bg-black\/5 { background-color: #0000000d; } @@ -5441,17 +5446,6 @@ .bg-linear-to-b { background-image: linear-gradient(var(--tw-gradient-stops)); } - .bg-linear-to-br { - --tw-gradient-position: to bottom right; - } - @supports (background-image: linear-gradient(in lab, red, red)) { - .bg-linear-to-br { - --tw-gradient-position: to bottom right in oklab; - } - } - .bg-linear-to-br { - background-image: linear-gradient(var(--tw-gradient-stops)); - } .bg-linear-to-r { --tw-gradient-position: to right; } @@ -5503,15 +5497,6 @@ var(--tw-gradient-to) var(--tw-gradient-to-position) ); } - .from-slate-50 { - --tw-gradient-from: var(--color-slate-50); - --tw-gradient-stops: var( - --tw-gradient-via-stops, - var(--tw-gradient-position), - var(--tw-gradient-from) var(--tw-gradient-from-position), - var(--tw-gradient-to) var(--tw-gradient-to-position) - ); - } .from-slate-100 { --tw-gradient-from: var(--color-slate-100); --tw-gradient-stops: var( @@ -5537,13 +5522,6 @@ var(--tw-gradient-via) var(--tw-gradient-via-position), var(--tw-gradient-to) var(--tw-gradient-to-position); --tw-gradient-stops: var(--tw-gradient-via-stops); } - .via-slate-100 { - --tw-gradient-via: var(--color-slate-100); - --tw-gradient-via-stops: - var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), - var(--tw-gradient-via) var(--tw-gradient-via-position), var(--tw-gradient-to) var(--tw-gradient-to-position); - --tw-gradient-stops: var(--tw-gradient-via-stops); - } .to-emerald-500 { --tw-gradient-to: var(--color-emerald-500); --tw-gradient-stops: var( @@ -5598,15 +5576,6 @@ var(--tw-gradient-to) var(--tw-gradient-to-position) ); } - .to-white { - --tw-gradient-to: var(--color-white); - --tw-gradient-stops: var( - --tw-gradient-via-stops, - var(--tw-gradient-position), - var(--tw-gradient-from) var(--tw-gradient-from-position), - var(--tw-gradient-to) var(--tw-gradient-to-position) - ); - } .box-decoration-clone { -webkit-box-decoration-break: clone; box-decoration-break: clone; @@ -5958,6 +5927,9 @@ .pr-4 { padding-right: calc(var(--spacing) * 4); } + .pr-5 { + padding-right: calc(var(--spacing) * 5); + } .pr-7 { padding-right: calc(var(--spacing) * 7); } @@ -8165,6 +8137,9 @@ .group-open\:rotate-90:is(:where(.group):is([open], :popover-open, :open) *) { rotate: 90deg; } + .group-open\:rotate-180:is(:where(.group):is([open], :popover-open, :open) *) { + rotate: 180deg; + } .group-focus-within\:opacity-100:is(:where(.group):focus-within *) { opacity: 1; } @@ -9037,14 +9012,6 @@ --tw-ring-color: color-mix(in oklab, var(--color-blue-500) 50%, transparent); } } - .focus\:ring-blue-500\/60:focus { - --tw-ring-color: #3080ff99; - } - @supports (color: color-mix(in lab, red, red)) { - .focus\:ring-blue-500\/60:focus { - --tw-ring-color: color-mix(in oklab, var(--color-blue-500) 60%, transparent); - } - } .focus\:ring-blue-600:focus { --tw-ring-color: var(--color-blue-600); } @@ -9140,9 +9107,6 @@ .focus-visible\:outline-offset-2:focus-visible { outline-offset: 2px; } - .focus-visible\:outline-blue-500:focus-visible { - outline-color: var(--color-blue-500); - } .focus-visible\:outline-gray-500:focus-visible { outline-color: var(--color-gray-500); } @@ -9168,9 +9132,6 @@ .active\:cursor-grabbing:active { cursor: grabbing; } - .active\:bg-gray-100:active { - background-color: var(--color-gray-100); - } .disabled\:pointer-events-none:disabled { pointer-events: none; } @@ -9649,9 +9610,6 @@ .sm\:justify-start { justify-content: flex-start; } - .sm\:gap-0 { - gap: 0; - } .sm\:gap-0\.5 { gap: calc(var(--spacing) * 0.5); } @@ -11539,14 +11497,6 @@ .dark\:bg-white:where(.dark, .dark *) { background-color: var(--color-white); } - .dark\:bg-white\/2:where(.dark, .dark *) { - background-color: #ffffff05; - } - @supports (color: color-mix(in lab, red, red)) { - .dark\:bg-white\/2:where(.dark, .dark *) { - background-color: color-mix(in oklab, var(--color-white) 2%, transparent); - } - } .dark\:bg-white\/5:where(.dark, .dark *) { background-color: #ffffff0d; } @@ -11590,9 +11540,6 @@ .dark\:bg-zinc-100:where(.dark, .dark *) { background-color: var(--color-zinc-100); } - .dark\:bg-zinc-500:where(.dark, .dark *) { - background-color: var(--color-zinc-500); - } .dark\:bg-zinc-600\/70:where(.dark, .dark *) { background-color: #52525cb3; } @@ -11840,13 +11787,6 @@ var(--tw-gradient-via) var(--tw-gradient-via-position), var(--tw-gradient-to) var(--tw-gradient-to-position); --tw-gradient-stops: var(--tw-gradient-via-stops); } - .dark\:via-zinc-900:where(.dark, .dark *) { - --tw-gradient-via: var(--color-zinc-900); - --tw-gradient-via-stops: - var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), - var(--tw-gradient-via) var(--tw-gradient-via-position), var(--tw-gradient-to) var(--tw-gradient-to-position); - --tw-gradient-stops: var(--tw-gradient-via-stops); - } .dark\:to-zinc-900:where(.dark, .dark *) { --tw-gradient-to: var(--color-zinc-900); --tw-gradient-stops: var( @@ -12728,14 +12668,6 @@ background-color: color-mix(in oklab, var(--color-zinc-900) 70%, transparent); } } - .dark\:hover\:bg-zinc-900\/80:where(.dark, .dark *):hover { - background-color: #18181bcc; - } - @supports (color: color-mix(in lab, red, red)) { - .dark\:hover\:bg-zinc-900\/80:where(.dark, .dark *):hover { - background-color: color-mix(in oklab, var(--color-zinc-900) 80%, transparent); - } - } .dark\:hover\:text-amber-200:where(.dark, .dark *):hover { color: var(--color-amber-200); } diff --git a/meshchatx/src/frontend/components/contacts/ContactsPage.vue b/meshchatx/src/frontend/components/contacts/ContactsPage.vue deleted file mode 100644 index 63b89dcc9..000000000 --- a/meshchatx/src/frontend/components/contacts/ContactsPage.vue +++ /dev/null @@ -1,1080 +0,0 @@ - - - - - - - diff --git a/meshchatx/src/frontend/components/debug/DebugLogsPage.vue b/meshchatx/src/frontend/components/debug/DebugLogsPage.vue deleted file mode 100644 index 0995d75d6..000000000 --- a/meshchatx/src/frontend/components/debug/DebugLogsPage.vue +++ /dev/null @@ -1,501 +0,0 @@ - - - - - - - diff --git a/meshchatx/src/frontend/components/licenses/LicensesPage.vue b/meshchatx/src/frontend/components/licenses/LicensesPage.vue deleted file mode 100644 index 2a838e9fb..000000000 --- a/meshchatx/src/frontend/components/licenses/LicensesPage.vue +++ /dev/null @@ -1,301 +0,0 @@ - - - - - - - diff --git a/meshchatx/src/frontend/components/ping/PingPage.vue b/meshchatx/src/frontend/components/ping/PingPage.vue deleted file mode 100644 index e29f70e76..000000000 --- a/meshchatx/src/frontend/components/ping/PingPage.vue +++ /dev/null @@ -1,341 +0,0 @@ - - - - - diff --git a/meshchatx/src/frontend/components/tools/ToolListRow.vue b/meshchatx/src/frontend/components/tools/ToolListRow.vue deleted file mode 100644 index a67967118..000000000 --- a/meshchatx/src/frontend/components/tools/ToolListRow.vue +++ /dev/null @@ -1,79 +0,0 @@ - - - - - - - diff --git a/meshchatx/src/frontend/components/tools/ToolsPage.vue b/meshchatx/src/frontend/components/tools/ToolsPage.vue deleted file mode 100644 index 9fc32ee0d..000000000 --- a/meshchatx/src/frontend/components/tools/ToolsPage.vue +++ /dev/null @@ -1,183 +0,0 @@ - - - - - - - diff --git a/meshchatx/src/frontend/features/contacts/ContactsPage.svelte b/meshchatx/src/frontend/features/contacts/ContactsPage.svelte new file mode 100644 index 000000000..67875089d --- /dev/null +++ b/meshchatx/src/frontend/features/contacts/ContactsPage.svelte @@ -0,0 +1,374 @@ + + + + +
+
+
+ (isMyIdentityDialogOpen = true)} + onExport={exportContactsFile} + onImport={() => { + importError = null; + isImportDialogOpen = true; + }} + onAdd={openAddDialog} + /> +
+ +
+
+
+ + +
+
+ +
+ {#if isLoading && contacts.length === 0} + + {:else if !isLoading && contacts.length === 0} + + {:else} +
+ {#each mergedContacts as contact (String(contact.id))} + copyToClipboard(hash, t("common.copied"))} + /> + {/each} + {#if hasMoreContacts && !isLoadingMore} +
+ +
+ {/if} + {#if isLoadingMore} +
+
+
+ {/if} +
+ {/if} +
+
+
+ + + + contextMenu.contact && openConversation(contextMenu.contact)} + onCall={() => contextMenu.contact && callContact(contextMenu.contact)} + onEdit={() => contextMenu.contact && editContactNameWithDuplicates(contextMenu.contact, contacts, getContacts)} + onShare={async () => { + if (!contextMenu.contact) return; + const c = contextMenu.contact; + closeContextMenu(); + const lxmaUri = await fetchContactLxmaUri(c); + const dest = c?.lxmf_address || c?.remote_identity_hash; + const uri = lxmaUri || (dest ? `lxmf://${dest}` : null); + if (!uri) ToastUtils.error(t("contacts.failed_build_contact_uri")); + else await shareUri(uri); + }} + onCopyUri={async () => { + if (!contextMenu.contact) return; + const c = contextMenu.contact; + closeContextMenu(); + const lxmaUri = await fetchContactLxmaUri(c); + if (lxmaUri) { + await copyToClipboard(lxmaUri, t("contacts.contact_uri_copied")); + return; + } + const dest = c?.lxmf_address || c?.remote_identity_hash; + if (dest) await copyToClipboard(`lxmf://${dest}`, t("contacts.contact_uri_copied")); + else ToastUtils.error(t("contacts.failed_build_contact_uri")); + }} + onRemove={() => contextMenu.contact && removeContactWithDuplicates(contextMenu.contact, contacts, getContacts)} + /> + + { + isAddDialogOpen = false; + pendingLxmaImport = false; + }} + onSubmit={submitAddContact} + onPaste={async () => { + try { + newContactInput = await navigator.clipboard.readText(); + } catch { + ToastUtils.error(t("messages.failed_read_clipboard")); + } + }} + onScan={() => (isScannerDialogOpen = true)} + /> + + (isScannerDialogOpen = false)} + onScanned={(value) => (newContactInput = value)} + /> + + { + isImportDialogOpen = false; + importError = null; + }} + onFileSelected={onImportFileSelected} + /> + + (isMyIdentityDialogOpen = false)} + onCopy={() => myIdentityUri && copyToClipboard(myIdentityUri, t("contacts.identity_uri_copied"))} + onShare={() => myIdentityUri && shareUri(myIdentityUri)} + /> +
diff --git a/meshchatx/src/frontend/features/contacts/components/ContactListRow.svelte b/meshchatx/src/frontend/features/contacts/components/ContactListRow.svelte new file mode 100644 index 000000000..28c64d5d9 --- /dev/null +++ b/meshchatx/src/frontend/features/contacts/components/ContactListRow.svelte @@ -0,0 +1,122 @@ + + + + +
+
+ +
+
+
{String(contact.name || "")}
+
+ {#if contact.remote_destination_hash} +
+ + +
+ {/if} + {#if contact.remote_telephony_hash} +
+ + {String(contact.remote_telephony_hash)} +
+ {/if} + {#if !contact.remote_destination_hash && !contact.remote_telephony_hash} + {String(contact.lxmf_address || contact.remote_identity_hash || "")} + {/if} +
+
+
+ + +
+ +
diff --git a/meshchatx/src/frontend/features/contacts/components/ContactsAddDialog.svelte b/meshchatx/src/frontend/features/contacts/components/ContactsAddDialog.svelte new file mode 100644 index 000000000..4665a5dc3 --- /dev/null +++ b/meshchatx/src/frontend/features/contacts/components/ContactsAddDialog.svelte @@ -0,0 +1,143 @@ + + + + +{#if open} +
{ + if (e.target === e.currentTarget) onClose?.(); + }} + onkeydown={(e) => { + if (e.key === "Escape") onClose?.(); + }} + role="presentation" + > + +
+{/if} diff --git a/meshchatx/src/frontend/features/contacts/components/ContactsContextMenu.svelte b/meshchatx/src/frontend/features/contacts/components/ContactsContextMenu.svelte new file mode 100644 index 000000000..37c463927 --- /dev/null +++ b/meshchatx/src/frontend/features/contacts/components/ContactsContextMenu.svelte @@ -0,0 +1,94 @@ + + + + +{#if visible} + +{/if} diff --git a/meshchatx/src/frontend/features/contacts/components/ContactsImportDialog.svelte b/meshchatx/src/frontend/features/contacts/components/ContactsImportDialog.svelte new file mode 100644 index 000000000..8d684022f --- /dev/null +++ b/meshchatx/src/frontend/features/contacts/components/ContactsImportDialog.svelte @@ -0,0 +1,74 @@ + + + + +{#if open} +
{ + if (e.target === e.currentTarget) onClose?.(); + }} + onkeydown={(e) => { + if (e.key === "Escape") onClose?.(); + }} + role="presentation" + > + +
+{/if} diff --git a/meshchatx/src/frontend/features/contacts/components/ContactsMyIdentityDialog.svelte b/meshchatx/src/frontend/features/contacts/components/ContactsMyIdentityDialog.svelte new file mode 100644 index 000000000..d60d7067e --- /dev/null +++ b/meshchatx/src/frontend/features/contacts/components/ContactsMyIdentityDialog.svelte @@ -0,0 +1,66 @@ + + + + +{#if open} +
{ + if (e.target === e.currentTarget) onClose?.(); + }} + onkeydown={(e) => { + if (e.key === "Escape") onClose?.(); + }} + role="presentation" + > + +
+{/if} diff --git a/meshchatx/src/frontend/features/contacts/components/ContactsScannerDialog.svelte b/meshchatx/src/frontend/features/contacts/components/ContactsScannerDialog.svelte new file mode 100644 index 000000000..e5fd63bc0 --- /dev/null +++ b/meshchatx/src/frontend/features/contacts/components/ContactsScannerDialog.svelte @@ -0,0 +1,152 @@ + + + + +{#if open} +
{ + if (e.target === e.currentTarget) onClose?.(); + }} + onkeydown={(e) => { + if (e.key === "Escape") onClose?.(); + }} + role="presentation" + > + +
+{/if} diff --git a/meshchatx/src/frontend/features/contacts/components/ContactsToolbar.svelte b/meshchatx/src/frontend/features/contacts/components/ContactsToolbar.svelte new file mode 100644 index 000000000..eb72ada82 --- /dev/null +++ b/meshchatx/src/frontend/features/contacts/components/ContactsToolbar.svelte @@ -0,0 +1,53 @@ + + + + +
+
+

{t("contacts.title")}

+

{t("contacts.description")}

+
+
+ + + + +
+
diff --git a/meshchatx/src/frontend/features/contacts/index.js b/meshchatx/src/frontend/features/contacts/index.js new file mode 100644 index 000000000..e67650759 --- /dev/null +++ b/meshchatx/src/frontend/features/contacts/index.js @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: 0BSD + +import { registerFeature } from "../../js/registries/featureRegistry.js"; + +export function registerContactsFeature() { + registerFeature({ + id: "contacts", + routes: [ + { + name: "contacts", + path: "/contacts", + mount: "svelte", + load: () => import("./ContactsPage.svelte"), + }, + ], + }); +} diff --git a/meshchatx/src/frontend/features/contacts/lib/contactUri.js b/meshchatx/src/frontend/features/contacts/lib/contactUri.js new file mode 100644 index 000000000..4fa9a8c4d --- /dev/null +++ b/meshchatx/src/frontend/features/contacts/lib/contactUri.js @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: 0BSD + +/** + * @param {string} input + * @returns {{ destinationHash: string, publicKeyHex: string, normalizedUri: string } | null} + */ +export function parseLxmaUri(input) { + const normalized = input.trim(); + const match = normalized.match(/^lxma:\/\/([0-9a-f]{32}):([0-9a-f]{64}|[0-9a-f]{128})$/i); + if (!match) { + return null; + } + return { + destinationHash: match[1].toLowerCase(), + publicKeyHex: match[2].toLowerCase(), + normalizedUri: `lxma://${match[1].toLowerCase()}:${match[2].toLowerCase()}`, + }; +} + +/** + * @param {string} input + * @returns {string | null} + */ +export function extractDestinationHash(input) { + const raw = input.trim().toLowerCase(); + if (/^[0-9a-f]{32}$/.test(raw)) { + return raw; + } + const lxmfMatch = raw.match(/^lxmf:\/\/([0-9a-f]{32})$/); + if (lxmfMatch) { + return lxmfMatch[1]; + } + const lxmMatch = raw.match(/^lxm:\/\/([0-9a-f]{32})$/); + if (lxmMatch) { + return lxmMatch[1]; + } + return null; +} + +/** + * @param {{ lxmf_address_hash?: string, identity_public_key?: string } | null | undefined} config + * @returns {string | null} + */ +export function buildMyIdentityUri(config) { + if (!config?.lxmf_address_hash) { + return null; + } + if (config?.identity_public_key) { + return `lxma://${config.lxmf_address_hash}:${config.identity_public_key}`; + } + return `lxmf://${config.lxmf_address_hash}`; +} + +/** + * Decode announce identity_public_key (base64) to hex for lxma URIs. + * @param {string | null | undefined} publicKeyBase64 + * @returns {string | null} + */ +export function publicKeyFromAnnounce(publicKeyBase64) { + if (!publicKeyBase64) { + return null; + } + try { + const binary = atob(publicKeyBase64); + const publicKeyHex = Array.from(binary) + .map((c) => c.charCodeAt(0).toString(16).padStart(2, "0")) + .join(""); + if (publicKeyHex.length !== 64 && publicKeyHex.length !== 128) { + return null; + } + return publicKeyHex; + } catch { + return null; + } +} + +/** + * @param {{ lxmf_address?: string, remote_identity_hash?: string }} contact + * @param {string | null} publicKeyHex + * @returns {string | null} + */ +export function buildContactUri(contact, publicKeyHex) { + const destinationHash = (contact?.lxmf_address || contact?.remote_identity_hash || "").toLowerCase(); + if (!/^[0-9a-f]{32}$/.test(destinationHash)) { + return null; + } + if (publicKeyHex) { + return `lxma://${destinationHash}:${publicKeyHex}`; + } + return `lxmf://${destinationHash}`; +} diff --git a/meshchatx/src/frontend/features/contacts/lib/contactsActions.js b/meshchatx/src/frontend/features/contacts/lib/contactsActions.js new file mode 100644 index 000000000..24586047d --- /dev/null +++ b/meshchatx/src/frontend/features/contacts/lib/contactsActions.js @@ -0,0 +1,187 @@ +// SPDX-License-Identifier: 0BSD + +import ToastUtils from "../../../js/ToastUtils.js"; +import DownloadUtils from "../../../js/DownloadUtils.js"; +import DialogUtils from "../../../js/DialogUtils.js"; +import WebSocketConnection from "../../../js/WebSocketConnection.js"; +import { t } from "../../../js/i18n.js"; +import { buildContactUri, extractDestinationHash, parseLxmaUri, publicKeyFromAnnounce } from "./contactUri.js"; + +/** + * @param {string} value + * @param {string} [successMessage] + */ +export async function copyToClipboard(value, successMessage) { + try { + await navigator.clipboard.writeText(value); + ToastUtils.success(successMessage || t("common.copied")); + } catch { + ToastUtils.error(t("common.failed_to_copy")); + } +} + +/** + * @param {string} uri + */ +export async function shareUri(uri) { + try { + if (navigator.share) { + await navigator.share({ title: t("contacts.share"), text: uri }); + return; + } + } catch { + // fall through + } + await copyToClipboard(uri, t("contacts.contact_uri_copied")); +} + +/** + * @param {Record} contact + * @returns {Promise} + */ +export async function fetchContactLxmaUri(contact) { + const destinationHash = String(contact?.lxmf_address || contact?.remote_identity_hash || "").toLowerCase(); + if (!/^[0-9a-f]{32}$/.test(destinationHash)) return null; + try { + const response = await window.api.get("/api/v1/announces", { + params: { destination_hash: destinationHash, limit: 1 }, + }); + const publicKeyHex = publicKeyFromAnnounce(response.data?.announces?.[0]?.identity_public_key); + return buildContactUri(contact, publicKeyHex); + } catch { + return null; + } +} + +/** + * @param {string} input + * @param {string} name + * @param {{ + * setPendingLxma: (v: boolean) => void, + * onAdded: () => Promise, + * }} hooks + */ +export async function addContactFromInput(input, name, hooks) { + const lxmaData = parseLxmaUri(input); + if (lxmaData) { + hooks.setPendingLxma(true); + WebSocketConnection.send(JSON.stringify({ type: "lxm.ingest_uri", uri: lxmaData.normalizedUri })); + ToastUtils.info(t("contacts.importing_lxma")); + return { pending: true }; + } + const destinationHash = extractDestinationHash(input); + if (!destinationHash) { + ToastUtils.error(t("contacts.invalid_contact_input")); + return { pending: false }; + } + const existing = await window.api.get(`/api/v1/telephone/contacts/check/${destinationHash}`); + if (existing.data?.id) { + ToastUtils.info(t("contacts.contact_already_exists")); + return { pending: false }; + } + await window.api.post("/api/v1/telephone/contacts", { + name: name?.trim() || `Contact ${destinationHash.slice(0, 8)}`, + lxmf_address: destinationHash, + }); + ToastUtils.success(t("contacts.contact_added")); + await hooks.onAdded(); + return { pending: false, added: true }; +} + +/** + * @param {Record} contact + * @param {Array>} contacts + * @param {() => Promise} onDone + */ +export async function removeContactWithDuplicates(contact, contacts, onDone) { + if (!contact?.id) return; + const duplicates = contacts.filter((c) => c.name === contact.name && c.id !== contact.id); + const confirmMsg = + duplicates.length > 0 + ? t("contacts.remove_duplicates_confirm", { count: duplicates.length, name: contact.name }) + : t("contacts.remove_contact_confirm"); + if (!(await DialogUtils.confirm(confirmMsg))) return; + try { + for (const id of [contact.id, ...duplicates.map((c) => c.id)]) { + await window.api.delete(`/api/v1/telephone/contacts/${id}`); + } + ToastUtils.success(t("contacts.contact_removed")); + await onDone(); + } catch { + ToastUtils.error(t("contacts.failed_remove_contact")); + } +} + +/** + * @param {Record} contact + * @param {Array>} contacts + * @param {() => Promise} onDone + */ +export async function editContactNameWithDuplicates(contact, contacts, onDone) { + if (!contact?.id) return; + const name = await DialogUtils.prompt(t("contacts.enter_contact_name"), String(contact.name || "")); + if (name == null || name === contact.name) return; + try { + const duplicates = contacts.filter((c) => c.name === contact.name && c.id !== contact.id); + for (const c of [contact, ...duplicates]) { + await window.api.patch(`/api/v1/telephone/contacts/${c.id}`, { name }); + const destHash = c.remote_destination_hash || c.lxmf_address || c.remote_identity_hash; + if (destHash && name.length > 0) { + await window.api.post(`/api/v1/destination/${destHash}/custom-display-name/update`, { + display_name: name, + }); + } + } + ToastUtils.success(t("contacts.contact_updated")); + await onDone(); + } catch { + ToastUtils.error(t("contacts.failed_update_contact")); + } +} + +export async function exportContactsFile() { + try { + const response = await window.api.get("/api/v1/telephone/contacts/export"); + const blob = new Blob([JSON.stringify({ contacts: response.data?.contacts ?? [] }, null, 2)], { + type: "application/json", + }); + await DownloadUtils.downloadFile("contacts_export.json", blob); + ToastUtils.success(t("contacts.export_success")); + } catch (e) { + ToastUtils.error(e?.response?.data?.message || t("contacts.export_failed")); + } +} + +/** + * @param {unknown[]} list + * @param {() => Promise} onDone + * @returns {Promise} error message or null + */ +export async function importContactsList(list, onDone) { + try { + const response = await window.api.post("/api/v1/telephone/contacts/import", { contacts: list }); + ToastUtils.success(t("contacts.import_success", { added: response.data?.added ?? 0 })); + await onDone(); + return null; + } catch (err) { + return err?.response?.data?.message || t("contacts.import_failed"); + } +} + +/** + * @param {Record} contact + */ +export function messagesHashHref(contact) { + const hash = String(contact.remote_destination_hash || contact.lxmf_address || contact.remote_identity_hash || ""); + return hash ? `#/messages/${hash}` : ""; +} + +/** + * @param {Record} contact + */ +export function callHashHref(contact) { + const hash = String( + contact.remote_telephony_hash || contact.remote_destination_hash || contact.remote_identity_hash || "" + ); + return hash ? `#/call?destination_hash=${encodeURIComponent(hash)}&tab=phone` : ""; +} diff --git a/meshchatx/src/frontend/features/contacts/lib/mergeContacts.js b/meshchatx/src/frontend/features/contacts/lib/mergeContacts.js new file mode 100644 index 000000000..72e04ae6a --- /dev/null +++ b/meshchatx/src/frontend/features/contacts/lib/mergeContacts.js @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: 0BSD + +/** + * Merge contacts that share the same display name so LXMF and LXST hashes show on one row. + * @param {Array>} contacts + * @returns {Array>} + */ +export function mergeContactsByName(contacts) { + const map = new Map(); + for (const c of contacts) { + const key = String(c.name || "").toLowerCase(); + if (!map.has(key)) { + map.set(key, { ...c }); + } else { + const existing = map.get(key); + existing.lxmf_address = existing.lxmf_address || c.lxmf_address; + existing.lxst_address = existing.lxst_address || c.lxst_address; + existing.remote_destination_hash = existing.remote_destination_hash || c.remote_destination_hash; + existing.remote_telephony_hash = existing.remote_telephony_hash || c.remote_telephony_hash; + existing.remote_identity_hash = existing.remote_identity_hash || c.remote_identity_hash; + } + } + return Array.from(map.values()); +} diff --git a/meshchatx/src/frontend/features/debug-logs/DebugLogsPage.svelte b/meshchatx/src/frontend/features/debug-logs/DebugLogsPage.svelte new file mode 100644 index 000000000..67322407a --- /dev/null +++ b/meshchatx/src/frontend/features/debug-logs/DebugLogsPage.svelte @@ -0,0 +1,501 @@ + + + + +
+ + {#snippet children()} + + + {/snippet} + + +
+ + +
+ +
+ {#if activeTab === "logs"} +
+
+ +
+
+ +
+ +
+
+
+ + +
+
+ +
+
+ {:else} +
+
+ +
+
+ +
+ +
+
+
+ + +
+
+ {/if} + +
+ {#if (activeTab === "logs" && loading) || (activeTab === "access" && accessLoading)} +
+ +
+ {:else if activeTab === "logs" && logs.length === 0} +
+ +

{t("debug.no_logs_found")}

+
+ {:else if activeTab === "access" && accessAttempts.length === 0} +
+ +

{t("debug.no_access_attempts")}

+
+ {:else if activeTab === "logs"} +
+ {#each logs as log, index (`${log.timestamp}-${index}-${log.message}`)} +
copyLogLine(log)} + onkeydown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + copyLogLine(log); + } + }} + > +
+ {formatDebugTime(log.timestamp)} +
+
+ {log.level} +
+
+ {log.module} +
+
+ {#if log.is_anomaly} + + {log.anomaly_type || "ANOMALY"} + + {/if} + {log.message} +
+
+ {/each} +
+ {:else} +
+ {#each accessAttempts as row (`${row.id}-${row.created_at}`)} +
copyAccessLine(row)} + onkeydown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + copyAccessLine(row); + } + }} + > +
+ + {formatDebugTime(row.created_at)} + + + {row.outcome} + + {row.method} {row.path} + {row.client_ip} +
+
+ UA + {row.user_agent || "-"} +
+ {#if row.detail} +
{row.detail}
+ {/if} +
+ {/each} +
+ {/if} + +
+
+ + +
+ +
+
+
+
+ + diff --git a/meshchatx/src/frontend/features/debug-logs/index.js b/meshchatx/src/frontend/features/debug-logs/index.js new file mode 100644 index 000000000..4ede2b39e --- /dev/null +++ b/meshchatx/src/frontend/features/debug-logs/index.js @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: 0BSD + +import { registerFeature } from "../../js/registries/featureRegistry.js"; + +export function registerDebugLogsFeature() { + registerFeature({ + id: "debug-logs", + routes: [ + { + name: "debug-logs", + path: "/debug/logs", + mount: "svelte", + load: () => import("./DebugLogsPage.svelte"), + }, + ], + }); +} diff --git a/meshchatx/src/frontend/features/debug-logs/lib/debugFormat.js b/meshchatx/src/frontend/features/debug-logs/lib/debugFormat.js new file mode 100644 index 000000000..25c51a8e6 --- /dev/null +++ b/meshchatx/src/frontend/features/debug-logs/lib/debugFormat.js @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: 0BSD + +/** + * @param {unknown} timestamp + * @returns {string} + */ +export function formatDebugTime(timestamp) { + try { + const ts = typeof timestamp === "number" ? timestamp * 1000 : timestamp; + return new Date(ts).toLocaleString(); + } catch { + return String(timestamp); + } +} + +/** + * @param {string} level + * @returns {string} + */ +export function debugLevelClass(level) { + const l = String(level || "").toUpperCase(); + if (l === "ERROR" || l === "CRITICAL") return "text-red-500"; + if (l === "WARNING") return "text-orange-500"; + if (l === "INFO") return "text-blue-500"; + if (l === "DEBUG") return "text-gray-500"; + return "text-gray-400"; +} + +/** + * @param {object} log + * @param {(ts: unknown) => string} formatTime + * @returns {string} + */ +export function formatLogLine(log, formatTime = formatDebugTime) { + return `${formatTime(log.timestamp)} [${log.level}] [${log.module}] ${log.message}${log.is_anomaly ? " [ANOMALY:" + (log.anomaly_type || "unknown") + "]" : ""}`; +} diff --git a/meshchatx/src/frontend/features/licenses/LicensesPage.svelte b/meshchatx/src/frontend/features/licenses/LicensesPage.svelte new file mode 100644 index 000000000..433d0aa40 --- /dev/null +++ b/meshchatx/src/frontend/features/licenses/LicensesPage.svelte @@ -0,0 +1,199 @@ + + + + +
+
+
+
+
+
+ {t("licenses.section_label")} +
+
+ {t("licenses.title")} +
+
+ {t("licenses.description")} +
+ {#if meta?.generated_at} +

+ {t("licenses.generated_at", { time: meta.generated_at })} + {#if meta.frontend_source} + + ({t("licenses.frontend_source", { source: meta.frontend_source })}) + + {/if} +

+ {/if} +
+ +
+
+
+ +
+ + {#if searchQuery} + + {/if} +
+
+
+
+ +
+ {#if loadError} +
+ {loadError} +
+ {/if} + + {#if loading} +
+ + {t("common.loading")} +
+ {:else} +
+ {#each [{ key: "backend", rows: filteredBackend, titleKey: "licenses.backend_section" }, { key: "frontend", rows: filteredFrontend, titleKey: "licenses.frontend_section" }] as section (section.key)} +
+ + {t(section.titleKey)} ({section.rows.length}) + + + + +
+ + + + + + + + + + + {#each section.rows as row (`${section.key}-${row.name}-${row.version}`)} + + + + + + + {/each} + +
{t("licenses.col_package")}{t("licenses.col_version")}{t("licenses.col_author")}{t("licenses.col_license")}
{row.name}{row.version}{row.author}{row.license}
+ {#if section.rows.length === 0} +

{t("common.no_results")}

+ {/if} +
+
+ {/each} +
+ {/if} +
+
+
+ + diff --git a/meshchatx/src/frontend/features/licenses/index.js b/meshchatx/src/frontend/features/licenses/index.js new file mode 100644 index 000000000..f918c2be3 --- /dev/null +++ b/meshchatx/src/frontend/features/licenses/index.js @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: 0BSD + +import { registerFeature } from "../../js/registries/featureRegistry.js"; + +export function registerLicensesFeature() { + registerFeature({ + id: "licenses", + routes: [ + { + name: "licenses", + path: "/licenses", + mount: "svelte", + load: () => import("./LicensesPage.svelte"), + }, + ], + }); +} diff --git a/meshchatx/src/frontend/features/licenses/lib/licenseFilter.js b/meshchatx/src/frontend/features/licenses/lib/licenseFilter.js new file mode 100644 index 000000000..8e86fe1cf --- /dev/null +++ b/meshchatx/src/frontend/features/licenses/lib/licenseFilter.js @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: 0BSD + +/** + * @param {Array<{ name?: string, version?: string, author?: string, license?: string }>} rows + * @param {string} query + * @returns {typeof rows} + */ +export function filterLicenseRows(rows, query) { + const q = String(query || "") + .trim() + .toLowerCase(); + if (!q) { + return rows; + } + return rows.filter((r) => { + const blob = `${r.name} ${r.version} ${r.author} ${r.license}`.toLowerCase(); + return blob.includes(q); + }); +} diff --git a/meshchatx/src/frontend/features/ping/PingPage.svelte b/meshchatx/src/frontend/features/ping/PingPage.svelte new file mode 100644 index 000000000..253ef2c2c --- /dev/null +++ b/meshchatx/src/frontend/features/ping/PingPage.svelte @@ -0,0 +1,272 @@ + + + + +
+ +
+
+
+
+ {@html t("ping.description", { + code: "lxmf.delivery", + })} +
+ +
+
+ + +
+
+ + +
+
+ +
+ {#if !isRunning} + + {:else} + + {/if} + + +
+ +
+ + {t("ping.status")}: {isRunning ? t("ping.running") : t("ping.idle")} + + {#if lastPingSummary?.duration} + + {t("ping.last_rtt")}: {lastPingSummary.duration} + + {/if} + {#if lastPingSummary?.error} + + {t("ping.last_error")}: {lastPingSummary.error} + + {/if} +
+
+ +
+
+
+
{t("ping.console_output")}
+
{t("ping.streaming_responses")}
+
+
seq #{seq}
+
+ + {#if lastPingSummary && !lastPingSummary.error} +
+ {#if lastPingSummary.hopsThere != null} + {t("rnprobe.hops")} there: {lastPingSummary.hopsThere} + {/if} + {#if lastPingSummary.hopsBack != null} + {t("rnprobe.hops")} back: {lastPingSummary.hopsBack} + {/if} + {#if lastPingSummary.rssi != null} + {t("rnprobe.rssi")} {lastPingSummary.rssi} dBm + {/if} + {#if lastPingSummary.snr != null} + {t("rnprobe.snr")} {lastPingSummary.snr} dB + {/if} + {#if lastPingSummary.quality != null} + {t("rnprobe.quality")} {lastPingSummary.quality}% + {/if} + {#if lastPingSummary.via} + {t("app.interfaces")} {lastPingSummary.via} + {/if} +
+ {/if} + +
+ {#if pingResults.length === 0} +
{t("ping.no_pings_yet")}
+ {:else} + {#each pingResults as pingResult, index (`${index}-${pingResult}`)} +
{pingResult}
+ {/each} + {/if} +
+
+
+
+
diff --git a/meshchatx/src/frontend/features/ping/index.js b/meshchatx/src/frontend/features/ping/index.js new file mode 100644 index 000000000..acf0caaa4 --- /dev/null +++ b/meshchatx/src/frontend/features/ping/index.js @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: 0BSD + +import { registerFeature } from "../../js/registries/featureRegistry.js"; + +export function registerPingFeature() { + registerFeature({ + id: "ping", + routes: [ + { + name: "ping", + path: "/ping", + mount: "svelte", + load: () => import("./PingPage.svelte"), + }, + ], + }); +} diff --git a/meshchatx/src/frontend/features/ping/lib/pingFormat.js b/meshchatx/src/frontend/features/ping/lib/pingFormat.js new file mode 100644 index 000000000..b7e6ef2a5 --- /dev/null +++ b/meshchatx/src/frontend/features/ping/lib/pingFormat.js @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: 0BSD + +/** + * @param {string | null | undefined} hash + * @returns {boolean} + */ +export function isValidPingDestinationHash(hash) { + return hash != null && String(hash).length === 32; +} + +/** + * @param {unknown} timeout + * @returns {boolean} + */ +export function isValidPingTimeout(timeout) { + const n = Number(timeout); + return Number.isFinite(n) && n >= 1 && n <= 600; +} + +/** + * @param {object} pingResult + * @param {number} seq + * @returns {{ line: string, summary: object }} + */ +export function formatPingSuccess(pingResult, seq) { + const rttMilliseconds = (pingResult.rtt * 1000).toFixed(3); + const rttDurationString = `${rttMilliseconds}ms`; + const info = [ + `seq=${seq}`, + `duration=${rttDurationString}`, + `hops_there=${pingResult.hops_there}`, + `hops_back=${pingResult.hops_back}`, + ]; + if (pingResult.rssi != null) { + info.push(`rssi=${pingResult.rssi}dBm`); + } + if (pingResult.snr != null) { + info.push(`snr=${pingResult.snr}dB`); + } + if (pingResult.quality != null) { + info.push(`quality=${pingResult.quality}%`); + } + info.push(`via=${pingResult.receiving_interface}`); + return { + line: info.join(" "), + summary: { + duration: rttDurationString, + hopsThere: pingResult.hops_there, + hopsBack: pingResult.hops_back, + rssi: pingResult.rssi, + snr: pingResult.snr, + quality: pingResult.quality, + via: pingResult.receiving_interface, + }, + }; +} diff --git a/meshchatx/src/frontend/features/registerAllFeatures.js b/meshchatx/src/frontend/features/registerAllFeatures.js index e03484c0e..b05161e07 100644 --- a/meshchatx/src/frontend/features/registerAllFeatures.js +++ b/meshchatx/src/frontend/features/registerAllFeatures.js @@ -1,7 +1,12 @@ // SPDX-License-Identifier: 0BSD import { registerBlockedFeature } from "./blocked/index.js"; +import { registerContactsFeature } from "./contacts/index.js"; +import { registerDebugLogsFeature } from "./debug-logs/index.js"; import { registerForwarderFeature } from "./forwarder/index.js"; +import { registerLicensesFeature } from "./licenses/index.js"; +import { registerPingFeature } from "./ping/index.js"; +import { registerToolsFeature } from "./tools/index.js"; let featuresRegistered = false; @@ -23,5 +28,10 @@ export function registerAllFeatures() { } featuresRegistered = true; registerBlockedFeature(); + registerContactsFeature(); + registerDebugLogsFeature(); registerForwarderFeature(); + registerLicensesFeature(); + registerPingFeature(); + registerToolsFeature(); } diff --git a/meshchatx/src/frontend/features/tools/ToolListRow.svelte b/meshchatx/src/frontend/features/tools/ToolListRow.svelte new file mode 100644 index 000000000..49fc37bbb --- /dev/null +++ b/meshchatx/src/frontend/features/tools/ToolListRow.svelte @@ -0,0 +1,104 @@ + + + + +
+ {#if tool.icon} + + {:else if tool.image} + {String(tool.imageAlt + {/if} +
+
+
+
{String(tool.title || "")}
+ {#if tool.alpha} + + {t("tools.alpha_badge")} + + {/if} + {#if tool.beta} + + {t("tools.beta_badge")} + + {/if} + {#if tool.comingSoon} + + {t("tools.coming_soon_badge")} + + {/if} +
+
{String(tool.description || "")}
+
+{#if !tool.comingSoon} +
+ {#if tool.extraAction && typeof tool.extraAction === "object"} + {@const action = tool.extraAction} + + {:else} + + {/if} +
+{/if} + + diff --git a/meshchatx/src/frontend/features/tools/ToolsPage.svelte b/meshchatx/src/frontend/features/tools/ToolsPage.svelte new file mode 100644 index 000000000..577031dc4 --- /dev/null +++ b/meshchatx/src/frontend/features/tools/ToolsPage.svelte @@ -0,0 +1,139 @@ + + + + +
+
+
+
+
+
+ {t("tools.power_tools")} +
+
+ {t("tools.diagnostics_description")} +
+
+ +
+
+
+ +
+ + {#if searchQuery} + + {/if} +
+
+
+
+ +
+ {#if groupedToolSections} + {#each groupedToolSections as section (section.id)} + + {/each} + {:else if filteredTools.length > 0} +
+
+ {#each filteredTools as tool (String(tool.name))} + {#if tool.comingSoon} +
+ +
+ {:else} + + + + {/if} + {/each} +
+
+ {/if} + + {#if filteredTools.length === 0} + + {/if} +
+
+
+ + diff --git a/meshchatx/src/frontend/features/tools/ToolsSection.svelte b/meshchatx/src/frontend/features/tools/ToolsSection.svelte new file mode 100644 index 000000000..1e6f27b16 --- /dev/null +++ b/meshchatx/src/frontend/features/tools/ToolsSection.svelte @@ -0,0 +1,61 @@ + + + + +
+ + {#if !collapsed} +
+ {#each tools as tool (String(tool.name))} + {#if tool.comingSoon} +
+ +
+ {:else} + + + + {/if} + {/each} +
+ {/if} +
diff --git a/meshchatx/src/frontend/features/tools/index.js b/meshchatx/src/frontend/features/tools/index.js new file mode 100644 index 000000000..6265128d0 --- /dev/null +++ b/meshchatx/src/frontend/features/tools/index.js @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: 0BSD AND MIT + +import { registerFeature } from "../../js/registries/featureRegistry.js"; + +export function registerToolsFeature() { + registerFeature({ + id: "tools", + routes: [ + { + name: "tools", + path: "/tools", + mount: "svelte", + load: () => import("./ToolsPage.svelte"), + }, + ], + }); +} diff --git a/meshchatx/src/frontend/features/tools/lib/toolsList.js b/meshchatx/src/frontend/features/tools/lib/toolsList.js new file mode 100644 index 000000000..3cf1ccfde --- /dev/null +++ b/meshchatx/src/frontend/features/tools/lib/toolsList.js @@ -0,0 +1,144 @@ +// SPDX-License-Identifier: 0BSD AND MIT + +import { listRoutes } from "../../../js/registries/routeRegistry.js"; +import { listTools } from "../../../js/registries/toolsRegistry.js"; + +export const TOOL_GROUP_ORDER = ["diagnostics", "transfer", "messaging", "network", "other"]; + +/** Route names whose vue-router path is under /tools/ */ +const TOOLS_PREFIXED_ROUTE_NAMES = new Set([ + "paper-message", + "sieve-filters", + "message-blocklist", + "rnode-flasher", + "repository-server", + "reticulum-config-editor", +]); + +/** + * Build a hash href from a tool.route value ({ name }, { path }, or string). + * @param {{ name?: string, path?: string } | string | null | undefined} route + * @returns {string} + */ +export function toolRouteHref(route) { + if (!route) { + return ""; + } + if (typeof route === "string") { + const path = route.startsWith("/") ? route : `/${route}`; + return `#${path}`; + } + if (route.path) { + const path = route.path.startsWith("/") ? route.path : `/${route.path}`; + return `#${path}`; + } + const name = route.name; + if (!name) { + return ""; + } + try { + const registered = listRoutes().find((entry) => entry.name === name); + if (registered?.path) { + const path = registered.path.startsWith("/") ? registered.path : `/${registered.path}`; + return `#${path}`; + } + } catch { + // routeRegistry may be empty in isolated tests + } + if (TOOLS_PREFIXED_ROUTE_NAMES.has(name)) { + return `#/tools/${name}`; + } + return `#/${name}`; +} + +/** + * @param {(key: string, params?: Record) => string} t + * @returns {Array>} + */ +export function translateTools(t) { + return listTools().map((tool) => ({ + ...tool, + title: tool.title || (tool.titleKey ? t(tool.titleKey) : ""), + description: tool.description || (tool.descriptionKey ? t(tool.descriptionKey) : ""), + })); +} + +/** + * @param {Array>} tools + * @param {string} searchQuery + * @returns {Array>} + */ +export function filterTools(tools, searchQuery) { + if (!searchQuery.trim()) { + return tools; + } + const query = searchQuery.toLowerCase().trim(); + return tools.filter((tool) => { + const title = String(tool.title || "").toLowerCase(); + const description = String(tool.description || "").toLowerCase(); + const name = String(tool.name || "").toLowerCase(); + return title.includes(query) || description.includes(query) || name.includes(query); + }); +} + +/** + * @param {Array>} tools + * @returns {Array<{ id: string, tools: Array> }> | null} + */ +export function groupTools(tools) { + /** @type {Record>>} */ + const groups = {}; + for (const tool of tools) { + const groupId = String(tool.group || "other"); + if (!groups[groupId]) { + groups[groupId] = []; + } + groups[groupId].push(tool); + } + return TOOL_GROUP_ORDER.filter((groupId) => groups[groupId]?.length).map((groupId) => ({ + id: groupId, + tools: groups[groupId], + })); +} + +/** + * @param {Record} tool + * @returns {string} + */ +export function toolRowClass(tool) { + return ["tool-row", tool.customClass, tool.comingSoon ? "opacity-60 grayscale-[0.5] cursor-default" : ""] + .filter(Boolean) + .join(" "); +} + +const COLLAPSED_STORAGE_KEY = "meshchatx.tools.collapsedSections"; + +/** + * @returns {Record} + */ +export function loadCollapsedSections() { + try { + const raw = localStorage.getItem(COLLAPSED_STORAGE_KEY); + if (!raw) { + return {}; + } + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + return /** @type {Record} */ (parsed); + } + } catch { + // ignore corrupt storage + } + return {}; +} + +/** + * @param {Record} map + */ +export function saveCollapsedSections(map) { + try { + localStorage.setItem(COLLAPSED_STORAGE_KEY, JSON.stringify(map)); + } catch { + // ignore quota / private mode + } +} diff --git a/meshchatx/src/frontend/main.js b/meshchatx/src/frontend/main.js index 983fbd1bd..c3dc67ab7 100644 --- a/meshchatx/src/frontend/main.js +++ b/meshchatx/src/frontend/main.js @@ -100,11 +100,6 @@ const router = createRouter({ meta: { stableKey: true }, component: () => import("./components/messages/MessagesPage.vue"), }, - { - name: "contacts", - path: "/contacts", - component: () => import("./components/contacts/ContactsPage.vue"), - }, { name: "map", path: "/map", @@ -165,11 +160,6 @@ const router = createRouter({ path: "/propagation-nodes", component: () => import("./components/propagation-nodes/PropagationNodesPage.vue"), }, - { - name: "ping", - path: "/ping", - component: () => import("./components/ping/PingPage.vue"), - }, { name: "rncp", path: "/rncp", @@ -255,16 +245,6 @@ const router = createRouter({ path: "/identities", component: () => import("./components/settings/IdentitiesPage.vue"), }, - { - name: "tools", - path: "/tools", - component: () => import("./components/tools/ToolsPage.vue"), - }, - { - name: "licenses", - path: "/licenses", - component: () => import("./components/licenses/LicensesPage.vue"), - }, { name: "paper-message", path: "/tools/paper-message", @@ -290,11 +270,6 @@ const router = createRouter({ path: "/tools/repository-server", component: () => import("./components/tools/RepositoryServerPage.vue"), }, - { - name: "debug-logs", - path: "/debug/logs", - component: () => import("./components/debug/DebugLogsPage.vue"), - }, { name: "call", path: "/call", @@ -482,7 +457,7 @@ if (networkReady) { function preloadCriticalRouteChunks() { void import("./components/messages/MessagesPage.vue"); - void import("./components/contacts/ContactsPage.vue"); + void import("./features/contacts/ContactsPage.svelte"); void import("./components/interfaces/InterfacesPage.vue"); } diff --git a/meshchatx/src/frontend/public/assets/css/electron-shell.css b/meshchatx/src/frontend/public/assets/css/electron-shell.css index a1762da8b..fa78903c4 100644 --- a/meshchatx/src/frontend/public/assets/css/electron-shell.css +++ b/meshchatx/src/frontend/public/assets/css/electron-shell.css @@ -1,2 +1,2 @@ /*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */ -@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1;--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-pan-x:initial;--tw-pan-y:initial;--tw-pinch-zoom:initial;--tw-scroll-snap-strictness:proximity;--tw-space-y-reverse:0;--tw-space-x-reverse:0;--tw-divide-x-reverse:0;--tw-border-style:solid;--tw-divide-y-reverse:0;--tw-gradient-position:initial;--tw-gradient-from:#0000;--tw-gradient-via:#0000;--tw-gradient-to:#0000;--tw-gradient-stops:initial;--tw-gradient-via-stops:initial;--tw-gradient-from-position:0%;--tw-gradient-via-position:50%;--tw-gradient-to-position:100%;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial;--tw-ease:initial;--tw-contain-size:initial;--tw-contain-layout:initial;--tw-contain-paint:initial;--tw-contain-style:initial;--tw-content:""}}}@layer theme{:root,:host{--font-sans:-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-red-50:oklch(97.1% .013 17.38);--color-red-100:oklch(93.6% .032 17.717);--color-red-200:oklch(88.5% .062 18.334);--color-red-300:oklch(80.8% .114 19.571);--color-red-400:oklch(70.4% .191 22.216);--color-red-500:oklch(63.7% .237 25.331);--color-red-600:oklch(57.7% .245 27.325);--color-red-700:oklch(50.5% .213 27.518);--color-red-800:oklch(44.4% .177 26.899);--color-red-900:oklch(39.6% .141 25.723);--color-red-950:oklch(25.8% .092 26.042);--color-orange-50:oklch(98% .016 73.684);--color-orange-100:oklch(95.4% .038 75.164);--color-orange-200:oklch(90.1% .076 70.697);--color-orange-300:oklch(83.7% .128 66.29);--color-orange-400:oklch(75% .183 55.934);--color-orange-500:oklch(70.5% .213 47.604);--color-orange-600:oklch(64.6% .222 41.116);--color-orange-700:oklch(55.3% .195 38.402);--color-orange-800:oklch(47% .157 37.304);--color-orange-900:oklch(40.8% .123 38.172);--color-amber-50:oklch(98.7% .022 95.277);--color-amber-100:oklch(96.2% .059 95.617);--color-amber-200:oklch(92.4% .12 95.746);--color-amber-300:oklch(87.9% .169 91.605);--color-amber-400:oklch(82.8% .189 84.429);--color-amber-500:oklch(76.9% .188 70.08);--color-amber-600:oklch(66.6% .179 58.318);--color-amber-700:oklch(55.5% .163 48.998);--color-amber-800:oklch(47.3% .137 46.201);--color-amber-900:oklch(41.4% .112 45.904);--color-amber-950:oklch(27.9% .077 45.635);--color-yellow-50:oklch(98.7% .026 102.212);--color-yellow-100:oklch(97.3% .071 103.193);--color-yellow-200:oklch(94.5% .129 101.54);--color-yellow-300:oklch(90.5% .182 98.111);--color-yellow-400:oklch(85.2% .199 91.936);--color-yellow-500:oklch(79.5% .184 86.047);--color-yellow-600:oklch(68.1% .162 75.834);--color-yellow-700:oklch(55.4% .135 66.442);--color-yellow-800:oklch(47.6% .114 61.907);--color-yellow-900:oklch(42.1% .095 57.708);--color-green-50:oklch(98.2% .018 155.826);--color-green-100:oklch(96.2% .044 156.743);--color-green-200:oklch(92.5% .084 155.995);--color-green-300:oklch(87.1% .15 154.449);--color-green-400:oklch(79.2% .209 151.711);--color-green-500:oklch(72.3% .219 149.579);--color-green-600:oklch(62.7% .194 149.214);--color-green-700:oklch(52.7% .154 150.069);--color-green-800:oklch(44.8% .119 151.328);--color-green-900:oklch(39.3% .095 152.535);--color-emerald-50:oklch(97.9% .021 166.113);--color-emerald-100:oklch(95% .052 163.051);--color-emerald-200:oklch(90.5% .093 164.15);--color-emerald-300:oklch(84.5% .143 164.978);--color-emerald-400:oklch(76.5% .177 163.223);--color-emerald-500:oklch(69.6% .17 162.48);--color-emerald-600:oklch(59.6% .145 163.225);--color-emerald-700:oklch(50.8% .118 165.612);--color-emerald-800:oklch(43.2% .095 166.913);--color-emerald-900:oklch(37.8% .077 168.94);--color-emerald-950:oklch(26.2% .051 172.552);--color-teal-50:oklch(98.4% .014 180.72);--color-teal-100:oklch(95.3% .051 180.801);--color-teal-200:oklch(91% .096 180.426);--color-teal-300:oklch(85.5% .138 181.071);--color-teal-400:oklch(77.7% .152 181.912);--color-teal-500:oklch(70.4% .14 182.503);--color-teal-600:oklch(60% .118 184.704);--color-teal-700:oklch(51.1% .096 186.391);--color-teal-800:oklch(43.7% .078 188.216);--color-teal-900:oklch(38.6% .063 188.416);--color-cyan-50:oklch(98.4% .019 200.873);--color-cyan-100:oklch(95.6% .045 203.388);--color-cyan-200:oklch(91.7% .08 205.041);--color-cyan-300:oklch(86.5% .127 207.078);--color-cyan-400:oklch(78.9% .154 211.53);--color-cyan-500:oklch(71.5% .143 215.221);--color-cyan-600:oklch(60.9% .126 221.723);--color-cyan-700:oklch(52% .105 223.128);--color-cyan-800:oklch(45% .085 224.283);--color-cyan-900:oklch(39.8% .07 227.392);--color-cyan-950:oklch(30.2% .056 229.695);--color-sky-50:oklch(97.7% .013 236.62);--color-sky-100:oklch(95.1% .026 236.824);--color-sky-200:oklch(90.1% .058 230.902);--color-sky-300:oklch(82.8% .111 230.318);--color-sky-400:oklch(74.6% .16 232.661);--color-sky-500:oklch(68.5% .169 237.323);--color-sky-600:oklch(58.8% .158 241.966);--color-sky-700:oklch(50% .134 242.749);--color-sky-800:oklch(44.3% .11 240.79);--color-sky-900:oklch(39.1% .09 240.876);--color-sky-950:oklch(29.3% .066 243.157);--color-blue-50:oklch(97% .014 254.604);--color-blue-100:oklch(93.2% .032 255.585);--color-blue-200:oklch(88.2% .059 254.128);--color-blue-300:oklch(80.9% .105 251.813);--color-blue-400:oklch(70.7% .165 254.624);--color-blue-500:oklch(62.3% .214 259.815);--color-blue-600:oklch(54.6% .245 262.881);--color-blue-700:oklch(48.8% .243 264.376);--color-blue-800:oklch(42.4% .199 265.638);--color-blue-900:oklch(37.9% .146 265.522);--color-blue-950:oklch(28.2% .091 267.935);--color-indigo-50:oklch(96.2% .018 272.314);--color-indigo-100:oklch(93% .034 272.788);--color-indigo-200:oklch(87% .065 274.039);--color-indigo-300:oklch(78.5% .115 274.713);--color-indigo-400:oklch(67.3% .182 276.935);--color-indigo-500:oklch(58.5% .233 277.117);--color-indigo-600:oklch(51.1% .262 276.966);--color-indigo-700:oklch(45.7% .24 277.023);--color-indigo-800:oklch(39.8% .195 277.366);--color-indigo-900:oklch(35.9% .144 278.697);--color-indigo-950:oklch(25.7% .09 281.288);--color-violet-50:oklch(96.9% .016 293.756);--color-violet-100:oklch(94.3% .029 294.588);--color-violet-200:oklch(89.4% .057 293.283);--color-violet-300:oklch(81.1% .111 293.571);--color-violet-400:oklch(70.2% .183 293.541);--color-violet-600:oklch(54.1% .281 293.009);--color-violet-700:oklch(49.1% .27 292.581);--color-violet-800:oklch(43.2% .232 292.759);--color-violet-900:oklch(38% .189 293.745);--color-violet-950:oklch(28.3% .141 291.089);--color-purple-50:oklch(97.7% .014 308.299);--color-purple-100:oklch(94.6% .033 307.174);--color-purple-200:oklch(90.2% .063 306.703);--color-purple-300:oklch(82.7% .119 306.383);--color-purple-400:oklch(71.4% .203 305.504);--color-purple-500:oklch(62.7% .265 303.9);--color-purple-600:oklch(55.8% .288 302.321);--color-purple-800:oklch(43.8% .218 303.724);--color-purple-900:oklch(38.1% .176 304.987);--color-pink-50:oklch(97.1% .014 343.198);--color-pink-100:oklch(94.8% .028 342.258);--color-pink-200:oklch(89.9% .061 343.231);--color-pink-300:oklch(82.3% .12 346.018);--color-pink-400:oklch(71.8% .202 349.761);--color-pink-500:oklch(65.6% .241 354.308);--color-pink-600:oklch(59.2% .249 .584);--color-pink-700:oklch(52.5% .223 3.958);--color-pink-800:oklch(45.9% .187 3.815);--color-pink-900:oklch(40.8% .153 2.432);--color-rose-50:oklch(96.9% .015 12.422);--color-rose-100:oklch(94.1% .03 12.58);--color-rose-200:oklch(89.2% .058 10.001);--color-rose-400:oklch(71.2% .194 13.428);--color-rose-500:oklch(64.5% .246 16.439);--color-rose-600:oklch(58.6% .253 17.585);--color-rose-700:oklch(51.4% .222 16.935);--color-rose-900:oklch(41% .159 10.272);--color-slate-50:oklch(98.4% .003 247.858);--color-slate-100:oklch(96.8% .007 247.896);--color-slate-200:oklch(92.9% .013 255.508);--color-slate-300:oklch(86.9% .022 252.894);--color-slate-400:oklch(70.4% .04 256.788);--color-slate-500:oklch(55.4% .046 257.417);--color-slate-600:oklch(44.6% .043 257.281);--color-slate-700:oklch(37.2% .044 257.287);--color-slate-800:oklch(27.9% .041 260.031);--color-slate-900:oklch(20.8% .042 265.755);--color-gray-50:oklch(98.5% .002 247.839);--color-gray-100:oklch(96.7% .003 264.542);--color-gray-200:oklch(92.8% .006 264.531);--color-gray-300:oklch(87.2% .01 258.338);--color-gray-400:oklch(70.7% .022 261.325);--color-gray-500:oklch(55.1% .027 264.364);--color-gray-600:oklch(44.6% .03 256.802);--color-gray-700:oklch(37.3% .034 259.733);--color-gray-800:oklch(27.8% .033 256.848);--color-gray-900:oklch(21% .034 264.665);--color-zinc-50:oklch(98.5% 0 none);--color-zinc-100:oklch(96.7% .001 286.375);--color-zinc-200:oklch(92% .004 286.32);--color-zinc-300:oklch(87.1% .006 286.286);--color-zinc-400:oklch(70.5% .015 286.067);--color-zinc-500:oklch(55.2% .016 285.938);--color-zinc-600:oklch(44.2% .017 285.786);--color-zinc-700:oklch(37% .013 285.805);--color-zinc-800:oklch(27.4% .006 286.033);--color-zinc-900:oklch(21% .006 285.885);--color-zinc-950:oklch(14.1% .005 285.823);--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-xs:20rem;--container-sm:24rem;--container-md:28rem;--container-lg:32rem;--container-xl:36rem;--container-2xl:42rem;--container-3xl:48rem;--container-4xl:56rem;--container-5xl:64rem;--container-6xl:72rem;--container-7xl:80rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5 / 2.25);--text-5xl:3rem;--text-5xl--line-height:1;--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--font-weight-black:900;--tracking-tighter:-.05em;--tracking-tight:-.025em;--tracking-normal:0em;--tracking-wide:.025em;--tracking-wider:.05em;--tracking-widest:.1em;--leading-tight:1.25;--leading-snug:1.375;--leading-normal:1.5;--leading-relaxed:1.625;--radius-xs:.125rem;--radius-sm:.25rem;--radius-md:.375rem;--radius-lg:.5rem;--radius-xl:.75rem;--radius-2xl:1rem;--radius-3xl:1.5rem;--radius-4xl:2rem;--ease-in:cubic-bezier(.4, 0, 1, 1);--ease-out:cubic-bezier(0, 0, .2, 1);--ease-in-out:cubic-bezier(.4, 0, .2, 1);--animate-spin:spin 1s linear infinite;--animate-ping:ping 1s cubic-bezier(0, 0, .2, 1) infinite;--animate-pulse:pulse 2s cubic-bezier(.4, 0, .6, 1) infinite;--animate-bounce:bounce 1s infinite;--blur-xs:4px;--blur-sm:8px;--blur-md:12px;--aspect-video:16 / 9;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}input:where([type=text]),input:where(:not([type])),input:where([type=email]),input:where([type=url]),input:where([type=password]),input:where([type=number]),input:where([type=date]),input:where([type=datetime-local]),input:where([type=month]),input:where([type=search]),input:where([type=tel]),input:where([type=time]),input:where([type=week]),select:where([multiple]),textarea,select{appearance:none;--tw-shadow:0 0 #0000;background-color:#fff;border-width:1px;border-color:oklch(55.1% .027 264.364);border-radius:0;padding:.5rem .75rem;font-size:1rem;line-height:1.5rem}:is(input:where([type=text]),input:where(:not([type])),input:where([type=email]),input:where([type=url]),input:where([type=password]),input:where([type=number]),input:where([type=date]),input:where([type=datetime-local]),input:where([type=month]),input:where([type=search]),input:where([type=tel]),input:where([type=time]),input:where([type=week]),select:where([multiple]),textarea,select):focus{outline-offset:2px;--tw-ring-inset:var(--tw-empty, );--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:oklch(54.6% .245 262.881);--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);border-color:oklch(54.6% .245 262.881);outline:2px solid #0000}input::placeholder,textarea::placeholder{color:oklch(55.1% .027 264.364);opacity:1}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-date-and-time-value{min-height:1.5em;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit{padding-top:0;padding-bottom:0}::-webkit-datetime-edit-year-field{padding-top:0;padding-bottom:0}::-webkit-datetime-edit-month-field{padding-top:0;padding-bottom:0}::-webkit-datetime-edit-day-field{padding-top:0;padding-bottom:0}::-webkit-datetime-edit-hour-field{padding-top:0;padding-bottom:0}::-webkit-datetime-edit-minute-field{padding-top:0;padding-bottom:0}::-webkit-datetime-edit-second-field{padding-top:0;padding-bottom:0}::-webkit-datetime-edit-millisecond-field{padding-top:0;padding-bottom:0}::-webkit-datetime-edit-meridiem-field{padding-top:0;padding-bottom:0}select{-webkit-print-color-adjust:exact;print-color-adjust:exact;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='oklch(55.1%25 0.027 264.364)' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e");background-position:right .5rem center;background-repeat:no-repeat;background-size:1.5em 1.5em;padding-right:2.5rem}select:where([multiple]),select:where([size]:not([size="1"])){background-image:initial;background-position:initial;background-repeat:unset;background-size:initial;print-color-adjust:unset;padding-right:.75rem}input:where([type=checkbox]),input:where([type=radio]){appearance:none;-webkit-print-color-adjust:exact;print-color-adjust:exact;vertical-align:middle;-webkit-user-select:none;user-select:none;color:oklch(54.6% .245 262.881);--tw-shadow:0 0 #0000;background-color:#fff;background-origin:border-box;border-width:1px;border-color:oklch(55.1% .027 264.364);flex-shrink:0;width:1rem;height:1rem;padding:0;display:inline-block}input:where([type=checkbox]){border-radius:0}input:where([type=radio]){border-radius:100%}input:where([type=checkbox]):focus,input:where([type=radio]):focus{outline-offset:2px;--tw-ring-inset:var(--tw-empty, );--tw-ring-offset-width:2px;--tw-ring-offset-color:#fff;--tw-ring-color:oklch(54.6% .245 262.881);--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);outline:2px solid #0000}input:where([type=checkbox]):checked,input:where([type=radio]):checked{background-color:currentColor;background-position:50%;background-repeat:no-repeat;background-size:100% 100%;border-color:#0000}input:where([type=checkbox]):checked{background-image:url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e")}@media (forced-colors:active){input:where([type=checkbox]):checked{appearance:auto}}input:where([type=radio]):checked{background-image:url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3ccircle cx='8' cy='8' r='3'/%3e%3c/svg%3e")}@media (forced-colors:active){input:where([type=radio]):checked{appearance:auto}}input:where([type=checkbox]):checked:hover,input:where([type=checkbox]):checked:focus,input:where([type=radio]):checked:hover,input:where([type=radio]):checked:focus{background-color:currentColor;border-color:#0000}input:where([type=checkbox]):indeterminate{background-color:currentColor;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 16 16'%3e%3cpath stroke='white' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M4 8h8'/%3e%3c/svg%3e");background-position:50%;background-repeat:no-repeat;background-size:100% 100%;border-color:#0000}@media (forced-colors:active){input:where([type=checkbox]):indeterminate{appearance:auto}}input:where([type=checkbox]):indeterminate:hover,input:where([type=checkbox]):indeterminate:focus{background-color:currentColor;border-color:#0000}input:where([type=file]){background:unset;border-color:inherit;font-size:unset;line-height:inherit;border-width:0;border-radius:0;padding:0}input:where([type=file]):focus{outline:1px solid buttontext;outline:1px auto -webkit-focus-ring-color}}@layer components;@layer utilities{.pointer-events-auto{pointer-events:auto}.pointer-events-none{pointer-events:none}.collapse{visibility:collapse}.invisible{visibility:hidden}.visible{visibility:visible}.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.not-sr-only{clip-path:none;white-space:normal;width:auto;height:auto;margin:0;padding:0;position:static;overflow:visible}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.sticky{position:sticky}.-inset-2{inset:calc(var(--spacing) * -2)}.inset-0{inset:0}.inset-x-0{inset-inline:0}.inset-y-0{inset-block:0}.-top-0\.5{top:calc(var(--spacing) * -.5)}.-top-1{top:calc(var(--spacing) * -1)}.-top-2{top:calc(var(--spacing) * -2)}.-top-10{top:calc(var(--spacing) * -10)}.-top-12{top:calc(var(--spacing) * -12)}.top-0{top:0}.top-1{top:var(--spacing)}.top-1\.5{top:calc(var(--spacing) * 1.5)}.top-1\/2{top:50%}.top-2{top:calc(var(--spacing) * 2)}.top-3{top:calc(var(--spacing) * 3)}.top-4{top:calc(var(--spacing) * 4)}.top-14{top:calc(var(--spacing) * 14)}.top-\[calc\(0\.5rem\+2\.75rem\+0\.5rem\)\]{top:3.75rem}.top-\[calc\(0\.5rem\+2\.75rem\+0\.5rem\+2\.75rem\)\]{top:6.5rem}.top-full{top:100%}.-right-0\.5{right:calc(var(--spacing) * -.5)}.-right-1{right:calc(var(--spacing) * -1)}.-right-1\.5{right:calc(var(--spacing) * -1.5)}.-right-2{right:calc(var(--spacing) * -2)}.-right-3{right:calc(var(--spacing) * -3)}.right-0{right:0}.right-1{right:var(--spacing)}.right-1\.5{right:calc(var(--spacing) * 1.5)}.right-2{right:calc(var(--spacing) * 2)}.right-3{right:calc(var(--spacing) * 3)}.right-4{right:calc(var(--spacing) * 4)}.right-\[max\(0\.75rem\,env\(safe-area-inset-right\)\)\]{right:max(.75rem, env(safe-area-inset-right))}.right-\[max\(1rem\,env\(safe-area-inset-right\,0px\)\)\]{right:max(1rem, env(safe-area-inset-right,0px))}.-bottom-0\.5{bottom:calc(var(--spacing) * -.5)}.-bottom-1{bottom:calc(var(--spacing) * -1)}.-bottom-2{bottom:calc(var(--spacing) * -2)}.bottom-0{bottom:0}.bottom-1{bottom:var(--spacing)}.bottom-2{bottom:calc(var(--spacing) * 2)}.bottom-3{bottom:calc(var(--spacing) * 3)}.bottom-4{bottom:calc(var(--spacing) * 4)}.bottom-5{bottom:calc(var(--spacing) * 5)}.bottom-9{bottom:calc(var(--spacing) * 9)}.bottom-\[max\(0\.75rem\,env\(safe-area-inset-bottom\)\)\]{bottom:max(.75rem, env(safe-area-inset-bottom))}.bottom-\[max\(1\.25rem\,env\(safe-area-inset-bottom\,0px\)\)\]{bottom:max(1.25rem, env(safe-area-inset-bottom,0px))}.bottom-\[max\(1rem\,env\(safe-area-inset-bottom\,0px\)\)\]{bottom:max(1rem, env(safe-area-inset-bottom,0px))}.bottom-full{bottom:100%}.-left-1\.5{left:calc(var(--spacing) * -1.5)}.-left-3{left:calc(var(--spacing) * -3)}.left-0{left:0}.left-1{left:var(--spacing)}.left-1\/2{left:50%}.left-2{left:calc(var(--spacing) * 2)}.left-2\.5{left:calc(var(--spacing) * 2.5)}.left-3{left:calc(var(--spacing) * 3)}.left-3\.5{left:calc(var(--spacing) * 3.5)}.left-4{left:calc(var(--spacing) * 4)}.left-\[-2px\]{left:-2px}.isolate{isolation:isolate}.isolation-auto{isolation:auto}.z-0{z-index:0}.z-1{z-index:1}.z-5{z-index:5}.z-10{z-index:10}.z-19{z-index:19}.z-20{z-index:20}.z-30{z-index:30}.z-40{z-index:40}.z-45{z-index:45}.z-50{z-index:50}.z-60{z-index:60}.z-65{z-index:65}.z-70{z-index:70}.z-90{z-index:90}.z-95{z-index:95}.z-100{z-index:100}.z-120{z-index:120}.z-150{z-index:150}.z-180{z-index:180}.z-190{z-index:190}.z-200{z-index:200}.z-210{z-index:210}.z-220{z-index:220}.z-9999{z-index:9999}.z-\[60\]{z-index:60}.z-\[200\]{z-index:200}.z-\[220\]{z-index:220}.z-\[250\]{z-index:250}.z-\[500\]{z-index:500}.order-1{order:1}.order-2{order:2}.order-first{order:-9999}.col-span-2{grid-column:span 2/span 2}.float-end{float:inline-end}.float-left{float:left}.float-none{float:none}.float-right{float:right}.float-start{float:inline-start}.clear-both{clear:both}.clear-end{clear:inline-end}.clear-left{clear:left}.clear-none{clear:none}.clear-right{clear:right}.clear-start{clear:inline-start}.container{width:100%}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.-m-px{margin:-1px}.m-0{margin:0}.m-4{margin:calc(var(--spacing) * 4)}.m-\[-1px\]{margin:-1px}.-mx-3{margin-inline:calc(var(--spacing) * -3)}.-mx-4{margin-inline:calc(var(--spacing) * -4)}.mx-0\.5{margin-inline:calc(var(--spacing) * .5)}.mx-1{margin-inline:var(--spacing)}.mx-3{margin-inline:calc(var(--spacing) * 3)}.mx-4{margin-inline:calc(var(--spacing) * 4)}.mx-auto{margin-inline:auto}.-my-2{margin-block:calc(var(--spacing) * -2)}.my-1{margin-block:var(--spacing)}.my-2{margin-block:calc(var(--spacing) * 2)}.my-3{margin-block:calc(var(--spacing) * 3)}.my-4{margin-block:calc(var(--spacing) * 4)}.my-6{margin-block:calc(var(--spacing) * 6)}.my-8{margin-block:calc(var(--spacing) * 8)}.my-10{margin-block:calc(var(--spacing) * 10)}.my-auto{margin-block:auto}.-mt-0\.5{margin-top:calc(var(--spacing) * -.5)}.-mt-1{margin-top:calc(var(--spacing) * -1)}.mt-0\.5{margin-top:calc(var(--spacing) * .5)}.mt-1{margin-top:var(--spacing)}.mt-1\.5{margin-top:calc(var(--spacing) * 1.5)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-5{margin-top:calc(var(--spacing) * 5)}.mt-6{margin-top:calc(var(--spacing) * 6)}.mt-7{margin-top:calc(var(--spacing) * 7)}.mt-8{margin-top:calc(var(--spacing) * 8)}.mt-10{margin-top:calc(var(--spacing) * 10)}.mt-12{margin-top:calc(var(--spacing) * 12)}.mt-14{margin-top:calc(var(--spacing) * 14)}.mt-auto{margin-top:auto}.-mr-2{margin-right:calc(var(--spacing) * -2)}.mr-0{margin-right:0}.mr-1{margin-right:var(--spacing)}.mr-1\.5{margin-right:calc(var(--spacing) * 1.5)}.mr-2{margin-right:calc(var(--spacing) * 2)}.mr-3{margin-right:calc(var(--spacing) * 3)}.mr-4{margin-right:calc(var(--spacing) * 4)}.mr-auto{margin-right:auto}.-mb-0\.5{margin-bottom:calc(var(--spacing) * -.5)}.-mb-px{margin-bottom:-1px}.mb-0{margin-bottom:0}.mb-0\!{margin-bottom:0!important}.mb-0\.5{margin-bottom:calc(var(--spacing) * .5)}.mb-1{margin-bottom:var(--spacing)}.mb-1\.5{margin-bottom:calc(var(--spacing) * 1.5)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.mb-6{margin-bottom:calc(var(--spacing) * 6)}.mb-8{margin-bottom:calc(var(--spacing) * 8)}.mb-10{margin-bottom:calc(var(--spacing) * 10)}.-ml-1{margin-left:calc(var(--spacing) * -1)}.-ml-11{margin-left:calc(var(--spacing) * -11)}.ml-0\.5{margin-left:calc(var(--spacing) * .5)}.ml-1{margin-left:var(--spacing)}.ml-1\.5{margin-left:calc(var(--spacing) * 1.5)}.ml-2{margin-left:calc(var(--spacing) * 2)}.ml-3{margin-left:calc(var(--spacing) * 3)}.ml-4{margin-left:calc(var(--spacing) * 4)}.ml-6{margin-left:calc(var(--spacing) * 6)}.ml-auto{margin-left:auto}.box-border{box-sizing:border-box}.box-content{box-sizing:content-box}.form-checkbox{appearance:none;-webkit-print-color-adjust:exact;print-color-adjust:exact;vertical-align:middle;-webkit-user-select:none;user-select:none;color:oklch(54.6% .245 262.881);--tw-shadow:0 0 #0000;background-color:#fff;background-origin:border-box;border-width:1px;border-color:oklch(55.1% .027 264.364);border-radius:0;flex-shrink:0;width:1rem;height:1rem;padding:0;display:inline-block}.form-checkbox:focus{outline-offset:2px;--tw-ring-inset:var(--tw-empty, );--tw-ring-offset-width:2px;--tw-ring-offset-color:#fff;--tw-ring-color:oklch(54.6% .245 262.881);--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);outline:2px solid #0000}.form-checkbox:checked{background-color:currentColor;background-image:url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e");background-position:50%;background-repeat:no-repeat;background-size:100% 100%;border-color:#0000}@media (forced-colors:active){.form-checkbox:checked{appearance:auto}}.form-checkbox:checked:hover,.form-checkbox:checked:focus{background-color:currentColor;border-color:#0000}.form-checkbox:indeterminate{background-color:currentColor;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 16 16'%3e%3cpath stroke='white' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M4 8h8'/%3e%3c/svg%3e");background-position:50%;background-repeat:no-repeat;background-size:100% 100%;border-color:#0000}@media (forced-colors:active){.form-checkbox:indeterminate{appearance:auto}}.form-checkbox:indeterminate:hover,.form-checkbox:indeterminate:focus{background-color:currentColor;border-color:#0000}.form-radio{appearance:none;-webkit-print-color-adjust:exact;print-color-adjust:exact;vertical-align:middle;-webkit-user-select:none;user-select:none;color:oklch(54.6% .245 262.881);--tw-shadow:0 0 #0000;background-color:#fff;background-origin:border-box;border-width:1px;border-color:oklch(55.1% .027 264.364);border-radius:100%;flex-shrink:0;width:1rem;height:1rem;padding:0;display:inline-block}.form-radio:focus{outline-offset:2px;--tw-ring-inset:var(--tw-empty, );--tw-ring-offset-width:2px;--tw-ring-offset-color:#fff;--tw-ring-color:oklch(54.6% .245 262.881);--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);outline:2px solid #0000}.form-radio:checked{background-color:currentColor;background-image:url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3ccircle cx='8' cy='8' r='3'/%3e%3c/svg%3e");background-position:50%;background-repeat:no-repeat;background-size:100% 100%;border-color:#0000}@media (forced-colors:active){.form-radio:checked{appearance:auto}}.form-radio:checked:hover,.form-radio:checked:focus{background-color:currentColor;border-color:#0000}.form-input{appearance:none;--tw-shadow:0 0 #0000;background-color:#fff;border-width:1px;border-color:oklch(55.1% .027 264.364);border-radius:0;padding:.5rem .75rem;font-size:1rem;line-height:1.5rem}.form-input:focus{outline-offset:2px;--tw-ring-inset:var(--tw-empty, );--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:oklch(54.6% .245 262.881);--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);border-color:oklch(54.6% .245 262.881);outline:2px solid #0000}.form-input::placeholder{color:oklch(55.1% .027 264.364);opacity:1}.form-input::-webkit-datetime-edit-fields-wrapper{padding:0}.form-input::-webkit-date-and-time-value{min-height:1.5em}.form-input::-webkit-date-and-time-value{text-align:inherit}.form-input::-webkit-datetime-edit{display:inline-flex}.form-input::-webkit-datetime-edit{padding-top:0;padding-bottom:0}.form-input::-webkit-datetime-edit-year-field{padding-top:0;padding-bottom:0}.form-input::-webkit-datetime-edit-month-field{padding-top:0;padding-bottom:0}.form-input::-webkit-datetime-edit-day-field{padding-top:0;padding-bottom:0}.form-input::-webkit-datetime-edit-hour-field{padding-top:0;padding-bottom:0}.form-input::-webkit-datetime-edit-minute-field{padding-top:0;padding-bottom:0}.form-input::-webkit-datetime-edit-second-field{padding-top:0;padding-bottom:0}.form-input::-webkit-datetime-edit-millisecond-field{padding-top:0;padding-bottom:0}.form-input::-webkit-datetime-edit-meridiem-field{padding-top:0;padding-bottom:0}.line-clamp-2{-webkit-line-clamp:2;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.line-clamp-3{-webkit-line-clamp:3;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.line-clamp-4{-webkit-line-clamp:4;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.line-clamp-none{-webkit-line-clamp:unset;-webkit-box-orient:horizontal;display:block;overflow:visible}.block{display:block}.contents{display:contents}.flex{display:flex}.flow-root{display:flow-root}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-block{display:inline-block}.inline-flex{display:inline-flex}.inline-grid{display:inline-grid}.inline-table{display:inline-table}.list-item{display:list-item}.table{display:table}.table-caption{display:table-caption}.table-cell{display:table-cell}.table-column{display:table-column}.table-column-group{display:table-column-group}.table-footer-group{display:table-footer-group}.table-header-group{display:table-header-group}.table-row{display:table-row}.table-row-group{display:table-row-group}.aspect-2\/1{aspect-ratio:2}.aspect-square{aspect-ratio:1}.aspect-video{aspect-ratio:var(--aspect-video)}.size-1\.5{width:calc(var(--spacing) * 1.5);height:calc(var(--spacing) * 1.5)}.size-2{width:calc(var(--spacing) * 2);height:calc(var(--spacing) * 2)}.size-2\.5{width:calc(var(--spacing) * 2.5);height:calc(var(--spacing) * 2.5)}.size-3{width:calc(var(--spacing) * 3);height:calc(var(--spacing) * 3)}.size-3\.5{width:calc(var(--spacing) * 3.5);height:calc(var(--spacing) * 3.5)}.size-4{width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.size-5{width:calc(var(--spacing) * 5);height:calc(var(--spacing) * 5)}.size-6{width:calc(var(--spacing) * 6);height:calc(var(--spacing) * 6)}.size-7{width:calc(var(--spacing) * 7);height:calc(var(--spacing) * 7)}.size-8{width:calc(var(--spacing) * 8);height:calc(var(--spacing) * 8)}.size-9{width:calc(var(--spacing) * 9);height:calc(var(--spacing) * 9)}.size-10{width:calc(var(--spacing) * 10);height:calc(var(--spacing) * 10)}.size-12{width:calc(var(--spacing) * 12);height:calc(var(--spacing) * 12)}.size-14{width:calc(var(--spacing) * 14);height:calc(var(--spacing) * 14)}.size-16{width:calc(var(--spacing) * 16);height:calc(var(--spacing) * 16)}.size-20{width:calc(var(--spacing) * 20);height:calc(var(--spacing) * 20)}.size-24{width:calc(var(--spacing) * 24);height:calc(var(--spacing) * 24)}.size-28{width:calc(var(--spacing) * 28);height:calc(var(--spacing) * 28)}.size-32{width:calc(var(--spacing) * 32);height:calc(var(--spacing) * 32)}.size-40{width:calc(var(--spacing) * 40);height:calc(var(--spacing) * 40)}.size-\[14px\]{width:14px;height:14px}.size-\[18px\]{width:18px;height:18px}.size-\[22px\]{width:22px;height:22px}.size-\[34px\]{width:34px;height:34px}.size-\[52px\]{width:52px;height:52px}.size-\[120px\]{width:120px;height:120px}.size-full{width:100%;height:100%}.h-0{height:0}.h-0\.5{height:calc(var(--spacing) * .5)}.h-1{height:var(--spacing)}.h-1\.5{height:calc(var(--spacing) * 1.5)}.h-2{height:calc(var(--spacing) * 2)}.h-2\.5{height:calc(var(--spacing) * 2.5)}.h-3{height:calc(var(--spacing) * 3)}.h-3\.5{height:calc(var(--spacing) * 3.5)}.h-4{height:calc(var(--spacing) * 4)}.h-5{height:calc(var(--spacing) * 5)}.h-6{height:calc(var(--spacing) * 6)}.h-7{height:calc(var(--spacing) * 7)}.h-8{height:calc(var(--spacing) * 8)}.h-9{height:calc(var(--spacing) * 9)}.h-10{height:calc(var(--spacing) * 10)}.h-10\!{height:calc(var(--spacing) * 10)!important}.h-11{height:calc(var(--spacing) * 11)}.h-12{height:calc(var(--spacing) * 12)}.h-14{height:calc(var(--spacing) * 14)}.h-16{height:calc(var(--spacing) * 16)}.h-20{height:calc(var(--spacing) * 20)}.h-24{height:calc(var(--spacing) * 24)}.h-28{height:calc(var(--spacing) * 28)}.h-32{height:calc(var(--spacing) * 32)}.h-40{height:calc(var(--spacing) * 40)}.h-44{height:calc(var(--spacing) * 44)}.h-48{height:calc(var(--spacing) * 48)}.h-52{height:calc(var(--spacing) * 52)}.h-64{height:calc(var(--spacing) * 64)}.h-\[42\%\]{height:42%}.h-auto{height:auto}.h-dvh{height:100dvh}.h-fit{height:fit-content}.h-full{height:100%}.h-px{height:1px}.max-h-12{max-height:calc(var(--spacing) * 12)}.max-h-20{max-height:calc(var(--spacing) * 20)}.max-h-36{max-height:calc(var(--spacing) * 36)}.max-h-44{max-height:calc(var(--spacing) * 44)}.max-h-48{max-height:calc(var(--spacing) * 48)}.max-h-52{max-height:calc(var(--spacing) * 52)}.max-h-56{max-height:calc(var(--spacing) * 56)}.max-h-60{max-height:calc(var(--spacing) * 60)}.max-h-64{max-height:calc(var(--spacing) * 64)}.max-h-72{max-height:calc(var(--spacing) * 72)}.max-h-80{max-height:calc(var(--spacing) * 80)}.max-h-\[36vh\]{max-height:36vh}.max-h-\[60vh\]{max-height:60vh}.max-h-\[80vh\]{max-height:80vh}.max-h-\[90dvh\]{max-height:90dvh}.max-h-\[90vh\]{max-height:90vh}.max-h-\[200px\]{max-height:200px}.max-h-\[220px\]{max-height:220px}.max-h-\[260px\]{max-height:260px}.max-h-\[400px\]{max-height:400px}.max-h-\[480px\]{max-height:480px}.max-h-\[500px\]{max-height:500px}.max-h-\[min\(22rem\,calc\(100vh-6rem\)\)\]{max-height:min(22rem,100vh - 6rem)}.max-h-\[min\(36rem\,90vh\)\]{max-height:min(36rem,90vh)}.max-h-\[min\(36rem\,calc\(100\%-4rem\)\)\]{max-height:min(36rem,100% - 4rem)}.max-h-\[min\(50vh\,28rem\)\]{max-height:min(50vh,28rem)}.max-h-\[min\(65vh\,32rem\)\]{max-height:min(65vh,32rem)}.max-h-\[min\(70dvh\,70vh\)\]{max-height:min(70dvh,70vh)}.max-h-\[min\(90dvh\,40rem\)\]{max-height:min(90dvh,40rem)}.max-h-\[min\(90dvh\,48rem\)\]{max-height:min(90dvh,48rem)}.max-h-\[min\(320px\,55vh\)\]{max-height:min(320px,55vh)}.max-h-\[min\(420px\,70vh\)\]{max-height:min(420px,70vh)}.max-h-dvh{max-height:100dvh}.max-h-full{max-height:100%}.min-h-0{min-height:0}.min-h-4\.5{min-height:calc(var(--spacing) * 4.5)}.min-h-9{min-height:calc(var(--spacing) * 9)}.min-h-10{min-height:calc(var(--spacing) * 10)}.min-h-12{min-height:calc(var(--spacing) * 12)}.min-h-17{min-height:calc(var(--spacing) * 17)}.min-h-32{min-height:calc(var(--spacing) * 32)}.min-h-40{min-height:calc(var(--spacing) * 40)}.min-h-\[1\.25rem\]{min-height:1.25rem}.min-h-\[2\.5rem\]{min-height:2.5rem}.min-h-\[2\.25rem\]{min-height:2.25rem}.min-h-\[2rem\]{min-height:2rem}.min-h-\[4\.5rem\]{min-height:4.5rem}.min-h-\[5\.5rem\]{min-height:5.5rem}.min-h-\[5rem\]{min-height:5rem}.min-h-\[6\.5rem\]{min-height:6.5rem}.min-h-\[6rem\]{min-height:6rem}.min-h-\[9\.5rem\]{min-height:9.5rem}.min-h-\[10rem\]{min-height:10rem}.min-h-\[12rem\]{min-height:12rem}.min-h-\[28px\]{min-height:28px}.min-h-\[36px\]{min-height:36px}.min-h-\[40px\]{min-height:40px}.min-h-\[44px\]{min-height:44px}.min-h-\[48px\]{min-height:48px}.min-h-\[52px\]{min-height:52px}.min-h-\[54px\]{min-height:54px}.min-h-\[72px\]{min-height:72px}.min-h-\[80px\]{min-height:80px}.min-h-\[88px\]{min-height:88px}.min-h-\[96px\]{min-height:96px}.min-h-\[120px\]{min-height:120px}.min-h-\[140px\]{min-height:140px}.min-h-\[200px\]{min-height:200px}.min-h-\[240px\]{min-height:240px}.min-h-\[320px\]{min-height:320px}.min-h-\[min\(50vh\,18rem\)\]{min-height:min(50vh,18rem)}.min-h-dvh{min-height:100dvh}.min-h-full{min-height:100%}.min-h-screen{min-height:100vh}.w-0\.5{width:calc(var(--spacing) * .5)}.w-1{width:var(--spacing)}.w-1\/2{width:50%}.w-1\/3{width:33.3333%}.w-2{width:calc(var(--spacing) * 2)}.w-2\.5{width:calc(var(--spacing) * 2.5)}.w-3{width:calc(var(--spacing) * 3)}.w-3\.5{width:calc(var(--spacing) * 3.5)}.w-4{width:calc(var(--spacing) * 4)}.w-5{width:calc(var(--spacing) * 5)}.w-6{width:calc(var(--spacing) * 6)}.w-7{width:calc(var(--spacing) * 7)}.w-8{width:calc(var(--spacing) * 8)}.w-9{width:calc(var(--spacing) * 9)}.w-10{width:calc(var(--spacing) * 10)}.w-11{width:calc(var(--spacing) * 11)}.w-12{width:calc(var(--spacing) * 12)}.w-13{width:calc(var(--spacing) * 13)}.w-14{width:calc(var(--spacing) * 14)}.w-16{width:calc(var(--spacing) * 16)}.w-20{width:calc(var(--spacing) * 20)}.w-24{width:calc(var(--spacing) * 24)}.w-28{width:calc(var(--spacing) * 28)}.w-32{width:calc(var(--spacing) * 32)}.w-40{width:calc(var(--spacing) * 40)}.w-44{width:calc(var(--spacing) * 44)}.w-48{width:calc(var(--spacing) * 48)}.w-52{width:calc(var(--spacing) * 52)}.w-56{width:calc(var(--spacing) * 56)}.w-64{width:calc(var(--spacing) * 64)}.w-72{width:calc(var(--spacing) * 72)}.w-80{width:calc(var(--spacing) * 80)}.w-96{width:calc(var(--spacing) * 96)}.w-\[2px\]{width:2px}.w-\[4\.5rem\]{width:4.5rem}.w-\[42\%\]{width:42%}.w-\[calc\(100\%-2rem\)\]{width:calc(100% - 2rem)}.w-\[min\(20rem\,calc\(100\%-1\.5rem\)\)\]{width:min(20rem,100% - 1.5rem)}.w-\[min\(24rem\,calc\(100\%-1rem\)\)\]{width:min(24rem,100% - 1rem)}.w-\[min\(100vw-2rem\,24rem\)\]{width:min(100vw - 2rem,24rem)}.w-\[min\(320px\,85vw\)\]{width:min(320px,85vw)}.w-\[min\(calc\(100\%-1\.25rem\)\,24rem\)\]{width:min(100% - 1.25rem,24rem)}.w-auto{width:auto}.w-fit{width:fit-content}.w-full{width:100%}.w-max{width:max-content}.w-px{width:1px}.max-w-2xl{max-width:var(--container-2xl)}.max-w-3xl{max-width:var(--container-3xl)}.max-w-4xl{max-width:var(--container-4xl)}.max-w-5xl{max-width:var(--container-5xl)}.max-w-6xl{max-width:var(--container-6xl)}.max-w-7xl{max-width:var(--container-7xl)}.max-w-16{max-width:calc(var(--spacing) * 16)}.max-w-18{max-width:calc(var(--spacing) * 18)}.max-w-32{max-width:calc(var(--spacing) * 32)}.max-w-40{max-width:calc(var(--spacing) * 40)}.max-w-\[5rem\]{max-width:5rem}.max-w-\[7\.5rem\]{max-width:7.5rem}.max-w-\[8rem\]{max-width:8rem}.max-w-\[12rem\]{max-width:12rem}.max-w-\[14rem\]{max-width:14rem}.max-w-\[16rem\]{max-width:16rem}.max-w-\[20rem\]{max-width:20rem}.max-w-\[45\%\]{max-width:45%}.max-w-\[70\%\]{max-width:70%}.max-w-\[85\%\]{max-width:85%}.max-w-\[90\%\]{max-width:90%}.max-w-\[98vw\]{max-width:98vw}.max-w-\[100px\]{max-width:100px}.max-w-\[120px\]{max-width:120px}.max-w-\[160px\]{max-width:160px}.max-w-\[200px\]{max-width:200px}.max-w-\[260px\]{max-width:260px}.max-w-\[280px\]{max-width:280px}.max-w-\[1920px\]{max-width:1920px}.max-w-\[calc\(100\%-0\.75rem\)\]{max-width:calc(100% - .75rem)}.max-w-\[min\(18rem\,100\%\)\]{max-width:min(18rem,100%)}.max-w-\[min\(20rem\,85vw\)\]{max-width:min(20rem,85vw)}.max-w-\[min\(20rem\,100\%\)\]{max-width:min(20rem,100%)}.max-w-\[min\(20rem\,calc\(100vw-1\.5rem\)\)\]{max-width:min(20rem,100vw - 1.5rem)}.max-w-\[min\(22rem\,calc\(100vw-2rem\)\)\]{max-width:min(22rem,100vw - 2rem)}.max-w-\[min\(40vw\,12rem\)\]{max-width:min(40vw,12rem)}.max-w-\[min\(55vw\,14rem\)\]{max-width:min(55vw,14rem)}.max-w-\[min\(100\%\,12rem\)\]{max-width:min(100%,12rem)}.max-w-\[min\(100\%\,14rem\)\]{max-width:min(100%,14rem)}.max-w-\[min\(100\%\,18rem\)\]{max-width:min(100%,18rem)}.max-w-\[min\(100\%\,calc\(100\%-2rem\)\)\]{max-width:min(100%,100% - 2rem)}.max-w-\[min\(100vw-2rem\,22rem\)\]{max-width:min(100vw - 2rem,22rem)}.max-w-\[min\(100vw-2rem\,28rem\)\]{max-width:min(100vw - 2rem,28rem)}.max-w-\[min\(100vw-2rem\,36rem\)\]{max-width:min(100vw - 2rem,36rem)}.max-w-\[min\(280px\,85vw\)\]{max-width:min(280px,85vw)}.max-w-\[min\(280px\,100\%\)\]{max-width:min(280px,100%)}.max-w-full{max-width:100%}.max-w-lg{max-width:var(--container-lg)}.max-w-md{max-width:var(--container-md)}.max-w-none{max-width:none}.max-w-sm{max-width:var(--container-sm)}.max-w-xl{max-width:var(--container-xl)}.max-w-xs{max-width:var(--container-xs)}.min-w-0{min-width:0}.min-w-4{min-width:calc(var(--spacing) * 4)}.min-w-4\.5{min-width:calc(var(--spacing) * 4.5)}.min-w-5{min-width:calc(var(--spacing) * 5)}.min-w-9{min-width:calc(var(--spacing) * 9)}.min-w-16{min-width:calc(var(--spacing) * 16)}.min-w-40{min-width:calc(var(--spacing) * 40)}.min-w-44{min-width:calc(var(--spacing) * 44)}.min-w-48{min-width:calc(var(--spacing) * 48)}.min-w-52{min-width:calc(var(--spacing) * 52)}.min-w-56{min-width:calc(var(--spacing) * 56)}.min-w-\[1\.25rem\]{min-width:1.25rem}.min-w-\[1\.125rem\]{min-width:1.125rem}.min-w-\[6\.5rem\]{min-width:6.5rem}.min-w-\[7rem\]{min-width:7rem}.min-w-\[10rem\]{min-width:10rem}.min-w-\[14px\]{min-width:14px}.min-w-\[16rem\]{min-width:16rem}.min-w-\[28px\]{min-width:28px}.min-w-\[36px\]{min-width:36px}.min-w-\[40px\]{min-width:40px}.min-w-\[44px\]{min-width:44px}.min-w-\[80px\]{min-width:80px}.min-w-\[120px\]{min-width:120px}.min-w-\[125px\]{min-width:125px}.min-w-\[160px\]{min-width:160px}.min-w-\[200px\]{min-width:200px}.min-w-\[220px\]{min-width:220px}.min-w-fit{min-width:fit-content}.min-w-full{min-width:100%}.flex-1{flex:1}.flex-2{flex:2}.flex-shrink{flex-shrink:1}.flex-shrink-0{flex-shrink:0}.shrink{flex-shrink:1}.shrink-0{flex-shrink:0}.flex-grow,.grow{flex-grow:1}.basis-0{flex-basis:0}.table-auto{table-layout:auto}.table-fixed{table-layout:fixed}.caption-bottom{caption-side:bottom}.caption-top{caption-side:top}.border-collapse{border-collapse:collapse}.border-separate{border-collapse:separate}.origin-center{transform-origin:50%}.-translate-x-1\/2{--tw-translate-x:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.-translate-x-full{--tw-translate-x:-100%;translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-x-0{--tw-translate-x:0px;translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-x-5{--tw-translate-x:calc(var(--spacing) * 5);translate:var(--tw-translate-x) var(--tw-translate-y)}.-translate-y-1\/2{--tw-translate-y:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.-translate-y-full{--tw-translate-y:-100%;translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-y-0{--tw-translate-y:0px;translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-y-1\/2{--tw-translate-y:calc(1 / 2 * 100%);translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-y-2{--tw-translate-y:calc(var(--spacing) * 2);translate:var(--tw-translate-x) var(--tw-translate-y)}.scale-95{--tw-scale-x:95%;--tw-scale-y:95%;--tw-scale-z:95%;scale:var(--tw-scale-x) var(--tw-scale-y)}.scale-100{--tw-scale-x:100%;--tw-scale-y:100%;--tw-scale-z:100%;scale:var(--tw-scale-x) var(--tw-scale-y)}.scale-\[1\.02\]{scale:1.02}.rotate-12{rotate:12deg}.rotate-90{rotate:90deg}.rotate-135{rotate:135deg}.rotate-180{rotate:180deg}.transform,.transform-cpu{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}.transform-gpu{transform:translateZ(0) var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}.transform-none{transform:none}.animate-bounce{animation:var(--animate-bounce)}.animate-ping{animation:var(--animate-ping)}.animate-pulse{animation:var(--animate-pulse)}.animate-spin{animation:var(--animate-spin)}.cursor-col-resize{cursor:col-resize}.cursor-copy{cursor:copy}.cursor-crosshair{cursor:crosshair}.cursor-default{cursor:default}.cursor-ew-resize{cursor:ew-resize}.cursor-grab{cursor:grab}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.touch-pan-left{--tw-pan-x:pan-left;touch-action:var(--tw-pan-x,) var(--tw-pan-y,) var(--tw-pinch-zoom,)}.touch-pan-right{--tw-pan-x:pan-right;touch-action:var(--tw-pan-x,) var(--tw-pan-y,) var(--tw-pinch-zoom,)}.touch-pan-x{--tw-pan-x:pan-x;touch-action:var(--tw-pan-x,) var(--tw-pan-y,) var(--tw-pinch-zoom,)}.touch-pan-down{--tw-pan-y:pan-down;touch-action:var(--tw-pan-x,) var(--tw-pan-y,) var(--tw-pinch-zoom,)}.touch-pan-up{--tw-pan-y:pan-up;touch-action:var(--tw-pan-x,) var(--tw-pan-y,) var(--tw-pinch-zoom,)}.touch-pan-y{--tw-pan-y:pan-y;touch-action:var(--tw-pan-x,) var(--tw-pan-y,) var(--tw-pinch-zoom,)}.touch-pinch-zoom{--tw-pinch-zoom:pinch-zoom;touch-action:var(--tw-pan-x,) var(--tw-pan-y,) var(--tw-pinch-zoom,)}.touch-auto{touch-action:auto}.touch-manipulation{touch-action:manipulation}.touch-none{touch-action:none}.resize{resize:both}.resize-none{resize:none}.resize-x{resize:horizontal}.resize-y{resize:vertical}.snap-both{scroll-snap-type:both var(--tw-scroll-snap-strictness)}.snap-none{scroll-snap-type:none}.snap-x{scroll-snap-type:x var(--tw-scroll-snap-strictness)}.snap-y{scroll-snap-type:y var(--tw-scroll-snap-strictness)}.snap-mandatory{--tw-scroll-snap-strictness:mandatory}.snap-proximity{--tw-scroll-snap-strictness:proximity}.snap-align-none{scroll-snap-align:none}.snap-center{scroll-snap-align:center}.snap-end{scroll-snap-align:end}.snap-start{scroll-snap-align:start}.snap-always{scroll-snap-stop:always}.snap-normal{scroll-snap-stop:normal}.scroll-mt-24{scroll-margin-top:calc(var(--spacing) * 24)}.scrollbar-thin{scrollbar-width:thin}.list-inside{list-style-position:inside}.list-outside{list-style-position:outside}.list-decimal{list-style-type:decimal}.list-disc{list-style-type:disc}.list-none{list-style-type:none}.form-select{appearance:none;--tw-shadow:0 0 #0000;background-color:#fff;border-width:1px;border-color:oklch(55.1% .027 264.364);border-radius:0;padding:.5rem .75rem;font-size:1rem;line-height:1.5rem}.form-select:focus{outline-offset:2px;--tw-ring-inset:var(--tw-empty, );--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:oklch(54.6% .245 262.881);--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);border-color:oklch(54.6% .245 262.881);outline:2px solid #0000}.form-select{-webkit-print-color-adjust:exact;print-color-adjust:exact;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='oklch(55.1%25 0.027 264.364)' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e");background-position:right .5rem center;background-repeat:no-repeat;background-size:1.5em 1.5em;padding-right:2.5rem}.form-select:where([size]:not([size="1"])){background-image:initial;background-position:initial;background-repeat:unset;background-size:initial;print-color-adjust:unset;padding-right:.75rem}.form-textarea{appearance:none;--tw-shadow:0 0 #0000;background-color:#fff;border-width:1px;border-color:oklch(55.1% .027 264.364);border-radius:0;padding:.5rem .75rem;font-size:1rem;line-height:1.5rem}.form-textarea:focus{outline-offset:2px;--tw-ring-inset:var(--tw-empty, );--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:oklch(54.6% .245 262.881);--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);border-color:oklch(54.6% .245 262.881);outline:2px solid #0000}.form-textarea::placeholder{color:oklch(55.1% .027 264.364);opacity:1}.form-multiselect{appearance:none;--tw-shadow:0 0 #0000;background-color:#fff;border-width:1px;border-color:oklch(55.1% .027 264.364);border-radius:0;padding:.5rem .75rem;font-size:1rem;line-height:1.5rem}.form-multiselect:focus{outline-offset:2px;--tw-ring-inset:var(--tw-empty, );--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:oklch(54.6% .245 262.881);--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);border-color:oklch(54.6% .245 262.881);outline:2px solid #0000}.appearance-auto{appearance:auto}.appearance-none{appearance:none}.break-before-all{break-before:all}.break-before-auto{break-before:auto}.break-before-avoid{break-before:avoid}.break-before-avoid-page{break-before:avoid-page}.break-before-column{break-before:column}.break-before-left{break-before:left}.break-before-page{break-before:page}.break-before-right{break-before:right}.break-inside-auto{break-inside:auto}.break-inside-avoid{break-inside:avoid}.break-inside-avoid-column{break-inside:avoid-column}.break-inside-avoid-page{break-inside:avoid-page}.break-after-all{break-after:all}.break-after-auto{break-after:auto}.break-after-avoid{break-after:avoid}.break-after-avoid-page{break-after:avoid-page}.break-after-column{break-after:column}.break-after-left{break-after:left}.break-after-page{break-after:page}.break-after-right{break-after:right}.grid-flow-col{grid-auto-flow:column}.grid-flow-col-dense{grid-auto-flow:column dense}.grid-flow-dense{grid-auto-flow:dense}.grid-flow-row{grid-auto-flow:row}.grid-flow-row-dense{grid-auto-flow:dense}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.grid-cols-5{grid-template-columns:repeat(5,minmax(0,1fr))}.grid-cols-6{grid-template-columns:repeat(6,minmax(0,1fr))}.grid-cols-\[minmax\(0\,38\%\)_1fr\]{grid-template-columns:minmax(0,38%) 1fr}.grid-rows-4{grid-template-rows:repeat(4,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-col-reverse{flex-direction:column-reverse}.flex-row{flex-direction:row}.flex-row-reverse{flex-direction:row-reverse}.flex-nowrap{flex-wrap:nowrap}.flex-wrap{flex-wrap:wrap}.flex-wrap-reverse{flex-wrap:wrap-reverse}.place-content-around{place-content:space-around}.place-content-baseline{place-content:baseline start}.place-content-between{place-content:space-between}.place-content-center{place-content:center}.place-content-end{place-content:end}.place-content-evenly{place-content:space-evenly}.place-content-start{place-content:start}.place-content-stretch{place-content:stretch}.place-items-baseline{place-items:baseline}.place-items-center{place-items:center}.place-items-end{place-items:end}.place-items-start{place-items:start}.place-items-stretch{place-items:stretch stretch}.content-around{align-content:space-around}.content-baseline{align-content:baseline}.content-between{align-content:space-between}.content-center{align-content:center}.content-end{align-content:flex-end}.content-evenly{align-content:space-evenly}.content-normal{align-content:normal}.content-start{align-content:flex-start}.content-stretch{align-content:stretch}.items-baseline{align-items:baseline}.items-center{align-items:center}.items-end{align-items:flex-end}.items-start{align-items:flex-start}.items-stretch{align-items:stretch}.justify-around{justify-content:space-around}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.justify-evenly{justify-content:space-evenly}.justify-normal{justify-content:normal}.justify-start{justify-content:flex-start}.justify-stretch{justify-content:stretch}.justify-items-center{justify-items:center}.justify-items-end{justify-items:end}.justify-items-start{justify-items:start}.justify-items-stretch{justify-items:stretch}.gap-0{gap:0}.gap-0\.5{gap:calc(var(--spacing) * .5)}.gap-1{gap:var(--spacing)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-2\.5{gap:calc(var(--spacing) * 2.5)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-5{gap:calc(var(--spacing) * 5)}.gap-6{gap:calc(var(--spacing) * 6)}.gap-8{gap:calc(var(--spacing) * 8)}.gap-10{gap:calc(var(--spacing) * 10)}:where(.space-y-0>:not(:last-child)){--tw-space-y-reverse:0;margin-block:0}:where(.space-y-0\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * .5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * .5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-1>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(var(--spacing) * var(--tw-space-y-reverse));margin-block-end:calc(var(--spacing) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-1\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 1.5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 1.5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2.5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2.5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-8>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 8) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 8) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-10>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 10) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 10) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-reverse>:not(:last-child)){--tw-space-y-reverse:1}.gap-x-1{column-gap:var(--spacing)}.gap-x-1\.5{column-gap:calc(var(--spacing) * 1.5)}.gap-x-2{column-gap:calc(var(--spacing) * 2)}.gap-x-3{column-gap:calc(var(--spacing) * 3)}.gap-x-4{column-gap:calc(var(--spacing) * 4)}.gap-x-6{column-gap:calc(var(--spacing) * 6)}:where(.space-x-1>:not(:last-child)){--tw-space-x-reverse:0;margin-inline-start:calc(var(--spacing) * var(--tw-space-x-reverse));margin-inline-end:calc(var(--spacing) * calc(1 - var(--tw-space-x-reverse)))}:where(.space-x-1\.5>:not(:last-child)){--tw-space-x-reverse:0;margin-inline-start:calc(calc(var(--spacing) * 1.5) * var(--tw-space-x-reverse));margin-inline-end:calc(calc(var(--spacing) * 1.5) * calc(1 - var(--tw-space-x-reverse)))}:where(.space-x-2>:not(:last-child)){--tw-space-x-reverse:0;margin-inline-start:calc(calc(var(--spacing) * 2) * var(--tw-space-x-reverse));margin-inline-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-x-reverse)))}:where(.space-x-3>:not(:last-child)){--tw-space-x-reverse:0;margin-inline-start:calc(calc(var(--spacing) * 3) * var(--tw-space-x-reverse));margin-inline-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-x-reverse)))}:where(.space-x-4>:not(:last-child)){--tw-space-x-reverse:0;margin-inline-start:calc(calc(var(--spacing) * 4) * var(--tw-space-x-reverse));margin-inline-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-x-reverse)))}:where(.space-x-reverse>:not(:last-child)){--tw-space-x-reverse:1}.gap-y-0\.5{row-gap:calc(var(--spacing) * .5)}.gap-y-1{row-gap:var(--spacing)}.gap-y-2{row-gap:calc(var(--spacing) * 2)}.gap-y-3{row-gap:calc(var(--spacing) * 3)}.gap-y-4{row-gap:calc(var(--spacing) * 4)}:where(.divide-x>:not(:last-child)){--tw-divide-x-reverse:0;border-inline-style:var(--tw-border-style);border-inline-start-width:calc(1px * var(--tw-divide-x-reverse));border-inline-end-width:calc(1px * calc(1 - var(--tw-divide-x-reverse)))}:where(.divide-x-0>:not(:last-child)){--tw-divide-x-reverse:0;border-inline-style:var(--tw-border-style);border-inline-start-width:calc(0px * var(--tw-divide-x-reverse));border-inline-end-width:calc(0px * calc(1 - var(--tw-divide-x-reverse)))}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-y-reverse>:not(:last-child)){--tw-divide-y-reverse:1}:where(.divide-dashed>:not(:last-child)){--tw-border-style:dashed;border-style:dashed}:where(.divide-dotted>:not(:last-child)){--tw-border-style:dotted;border-style:dotted}:where(.divide-double>:not(:last-child)){--tw-border-style:double;border-style:double}:where(.divide-none>:not(:last-child)){--tw-border-style:none;border-style:none}:where(.divide-solid>:not(:last-child)){--tw-border-style:solid;border-style:solid}:where(.divide-gray-100>:not(:last-child)){border-color:var(--color-gray-100)}:where(.divide-gray-200>:not(:last-child)){border-color:var(--color-gray-200)}:where(.divide-gray-200\/60>:not(:last-child)){border-color:#e5e7eb99}@supports (color:color-mix(in lab, red, red)){:where(.divide-gray-200\/60>:not(:last-child)){border-color:color-mix(in oklab, var(--color-gray-200) 60%, transparent)}}:where(.divide-gray-300>:not(:last-child)){border-color:var(--color-gray-300)}.place-self-auto{place-self:auto}.place-self-center{place-self:center}.place-self-end{place-self:end}.place-self-start{place-self:start}.place-self-stretch{place-self:stretch stretch}.self-auto{align-self:auto}.self-baseline{align-self:baseline}.self-center{align-self:center}.self-end{align-self:flex-end}.self-start{align-self:flex-start}.self-stretch{align-self:stretch}.justify-self-auto{justify-self:auto}.justify-self-center{justify-self:center}.justify-self-end{justify-self:flex-end}.justify-self-start{justify-self:flex-start}.justify-self-stretch{justify-self:stretch}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-auto{overflow:auto}.overflow-clip{overflow:clip}.overflow-hidden{overflow:hidden}.overflow-scroll{overflow:scroll}.overflow-visible{overflow:visible}.overflow-x-auto{overflow-x:auto}.overflow-x-clip{overflow-x:clip}.overflow-x-hidden{overflow-x:hidden}.overflow-x-scroll{overflow-x:scroll}.overflow-x-visible{overflow-x:visible}.overflow-y-auto{overflow-y:auto}.overflow-y-clip{overflow-y:clip}.overflow-y-hidden{overflow-y:hidden}.overflow-y-scroll{overflow-y:scroll}.overflow-y-visible{overflow-y:visible}.overscroll-auto{overscroll-behavior:auto}.overscroll-contain{overscroll-behavior:contain}.overscroll-none{overscroll-behavior:none}.overscroll-x-auto{overscroll-behavior-x:auto}.overscroll-x-contain{overscroll-behavior-x:contain}.overscroll-x-none{overscroll-behavior-x:none}.overscroll-y-auto{overscroll-behavior-y:auto}.overscroll-y-contain{overscroll-behavior-y:contain}.overscroll-y-none{overscroll-behavior-y:none}.scroll-auto{scroll-behavior:auto}.scroll-smooth{scroll-behavior:smooth}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:var(--radius-2xl)}.rounded-3xl{border-radius:var(--radius-3xl)}.rounded-4xl{border-radius:var(--radius-4xl)}.rounded-\[3rem\]{border-radius:3rem}.rounded-\[10px\]{border-radius:10px}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-lg\!{border-radius:var(--radius-lg)!important}.rounded-md{border-radius:var(--radius-md)}.rounded-none{border-radius:0}.rounded-sm{border-radius:var(--radius-sm)}.rounded-xl{border-radius:var(--radius-xl)}.rounded-xl\!{border-radius:var(--radius-xl)!important}.rounded-xs{border-radius:var(--radius-xs)}.rounded-s{border-start-start-radius:.25rem;border-end-start-radius:.25rem}.rounded-ss{border-start-start-radius:.25rem}.rounded-e{border-start-end-radius:.25rem;border-end-end-radius:.25rem}.rounded-se{border-start-end-radius:.25rem}.rounded-ee{border-end-end-radius:.25rem}.rounded-es{border-end-start-radius:.25rem}.rounded-t{border-top-left-radius:.25rem;border-top-right-radius:.25rem}.rounded-t-2xl{border-top-left-radius:var(--radius-2xl);border-top-right-radius:var(--radius-2xl)}.rounded-t-lg{border-top-left-radius:var(--radius-lg);border-top-right-radius:var(--radius-lg)}.rounded-t-md{border-top-left-radius:var(--radius-md);border-top-right-radius:var(--radius-md)}.rounded-l{border-top-left-radius:.25rem;border-bottom-left-radius:.25rem}.rounded-l-xl{border-top-left-radius:var(--radius-xl);border-bottom-left-radius:var(--radius-xl)}.rounded-tl{border-top-left-radius:.25rem}.rounded-r{border-top-right-radius:.25rem;border-bottom-right-radius:.25rem}.rounded-r-full{border-top-right-radius:3.40282e38px;border-bottom-right-radius:3.40282e38px}.rounded-r-lg{border-top-right-radius:var(--radius-lg);border-bottom-right-radius:var(--radius-lg)}.rounded-r-xl{border-top-right-radius:var(--radius-xl);border-bottom-right-radius:var(--radius-xl)}.rounded-tr{border-top-right-radius:.25rem}.rounded-b{border-bottom-right-radius:.25rem;border-bottom-left-radius:.25rem}.rounded-b-2xl{border-bottom-right-radius:var(--radius-2xl);border-bottom-left-radius:var(--radius-2xl)}.rounded-br{border-bottom-right-radius:.25rem}.rounded-bl{border-bottom-left-radius:.25rem}.border{border-style:var(--tw-border-style);border-width:1px}.border\!{border-style:var(--tw-border-style)!important;border-width:1px!important}.border-0{border-style:var(--tw-border-style);border-width:0}.border-2{border-style:var(--tw-border-style);border-width:2px}.border-2\!{border-style:var(--tw-border-style)!important;border-width:2px!important}.border-4{border-style:var(--tw-border-style);border-width:4px}.border-x{border-inline-style:var(--tw-border-style);border-inline-width:1px}.border-x-2{border-inline-style:var(--tw-border-style);border-inline-width:2px}.border-y{border-block-style:var(--tw-border-style);border-block-width:1px}.border-s{border-inline-start-style:var(--tw-border-style);border-inline-start-width:1px}.border-e{border-inline-end-style:var(--tw-border-style);border-inline-end-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-t-2{border-top-style:var(--tw-border-style);border-top-width:2px}.border-r{border-right-style:var(--tw-border-style);border-right-width:1px}.border-r-2{border-right-style:var(--tw-border-style);border-right-width:2px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-b-2{border-bottom-style:var(--tw-border-style);border-bottom-width:2px}.border-l{border-left-style:var(--tw-border-style);border-left-width:1px}.border-l-2{border-left-style:var(--tw-border-style);border-left-width:2px}.border-l-4{border-left-style:var(--tw-border-style);border-left-width:4px}.border-dashed{--tw-border-style:dashed;border-style:dashed}.border-dotted{--tw-border-style:dotted;border-style:dotted}.border-double{--tw-border-style:double;border-style:double}.border-hidden{--tw-border-style:hidden;border-style:hidden}.border-none{--tw-border-style:none;border-style:none}.border-none\!{--tw-border-style:none!important;border-style:none!important}.border-solid{--tw-border-style:solid;border-style:solid}.border-\[var\(--mc-border-strong\)\]{border-color:var(--mc-border-strong)}.border-amber-100{border-color:var(--color-amber-100)}.border-amber-200{border-color:var(--color-amber-200)}.border-amber-200\/50{border-color:#fee68580}@supports (color:color-mix(in lab, red, red)){.border-amber-200\/50{border-color:color-mix(in oklab, var(--color-amber-200) 50%, transparent)}}.border-amber-200\/70{border-color:#fee685b3}@supports (color:color-mix(in lab, red, red)){.border-amber-200\/70{border-color:color-mix(in oklab, var(--color-amber-200) 70%, transparent)}}.border-amber-200\/80{border-color:#fee685cc}@supports (color:color-mix(in lab, red, red)){.border-amber-200\/80{border-color:color-mix(in oklab, var(--color-amber-200) 80%, transparent)}}.border-amber-200\/85{border-color:#fee685d9}@supports (color:color-mix(in lab, red, red)){.border-amber-200\/85{border-color:color-mix(in oklab, var(--color-amber-200) 85%, transparent)}}.border-amber-200\/90{border-color:#fee685e6}@supports (color:color-mix(in lab, red, red)){.border-amber-200\/90{border-color:color-mix(in oklab, var(--color-amber-200) 90%, transparent)}}.border-amber-300{border-color:var(--color-amber-300)}.border-amber-400{border-color:var(--color-amber-400)}.border-amber-500{border-color:var(--color-amber-500)}.border-amber-500\/30{border-color:#f99c004d}@supports (color:color-mix(in lab, red, red)){.border-amber-500\/30{border-color:color-mix(in oklab, var(--color-amber-500) 30%, transparent)}}.border-amber-500\/35{border-color:#f99c0059}@supports (color:color-mix(in lab, red, red)){.border-amber-500\/35{border-color:color-mix(in oklab, var(--color-amber-500) 35%, transparent)}}.border-amber-500\/40{border-color:#f99c0066}@supports (color:color-mix(in lab, red, red)){.border-amber-500\/40{border-color:color-mix(in oklab, var(--color-amber-500) 40%, transparent)}}.border-amber-700\/80{border-color:#b75000cc}@supports (color:color-mix(in lab, red, red)){.border-amber-700\/80{border-color:color-mix(in oklab, var(--color-amber-700) 80%, transparent)}}.border-amber-800\/80{border-color:#953d00cc}@supports (color:color-mix(in lab, red, red)){.border-amber-800\/80{border-color:color-mix(in oklab, var(--color-amber-800) 80%, transparent)}}.border-black\/5{border-color:#0000000d}@supports (color:color-mix(in lab, red, red)){.border-black\/5{border-color:color-mix(in oklab, var(--color-black) 5%, transparent)}}.border-black\/8{border-color:#00000014}@supports (color:color-mix(in lab, red, red)){.border-black\/8{border-color:color-mix(in oklab, var(--color-black) 8%, transparent)}}.border-black\/10{border-color:#0000001a}@supports (color:color-mix(in lab, red, red)){.border-black\/10{border-color:color-mix(in oklab, var(--color-black) 10%, transparent)}}.border-blue-100{border-color:var(--color-blue-100)}.border-blue-100\/50{border-color:#dbeafe80}@supports (color:color-mix(in lab, red, red)){.border-blue-100\/50{border-color:color-mix(in oklab, var(--color-blue-100) 50%, transparent)}}.border-blue-200{border-color:var(--color-blue-200)}.border-blue-200\/50{border-color:#bedbff80}@supports (color:color-mix(in lab, red, red)){.border-blue-200\/50{border-color:color-mix(in oklab, var(--color-blue-200) 50%, transparent)}}.border-blue-200\/60{border-color:#bedbff99}@supports (color:color-mix(in lab, red, red)){.border-blue-200\/60{border-color:color-mix(in oklab, var(--color-blue-200) 60%, transparent)}}.border-blue-300{border-color:var(--color-blue-300)}.border-blue-400{border-color:var(--color-blue-400)}.border-blue-400\/70{border-color:#54a2ffb3}@supports (color:color-mix(in lab, red, red)){.border-blue-400\/70{border-color:color-mix(in oklab, var(--color-blue-400) 70%, transparent)}}.border-blue-500{border-color:var(--color-blue-500)}.border-blue-500\/10{border-color:#3080ff1a}@supports (color:color-mix(in lab, red, red)){.border-blue-500\/10{border-color:color-mix(in oklab, var(--color-blue-500) 10%, transparent)}}.border-blue-500\/20{border-color:#3080ff33}@supports (color:color-mix(in lab, red, red)){.border-blue-500\/20{border-color:color-mix(in oklab, var(--color-blue-500) 20%, transparent)}}.border-blue-500\/30{border-color:#3080ff4d}@supports (color:color-mix(in lab, red, red)){.border-blue-500\/30{border-color:color-mix(in oklab, var(--color-blue-500) 30%, transparent)}}.border-blue-500\/50{border-color:#3080ff80}@supports (color:color-mix(in lab, red, red)){.border-blue-500\/50{border-color:color-mix(in oklab, var(--color-blue-500) 50%, transparent)}}.border-blue-600{border-color:var(--color-blue-600)}.border-blue-700{border-color:var(--color-blue-700)}.border-current,.border-current\/20{border-color:currentColor}@supports (color:color-mix(in lab, red, red)){.border-current\/20{border-color:color-mix(in oklab, currentcolor 20%, transparent)}}.border-cyan-200{border-color:var(--color-cyan-200)}.border-cyan-500\/50{border-color:#00b7d780}@supports (color:color-mix(in lab, red, red)){.border-cyan-500\/50{border-color:color-mix(in oklab, var(--color-cyan-500) 50%, transparent)}}.border-emerald-100{border-color:var(--color-emerald-100)}.border-emerald-100\/50{border-color:#d0fae580}@supports (color:color-mix(in lab, red, red)){.border-emerald-100\/50{border-color:color-mix(in oklab, var(--color-emerald-100) 50%, transparent)}}.border-emerald-200{border-color:var(--color-emerald-200)}.border-emerald-200\/60{border-color:#a4f4cf99}@supports (color:color-mix(in lab, red, red)){.border-emerald-200\/60{border-color:color-mix(in oklab, var(--color-emerald-200) 60%, transparent)}}.border-emerald-500{border-color:var(--color-emerald-500)}.border-emerald-500\/20{border-color:#00bb7f33}@supports (color:color-mix(in lab, red, red)){.border-emerald-500\/20{border-color:color-mix(in oklab, var(--color-emerald-500) 20%, transparent)}}.border-emerald-500\/35{border-color:#00bb7f59}@supports (color:color-mix(in lab, red, red)){.border-emerald-500\/35{border-color:color-mix(in oklab, var(--color-emerald-500) 35%, transparent)}}.border-emerald-800\/80{border-color:#005f46cc}@supports (color:color-mix(in lab, red, red)){.border-emerald-800\/80{border-color:color-mix(in oklab, var(--color-emerald-800) 80%, transparent)}}.border-gray-100{border-color:var(--color-gray-100)}.border-gray-100\/20{border-color:#f3f4f633}@supports (color:color-mix(in lab, red, red)){.border-gray-100\/20{border-color:color-mix(in oklab, var(--color-gray-100) 20%, transparent)}}.border-gray-100\/50{border-color:#f3f4f680}@supports (color:color-mix(in lab, red, red)){.border-gray-100\/50{border-color:color-mix(in oklab, var(--color-gray-100) 50%, transparent)}}.border-gray-100\/60{border-color:#f3f4f699}@supports (color:color-mix(in lab, red, red)){.border-gray-100\/60{border-color:color-mix(in oklab, var(--color-gray-100) 60%, transparent)}}.border-gray-100\/80{border-color:#f3f4f6cc}@supports (color:color-mix(in lab, red, red)){.border-gray-100\/80{border-color:color-mix(in oklab, var(--color-gray-100) 80%, transparent)}}.border-gray-200{border-color:var(--color-gray-200)}.border-gray-200\!{border-color:var(--color-gray-200)!important}.border-gray-200\/50{border-color:#e5e7eb80}@supports (color:color-mix(in lab, red, red)){.border-gray-200\/50{border-color:color-mix(in oklab, var(--color-gray-200) 50%, transparent)}}.border-gray-200\/60{border-color:#e5e7eb99}@supports (color:color-mix(in lab, red, red)){.border-gray-200\/60{border-color:color-mix(in oklab, var(--color-gray-200) 60%, transparent)}}.border-gray-200\/70{border-color:#e5e7ebb3}@supports (color:color-mix(in lab, red, red)){.border-gray-200\/70{border-color:color-mix(in oklab, var(--color-gray-200) 70%, transparent)}}.border-gray-200\/80{border-color:#e5e7ebcc}@supports (color:color-mix(in lab, red, red)){.border-gray-200\/80{border-color:color-mix(in oklab, var(--color-gray-200) 80%, transparent)}}.border-gray-200\/90{border-color:#e5e7ebe6}@supports (color:color-mix(in lab, red, red)){.border-gray-200\/90{border-color:color-mix(in oklab, var(--color-gray-200) 90%, transparent)}}.border-gray-300{border-color:var(--color-gray-300)}.border-gray-400{border-color:var(--color-gray-400)}.border-gray-500{border-color:var(--color-gray-500)}.border-gray-700{border-color:var(--color-gray-700)}.border-green-200{border-color:var(--color-green-200)}.border-green-500\/30{border-color:#00c7584d}@supports (color:color-mix(in lab, red, red)){.border-green-500\/30{border-color:color-mix(in oklab, var(--color-green-500) 30%, transparent)}}.border-indigo-200{border-color:var(--color-indigo-200)}.border-indigo-200\/60{border-color:#c7d2ff99}@supports (color:color-mix(in lab, red, red)){.border-indigo-200\/60{border-color:color-mix(in oklab, var(--color-indigo-200) 60%, transparent)}}.border-indigo-300{border-color:var(--color-indigo-300)}.border-indigo-500{border-color:var(--color-indigo-500)}.border-indigo-500\/20{border-color:#625fff33}@supports (color:color-mix(in lab, red, red)){.border-indigo-500\/20{border-color:color-mix(in oklab, var(--color-indigo-500) 20%, transparent)}}.border-orange-200{border-color:var(--color-orange-200)}.border-pink-100{border-color:var(--color-pink-100)}.border-pink-200{border-color:var(--color-pink-200)}.border-purple-200{border-color:var(--color-purple-200)}.border-purple-500\/20{border-color:#ac4bff33}@supports (color:color-mix(in lab, red, red)){.border-purple-500\/20{border-color:color-mix(in oklab, var(--color-purple-500) 20%, transparent)}}.border-purple-500\/50{border-color:#ac4bff80}@supports (color:color-mix(in lab, red, red)){.border-purple-500\/50{border-color:color-mix(in oklab, var(--color-purple-500) 50%, transparent)}}.border-purple-500\/60{border-color:#ac4bff99}@supports (color:color-mix(in lab, red, red)){.border-purple-500\/60{border-color:color-mix(in oklab, var(--color-purple-500) 60%, transparent)}}.border-red-100{border-color:var(--color-red-100)}.border-red-200{border-color:var(--color-red-200)}.border-red-200\!{border-color:var(--color-red-200)!important}.border-red-200\/60{border-color:#ffcaca99}@supports (color:color-mix(in lab, red, red)){.border-red-200\/60{border-color:color-mix(in oklab, var(--color-red-200) 60%, transparent)}}.border-red-200\/70{border-color:#ffcacab3}@supports (color:color-mix(in lab, red, red)){.border-red-200\/70{border-color:color-mix(in oklab, var(--color-red-200) 70%, transparent)}}.border-red-200\/80{border-color:#ffcacacc}@supports (color:color-mix(in lab, red, red)){.border-red-200\/80{border-color:color-mix(in oklab, var(--color-red-200) 80%, transparent)}}.border-red-200\/90{border-color:#ffcacae6}@supports (color:color-mix(in lab, red, red)){.border-red-200\/90{border-color:color-mix(in oklab, var(--color-red-200) 90%, transparent)}}.border-red-300{border-color:var(--color-red-300)}.border-red-400{border-color:var(--color-red-400)}.border-red-500{border-color:var(--color-red-500)}.border-red-500\/20{border-color:#fb2c3633}@supports (color:color-mix(in lab, red, red)){.border-red-500\/20{border-color:color-mix(in oklab, var(--color-red-500) 20%, transparent)}}.border-red-500\/30{border-color:#fb2c364d}@supports (color:color-mix(in lab, red, red)){.border-red-500\/30{border-color:color-mix(in oklab, var(--color-red-500) 30%, transparent)}}.border-red-800\/80{border-color:#9f0712cc}@supports (color:color-mix(in lab, red, red)){.border-red-800\/80{border-color:color-mix(in oklab, var(--color-red-800) 80%, transparent)}}.border-rose-500\/20{border-color:#ff235733}@supports (color:color-mix(in lab, red, red)){.border-rose-500\/20{border-color:color-mix(in oklab, var(--color-rose-500) 20%, transparent)}}.border-sky-200{border-color:var(--color-sky-200)}.border-sky-200\/70{border-color:#b8e6feb3}@supports (color:color-mix(in lab, red, red)){.border-sky-200\/70{border-color:color-mix(in oklab, var(--color-sky-200) 70%, transparent)}}.border-sky-200\/90{border-color:#b8e6fee6}@supports (color:color-mix(in lab, red, red)){.border-sky-200\/90{border-color:color-mix(in oklab, var(--color-sky-200) 90%, transparent)}}.border-sky-300\/45{border-color:#77d4ff73}@supports (color:color-mix(in lab, red, red)){.border-sky-300\/45{border-color:color-mix(in oklab, var(--color-sky-300) 45%, transparent)}}.border-sky-900\/80{border-color:#024a70cc}@supports (color:color-mix(in lab, red, red)){.border-sky-900\/80{border-color:color-mix(in oklab, var(--color-sky-900) 80%, transparent)}}.border-slate-200{border-color:var(--color-slate-200)}.border-slate-200\/80{border-color:#e2e8f0cc}@supports (color:color-mix(in lab, red, red)){.border-slate-200\/80{border-color:color-mix(in oklab, var(--color-slate-200) 80%, transparent)}}.border-teal-200{border-color:var(--color-teal-200)}.border-teal-300\!{border-color:var(--color-teal-300)!important}.border-teal-500{border-color:var(--color-teal-500)}.border-transparent{border-color:#0000}.border-violet-200{border-color:var(--color-violet-200)}.border-white{border-color:var(--color-white)}.border-white\/10{border-color:#ffffff1a}@supports (color:color-mix(in lab, red, red)){.border-white\/10{border-color:color-mix(in oklab, var(--color-white) 10%, transparent)}}.border-white\/20{border-color:#fff3}@supports (color:color-mix(in lab, red, red)){.border-white\/20{border-color:color-mix(in oklab, var(--color-white) 20%, transparent)}}.border-white\/40{border-color:#fff6}@supports (color:color-mix(in lab, red, red)){.border-white\/40{border-color:color-mix(in oklab, var(--color-white) 40%, transparent)}}.border-yellow-200{border-color:var(--color-yellow-200)}.border-yellow-300{border-color:var(--color-yellow-300)}.border-yellow-700\/50{border-color:#a3610080}@supports (color:color-mix(in lab, red, red)){.border-yellow-700\/50{border-color:color-mix(in oklab, var(--color-yellow-700) 50%, transparent)}}.border-zinc-100{border-color:var(--color-zinc-100)}.border-zinc-200{border-color:var(--color-zinc-200)}.border-zinc-600\/50{border-color:#52525c80}@supports (color:color-mix(in lab, red, red)){.border-zinc-600\/50{border-color:color-mix(in oklab, var(--color-zinc-600) 50%, transparent)}}.border-zinc-700{border-color:var(--color-zinc-700)}.border-zinc-700\/80{border-color:#3f3f46cc}@supports (color:color-mix(in lab, red, red)){.border-zinc-700\/80{border-color:color-mix(in oklab, var(--color-zinc-700) 80%, transparent)}}.border-zinc-800{border-color:var(--color-zinc-800)}.border-zinc-900{border-color:var(--color-zinc-900)}.border-t-blue-500{border-top-color:var(--color-blue-500)}.border-t-current{border-top-color:currentColor}.border-t-indigo-600{border-top-color:var(--color-indigo-600)}.border-t-transparent{border-top-color:#0000}.border-t-white{border-top-color:var(--color-white)}.border-b-emerald-500{border-bottom-color:var(--color-emerald-500)}.border-b-transparent{border-bottom-color:#0000}.border-l-gray-300{border-left-color:var(--color-gray-300)}.border-l-green-500{border-left-color:var(--color-green-500)}.border-l-red-500{border-left-color:var(--color-red-500)}.bg-\[\#1a0b33\]{background-color:#1a0b33}.bg-\[\#2b1065\]{background-color:#2b1065}.bg-\[var\(--mc-surface\)\]{background-color:var(--mc-surface)}.bg-\[var\(--mc-surface-hover\)\]{background-color:var(--mc-surface-hover)}.bg-amber-50{background-color:var(--color-amber-50)}.bg-amber-50\/40{background-color:#fffbeb66}@supports (color:color-mix(in lab, red, red)){.bg-amber-50\/40{background-color:color-mix(in oklab, var(--color-amber-50) 40%, transparent)}}.bg-amber-50\/50{background-color:#fffbeb80}@supports (color:color-mix(in lab, red, red)){.bg-amber-50\/50{background-color:color-mix(in oklab, var(--color-amber-50) 50%, transparent)}}.bg-amber-50\/60{background-color:#fffbeb99}@supports (color:color-mix(in lab, red, red)){.bg-amber-50\/60{background-color:color-mix(in oklab, var(--color-amber-50) 60%, transparent)}}.bg-amber-50\/70{background-color:#fffbebb3}@supports (color:color-mix(in lab, red, red)){.bg-amber-50\/70{background-color:color-mix(in oklab, var(--color-amber-50) 70%, transparent)}}.bg-amber-50\/80{background-color:#fffbebcc}@supports (color:color-mix(in lab, red, red)){.bg-amber-50\/80{background-color:color-mix(in oklab, var(--color-amber-50) 80%, transparent)}}.bg-amber-50\/90{background-color:#fffbebe6}@supports (color:color-mix(in lab, red, red)){.bg-amber-50\/90{background-color:color-mix(in oklab, var(--color-amber-50) 90%, transparent)}}.bg-amber-100{background-color:var(--color-amber-100)}.bg-amber-100\/50{background-color:#fef3c680}@supports (color:color-mix(in lab, red, red)){.bg-amber-100\/50{background-color:color-mix(in oklab, var(--color-amber-100) 50%, transparent)}}.bg-amber-300{background-color:var(--color-amber-300)}.bg-amber-500{background-color:var(--color-amber-500)}.bg-amber-500\/5{background-color:#f99c000d}@supports (color:color-mix(in lab, red, red)){.bg-amber-500\/5{background-color:color-mix(in oklab, var(--color-amber-500) 5%, transparent)}}.bg-amber-500\/10{background-color:#f99c001a}@supports (color:color-mix(in lab, red, red)){.bg-amber-500\/10{background-color:color-mix(in oklab, var(--color-amber-500) 10%, transparent)}}.bg-amber-500\/90{background-color:#f99c00e6}@supports (color:color-mix(in lab, red, red)){.bg-amber-500\/90{background-color:color-mix(in oklab, var(--color-amber-500) 90%, transparent)}}.bg-amber-600{background-color:var(--color-amber-600)}.bg-amber-600\/40{background-color:#dd740066}@supports (color:color-mix(in lab, red, red)){.bg-amber-600\/40{background-color:color-mix(in oklab, var(--color-amber-600) 40%, transparent)}}.bg-amber-700{background-color:var(--color-amber-700)}.bg-black{background-color:var(--color-black)}.bg-black\/2{background-color:#00000005}@supports (color:color-mix(in lab, red, red)){.bg-black\/2{background-color:color-mix(in oklab, var(--color-black) 2%, transparent)}}.bg-black\/5{background-color:#0000000d}@supports (color:color-mix(in lab, red, red)){.bg-black\/5{background-color:color-mix(in oklab, var(--color-black) 5%, transparent)}}.bg-black\/10{background-color:#0000001a}@supports (color:color-mix(in lab, red, red)){.bg-black\/10{background-color:color-mix(in oklab, var(--color-black) 10%, transparent)}}.bg-black\/20{background-color:#0003}@supports (color:color-mix(in lab, red, red)){.bg-black\/20{background-color:color-mix(in oklab, var(--color-black) 20%, transparent)}}.bg-black\/40{background-color:#0006}@supports (color:color-mix(in lab, red, red)){.bg-black\/40{background-color:color-mix(in oklab, var(--color-black) 40%, transparent)}}.bg-black\/50{background-color:#00000080}@supports (color:color-mix(in lab, red, red)){.bg-black\/50{background-color:color-mix(in oklab, var(--color-black) 50%, transparent)}}.bg-black\/55{background-color:#0000008c}@supports (color:color-mix(in lab, red, red)){.bg-black\/55{background-color:color-mix(in oklab, var(--color-black) 55%, transparent)}}.bg-black\/60{background-color:#0009}@supports (color:color-mix(in lab, red, red)){.bg-black\/60{background-color:color-mix(in oklab, var(--color-black) 60%, transparent)}}.bg-black\/70{background-color:#000000b3}@supports (color:color-mix(in lab, red, red)){.bg-black\/70{background-color:color-mix(in oklab, var(--color-black) 70%, transparent)}}.bg-black\/75{background-color:#000000bf}@supports (color:color-mix(in lab, red, red)){.bg-black\/75{background-color:color-mix(in oklab, var(--color-black) 75%, transparent)}}.bg-black\/80{background-color:#000c}@supports (color:color-mix(in lab, red, red)){.bg-black\/80{background-color:color-mix(in oklab, var(--color-black) 80%, transparent)}}.bg-black\/\[0\.04\]{background-color:#0000000a}@supports (color:color-mix(in lab, red, red)){.bg-black\/\[0\.04\]{background-color:color-mix(in oklab, var(--color-black) 4%, transparent)}}.bg-blue-50{background-color:var(--color-blue-50)}.bg-blue-50\/20{background-color:#eff6ff33}@supports (color:color-mix(in lab, red, red)){.bg-blue-50\/20{background-color:color-mix(in oklab, var(--color-blue-50) 20%, transparent)}}.bg-blue-50\/30{background-color:#eff6ff4d}@supports (color:color-mix(in lab, red, red)){.bg-blue-50\/30{background-color:color-mix(in oklab, var(--color-blue-50) 30%, transparent)}}.bg-blue-50\/40{background-color:#eff6ff66}@supports (color:color-mix(in lab, red, red)){.bg-blue-50\/40{background-color:color-mix(in oklab, var(--color-blue-50) 40%, transparent)}}.bg-blue-50\/50{background-color:#eff6ff80}@supports (color:color-mix(in lab, red, red)){.bg-blue-50\/50{background-color:color-mix(in oklab, var(--color-blue-50) 50%, transparent)}}.bg-blue-50\/60{background-color:#eff6ff99}@supports (color:color-mix(in lab, red, red)){.bg-blue-50\/60{background-color:color-mix(in oklab, var(--color-blue-50) 60%, transparent)}}.bg-blue-50\/70{background-color:#eff6ffb3}@supports (color:color-mix(in lab, red, red)){.bg-blue-50\/70{background-color:color-mix(in oklab, var(--color-blue-50) 70%, transparent)}}.bg-blue-50\/80{background-color:#eff6ffcc}@supports (color:color-mix(in lab, red, red)){.bg-blue-50\/80{background-color:color-mix(in oklab, var(--color-blue-50) 80%, transparent)}}.bg-blue-50\/90{background-color:#eff6ffe6}@supports (color:color-mix(in lab, red, red)){.bg-blue-50\/90{background-color:color-mix(in oklab, var(--color-blue-50) 90%, transparent)}}.bg-blue-100{background-color:var(--color-blue-100)}.bg-blue-100\/50{background-color:#dbeafe80}@supports (color:color-mix(in lab, red, red)){.bg-blue-100\/50{background-color:color-mix(in oklab, var(--color-blue-100) 50%, transparent)}}.bg-blue-400{background-color:var(--color-blue-400)}.bg-blue-500{background-color:var(--color-blue-500)}.bg-blue-500\/5{background-color:#3080ff0d}@supports (color:color-mix(in lab, red, red)){.bg-blue-500\/5{background-color:color-mix(in oklab, var(--color-blue-500) 5%, transparent)}}.bg-blue-500\/6{background-color:#3080ff0f}@supports (color:color-mix(in lab, red, red)){.bg-blue-500\/6{background-color:color-mix(in oklab, var(--color-blue-500) 6%, transparent)}}.bg-blue-500\/10{background-color:#3080ff1a}@supports (color:color-mix(in lab, red, red)){.bg-blue-500\/10{background-color:color-mix(in oklab, var(--color-blue-500) 10%, transparent)}}.bg-blue-500\/20{background-color:#3080ff33}@supports (color:color-mix(in lab, red, red)){.bg-blue-500\/20{background-color:color-mix(in oklab, var(--color-blue-500) 20%, transparent)}}.bg-blue-500\/30{background-color:#3080ff4d}@supports (color:color-mix(in lab, red, red)){.bg-blue-500\/30{background-color:color-mix(in oklab, var(--color-blue-500) 30%, transparent)}}.bg-blue-600{background-color:var(--color-blue-600)}.bg-current{background-color:currentColor}.bg-cyan-50{background-color:var(--color-cyan-50)}.bg-cyan-100{background-color:var(--color-cyan-100)}.bg-cyan-500\/10{background-color:#00b7d71a}@supports (color:color-mix(in lab, red, red)){.bg-cyan-500\/10{background-color:color-mix(in oklab, var(--color-cyan-500) 10%, transparent)}}.bg-emerald-50{background-color:var(--color-emerald-50)}.bg-emerald-50\/20{background-color:#ecfdf533}@supports (color:color-mix(in lab, red, red)){.bg-emerald-50\/20{background-color:color-mix(in oklab, var(--color-emerald-50) 20%, transparent)}}.bg-emerald-50\/50{background-color:#ecfdf580}@supports (color:color-mix(in lab, red, red)){.bg-emerald-50\/50{background-color:color-mix(in oklab, var(--color-emerald-50) 50%, transparent)}}.bg-emerald-50\/80{background-color:#ecfdf5cc}@supports (color:color-mix(in lab, red, red)){.bg-emerald-50\/80{background-color:color-mix(in oklab, var(--color-emerald-50) 80%, transparent)}}.bg-emerald-100{background-color:var(--color-emerald-100)}.bg-emerald-500{background-color:var(--color-emerald-500)}.bg-emerald-500\/5{background-color:#00bb7f0d}@supports (color:color-mix(in lab, red, red)){.bg-emerald-500\/5{background-color:color-mix(in oklab, var(--color-emerald-500) 5%, transparent)}}.bg-emerald-500\/10{background-color:#00bb7f1a}@supports (color:color-mix(in lab, red, red)){.bg-emerald-500\/10{background-color:color-mix(in oklab, var(--color-emerald-500) 10%, transparent)}}.bg-emerald-500\/15{background-color:#00bb7f26}@supports (color:color-mix(in lab, red, red)){.bg-emerald-500\/15{background-color:color-mix(in oklab, var(--color-emerald-500) 15%, transparent)}}.bg-emerald-500\/20{background-color:#00bb7f33}@supports (color:color-mix(in lab, red, red)){.bg-emerald-500\/20{background-color:color-mix(in oklab, var(--color-emerald-500) 20%, transparent)}}.bg-emerald-600{background-color:var(--color-emerald-600)}.bg-emerald-700{background-color:var(--color-emerald-700)}.bg-gray-50{background-color:var(--color-gray-50)}.bg-gray-50\/30{background-color:#f9fafb4d}@supports (color:color-mix(in lab, red, red)){.bg-gray-50\/30{background-color:color-mix(in oklab, var(--color-gray-50) 30%, transparent)}}.bg-gray-50\/40{background-color:#f9fafb66}@supports (color:color-mix(in lab, red, red)){.bg-gray-50\/40{background-color:color-mix(in oklab, var(--color-gray-50) 40%, transparent)}}.bg-gray-50\/50{background-color:#f9fafb80}@supports (color:color-mix(in lab, red, red)){.bg-gray-50\/50{background-color:color-mix(in oklab, var(--color-gray-50) 50%, transparent)}}.bg-gray-50\/60{background-color:#f9fafb99}@supports (color:color-mix(in lab, red, red)){.bg-gray-50\/60{background-color:color-mix(in oklab, var(--color-gray-50) 60%, transparent)}}.bg-gray-50\/70{background-color:#f9fafbb3}@supports (color:color-mix(in lab, red, red)){.bg-gray-50\/70{background-color:color-mix(in oklab, var(--color-gray-50) 70%, transparent)}}.bg-gray-50\/80{background-color:#f9fafbcc}@supports (color:color-mix(in lab, red, red)){.bg-gray-50\/80{background-color:color-mix(in oklab, var(--color-gray-50) 80%, transparent)}}.bg-gray-50\/90{background-color:#f9fafbe6}@supports (color:color-mix(in lab, red, red)){.bg-gray-50\/90{background-color:color-mix(in oklab, var(--color-gray-50) 90%, transparent)}}.bg-gray-50\/95{background-color:#f9fafbf2}@supports (color:color-mix(in lab, red, red)){.bg-gray-50\/95{background-color:color-mix(in oklab, var(--color-gray-50) 95%, transparent)}}.bg-gray-100{background-color:var(--color-gray-100)}.bg-gray-100\/50{background-color:#f3f4f680}@supports (color:color-mix(in lab, red, red)){.bg-gray-100\/50{background-color:color-mix(in oklab, var(--color-gray-100) 50%, transparent)}}.bg-gray-100\/90{background-color:#f3f4f6e6}@supports (color:color-mix(in lab, red, red)){.bg-gray-100\/90{background-color:color-mix(in oklab, var(--color-gray-100) 90%, transparent)}}.bg-gray-200{background-color:var(--color-gray-200)}.bg-gray-200\/50{background-color:#e5e7eb80}@supports (color:color-mix(in lab, red, red)){.bg-gray-200\/50{background-color:color-mix(in oklab, var(--color-gray-200) 50%, transparent)}}.bg-gray-200\/80{background-color:#e5e7ebcc}@supports (color:color-mix(in lab, red, red)){.bg-gray-200\/80{background-color:color-mix(in oklab, var(--color-gray-200) 80%, transparent)}}.bg-gray-200\/90{background-color:#e5e7ebe6}@supports (color:color-mix(in lab, red, red)){.bg-gray-200\/90{background-color:color-mix(in oklab, var(--color-gray-200) 90%, transparent)}}.bg-gray-300\/85{background-color:#d1d5dcd9}@supports (color:color-mix(in lab, red, red)){.bg-gray-300\/85{background-color:color-mix(in oklab, var(--color-gray-300) 85%, transparent)}}.bg-gray-400{background-color:var(--color-gray-400)}.bg-gray-500{background-color:var(--color-gray-500)}.bg-gray-600{background-color:var(--color-gray-600)}.bg-gray-800{background-color:var(--color-gray-800)}.bg-gray-900{background-color:var(--color-gray-900)}.bg-green-50{background-color:var(--color-green-50)}.bg-green-50\/80{background-color:#f0fdf4cc}@supports (color:color-mix(in lab, red, red)){.bg-green-50\/80{background-color:color-mix(in oklab, var(--color-green-50) 80%, transparent)}}.bg-green-100{background-color:var(--color-green-100)}.bg-green-500{background-color:var(--color-green-500)}.bg-green-500\/10{background-color:#00c7581a}@supports (color:color-mix(in lab, red, red)){.bg-green-500\/10{background-color:color-mix(in oklab, var(--color-green-500) 10%, transparent)}}.bg-green-500\/20{background-color:#00c75833}@supports (color:color-mix(in lab, red, red)){.bg-green-500\/20{background-color:color-mix(in oklab, var(--color-green-500) 20%, transparent)}}.bg-green-600{background-color:var(--color-green-600)}.bg-indigo-50{background-color:var(--color-indigo-50)}.bg-indigo-50\/80{background-color:#eef2ffcc}@supports (color:color-mix(in lab, red, red)){.bg-indigo-50\/80{background-color:color-mix(in oklab, var(--color-indigo-50) 80%, transparent)}}.bg-indigo-100{background-color:var(--color-indigo-100)}.bg-indigo-500{background-color:var(--color-indigo-500)}.bg-indigo-500\/5{background-color:#625fff0d}@supports (color:color-mix(in lab, red, red)){.bg-indigo-500\/5{background-color:color-mix(in oklab, var(--color-indigo-500) 5%, transparent)}}.bg-indigo-500\/10{background-color:#625fff1a}@supports (color:color-mix(in lab, red, red)){.bg-indigo-500\/10{background-color:color-mix(in oklab, var(--color-indigo-500) 10%, transparent)}}.bg-indigo-500\/30{background-color:#625fff4d}@supports (color:color-mix(in lab, red, red)){.bg-indigo-500\/30{background-color:color-mix(in oklab, var(--color-indigo-500) 30%, transparent)}}.bg-indigo-600{background-color:var(--color-indigo-600)}.bg-orange-50{background-color:var(--color-orange-50)}.bg-orange-100{background-color:var(--color-orange-100)}.bg-orange-500{background-color:var(--color-orange-500)}.bg-orange-600{background-color:var(--color-orange-600)}.bg-pink-50{background-color:var(--color-pink-50)}.bg-pink-50\/50{background-color:#fdf2f880}@supports (color:color-mix(in lab, red, red)){.bg-pink-50\/50{background-color:color-mix(in oklab, var(--color-pink-50) 50%, transparent)}}.bg-purple-50{background-color:var(--color-purple-50)}.bg-purple-100{background-color:var(--color-purple-100)}.bg-purple-500\/10{background-color:#ac4bff1a}@supports (color:color-mix(in lab, red, red)){.bg-purple-500\/10{background-color:color-mix(in oklab, var(--color-purple-500) 10%, transparent)}}.bg-red-50{background-color:var(--color-red-50)}.bg-red-50\/30{background-color:#fef2f24d}@supports (color:color-mix(in lab, red, red)){.bg-red-50\/30{background-color:color-mix(in oklab, var(--color-red-50) 30%, transparent)}}.bg-red-50\/50{background-color:#fef2f280}@supports (color:color-mix(in lab, red, red)){.bg-red-50\/50{background-color:color-mix(in oklab, var(--color-red-50) 50%, transparent)}}.bg-red-50\/60{background-color:#fef2f299}@supports (color:color-mix(in lab, red, red)){.bg-red-50\/60{background-color:color-mix(in oklab, var(--color-red-50) 60%, transparent)}}.bg-red-50\/70{background-color:#fef2f2b3}@supports (color:color-mix(in lab, red, red)){.bg-red-50\/70{background-color:color-mix(in oklab, var(--color-red-50) 70%, transparent)}}.bg-red-50\/80{background-color:#fef2f2cc}@supports (color:color-mix(in lab, red, red)){.bg-red-50\/80{background-color:color-mix(in oklab, var(--color-red-50) 80%, transparent)}}.bg-red-50\/90{background-color:#fef2f2e6}@supports (color:color-mix(in lab, red, red)){.bg-red-50\/90{background-color:color-mix(in oklab, var(--color-red-50) 90%, transparent)}}.bg-red-100{background-color:var(--color-red-100)}.bg-red-500{background-color:var(--color-red-500)}.bg-red-500\/10{background-color:#fb2c361a}@supports (color:color-mix(in lab, red, red)){.bg-red-500\/10{background-color:color-mix(in oklab, var(--color-red-500) 10%, transparent)}}.bg-red-500\/90{background-color:#fb2c36e6}@supports (color:color-mix(in lab, red, red)){.bg-red-500\/90{background-color:color-mix(in oklab, var(--color-red-500) 90%, transparent)}}.bg-red-600{background-color:var(--color-red-600)}.bg-red-600\!{background-color:var(--color-red-600)!important}.bg-red-700{background-color:var(--color-red-700)}.bg-rose-50{background-color:var(--color-rose-50)}.bg-rose-100{background-color:var(--color-rose-100)}.bg-rose-500\/10{background-color:#ff23571a}@supports (color:color-mix(in lab, red, red)){.bg-rose-500\/10{background-color:color-mix(in oklab, var(--color-rose-500) 10%, transparent)}}.bg-rose-600{background-color:var(--color-rose-600)}.bg-sky-50{background-color:var(--color-sky-50)}.bg-sky-50\/40{background-color:#f0f9ff66}@supports (color:color-mix(in lab, red, red)){.bg-sky-50\/40{background-color:color-mix(in oklab, var(--color-sky-50) 40%, transparent)}}.bg-sky-100{background-color:var(--color-sky-100)}.bg-sky-600{background-color:var(--color-sky-600)}.bg-sky-700{background-color:var(--color-sky-700)}.bg-sky-800{background-color:var(--color-sky-800)}.bg-sky-900\/10{background-color:#024a701a}@supports (color:color-mix(in lab, red, red)){.bg-sky-900\/10{background-color:color-mix(in oklab, var(--color-sky-900) 10%, transparent)}}.bg-slate-50{background-color:var(--color-slate-50)}.bg-slate-50\/70{background-color:#f8fafcb3}@supports (color:color-mix(in lab, red, red)){.bg-slate-50\/70{background-color:color-mix(in oklab, var(--color-slate-50) 70%, transparent)}}.bg-slate-100{background-color:var(--color-slate-100)}.bg-slate-200{background-color:var(--color-slate-200)}.bg-slate-300{background-color:var(--color-slate-300)}.bg-slate-900\/45{background-color:#0f172b73}@supports (color:color-mix(in lab, red, red)){.bg-slate-900\/45{background-color:color-mix(in oklab, var(--color-slate-900) 45%, transparent)}}.bg-teal-50{background-color:var(--color-teal-50)}.bg-teal-100{background-color:var(--color-teal-100)}.bg-teal-500\/10{background-color:#00baa71a}@supports (color:color-mix(in lab, red, red)){.bg-teal-500\/10{background-color:color-mix(in oklab, var(--color-teal-500) 10%, transparent)}}.bg-teal-600{background-color:var(--color-teal-600)}.bg-transparent{background-color:#0000}.bg-violet-50{background-color:var(--color-violet-50)}.bg-violet-100{background-color:var(--color-violet-100)}.bg-violet-600{background-color:var(--color-violet-600)}.bg-white{background-color:var(--color-white)}.bg-white\/10{background-color:#ffffff1a}@supports (color:color-mix(in lab, red, red)){.bg-white\/10{background-color:color-mix(in oklab, var(--color-white) 10%, transparent)}}.bg-white\/15{background-color:#ffffff26}@supports (color:color-mix(in lab, red, red)){.bg-white\/15{background-color:color-mix(in oklab, var(--color-white) 15%, transparent)}}.bg-white\/20{background-color:#fff3}@supports (color:color-mix(in lab, red, red)){.bg-white\/20{background-color:color-mix(in oklab, var(--color-white) 20%, transparent)}}.bg-white\/25{background-color:#ffffff40}@supports (color:color-mix(in lab, red, red)){.bg-white\/25{background-color:color-mix(in oklab, var(--color-white) 25%, transparent)}}.bg-white\/50{background-color:#ffffff80}@supports (color:color-mix(in lab, red, red)){.bg-white\/50{background-color:color-mix(in oklab, var(--color-white) 50%, transparent)}}.bg-white\/60{background-color:#fff9}@supports (color:color-mix(in lab, red, red)){.bg-white\/60{background-color:color-mix(in oklab, var(--color-white) 60%, transparent)}}.bg-white\/70{background-color:#ffffffb3}@supports (color:color-mix(in lab, red, red)){.bg-white\/70{background-color:color-mix(in oklab, var(--color-white) 70%, transparent)}}.bg-white\/80{background-color:#fffc}@supports (color:color-mix(in lab, red, red)){.bg-white\/80{background-color:color-mix(in oklab, var(--color-white) 80%, transparent)}}.bg-white\/90{background-color:#ffffffe6}@supports (color:color-mix(in lab, red, red)){.bg-white\/90{background-color:color-mix(in oklab, var(--color-white) 90%, transparent)}}.bg-white\/95{background-color:#fffffff2}@supports (color:color-mix(in lab, red, red)){.bg-white\/95{background-color:color-mix(in oklab, var(--color-white) 95%, transparent)}}.bg-yellow-50{background-color:var(--color-yellow-50)}.bg-yellow-100{background-color:var(--color-yellow-100)}.bg-yellow-700\/50{background-color:#a3610080}@supports (color:color-mix(in lab, red, red)){.bg-yellow-700\/50{background-color:color-mix(in oklab, var(--color-yellow-700) 50%, transparent)}}.bg-yellow-900\/40{background-color:#733e0a66}@supports (color:color-mix(in lab, red, red)){.bg-yellow-900\/40{background-color:color-mix(in oklab, var(--color-yellow-900) 40%, transparent)}}.bg-zinc-50{background-color:var(--color-zinc-50)}.bg-zinc-50\!{background-color:var(--color-zinc-50)!important}.bg-zinc-100{background-color:var(--color-zinc-100)}.bg-zinc-200{background-color:var(--color-zinc-200)}.bg-zinc-200\/30{background-color:#e4e4e74d}@supports (color:color-mix(in lab, red, red)){.bg-zinc-200\/30{background-color:color-mix(in oklab, var(--color-zinc-200) 30%, transparent)}}.bg-zinc-600{background-color:var(--color-zinc-600)}.bg-zinc-700{background-color:var(--color-zinc-700)}.bg-zinc-800{background-color:var(--color-zinc-800)}.bg-zinc-900{background-color:var(--color-zinc-900)}.bg-zinc-950{background-color:var(--color-zinc-950)}.bg-zinc-950\/5{background-color:#09090b0d}@supports (color:color-mix(in lab, red, red)){.bg-zinc-950\/5{background-color:color-mix(in oklab, var(--color-zinc-950) 5%, transparent)}}.bg-zinc-950\/15{background-color:#09090b26}@supports (color:color-mix(in lab, red, red)){.bg-zinc-950\/15{background-color:color-mix(in oklab, var(--color-zinc-950) 15%, transparent)}}.bg-zinc-950\/90{background-color:#09090be6}@supports (color:color-mix(in lab, red, red)){.bg-zinc-950\/90{background-color:color-mix(in oklab, var(--color-zinc-950) 90%, transparent)}}.bg-linear-to-b{--tw-gradient-position:to bottom}@supports (background-image:linear-gradient(in lab, red, red)){.bg-linear-to-b{--tw-gradient-position:to bottom in oklab}}.bg-linear-to-b{background-image:linear-gradient(var(--tw-gradient-stops))}.bg-linear-to-br{--tw-gradient-position:to bottom right}@supports (background-image:linear-gradient(in lab, red, red)){.bg-linear-to-br{--tw-gradient-position:to bottom right in oklab}}.bg-linear-to-br{background-image:linear-gradient(var(--tw-gradient-stops))}.bg-linear-to-r{--tw-gradient-position:to right}@supports (background-image:linear-gradient(in lab, red, red)){.bg-linear-to-r{--tw-gradient-position:to right in oklab}}.bg-linear-to-r{background-image:linear-gradient(var(--tw-gradient-stops))}.bg-gradient-to-b{--tw-gradient-position:to bottom in oklab;background-image:linear-gradient(var(--tw-gradient-stops))}.from-blue-500{--tw-gradient-from:var(--color-blue-500);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.from-emerald-500{--tw-gradient-from:var(--color-emerald-500);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.from-purple-500{--tw-gradient-from:var(--color-purple-500);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.from-rose-500{--tw-gradient-from:var(--color-rose-500);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.from-slate-50{--tw-gradient-from:var(--color-slate-50);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.from-slate-100{--tw-gradient-from:var(--color-slate-100);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.from-transparent{--tw-gradient-from:transparent;--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.via-gray-200{--tw-gradient-via:var(--color-gray-200);--tw-gradient-via-stops:var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-via) var(--tw-gradient-via-position), var(--tw-gradient-to) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-via-stops)}.via-slate-100{--tw-gradient-via:var(--color-slate-100);--tw-gradient-via-stops:var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-via) var(--tw-gradient-via-position), var(--tw-gradient-to) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-via-stops)}.to-emerald-500{--tw-gradient-to:var(--color-emerald-500);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.to-indigo-500{--tw-gradient-to:var(--color-indigo-500);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.to-purple-500{--tw-gradient-to:var(--color-purple-500);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.to-rose-500{--tw-gradient-to:var(--color-rose-500);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.to-slate-200{--tw-gradient-to:var(--color-slate-200);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.to-transparent{--tw-gradient-to:transparent;--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.to-white{--tw-gradient-to:var(--color-white);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.box-decoration-clone{-webkit-box-decoration-break:clone;box-decoration-break:clone}.box-decoration-slice{-webkit-box-decoration-break:slice;box-decoration-break:slice}.decoration-clone{-webkit-box-decoration-break:clone;box-decoration-break:clone}.decoration-slice{-webkit-box-decoration-break:slice;box-decoration-break:slice}.bg-fixed{background-attachment:fixed}.bg-local{background-attachment:local}.bg-scroll{background-attachment:scroll}.bg-clip-border{background-clip:border-box}.bg-clip-content{background-clip:content-box}.bg-clip-padding{background-clip:padding-box}.bg-clip-text{-webkit-background-clip:text;background-clip:text}.bg-no-repeat{background-repeat:no-repeat}.bg-repeat{background-repeat:repeat}.bg-repeat-round{background-repeat:round}.bg-repeat-space{background-repeat:space}.bg-repeat-x{background-repeat:repeat-x}.bg-repeat-y{background-repeat:repeat-y}.bg-origin-border{background-origin:border-box}.bg-origin-content{background-origin:content-box}.bg-origin-padding{background-origin:padding-box}.mask-repeat{-webkit-mask-repeat:repeat;mask-repeat:repeat}.object-contain{object-fit:contain}.object-cover{object-fit:cover}.object-fill{object-fit:fill}.object-none{object-fit:none}.object-scale-down{object-fit:scale-down}.object-center{object-position:center}.p-0{padding:0}.p-0\!{padding:0!important}.p-0\.5{padding:calc(var(--spacing) * .5)}.p-1{padding:var(--spacing)}.p-1\.5{padding:calc(var(--spacing) * 1.5)}.p-1\.5\!{padding:calc(var(--spacing) * 1.5)!important}.p-2{padding:calc(var(--spacing) * 2)}.p-2\!{padding:calc(var(--spacing) * 2)!important}.p-2\.5{padding:calc(var(--spacing) * 2.5)}.p-2\.5\!{padding:calc(var(--spacing) * 2.5)!important}.p-3{padding:calc(var(--spacing) * 3)}.p-3\!{padding:calc(var(--spacing) * 3)!important}.p-3\.5{padding:calc(var(--spacing) * 3.5)}.p-4{padding:calc(var(--spacing) * 4)}.p-4\!{padding:calc(var(--spacing) * 4)!important}.p-5{padding:calc(var(--spacing) * 5)}.p-6{padding:calc(var(--spacing) * 6)}.p-7{padding:calc(var(--spacing) * 7)}.p-8{padding:calc(var(--spacing) * 8)}.p-8\!{padding:calc(var(--spacing) * 8)!important}.p-12{padding:calc(var(--spacing) * 12)}.p-\[10\%\]{padding:10%}.px-0{padding-inline:0}.px-0\.5{padding-inline:calc(var(--spacing) * .5)}.px-1{padding-inline:var(--spacing)}.px-1\!{padding-inline:var(--spacing)!important}.px-1\.5{padding-inline:calc(var(--spacing) * 1.5)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-2\!{padding-inline:calc(var(--spacing) * 2)!important}.px-2\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-2\.5\!{padding-inline:calc(var(--spacing) * 2.5)!important}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-3\!{padding-inline:calc(var(--spacing) * 3)!important}.px-3\.5{padding-inline:calc(var(--spacing) * 3.5)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-4\!{padding-inline:calc(var(--spacing) * 4)!important}.px-5{padding-inline:calc(var(--spacing) * 5)}.px-5\!{padding-inline:calc(var(--spacing) * 5)!important}.px-6{padding-inline:calc(var(--spacing) * 6)}.px-6\!{padding-inline:calc(var(--spacing) * 6)!important}.px-8\!{padding-inline:calc(var(--spacing) * 8)!important}.px-10\!{padding-inline:calc(var(--spacing) * 10)!important}.py-0{padding-block:0}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-0\.5\!{padding-block:calc(var(--spacing) * .5)!important}.py-1{padding-block:var(--spacing)}.py-1\!{padding-block:var(--spacing)!important}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-1\.5\!{padding-block:calc(var(--spacing) * 1.5)!important}.py-2{padding-block:calc(var(--spacing) * 2)}.py-2\!{padding-block:calc(var(--spacing) * 2)!important}.py-2\.5{padding-block:calc(var(--spacing) * 2.5)}.py-2\.5\!{padding-block:calc(var(--spacing) * 2.5)!important}.py-3{padding-block:calc(var(--spacing) * 3)}.py-3\!{padding-block:calc(var(--spacing) * 3)!important}.py-3\.5{padding-block:calc(var(--spacing) * 3.5)}.py-4{padding-block:calc(var(--spacing) * 4)}.py-5{padding-block:calc(var(--spacing) * 5)}.py-6{padding-block:calc(var(--spacing) * 6)}.py-8{padding-block:calc(var(--spacing) * 8)}.py-10{padding-block:calc(var(--spacing) * 10)}.py-12{padding-block:calc(var(--spacing) * 12)}.py-16{padding-block:calc(var(--spacing) * 16)}.py-20{padding-block:calc(var(--spacing) * 20)}.pt-0\.5{padding-top:calc(var(--spacing) * .5)}.pt-1{padding-top:var(--spacing)}.pt-1\.5{padding-top:calc(var(--spacing) * 1.5)}.pt-2{padding-top:calc(var(--spacing) * 2)}.pt-3{padding-top:calc(var(--spacing) * 3)}.pt-4{padding-top:calc(var(--spacing) * 4)}.pt-6{padding-top:calc(var(--spacing) * 6)}.pt-7{padding-top:calc(var(--spacing) * 7)}.pt-8{padding-top:calc(var(--spacing) * 8)}.pt-11{padding-top:calc(var(--spacing) * 11)}.pt-16{padding-top:calc(var(--spacing) * 16)}.pt-\[env\(safe-area-inset-top\,0px\)\]{padding-top:env(safe-area-inset-top,0px)}.pt-\[max\(0\.5rem\,env\(safe-area-inset-top\)\)\]{padding-top:max(.5rem, env(safe-area-inset-top))}.pt-\[max\(0\.75rem\,env\(safe-area-inset-top\)\)\]{padding-top:max(.75rem, env(safe-area-inset-top))}.pt-\[max\(1rem\,env\(safe-area-inset-top\)\)\]{padding-top:max(1rem, env(safe-area-inset-top))}.pr-0{padding-right:0}.pr-0\.5{padding-right:calc(var(--spacing) * .5)}.pr-1{padding-right:var(--spacing)}.pr-1\.5{padding-right:calc(var(--spacing) * 1.5)}.pr-2{padding-right:calc(var(--spacing) * 2)}.pr-2\.5{padding-right:calc(var(--spacing) * 2.5)}.pr-3{padding-right:calc(var(--spacing) * 3)}.pr-3\!{padding-right:calc(var(--spacing) * 3)!important}.pr-4{padding-right:calc(var(--spacing) * 4)}.pr-7{padding-right:calc(var(--spacing) * 7)}.pr-8{padding-right:calc(var(--spacing) * 8)}.pr-9{padding-right:calc(var(--spacing) * 9)}.pr-10{padding-right:calc(var(--spacing) * 10)}.pr-12{padding-right:calc(var(--spacing) * 12)}.pr-12\!{padding-right:calc(var(--spacing) * 12)!important}.pr-16{padding-right:calc(var(--spacing) * 16)}.pr-28{padding-right:calc(var(--spacing) * 28)}.pb-1{padding-bottom:var(--spacing)}.pb-1\.5{padding-bottom:calc(var(--spacing) * 1.5)}.pb-2{padding-bottom:calc(var(--spacing) * 2)}.pb-3{padding-bottom:calc(var(--spacing) * 3)}.pb-4{padding-bottom:calc(var(--spacing) * 4)}.pb-5{padding-bottom:calc(var(--spacing) * 5)}.pb-6{padding-bottom:calc(var(--spacing) * 6)}.pb-7{padding-bottom:calc(var(--spacing) * 7)}.pb-16{padding-bottom:calc(var(--spacing) * 16)}.pb-20{padding-bottom:calc(var(--spacing) * 20)}.pb-\[env\(safe-area-inset-bottom\)\]{padding-bottom:env(safe-area-inset-bottom)}.pb-\[max\(0\.5rem\,env\(safe-area-inset-bottom\)\)\]{padding-bottom:max(.5rem, env(safe-area-inset-bottom))}.pb-\[max\(0\.25rem\,env\(safe-area-inset-bottom\)\)\]{padding-bottom:max(.25rem, env(safe-area-inset-bottom))}.pb-\[max\(0\.75rem\,env\(safe-area-inset-bottom\)\)\]{padding-bottom:max(.75rem, env(safe-area-inset-bottom))}.pb-\[max\(0px\,env\(safe-area-inset-bottom\)\)\]{padding-bottom:max(0px, env(safe-area-inset-bottom))}.pb-\[max\(1\.5rem\,env\(safe-area-inset-bottom\)\)\]{padding-bottom:max(1.5rem, env(safe-area-inset-bottom))}.pb-\[max\(1rem\,env\(safe-area-inset-bottom\)\)\]{padding-bottom:max(1rem, env(safe-area-inset-bottom))}.pl-0{padding-left:0}.pl-0\.5{padding-left:calc(var(--spacing) * .5)}.pl-1\.5{padding-left:calc(var(--spacing) * 1.5)}.pl-2{padding-left:calc(var(--spacing) * 2)}.pl-2\.5{padding-left:calc(var(--spacing) * 2.5)}.pl-3{padding-left:calc(var(--spacing) * 3)}.pl-4{padding-left:calc(var(--spacing) * 4)}.pl-5{padding-left:calc(var(--spacing) * 5)}.pl-6{padding-left:calc(var(--spacing) * 6)}.pl-8{padding-left:calc(var(--spacing) * 8)}.pl-9{padding-left:calc(var(--spacing) * 9)}.pl-9\!{padding-left:calc(var(--spacing) * 9)!important}.pl-10{padding-left:calc(var(--spacing) * 10)}.pl-10\!{padding-left:calc(var(--spacing) * 10)!important}.pl-11{padding-left:calc(var(--spacing) * 11)}.pl-11\!{padding-left:calc(var(--spacing) * 11)!important}.pl-12{padding-left:calc(var(--spacing) * 12)}.text-center{text-align:center}.text-end{text-align:end}.text-justify{text-align:justify}.text-left{text-align:left}.text-right{text-align:right}.text-start{text-align:start}.align-\[-0\.1em\]{vertical-align:-.1em}.align-baseline{vertical-align:baseline}.align-bottom{vertical-align:bottom}.align-middle{vertical-align:middle}.align-sub{vertical-align:sub}.align-super{vertical-align:super}.align-text-bottom{vertical-align:text-bottom}.align-text-top{vertical-align:text-top}.align-top{vertical-align:top}.font-mono{font-family:var(--font-mono)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.text-4xl{font-size:var(--text-4xl);line-height:var(--tw-leading,var(--text-4xl--line-height))}.text-5xl{font-size:var(--text-5xl);line-height:var(--tw-leading,var(--text-5xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-sm\!{font-size:var(--text-sm)!important;line-height:var(--tw-leading,var(--text-sm--line-height))!important}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-xs\!{font-size:var(--text-xs)!important;line-height:var(--tw-leading,var(--text-xs--line-height))!important}.text-\[0\.9em\]{font-size:.9em}.text-\[0\.85em\]{font-size:.85em}.text-\[8px\]{font-size:8px}.text-\[9px\]{font-size:9px}.text-\[9px\]\!{font-size:9px!important}.text-\[10px\]{font-size:10px}.text-\[10px\]\!{font-size:10px!important}.text-\[11px\]{font-size:11px}.text-\[11px\]\!{font-size:11px!important}.text-\[12px\]{font-size:12px}.leading-4{--tw-leading:calc(var(--spacing) * 4);line-height:calc(var(--spacing) * 4)}.leading-5{--tw-leading:calc(var(--spacing) * 5);line-height:calc(var(--spacing) * 5)}.leading-none{--tw-leading:1;line-height:1}.leading-normal{--tw-leading:var(--leading-normal);line-height:var(--leading-normal)}.leading-relaxed{--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}.leading-snug{--tw-leading:var(--leading-snug);line-height:var(--leading-snug)}.leading-tight{--tw-leading:var(--leading-tight);line-height:var(--leading-tight)}.font-black{--tw-font-weight:var(--font-weight-black);font-weight:var(--font-weight-black)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-\[0\.2em\]{--tw-tracking:.2em;letter-spacing:.2em}.tracking-\[0\.3em\]{--tw-tracking:.3em;letter-spacing:.3em}.tracking-normal{--tw-tracking:var(--tracking-normal);letter-spacing:var(--tracking-normal)}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.tracking-tighter{--tw-tracking:var(--tracking-tighter);letter-spacing:var(--tracking-tighter)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.tracking-wider{--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider)}.tracking-widest{--tw-tracking:var(--tracking-widest);letter-spacing:var(--tracking-widest)}.tracking-widest\!{--tw-tracking:var(--tracking-widest)!important;letter-spacing:var(--tracking-widest)!important}.text-balance{text-wrap:balance}.text-nowrap{text-wrap:nowrap}.text-pretty{text-wrap:pretty}.text-wrap{text-wrap:wrap}.break-normal{overflow-wrap:normal;word-break:normal}.break-words,.wrap-break-word{overflow-wrap:break-word}.\[word-break\:break-word\]{word-break:break-word}.break-all{word-break:break-all}.break-keep{word-break:keep-all}.overflow-ellipsis{text-overflow:ellipsis}.text-clip{text-overflow:clip}.text-ellipsis{text-overflow:ellipsis}.hyphens-auto{-webkit-hyphens:auto;hyphens:auto}.hyphens-manual{-webkit-hyphens:manual;hyphens:manual}.hyphens-none{-webkit-hyphens:none;hyphens:none}.whitespace-break-spaces{white-space:break-spaces}.whitespace-normal{white-space:normal}.whitespace-nowrap{white-space:nowrap}.whitespace-pre{white-space:pre}.whitespace-pre-line{white-space:pre-line}.whitespace-pre-wrap{white-space:pre-wrap}.tab-1{tab-size:1}.text-\[inherit\]{color:inherit}.text-\[var\(--mc-text-secondary\)\]{color:var(--mc-text-secondary)}.text-amber-200{color:var(--color-amber-200)}.text-amber-200\/95{color:#fee685f2}@supports (color:color-mix(in lab, red, red)){.text-amber-200\/95{color:color-mix(in oklab, var(--color-amber-200) 95%, transparent)}}.text-amber-400{color:var(--color-amber-400)}.text-amber-500{color:var(--color-amber-500)}.text-amber-600{color:var(--color-amber-600)}.text-amber-700{color:var(--color-amber-700)}.text-amber-700\/90{color:#b75000e6}@supports (color:color-mix(in lab, red, red)){.text-amber-700\/90{color:color-mix(in oklab, var(--color-amber-700) 90%, transparent)}}.text-amber-800{color:var(--color-amber-800)}.text-amber-800\/80{color:#953d00cc}@supports (color:color-mix(in lab, red, red)){.text-amber-800\/80{color:color-mix(in oklab, var(--color-amber-800) 80%, transparent)}}.text-amber-800\/90{color:#953d00e6}@supports (color:color-mix(in lab, red, red)){.text-amber-800\/90{color:color-mix(in oklab, var(--color-amber-800) 90%, transparent)}}.text-amber-900{color:var(--color-amber-900)}.text-amber-900\/80{color:#7b3306cc}@supports (color:color-mix(in lab, red, red)){.text-amber-900\/80{color:color-mix(in oklab, var(--color-amber-900) 80%, transparent)}}.text-amber-950{color:var(--color-amber-950)}.text-amber-950\/90{color:#461901e6}@supports (color:color-mix(in lab, red, red)){.text-amber-950\/90{color:color-mix(in oklab, var(--color-amber-950) 90%, transparent)}}.text-black{color:var(--color-black)}.text-blue-300{color:var(--color-blue-300)}.text-blue-400{color:var(--color-blue-400)}.text-blue-500{color:var(--color-blue-500)}.text-blue-600{color:var(--color-blue-600)}.text-blue-600\/80{color:#155dfccc}@supports (color:color-mix(in lab, red, red)){.text-blue-600\/80{color:color-mix(in oklab, var(--color-blue-600) 80%, transparent)}}.text-blue-700{color:var(--color-blue-700)}.text-blue-800{color:var(--color-blue-800)}.text-blue-800\/80{color:#193cb8cc}@supports (color:color-mix(in lab, red, red)){.text-blue-800\/80{color:color-mix(in oklab, var(--color-blue-800) 80%, transparent)}}.text-blue-900{color:var(--color-blue-900)}.text-blue-950{color:var(--color-blue-950)}.text-blue-950\/90{color:#162456e6}@supports (color:color-mix(in lab, red, red)){.text-blue-950\/90{color:color-mix(in oklab, var(--color-blue-950) 90%, transparent)}}.text-cyan-500{color:var(--color-cyan-500)}.text-cyan-600{color:var(--color-cyan-600)}.text-cyan-700{color:var(--color-cyan-700)}.text-emerald-300{color:var(--color-emerald-300)}.text-emerald-400{color:var(--color-emerald-400)}.text-emerald-500{color:var(--color-emerald-500)}.text-emerald-500\/80{color:#00bb7fcc}@supports (color:color-mix(in lab, red, red)){.text-emerald-500\/80{color:color-mix(in oklab, var(--color-emerald-500) 80%, transparent)}}.text-emerald-600{color:var(--color-emerald-600)}.text-emerald-600\/80{color:#009767cc}@supports (color:color-mix(in lab, red, red)){.text-emerald-600\/80{color:color-mix(in oklab, var(--color-emerald-600) 80%, transparent)}}.text-emerald-700{color:var(--color-emerald-700)}.text-emerald-800{color:var(--color-emerald-800)}.text-emerald-800\/80{color:#005f46cc}@supports (color:color-mix(in lab, red, red)){.text-emerald-800\/80{color:color-mix(in oklab, var(--color-emerald-800) 80%, transparent)}}.text-gray-100{color:var(--color-gray-100)}.text-gray-200{color:var(--color-gray-200)}.text-gray-200\/90{color:#e5e7ebe6}@supports (color:color-mix(in lab, red, red)){.text-gray-200\/90{color:color-mix(in oklab, var(--color-gray-200) 90%, transparent)}}.text-gray-300{color:var(--color-gray-300)}.text-gray-400{color:var(--color-gray-400)}.text-gray-500{color:var(--color-gray-500)}.text-gray-500\/95{color:#6a7282f2}@supports (color:color-mix(in lab, red, red)){.text-gray-500\/95{color:color-mix(in oklab, var(--color-gray-500) 95%, transparent)}}.text-gray-600{color:var(--color-gray-600)}.text-gray-700{color:var(--color-gray-700)}.text-gray-800{color:var(--color-gray-800)}.text-gray-900{color:var(--color-gray-900)}.text-green-400{color:var(--color-green-400)}.text-green-500{color:var(--color-green-500)}.text-green-600{color:var(--color-green-600)}.text-green-700{color:var(--color-green-700)}.text-green-800{color:var(--color-green-800)}.text-indigo-500{color:var(--color-indigo-500)}.text-indigo-500\/80{color:#625fffcc}@supports (color:color-mix(in lab, red, red)){.text-indigo-500\/80{color:color-mix(in oklab, var(--color-indigo-500) 80%, transparent)}}.text-indigo-600{color:var(--color-indigo-600)}.text-indigo-600\/90{color:#4f39f6e6}@supports (color:color-mix(in lab, red, red)){.text-indigo-600\/90{color:color-mix(in oklab, var(--color-indigo-600) 90%, transparent)}}.text-indigo-700{color:var(--color-indigo-700)}.text-indigo-700\/90{color:#432dd7e6}@supports (color:color-mix(in lab, red, red)){.text-indigo-700\/90{color:color-mix(in oklab, var(--color-indigo-700) 90%, transparent)}}.text-indigo-900\/90{color:#312c85e6}@supports (color:color-mix(in lab, red, red)){.text-indigo-900\/90{color:color-mix(in oklab, var(--color-indigo-900) 90%, transparent)}}.text-indigo-950{color:var(--color-indigo-950)}.text-inherit{color:inherit}.text-orange-200{color:var(--color-orange-200)}.text-orange-500{color:var(--color-orange-500)}.text-orange-600{color:var(--color-orange-600)}.text-orange-700{color:var(--color-orange-700)}.text-orange-800{color:var(--color-orange-800)}.text-pink-500{color:var(--color-pink-500)}.text-pink-600{color:var(--color-pink-600)}.text-pink-700{color:var(--color-pink-700)}.text-pink-800{color:var(--color-pink-800)}.text-purple-100{color:var(--color-purple-100)}.text-purple-200{color:var(--color-purple-200)}.text-purple-300{color:var(--color-purple-300)}.text-purple-300\/90{color:#d9b3ffe6}@supports (color:color-mix(in lab, red, red)){.text-purple-300\/90{color:color-mix(in oklab, var(--color-purple-300) 90%, transparent)}}.text-purple-400{color:var(--color-purple-400)}.text-purple-500{color:var(--color-purple-500)}.text-purple-600{color:var(--color-purple-600)}.text-red-400{color:var(--color-red-400)}.text-red-500{color:var(--color-red-500)}.text-red-500\!{color:var(--color-red-500)!important}.text-red-500\/60{color:#fb2c3699}@supports (color:color-mix(in lab, red, red)){.text-red-500\/60{color:color-mix(in oklab, var(--color-red-500) 60%, transparent)}}.text-red-600{color:var(--color-red-600)}.text-red-600\!{color:var(--color-red-600)!important}.text-red-700{color:var(--color-red-700)}.text-red-800{color:var(--color-red-800)}.text-red-800\/95{color:#9f0712f2}@supports (color:color-mix(in lab, red, red)){.text-red-800\/95{color:color-mix(in oklab, var(--color-red-800) 95%, transparent)}}.text-red-900{color:var(--color-red-900)}.text-rose-500{color:var(--color-rose-500)}.text-rose-600{color:var(--color-rose-600)}.text-sky-500{color:var(--color-sky-500)}.text-sky-600{color:var(--color-sky-600)}.text-sky-600\/70{color:#0084ccb3}@supports (color:color-mix(in lab, red, red)){.text-sky-600\/70{color:color-mix(in oklab, var(--color-sky-600) 70%, transparent)}}.text-sky-700{color:var(--color-sky-700)}.text-sky-700\/90{color:#0069a4e6}@supports (color:color-mix(in lab, red, red)){.text-sky-700\/90{color:color-mix(in oklab, var(--color-sky-700) 90%, transparent)}}.text-sky-800{color:var(--color-sky-800)}.text-sky-800\/75{color:#005986bf}@supports (color:color-mix(in lab, red, red)){.text-sky-800\/75{color:color-mix(in oklab, var(--color-sky-800) 75%, transparent)}}.text-sky-900{color:var(--color-sky-900)}.text-sky-900\/80{color:#024a70cc}@supports (color:color-mix(in lab, red, red)){.text-sky-900\/80{color:color-mix(in oklab, var(--color-sky-900) 80%, transparent)}}.text-slate-400{color:var(--color-slate-400)}.text-slate-500{color:var(--color-slate-500)}.text-slate-600{color:var(--color-slate-600)}.text-slate-700{color:var(--color-slate-700)}.text-slate-800{color:var(--color-slate-800)}.text-slate-900{color:var(--color-slate-900)}.text-teal-500{color:var(--color-teal-500)}.text-teal-600{color:var(--color-teal-600)}.text-teal-600\!{color:var(--color-teal-600)!important}.text-teal-700{color:var(--color-teal-700)}.text-teal-800{color:var(--color-teal-800)}.text-violet-600{color:var(--color-violet-600)}.text-violet-700{color:var(--color-violet-700)}.text-violet-800{color:var(--color-violet-800)}.text-violet-900{color:var(--color-violet-900)}.text-violet-900\/80{color:#4d179acc}@supports (color:color-mix(in lab, red, red)){.text-violet-900\/80{color:color-mix(in oklab, var(--color-violet-900) 80%, transparent)}}.text-violet-900\/90{color:#4d179ae6}@supports (color:color-mix(in lab, red, red)){.text-violet-900\/90{color:color-mix(in oklab, var(--color-violet-900) 90%, transparent)}}.text-violet-950{color:var(--color-violet-950)}.text-white{color:var(--color-white)}.text-white\!{color:var(--color-white)!important}.text-white\/60{color:#fff9}@supports (color:color-mix(in lab, red, red)){.text-white\/60{color:color-mix(in oklab, var(--color-white) 60%, transparent)}}.text-white\/70{color:#ffffffb3}@supports (color:color-mix(in lab, red, red)){.text-white\/70{color:color-mix(in oklab, var(--color-white) 70%, transparent)}}.text-white\/75{color:#ffffffbf}@supports (color:color-mix(in lab, red, red)){.text-white\/75{color:color-mix(in oklab, var(--color-white) 75%, transparent)}}.text-white\/80{color:#fffc}@supports (color:color-mix(in lab, red, red)){.text-white\/80{color:color-mix(in oklab, var(--color-white) 80%, transparent)}}.text-white\/90{color:#ffffffe6}@supports (color:color-mix(in lab, red, red)){.text-white\/90{color:color-mix(in oklab, var(--color-white) 90%, transparent)}}.text-white\/95{color:#fffffff2}@supports (color:color-mix(in lab, red, red)){.text-white\/95{color:color-mix(in oklab, var(--color-white) 95%, transparent)}}.text-yellow-200{color:var(--color-yellow-200)}.text-yellow-400{color:var(--color-yellow-400)}.text-yellow-500{color:var(--color-yellow-500)}.text-yellow-600{color:var(--color-yellow-600)}.text-yellow-800{color:var(--color-yellow-800)}.text-yellow-900{color:var(--color-yellow-900)}.text-zinc-100{color:var(--color-zinc-100)}.text-zinc-300{color:var(--color-zinc-300)}.text-zinc-400{color:var(--color-zinc-400)}.text-zinc-500{color:var(--color-zinc-500)}.text-zinc-600{color:var(--color-zinc-600)}.text-zinc-700{color:var(--color-zinc-700)}.text-zinc-900{color:var(--color-zinc-900)}.capitalize{text-transform:capitalize}.lowercase{text-transform:lowercase}.normal-case{text-transform:none}.uppercase{text-transform:uppercase}.italic{font-style:italic}.not-italic{font-style:normal}.diagonal-fractions{--tw-numeric-fraction:diagonal-fractions;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.lining-nums{--tw-numeric-figure:lining-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.oldstyle-nums{--tw-numeric-figure:oldstyle-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.ordinal{--tw-ordinal:ordinal;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.proportional-nums{--tw-numeric-spacing:proportional-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.slashed-zero{--tw-slashed-zero:slashed-zero;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.stacked-fractions{--tw-numeric-fraction:stacked-fractions;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.normal-nums{font-variant-numeric:normal}.line-through{text-decoration-line:line-through}.no-underline{text-decoration-line:none}.overline{text-decoration-line:overline}.underline{text-decoration-line:underline}.decoration-dashed{text-decoration-style:dashed}.decoration-dotted{text-decoration-style:dotted}.decoration-double{text-decoration-style:double}.decoration-solid{text-decoration-style:solid}.decoration-wavy{text-decoration-style:wavy}.decoration-2{text-decoration-thickness:2px}.underline-offset-2{text-underline-offset:2px}.underline-offset-4{text-underline-offset:4px}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.subpixel-antialiased{-webkit-font-smoothing:auto;-moz-osx-font-smoothing:auto}.placeholder-gray-400::placeholder{color:var(--color-gray-400)}.placeholder-gray-500::placeholder{color:var(--color-gray-500)}.accent-blue-500{accent-color:var(--color-blue-500)}.accent-blue-600{accent-color:var(--color-blue-600)}.accent-emerald-500{accent-color:var(--color-emerald-500)}.opacity-0{opacity:0}.opacity-20{opacity:.2}.opacity-25{opacity:.25}.opacity-30{opacity:.3}.opacity-40{opacity:.4}.opacity-50{opacity:.5}.opacity-60{opacity:.6}.opacity-70{opacity:.7}.opacity-75{opacity:.75}.opacity-80{opacity:.8}.opacity-85{opacity:.85}.opacity-90{opacity:.9}.opacity-100{opacity:1}.opacity-100\!{opacity:1!important}.bg-blend-color{background-blend-mode:color}.bg-blend-color-burn{background-blend-mode:color-burn}.bg-blend-color-dodge{background-blend-mode:color-dodge}.bg-blend-darken{background-blend-mode:darken}.bg-blend-difference{background-blend-mode:difference}.bg-blend-exclusion{background-blend-mode:exclusion}.bg-blend-hard-light{background-blend-mode:hard-light}.bg-blend-hue{background-blend-mode:hue}.bg-blend-lighten{background-blend-mode:lighten}.bg-blend-luminosity{background-blend-mode:luminosity}.bg-blend-multiply{background-blend-mode:multiply}.bg-blend-normal{background-blend-mode:normal}.bg-blend-overlay{background-blend-mode:overlay}.bg-blend-saturation{background-blend-mode:saturation}.bg-blend-screen{background-blend-mode:screen}.bg-blend-soft-light{background-blend-mode:soft-light}.mix-blend-color{mix-blend-mode:color}.mix-blend-color-burn{mix-blend-mode:color-burn}.mix-blend-color-dodge{mix-blend-mode:color-dodge}.mix-blend-darken{mix-blend-mode:darken}.mix-blend-difference{mix-blend-mode:difference}.mix-blend-exclusion{mix-blend-mode:exclusion}.mix-blend-hard-light{mix-blend-mode:hard-light}.mix-blend-hue{mix-blend-mode:hue}.mix-blend-lighten{mix-blend-mode:lighten}.mix-blend-luminosity{mix-blend-mode:luminosity}.mix-blend-multiply{mix-blend-mode:multiply}.mix-blend-normal{mix-blend-mode:normal}.mix-blend-overlay{mix-blend-mode:overlay}.mix-blend-plus-darker{mix-blend-mode:plus-darker}.mix-blend-plus-lighter{mix-blend-mode:plus-lighter}.mix-blend-saturation{mix-blend-mode:saturation}.mix-blend-screen{mix-blend-mode:screen}.mix-blend-soft-light{mix-blend-mode:soft-light}.shadow{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-2xl{--tw-shadow:0 25px 50px -12px var(--tw-shadow-color,#00000040);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-\[0_0_8px_rgba\(59\,130\,246\,0\.5\)\]{--tw-shadow:0 0 8px var(--tw-shadow-color,#3b82f680);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-\[inset_2px_2px_5px_rgba\(0\,0\,0\,0\.12\)\,inset_-1px_-1px_2px_rgba\(255\,255\,255\,0\.6\)\]{--tw-shadow:inset 2px 2px 5px var(--tw-shadow-color,#0000001f), inset -1px -1px 2px var(--tw-shadow-color,#fff9);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-inner{--tw-shadow:inset 0 2px 4px 0 var(--tw-shadow-color,#0000000d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-lg\!{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a)!important;box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)!important}.shadow-md{--tw-shadow:0 4px 6px -1px var(--tw-shadow-color,#0000001a), 0 2px 4px -2px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-none{--tw-shadow:0 0 #0000;box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-xl{--tw-shadow:0 20px 25px -5px var(--tw-shadow-color,#0000001a), 0 8px 10px -6px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-xs{--tw-shadow:0 1px 2px 0 var(--tw-shadow-color,#0000000d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-0{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-1{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-2{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-4{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(4px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-amber-500\/30{--tw-shadow-color:#f99c004d}@supports (color:color-mix(in lab, red, red)){.shadow-amber-500\/30{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-amber-500) 30%, transparent) var(--tw-shadow-alpha), transparent)}}.shadow-amber-500\/40{--tw-shadow-color:#f99c0066}@supports (color:color-mix(in lab, red, red)){.shadow-amber-500\/40{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-amber-500) 40%, transparent) var(--tw-shadow-alpha), transparent)}}.shadow-blue-500\/10{--tw-shadow-color:#3080ff1a}@supports (color:color-mix(in lab, red, red)){.shadow-blue-500\/10{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-blue-500) 10%, transparent) var(--tw-shadow-alpha), transparent)}}.shadow-blue-500\/20{--tw-shadow-color:#3080ff33}@supports (color:color-mix(in lab, red, red)){.shadow-blue-500\/20{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-blue-500) 20%, transparent) var(--tw-shadow-alpha), transparent)}}.shadow-blue-500\/25{--tw-shadow-color:#3080ff40}@supports (color:color-mix(in lab, red, red)){.shadow-blue-500\/25{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-blue-500) 25%, transparent) var(--tw-shadow-alpha), transparent)}}.shadow-blue-500\/30{--tw-shadow-color:#3080ff4d}@supports (color:color-mix(in lab, red, red)){.shadow-blue-500\/30{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-blue-500) 30%, transparent) var(--tw-shadow-alpha), transparent)}}.shadow-blue-600\/20{--tw-shadow-color:#155dfc33}@supports (color:color-mix(in lab, red, red)){.shadow-blue-600\/20{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-blue-600) 20%, transparent) var(--tw-shadow-alpha), transparent)}}.shadow-blue-600\/30{--tw-shadow-color:#155dfc4d}@supports (color:color-mix(in lab, red, red)){.shadow-blue-600\/30{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-blue-600) 30%, transparent) var(--tw-shadow-alpha), transparent)}}.shadow-emerald-500\/20{--tw-shadow-color:#00bb7f33}@supports (color:color-mix(in lab, red, red)){.shadow-emerald-500\/20{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-emerald-500) 20%, transparent) var(--tw-shadow-alpha), transparent)}}.shadow-gray-200\/20{--tw-shadow-color:#e5e7eb33}@supports (color:color-mix(in lab, red, red)){.shadow-gray-200\/20{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-gray-200) 20%, transparent) var(--tw-shadow-alpha), transparent)}}.shadow-green-600\/20{--tw-shadow-color:#00a54433}@supports (color:color-mix(in lab, red, red)){.shadow-green-600\/20{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-green-600) 20%, transparent) var(--tw-shadow-alpha), transparent)}}.shadow-green-600\/30{--tw-shadow-color:#00a5444d}@supports (color:color-mix(in lab, red, red)){.shadow-green-600\/30{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-green-600) 30%, transparent) var(--tw-shadow-alpha), transparent)}}.shadow-indigo-500\/30{--tw-shadow-color:#625fff4d}@supports (color:color-mix(in lab, red, red)){.shadow-indigo-500\/30{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-indigo-500) 30%, transparent) var(--tw-shadow-alpha), transparent)}}.shadow-indigo-500\/50{--tw-shadow-color:#625fff80}@supports (color:color-mix(in lab, red, red)){.shadow-indigo-500\/50{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-indigo-500) 50%, transparent) var(--tw-shadow-alpha), transparent)}}.shadow-indigo-900\/5{--tw-shadow-color:#312c850d}@supports (color:color-mix(in lab, red, red)){.shadow-indigo-900\/5{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-indigo-900) 5%, transparent) var(--tw-shadow-alpha), transparent)}}.shadow-orange-500\/20{--tw-shadow-color:#fe6e0033}@supports (color:color-mix(in lab, red, red)){.shadow-orange-500\/20{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-orange-500) 20%, transparent) var(--tw-shadow-alpha), transparent)}}.shadow-red-500\/20{--tw-shadow-color:#fb2c3633}@supports (color:color-mix(in lab, red, red)){.shadow-red-500\/20{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-red-500) 20%, transparent) var(--tw-shadow-alpha), transparent)}}.shadow-red-500\/30{--tw-shadow-color:#fb2c364d}@supports (color:color-mix(in lab, red, red)){.shadow-red-500\/30{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-red-500) 30%, transparent) var(--tw-shadow-alpha), transparent)}}.shadow-red-600\/20{--tw-shadow-color:#e4001433}@supports (color:color-mix(in lab, red, red)){.shadow-red-600\/20{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-red-600) 20%, transparent) var(--tw-shadow-alpha), transparent)}}.shadow-red-600\/30{--tw-shadow-color:#e400144d}@supports (color:color-mix(in lab, red, red)){.shadow-red-600\/30{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-red-600) 30%, transparent) var(--tw-shadow-alpha), transparent)}}.shadow-slate-200\/50{--tw-shadow-color:#e2e8f080}@supports (color:color-mix(in lab, red, red)){.shadow-slate-200\/50{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-slate-200) 50%, transparent) var(--tw-shadow-alpha), transparent)}}.shadow-teal-600\/30{--tw-shadow-color:#0095884d}@supports (color:color-mix(in lab, red, red)){.shadow-teal-600\/30{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-teal-600) 30%, transparent) var(--tw-shadow-alpha), transparent)}}.ring-amber-400{--tw-ring-color:var(--color-amber-400)}.ring-amber-500{--tw-ring-color:var(--color-amber-500)}.ring-black\/10{--tw-ring-color:#0000001a}@supports (color:color-mix(in lab, red, red)){.ring-black\/10{--tw-ring-color:color-mix(in oklab, var(--color-black) 10%, transparent)}}.ring-blue-300{--tw-ring-color:var(--color-blue-300)}.ring-blue-400{--tw-ring-color:var(--color-blue-400)}.ring-blue-400\/30{--tw-ring-color:#54a2ff4d}@supports (color:color-mix(in lab, red, red)){.ring-blue-400\/30{--tw-ring-color:color-mix(in oklab, var(--color-blue-400) 30%, transparent)}}.ring-blue-400\/60{--tw-ring-color:#54a2ff99}@supports (color:color-mix(in lab, red, red)){.ring-blue-400\/60{--tw-ring-color:color-mix(in oklab, var(--color-blue-400) 60%, transparent)}}.ring-blue-500{--tw-ring-color:var(--color-blue-500)}.ring-blue-500\/20{--tw-ring-color:#3080ff33}@supports (color:color-mix(in lab, red, red)){.ring-blue-500\/20{--tw-ring-color:color-mix(in oklab, var(--color-blue-500) 20%, transparent)}}.ring-blue-500\/30{--tw-ring-color:#3080ff4d}@supports (color:color-mix(in lab, red, red)){.ring-blue-500\/30{--tw-ring-color:color-mix(in oklab, var(--color-blue-500) 30%, transparent)}}.ring-blue-500\/50{--tw-ring-color:#3080ff80}@supports (color:color-mix(in lab, red, red)){.ring-blue-500\/50{--tw-ring-color:color-mix(in oklab, var(--color-blue-500) 50%, transparent)}}.ring-blue-700\/10{--tw-ring-color:#1447e61a}@supports (color:color-mix(in lab, red, red)){.ring-blue-700\/10{--tw-ring-color:color-mix(in oklab, var(--color-blue-700) 10%, transparent)}}.ring-cyan-200\/80{--tw-ring-color:#a2f4fdcc}@supports (color:color-mix(in lab, red, red)){.ring-cyan-200\/80{--tw-ring-color:color-mix(in oklab, var(--color-cyan-200) 80%, transparent)}}.ring-emerald-500\/30{--tw-ring-color:#00bb7f4d}@supports (color:color-mix(in lab, red, red)){.ring-emerald-500\/30{--tw-ring-color:color-mix(in oklab, var(--color-emerald-500) 30%, transparent)}}.ring-gray-200{--tw-ring-color:var(--color-gray-200)}.ring-gray-200\/80{--tw-ring-color:#e5e7ebcc}@supports (color:color-mix(in lab, red, red)){.ring-gray-200\/80{--tw-ring-color:color-mix(in oklab, var(--color-gray-200) 80%, transparent)}}.ring-gray-300{--tw-ring-color:var(--color-gray-300)}.ring-gray-500\/30{--tw-ring-color:#6a72824d}@supports (color:color-mix(in lab, red, red)){.ring-gray-500\/30{--tw-ring-color:color-mix(in oklab, var(--color-gray-500) 30%, transparent)}}.ring-green-600\/20{--tw-ring-color:#00a54433}@supports (color:color-mix(in lab, red, red)){.ring-green-600\/20{--tw-ring-color:color-mix(in oklab, var(--color-green-600) 20%, transparent)}}.ring-indigo-400\/60{--tw-ring-color:#7d87ff99}@supports (color:color-mix(in lab, red, red)){.ring-indigo-400\/60{--tw-ring-color:color-mix(in oklab, var(--color-indigo-400) 60%, transparent)}}.ring-indigo-500{--tw-ring-color:var(--color-indigo-500)}.ring-indigo-500\/30{--tw-ring-color:#625fff4d}@supports (color:color-mix(in lab, red, red)){.ring-indigo-500\/30{--tw-ring-color:color-mix(in oklab, var(--color-indigo-500) 30%, transparent)}}.ring-red-200{--tw-ring-color:var(--color-red-200)}.ring-red-500{--tw-ring-color:var(--color-red-500)}.ring-white\/10{--tw-ring-color:#ffffff1a}@supports (color:color-mix(in lab, red, red)){.ring-white\/10{--tw-ring-color:color-mix(in oklab, var(--color-white) 10%, transparent)}}.ring-white\/90{--tw-ring-color:#ffffffe6}@supports (color:color-mix(in lab, red, red)){.ring-white\/90{--tw-ring-color:color-mix(in oklab, var(--color-white) 90%, transparent)}}.ring-offset-1{--tw-ring-offset-width:1px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.ring-offset-2{--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.ring-offset-white{--tw-ring-offset-color:var(--color-white)}.outline-hidden{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.outline-hidden{outline-offset:2px;outline:2px solid #0000}}.outline{outline-style:var(--tw-outline-style);outline-width:1px}.blur{--tw-blur:blur(8px);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.brightness-4{--tw-brightness:brightness(4%);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.brightness-6{--tw-brightness:brightness(6%);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.drop-shadow{--tw-drop-shadow-size:drop-shadow(0 1px 2px var(--tw-drop-shadow-color,#0000001a)) drop-shadow(0 1px 1px var(--tw-drop-shadow-color,#0000000f));--tw-drop-shadow:drop-shadow(0 1px 2px #0000001a) drop-shadow(0 1px 1px #0000000f);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.grayscale{--tw-grayscale:grayscale(100%);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.grayscale-\[0\.5\]{--tw-grayscale:grayscale(.5);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.invert{--tw-invert:invert(100%);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.sepia{--tw-sepia:sepia(100%);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter-none{filter:none}.backdrop-blur{--tw-backdrop-blur:blur(8px);-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-blur-\[0\.5px\]{--tw-backdrop-blur:blur(.5px);-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-blur-\[2px\]{--tw-backdrop-blur:blur(2px);-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-blur-md{--tw-backdrop-blur:blur(var(--blur-md));-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-blur-sm{--tw-backdrop-blur:blur(var(--blur-sm));-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-blur-xs{--tw-backdrop-blur:blur(var(--blur-xs));-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-grayscale{--tw-backdrop-grayscale:grayscale(100%);-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-invert{--tw-backdrop-invert:invert(100%);-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-sepia{--tw-backdrop-sepia:sepia(100%);-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-filter{-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-filter-none{-webkit-backdrop-filter:none;backdrop-filter:none}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[box-shadow\]{transition-property:box-shadow;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[left\]{transition-property:left;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[opacity\,background-color\,border-color\]{transition-property:opacity,background-color,border-color;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[width\]{transition-property:width;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-opacity{transition-property:opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-shadow{transition-property:box-shadow;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.duration-75{--tw-duration:75ms;transition-duration:75ms}.duration-100{--tw-duration:.1s;transition-duration:.1s}.duration-150{--tw-duration:.15s;transition-duration:.15s}.duration-200{--tw-duration:.2s;transition-duration:.2s}.duration-300{--tw-duration:.3s;transition-duration:.3s}.duration-500{--tw-duration:.5s;transition-duration:.5s}.duration-1000{--tw-duration:1s;transition-duration:1s}.ease-in{--tw-ease:var(--ease-in);transition-timing-function:var(--ease-in)}.ease-in-out{--tw-ease:var(--ease-in-out);transition-timing-function:var(--ease-in-out)}.ease-linear{--tw-ease:linear;transition-timing-function:linear}.ease-out{--tw-ease:var(--ease-out);transition-timing-function:var(--ease-out)}.contain-inline-size{--tw-contain-size:inline-size;contain:var(--tw-contain-size,) var(--tw-contain-layout,) var(--tw-contain-paint,) var(--tw-contain-style,)}.contain-layout{--tw-contain-layout:layout;contain:var(--tw-contain-size,) var(--tw-contain-layout,) var(--tw-contain-paint,) var(--tw-contain-style,)}.contain-paint{--tw-contain-paint:paint;contain:var(--tw-contain-size,) var(--tw-contain-layout,) var(--tw-contain-paint,) var(--tw-contain-style,)}.contain-size{--tw-contain-size:size;contain:var(--tw-contain-size,) var(--tw-contain-layout,) var(--tw-contain-paint,) var(--tw-contain-style,)}.contain-style{--tw-contain-style:style;contain:var(--tw-contain-size,) var(--tw-contain-layout,) var(--tw-contain-paint,) var(--tw-contain-style,)}.contain-\[layout_paint\]{contain:layout paint}.contain-content{contain:content}.contain-none{contain:none}.contain-strict{contain:strict}.forced-color-adjust-auto{forced-color-adjust:auto}.forced-color-adjust-none{forced-color-adjust:none}.outline-dashed{--tw-outline-style:dashed;outline-style:dashed}.outline-dotted{--tw-outline-style:dotted;outline-style:dotted}.outline-double{--tw-outline-style:double;outline-style:double}.outline-none{--tw-outline-style:none;outline-style:none}.select-all{-webkit-user-select:all;user-select:all}.select-auto{-webkit-user-select:auto;user-select:auto}.select-none{-webkit-user-select:none;user-select:none}.select-text{-webkit-user-select:text;user-select:text}.\[android\:prepare\]{android:prepare}.\[bench\:viz-wasm\]{bench:viz-wasm}.\[build-docs\:rns\]{build-docs:rns}.\[build\:all\]{build:all}.\[build\:fe\]{build:fe}.\[build\:frontend\]{build:frontend}.\[build\:viz-wasm\]{build:viz-wasm}.\[deps\:be\]{deps:be}.\[deps\:fe\]{deps:fe}.\[deps\:pins\]{deps:pins}.\[image\:tag\]{image:tag}.\[lint\:all\]{lint:all}.\[lint\:be\]{lint:be}.\[lint\:fe\]{lint:fe}.\[overflow-anchor\:none\]{overflow-anchor:none}.\[test\:all\]{test:all}.\[test\:fe\]{test:fe}.\[test\:viz-wasm\]{test:viz-wasm}:where(.divide-x-reverse>:not(:last-child)){--tw-divide-x-reverse:1}.ring-inset{--tw-ring-inset:inset}.group-open\:rotate-90:is(:where(.group):is([open],:popover-open,:open) *){rotate:90deg}.group-focus-within\:opacity-100:is(:where(.group):focus-within *){opacity:1}@media (hover:hover){.group-hover\:flex:is(:where(.group):hover *){display:flex}.group-hover\:h-4:is(:where(.group):hover *){height:calc(var(--spacing) * 4)}.group-hover\:scale-110:is(:where(.group):hover *){--tw-scale-x:110%;--tw-scale-y:110%;--tw-scale-z:110%;scale:var(--tw-scale-x) var(--tw-scale-y)}.group-hover\:text-blue-600:is(:where(.group):hover *){color:var(--color-blue-600)}.group-hover\:text-gray-600:is(:where(.group):hover *){color:var(--color-gray-600)}.group-hover\:text-gray-700:is(:where(.group):hover *){color:var(--color-gray-700)}.group-hover\:text-gray-900:is(:where(.group):hover *){color:var(--color-gray-900)}.group-hover\:opacity-20:is(:where(.group):hover *){opacity:.2}.group-hover\:opacity-100:is(:where(.group):hover *){opacity:1}.group-hover\:shadow-indigo-500\/20:is(:where(.group):hover *){--tw-shadow-color:#625fff33}@supports (color:color-mix(in lab, red, red)){.group-hover\:shadow-indigo-500\/20:is(:where(.group):hover *){--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-indigo-500) 20%, transparent) var(--tw-shadow-alpha), transparent)}}.group-hover\/image-modal\:opacity-100:is(:where(.group\/image-modal):hover *),.group-hover\/img\:opacity-100:is(:where(.group\/img):hover *){opacity:1}}.peer-checked\:bg-blue-600:is(:where(.peer):checked~*){background-color:var(--color-blue-600)}.peer-focus\:ring-4:is(:where(.peer):focus~*){--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(4px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.peer-focus\:ring-blue-300:is(:where(.peer):focus~*){--tw-ring-color:var(--color-blue-300)}.peer-focus\:outline-hidden:is(:where(.peer):focus~*){--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.peer-focus\:outline-hidden:is(:where(.peer):focus~*){outline-offset:2px;outline:2px solid #0000}}.file\:mr-4::file-selector-button{margin-right:calc(var(--spacing) * 4)}.file\:rounded-l-xl::file-selector-button{border-top-left-radius:var(--radius-xl);border-bottom-left-radius:var(--radius-xl)}.file\:border-0::file-selector-button{border-style:var(--tw-border-style);border-width:0}.file\:bg-blue-600::file-selector-button{background-color:var(--color-blue-600)}.file\:bg-zinc-200::file-selector-button{background-color:var(--color-zinc-200)}.file\:px-4::file-selector-button{padding-inline:calc(var(--spacing) * 4)}.file\:py-2::file-selector-button{padding-block:calc(var(--spacing) * 2)}.file\:py-2\.5::file-selector-button{padding-block:calc(var(--spacing) * 2.5)}.file\:text-sm::file-selector-button{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.file\:font-bold::file-selector-button{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.file\:font-semibold::file-selector-button{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.file\:text-white::file-selector-button{color:var(--color-white)}.file\:text-zinc-700::file-selector-button{color:var(--color-zinc-700)}.placeholder\:text-gray-400::placeholder{color:var(--color-gray-400)}.after\:absolute:after{content:var(--tw-content);position:absolute}.after\:top-\[2px\]:after{content:var(--tw-content);top:2px}.after\:left-\[2px\]:after{content:var(--tw-content);left:2px}.after\:h-5:after{content:var(--tw-content);height:calc(var(--spacing) * 5)}.after\:w-5:after{content:var(--tw-content);width:calc(var(--spacing) * 5)}.after\:rounded-full:after{content:var(--tw-content);border-radius:3.40282e38px}.after\:border:after{content:var(--tw-content);border-style:var(--tw-border-style);border-width:1px}.after\:border-gray-300:after{content:var(--tw-content);border-color:var(--color-gray-300)}.after\:bg-white:after{content:var(--tw-content);background-color:var(--color-white)}.after\:transition-all:after{content:var(--tw-content);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.after\:content-\[\'\'\]:after{--tw-content:"";content:var(--tw-content)}.peer-checked\:after\:translate-x-full:is(:where(.peer):checked~*):after{content:var(--tw-content);--tw-translate-x:100%;translate:var(--tw-translate-x) var(--tw-translate-y)}.peer-checked\:after\:border-white:is(:where(.peer):checked~*):after{content:var(--tw-content);border-color:var(--color-white)}.first\:mt-0:first-child{margin-top:0}.first\:pt-0:first-child{padding-top:0}.last\:border-0:last-child{border-style:var(--tw-border-style);border-width:0}.last\:border-b-0:last-child{border-bottom-style:var(--tw-border-style);border-bottom-width:0}.open\:bg-white:is([open],:popover-open,:open){background-color:var(--color-white)}.focus-within\:ring-2:focus-within{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}@media (hover:hover){.hover\:z-10:hover{z-index:10}.hover\:scale-105:hover{--tw-scale-x:105%;--tw-scale-y:105%;--tw-scale-z:105%;scale:var(--tw-scale-x) var(--tw-scale-y)}.hover\:scale-110:hover{--tw-scale-x:110%;--tw-scale-y:110%;--tw-scale-z:110%;scale:var(--tw-scale-x) var(--tw-scale-y)}.hover\:scale-\[1\.01\]:hover{scale:1.01}.hover\:scale-\[1\.02\]:hover{scale:1.02}.hover\:scale-\[1\.03\]:hover{scale:1.03}.hover\:border-amber-300:hover{border-color:var(--color-amber-300)}.hover\:border-amber-400:hover{border-color:var(--color-amber-400)}.hover\:border-amber-500:hover{border-color:var(--color-amber-500)}.hover\:border-blue-200:hover{border-color:var(--color-blue-200)}.hover\:border-blue-300:hover{border-color:var(--color-blue-300)}.hover\:border-blue-400:hover{border-color:var(--color-blue-400)}.hover\:border-blue-400\/60:hover{border-color:#54a2ff99}@supports (color:color-mix(in lab, red, red)){.hover\:border-blue-400\/60:hover{border-color:color-mix(in oklab, var(--color-blue-400) 60%, transparent)}}.hover\:border-blue-400\/70:hover{border-color:#54a2ffb3}@supports (color:color-mix(in lab, red, red)){.hover\:border-blue-400\/70:hover{border-color:color-mix(in oklab, var(--color-blue-400) 70%, transparent)}}.hover\:border-blue-500:hover{border-color:var(--color-blue-500)}.hover\:border-cyan-500:hover{border-color:var(--color-cyan-500)}.hover\:border-emerald-400:hover{border-color:var(--color-emerald-400)}.hover\:border-emerald-500:hover{border-color:var(--color-emerald-500)}.hover\:border-gray-300:hover{border-color:var(--color-gray-300)}.hover\:border-gray-400:hover{border-color:var(--color-gray-400)}.hover\:border-gray-500:hover{border-color:var(--color-gray-500)}.hover\:border-indigo-500:hover{border-color:var(--color-indigo-500)}.hover\:border-purple-500:hover{border-color:var(--color-purple-500)}.hover\:border-red-400:hover{border-color:var(--color-red-400)}.hover\:border-teal-500:hover{border-color:var(--color-teal-500)}.hover\:bg-\[var\(--mc-border-strong\)\]:hover{background-color:var(--mc-border-strong)}.hover\:bg-amber-50:hover{background-color:var(--color-amber-50)}.hover\:bg-amber-100:hover{background-color:var(--color-amber-100)}.hover\:bg-amber-200:hover{background-color:var(--color-amber-200)}.hover\:bg-amber-600:hover{background-color:var(--color-amber-600)}.hover\:bg-amber-700:hover{background-color:var(--color-amber-700)}.hover\:bg-amber-800:hover{background-color:var(--color-amber-800)}.hover\:bg-black\/5:hover{background-color:#0000000d}@supports (color:color-mix(in lab, red, red)){.hover\:bg-black\/5:hover{background-color:color-mix(in oklab, var(--color-black) 5%, transparent)}}.hover\:bg-black\/10:hover{background-color:#0000001a}@supports (color:color-mix(in lab, red, red)){.hover\:bg-black\/10:hover{background-color:color-mix(in oklab, var(--color-black) 10%, transparent)}}.hover\:bg-black\/55:hover{background-color:#0000008c}@supports (color:color-mix(in lab, red, red)){.hover\:bg-black\/55:hover{background-color:color-mix(in oklab, var(--color-black) 55%, transparent)}}.hover\:bg-black\/70:hover{background-color:#000000b3}@supports (color:color-mix(in lab, red, red)){.hover\:bg-black\/70:hover{background-color:color-mix(in oklab, var(--color-black) 70%, transparent)}}.hover\:bg-blue-50:hover{background-color:var(--color-blue-50)}.hover\:bg-blue-50\/30:hover{background-color:#eff6ff4d}@supports (color:color-mix(in lab, red, red)){.hover\:bg-blue-50\/30:hover{background-color:color-mix(in oklab, var(--color-blue-50) 30%, transparent)}}.hover\:bg-blue-50\/60:hover{background-color:#eff6ff99}@supports (color:color-mix(in lab, red, red)){.hover\:bg-blue-50\/60:hover{background-color:color-mix(in oklab, var(--color-blue-50) 60%, transparent)}}.hover\:bg-blue-100:hover{background-color:var(--color-blue-100)}.hover\:bg-blue-200:hover{background-color:var(--color-blue-200)}.hover\:bg-blue-500:hover{background-color:var(--color-blue-500)}.hover\:bg-blue-500\/15:hover{background-color:#3080ff26}@supports (color:color-mix(in lab, red, red)){.hover\:bg-blue-500\/15:hover{background-color:color-mix(in oklab, var(--color-blue-500) 15%, transparent)}}.hover\:bg-blue-500\/20:hover{background-color:#3080ff33}@supports (color:color-mix(in lab, red, red)){.hover\:bg-blue-500\/20:hover{background-color:color-mix(in oklab, var(--color-blue-500) 20%, transparent)}}.hover\:bg-blue-600:hover{background-color:var(--color-blue-600)}.hover\:bg-blue-700:hover{background-color:var(--color-blue-700)}.hover\:bg-emerald-50:hover{background-color:var(--color-emerald-50)}.hover\:bg-emerald-100:hover{background-color:var(--color-emerald-100)}.hover\:bg-emerald-500:hover{background-color:var(--color-emerald-500)}.hover\:bg-emerald-500\/20:hover{background-color:#00bb7f33}@supports (color:color-mix(in lab, red, red)){.hover\:bg-emerald-500\/20:hover{background-color:color-mix(in oklab, var(--color-emerald-500) 20%, transparent)}}.hover\:bg-emerald-600:hover{background-color:var(--color-emerald-600)}.hover\:bg-emerald-700:hover{background-color:var(--color-emerald-700)}.hover\:bg-gray-50:hover{background-color:var(--color-gray-50)}.hover\:bg-gray-50\/30:hover{background-color:#f9fafb4d}@supports (color:color-mix(in lab, red, red)){.hover\:bg-gray-50\/30:hover{background-color:color-mix(in oklab, var(--color-gray-50) 30%, transparent)}}.hover\:bg-gray-50\/50:hover{background-color:#f9fafb80}@supports (color:color-mix(in lab, red, red)){.hover\:bg-gray-50\/50:hover{background-color:color-mix(in oklab, var(--color-gray-50) 50%, transparent)}}.hover\:bg-gray-50\/80:hover{background-color:#f9fafbcc}@supports (color:color-mix(in lab, red, red)){.hover\:bg-gray-50\/80:hover{background-color:color-mix(in oklab, var(--color-gray-50) 80%, transparent)}}.hover\:bg-gray-100:hover{background-color:var(--color-gray-100)}.hover\:bg-gray-100\/80:hover{background-color:#f3f4f6cc}@supports (color:color-mix(in lab, red, red)){.hover\:bg-gray-100\/80:hover{background-color:color-mix(in oklab, var(--color-gray-100) 80%, transparent)}}.hover\:bg-gray-200:hover{background-color:var(--color-gray-200)}.hover\:bg-gray-200\/50:hover{background-color:#e5e7eb80}@supports (color:color-mix(in lab, red, red)){.hover\:bg-gray-200\/50:hover{background-color:color-mix(in oklab, var(--color-gray-200) 50%, transparent)}}.hover\:bg-gray-300:hover{background-color:var(--color-gray-300)}.hover\:bg-gray-400:hover{background-color:var(--color-gray-400)}.hover\:bg-gray-700:hover{background-color:var(--color-gray-700)}.hover\:bg-green-100:hover{background-color:var(--color-green-100)}.hover\:bg-green-500:hover{background-color:var(--color-green-500)}.hover\:bg-green-500\/20:hover{background-color:#00c75833}@supports (color:color-mix(in lab, red, red)){.hover\:bg-green-500\/20:hover{background-color:color-mix(in oklab, var(--color-green-500) 20%, transparent)}}.hover\:bg-green-700:hover{background-color:var(--color-green-700)}.hover\:bg-indigo-100:hover{background-color:var(--color-indigo-100)}.hover\:bg-indigo-200:hover{background-color:var(--color-indigo-200)}.hover\:bg-indigo-500:hover{background-color:var(--color-indigo-500)}.hover\:bg-orange-100:hover{background-color:var(--color-orange-100)}.hover\:bg-orange-500:hover{background-color:var(--color-orange-500)}.hover\:bg-orange-700:hover{background-color:var(--color-orange-700)}.hover\:bg-pink-100:hover{background-color:var(--color-pink-100)}.hover\:bg-purple-800\/60:hover{background-color:#6e11b099}@supports (color:color-mix(in lab, red, red)){.hover\:bg-purple-800\/60:hover{background-color:color-mix(in oklab, var(--color-purple-800) 60%, transparent)}}.hover\:bg-purple-900\/40:hover{background-color:#59168b66}@supports (color:color-mix(in lab, red, red)){.hover\:bg-purple-900\/40:hover{background-color:color-mix(in oklab, var(--color-purple-900) 40%, transparent)}}.hover\:bg-red-50:hover{background-color:var(--color-red-50)}.hover\:bg-red-50\!:hover{background-color:var(--color-red-50)!important}.hover\:bg-red-100:hover{background-color:var(--color-red-100)}.hover\:bg-red-200:hover{background-color:var(--color-red-200)}.hover\:bg-red-500:hover{background-color:var(--color-red-500)}.hover\:bg-red-600:hover{background-color:var(--color-red-600)}.hover\:bg-red-700:hover{background-color:var(--color-red-700)}.hover\:bg-rose-700:hover{background-color:var(--color-rose-700)}.hover\:bg-sky-700:hover{background-color:var(--color-sky-700)}.hover\:bg-sky-900\/10:hover{background-color:#024a701a}@supports (color:color-mix(in lab, red, red)){.hover\:bg-sky-900\/10:hover{background-color:color-mix(in oklab, var(--color-sky-900) 10%, transparent)}}.hover\:bg-sky-900\/14:hover{background-color:#024a7024}@supports (color:color-mix(in lab, red, red)){.hover\:bg-sky-900\/14:hover{background-color:color-mix(in oklab, var(--color-sky-900) 14%, transparent)}}.hover\:bg-slate-50:hover{background-color:var(--color-slate-50)}.hover\:bg-slate-100\/80:hover{background-color:#f1f5f9cc}@supports (color:color-mix(in lab, red, red)){.hover\:bg-slate-100\/80:hover{background-color:color-mix(in oklab, var(--color-slate-100) 80%, transparent)}}.hover\:bg-teal-100:hover{background-color:var(--color-teal-100)}.hover\:bg-teal-500:hover{background-color:var(--color-teal-500)}.hover\:bg-violet-100:hover{background-color:var(--color-violet-100)}.hover\:bg-violet-700:hover{background-color:var(--color-violet-700)}.hover\:bg-white:hover{background-color:var(--color-white)}.hover\:bg-white\/10:hover{background-color:#ffffff1a}@supports (color:color-mix(in lab, red, red)){.hover\:bg-white\/10:hover{background-color:color-mix(in oklab, var(--color-white) 10%, transparent)}}.hover\:bg-white\/20:hover{background-color:#fff3}@supports (color:color-mix(in lab, red, red)){.hover\:bg-white\/20:hover{background-color:color-mix(in oklab, var(--color-white) 20%, transparent)}}.hover\:bg-white\/25:hover{background-color:#ffffff40}@supports (color:color-mix(in lab, red, red)){.hover\:bg-white\/25:hover{background-color:color-mix(in oklab, var(--color-white) 25%, transparent)}}.hover\:bg-white\/30:hover{background-color:#ffffff4d}@supports (color:color-mix(in lab, red, red)){.hover\:bg-white\/30:hover{background-color:color-mix(in oklab, var(--color-white) 30%, transparent)}}.hover\:bg-white\/50:hover{background-color:#ffffff80}@supports (color:color-mix(in lab, red, red)){.hover\:bg-white\/50:hover{background-color:color-mix(in oklab, var(--color-white) 50%, transparent)}}.hover\:bg-white\/90:hover{background-color:#ffffffe6}@supports (color:color-mix(in lab, red, red)){.hover\:bg-white\/90:hover{background-color:color-mix(in oklab, var(--color-white) 90%, transparent)}}.hover\:bg-yellow-700:hover{background-color:var(--color-yellow-700)}.hover\:bg-zinc-600:hover{background-color:var(--color-zinc-600)}.hover\:bg-zinc-700:hover{background-color:var(--color-zinc-700)}.hover\:bg-zinc-800:hover{background-color:var(--color-zinc-800)}.hover\:text-amber-500:hover{color:var(--color-amber-500)}.hover\:text-amber-600:hover{color:var(--color-amber-600)}.hover\:text-amber-800:hover{color:var(--color-amber-800)}.hover\:text-blue-300:hover{color:var(--color-blue-300)}.hover\:text-blue-500:hover{color:var(--color-blue-500)}.hover\:text-blue-600:hover{color:var(--color-blue-600)}.hover\:text-blue-700:hover{color:var(--color-blue-700)}.hover\:text-cyan-600:hover{color:var(--color-cyan-600)}.hover\:text-emerald-600:hover{color:var(--color-emerald-600)}.hover\:text-gray-500:hover{color:var(--color-gray-500)}.hover\:text-gray-600:hover{color:var(--color-gray-600)}.hover\:text-gray-700:hover{color:var(--color-gray-700)}.hover\:text-gray-800:hover{color:var(--color-gray-800)}.hover\:text-gray-900:hover{color:var(--color-gray-900)}.hover\:text-green-500:hover{color:var(--color-green-500)}.hover\:text-green-600:hover{color:var(--color-green-600)}.hover\:text-indigo-500:hover{color:var(--color-indigo-500)}.hover\:text-purple-100:hover{color:var(--color-purple-100)}.hover\:text-purple-200:hover{color:var(--color-purple-200)}.hover\:text-red-400:hover{color:var(--color-red-400)}.hover\:text-red-500:hover{color:var(--color-red-500)}.hover\:text-red-600:hover{color:var(--color-red-600)}.hover\:text-teal-500:hover{color:var(--color-teal-500)}.hover\:text-violet-600:hover{color:var(--color-violet-600)}.hover\:text-white:hover{color:var(--color-white)}.hover\:text-zinc-200:hover{color:var(--color-zinc-200)}.hover\:underline:hover{text-decoration-line:underline}.hover\:opacity-70:hover{opacity:.7}.hover\:opacity-90:hover{opacity:.9}.hover\:opacity-100:hover{opacity:1}.hover\:shadow-2xl:hover{--tw-shadow:0 25px 50px -12px var(--tw-shadow-color,#00000040);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.hover\:shadow-md:hover{--tw-shadow:0 4px 6px -1px var(--tw-shadow-color,#0000001a), 0 2px 4px -2px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.hover\:shadow-xl:hover{--tw-shadow:0 20px 25px -5px var(--tw-shadow-color,#0000001a), 0 8px 10px -6px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.hover\:ring-2:hover{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.hover\:ring-blue-500\/50:hover{--tw-ring-color:#3080ff80}@supports (color:color-mix(in lab, red, red)){.hover\:ring-blue-500\/50:hover{--tw-ring-color:color-mix(in oklab, var(--color-blue-500) 50%, transparent)}}.hover\:file\:bg-blue-700:hover::file-selector-button{background-color:var(--color-blue-700)}.hover\:file\:bg-zinc-300:hover::file-selector-button{background-color:var(--color-zinc-300)}}.focus\:border-blue-400:focus{border-color:var(--color-blue-400)}.focus\:border-blue-500:focus{border-color:var(--color-blue-500)}.focus\:border-indigo-400:focus{border-color:var(--color-indigo-400)}.focus\:border-indigo-500:focus{border-color:var(--color-indigo-500)}.focus\:border-transparent:focus{border-color:#0000}.focus\:opacity-100:focus{opacity:1}.focus\:ring-0:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\:ring-1:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\:ring-2:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\:ring-amber-500:focus{--tw-ring-color:var(--color-amber-500)}.focus\:ring-blue-400:focus{--tw-ring-color:var(--color-blue-400)}.focus\:ring-blue-500:focus{--tw-ring-color:var(--color-blue-500)}.focus\:ring-blue-500\/20:focus{--tw-ring-color:#3080ff33}@supports (color:color-mix(in lab, red, red)){.focus\:ring-blue-500\/20:focus{--tw-ring-color:color-mix(in oklab, var(--color-blue-500) 20%, transparent)}}.focus\:ring-blue-500\/30:focus{--tw-ring-color:#3080ff4d}@supports (color:color-mix(in lab, red, red)){.focus\:ring-blue-500\/30:focus{--tw-ring-color:color-mix(in oklab, var(--color-blue-500) 30%, transparent)}}.focus\:ring-blue-500\/40:focus{--tw-ring-color:#3080ff66}@supports (color:color-mix(in lab, red, red)){.focus\:ring-blue-500\/40:focus{--tw-ring-color:color-mix(in oklab, var(--color-blue-500) 40%, transparent)}}.focus\:ring-blue-500\/50:focus{--tw-ring-color:#3080ff80}@supports (color:color-mix(in lab, red, red)){.focus\:ring-blue-500\/50:focus{--tw-ring-color:color-mix(in oklab, var(--color-blue-500) 50%, transparent)}}.focus\:ring-blue-500\/60:focus{--tw-ring-color:#3080ff99}@supports (color:color-mix(in lab, red, red)){.focus\:ring-blue-500\/60:focus{--tw-ring-color:color-mix(in oklab, var(--color-blue-500) 60%, transparent)}}.focus\:ring-blue-600:focus{--tw-ring-color:var(--color-blue-600)}.focus\:ring-emerald-500:focus{--tw-ring-color:var(--color-emerald-500)}.focus\:ring-indigo-400:focus{--tw-ring-color:var(--color-indigo-400)}.focus\:ring-indigo-500:focus{--tw-ring-color:var(--color-indigo-500)}.focus\:ring-red-500:focus{--tw-ring-color:var(--color-red-500)}.focus\:ring-white\/50:focus{--tw-ring-color:#ffffff80}@supports (color:color-mix(in lab, red, red)){.focus\:ring-white\/50:focus{--tw-ring-color:color-mix(in oklab, var(--color-white) 50%, transparent)}}.focus\:ring-offset-2:focus{--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.focus\:outline-hidden:focus{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.focus\:outline-hidden:focus{outline-offset:2px;outline:2px solid #0000}}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}.focus\:ring-inset:focus{--tw-ring-inset:inset}.focus-visible\:ring-1:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus-visible\:ring-2:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus-visible\:ring-blue-500:focus-visible{--tw-ring-color:var(--color-blue-500)}.focus-visible\:ring-blue-500\/50:focus-visible{--tw-ring-color:#3080ff80}@supports (color:color-mix(in lab, red, red)){.focus-visible\:ring-blue-500\/50:focus-visible{--tw-ring-color:color-mix(in oklab, var(--color-blue-500) 50%, transparent)}}.focus-visible\:ring-blue-500\/60:focus-visible{--tw-ring-color:#3080ff99}@supports (color:color-mix(in lab, red, red)){.focus-visible\:ring-blue-500\/60:focus-visible{--tw-ring-color:color-mix(in oklab, var(--color-blue-500) 60%, transparent)}}.focus-visible\:ring-indigo-500:focus-visible{--tw-ring-color:var(--color-indigo-500)}.focus-visible\:ring-white\/80:focus-visible{--tw-ring-color:#fffc}@supports (color:color-mix(in lab, red, red)){.focus-visible\:ring-white\/80:focus-visible{--tw-ring-color:color-mix(in oklab, var(--color-white) 80%, transparent)}}.focus-visible\:outline-2:focus-visible{outline-style:var(--tw-outline-style);outline-width:2px}.focus-visible\:outline-offset-2:focus-visible{outline-offset:2px}.focus-visible\:outline-blue-500:focus-visible{outline-color:var(--color-blue-500)}.focus-visible\:outline-gray-500:focus-visible{outline-color:var(--color-gray-500)}.focus-visible\:outline-solid:focus-visible{--tw-outline-style:solid;outline-style:solid}.active\:scale-90:active{--tw-scale-x:90%;--tw-scale-y:90%;--tw-scale-z:90%;scale:var(--tw-scale-x) var(--tw-scale-y)}.active\:scale-95:active{--tw-scale-x:95%;--tw-scale-y:95%;--tw-scale-z:95%;scale:var(--tw-scale-x) var(--tw-scale-y)}.active\:scale-\[0\.98\]:active{scale:.98}.active\:cursor-grabbing:active{cursor:grabbing}.active\:bg-gray-100:active{background-color:var(--color-gray-100)}.disabled\:pointer-events-none:disabled{pointer-events:none}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:bg-blue-300:disabled{background-color:var(--color-blue-300)}.disabled\:bg-gray-100:disabled{background-color:var(--color-gray-100)}.disabled\:bg-gray-300:disabled{background-color:var(--color-gray-300)}.disabled\:bg-gray-400:disabled{background-color:var(--color-gray-400)}.disabled\:text-gray-300:disabled{color:var(--color-gray-300)}.disabled\:opacity-30:disabled{opacity:.3}.disabled\:opacity-40:disabled{opacity:.4}.disabled\:opacity-50:disabled{opacity:.5}.disabled\:opacity-60:disabled{opacity:.6}@media (hover:hover){.disabled\:hover\:scale-100:disabled:hover{--tw-scale-x:100%;--tw-scale-y:100%;--tw-scale-z:100%;scale:var(--tw-scale-x) var(--tw-scale-y)}.disabled\:hover\:bg-violet-50:disabled:hover{background-color:var(--color-violet-50)}}@media (prefers-reduced-motion:reduce){.motion-reduce\:animate-none{animation:none}}@media not all and (min-width:40rem){.max-sm\:right-auto{right:auto}.max-sm\:bottom-22{bottom:calc(var(--spacing) * 22)}.max-sm\:bottom-\[calc\(1rem\+env\(safe-area-inset-bottom\,0px\)\)\]{bottom:calc(1rem + env(safe-area-inset-bottom,0px))}.max-sm\:bottom-\[calc\(5\.5rem\+env\(safe-area-inset-bottom\,0px\)\)\]{bottom:calc(5.5rem + env(safe-area-inset-bottom,0px))}.max-sm\:left-1\/2{left:50%}.max-sm\:h-\[92dvh\]{height:92dvh}.max-sm\:max-h-\[92dvh\]{max-height:92dvh}.max-sm\:min-h-\[55dvh\]{min-height:55dvh}.max-sm\:min-w-max{min-width:max-content}.max-sm\:-translate-x-1\/2{--tw-translate-x:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.max-sm\:flex-nowrap{flex-wrap:nowrap}.max-sm\:text-\[8px\]{font-size:8px}.max-sm\:text-\[9px\]{font-size:9px}.max-sm\:leading-snug{--tw-leading:var(--leading-snug);line-height:var(--leading-snug)}.max-sm\:tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.max-sm\:break-all{word-break:break-all}.max-sm\:whitespace-nowrap{white-space:nowrap}}@media (min-width:900px){.min-\[900px\]\:size-4{width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.min-\[900px\]\:size-5{width:calc(var(--spacing) * 5);height:calc(var(--spacing) * 5)}.min-\[900px\]\:h-9{height:calc(var(--spacing) * 9)}.min-\[900px\]\:w-9{width:calc(var(--spacing) * 9)}.min-\[900px\]\:max-w-\[14rem\]{max-width:14rem}.min-\[900px\]\:min-w-\[7rem\]{min-width:7rem}.min-\[900px\]\:px-3{padding-inline:calc(var(--spacing) * 3)}.min-\[900px\]\:text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}}@media (min-width:40rem){.sm\:relative{position:relative}.sm\:inset-auto{inset:auto}.sm\:top-2{top:calc(var(--spacing) * 2)}.sm\:top-4{top:calc(var(--spacing) * 4)}.sm\:right-0{right:0}.sm\:right-4{right:calc(var(--spacing) * 4)}.sm\:right-auto{right:auto}.sm\:bottom-4{bottom:calc(var(--spacing) * 4)}.sm\:bottom-5{bottom:calc(var(--spacing) * 5)}.sm\:left-4{left:calc(var(--spacing) * 4)}.sm\:left-auto{left:auto}.sm\:z-0{z-index:0}.sm\:z-auto{z-index:auto}.sm\:col-span-1{grid-column:span 1/span 1}.sm\:col-span-2{grid-column:span 2/span 2}.sm\:m-0{margin:0}.sm\:-mx-4{margin-inline:calc(var(--spacing) * -4)}.sm\:mx-0{margin-inline:0}.sm\:mx-1{margin-inline:var(--spacing)}.sm\:my-auto{margin-block:auto}.sm\:mt-0{margin-top:0}.sm\:mt-2{margin-top:calc(var(--spacing) * 2)}.sm\:mt-8{margin-top:calc(var(--spacing) * 8)}.sm\:mr-2{margin-right:calc(var(--spacing) * 2)}.sm\:mb-4{margin-bottom:calc(var(--spacing) * 4)}.sm\:ml-0{margin-left:0}.sm\:ml-1\.5{margin-left:calc(var(--spacing) * 1.5)}.sm\:ml-auto{margin-left:auto}.sm\:line-clamp-none{-webkit-line-clamp:unset;-webkit-box-orient:horizontal;display:block;overflow:visible}.sm\:block{display:block}.sm\:flex{display:flex}.sm\:hidden{display:none}.sm\:inline{display:inline}.sm\:inline-block{display:inline-block}.sm\:inline-flex{display:inline-flex}.sm\:size-5{width:calc(var(--spacing) * 5);height:calc(var(--spacing) * 5)}.sm\:size-5\!{width:calc(var(--spacing) * 5)!important;height:calc(var(--spacing) * 5)!important}.sm\:size-6{width:calc(var(--spacing) * 6);height:calc(var(--spacing) * 6)}.sm\:size-7{width:calc(var(--spacing) * 7);height:calc(var(--spacing) * 7)}.sm\:size-12{width:calc(var(--spacing) * 12);height:calc(var(--spacing) * 12)}.sm\:size-48{width:calc(var(--spacing) * 48);height:calc(var(--spacing) * 48)}.sm\:h-4{height:calc(var(--spacing) * 4)}.sm\:h-5{height:calc(var(--spacing) * 5)}.sm\:h-6{height:calc(var(--spacing) * 6)}.sm\:h-9{height:calc(var(--spacing) * 9)}.sm\:h-11{height:calc(var(--spacing) * 11)}.sm\:h-12{height:calc(var(--spacing) * 12)}.sm\:h-14{height:calc(var(--spacing) * 14)}.sm\:h-28{height:calc(var(--spacing) * 28)}.sm\:h-\[320px\]{height:320px}.sm\:max-h-\[90vh\]{max-height:90vh}.sm\:min-h-0{min-height:0}.sm\:min-h-14{min-height:calc(var(--spacing) * 14)}.sm\:min-h-\[12rem\]{min-height:12rem}.sm\:min-h-\[280px\]{min-height:280px}.sm\:w-4{width:calc(var(--spacing) * 4)}.sm\:w-5{width:calc(var(--spacing) * 5)}.sm\:w-6{width:calc(var(--spacing) * 6)}.sm\:w-9{width:calc(var(--spacing) * 9)}.sm\:w-11{width:calc(var(--spacing) * 11)}.sm\:w-12{width:calc(var(--spacing) * 12)}.sm\:w-14{width:calc(var(--spacing) * 14)}.sm\:w-24{width:calc(var(--spacing) * 24)}.sm\:w-40{width:calc(var(--spacing) * 40)}.sm\:w-64{width:calc(var(--spacing) * 64)}.sm\:w-80{width:calc(var(--spacing) * 80)}.sm\:w-\[280px\]{width:280px}.sm\:w-auto{width:auto}.sm\:max-w-3xl{max-width:var(--container-3xl)}.sm\:max-w-48{max-width:calc(var(--spacing) * 48)}.sm\:max-w-56{max-width:calc(var(--spacing) * 56)}.sm\:max-w-\[16rem\]{max-width:16rem}.sm\:max-w-\[75\%\]{max-width:75%}.sm\:max-w-\[280px\]{max-width:280px}.sm\:max-w-lg{max-width:var(--container-lg)}.sm\:max-w-md{max-width:var(--container-md)}.sm\:max-w-none{max-width:none}.sm\:max-w-sm{max-width:var(--container-sm)}.sm\:max-w-xs{max-width:var(--container-xs)}.sm\:min-w-0{min-width:0}.sm\:min-w-12{min-width:calc(var(--spacing) * 12)}.sm\:min-w-80{min-width:calc(var(--spacing) * 80)}.sm\:min-w-\[280px\]{min-width:280px}.sm\:min-w-\[300px\]{min-width:300px}.sm\:min-w-\[400px\]{min-width:400px}.sm\:flex-1{flex:1}.sm\:flex-initial{flex:0 auto}.sm\:flex-none{flex:none}.sm\:shrink-0{flex-shrink:0}.sm\:translate-x-0{--tw-translate-x:0px;translate:var(--tw-translate-x) var(--tw-translate-y)}.sm\:grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.sm\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.sm\:grid-cols-5{grid-template-columns:repeat(5,minmax(0,1fr))}.sm\:grid-cols-\[1fr_10rem\]{grid-template-columns:1fr 10rem}.sm\:grid-cols-\[1fr_auto\]{grid-template-columns:1fr auto}.sm\:grid-cols-\[auto_minmax\(0\,0\.75fr\)_minmax\(0\,1fr\)_minmax\(0\,1\.1fr\)\]{grid-template-columns:auto minmax(0,.75fr) minmax(0,1fr) minmax(0,1.1fr)}.sm\:grid-cols-\[minmax\(0\,1\.15fr\)_auto_minmax\(0\,1\.35fr\)_auto\]{grid-template-columns:minmax(0,1.15fr) auto minmax(0,1.35fr) auto}.sm\:flex-col{flex-direction:column}.sm\:flex-row{flex-direction:row}.sm\:flex-nowrap{flex-wrap:nowrap}.sm\:flex-wrap{flex-wrap:wrap}.sm\:items-baseline{align-items:baseline}.sm\:items-center{align-items:center}.sm\:items-end{align-items:flex-end}.sm\:items-start{align-items:flex-start}.sm\:justify-between{justify-content:space-between}.sm\:justify-center{justify-content:center}.sm\:justify-end{justify-content:flex-end}.sm\:justify-start{justify-content:flex-start}.sm\:gap-0{gap:0}.sm\:gap-0\.5{gap:calc(var(--spacing) * .5)}.sm\:gap-1{gap:var(--spacing)}.sm\:gap-1\.5{gap:calc(var(--spacing) * 1.5)}.sm\:gap-2{gap:calc(var(--spacing) * 2)}.sm\:gap-3{gap:calc(var(--spacing) * 3)}.sm\:gap-4{gap:calc(var(--spacing) * 4)}.sm\:gap-5{gap:calc(var(--spacing) * 5)}.sm\:gap-6{gap:calc(var(--spacing) * 6)}:where(.sm\:space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}:where(.sm\:space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}:where(.sm\:space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}.sm\:gap-x-3{column-gap:calc(var(--spacing) * 3)}:where(.sm\:space-x-2>:not(:last-child)){--tw-space-x-reverse:0;margin-inline-start:calc(calc(var(--spacing) * 2) * var(--tw-space-x-reverse));margin-inline-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-x-reverse)))}.sm\:gap-y-0{row-gap:0}.sm\:gap-y-1{row-gap:var(--spacing)}.sm\:self-auto{align-self:auto}.sm\:rounded-2xl{border-radius:var(--radius-2xl)}.sm\:rounded-3xl{border-radius:var(--radius-3xl)}.sm\:rounded-\[2rem\]{border-radius:2rem}.sm\:rounded-lg{border-radius:var(--radius-lg)}.sm\:rounded-xl{border-radius:var(--radius-xl)}.sm\:border{border-style:var(--tw-border-style);border-width:1px}.sm\:border-0{border-style:var(--tw-border-style);border-width:0}.sm\:bg-black\/2{background-color:#00000005}@supports (color:color-mix(in lab, red, red)){.sm\:bg-black\/2{background-color:color-mix(in oklab, var(--color-black) 2%, transparent)}}.sm\:bg-black\/50{background-color:#00000080}@supports (color:color-mix(in lab, red, red)){.sm\:bg-black\/50{background-color:color-mix(in oklab, var(--color-black) 50%, transparent)}}.sm\:p-1{padding:var(--spacing)}.sm\:p-1\.5{padding:calc(var(--spacing) * 1.5)}.sm\:p-2{padding:calc(var(--spacing) * 2)}.sm\:p-3{padding:calc(var(--spacing) * 3)}.sm\:p-4{padding:calc(var(--spacing) * 4)}.sm\:p-5{padding:calc(var(--spacing) * 5)}.sm\:p-6{padding:calc(var(--spacing) * 6)}.sm\:p-8{padding:calc(var(--spacing) * 8)}.sm\:p-12{padding:calc(var(--spacing) * 12)}.sm\:px-0{padding-inline:0}.sm\:px-1{padding-inline:var(--spacing)}.sm\:px-2{padding-inline:calc(var(--spacing) * 2)}.sm\:px-3{padding-inline:calc(var(--spacing) * 3)}.sm\:px-4{padding-inline:calc(var(--spacing) * 4)}.sm\:px-5{padding-inline:calc(var(--spacing) * 5)}.sm\:px-6{padding-inline:calc(var(--spacing) * 6)}.sm\:py-1{padding-block:var(--spacing)}.sm\:py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.sm\:py-2{padding-block:calc(var(--spacing) * 2)}.sm\:py-2\.5{padding-block:calc(var(--spacing) * 2.5)}.sm\:py-3{padding-block:calc(var(--spacing) * 3)}.sm\:py-4{padding-block:calc(var(--spacing) * 4)}.sm\:py-6{padding-block:calc(var(--spacing) * 6)}.sm\:py-8{padding-block:calc(var(--spacing) * 8)}.sm\:py-10{padding-block:calc(var(--spacing) * 10)}.sm\:py-12{padding-block:calc(var(--spacing) * 12)}.sm\:py-20{padding-block:calc(var(--spacing) * 20)}.sm\:ps-0{padding-inline-start:0}.sm\:pe-3{padding-inline-end:calc(var(--spacing) * 3)}.sm\:pt-0{padding-top:0}.sm\:pt-0\.5{padding-top:calc(var(--spacing) * .5)}.sm\:pt-1{padding-top:var(--spacing)}.sm\:pt-1\.5{padding-top:calc(var(--spacing) * 1.5)}.sm\:pt-2{padding-top:calc(var(--spacing) * 2)}.sm\:pr-1{padding-right:var(--spacing)}.sm\:pr-2{padding-right:calc(var(--spacing) * 2)}.sm\:pr-4{padding-right:calc(var(--spacing) * 4)}.sm\:pr-12{padding-right:calc(var(--spacing) * 12)}.sm\:pr-40{padding-right:calc(var(--spacing) * 40)}.sm\:pb-4{padding-bottom:calc(var(--spacing) * 4)}.sm\:pb-24{padding-bottom:calc(var(--spacing) * 24)}.sm\:pl-0{padding-left:0}.sm\:pl-1{padding-left:var(--spacing)}.sm\:pl-2{padding-left:calc(var(--spacing) * 2)}.sm\:pl-4{padding-left:calc(var(--spacing) * 4)}.sm\:pl-14{padding-left:calc(var(--spacing) * 14)}.sm\:text-left{text-align:left}.sm\:text-right{text-align:right}.sm\:text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.sm\:text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.sm\:text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.sm\:text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.sm\:text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.sm\:text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.sm\:text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.sm\:text-\[10px\]{font-size:10px}.sm\:text-\[11px\]{font-size:11px}.sm\:leading-6{--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6)}.sm\:leading-snug{--tw-leading:var(--leading-snug);line-height:var(--leading-snug)}.sm\:wrap-break-word{overflow-wrap:break-word}.sm\:opacity-100{opacity:1}.sm\:focus\:w-80:focus{width:calc(var(--spacing) * 80)}}@media (min-width:48rem){.md\:static{position:static}.md\:z-auto{z-index:auto}.md\:flex{display:flex}.md\:hidden{display:none}.md\:inline{display:inline}.md\:size-6{width:calc(var(--spacing) * 6);height:calc(var(--spacing) * 6)}.md\:w-72{width:calc(var(--spacing) * 72)}.md\:w-80{width:calc(var(--spacing) * 80)}.md\:w-96{width:calc(var(--spacing) * 96)}.md\:w-auto{width:auto}.md\:max-w-\[9rem\]{max-width:9rem}.md\:max-w-none{max-width:none}.md\:max-w-sm{max-width:var(--container-sm)}.md\:max-w-xs{max-width:var(--container-xs)}.md\:min-w-\[200px\]{min-width:200px}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.md\:rounded-xl{border-radius:var(--radius-xl)}.md\:border{border-style:var(--tw-border-style);border-width:1px}.md\:border-r{border-right-style:var(--tw-border-style);border-right-width:1px}.md\:border-b-0{border-bottom-style:var(--tw-border-style);border-bottom-width:0}.md\:p-4{padding:calc(var(--spacing) * 4)}.md\:p-6{padding:calc(var(--spacing) * 6)}.md\:p-8{padding:calc(var(--spacing) * 8)}.md\:p-10{padding:calc(var(--spacing) * 10)}.md\:px-4{padding-inline:calc(var(--spacing) * 4)}.md\:px-5{padding-inline:calc(var(--spacing) * 5)}.md\:px-6{padding-inline:calc(var(--spacing) * 6)}.md\:px-10{padding-inline:calc(var(--spacing) * 10)}.md\:px-12{padding-inline:calc(var(--spacing) * 12)}.md\:py-5{padding-block:calc(var(--spacing) * 5)}.md\:py-6{padding-block:calc(var(--spacing) * 6)}.md\:py-10{padding-block:calc(var(--spacing) * 10)}.md\:py-12{padding-block:calc(var(--spacing) * 12)}.md\:pr-6{padding-right:calc(var(--spacing) * 6)}.md\:pl-4{padding-left:calc(var(--spacing) * 4)}.md\:text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.md\:text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.md\:text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.md\:text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.md\:text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.md\:leading-relaxed{--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}.md\:opacity-70{opacity:.7}.md\:shadow-none{--tw-shadow:0 0 #0000;box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.md\:grayscale-\[0\.3\]{--tw-grayscale:grayscale(.3);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.md\:backdrop-blur-\[0\.5px\]{--tw-backdrop-blur:blur(.5px);-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}@media not all and (min-width:64rem){.md\:max-lg\:w-64{width:calc(var(--spacing) * 64)}.md\:max-lg\:w-72{width:calc(var(--spacing) * 72)}.md\:max-lg\:max-w-\[70\%\]{max-width:70%}.md\:max-lg\:min-w-64{min-width:calc(var(--spacing) * 64)}.md\:max-lg\:focus\:w-72:focus{width:calc(var(--spacing) * 72)}}}@media (min-width:64rem){.lg\:sticky{position:sticky}.lg\:top-20{top:calc(var(--spacing) * 20)}.lg\:col-span-1{grid-column:span 1/span 1}.lg\:col-span-2{grid-column:span 2/span 2}.lg\:col-span-3{grid-column:span 3/span 3}.lg\:block{display:block}.lg\:flex{display:flex}.lg\:hidden{display:none}.lg\:inline{display:inline}.lg\:inline-flex{display:inline-flex}.lg\:max-h-none{max-height:none}.lg\:w-52{width:calc(var(--spacing) * 52)}.lg\:w-80{width:calc(var(--spacing) * 80)}.lg\:w-auto{width:auto}.lg\:max-w-\[65\%\]{max-width:65%}.lg\:max-w-md{max-width:var(--container-md)}.lg\:max-w-sm{max-width:var(--container-sm)}.lg\:min-w-80{min-width:calc(var(--spacing) * 80)}.lg\:min-w-\[120px\]{min-width:120px}.lg\:flex-none{flex:none}.lg\:shrink-0{flex-shrink:0}.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.lg\:grid-cols-5{grid-template-columns:repeat(5,minmax(0,1fr))}.lg\:flex-col{flex-direction:column}.lg\:flex-row{flex-direction:row}.lg\:items-center{align-items:center}.lg\:items-end{align-items:flex-end}.lg\:items-start{align-items:flex-start}.lg\:justify-between{justify-content:space-between}.lg\:gap-0\.5{gap:calc(var(--spacing) * .5)}.lg\:gap-6{gap:calc(var(--spacing) * 6)}.lg\:gap-8{gap:calc(var(--spacing) * 8)}.lg\:gap-12{gap:calc(var(--spacing) * 12)}:where(.lg\:divide-x>:not(:last-child)){--tw-divide-x-reverse:0;border-inline-style:var(--tw-border-style);border-inline-start-width:calc(1px * var(--tw-divide-x-reverse));border-inline-end-width:calc(1px * calc(1 - var(--tw-divide-x-reverse)))}:where(.lg\:divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)))}.lg\:overflow-x-visible{overflow-x:visible}.lg\:border-t-0{border-top-style:var(--tw-border-style);border-top-width:0}.lg\:border-r{border-right-style:var(--tw-border-style);border-right-width:1px}.lg\:border-b-0{border-bottom-style:var(--tw-border-style);border-bottom-width:0}.lg\:p-16{padding:calc(var(--spacing) * 16)}.lg\:px-8{padding-inline:calc(var(--spacing) * 8)}.lg\:py-8{padding-block:calc(var(--spacing) * 8)}.lg\:pb-0{padding-bottom:0}.lg\:opacity-0{opacity:0}@media (hover:hover){.lg\:group-hover\:opacity-100:is(:where(.group):hover *){opacity:1}}.lg\:focus\:w-80:focus{width:calc(var(--spacing) * 80)}}@media (min-width:80rem){.xl\:sticky{position:sticky}.xl\:top-4{top:calc(var(--spacing) * 4)}.xl\:order-1{order:1}.xl\:order-2{order:2}.xl\:col-span-2{grid-column:span 2/span 2}.xl\:flex{display:flex}.xl\:max-h-\[min\(calc\(100dvh-6rem\)\,920px\)\]{max-height:min(100dvh - 6rem,920px)}.xl\:max-h-\[min\(calc\(100dvh-14rem\)\,44rem\)\]{max-height:min(100dvh - 14rem,44rem)}.xl\:w-96{width:calc(var(--spacing) * 96)}.xl\:max-w-4xl{max-width:var(--container-4xl)}.xl\:max-w-7xl{max-width:var(--container-7xl)}.xl\:max-w-\[min\(100\%\,96rem\)\]{max-width:min(100%,96rem)}.xl\:max-w-lg{max-width:var(--container-lg)}.xl\:max-w-sm{max-width:var(--container-sm)}.xl\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.xl\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.xl\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.xl\:flex-row{flex-direction:row}.xl\:items-start{align-items:flex-start}.xl\:gap-6{gap:calc(var(--spacing) * 6)}.xl\:gap-10{gap:calc(var(--spacing) * 10)}.xl\:self-start{align-self:flex-start}.xl\:overflow-y-auto{overflow-y:auto}.xl\:border-l{border-left-style:var(--tw-border-style);border-left-width:1px}.xl\:p-8{padding:calc(var(--spacing) * 8)}.xl\:pl-8{padding-left:calc(var(--spacing) * 8)}}@media (min-width:96rem){.\32 xl\:max-w-5xl{max-width:var(--container-5xl)}.\32 xl\:max-w-360{max-width:calc(var(--spacing) * 360)}.\32 xl\:max-w-384{max-width:calc(var(--spacing) * 384)}.\32 xl\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.\32 xl\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}:where(.dark\:divide-zinc-700:where(.dark,.dark *)>:not(:last-child)){border-color:var(--color-zinc-700)}:where(.dark\:divide-zinc-800:where(.dark,.dark *)>:not(:last-child)){border-color:var(--color-zinc-800)}:where(.dark\:divide-zinc-800\/50:where(.dark,.dark *)>:not(:last-child)){border-color:#27272a80}@supports (color:color-mix(in lab, red, red)){:where(.dark\:divide-zinc-800\/50:where(.dark,.dark *)>:not(:last-child)){border-color:color-mix(in oklab, var(--color-zinc-800) 50%, transparent)}}:where(.dark\:divide-zinc-800\/60:where(.dark,.dark *)>:not(:last-child)){border-color:#27272a99}@supports (color:color-mix(in lab, red, red)){:where(.dark\:divide-zinc-800\/60:where(.dark,.dark *)>:not(:last-child)){border-color:color-mix(in oklab, var(--color-zinc-800) 60%, transparent)}}.dark\:border-amber-600:where(.dark,.dark *){border-color:var(--color-amber-600)}.dark\:border-amber-700:where(.dark,.dark *){border-color:var(--color-amber-700)}.dark\:border-amber-800:where(.dark,.dark *){border-color:var(--color-amber-800)}.dark\:border-amber-800\/30:where(.dark,.dark *){border-color:#953d004d}@supports (color:color-mix(in lab, red, red)){.dark\:border-amber-800\/30:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-amber-800) 30%, transparent)}}.dark\:border-amber-800\/40:where(.dark,.dark *){border-color:#953d0066}@supports (color:color-mix(in lab, red, red)){.dark\:border-amber-800\/40:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-amber-800) 40%, transparent)}}.dark\:border-amber-800\/50:where(.dark,.dark *){border-color:#953d0080}@supports (color:color-mix(in lab, red, red)){.dark\:border-amber-800\/50:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-amber-800) 50%, transparent)}}.dark\:border-amber-800\/60:where(.dark,.dark *){border-color:#953d0099}@supports (color:color-mix(in lab, red, red)){.dark\:border-amber-800\/60:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-amber-800) 60%, transparent)}}.dark\:border-amber-900:where(.dark,.dark *){border-color:var(--color-amber-900)}.dark\:border-amber-900\/30:where(.dark,.dark *){border-color:#7b33064d}@supports (color:color-mix(in lab, red, red)){.dark\:border-amber-900\/30:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-amber-900) 30%, transparent)}}.dark\:border-amber-900\/40:where(.dark,.dark *){border-color:#7b330666}@supports (color:color-mix(in lab, red, red)){.dark\:border-amber-900\/40:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-amber-900) 40%, transparent)}}.dark\:border-amber-900\/50:where(.dark,.dark *){border-color:#7b330680}@supports (color:color-mix(in lab, red, red)){.dark\:border-amber-900\/50:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-amber-900) 50%, transparent)}}.dark\:border-blue-400:where(.dark,.dark *){border-color:var(--color-blue-400)}.dark\:border-blue-500:where(.dark,.dark *){border-color:var(--color-blue-500)}.dark\:border-blue-700:where(.dark,.dark *){border-color:var(--color-blue-700)}.dark\:border-blue-800:where(.dark,.dark *){border-color:var(--color-blue-800)}.dark\:border-blue-800\/30:where(.dark,.dark *){border-color:#193cb84d}@supports (color:color-mix(in lab, red, red)){.dark\:border-blue-800\/30:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-blue-800) 30%, transparent)}}.dark\:border-blue-900\/20:where(.dark,.dark *){border-color:#1c398e33}@supports (color:color-mix(in lab, red, red)){.dark\:border-blue-900\/20:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-blue-900) 20%, transparent)}}.dark\:border-blue-900\/30:where(.dark,.dark *){border-color:#1c398e4d}@supports (color:color-mix(in lab, red, red)){.dark\:border-blue-900\/30:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-blue-900) 30%, transparent)}}.dark\:border-blue-900\/40:where(.dark,.dark *){border-color:#1c398e66}@supports (color:color-mix(in lab, red, red)){.dark\:border-blue-900\/40:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-blue-900) 40%, transparent)}}.dark\:border-blue-900\/50:where(.dark,.dark *){border-color:#1c398e80}@supports (color:color-mix(in lab, red, red)){.dark\:border-blue-900\/50:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-blue-900) 50%, transparent)}}.dark\:border-emerald-500\/30:where(.dark,.dark *){border-color:#00bb7f4d}@supports (color:color-mix(in lab, red, red)){.dark\:border-emerald-500\/30:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-emerald-500) 30%, transparent)}}.dark\:border-emerald-800\/40:where(.dark,.dark *){border-color:#005f4666}@supports (color:color-mix(in lab, red, red)){.dark\:border-emerald-800\/40:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-emerald-800) 40%, transparent)}}.dark\:border-emerald-900\/20:where(.dark,.dark *){border-color:#004e3b33}@supports (color:color-mix(in lab, red, red)){.dark\:border-emerald-900\/20:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-emerald-900) 20%, transparent)}}.dark\:border-emerald-900\/30:where(.dark,.dark *){border-color:#004e3b4d}@supports (color:color-mix(in lab, red, red)){.dark\:border-emerald-900\/30:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-emerald-900) 30%, transparent)}}.dark\:border-emerald-900\/40:where(.dark,.dark *){border-color:#004e3b66}@supports (color:color-mix(in lab, red, red)){.dark\:border-emerald-900\/40:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-emerald-900) 40%, transparent)}}.dark\:border-gray-600:where(.dark,.dark *){border-color:var(--color-gray-600)}.dark\:border-gray-700:where(.dark,.dark *){border-color:var(--color-gray-700)}.dark\:border-green-800:where(.dark,.dark *){border-color:var(--color-green-800)}.dark\:border-indigo-400:where(.dark,.dark *){border-color:var(--color-indigo-400)}.dark\:border-indigo-500\/30:where(.dark,.dark *){border-color:#625fff4d}@supports (color:color-mix(in lab, red, red)){.dark\:border-indigo-500\/30:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-indigo-500) 30%, transparent)}}.dark\:border-indigo-800:where(.dark,.dark *){border-color:var(--color-indigo-800)}.dark\:border-indigo-900\/30:where(.dark,.dark *){border-color:#312c854d}@supports (color:color-mix(in lab, red, red)){.dark\:border-indigo-900\/30:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-indigo-900) 30%, transparent)}}.dark\:border-orange-900\/30:where(.dark,.dark *){border-color:#7e2a0c4d}@supports (color:color-mix(in lab, red, red)){.dark\:border-orange-900\/30:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-orange-900) 30%, transparent)}}.dark\:border-pink-900\/20:where(.dark,.dark *){border-color:#86104333}@supports (color:color-mix(in lab, red, red)){.dark\:border-pink-900\/20:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-pink-900) 20%, transparent)}}.dark\:border-pink-900\/30:where(.dark,.dark *){border-color:#8610434d}@supports (color:color-mix(in lab, red, red)){.dark\:border-pink-900\/30:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-pink-900) 30%, transparent)}}.dark\:border-red-500\/40:where(.dark,.dark *){border-color:#fb2c3666}@supports (color:color-mix(in lab, red, red)){.dark\:border-red-500\/40:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-red-500) 40%, transparent)}}.dark\:border-red-500\/50:where(.dark,.dark *){border-color:#fb2c3680}@supports (color:color-mix(in lab, red, red)){.dark\:border-red-500\/50:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-red-500) 50%, transparent)}}.dark\:border-red-500\/50\!:where(.dark,.dark *){border-color:#fb2c3680!important}@supports (color:color-mix(in lab, red, red)){.dark\:border-red-500\/50\!:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-red-500) 50%, transparent)!important}}.dark\:border-red-700:where(.dark,.dark *){border-color:var(--color-red-700)}.dark\:border-red-800:where(.dark,.dark *){border-color:var(--color-red-800)}.dark\:border-red-800\/40:where(.dark,.dark *){border-color:#9f071266}@supports (color:color-mix(in lab, red, red)){.dark\:border-red-800\/40:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-red-800) 40%, transparent)}}.dark\:border-red-900\/30:where(.dark,.dark *){border-color:#82181a4d}@supports (color:color-mix(in lab, red, red)){.dark\:border-red-900\/30:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-red-900) 30%, transparent)}}.dark\:border-red-900\/40:where(.dark,.dark *){border-color:#82181a66}@supports (color:color-mix(in lab, red, red)){.dark\:border-red-900\/40:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-red-900) 40%, transparent)}}.dark\:border-red-900\/50:where(.dark,.dark *){border-color:#82181a80}@supports (color:color-mix(in lab, red, red)){.dark\:border-red-900\/50:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-red-900) 50%, transparent)}}.dark\:border-red-900\/70:where(.dark,.dark *){border-color:#82181ab3}@supports (color:color-mix(in lab, red, red)){.dark\:border-red-900\/70:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-red-900) 70%, transparent)}}.dark\:border-sky-700\/45:where(.dark,.dark *){border-color:#0069a473}@supports (color:color-mix(in lab, red, red)){.dark\:border-sky-700\/45:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-sky-700) 45%, transparent)}}.dark\:border-sky-800:where(.dark,.dark *){border-color:var(--color-sky-800)}.dark\:border-sky-800\/50:where(.dark,.dark *){border-color:#00598680}@supports (color:color-mix(in lab, red, red)){.dark\:border-sky-800\/50:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-sky-800) 50%, transparent)}}.dark\:border-sky-800\/55:where(.dark,.dark *){border-color:#0059868c}@supports (color:color-mix(in lab, red, red)){.dark\:border-sky-800\/55:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-sky-800) 55%, transparent)}}.dark\:border-teal-700\!:where(.dark,.dark *){border-color:var(--color-teal-700)!important}.dark\:border-teal-900\/30:where(.dark,.dark *){border-color:#0b4f4a4d}@supports (color:color-mix(in lab, red, red)){.dark\:border-teal-900\/30:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-teal-900) 30%, transparent)}}.dark\:border-violet-800:where(.dark,.dark *){border-color:var(--color-violet-800)}.dark\:border-violet-800\/50:where(.dark,.dark *){border-color:#5d0ec080}@supports (color:color-mix(in lab, red, red)){.dark\:border-violet-800\/50:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-violet-800) 50%, transparent)}}.dark\:border-violet-900\/30:where(.dark,.dark *){border-color:#4d179a4d}@supports (color:color-mix(in lab, red, red)){.dark\:border-violet-900\/30:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-violet-900) 30%, transparent)}}.dark\:border-violet-900\/60:where(.dark,.dark *){border-color:#4d179a99}@supports (color:color-mix(in lab, red, red)){.dark\:border-violet-900\/60:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-violet-900) 60%, transparent)}}.dark\:border-white\/5:where(.dark,.dark *){border-color:#ffffff0d}@supports (color:color-mix(in lab, red, red)){.dark\:border-white\/5:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-white) 5%, transparent)}}.dark\:border-white\/10:where(.dark,.dark *){border-color:#ffffff1a}@supports (color:color-mix(in lab, red, red)){.dark\:border-white\/10:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-white) 10%, transparent)}}.dark\:border-yellow-700:where(.dark,.dark *){border-color:var(--color-yellow-700)}.dark\:border-yellow-800:where(.dark,.dark *){border-color:var(--color-yellow-800)}.dark\:border-zinc-600:where(.dark,.dark *){border-color:var(--color-zinc-600)}.dark\:border-zinc-600\/90:where(.dark,.dark *){border-color:#52525ce6}@supports (color:color-mix(in lab, red, red)){.dark\:border-zinc-600\/90:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-zinc-600) 90%, transparent)}}.dark\:border-zinc-700:where(.dark,.dark *){border-color:var(--color-zinc-700)}.dark\:border-zinc-700\/50:where(.dark,.dark *){border-color:#3f3f4680}@supports (color:color-mix(in lab, red, red)){.dark\:border-zinc-700\/50:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-zinc-700) 50%, transparent)}}.dark\:border-zinc-700\/70:where(.dark,.dark *){border-color:#3f3f46b3}@supports (color:color-mix(in lab, red, red)){.dark\:border-zinc-700\/70:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-zinc-700) 70%, transparent)}}.dark\:border-zinc-700\/80:where(.dark,.dark *){border-color:#3f3f46cc}@supports (color:color-mix(in lab, red, red)){.dark\:border-zinc-700\/80:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-zinc-700) 80%, transparent)}}.dark\:border-zinc-700\/90:where(.dark,.dark *){border-color:#3f3f46e6}@supports (color:color-mix(in lab, red, red)){.dark\:border-zinc-700\/90:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-zinc-700) 90%, transparent)}}.dark\:border-zinc-800:where(.dark,.dark *){border-color:var(--color-zinc-800)}.dark\:border-zinc-800\!:where(.dark,.dark *){border-color:var(--color-zinc-800)!important}.dark\:border-zinc-800\/50:where(.dark,.dark *){border-color:#27272a80}@supports (color:color-mix(in lab, red, red)){.dark\:border-zinc-800\/50:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-zinc-800) 50%, transparent)}}.dark\:border-zinc-800\/60:where(.dark,.dark *){border-color:#27272a99}@supports (color:color-mix(in lab, red, red)){.dark\:border-zinc-800\/60:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-zinc-800) 60%, transparent)}}.dark\:border-zinc-800\/80:where(.dark,.dark *){border-color:#27272acc}@supports (color:color-mix(in lab, red, red)){.dark\:border-zinc-800\/80:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-zinc-800) 80%, transparent)}}.dark\:border-zinc-900:where(.dark,.dark *){border-color:var(--color-zinc-900)}.dark\:border-l-zinc-700:where(.dark,.dark *){border-left-color:var(--color-zinc-700)}.dark\:bg-amber-700:where(.dark,.dark *){background-color:var(--color-amber-700)}.dark\:bg-amber-900\/10:where(.dark,.dark *){background-color:#7b33061a}@supports (color:color-mix(in lab, red, red)){.dark\:bg-amber-900\/10:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-amber-900) 10%, transparent)}}.dark\:bg-amber-900\/15:where(.dark,.dark *){background-color:#7b330626}@supports (color:color-mix(in lab, red, red)){.dark\:bg-amber-900\/15:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-amber-900) 15%, transparent)}}.dark\:bg-amber-900\/20:where(.dark,.dark *){background-color:#7b330633}@supports (color:color-mix(in lab, red, red)){.dark\:bg-amber-900\/20:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-amber-900) 20%, transparent)}}.dark\:bg-amber-900\/30:where(.dark,.dark *){background-color:#7b33064d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-amber-900\/30:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-amber-900) 30%, transparent)}}.dark\:bg-amber-900\/40:where(.dark,.dark *){background-color:#7b330666}@supports (color:color-mix(in lab, red, red)){.dark\:bg-amber-900\/40:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-amber-900) 40%, transparent)}}.dark\:bg-amber-900\/45:where(.dark,.dark *){background-color:#7b330673}@supports (color:color-mix(in lab, red, red)){.dark\:bg-amber-900\/45:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-amber-900) 45%, transparent)}}.dark\:bg-amber-950\/20:where(.dark,.dark *){background-color:#46190133}@supports (color:color-mix(in lab, red, red)){.dark\:bg-amber-950\/20:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-amber-950) 20%, transparent)}}.dark\:bg-amber-950\/25:where(.dark,.dark *){background-color:#46190140}@supports (color:color-mix(in lab, red, red)){.dark\:bg-amber-950\/25:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-amber-950) 25%, transparent)}}.dark\:bg-amber-950\/30:where(.dark,.dark *){background-color:#4619014d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-amber-950\/30:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-amber-950) 30%, transparent)}}.dark\:bg-amber-950\/40:where(.dark,.dark *){background-color:#46190166}@supports (color:color-mix(in lab, red, red)){.dark\:bg-amber-950\/40:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-amber-950) 40%, transparent)}}.dark\:bg-amber-950\/50:where(.dark,.dark *){background-color:#46190180}@supports (color:color-mix(in lab, red, red)){.dark\:bg-amber-950\/50:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-amber-950) 50%, transparent)}}.dark\:bg-black:where(.dark,.dark *){background-color:var(--color-black)}.dark\:bg-black\/20:where(.dark,.dark *){background-color:#0003}@supports (color:color-mix(in lab, red, red)){.dark\:bg-black\/20:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-black) 20%, transparent)}}.dark\:bg-black\/30:where(.dark,.dark *){background-color:#0000004d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-black\/30:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-black) 30%, transparent)}}.dark\:bg-black\/50:where(.dark,.dark *){background-color:#00000080}@supports (color:color-mix(in lab, red, red)){.dark\:bg-black\/50:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-black) 50%, transparent)}}.dark\:bg-black\/55:where(.dark,.dark *){background-color:#0000008c}@supports (color:color-mix(in lab, red, red)){.dark\:bg-black\/55:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-black) 55%, transparent)}}.dark\:bg-black\/60:where(.dark,.dark *){background-color:#0009}@supports (color:color-mix(in lab, red, red)){.dark\:bg-black\/60:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-black) 60%, transparent)}}.dark\:bg-black\/70:where(.dark,.dark *){background-color:#000000b3}@supports (color:color-mix(in lab, red, red)){.dark\:bg-black\/70:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-black) 70%, transparent)}}.dark\:bg-black\/90:where(.dark,.dark *){background-color:#000000e6}@supports (color:color-mix(in lab, red, red)){.dark\:bg-black\/90:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-black) 90%, transparent)}}.dark\:bg-blue-400:where(.dark,.dark *){background-color:var(--color-blue-400)}.dark\:bg-blue-400\/8:where(.dark,.dark *){background-color:#54a2ff14}@supports (color:color-mix(in lab, red, red)){.dark\:bg-blue-400\/8:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-blue-400) 8%, transparent)}}.dark\:bg-blue-500:where(.dark,.dark *){background-color:var(--color-blue-500)}.dark\:bg-blue-500\/10:where(.dark,.dark *){background-color:#3080ff1a}@supports (color:color-mix(in lab, red, red)){.dark\:bg-blue-500\/10:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-blue-500) 10%, transparent)}}.dark\:bg-blue-500\/20:where(.dark,.dark *){background-color:#3080ff33}@supports (color:color-mix(in lab, red, red)){.dark\:bg-blue-500\/20:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-blue-500) 20%, transparent)}}.dark\:bg-blue-600:where(.dark,.dark *){background-color:var(--color-blue-600)}.dark\:bg-blue-700:where(.dark,.dark *){background-color:var(--color-blue-700)}.dark\:bg-blue-900\/5:where(.dark,.dark *){background-color:#1c398e0d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-blue-900\/5:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-blue-900) 5%, transparent)}}.dark\:bg-blue-900\/10:where(.dark,.dark *){background-color:#1c398e1a}@supports (color:color-mix(in lab, red, red)){.dark\:bg-blue-900\/10:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-blue-900) 10%, transparent)}}.dark\:bg-blue-900\/20:where(.dark,.dark *){background-color:#1c398e33}@supports (color:color-mix(in lab, red, red)){.dark\:bg-blue-900\/20:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-blue-900) 20%, transparent)}}.dark\:bg-blue-900\/30:where(.dark,.dark *){background-color:#1c398e4d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-blue-900\/30:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-blue-900) 30%, transparent)}}.dark\:bg-blue-900\/40:where(.dark,.dark *){background-color:#1c398e66}@supports (color:color-mix(in lab, red, red)){.dark\:bg-blue-900\/40:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-blue-900) 40%, transparent)}}.dark\:bg-blue-900\/50:where(.dark,.dark *){background-color:#1c398e80}@supports (color:color-mix(in lab, red, red)){.dark\:bg-blue-900\/50:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-blue-900) 50%, transparent)}}.dark\:bg-blue-950\/20:where(.dark,.dark *){background-color:#16245633}@supports (color:color-mix(in lab, red, red)){.dark\:bg-blue-950\/20:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-blue-950) 20%, transparent)}}.dark\:bg-blue-950\/30:where(.dark,.dark *){background-color:#1624564d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-blue-950\/30:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-blue-950) 30%, transparent)}}.dark\:bg-blue-950\/40:where(.dark,.dark *){background-color:#16245666}@supports (color:color-mix(in lab, red, red)){.dark\:bg-blue-950\/40:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-blue-950) 40%, transparent)}}.dark\:bg-blue-950\/60:where(.dark,.dark *){background-color:#16245699}@supports (color:color-mix(in lab, red, red)){.dark\:bg-blue-950\/60:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-blue-950) 60%, transparent)}}.dark\:bg-cyan-900\/30:where(.dark,.dark *){background-color:#104e644d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-cyan-900\/30:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-cyan-900) 30%, transparent)}}.dark\:bg-cyan-950\/40:where(.dark,.dark *){background-color:#05334566}@supports (color:color-mix(in lab, red, red)){.dark\:bg-cyan-950\/40:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-cyan-950) 40%, transparent)}}.dark\:bg-emerald-500\/10:where(.dark,.dark *){background-color:#00bb7f1a}@supports (color:color-mix(in lab, red, red)){.dark\:bg-emerald-500\/10:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-emerald-500) 10%, transparent)}}.dark\:bg-emerald-900\/5:where(.dark,.dark *){background-color:#004e3b0d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-emerald-900\/5:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-emerald-900) 5%, transparent)}}.dark\:bg-emerald-900\/10:where(.dark,.dark *){background-color:#004e3b1a}@supports (color:color-mix(in lab, red, red)){.dark\:bg-emerald-900\/10:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-emerald-900) 10%, transparent)}}.dark\:bg-emerald-900\/20:where(.dark,.dark *){background-color:#004e3b33}@supports (color:color-mix(in lab, red, red)){.dark\:bg-emerald-900\/20:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-emerald-900) 20%, transparent)}}.dark\:bg-emerald-900\/30:where(.dark,.dark *){background-color:#004e3b4d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-emerald-900\/30:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-emerald-900) 30%, transparent)}}.dark\:bg-emerald-900\/40:where(.dark,.dark *){background-color:#004e3b66}@supports (color:color-mix(in lab, red, red)){.dark\:bg-emerald-900\/40:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-emerald-900) 40%, transparent)}}.dark\:bg-emerald-900\/45:where(.dark,.dark *){background-color:#004e3b73}@supports (color:color-mix(in lab, red, red)){.dark\:bg-emerald-900\/45:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-emerald-900) 45%, transparent)}}.dark\:bg-emerald-900\/50:where(.dark,.dark *){background-color:#004e3b80}@supports (color:color-mix(in lab, red, red)){.dark\:bg-emerald-900\/50:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-emerald-900) 50%, transparent)}}.dark\:bg-emerald-950\/20:where(.dark,.dark *){background-color:#002c2233}@supports (color:color-mix(in lab, red, red)){.dark\:bg-emerald-950\/20:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-emerald-950) 20%, transparent)}}.dark\:bg-gray-500:where(.dark,.dark *){background-color:var(--color-gray-500)}.dark\:bg-gray-800:where(.dark,.dark *){background-color:var(--color-gray-800)}.dark\:bg-gray-900:where(.dark,.dark *){background-color:var(--color-gray-900)}.dark\:bg-gray-900\/40:where(.dark,.dark *){background-color:#10182866}@supports (color:color-mix(in lab, red, red)){.dark\:bg-gray-900\/40:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-gray-900) 40%, transparent)}}.dark\:bg-green-900\/15:where(.dark,.dark *){background-color:#0d542b26}@supports (color:color-mix(in lab, red, red)){.dark\:bg-green-900\/15:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-green-900) 15%, transparent)}}.dark\:bg-green-900\/20:where(.dark,.dark *){background-color:#0d542b33}@supports (color:color-mix(in lab, red, red)){.dark\:bg-green-900\/20:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-green-900) 20%, transparent)}}.dark\:bg-green-900\/30:where(.dark,.dark *){background-color:#0d542b4d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-green-900\/30:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-green-900) 30%, transparent)}}.dark\:bg-green-900\/40:where(.dark,.dark *){background-color:#0d542b66}@supports (color:color-mix(in lab, red, red)){.dark\:bg-green-900\/40:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-green-900) 40%, transparent)}}.dark\:bg-green-900\/45:where(.dark,.dark *){background-color:#0d542b73}@supports (color:color-mix(in lab, red, red)){.dark\:bg-green-900\/45:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-green-900) 45%, transparent)}}.dark\:bg-indigo-500\/10:where(.dark,.dark *){background-color:#625fff1a}@supports (color:color-mix(in lab, red, red)){.dark\:bg-indigo-500\/10:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-indigo-500) 10%, transparent)}}.dark\:bg-indigo-900\/10:where(.dark,.dark *){background-color:#312c851a}@supports (color:color-mix(in lab, red, red)){.dark\:bg-indigo-900\/10:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-indigo-900) 10%, transparent)}}.dark\:bg-indigo-900\/20:where(.dark,.dark *){background-color:#312c8533}@supports (color:color-mix(in lab, red, red)){.dark\:bg-indigo-900\/20:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-indigo-900) 20%, transparent)}}.dark\:bg-indigo-900\/30:where(.dark,.dark *){background-color:#312c854d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-indigo-900\/30:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-indigo-900) 30%, transparent)}}.dark\:bg-indigo-900\/35:where(.dark,.dark *){background-color:#312c8559}@supports (color:color-mix(in lab, red, red)){.dark\:bg-indigo-900\/35:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-indigo-900) 35%, transparent)}}.dark\:bg-indigo-950\/25:where(.dark,.dark *){background-color:#1e1a4d40}@supports (color:color-mix(in lab, red, red)){.dark\:bg-indigo-950\/25:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-indigo-950) 25%, transparent)}}.dark\:bg-indigo-950\/40:where(.dark,.dark *){background-color:#1e1a4d66}@supports (color:color-mix(in lab, red, red)){.dark\:bg-indigo-950\/40:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-indigo-950) 40%, transparent)}}.dark\:bg-orange-900\/10:where(.dark,.dark *){background-color:#7e2a0c1a}@supports (color:color-mix(in lab, red, red)){.dark\:bg-orange-900\/10:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-orange-900) 10%, transparent)}}.dark\:bg-orange-900\/30:where(.dark,.dark *){background-color:#7e2a0c4d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-orange-900\/30:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-orange-900) 30%, transparent)}}.dark\:bg-orange-900\/50:where(.dark,.dark *){background-color:#7e2a0c80}@supports (color:color-mix(in lab, red, red)){.dark\:bg-orange-900\/50:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-orange-900) 50%, transparent)}}.dark\:bg-pink-900\/10:where(.dark,.dark *){background-color:#8610431a}@supports (color:color-mix(in lab, red, red)){.dark\:bg-pink-900\/10:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-pink-900) 10%, transparent)}}.dark\:bg-purple-900\/30:where(.dark,.dark *){background-color:#59168b4d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-purple-900\/30:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-purple-900) 30%, transparent)}}.dark\:bg-red-400:where(.dark,.dark *){background-color:var(--color-red-400)}.dark\:bg-red-700:where(.dark,.dark *){background-color:var(--color-red-700)}.dark\:bg-red-900:where(.dark,.dark *){background-color:var(--color-red-900)}.dark\:bg-red-900\/10:where(.dark,.dark *){background-color:#82181a1a}@supports (color:color-mix(in lab, red, red)){.dark\:bg-red-900\/10:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-red-900) 10%, transparent)}}.dark\:bg-red-900\/20:where(.dark,.dark *){background-color:#82181a33}@supports (color:color-mix(in lab, red, red)){.dark\:bg-red-900\/20:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-red-900) 20%, transparent)}}.dark\:bg-red-900\/30:where(.dark,.dark *){background-color:#82181a4d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-red-900\/30:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-red-900) 30%, transparent)}}.dark\:bg-red-900\/40:where(.dark,.dark *){background-color:#82181a66}@supports (color:color-mix(in lab, red, red)){.dark\:bg-red-900\/40:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-red-900) 40%, transparent)}}.dark\:bg-red-900\/45:where(.dark,.dark *){background-color:#82181a73}@supports (color:color-mix(in lab, red, red)){.dark\:bg-red-900\/45:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-red-900) 45%, transparent)}}.dark\:bg-red-950\/20:where(.dark,.dark *){background-color:#46080933}@supports (color:color-mix(in lab, red, red)){.dark\:bg-red-950\/20:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-red-950) 20%, transparent)}}.dark\:bg-red-950\/30:where(.dark,.dark *){background-color:#4608094d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-red-950\/30:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-red-950) 30%, transparent)}}.dark\:bg-red-950\/40:where(.dark,.dark *){background-color:#46080966}@supports (color:color-mix(in lab, red, red)){.dark\:bg-red-950\/40:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-red-950) 40%, transparent)}}.dark\:bg-red-950\/50:where(.dark,.dark *){background-color:#46080980}@supports (color:color-mix(in lab, red, red)){.dark\:bg-red-950\/50:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-red-950) 50%, transparent)}}.dark\:bg-rose-900\/30:where(.dark,.dark *){background-color:#8b08364d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-rose-900\/30:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-rose-900) 30%, transparent)}}.dark\:bg-sky-900\/30:where(.dark,.dark *){background-color:#024a704d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-sky-900\/30:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-sky-900) 30%, transparent)}}.dark\:bg-sky-950\/30:where(.dark,.dark *){background-color:#052f4a4d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-sky-950\/30:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-sky-950) 30%, transparent)}}.dark\:bg-sky-950\/35:where(.dark,.dark *){background-color:#052f4a59}@supports (color:color-mix(in lab, red, red)){.dark\:bg-sky-950\/35:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-sky-950) 35%, transparent)}}.dark\:bg-sky-950\/45:where(.dark,.dark *){background-color:#052f4a73}@supports (color:color-mix(in lab, red, red)){.dark\:bg-sky-950\/45:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-sky-950) 45%, transparent)}}.dark\:bg-teal-900\/10:where(.dark,.dark *){background-color:#0b4f4a1a}@supports (color:color-mix(in lab, red, red)){.dark\:bg-teal-900\/10:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-teal-900) 10%, transparent)}}.dark\:bg-teal-900\/30:where(.dark,.dark *){background-color:#0b4f4a4d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-teal-900\/30:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-teal-900) 30%, transparent)}}.dark\:bg-transparent:where(.dark,.dark *){background-color:#0000}.dark\:bg-violet-900\/10:where(.dark,.dark *){background-color:#4d179a1a}@supports (color:color-mix(in lab, red, red)){.dark\:bg-violet-900\/10:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-violet-900) 10%, transparent)}}.dark\:bg-violet-900\/30:where(.dark,.dark *){background-color:#4d179a4d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-violet-900\/30:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-violet-900) 30%, transparent)}}.dark\:bg-violet-900\/40:where(.dark,.dark *){background-color:#4d179a66}@supports (color:color-mix(in lab, red, red)){.dark\:bg-violet-900\/40:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-violet-900) 40%, transparent)}}.dark\:bg-violet-950\/30:where(.dark,.dark *){background-color:#2f0d684d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-violet-950\/30:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-violet-950) 30%, transparent)}}.dark\:bg-white:where(.dark,.dark *){background-color:var(--color-white)}.dark\:bg-white\/2:where(.dark,.dark *){background-color:#ffffff05}@supports (color:color-mix(in lab, red, red)){.dark\:bg-white\/2:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-white) 2%, transparent)}}.dark\:bg-white\/5:where(.dark,.dark *){background-color:#ffffff0d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-white\/5:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-white) 5%, transparent)}}.dark\:bg-white\/10:where(.dark,.dark *){background-color:#ffffff1a}@supports (color:color-mix(in lab, red, red)){.dark\:bg-white\/10:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-white) 10%, transparent)}}.dark\:bg-white\/\[0\.05\]:where(.dark,.dark *){background-color:#ffffff0d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-white\/\[0\.05\]:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-white) 5%, transparent)}}.dark\:bg-yellow-900\/20:where(.dark,.dark *){background-color:#733e0a33}@supports (color:color-mix(in lab, red, red)){.dark\:bg-yellow-900\/20:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-yellow-900) 20%, transparent)}}.dark\:bg-yellow-900\/30:where(.dark,.dark *){background-color:#733e0a4d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-yellow-900\/30:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-yellow-900) 30%, transparent)}}.dark\:bg-zinc-100:where(.dark,.dark *){background-color:var(--color-zinc-100)}.dark\:bg-zinc-500:where(.dark,.dark *){background-color:var(--color-zinc-500)}.dark\:bg-zinc-600\/70:where(.dark,.dark *){background-color:#52525cb3}@supports (color:color-mix(in lab, red, red)){.dark\:bg-zinc-600\/70:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-zinc-600) 70%, transparent)}}.dark\:bg-zinc-700:where(.dark,.dark *){background-color:var(--color-zinc-700)}.dark\:bg-zinc-700\/50:where(.dark,.dark *){background-color:#3f3f4680}@supports (color:color-mix(in lab, red, red)){.dark\:bg-zinc-700\/50:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-zinc-700) 50%, transparent)}}.dark\:bg-zinc-700\/90:where(.dark,.dark *){background-color:#3f3f46e6}@supports (color:color-mix(in lab, red, red)){.dark\:bg-zinc-700\/90:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-zinc-700) 90%, transparent)}}.dark\:bg-zinc-800:where(.dark,.dark *){background-color:var(--color-zinc-800)}.dark\:bg-zinc-800\/10:where(.dark,.dark *){background-color:#27272a1a}@supports (color:color-mix(in lab, red, red)){.dark\:bg-zinc-800\/10:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-zinc-800) 10%, transparent)}}.dark\:bg-zinc-800\/30:where(.dark,.dark *){background-color:#27272a4d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-zinc-800\/30:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-zinc-800) 30%, transparent)}}.dark\:bg-zinc-800\/40:where(.dark,.dark *){background-color:#27272a66}@supports (color:color-mix(in lab, red, red)){.dark\:bg-zinc-800\/40:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-zinc-800) 40%, transparent)}}.dark\:bg-zinc-800\/50:where(.dark,.dark *){background-color:#27272a80}@supports (color:color-mix(in lab, red, red)){.dark\:bg-zinc-800\/50:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-zinc-800) 50%, transparent)}}.dark\:bg-zinc-800\/60:where(.dark,.dark *){background-color:#27272a99}@supports (color:color-mix(in lab, red, red)){.dark\:bg-zinc-800\/60:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-zinc-800) 60%, transparent)}}.dark\:bg-zinc-800\/70:where(.dark,.dark *){background-color:#27272ab3}@supports (color:color-mix(in lab, red, red)){.dark\:bg-zinc-800\/70:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-zinc-800) 70%, transparent)}}.dark\:bg-zinc-800\/80:where(.dark,.dark *){background-color:#27272acc}@supports (color:color-mix(in lab, red, red)){.dark\:bg-zinc-800\/80:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-zinc-800) 80%, transparent)}}.dark\:bg-zinc-800\/90:where(.dark,.dark *){background-color:#27272ae6}@supports (color:color-mix(in lab, red, red)){.dark\:bg-zinc-800\/90:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-zinc-800) 90%, transparent)}}.dark\:bg-zinc-900:where(.dark,.dark *){background-color:var(--color-zinc-900)}.dark\:bg-zinc-900\/10:where(.dark,.dark *){background-color:#18181b1a}@supports (color:color-mix(in lab, red, red)){.dark\:bg-zinc-900\/10:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-zinc-900) 10%, transparent)}}.dark\:bg-zinc-900\/20:where(.dark,.dark *){background-color:#18181b33}@supports (color:color-mix(in lab, red, red)){.dark\:bg-zinc-900\/20:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-zinc-900) 20%, transparent)}}.dark\:bg-zinc-900\/25:where(.dark,.dark *){background-color:#18181b40}@supports (color:color-mix(in lab, red, red)){.dark\:bg-zinc-900\/25:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-zinc-900) 25%, transparent)}}.dark\:bg-zinc-900\/30:where(.dark,.dark *){background-color:#18181b4d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-zinc-900\/30:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-zinc-900) 30%, transparent)}}.dark\:bg-zinc-900\/40:where(.dark,.dark *){background-color:#18181b66}@supports (color:color-mix(in lab, red, red)){.dark\:bg-zinc-900\/40:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-zinc-900) 40%, transparent)}}.dark\:bg-zinc-900\/50:where(.dark,.dark *){background-color:#18181b80}@supports (color:color-mix(in lab, red, red)){.dark\:bg-zinc-900\/50:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-zinc-900) 50%, transparent)}}.dark\:bg-zinc-900\/60:where(.dark,.dark *){background-color:#18181b99}@supports (color:color-mix(in lab, red, red)){.dark\:bg-zinc-900\/60:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-zinc-900) 60%, transparent)}}.dark\:bg-zinc-900\/70:where(.dark,.dark *){background-color:#18181bb3}@supports (color:color-mix(in lab, red, red)){.dark\:bg-zinc-900\/70:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-zinc-900) 70%, transparent)}}.dark\:bg-zinc-900\/80:where(.dark,.dark *){background-color:#18181bcc}@supports (color:color-mix(in lab, red, red)){.dark\:bg-zinc-900\/80:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-zinc-900) 80%, transparent)}}.dark\:bg-zinc-900\/85:where(.dark,.dark *){background-color:#18181bd9}@supports (color:color-mix(in lab, red, red)){.dark\:bg-zinc-900\/85:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-zinc-900) 85%, transparent)}}.dark\:bg-zinc-900\/90:where(.dark,.dark *){background-color:#18181be6}@supports (color:color-mix(in lab, red, red)){.dark\:bg-zinc-900\/90:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-zinc-900) 90%, transparent)}}.dark\:bg-zinc-900\/95:where(.dark,.dark *){background-color:#18181bf2}@supports (color:color-mix(in lab, red, red)){.dark\:bg-zinc-900\/95:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-zinc-900) 95%, transparent)}}.dark\:bg-zinc-950:where(.dark,.dark *){background-color:var(--color-zinc-950)}.dark\:bg-zinc-950\!:where(.dark,.dark *){background-color:var(--color-zinc-950)!important}.dark\:bg-zinc-950\/40:where(.dark,.dark *){background-color:#09090b66}@supports (color:color-mix(in lab, red, red)){.dark\:bg-zinc-950\/40:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-zinc-950) 40%, transparent)}}.dark\:bg-zinc-950\/50:where(.dark,.dark *){background-color:#09090b80}@supports (color:color-mix(in lab, red, red)){.dark\:bg-zinc-950\/50:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-zinc-950) 50%, transparent)}}.dark\:bg-zinc-950\/80:where(.dark,.dark *){background-color:#09090bcc}@supports (color:color-mix(in lab, red, red)){.dark\:bg-zinc-950\/80:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-zinc-950) 80%, transparent)}}.dark\:bg-zinc-950\/95:where(.dark,.dark *){background-color:#09090bf2}@supports (color:color-mix(in lab, red, red)){.dark\:bg-zinc-950\/95:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-zinc-950) 95%, transparent)}}.dark\:from-zinc-950:where(.dark,.dark *){--tw-gradient-from:var(--color-zinc-950);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.dark\:via-zinc-800:where(.dark,.dark *){--tw-gradient-via:var(--color-zinc-800);--tw-gradient-via-stops:var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-via) var(--tw-gradient-via-position), var(--tw-gradient-to) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-via-stops)}.dark\:via-zinc-900:where(.dark,.dark *){--tw-gradient-via:var(--color-zinc-900);--tw-gradient-via-stops:var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-via) var(--tw-gradient-via-position), var(--tw-gradient-to) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-via-stops)}.dark\:to-zinc-900:where(.dark,.dark *){--tw-gradient-to:var(--color-zinc-900);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.dark\:fill-zinc-900:where(.dark,.dark *){fill:var(--color-zinc-900)}.dark\:stroke-zinc-900:where(.dark,.dark *){stroke:var(--color-zinc-900)}.dark\:text-amber-100:where(.dark,.dark *){color:var(--color-amber-100)}.dark\:text-amber-100\/80:where(.dark,.dark *){color:#fef3c6cc}@supports (color:color-mix(in lab, red, red)){.dark\:text-amber-100\/80:where(.dark,.dark *){color:color-mix(in oklab, var(--color-amber-100) 80%, transparent)}}.dark\:text-amber-100\/90:where(.dark,.dark *){color:#fef3c6e6}@supports (color:color-mix(in lab, red, red)){.dark\:text-amber-100\/90:where(.dark,.dark *){color:color-mix(in oklab, var(--color-amber-100) 90%, transparent)}}.dark\:text-amber-200:where(.dark,.dark *){color:var(--color-amber-200)}.dark\:text-amber-200\/70:where(.dark,.dark *){color:#fee685b3}@supports (color:color-mix(in lab, red, red)){.dark\:text-amber-200\/70:where(.dark,.dark *){color:color-mix(in oklab, var(--color-amber-200) 70%, transparent)}}.dark\:text-amber-200\/80:where(.dark,.dark *){color:#fee685cc}@supports (color:color-mix(in lab, red, red)){.dark\:text-amber-200\/80:where(.dark,.dark *){color:color-mix(in oklab, var(--color-amber-200) 80%, transparent)}}.dark\:text-amber-200\/90:where(.dark,.dark *){color:#fee685e6}@supports (color:color-mix(in lab, red, red)){.dark\:text-amber-200\/90:where(.dark,.dark *){color:color-mix(in oklab, var(--color-amber-200) 90%, transparent)}}.dark\:text-amber-300:where(.dark,.dark *){color:var(--color-amber-300)}.dark\:text-amber-300\/90:where(.dark,.dark *){color:#ffd236e6}@supports (color:color-mix(in lab, red, red)){.dark\:text-amber-300\/90:where(.dark,.dark *){color:color-mix(in oklab, var(--color-amber-300) 90%, transparent)}}.dark\:text-amber-400:where(.dark,.dark *){color:var(--color-amber-400)}.dark\:text-amber-500:where(.dark,.dark *){color:var(--color-amber-500)}.dark\:text-blue-100:where(.dark,.dark *){color:var(--color-blue-100)}.dark\:text-blue-100\/90:where(.dark,.dark *){color:#dbeafee6}@supports (color:color-mix(in lab, red, red)){.dark\:text-blue-100\/90:where(.dark,.dark *){color:color-mix(in oklab, var(--color-blue-100) 90%, transparent)}}.dark\:text-blue-200:where(.dark,.dark *){color:var(--color-blue-200)}.dark\:text-blue-300:where(.dark,.dark *){color:var(--color-blue-300)}.dark\:text-blue-300\/80:where(.dark,.dark *){color:#90c5ffcc}@supports (color:color-mix(in lab, red, red)){.dark\:text-blue-300\/80:where(.dark,.dark *){color:color-mix(in oklab, var(--color-blue-300) 80%, transparent)}}.dark\:text-blue-400:where(.dark,.dark *){color:var(--color-blue-400)}.dark\:text-blue-400\/80:where(.dark,.dark *){color:#54a2ffcc}@supports (color:color-mix(in lab, red, red)){.dark\:text-blue-400\/80:where(.dark,.dark *){color:color-mix(in oklab, var(--color-blue-400) 80%, transparent)}}.dark\:text-cyan-200:where(.dark,.dark *){color:var(--color-cyan-200)}.dark\:text-cyan-300:where(.dark,.dark *){color:var(--color-cyan-300)}.dark\:text-cyan-400:where(.dark,.dark *){color:var(--color-cyan-400)}.dark\:text-emerald-100:where(.dark,.dark *){color:var(--color-emerald-100)}.dark\:text-emerald-200:where(.dark,.dark *){color:var(--color-emerald-200)}.dark\:text-emerald-300:where(.dark,.dark *){color:var(--color-emerald-300)}.dark\:text-emerald-300\/80:where(.dark,.dark *){color:#5ee9b5cc}@supports (color:color-mix(in lab, red, red)){.dark\:text-emerald-300\/80:where(.dark,.dark *){color:color-mix(in oklab, var(--color-emerald-300) 80%, transparent)}}.dark\:text-emerald-400:where(.dark,.dark *){color:var(--color-emerald-400)}.dark\:text-emerald-500:where(.dark,.dark *){color:var(--color-emerald-500)}.dark\:text-gray-100:where(.dark,.dark *){color:var(--color-gray-100)}.dark\:text-gray-200:where(.dark,.dark *){color:var(--color-gray-200)}.dark\:text-gray-300:where(.dark,.dark *){color:var(--color-gray-300)}.dark\:text-gray-400:where(.dark,.dark *){color:var(--color-gray-400)}.dark\:text-gray-500:where(.dark,.dark *){color:var(--color-gray-500)}.dark\:text-gray-600:where(.dark,.dark *){color:var(--color-gray-600)}.dark\:text-green-100:where(.dark,.dark *){color:var(--color-green-100)}.dark\:text-green-200:where(.dark,.dark *){color:var(--color-green-200)}.dark\:text-green-300:where(.dark,.dark *){color:var(--color-green-300)}.dark\:text-green-400:where(.dark,.dark *){color:var(--color-green-400)}.dark\:text-indigo-100:where(.dark,.dark *){color:var(--color-indigo-100)}.dark\:text-indigo-200:where(.dark,.dark *){color:var(--color-indigo-200)}.dark\:text-indigo-200\/90:where(.dark,.dark *){color:#c7d2ffe6}@supports (color:color-mix(in lab, red, red)){.dark\:text-indigo-200\/90:where(.dark,.dark *){color:color-mix(in oklab, var(--color-indigo-200) 90%, transparent)}}.dark\:text-indigo-300:where(.dark,.dark *){color:var(--color-indigo-300)}.dark\:text-indigo-300\/90:where(.dark,.dark *){color:#a4b3ffe6}@supports (color:color-mix(in lab, red, red)){.dark\:text-indigo-300\/90:where(.dark,.dark *){color:color-mix(in oklab, var(--color-indigo-300) 90%, transparent)}}.dark\:text-indigo-400:where(.dark,.dark *){color:var(--color-indigo-400)}.dark\:text-orange-200:where(.dark,.dark *){color:var(--color-orange-200)}.dark\:text-orange-300:where(.dark,.dark *){color:var(--color-orange-300)}.dark\:text-orange-400:where(.dark,.dark *){color:var(--color-orange-400)}.dark\:text-pink-300:where(.dark,.dark *){color:var(--color-pink-300)}.dark\:text-pink-400:where(.dark,.dark *){color:var(--color-pink-400)}.dark\:text-purple-200:where(.dark,.dark *){color:var(--color-purple-200)}.dark\:text-purple-400:where(.dark,.dark *){color:var(--color-purple-400)}.dark\:text-red-100:where(.dark,.dark *){color:var(--color-red-100)}.dark\:text-red-100\/90:where(.dark,.dark *){color:#ffe2e2e6}@supports (color:color-mix(in lab, red, red)){.dark\:text-red-100\/90:where(.dark,.dark *){color:color-mix(in oklab, var(--color-red-100) 90%, transparent)}}.dark\:text-red-200:where(.dark,.dark *){color:var(--color-red-200)}.dark\:text-red-300:where(.dark,.dark *){color:var(--color-red-300)}.dark\:text-red-300\!:where(.dark,.dark *){color:var(--color-red-300)!important}.dark\:text-red-400:where(.dark,.dark *){color:var(--color-red-400)}.dark\:text-rose-200:where(.dark,.dark *){color:var(--color-rose-200)}.dark\:text-rose-400:where(.dark,.dark *){color:var(--color-rose-400)}.dark\:text-sky-50:where(.dark,.dark *){color:var(--color-sky-50)}.dark\:text-sky-100:where(.dark,.dark *){color:var(--color-sky-100)}.dark\:text-sky-200:where(.dark,.dark *){color:var(--color-sky-200)}.dark\:text-sky-200\/75:where(.dark,.dark *){color:#b8e6febf}@supports (color:color-mix(in lab, red, red)){.dark\:text-sky-200\/75:where(.dark,.dark *){color:color-mix(in oklab, var(--color-sky-200) 75%, transparent)}}.dark\:text-sky-200\/85:where(.dark,.dark *){color:#b8e6fed9}@supports (color:color-mix(in lab, red, red)){.dark\:text-sky-200\/85:where(.dark,.dark *){color:color-mix(in oklab, var(--color-sky-200) 85%, transparent)}}.dark\:text-sky-200\/90:where(.dark,.dark *){color:#b8e6fee6}@supports (color:color-mix(in lab, red, red)){.dark\:text-sky-200\/90:where(.dark,.dark *){color:color-mix(in oklab, var(--color-sky-200) 90%, transparent)}}.dark\:text-sky-300:where(.dark,.dark *){color:var(--color-sky-300)}.dark\:text-sky-400:where(.dark,.dark *){color:var(--color-sky-400)}.dark\:text-sky-400\/80:where(.dark,.dark *){color:#00bcfecc}@supports (color:color-mix(in lab, red, red)){.dark\:text-sky-400\/80:where(.dark,.dark *){color:color-mix(in oklab, var(--color-sky-400) 80%, transparent)}}.dark\:text-teal-200:where(.dark,.dark *){color:var(--color-teal-200)}.dark\:text-teal-300:where(.dark,.dark *){color:var(--color-teal-300)}.dark\:text-teal-300\!:where(.dark,.dark *){color:var(--color-teal-300)!important}.dark\:text-teal-400:where(.dark,.dark *){color:var(--color-teal-400)}.dark\:text-violet-100:where(.dark,.dark *){color:var(--color-violet-100)}.dark\:text-violet-200:where(.dark,.dark *){color:var(--color-violet-200)}.dark\:text-violet-200\/90:where(.dark,.dark *){color:#ddd6ffe6}@supports (color:color-mix(in lab, red, red)){.dark\:text-violet-200\/90:where(.dark,.dark *){color:color-mix(in oklab, var(--color-violet-200) 90%, transparent)}}.dark\:text-violet-300:where(.dark,.dark *){color:var(--color-violet-300)}.dark\:text-violet-400:where(.dark,.dark *){color:var(--color-violet-400)}.dark\:text-white:where(.dark,.dark *){color:var(--color-white)}.dark\:text-yellow-100:where(.dark,.dark *){color:var(--color-yellow-100)}.dark\:text-yellow-200:where(.dark,.dark *){color:var(--color-yellow-200)}.dark\:text-yellow-300:where(.dark,.dark *){color:var(--color-yellow-300)}.dark\:text-yellow-400:where(.dark,.dark *){color:var(--color-yellow-400)}.dark\:text-zinc-100:where(.dark,.dark *){color:var(--color-zinc-100)}.dark\:text-zinc-200:where(.dark,.dark *){color:var(--color-zinc-200)}.dark\:text-zinc-300:where(.dark,.dark *){color:var(--color-zinc-300)}.dark\:text-zinc-400:where(.dark,.dark *){color:var(--color-zinc-400)}.dark\:text-zinc-500:where(.dark,.dark *){color:var(--color-zinc-500)}.dark\:text-zinc-600:where(.dark,.dark *){color:var(--color-zinc-600)}.dark\:text-zinc-700:where(.dark,.dark *){color:var(--color-zinc-700)}.dark\:text-zinc-700\/90:where(.dark,.dark *){color:#3f3f46e6}@supports (color:color-mix(in lab, red, red)){.dark\:text-zinc-700\/90:where(.dark,.dark *){color:color-mix(in oklab, var(--color-zinc-700) 90%, transparent)}}.dark\:text-zinc-800:where(.dark,.dark *){color:var(--color-zinc-800)}.dark\:text-zinc-900:where(.dark,.dark *){color:var(--color-zinc-900)}.dark\:placeholder-zinc-500:where(.dark,.dark *)::placeholder{color:var(--color-zinc-500)}.dark\:accent-blue-500:where(.dark,.dark *){accent-color:var(--color-blue-500)}.dark\:shadow-\[inset_2px_2px_6px_rgba\(0\,0\,0\,0\.72\)\,inset_-1px_-1px_1px_rgba\(255\,255\,255\,0\.07\)\]:where(.dark,.dark *){--tw-shadow:inset 2px 2px 6px var(--tw-shadow-color,#000000b8), inset -1px -1px 1px var(--tw-shadow-color,#ffffff12);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.dark\:shadow-black\/20:where(.dark,.dark *){--tw-shadow-color:#0003}@supports (color:color-mix(in lab, red, red)){.dark\:shadow-black\/20:where(.dark,.dark *){--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-black) 20%, transparent) var(--tw-shadow-alpha), transparent)}}.dark\:shadow-black\/30:where(.dark,.dark *){--tw-shadow-color:#0000004d}@supports (color:color-mix(in lab, red, red)){.dark\:shadow-black\/30:where(.dark,.dark *){--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-black) 30%, transparent) var(--tw-shadow-alpha), transparent)}}.dark\:shadow-black\/40:where(.dark,.dark *){--tw-shadow-color:#0006}@supports (color:color-mix(in lab, red, red)){.dark\:shadow-black\/40:where(.dark,.dark *){--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-black) 40%, transparent) var(--tw-shadow-alpha), transparent)}}.dark\:ring-blue-500\/50:where(.dark,.dark *){--tw-ring-color:#3080ff80}@supports (color:color-mix(in lab, red, red)){.dark\:ring-blue-500\/50:where(.dark,.dark *){--tw-ring-color:color-mix(in oklab, var(--color-blue-500) 50%, transparent)}}.dark\:ring-blue-600:where(.dark,.dark *){--tw-ring-color:var(--color-blue-600)}.dark\:ring-blue-700:where(.dark,.dark *){--tw-ring-color:var(--color-blue-700)}.dark\:ring-cyan-800\/60:where(.dark,.dark *){--tw-ring-color:#005f7899}@supports (color:color-mix(in lab, red, red)){.dark\:ring-cyan-800\/60:where(.dark,.dark *){--tw-ring-color:color-mix(in oklab, var(--color-cyan-800) 60%, transparent)}}.dark\:ring-indigo-500\/40:where(.dark,.dark *){--tw-ring-color:#625fff66}@supports (color:color-mix(in lab, red, red)){.dark\:ring-indigo-500\/40:where(.dark,.dark *){--tw-ring-color:color-mix(in oklab, var(--color-indigo-500) 40%, transparent)}}.dark\:ring-red-900\/60:where(.dark,.dark *){--tw-ring-color:#82181a99}@supports (color:color-mix(in lab, red, red)){.dark\:ring-red-900\/60:where(.dark,.dark *){--tw-ring-color:color-mix(in oklab, var(--color-red-900) 60%, transparent)}}.dark\:ring-white\/10:where(.dark,.dark *){--tw-ring-color:#ffffff1a}@supports (color:color-mix(in lab, red, red)){.dark\:ring-white\/10:where(.dark,.dark *){--tw-ring-color:color-mix(in oklab, var(--color-white) 10%, transparent)}}.dark\:ring-zinc-700:where(.dark,.dark *){--tw-ring-color:var(--color-zinc-700)}.dark\:ring-zinc-800:where(.dark,.dark *){--tw-ring-color:var(--color-zinc-800)}.dark\:ring-zinc-800\/90:where(.dark,.dark *){--tw-ring-color:#27272ae6}@supports (color:color-mix(in lab, red, red)){.dark\:ring-zinc-800\/90:where(.dark,.dark *){--tw-ring-color:color-mix(in oklab, var(--color-zinc-800) 90%, transparent)}}.dark\:ring-offset-zinc-900:where(.dark,.dark *){--tw-ring-offset-color:var(--color-zinc-900)}.dark\:ring-offset-zinc-950:where(.dark,.dark *){--tw-ring-offset-color:var(--color-zinc-950)}@media (hover:hover){.dark\:group-hover\:text-blue-400:where(.dark,.dark *):is(:where(.group):hover *){color:var(--color-blue-400)}.dark\:group-hover\:text-zinc-200:where(.dark,.dark *):is(:where(.group):hover *){color:var(--color-zinc-200)}.dark\:group-hover\:text-zinc-300:where(.dark,.dark *):is(:where(.group):hover *){color:var(--color-zinc-300)}}.dark\:peer-checked\:bg-blue-600:where(.dark,.dark *):is(:where(.peer):checked~*){background-color:var(--color-blue-600)}.dark\:peer-focus\:ring-blue-800:where(.dark,.dark *):is(:where(.peer):focus~*){--tw-ring-color:var(--color-blue-800)}.dark\:file\:bg-blue-600:where(.dark,.dark *)::file-selector-button{background-color:var(--color-blue-600)}.dark\:file\:bg-zinc-700:where(.dark,.dark *)::file-selector-button{background-color:var(--color-zinc-700)}.dark\:file\:text-zinc-200:where(.dark,.dark *)::file-selector-button{color:var(--color-zinc-200)}.dark\:placeholder\:text-zinc-500:where(.dark,.dark *)::placeholder{color:var(--color-zinc-500)}.dark\:open\:bg-zinc-950:where(.dark,.dark *):is([open],:popover-open,:open){background-color:var(--color-zinc-950)}@media (hover:hover){.dark\:hover\:border-amber-500\/60:where(.dark,.dark *):hover{border-color:#f99c0099}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:border-amber-500\/60:where(.dark,.dark *):hover{border-color:color-mix(in oklab, var(--color-amber-500) 60%, transparent)}}.dark\:hover\:border-amber-600:where(.dark,.dark *):hover{border-color:var(--color-amber-600)}.dark\:hover\:border-blue-500:where(.dark,.dark *):hover{border-color:var(--color-blue-500)}.dark\:hover\:border-blue-600:where(.dark,.dark *):hover{border-color:var(--color-blue-600)}.dark\:hover\:border-blue-800:where(.dark,.dark *):hover{border-color:var(--color-blue-800)}.dark\:hover\:border-blue-900\/30:where(.dark,.dark *):hover{border-color:#1c398e4d}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:border-blue-900\/30:where(.dark,.dark *):hover{border-color:color-mix(in oklab, var(--color-blue-900) 30%, transparent)}}.dark\:hover\:border-zinc-500:where(.dark,.dark *):hover{border-color:var(--color-zinc-500)}.dark\:hover\:border-zinc-600:where(.dark,.dark *):hover{border-color:var(--color-zinc-600)}.dark\:hover\:bg-amber-600:where(.dark,.dark *):hover{background-color:var(--color-amber-600)}.dark\:hover\:bg-amber-900\/20:where(.dark,.dark *):hover{background-color:#7b330633}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-amber-900\/20:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-amber-900) 20%, transparent)}}.dark\:hover\:bg-amber-900\/40:where(.dark,.dark *):hover{background-color:#7b330666}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-amber-900\/40:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-amber-900) 40%, transparent)}}.dark\:hover\:bg-amber-900\/50:where(.dark,.dark *):hover{background-color:#7b330680}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-amber-900\/50:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-amber-900) 50%, transparent)}}.dark\:hover\:bg-blue-500:where(.dark,.dark *):hover{background-color:var(--color-blue-500)}.dark\:hover\:bg-blue-500\/20:where(.dark,.dark *):hover{background-color:#3080ff33}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-blue-500\/20:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-blue-500) 20%, transparent)}}.dark\:hover\:bg-blue-600:where(.dark,.dark *):hover{background-color:var(--color-blue-600)}.dark\:hover\:bg-blue-700:where(.dark,.dark *):hover{background-color:var(--color-blue-700)}.dark\:hover\:bg-blue-800:where(.dark,.dark *):hover{background-color:var(--color-blue-800)}.dark\:hover\:bg-blue-900\/10:where(.dark,.dark *):hover{background-color:#1c398e1a}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-blue-900\/10:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-blue-900) 10%, transparent)}}.dark\:hover\:bg-blue-900\/20:where(.dark,.dark *):hover{background-color:#1c398e33}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-blue-900\/20:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-blue-900) 20%, transparent)}}.dark\:hover\:bg-blue-900\/40:where(.dark,.dark *):hover{background-color:#1c398e66}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-blue-900\/40:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-blue-900) 40%, transparent)}}.dark\:hover\:bg-blue-900\/50:where(.dark,.dark *):hover{background-color:#1c398e80}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-blue-900\/50:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-blue-900) 50%, transparent)}}.dark\:hover\:bg-blue-950\/30:where(.dark,.dark *):hover{background-color:#1624564d}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-blue-950\/30:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-blue-950) 30%, transparent)}}.dark\:hover\:bg-emerald-900\/20:where(.dark,.dark *):hover{background-color:#004e3b33}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-emerald-900\/20:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-emerald-900) 20%, transparent)}}.dark\:hover\:bg-gray-800:where(.dark,.dark *):hover{background-color:var(--color-gray-800)}.dark\:hover\:bg-green-900\/40:where(.dark,.dark *):hover{background-color:#0d542b66}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-green-900\/40:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-green-900) 40%, transparent)}}.dark\:hover\:bg-indigo-900\/20:where(.dark,.dark *):hover{background-color:#312c8533}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-indigo-900\/20:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-indigo-900) 20%, transparent)}}.dark\:hover\:bg-indigo-900\/50:where(.dark,.dark *):hover{background-color:#312c8580}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-indigo-900\/50:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-indigo-900) 50%, transparent)}}.dark\:hover\:bg-orange-900\/20:where(.dark,.dark *):hover{background-color:#7e2a0c33}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-orange-900\/20:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-orange-900) 20%, transparent)}}.dark\:hover\:bg-pink-900\/20:where(.dark,.dark *):hover{background-color:#86104333}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-pink-900\/20:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-pink-900) 20%, transparent)}}.dark\:hover\:bg-red-800:where(.dark,.dark *):hover{background-color:var(--color-red-800)}.dark\:hover\:bg-red-900\/20:where(.dark,.dark *):hover{background-color:#82181a33}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-red-900\/20:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-red-900) 20%, transparent)}}.dark\:hover\:bg-red-900\/20\!:where(.dark,.dark *):hover{background-color:#82181a33!important}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-red-900\/20\!:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-red-900) 20%, transparent)!important}}.dark\:hover\:bg-red-900\/30:where(.dark,.dark *):hover{background-color:#82181a4d}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-red-900\/30:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-red-900) 30%, transparent)}}.dark\:hover\:bg-red-900\/40:where(.dark,.dark *):hover{background-color:#82181a66}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-red-900\/40:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-red-900) 40%, transparent)}}.dark\:hover\:bg-red-900\/50:where(.dark,.dark *):hover{background-color:#82181a80}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-red-900\/50:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-red-900) 50%, transparent)}}.dark\:hover\:bg-red-950\/30:where(.dark,.dark *):hover{background-color:#4608094d}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-red-950\/30:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-red-950) 30%, transparent)}}.dark\:hover\:bg-red-950\/40:where(.dark,.dark *):hover{background-color:#46080966}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-red-950\/40:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-red-950) 40%, transparent)}}.dark\:hover\:bg-teal-900\/20:where(.dark,.dark *):hover{background-color:#0b4f4a33}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-teal-900\/20:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-teal-900) 20%, transparent)}}.dark\:hover\:bg-violet-900\/20:where(.dark,.dark *):hover{background-color:#4d179a33}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-violet-900\/20:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-violet-900) 20%, transparent)}}.dark\:hover\:bg-white\/5:where(.dark,.dark *):hover{background-color:#ffffff0d}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-white\/5:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-white) 5%, transparent)}}.dark\:hover\:bg-white\/10:where(.dark,.dark *):hover{background-color:#ffffff1a}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-white\/10:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-white) 10%, transparent)}}.dark\:hover\:bg-white\/15:where(.dark,.dark *):hover{background-color:#ffffff26}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-white\/15:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-white) 15%, transparent)}}.dark\:hover\:bg-zinc-600:where(.dark,.dark *):hover{background-color:var(--color-zinc-600)}.dark\:hover\:bg-zinc-700:where(.dark,.dark *):hover{background-color:var(--color-zinc-700)}.dark\:hover\:bg-zinc-800:where(.dark,.dark *):hover{background-color:var(--color-zinc-800)}.dark\:hover\:bg-zinc-800\/20:where(.dark,.dark *):hover{background-color:#27272a33}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-zinc-800\/20:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-zinc-800) 20%, transparent)}}.dark\:hover\:bg-zinc-800\/50:where(.dark,.dark *):hover{background-color:#27272a80}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-zinc-800\/50:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-zinc-800) 50%, transparent)}}.dark\:hover\:bg-zinc-800\/80:where(.dark,.dark *):hover{background-color:#27272acc}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-zinc-800\/80:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-zinc-800) 80%, transparent)}}.dark\:hover\:bg-zinc-900:where(.dark,.dark *):hover{background-color:var(--color-zinc-900)}.dark\:hover\:bg-zinc-900\/20:where(.dark,.dark *):hover{background-color:#18181b33}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-zinc-900\/20:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-zinc-900) 20%, transparent)}}.dark\:hover\:bg-zinc-900\/40:where(.dark,.dark *):hover{background-color:#18181b66}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-zinc-900\/40:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-zinc-900) 40%, transparent)}}.dark\:hover\:bg-zinc-900\/50:where(.dark,.dark *):hover{background-color:#18181b80}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-zinc-900\/50:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-zinc-900) 50%, transparent)}}.dark\:hover\:bg-zinc-900\/60:where(.dark,.dark *):hover{background-color:#18181b99}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-zinc-900\/60:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-zinc-900) 60%, transparent)}}.dark\:hover\:bg-zinc-900\/70:where(.dark,.dark *):hover{background-color:#18181bb3}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-zinc-900\/70:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-zinc-900) 70%, transparent)}}.dark\:hover\:bg-zinc-900\/80:where(.dark,.dark *):hover{background-color:#18181bcc}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-zinc-900\/80:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-zinc-900) 80%, transparent)}}.dark\:hover\:text-amber-200:where(.dark,.dark *):hover{color:var(--color-amber-200)}.dark\:hover\:text-amber-400:where(.dark,.dark *):hover{color:var(--color-amber-400)}.dark\:hover\:text-blue-300:where(.dark,.dark *):hover{color:var(--color-blue-300)}.dark\:hover\:text-blue-400:where(.dark,.dark *):hover{color:var(--color-blue-400)}.dark\:hover\:text-cyan-400:where(.dark,.dark *):hover{color:var(--color-cyan-400)}.dark\:hover\:text-emerald-400:where(.dark,.dark *):hover{color:var(--color-emerald-400)}.dark\:hover\:text-gray-200:where(.dark,.dark *):hover{color:var(--color-gray-200)}.dark\:hover\:text-gray-300:where(.dark,.dark *):hover{color:var(--color-gray-300)}.dark\:hover\:text-green-400:where(.dark,.dark *):hover{color:var(--color-green-400)}.dark\:hover\:text-indigo-400:where(.dark,.dark *):hover{color:var(--color-indigo-400)}.dark\:hover\:text-red-400:where(.dark,.dark *):hover{color:var(--color-red-400)}.dark\:hover\:text-violet-400:where(.dark,.dark *):hover{color:var(--color-violet-400)}.dark\:hover\:text-white:where(.dark,.dark *):hover{color:var(--color-white)}.dark\:hover\:text-zinc-100:where(.dark,.dark *):hover{color:var(--color-zinc-100)}.dark\:hover\:text-zinc-300:where(.dark,.dark *):hover{color:var(--color-zinc-300)}.dark\:hover\:file\:bg-blue-700:where(.dark,.dark *):hover::file-selector-button{background-color:var(--color-blue-700)}.dark\:hover\:file\:bg-zinc-600:where(.dark,.dark *):hover::file-selector-button{background-color:var(--color-zinc-600)}}.dark\:focus\:border-blue-400:where(.dark,.dark *):focus{border-color:var(--color-blue-400)}.dark\:focus\:border-blue-500:where(.dark,.dark *):focus{border-color:var(--color-blue-500)}.dark\:focus\:border-indigo-500:where(.dark,.dark *):focus{border-color:var(--color-indigo-500)}.dark\:focus\:ring-blue-50:where(.dark,.dark *):focus{--tw-ring-color:var(--color-blue-50)}.dark\:focus\:ring-blue-400:where(.dark,.dark *):focus{--tw-ring-color:var(--color-blue-400)}.dark\:focus\:ring-blue-500:where(.dark,.dark *):focus{--tw-ring-color:var(--color-blue-500)}.dark\:focus\:ring-indigo-500:where(.dark,.dark *):focus{--tw-ring-color:var(--color-indigo-500)}.dark\:focus\:ring-offset-zinc-900:where(.dark,.dark *):focus{--tw-ring-offset-color:var(--color-zinc-900)}.dark\:focus-visible\:ring-blue-400:where(.dark,.dark *):focus-visible{--tw-ring-color:var(--color-blue-400)}.dark\:focus-visible\:outline-zinc-500:where(.dark,.dark *):focus-visible{outline-color:var(--color-zinc-500)}.dark\:disabled\:bg-zinc-700:where(.dark,.dark *):disabled{background-color:var(--color-zinc-700)}.dark\:disabled\:bg-zinc-800:where(.dark,.dark *):disabled{background-color:var(--color-zinc-800)}@media (hover:hover){.dark\:disabled\:hover\:bg-violet-900\/10:where(.dark,.dark *):disabled:hover{background-color:#4d179a1a}@supports (color:color-mix(in lab, red, red)){.dark\:disabled\:hover\:bg-violet-900\/10:where(.dark,.dark *):disabled:hover{background-color:color-mix(in oklab, var(--color-violet-900) 10%, transparent)}}}@media (min-width:40rem){.dark\:sm\:bg-white\/2:where(.dark,.dark *){background-color:#ffffff05}@supports (color:color-mix(in lab, red, red)){.dark\:sm\:bg-white\/2:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-white) 2%, transparent)}}}.\[\&_\*\]\:bg-transparent\! *{background-color:#0000!important}.\[\&_\*\]\:text-inherit\! *{color:inherit!important}}@property --tw-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-z{syntax:"*";inherits:false;initial-value:0}@property --tw-scale-x{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-y{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-z{syntax:"*";inherits:false;initial-value:1}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-pan-x{syntax:"*";inherits:false}@property --tw-pan-y{syntax:"*";inherits:false}@property --tw-pinch-zoom{syntax:"*";inherits:false}@property --tw-scroll-snap-strictness{syntax:"*";inherits:false;initial-value:proximity}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-space-x-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-divide-x-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-gradient-position{syntax:"*";inherits:false}@property --tw-gradient-from{syntax:"";inherits:false;initial-value:#0000}@property --tw-gradient-via{syntax:"";inherits:false;initial-value:#0000}@property --tw-gradient-to{syntax:"";inherits:false;initial-value:#0000}@property --tw-gradient-stops{syntax:"*";inherits:false}@property --tw-gradient-via-stops{syntax:"*";inherits:false}@property --tw-gradient-from-position{syntax:"";inherits:false;initial-value:0%}@property --tw-gradient-via-position{syntax:"";inherits:false;initial-value:50%}@property --tw-gradient-to-position{syntax:"";inherits:false;initial-value:100%}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-ordinal{syntax:"*";inherits:false}@property --tw-slashed-zero{syntax:"*";inherits:false}@property --tw-numeric-figure{syntax:"*";inherits:false}@property --tw-numeric-spacing{syntax:"*";inherits:false}@property --tw-numeric-fraction{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-outline-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-backdrop-blur{syntax:"*";inherits:false}@property --tw-backdrop-brightness{syntax:"*";inherits:false}@property --tw-backdrop-contrast{syntax:"*";inherits:false}@property --tw-backdrop-grayscale{syntax:"*";inherits:false}@property --tw-backdrop-hue-rotate{syntax:"*";inherits:false}@property --tw-backdrop-invert{syntax:"*";inherits:false}@property --tw-backdrop-opacity{syntax:"*";inherits:false}@property --tw-backdrop-saturate{syntax:"*";inherits:false}@property --tw-backdrop-sepia{syntax:"*";inherits:false}@property --tw-duration{syntax:"*";inherits:false}@property --tw-ease{syntax:"*";inherits:false}@property --tw-contain-size{syntax:"*";inherits:false}@property --tw-contain-layout{syntax:"*";inherits:false}@property --tw-contain-paint{syntax:"*";inherits:false}@property --tw-contain-style{syntax:"*";inherits:false}@property --tw-content{syntax:"*";inherits:false;initial-value:""}@keyframes spin{to{transform:rotate(360deg)}}@keyframes ping{75%,to{opacity:0;transform:scale(2)}}@keyframes pulse{50%{opacity:.5}}@keyframes bounce{0%,to{animation-timing-function:cubic-bezier(.8,0,1,1);transform:translateY(-25%)}50%{animation-timing-function:cubic-bezier(0,0,.2,1);transform:none}} \ No newline at end of file +@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1;--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-pan-x:initial;--tw-pan-y:initial;--tw-pinch-zoom:initial;--tw-scroll-snap-strictness:proximity;--tw-space-y-reverse:0;--tw-space-x-reverse:0;--tw-divide-x-reverse:0;--tw-border-style:solid;--tw-divide-y-reverse:0;--tw-gradient-position:initial;--tw-gradient-from:#0000;--tw-gradient-via:#0000;--tw-gradient-to:#0000;--tw-gradient-stops:initial;--tw-gradient-via-stops:initial;--tw-gradient-from-position:0%;--tw-gradient-via-position:50%;--tw-gradient-to-position:100%;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial;--tw-ease:initial;--tw-contain-size:initial;--tw-contain-layout:initial;--tw-contain-paint:initial;--tw-contain-style:initial;--tw-content:""}}}@layer theme{:root,:host{--font-sans:-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-red-50:oklch(97.1% .013 17.38);--color-red-100:oklch(93.6% .032 17.717);--color-red-200:oklch(88.5% .062 18.334);--color-red-300:oklch(80.8% .114 19.571);--color-red-400:oklch(70.4% .191 22.216);--color-red-500:oklch(63.7% .237 25.331);--color-red-600:oklch(57.7% .245 27.325);--color-red-700:oklch(50.5% .213 27.518);--color-red-800:oklch(44.4% .177 26.899);--color-red-900:oklch(39.6% .141 25.723);--color-red-950:oklch(25.8% .092 26.042);--color-orange-50:oklch(98% .016 73.684);--color-orange-100:oklch(95.4% .038 75.164);--color-orange-200:oklch(90.1% .076 70.697);--color-orange-300:oklch(83.7% .128 66.29);--color-orange-400:oklch(75% .183 55.934);--color-orange-500:oklch(70.5% .213 47.604);--color-orange-600:oklch(64.6% .222 41.116);--color-orange-700:oklch(55.3% .195 38.402);--color-orange-800:oklch(47% .157 37.304);--color-orange-900:oklch(40.8% .123 38.172);--color-amber-50:oklch(98.7% .022 95.277);--color-amber-100:oklch(96.2% .059 95.617);--color-amber-200:oklch(92.4% .12 95.746);--color-amber-300:oklch(87.9% .169 91.605);--color-amber-400:oklch(82.8% .189 84.429);--color-amber-500:oklch(76.9% .188 70.08);--color-amber-600:oklch(66.6% .179 58.318);--color-amber-700:oklch(55.5% .163 48.998);--color-amber-800:oklch(47.3% .137 46.201);--color-amber-900:oklch(41.4% .112 45.904);--color-amber-950:oklch(27.9% .077 45.635);--color-yellow-50:oklch(98.7% .026 102.212);--color-yellow-100:oklch(97.3% .071 103.193);--color-yellow-200:oklch(94.5% .129 101.54);--color-yellow-300:oklch(90.5% .182 98.111);--color-yellow-400:oklch(85.2% .199 91.936);--color-yellow-500:oklch(79.5% .184 86.047);--color-yellow-600:oklch(68.1% .162 75.834);--color-yellow-700:oklch(55.4% .135 66.442);--color-yellow-800:oklch(47.6% .114 61.907);--color-yellow-900:oklch(42.1% .095 57.708);--color-green-50:oklch(98.2% .018 155.826);--color-green-100:oklch(96.2% .044 156.743);--color-green-200:oklch(92.5% .084 155.995);--color-green-300:oklch(87.1% .15 154.449);--color-green-400:oklch(79.2% .209 151.711);--color-green-500:oklch(72.3% .219 149.579);--color-green-600:oklch(62.7% .194 149.214);--color-green-700:oklch(52.7% .154 150.069);--color-green-800:oklch(44.8% .119 151.328);--color-green-900:oklch(39.3% .095 152.535);--color-emerald-50:oklch(97.9% .021 166.113);--color-emerald-100:oklch(95% .052 163.051);--color-emerald-200:oklch(90.5% .093 164.15);--color-emerald-300:oklch(84.5% .143 164.978);--color-emerald-400:oklch(76.5% .177 163.223);--color-emerald-500:oklch(69.6% .17 162.48);--color-emerald-600:oklch(59.6% .145 163.225);--color-emerald-700:oklch(50.8% .118 165.612);--color-emerald-800:oklch(43.2% .095 166.913);--color-emerald-900:oklch(37.8% .077 168.94);--color-emerald-950:oklch(26.2% .051 172.552);--color-teal-50:oklch(98.4% .014 180.72);--color-teal-100:oklch(95.3% .051 180.801);--color-teal-200:oklch(91% .096 180.426);--color-teal-300:oklch(85.5% .138 181.071);--color-teal-400:oklch(77.7% .152 181.912);--color-teal-500:oklch(70.4% .14 182.503);--color-teal-600:oklch(60% .118 184.704);--color-teal-700:oklch(51.1% .096 186.391);--color-teal-800:oklch(43.7% .078 188.216);--color-teal-900:oklch(38.6% .063 188.416);--color-cyan-50:oklch(98.4% .019 200.873);--color-cyan-100:oklch(95.6% .045 203.388);--color-cyan-200:oklch(91.7% .08 205.041);--color-cyan-300:oklch(86.5% .127 207.078);--color-cyan-400:oklch(78.9% .154 211.53);--color-cyan-500:oklch(71.5% .143 215.221);--color-cyan-600:oklch(60.9% .126 221.723);--color-cyan-700:oklch(52% .105 223.128);--color-cyan-800:oklch(45% .085 224.283);--color-cyan-900:oklch(39.8% .07 227.392);--color-cyan-950:oklch(30.2% .056 229.695);--color-sky-50:oklch(97.7% .013 236.62);--color-sky-100:oklch(95.1% .026 236.824);--color-sky-200:oklch(90.1% .058 230.902);--color-sky-300:oklch(82.8% .111 230.318);--color-sky-400:oklch(74.6% .16 232.661);--color-sky-500:oklch(68.5% .169 237.323);--color-sky-600:oklch(58.8% .158 241.966);--color-sky-700:oklch(50% .134 242.749);--color-sky-800:oklch(44.3% .11 240.79);--color-sky-900:oklch(39.1% .09 240.876);--color-sky-950:oklch(29.3% .066 243.157);--color-blue-50:oklch(97% .014 254.604);--color-blue-100:oklch(93.2% .032 255.585);--color-blue-200:oklch(88.2% .059 254.128);--color-blue-300:oklch(80.9% .105 251.813);--color-blue-400:oklch(70.7% .165 254.624);--color-blue-500:oklch(62.3% .214 259.815);--color-blue-600:oklch(54.6% .245 262.881);--color-blue-700:oklch(48.8% .243 264.376);--color-blue-800:oklch(42.4% .199 265.638);--color-blue-900:oklch(37.9% .146 265.522);--color-blue-950:oklch(28.2% .091 267.935);--color-indigo-50:oklch(96.2% .018 272.314);--color-indigo-100:oklch(93% .034 272.788);--color-indigo-200:oklch(87% .065 274.039);--color-indigo-300:oklch(78.5% .115 274.713);--color-indigo-400:oklch(67.3% .182 276.935);--color-indigo-500:oklch(58.5% .233 277.117);--color-indigo-600:oklch(51.1% .262 276.966);--color-indigo-700:oklch(45.7% .24 277.023);--color-indigo-800:oklch(39.8% .195 277.366);--color-indigo-900:oklch(35.9% .144 278.697);--color-indigo-950:oklch(25.7% .09 281.288);--color-violet-50:oklch(96.9% .016 293.756);--color-violet-100:oklch(94.3% .029 294.588);--color-violet-200:oklch(89.4% .057 293.283);--color-violet-300:oklch(81.1% .111 293.571);--color-violet-400:oklch(70.2% .183 293.541);--color-violet-600:oklch(54.1% .281 293.009);--color-violet-700:oklch(49.1% .27 292.581);--color-violet-800:oklch(43.2% .232 292.759);--color-violet-900:oklch(38% .189 293.745);--color-violet-950:oklch(28.3% .141 291.089);--color-purple-50:oklch(97.7% .014 308.299);--color-purple-100:oklch(94.6% .033 307.174);--color-purple-200:oklch(90.2% .063 306.703);--color-purple-300:oklch(82.7% .119 306.383);--color-purple-400:oklch(71.4% .203 305.504);--color-purple-500:oklch(62.7% .265 303.9);--color-purple-600:oklch(55.8% .288 302.321);--color-purple-800:oklch(43.8% .218 303.724);--color-purple-900:oklch(38.1% .176 304.987);--color-pink-50:oklch(97.1% .014 343.198);--color-pink-100:oklch(94.8% .028 342.258);--color-pink-200:oklch(89.9% .061 343.231);--color-pink-300:oklch(82.3% .12 346.018);--color-pink-400:oklch(71.8% .202 349.761);--color-pink-500:oklch(65.6% .241 354.308);--color-pink-600:oklch(59.2% .249 .584);--color-pink-700:oklch(52.5% .223 3.958);--color-pink-800:oklch(45.9% .187 3.815);--color-pink-900:oklch(40.8% .153 2.432);--color-rose-50:oklch(96.9% .015 12.422);--color-rose-100:oklch(94.1% .03 12.58);--color-rose-200:oklch(89.2% .058 10.001);--color-rose-400:oklch(71.2% .194 13.428);--color-rose-500:oklch(64.5% .246 16.439);--color-rose-600:oklch(58.6% .253 17.585);--color-rose-700:oklch(51.4% .222 16.935);--color-rose-900:oklch(41% .159 10.272);--color-slate-50:oklch(98.4% .003 247.858);--color-slate-100:oklch(96.8% .007 247.896);--color-slate-200:oklch(92.9% .013 255.508);--color-slate-300:oklch(86.9% .022 252.894);--color-slate-400:oklch(70.4% .04 256.788);--color-slate-500:oklch(55.4% .046 257.417);--color-slate-600:oklch(44.6% .043 257.281);--color-slate-700:oklch(37.2% .044 257.287);--color-slate-800:oklch(27.9% .041 260.031);--color-slate-900:oklch(20.8% .042 265.755);--color-gray-50:oklch(98.5% .002 247.839);--color-gray-100:oklch(96.7% .003 264.542);--color-gray-200:oklch(92.8% .006 264.531);--color-gray-300:oklch(87.2% .01 258.338);--color-gray-400:oklch(70.7% .022 261.325);--color-gray-500:oklch(55.1% .027 264.364);--color-gray-600:oklch(44.6% .03 256.802);--color-gray-700:oklch(37.3% .034 259.733);--color-gray-800:oklch(27.8% .033 256.848);--color-gray-900:oklch(21% .034 264.665);--color-zinc-50:oklch(98.5% 0 none);--color-zinc-100:oklch(96.7% .001 286.375);--color-zinc-200:oklch(92% .004 286.32);--color-zinc-300:oklch(87.1% .006 286.286);--color-zinc-400:oklch(70.5% .015 286.067);--color-zinc-500:oklch(55.2% .016 285.938);--color-zinc-600:oklch(44.2% .017 285.786);--color-zinc-700:oklch(37% .013 285.805);--color-zinc-800:oklch(27.4% .006 286.033);--color-zinc-900:oklch(21% .006 285.885);--color-zinc-950:oklch(14.1% .005 285.823);--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-xs:20rem;--container-sm:24rem;--container-md:28rem;--container-lg:32rem;--container-xl:36rem;--container-2xl:42rem;--container-3xl:48rem;--container-4xl:56rem;--container-5xl:64rem;--container-6xl:72rem;--container-7xl:80rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5 / 2.25);--text-5xl:3rem;--text-5xl--line-height:1;--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--font-weight-black:900;--tracking-tighter:-.05em;--tracking-tight:-.025em;--tracking-normal:0em;--tracking-wide:.025em;--tracking-wider:.05em;--tracking-widest:.1em;--leading-tight:1.25;--leading-snug:1.375;--leading-normal:1.5;--leading-relaxed:1.625;--radius-xs:.125rem;--radius-sm:.25rem;--radius-md:.375rem;--radius-lg:.5rem;--radius-xl:.75rem;--radius-2xl:1rem;--radius-3xl:1.5rem;--radius-4xl:2rem;--ease-in:cubic-bezier(.4, 0, 1, 1);--ease-out:cubic-bezier(0, 0, .2, 1);--ease-in-out:cubic-bezier(.4, 0, .2, 1);--animate-spin:spin 1s linear infinite;--animate-ping:ping 1s cubic-bezier(0, 0, .2, 1) infinite;--animate-pulse:pulse 2s cubic-bezier(.4, 0, .6, 1) infinite;--animate-bounce:bounce 1s infinite;--blur-xs:4px;--blur-sm:8px;--blur-md:12px;--aspect-video:16 / 9;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}input:where([type=text]),input:where(:not([type])),input:where([type=email]),input:where([type=url]),input:where([type=password]),input:where([type=number]),input:where([type=date]),input:where([type=datetime-local]),input:where([type=month]),input:where([type=search]),input:where([type=tel]),input:where([type=time]),input:where([type=week]),select:where([multiple]),textarea,select{appearance:none;--tw-shadow:0 0 #0000;background-color:#fff;border-width:1px;border-color:oklch(55.1% .027 264.364);border-radius:0;padding:.5rem .75rem;font-size:1rem;line-height:1.5rem}:is(input:where([type=text]),input:where(:not([type])),input:where([type=email]),input:where([type=url]),input:where([type=password]),input:where([type=number]),input:where([type=date]),input:where([type=datetime-local]),input:where([type=month]),input:where([type=search]),input:where([type=tel]),input:where([type=time]),input:where([type=week]),select:where([multiple]),textarea,select):focus{outline-offset:2px;--tw-ring-inset:var(--tw-empty, );--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:oklch(54.6% .245 262.881);--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);border-color:oklch(54.6% .245 262.881);outline:2px solid #0000}input::placeholder,textarea::placeholder{color:oklch(55.1% .027 264.364);opacity:1}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-date-and-time-value{min-height:1.5em;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit{padding-top:0;padding-bottom:0}::-webkit-datetime-edit-year-field{padding-top:0;padding-bottom:0}::-webkit-datetime-edit-month-field{padding-top:0;padding-bottom:0}::-webkit-datetime-edit-day-field{padding-top:0;padding-bottom:0}::-webkit-datetime-edit-hour-field{padding-top:0;padding-bottom:0}::-webkit-datetime-edit-minute-field{padding-top:0;padding-bottom:0}::-webkit-datetime-edit-second-field{padding-top:0;padding-bottom:0}::-webkit-datetime-edit-millisecond-field{padding-top:0;padding-bottom:0}::-webkit-datetime-edit-meridiem-field{padding-top:0;padding-bottom:0}select{-webkit-print-color-adjust:exact;print-color-adjust:exact;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='oklch(55.1%25 0.027 264.364)' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e");background-position:right .5rem center;background-repeat:no-repeat;background-size:1.5em 1.5em;padding-right:2.5rem}select:where([multiple]),select:where([size]:not([size="1"])){background-image:initial;background-position:initial;background-repeat:unset;background-size:initial;print-color-adjust:unset;padding-right:.75rem}input:where([type=checkbox]),input:where([type=radio]){appearance:none;-webkit-print-color-adjust:exact;print-color-adjust:exact;vertical-align:middle;-webkit-user-select:none;user-select:none;color:oklch(54.6% .245 262.881);--tw-shadow:0 0 #0000;background-color:#fff;background-origin:border-box;border-width:1px;border-color:oklch(55.1% .027 264.364);flex-shrink:0;width:1rem;height:1rem;padding:0;display:inline-block}input:where([type=checkbox]){border-radius:0}input:where([type=radio]){border-radius:100%}input:where([type=checkbox]):focus,input:where([type=radio]):focus{outline-offset:2px;--tw-ring-inset:var(--tw-empty, );--tw-ring-offset-width:2px;--tw-ring-offset-color:#fff;--tw-ring-color:oklch(54.6% .245 262.881);--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);outline:2px solid #0000}input:where([type=checkbox]):checked,input:where([type=radio]):checked{background-color:currentColor;background-position:50%;background-repeat:no-repeat;background-size:100% 100%;border-color:#0000}input:where([type=checkbox]):checked{background-image:url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e")}@media (forced-colors:active){input:where([type=checkbox]):checked{appearance:auto}}input:where([type=radio]):checked{background-image:url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3ccircle cx='8' cy='8' r='3'/%3e%3c/svg%3e")}@media (forced-colors:active){input:where([type=radio]):checked{appearance:auto}}input:where([type=checkbox]):checked:hover,input:where([type=checkbox]):checked:focus,input:where([type=radio]):checked:hover,input:where([type=radio]):checked:focus{background-color:currentColor;border-color:#0000}input:where([type=checkbox]):indeterminate{background-color:currentColor;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 16 16'%3e%3cpath stroke='white' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M4 8h8'/%3e%3c/svg%3e");background-position:50%;background-repeat:no-repeat;background-size:100% 100%;border-color:#0000}@media (forced-colors:active){input:where([type=checkbox]):indeterminate{appearance:auto}}input:where([type=checkbox]):indeterminate:hover,input:where([type=checkbox]):indeterminate:focus{background-color:currentColor;border-color:#0000}input:where([type=file]){background:unset;border-color:inherit;font-size:unset;line-height:inherit;border-width:0;border-radius:0;padding:0}input:where([type=file]):focus{outline:1px solid buttontext;outline:1px auto -webkit-focus-ring-color}}@layer components;@layer utilities{.pointer-events-auto{pointer-events:auto}.pointer-events-none{pointer-events:none}.collapse{visibility:collapse}.invisible{visibility:hidden}.visible{visibility:visible}.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.not-sr-only{clip-path:none;white-space:normal;width:auto;height:auto;margin:0;padding:0;position:static;overflow:visible}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.sticky{position:sticky}.-inset-2{inset:calc(var(--spacing) * -2)}.inset-0{inset:0}.inset-x-0{inset-inline:0}.inset-y-0{inset-block:0}.-top-0\.5{top:calc(var(--spacing) * -.5)}.-top-1{top:calc(var(--spacing) * -1)}.-top-2{top:calc(var(--spacing) * -2)}.-top-10{top:calc(var(--spacing) * -10)}.-top-12{top:calc(var(--spacing) * -12)}.top-0{top:0}.top-1{top:var(--spacing)}.top-1\.5{top:calc(var(--spacing) * 1.5)}.top-1\/2{top:50%}.top-2{top:calc(var(--spacing) * 2)}.top-3{top:calc(var(--spacing) * 3)}.top-4{top:calc(var(--spacing) * 4)}.top-14{top:calc(var(--spacing) * 14)}.top-\[calc\(0\.5rem\+2\.75rem\+0\.5rem\)\]{top:3.75rem}.top-\[calc\(0\.5rem\+2\.75rem\+0\.5rem\+2\.75rem\)\]{top:6.5rem}.top-full{top:100%}.-right-0\.5{right:calc(var(--spacing) * -.5)}.-right-1{right:calc(var(--spacing) * -1)}.-right-1\.5{right:calc(var(--spacing) * -1.5)}.-right-2{right:calc(var(--spacing) * -2)}.-right-3{right:calc(var(--spacing) * -3)}.right-0{right:0}.right-1{right:var(--spacing)}.right-1\.5{right:calc(var(--spacing) * 1.5)}.right-2{right:calc(var(--spacing) * 2)}.right-3{right:calc(var(--spacing) * 3)}.right-4{right:calc(var(--spacing) * 4)}.right-\[max\(0\.75rem\,env\(safe-area-inset-right\)\)\]{right:max(.75rem, env(safe-area-inset-right))}.right-\[max\(1rem\,env\(safe-area-inset-right\,0px\)\)\]{right:max(1rem, env(safe-area-inset-right,0px))}.-bottom-0\.5{bottom:calc(var(--spacing) * -.5)}.-bottom-1{bottom:calc(var(--spacing) * -1)}.-bottom-2{bottom:calc(var(--spacing) * -2)}.bottom-0{bottom:0}.bottom-1{bottom:var(--spacing)}.bottom-2{bottom:calc(var(--spacing) * 2)}.bottom-3{bottom:calc(var(--spacing) * 3)}.bottom-4{bottom:calc(var(--spacing) * 4)}.bottom-5{bottom:calc(var(--spacing) * 5)}.bottom-9{bottom:calc(var(--spacing) * 9)}.bottom-\[max\(0\.75rem\,env\(safe-area-inset-bottom\)\)\]{bottom:max(.75rem, env(safe-area-inset-bottom))}.bottom-\[max\(1\.25rem\,env\(safe-area-inset-bottom\,0px\)\)\]{bottom:max(1.25rem, env(safe-area-inset-bottom,0px))}.bottom-\[max\(1rem\,env\(safe-area-inset-bottom\,0px\)\)\]{bottom:max(1rem, env(safe-area-inset-bottom,0px))}.bottom-full{bottom:100%}.-left-1\.5{left:calc(var(--spacing) * -1.5)}.-left-3{left:calc(var(--spacing) * -3)}.left-0{left:0}.left-1{left:var(--spacing)}.left-1\/2{left:50%}.left-2{left:calc(var(--spacing) * 2)}.left-2\.5{left:calc(var(--spacing) * 2.5)}.left-3{left:calc(var(--spacing) * 3)}.left-3\.5{left:calc(var(--spacing) * 3.5)}.left-4{left:calc(var(--spacing) * 4)}.left-\[-2px\]{left:-2px}.isolate{isolation:isolate}.isolation-auto{isolation:auto}.z-0{z-index:0}.z-1{z-index:1}.z-5{z-index:5}.z-10{z-index:10}.z-19{z-index:19}.z-20{z-index:20}.z-30{z-index:30}.z-40{z-index:40}.z-45{z-index:45}.z-50{z-index:50}.z-60{z-index:60}.z-65{z-index:65}.z-70{z-index:70}.z-90{z-index:90}.z-95{z-index:95}.z-100{z-index:100}.z-120{z-index:120}.z-150{z-index:150}.z-180{z-index:180}.z-190{z-index:190}.z-200{z-index:200}.z-210{z-index:210}.z-220{z-index:220}.z-9999{z-index:9999}.z-\[60\]{z-index:60}.z-\[200\]{z-index:200}.z-\[220\]{z-index:220}.z-\[250\]{z-index:250}.z-\[500\]{z-index:500}.order-1{order:1}.order-2{order:2}.order-first{order:-9999}.col-span-2{grid-column:span 2/span 2}.float-end{float:inline-end}.float-left{float:left}.float-none{float:none}.float-right{float:right}.float-start{float:inline-start}.clear-both{clear:both}.clear-end{clear:inline-end}.clear-left{clear:left}.clear-none{clear:none}.clear-right{clear:right}.clear-start{clear:inline-start}.container{width:100%}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.-m-px{margin:-1px}.m-0{margin:0}.m-4{margin:calc(var(--spacing) * 4)}.m-\[-1px\]{margin:-1px}.-mx-3{margin-inline:calc(var(--spacing) * -3)}.-mx-4{margin-inline:calc(var(--spacing) * -4)}.mx-0\.5{margin-inline:calc(var(--spacing) * .5)}.mx-1{margin-inline:var(--spacing)}.mx-3{margin-inline:calc(var(--spacing) * 3)}.mx-4{margin-inline:calc(var(--spacing) * 4)}.mx-auto{margin-inline:auto}.-my-2{margin-block:calc(var(--spacing) * -2)}.my-1{margin-block:var(--spacing)}.my-2{margin-block:calc(var(--spacing) * 2)}.my-3{margin-block:calc(var(--spacing) * 3)}.my-4{margin-block:calc(var(--spacing) * 4)}.my-6{margin-block:calc(var(--spacing) * 6)}.my-8{margin-block:calc(var(--spacing) * 8)}.my-10{margin-block:calc(var(--spacing) * 10)}.my-auto{margin-block:auto}.-mt-0\.5{margin-top:calc(var(--spacing) * -.5)}.-mt-1{margin-top:calc(var(--spacing) * -1)}.mt-0\.5{margin-top:calc(var(--spacing) * .5)}.mt-1{margin-top:var(--spacing)}.mt-1\.5{margin-top:calc(var(--spacing) * 1.5)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-5{margin-top:calc(var(--spacing) * 5)}.mt-6{margin-top:calc(var(--spacing) * 6)}.mt-7{margin-top:calc(var(--spacing) * 7)}.mt-8{margin-top:calc(var(--spacing) * 8)}.mt-10{margin-top:calc(var(--spacing) * 10)}.mt-12{margin-top:calc(var(--spacing) * 12)}.mt-14{margin-top:calc(var(--spacing) * 14)}.mt-auto{margin-top:auto}.-mr-2{margin-right:calc(var(--spacing) * -2)}.mr-0{margin-right:0}.mr-1{margin-right:var(--spacing)}.mr-1\.5{margin-right:calc(var(--spacing) * 1.5)}.mr-2{margin-right:calc(var(--spacing) * 2)}.mr-3{margin-right:calc(var(--spacing) * 3)}.mr-4{margin-right:calc(var(--spacing) * 4)}.mr-auto{margin-right:auto}.-mb-0\.5{margin-bottom:calc(var(--spacing) * -.5)}.-mb-px{margin-bottom:-1px}.mb-0{margin-bottom:0}.mb-0\!{margin-bottom:0!important}.mb-0\.5{margin-bottom:calc(var(--spacing) * .5)}.mb-1{margin-bottom:var(--spacing)}.mb-1\.5{margin-bottom:calc(var(--spacing) * 1.5)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.mb-6{margin-bottom:calc(var(--spacing) * 6)}.mb-8{margin-bottom:calc(var(--spacing) * 8)}.mb-10{margin-bottom:calc(var(--spacing) * 10)}.-ml-1{margin-left:calc(var(--spacing) * -1)}.-ml-11{margin-left:calc(var(--spacing) * -11)}.ml-0\.5{margin-left:calc(var(--spacing) * .5)}.ml-1{margin-left:var(--spacing)}.ml-1\.5{margin-left:calc(var(--spacing) * 1.5)}.ml-2{margin-left:calc(var(--spacing) * 2)}.ml-3{margin-left:calc(var(--spacing) * 3)}.ml-4{margin-left:calc(var(--spacing) * 4)}.ml-6{margin-left:calc(var(--spacing) * 6)}.ml-auto{margin-left:auto}.box-border{box-sizing:border-box}.box-content{box-sizing:content-box}.form-checkbox{appearance:none;-webkit-print-color-adjust:exact;print-color-adjust:exact;vertical-align:middle;-webkit-user-select:none;user-select:none;color:oklch(54.6% .245 262.881);--tw-shadow:0 0 #0000;background-color:#fff;background-origin:border-box;border-width:1px;border-color:oklch(55.1% .027 264.364);border-radius:0;flex-shrink:0;width:1rem;height:1rem;padding:0;display:inline-block}.form-checkbox:focus{outline-offset:2px;--tw-ring-inset:var(--tw-empty, );--tw-ring-offset-width:2px;--tw-ring-offset-color:#fff;--tw-ring-color:oklch(54.6% .245 262.881);--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);outline:2px solid #0000}.form-checkbox:checked{background-color:currentColor;background-image:url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e");background-position:50%;background-repeat:no-repeat;background-size:100% 100%;border-color:#0000}@media (forced-colors:active){.form-checkbox:checked{appearance:auto}}.form-checkbox:checked:hover,.form-checkbox:checked:focus{background-color:currentColor;border-color:#0000}.form-checkbox:indeterminate{background-color:currentColor;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 16 16'%3e%3cpath stroke='white' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M4 8h8'/%3e%3c/svg%3e");background-position:50%;background-repeat:no-repeat;background-size:100% 100%;border-color:#0000}@media (forced-colors:active){.form-checkbox:indeterminate{appearance:auto}}.form-checkbox:indeterminate:hover,.form-checkbox:indeterminate:focus{background-color:currentColor;border-color:#0000}.form-radio{appearance:none;-webkit-print-color-adjust:exact;print-color-adjust:exact;vertical-align:middle;-webkit-user-select:none;user-select:none;color:oklch(54.6% .245 262.881);--tw-shadow:0 0 #0000;background-color:#fff;background-origin:border-box;border-width:1px;border-color:oklch(55.1% .027 264.364);border-radius:100%;flex-shrink:0;width:1rem;height:1rem;padding:0;display:inline-block}.form-radio:focus{outline-offset:2px;--tw-ring-inset:var(--tw-empty, );--tw-ring-offset-width:2px;--tw-ring-offset-color:#fff;--tw-ring-color:oklch(54.6% .245 262.881);--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);outline:2px solid #0000}.form-radio:checked{background-color:currentColor;background-image:url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3ccircle cx='8' cy='8' r='3'/%3e%3c/svg%3e");background-position:50%;background-repeat:no-repeat;background-size:100% 100%;border-color:#0000}@media (forced-colors:active){.form-radio:checked{appearance:auto}}.form-radio:checked:hover,.form-radio:checked:focus{background-color:currentColor;border-color:#0000}.form-input{appearance:none;--tw-shadow:0 0 #0000;background-color:#fff;border-width:1px;border-color:oklch(55.1% .027 264.364);border-radius:0;padding:.5rem .75rem;font-size:1rem;line-height:1.5rem}.form-input:focus{outline-offset:2px;--tw-ring-inset:var(--tw-empty, );--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:oklch(54.6% .245 262.881);--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);border-color:oklch(54.6% .245 262.881);outline:2px solid #0000}.form-input::placeholder{color:oklch(55.1% .027 264.364);opacity:1}.form-input::-webkit-datetime-edit-fields-wrapper{padding:0}.form-input::-webkit-date-and-time-value{min-height:1.5em}.form-input::-webkit-date-and-time-value{text-align:inherit}.form-input::-webkit-datetime-edit{display:inline-flex}.form-input::-webkit-datetime-edit{padding-top:0;padding-bottom:0}.form-input::-webkit-datetime-edit-year-field{padding-top:0;padding-bottom:0}.form-input::-webkit-datetime-edit-month-field{padding-top:0;padding-bottom:0}.form-input::-webkit-datetime-edit-day-field{padding-top:0;padding-bottom:0}.form-input::-webkit-datetime-edit-hour-field{padding-top:0;padding-bottom:0}.form-input::-webkit-datetime-edit-minute-field{padding-top:0;padding-bottom:0}.form-input::-webkit-datetime-edit-second-field{padding-top:0;padding-bottom:0}.form-input::-webkit-datetime-edit-millisecond-field{padding-top:0;padding-bottom:0}.form-input::-webkit-datetime-edit-meridiem-field{padding-top:0;padding-bottom:0}.line-clamp-2{-webkit-line-clamp:2;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.line-clamp-3{-webkit-line-clamp:3;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.line-clamp-4{-webkit-line-clamp:4;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.line-clamp-none{-webkit-line-clamp:unset;-webkit-box-orient:horizontal;display:block;overflow:visible}.block{display:block}.contents{display:contents}.flex{display:flex}.flow-root{display:flow-root}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-block{display:inline-block}.inline-flex{display:inline-flex}.inline-grid{display:inline-grid}.inline-table{display:inline-table}.list-item{display:list-item}.table{display:table}.table-caption{display:table-caption}.table-cell{display:table-cell}.table-column{display:table-column}.table-column-group{display:table-column-group}.table-footer-group{display:table-footer-group}.table-header-group{display:table-header-group}.table-row{display:table-row}.table-row-group{display:table-row-group}.aspect-2\/1{aspect-ratio:2}.aspect-square{aspect-ratio:1}.aspect-video{aspect-ratio:var(--aspect-video)}.size-1\.5{width:calc(var(--spacing) * 1.5);height:calc(var(--spacing) * 1.5)}.size-2{width:calc(var(--spacing) * 2);height:calc(var(--spacing) * 2)}.size-2\.5{width:calc(var(--spacing) * 2.5);height:calc(var(--spacing) * 2.5)}.size-3{width:calc(var(--spacing) * 3);height:calc(var(--spacing) * 3)}.size-3\.5{width:calc(var(--spacing) * 3.5);height:calc(var(--spacing) * 3.5)}.size-4{width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.size-5{width:calc(var(--spacing) * 5);height:calc(var(--spacing) * 5)}.size-6{width:calc(var(--spacing) * 6);height:calc(var(--spacing) * 6)}.size-7{width:calc(var(--spacing) * 7);height:calc(var(--spacing) * 7)}.size-8{width:calc(var(--spacing) * 8);height:calc(var(--spacing) * 8)}.size-9{width:calc(var(--spacing) * 9);height:calc(var(--spacing) * 9)}.size-10{width:calc(var(--spacing) * 10);height:calc(var(--spacing) * 10)}.size-12{width:calc(var(--spacing) * 12);height:calc(var(--spacing) * 12)}.size-14{width:calc(var(--spacing) * 14);height:calc(var(--spacing) * 14)}.size-16{width:calc(var(--spacing) * 16);height:calc(var(--spacing) * 16)}.size-20{width:calc(var(--spacing) * 20);height:calc(var(--spacing) * 20)}.size-24{width:calc(var(--spacing) * 24);height:calc(var(--spacing) * 24)}.size-28{width:calc(var(--spacing) * 28);height:calc(var(--spacing) * 28)}.size-32{width:calc(var(--spacing) * 32);height:calc(var(--spacing) * 32)}.size-40{width:calc(var(--spacing) * 40);height:calc(var(--spacing) * 40)}.size-\[14px\]{width:14px;height:14px}.size-\[18px\]{width:18px;height:18px}.size-\[22px\]{width:22px;height:22px}.size-\[34px\]{width:34px;height:34px}.size-\[52px\]{width:52px;height:52px}.size-\[120px\]{width:120px;height:120px}.size-full{width:100%;height:100%}.h-0{height:0}.h-0\.5{height:calc(var(--spacing) * .5)}.h-1{height:var(--spacing)}.h-1\.5{height:calc(var(--spacing) * 1.5)}.h-2{height:calc(var(--spacing) * 2)}.h-2\.5{height:calc(var(--spacing) * 2.5)}.h-3{height:calc(var(--spacing) * 3)}.h-3\.5{height:calc(var(--spacing) * 3.5)}.h-4{height:calc(var(--spacing) * 4)}.h-5{height:calc(var(--spacing) * 5)}.h-6{height:calc(var(--spacing) * 6)}.h-7{height:calc(var(--spacing) * 7)}.h-8{height:calc(var(--spacing) * 8)}.h-9{height:calc(var(--spacing) * 9)}.h-10{height:calc(var(--spacing) * 10)}.h-10\!{height:calc(var(--spacing) * 10)!important}.h-11{height:calc(var(--spacing) * 11)}.h-12{height:calc(var(--spacing) * 12)}.h-14{height:calc(var(--spacing) * 14)}.h-16{height:calc(var(--spacing) * 16)}.h-20{height:calc(var(--spacing) * 20)}.h-24{height:calc(var(--spacing) * 24)}.h-28{height:calc(var(--spacing) * 28)}.h-32{height:calc(var(--spacing) * 32)}.h-40{height:calc(var(--spacing) * 40)}.h-44{height:calc(var(--spacing) * 44)}.h-48{height:calc(var(--spacing) * 48)}.h-52{height:calc(var(--spacing) * 52)}.h-64{height:calc(var(--spacing) * 64)}.h-\[42\%\]{height:42%}.h-auto{height:auto}.h-dvh{height:100dvh}.h-fit{height:fit-content}.h-full{height:100%}.h-px{height:1px}.max-h-12{max-height:calc(var(--spacing) * 12)}.max-h-20{max-height:calc(var(--spacing) * 20)}.max-h-36{max-height:calc(var(--spacing) * 36)}.max-h-44{max-height:calc(var(--spacing) * 44)}.max-h-48{max-height:calc(var(--spacing) * 48)}.max-h-52{max-height:calc(var(--spacing) * 52)}.max-h-56{max-height:calc(var(--spacing) * 56)}.max-h-60{max-height:calc(var(--spacing) * 60)}.max-h-64{max-height:calc(var(--spacing) * 64)}.max-h-72{max-height:calc(var(--spacing) * 72)}.max-h-80{max-height:calc(var(--spacing) * 80)}.max-h-\[36vh\]{max-height:36vh}.max-h-\[60vh\]{max-height:60vh}.max-h-\[80vh\]{max-height:80vh}.max-h-\[90dvh\]{max-height:90dvh}.max-h-\[90vh\]{max-height:90vh}.max-h-\[200px\]{max-height:200px}.max-h-\[220px\]{max-height:220px}.max-h-\[260px\]{max-height:260px}.max-h-\[400px\]{max-height:400px}.max-h-\[480px\]{max-height:480px}.max-h-\[500px\]{max-height:500px}.max-h-\[min\(22rem\,calc\(100vh-6rem\)\)\]{max-height:min(22rem,100vh - 6rem)}.max-h-\[min\(36rem\,90vh\)\]{max-height:min(36rem,90vh)}.max-h-\[min\(36rem\,calc\(100\%-4rem\)\)\]{max-height:min(36rem,100% - 4rem)}.max-h-\[min\(50vh\,28rem\)\]{max-height:min(50vh,28rem)}.max-h-\[min\(65vh\,32rem\)\]{max-height:min(65vh,32rem)}.max-h-\[min\(70dvh\,70vh\)\]{max-height:min(70dvh,70vh)}.max-h-\[min\(90dvh\,40rem\)\]{max-height:min(90dvh,40rem)}.max-h-\[min\(90dvh\,48rem\)\]{max-height:min(90dvh,48rem)}.max-h-\[min\(320px\,55vh\)\]{max-height:min(320px,55vh)}.max-h-\[min\(420px\,70vh\)\]{max-height:min(420px,70vh)}.max-h-dvh{max-height:100dvh}.max-h-full{max-height:100%}.min-h-0{min-height:0}.min-h-4\.5{min-height:calc(var(--spacing) * 4.5)}.min-h-9{min-height:calc(var(--spacing) * 9)}.min-h-10{min-height:calc(var(--spacing) * 10)}.min-h-11{min-height:calc(var(--spacing) * 11)}.min-h-12{min-height:calc(var(--spacing) * 12)}.min-h-17{min-height:calc(var(--spacing) * 17)}.min-h-32{min-height:calc(var(--spacing) * 32)}.min-h-40{min-height:calc(var(--spacing) * 40)}.min-h-\[1\.25rem\]{min-height:1.25rem}.min-h-\[2\.5rem\]{min-height:2.5rem}.min-h-\[2\.25rem\]{min-height:2.25rem}.min-h-\[2rem\]{min-height:2rem}.min-h-\[4\.5rem\]{min-height:4.5rem}.min-h-\[5\.5rem\]{min-height:5.5rem}.min-h-\[5rem\]{min-height:5rem}.min-h-\[6\.5rem\]{min-height:6.5rem}.min-h-\[6rem\]{min-height:6rem}.min-h-\[9\.5rem\]{min-height:9.5rem}.min-h-\[10rem\]{min-height:10rem}.min-h-\[12rem\]{min-height:12rem}.min-h-\[28px\]{min-height:28px}.min-h-\[40px\]{min-height:40px}.min-h-\[44px\]{min-height:44px}.min-h-\[48px\]{min-height:48px}.min-h-\[52px\]{min-height:52px}.min-h-\[54px\]{min-height:54px}.min-h-\[72px\]{min-height:72px}.min-h-\[80px\]{min-height:80px}.min-h-\[88px\]{min-height:88px}.min-h-\[96px\]{min-height:96px}.min-h-\[120px\]{min-height:120px}.min-h-\[140px\]{min-height:140px}.min-h-\[200px\]{min-height:200px}.min-h-\[240px\]{min-height:240px}.min-h-\[320px\]{min-height:320px}.min-h-\[min\(50vh\,18rem\)\]{min-height:min(50vh,18rem)}.min-h-dvh{min-height:100dvh}.min-h-full{min-height:100%}.min-h-screen{min-height:100vh}.w-0\.5{width:calc(var(--spacing) * .5)}.w-1{width:var(--spacing)}.w-1\/2{width:50%}.w-1\/3{width:33.3333%}.w-2{width:calc(var(--spacing) * 2)}.w-2\.5{width:calc(var(--spacing) * 2.5)}.w-2\/3{width:66.6667%}.w-3{width:calc(var(--spacing) * 3)}.w-3\.5{width:calc(var(--spacing) * 3.5)}.w-4{width:calc(var(--spacing) * 4)}.w-5{width:calc(var(--spacing) * 5)}.w-6{width:calc(var(--spacing) * 6)}.w-7{width:calc(var(--spacing) * 7)}.w-8{width:calc(var(--spacing) * 8)}.w-9{width:calc(var(--spacing) * 9)}.w-10{width:calc(var(--spacing) * 10)}.w-11{width:calc(var(--spacing) * 11)}.w-12{width:calc(var(--spacing) * 12)}.w-13{width:calc(var(--spacing) * 13)}.w-14{width:calc(var(--spacing) * 14)}.w-16{width:calc(var(--spacing) * 16)}.w-20{width:calc(var(--spacing) * 20)}.w-24{width:calc(var(--spacing) * 24)}.w-28{width:calc(var(--spacing) * 28)}.w-32{width:calc(var(--spacing) * 32)}.w-40{width:calc(var(--spacing) * 40)}.w-44{width:calc(var(--spacing) * 44)}.w-48{width:calc(var(--spacing) * 48)}.w-52{width:calc(var(--spacing) * 52)}.w-56{width:calc(var(--spacing) * 56)}.w-64{width:calc(var(--spacing) * 64)}.w-72{width:calc(var(--spacing) * 72)}.w-80{width:calc(var(--spacing) * 80)}.w-96{width:calc(var(--spacing) * 96)}.w-\[2px\]{width:2px}.w-\[4\.5rem\]{width:4.5rem}.w-\[42\%\]{width:42%}.w-\[calc\(100\%-2rem\)\]{width:calc(100% - 2rem)}.w-\[min\(20rem\,calc\(100\%-1\.5rem\)\)\]{width:min(20rem,100% - 1.5rem)}.w-\[min\(24rem\,calc\(100\%-1rem\)\)\]{width:min(24rem,100% - 1rem)}.w-\[min\(100vw-2rem\,24rem\)\]{width:min(100vw - 2rem,24rem)}.w-\[min\(320px\,85vw\)\]{width:min(320px,85vw)}.w-\[min\(calc\(100\%-1\.25rem\)\,24rem\)\]{width:min(100% - 1.25rem,24rem)}.w-auto{width:auto}.w-fit{width:fit-content}.w-full{width:100%}.w-full\!{width:100%!important}.w-max{width:max-content}.w-px{width:1px}.max-w-2xl{max-width:var(--container-2xl)}.max-w-3xl{max-width:var(--container-3xl)}.max-w-4xl{max-width:var(--container-4xl)}.max-w-5xl{max-width:var(--container-5xl)}.max-w-6xl{max-width:var(--container-6xl)}.max-w-7xl{max-width:var(--container-7xl)}.max-w-16{max-width:calc(var(--spacing) * 16)}.max-w-18{max-width:calc(var(--spacing) * 18)}.max-w-32{max-width:calc(var(--spacing) * 32)}.max-w-40{max-width:calc(var(--spacing) * 40)}.max-w-\[5rem\]{max-width:5rem}.max-w-\[7\.5rem\]{max-width:7.5rem}.max-w-\[8\.5rem\]{max-width:8.5rem}.max-w-\[8rem\]{max-width:8rem}.max-w-\[12rem\]{max-width:12rem}.max-w-\[14rem\]{max-width:14rem}.max-w-\[16rem\]{max-width:16rem}.max-w-\[18rem\]{max-width:18rem}.max-w-\[20rem\]{max-width:20rem}.max-w-\[45\%\]{max-width:45%}.max-w-\[70\%\]{max-width:70%}.max-w-\[85\%\]{max-width:85%}.max-w-\[90\%\]{max-width:90%}.max-w-\[98vw\]{max-width:98vw}.max-w-\[100px\]{max-width:100px}.max-w-\[120px\]{max-width:120px}.max-w-\[160px\]{max-width:160px}.max-w-\[200px\]{max-width:200px}.max-w-\[260px\]{max-width:260px}.max-w-\[280px\]{max-width:280px}.max-w-\[1920px\]{max-width:1920px}.max-w-\[calc\(100\%-0\.75rem\)\]{max-width:calc(100% - .75rem)}.max-w-\[min\(18rem\,100\%\)\]{max-width:min(18rem,100%)}.max-w-\[min\(20rem\,85vw\)\]{max-width:min(20rem,85vw)}.max-w-\[min\(20rem\,100\%\)\]{max-width:min(20rem,100%)}.max-w-\[min\(20rem\,calc\(100vw-1\.5rem\)\)\]{max-width:min(20rem,100vw - 1.5rem)}.max-w-\[min\(22rem\,calc\(100vw-2rem\)\)\]{max-width:min(22rem,100vw - 2rem)}.max-w-\[min\(40vw\,12rem\)\]{max-width:min(40vw,12rem)}.max-w-\[min\(55vw\,14rem\)\]{max-width:min(55vw,14rem)}.max-w-\[min\(100\%\,12rem\)\]{max-width:min(100%,12rem)}.max-w-\[min\(100\%\,14rem\)\]{max-width:min(100%,14rem)}.max-w-\[min\(100\%\,18rem\)\]{max-width:min(100%,18rem)}.max-w-\[min\(100\%\,42rem\)\]{max-width:min(100%,42rem)}.max-w-\[min\(100\%\,calc\(100\%-2rem\)\)\]{max-width:min(100%,100% - 2rem)}.max-w-\[min\(100vw-2rem\,22rem\)\]{max-width:min(100vw - 2rem,22rem)}.max-w-\[min\(100vw-2rem\,28rem\)\]{max-width:min(100vw - 2rem,28rem)}.max-w-\[min\(100vw-2rem\,36rem\)\]{max-width:min(100vw - 2rem,36rem)}.max-w-\[min\(280px\,85vw\)\]{max-width:min(280px,85vw)}.max-w-\[min\(280px\,100\%\)\]{max-width:min(280px,100%)}.max-w-full{max-width:100%}.max-w-lg{max-width:var(--container-lg)}.max-w-md{max-width:var(--container-md)}.max-w-none{max-width:none}.max-w-sm{max-width:var(--container-sm)}.max-w-xl{max-width:var(--container-xl)}.max-w-xs{max-width:var(--container-xs)}.min-w-0{min-width:0}.min-w-4{min-width:calc(var(--spacing) * 4)}.min-w-4\.5{min-width:calc(var(--spacing) * 4.5)}.min-w-5{min-width:calc(var(--spacing) * 5)}.min-w-9{min-width:calc(var(--spacing) * 9)}.min-w-11{min-width:calc(var(--spacing) * 11)}.min-w-16{min-width:calc(var(--spacing) * 16)}.min-w-40{min-width:calc(var(--spacing) * 40)}.min-w-44{min-width:calc(var(--spacing) * 44)}.min-w-48{min-width:calc(var(--spacing) * 48)}.min-w-52{min-width:calc(var(--spacing) * 52)}.min-w-56{min-width:calc(var(--spacing) * 56)}.min-w-\[1\.25rem\]{min-width:1.25rem}.min-w-\[1\.125rem\]{min-width:1.125rem}.min-w-\[5\.5rem\]{min-width:5.5rem}.min-w-\[6\.5rem\]{min-width:6.5rem}.min-w-\[7rem\]{min-width:7rem}.min-w-\[10rem\]{min-width:10rem}.min-w-\[11rem\]{min-width:11rem}.min-w-\[14px\]{min-width:14px}.min-w-\[16rem\]{min-width:16rem}.min-w-\[28px\]{min-width:28px}.min-w-\[40px\]{min-width:40px}.min-w-\[44px\]{min-width:44px}.min-w-\[80px\]{min-width:80px}.min-w-\[120px\]{min-width:120px}.min-w-\[125px\]{min-width:125px}.min-w-\[160px\]{min-width:160px}.min-w-\[200px\]{min-width:200px}.min-w-\[220px\]{min-width:220px}.min-w-fit{min-width:fit-content}.min-w-full{min-width:100%}.flex-1{flex:1}.flex-2{flex:2}.flex-shrink{flex-shrink:1}.flex-shrink-0{flex-shrink:0}.shrink{flex-shrink:1}.shrink-0{flex-shrink:0}.flex-grow,.grow{flex-grow:1}.basis-0{flex-basis:0}.table-auto{table-layout:auto}.table-fixed{table-layout:fixed}.caption-bottom{caption-side:bottom}.caption-top{caption-side:top}.border-collapse{border-collapse:collapse}.border-separate{border-collapse:separate}.origin-center{transform-origin:50%}.-translate-x-1\/2{--tw-translate-x:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.-translate-x-full{--tw-translate-x:-100%;translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-x-0{--tw-translate-x:0px;translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-x-5{--tw-translate-x:calc(var(--spacing) * 5);translate:var(--tw-translate-x) var(--tw-translate-y)}.-translate-y-1\/2{--tw-translate-y:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.-translate-y-full{--tw-translate-y:-100%;translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-y-0{--tw-translate-y:0px;translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-y-1\/2{--tw-translate-y:calc(1 / 2 * 100%);translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-y-2{--tw-translate-y:calc(var(--spacing) * 2);translate:var(--tw-translate-x) var(--tw-translate-y)}.scale-95{--tw-scale-x:95%;--tw-scale-y:95%;--tw-scale-z:95%;scale:var(--tw-scale-x) var(--tw-scale-y)}.scale-100{--tw-scale-x:100%;--tw-scale-y:100%;--tw-scale-z:100%;scale:var(--tw-scale-x) var(--tw-scale-y)}.scale-\[1\.02\]{scale:1.02}.rotate-12{rotate:12deg}.rotate-90{rotate:90deg}.rotate-135{rotate:135deg}.rotate-180{rotate:180deg}.transform,.transform-cpu{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}.transform-gpu{transform:translateZ(0) var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}.transform-none{transform:none}.animate-bounce{animation:var(--animate-bounce)}.animate-ping{animation:var(--animate-ping)}.animate-pulse{animation:var(--animate-pulse)}.animate-spin{animation:var(--animate-spin)}.cursor-col-resize{cursor:col-resize}.cursor-copy{cursor:copy}.cursor-crosshair{cursor:crosshair}.cursor-default{cursor:default}.cursor-ew-resize{cursor:ew-resize}.cursor-grab{cursor:grab}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.touch-pan-left{--tw-pan-x:pan-left;touch-action:var(--tw-pan-x,) var(--tw-pan-y,) var(--tw-pinch-zoom,)}.touch-pan-right{--tw-pan-x:pan-right;touch-action:var(--tw-pan-x,) var(--tw-pan-y,) var(--tw-pinch-zoom,)}.touch-pan-x{--tw-pan-x:pan-x;touch-action:var(--tw-pan-x,) var(--tw-pan-y,) var(--tw-pinch-zoom,)}.touch-pan-down{--tw-pan-y:pan-down;touch-action:var(--tw-pan-x,) var(--tw-pan-y,) var(--tw-pinch-zoom,)}.touch-pan-up{--tw-pan-y:pan-up;touch-action:var(--tw-pan-x,) var(--tw-pan-y,) var(--tw-pinch-zoom,)}.touch-pan-y{--tw-pan-y:pan-y;touch-action:var(--tw-pan-x,) var(--tw-pan-y,) var(--tw-pinch-zoom,)}.touch-pinch-zoom{--tw-pinch-zoom:pinch-zoom;touch-action:var(--tw-pan-x,) var(--tw-pan-y,) var(--tw-pinch-zoom,)}.touch-auto{touch-action:auto}.touch-manipulation{touch-action:manipulation}.touch-none{touch-action:none}.resize{resize:both}.resize-none{resize:none}.resize-x{resize:horizontal}.resize-y{resize:vertical}.snap-both{scroll-snap-type:both var(--tw-scroll-snap-strictness)}.snap-none{scroll-snap-type:none}.snap-x{scroll-snap-type:x var(--tw-scroll-snap-strictness)}.snap-y{scroll-snap-type:y var(--tw-scroll-snap-strictness)}.snap-mandatory{--tw-scroll-snap-strictness:mandatory}.snap-proximity{--tw-scroll-snap-strictness:proximity}.snap-align-none{scroll-snap-align:none}.snap-center{scroll-snap-align:center}.snap-end{scroll-snap-align:end}.snap-start{scroll-snap-align:start}.snap-always{scroll-snap-stop:always}.snap-normal{scroll-snap-stop:normal}.scroll-mt-24{scroll-margin-top:calc(var(--spacing) * 24)}.scrollbar-thin{scrollbar-width:thin}.list-inside{list-style-position:inside}.list-outside{list-style-position:outside}.list-decimal{list-style-type:decimal}.list-disc{list-style-type:disc}.list-none{list-style-type:none}.form-select{appearance:none;--tw-shadow:0 0 #0000;background-color:#fff;border-width:1px;border-color:oklch(55.1% .027 264.364);border-radius:0;padding:.5rem .75rem;font-size:1rem;line-height:1.5rem}.form-select:focus{outline-offset:2px;--tw-ring-inset:var(--tw-empty, );--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:oklch(54.6% .245 262.881);--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);border-color:oklch(54.6% .245 262.881);outline:2px solid #0000}.form-select{-webkit-print-color-adjust:exact;print-color-adjust:exact;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='oklch(55.1%25 0.027 264.364)' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e");background-position:right .5rem center;background-repeat:no-repeat;background-size:1.5em 1.5em;padding-right:2.5rem}.form-select:where([size]:not([size="1"])){background-image:initial;background-position:initial;background-repeat:unset;background-size:initial;print-color-adjust:unset;padding-right:.75rem}.form-textarea{appearance:none;--tw-shadow:0 0 #0000;background-color:#fff;border-width:1px;border-color:oklch(55.1% .027 264.364);border-radius:0;padding:.5rem .75rem;font-size:1rem;line-height:1.5rem}.form-textarea:focus{outline-offset:2px;--tw-ring-inset:var(--tw-empty, );--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:oklch(54.6% .245 262.881);--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);border-color:oklch(54.6% .245 262.881);outline:2px solid #0000}.form-textarea::placeholder{color:oklch(55.1% .027 264.364);opacity:1}.form-multiselect{appearance:none;--tw-shadow:0 0 #0000;background-color:#fff;border-width:1px;border-color:oklch(55.1% .027 264.364);border-radius:0;padding:.5rem .75rem;font-size:1rem;line-height:1.5rem}.form-multiselect:focus{outline-offset:2px;--tw-ring-inset:var(--tw-empty, );--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:oklch(54.6% .245 262.881);--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);border-color:oklch(54.6% .245 262.881);outline:2px solid #0000}.appearance-auto{appearance:auto}.appearance-none{appearance:none}.break-before-all{break-before:all}.break-before-auto{break-before:auto}.break-before-avoid{break-before:avoid}.break-before-avoid-page{break-before:avoid-page}.break-before-column{break-before:column}.break-before-left{break-before:left}.break-before-page{break-before:page}.break-before-right{break-before:right}.break-inside-auto{break-inside:auto}.break-inside-avoid{break-inside:avoid}.break-inside-avoid-column{break-inside:avoid-column}.break-inside-avoid-page{break-inside:avoid-page}.break-after-all{break-after:all}.break-after-auto{break-after:auto}.break-after-avoid{break-after:avoid}.break-after-avoid-page{break-after:avoid-page}.break-after-column{break-after:column}.break-after-left{break-after:left}.break-after-page{break-after:page}.break-after-right{break-after:right}.grid-flow-col{grid-auto-flow:column}.grid-flow-col-dense{grid-auto-flow:column dense}.grid-flow-dense{grid-auto-flow:dense}.grid-flow-row{grid-auto-flow:row}.grid-flow-row-dense{grid-auto-flow:dense}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.grid-cols-5{grid-template-columns:repeat(5,minmax(0,1fr))}.grid-cols-6{grid-template-columns:repeat(6,minmax(0,1fr))}.grid-cols-\[minmax\(0\,38\%\)_1fr\]{grid-template-columns:minmax(0,38%) 1fr}.grid-rows-4{grid-template-rows:repeat(4,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-col-reverse{flex-direction:column-reverse}.flex-row{flex-direction:row}.flex-row-reverse{flex-direction:row-reverse}.flex-nowrap{flex-wrap:nowrap}.flex-wrap{flex-wrap:wrap}.flex-wrap-reverse{flex-wrap:wrap-reverse}.place-content-around{place-content:space-around}.place-content-baseline{place-content:baseline start}.place-content-between{place-content:space-between}.place-content-center{place-content:center}.place-content-end{place-content:end}.place-content-evenly{place-content:space-evenly}.place-content-start{place-content:start}.place-content-stretch{place-content:stretch}.place-items-baseline{place-items:baseline}.place-items-center{place-items:center}.place-items-end{place-items:end}.place-items-start{place-items:start}.place-items-stretch{place-items:stretch stretch}.content-around{align-content:space-around}.content-baseline{align-content:baseline}.content-between{align-content:space-between}.content-center{align-content:center}.content-end{align-content:flex-end}.content-evenly{align-content:space-evenly}.content-normal{align-content:normal}.content-start{align-content:flex-start}.content-stretch{align-content:stretch}.items-baseline{align-items:baseline}.items-center{align-items:center}.items-end{align-items:flex-end}.items-start{align-items:flex-start}.items-stretch{align-items:stretch}.justify-around{justify-content:space-around}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.justify-evenly{justify-content:space-evenly}.justify-normal{justify-content:normal}.justify-start{justify-content:flex-start}.justify-stretch{justify-content:stretch}.justify-items-center{justify-items:center}.justify-items-end{justify-items:end}.justify-items-start{justify-items:start}.justify-items-stretch{justify-items:stretch}.gap-0{gap:0}.gap-0\.5{gap:calc(var(--spacing) * .5)}.gap-1{gap:var(--spacing)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-2\.5{gap:calc(var(--spacing) * 2.5)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-5{gap:calc(var(--spacing) * 5)}.gap-6{gap:calc(var(--spacing) * 6)}.gap-8{gap:calc(var(--spacing) * 8)}.gap-10{gap:calc(var(--spacing) * 10)}:where(.space-y-0>:not(:last-child)){--tw-space-y-reverse:0;margin-block:0}:where(.space-y-0\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * .5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * .5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-1>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(var(--spacing) * var(--tw-space-y-reverse));margin-block-end:calc(var(--spacing) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-1\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 1.5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 1.5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2.5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2.5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-8>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 8) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 8) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-10>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 10) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 10) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-reverse>:not(:last-child)){--tw-space-y-reverse:1}.gap-x-1{column-gap:var(--spacing)}.gap-x-1\.5{column-gap:calc(var(--spacing) * 1.5)}.gap-x-2{column-gap:calc(var(--spacing) * 2)}.gap-x-3{column-gap:calc(var(--spacing) * 3)}.gap-x-4{column-gap:calc(var(--spacing) * 4)}.gap-x-6{column-gap:calc(var(--spacing) * 6)}:where(.space-x-1>:not(:last-child)){--tw-space-x-reverse:0;margin-inline-start:calc(var(--spacing) * var(--tw-space-x-reverse));margin-inline-end:calc(var(--spacing) * calc(1 - var(--tw-space-x-reverse)))}:where(.space-x-1\.5>:not(:last-child)){--tw-space-x-reverse:0;margin-inline-start:calc(calc(var(--spacing) * 1.5) * var(--tw-space-x-reverse));margin-inline-end:calc(calc(var(--spacing) * 1.5) * calc(1 - var(--tw-space-x-reverse)))}:where(.space-x-2>:not(:last-child)){--tw-space-x-reverse:0;margin-inline-start:calc(calc(var(--spacing) * 2) * var(--tw-space-x-reverse));margin-inline-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-x-reverse)))}:where(.space-x-3>:not(:last-child)){--tw-space-x-reverse:0;margin-inline-start:calc(calc(var(--spacing) * 3) * var(--tw-space-x-reverse));margin-inline-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-x-reverse)))}:where(.space-x-4>:not(:last-child)){--tw-space-x-reverse:0;margin-inline-start:calc(calc(var(--spacing) * 4) * var(--tw-space-x-reverse));margin-inline-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-x-reverse)))}:where(.space-x-reverse>:not(:last-child)){--tw-space-x-reverse:1}.gap-y-0\.5{row-gap:calc(var(--spacing) * .5)}.gap-y-1{row-gap:var(--spacing)}.gap-y-2{row-gap:calc(var(--spacing) * 2)}.gap-y-3{row-gap:calc(var(--spacing) * 3)}.gap-y-4{row-gap:calc(var(--spacing) * 4)}:where(.divide-x>:not(:last-child)){--tw-divide-x-reverse:0;border-inline-style:var(--tw-border-style);border-inline-start-width:calc(1px * var(--tw-divide-x-reverse));border-inline-end-width:calc(1px * calc(1 - var(--tw-divide-x-reverse)))}:where(.divide-x-0>:not(:last-child)){--tw-divide-x-reverse:0;border-inline-style:var(--tw-border-style);border-inline-start-width:calc(0px * var(--tw-divide-x-reverse));border-inline-end-width:calc(0px * calc(1 - var(--tw-divide-x-reverse)))}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-y-reverse>:not(:last-child)){--tw-divide-y-reverse:1}:where(.divide-dashed>:not(:last-child)){--tw-border-style:dashed;border-style:dashed}:where(.divide-dotted>:not(:last-child)){--tw-border-style:dotted;border-style:dotted}:where(.divide-double>:not(:last-child)){--tw-border-style:double;border-style:double}:where(.divide-none>:not(:last-child)){--tw-border-style:none;border-style:none}:where(.divide-solid>:not(:last-child)){--tw-border-style:solid;border-style:solid}:where(.divide-gray-100>:not(:last-child)){border-color:var(--color-gray-100)}:where(.divide-gray-200>:not(:last-child)){border-color:var(--color-gray-200)}:where(.divide-gray-200\/60>:not(:last-child)){border-color:#e5e7eb99}@supports (color:color-mix(in lab, red, red)){:where(.divide-gray-200\/60>:not(:last-child)){border-color:color-mix(in oklab, var(--color-gray-200) 60%, transparent)}}:where(.divide-gray-300>:not(:last-child)){border-color:var(--color-gray-300)}.place-self-auto{place-self:auto}.place-self-center{place-self:center}.place-self-end{place-self:end}.place-self-start{place-self:start}.place-self-stretch{place-self:stretch stretch}.self-auto{align-self:auto}.self-baseline{align-self:baseline}.self-center{align-self:center}.self-end{align-self:flex-end}.self-start{align-self:flex-start}.self-stretch{align-self:stretch}.justify-self-auto{justify-self:auto}.justify-self-center{justify-self:center}.justify-self-end{justify-self:flex-end}.justify-self-start{justify-self:flex-start}.justify-self-stretch{justify-self:stretch}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-auto{overflow:auto}.overflow-clip{overflow:clip}.overflow-hidden{overflow:hidden}.overflow-scroll{overflow:scroll}.overflow-visible{overflow:visible}.overflow-x-auto{overflow-x:auto}.overflow-x-clip{overflow-x:clip}.overflow-x-hidden{overflow-x:hidden}.overflow-x-scroll{overflow-x:scroll}.overflow-x-visible{overflow-x:visible}.overflow-y-auto{overflow-y:auto}.overflow-y-clip{overflow-y:clip}.overflow-y-hidden{overflow-y:hidden}.overflow-y-scroll{overflow-y:scroll}.overflow-y-visible{overflow-y:visible}.overscroll-auto{overscroll-behavior:auto}.overscroll-contain{overscroll-behavior:contain}.overscroll-none{overscroll-behavior:none}.overscroll-x-auto{overscroll-behavior-x:auto}.overscroll-x-contain{overscroll-behavior-x:contain}.overscroll-x-none{overscroll-behavior-x:none}.overscroll-y-auto{overscroll-behavior-y:auto}.overscroll-y-contain{overscroll-behavior-y:contain}.overscroll-y-none{overscroll-behavior-y:none}.scroll-auto{scroll-behavior:auto}.scroll-smooth{scroll-behavior:smooth}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:var(--radius-2xl)}.rounded-2xl\!{border-radius:var(--radius-2xl)!important}.rounded-3xl{border-radius:var(--radius-3xl)}.rounded-4xl{border-radius:var(--radius-4xl)}.rounded-\[3rem\]{border-radius:3rem}.rounded-\[10px\]{border-radius:10px}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-lg\!{border-radius:var(--radius-lg)!important}.rounded-md{border-radius:var(--radius-md)}.rounded-none{border-radius:0}.rounded-sm{border-radius:var(--radius-sm)}.rounded-xl{border-radius:var(--radius-xl)}.rounded-xl\!{border-radius:var(--radius-xl)!important}.rounded-xs{border-radius:var(--radius-xs)}.rounded-s{border-start-start-radius:.25rem;border-end-start-radius:.25rem}.rounded-ss{border-start-start-radius:.25rem}.rounded-e{border-start-end-radius:.25rem;border-end-end-radius:.25rem}.rounded-se{border-start-end-radius:.25rem}.rounded-ee{border-end-end-radius:.25rem}.rounded-es{border-end-start-radius:.25rem}.rounded-t{border-top-left-radius:.25rem;border-top-right-radius:.25rem}.rounded-t-2xl{border-top-left-radius:var(--radius-2xl);border-top-right-radius:var(--radius-2xl)}.rounded-t-lg{border-top-left-radius:var(--radius-lg);border-top-right-radius:var(--radius-lg)}.rounded-t-md{border-top-left-radius:var(--radius-md);border-top-right-radius:var(--radius-md)}.rounded-l{border-top-left-radius:.25rem;border-bottom-left-radius:.25rem}.rounded-l-xl{border-top-left-radius:var(--radius-xl);border-bottom-left-radius:var(--radius-xl)}.rounded-tl{border-top-left-radius:.25rem}.rounded-r{border-top-right-radius:.25rem;border-bottom-right-radius:.25rem}.rounded-r-full{border-top-right-radius:3.40282e38px;border-bottom-right-radius:3.40282e38px}.rounded-r-lg{border-top-right-radius:var(--radius-lg);border-bottom-right-radius:var(--radius-lg)}.rounded-r-xl{border-top-right-radius:var(--radius-xl);border-bottom-right-radius:var(--radius-xl)}.rounded-tr{border-top-right-radius:.25rem}.rounded-b{border-bottom-right-radius:.25rem;border-bottom-left-radius:.25rem}.rounded-b-2xl{border-bottom-right-radius:var(--radius-2xl);border-bottom-left-radius:var(--radius-2xl)}.rounded-br{border-bottom-right-radius:.25rem}.rounded-bl{border-bottom-left-radius:.25rem}.border{border-style:var(--tw-border-style);border-width:1px}.border\!{border-style:var(--tw-border-style)!important;border-width:1px!important}.border-0{border-style:var(--tw-border-style);border-width:0}.border-2{border-style:var(--tw-border-style);border-width:2px}.border-2\!{border-style:var(--tw-border-style)!important;border-width:2px!important}.border-4{border-style:var(--tw-border-style);border-width:4px}.border-x{border-inline-style:var(--tw-border-style);border-inline-width:1px}.border-x-2{border-inline-style:var(--tw-border-style);border-inline-width:2px}.border-y{border-block-style:var(--tw-border-style);border-block-width:1px}.border-s{border-inline-start-style:var(--tw-border-style);border-inline-start-width:1px}.border-e{border-inline-end-style:var(--tw-border-style);border-inline-end-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-t-2{border-top-style:var(--tw-border-style);border-top-width:2px}.border-r{border-right-style:var(--tw-border-style);border-right-width:1px}.border-r-2{border-right-style:var(--tw-border-style);border-right-width:2px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-b-2{border-bottom-style:var(--tw-border-style);border-bottom-width:2px}.border-l{border-left-style:var(--tw-border-style);border-left-width:1px}.border-l-2{border-left-style:var(--tw-border-style);border-left-width:2px}.border-l-4{border-left-style:var(--tw-border-style);border-left-width:4px}.border-dashed{--tw-border-style:dashed;border-style:dashed}.border-dotted{--tw-border-style:dotted;border-style:dotted}.border-double{--tw-border-style:double;border-style:double}.border-hidden{--tw-border-style:hidden;border-style:hidden}.border-none{--tw-border-style:none;border-style:none}.border-none\!{--tw-border-style:none!important;border-style:none!important}.border-solid{--tw-border-style:solid;border-style:solid}.border-\[var\(--mc-border-strong\)\]{border-color:var(--mc-border-strong)}.border-amber-100{border-color:var(--color-amber-100)}.border-amber-200{border-color:var(--color-amber-200)}.border-amber-200\/50{border-color:#fee68580}@supports (color:color-mix(in lab, red, red)){.border-amber-200\/50{border-color:color-mix(in oklab, var(--color-amber-200) 50%, transparent)}}.border-amber-200\/70{border-color:#fee685b3}@supports (color:color-mix(in lab, red, red)){.border-amber-200\/70{border-color:color-mix(in oklab, var(--color-amber-200) 70%, transparent)}}.border-amber-200\/80{border-color:#fee685cc}@supports (color:color-mix(in lab, red, red)){.border-amber-200\/80{border-color:color-mix(in oklab, var(--color-amber-200) 80%, transparent)}}.border-amber-200\/85{border-color:#fee685d9}@supports (color:color-mix(in lab, red, red)){.border-amber-200\/85{border-color:color-mix(in oklab, var(--color-amber-200) 85%, transparent)}}.border-amber-200\/90{border-color:#fee685e6}@supports (color:color-mix(in lab, red, red)){.border-amber-200\/90{border-color:color-mix(in oklab, var(--color-amber-200) 90%, transparent)}}.border-amber-300{border-color:var(--color-amber-300)}.border-amber-400{border-color:var(--color-amber-400)}.border-amber-500{border-color:var(--color-amber-500)}.border-amber-500\/30{border-color:#f99c004d}@supports (color:color-mix(in lab, red, red)){.border-amber-500\/30{border-color:color-mix(in oklab, var(--color-amber-500) 30%, transparent)}}.border-amber-500\/35{border-color:#f99c0059}@supports (color:color-mix(in lab, red, red)){.border-amber-500\/35{border-color:color-mix(in oklab, var(--color-amber-500) 35%, transparent)}}.border-amber-500\/40{border-color:#f99c0066}@supports (color:color-mix(in lab, red, red)){.border-amber-500\/40{border-color:color-mix(in oklab, var(--color-amber-500) 40%, transparent)}}.border-amber-700\/80{border-color:#b75000cc}@supports (color:color-mix(in lab, red, red)){.border-amber-700\/80{border-color:color-mix(in oklab, var(--color-amber-700) 80%, transparent)}}.border-amber-800\/80{border-color:#953d00cc}@supports (color:color-mix(in lab, red, red)){.border-amber-800\/80{border-color:color-mix(in oklab, var(--color-amber-800) 80%, transparent)}}.border-black\/5{border-color:#0000000d}@supports (color:color-mix(in lab, red, red)){.border-black\/5{border-color:color-mix(in oklab, var(--color-black) 5%, transparent)}}.border-black\/8{border-color:#00000014}@supports (color:color-mix(in lab, red, red)){.border-black\/8{border-color:color-mix(in oklab, var(--color-black) 8%, transparent)}}.border-black\/10{border-color:#0000001a}@supports (color:color-mix(in lab, red, red)){.border-black\/10{border-color:color-mix(in oklab, var(--color-black) 10%, transparent)}}.border-blue-100{border-color:var(--color-blue-100)}.border-blue-100\/50{border-color:#dbeafe80}@supports (color:color-mix(in lab, red, red)){.border-blue-100\/50{border-color:color-mix(in oklab, var(--color-blue-100) 50%, transparent)}}.border-blue-200{border-color:var(--color-blue-200)}.border-blue-200\/50{border-color:#bedbff80}@supports (color:color-mix(in lab, red, red)){.border-blue-200\/50{border-color:color-mix(in oklab, var(--color-blue-200) 50%, transparent)}}.border-blue-200\/60{border-color:#bedbff99}@supports (color:color-mix(in lab, red, red)){.border-blue-200\/60{border-color:color-mix(in oklab, var(--color-blue-200) 60%, transparent)}}.border-blue-300{border-color:var(--color-blue-300)}.border-blue-400{border-color:var(--color-blue-400)}.border-blue-400\/70{border-color:#54a2ffb3}@supports (color:color-mix(in lab, red, red)){.border-blue-400\/70{border-color:color-mix(in oklab, var(--color-blue-400) 70%, transparent)}}.border-blue-500{border-color:var(--color-blue-500)}.border-blue-500\/10{border-color:#3080ff1a}@supports (color:color-mix(in lab, red, red)){.border-blue-500\/10{border-color:color-mix(in oklab, var(--color-blue-500) 10%, transparent)}}.border-blue-500\/20{border-color:#3080ff33}@supports (color:color-mix(in lab, red, red)){.border-blue-500\/20{border-color:color-mix(in oklab, var(--color-blue-500) 20%, transparent)}}.border-blue-500\/30{border-color:#3080ff4d}@supports (color:color-mix(in lab, red, red)){.border-blue-500\/30{border-color:color-mix(in oklab, var(--color-blue-500) 30%, transparent)}}.border-blue-500\/50{border-color:#3080ff80}@supports (color:color-mix(in lab, red, red)){.border-blue-500\/50{border-color:color-mix(in oklab, var(--color-blue-500) 50%, transparent)}}.border-blue-600{border-color:var(--color-blue-600)}.border-current,.border-current\/20{border-color:currentColor}@supports (color:color-mix(in lab, red, red)){.border-current\/20{border-color:color-mix(in oklab, currentcolor 20%, transparent)}}.border-cyan-200{border-color:var(--color-cyan-200)}.border-cyan-500\/50{border-color:#00b7d780}@supports (color:color-mix(in lab, red, red)){.border-cyan-500\/50{border-color:color-mix(in oklab, var(--color-cyan-500) 50%, transparent)}}.border-emerald-100{border-color:var(--color-emerald-100)}.border-emerald-100\/50{border-color:#d0fae580}@supports (color:color-mix(in lab, red, red)){.border-emerald-100\/50{border-color:color-mix(in oklab, var(--color-emerald-100) 50%, transparent)}}.border-emerald-200{border-color:var(--color-emerald-200)}.border-emerald-200\/60{border-color:#a4f4cf99}@supports (color:color-mix(in lab, red, red)){.border-emerald-200\/60{border-color:color-mix(in oklab, var(--color-emerald-200) 60%, transparent)}}.border-emerald-500{border-color:var(--color-emerald-500)}.border-emerald-500\/20{border-color:#00bb7f33}@supports (color:color-mix(in lab, red, red)){.border-emerald-500\/20{border-color:color-mix(in oklab, var(--color-emerald-500) 20%, transparent)}}.border-emerald-500\/35{border-color:#00bb7f59}@supports (color:color-mix(in lab, red, red)){.border-emerald-500\/35{border-color:color-mix(in oklab, var(--color-emerald-500) 35%, transparent)}}.border-emerald-800\/80{border-color:#005f46cc}@supports (color:color-mix(in lab, red, red)){.border-emerald-800\/80{border-color:color-mix(in oklab, var(--color-emerald-800) 80%, transparent)}}.border-gray-100{border-color:var(--color-gray-100)}.border-gray-100\/20{border-color:#f3f4f633}@supports (color:color-mix(in lab, red, red)){.border-gray-100\/20{border-color:color-mix(in oklab, var(--color-gray-100) 20%, transparent)}}.border-gray-100\/50{border-color:#f3f4f680}@supports (color:color-mix(in lab, red, red)){.border-gray-100\/50{border-color:color-mix(in oklab, var(--color-gray-100) 50%, transparent)}}.border-gray-100\/80{border-color:#f3f4f6cc}@supports (color:color-mix(in lab, red, red)){.border-gray-100\/80{border-color:color-mix(in oklab, var(--color-gray-100) 80%, transparent)}}.border-gray-200{border-color:var(--color-gray-200)}.border-gray-200\!{border-color:var(--color-gray-200)!important}.border-gray-200\/50{border-color:#e5e7eb80}@supports (color:color-mix(in lab, red, red)){.border-gray-200\/50{border-color:color-mix(in oklab, var(--color-gray-200) 50%, transparent)}}.border-gray-200\/60{border-color:#e5e7eb99}@supports (color:color-mix(in lab, red, red)){.border-gray-200\/60{border-color:color-mix(in oklab, var(--color-gray-200) 60%, transparent)}}.border-gray-200\/70{border-color:#e5e7ebb3}@supports (color:color-mix(in lab, red, red)){.border-gray-200\/70{border-color:color-mix(in oklab, var(--color-gray-200) 70%, transparent)}}.border-gray-200\/80{border-color:#e5e7ebcc}@supports (color:color-mix(in lab, red, red)){.border-gray-200\/80{border-color:color-mix(in oklab, var(--color-gray-200) 80%, transparent)}}.border-gray-200\/90{border-color:#e5e7ebe6}@supports (color:color-mix(in lab, red, red)){.border-gray-200\/90{border-color:color-mix(in oklab, var(--color-gray-200) 90%, transparent)}}.border-gray-300{border-color:var(--color-gray-300)}.border-gray-400{border-color:var(--color-gray-400)}.border-gray-500{border-color:var(--color-gray-500)}.border-gray-700{border-color:var(--color-gray-700)}.border-green-200{border-color:var(--color-green-200)}.border-green-500\/30{border-color:#00c7584d}@supports (color:color-mix(in lab, red, red)){.border-green-500\/30{border-color:color-mix(in oklab, var(--color-green-500) 30%, transparent)}}.border-indigo-200{border-color:var(--color-indigo-200)}.border-indigo-200\/60{border-color:#c7d2ff99}@supports (color:color-mix(in lab, red, red)){.border-indigo-200\/60{border-color:color-mix(in oklab, var(--color-indigo-200) 60%, transparent)}}.border-indigo-300{border-color:var(--color-indigo-300)}.border-indigo-500{border-color:var(--color-indigo-500)}.border-indigo-500\/20{border-color:#625fff33}@supports (color:color-mix(in lab, red, red)){.border-indigo-500\/20{border-color:color-mix(in oklab, var(--color-indigo-500) 20%, transparent)}}.border-orange-200{border-color:var(--color-orange-200)}.border-pink-100{border-color:var(--color-pink-100)}.border-pink-200{border-color:var(--color-pink-200)}.border-purple-200{border-color:var(--color-purple-200)}.border-purple-500\/20{border-color:#ac4bff33}@supports (color:color-mix(in lab, red, red)){.border-purple-500\/20{border-color:color-mix(in oklab, var(--color-purple-500) 20%, transparent)}}.border-purple-500\/50{border-color:#ac4bff80}@supports (color:color-mix(in lab, red, red)){.border-purple-500\/50{border-color:color-mix(in oklab, var(--color-purple-500) 50%, transparent)}}.border-purple-500\/60{border-color:#ac4bff99}@supports (color:color-mix(in lab, red, red)){.border-purple-500\/60{border-color:color-mix(in oklab, var(--color-purple-500) 60%, transparent)}}.border-red-100{border-color:var(--color-red-100)}.border-red-200{border-color:var(--color-red-200)}.border-red-200\!{border-color:var(--color-red-200)!important}.border-red-200\/60{border-color:#ffcaca99}@supports (color:color-mix(in lab, red, red)){.border-red-200\/60{border-color:color-mix(in oklab, var(--color-red-200) 60%, transparent)}}.border-red-200\/70{border-color:#ffcacab3}@supports (color:color-mix(in lab, red, red)){.border-red-200\/70{border-color:color-mix(in oklab, var(--color-red-200) 70%, transparent)}}.border-red-200\/80{border-color:#ffcacacc}@supports (color:color-mix(in lab, red, red)){.border-red-200\/80{border-color:color-mix(in oklab, var(--color-red-200) 80%, transparent)}}.border-red-200\/90{border-color:#ffcacae6}@supports (color:color-mix(in lab, red, red)){.border-red-200\/90{border-color:color-mix(in oklab, var(--color-red-200) 90%, transparent)}}.border-red-300{border-color:var(--color-red-300)}.border-red-400{border-color:var(--color-red-400)}.border-red-500{border-color:var(--color-red-500)}.border-red-500\/20{border-color:#fb2c3633}@supports (color:color-mix(in lab, red, red)){.border-red-500\/20{border-color:color-mix(in oklab, var(--color-red-500) 20%, transparent)}}.border-red-500\/30{border-color:#fb2c364d}@supports (color:color-mix(in lab, red, red)){.border-red-500\/30{border-color:color-mix(in oklab, var(--color-red-500) 30%, transparent)}}.border-red-800\/80{border-color:#9f0712cc}@supports (color:color-mix(in lab, red, red)){.border-red-800\/80{border-color:color-mix(in oklab, var(--color-red-800) 80%, transparent)}}.border-rose-500\/20{border-color:#ff235733}@supports (color:color-mix(in lab, red, red)){.border-rose-500\/20{border-color:color-mix(in oklab, var(--color-rose-500) 20%, transparent)}}.border-sky-200{border-color:var(--color-sky-200)}.border-sky-200\/70{border-color:#b8e6feb3}@supports (color:color-mix(in lab, red, red)){.border-sky-200\/70{border-color:color-mix(in oklab, var(--color-sky-200) 70%, transparent)}}.border-sky-200\/90{border-color:#b8e6fee6}@supports (color:color-mix(in lab, red, red)){.border-sky-200\/90{border-color:color-mix(in oklab, var(--color-sky-200) 90%, transparent)}}.border-sky-300\/45{border-color:#77d4ff73}@supports (color:color-mix(in lab, red, red)){.border-sky-300\/45{border-color:color-mix(in oklab, var(--color-sky-300) 45%, transparent)}}.border-sky-900\/80{border-color:#024a70cc}@supports (color:color-mix(in lab, red, red)){.border-sky-900\/80{border-color:color-mix(in oklab, var(--color-sky-900) 80%, transparent)}}.border-slate-200{border-color:var(--color-slate-200)}.border-slate-200\/80{border-color:#e2e8f0cc}@supports (color:color-mix(in lab, red, red)){.border-slate-200\/80{border-color:color-mix(in oklab, var(--color-slate-200) 80%, transparent)}}.border-teal-200{border-color:var(--color-teal-200)}.border-teal-300\!{border-color:var(--color-teal-300)!important}.border-teal-500{border-color:var(--color-teal-500)}.border-transparent{border-color:#0000}.border-violet-200{border-color:var(--color-violet-200)}.border-white{border-color:var(--color-white)}.border-white\/10{border-color:#ffffff1a}@supports (color:color-mix(in lab, red, red)){.border-white\/10{border-color:color-mix(in oklab, var(--color-white) 10%, transparent)}}.border-white\/20{border-color:#fff3}@supports (color:color-mix(in lab, red, red)){.border-white\/20{border-color:color-mix(in oklab, var(--color-white) 20%, transparent)}}.border-white\/40{border-color:#fff6}@supports (color:color-mix(in lab, red, red)){.border-white\/40{border-color:color-mix(in oklab, var(--color-white) 40%, transparent)}}.border-yellow-200{border-color:var(--color-yellow-200)}.border-yellow-300{border-color:var(--color-yellow-300)}.border-yellow-700\/50{border-color:#a3610080}@supports (color:color-mix(in lab, red, red)){.border-yellow-700\/50{border-color:color-mix(in oklab, var(--color-yellow-700) 50%, transparent)}}.border-zinc-100{border-color:var(--color-zinc-100)}.border-zinc-200{border-color:var(--color-zinc-200)}.border-zinc-600\/50{border-color:#52525c80}@supports (color:color-mix(in lab, red, red)){.border-zinc-600\/50{border-color:color-mix(in oklab, var(--color-zinc-600) 50%, transparent)}}.border-zinc-700{border-color:var(--color-zinc-700)}.border-zinc-700\/80{border-color:#3f3f46cc}@supports (color:color-mix(in lab, red, red)){.border-zinc-700\/80{border-color:color-mix(in oklab, var(--color-zinc-700) 80%, transparent)}}.border-zinc-800{border-color:var(--color-zinc-800)}.border-zinc-900{border-color:var(--color-zinc-900)}.border-t-blue-500{border-top-color:var(--color-blue-500)}.border-t-current{border-top-color:currentColor}.border-t-indigo-600{border-top-color:var(--color-indigo-600)}.border-t-transparent{border-top-color:#0000}.border-t-white{border-top-color:var(--color-white)}.border-b-emerald-500{border-bottom-color:var(--color-emerald-500)}.border-b-transparent{border-bottom-color:#0000}.border-l-gray-300{border-left-color:var(--color-gray-300)}.border-l-green-500{border-left-color:var(--color-green-500)}.border-l-red-500{border-left-color:var(--color-red-500)}.bg-\[\#1a0b33\]{background-color:#1a0b33}.bg-\[\#2b1065\]{background-color:#2b1065}.bg-\[var\(--mc-surface\)\]{background-color:var(--mc-surface)}.bg-\[var\(--mc-surface-hover\)\]{background-color:var(--mc-surface-hover)}.bg-amber-50{background-color:var(--color-amber-50)}.bg-amber-50\/40{background-color:#fffbeb66}@supports (color:color-mix(in lab, red, red)){.bg-amber-50\/40{background-color:color-mix(in oklab, var(--color-amber-50) 40%, transparent)}}.bg-amber-50\/50{background-color:#fffbeb80}@supports (color:color-mix(in lab, red, red)){.bg-amber-50\/50{background-color:color-mix(in oklab, var(--color-amber-50) 50%, transparent)}}.bg-amber-50\/60{background-color:#fffbeb99}@supports (color:color-mix(in lab, red, red)){.bg-amber-50\/60{background-color:color-mix(in oklab, var(--color-amber-50) 60%, transparent)}}.bg-amber-50\/70{background-color:#fffbebb3}@supports (color:color-mix(in lab, red, red)){.bg-amber-50\/70{background-color:color-mix(in oklab, var(--color-amber-50) 70%, transparent)}}.bg-amber-50\/80{background-color:#fffbebcc}@supports (color:color-mix(in lab, red, red)){.bg-amber-50\/80{background-color:color-mix(in oklab, var(--color-amber-50) 80%, transparent)}}.bg-amber-50\/90{background-color:#fffbebe6}@supports (color:color-mix(in lab, red, red)){.bg-amber-50\/90{background-color:color-mix(in oklab, var(--color-amber-50) 90%, transparent)}}.bg-amber-100{background-color:var(--color-amber-100)}.bg-amber-100\/50{background-color:#fef3c680}@supports (color:color-mix(in lab, red, red)){.bg-amber-100\/50{background-color:color-mix(in oklab, var(--color-amber-100) 50%, transparent)}}.bg-amber-300{background-color:var(--color-amber-300)}.bg-amber-500{background-color:var(--color-amber-500)}.bg-amber-500\/5{background-color:#f99c000d}@supports (color:color-mix(in lab, red, red)){.bg-amber-500\/5{background-color:color-mix(in oklab, var(--color-amber-500) 5%, transparent)}}.bg-amber-500\/10{background-color:#f99c001a}@supports (color:color-mix(in lab, red, red)){.bg-amber-500\/10{background-color:color-mix(in oklab, var(--color-amber-500) 10%, transparent)}}.bg-amber-500\/90{background-color:#f99c00e6}@supports (color:color-mix(in lab, red, red)){.bg-amber-500\/90{background-color:color-mix(in oklab, var(--color-amber-500) 90%, transparent)}}.bg-amber-600{background-color:var(--color-amber-600)}.bg-amber-600\/40{background-color:#dd740066}@supports (color:color-mix(in lab, red, red)){.bg-amber-600\/40{background-color:color-mix(in oklab, var(--color-amber-600) 40%, transparent)}}.bg-amber-700{background-color:var(--color-amber-700)}.bg-black{background-color:var(--color-black)}.bg-black\/5{background-color:#0000000d}@supports (color:color-mix(in lab, red, red)){.bg-black\/5{background-color:color-mix(in oklab, var(--color-black) 5%, transparent)}}.bg-black\/10{background-color:#0000001a}@supports (color:color-mix(in lab, red, red)){.bg-black\/10{background-color:color-mix(in oklab, var(--color-black) 10%, transparent)}}.bg-black\/20{background-color:#0003}@supports (color:color-mix(in lab, red, red)){.bg-black\/20{background-color:color-mix(in oklab, var(--color-black) 20%, transparent)}}.bg-black\/40{background-color:#0006}@supports (color:color-mix(in lab, red, red)){.bg-black\/40{background-color:color-mix(in oklab, var(--color-black) 40%, transparent)}}.bg-black\/50{background-color:#00000080}@supports (color:color-mix(in lab, red, red)){.bg-black\/50{background-color:color-mix(in oklab, var(--color-black) 50%, transparent)}}.bg-black\/55{background-color:#0000008c}@supports (color:color-mix(in lab, red, red)){.bg-black\/55{background-color:color-mix(in oklab, var(--color-black) 55%, transparent)}}.bg-black\/60{background-color:#0009}@supports (color:color-mix(in lab, red, red)){.bg-black\/60{background-color:color-mix(in oklab, var(--color-black) 60%, transparent)}}.bg-black\/70{background-color:#000000b3}@supports (color:color-mix(in lab, red, red)){.bg-black\/70{background-color:color-mix(in oklab, var(--color-black) 70%, transparent)}}.bg-black\/75{background-color:#000000bf}@supports (color:color-mix(in lab, red, red)){.bg-black\/75{background-color:color-mix(in oklab, var(--color-black) 75%, transparent)}}.bg-black\/80{background-color:#000c}@supports (color:color-mix(in lab, red, red)){.bg-black\/80{background-color:color-mix(in oklab, var(--color-black) 80%, transparent)}}.bg-black\/\[0\.04\]{background-color:#0000000a}@supports (color:color-mix(in lab, red, red)){.bg-black\/\[0\.04\]{background-color:color-mix(in oklab, var(--color-black) 4%, transparent)}}.bg-blue-50{background-color:var(--color-blue-50)}.bg-blue-50\/20{background-color:#eff6ff33}@supports (color:color-mix(in lab, red, red)){.bg-blue-50\/20{background-color:color-mix(in oklab, var(--color-blue-50) 20%, transparent)}}.bg-blue-50\/30{background-color:#eff6ff4d}@supports (color:color-mix(in lab, red, red)){.bg-blue-50\/30{background-color:color-mix(in oklab, var(--color-blue-50) 30%, transparent)}}.bg-blue-50\/40{background-color:#eff6ff66}@supports (color:color-mix(in lab, red, red)){.bg-blue-50\/40{background-color:color-mix(in oklab, var(--color-blue-50) 40%, transparent)}}.bg-blue-50\/50{background-color:#eff6ff80}@supports (color:color-mix(in lab, red, red)){.bg-blue-50\/50{background-color:color-mix(in oklab, var(--color-blue-50) 50%, transparent)}}.bg-blue-50\/60{background-color:#eff6ff99}@supports (color:color-mix(in lab, red, red)){.bg-blue-50\/60{background-color:color-mix(in oklab, var(--color-blue-50) 60%, transparent)}}.bg-blue-50\/70{background-color:#eff6ffb3}@supports (color:color-mix(in lab, red, red)){.bg-blue-50\/70{background-color:color-mix(in oklab, var(--color-blue-50) 70%, transparent)}}.bg-blue-50\/80{background-color:#eff6ffcc}@supports (color:color-mix(in lab, red, red)){.bg-blue-50\/80{background-color:color-mix(in oklab, var(--color-blue-50) 80%, transparent)}}.bg-blue-50\/90{background-color:#eff6ffe6}@supports (color:color-mix(in lab, red, red)){.bg-blue-50\/90{background-color:color-mix(in oklab, var(--color-blue-50) 90%, transparent)}}.bg-blue-100{background-color:var(--color-blue-100)}.bg-blue-100\/50{background-color:#dbeafe80}@supports (color:color-mix(in lab, red, red)){.bg-blue-100\/50{background-color:color-mix(in oklab, var(--color-blue-100) 50%, transparent)}}.bg-blue-400{background-color:var(--color-blue-400)}.bg-blue-500{background-color:var(--color-blue-500)}.bg-blue-500\/5{background-color:#3080ff0d}@supports (color:color-mix(in lab, red, red)){.bg-blue-500\/5{background-color:color-mix(in oklab, var(--color-blue-500) 5%, transparent)}}.bg-blue-500\/6{background-color:#3080ff0f}@supports (color:color-mix(in lab, red, red)){.bg-blue-500\/6{background-color:color-mix(in oklab, var(--color-blue-500) 6%, transparent)}}.bg-blue-500\/10{background-color:#3080ff1a}@supports (color:color-mix(in lab, red, red)){.bg-blue-500\/10{background-color:color-mix(in oklab, var(--color-blue-500) 10%, transparent)}}.bg-blue-500\/20{background-color:#3080ff33}@supports (color:color-mix(in lab, red, red)){.bg-blue-500\/20{background-color:color-mix(in oklab, var(--color-blue-500) 20%, transparent)}}.bg-blue-500\/30{background-color:#3080ff4d}@supports (color:color-mix(in lab, red, red)){.bg-blue-500\/30{background-color:color-mix(in oklab, var(--color-blue-500) 30%, transparent)}}.bg-blue-600{background-color:var(--color-blue-600)}.bg-current{background-color:currentColor}.bg-cyan-50{background-color:var(--color-cyan-50)}.bg-cyan-100{background-color:var(--color-cyan-100)}.bg-cyan-500\/10{background-color:#00b7d71a}@supports (color:color-mix(in lab, red, red)){.bg-cyan-500\/10{background-color:color-mix(in oklab, var(--color-cyan-500) 10%, transparent)}}.bg-emerald-50{background-color:var(--color-emerald-50)}.bg-emerald-50\/20{background-color:#ecfdf533}@supports (color:color-mix(in lab, red, red)){.bg-emerald-50\/20{background-color:color-mix(in oklab, var(--color-emerald-50) 20%, transparent)}}.bg-emerald-50\/50{background-color:#ecfdf580}@supports (color:color-mix(in lab, red, red)){.bg-emerald-50\/50{background-color:color-mix(in oklab, var(--color-emerald-50) 50%, transparent)}}.bg-emerald-50\/80{background-color:#ecfdf5cc}@supports (color:color-mix(in lab, red, red)){.bg-emerald-50\/80{background-color:color-mix(in oklab, var(--color-emerald-50) 80%, transparent)}}.bg-emerald-100{background-color:var(--color-emerald-100)}.bg-emerald-500{background-color:var(--color-emerald-500)}.bg-emerald-500\/5{background-color:#00bb7f0d}@supports (color:color-mix(in lab, red, red)){.bg-emerald-500\/5{background-color:color-mix(in oklab, var(--color-emerald-500) 5%, transparent)}}.bg-emerald-500\/10{background-color:#00bb7f1a}@supports (color:color-mix(in lab, red, red)){.bg-emerald-500\/10{background-color:color-mix(in oklab, var(--color-emerald-500) 10%, transparent)}}.bg-emerald-500\/15{background-color:#00bb7f26}@supports (color:color-mix(in lab, red, red)){.bg-emerald-500\/15{background-color:color-mix(in oklab, var(--color-emerald-500) 15%, transparent)}}.bg-emerald-500\/20{background-color:#00bb7f33}@supports (color:color-mix(in lab, red, red)){.bg-emerald-500\/20{background-color:color-mix(in oklab, var(--color-emerald-500) 20%, transparent)}}.bg-emerald-600{background-color:var(--color-emerald-600)}.bg-emerald-700{background-color:var(--color-emerald-700)}.bg-gray-50{background-color:var(--color-gray-50)}.bg-gray-50\/30{background-color:#f9fafb4d}@supports (color:color-mix(in lab, red, red)){.bg-gray-50\/30{background-color:color-mix(in oklab, var(--color-gray-50) 30%, transparent)}}.bg-gray-50\/40{background-color:#f9fafb66}@supports (color:color-mix(in lab, red, red)){.bg-gray-50\/40{background-color:color-mix(in oklab, var(--color-gray-50) 40%, transparent)}}.bg-gray-50\/50{background-color:#f9fafb80}@supports (color:color-mix(in lab, red, red)){.bg-gray-50\/50{background-color:color-mix(in oklab, var(--color-gray-50) 50%, transparent)}}.bg-gray-50\/60{background-color:#f9fafb99}@supports (color:color-mix(in lab, red, red)){.bg-gray-50\/60{background-color:color-mix(in oklab, var(--color-gray-50) 60%, transparent)}}.bg-gray-50\/70{background-color:#f9fafbb3}@supports (color:color-mix(in lab, red, red)){.bg-gray-50\/70{background-color:color-mix(in oklab, var(--color-gray-50) 70%, transparent)}}.bg-gray-50\/80{background-color:#f9fafbcc}@supports (color:color-mix(in lab, red, red)){.bg-gray-50\/80{background-color:color-mix(in oklab, var(--color-gray-50) 80%, transparent)}}.bg-gray-50\/90{background-color:#f9fafbe6}@supports (color:color-mix(in lab, red, red)){.bg-gray-50\/90{background-color:color-mix(in oklab, var(--color-gray-50) 90%, transparent)}}.bg-gray-50\/95{background-color:#f9fafbf2}@supports (color:color-mix(in lab, red, red)){.bg-gray-50\/95{background-color:color-mix(in oklab, var(--color-gray-50) 95%, transparent)}}.bg-gray-100{background-color:var(--color-gray-100)}.bg-gray-100\/50{background-color:#f3f4f680}@supports (color:color-mix(in lab, red, red)){.bg-gray-100\/50{background-color:color-mix(in oklab, var(--color-gray-100) 50%, transparent)}}.bg-gray-100\/90{background-color:#f3f4f6e6}@supports (color:color-mix(in lab, red, red)){.bg-gray-100\/90{background-color:color-mix(in oklab, var(--color-gray-100) 90%, transparent)}}.bg-gray-200{background-color:var(--color-gray-200)}.bg-gray-200\/50{background-color:#e5e7eb80}@supports (color:color-mix(in lab, red, red)){.bg-gray-200\/50{background-color:color-mix(in oklab, var(--color-gray-200) 50%, transparent)}}.bg-gray-200\/80{background-color:#e5e7ebcc}@supports (color:color-mix(in lab, red, red)){.bg-gray-200\/80{background-color:color-mix(in oklab, var(--color-gray-200) 80%, transparent)}}.bg-gray-200\/90{background-color:#e5e7ebe6}@supports (color:color-mix(in lab, red, red)){.bg-gray-200\/90{background-color:color-mix(in oklab, var(--color-gray-200) 90%, transparent)}}.bg-gray-300\/85{background-color:#d1d5dcd9}@supports (color:color-mix(in lab, red, red)){.bg-gray-300\/85{background-color:color-mix(in oklab, var(--color-gray-300) 85%, transparent)}}.bg-gray-400{background-color:var(--color-gray-400)}.bg-gray-500{background-color:var(--color-gray-500)}.bg-gray-600{background-color:var(--color-gray-600)}.bg-gray-800{background-color:var(--color-gray-800)}.bg-gray-900{background-color:var(--color-gray-900)}.bg-green-50{background-color:var(--color-green-50)}.bg-green-50\/80{background-color:#f0fdf4cc}@supports (color:color-mix(in lab, red, red)){.bg-green-50\/80{background-color:color-mix(in oklab, var(--color-green-50) 80%, transparent)}}.bg-green-100{background-color:var(--color-green-100)}.bg-green-500{background-color:var(--color-green-500)}.bg-green-500\/10{background-color:#00c7581a}@supports (color:color-mix(in lab, red, red)){.bg-green-500\/10{background-color:color-mix(in oklab, var(--color-green-500) 10%, transparent)}}.bg-green-500\/20{background-color:#00c75833}@supports (color:color-mix(in lab, red, red)){.bg-green-500\/20{background-color:color-mix(in oklab, var(--color-green-500) 20%, transparent)}}.bg-green-600{background-color:var(--color-green-600)}.bg-indigo-50{background-color:var(--color-indigo-50)}.bg-indigo-50\/80{background-color:#eef2ffcc}@supports (color:color-mix(in lab, red, red)){.bg-indigo-50\/80{background-color:color-mix(in oklab, var(--color-indigo-50) 80%, transparent)}}.bg-indigo-100{background-color:var(--color-indigo-100)}.bg-indigo-500{background-color:var(--color-indigo-500)}.bg-indigo-500\/5{background-color:#625fff0d}@supports (color:color-mix(in lab, red, red)){.bg-indigo-500\/5{background-color:color-mix(in oklab, var(--color-indigo-500) 5%, transparent)}}.bg-indigo-500\/10{background-color:#625fff1a}@supports (color:color-mix(in lab, red, red)){.bg-indigo-500\/10{background-color:color-mix(in oklab, var(--color-indigo-500) 10%, transparent)}}.bg-indigo-500\/30{background-color:#625fff4d}@supports (color:color-mix(in lab, red, red)){.bg-indigo-500\/30{background-color:color-mix(in oklab, var(--color-indigo-500) 30%, transparent)}}.bg-indigo-600{background-color:var(--color-indigo-600)}.bg-orange-50{background-color:var(--color-orange-50)}.bg-orange-100{background-color:var(--color-orange-100)}.bg-orange-500{background-color:var(--color-orange-500)}.bg-orange-600{background-color:var(--color-orange-600)}.bg-pink-50{background-color:var(--color-pink-50)}.bg-pink-50\/50{background-color:#fdf2f880}@supports (color:color-mix(in lab, red, red)){.bg-pink-50\/50{background-color:color-mix(in oklab, var(--color-pink-50) 50%, transparent)}}.bg-purple-50{background-color:var(--color-purple-50)}.bg-purple-100{background-color:var(--color-purple-100)}.bg-purple-500\/10{background-color:#ac4bff1a}@supports (color:color-mix(in lab, red, red)){.bg-purple-500\/10{background-color:color-mix(in oklab, var(--color-purple-500) 10%, transparent)}}.bg-red-50{background-color:var(--color-red-50)}.bg-red-50\/30{background-color:#fef2f24d}@supports (color:color-mix(in lab, red, red)){.bg-red-50\/30{background-color:color-mix(in oklab, var(--color-red-50) 30%, transparent)}}.bg-red-50\/50{background-color:#fef2f280}@supports (color:color-mix(in lab, red, red)){.bg-red-50\/50{background-color:color-mix(in oklab, var(--color-red-50) 50%, transparent)}}.bg-red-50\/60{background-color:#fef2f299}@supports (color:color-mix(in lab, red, red)){.bg-red-50\/60{background-color:color-mix(in oklab, var(--color-red-50) 60%, transparent)}}.bg-red-50\/70{background-color:#fef2f2b3}@supports (color:color-mix(in lab, red, red)){.bg-red-50\/70{background-color:color-mix(in oklab, var(--color-red-50) 70%, transparent)}}.bg-red-50\/80{background-color:#fef2f2cc}@supports (color:color-mix(in lab, red, red)){.bg-red-50\/80{background-color:color-mix(in oklab, var(--color-red-50) 80%, transparent)}}.bg-red-50\/90{background-color:#fef2f2e6}@supports (color:color-mix(in lab, red, red)){.bg-red-50\/90{background-color:color-mix(in oklab, var(--color-red-50) 90%, transparent)}}.bg-red-100{background-color:var(--color-red-100)}.bg-red-500{background-color:var(--color-red-500)}.bg-red-500\/10{background-color:#fb2c361a}@supports (color:color-mix(in lab, red, red)){.bg-red-500\/10{background-color:color-mix(in oklab, var(--color-red-500) 10%, transparent)}}.bg-red-500\/90{background-color:#fb2c36e6}@supports (color:color-mix(in lab, red, red)){.bg-red-500\/90{background-color:color-mix(in oklab, var(--color-red-500) 90%, transparent)}}.bg-red-600{background-color:var(--color-red-600)}.bg-red-600\!{background-color:var(--color-red-600)!important}.bg-red-700{background-color:var(--color-red-700)}.bg-rose-50{background-color:var(--color-rose-50)}.bg-rose-100{background-color:var(--color-rose-100)}.bg-rose-500\/10{background-color:#ff23571a}@supports (color:color-mix(in lab, red, red)){.bg-rose-500\/10{background-color:color-mix(in oklab, var(--color-rose-500) 10%, transparent)}}.bg-rose-600{background-color:var(--color-rose-600)}.bg-sky-50{background-color:var(--color-sky-50)}.bg-sky-50\/40{background-color:#f0f9ff66}@supports (color:color-mix(in lab, red, red)){.bg-sky-50\/40{background-color:color-mix(in oklab, var(--color-sky-50) 40%, transparent)}}.bg-sky-100{background-color:var(--color-sky-100)}.bg-sky-600{background-color:var(--color-sky-600)}.bg-sky-700{background-color:var(--color-sky-700)}.bg-sky-800{background-color:var(--color-sky-800)}.bg-sky-900\/10{background-color:#024a701a}@supports (color:color-mix(in lab, red, red)){.bg-sky-900\/10{background-color:color-mix(in oklab, var(--color-sky-900) 10%, transparent)}}.bg-slate-50{background-color:var(--color-slate-50)}.bg-slate-50\/70{background-color:#f8fafcb3}@supports (color:color-mix(in lab, red, red)){.bg-slate-50\/70{background-color:color-mix(in oklab, var(--color-slate-50) 70%, transparent)}}.bg-slate-100{background-color:var(--color-slate-100)}.bg-slate-200{background-color:var(--color-slate-200)}.bg-slate-300{background-color:var(--color-slate-300)}.bg-slate-900\/45{background-color:#0f172b73}@supports (color:color-mix(in lab, red, red)){.bg-slate-900\/45{background-color:color-mix(in oklab, var(--color-slate-900) 45%, transparent)}}.bg-teal-50{background-color:var(--color-teal-50)}.bg-teal-100{background-color:var(--color-teal-100)}.bg-teal-500\/10{background-color:#00baa71a}@supports (color:color-mix(in lab, red, red)){.bg-teal-500\/10{background-color:color-mix(in oklab, var(--color-teal-500) 10%, transparent)}}.bg-teal-600{background-color:var(--color-teal-600)}.bg-transparent{background-color:#0000}.bg-violet-50{background-color:var(--color-violet-50)}.bg-violet-100{background-color:var(--color-violet-100)}.bg-violet-600{background-color:var(--color-violet-600)}.bg-white{background-color:var(--color-white)}.bg-white\/10{background-color:#ffffff1a}@supports (color:color-mix(in lab, red, red)){.bg-white\/10{background-color:color-mix(in oklab, var(--color-white) 10%, transparent)}}.bg-white\/15{background-color:#ffffff26}@supports (color:color-mix(in lab, red, red)){.bg-white\/15{background-color:color-mix(in oklab, var(--color-white) 15%, transparent)}}.bg-white\/20{background-color:#fff3}@supports (color:color-mix(in lab, red, red)){.bg-white\/20{background-color:color-mix(in oklab, var(--color-white) 20%, transparent)}}.bg-white\/25{background-color:#ffffff40}@supports (color:color-mix(in lab, red, red)){.bg-white\/25{background-color:color-mix(in oklab, var(--color-white) 25%, transparent)}}.bg-white\/50{background-color:#ffffff80}@supports (color:color-mix(in lab, red, red)){.bg-white\/50{background-color:color-mix(in oklab, var(--color-white) 50%, transparent)}}.bg-white\/60{background-color:#fff9}@supports (color:color-mix(in lab, red, red)){.bg-white\/60{background-color:color-mix(in oklab, var(--color-white) 60%, transparent)}}.bg-white\/70{background-color:#ffffffb3}@supports (color:color-mix(in lab, red, red)){.bg-white\/70{background-color:color-mix(in oklab, var(--color-white) 70%, transparent)}}.bg-white\/80{background-color:#fffc}@supports (color:color-mix(in lab, red, red)){.bg-white\/80{background-color:color-mix(in oklab, var(--color-white) 80%, transparent)}}.bg-white\/90{background-color:#ffffffe6}@supports (color:color-mix(in lab, red, red)){.bg-white\/90{background-color:color-mix(in oklab, var(--color-white) 90%, transparent)}}.bg-white\/95{background-color:#fffffff2}@supports (color:color-mix(in lab, red, red)){.bg-white\/95{background-color:color-mix(in oklab, var(--color-white) 95%, transparent)}}.bg-yellow-50{background-color:var(--color-yellow-50)}.bg-yellow-100{background-color:var(--color-yellow-100)}.bg-yellow-700\/50{background-color:#a3610080}@supports (color:color-mix(in lab, red, red)){.bg-yellow-700\/50{background-color:color-mix(in oklab, var(--color-yellow-700) 50%, transparent)}}.bg-yellow-900\/40{background-color:#733e0a66}@supports (color:color-mix(in lab, red, red)){.bg-yellow-900\/40{background-color:color-mix(in oklab, var(--color-yellow-900) 40%, transparent)}}.bg-zinc-50{background-color:var(--color-zinc-50)}.bg-zinc-50\!{background-color:var(--color-zinc-50)!important}.bg-zinc-100{background-color:var(--color-zinc-100)}.bg-zinc-200{background-color:var(--color-zinc-200)}.bg-zinc-200\/30{background-color:#e4e4e74d}@supports (color:color-mix(in lab, red, red)){.bg-zinc-200\/30{background-color:color-mix(in oklab, var(--color-zinc-200) 30%, transparent)}}.bg-zinc-600{background-color:var(--color-zinc-600)}.bg-zinc-700{background-color:var(--color-zinc-700)}.bg-zinc-800{background-color:var(--color-zinc-800)}.bg-zinc-900{background-color:var(--color-zinc-900)}.bg-zinc-950{background-color:var(--color-zinc-950)}.bg-zinc-950\/5{background-color:#09090b0d}@supports (color:color-mix(in lab, red, red)){.bg-zinc-950\/5{background-color:color-mix(in oklab, var(--color-zinc-950) 5%, transparent)}}.bg-zinc-950\/15{background-color:#09090b26}@supports (color:color-mix(in lab, red, red)){.bg-zinc-950\/15{background-color:color-mix(in oklab, var(--color-zinc-950) 15%, transparent)}}.bg-zinc-950\/90{background-color:#09090be6}@supports (color:color-mix(in lab, red, red)){.bg-zinc-950\/90{background-color:color-mix(in oklab, var(--color-zinc-950) 90%, transparent)}}.bg-linear-to-b{--tw-gradient-position:to bottom}@supports (background-image:linear-gradient(in lab, red, red)){.bg-linear-to-b{--tw-gradient-position:to bottom in oklab}}.bg-linear-to-b{background-image:linear-gradient(var(--tw-gradient-stops))}.bg-linear-to-r{--tw-gradient-position:to right}@supports (background-image:linear-gradient(in lab, red, red)){.bg-linear-to-r{--tw-gradient-position:to right in oklab}}.bg-linear-to-r{background-image:linear-gradient(var(--tw-gradient-stops))}.bg-gradient-to-b{--tw-gradient-position:to bottom in oklab;background-image:linear-gradient(var(--tw-gradient-stops))}.from-blue-500{--tw-gradient-from:var(--color-blue-500);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.from-emerald-500{--tw-gradient-from:var(--color-emerald-500);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.from-purple-500{--tw-gradient-from:var(--color-purple-500);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.from-rose-500{--tw-gradient-from:var(--color-rose-500);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.from-slate-100{--tw-gradient-from:var(--color-slate-100);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.from-transparent{--tw-gradient-from:transparent;--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.via-gray-200{--tw-gradient-via:var(--color-gray-200);--tw-gradient-via-stops:var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-via) var(--tw-gradient-via-position), var(--tw-gradient-to) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-via-stops)}.to-emerald-500{--tw-gradient-to:var(--color-emerald-500);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.to-indigo-500{--tw-gradient-to:var(--color-indigo-500);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.to-purple-500{--tw-gradient-to:var(--color-purple-500);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.to-rose-500{--tw-gradient-to:var(--color-rose-500);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.to-slate-200{--tw-gradient-to:var(--color-slate-200);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.to-transparent{--tw-gradient-to:transparent;--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.box-decoration-clone{-webkit-box-decoration-break:clone;box-decoration-break:clone}.box-decoration-slice{-webkit-box-decoration-break:slice;box-decoration-break:slice}.decoration-clone{-webkit-box-decoration-break:clone;box-decoration-break:clone}.decoration-slice{-webkit-box-decoration-break:slice;box-decoration-break:slice}.bg-fixed{background-attachment:fixed}.bg-local{background-attachment:local}.bg-scroll{background-attachment:scroll}.bg-clip-border{background-clip:border-box}.bg-clip-content{background-clip:content-box}.bg-clip-padding{background-clip:padding-box}.bg-clip-text{-webkit-background-clip:text;background-clip:text}.bg-no-repeat{background-repeat:no-repeat}.bg-repeat{background-repeat:repeat}.bg-repeat-round{background-repeat:round}.bg-repeat-space{background-repeat:space}.bg-repeat-x{background-repeat:repeat-x}.bg-repeat-y{background-repeat:repeat-y}.bg-origin-border{background-origin:border-box}.bg-origin-content{background-origin:content-box}.bg-origin-padding{background-origin:padding-box}.mask-repeat{-webkit-mask-repeat:repeat;mask-repeat:repeat}.object-contain{object-fit:contain}.object-cover{object-fit:cover}.object-fill{object-fit:fill}.object-none{object-fit:none}.object-scale-down{object-fit:scale-down}.object-center{object-position:center}.p-0{padding:0}.p-0\!{padding:0!important}.p-0\.5{padding:calc(var(--spacing) * .5)}.p-1{padding:var(--spacing)}.p-1\.5{padding:calc(var(--spacing) * 1.5)}.p-1\.5\!{padding:calc(var(--spacing) * 1.5)!important}.p-2{padding:calc(var(--spacing) * 2)}.p-2\!{padding:calc(var(--spacing) * 2)!important}.p-2\.5{padding:calc(var(--spacing) * 2.5)}.p-2\.5\!{padding:calc(var(--spacing) * 2.5)!important}.p-3{padding:calc(var(--spacing) * 3)}.p-3\!{padding:calc(var(--spacing) * 3)!important}.p-3\.5{padding:calc(var(--spacing) * 3.5)}.p-4{padding:calc(var(--spacing) * 4)}.p-4\!{padding:calc(var(--spacing) * 4)!important}.p-5{padding:calc(var(--spacing) * 5)}.p-6{padding:calc(var(--spacing) * 6)}.p-7{padding:calc(var(--spacing) * 7)}.p-8{padding:calc(var(--spacing) * 8)}.p-8\!{padding:calc(var(--spacing) * 8)!important}.p-12{padding:calc(var(--spacing) * 12)}.p-\[10\%\]{padding:10%}.px-0{padding-inline:0}.px-0\.5{padding-inline:calc(var(--spacing) * .5)}.px-1{padding-inline:var(--spacing)}.px-1\!{padding-inline:var(--spacing)!important}.px-1\.5{padding-inline:calc(var(--spacing) * 1.5)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-2\!{padding-inline:calc(var(--spacing) * 2)!important}.px-2\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-2\.5\!{padding-inline:calc(var(--spacing) * 2.5)!important}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-3\!{padding-inline:calc(var(--spacing) * 3)!important}.px-3\.5{padding-inline:calc(var(--spacing) * 3.5)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-4\!{padding-inline:calc(var(--spacing) * 4)!important}.px-5{padding-inline:calc(var(--spacing) * 5)}.px-5\!{padding-inline:calc(var(--spacing) * 5)!important}.px-6{padding-inline:calc(var(--spacing) * 6)}.px-6\!{padding-inline:calc(var(--spacing) * 6)!important}.px-8\!{padding-inline:calc(var(--spacing) * 8)!important}.px-10\!{padding-inline:calc(var(--spacing) * 10)!important}.py-0{padding-block:0}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-0\.5\!{padding-block:calc(var(--spacing) * .5)!important}.py-1{padding-block:var(--spacing)}.py-1\!{padding-block:var(--spacing)!important}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-1\.5\!{padding-block:calc(var(--spacing) * 1.5)!important}.py-2{padding-block:calc(var(--spacing) * 2)}.py-2\!{padding-block:calc(var(--spacing) * 2)!important}.py-2\.5{padding-block:calc(var(--spacing) * 2.5)}.py-2\.5\!{padding-block:calc(var(--spacing) * 2.5)!important}.py-3{padding-block:calc(var(--spacing) * 3)}.py-3\!{padding-block:calc(var(--spacing) * 3)!important}.py-3\.5{padding-block:calc(var(--spacing) * 3.5)}.py-4{padding-block:calc(var(--spacing) * 4)}.py-5{padding-block:calc(var(--spacing) * 5)}.py-6{padding-block:calc(var(--spacing) * 6)}.py-8{padding-block:calc(var(--spacing) * 8)}.py-10{padding-block:calc(var(--spacing) * 10)}.py-12{padding-block:calc(var(--spacing) * 12)}.py-16{padding-block:calc(var(--spacing) * 16)}.py-20{padding-block:calc(var(--spacing) * 20)}.pt-0\.5{padding-top:calc(var(--spacing) * .5)}.pt-1{padding-top:var(--spacing)}.pt-1\.5{padding-top:calc(var(--spacing) * 1.5)}.pt-2{padding-top:calc(var(--spacing) * 2)}.pt-3{padding-top:calc(var(--spacing) * 3)}.pt-4{padding-top:calc(var(--spacing) * 4)}.pt-6{padding-top:calc(var(--spacing) * 6)}.pt-7{padding-top:calc(var(--spacing) * 7)}.pt-8{padding-top:calc(var(--spacing) * 8)}.pt-11{padding-top:calc(var(--spacing) * 11)}.pt-16{padding-top:calc(var(--spacing) * 16)}.pt-\[env\(safe-area-inset-top\,0px\)\]{padding-top:env(safe-area-inset-top,0px)}.pt-\[max\(0\.5rem\,env\(safe-area-inset-top\)\)\]{padding-top:max(.5rem, env(safe-area-inset-top))}.pt-\[max\(0\.75rem\,env\(safe-area-inset-top\)\)\]{padding-top:max(.75rem, env(safe-area-inset-top))}.pt-\[max\(1rem\,env\(safe-area-inset-top\)\)\]{padding-top:max(1rem, env(safe-area-inset-top))}.pr-0{padding-right:0}.pr-0\.5{padding-right:calc(var(--spacing) * .5)}.pr-1{padding-right:var(--spacing)}.pr-1\.5{padding-right:calc(var(--spacing) * 1.5)}.pr-2{padding-right:calc(var(--spacing) * 2)}.pr-2\.5{padding-right:calc(var(--spacing) * 2.5)}.pr-3{padding-right:calc(var(--spacing) * 3)}.pr-3\!{padding-right:calc(var(--spacing) * 3)!important}.pr-4{padding-right:calc(var(--spacing) * 4)}.pr-5{padding-right:calc(var(--spacing) * 5)}.pr-7{padding-right:calc(var(--spacing) * 7)}.pr-8{padding-right:calc(var(--spacing) * 8)}.pr-9{padding-right:calc(var(--spacing) * 9)}.pr-10{padding-right:calc(var(--spacing) * 10)}.pr-12{padding-right:calc(var(--spacing) * 12)}.pr-12\!{padding-right:calc(var(--spacing) * 12)!important}.pr-16{padding-right:calc(var(--spacing) * 16)}.pr-28{padding-right:calc(var(--spacing) * 28)}.pb-1{padding-bottom:var(--spacing)}.pb-1\.5{padding-bottom:calc(var(--spacing) * 1.5)}.pb-2{padding-bottom:calc(var(--spacing) * 2)}.pb-3{padding-bottom:calc(var(--spacing) * 3)}.pb-4{padding-bottom:calc(var(--spacing) * 4)}.pb-5{padding-bottom:calc(var(--spacing) * 5)}.pb-6{padding-bottom:calc(var(--spacing) * 6)}.pb-7{padding-bottom:calc(var(--spacing) * 7)}.pb-16{padding-bottom:calc(var(--spacing) * 16)}.pb-20{padding-bottom:calc(var(--spacing) * 20)}.pb-\[env\(safe-area-inset-bottom\)\]{padding-bottom:env(safe-area-inset-bottom)}.pb-\[max\(0\.5rem\,env\(safe-area-inset-bottom\)\)\]{padding-bottom:max(.5rem, env(safe-area-inset-bottom))}.pb-\[max\(0\.25rem\,env\(safe-area-inset-bottom\)\)\]{padding-bottom:max(.25rem, env(safe-area-inset-bottom))}.pb-\[max\(0\.75rem\,env\(safe-area-inset-bottom\)\)\]{padding-bottom:max(.75rem, env(safe-area-inset-bottom))}.pb-\[max\(0px\,env\(safe-area-inset-bottom\)\)\]{padding-bottom:max(0px, env(safe-area-inset-bottom))}.pb-\[max\(1\.5rem\,env\(safe-area-inset-bottom\)\)\]{padding-bottom:max(1.5rem, env(safe-area-inset-bottom))}.pb-\[max\(1rem\,env\(safe-area-inset-bottom\)\)\]{padding-bottom:max(1rem, env(safe-area-inset-bottom))}.pl-0{padding-left:0}.pl-0\.5{padding-left:calc(var(--spacing) * .5)}.pl-1\.5{padding-left:calc(var(--spacing) * 1.5)}.pl-2{padding-left:calc(var(--spacing) * 2)}.pl-2\.5{padding-left:calc(var(--spacing) * 2.5)}.pl-3{padding-left:calc(var(--spacing) * 3)}.pl-4{padding-left:calc(var(--spacing) * 4)}.pl-5{padding-left:calc(var(--spacing) * 5)}.pl-6{padding-left:calc(var(--spacing) * 6)}.pl-8{padding-left:calc(var(--spacing) * 8)}.pl-9{padding-left:calc(var(--spacing) * 9)}.pl-9\!{padding-left:calc(var(--spacing) * 9)!important}.pl-10{padding-left:calc(var(--spacing) * 10)}.pl-10\!{padding-left:calc(var(--spacing) * 10)!important}.pl-11{padding-left:calc(var(--spacing) * 11)}.pl-11\!{padding-left:calc(var(--spacing) * 11)!important}.pl-12{padding-left:calc(var(--spacing) * 12)}.text-center{text-align:center}.text-end{text-align:end}.text-justify{text-align:justify}.text-left{text-align:left}.text-right{text-align:right}.text-start{text-align:start}.align-\[-0\.1em\]{vertical-align:-.1em}.align-baseline{vertical-align:baseline}.align-bottom{vertical-align:bottom}.align-middle{vertical-align:middle}.align-sub{vertical-align:sub}.align-super{vertical-align:super}.align-text-bottom{vertical-align:text-bottom}.align-text-top{vertical-align:text-top}.align-top{vertical-align:top}.font-mono{font-family:var(--font-mono)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.text-4xl{font-size:var(--text-4xl);line-height:var(--tw-leading,var(--text-4xl--line-height))}.text-5xl{font-size:var(--text-5xl);line-height:var(--tw-leading,var(--text-5xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-sm\!{font-size:var(--text-sm)!important;line-height:var(--tw-leading,var(--text-sm--line-height))!important}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-xs\!{font-size:var(--text-xs)!important;line-height:var(--tw-leading,var(--text-xs--line-height))!important}.text-\[0\.9em\]{font-size:.9em}.text-\[0\.85em\]{font-size:.85em}.text-\[8px\]{font-size:8px}.text-\[9px\]{font-size:9px}.text-\[9px\]\!{font-size:9px!important}.text-\[10px\]{font-size:10px}.text-\[10px\]\!{font-size:10px!important}.text-\[11px\]{font-size:11px}.text-\[11px\]\!{font-size:11px!important}.text-\[12px\]{font-size:12px}.leading-4{--tw-leading:calc(var(--spacing) * 4);line-height:calc(var(--spacing) * 4)}.leading-5{--tw-leading:calc(var(--spacing) * 5);line-height:calc(var(--spacing) * 5)}.leading-none{--tw-leading:1;line-height:1}.leading-normal{--tw-leading:var(--leading-normal);line-height:var(--leading-normal)}.leading-relaxed{--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}.leading-snug{--tw-leading:var(--leading-snug);line-height:var(--leading-snug)}.leading-tight{--tw-leading:var(--leading-tight);line-height:var(--leading-tight)}.font-black{--tw-font-weight:var(--font-weight-black);font-weight:var(--font-weight-black)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-\[0\.2em\]{--tw-tracking:.2em;letter-spacing:.2em}.tracking-\[0\.3em\]{--tw-tracking:.3em;letter-spacing:.3em}.tracking-normal{--tw-tracking:var(--tracking-normal);letter-spacing:var(--tracking-normal)}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.tracking-tighter{--tw-tracking:var(--tracking-tighter);letter-spacing:var(--tracking-tighter)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.tracking-wider{--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider)}.tracking-widest{--tw-tracking:var(--tracking-widest);letter-spacing:var(--tracking-widest)}.tracking-widest\!{--tw-tracking:var(--tracking-widest)!important;letter-spacing:var(--tracking-widest)!important}.text-balance{text-wrap:balance}.text-nowrap{text-wrap:nowrap}.text-pretty{text-wrap:pretty}.text-wrap{text-wrap:wrap}.break-normal{overflow-wrap:normal;word-break:normal}.break-words,.wrap-break-word{overflow-wrap:break-word}.\[word-break\:break-word\]{word-break:break-word}.break-all{word-break:break-all}.break-keep{word-break:keep-all}.overflow-ellipsis{text-overflow:ellipsis}.text-clip{text-overflow:clip}.text-ellipsis{text-overflow:ellipsis}.hyphens-auto{-webkit-hyphens:auto;hyphens:auto}.hyphens-manual{-webkit-hyphens:manual;hyphens:manual}.hyphens-none{-webkit-hyphens:none;hyphens:none}.whitespace-break-spaces{white-space:break-spaces}.whitespace-normal{white-space:normal}.whitespace-nowrap{white-space:nowrap}.whitespace-pre{white-space:pre}.whitespace-pre-line{white-space:pre-line}.whitespace-pre-wrap{white-space:pre-wrap}.tab-1{tab-size:1}.text-\[inherit\]{color:inherit}.text-\[var\(--mc-text-secondary\)\]{color:var(--mc-text-secondary)}.text-amber-200{color:var(--color-amber-200)}.text-amber-200\/95{color:#fee685f2}@supports (color:color-mix(in lab, red, red)){.text-amber-200\/95{color:color-mix(in oklab, var(--color-amber-200) 95%, transparent)}}.text-amber-400{color:var(--color-amber-400)}.text-amber-500{color:var(--color-amber-500)}.text-amber-600{color:var(--color-amber-600)}.text-amber-700{color:var(--color-amber-700)}.text-amber-700\/90{color:#b75000e6}@supports (color:color-mix(in lab, red, red)){.text-amber-700\/90{color:color-mix(in oklab, var(--color-amber-700) 90%, transparent)}}.text-amber-800{color:var(--color-amber-800)}.text-amber-800\/80{color:#953d00cc}@supports (color:color-mix(in lab, red, red)){.text-amber-800\/80{color:color-mix(in oklab, var(--color-amber-800) 80%, transparent)}}.text-amber-800\/90{color:#953d00e6}@supports (color:color-mix(in lab, red, red)){.text-amber-800\/90{color:color-mix(in oklab, var(--color-amber-800) 90%, transparent)}}.text-amber-900{color:var(--color-amber-900)}.text-amber-900\/80{color:#7b3306cc}@supports (color:color-mix(in lab, red, red)){.text-amber-900\/80{color:color-mix(in oklab, var(--color-amber-900) 80%, transparent)}}.text-amber-950{color:var(--color-amber-950)}.text-amber-950\/90{color:#461901e6}@supports (color:color-mix(in lab, red, red)){.text-amber-950\/90{color:color-mix(in oklab, var(--color-amber-950) 90%, transparent)}}.text-black{color:var(--color-black)}.text-blue-300{color:var(--color-blue-300)}.text-blue-400{color:var(--color-blue-400)}.text-blue-500{color:var(--color-blue-500)}.text-blue-600{color:var(--color-blue-600)}.text-blue-600\/80{color:#155dfccc}@supports (color:color-mix(in lab, red, red)){.text-blue-600\/80{color:color-mix(in oklab, var(--color-blue-600) 80%, transparent)}}.text-blue-700{color:var(--color-blue-700)}.text-blue-800{color:var(--color-blue-800)}.text-blue-800\/80{color:#193cb8cc}@supports (color:color-mix(in lab, red, red)){.text-blue-800\/80{color:color-mix(in oklab, var(--color-blue-800) 80%, transparent)}}.text-blue-900{color:var(--color-blue-900)}.text-blue-950{color:var(--color-blue-950)}.text-blue-950\/90{color:#162456e6}@supports (color:color-mix(in lab, red, red)){.text-blue-950\/90{color:color-mix(in oklab, var(--color-blue-950) 90%, transparent)}}.text-cyan-500{color:var(--color-cyan-500)}.text-cyan-600{color:var(--color-cyan-600)}.text-cyan-700{color:var(--color-cyan-700)}.text-emerald-300{color:var(--color-emerald-300)}.text-emerald-400{color:var(--color-emerald-400)}.text-emerald-500{color:var(--color-emerald-500)}.text-emerald-500\/80{color:#00bb7fcc}@supports (color:color-mix(in lab, red, red)){.text-emerald-500\/80{color:color-mix(in oklab, var(--color-emerald-500) 80%, transparent)}}.text-emerald-600{color:var(--color-emerald-600)}.text-emerald-600\/80{color:#009767cc}@supports (color:color-mix(in lab, red, red)){.text-emerald-600\/80{color:color-mix(in oklab, var(--color-emerald-600) 80%, transparent)}}.text-emerald-700{color:var(--color-emerald-700)}.text-emerald-800{color:var(--color-emerald-800)}.text-emerald-800\/80{color:#005f46cc}@supports (color:color-mix(in lab, red, red)){.text-emerald-800\/80{color:color-mix(in oklab, var(--color-emerald-800) 80%, transparent)}}.text-gray-100{color:var(--color-gray-100)}.text-gray-200{color:var(--color-gray-200)}.text-gray-200\/90{color:#e5e7ebe6}@supports (color:color-mix(in lab, red, red)){.text-gray-200\/90{color:color-mix(in oklab, var(--color-gray-200) 90%, transparent)}}.text-gray-300{color:var(--color-gray-300)}.text-gray-400{color:var(--color-gray-400)}.text-gray-500{color:var(--color-gray-500)}.text-gray-500\/95{color:#6a7282f2}@supports (color:color-mix(in lab, red, red)){.text-gray-500\/95{color:color-mix(in oklab, var(--color-gray-500) 95%, transparent)}}.text-gray-600{color:var(--color-gray-600)}.text-gray-700{color:var(--color-gray-700)}.text-gray-800{color:var(--color-gray-800)}.text-gray-900{color:var(--color-gray-900)}.text-green-400{color:var(--color-green-400)}.text-green-500{color:var(--color-green-500)}.text-green-600{color:var(--color-green-600)}.text-green-700{color:var(--color-green-700)}.text-green-800{color:var(--color-green-800)}.text-indigo-500{color:var(--color-indigo-500)}.text-indigo-500\/80{color:#625fffcc}@supports (color:color-mix(in lab, red, red)){.text-indigo-500\/80{color:color-mix(in oklab, var(--color-indigo-500) 80%, transparent)}}.text-indigo-600{color:var(--color-indigo-600)}.text-indigo-600\/90{color:#4f39f6e6}@supports (color:color-mix(in lab, red, red)){.text-indigo-600\/90{color:color-mix(in oklab, var(--color-indigo-600) 90%, transparent)}}.text-indigo-700{color:var(--color-indigo-700)}.text-indigo-700\/90{color:#432dd7e6}@supports (color:color-mix(in lab, red, red)){.text-indigo-700\/90{color:color-mix(in oklab, var(--color-indigo-700) 90%, transparent)}}.text-indigo-900\/90{color:#312c85e6}@supports (color:color-mix(in lab, red, red)){.text-indigo-900\/90{color:color-mix(in oklab, var(--color-indigo-900) 90%, transparent)}}.text-indigo-950{color:var(--color-indigo-950)}.text-inherit{color:inherit}.text-orange-200{color:var(--color-orange-200)}.text-orange-500{color:var(--color-orange-500)}.text-orange-600{color:var(--color-orange-600)}.text-orange-700{color:var(--color-orange-700)}.text-orange-800{color:var(--color-orange-800)}.text-pink-500{color:var(--color-pink-500)}.text-pink-600{color:var(--color-pink-600)}.text-pink-700{color:var(--color-pink-700)}.text-pink-800{color:var(--color-pink-800)}.text-purple-100{color:var(--color-purple-100)}.text-purple-200{color:var(--color-purple-200)}.text-purple-300{color:var(--color-purple-300)}.text-purple-300\/90{color:#d9b3ffe6}@supports (color:color-mix(in lab, red, red)){.text-purple-300\/90{color:color-mix(in oklab, var(--color-purple-300) 90%, transparent)}}.text-purple-400{color:var(--color-purple-400)}.text-purple-500{color:var(--color-purple-500)}.text-purple-600{color:var(--color-purple-600)}.text-red-400{color:var(--color-red-400)}.text-red-500{color:var(--color-red-500)}.text-red-500\!{color:var(--color-red-500)!important}.text-red-500\/60{color:#fb2c3699}@supports (color:color-mix(in lab, red, red)){.text-red-500\/60{color:color-mix(in oklab, var(--color-red-500) 60%, transparent)}}.text-red-600{color:var(--color-red-600)}.text-red-600\!{color:var(--color-red-600)!important}.text-red-700{color:var(--color-red-700)}.text-red-800{color:var(--color-red-800)}.text-red-800\/95{color:#9f0712f2}@supports (color:color-mix(in lab, red, red)){.text-red-800\/95{color:color-mix(in oklab, var(--color-red-800) 95%, transparent)}}.text-red-900{color:var(--color-red-900)}.text-rose-500{color:var(--color-rose-500)}.text-rose-600{color:var(--color-rose-600)}.text-sky-500{color:var(--color-sky-500)}.text-sky-600{color:var(--color-sky-600)}.text-sky-600\/70{color:#0084ccb3}@supports (color:color-mix(in lab, red, red)){.text-sky-600\/70{color:color-mix(in oklab, var(--color-sky-600) 70%, transparent)}}.text-sky-700{color:var(--color-sky-700)}.text-sky-700\/90{color:#0069a4e6}@supports (color:color-mix(in lab, red, red)){.text-sky-700\/90{color:color-mix(in oklab, var(--color-sky-700) 90%, transparent)}}.text-sky-800{color:var(--color-sky-800)}.text-sky-800\/75{color:#005986bf}@supports (color:color-mix(in lab, red, red)){.text-sky-800\/75{color:color-mix(in oklab, var(--color-sky-800) 75%, transparent)}}.text-sky-900{color:var(--color-sky-900)}.text-sky-900\/80{color:#024a70cc}@supports (color:color-mix(in lab, red, red)){.text-sky-900\/80{color:color-mix(in oklab, var(--color-sky-900) 80%, transparent)}}.text-slate-400{color:var(--color-slate-400)}.text-slate-500{color:var(--color-slate-500)}.text-slate-600{color:var(--color-slate-600)}.text-slate-700{color:var(--color-slate-700)}.text-slate-800{color:var(--color-slate-800)}.text-slate-900{color:var(--color-slate-900)}.text-teal-500{color:var(--color-teal-500)}.text-teal-600{color:var(--color-teal-600)}.text-teal-600\!{color:var(--color-teal-600)!important}.text-teal-700{color:var(--color-teal-700)}.text-teal-800{color:var(--color-teal-800)}.text-violet-600{color:var(--color-violet-600)}.text-violet-700{color:var(--color-violet-700)}.text-violet-800{color:var(--color-violet-800)}.text-violet-900{color:var(--color-violet-900)}.text-violet-900\/80{color:#4d179acc}@supports (color:color-mix(in lab, red, red)){.text-violet-900\/80{color:color-mix(in oklab, var(--color-violet-900) 80%, transparent)}}.text-violet-900\/90{color:#4d179ae6}@supports (color:color-mix(in lab, red, red)){.text-violet-900\/90{color:color-mix(in oklab, var(--color-violet-900) 90%, transparent)}}.text-violet-950{color:var(--color-violet-950)}.text-white{color:var(--color-white)}.text-white\!{color:var(--color-white)!important}.text-white\/60{color:#fff9}@supports (color:color-mix(in lab, red, red)){.text-white\/60{color:color-mix(in oklab, var(--color-white) 60%, transparent)}}.text-white\/70{color:#ffffffb3}@supports (color:color-mix(in lab, red, red)){.text-white\/70{color:color-mix(in oklab, var(--color-white) 70%, transparent)}}.text-white\/75{color:#ffffffbf}@supports (color:color-mix(in lab, red, red)){.text-white\/75{color:color-mix(in oklab, var(--color-white) 75%, transparent)}}.text-white\/80{color:#fffc}@supports (color:color-mix(in lab, red, red)){.text-white\/80{color:color-mix(in oklab, var(--color-white) 80%, transparent)}}.text-white\/90{color:#ffffffe6}@supports (color:color-mix(in lab, red, red)){.text-white\/90{color:color-mix(in oklab, var(--color-white) 90%, transparent)}}.text-white\/95{color:#fffffff2}@supports (color:color-mix(in lab, red, red)){.text-white\/95{color:color-mix(in oklab, var(--color-white) 95%, transparent)}}.text-yellow-200{color:var(--color-yellow-200)}.text-yellow-400{color:var(--color-yellow-400)}.text-yellow-500{color:var(--color-yellow-500)}.text-yellow-600{color:var(--color-yellow-600)}.text-yellow-800{color:var(--color-yellow-800)}.text-yellow-900{color:var(--color-yellow-900)}.text-zinc-100{color:var(--color-zinc-100)}.text-zinc-300{color:var(--color-zinc-300)}.text-zinc-400{color:var(--color-zinc-400)}.text-zinc-500{color:var(--color-zinc-500)}.text-zinc-600{color:var(--color-zinc-600)}.text-zinc-700{color:var(--color-zinc-700)}.text-zinc-900{color:var(--color-zinc-900)}.capitalize{text-transform:capitalize}.lowercase{text-transform:lowercase}.normal-case{text-transform:none}.uppercase{text-transform:uppercase}.italic{font-style:italic}.not-italic{font-style:normal}.diagonal-fractions{--tw-numeric-fraction:diagonal-fractions;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.lining-nums{--tw-numeric-figure:lining-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.oldstyle-nums{--tw-numeric-figure:oldstyle-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.ordinal{--tw-ordinal:ordinal;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.proportional-nums{--tw-numeric-spacing:proportional-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.slashed-zero{--tw-slashed-zero:slashed-zero;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.stacked-fractions{--tw-numeric-fraction:stacked-fractions;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.normal-nums{font-variant-numeric:normal}.line-through{text-decoration-line:line-through}.no-underline{text-decoration-line:none}.overline{text-decoration-line:overline}.underline{text-decoration-line:underline}.decoration-dashed{text-decoration-style:dashed}.decoration-dotted{text-decoration-style:dotted}.decoration-double{text-decoration-style:double}.decoration-solid{text-decoration-style:solid}.decoration-wavy{text-decoration-style:wavy}.decoration-2{text-decoration-thickness:2px}.underline-offset-2{text-underline-offset:2px}.underline-offset-4{text-underline-offset:4px}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.subpixel-antialiased{-webkit-font-smoothing:auto;-moz-osx-font-smoothing:auto}.placeholder-gray-400::placeholder{color:var(--color-gray-400)}.placeholder-gray-500::placeholder{color:var(--color-gray-500)}.accent-blue-500{accent-color:var(--color-blue-500)}.accent-blue-600{accent-color:var(--color-blue-600)}.accent-emerald-500{accent-color:var(--color-emerald-500)}.opacity-0{opacity:0}.opacity-20{opacity:.2}.opacity-25{opacity:.25}.opacity-30{opacity:.3}.opacity-40{opacity:.4}.opacity-50{opacity:.5}.opacity-60{opacity:.6}.opacity-70{opacity:.7}.opacity-75{opacity:.75}.opacity-80{opacity:.8}.opacity-85{opacity:.85}.opacity-90{opacity:.9}.opacity-100{opacity:1}.opacity-100\!{opacity:1!important}.bg-blend-color{background-blend-mode:color}.bg-blend-color-burn{background-blend-mode:color-burn}.bg-blend-color-dodge{background-blend-mode:color-dodge}.bg-blend-darken{background-blend-mode:darken}.bg-blend-difference{background-blend-mode:difference}.bg-blend-exclusion{background-blend-mode:exclusion}.bg-blend-hard-light{background-blend-mode:hard-light}.bg-blend-hue{background-blend-mode:hue}.bg-blend-lighten{background-blend-mode:lighten}.bg-blend-luminosity{background-blend-mode:luminosity}.bg-blend-multiply{background-blend-mode:multiply}.bg-blend-normal{background-blend-mode:normal}.bg-blend-overlay{background-blend-mode:overlay}.bg-blend-saturation{background-blend-mode:saturation}.bg-blend-screen{background-blend-mode:screen}.bg-blend-soft-light{background-blend-mode:soft-light}.mix-blend-color{mix-blend-mode:color}.mix-blend-color-burn{mix-blend-mode:color-burn}.mix-blend-color-dodge{mix-blend-mode:color-dodge}.mix-blend-darken{mix-blend-mode:darken}.mix-blend-difference{mix-blend-mode:difference}.mix-blend-exclusion{mix-blend-mode:exclusion}.mix-blend-hard-light{mix-blend-mode:hard-light}.mix-blend-hue{mix-blend-mode:hue}.mix-blend-lighten{mix-blend-mode:lighten}.mix-blend-luminosity{mix-blend-mode:luminosity}.mix-blend-multiply{mix-blend-mode:multiply}.mix-blend-normal{mix-blend-mode:normal}.mix-blend-overlay{mix-blend-mode:overlay}.mix-blend-plus-darker{mix-blend-mode:plus-darker}.mix-blend-plus-lighter{mix-blend-mode:plus-lighter}.mix-blend-saturation{mix-blend-mode:saturation}.mix-blend-screen{mix-blend-mode:screen}.mix-blend-soft-light{mix-blend-mode:soft-light}.shadow{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-2xl{--tw-shadow:0 25px 50px -12px var(--tw-shadow-color,#00000040);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-\[0_0_8px_rgba\(59\,130\,246\,0\.5\)\]{--tw-shadow:0 0 8px var(--tw-shadow-color,#3b82f680);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-\[inset_2px_2px_5px_rgba\(0\,0\,0\,0\.12\)\,inset_-1px_-1px_2px_rgba\(255\,255\,255\,0\.6\)\]{--tw-shadow:inset 2px 2px 5px var(--tw-shadow-color,#0000001f), inset -1px -1px 2px var(--tw-shadow-color,#fff9);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-inner{--tw-shadow:inset 0 2px 4px 0 var(--tw-shadow-color,#0000000d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-lg\!{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a)!important;box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)!important}.shadow-md{--tw-shadow:0 4px 6px -1px var(--tw-shadow-color,#0000001a), 0 2px 4px -2px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-none{--tw-shadow:0 0 #0000;box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-xl{--tw-shadow:0 20px 25px -5px var(--tw-shadow-color,#0000001a), 0 8px 10px -6px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-xs{--tw-shadow:0 1px 2px 0 var(--tw-shadow-color,#0000000d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-0{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-1{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-2{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-4{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(4px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-amber-500\/30{--tw-shadow-color:#f99c004d}@supports (color:color-mix(in lab, red, red)){.shadow-amber-500\/30{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-amber-500) 30%, transparent) var(--tw-shadow-alpha), transparent)}}.shadow-amber-500\/40{--tw-shadow-color:#f99c0066}@supports (color:color-mix(in lab, red, red)){.shadow-amber-500\/40{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-amber-500) 40%, transparent) var(--tw-shadow-alpha), transparent)}}.shadow-blue-500\/10{--tw-shadow-color:#3080ff1a}@supports (color:color-mix(in lab, red, red)){.shadow-blue-500\/10{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-blue-500) 10%, transparent) var(--tw-shadow-alpha), transparent)}}.shadow-blue-500\/20{--tw-shadow-color:#3080ff33}@supports (color:color-mix(in lab, red, red)){.shadow-blue-500\/20{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-blue-500) 20%, transparent) var(--tw-shadow-alpha), transparent)}}.shadow-blue-500\/25{--tw-shadow-color:#3080ff40}@supports (color:color-mix(in lab, red, red)){.shadow-blue-500\/25{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-blue-500) 25%, transparent) var(--tw-shadow-alpha), transparent)}}.shadow-blue-500\/30{--tw-shadow-color:#3080ff4d}@supports (color:color-mix(in lab, red, red)){.shadow-blue-500\/30{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-blue-500) 30%, transparent) var(--tw-shadow-alpha), transparent)}}.shadow-blue-600\/20{--tw-shadow-color:#155dfc33}@supports (color:color-mix(in lab, red, red)){.shadow-blue-600\/20{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-blue-600) 20%, transparent) var(--tw-shadow-alpha), transparent)}}.shadow-blue-600\/30{--tw-shadow-color:#155dfc4d}@supports (color:color-mix(in lab, red, red)){.shadow-blue-600\/30{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-blue-600) 30%, transparent) var(--tw-shadow-alpha), transparent)}}.shadow-emerald-500\/20{--tw-shadow-color:#00bb7f33}@supports (color:color-mix(in lab, red, red)){.shadow-emerald-500\/20{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-emerald-500) 20%, transparent) var(--tw-shadow-alpha), transparent)}}.shadow-gray-200\/20{--tw-shadow-color:#e5e7eb33}@supports (color:color-mix(in lab, red, red)){.shadow-gray-200\/20{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-gray-200) 20%, transparent) var(--tw-shadow-alpha), transparent)}}.shadow-green-600\/20{--tw-shadow-color:#00a54433}@supports (color:color-mix(in lab, red, red)){.shadow-green-600\/20{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-green-600) 20%, transparent) var(--tw-shadow-alpha), transparent)}}.shadow-green-600\/30{--tw-shadow-color:#00a5444d}@supports (color:color-mix(in lab, red, red)){.shadow-green-600\/30{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-green-600) 30%, transparent) var(--tw-shadow-alpha), transparent)}}.shadow-indigo-500\/30{--tw-shadow-color:#625fff4d}@supports (color:color-mix(in lab, red, red)){.shadow-indigo-500\/30{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-indigo-500) 30%, transparent) var(--tw-shadow-alpha), transparent)}}.shadow-indigo-500\/50{--tw-shadow-color:#625fff80}@supports (color:color-mix(in lab, red, red)){.shadow-indigo-500\/50{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-indigo-500) 50%, transparent) var(--tw-shadow-alpha), transparent)}}.shadow-indigo-900\/5{--tw-shadow-color:#312c850d}@supports (color:color-mix(in lab, red, red)){.shadow-indigo-900\/5{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-indigo-900) 5%, transparent) var(--tw-shadow-alpha), transparent)}}.shadow-orange-500\/20{--tw-shadow-color:#fe6e0033}@supports (color:color-mix(in lab, red, red)){.shadow-orange-500\/20{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-orange-500) 20%, transparent) var(--tw-shadow-alpha), transparent)}}.shadow-red-500\/20{--tw-shadow-color:#fb2c3633}@supports (color:color-mix(in lab, red, red)){.shadow-red-500\/20{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-red-500) 20%, transparent) var(--tw-shadow-alpha), transparent)}}.shadow-red-500\/30{--tw-shadow-color:#fb2c364d}@supports (color:color-mix(in lab, red, red)){.shadow-red-500\/30{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-red-500) 30%, transparent) var(--tw-shadow-alpha), transparent)}}.shadow-red-600\/20{--tw-shadow-color:#e4001433}@supports (color:color-mix(in lab, red, red)){.shadow-red-600\/20{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-red-600) 20%, transparent) var(--tw-shadow-alpha), transparent)}}.shadow-red-600\/30{--tw-shadow-color:#e400144d}@supports (color:color-mix(in lab, red, red)){.shadow-red-600\/30{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-red-600) 30%, transparent) var(--tw-shadow-alpha), transparent)}}.shadow-slate-200\/50{--tw-shadow-color:#e2e8f080}@supports (color:color-mix(in lab, red, red)){.shadow-slate-200\/50{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-slate-200) 50%, transparent) var(--tw-shadow-alpha), transparent)}}.shadow-teal-600\/30{--tw-shadow-color:#0095884d}@supports (color:color-mix(in lab, red, red)){.shadow-teal-600\/30{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-teal-600) 30%, transparent) var(--tw-shadow-alpha), transparent)}}.ring-amber-400{--tw-ring-color:var(--color-amber-400)}.ring-amber-500{--tw-ring-color:var(--color-amber-500)}.ring-black\/10{--tw-ring-color:#0000001a}@supports (color:color-mix(in lab, red, red)){.ring-black\/10{--tw-ring-color:color-mix(in oklab, var(--color-black) 10%, transparent)}}.ring-blue-300{--tw-ring-color:var(--color-blue-300)}.ring-blue-400{--tw-ring-color:var(--color-blue-400)}.ring-blue-400\/30{--tw-ring-color:#54a2ff4d}@supports (color:color-mix(in lab, red, red)){.ring-blue-400\/30{--tw-ring-color:color-mix(in oklab, var(--color-blue-400) 30%, transparent)}}.ring-blue-400\/60{--tw-ring-color:#54a2ff99}@supports (color:color-mix(in lab, red, red)){.ring-blue-400\/60{--tw-ring-color:color-mix(in oklab, var(--color-blue-400) 60%, transparent)}}.ring-blue-500{--tw-ring-color:var(--color-blue-500)}.ring-blue-500\/20{--tw-ring-color:#3080ff33}@supports (color:color-mix(in lab, red, red)){.ring-blue-500\/20{--tw-ring-color:color-mix(in oklab, var(--color-blue-500) 20%, transparent)}}.ring-blue-500\/30{--tw-ring-color:#3080ff4d}@supports (color:color-mix(in lab, red, red)){.ring-blue-500\/30{--tw-ring-color:color-mix(in oklab, var(--color-blue-500) 30%, transparent)}}.ring-blue-500\/50{--tw-ring-color:#3080ff80}@supports (color:color-mix(in lab, red, red)){.ring-blue-500\/50{--tw-ring-color:color-mix(in oklab, var(--color-blue-500) 50%, transparent)}}.ring-blue-700\/10{--tw-ring-color:#1447e61a}@supports (color:color-mix(in lab, red, red)){.ring-blue-700\/10{--tw-ring-color:color-mix(in oklab, var(--color-blue-700) 10%, transparent)}}.ring-cyan-200\/80{--tw-ring-color:#a2f4fdcc}@supports (color:color-mix(in lab, red, red)){.ring-cyan-200\/80{--tw-ring-color:color-mix(in oklab, var(--color-cyan-200) 80%, transparent)}}.ring-emerald-500\/30{--tw-ring-color:#00bb7f4d}@supports (color:color-mix(in lab, red, red)){.ring-emerald-500\/30{--tw-ring-color:color-mix(in oklab, var(--color-emerald-500) 30%, transparent)}}.ring-gray-200{--tw-ring-color:var(--color-gray-200)}.ring-gray-200\/80{--tw-ring-color:#e5e7ebcc}@supports (color:color-mix(in lab, red, red)){.ring-gray-200\/80{--tw-ring-color:color-mix(in oklab, var(--color-gray-200) 80%, transparent)}}.ring-gray-300{--tw-ring-color:var(--color-gray-300)}.ring-gray-500\/30{--tw-ring-color:#6a72824d}@supports (color:color-mix(in lab, red, red)){.ring-gray-500\/30{--tw-ring-color:color-mix(in oklab, var(--color-gray-500) 30%, transparent)}}.ring-green-600\/20{--tw-ring-color:#00a54433}@supports (color:color-mix(in lab, red, red)){.ring-green-600\/20{--tw-ring-color:color-mix(in oklab, var(--color-green-600) 20%, transparent)}}.ring-indigo-400\/60{--tw-ring-color:#7d87ff99}@supports (color:color-mix(in lab, red, red)){.ring-indigo-400\/60{--tw-ring-color:color-mix(in oklab, var(--color-indigo-400) 60%, transparent)}}.ring-indigo-500{--tw-ring-color:var(--color-indigo-500)}.ring-indigo-500\/30{--tw-ring-color:#625fff4d}@supports (color:color-mix(in lab, red, red)){.ring-indigo-500\/30{--tw-ring-color:color-mix(in oklab, var(--color-indigo-500) 30%, transparent)}}.ring-red-200{--tw-ring-color:var(--color-red-200)}.ring-red-500{--tw-ring-color:var(--color-red-500)}.ring-white\/10{--tw-ring-color:#ffffff1a}@supports (color:color-mix(in lab, red, red)){.ring-white\/10{--tw-ring-color:color-mix(in oklab, var(--color-white) 10%, transparent)}}.ring-white\/90{--tw-ring-color:#ffffffe6}@supports (color:color-mix(in lab, red, red)){.ring-white\/90{--tw-ring-color:color-mix(in oklab, var(--color-white) 90%, transparent)}}.ring-offset-1{--tw-ring-offset-width:1px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.ring-offset-2{--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.ring-offset-white{--tw-ring-offset-color:var(--color-white)}.outline-hidden{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.outline-hidden{outline-offset:2px;outline:2px solid #0000}}.outline{outline-style:var(--tw-outline-style);outline-width:1px}.blur{--tw-blur:blur(8px);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.brightness-4{--tw-brightness:brightness(4%);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.brightness-6{--tw-brightness:brightness(6%);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.drop-shadow{--tw-drop-shadow-size:drop-shadow(0 1px 2px var(--tw-drop-shadow-color,#0000001a)) drop-shadow(0 1px 1px var(--tw-drop-shadow-color,#0000000f));--tw-drop-shadow:drop-shadow(0 1px 2px #0000001a) drop-shadow(0 1px 1px #0000000f);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.grayscale{--tw-grayscale:grayscale(100%);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.grayscale-\[0\.5\]{--tw-grayscale:grayscale(.5);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.invert{--tw-invert:invert(100%);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.sepia{--tw-sepia:sepia(100%);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter-none{filter:none}.backdrop-blur{--tw-backdrop-blur:blur(8px);-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-blur-\[0\.5px\]{--tw-backdrop-blur:blur(.5px);-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-blur-\[2px\]{--tw-backdrop-blur:blur(2px);-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-blur-md{--tw-backdrop-blur:blur(var(--blur-md));-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-blur-sm{--tw-backdrop-blur:blur(var(--blur-sm));-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-blur-xs{--tw-backdrop-blur:blur(var(--blur-xs));-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-grayscale{--tw-backdrop-grayscale:grayscale(100%);-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-invert{--tw-backdrop-invert:invert(100%);-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-sepia{--tw-backdrop-sepia:sepia(100%);-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-filter{-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-filter-none{-webkit-backdrop-filter:none;backdrop-filter:none}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[box-shadow\]{transition-property:box-shadow;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[left\]{transition-property:left;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[opacity\,background-color\,border-color\]{transition-property:opacity,background-color,border-color;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[width\]{transition-property:width;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-opacity{transition-property:opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-shadow{transition-property:box-shadow;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.duration-75{--tw-duration:75ms;transition-duration:75ms}.duration-100{--tw-duration:.1s;transition-duration:.1s}.duration-150{--tw-duration:.15s;transition-duration:.15s}.duration-200{--tw-duration:.2s;transition-duration:.2s}.duration-300{--tw-duration:.3s;transition-duration:.3s}.duration-500{--tw-duration:.5s;transition-duration:.5s}.duration-1000{--tw-duration:1s;transition-duration:1s}.ease-in{--tw-ease:var(--ease-in);transition-timing-function:var(--ease-in)}.ease-in-out{--tw-ease:var(--ease-in-out);transition-timing-function:var(--ease-in-out)}.ease-linear{--tw-ease:linear;transition-timing-function:linear}.ease-out{--tw-ease:var(--ease-out);transition-timing-function:var(--ease-out)}.contain-inline-size{--tw-contain-size:inline-size;contain:var(--tw-contain-size,) var(--tw-contain-layout,) var(--tw-contain-paint,) var(--tw-contain-style,)}.contain-layout{--tw-contain-layout:layout;contain:var(--tw-contain-size,) var(--tw-contain-layout,) var(--tw-contain-paint,) var(--tw-contain-style,)}.contain-paint{--tw-contain-paint:paint;contain:var(--tw-contain-size,) var(--tw-contain-layout,) var(--tw-contain-paint,) var(--tw-contain-style,)}.contain-size{--tw-contain-size:size;contain:var(--tw-contain-size,) var(--tw-contain-layout,) var(--tw-contain-paint,) var(--tw-contain-style,)}.contain-style{--tw-contain-style:style;contain:var(--tw-contain-size,) var(--tw-contain-layout,) var(--tw-contain-paint,) var(--tw-contain-style,)}.contain-\[layout_paint\]{contain:layout paint}.contain-content{contain:content}.contain-none{contain:none}.contain-strict{contain:strict}.forced-color-adjust-auto{forced-color-adjust:auto}.forced-color-adjust-none{forced-color-adjust:none}.outline-dashed{--tw-outline-style:dashed;outline-style:dashed}.outline-dotted{--tw-outline-style:dotted;outline-style:dotted}.outline-double{--tw-outline-style:double;outline-style:double}.outline-none{--tw-outline-style:none;outline-style:none}.select-all{-webkit-user-select:all;user-select:all}.select-auto{-webkit-user-select:auto;user-select:auto}.select-none{-webkit-user-select:none;user-select:none}.select-text{-webkit-user-select:text;user-select:text}.\[android\:prepare\]{android:prepare}.\[bench\:viz-wasm\]{bench:viz-wasm}.\[build-docs\:rns\]{build-docs:rns}.\[build\:all\]{build:all}.\[build\:fe\]{build:fe}.\[build\:frontend\]{build:frontend}.\[build\:viz-wasm\]{build:viz-wasm}.\[deps\:be\]{deps:be}.\[deps\:fe\]{deps:fe}.\[deps\:pins\]{deps:pins}.\[image\:tag\]{image:tag}.\[lint\:all\]{lint:all}.\[lint\:be\]{lint:be}.\[lint\:fe\]{lint:fe}.\[overflow-anchor\:none\]{overflow-anchor:none}.\[test\:all\]{test:all}.\[test\:fe\]{test:fe}.\[test\:viz-wasm\]{test:viz-wasm}:where(.divide-x-reverse>:not(:last-child)){--tw-divide-x-reverse:1}.ring-inset{--tw-ring-inset:inset}.group-open\:rotate-90:is(:where(.group):is([open],:popover-open,:open) *){rotate:90deg}.group-open\:rotate-180:is(:where(.group):is([open],:popover-open,:open) *){rotate:180deg}.group-focus-within\:opacity-100:is(:where(.group):focus-within *){opacity:1}@media (hover:hover){.group-hover\:flex:is(:where(.group):hover *){display:flex}.group-hover\:h-4:is(:where(.group):hover *){height:calc(var(--spacing) * 4)}.group-hover\:scale-110:is(:where(.group):hover *){--tw-scale-x:110%;--tw-scale-y:110%;--tw-scale-z:110%;scale:var(--tw-scale-x) var(--tw-scale-y)}.group-hover\:text-blue-600:is(:where(.group):hover *){color:var(--color-blue-600)}.group-hover\:text-gray-600:is(:where(.group):hover *){color:var(--color-gray-600)}.group-hover\:text-gray-700:is(:where(.group):hover *){color:var(--color-gray-700)}.group-hover\:text-gray-900:is(:where(.group):hover *){color:var(--color-gray-900)}.group-hover\:opacity-20:is(:where(.group):hover *){opacity:.2}.group-hover\:opacity-100:is(:where(.group):hover *){opacity:1}.group-hover\:shadow-indigo-500\/20:is(:where(.group):hover *){--tw-shadow-color:#625fff33}@supports (color:color-mix(in lab, red, red)){.group-hover\:shadow-indigo-500\/20:is(:where(.group):hover *){--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-indigo-500) 20%, transparent) var(--tw-shadow-alpha), transparent)}}.group-hover\/image-modal\:opacity-100:is(:where(.group\/image-modal):hover *),.group-hover\/img\:opacity-100:is(:where(.group\/img):hover *){opacity:1}}.peer-checked\:bg-blue-600:is(:where(.peer):checked~*){background-color:var(--color-blue-600)}.peer-focus\:ring-4:is(:where(.peer):focus~*){--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(4px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.peer-focus\:ring-blue-300:is(:where(.peer):focus~*){--tw-ring-color:var(--color-blue-300)}.peer-focus\:outline-hidden:is(:where(.peer):focus~*){--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.peer-focus\:outline-hidden:is(:where(.peer):focus~*){outline-offset:2px;outline:2px solid #0000}}.file\:mr-4::file-selector-button{margin-right:calc(var(--spacing) * 4)}.file\:rounded-l-xl::file-selector-button{border-top-left-radius:var(--radius-xl);border-bottom-left-radius:var(--radius-xl)}.file\:border-0::file-selector-button{border-style:var(--tw-border-style);border-width:0}.file\:bg-blue-600::file-selector-button{background-color:var(--color-blue-600)}.file\:bg-zinc-200::file-selector-button{background-color:var(--color-zinc-200)}.file\:px-4::file-selector-button{padding-inline:calc(var(--spacing) * 4)}.file\:py-2::file-selector-button{padding-block:calc(var(--spacing) * 2)}.file\:py-2\.5::file-selector-button{padding-block:calc(var(--spacing) * 2.5)}.file\:text-sm::file-selector-button{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.file\:font-bold::file-selector-button{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.file\:font-semibold::file-selector-button{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.file\:text-white::file-selector-button{color:var(--color-white)}.file\:text-zinc-700::file-selector-button{color:var(--color-zinc-700)}.placeholder\:text-gray-400::placeholder{color:var(--color-gray-400)}.after\:absolute:after{content:var(--tw-content);position:absolute}.after\:top-\[2px\]:after{content:var(--tw-content);top:2px}.after\:left-\[2px\]:after{content:var(--tw-content);left:2px}.after\:h-5:after{content:var(--tw-content);height:calc(var(--spacing) * 5)}.after\:w-5:after{content:var(--tw-content);width:calc(var(--spacing) * 5)}.after\:rounded-full:after{content:var(--tw-content);border-radius:3.40282e38px}.after\:border:after{content:var(--tw-content);border-style:var(--tw-border-style);border-width:1px}.after\:border-gray-300:after{content:var(--tw-content);border-color:var(--color-gray-300)}.after\:bg-white:after{content:var(--tw-content);background-color:var(--color-white)}.after\:transition-all:after{content:var(--tw-content);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.after\:content-\[\'\'\]:after{--tw-content:"";content:var(--tw-content)}.peer-checked\:after\:translate-x-full:is(:where(.peer):checked~*):after{content:var(--tw-content);--tw-translate-x:100%;translate:var(--tw-translate-x) var(--tw-translate-y)}.peer-checked\:after\:border-white:is(:where(.peer):checked~*):after{content:var(--tw-content);border-color:var(--color-white)}.first\:mt-0:first-child{margin-top:0}.first\:pt-0:first-child{padding-top:0}.last\:border-0:last-child{border-style:var(--tw-border-style);border-width:0}.last\:border-b-0:last-child{border-bottom-style:var(--tw-border-style);border-bottom-width:0}.open\:bg-white:is([open],:popover-open,:open){background-color:var(--color-white)}.focus-within\:ring-2:focus-within{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}@media (hover:hover){.hover\:z-10:hover{z-index:10}.hover\:scale-105:hover{--tw-scale-x:105%;--tw-scale-y:105%;--tw-scale-z:105%;scale:var(--tw-scale-x) var(--tw-scale-y)}.hover\:scale-110:hover{--tw-scale-x:110%;--tw-scale-y:110%;--tw-scale-z:110%;scale:var(--tw-scale-x) var(--tw-scale-y)}.hover\:scale-\[1\.01\]:hover{scale:1.01}.hover\:scale-\[1\.02\]:hover{scale:1.02}.hover\:scale-\[1\.03\]:hover{scale:1.03}.hover\:border-amber-300:hover{border-color:var(--color-amber-300)}.hover\:border-amber-400:hover{border-color:var(--color-amber-400)}.hover\:border-amber-500:hover{border-color:var(--color-amber-500)}.hover\:border-blue-200:hover{border-color:var(--color-blue-200)}.hover\:border-blue-300:hover{border-color:var(--color-blue-300)}.hover\:border-blue-400:hover{border-color:var(--color-blue-400)}.hover\:border-blue-400\/60:hover{border-color:#54a2ff99}@supports (color:color-mix(in lab, red, red)){.hover\:border-blue-400\/60:hover{border-color:color-mix(in oklab, var(--color-blue-400) 60%, transparent)}}.hover\:border-blue-400\/70:hover{border-color:#54a2ffb3}@supports (color:color-mix(in lab, red, red)){.hover\:border-blue-400\/70:hover{border-color:color-mix(in oklab, var(--color-blue-400) 70%, transparent)}}.hover\:border-blue-500:hover{border-color:var(--color-blue-500)}.hover\:border-cyan-500:hover{border-color:var(--color-cyan-500)}.hover\:border-emerald-400:hover{border-color:var(--color-emerald-400)}.hover\:border-emerald-500:hover{border-color:var(--color-emerald-500)}.hover\:border-gray-300:hover{border-color:var(--color-gray-300)}.hover\:border-gray-400:hover{border-color:var(--color-gray-400)}.hover\:border-gray-500:hover{border-color:var(--color-gray-500)}.hover\:border-indigo-500:hover{border-color:var(--color-indigo-500)}.hover\:border-purple-500:hover{border-color:var(--color-purple-500)}.hover\:border-red-400:hover{border-color:var(--color-red-400)}.hover\:border-teal-500:hover{border-color:var(--color-teal-500)}.hover\:bg-\[var\(--mc-border-strong\)\]:hover{background-color:var(--mc-border-strong)}.hover\:bg-amber-50:hover{background-color:var(--color-amber-50)}.hover\:bg-amber-100:hover{background-color:var(--color-amber-100)}.hover\:bg-amber-200:hover{background-color:var(--color-amber-200)}.hover\:bg-amber-600:hover{background-color:var(--color-amber-600)}.hover\:bg-amber-700:hover{background-color:var(--color-amber-700)}.hover\:bg-amber-800:hover{background-color:var(--color-amber-800)}.hover\:bg-black\/5:hover{background-color:#0000000d}@supports (color:color-mix(in lab, red, red)){.hover\:bg-black\/5:hover{background-color:color-mix(in oklab, var(--color-black) 5%, transparent)}}.hover\:bg-black\/10:hover{background-color:#0000001a}@supports (color:color-mix(in lab, red, red)){.hover\:bg-black\/10:hover{background-color:color-mix(in oklab, var(--color-black) 10%, transparent)}}.hover\:bg-black\/55:hover{background-color:#0000008c}@supports (color:color-mix(in lab, red, red)){.hover\:bg-black\/55:hover{background-color:color-mix(in oklab, var(--color-black) 55%, transparent)}}.hover\:bg-black\/70:hover{background-color:#000000b3}@supports (color:color-mix(in lab, red, red)){.hover\:bg-black\/70:hover{background-color:color-mix(in oklab, var(--color-black) 70%, transparent)}}.hover\:bg-blue-50:hover{background-color:var(--color-blue-50)}.hover\:bg-blue-50\/30:hover{background-color:#eff6ff4d}@supports (color:color-mix(in lab, red, red)){.hover\:bg-blue-50\/30:hover{background-color:color-mix(in oklab, var(--color-blue-50) 30%, transparent)}}.hover\:bg-blue-50\/60:hover{background-color:#eff6ff99}@supports (color:color-mix(in lab, red, red)){.hover\:bg-blue-50\/60:hover{background-color:color-mix(in oklab, var(--color-blue-50) 60%, transparent)}}.hover\:bg-blue-100:hover{background-color:var(--color-blue-100)}.hover\:bg-blue-200:hover{background-color:var(--color-blue-200)}.hover\:bg-blue-500:hover{background-color:var(--color-blue-500)}.hover\:bg-blue-500\/15:hover{background-color:#3080ff26}@supports (color:color-mix(in lab, red, red)){.hover\:bg-blue-500\/15:hover{background-color:color-mix(in oklab, var(--color-blue-500) 15%, transparent)}}.hover\:bg-blue-500\/20:hover{background-color:#3080ff33}@supports (color:color-mix(in lab, red, red)){.hover\:bg-blue-500\/20:hover{background-color:color-mix(in oklab, var(--color-blue-500) 20%, transparent)}}.hover\:bg-blue-600:hover{background-color:var(--color-blue-600)}.hover\:bg-blue-700:hover{background-color:var(--color-blue-700)}.hover\:bg-emerald-50:hover{background-color:var(--color-emerald-50)}.hover\:bg-emerald-100:hover{background-color:var(--color-emerald-100)}.hover\:bg-emerald-500:hover{background-color:var(--color-emerald-500)}.hover\:bg-emerald-500\/20:hover{background-color:#00bb7f33}@supports (color:color-mix(in lab, red, red)){.hover\:bg-emerald-500\/20:hover{background-color:color-mix(in oklab, var(--color-emerald-500) 20%, transparent)}}.hover\:bg-emerald-600:hover{background-color:var(--color-emerald-600)}.hover\:bg-emerald-700:hover{background-color:var(--color-emerald-700)}.hover\:bg-gray-50:hover{background-color:var(--color-gray-50)}.hover\:bg-gray-50\/30:hover{background-color:#f9fafb4d}@supports (color:color-mix(in lab, red, red)){.hover\:bg-gray-50\/30:hover{background-color:color-mix(in oklab, var(--color-gray-50) 30%, transparent)}}.hover\:bg-gray-50\/50:hover{background-color:#f9fafb80}@supports (color:color-mix(in lab, red, red)){.hover\:bg-gray-50\/50:hover{background-color:color-mix(in oklab, var(--color-gray-50) 50%, transparent)}}.hover\:bg-gray-50\/80:hover{background-color:#f9fafbcc}@supports (color:color-mix(in lab, red, red)){.hover\:bg-gray-50\/80:hover{background-color:color-mix(in oklab, var(--color-gray-50) 80%, transparent)}}.hover\:bg-gray-100:hover{background-color:var(--color-gray-100)}.hover\:bg-gray-100\/80:hover{background-color:#f3f4f6cc}@supports (color:color-mix(in lab, red, red)){.hover\:bg-gray-100\/80:hover{background-color:color-mix(in oklab, var(--color-gray-100) 80%, transparent)}}.hover\:bg-gray-200:hover{background-color:var(--color-gray-200)}.hover\:bg-gray-200\/50:hover{background-color:#e5e7eb80}@supports (color:color-mix(in lab, red, red)){.hover\:bg-gray-200\/50:hover{background-color:color-mix(in oklab, var(--color-gray-200) 50%, transparent)}}.hover\:bg-gray-300:hover{background-color:var(--color-gray-300)}.hover\:bg-gray-400:hover{background-color:var(--color-gray-400)}.hover\:bg-gray-700:hover{background-color:var(--color-gray-700)}.hover\:bg-green-100:hover{background-color:var(--color-green-100)}.hover\:bg-green-500:hover{background-color:var(--color-green-500)}.hover\:bg-green-500\/20:hover{background-color:#00c75833}@supports (color:color-mix(in lab, red, red)){.hover\:bg-green-500\/20:hover{background-color:color-mix(in oklab, var(--color-green-500) 20%, transparent)}}.hover\:bg-green-700:hover{background-color:var(--color-green-700)}.hover\:bg-indigo-100:hover{background-color:var(--color-indigo-100)}.hover\:bg-indigo-200:hover{background-color:var(--color-indigo-200)}.hover\:bg-indigo-500:hover{background-color:var(--color-indigo-500)}.hover\:bg-orange-100:hover{background-color:var(--color-orange-100)}.hover\:bg-orange-500:hover{background-color:var(--color-orange-500)}.hover\:bg-orange-700:hover{background-color:var(--color-orange-700)}.hover\:bg-pink-100:hover{background-color:var(--color-pink-100)}.hover\:bg-purple-800\/60:hover{background-color:#6e11b099}@supports (color:color-mix(in lab, red, red)){.hover\:bg-purple-800\/60:hover{background-color:color-mix(in oklab, var(--color-purple-800) 60%, transparent)}}.hover\:bg-purple-900\/40:hover{background-color:#59168b66}@supports (color:color-mix(in lab, red, red)){.hover\:bg-purple-900\/40:hover{background-color:color-mix(in oklab, var(--color-purple-900) 40%, transparent)}}.hover\:bg-red-50:hover{background-color:var(--color-red-50)}.hover\:bg-red-50\!:hover{background-color:var(--color-red-50)!important}.hover\:bg-red-100:hover{background-color:var(--color-red-100)}.hover\:bg-red-200:hover{background-color:var(--color-red-200)}.hover\:bg-red-500:hover{background-color:var(--color-red-500)}.hover\:bg-red-600:hover{background-color:var(--color-red-600)}.hover\:bg-red-700:hover{background-color:var(--color-red-700)}.hover\:bg-rose-700:hover{background-color:var(--color-rose-700)}.hover\:bg-sky-700:hover{background-color:var(--color-sky-700)}.hover\:bg-sky-900\/10:hover{background-color:#024a701a}@supports (color:color-mix(in lab, red, red)){.hover\:bg-sky-900\/10:hover{background-color:color-mix(in oklab, var(--color-sky-900) 10%, transparent)}}.hover\:bg-sky-900\/14:hover{background-color:#024a7024}@supports (color:color-mix(in lab, red, red)){.hover\:bg-sky-900\/14:hover{background-color:color-mix(in oklab, var(--color-sky-900) 14%, transparent)}}.hover\:bg-slate-50:hover{background-color:var(--color-slate-50)}.hover\:bg-slate-100\/80:hover{background-color:#f1f5f9cc}@supports (color:color-mix(in lab, red, red)){.hover\:bg-slate-100\/80:hover{background-color:color-mix(in oklab, var(--color-slate-100) 80%, transparent)}}.hover\:bg-teal-100:hover{background-color:var(--color-teal-100)}.hover\:bg-teal-500:hover{background-color:var(--color-teal-500)}.hover\:bg-violet-100:hover{background-color:var(--color-violet-100)}.hover\:bg-violet-700:hover{background-color:var(--color-violet-700)}.hover\:bg-white:hover{background-color:var(--color-white)}.hover\:bg-white\/10:hover{background-color:#ffffff1a}@supports (color:color-mix(in lab, red, red)){.hover\:bg-white\/10:hover{background-color:color-mix(in oklab, var(--color-white) 10%, transparent)}}.hover\:bg-white\/20:hover{background-color:#fff3}@supports (color:color-mix(in lab, red, red)){.hover\:bg-white\/20:hover{background-color:color-mix(in oklab, var(--color-white) 20%, transparent)}}.hover\:bg-white\/25:hover{background-color:#ffffff40}@supports (color:color-mix(in lab, red, red)){.hover\:bg-white\/25:hover{background-color:color-mix(in oklab, var(--color-white) 25%, transparent)}}.hover\:bg-white\/30:hover{background-color:#ffffff4d}@supports (color:color-mix(in lab, red, red)){.hover\:bg-white\/30:hover{background-color:color-mix(in oklab, var(--color-white) 30%, transparent)}}.hover\:bg-white\/50:hover{background-color:#ffffff80}@supports (color:color-mix(in lab, red, red)){.hover\:bg-white\/50:hover{background-color:color-mix(in oklab, var(--color-white) 50%, transparent)}}.hover\:bg-white\/90:hover{background-color:#ffffffe6}@supports (color:color-mix(in lab, red, red)){.hover\:bg-white\/90:hover{background-color:color-mix(in oklab, var(--color-white) 90%, transparent)}}.hover\:bg-yellow-700:hover{background-color:var(--color-yellow-700)}.hover\:bg-zinc-600:hover{background-color:var(--color-zinc-600)}.hover\:bg-zinc-700:hover{background-color:var(--color-zinc-700)}.hover\:bg-zinc-800:hover{background-color:var(--color-zinc-800)}.hover\:text-amber-500:hover{color:var(--color-amber-500)}.hover\:text-amber-600:hover{color:var(--color-amber-600)}.hover\:text-amber-800:hover{color:var(--color-amber-800)}.hover\:text-blue-300:hover{color:var(--color-blue-300)}.hover\:text-blue-500:hover{color:var(--color-blue-500)}.hover\:text-blue-600:hover{color:var(--color-blue-600)}.hover\:text-blue-700:hover{color:var(--color-blue-700)}.hover\:text-cyan-600:hover{color:var(--color-cyan-600)}.hover\:text-emerald-600:hover{color:var(--color-emerald-600)}.hover\:text-gray-500:hover{color:var(--color-gray-500)}.hover\:text-gray-600:hover{color:var(--color-gray-600)}.hover\:text-gray-700:hover{color:var(--color-gray-700)}.hover\:text-gray-800:hover{color:var(--color-gray-800)}.hover\:text-gray-900:hover{color:var(--color-gray-900)}.hover\:text-green-500:hover{color:var(--color-green-500)}.hover\:text-green-600:hover{color:var(--color-green-600)}.hover\:text-indigo-500:hover{color:var(--color-indigo-500)}.hover\:text-purple-100:hover{color:var(--color-purple-100)}.hover\:text-purple-200:hover{color:var(--color-purple-200)}.hover\:text-red-400:hover{color:var(--color-red-400)}.hover\:text-red-500:hover{color:var(--color-red-500)}.hover\:text-red-600:hover{color:var(--color-red-600)}.hover\:text-teal-500:hover{color:var(--color-teal-500)}.hover\:text-violet-600:hover{color:var(--color-violet-600)}.hover\:text-white:hover{color:var(--color-white)}.hover\:text-zinc-200:hover{color:var(--color-zinc-200)}.hover\:underline:hover{text-decoration-line:underline}.hover\:opacity-70:hover{opacity:.7}.hover\:opacity-90:hover{opacity:.9}.hover\:opacity-100:hover{opacity:1}.hover\:shadow-2xl:hover{--tw-shadow:0 25px 50px -12px var(--tw-shadow-color,#00000040);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.hover\:shadow-md:hover{--tw-shadow:0 4px 6px -1px var(--tw-shadow-color,#0000001a), 0 2px 4px -2px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.hover\:shadow-xl:hover{--tw-shadow:0 20px 25px -5px var(--tw-shadow-color,#0000001a), 0 8px 10px -6px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.hover\:ring-2:hover{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.hover\:ring-blue-500\/50:hover{--tw-ring-color:#3080ff80}@supports (color:color-mix(in lab, red, red)){.hover\:ring-blue-500\/50:hover{--tw-ring-color:color-mix(in oklab, var(--color-blue-500) 50%, transparent)}}.hover\:file\:bg-blue-700:hover::file-selector-button{background-color:var(--color-blue-700)}.hover\:file\:bg-zinc-300:hover::file-selector-button{background-color:var(--color-zinc-300)}}.focus\:border-blue-400:focus{border-color:var(--color-blue-400)}.focus\:border-blue-500:focus{border-color:var(--color-blue-500)}.focus\:border-indigo-400:focus{border-color:var(--color-indigo-400)}.focus\:border-indigo-500:focus{border-color:var(--color-indigo-500)}.focus\:border-transparent:focus{border-color:#0000}.focus\:opacity-100:focus{opacity:1}.focus\:ring-0:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\:ring-1:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\:ring-2:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\:ring-amber-500:focus{--tw-ring-color:var(--color-amber-500)}.focus\:ring-blue-400:focus{--tw-ring-color:var(--color-blue-400)}.focus\:ring-blue-500:focus{--tw-ring-color:var(--color-blue-500)}.focus\:ring-blue-500\/20:focus{--tw-ring-color:#3080ff33}@supports (color:color-mix(in lab, red, red)){.focus\:ring-blue-500\/20:focus{--tw-ring-color:color-mix(in oklab, var(--color-blue-500) 20%, transparent)}}.focus\:ring-blue-500\/30:focus{--tw-ring-color:#3080ff4d}@supports (color:color-mix(in lab, red, red)){.focus\:ring-blue-500\/30:focus{--tw-ring-color:color-mix(in oklab, var(--color-blue-500) 30%, transparent)}}.focus\:ring-blue-500\/40:focus{--tw-ring-color:#3080ff66}@supports (color:color-mix(in lab, red, red)){.focus\:ring-blue-500\/40:focus{--tw-ring-color:color-mix(in oklab, var(--color-blue-500) 40%, transparent)}}.focus\:ring-blue-500\/50:focus{--tw-ring-color:#3080ff80}@supports (color:color-mix(in lab, red, red)){.focus\:ring-blue-500\/50:focus{--tw-ring-color:color-mix(in oklab, var(--color-blue-500) 50%, transparent)}}.focus\:ring-blue-600:focus{--tw-ring-color:var(--color-blue-600)}.focus\:ring-emerald-500:focus{--tw-ring-color:var(--color-emerald-500)}.focus\:ring-indigo-400:focus{--tw-ring-color:var(--color-indigo-400)}.focus\:ring-indigo-500:focus{--tw-ring-color:var(--color-indigo-500)}.focus\:ring-red-500:focus{--tw-ring-color:var(--color-red-500)}.focus\:ring-white\/50:focus{--tw-ring-color:#ffffff80}@supports (color:color-mix(in lab, red, red)){.focus\:ring-white\/50:focus{--tw-ring-color:color-mix(in oklab, var(--color-white) 50%, transparent)}}.focus\:ring-offset-2:focus{--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.focus\:outline-hidden:focus{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.focus\:outline-hidden:focus{outline-offset:2px;outline:2px solid #0000}}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}.focus\:ring-inset:focus{--tw-ring-inset:inset}.focus-visible\:ring-1:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus-visible\:ring-2:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus-visible\:ring-blue-500:focus-visible{--tw-ring-color:var(--color-blue-500)}.focus-visible\:ring-blue-500\/50:focus-visible{--tw-ring-color:#3080ff80}@supports (color:color-mix(in lab, red, red)){.focus-visible\:ring-blue-500\/50:focus-visible{--tw-ring-color:color-mix(in oklab, var(--color-blue-500) 50%, transparent)}}.focus-visible\:ring-blue-500\/60:focus-visible{--tw-ring-color:#3080ff99}@supports (color:color-mix(in lab, red, red)){.focus-visible\:ring-blue-500\/60:focus-visible{--tw-ring-color:color-mix(in oklab, var(--color-blue-500) 60%, transparent)}}.focus-visible\:ring-indigo-500:focus-visible{--tw-ring-color:var(--color-indigo-500)}.focus-visible\:ring-white\/80:focus-visible{--tw-ring-color:#fffc}@supports (color:color-mix(in lab, red, red)){.focus-visible\:ring-white\/80:focus-visible{--tw-ring-color:color-mix(in oklab, var(--color-white) 80%, transparent)}}.focus-visible\:outline-2:focus-visible{outline-style:var(--tw-outline-style);outline-width:2px}.focus-visible\:outline-offset-2:focus-visible{outline-offset:2px}.focus-visible\:outline-gray-500:focus-visible{outline-color:var(--color-gray-500)}.focus-visible\:outline-solid:focus-visible{--tw-outline-style:solid;outline-style:solid}.active\:scale-90:active{--tw-scale-x:90%;--tw-scale-y:90%;--tw-scale-z:90%;scale:var(--tw-scale-x) var(--tw-scale-y)}.active\:scale-95:active{--tw-scale-x:95%;--tw-scale-y:95%;--tw-scale-z:95%;scale:var(--tw-scale-x) var(--tw-scale-y)}.active\:scale-\[0\.98\]:active{scale:.98}.active\:cursor-grabbing:active{cursor:grabbing}.disabled\:pointer-events-none:disabled{pointer-events:none}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:bg-blue-300:disabled{background-color:var(--color-blue-300)}.disabled\:bg-gray-100:disabled{background-color:var(--color-gray-100)}.disabled\:bg-gray-300:disabled{background-color:var(--color-gray-300)}.disabled\:bg-gray-400:disabled{background-color:var(--color-gray-400)}.disabled\:text-gray-300:disabled{color:var(--color-gray-300)}.disabled\:opacity-30:disabled{opacity:.3}.disabled\:opacity-40:disabled{opacity:.4}.disabled\:opacity-50:disabled{opacity:.5}.disabled\:opacity-60:disabled{opacity:.6}@media (hover:hover){.disabled\:hover\:scale-100:disabled:hover{--tw-scale-x:100%;--tw-scale-y:100%;--tw-scale-z:100%;scale:var(--tw-scale-x) var(--tw-scale-y)}.disabled\:hover\:bg-violet-50:disabled:hover{background-color:var(--color-violet-50)}}@media (prefers-reduced-motion:reduce){.motion-reduce\:animate-none{animation:none}}@media not all and (min-width:40rem){.max-sm\:right-auto{right:auto}.max-sm\:bottom-22{bottom:calc(var(--spacing) * 22)}.max-sm\:bottom-\[calc\(1rem\+env\(safe-area-inset-bottom\,0px\)\)\]{bottom:calc(1rem + env(safe-area-inset-bottom,0px))}.max-sm\:bottom-\[calc\(5\.5rem\+env\(safe-area-inset-bottom\,0px\)\)\]{bottom:calc(5.5rem + env(safe-area-inset-bottom,0px))}.max-sm\:left-1\/2{left:50%}.max-sm\:h-\[92dvh\]{height:92dvh}.max-sm\:max-h-\[92dvh\]{max-height:92dvh}.max-sm\:min-h-\[55dvh\]{min-height:55dvh}.max-sm\:min-w-max{min-width:max-content}.max-sm\:-translate-x-1\/2{--tw-translate-x:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.max-sm\:flex-nowrap{flex-wrap:nowrap}.max-sm\:text-\[8px\]{font-size:8px}.max-sm\:text-\[9px\]{font-size:9px}.max-sm\:leading-snug{--tw-leading:var(--leading-snug);line-height:var(--leading-snug)}.max-sm\:tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.max-sm\:break-all{word-break:break-all}.max-sm\:whitespace-nowrap{white-space:nowrap}}@media (min-width:900px){.min-\[900px\]\:size-4{width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.min-\[900px\]\:size-5{width:calc(var(--spacing) * 5);height:calc(var(--spacing) * 5)}.min-\[900px\]\:h-9{height:calc(var(--spacing) * 9)}.min-\[900px\]\:w-9{width:calc(var(--spacing) * 9)}.min-\[900px\]\:max-w-\[14rem\]{max-width:14rem}.min-\[900px\]\:min-w-\[7rem\]{min-width:7rem}.min-\[900px\]\:px-3{padding-inline:calc(var(--spacing) * 3)}.min-\[900px\]\:text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}}@media (min-width:40rem){.sm\:relative{position:relative}.sm\:inset-auto{inset:auto}.sm\:top-2{top:calc(var(--spacing) * 2)}.sm\:top-4{top:calc(var(--spacing) * 4)}.sm\:right-0{right:0}.sm\:right-4{right:calc(var(--spacing) * 4)}.sm\:right-auto{right:auto}.sm\:bottom-4{bottom:calc(var(--spacing) * 4)}.sm\:bottom-5{bottom:calc(var(--spacing) * 5)}.sm\:left-4{left:calc(var(--spacing) * 4)}.sm\:left-auto{left:auto}.sm\:z-0{z-index:0}.sm\:z-auto{z-index:auto}.sm\:col-span-1{grid-column:span 1/span 1}.sm\:col-span-2{grid-column:span 2/span 2}.sm\:m-0{margin:0}.sm\:-mx-4{margin-inline:calc(var(--spacing) * -4)}.sm\:mx-0{margin-inline:0}.sm\:mx-1{margin-inline:var(--spacing)}.sm\:my-auto{margin-block:auto}.sm\:mt-0{margin-top:0}.sm\:mt-2{margin-top:calc(var(--spacing) * 2)}.sm\:mt-8{margin-top:calc(var(--spacing) * 8)}.sm\:mr-2{margin-right:calc(var(--spacing) * 2)}.sm\:mb-4{margin-bottom:calc(var(--spacing) * 4)}.sm\:ml-0{margin-left:0}.sm\:ml-1\.5{margin-left:calc(var(--spacing) * 1.5)}.sm\:ml-auto{margin-left:auto}.sm\:line-clamp-none{-webkit-line-clamp:unset;-webkit-box-orient:horizontal;display:block;overflow:visible}.sm\:block{display:block}.sm\:flex{display:flex}.sm\:hidden{display:none}.sm\:inline{display:inline}.sm\:inline-block{display:inline-block}.sm\:inline-flex{display:inline-flex}.sm\:size-5{width:calc(var(--spacing) * 5);height:calc(var(--spacing) * 5)}.sm\:size-5\!{width:calc(var(--spacing) * 5)!important;height:calc(var(--spacing) * 5)!important}.sm\:size-6{width:calc(var(--spacing) * 6);height:calc(var(--spacing) * 6)}.sm\:size-7{width:calc(var(--spacing) * 7);height:calc(var(--spacing) * 7)}.sm\:size-12{width:calc(var(--spacing) * 12);height:calc(var(--spacing) * 12)}.sm\:size-48{width:calc(var(--spacing) * 48);height:calc(var(--spacing) * 48)}.sm\:h-4{height:calc(var(--spacing) * 4)}.sm\:h-5{height:calc(var(--spacing) * 5)}.sm\:h-6{height:calc(var(--spacing) * 6)}.sm\:h-9{height:calc(var(--spacing) * 9)}.sm\:h-11{height:calc(var(--spacing) * 11)}.sm\:h-12{height:calc(var(--spacing) * 12)}.sm\:h-14{height:calc(var(--spacing) * 14)}.sm\:h-28{height:calc(var(--spacing) * 28)}.sm\:h-\[320px\]{height:320px}.sm\:max-h-\[90vh\]{max-height:90vh}.sm\:min-h-0{min-height:0}.sm\:min-h-14{min-height:calc(var(--spacing) * 14)}.sm\:min-h-\[12rem\]{min-height:12rem}.sm\:min-h-\[280px\]{min-height:280px}.sm\:w-4{width:calc(var(--spacing) * 4)}.sm\:w-5{width:calc(var(--spacing) * 5)}.sm\:w-6{width:calc(var(--spacing) * 6)}.sm\:w-9{width:calc(var(--spacing) * 9)}.sm\:w-11{width:calc(var(--spacing) * 11)}.sm\:w-12{width:calc(var(--spacing) * 12)}.sm\:w-14{width:calc(var(--spacing) * 14)}.sm\:w-24{width:calc(var(--spacing) * 24)}.sm\:w-40{width:calc(var(--spacing) * 40)}.sm\:w-64{width:calc(var(--spacing) * 64)}.sm\:w-80{width:calc(var(--spacing) * 80)}.sm\:w-\[280px\]{width:280px}.sm\:w-auto{width:auto}.sm\:max-w-3xl{max-width:var(--container-3xl)}.sm\:max-w-48{max-width:calc(var(--spacing) * 48)}.sm\:max-w-56{max-width:calc(var(--spacing) * 56)}.sm\:max-w-\[16rem\]{max-width:16rem}.sm\:max-w-\[75\%\]{max-width:75%}.sm\:max-w-\[280px\]{max-width:280px}.sm\:max-w-lg{max-width:var(--container-lg)}.sm\:max-w-md{max-width:var(--container-md)}.sm\:max-w-none{max-width:none}.sm\:max-w-sm{max-width:var(--container-sm)}.sm\:max-w-xs{max-width:var(--container-xs)}.sm\:min-w-0{min-width:0}.sm\:min-w-12{min-width:calc(var(--spacing) * 12)}.sm\:min-w-80{min-width:calc(var(--spacing) * 80)}.sm\:min-w-\[280px\]{min-width:280px}.sm\:min-w-\[300px\]{min-width:300px}.sm\:min-w-\[400px\]{min-width:400px}.sm\:flex-1{flex:1}.sm\:flex-initial{flex:0 auto}.sm\:flex-none{flex:none}.sm\:shrink-0{flex-shrink:0}.sm\:translate-x-0{--tw-translate-x:0px;translate:var(--tw-translate-x) var(--tw-translate-y)}.sm\:grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.sm\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.sm\:grid-cols-5{grid-template-columns:repeat(5,minmax(0,1fr))}.sm\:grid-cols-\[1fr_10rem\]{grid-template-columns:1fr 10rem}.sm\:grid-cols-\[1fr_auto\]{grid-template-columns:1fr auto}.sm\:grid-cols-\[auto_minmax\(0\,0\.75fr\)_minmax\(0\,1fr\)_minmax\(0\,1\.1fr\)\]{grid-template-columns:auto minmax(0,.75fr) minmax(0,1fr) minmax(0,1.1fr)}.sm\:grid-cols-\[minmax\(0\,1\.15fr\)_auto_minmax\(0\,1\.35fr\)_auto\]{grid-template-columns:minmax(0,1.15fr) auto minmax(0,1.35fr) auto}.sm\:flex-col{flex-direction:column}.sm\:flex-row{flex-direction:row}.sm\:flex-nowrap{flex-wrap:nowrap}.sm\:flex-wrap{flex-wrap:wrap}.sm\:items-baseline{align-items:baseline}.sm\:items-center{align-items:center}.sm\:items-end{align-items:flex-end}.sm\:items-start{align-items:flex-start}.sm\:justify-between{justify-content:space-between}.sm\:justify-center{justify-content:center}.sm\:justify-end{justify-content:flex-end}.sm\:justify-start{justify-content:flex-start}.sm\:gap-0\.5{gap:calc(var(--spacing) * .5)}.sm\:gap-1{gap:var(--spacing)}.sm\:gap-1\.5{gap:calc(var(--spacing) * 1.5)}.sm\:gap-2{gap:calc(var(--spacing) * 2)}.sm\:gap-3{gap:calc(var(--spacing) * 3)}.sm\:gap-4{gap:calc(var(--spacing) * 4)}.sm\:gap-5{gap:calc(var(--spacing) * 5)}.sm\:gap-6{gap:calc(var(--spacing) * 6)}:where(.sm\:space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}:where(.sm\:space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}:where(.sm\:space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}.sm\:gap-x-3{column-gap:calc(var(--spacing) * 3)}:where(.sm\:space-x-2>:not(:last-child)){--tw-space-x-reverse:0;margin-inline-start:calc(calc(var(--spacing) * 2) * var(--tw-space-x-reverse));margin-inline-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-x-reverse)))}.sm\:gap-y-0{row-gap:0}.sm\:gap-y-1{row-gap:var(--spacing)}.sm\:self-auto{align-self:auto}.sm\:rounded-2xl{border-radius:var(--radius-2xl)}.sm\:rounded-3xl{border-radius:var(--radius-3xl)}.sm\:rounded-\[2rem\]{border-radius:2rem}.sm\:rounded-lg{border-radius:var(--radius-lg)}.sm\:rounded-xl{border-radius:var(--radius-xl)}.sm\:border{border-style:var(--tw-border-style);border-width:1px}.sm\:border-0{border-style:var(--tw-border-style);border-width:0}.sm\:bg-black\/2{background-color:#00000005}@supports (color:color-mix(in lab, red, red)){.sm\:bg-black\/2{background-color:color-mix(in oklab, var(--color-black) 2%, transparent)}}.sm\:bg-black\/50{background-color:#00000080}@supports (color:color-mix(in lab, red, red)){.sm\:bg-black\/50{background-color:color-mix(in oklab, var(--color-black) 50%, transparent)}}.sm\:p-1{padding:var(--spacing)}.sm\:p-1\.5{padding:calc(var(--spacing) * 1.5)}.sm\:p-2{padding:calc(var(--spacing) * 2)}.sm\:p-3{padding:calc(var(--spacing) * 3)}.sm\:p-4{padding:calc(var(--spacing) * 4)}.sm\:p-5{padding:calc(var(--spacing) * 5)}.sm\:p-6{padding:calc(var(--spacing) * 6)}.sm\:p-8{padding:calc(var(--spacing) * 8)}.sm\:p-12{padding:calc(var(--spacing) * 12)}.sm\:px-0{padding-inline:0}.sm\:px-1{padding-inline:var(--spacing)}.sm\:px-2{padding-inline:calc(var(--spacing) * 2)}.sm\:px-3{padding-inline:calc(var(--spacing) * 3)}.sm\:px-4{padding-inline:calc(var(--spacing) * 4)}.sm\:px-5{padding-inline:calc(var(--spacing) * 5)}.sm\:px-6{padding-inline:calc(var(--spacing) * 6)}.sm\:py-1{padding-block:var(--spacing)}.sm\:py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.sm\:py-2{padding-block:calc(var(--spacing) * 2)}.sm\:py-2\.5{padding-block:calc(var(--spacing) * 2.5)}.sm\:py-3{padding-block:calc(var(--spacing) * 3)}.sm\:py-4{padding-block:calc(var(--spacing) * 4)}.sm\:py-6{padding-block:calc(var(--spacing) * 6)}.sm\:py-8{padding-block:calc(var(--spacing) * 8)}.sm\:py-10{padding-block:calc(var(--spacing) * 10)}.sm\:py-12{padding-block:calc(var(--spacing) * 12)}.sm\:py-20{padding-block:calc(var(--spacing) * 20)}.sm\:ps-0{padding-inline-start:0}.sm\:pe-3{padding-inline-end:calc(var(--spacing) * 3)}.sm\:pt-0{padding-top:0}.sm\:pt-0\.5{padding-top:calc(var(--spacing) * .5)}.sm\:pt-1{padding-top:var(--spacing)}.sm\:pt-1\.5{padding-top:calc(var(--spacing) * 1.5)}.sm\:pt-2{padding-top:calc(var(--spacing) * 2)}.sm\:pr-1{padding-right:var(--spacing)}.sm\:pr-2{padding-right:calc(var(--spacing) * 2)}.sm\:pr-4{padding-right:calc(var(--spacing) * 4)}.sm\:pr-12{padding-right:calc(var(--spacing) * 12)}.sm\:pr-40{padding-right:calc(var(--spacing) * 40)}.sm\:pb-4{padding-bottom:calc(var(--spacing) * 4)}.sm\:pb-24{padding-bottom:calc(var(--spacing) * 24)}.sm\:pl-0{padding-left:0}.sm\:pl-1{padding-left:var(--spacing)}.sm\:pl-2{padding-left:calc(var(--spacing) * 2)}.sm\:pl-4{padding-left:calc(var(--spacing) * 4)}.sm\:pl-14{padding-left:calc(var(--spacing) * 14)}.sm\:text-left{text-align:left}.sm\:text-right{text-align:right}.sm\:text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.sm\:text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.sm\:text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.sm\:text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.sm\:text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.sm\:text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.sm\:text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.sm\:text-\[10px\]{font-size:10px}.sm\:text-\[11px\]{font-size:11px}.sm\:leading-6{--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6)}.sm\:leading-snug{--tw-leading:var(--leading-snug);line-height:var(--leading-snug)}.sm\:wrap-break-word{overflow-wrap:break-word}.sm\:opacity-100{opacity:1}.sm\:focus\:w-80:focus{width:calc(var(--spacing) * 80)}}@media (min-width:48rem){.md\:static{position:static}.md\:z-auto{z-index:auto}.md\:flex{display:flex}.md\:hidden{display:none}.md\:inline{display:inline}.md\:size-6{width:calc(var(--spacing) * 6);height:calc(var(--spacing) * 6)}.md\:w-72{width:calc(var(--spacing) * 72)}.md\:w-80{width:calc(var(--spacing) * 80)}.md\:w-96{width:calc(var(--spacing) * 96)}.md\:w-auto{width:auto}.md\:max-w-\[9rem\]{max-width:9rem}.md\:max-w-none{max-width:none}.md\:max-w-sm{max-width:var(--container-sm)}.md\:max-w-xs{max-width:var(--container-xs)}.md\:min-w-\[200px\]{min-width:200px}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.md\:rounded-xl{border-radius:var(--radius-xl)}.md\:border{border-style:var(--tw-border-style);border-width:1px}.md\:border-r{border-right-style:var(--tw-border-style);border-right-width:1px}.md\:border-b-0{border-bottom-style:var(--tw-border-style);border-bottom-width:0}.md\:p-4{padding:calc(var(--spacing) * 4)}.md\:p-6{padding:calc(var(--spacing) * 6)}.md\:p-8{padding:calc(var(--spacing) * 8)}.md\:p-10{padding:calc(var(--spacing) * 10)}.md\:px-4{padding-inline:calc(var(--spacing) * 4)}.md\:px-5{padding-inline:calc(var(--spacing) * 5)}.md\:px-6{padding-inline:calc(var(--spacing) * 6)}.md\:px-10{padding-inline:calc(var(--spacing) * 10)}.md\:px-12{padding-inline:calc(var(--spacing) * 12)}.md\:py-5{padding-block:calc(var(--spacing) * 5)}.md\:py-6{padding-block:calc(var(--spacing) * 6)}.md\:py-10{padding-block:calc(var(--spacing) * 10)}.md\:py-12{padding-block:calc(var(--spacing) * 12)}.md\:pr-6{padding-right:calc(var(--spacing) * 6)}.md\:pl-4{padding-left:calc(var(--spacing) * 4)}.md\:text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.md\:text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.md\:text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.md\:text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.md\:text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.md\:leading-relaxed{--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}.md\:opacity-70{opacity:.7}.md\:shadow-none{--tw-shadow:0 0 #0000;box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.md\:grayscale-\[0\.3\]{--tw-grayscale:grayscale(.3);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.md\:backdrop-blur-\[0\.5px\]{--tw-backdrop-blur:blur(.5px);-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}@media not all and (min-width:64rem){.md\:max-lg\:w-64{width:calc(var(--spacing) * 64)}.md\:max-lg\:w-72{width:calc(var(--spacing) * 72)}.md\:max-lg\:max-w-\[70\%\]{max-width:70%}.md\:max-lg\:min-w-64{min-width:calc(var(--spacing) * 64)}.md\:max-lg\:focus\:w-72:focus{width:calc(var(--spacing) * 72)}}}@media (min-width:64rem){.lg\:sticky{position:sticky}.lg\:top-20{top:calc(var(--spacing) * 20)}.lg\:col-span-1{grid-column:span 1/span 1}.lg\:col-span-2{grid-column:span 2/span 2}.lg\:col-span-3{grid-column:span 3/span 3}.lg\:block{display:block}.lg\:flex{display:flex}.lg\:hidden{display:none}.lg\:inline{display:inline}.lg\:inline-flex{display:inline-flex}.lg\:max-h-none{max-height:none}.lg\:w-52{width:calc(var(--spacing) * 52)}.lg\:w-80{width:calc(var(--spacing) * 80)}.lg\:w-auto{width:auto}.lg\:max-w-\[65\%\]{max-width:65%}.lg\:max-w-md{max-width:var(--container-md)}.lg\:max-w-sm{max-width:var(--container-sm)}.lg\:min-w-80{min-width:calc(var(--spacing) * 80)}.lg\:min-w-\[120px\]{min-width:120px}.lg\:flex-none{flex:none}.lg\:shrink-0{flex-shrink:0}.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.lg\:grid-cols-5{grid-template-columns:repeat(5,minmax(0,1fr))}.lg\:flex-col{flex-direction:column}.lg\:flex-row{flex-direction:row}.lg\:items-center{align-items:center}.lg\:items-end{align-items:flex-end}.lg\:items-start{align-items:flex-start}.lg\:justify-between{justify-content:space-between}.lg\:gap-0\.5{gap:calc(var(--spacing) * .5)}.lg\:gap-6{gap:calc(var(--spacing) * 6)}.lg\:gap-8{gap:calc(var(--spacing) * 8)}.lg\:gap-12{gap:calc(var(--spacing) * 12)}:where(.lg\:divide-x>:not(:last-child)){--tw-divide-x-reverse:0;border-inline-style:var(--tw-border-style);border-inline-start-width:calc(1px * var(--tw-divide-x-reverse));border-inline-end-width:calc(1px * calc(1 - var(--tw-divide-x-reverse)))}:where(.lg\:divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)))}.lg\:overflow-x-visible{overflow-x:visible}.lg\:border-t-0{border-top-style:var(--tw-border-style);border-top-width:0}.lg\:border-r{border-right-style:var(--tw-border-style);border-right-width:1px}.lg\:border-b-0{border-bottom-style:var(--tw-border-style);border-bottom-width:0}.lg\:p-16{padding:calc(var(--spacing) * 16)}.lg\:px-8{padding-inline:calc(var(--spacing) * 8)}.lg\:py-8{padding-block:calc(var(--spacing) * 8)}.lg\:pb-0{padding-bottom:0}.lg\:opacity-0{opacity:0}@media (hover:hover){.lg\:group-hover\:opacity-100:is(:where(.group):hover *){opacity:1}}.lg\:focus\:w-80:focus{width:calc(var(--spacing) * 80)}}@media (min-width:80rem){.xl\:sticky{position:sticky}.xl\:top-4{top:calc(var(--spacing) * 4)}.xl\:order-1{order:1}.xl\:order-2{order:2}.xl\:col-span-2{grid-column:span 2/span 2}.xl\:flex{display:flex}.xl\:max-h-\[min\(calc\(100dvh-6rem\)\,920px\)\]{max-height:min(100dvh - 6rem,920px)}.xl\:max-h-\[min\(calc\(100dvh-14rem\)\,44rem\)\]{max-height:min(100dvh - 14rem,44rem)}.xl\:w-96{width:calc(var(--spacing) * 96)}.xl\:max-w-4xl{max-width:var(--container-4xl)}.xl\:max-w-7xl{max-width:var(--container-7xl)}.xl\:max-w-\[min\(100\%\,96rem\)\]{max-width:min(100%,96rem)}.xl\:max-w-lg{max-width:var(--container-lg)}.xl\:max-w-sm{max-width:var(--container-sm)}.xl\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.xl\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.xl\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.xl\:flex-row{flex-direction:row}.xl\:items-start{align-items:flex-start}.xl\:gap-6{gap:calc(var(--spacing) * 6)}.xl\:gap-10{gap:calc(var(--spacing) * 10)}.xl\:self-start{align-self:flex-start}.xl\:overflow-y-auto{overflow-y:auto}.xl\:border-l{border-left-style:var(--tw-border-style);border-left-width:1px}.xl\:p-8{padding:calc(var(--spacing) * 8)}.xl\:pl-8{padding-left:calc(var(--spacing) * 8)}}@media (min-width:96rem){.\32 xl\:max-w-5xl{max-width:var(--container-5xl)}.\32 xl\:max-w-360{max-width:calc(var(--spacing) * 360)}.\32 xl\:max-w-384{max-width:calc(var(--spacing) * 384)}.\32 xl\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.\32 xl\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}:where(.dark\:divide-zinc-700:where(.dark,.dark *)>:not(:last-child)){border-color:var(--color-zinc-700)}:where(.dark\:divide-zinc-800:where(.dark,.dark *)>:not(:last-child)){border-color:var(--color-zinc-800)}:where(.dark\:divide-zinc-800\/50:where(.dark,.dark *)>:not(:last-child)){border-color:#27272a80}@supports (color:color-mix(in lab, red, red)){:where(.dark\:divide-zinc-800\/50:where(.dark,.dark *)>:not(:last-child)){border-color:color-mix(in oklab, var(--color-zinc-800) 50%, transparent)}}:where(.dark\:divide-zinc-800\/60:where(.dark,.dark *)>:not(:last-child)){border-color:#27272a99}@supports (color:color-mix(in lab, red, red)){:where(.dark\:divide-zinc-800\/60:where(.dark,.dark *)>:not(:last-child)){border-color:color-mix(in oklab, var(--color-zinc-800) 60%, transparent)}}.dark\:border-amber-600:where(.dark,.dark *){border-color:var(--color-amber-600)}.dark\:border-amber-700:where(.dark,.dark *){border-color:var(--color-amber-700)}.dark\:border-amber-800:where(.dark,.dark *){border-color:var(--color-amber-800)}.dark\:border-amber-800\/30:where(.dark,.dark *){border-color:#953d004d}@supports (color:color-mix(in lab, red, red)){.dark\:border-amber-800\/30:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-amber-800) 30%, transparent)}}.dark\:border-amber-800\/40:where(.dark,.dark *){border-color:#953d0066}@supports (color:color-mix(in lab, red, red)){.dark\:border-amber-800\/40:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-amber-800) 40%, transparent)}}.dark\:border-amber-800\/50:where(.dark,.dark *){border-color:#953d0080}@supports (color:color-mix(in lab, red, red)){.dark\:border-amber-800\/50:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-amber-800) 50%, transparent)}}.dark\:border-amber-800\/60:where(.dark,.dark *){border-color:#953d0099}@supports (color:color-mix(in lab, red, red)){.dark\:border-amber-800\/60:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-amber-800) 60%, transparent)}}.dark\:border-amber-900:where(.dark,.dark *){border-color:var(--color-amber-900)}.dark\:border-amber-900\/30:where(.dark,.dark *){border-color:#7b33064d}@supports (color:color-mix(in lab, red, red)){.dark\:border-amber-900\/30:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-amber-900) 30%, transparent)}}.dark\:border-amber-900\/40:where(.dark,.dark *){border-color:#7b330666}@supports (color:color-mix(in lab, red, red)){.dark\:border-amber-900\/40:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-amber-900) 40%, transparent)}}.dark\:border-amber-900\/50:where(.dark,.dark *){border-color:#7b330680}@supports (color:color-mix(in lab, red, red)){.dark\:border-amber-900\/50:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-amber-900) 50%, transparent)}}.dark\:border-blue-400:where(.dark,.dark *){border-color:var(--color-blue-400)}.dark\:border-blue-500:where(.dark,.dark *){border-color:var(--color-blue-500)}.dark\:border-blue-700:where(.dark,.dark *){border-color:var(--color-blue-700)}.dark\:border-blue-800:where(.dark,.dark *){border-color:var(--color-blue-800)}.dark\:border-blue-800\/30:where(.dark,.dark *){border-color:#193cb84d}@supports (color:color-mix(in lab, red, red)){.dark\:border-blue-800\/30:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-blue-800) 30%, transparent)}}.dark\:border-blue-900\/20:where(.dark,.dark *){border-color:#1c398e33}@supports (color:color-mix(in lab, red, red)){.dark\:border-blue-900\/20:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-blue-900) 20%, transparent)}}.dark\:border-blue-900\/30:where(.dark,.dark *){border-color:#1c398e4d}@supports (color:color-mix(in lab, red, red)){.dark\:border-blue-900\/30:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-blue-900) 30%, transparent)}}.dark\:border-blue-900\/40:where(.dark,.dark *){border-color:#1c398e66}@supports (color:color-mix(in lab, red, red)){.dark\:border-blue-900\/40:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-blue-900) 40%, transparent)}}.dark\:border-blue-900\/50:where(.dark,.dark *){border-color:#1c398e80}@supports (color:color-mix(in lab, red, red)){.dark\:border-blue-900\/50:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-blue-900) 50%, transparent)}}.dark\:border-emerald-500\/30:where(.dark,.dark *){border-color:#00bb7f4d}@supports (color:color-mix(in lab, red, red)){.dark\:border-emerald-500\/30:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-emerald-500) 30%, transparent)}}.dark\:border-emerald-800\/40:where(.dark,.dark *){border-color:#005f4666}@supports (color:color-mix(in lab, red, red)){.dark\:border-emerald-800\/40:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-emerald-800) 40%, transparent)}}.dark\:border-emerald-900\/20:where(.dark,.dark *){border-color:#004e3b33}@supports (color:color-mix(in lab, red, red)){.dark\:border-emerald-900\/20:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-emerald-900) 20%, transparent)}}.dark\:border-emerald-900\/30:where(.dark,.dark *){border-color:#004e3b4d}@supports (color:color-mix(in lab, red, red)){.dark\:border-emerald-900\/30:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-emerald-900) 30%, transparent)}}.dark\:border-emerald-900\/40:where(.dark,.dark *){border-color:#004e3b66}@supports (color:color-mix(in lab, red, red)){.dark\:border-emerald-900\/40:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-emerald-900) 40%, transparent)}}.dark\:border-gray-600:where(.dark,.dark *){border-color:var(--color-gray-600)}.dark\:border-gray-700:where(.dark,.dark *){border-color:var(--color-gray-700)}.dark\:border-green-800:where(.dark,.dark *){border-color:var(--color-green-800)}.dark\:border-indigo-400:where(.dark,.dark *){border-color:var(--color-indigo-400)}.dark\:border-indigo-500\/30:where(.dark,.dark *){border-color:#625fff4d}@supports (color:color-mix(in lab, red, red)){.dark\:border-indigo-500\/30:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-indigo-500) 30%, transparent)}}.dark\:border-indigo-800:where(.dark,.dark *){border-color:var(--color-indigo-800)}.dark\:border-indigo-900\/30:where(.dark,.dark *){border-color:#312c854d}@supports (color:color-mix(in lab, red, red)){.dark\:border-indigo-900\/30:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-indigo-900) 30%, transparent)}}.dark\:border-orange-900\/30:where(.dark,.dark *){border-color:#7e2a0c4d}@supports (color:color-mix(in lab, red, red)){.dark\:border-orange-900\/30:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-orange-900) 30%, transparent)}}.dark\:border-pink-900\/20:where(.dark,.dark *){border-color:#86104333}@supports (color:color-mix(in lab, red, red)){.dark\:border-pink-900\/20:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-pink-900) 20%, transparent)}}.dark\:border-pink-900\/30:where(.dark,.dark *){border-color:#8610434d}@supports (color:color-mix(in lab, red, red)){.dark\:border-pink-900\/30:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-pink-900) 30%, transparent)}}.dark\:border-red-500\/40:where(.dark,.dark *){border-color:#fb2c3666}@supports (color:color-mix(in lab, red, red)){.dark\:border-red-500\/40:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-red-500) 40%, transparent)}}.dark\:border-red-500\/50:where(.dark,.dark *){border-color:#fb2c3680}@supports (color:color-mix(in lab, red, red)){.dark\:border-red-500\/50:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-red-500) 50%, transparent)}}.dark\:border-red-500\/50\!:where(.dark,.dark *){border-color:#fb2c3680!important}@supports (color:color-mix(in lab, red, red)){.dark\:border-red-500\/50\!:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-red-500) 50%, transparent)!important}}.dark\:border-red-700:where(.dark,.dark *){border-color:var(--color-red-700)}.dark\:border-red-800:where(.dark,.dark *){border-color:var(--color-red-800)}.dark\:border-red-800\/40:where(.dark,.dark *){border-color:#9f071266}@supports (color:color-mix(in lab, red, red)){.dark\:border-red-800\/40:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-red-800) 40%, transparent)}}.dark\:border-red-900\/30:where(.dark,.dark *){border-color:#82181a4d}@supports (color:color-mix(in lab, red, red)){.dark\:border-red-900\/30:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-red-900) 30%, transparent)}}.dark\:border-red-900\/40:where(.dark,.dark *){border-color:#82181a66}@supports (color:color-mix(in lab, red, red)){.dark\:border-red-900\/40:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-red-900) 40%, transparent)}}.dark\:border-red-900\/50:where(.dark,.dark *){border-color:#82181a80}@supports (color:color-mix(in lab, red, red)){.dark\:border-red-900\/50:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-red-900) 50%, transparent)}}.dark\:border-red-900\/70:where(.dark,.dark *){border-color:#82181ab3}@supports (color:color-mix(in lab, red, red)){.dark\:border-red-900\/70:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-red-900) 70%, transparent)}}.dark\:border-sky-700\/45:where(.dark,.dark *){border-color:#0069a473}@supports (color:color-mix(in lab, red, red)){.dark\:border-sky-700\/45:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-sky-700) 45%, transparent)}}.dark\:border-sky-800:where(.dark,.dark *){border-color:var(--color-sky-800)}.dark\:border-sky-800\/50:where(.dark,.dark *){border-color:#00598680}@supports (color:color-mix(in lab, red, red)){.dark\:border-sky-800\/50:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-sky-800) 50%, transparent)}}.dark\:border-sky-800\/55:where(.dark,.dark *){border-color:#0059868c}@supports (color:color-mix(in lab, red, red)){.dark\:border-sky-800\/55:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-sky-800) 55%, transparent)}}.dark\:border-teal-700\!:where(.dark,.dark *){border-color:var(--color-teal-700)!important}.dark\:border-teal-900\/30:where(.dark,.dark *){border-color:#0b4f4a4d}@supports (color:color-mix(in lab, red, red)){.dark\:border-teal-900\/30:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-teal-900) 30%, transparent)}}.dark\:border-violet-800:where(.dark,.dark *){border-color:var(--color-violet-800)}.dark\:border-violet-800\/50:where(.dark,.dark *){border-color:#5d0ec080}@supports (color:color-mix(in lab, red, red)){.dark\:border-violet-800\/50:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-violet-800) 50%, transparent)}}.dark\:border-violet-900\/30:where(.dark,.dark *){border-color:#4d179a4d}@supports (color:color-mix(in lab, red, red)){.dark\:border-violet-900\/30:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-violet-900) 30%, transparent)}}.dark\:border-violet-900\/60:where(.dark,.dark *){border-color:#4d179a99}@supports (color:color-mix(in lab, red, red)){.dark\:border-violet-900\/60:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-violet-900) 60%, transparent)}}.dark\:border-white\/5:where(.dark,.dark *){border-color:#ffffff0d}@supports (color:color-mix(in lab, red, red)){.dark\:border-white\/5:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-white) 5%, transparent)}}.dark\:border-white\/10:where(.dark,.dark *){border-color:#ffffff1a}@supports (color:color-mix(in lab, red, red)){.dark\:border-white\/10:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-white) 10%, transparent)}}.dark\:border-yellow-700:where(.dark,.dark *){border-color:var(--color-yellow-700)}.dark\:border-yellow-800:where(.dark,.dark *){border-color:var(--color-yellow-800)}.dark\:border-zinc-600:where(.dark,.dark *){border-color:var(--color-zinc-600)}.dark\:border-zinc-600\/90:where(.dark,.dark *){border-color:#52525ce6}@supports (color:color-mix(in lab, red, red)){.dark\:border-zinc-600\/90:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-zinc-600) 90%, transparent)}}.dark\:border-zinc-700:where(.dark,.dark *){border-color:var(--color-zinc-700)}.dark\:border-zinc-700\/50:where(.dark,.dark *){border-color:#3f3f4680}@supports (color:color-mix(in lab, red, red)){.dark\:border-zinc-700\/50:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-zinc-700) 50%, transparent)}}.dark\:border-zinc-700\/70:where(.dark,.dark *){border-color:#3f3f46b3}@supports (color:color-mix(in lab, red, red)){.dark\:border-zinc-700\/70:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-zinc-700) 70%, transparent)}}.dark\:border-zinc-700\/80:where(.dark,.dark *){border-color:#3f3f46cc}@supports (color:color-mix(in lab, red, red)){.dark\:border-zinc-700\/80:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-zinc-700) 80%, transparent)}}.dark\:border-zinc-700\/90:where(.dark,.dark *){border-color:#3f3f46e6}@supports (color:color-mix(in lab, red, red)){.dark\:border-zinc-700\/90:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-zinc-700) 90%, transparent)}}.dark\:border-zinc-800:where(.dark,.dark *){border-color:var(--color-zinc-800)}.dark\:border-zinc-800\!:where(.dark,.dark *){border-color:var(--color-zinc-800)!important}.dark\:border-zinc-800\/50:where(.dark,.dark *){border-color:#27272a80}@supports (color:color-mix(in lab, red, red)){.dark\:border-zinc-800\/50:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-zinc-800) 50%, transparent)}}.dark\:border-zinc-800\/60:where(.dark,.dark *){border-color:#27272a99}@supports (color:color-mix(in lab, red, red)){.dark\:border-zinc-800\/60:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-zinc-800) 60%, transparent)}}.dark\:border-zinc-800\/80:where(.dark,.dark *){border-color:#27272acc}@supports (color:color-mix(in lab, red, red)){.dark\:border-zinc-800\/80:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-zinc-800) 80%, transparent)}}.dark\:border-zinc-900:where(.dark,.dark *){border-color:var(--color-zinc-900)}.dark\:border-l-zinc-700:where(.dark,.dark *){border-left-color:var(--color-zinc-700)}.dark\:bg-amber-700:where(.dark,.dark *){background-color:var(--color-amber-700)}.dark\:bg-amber-900\/10:where(.dark,.dark *){background-color:#7b33061a}@supports (color:color-mix(in lab, red, red)){.dark\:bg-amber-900\/10:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-amber-900) 10%, transparent)}}.dark\:bg-amber-900\/15:where(.dark,.dark *){background-color:#7b330626}@supports (color:color-mix(in lab, red, red)){.dark\:bg-amber-900\/15:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-amber-900) 15%, transparent)}}.dark\:bg-amber-900\/20:where(.dark,.dark *){background-color:#7b330633}@supports (color:color-mix(in lab, red, red)){.dark\:bg-amber-900\/20:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-amber-900) 20%, transparent)}}.dark\:bg-amber-900\/30:where(.dark,.dark *){background-color:#7b33064d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-amber-900\/30:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-amber-900) 30%, transparent)}}.dark\:bg-amber-900\/40:where(.dark,.dark *){background-color:#7b330666}@supports (color:color-mix(in lab, red, red)){.dark\:bg-amber-900\/40:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-amber-900) 40%, transparent)}}.dark\:bg-amber-900\/45:where(.dark,.dark *){background-color:#7b330673}@supports (color:color-mix(in lab, red, red)){.dark\:bg-amber-900\/45:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-amber-900) 45%, transparent)}}.dark\:bg-amber-950\/20:where(.dark,.dark *){background-color:#46190133}@supports (color:color-mix(in lab, red, red)){.dark\:bg-amber-950\/20:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-amber-950) 20%, transparent)}}.dark\:bg-amber-950\/25:where(.dark,.dark *){background-color:#46190140}@supports (color:color-mix(in lab, red, red)){.dark\:bg-amber-950\/25:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-amber-950) 25%, transparent)}}.dark\:bg-amber-950\/30:where(.dark,.dark *){background-color:#4619014d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-amber-950\/30:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-amber-950) 30%, transparent)}}.dark\:bg-amber-950\/40:where(.dark,.dark *){background-color:#46190166}@supports (color:color-mix(in lab, red, red)){.dark\:bg-amber-950\/40:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-amber-950) 40%, transparent)}}.dark\:bg-amber-950\/50:where(.dark,.dark *){background-color:#46190180}@supports (color:color-mix(in lab, red, red)){.dark\:bg-amber-950\/50:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-amber-950) 50%, transparent)}}.dark\:bg-black:where(.dark,.dark *){background-color:var(--color-black)}.dark\:bg-black\/20:where(.dark,.dark *){background-color:#0003}@supports (color:color-mix(in lab, red, red)){.dark\:bg-black\/20:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-black) 20%, transparent)}}.dark\:bg-black\/30:where(.dark,.dark *){background-color:#0000004d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-black\/30:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-black) 30%, transparent)}}.dark\:bg-black\/50:where(.dark,.dark *){background-color:#00000080}@supports (color:color-mix(in lab, red, red)){.dark\:bg-black\/50:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-black) 50%, transparent)}}.dark\:bg-black\/55:where(.dark,.dark *){background-color:#0000008c}@supports (color:color-mix(in lab, red, red)){.dark\:bg-black\/55:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-black) 55%, transparent)}}.dark\:bg-black\/60:where(.dark,.dark *){background-color:#0009}@supports (color:color-mix(in lab, red, red)){.dark\:bg-black\/60:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-black) 60%, transparent)}}.dark\:bg-black\/70:where(.dark,.dark *){background-color:#000000b3}@supports (color:color-mix(in lab, red, red)){.dark\:bg-black\/70:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-black) 70%, transparent)}}.dark\:bg-black\/90:where(.dark,.dark *){background-color:#000000e6}@supports (color:color-mix(in lab, red, red)){.dark\:bg-black\/90:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-black) 90%, transparent)}}.dark\:bg-blue-400:where(.dark,.dark *){background-color:var(--color-blue-400)}.dark\:bg-blue-400\/8:where(.dark,.dark *){background-color:#54a2ff14}@supports (color:color-mix(in lab, red, red)){.dark\:bg-blue-400\/8:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-blue-400) 8%, transparent)}}.dark\:bg-blue-500:where(.dark,.dark *){background-color:var(--color-blue-500)}.dark\:bg-blue-500\/10:where(.dark,.dark *){background-color:#3080ff1a}@supports (color:color-mix(in lab, red, red)){.dark\:bg-blue-500\/10:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-blue-500) 10%, transparent)}}.dark\:bg-blue-500\/20:where(.dark,.dark *){background-color:#3080ff33}@supports (color:color-mix(in lab, red, red)){.dark\:bg-blue-500\/20:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-blue-500) 20%, transparent)}}.dark\:bg-blue-600:where(.dark,.dark *){background-color:var(--color-blue-600)}.dark\:bg-blue-700:where(.dark,.dark *){background-color:var(--color-blue-700)}.dark\:bg-blue-900\/5:where(.dark,.dark *){background-color:#1c398e0d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-blue-900\/5:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-blue-900) 5%, transparent)}}.dark\:bg-blue-900\/10:where(.dark,.dark *){background-color:#1c398e1a}@supports (color:color-mix(in lab, red, red)){.dark\:bg-blue-900\/10:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-blue-900) 10%, transparent)}}.dark\:bg-blue-900\/20:where(.dark,.dark *){background-color:#1c398e33}@supports (color:color-mix(in lab, red, red)){.dark\:bg-blue-900\/20:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-blue-900) 20%, transparent)}}.dark\:bg-blue-900\/30:where(.dark,.dark *){background-color:#1c398e4d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-blue-900\/30:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-blue-900) 30%, transparent)}}.dark\:bg-blue-900\/40:where(.dark,.dark *){background-color:#1c398e66}@supports (color:color-mix(in lab, red, red)){.dark\:bg-blue-900\/40:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-blue-900) 40%, transparent)}}.dark\:bg-blue-900\/50:where(.dark,.dark *){background-color:#1c398e80}@supports (color:color-mix(in lab, red, red)){.dark\:bg-blue-900\/50:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-blue-900) 50%, transparent)}}.dark\:bg-blue-950\/20:where(.dark,.dark *){background-color:#16245633}@supports (color:color-mix(in lab, red, red)){.dark\:bg-blue-950\/20:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-blue-950) 20%, transparent)}}.dark\:bg-blue-950\/30:where(.dark,.dark *){background-color:#1624564d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-blue-950\/30:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-blue-950) 30%, transparent)}}.dark\:bg-blue-950\/40:where(.dark,.dark *){background-color:#16245666}@supports (color:color-mix(in lab, red, red)){.dark\:bg-blue-950\/40:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-blue-950) 40%, transparent)}}.dark\:bg-blue-950\/60:where(.dark,.dark *){background-color:#16245699}@supports (color:color-mix(in lab, red, red)){.dark\:bg-blue-950\/60:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-blue-950) 60%, transparent)}}.dark\:bg-cyan-900\/30:where(.dark,.dark *){background-color:#104e644d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-cyan-900\/30:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-cyan-900) 30%, transparent)}}.dark\:bg-cyan-950\/40:where(.dark,.dark *){background-color:#05334566}@supports (color:color-mix(in lab, red, red)){.dark\:bg-cyan-950\/40:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-cyan-950) 40%, transparent)}}.dark\:bg-emerald-500\/10:where(.dark,.dark *){background-color:#00bb7f1a}@supports (color:color-mix(in lab, red, red)){.dark\:bg-emerald-500\/10:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-emerald-500) 10%, transparent)}}.dark\:bg-emerald-900\/5:where(.dark,.dark *){background-color:#004e3b0d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-emerald-900\/5:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-emerald-900) 5%, transparent)}}.dark\:bg-emerald-900\/10:where(.dark,.dark *){background-color:#004e3b1a}@supports (color:color-mix(in lab, red, red)){.dark\:bg-emerald-900\/10:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-emerald-900) 10%, transparent)}}.dark\:bg-emerald-900\/20:where(.dark,.dark *){background-color:#004e3b33}@supports (color:color-mix(in lab, red, red)){.dark\:bg-emerald-900\/20:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-emerald-900) 20%, transparent)}}.dark\:bg-emerald-900\/30:where(.dark,.dark *){background-color:#004e3b4d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-emerald-900\/30:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-emerald-900) 30%, transparent)}}.dark\:bg-emerald-900\/40:where(.dark,.dark *){background-color:#004e3b66}@supports (color:color-mix(in lab, red, red)){.dark\:bg-emerald-900\/40:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-emerald-900) 40%, transparent)}}.dark\:bg-emerald-900\/45:where(.dark,.dark *){background-color:#004e3b73}@supports (color:color-mix(in lab, red, red)){.dark\:bg-emerald-900\/45:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-emerald-900) 45%, transparent)}}.dark\:bg-emerald-900\/50:where(.dark,.dark *){background-color:#004e3b80}@supports (color:color-mix(in lab, red, red)){.dark\:bg-emerald-900\/50:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-emerald-900) 50%, transparent)}}.dark\:bg-emerald-950\/20:where(.dark,.dark *){background-color:#002c2233}@supports (color:color-mix(in lab, red, red)){.dark\:bg-emerald-950\/20:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-emerald-950) 20%, transparent)}}.dark\:bg-gray-500:where(.dark,.dark *){background-color:var(--color-gray-500)}.dark\:bg-gray-800:where(.dark,.dark *){background-color:var(--color-gray-800)}.dark\:bg-gray-900:where(.dark,.dark *){background-color:var(--color-gray-900)}.dark\:bg-gray-900\/40:where(.dark,.dark *){background-color:#10182866}@supports (color:color-mix(in lab, red, red)){.dark\:bg-gray-900\/40:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-gray-900) 40%, transparent)}}.dark\:bg-green-900\/15:where(.dark,.dark *){background-color:#0d542b26}@supports (color:color-mix(in lab, red, red)){.dark\:bg-green-900\/15:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-green-900) 15%, transparent)}}.dark\:bg-green-900\/20:where(.dark,.dark *){background-color:#0d542b33}@supports (color:color-mix(in lab, red, red)){.dark\:bg-green-900\/20:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-green-900) 20%, transparent)}}.dark\:bg-green-900\/30:where(.dark,.dark *){background-color:#0d542b4d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-green-900\/30:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-green-900) 30%, transparent)}}.dark\:bg-green-900\/40:where(.dark,.dark *){background-color:#0d542b66}@supports (color:color-mix(in lab, red, red)){.dark\:bg-green-900\/40:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-green-900) 40%, transparent)}}.dark\:bg-green-900\/45:where(.dark,.dark *){background-color:#0d542b73}@supports (color:color-mix(in lab, red, red)){.dark\:bg-green-900\/45:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-green-900) 45%, transparent)}}.dark\:bg-indigo-500\/10:where(.dark,.dark *){background-color:#625fff1a}@supports (color:color-mix(in lab, red, red)){.dark\:bg-indigo-500\/10:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-indigo-500) 10%, transparent)}}.dark\:bg-indigo-900\/10:where(.dark,.dark *){background-color:#312c851a}@supports (color:color-mix(in lab, red, red)){.dark\:bg-indigo-900\/10:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-indigo-900) 10%, transparent)}}.dark\:bg-indigo-900\/20:where(.dark,.dark *){background-color:#312c8533}@supports (color:color-mix(in lab, red, red)){.dark\:bg-indigo-900\/20:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-indigo-900) 20%, transparent)}}.dark\:bg-indigo-900\/30:where(.dark,.dark *){background-color:#312c854d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-indigo-900\/30:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-indigo-900) 30%, transparent)}}.dark\:bg-indigo-900\/35:where(.dark,.dark *){background-color:#312c8559}@supports (color:color-mix(in lab, red, red)){.dark\:bg-indigo-900\/35:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-indigo-900) 35%, transparent)}}.dark\:bg-indigo-950\/25:where(.dark,.dark *){background-color:#1e1a4d40}@supports (color:color-mix(in lab, red, red)){.dark\:bg-indigo-950\/25:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-indigo-950) 25%, transparent)}}.dark\:bg-indigo-950\/40:where(.dark,.dark *){background-color:#1e1a4d66}@supports (color:color-mix(in lab, red, red)){.dark\:bg-indigo-950\/40:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-indigo-950) 40%, transparent)}}.dark\:bg-orange-900\/10:where(.dark,.dark *){background-color:#7e2a0c1a}@supports (color:color-mix(in lab, red, red)){.dark\:bg-orange-900\/10:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-orange-900) 10%, transparent)}}.dark\:bg-orange-900\/30:where(.dark,.dark *){background-color:#7e2a0c4d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-orange-900\/30:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-orange-900) 30%, transparent)}}.dark\:bg-orange-900\/50:where(.dark,.dark *){background-color:#7e2a0c80}@supports (color:color-mix(in lab, red, red)){.dark\:bg-orange-900\/50:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-orange-900) 50%, transparent)}}.dark\:bg-pink-900\/10:where(.dark,.dark *){background-color:#8610431a}@supports (color:color-mix(in lab, red, red)){.dark\:bg-pink-900\/10:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-pink-900) 10%, transparent)}}.dark\:bg-purple-900\/30:where(.dark,.dark *){background-color:#59168b4d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-purple-900\/30:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-purple-900) 30%, transparent)}}.dark\:bg-red-400:where(.dark,.dark *){background-color:var(--color-red-400)}.dark\:bg-red-700:where(.dark,.dark *){background-color:var(--color-red-700)}.dark\:bg-red-900:where(.dark,.dark *){background-color:var(--color-red-900)}.dark\:bg-red-900\/10:where(.dark,.dark *){background-color:#82181a1a}@supports (color:color-mix(in lab, red, red)){.dark\:bg-red-900\/10:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-red-900) 10%, transparent)}}.dark\:bg-red-900\/20:where(.dark,.dark *){background-color:#82181a33}@supports (color:color-mix(in lab, red, red)){.dark\:bg-red-900\/20:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-red-900) 20%, transparent)}}.dark\:bg-red-900\/30:where(.dark,.dark *){background-color:#82181a4d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-red-900\/30:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-red-900) 30%, transparent)}}.dark\:bg-red-900\/40:where(.dark,.dark *){background-color:#82181a66}@supports (color:color-mix(in lab, red, red)){.dark\:bg-red-900\/40:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-red-900) 40%, transparent)}}.dark\:bg-red-900\/45:where(.dark,.dark *){background-color:#82181a73}@supports (color:color-mix(in lab, red, red)){.dark\:bg-red-900\/45:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-red-900) 45%, transparent)}}.dark\:bg-red-950\/20:where(.dark,.dark *){background-color:#46080933}@supports (color:color-mix(in lab, red, red)){.dark\:bg-red-950\/20:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-red-950) 20%, transparent)}}.dark\:bg-red-950\/30:where(.dark,.dark *){background-color:#4608094d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-red-950\/30:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-red-950) 30%, transparent)}}.dark\:bg-red-950\/40:where(.dark,.dark *){background-color:#46080966}@supports (color:color-mix(in lab, red, red)){.dark\:bg-red-950\/40:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-red-950) 40%, transparent)}}.dark\:bg-red-950\/50:where(.dark,.dark *){background-color:#46080980}@supports (color:color-mix(in lab, red, red)){.dark\:bg-red-950\/50:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-red-950) 50%, transparent)}}.dark\:bg-rose-900\/30:where(.dark,.dark *){background-color:#8b08364d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-rose-900\/30:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-rose-900) 30%, transparent)}}.dark\:bg-sky-900\/30:where(.dark,.dark *){background-color:#024a704d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-sky-900\/30:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-sky-900) 30%, transparent)}}.dark\:bg-sky-950\/30:where(.dark,.dark *){background-color:#052f4a4d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-sky-950\/30:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-sky-950) 30%, transparent)}}.dark\:bg-sky-950\/35:where(.dark,.dark *){background-color:#052f4a59}@supports (color:color-mix(in lab, red, red)){.dark\:bg-sky-950\/35:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-sky-950) 35%, transparent)}}.dark\:bg-sky-950\/45:where(.dark,.dark *){background-color:#052f4a73}@supports (color:color-mix(in lab, red, red)){.dark\:bg-sky-950\/45:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-sky-950) 45%, transparent)}}.dark\:bg-teal-900\/10:where(.dark,.dark *){background-color:#0b4f4a1a}@supports (color:color-mix(in lab, red, red)){.dark\:bg-teal-900\/10:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-teal-900) 10%, transparent)}}.dark\:bg-teal-900\/30:where(.dark,.dark *){background-color:#0b4f4a4d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-teal-900\/30:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-teal-900) 30%, transparent)}}.dark\:bg-transparent:where(.dark,.dark *){background-color:#0000}.dark\:bg-violet-900\/10:where(.dark,.dark *){background-color:#4d179a1a}@supports (color:color-mix(in lab, red, red)){.dark\:bg-violet-900\/10:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-violet-900) 10%, transparent)}}.dark\:bg-violet-900\/30:where(.dark,.dark *){background-color:#4d179a4d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-violet-900\/30:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-violet-900) 30%, transparent)}}.dark\:bg-violet-900\/40:where(.dark,.dark *){background-color:#4d179a66}@supports (color:color-mix(in lab, red, red)){.dark\:bg-violet-900\/40:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-violet-900) 40%, transparent)}}.dark\:bg-violet-950\/30:where(.dark,.dark *){background-color:#2f0d684d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-violet-950\/30:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-violet-950) 30%, transparent)}}.dark\:bg-white:where(.dark,.dark *){background-color:var(--color-white)}.dark\:bg-white\/5:where(.dark,.dark *){background-color:#ffffff0d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-white\/5:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-white) 5%, transparent)}}.dark\:bg-white\/10:where(.dark,.dark *){background-color:#ffffff1a}@supports (color:color-mix(in lab, red, red)){.dark\:bg-white\/10:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-white) 10%, transparent)}}.dark\:bg-white\/\[0\.05\]:where(.dark,.dark *){background-color:#ffffff0d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-white\/\[0\.05\]:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-white) 5%, transparent)}}.dark\:bg-yellow-900\/20:where(.dark,.dark *){background-color:#733e0a33}@supports (color:color-mix(in lab, red, red)){.dark\:bg-yellow-900\/20:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-yellow-900) 20%, transparent)}}.dark\:bg-yellow-900\/30:where(.dark,.dark *){background-color:#733e0a4d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-yellow-900\/30:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-yellow-900) 30%, transparent)}}.dark\:bg-zinc-100:where(.dark,.dark *){background-color:var(--color-zinc-100)}.dark\:bg-zinc-600\/70:where(.dark,.dark *){background-color:#52525cb3}@supports (color:color-mix(in lab, red, red)){.dark\:bg-zinc-600\/70:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-zinc-600) 70%, transparent)}}.dark\:bg-zinc-700:where(.dark,.dark *){background-color:var(--color-zinc-700)}.dark\:bg-zinc-700\/50:where(.dark,.dark *){background-color:#3f3f4680}@supports (color:color-mix(in lab, red, red)){.dark\:bg-zinc-700\/50:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-zinc-700) 50%, transparent)}}.dark\:bg-zinc-700\/90:where(.dark,.dark *){background-color:#3f3f46e6}@supports (color:color-mix(in lab, red, red)){.dark\:bg-zinc-700\/90:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-zinc-700) 90%, transparent)}}.dark\:bg-zinc-800:where(.dark,.dark *){background-color:var(--color-zinc-800)}.dark\:bg-zinc-800\/10:where(.dark,.dark *){background-color:#27272a1a}@supports (color:color-mix(in lab, red, red)){.dark\:bg-zinc-800\/10:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-zinc-800) 10%, transparent)}}.dark\:bg-zinc-800\/30:where(.dark,.dark *){background-color:#27272a4d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-zinc-800\/30:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-zinc-800) 30%, transparent)}}.dark\:bg-zinc-800\/40:where(.dark,.dark *){background-color:#27272a66}@supports (color:color-mix(in lab, red, red)){.dark\:bg-zinc-800\/40:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-zinc-800) 40%, transparent)}}.dark\:bg-zinc-800\/50:where(.dark,.dark *){background-color:#27272a80}@supports (color:color-mix(in lab, red, red)){.dark\:bg-zinc-800\/50:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-zinc-800) 50%, transparent)}}.dark\:bg-zinc-800\/60:where(.dark,.dark *){background-color:#27272a99}@supports (color:color-mix(in lab, red, red)){.dark\:bg-zinc-800\/60:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-zinc-800) 60%, transparent)}}.dark\:bg-zinc-800\/70:where(.dark,.dark *){background-color:#27272ab3}@supports (color:color-mix(in lab, red, red)){.dark\:bg-zinc-800\/70:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-zinc-800) 70%, transparent)}}.dark\:bg-zinc-800\/80:where(.dark,.dark *){background-color:#27272acc}@supports (color:color-mix(in lab, red, red)){.dark\:bg-zinc-800\/80:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-zinc-800) 80%, transparent)}}.dark\:bg-zinc-800\/90:where(.dark,.dark *){background-color:#27272ae6}@supports (color:color-mix(in lab, red, red)){.dark\:bg-zinc-800\/90:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-zinc-800) 90%, transparent)}}.dark\:bg-zinc-900:where(.dark,.dark *){background-color:var(--color-zinc-900)}.dark\:bg-zinc-900\/10:where(.dark,.dark *){background-color:#18181b1a}@supports (color:color-mix(in lab, red, red)){.dark\:bg-zinc-900\/10:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-zinc-900) 10%, transparent)}}.dark\:bg-zinc-900\/20:where(.dark,.dark *){background-color:#18181b33}@supports (color:color-mix(in lab, red, red)){.dark\:bg-zinc-900\/20:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-zinc-900) 20%, transparent)}}.dark\:bg-zinc-900\/25:where(.dark,.dark *){background-color:#18181b40}@supports (color:color-mix(in lab, red, red)){.dark\:bg-zinc-900\/25:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-zinc-900) 25%, transparent)}}.dark\:bg-zinc-900\/30:where(.dark,.dark *){background-color:#18181b4d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-zinc-900\/30:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-zinc-900) 30%, transparent)}}.dark\:bg-zinc-900\/40:where(.dark,.dark *){background-color:#18181b66}@supports (color:color-mix(in lab, red, red)){.dark\:bg-zinc-900\/40:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-zinc-900) 40%, transparent)}}.dark\:bg-zinc-900\/50:where(.dark,.dark *){background-color:#18181b80}@supports (color:color-mix(in lab, red, red)){.dark\:bg-zinc-900\/50:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-zinc-900) 50%, transparent)}}.dark\:bg-zinc-900\/60:where(.dark,.dark *){background-color:#18181b99}@supports (color:color-mix(in lab, red, red)){.dark\:bg-zinc-900\/60:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-zinc-900) 60%, transparent)}}.dark\:bg-zinc-900\/70:where(.dark,.dark *){background-color:#18181bb3}@supports (color:color-mix(in lab, red, red)){.dark\:bg-zinc-900\/70:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-zinc-900) 70%, transparent)}}.dark\:bg-zinc-900\/80:where(.dark,.dark *){background-color:#18181bcc}@supports (color:color-mix(in lab, red, red)){.dark\:bg-zinc-900\/80:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-zinc-900) 80%, transparent)}}.dark\:bg-zinc-900\/85:where(.dark,.dark *){background-color:#18181bd9}@supports (color:color-mix(in lab, red, red)){.dark\:bg-zinc-900\/85:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-zinc-900) 85%, transparent)}}.dark\:bg-zinc-900\/90:where(.dark,.dark *){background-color:#18181be6}@supports (color:color-mix(in lab, red, red)){.dark\:bg-zinc-900\/90:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-zinc-900) 90%, transparent)}}.dark\:bg-zinc-900\/95:where(.dark,.dark *){background-color:#18181bf2}@supports (color:color-mix(in lab, red, red)){.dark\:bg-zinc-900\/95:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-zinc-900) 95%, transparent)}}.dark\:bg-zinc-950:where(.dark,.dark *){background-color:var(--color-zinc-950)}.dark\:bg-zinc-950\!:where(.dark,.dark *){background-color:var(--color-zinc-950)!important}.dark\:bg-zinc-950\/40:where(.dark,.dark *){background-color:#09090b66}@supports (color:color-mix(in lab, red, red)){.dark\:bg-zinc-950\/40:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-zinc-950) 40%, transparent)}}.dark\:bg-zinc-950\/50:where(.dark,.dark *){background-color:#09090b80}@supports (color:color-mix(in lab, red, red)){.dark\:bg-zinc-950\/50:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-zinc-950) 50%, transparent)}}.dark\:bg-zinc-950\/80:where(.dark,.dark *){background-color:#09090bcc}@supports (color:color-mix(in lab, red, red)){.dark\:bg-zinc-950\/80:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-zinc-950) 80%, transparent)}}.dark\:bg-zinc-950\/95:where(.dark,.dark *){background-color:#09090bf2}@supports (color:color-mix(in lab, red, red)){.dark\:bg-zinc-950\/95:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-zinc-950) 95%, transparent)}}.dark\:from-zinc-950:where(.dark,.dark *){--tw-gradient-from:var(--color-zinc-950);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.dark\:via-zinc-800:where(.dark,.dark *){--tw-gradient-via:var(--color-zinc-800);--tw-gradient-via-stops:var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-via) var(--tw-gradient-via-position), var(--tw-gradient-to) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-via-stops)}.dark\:to-zinc-900:where(.dark,.dark *){--tw-gradient-to:var(--color-zinc-900);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.dark\:fill-zinc-900:where(.dark,.dark *){fill:var(--color-zinc-900)}.dark\:stroke-zinc-900:where(.dark,.dark *){stroke:var(--color-zinc-900)}.dark\:text-amber-100:where(.dark,.dark *){color:var(--color-amber-100)}.dark\:text-amber-100\/80:where(.dark,.dark *){color:#fef3c6cc}@supports (color:color-mix(in lab, red, red)){.dark\:text-amber-100\/80:where(.dark,.dark *){color:color-mix(in oklab, var(--color-amber-100) 80%, transparent)}}.dark\:text-amber-100\/90:where(.dark,.dark *){color:#fef3c6e6}@supports (color:color-mix(in lab, red, red)){.dark\:text-amber-100\/90:where(.dark,.dark *){color:color-mix(in oklab, var(--color-amber-100) 90%, transparent)}}.dark\:text-amber-200:where(.dark,.dark *){color:var(--color-amber-200)}.dark\:text-amber-200\/70:where(.dark,.dark *){color:#fee685b3}@supports (color:color-mix(in lab, red, red)){.dark\:text-amber-200\/70:where(.dark,.dark *){color:color-mix(in oklab, var(--color-amber-200) 70%, transparent)}}.dark\:text-amber-200\/80:where(.dark,.dark *){color:#fee685cc}@supports (color:color-mix(in lab, red, red)){.dark\:text-amber-200\/80:where(.dark,.dark *){color:color-mix(in oklab, var(--color-amber-200) 80%, transparent)}}.dark\:text-amber-200\/90:where(.dark,.dark *){color:#fee685e6}@supports (color:color-mix(in lab, red, red)){.dark\:text-amber-200\/90:where(.dark,.dark *){color:color-mix(in oklab, var(--color-amber-200) 90%, transparent)}}.dark\:text-amber-300:where(.dark,.dark *){color:var(--color-amber-300)}.dark\:text-amber-300\/90:where(.dark,.dark *){color:#ffd236e6}@supports (color:color-mix(in lab, red, red)){.dark\:text-amber-300\/90:where(.dark,.dark *){color:color-mix(in oklab, var(--color-amber-300) 90%, transparent)}}.dark\:text-amber-400:where(.dark,.dark *){color:var(--color-amber-400)}.dark\:text-amber-500:where(.dark,.dark *){color:var(--color-amber-500)}.dark\:text-blue-100:where(.dark,.dark *){color:var(--color-blue-100)}.dark\:text-blue-100\/90:where(.dark,.dark *){color:#dbeafee6}@supports (color:color-mix(in lab, red, red)){.dark\:text-blue-100\/90:where(.dark,.dark *){color:color-mix(in oklab, var(--color-blue-100) 90%, transparent)}}.dark\:text-blue-200:where(.dark,.dark *){color:var(--color-blue-200)}.dark\:text-blue-300:where(.dark,.dark *){color:var(--color-blue-300)}.dark\:text-blue-300\/80:where(.dark,.dark *){color:#90c5ffcc}@supports (color:color-mix(in lab, red, red)){.dark\:text-blue-300\/80:where(.dark,.dark *){color:color-mix(in oklab, var(--color-blue-300) 80%, transparent)}}.dark\:text-blue-400:where(.dark,.dark *){color:var(--color-blue-400)}.dark\:text-blue-400\/80:where(.dark,.dark *){color:#54a2ffcc}@supports (color:color-mix(in lab, red, red)){.dark\:text-blue-400\/80:where(.dark,.dark *){color:color-mix(in oklab, var(--color-blue-400) 80%, transparent)}}.dark\:text-cyan-200:where(.dark,.dark *){color:var(--color-cyan-200)}.dark\:text-cyan-300:where(.dark,.dark *){color:var(--color-cyan-300)}.dark\:text-cyan-400:where(.dark,.dark *){color:var(--color-cyan-400)}.dark\:text-emerald-100:where(.dark,.dark *){color:var(--color-emerald-100)}.dark\:text-emerald-200:where(.dark,.dark *){color:var(--color-emerald-200)}.dark\:text-emerald-300:where(.dark,.dark *){color:var(--color-emerald-300)}.dark\:text-emerald-300\/80:where(.dark,.dark *){color:#5ee9b5cc}@supports (color:color-mix(in lab, red, red)){.dark\:text-emerald-300\/80:where(.dark,.dark *){color:color-mix(in oklab, var(--color-emerald-300) 80%, transparent)}}.dark\:text-emerald-400:where(.dark,.dark *){color:var(--color-emerald-400)}.dark\:text-emerald-500:where(.dark,.dark *){color:var(--color-emerald-500)}.dark\:text-gray-100:where(.dark,.dark *){color:var(--color-gray-100)}.dark\:text-gray-200:where(.dark,.dark *){color:var(--color-gray-200)}.dark\:text-gray-300:where(.dark,.dark *){color:var(--color-gray-300)}.dark\:text-gray-400:where(.dark,.dark *){color:var(--color-gray-400)}.dark\:text-gray-500:where(.dark,.dark *){color:var(--color-gray-500)}.dark\:text-gray-600:where(.dark,.dark *){color:var(--color-gray-600)}.dark\:text-green-100:where(.dark,.dark *){color:var(--color-green-100)}.dark\:text-green-200:where(.dark,.dark *){color:var(--color-green-200)}.dark\:text-green-300:where(.dark,.dark *){color:var(--color-green-300)}.dark\:text-green-400:where(.dark,.dark *){color:var(--color-green-400)}.dark\:text-indigo-100:where(.dark,.dark *){color:var(--color-indigo-100)}.dark\:text-indigo-200:where(.dark,.dark *){color:var(--color-indigo-200)}.dark\:text-indigo-200\/90:where(.dark,.dark *){color:#c7d2ffe6}@supports (color:color-mix(in lab, red, red)){.dark\:text-indigo-200\/90:where(.dark,.dark *){color:color-mix(in oklab, var(--color-indigo-200) 90%, transparent)}}.dark\:text-indigo-300:where(.dark,.dark *){color:var(--color-indigo-300)}.dark\:text-indigo-300\/90:where(.dark,.dark *){color:#a4b3ffe6}@supports (color:color-mix(in lab, red, red)){.dark\:text-indigo-300\/90:where(.dark,.dark *){color:color-mix(in oklab, var(--color-indigo-300) 90%, transparent)}}.dark\:text-indigo-400:where(.dark,.dark *){color:var(--color-indigo-400)}.dark\:text-orange-200:where(.dark,.dark *){color:var(--color-orange-200)}.dark\:text-orange-300:where(.dark,.dark *){color:var(--color-orange-300)}.dark\:text-orange-400:where(.dark,.dark *){color:var(--color-orange-400)}.dark\:text-pink-300:where(.dark,.dark *){color:var(--color-pink-300)}.dark\:text-pink-400:where(.dark,.dark *){color:var(--color-pink-400)}.dark\:text-purple-200:where(.dark,.dark *){color:var(--color-purple-200)}.dark\:text-purple-400:where(.dark,.dark *){color:var(--color-purple-400)}.dark\:text-red-100:where(.dark,.dark *){color:var(--color-red-100)}.dark\:text-red-100\/90:where(.dark,.dark *){color:#ffe2e2e6}@supports (color:color-mix(in lab, red, red)){.dark\:text-red-100\/90:where(.dark,.dark *){color:color-mix(in oklab, var(--color-red-100) 90%, transparent)}}.dark\:text-red-200:where(.dark,.dark *){color:var(--color-red-200)}.dark\:text-red-300:where(.dark,.dark *){color:var(--color-red-300)}.dark\:text-red-300\!:where(.dark,.dark *){color:var(--color-red-300)!important}.dark\:text-red-400:where(.dark,.dark *){color:var(--color-red-400)}.dark\:text-rose-200:where(.dark,.dark *){color:var(--color-rose-200)}.dark\:text-rose-400:where(.dark,.dark *){color:var(--color-rose-400)}.dark\:text-sky-50:where(.dark,.dark *){color:var(--color-sky-50)}.dark\:text-sky-100:where(.dark,.dark *){color:var(--color-sky-100)}.dark\:text-sky-200:where(.dark,.dark *){color:var(--color-sky-200)}.dark\:text-sky-200\/75:where(.dark,.dark *){color:#b8e6febf}@supports (color:color-mix(in lab, red, red)){.dark\:text-sky-200\/75:where(.dark,.dark *){color:color-mix(in oklab, var(--color-sky-200) 75%, transparent)}}.dark\:text-sky-200\/85:where(.dark,.dark *){color:#b8e6fed9}@supports (color:color-mix(in lab, red, red)){.dark\:text-sky-200\/85:where(.dark,.dark *){color:color-mix(in oklab, var(--color-sky-200) 85%, transparent)}}.dark\:text-sky-200\/90:where(.dark,.dark *){color:#b8e6fee6}@supports (color:color-mix(in lab, red, red)){.dark\:text-sky-200\/90:where(.dark,.dark *){color:color-mix(in oklab, var(--color-sky-200) 90%, transparent)}}.dark\:text-sky-300:where(.dark,.dark *){color:var(--color-sky-300)}.dark\:text-sky-400:where(.dark,.dark *){color:var(--color-sky-400)}.dark\:text-sky-400\/80:where(.dark,.dark *){color:#00bcfecc}@supports (color:color-mix(in lab, red, red)){.dark\:text-sky-400\/80:where(.dark,.dark *){color:color-mix(in oklab, var(--color-sky-400) 80%, transparent)}}.dark\:text-teal-200:where(.dark,.dark *){color:var(--color-teal-200)}.dark\:text-teal-300:where(.dark,.dark *){color:var(--color-teal-300)}.dark\:text-teal-300\!:where(.dark,.dark *){color:var(--color-teal-300)!important}.dark\:text-teal-400:where(.dark,.dark *){color:var(--color-teal-400)}.dark\:text-violet-100:where(.dark,.dark *){color:var(--color-violet-100)}.dark\:text-violet-200:where(.dark,.dark *){color:var(--color-violet-200)}.dark\:text-violet-200\/90:where(.dark,.dark *){color:#ddd6ffe6}@supports (color:color-mix(in lab, red, red)){.dark\:text-violet-200\/90:where(.dark,.dark *){color:color-mix(in oklab, var(--color-violet-200) 90%, transparent)}}.dark\:text-violet-300:where(.dark,.dark *){color:var(--color-violet-300)}.dark\:text-violet-400:where(.dark,.dark *){color:var(--color-violet-400)}.dark\:text-white:where(.dark,.dark *){color:var(--color-white)}.dark\:text-yellow-100:where(.dark,.dark *){color:var(--color-yellow-100)}.dark\:text-yellow-200:where(.dark,.dark *){color:var(--color-yellow-200)}.dark\:text-yellow-300:where(.dark,.dark *){color:var(--color-yellow-300)}.dark\:text-yellow-400:where(.dark,.dark *){color:var(--color-yellow-400)}.dark\:text-zinc-100:where(.dark,.dark *){color:var(--color-zinc-100)}.dark\:text-zinc-200:where(.dark,.dark *){color:var(--color-zinc-200)}.dark\:text-zinc-300:where(.dark,.dark *){color:var(--color-zinc-300)}.dark\:text-zinc-400:where(.dark,.dark *){color:var(--color-zinc-400)}.dark\:text-zinc-500:where(.dark,.dark *){color:var(--color-zinc-500)}.dark\:text-zinc-600:where(.dark,.dark *){color:var(--color-zinc-600)}.dark\:text-zinc-700:where(.dark,.dark *){color:var(--color-zinc-700)}.dark\:text-zinc-700\/90:where(.dark,.dark *){color:#3f3f46e6}@supports (color:color-mix(in lab, red, red)){.dark\:text-zinc-700\/90:where(.dark,.dark *){color:color-mix(in oklab, var(--color-zinc-700) 90%, transparent)}}.dark\:text-zinc-800:where(.dark,.dark *){color:var(--color-zinc-800)}.dark\:text-zinc-900:where(.dark,.dark *){color:var(--color-zinc-900)}.dark\:placeholder-zinc-500:where(.dark,.dark *)::placeholder{color:var(--color-zinc-500)}.dark\:accent-blue-500:where(.dark,.dark *){accent-color:var(--color-blue-500)}.dark\:shadow-\[inset_2px_2px_6px_rgba\(0\,0\,0\,0\.72\)\,inset_-1px_-1px_1px_rgba\(255\,255\,255\,0\.07\)\]:where(.dark,.dark *){--tw-shadow:inset 2px 2px 6px var(--tw-shadow-color,#000000b8), inset -1px -1px 1px var(--tw-shadow-color,#ffffff12);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.dark\:shadow-black\/20:where(.dark,.dark *){--tw-shadow-color:#0003}@supports (color:color-mix(in lab, red, red)){.dark\:shadow-black\/20:where(.dark,.dark *){--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-black) 20%, transparent) var(--tw-shadow-alpha), transparent)}}.dark\:shadow-black\/30:where(.dark,.dark *){--tw-shadow-color:#0000004d}@supports (color:color-mix(in lab, red, red)){.dark\:shadow-black\/30:where(.dark,.dark *){--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-black) 30%, transparent) var(--tw-shadow-alpha), transparent)}}.dark\:shadow-black\/40:where(.dark,.dark *){--tw-shadow-color:#0006}@supports (color:color-mix(in lab, red, red)){.dark\:shadow-black\/40:where(.dark,.dark *){--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-black) 40%, transparent) var(--tw-shadow-alpha), transparent)}}.dark\:ring-blue-500\/50:where(.dark,.dark *){--tw-ring-color:#3080ff80}@supports (color:color-mix(in lab, red, red)){.dark\:ring-blue-500\/50:where(.dark,.dark *){--tw-ring-color:color-mix(in oklab, var(--color-blue-500) 50%, transparent)}}.dark\:ring-blue-600:where(.dark,.dark *){--tw-ring-color:var(--color-blue-600)}.dark\:ring-blue-700:where(.dark,.dark *){--tw-ring-color:var(--color-blue-700)}.dark\:ring-cyan-800\/60:where(.dark,.dark *){--tw-ring-color:#005f7899}@supports (color:color-mix(in lab, red, red)){.dark\:ring-cyan-800\/60:where(.dark,.dark *){--tw-ring-color:color-mix(in oklab, var(--color-cyan-800) 60%, transparent)}}.dark\:ring-indigo-500\/40:where(.dark,.dark *){--tw-ring-color:#625fff66}@supports (color:color-mix(in lab, red, red)){.dark\:ring-indigo-500\/40:where(.dark,.dark *){--tw-ring-color:color-mix(in oklab, var(--color-indigo-500) 40%, transparent)}}.dark\:ring-red-900\/60:where(.dark,.dark *){--tw-ring-color:#82181a99}@supports (color:color-mix(in lab, red, red)){.dark\:ring-red-900\/60:where(.dark,.dark *){--tw-ring-color:color-mix(in oklab, var(--color-red-900) 60%, transparent)}}.dark\:ring-white\/10:where(.dark,.dark *){--tw-ring-color:#ffffff1a}@supports (color:color-mix(in lab, red, red)){.dark\:ring-white\/10:where(.dark,.dark *){--tw-ring-color:color-mix(in oklab, var(--color-white) 10%, transparent)}}.dark\:ring-zinc-700:where(.dark,.dark *){--tw-ring-color:var(--color-zinc-700)}.dark\:ring-zinc-800:where(.dark,.dark *){--tw-ring-color:var(--color-zinc-800)}.dark\:ring-zinc-800\/90:where(.dark,.dark *){--tw-ring-color:#27272ae6}@supports (color:color-mix(in lab, red, red)){.dark\:ring-zinc-800\/90:where(.dark,.dark *){--tw-ring-color:color-mix(in oklab, var(--color-zinc-800) 90%, transparent)}}.dark\:ring-offset-zinc-900:where(.dark,.dark *){--tw-ring-offset-color:var(--color-zinc-900)}.dark\:ring-offset-zinc-950:where(.dark,.dark *){--tw-ring-offset-color:var(--color-zinc-950)}@media (hover:hover){.dark\:group-hover\:text-blue-400:where(.dark,.dark *):is(:where(.group):hover *){color:var(--color-blue-400)}.dark\:group-hover\:text-zinc-200:where(.dark,.dark *):is(:where(.group):hover *){color:var(--color-zinc-200)}.dark\:group-hover\:text-zinc-300:where(.dark,.dark *):is(:where(.group):hover *){color:var(--color-zinc-300)}}.dark\:peer-checked\:bg-blue-600:where(.dark,.dark *):is(:where(.peer):checked~*){background-color:var(--color-blue-600)}.dark\:peer-focus\:ring-blue-800:where(.dark,.dark *):is(:where(.peer):focus~*){--tw-ring-color:var(--color-blue-800)}.dark\:file\:bg-blue-600:where(.dark,.dark *)::file-selector-button{background-color:var(--color-blue-600)}.dark\:file\:bg-zinc-700:where(.dark,.dark *)::file-selector-button{background-color:var(--color-zinc-700)}.dark\:file\:text-zinc-200:where(.dark,.dark *)::file-selector-button{color:var(--color-zinc-200)}.dark\:placeholder\:text-zinc-500:where(.dark,.dark *)::placeholder{color:var(--color-zinc-500)}.dark\:open\:bg-zinc-950:where(.dark,.dark *):is([open],:popover-open,:open){background-color:var(--color-zinc-950)}@media (hover:hover){.dark\:hover\:border-amber-500\/60:where(.dark,.dark *):hover{border-color:#f99c0099}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:border-amber-500\/60:where(.dark,.dark *):hover{border-color:color-mix(in oklab, var(--color-amber-500) 60%, transparent)}}.dark\:hover\:border-amber-600:where(.dark,.dark *):hover{border-color:var(--color-amber-600)}.dark\:hover\:border-blue-500:where(.dark,.dark *):hover{border-color:var(--color-blue-500)}.dark\:hover\:border-blue-600:where(.dark,.dark *):hover{border-color:var(--color-blue-600)}.dark\:hover\:border-blue-800:where(.dark,.dark *):hover{border-color:var(--color-blue-800)}.dark\:hover\:border-blue-900\/30:where(.dark,.dark *):hover{border-color:#1c398e4d}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:border-blue-900\/30:where(.dark,.dark *):hover{border-color:color-mix(in oklab, var(--color-blue-900) 30%, transparent)}}.dark\:hover\:border-zinc-500:where(.dark,.dark *):hover{border-color:var(--color-zinc-500)}.dark\:hover\:border-zinc-600:where(.dark,.dark *):hover{border-color:var(--color-zinc-600)}.dark\:hover\:bg-amber-600:where(.dark,.dark *):hover{background-color:var(--color-amber-600)}.dark\:hover\:bg-amber-900\/20:where(.dark,.dark *):hover{background-color:#7b330633}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-amber-900\/20:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-amber-900) 20%, transparent)}}.dark\:hover\:bg-amber-900\/40:where(.dark,.dark *):hover{background-color:#7b330666}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-amber-900\/40:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-amber-900) 40%, transparent)}}.dark\:hover\:bg-amber-900\/50:where(.dark,.dark *):hover{background-color:#7b330680}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-amber-900\/50:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-amber-900) 50%, transparent)}}.dark\:hover\:bg-blue-500:where(.dark,.dark *):hover{background-color:var(--color-blue-500)}.dark\:hover\:bg-blue-500\/20:where(.dark,.dark *):hover{background-color:#3080ff33}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-blue-500\/20:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-blue-500) 20%, transparent)}}.dark\:hover\:bg-blue-600:where(.dark,.dark *):hover{background-color:var(--color-blue-600)}.dark\:hover\:bg-blue-700:where(.dark,.dark *):hover{background-color:var(--color-blue-700)}.dark\:hover\:bg-blue-800:where(.dark,.dark *):hover{background-color:var(--color-blue-800)}.dark\:hover\:bg-blue-900\/10:where(.dark,.dark *):hover{background-color:#1c398e1a}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-blue-900\/10:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-blue-900) 10%, transparent)}}.dark\:hover\:bg-blue-900\/20:where(.dark,.dark *):hover{background-color:#1c398e33}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-blue-900\/20:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-blue-900) 20%, transparent)}}.dark\:hover\:bg-blue-900\/40:where(.dark,.dark *):hover{background-color:#1c398e66}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-blue-900\/40:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-blue-900) 40%, transparent)}}.dark\:hover\:bg-blue-900\/50:where(.dark,.dark *):hover{background-color:#1c398e80}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-blue-900\/50:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-blue-900) 50%, transparent)}}.dark\:hover\:bg-blue-950\/30:where(.dark,.dark *):hover{background-color:#1624564d}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-blue-950\/30:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-blue-950) 30%, transparent)}}.dark\:hover\:bg-emerald-900\/20:where(.dark,.dark *):hover{background-color:#004e3b33}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-emerald-900\/20:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-emerald-900) 20%, transparent)}}.dark\:hover\:bg-gray-800:where(.dark,.dark *):hover{background-color:var(--color-gray-800)}.dark\:hover\:bg-green-900\/40:where(.dark,.dark *):hover{background-color:#0d542b66}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-green-900\/40:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-green-900) 40%, transparent)}}.dark\:hover\:bg-indigo-900\/20:where(.dark,.dark *):hover{background-color:#312c8533}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-indigo-900\/20:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-indigo-900) 20%, transparent)}}.dark\:hover\:bg-indigo-900\/50:where(.dark,.dark *):hover{background-color:#312c8580}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-indigo-900\/50:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-indigo-900) 50%, transparent)}}.dark\:hover\:bg-orange-900\/20:where(.dark,.dark *):hover{background-color:#7e2a0c33}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-orange-900\/20:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-orange-900) 20%, transparent)}}.dark\:hover\:bg-pink-900\/20:where(.dark,.dark *):hover{background-color:#86104333}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-pink-900\/20:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-pink-900) 20%, transparent)}}.dark\:hover\:bg-red-800:where(.dark,.dark *):hover{background-color:var(--color-red-800)}.dark\:hover\:bg-red-900\/20:where(.dark,.dark *):hover{background-color:#82181a33}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-red-900\/20:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-red-900) 20%, transparent)}}.dark\:hover\:bg-red-900\/20\!:where(.dark,.dark *):hover{background-color:#82181a33!important}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-red-900\/20\!:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-red-900) 20%, transparent)!important}}.dark\:hover\:bg-red-900\/30:where(.dark,.dark *):hover{background-color:#82181a4d}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-red-900\/30:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-red-900) 30%, transparent)}}.dark\:hover\:bg-red-900\/40:where(.dark,.dark *):hover{background-color:#82181a66}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-red-900\/40:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-red-900) 40%, transparent)}}.dark\:hover\:bg-red-900\/50:where(.dark,.dark *):hover{background-color:#82181a80}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-red-900\/50:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-red-900) 50%, transparent)}}.dark\:hover\:bg-red-950\/30:where(.dark,.dark *):hover{background-color:#4608094d}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-red-950\/30:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-red-950) 30%, transparent)}}.dark\:hover\:bg-red-950\/40:where(.dark,.dark *):hover{background-color:#46080966}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-red-950\/40:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-red-950) 40%, transparent)}}.dark\:hover\:bg-teal-900\/20:where(.dark,.dark *):hover{background-color:#0b4f4a33}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-teal-900\/20:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-teal-900) 20%, transparent)}}.dark\:hover\:bg-violet-900\/20:where(.dark,.dark *):hover{background-color:#4d179a33}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-violet-900\/20:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-violet-900) 20%, transparent)}}.dark\:hover\:bg-white\/5:where(.dark,.dark *):hover{background-color:#ffffff0d}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-white\/5:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-white) 5%, transparent)}}.dark\:hover\:bg-white\/10:where(.dark,.dark *):hover{background-color:#ffffff1a}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-white\/10:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-white) 10%, transparent)}}.dark\:hover\:bg-white\/15:where(.dark,.dark *):hover{background-color:#ffffff26}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-white\/15:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-white) 15%, transparent)}}.dark\:hover\:bg-zinc-600:where(.dark,.dark *):hover{background-color:var(--color-zinc-600)}.dark\:hover\:bg-zinc-700:where(.dark,.dark *):hover{background-color:var(--color-zinc-700)}.dark\:hover\:bg-zinc-800:where(.dark,.dark *):hover{background-color:var(--color-zinc-800)}.dark\:hover\:bg-zinc-800\/20:where(.dark,.dark *):hover{background-color:#27272a33}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-zinc-800\/20:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-zinc-800) 20%, transparent)}}.dark\:hover\:bg-zinc-800\/50:where(.dark,.dark *):hover{background-color:#27272a80}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-zinc-800\/50:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-zinc-800) 50%, transparent)}}.dark\:hover\:bg-zinc-800\/80:where(.dark,.dark *):hover{background-color:#27272acc}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-zinc-800\/80:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-zinc-800) 80%, transparent)}}.dark\:hover\:bg-zinc-900:where(.dark,.dark *):hover{background-color:var(--color-zinc-900)}.dark\:hover\:bg-zinc-900\/20:where(.dark,.dark *):hover{background-color:#18181b33}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-zinc-900\/20:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-zinc-900) 20%, transparent)}}.dark\:hover\:bg-zinc-900\/40:where(.dark,.dark *):hover{background-color:#18181b66}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-zinc-900\/40:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-zinc-900) 40%, transparent)}}.dark\:hover\:bg-zinc-900\/50:where(.dark,.dark *):hover{background-color:#18181b80}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-zinc-900\/50:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-zinc-900) 50%, transparent)}}.dark\:hover\:bg-zinc-900\/60:where(.dark,.dark *):hover{background-color:#18181b99}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-zinc-900\/60:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-zinc-900) 60%, transparent)}}.dark\:hover\:bg-zinc-900\/70:where(.dark,.dark *):hover{background-color:#18181bb3}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-zinc-900\/70:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-zinc-900) 70%, transparent)}}.dark\:hover\:text-amber-200:where(.dark,.dark *):hover{color:var(--color-amber-200)}.dark\:hover\:text-amber-400:where(.dark,.dark *):hover{color:var(--color-amber-400)}.dark\:hover\:text-blue-300:where(.dark,.dark *):hover{color:var(--color-blue-300)}.dark\:hover\:text-blue-400:where(.dark,.dark *):hover{color:var(--color-blue-400)}.dark\:hover\:text-cyan-400:where(.dark,.dark *):hover{color:var(--color-cyan-400)}.dark\:hover\:text-emerald-400:where(.dark,.dark *):hover{color:var(--color-emerald-400)}.dark\:hover\:text-gray-200:where(.dark,.dark *):hover{color:var(--color-gray-200)}.dark\:hover\:text-gray-300:where(.dark,.dark *):hover{color:var(--color-gray-300)}.dark\:hover\:text-green-400:where(.dark,.dark *):hover{color:var(--color-green-400)}.dark\:hover\:text-indigo-400:where(.dark,.dark *):hover{color:var(--color-indigo-400)}.dark\:hover\:text-red-400:where(.dark,.dark *):hover{color:var(--color-red-400)}.dark\:hover\:text-violet-400:where(.dark,.dark *):hover{color:var(--color-violet-400)}.dark\:hover\:text-white:where(.dark,.dark *):hover{color:var(--color-white)}.dark\:hover\:text-zinc-100:where(.dark,.dark *):hover{color:var(--color-zinc-100)}.dark\:hover\:text-zinc-300:where(.dark,.dark *):hover{color:var(--color-zinc-300)}.dark\:hover\:file\:bg-blue-700:where(.dark,.dark *):hover::file-selector-button{background-color:var(--color-blue-700)}.dark\:hover\:file\:bg-zinc-600:where(.dark,.dark *):hover::file-selector-button{background-color:var(--color-zinc-600)}}.dark\:focus\:border-blue-400:where(.dark,.dark *):focus{border-color:var(--color-blue-400)}.dark\:focus\:border-blue-500:where(.dark,.dark *):focus{border-color:var(--color-blue-500)}.dark\:focus\:border-indigo-500:where(.dark,.dark *):focus{border-color:var(--color-indigo-500)}.dark\:focus\:ring-blue-50:where(.dark,.dark *):focus{--tw-ring-color:var(--color-blue-50)}.dark\:focus\:ring-blue-400:where(.dark,.dark *):focus{--tw-ring-color:var(--color-blue-400)}.dark\:focus\:ring-blue-500:where(.dark,.dark *):focus{--tw-ring-color:var(--color-blue-500)}.dark\:focus\:ring-indigo-500:where(.dark,.dark *):focus{--tw-ring-color:var(--color-indigo-500)}.dark\:focus\:ring-offset-zinc-900:where(.dark,.dark *):focus{--tw-ring-offset-color:var(--color-zinc-900)}.dark\:focus-visible\:ring-blue-400:where(.dark,.dark *):focus-visible{--tw-ring-color:var(--color-blue-400)}.dark\:focus-visible\:outline-zinc-500:where(.dark,.dark *):focus-visible{outline-color:var(--color-zinc-500)}.dark\:disabled\:bg-zinc-700:where(.dark,.dark *):disabled{background-color:var(--color-zinc-700)}.dark\:disabled\:bg-zinc-800:where(.dark,.dark *):disabled{background-color:var(--color-zinc-800)}@media (hover:hover){.dark\:disabled\:hover\:bg-violet-900\/10:where(.dark,.dark *):disabled:hover{background-color:#4d179a1a}@supports (color:color-mix(in lab, red, red)){.dark\:disabled\:hover\:bg-violet-900\/10:where(.dark,.dark *):disabled:hover{background-color:color-mix(in oklab, var(--color-violet-900) 10%, transparent)}}}@media (min-width:40rem){.dark\:sm\:bg-white\/2:where(.dark,.dark *){background-color:#ffffff05}@supports (color:color-mix(in lab, red, red)){.dark\:sm\:bg-white\/2:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-white) 2%, transparent)}}}.\[\&_\*\]\:bg-transparent\! *{background-color:#0000!important}.\[\&_\*\]\:text-inherit\! *{color:inherit!important}}@property --tw-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-z{syntax:"*";inherits:false;initial-value:0}@property --tw-scale-x{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-y{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-z{syntax:"*";inherits:false;initial-value:1}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-pan-x{syntax:"*";inherits:false}@property --tw-pan-y{syntax:"*";inherits:false}@property --tw-pinch-zoom{syntax:"*";inherits:false}@property --tw-scroll-snap-strictness{syntax:"*";inherits:false;initial-value:proximity}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-space-x-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-divide-x-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-gradient-position{syntax:"*";inherits:false}@property --tw-gradient-from{syntax:"";inherits:false;initial-value:#0000}@property --tw-gradient-via{syntax:"";inherits:false;initial-value:#0000}@property --tw-gradient-to{syntax:"";inherits:false;initial-value:#0000}@property --tw-gradient-stops{syntax:"*";inherits:false}@property --tw-gradient-via-stops{syntax:"*";inherits:false}@property --tw-gradient-from-position{syntax:"";inherits:false;initial-value:0%}@property --tw-gradient-via-position{syntax:"";inherits:false;initial-value:50%}@property --tw-gradient-to-position{syntax:"";inherits:false;initial-value:100%}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-ordinal{syntax:"*";inherits:false}@property --tw-slashed-zero{syntax:"*";inherits:false}@property --tw-numeric-figure{syntax:"*";inherits:false}@property --tw-numeric-spacing{syntax:"*";inherits:false}@property --tw-numeric-fraction{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-outline-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-backdrop-blur{syntax:"*";inherits:false}@property --tw-backdrop-brightness{syntax:"*";inherits:false}@property --tw-backdrop-contrast{syntax:"*";inherits:false}@property --tw-backdrop-grayscale{syntax:"*";inherits:false}@property --tw-backdrop-hue-rotate{syntax:"*";inherits:false}@property --tw-backdrop-invert{syntax:"*";inherits:false}@property --tw-backdrop-opacity{syntax:"*";inherits:false}@property --tw-backdrop-saturate{syntax:"*";inherits:false}@property --tw-backdrop-sepia{syntax:"*";inherits:false}@property --tw-duration{syntax:"*";inherits:false}@property --tw-ease{syntax:"*";inherits:false}@property --tw-contain-size{syntax:"*";inherits:false}@property --tw-contain-layout{syntax:"*";inherits:false}@property --tw-contain-paint{syntax:"*";inherits:false}@property --tw-contain-style{syntax:"*";inherits:false}@property --tw-content{syntax:"*";inherits:false;initial-value:""}@keyframes spin{to{transform:rotate(360deg)}}@keyframes ping{75%,to{opacity:0;transform:scale(2)}}@keyframes pulse{50%{opacity:.5}}@keyframes bounce{0%,to{animation-timing-function:cubic-bezier(.8,0,1,1);transform:translateY(-25%)}50%{animation-timing-function:cubic-bezier(0,0,.2,1);transform:none}} \ No newline at end of file diff --git a/meshchatx/src/frontend/public/meshchatx-docs/en/nomad-network.md b/meshchatx/src/frontend/public/meshchatx-docs/en/nomad-network.md index fad3693ec..3ed82302e 100644 --- a/meshchatx/src/frontend/public/meshchatx-docs/en/nomad-network.md +++ b/meshchatx/src/frontend/public/meshchatx-docs/en/nomad-network.md @@ -23,6 +23,8 @@ Rendering uses NomadPageRenderer.js with DOMPurify sanitization. Micron can use Save frequent nodes as favourites. Link caching (nomadnet_cached_links) speeds up repeat visits on slow links. +Browsing is anonymous by default (null identity on the RNS link), matching NomadNet. The fingerprint control is a sticky **Identify when connecting** toggle stored on the favourite. When it is on, MeshChatX calls `link.identify` with your identity before each page or file request on that node. Turning it off clears the cached link so the next visit is anonymous again. Private tabs never identify. + ## Archives When **page archiver** is enabled, MeshChatX stores versioned snapshots of pages you visit. Open **Archives** to browse historical copies. An optional crawler can archive automatically. diff --git a/meshchatx/src/frontend/shell/FeaturePageHost.vue b/meshchatx/src/frontend/shell/FeaturePageHost.vue index da9e39954..2f98aec12 100644 --- a/meshchatx/src/frontend/shell/FeaturePageHost.vue +++ b/meshchatx/src/frontend/shell/FeaturePageHost.vue @@ -63,7 +63,10 @@ export default { } this.svelteApp = mount(Comp, { target: root, - props: { ...(this.$route.params || {}) }, + props: { + ...(this.$route.params || {}), + routeQuery: { ...(this.$route.query || {}) }, + }, }); }, }, diff --git a/meshchatx/src/frontend/ui/svelte/EmptyState.svelte b/meshchatx/src/frontend/ui/svelte/EmptyState.svelte new file mode 100644 index 000000000..123464fc2 --- /dev/null +++ b/meshchatx/src/frontend/ui/svelte/EmptyState.svelte @@ -0,0 +1,49 @@ + + + + +
+ {#if icon} + + {/if} + {#if title} +
{title}
+ {/if} + {#if description} +

{description}

+ {/if} + {#if children} +
+ {@render children()} +
+ {/if} +
diff --git a/meshchatx/src/frontend/ui/svelte/LoadingState.svelte b/meshchatx/src/frontend/ui/svelte/LoadingState.svelte new file mode 100644 index 000000000..eec6d6d17 --- /dev/null +++ b/meshchatx/src/frontend/ui/svelte/LoadingState.svelte @@ -0,0 +1,20 @@ + + + + +
+ + {#if message} +

{message}

+ {/if} +
diff --git a/meshchatx/src/frontend/ui/svelte/LxmfUserIcon.svelte b/meshchatx/src/frontend/ui/svelte/LxmfUserIcon.svelte new file mode 100644 index 000000000..201afd8ae --- /dev/null +++ b/meshchatx/src/frontend/ui/svelte/LxmfUserIcon.svelte @@ -0,0 +1,68 @@ + + + + +{#if customImage} +
+ +
+{:else if iconName} +
+ +
+{:else} +
+ +
+{/if} diff --git a/meshchatx/src/frontend/ui/svelte/MaterialDesignIcon.svelte b/meshchatx/src/frontend/ui/svelte/MaterialDesignIcon.svelte index d5c33ea90..7bc06df92 100644 --- a/meshchatx/src/frontend/ui/svelte/MaterialDesignIcon.svelte +++ b/meshchatx/src/frontend/ui/svelte/MaterialDesignIcon.svelte @@ -3,8 +3,8 @@ @@ -18,7 +18,7 @@ width="20" height="20" style="display: block" - class="antialiased shrink-0" + class="antialiased shrink-0 {className}" > diff --git a/meshchatx/src/frontend/ui/svelte/ToolsPageHeader.svelte b/meshchatx/src/frontend/ui/svelte/ToolsPageHeader.svelte index 8d10ead19..67f16c7c7 100644 --- a/meshchatx/src/frontend/ui/svelte/ToolsPageHeader.svelte +++ b/meshchatx/src/frontend/ui/svelte/ToolsPageHeader.svelte @@ -16,6 +16,7 @@ violet: { wrap: "bg-violet-100 dark:bg-violet-900/30", icon: "text-violet-600 dark:text-violet-400" }, amber: { wrap: "bg-amber-100 dark:bg-amber-900/30", icon: "text-amber-600 dark:text-amber-400" }, sky: { wrap: "bg-sky-100 dark:bg-sky-900/30", icon: "text-sky-600 dark:text-sky-400" }, + red: { wrap: "bg-red-100 dark:bg-red-900/30", icon: "text-red-600 dark:text-red-400" }, zinc: { wrap: "bg-zinc-100 dark:bg-zinc-800", icon: "text-zinc-600 text-sem-fg-muted" }, }; diff --git a/package.json b/package.json index b0f02079b..8c696c9c0 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "knip": "knip", "format": "prettier --write .", "format:check": "prettier --check .", + "format:check:svelte": "prettier --check \"meshchatx/src/frontend/**/*.svelte\" \"meshchatx/src/frontend/features/**/*.js\" \"svelte.config.js\"", "commitlint": "commitlint --edit", "test": "vitest run --exclude tests/frontend/LoadTimePerformance.test.js && vitest run --config vitest.electron.config.js", "test:coverage": "vitest run --coverage --exclude tests/frontend/LoadTimePerformance.test.js && vitest run --config vitest.electron.config.js --coverage", diff --git a/tests/frontend/ContactsPage.test.js b/tests/frontend/ContactsPage.test.js index d1dab238e..fc5f4ded0 100644 --- a/tests/frontend/ContactsPage.test.js +++ b/tests/frontend/ContactsPage.test.js @@ -1,8 +1,16 @@ -import { flushPromises, mount } from "@vue/test-utils"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import ContactsPage from "@/components/contacts/ContactsPage.vue"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, cleanup, waitFor, screen } from "@testing-library/svelte"; +import ContactsPage from "@/features/contacts/ContactsPage.svelte"; +import { parseLxmaUri, extractDestinationHash } from "@/features/contacts/lib/contactUri.js"; +import { mergeContactsByName } from "@/features/contacts/lib/mergeContacts.js"; +import { + addContactFromInput, + exportContactsFile, + importContactsList, +} from "@/features/contacts/lib/contactsActions.js"; import WebSocketConnection from "@/js/WebSocketConnection"; import ToastUtils from "@/js/ToastUtils"; +import { registerFallbackMessages, registerTranslator } from "@/js/i18n.js"; vi.mock("@/js/WebSocketConnection", () => ({ default: { @@ -29,15 +37,52 @@ vi.mock("qrcode", () => ({ }, })); -describe("ContactsPage.vue", () => { +describe("contactUri / mergeContacts", () => { + it("parses lxma URI correctly", () => { + const result = parseLxmaUri(`lxma://${"c".repeat(32)}:${"d".repeat(128)}`); + expect(result.destinationHash).toBe("c".repeat(32)); + expect(result.publicKeyHex).toBe("d".repeat(128)); + expect(extractDestinationHash("e".repeat(32))).toBe("e".repeat(32)); + }); + + it("merges contacts that share a name", () => { + const merged = mergeContactsByName([ + { id: 1, name: "A", lxmf_address: "a".repeat(32) }, + { id: 2, name: "A", remote_telephony_hash: "b".repeat(32) }, + ]); + expect(merged).toHaveLength(1); + expect(merged[0].lxmf_address).toBe("a".repeat(32)); + expect(merged[0].remote_telephony_hash).toBe("b".repeat(32)); + }); +}); + +describe("ContactsPage.svelte", () => { let axiosMock; beforeEach(() => { vi.clearAllMocks(); + registerTranslator(null); + registerFallbackMessages({ + contacts: { + title: "contacts.title", + description: "desc", + share_my_identity: "share", + export_contacts: "contacts.export_contacts", + import_contacts: "contacts.import_contacts", + add_contact: "contacts.add_contact", + search_placeholder: "Search", + loading: "loading", + no_contacts: "none", + load_more: "more", + failed_load_contacts: "contacts.failed_load_contacts", + }, + common: { cancel: "Cancel", copy: "Copy" }, + }); axiosMock = { get: vi.fn(), post: vi.fn(), delete: vi.fn(), + patch: vi.fn(), }; window.api = axiosMock; @@ -68,33 +113,16 @@ describe("ContactsPage.vue", () => { }); }); - const mountPage = () => - mount(ContactsPage, { - global: { - mocks: { - $t: (key) => key, - }, - stubs: { - MaterialDesignIcon: true, - }, - }, - }); - - it("parses lxma URI correctly", () => { - const wrapper = mountPage(); - const result = wrapper.vm.parseLxmaUri(`lxma://${"c".repeat(32)}:${"d".repeat(128)}`); - expect(result.destinationHash).toBe("c".repeat(32)); - expect(result.publicKeyHex).toBe("d".repeat(128)); + afterEach(() => { + cleanup(); + delete window.api; }); it("adds contact using manual destination hash", async () => { - const wrapper = mountPage(); - await wrapper.vm.$nextTick(); - wrapper.vm.newContactInput = "e".repeat(32); - wrapper.vm.newContactName = "Test Contact"; - - await wrapper.vm.submitAddContact(); - + await addContactFromInput("e".repeat(32), "Test Contact", { + setPendingLxma: vi.fn(), + onAdded: vi.fn(async () => {}), + }); expect(axiosMock.post).toHaveBeenCalledWith("/api/v1/telephone/contacts", { name: "Test Contact", lxmf_address: "e".repeat(32), @@ -102,12 +130,10 @@ describe("ContactsPage.vue", () => { }); it("uses websocket ingest for lxma URI input", async () => { - const wrapper = mountPage(); - await wrapper.vm.$nextTick(); - wrapper.vm.newContactInput = `lxma://${"f".repeat(32)}:${"1".repeat(128)}`; - - await wrapper.vm.submitAddContact(); - + await addContactFromInput(`lxma://${"f".repeat(32)}:${"1".repeat(128)}`, "", { + setPendingLxma: vi.fn(), + onAdded: vi.fn(async () => {}), + }); expect(WebSocketConnection.send).toHaveBeenCalledWith( JSON.stringify({ type: "lxm.ingest_uri", @@ -118,24 +144,28 @@ describe("ContactsPage.vue", () => { }); it("exports contacts via GET /api/v1/telephone/contacts/export", async () => { - const wrapper = mountPage(); - await wrapper.vm.$nextTick(); - - await wrapper.vm.exportContacts(); - + vi.stubGlobal( + "Blob", + class { + constructor() {} + } + ); + const DownloadUtils = await import("@/js/DownloadUtils"); + vi.spyOn(DownloadUtils.default, "downloadFile").mockResolvedValue(undefined); + await exportContactsFile(); expect(axiosMock.get).toHaveBeenCalledWith("/api/v1/telephone/contacts/export"); + vi.unstubAllGlobals(); }); it("imports contacts via POST /api/v1/telephone/contacts/import", async () => { - const wrapper = mountPage(); - await wrapper.vm.$nextTick(); axiosMock.post.mockResolvedValue({ data: { added: 2, skipped: 0 } }); - - await wrapper.vm.importContacts([ - { name: "A", remote_identity_hash: "a".repeat(32) }, - { name: "B", remote_identity_hash: "b".repeat(32) }, - ]); - + await importContactsList( + [ + { name: "A", remote_identity_hash: "a".repeat(32) }, + { name: "B", remote_identity_hash: "b".repeat(32) }, + ], + async () => {} + ); expect(axiosMock.post).toHaveBeenCalledWith("/api/v1/telephone/contacts/import", { contacts: [ { name: "A", remote_identity_hash: "a".repeat(32) }, @@ -146,25 +176,21 @@ describe("ContactsPage.vue", () => { it("mounts within 500ms", () => { const start = performance.now(); - const wrapper = mountPage(); + render(ContactsPage); const elapsed = performance.now() - start; - expect(wrapper.find("h1").exists()).toBe(true); + expect(screen.getByText("contacts.title")).toBeTruthy(); expect(elapsed).toBeLessThan(500); }); it("export and import buttons are present", async () => { - const wrapper = mountPage(); - await wrapper.vm.$nextTick(); - const html = wrapper.html(); - expect(html).toContain("contacts.export_contacts"); - expect(html).toContain("contacts.import_contacts"); + render(ContactsPage); + expect(await screen.findByTitle("contacts.export_contacts")).toBeTruthy(); + expect(screen.getByTitle("contacts.import_contacts")).toBeTruthy(); }); it("renders floating add-contact action for mobile layout", async () => { - const wrapper = mountPage(); - await wrapper.vm.$nextTick(); - const mobileFab = wrapper.find('button[title="contacts.add_contact"]'); - expect(mobileFab.exists()).toBe(true); + render(ContactsPage); + expect(await screen.findByTitle("contacts.add_contact")).toBeTruthy(); }); it("getContacts maps total_count from telephone contacts API", async () => { @@ -190,19 +216,10 @@ describe("ContactsPage.vue", () => { }, }); } - if (url === "/api/v1/telephone/contacts/export") { - return Promise.resolve({ data: { contacts: [] } }); - } - if (url.startsWith("/api/v1/telephone/contacts/check/")) { - return Promise.resolve({ data: { is_contact: false, contact: null } }); - } return Promise.resolve({ data: {} }); }); - const wrapper = mountPage(); - await flushPromises(); - expect(wrapper.vm.totalContactsCount).toBe(42); - expect(wrapper.vm.contacts).toHaveLength(1); - expect(wrapper.vm.contacts[0].name).toBe("One"); + render(ContactsPage); + expect(await screen.findByText("One")).toBeTruthy(); }); it("toasts failed_load_contacts when contacts GET fails", async () => { @@ -230,8 +247,7 @@ describe("ContactsPage.vue", () => { return Promise.resolve({ data: {} }); }); - mountPage(); - await flushPromises(); - await vi.waitFor(() => expect(ToastUtils.error).toHaveBeenCalledWith("contacts.failed_load_contacts")); + render(ContactsPage); + await waitFor(() => expect(ToastUtils.error).toHaveBeenCalledWith("contacts.failed_load_contacts")); }); }); diff --git a/tests/frontend/DebugLogsPage.test.js b/tests/frontend/DebugLogsPage.test.js index dfc549cc2..9c74d0f83 100644 --- a/tests/frontend/DebugLogsPage.test.js +++ b/tests/frontend/DebugLogsPage.test.js @@ -1,13 +1,31 @@ -import { mount } from "@vue/test-utils"; -import { describe, it, expect, vi, beforeEach } from "vitest"; -import DebugLogsPage from "@/components/debug/DebugLogsPage.vue"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, cleanup, fireEvent, waitFor, screen } from "@testing-library/svelte"; +import DebugLogsPage from "@/features/debug-logs/DebugLogsPage.svelte"; +import { formatLogLine, debugLevelClass } from "@/features/debug-logs/lib/debugFormat.js"; +import { registerFallbackMessages, registerTranslator } from "@/js/i18n.js"; +import ToastUtils from "@/js/ToastUtils"; + +vi.mock("@/js/ToastUtils", () => ({ + default: { + success: vi.fn(), + error: vi.fn(), + }, +})); -// Mock axios window.api = { get: vi.fn(), }; -describe("DebugLogsPage.vue", () => { +describe("debugFormat", () => { + it("formats log lines and level classes", () => { + expect(debugLevelClass("ERROR")).toContain("red"); + expect(formatLogLine({ timestamp: 1, level: "INFO", module: "m", message: "hi", is_anomaly: 0 })).toContain( + "[INFO]" + ); + }); +}); + +describe("DebugLogsPage.svelte", () => { beforeEach(() => { vi.clearAllMocks(); Object.assign(navigator, { @@ -15,6 +33,37 @@ describe("DebugLogsPage.vue", () => { writeText: vi.fn().mockResolvedValue(undefined), }, }); + registerTranslator(null); + registerFallbackMessages({ + app: { tools: "Tools" }, + tools: { back_to_tools: "Back" }, + common: { search: "Search", all: "All", refresh: "Refresh" }, + debug: { + logs_title: "Logs", + logs_description: "desc", + copy_logs: "Copy", + copy_access: "Copy access", + tab_logs: "debug.tab_logs", + tab_access_attempts: "debug.tab_access_attempts", + search_logs_placeholder: "debug.search_logs_placeholder", + search_access_placeholder: "debug.search_access_placeholder", + level: "Level", + anomalies_only: "Anomalies", + outcome: "Outcome", + no_logs_found: "none", + no_access_attempts: "none access", + logs_copied: "copied", + failed_copy_logs: "fail copy", + access_copied: "access copied", + failed_copy_access: "fail access copy", + failed_fetch_logs: "fail logs", + failed_fetch_access: "fail access", + }, + }); + }); + + afterEach(() => { + cleanup(); }); it("fetches and displays logs", async () => { @@ -39,22 +88,13 @@ describe("DebugLogsPage.vue", () => { }, }); - const wrapper = mount(DebugLogsPage, { - global: { - stubs: ["MaterialDesignIcon"], - mocks: { $t: (key) => key }, - }, - }); + render(DebugLogsPage); - await new Promise((resolve) => setTimeout(resolve, 10)); - await wrapper.vm.$nextTick(); - - const container = wrapper.find(".flex-1.overflow-auto.font-mono"); - const logRows = container.findAll(".border-b"); - expect(logRows.length).toBe(2); - expect(wrapper.text()).toContain("Hello"); - expect(wrapper.text()).toContain("Boom"); - expect(wrapper.text().toLowerCase()).toContain("repeat"); // Anomaly badge + await waitFor(() => { + expect(screen.getByText("Hello")).toBeTruthy(); + }); + expect(screen.getByText("Boom")).toBeTruthy(); + expect(screen.getByText(/repeat/i)).toBeTruthy(); }); it("handles search input", async () => { @@ -62,24 +102,22 @@ describe("DebugLogsPage.vue", () => { data: { logs: [], total: 0, limit: 100, offset: 0 }, }); - const wrapper = mount(DebugLogsPage, { - global: { - stubs: ["MaterialDesignIcon"], - mocks: { $t: (key) => key }, - }, - }); + render(DebugLogsPage); + await waitFor(() => expect(window.api.get).toHaveBeenCalled()); - const searchInput = wrapper.find("input[placeholder='debug.search_logs_placeholder']"); - await searchInput.setValue("error"); + const searchInput = screen.getByPlaceholderText("debug.search_logs_placeholder"); + await fireEvent.input(searchInput, { target: { value: "error" } }); - // Wait for debounce (500ms) - await new Promise((resolve) => setTimeout(resolve, 600)); - - expect(window.api.get).toHaveBeenCalledWith( - expect.stringContaining("/api/v1/debug/logs"), - expect.objectContaining({ - params: expect.objectContaining({ search: "error" }), - }) + await waitFor( + () => { + expect(window.api.get).toHaveBeenCalledWith( + expect.stringContaining("/api/v1/debug/logs"), + expect.objectContaining({ + params: expect.objectContaining({ search: "error" }), + }) + ); + }, + { timeout: 1000 } ); }); @@ -93,24 +131,20 @@ describe("DebugLogsPage.vue", () => { }, }); - const wrapper = mount(DebugLogsPage, { - global: { - stubs: ["MaterialDesignIcon"], - mocks: { $t: (key) => key }, - }, - }); + render(DebugLogsPage); + await waitFor(() => expect(window.api.get).toHaveBeenCalled()); - await new Promise((resolve) => setTimeout(resolve, 10)); + const nextButtons = screen.getAllByText("Next"); + await fireEvent.click(nextButtons[0]); - const nextButton = wrapper.findAll("button").find((b) => b.text().includes("Next")); - await nextButton.trigger("click"); - - expect(window.api.get).toHaveBeenCalledWith( - expect.stringContaining("/api/v1/debug/logs"), - expect.objectContaining({ - params: expect.objectContaining({ offset: 100 }), - }) - ); + await waitFor(() => { + expect(window.api.get).toHaveBeenCalledWith( + expect.stringContaining("/api/v1/debug/logs"), + expect.objectContaining({ + params: expect.objectContaining({ offset: 100 }), + }) + ); + }); }); it("loads access attempts when switching to Access attempts tab", async () => { @@ -142,28 +176,19 @@ describe("DebugLogsPage.vue", () => { }); }); - const wrapper = mount(DebugLogsPage, { - global: { - stubs: ["MaterialDesignIcon"], - mocks: { $t: (key) => key }, - }, - }); - - await new Promise((resolve) => setTimeout(resolve, 10)); - await wrapper.vm.$nextTick(); + render(DebugLogsPage); + await waitFor(() => expect(window.api.get).toHaveBeenCalled()); - const accessTab = wrapper.findAll("button").find((b) => b.text().includes("debug.tab_access_attempts")); - expect(accessTab).toBeTruthy(); - await accessTab.trigger("click"); - await new Promise((resolve) => setTimeout(resolve, 10)); - await wrapper.vm.$nextTick(); + await fireEvent.click(screen.getByText("debug.tab_access_attempts")); - expect(window.api.get).toHaveBeenCalledWith( - expect.stringContaining("/api/v1/debug/access-attempts"), - expect.any(Object) - ); - expect(wrapper.text()).toContain("failed_password"); - expect(wrapper.text()).toContain("10.0.0.1"); + await waitFor(() => { + expect(window.api.get).toHaveBeenCalledWith( + expect.stringContaining("/api/v1/debug/access-attempts"), + expect.any(Object) + ); + }); + expect(screen.getAllByText("failed_password").length).toBeGreaterThanOrEqual(1); + expect(screen.getByText("10.0.0.1")).toBeTruthy(); }); it("copies a single log line when tapping an entry", async () => { @@ -180,22 +205,15 @@ describe("DebugLogsPage.vue", () => { }, }); - const wrapper = mount(DebugLogsPage, { - global: { - stubs: ["MaterialDesignIcon"], - mocks: { $t: (key) => key }, - }, - }); + render(DebugLogsPage); + const boom = await screen.findByText("Boom"); + await fireEvent.click(boom.closest("[role='button']") || boom); - await new Promise((resolve) => setTimeout(resolve, 10)); - await wrapper.vm.$nextTick(); - - const row = wrapper.findAll(".border-b").find((r) => r.text().includes("Boom")); - expect(row).toBeTruthy(); - await row.trigger("click"); - - expect(navigator.clipboard.writeText).toHaveBeenCalledTimes(1); + await waitFor(() => { + expect(navigator.clipboard.writeText).toHaveBeenCalledTimes(1); + }); expect(navigator.clipboard.writeText.mock.calls[0][0]).toContain("[ERROR]"); expect(navigator.clipboard.writeText.mock.calls[0][0]).toContain("Boom"); + expect(ToastUtils.success).toHaveBeenCalled(); }); }); diff --git a/tests/frontend/LicensesPage.test.js b/tests/frontend/LicensesPage.test.js index 08e91260e..88d1b6298 100644 --- a/tests/frontend/LicensesPage.test.js +++ b/tests/frontend/LicensesPage.test.js @@ -1,14 +1,50 @@ -import { mount } from "@vue/test-utils"; -import { describe, it, expect, vi, beforeEach } from "vitest"; -import LicensesPage from "@/components/licenses/LicensesPage.vue"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, cleanup, fireEvent, waitFor, screen } from "@testing-library/svelte"; +import LicensesPage from "@/features/licenses/LicensesPage.svelte"; +import { filterLicenseRows } from "@/features/licenses/lib/licenseFilter.js"; +import { registerFallbackMessages, registerTranslator } from "@/js/i18n.js"; window.api = { get: vi.fn(), }; -describe("LicensesPage.vue", () => { +describe("filterLicenseRows", () => { + it("filters by package blob", () => { + const rows = [ + { name: "keep-be", version: "1", author: "x", license: "MIT" }, + { name: "other-fe", version: "2", author: "y", license: "MIT" }, + ]; + expect(filterLicenseRows(rows, "keep-be")).toHaveLength(1); + expect(filterLicenseRows(rows, "other-fe")[0].name).toBe("other-fe"); + expect(filterLicenseRows(rows, "")).toHaveLength(2); + }); +}); + +describe("LicensesPage.svelte", () => { beforeEach(() => { vi.clearAllMocks(); + registerTranslator(null); + registerFallbackMessages({ + licenses: { + section_label: "licenses.section_label", + title: "licenses.title", + description: "licenses.description", + generated_at: "licenses.generated_at", + frontend_source: "licenses.frontend_source", + search_placeholder: "Search", + backend_section: "licenses.backend_section", + frontend_section: "licenses.frontend_section", + col_package: "Pkg", + col_version: "Ver", + col_author: "Auth", + col_license: "Lic", + }, + common: { loading: "Loading", no_results: "None" }, + }); + }); + + afterEach(() => { + cleanup(); }); it("loads licenses from the API and renders rows", async () => { @@ -23,24 +59,13 @@ describe("LicensesPage.vue", () => { }, }); - const wrapper = mount(LicensesPage, { - global: { - mocks: { $t: (key, params) => (params ? `${key}:${JSON.stringify(params)}` : key) }, - stubs: { - MaterialDesignIcon: { - template: '', - props: ["iconName"], - }, - }, - }, - }); - - await new Promise((resolve) => setTimeout(resolve, 10)); - await wrapper.vm.$nextTick(); + render(LicensesPage); - expect(window.api.get).toHaveBeenCalledWith("/api/v1/licenses"); - expect(wrapper.text()).toContain("alpha-be"); - expect(wrapper.text()).toContain("zebra-fe"); + await waitFor(() => { + expect(window.api.get).toHaveBeenCalledWith("/api/v1/licenses"); + }); + expect(await screen.findByText("alpha-be")).toBeTruthy(); + expect(screen.getByText("zebra-fe")).toBeTruthy(); }); it("filters both sections with the search query", async () => { @@ -52,27 +77,16 @@ describe("LicensesPage.vue", () => { }, }); - const wrapper = mount(LicensesPage, { - global: { - mocks: { $t: (key) => key }, - stubs: { - MaterialDesignIcon: { - template: '', - props: ["iconName"], - }, - }, - }, - }); - - await new Promise((resolve) => setTimeout(resolve, 10)); - await wrapper.vm.$nextTick(); + render(LicensesPage); + await screen.findByText("keep-be"); - await wrapper.find('input[type="search"]').setValue("keep-be"); - expect(wrapper.vm.filteredBackend.length).toBe(1); - expect(wrapper.vm.filteredFrontend.length).toBe(0); + const input = screen.getByPlaceholderText("Search"); + await fireEvent.input(input, { target: { value: "keep-be" } }); + expect(screen.getByText("keep-be")).toBeTruthy(); + expect(screen.queryByText("other-fe")).toBeNull(); - await wrapper.find('input[type="search"]').setValue("other-fe"); - expect(wrapper.vm.filteredBackend.length).toBe(0); - expect(wrapper.vm.filteredFrontend.length).toBe(1); + await fireEvent.input(input, { target: { value: "other-fe" } }); + expect(screen.getByText("other-fe")).toBeTruthy(); + expect(screen.queryByText("keep-be")).toBeNull(); }); }); diff --git a/tests/frontend/NameOverwriteFixes.test.js b/tests/frontend/NameOverwriteFixes.test.js index 80b716adf..092595567 100644 --- a/tests/frontend/NameOverwriteFixes.test.js +++ b/tests/frontend/NameOverwriteFixes.test.js @@ -1,8 +1,9 @@ import { mount } from "@vue/test-utils"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import MessagesPage from "@/components/messages/MessagesPage.vue"; -import ContactsPage from "@/components/contacts/ContactsPage.vue"; import DialogUtils from "@/js/DialogUtils"; +import { editContactNameWithDuplicates } from "@/features/contacts/lib/contactsActions.js"; +import { registerTranslator } from "@/js/i18n.js"; vi.mock("@/js/DialogUtils", () => ({ default: { @@ -296,6 +297,7 @@ describe("ContactsPage edit contact name", () => { beforeEach(() => { vi.clearAllMocks(); + registerTranslator((key) => key); axiosMock = { get: vi.fn(), post: vi.fn(), @@ -304,49 +306,18 @@ describe("ContactsPage edit contact name", () => { }; window.api = axiosMock; - axiosMock.get.mockImplementation((url) => { - if (url === "/api/v1/config") - return Promise.resolve({ - data: { - config: { - lxmf_address_hash: "a".repeat(32), - identity_public_key: "b".repeat(128), - }, - }, - }); - if (url === "/api/v1/telephone/contacts") - return Promise.resolve({ data: { contacts: [], total_count: 0 } }); - return Promise.resolve({ data: {} }); - }); - axiosMock.patch.mockResolvedValue({ data: { message: "Contact updated" } }); axiosMock.post.mockResolvedValue({ data: { message: "OK" } }); }); afterEach(() => { delete window.api; + registerTranslator(null); }); - const mountPage = () => - mount(ContactsPage, { - global: { - mocks: { - $t: (key) => key, - $router: { push: vi.fn() }, - }, - stubs: { - MaterialDesignIcon: true, - LxmfUserIcon: true, - }, - }, - }); - it("editContactName updates both contact and custom display name", async () => { DialogUtils.prompt.mockResolvedValue("Renamed Alice"); - const wrapper = mountPage(); - await wrapper.vm.$nextTick(); - const contact = { id: 42, name: "Alice", @@ -355,7 +326,7 @@ describe("ContactsPage edit contact name", () => { remote_destination_hash: "a".repeat(32), }; - await wrapper.vm.editContactName(contact); + await editContactNameWithDuplicates(contact, [contact], async () => {}); expect(axiosMock.patch).toHaveBeenCalledWith("/api/v1/telephone/contacts/42", { name: "Renamed Alice", @@ -369,10 +340,7 @@ describe("ContactsPage edit contact name", () => { it("editContactName does nothing when user cancels prompt", async () => { DialogUtils.prompt.mockResolvedValue(null); - const wrapper = mountPage(); - await wrapper.vm.$nextTick(); - - await wrapper.vm.editContactName({ id: 1, name: "Alice" }); + await editContactNameWithDuplicates({ id: 1, name: "Alice" }, [], async () => {}); expect(axiosMock.patch).not.toHaveBeenCalled(); expect(axiosMock.post).not.toHaveBeenCalled(); @@ -381,10 +349,7 @@ describe("ContactsPage edit contact name", () => { it("editContactName does nothing when name unchanged", async () => { DialogUtils.prompt.mockResolvedValue("Alice"); - const wrapper = mountPage(); - await wrapper.vm.$nextTick(); - - await wrapper.vm.editContactName({ id: 1, name: "Alice" }); + await editContactNameWithDuplicates({ id: 1, name: "Alice" }, [], async () => {}); expect(axiosMock.patch).not.toHaveBeenCalled(); }); @@ -392,9 +357,6 @@ describe("ContactsPage edit contact name", () => { it("editContactName skips custom display name when no dest hash", async () => { DialogUtils.prompt.mockResolvedValue("New Name"); - const wrapper = mountPage(); - await wrapper.vm.$nextTick(); - const contact = { id: 99, name: "Old Name", @@ -402,7 +364,7 @@ describe("ContactsPage edit contact name", () => { lxmf_address: null, }; - await wrapper.vm.editContactName(contact); + await editContactNameWithDuplicates(contact, [contact], async () => {}); expect(axiosMock.patch).toHaveBeenCalledWith("/api/v1/telephone/contacts/99", { name: "New Name", @@ -413,9 +375,6 @@ describe("ContactsPage edit contact name", () => { it("editContactName uses lxmf_address when remote_destination_hash absent", async () => { DialogUtils.prompt.mockResolvedValue("Updated"); - const wrapper = mountPage(); - await wrapper.vm.$nextTick(); - const contact = { id: 10, name: "Old", @@ -423,7 +382,7 @@ describe("ContactsPage edit contact name", () => { lxmf_address: "l".repeat(32), }; - await wrapper.vm.editContactName(contact); + await editContactNameWithDuplicates(contact, [contact], async () => {}); expect(axiosMock.post).toHaveBeenCalledWith( `/api/v1/destination/${"l".repeat(32)}/custom-display-name/update`, @@ -435,12 +394,9 @@ describe("ContactsPage edit contact name", () => { DialogUtils.prompt.mockResolvedValue("Fail Name"); axiosMock.patch.mockRejectedValue(new Error("Network error")); - const wrapper = mountPage(); - await wrapper.vm.$nextTick(); - const contact = { id: 5, name: "Before", remote_identity_hash: "x".repeat(32) }; - await wrapper.vm.editContactName(contact); + await editContactNameWithDuplicates(contact, [contact], async () => {}); expect(axiosMock.patch).toHaveBeenCalled(); }); @@ -448,9 +404,6 @@ describe("ContactsPage edit contact name", () => { it("editContactName with empty string still calls patch but not custom display name", async () => { DialogUtils.prompt.mockResolvedValue(""); - const wrapper = mountPage(); - await wrapper.vm.$nextTick(); - const contact = { id: 7, name: "Was Something", @@ -458,7 +411,7 @@ describe("ContactsPage edit contact name", () => { lxmf_address: "r".repeat(32), }; - await wrapper.vm.editContactName(contact); + await editContactNameWithDuplicates(contact, [contact], async () => {}); expect(axiosMock.patch).toHaveBeenCalledWith("/api/v1/telephone/contacts/7", { name: "", @@ -467,10 +420,7 @@ describe("ContactsPage edit contact name", () => { }); it("editContactName with no contact id does nothing", async () => { - const wrapper = mountPage(); - await wrapper.vm.$nextTick(); - - await wrapper.vm.editContactName({}); + await editContactNameWithDuplicates({}, [], async () => {}); expect(DialogUtils.prompt).not.toHaveBeenCalled(); expect(axiosMock.patch).not.toHaveBeenCalled(); diff --git a/tests/frontend/PingPage.test.js b/tests/frontend/PingPage.test.js index 449948708..413d8a15c 100644 --- a/tests/frontend/PingPage.test.js +++ b/tests/frontend/PingPage.test.js @@ -1,7 +1,9 @@ -import { mount } from "@vue/test-utils"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import PingPage from "@/components/ping/PingPage.vue"; +import { render, cleanup, fireEvent, waitFor, screen } from "@testing-library/svelte"; +import PingPage from "@/features/ping/PingPage.svelte"; import DialogUtils from "@/js/DialogUtils"; +import { registerFallbackMessages, registerTranslator } from "@/js/i18n.js"; +import { formatPingSuccess, isValidPingDestinationHash, isValidPingTimeout } from "@/features/ping/lib/pingFormat.js"; vi.mock("@/js/DialogUtils", () => ({ default: { @@ -9,47 +11,88 @@ vi.mock("@/js/DialogUtils", () => ({ }, })); -describe("PingPage.vue", () => { +describe("pingFormat", () => { + it("validates hash and timeout", () => { + expect(isValidPingDestinationHash("a".repeat(32))).toBe(true); + expect(isValidPingDestinationHash("short")).toBe(false); + expect(isValidPingTimeout(10)).toBe(true); + expect(isValidPingTimeout(0)).toBe(false); + }); + + it("formats success lines", () => { + const { line, summary } = formatPingSuccess( + { + rtt: 0.1234, + hops_there: 1, + hops_back: 1, + rssi: -50, + snr: 5, + quality: 100, + receiving_interface: "UDP", + }, + 1 + ); + expect(line).toContain("duration=123.400ms"); + expect(line).toContain("rssi=-50dBm"); + expect(summary.duration).toBe("123.400ms"); + }); +}); + +describe("PingPage.svelte", () => { let axiosMock; beforeEach(() => { axiosMock = { get: vi.fn(), post: vi.fn(), + isCancel: vi.fn(() => false), }; window.api = axiosMock; + registerTranslator(null); + registerFallbackMessages({ + app: { tools: "Tools", interfaces: "via" }, + tools: { + back_to_tools: "Back", + ping: { description: "tools.ping.description" }, + }, + ping: { + title: "ping.title", + description: "ping.description {code}", + destination_hash: "Hash", + timeout_seconds: "Timeout", + start_ping: "ping.start_ping", + stop: "ping.stop", + clear_results: "Clear", + drop_path: "ping.drop_path", + status: "Status", + running: "running", + idle: "idle", + last_rtt: "RTT", + last_error: "Err", + console_output: "Out", + streaming_responses: "stream", + no_pings_yet: "none", + invalid_hash: "ping.invalid_hash", + timeout_must_be_number: "bad timeout", + }, + rnprobe: { hops: "hops", rssi: "rssi", snr: "snr", quality: "quality" }, + }); }); afterEach(() => { + cleanup(); delete window.api; vi.clearAllMocks(); }); - const mountPingPage = (query = {}) => { - return mount(PingPage, { - global: { - mocks: { - $t: (key, params) => key + (params ? JSON.stringify(params) : ""), - $route: { query }, - }, - stubs: { - MaterialDesignIcon: { - template: '
', - props: ["iconName"], - }, - }, - }, - }); - }; - it("renders the ping page", () => { - const wrapper = mountPingPage(); - expect(wrapper.text()).toContain("ping.title"); + render(PingPage); + expect(screen.getByText("ping.title")).toBeTruthy(); }); it("shows alert for invalid hash when starting", async () => { - const wrapper = mountPingPage(); - await wrapper.find("button").trigger("click"); + render(PingPage); + await fireEvent.click(screen.getByText("ping.start_ping")); expect(DialogUtils.alert).toHaveBeenCalledWith("ping.invalid_hash"); }); @@ -68,24 +111,18 @@ describe("PingPage.vue", () => { }, }); - const wrapper = mountPingPage(); - await wrapper.setData({ destinationHash: "a".repeat(32) }); - - // Mock sleep to resolve immediately - wrapper.vm.sleep = vi.fn().mockResolvedValue(); + render(PingPage); + const hashInput = screen.getByPlaceholderText(/7b746057/); + await fireEvent.input(hashInput, { target: { value: "a".repeat(32) } }); + await fireEvent.click(screen.getByText("ping.start_ping")); - // Start pinging, but make sure it stops - const pingSpy = vi.spyOn(wrapper.vm, "ping"); - - // We trigger start and then immediately set isRunning to false in the next microtask - wrapper.vm.start(); - await vi.waitFor(() => expect(pingSpy).toHaveBeenCalled()); - wrapper.vm.isRunning = false; + await waitFor(() => { + expect(screen.getByText(/duration=123.400ms/)).toBeTruthy(); + }); + expect(screen.getByText(/rssi=-50dBm/)).toBeTruthy(); + expect(screen.getByText("seq #1")).toBeTruthy(); - expect(wrapper.vm.pingResults.length).toBeGreaterThan(0); - expect(wrapper.vm.pingResults[0]).toContain("duration=123.400ms"); - expect(wrapper.vm.pingResults[0]).toContain("rssi=-50dBm"); - expect(wrapper.text()).toContain("seq #1"); + await fireEvent.click(screen.getByText("ping.stop")); }); it("terminates previous loop when stop and start are called sequentially", async () => { @@ -100,40 +137,26 @@ describe("PingPage.vue", () => { }, }); - const wrapper = mountPingPage(); - await wrapper.setData({ destinationHash: "a".repeat(32) }); - - // track calls - const pingSpy = vi.spyOn(wrapper.vm, "ping"); + render(PingPage); + const hashInput = screen.getByPlaceholderText(/7b746057/); + await fireEvent.input(hashInput, { target: { value: "a".repeat(32) } }); - wrapper.vm.sleep = vi.fn().mockImplementation(() => new Promise((r) => setTimeout(r, 50))); - - // Start 1st loop - wrapper.vm.start(); - await vi.waitFor(() => expect(pingSpy).toHaveBeenCalledTimes(1)); - - // Stop 1st loop and start 2nd loop immediately - wrapper.vm.stop(); - wrapper.vm.start(); - - // Wait a bit to let any concurrent loop run if it existed + await fireEvent.click(screen.getByText("ping.start_ping")); + await waitFor(() => expect(axiosMock.post).toHaveBeenCalled()); + await fireEvent.click(screen.getByText("ping.stop")); + await fireEvent.click(screen.getByText("ping.start_ping")); await new Promise((r) => setTimeout(r, 120)); - - // Total runs should be 1 from the first loop (before stop) and then continuing on the second loop. - // If the first loop did not terminate, they would both be calling ping, - // resulting in more calls than expected. - expect(pingSpy.mock.calls.length).toBeLessThanOrEqual(4); - - wrapper.vm.stop(); + expect(axiosMock.post.mock.calls.length).toBeLessThanOrEqual(4); + await fireEvent.click(screen.getByText("ping.stop")); }); it("calls drop path API", async () => { axiosMock.post.mockResolvedValue({ data: { message: "Path dropped" } }); - const wrapper = mountPingPage(); - await wrapper.setData({ destinationHash: "a".repeat(32) }); + render(PingPage); + const hashInput = screen.getByPlaceholderText(/7b746057/); + await fireEvent.input(hashInput, { target: { value: "a".repeat(32) } }); - const dropButton = wrapper.findAll("button").find((b) => b.text().includes("ping.drop_path")); - await dropButton.trigger("click"); + await fireEvent.click(screen.getByText("ping.drop_path")); expect(axiosMock.post).toHaveBeenCalledWith(`/api/v1/destination/${"a".repeat(32)}/drop-path`); expect(DialogUtils.alert).toHaveBeenCalledWith("Path dropped"); diff --git a/tests/frontend/ToolsPage.test.js b/tests/frontend/ToolsPage.test.js index f7ef900a3..e70308e70 100644 --- a/tests/frontend/ToolsPage.test.js +++ b/tests/frontend/ToolsPage.test.js @@ -1,105 +1,119 @@ -import { mount } from "@vue/test-utils"; -import { describe, it, expect, vi } from "vitest"; -import ToolsPage from "@/components/tools/ToolsPage.vue"; -import { createRouter, createWebHistory } from "vue-router"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, cleanup, fireEvent, waitFor, screen } from "@testing-library/svelte"; +import ToolsPage from "@/features/tools/ToolsPage.svelte"; +import { + filterTools, + groupTools, + loadCollapsedSections, + saveCollapsedSections, + toolRouteHref, +} from "@/features/tools/lib/toolsList.js"; import { registerCoreContributions } from "@/js/registries/registerCoreContributions.js"; +import { registerFallbackMessages, registerTranslator } from "@/js/i18n.js"; -describe("ToolsPage.vue", () => { - registerCoreContributions(); - const router = createRouter({ - history: createWebHistory(), - routes: [ - { path: "/ping", name: "ping", component: { template: "div" } }, - { path: "/rnprobe", name: "rnprobe", component: { template: "div" } }, - { path: "/rncp", name: "rncp", component: { template: "div" } }, - { path: "/rns-filesync", name: "rns-filesync", component: { template: "div" } }, - { path: "/rnsh", name: "rnsh", component: { template: "div" } }, - { path: "/rnstatus", name: "rnstatus", component: { template: "div" } }, - { path: "/rnpath", name: "rnpath", component: { template: "div" } }, - { path: "/rnpath-trace", name: "rnpath-trace", component: { template: "div" } }, - { path: "/translator", name: "translator", component: { template: "div" } }, - { path: "/bots", name: "bots", component: { template: "div" } }, - { path: "/propagation-nodes", name: "propagation-nodes", component: { template: "div" } }, - { path: "/forwarder", name: "forwarder", component: { template: "div" } }, - { path: "/documentation", name: "documentation", component: { template: "div" } }, - { path: "/micron-editor", name: "micron-editor", component: { template: "div" } }, - { path: "/tools/reticulum-config-editor", name: "reticulum-config-editor", component: { template: "div" } }, - { path: "/paper-message", name: "paper-message", component: { template: "div" } }, - { path: "/rnode-flasher", name: "rnode-flasher", component: { template: "div" } }, - { path: "/debug-logs", name: "debug-logs", component: { template: "div" } }, - { path: "/mesh-server", name: "mesh-server", component: { template: "div" } }, - { path: "/tools/repository-server", name: "repository-server", component: { template: "div" } }, - { path: "/tools/sieve-filters", name: "sieve-filters", component: { template: "div" } }, - ], +registerCoreContributions(); + +describe("toolsList helpers", () => { + it("builds hash hrefs", () => { + expect(toolRouteHref({ name: "ping" })).toBe("#/ping"); + expect(toolRouteHref({ name: "paper-message" })).toBe("#/tools/paper-message"); + expect(toolRouteHref({ path: "/rncp" })).toBe("#/rncp"); }); - const mountToolsPage = () => { - return mount(ToolsPage, { - global: { - plugins: [router], - mocks: { - $t: (key) => key, - }, - stubs: { - MaterialDesignIcon: { - template: '
', - props: ["iconName"], - }, + it("filters and groups tools", () => { + const tools = [ + { name: "ping", title: "Ping", description: "reach", group: "diagnostics" }, + { name: "rncp", title: "RNCP", description: "copy", group: "transfer" }, + ]; + expect(filterTools(tools, "ping")).toHaveLength(1); + expect(groupTools(tools)?.map((s) => s.id)).toEqual(["diagnostics", "transfer"]); + }); + + it("persists collapsed sections", () => { + localStorage.clear(); + saveCollapsedSections({ diagnostics: true }); + expect(loadCollapsedSections()).toEqual({ diagnostics: true }); + }); +}); + +describe("ToolsPage.svelte", () => { + beforeEach(() => { + localStorage.clear(); + registerTranslator(null); + registerFallbackMessages({ + common: { search: "Search", no_results: "common.no_results" }, + tools: { + power_tools: "tools.power_tools", + diagnostics_description: "desc", + alpha_badge: "tools.alpha_badge", + beta_badge: "beta", + coming_soon_badge: "soon", + group: { + diagnostics: "Diagnostics", + transfer: "Transfer", + messaging: "Messaging", + network: "Network", + other: "Other", }, + ping: { title: "tools.ping.title", description: "d" }, + rnsh: { title: "tools.rnsh.title", description: "d" }, + rns_filesync: { title: "tools.rns_filesync.title", description: "d" }, }, }); - }; + }); + + afterEach(() => { + cleanup(); + }); it("renders the tools page header", () => { - const wrapper = mountToolsPage(); - expect(wrapper.text()).toContain("tools.power_tools"); - expect(wrapper.text()).not.toContain("tools.utilities"); + render(ToolsPage); + expect(screen.getByText("tools.power_tools")).toBeTruthy(); + expect(screen.queryByText("tools.utilities")).toBeNull(); }); - it("renders all tool rows", () => { - const wrapper = mountToolsPage(); - const toolRows = wrapper.findAll(".tool-row"); - expect(toolRows.length).toBe(wrapper.vm.tools.length); + it("renders tool rows", () => { + const { container } = render(ToolsPage); + expect(container.querySelectorAll(".tool-row").length).toBeGreaterThan(5); }); it("filters tools based on search query", async () => { - const wrapper = mountToolsPage(); - const searchInput = wrapper.find("input"); - - await searchInput.setValue("ping"); - expect(wrapper.vm.filteredTools.length).toBe(1); - expect(wrapper.vm.filteredTools[0].name).toBe("ping"); - - await searchInput.setValue("nonexistenttool"); - expect(wrapper.vm.filteredTools.length).toBe(0); - expect(wrapper.text()).toContain("common.no_results"); + render(ToolsPage); + const searchInput = screen.getByPlaceholderText("Search"); + await fireEvent.input(searchInput, { target: { value: "ping" } }); + await waitFor(() => { + expect(screen.getByText("tools.ping.title")).toBeTruthy(); + }); + await fireEvent.input(searchInput, { target: { value: "nonexistenttool" } }); + expect(await screen.findByText("common.no_results")).toBeTruthy(); }); it("shows an alpha badge on the rnsh tool", () => { - const wrapper = mountToolsPage(); - const rnsh = wrapper.vm.tools.find((tool) => tool.name === "rnsh"); - expect(rnsh?.alpha).toBe(true); - const rnshRow = wrapper.findAll(".tool-row").find((row) => row.text().includes("tools.rnsh.title")); - expect(rnshRow?.text()).toContain("tools.alpha_badge"); + render(ToolsPage); + const rnsh = screen.getByText("tools.rnsh.title").closest(".tool-row"); + expect(rnsh?.textContent).toContain("tools.alpha_badge"); }); it("shows an alpha badge on the rns-filesync tool", () => { - const wrapper = mountToolsPage(); - const filesync = wrapper.vm.tools.find((tool) => tool.name === "rns-filesync"); - expect(filesync?.alpha).toBe(true); - const filesyncRow = wrapper.findAll(".tool-row").find((row) => row.text().includes("tools.rns_filesync.title")); - expect(filesyncRow?.text()).toContain("tools.alpha_badge"); + render(ToolsPage); + const row = screen.getByText("tools.rns_filesync.title").closest(".tool-row"); + expect(row?.textContent).toContain("tools.alpha_badge"); }); it("clears search query when close button is clicked", async () => { - const wrapper = mountToolsPage(); - const searchInput = wrapper.find("input"); - - await searchInput.setValue("ping"); - const clearButton = wrapper.find("button"); - await clearButton.trigger("click"); + render(ToolsPage); + const searchInput = screen.getByPlaceholderText("Search"); + await fireEvent.input(searchInput, { target: { value: "ping" } }); + await fireEvent.click(screen.getByLabelText("Clear search")); + expect(searchInput.value).toBe(""); + }); - expect(wrapper.vm.searchQuery).toBe(""); - expect(wrapper.vm.filteredTools.length).toBe(wrapper.vm.tools.length); + it("collapses a tools section when its header is clicked", async () => { + render(ToolsPage); + const toggle = screen.getByRole("button", { name: /Diagnostics/i }); + expect(toggle.getAttribute("aria-expanded")).toBe("true"); + await fireEvent.click(toggle); + expect(toggle.getAttribute("aria-expanded")).toBe("false"); + expect(loadCollapsedSections().diagnostics).toBe(true); }); }); diff --git a/tests/frontend/contextMenuStyles.test.js b/tests/frontend/contextMenuStyles.test.js index 7980959f3..4157cf139 100644 --- a/tests/frontend/contextMenuStyles.test.js +++ b/tests/frontend/contextMenuStyles.test.js @@ -37,7 +37,7 @@ describe("context menu styling", () => { it("uses ContextMenuPanel and ContextMenuItem on all right-click context menus", () => { const files = [ - "meshchatx/src/frontend/components/contacts/ContactsPage.vue", + "meshchatx/src/frontend/features/contacts/components/ContactsContextMenu.svelte", "meshchatx/src/frontend/components/messages/MessagesSidebar.vue", "meshchatx/src/frontend/components/messages/ConversationViewer.vue", "meshchatx/src/frontend/components/nomadnetwork/NomadNetworkSidebar.vue", @@ -45,8 +45,14 @@ describe("context menu styling", () => { ]; for (const f of files) { const src = readProjectFile(f); - expect(src, f).toContain("ContextMenuPanel"); - expect(src, f).toContain("ContextMenuItem"); + if (f.endsWith(".svelte")) { + expect(src, f).toContain("context-menu-panel"); + expect(src, f).toContain("context-item"); + expect(src, f).toContain("clampFloatingToViewport"); + } else { + expect(src, f).toContain("ContextMenuPanel"); + expect(src, f).toContain("ContextMenuItem"); + } } }); }); diff --git a/tests/frontend/featureModuleOwnership.test.js b/tests/frontend/featureModuleOwnership.test.js index f3a901553..78efa33a9 100644 --- a/tests/frontend/featureModuleOwnership.test.js +++ b/tests/frontend/featureModuleOwnership.test.js @@ -6,7 +6,12 @@ import { join } from "path"; import { clearRoutes, listRoutes } from "../../meshchatx/src/frontend/js/registries/routeRegistry.js"; import { clearFeatureIds, listFeatureIds } from "../../meshchatx/src/frontend/js/registries/featureRegistry.js"; import { registerBlockedFeature } from "../../meshchatx/src/frontend/features/blocked/index.js"; +import { registerContactsFeature } from "../../meshchatx/src/frontend/features/contacts/index.js"; +import { registerDebugLogsFeature } from "../../meshchatx/src/frontend/features/debug-logs/index.js"; import { registerForwarderFeature } from "../../meshchatx/src/frontend/features/forwarder/index.js"; +import { registerLicensesFeature } from "../../meshchatx/src/frontend/features/licenses/index.js"; +import { registerPingFeature } from "../../meshchatx/src/frontend/features/ping/index.js"; +import { registerToolsFeature } from "../../meshchatx/src/frontend/features/tools/index.js"; import { filterBlockedIdentities } from "../../meshchatx/src/frontend/features/blocked/lib/blockedList.js"; const repoRoot = process.cwd(); @@ -27,6 +32,30 @@ const FEATURE_MODULE_OWNERS = [ route_name: "blocked", mount: "svelte", }, + { + id: "contacts", + register: registerContactsFeature, + required_paths: [ + "meshchatx/src/frontend/features/contacts/index.js", + "meshchatx/src/frontend/features/contacts/ContactsPage.svelte", + "meshchatx/src/frontend/features/contacts/lib/contactUri.js", + "meshchatx/src/frontend/features/contacts/lib/contactsActions.js", + "meshchatx/src/frontend/features/contacts/components/ContactListRow.svelte", + ], + route_name: "contacts", + mount: "svelte", + }, + { + id: "debug-logs", + register: registerDebugLogsFeature, + required_paths: [ + "meshchatx/src/frontend/features/debug-logs/index.js", + "meshchatx/src/frontend/features/debug-logs/DebugLogsPage.svelte", + "meshchatx/src/frontend/features/debug-logs/lib/debugFormat.js", + ], + route_name: "debug-logs", + mount: "svelte", + }, { id: "forwarder", register: registerForwarderFeature, @@ -38,6 +67,40 @@ const FEATURE_MODULE_OWNERS = [ route_name: "forwarder", mount: "svelte", }, + { + id: "licenses", + register: registerLicensesFeature, + required_paths: [ + "meshchatx/src/frontend/features/licenses/index.js", + "meshchatx/src/frontend/features/licenses/LicensesPage.svelte", + "meshchatx/src/frontend/features/licenses/lib/licenseFilter.js", + ], + route_name: "licenses", + mount: "svelte", + }, + { + id: "ping", + register: registerPingFeature, + required_paths: [ + "meshchatx/src/frontend/features/ping/index.js", + "meshchatx/src/frontend/features/ping/PingPage.svelte", + "meshchatx/src/frontend/features/ping/lib/pingFormat.js", + ], + route_name: "ping", + mount: "svelte", + }, + { + id: "tools", + register: registerToolsFeature, + required_paths: [ + "meshchatx/src/frontend/features/tools/index.js", + "meshchatx/src/frontend/features/tools/ToolsPage.svelte", + "meshchatx/src/frontend/features/tools/ToolsSection.svelte", + "meshchatx/src/frontend/features/tools/lib/toolsList.js", + ], + route_name: "tools", + mount: "svelte", + }, ]; describe("feature module conveyor ownership", () => { diff --git a/tests/frontend/sidebarVirtualizationWiring.test.js b/tests/frontend/sidebarVirtualizationWiring.test.js index 8aa45a8e8..89447c4aa 100644 --- a/tests/frontend/sidebarVirtualizationWiring.test.js +++ b/tests/frontend/sidebarVirtualizationWiring.test.js @@ -26,9 +26,10 @@ describe("sidebar virtualization wiring oracle", () => { expect(src).toContain('@scroll="onNodesScroll"'); }); - it("ContactsPage virtualizes mergedContacts at the threshold", () => { - const src = readRepo("meshchatx/src/frontend/components/contacts/ContactsPage.vue"); - expect(src).toContain("SidebarVirtualList"); - expect(src).toContain("mergedContacts.length >= MIN_VIRTUAL_SIDEBAR_ITEMS"); + it("ContactsPage uses paginated list (virtual list deferred for Svelte port)", () => { + const src = readRepo("meshchatx/src/frontend/features/contacts/ContactsPage.svelte"); + expect(src).toContain("getContacts(true)"); + expect(src).toContain("mergedContacts"); + expect(src).not.toContain("SidebarVirtualList"); }); }); From 8170d61a9ffef358a0d9d040e0bc8c646986d0e7 Mon Sep 17 00:00:00 2001 From: Ivan Date: Sat, 5 Sep 2026 09:38:20 -0500 Subject: [PATCH 006/150] refactor(major): vue+js -> svelte+ts and various cleanup and changes to codebase brought to you by slopcoder5000 the worlds best model for massive refactors. --- .agents/conventions/frontend.md | 6 +- .agents/module-ownership.md | 4 +- .../skills/svelte-feature-modules/SKILL.md | 10 +- .dockerignore | 4 +- .github/workflows/android-apk-tag.yml | 1 - .github/workflows/android-build.yml | 1 - .github/workflows/docker.yml | 12 +- .github/workflows/security.yml | 2 +- CHANGELOG.md | 7 +- FAQ.md | 2 +- Makefile | 13 + README.md | 2 +- Taskfile.yml | 12 +- android/README.md | 3 +- android/app/build.gradle | 1 - android/app/src/main/AndroidManifest.xml | 9 + .../meshchatx/AndroidNotificationBridge.java | 170 +- .../java/com/meshchatx/LocalApiClient.java | 210 + .../com/meshchatx/MessagingReplyService.java | 83 + android/app/src/main/res/values/strings.xml | 4 + .../src/main/res/xml/automotive_app_desc.xml | 4 + .../meshchatx/AndroidAutoMessagingTest.java | 75 + Dockerfile => docker/Dockerfile | 0 Dockerfile.build => docker/Dockerfile.build | 4 +- .../Dockerfile.electron-wine | 4 +- .../Dockerfile.hardened | 0 .../docker-compose.coolify.yml | 0 .../docker-compose.demo.yml | 5 +- .../docker-compose.dev.yml | 4 +- .../docker-compose.yml | 0 docs/en/building.md | 10 +- docs/en/identity-and-security.md | 4 +- docs/en/installation.md | 8 +- electron/assets/css/electron-shell.css | 13167 +--------------- eslint.config.mjs | 21 +- meshchatx/meshchat.py | 6 - meshchatx/src/backend/altcha_auth.py | 95 - .../src/backend/data/THIRD_PARTY_NOTICES.txt | 170 +- .../src/backend/data/licenses_backend.json | 6 - .../src/backend/data/licenses_frontend.json | 302 +- meshchatx/src/backend/http/middleware.py | 2 - meshchatx/src/backend/http/routes/auth.py | 25 - meshchatx/src/backend/self_check.py | 2 +- meshchatx/src/frontend/components/App.vue | 19 + .../components/AudioWaveformPlayer.vue | 63 + .../src/frontend/components/auth/AuthPage.vue | 55 - .../frontend/components/call/CallOverlay.vue | 2 +- .../components/call/tabs/CallVoicemailTab.vue | 2 +- .../{clusterUtils.js => clusterUtils.ts} | 97 +- ...{discoveredIcons.js => discoveredIcons.ts} | 19 +- .../internal/{mapDedupe.js => mapDedupe.ts} | 75 +- .../{markerStyles.js => markerStyles.ts} | 192 +- .../messages/AudioWaveformPlayer.vue | 299 - .../messages/ConversationDropDownMenu.vue | 453 - .../messages/ConversationMessageEntry.vue | 1451 -- .../ConversationMessageListVirtual.vue | 82 - .../messages/ConversationPeerHeader.vue | 351 - .../messages/ConversationViewer.vue | 7642 --------- .../components/messages/InViewAnimatedImg.vue | 68 - .../messages/MessageReactionsOverlay.vue | 111 - .../components/messages/MessagesPage.vue | 1965 --- .../components/messages/MessagesSidebar.vue | 1668 -- .../messages/composer/AddAudioButton.vue | 111 - .../messages/composer/AddImageButton.vue | 172 - .../messages/composer/SendMessageButton.vue | 269 - .../components/messages/conversationScroll.js | 123 - .../modals/ConversationImageModal.vue | 104 - .../messages/modals/PaperMessageModal.vue | 372 - .../messages/modals/ShareContactModal.vue | 111 - .../OutboundTransferProgressFooter.vue | 69 - .../telemetry/TelemetryBatteryChart.vue | 244 - .../telemetry/TelemetryHistoryListItem.vue | 69 - .../telemetry/TelemetryHistoryModal.vue | 131 - ...rSliderMap.js => hopMaxFilterSliderMap.ts} | 13 +- ...tVirtual.js => relayMessageListVirtual.ts} | 39 +- ...LxmfConfigForm.js => botLxmfConfigForm.ts} | 40 +- .../features/blocked/{index.js => index.ts} | 2 +- .../lib/{blockedList.js => blockedList.ts} | 44 +- .../features/contacts/{index.js => index.ts} | 2 +- .../lib/{contactUri.js => contactUri.ts} | 45 +- ...{contactsActions.js => contactsActions.ts} | 123 +- .../{mergeContacts.js => mergeContacts.ts} | 17 +- .../features/debug-logs/DebugLogsPage.svelte | 18 +- .../debug-logs/{index.js => index.ts} | 2 +- .../lib/{debugFormat.js => debugFormat.ts} | 30 +- .../features/forwarder/{index.js => index.ts} | 2 +- .../{forwarderHash.js => forwarderHash.ts} | 6 +- .../features/licenses/{index.js => index.ts} | 2 +- .../{licenseFilter.js => licenseFilter.ts} | 14 +- .../features/messages/MessagesPage.svelte | 1357 ++ .../components/AudioWaveformPlayer.svelte | 275 + .../components/ComposerAttachmentStrip.svelte | 136 + .../ComposerEmojiStickerGifPicker.svelte | 361 + .../components/ConversationComposer.svelte | 235 + .../components/ConversationEmptyState.svelte | 246 + .../ConversationImageLightbox.svelte | 25 + .../ConversationMessageContextMenu.svelte | 228 + .../ConversationMessageEntry.svelte | 743 + .../ConversationMessageListVirtual.svelte | 120 + .../components/ConversationPeerHeader.svelte | 482 + .../ConversationRawMessageModal.svelte | 279 + .../ConversationReactionPicker.svelte | 70 + .../ConversationStrangerBanner.svelte | 44 + .../ConversationTranslateBars.svelte | 149 + .../components/ConversationViewer.svelte | 1328 ++ .../components/InViewAnimatedImg.svelte | 62 + .../components/MessagePanesHost.svelte | 161 + .../components/MessageReactionsOverlay.svelte | 85 + .../components/MessagesIngestDialog.svelte | 240 + .../components/MessagesMobileCompose.svelte | 87 + .../components/MessagesSidebar.svelte | 637 + .../components/composer/AddAudioButton.svelte | 97 + .../components/composer/AddImageButton.svelte | 144 + .../composer/SendMessageButton.svelte | 232 + .../modals/ConversationImageModal.svelte | 123 + .../modals/PaperMessageModal.svelte | 315 + .../modals/ShareContactModal.svelte | 127 + .../OutboundTransferProgressFooter.svelte | 49 + .../telemetry/TelemetryBatteryChart.svelte | 216 + .../telemetry/TelemetryHistoryListItem.svelte | 101 + .../telemetry/TelemetryHistoryModal.svelte | 130 + .../src/frontend/features/messages/index.ts | 29 + .../features/messages/lib/constants.ts | 63 + .../messages/lib/conversationAudioDecode.ts | 42 + .../lib/conversationDisplayGroups.ts} | 112 +- .../messages/lib/conversationDrafts.ts | 34 + .../messages/lib/conversationListApply.ts | 235 + .../lib/conversationMessageHelpers.ts} | 127 +- .../messages/lib/conversationOutboundUi.ts | 29 + .../messages/lib/conversationPaperIngest.ts} | 59 +- .../messages/lib/conversationPathActions.ts | 23 + .../features/messages/lib/conversationPeer.ts | 13 + .../messages/lib/conversationQuery.ts | 27 + .../messages/lib/conversationScroll.ts | 87 + .../messages/lib/conversationStickersGifs.ts | 13 + .../messages/lib/conversationViewerActions.ts | 351 + .../messages/lib/conversationViewerCtx.ts | 66 + .../messages/lib/conversationViewerGlobals.ts | 7 + .../lib/conversationViewerMessages.ts | 166 + .../messages/lib/conversationViewerPath.ts | 88 + .../messages/lib/conversationViewerSend.ts | 170 + .../messages/lib/lxmf/contactDisplay.ts} | 21 +- .../messages/lib/lxmf/normalize.ts} | 12 +- .../messages/lib/messageListVirtual.ts} | 65 +- .../features/messages/lib/paneLayout.ts | 190 + .../features/messages/lib/peerAnnounce.ts | 89 + .../frontend/features/messages/lib/types.ts | 98 + .../features/messages/lib/unreadDismiss.ts | 128 + .../features/messages/lib/viewerActions.ts | 144 + .../frontend/features/ping/PingPage.svelte | 4 +- .../features/ping/{index.js => index.ts} | 2 +- .../ping/lib/{pingFormat.js => pingFormat.ts} | 39 +- ...rAllFeatures.js => registerAllFeatures.ts} | 6 +- .../features/tools/ToolListRow.svelte | 65 +- .../frontend/features/tools/ToolsPage.svelte | 19 +- .../features/tools/{index.js => index.ts} | 2 +- .../tools/lib/{toolsList.js => toolsList.ts} | 69 +- meshchatx/src/frontend/index.html | 2 +- ...orageBridge.js => AndroidStorageBridge.ts} | 1 + .../js/{Codec2Loader.js => Codec2Loader.ts} | 10 +- .../js/{DialogUtils.js => DialogUtils.ts} | 33 +- .../js/{DownloadUtils.js => DownloadUtils.ts} | 2 +- .../js/{ElectronUtils.js => ElectronUtils.ts} | 0 .../js/{GeoWasmLoader.js => GeoWasmLoader.ts} | 2 +- .../js/{GlobalEmitter.js => GlobalEmitter.ts} | 9 +- .../js/{GlobalState.js => GlobalState.ts} | 0 .../js/{HeapMonitor.js => HeapMonitor.ts} | 0 .../js/{HelptipUtils.js => HelptipUtils.ts} | 0 ...boardShortcuts.js => KeyboardShortcuts.ts} | 18 +- .../js/{LinkUtils.js => LinkUtils.ts} | 0 ...arkdownRenderer.js => MarkdownRenderer.ts} | 0 .../js/{MicronParser.js => MicronParser.ts} | 29 +- .../js/{MicronStorage.js => MicronStorage.ts} | 9 +- ...icronWasmLoader.js => MicronWasmLoader.ts} | 2 +- ...erride.js => MicronWasmRuntimeOverride.ts} | 6 +- ...phoneRecorder.js => MicrophoneRecorder.ts} | 10 + ...rklet.js => MicrophoneRecorder.worklet.ts} | 13 +- ...adPageRenderer.js => NomadPageRenderer.ts} | 10 +- ...RichHtmlLinks.js => NomadRichHtmlLinks.ts} | 2 +- ...oundUtils.js => NotificationSoundUtils.ts} | 0 ...ificationUtils.js => NotificationUtils.ts} | 0 .../js/{TileCache.js => TileCache.ts} | 22 +- meshchatx/src/frontend/js/ToastUtils.js | 46 - meshchatx/src/frontend/js/ToastUtils.ts | 60 + .../js/{ToneGenerator.js => ToneGenerator.ts} | 8 + .../src/frontend/js/{Utils.js => Utils.ts} | 2 +- ...rWasmLoader.js => VisualiserWasmLoader.ts} | 2 +- ...etConnection.js => WebSocketConnection.ts} | 18 +- ...ionStore.js => activeConversationStore.ts} | 10 +- .../{activeSessions.js => activeSessions.ts} | 0 meshchatx/src/frontend/js/altchaWidget.js | 84 - ...derMap.js => announceIntervalSliderMap.ts} | 0 .../js/{apiClient.js => apiClient.ts} | 16 +- ...avEditHold.js => appSidebarNavEditHold.ts} | 2 +- ...barNavLayout.js => appSidebarNavLayout.ts} | 6 +- .../frontend/js/{appState.js => appState.ts} | 3 +- meshchatx/src/frontend/js/assets.d.ts | 8 + ...{authSessionSync.js => authSessionSync.ts} | 2 +- ...{bootSplashError.js => bootSplashError.ts} | 6 +- .../src/frontend/js/browser-globals.d.ts | 76 + ...erLayoutStore.js => browserLayoutStore.ts} | 2 +- ...Viewport.js => clampFloatingToViewport.ts} | 2 +- .../{clipboardUtils.js => clipboardUtils.ts} | 6 +- ...2DecodeLimits.js => codec2DecodeLimits.ts} | 0 ...ontextMenuUtils.js => contextMenuUtils.ts} | 2 +- .../js/{csrfToken.js => csrfToken.ts} | 0 ...lthWarning.js => databaseHealthWarning.ts} | 0 ...eliveryHelptips.js => deliveryHelptips.ts} | 2 +- .../frontend/js/{demoMode.js => demoMode.ts} | 4 - .../js/{demoUiPrefs.js => demoUiPrefs.ts} | 4 +- .../js/{deviceBattery.js => deviceBattery.ts} | 4 +- ...{fatalErrorState.js => fatalErrorState.ts} | 0 ...emoryWarning.js => healthMemoryWarning.ts} | 4 +- .../js/{helptipPolicy.js => helptipPolicy.ts} | 0 .../js/{httpRetry.js => httpRetry.ts} | 4 +- .../src/frontend/js/{i18n.js => i18n.ts} | 47 +- .../frontend/js/{idbOpen.js => idbOpen.ts} | 4 +- ...ntityHttpReady.js => identityHttpReady.ts} | 0 .../{inViewObserver.js => inViewObserver.ts} | 2 +- ...gation.js => ingestUriResultNavigation.ts} | 2 +- ...eryUtils.js => interfaceDiscoveryUtils.ts} | 2 +- .../js/{keepAlivePoll.js => keepAlivePoll.ts} | 0 .../{lanBindWarning.js => lanBindWarning.ts} | 2 +- .../js/{liveTransport.js => liveTransport.ts} | 26 +- ...eRetention.js => localMessageRetention.ts} | 0 .../js/{localeLoader.js => localeLoader.ts} | 4 +- ...eThemeOracles.js => localeThemeOracles.ts} | 0 ...istSync.js => lxmfConversationListSync.ts} | 0 ...nPreview.js => lxmfConversationPreview.ts} | 0 .../js/{lxmfReactions.js => lxmfReactions.ts} | 0 .../{constants.js => constants.ts} | 0 ...iptionFlatten.js => descriptionFlatten.ts} | 0 .../{geoJsonCodec.js => geoJsonCodec.ts} | 0 .../mapExchange/{gpxCodec.js => gpxCodec.ts} | 0 .../mapExchange/{kmlCodec.js => kmlCodec.ts} | 2 +- .../{kmlSanitize.js => kmlSanitize.ts} | 6 +- .../mapExchange/{kmzCodec.js => kmzCodec.ts} | 0 .../{metadataUtils.js => metadataUtils.ts} | 0 ...omProperties.js => styleFromProperties.ts} | 2 +- .../js/{mapGeoCoords.js => mapGeoCoords.ts} | 6 +- .../js/{mapGeodesy.js => mapGeodesy.ts} | 0 .../js/{mapLinkUtils.js => mapLinkUtils.ts} | 0 .../js/{mapLocalUrl.js => mapLocalUrl.ts} | 0 .../js/{mapStateKeys.js => mapStateKeys.ts} | 0 .../{mapTileNetwork.js => mapTileNetwork.ts} | 8 +- ...apTileProviders.js => mapTileProviders.ts} | 4 +- ...VectorWebFonts.js => mapVectorWebFonts.ts} | 0 .../js/{mdiIconNames.js => mdiIconNames.ts} | 4 +- ...splayLimits.js => messageDisplayLimits.ts} | 10 +- .../js/{messageImport.js => messageImport.ts} | 0 ...rouping.js => messageTimestampGrouping.ts} | 2 +- ...icronWasmUpload.js => micronWasmUpload.ts} | 0 ...ronWasmVersion.js => micronWasmVersion.ts} | 2 +- .../{navRouteActive.js => navRouteActive.ts} | 0 ...{networkRecovery.js => networkRecovery.ts} | 0 ...rkStartupWait.js => networkStartupWait.ts} | 8 +- ...iserCache.js => networkVisualiserCache.ts} | 8 +- ...aliserPerf.js => networkVisualiserPerf.ts} | 6 +- ...erPlanet.js => networkVisualiserPlanet.ts} | 14 +- ...iserWebGL.js => networkVisualiserWebGL.ts} | 2 +- ...ine.js => networkVisualiserWebGLEngine.ts} | 8 +- ...adCrashTabMain.js => nomadCrashTabMain.ts} | 11 +- ...CrashTabShell.js => nomadCrashTabShell.ts} | 0 ...omadCssSecurity.js => nomadCssSecurity.ts} | 0 ...Store.js => nomadFavouritesLayoutStore.ts} | 0 ...ownNodeName.js => nomadUnknownNodeName.ts} | 0 ...icationPolicy.js => notificationPolicy.ts} | 4 +- ...sageStatus.js => outboundMessageStatus.ts} | 0 ...boundSendQueue.js => outboundSendQueue.ts} | 0 .../plugins/{PluginHost.js => PluginHost.ts} | 3 + ...ginHostWidgets.js => pluginHostWidgets.ts} | 6 +- .../{pluginLabels.js => pluginLabels.ts} | 6 +- .../{pluginManifest.js => pluginManifest.ts} | 0 ...ginPermissions.js => pluginPermissions.ts} | 0 ...nUiDescriptor.js => pluginUiDescriptor.ts} | 2 +- ...luginUiRegistry.js => pluginUiRegistry.ts} | 0 .../{pluginWorker.js => pluginWorker.ts} | 10 +- ...omptState.js => postInstallPromptState.ts} | 2 +- .../{swCachePolicy.js => swCachePolicy.ts} | 0 ...wClientRegister.js => swClientRegister.ts} | 0 .../{swShellRuntime.js => swShellRuntime.ts} | 4 +- .../{qrScannerUtils.js => qrScannerUtils.ts} | 0 ...{commandRegistry.js => commandRegistry.ts} | 0 ...ommandEntries.js => coreCommandEntries.ts} | 0 .../{coreNavEntries.js => coreNavEntries.ts} | 0 ...ies.js => corePostInstallPromptEntries.ts} | 0 ...ords.js => coreSettingsSectionKeywords.ts} | 2 +- ...oreToolsEntries.js => coreToolsEntries.ts} | 0 ...{featureRegistry.js => featureRegistry.ts} | 0 .../{navRegistry.js => navRegistry.ts} | 0 ...gistry.js => postInstallPromptRegistry.ts} | 0 ...utions.js => registerCoreContributions.ts} | 0 .../{registryCore.js => registryCore.ts} | 0 .../{routeRegistry.js => routeRegistry.ts} | 0 ...Registry.js => settingsSectionRegistry.ts} | 2 +- .../{toolsRegistry.js => toolsRegistry.ts} | 2 +- .../{wsEventBridge.js => wsEventBridge.ts} | 0 ...{wsEventRegistry.js => wsEventRegistry.ts} | 0 ...dalClasses.js => relayHostModalClasses.ts} | 0 ...asses.js => relayHostModerationClasses.ts} | 0 ...elayLayoutStore.js => relayLayoutStore.ts} | 0 .../{relayLinkUtils.js => relayLinkUtils.ts} | 6 +- ...ayMentionCount.js => relayMentionCount.ts} | 0 ...MessageSearch.js => relayMessageSearch.ts} | 0 ...ageTimeline.js => relayMessageTimeline.ts} | 0 .../{releaseChannel.js => releaseChannel.ts} | 0 ...emoteBackendUrl.js => remoteBackendUrl.ts} | 0 ...ourceBreakdown.js => resourceBreakdown.ts} | 0 ...csEntryUrl.js => reticulumDocsEntryUrl.ts} | 0 ...vigation.js => reticulumDocsNavigation.ts} | 0 ...Pathfinding.js => reticulumPathfinding.ts} | 2 +- .../{AndroidBridge.js => AndroidBridge.ts} | 6 +- .../{Capabilities.js => Capabilities.ts} | 4 +- .../rnode/{Diagnostics.js => Diagnostics.ts} | 10 +- ...{Nrf52DfuFlasher.js => Nrf52DfuFlasher.ts} | 6 +- .../frontend/js/rnode/{RNode.js => RNode.ts} | 8 +- .../js/rnode/{RNodeUtils.js => RNodeUtils.ts} | 2 +- .../src/frontend/js/rnode/{ROM.js => ROM.ts} | 6 +- .../js/rnode/{products.js => products.ts} | 0 ...deIntegrityKey.js => rnodeIntegrityKey.ts} | 0 ...oothTransport.js => BluetoothTransport.ts} | 27 +- ...{SerialTransport.js => SerialTransport.ts} | 18 +- .../transports/{Transport.js => Transport.ts} | 3 + .../{WifiTransport.js => WifiTransport.ts} | 25 +- ...AvailableRooms.js => rrcAvailableRooms.ts} | 2 +- .../js/{sandboxStatus.js => sandboxStatus.ts} | 2 +- ...teApply.js => batterySaverBitrateApply.ts} | 4 +- ...terySaverPrefs.js => batterySaverPrefs.ts} | 8 +- ...iveryLimit.js => incomingDeliveryLimit.ts} | 0 ...figService.js => settingsConfigService.ts} | 0 ...Client.js => settingsMaintenanceClient.ts} | 0 ...js => settingsReticulumInstanceService.ts} | 0 .../{settingsTabs.js => settingsTabs.ts} | 0 ...Service.js => settingsTransportService.ts} | 0 ...serPrefs.js => settingsVisualiserPrefs.ts} | 8 +- ...sSearchUtils.js => settingsSearchUtils.ts} | 0 ...arListVirtual.js => sidebarListVirtual.ts} | 0 ...{appState.svelte.js => appState.svelte.ts} | 0 ...rtSpec.js => telemetryBatteryChartSpec.ts} | 4 +- ...et.js => telephone-pcm-capture.worklet.ts} | 13 +- .../{terminalRender.js => terminalRender.ts} | 0 .../js/{tgsDecode.js => tgsDecode.ts} | 0 ...Permission.js => webAudioMicPermission.ts} | 2 +- ...ctionSupport.js => wsConnectionSupport.ts} | 0 .../js/{wsLiveSync.js => wsLiveSync.ts} | 2 +- .../js/{wtJsonFraming.js => wtJsonFraming.ts} | 0 .../libs/{clickOutside.js => clickOutside.ts} | 2 +- .../libs/{datetime.js => datetime.ts} | 0 .../frontend/libs/{emitter.js => emitter.ts} | 0 .../src/frontend/libs/{index.js => index.ts} | 0 .../src/frontend/libs/{uuid.js => uuid.ts} | 61 +- meshchatx/src/frontend/locales/de.json | 10 +- meshchatx/src/frontend/locales/en.json | 10 +- meshchatx/src/frontend/locales/es.json | 10 +- meshchatx/src/frontend/locales/fi.json | 6 +- meshchatx/src/frontend/locales/fr.json | 10 +- meshchatx/src/frontend/locales/it.json | 10 +- meshchatx/src/frontend/locales/nl.json | 6 +- meshchatx/src/frontend/locales/ru.json | 10 +- meshchatx/src/frontend/locales/zh.json | 10 +- meshchatx/src/frontend/{main.js => main.ts} | 54 +- .../public/assets/css/electron-shell.css | 2 +- .../public/meshchatx-docs/en/building.md | 10 +- .../en/identity-and-security.md | 4 +- .../public/meshchatx-docs/en/installation.md | 8 +- .../src/frontend/public/service-worker.js | 6 +- ...ldRouterRoutes.js => buildRouterRoutes.ts} | 20 +- .../frontend/sw/service-worker.template.js | 2 +- .../{designTokens.js => designTokens.ts} | 28 +- .../theme/{themeEngine.js => themeEngine.ts} | 259 +- .../frontend/ui/svelte/LxmfUserIcon.svelte | 105 +- .../ui/svelte/MaterialDesignIcon.svelte | 6 +- .../src/frontend/ui/svelte/VueSfcHost.svelte | 49 + meshchatx/src/frontend/vite-env.d.ts | 9 + package.json | 4 +- pnpm-lock.yaml | 216 +- pyproject.toml | 1 - requirements.txt | 3 - scripts/build-android-wheels-local.sh | 16 - scripts/build/generate_service_worker.mjs | 5 +- scripts/ci/docker-build-entry.sh | 2 +- scripts/ci/docker-demo-smoke.sh | 14 +- scripts/ci/docker-electron-wine-entry.sh | 2 +- scripts/ci/docker-hardened-smoke.sh | 31 +- scripts/ci/docker-runtime-smoke.sh | 2 +- scripts/ci/github-apt-linux-packaging.sh | 2 +- scripts/ci/wine-python.sh | 2 +- scripts/docker/electron-wine-build.sh | 4 +- tests/backend/api_json_contract_schemas.py | 18 - tests/backend/conftest.py | 1 - .../fixtures/backend_module_ownership.json | 2 +- tests/backend/fixtures/http_api_routes.json | 4 - tests/backend/http_api_response_registry.py | 8 - tests/backend/test_altcha_oracle.py | 108 - tests/backend/test_demo_mode_http_oracle.py | 2 - tests/electron/electronShellAssets.test.js | 2 +- tests/frontend/AudioWaveformPlayer.test.js | 80 +- tests/frontend/AuthPage.test.js | 33 - tests/frontend/BootLoadSmoothness.test.js | 6 +- tests/frontend/CJKTextOverflow.test.js | 2 +- tests/frontend/ContactsPage.test.js | 6 +- .../ConversationDropDownMenu.shareApk.test.js | 108 - .../frontend/ConversationMessageEntry.test.js | 321 +- .../frontend/ConversationMobileChrome.test.js | 150 +- tests/frontend/ConversationPeerHeader.test.js | 92 +- ...onViewer.outboundPropagationStatus.test.js | 307 +- .../ConversationViewer.scroll.test.js | 293 +- tests/frontend/ConversationViewer.test.js | 2800 +--- .../ConversationViewerButtons.test.js | 587 +- ...ersationViewerPerformance.baseline.test.js | 205 +- .../ConversationViewerReactions.test.js | 371 +- tests/frontend/CspBootTheme.test.js | 4 +- tests/frontend/DebugLogsPage.test.js | 2 +- tests/frontend/ForwarderPage.test.js | 2 +- tests/frontend/Gifs.test.js | 447 +- tests/frontend/InViewAnimatedImg.test.js | 174 +- tests/frontend/LicensesPage.test.js | 2 +- tests/frontend/LoadTimePerformance.test.js | 30 +- .../frontend/MessageReactionsOverlay.test.js | 54 +- tests/frontend/MessageSendingFailures.test.js | 436 +- tests/frontend/MessagesPage.test.js | 906 +- tests/frontend/MessagesPageMultiPane.test.js | 483 +- .../MessagesPageSidebarIntegration.test.js | 176 +- tests/frontend/MessagesSidebar.test.js | 410 +- tests/frontend/MountLoadPerformance.test.js | 31 +- tests/frontend/NameOverwriteFixes.test.js | 415 +- tests/frontend/Performance.test.js | 64 +- tests/frontend/PingPage.test.js | 2 +- tests/frontend/Stickers.test.js | 424 +- tests/frontend/ToolsPage.test.js | 2 +- tests/frontend/UIComponents.test.js | 72 +- tests/frontend/altchaWidget.test.js | 43 - tests/frontend/behaviorContracts.test.js | 50 +- .../frontend/clampFloatingToViewport.test.js | 6 +- tests/frontend/contextMenuStyles.test.js | 3 +- .../conversationDisplayGroups.oracle.test.js | 2 +- .../frontend/conversationListApiCaps.test.js | 2 +- ...conversationLoadIncremental.oracle.test.js | 257 +- .../conversationMessageHelpers.test.js | 2 +- .../frontend/conversationPaperIngest.test.js | 2 +- tests/frontend/conversationScroll.test.js | 2 +- .../conversationViewerSelfPeer.test.js | 80 +- .../crossSurfaceIdentityUnreadOracle.test.js | 58 +- .../deepLinks.protocol.security.test.js | 16 + tests/frontend/featureModuleOwnership.test.js | 68 +- tests/frontend/featureRouteRegistry.test.js | 2 +- .../frontend_mega_page_ownership.json | 52 +- .../frontend_symbol_continuity/app.json | 2 +- .../frontend_symbol_continuity/messages.json | 10 +- .../frontendOwnershipContract.test.js | 2 +- tests/frontend/frontendStackOracle.test.js | 6 +- .../helpers/frontendOwnershipHelpers.js | 54 +- tests/frontend/hostFlipReadiness.test.js | 4 +- tests/frontend/layoutUxOracle.test.js | 25 +- tests/frontend/lxmfNormalize.test.js | 2 +- .../mapMessagesRnodeContracts.test.js | 24 +- tests/frontend/messageListVirtual.test.js | 4 +- ...messagesPageOutboundCompose.oracle.test.js | 141 +- tests/frontend/registries.test.js | 2 +- .../repositoryPaperPrintOracles.test.js | 39 +- tests/frontend/serviceWorkerBuild.test.js | 2 +- .../sidebarVirtualizationWiring.test.js | 11 +- tests/frontend/viteDx.test.js | 2 +- tsconfig.json | 1 + tsconfig.svelte.json | 7 +- uv.lock | 11 - vite.config.js | 4 +- vitest.config.js | 2 +- 468 files changed, 15452 insertions(+), 40003 deletions(-) create mode 100644 android/app/src/main/java/com/meshchatx/LocalApiClient.java create mode 100644 android/app/src/main/java/com/meshchatx/MessagingReplyService.java create mode 100644 android/app/src/main/res/xml/automotive_app_desc.xml create mode 100644 android/app/src/test/java/com/meshchatx/AndroidAutoMessagingTest.java rename Dockerfile => docker/Dockerfile (100%) rename Dockerfile.build => docker/Dockerfile.build (91%) rename Dockerfile.electron-wine => docker/Dockerfile.electron-wine (95%) rename Dockerfile.hardened => docker/Dockerfile.hardened (100%) rename docker-compose.coolify.yml => docker/docker-compose.coolify.yml (100%) rename docker-compose.demo.yml => docker/docker-compose.demo.yml (89%) rename docker-compose.dev.yml => docker/docker-compose.dev.yml (95%) rename docker-compose.yml => docker/docker-compose.yml (100%) delete mode 100644 meshchatx/src/backend/altcha_auth.py create mode 100644 meshchatx/src/frontend/components/AudioWaveformPlayer.vue rename meshchatx/src/frontend/components/map/internal/{clusterUtils.js => clusterUtils.ts} (50%) rename meshchatx/src/frontend/components/map/internal/{discoveredIcons.js => discoveredIcons.ts} (71%) rename meshchatx/src/frontend/components/map/internal/{mapDedupe.js => mapDedupe.ts} (51%) rename meshchatx/src/frontend/components/map/internal/{markerStyles.js => markerStyles.ts} (66%) delete mode 100644 meshchatx/src/frontend/components/messages/AudioWaveformPlayer.vue delete mode 100644 meshchatx/src/frontend/components/messages/ConversationDropDownMenu.vue delete mode 100644 meshchatx/src/frontend/components/messages/ConversationMessageEntry.vue delete mode 100644 meshchatx/src/frontend/components/messages/ConversationMessageListVirtual.vue delete mode 100644 meshchatx/src/frontend/components/messages/ConversationPeerHeader.vue delete mode 100644 meshchatx/src/frontend/components/messages/ConversationViewer.vue delete mode 100644 meshchatx/src/frontend/components/messages/InViewAnimatedImg.vue delete mode 100644 meshchatx/src/frontend/components/messages/MessageReactionsOverlay.vue delete mode 100644 meshchatx/src/frontend/components/messages/MessagesPage.vue delete mode 100644 meshchatx/src/frontend/components/messages/MessagesSidebar.vue delete mode 100644 meshchatx/src/frontend/components/messages/composer/AddAudioButton.vue delete mode 100644 meshchatx/src/frontend/components/messages/composer/AddImageButton.vue delete mode 100644 meshchatx/src/frontend/components/messages/composer/SendMessageButton.vue delete mode 100644 meshchatx/src/frontend/components/messages/conversationScroll.js delete mode 100644 meshchatx/src/frontend/components/messages/modals/ConversationImageModal.vue delete mode 100644 meshchatx/src/frontend/components/messages/modals/PaperMessageModal.vue delete mode 100644 meshchatx/src/frontend/components/messages/modals/ShareContactModal.vue delete mode 100644 meshchatx/src/frontend/components/messages/outbound/OutboundTransferProgressFooter.vue delete mode 100644 meshchatx/src/frontend/components/messages/telemetry/TelemetryBatteryChart.vue delete mode 100644 meshchatx/src/frontend/components/messages/telemetry/TelemetryHistoryListItem.vue delete mode 100644 meshchatx/src/frontend/components/messages/telemetry/TelemetryHistoryModal.vue rename meshchatx/src/frontend/components/network-visualiser/internal/{hopMaxFilterSliderMap.js => hopMaxFilterSliderMap.ts} (70%) rename meshchatx/src/frontend/components/relay/{relayMessageListVirtual.js => relayMessageListVirtual.ts} (52%) rename meshchatx/src/frontend/components/tools/internal/{botLxmfConfigForm.js => botLxmfConfigForm.ts} (71%) rename meshchatx/src/frontend/features/blocked/{index.js => index.ts} (90%) rename meshchatx/src/frontend/features/blocked/lib/{blockedList.js => blockedList.ts} (72%) rename meshchatx/src/frontend/features/contacts/{index.js => index.ts} (88%) rename meshchatx/src/frontend/features/contacts/lib/{contactUri.js => contactUri.ts} (69%) rename meshchatx/src/frontend/features/contacts/lib/{contactsActions.js => contactsActions.ts} (61%) rename meshchatx/src/frontend/features/contacts/lib/{mergeContacts.js => mergeContacts.ts} (66%) rename meshchatx/src/frontend/features/debug-logs/{index.js => index.ts} (88%) rename meshchatx/src/frontend/features/debug-logs/lib/{debugFormat.js => debugFormat.ts} (59%) rename meshchatx/src/frontend/features/forwarder/{index.js => index.ts} (89%) rename meshchatx/src/frontend/features/forwarder/lib/{forwarderHash.js => forwarderHash.ts} (55%) rename meshchatx/src/frontend/features/licenses/{index.js => index.ts} (88%) rename meshchatx/src/frontend/features/licenses/lib/{licenseFilter.js => licenseFilter.ts} (60%) create mode 100644 meshchatx/src/frontend/features/messages/MessagesPage.svelte create mode 100644 meshchatx/src/frontend/features/messages/components/AudioWaveformPlayer.svelte create mode 100644 meshchatx/src/frontend/features/messages/components/ComposerAttachmentStrip.svelte create mode 100644 meshchatx/src/frontend/features/messages/components/ComposerEmojiStickerGifPicker.svelte create mode 100644 meshchatx/src/frontend/features/messages/components/ConversationComposer.svelte create mode 100644 meshchatx/src/frontend/features/messages/components/ConversationEmptyState.svelte create mode 100644 meshchatx/src/frontend/features/messages/components/ConversationImageLightbox.svelte create mode 100644 meshchatx/src/frontend/features/messages/components/ConversationMessageContextMenu.svelte create mode 100644 meshchatx/src/frontend/features/messages/components/ConversationMessageEntry.svelte create mode 100644 meshchatx/src/frontend/features/messages/components/ConversationMessageListVirtual.svelte create mode 100644 meshchatx/src/frontend/features/messages/components/ConversationPeerHeader.svelte create mode 100644 meshchatx/src/frontend/features/messages/components/ConversationRawMessageModal.svelte create mode 100644 meshchatx/src/frontend/features/messages/components/ConversationReactionPicker.svelte create mode 100644 meshchatx/src/frontend/features/messages/components/ConversationStrangerBanner.svelte create mode 100644 meshchatx/src/frontend/features/messages/components/ConversationTranslateBars.svelte create mode 100644 meshchatx/src/frontend/features/messages/components/ConversationViewer.svelte create mode 100644 meshchatx/src/frontend/features/messages/components/InViewAnimatedImg.svelte create mode 100644 meshchatx/src/frontend/features/messages/components/MessagePanesHost.svelte create mode 100644 meshchatx/src/frontend/features/messages/components/MessageReactionsOverlay.svelte create mode 100644 meshchatx/src/frontend/features/messages/components/MessagesIngestDialog.svelte create mode 100644 meshchatx/src/frontend/features/messages/components/MessagesMobileCompose.svelte create mode 100644 meshchatx/src/frontend/features/messages/components/MessagesSidebar.svelte create mode 100644 meshchatx/src/frontend/features/messages/components/composer/AddAudioButton.svelte create mode 100644 meshchatx/src/frontend/features/messages/components/composer/AddImageButton.svelte create mode 100644 meshchatx/src/frontend/features/messages/components/composer/SendMessageButton.svelte create mode 100644 meshchatx/src/frontend/features/messages/components/modals/ConversationImageModal.svelte create mode 100644 meshchatx/src/frontend/features/messages/components/modals/PaperMessageModal.svelte create mode 100644 meshchatx/src/frontend/features/messages/components/modals/ShareContactModal.svelte create mode 100644 meshchatx/src/frontend/features/messages/components/outbound/OutboundTransferProgressFooter.svelte create mode 100644 meshchatx/src/frontend/features/messages/components/telemetry/TelemetryBatteryChart.svelte create mode 100644 meshchatx/src/frontend/features/messages/components/telemetry/TelemetryHistoryListItem.svelte create mode 100644 meshchatx/src/frontend/features/messages/components/telemetry/TelemetryHistoryModal.svelte create mode 100644 meshchatx/src/frontend/features/messages/index.ts create mode 100644 meshchatx/src/frontend/features/messages/lib/constants.ts create mode 100644 meshchatx/src/frontend/features/messages/lib/conversationAudioDecode.ts rename meshchatx/src/frontend/{components/messages/conversationDisplayGroups.js => features/messages/lib/conversationDisplayGroups.ts} (61%) create mode 100644 meshchatx/src/frontend/features/messages/lib/conversationDrafts.ts create mode 100644 meshchatx/src/frontend/features/messages/lib/conversationListApply.ts rename meshchatx/src/frontend/{components/messages/conversationMessageHelpers.js => features/messages/lib/conversationMessageHelpers.ts} (59%) create mode 100644 meshchatx/src/frontend/features/messages/lib/conversationOutboundUi.ts rename meshchatx/src/frontend/{components/messages/conversationPaperIngest.js => features/messages/lib/conversationPaperIngest.ts} (56%) create mode 100644 meshchatx/src/frontend/features/messages/lib/conversationPathActions.ts create mode 100644 meshchatx/src/frontend/features/messages/lib/conversationPeer.ts create mode 100644 meshchatx/src/frontend/features/messages/lib/conversationQuery.ts create mode 100644 meshchatx/src/frontend/features/messages/lib/conversationScroll.ts create mode 100644 meshchatx/src/frontend/features/messages/lib/conversationStickersGifs.ts create mode 100644 meshchatx/src/frontend/features/messages/lib/conversationViewerActions.ts create mode 100644 meshchatx/src/frontend/features/messages/lib/conversationViewerCtx.ts create mode 100644 meshchatx/src/frontend/features/messages/lib/conversationViewerGlobals.ts create mode 100644 meshchatx/src/frontend/features/messages/lib/conversationViewerMessages.ts create mode 100644 meshchatx/src/frontend/features/messages/lib/conversationViewerPath.ts create mode 100644 meshchatx/src/frontend/features/messages/lib/conversationViewerSend.ts rename meshchatx/src/frontend/{components/messages/lxmf/contactDisplay.js => features/messages/lib/lxmf/contactDisplay.ts} (61%) rename meshchatx/src/frontend/{components/messages/lxmf/normalize.js => features/messages/lib/lxmf/normalize.ts} (74%) rename meshchatx/src/frontend/{components/messages/messageListVirtual.js => features/messages/lib/messageListVirtual.ts} (51%) create mode 100644 meshchatx/src/frontend/features/messages/lib/paneLayout.ts create mode 100644 meshchatx/src/frontend/features/messages/lib/peerAnnounce.ts create mode 100644 meshchatx/src/frontend/features/messages/lib/types.ts create mode 100644 meshchatx/src/frontend/features/messages/lib/unreadDismiss.ts create mode 100644 meshchatx/src/frontend/features/messages/lib/viewerActions.ts rename meshchatx/src/frontend/features/ping/{index.js => index.ts} (88%) rename meshchatx/src/frontend/features/ping/lib/{pingFormat.js => pingFormat.ts} (63%) rename meshchatx/src/frontend/features/{registerAllFeatures.js => registerAllFeatures.ts} (84%) rename meshchatx/src/frontend/features/tools/{index.js => index.ts} (89%) rename meshchatx/src/frontend/features/tools/lib/{toolsList.js => toolsList.ts} (70%) rename meshchatx/src/frontend/js/{AndroidStorageBridge.js => AndroidStorageBridge.ts} (99%) rename meshchatx/src/frontend/js/{Codec2Loader.js => Codec2Loader.ts} (94%) rename meshchatx/src/frontend/js/{DialogUtils.js => DialogUtils.ts} (57%) rename meshchatx/src/frontend/js/{DownloadUtils.js => DownloadUtils.ts} (98%) rename meshchatx/src/frontend/js/{ElectronUtils.js => ElectronUtils.ts} (100%) rename meshchatx/src/frontend/js/{GeoWasmLoader.js => GeoWasmLoader.ts} (99%) rename meshchatx/src/frontend/js/{GlobalEmitter.js => GlobalEmitter.ts} (68%) rename meshchatx/src/frontend/js/{GlobalState.js => GlobalState.ts} (100%) rename meshchatx/src/frontend/js/{HeapMonitor.js => HeapMonitor.ts} (100%) rename meshchatx/src/frontend/js/{HelptipUtils.js => HelptipUtils.ts} (100%) rename meshchatx/src/frontend/js/{KeyboardShortcuts.js => KeyboardShortcuts.ts} (89%) rename meshchatx/src/frontend/js/{LinkUtils.js => LinkUtils.ts} (100%) rename meshchatx/src/frontend/js/{MarkdownRenderer.js => MarkdownRenderer.ts} (100%) rename meshchatx/src/frontend/js/{MicronParser.js => MicronParser.ts} (96%) rename meshchatx/src/frontend/js/{MicronStorage.js => MicronStorage.ts} (93%) rename meshchatx/src/frontend/js/{MicronWasmLoader.js => MicronWasmLoader.ts} (99%) rename meshchatx/src/frontend/js/{MicronWasmRuntimeOverride.js => MicronWasmRuntimeOverride.ts} (96%) rename meshchatx/src/frontend/js/{MicrophoneRecorder.js => MicrophoneRecorder.ts} (96%) rename meshchatx/src/frontend/js/{MicrophoneRecorder.worklet.js => MicrophoneRecorder.worklet.ts} (69%) rename meshchatx/src/frontend/js/{NomadPageRenderer.js => NomadPageRenderer.ts} (97%) rename meshchatx/src/frontend/js/{NomadRichHtmlLinks.js => NomadRichHtmlLinks.ts} (98%) rename meshchatx/src/frontend/js/{NotificationSoundUtils.js => NotificationSoundUtils.ts} (100%) rename meshchatx/src/frontend/js/{NotificationUtils.js => NotificationUtils.ts} (100%) rename meshchatx/src/frontend/js/{TileCache.js => TileCache.ts} (94%) delete mode 100644 meshchatx/src/frontend/js/ToastUtils.js create mode 100644 meshchatx/src/frontend/js/ToastUtils.ts rename meshchatx/src/frontend/js/{ToneGenerator.js => ToneGenerator.ts} (97%) rename meshchatx/src/frontend/js/{Utils.js => Utils.ts} (99%) rename meshchatx/src/frontend/js/{VisualiserWasmLoader.js => VisualiserWasmLoader.ts} (99%) rename meshchatx/src/frontend/js/{WebSocketConnection.js => WebSocketConnection.ts} (95%) rename meshchatx/src/frontend/js/{activeConversationStore.js => activeConversationStore.ts} (91%) rename meshchatx/src/frontend/js/{activeSessions.js => activeSessions.ts} (100%) delete mode 100644 meshchatx/src/frontend/js/altchaWidget.js rename meshchatx/src/frontend/js/{announceIntervalSliderMap.js => announceIntervalSliderMap.ts} (100%) rename meshchatx/src/frontend/js/{apiClient.js => apiClient.ts} (95%) rename meshchatx/src/frontend/js/{appSidebarNavEditHold.js => appSidebarNavEditHold.ts} (99%) rename meshchatx/src/frontend/js/{appSidebarNavLayout.js => appSidebarNavLayout.ts} (99%) rename meshchatx/src/frontend/js/{appState.js => appState.ts} (96%) create mode 100644 meshchatx/src/frontend/js/assets.d.ts rename meshchatx/src/frontend/js/{authSessionSync.js => authSessionSync.ts} (97%) rename meshchatx/src/frontend/js/{bootSplashError.js => bootSplashError.ts} (91%) create mode 100644 meshchatx/src/frontend/js/browser-globals.d.ts rename meshchatx/src/frontend/js/{browserLayoutStore.js => browserLayoutStore.ts} (99%) rename meshchatx/src/frontend/js/{clampFloatingToViewport.js => clampFloatingToViewport.ts} (96%) rename meshchatx/src/frontend/js/{clipboardUtils.js => clipboardUtils.ts} (96%) rename meshchatx/src/frontend/js/{codec2DecodeLimits.js => codec2DecodeLimits.ts} (100%) rename meshchatx/src/frontend/js/{contextMenuUtils.js => contextMenuUtils.ts} (98%) rename meshchatx/src/frontend/js/{csrfToken.js => csrfToken.ts} (100%) rename meshchatx/src/frontend/js/{databaseHealthWarning.js => databaseHealthWarning.ts} (100%) rename meshchatx/src/frontend/js/{deliveryHelptips.js => deliveryHelptips.ts} (99%) rename meshchatx/src/frontend/js/{demoMode.js => demoMode.ts} (55%) rename meshchatx/src/frontend/js/{demoUiPrefs.js => demoUiPrefs.ts} (98%) rename meshchatx/src/frontend/js/{deviceBattery.js => deviceBattery.ts} (98%) rename meshchatx/src/frontend/js/{fatalErrorState.js => fatalErrorState.ts} (100%) rename meshchatx/src/frontend/js/{healthMemoryWarning.js => healthMemoryWarning.ts} (98%) rename meshchatx/src/frontend/js/{helptipPolicy.js => helptipPolicy.ts} (100%) rename meshchatx/src/frontend/js/{httpRetry.js => httpRetry.ts} (95%) rename meshchatx/src/frontend/js/{i18n.js => i18n.ts} (51%) rename meshchatx/src/frontend/js/{idbOpen.js => idbOpen.ts} (93%) rename meshchatx/src/frontend/js/{identityHttpReady.js => identityHttpReady.ts} (100%) rename meshchatx/src/frontend/js/{inViewObserver.js => inViewObserver.ts} (93%) rename meshchatx/src/frontend/js/{ingestUriResultNavigation.js => ingestUriResultNavigation.ts} (98%) rename meshchatx/src/frontend/js/{interfaceDiscoveryUtils.js => interfaceDiscoveryUtils.ts} (97%) rename meshchatx/src/frontend/js/{keepAlivePoll.js => keepAlivePoll.ts} (100%) rename meshchatx/src/frontend/js/{lanBindWarning.js => lanBindWarning.ts} (98%) rename meshchatx/src/frontend/js/{liveTransport.js => liveTransport.ts} (93%) rename meshchatx/src/frontend/js/{localMessageRetention.js => localMessageRetention.ts} (100%) rename meshchatx/src/frontend/js/{localeLoader.js => localeLoader.ts} (98%) rename meshchatx/src/frontend/js/{localeThemeOracles.js => localeThemeOracles.ts} (100%) rename meshchatx/src/frontend/js/{lxmfConversationListSync.js => lxmfConversationListSync.ts} (100%) rename meshchatx/src/frontend/js/{lxmfConversationPreview.js => lxmfConversationPreview.ts} (100%) rename meshchatx/src/frontend/js/{lxmfReactions.js => lxmfReactions.ts} (100%) rename meshchatx/src/frontend/js/mapExchange/{constants.js => constants.ts} (100%) rename meshchatx/src/frontend/js/mapExchange/{descriptionFlatten.js => descriptionFlatten.ts} (100%) rename meshchatx/src/frontend/js/mapExchange/{geoJsonCodec.js => geoJsonCodec.ts} (100%) rename meshchatx/src/frontend/js/mapExchange/{gpxCodec.js => gpxCodec.ts} (100%) rename meshchatx/src/frontend/js/mapExchange/{kmlCodec.js => kmlCodec.ts} (98%) rename meshchatx/src/frontend/js/mapExchange/{kmlSanitize.js => kmlSanitize.ts} (97%) rename meshchatx/src/frontend/js/mapExchange/{kmzCodec.js => kmzCodec.ts} (100%) rename meshchatx/src/frontend/js/mapExchange/{metadataUtils.js => metadataUtils.ts} (100%) rename meshchatx/src/frontend/js/mapExchange/{styleFromProperties.js => styleFromProperties.ts} (99%) rename meshchatx/src/frontend/js/{mapGeoCoords.js => mapGeoCoords.ts} (96%) rename meshchatx/src/frontend/js/{mapGeodesy.js => mapGeodesy.ts} (100%) rename meshchatx/src/frontend/js/{mapLinkUtils.js => mapLinkUtils.ts} (100%) rename meshchatx/src/frontend/js/{mapLocalUrl.js => mapLocalUrl.ts} (100%) rename meshchatx/src/frontend/js/{mapStateKeys.js => mapStateKeys.ts} (100%) rename meshchatx/src/frontend/js/{mapTileNetwork.js => mapTileNetwork.ts} (91%) rename meshchatx/src/frontend/js/{mapTileProviders.js => mapTileProviders.ts} (97%) rename meshchatx/src/frontend/js/{mapVectorWebFonts.js => mapVectorWebFonts.ts} (100%) rename meshchatx/src/frontend/js/{mdiIconNames.js => mdiIconNames.ts} (98%) rename meshchatx/src/frontend/js/{messageDisplayLimits.js => messageDisplayLimits.ts} (54%) rename meshchatx/src/frontend/js/{messageImport.js => messageImport.ts} (100%) rename meshchatx/src/frontend/js/{messageTimestampGrouping.js => messageTimestampGrouping.ts} (99%) rename meshchatx/src/frontend/js/{micronWasmUpload.js => micronWasmUpload.ts} (100%) rename meshchatx/src/frontend/js/{micronWasmVersion.js => micronWasmVersion.ts} (96%) rename meshchatx/src/frontend/js/{navRouteActive.js => navRouteActive.ts} (100%) rename meshchatx/src/frontend/js/{networkRecovery.js => networkRecovery.ts} (100%) rename meshchatx/src/frontend/js/{networkStartupWait.js => networkStartupWait.ts} (95%) rename meshchatx/src/frontend/js/{networkVisualiserCache.js => networkVisualiserCache.ts} (95%) rename meshchatx/src/frontend/js/{networkVisualiserPerf.js => networkVisualiserPerf.ts} (99%) rename meshchatx/src/frontend/js/{networkVisualiserPlanet.js => networkVisualiserPlanet.ts} (99%) rename meshchatx/src/frontend/js/{networkVisualiserWebGL.js => networkVisualiserWebGL.ts} (99%) rename meshchatx/src/frontend/js/{networkVisualiserWebGLEngine.js => networkVisualiserWebGLEngine.ts} (99%) rename meshchatx/src/frontend/js/{nomadCrashTabMain.js => nomadCrashTabMain.ts} (97%) rename meshchatx/src/frontend/js/{nomadCrashTabShell.js => nomadCrashTabShell.ts} (100%) rename meshchatx/src/frontend/js/{nomadCssSecurity.js => nomadCssSecurity.ts} (100%) rename meshchatx/src/frontend/js/{nomadFavouritesLayoutStore.js => nomadFavouritesLayoutStore.ts} (100%) rename meshchatx/src/frontend/js/{nomadUnknownNodeName.js => nomadUnknownNodeName.ts} (100%) rename meshchatx/src/frontend/js/{notificationPolicy.js => notificationPolicy.ts} (99%) rename meshchatx/src/frontend/js/{outboundMessageStatus.js => outboundMessageStatus.ts} (100%) rename meshchatx/src/frontend/js/{outboundSendQueue.js => outboundSendQueue.ts} (100%) rename meshchatx/src/frontend/js/plugins/{PluginHost.js => PluginHost.ts} (99%) rename meshchatx/src/frontend/js/plugins/{pluginHostWidgets.js => pluginHostWidgets.ts} (96%) rename meshchatx/src/frontend/js/plugins/{pluginLabels.js => pluginLabels.ts} (95%) rename meshchatx/src/frontend/js/plugins/{pluginManifest.js => pluginManifest.ts} (100%) rename meshchatx/src/frontend/js/plugins/{pluginPermissions.js => pluginPermissions.ts} (100%) rename meshchatx/src/frontend/js/plugins/{pluginUiDescriptor.js => pluginUiDescriptor.ts} (98%) rename meshchatx/src/frontend/js/plugins/{pluginUiRegistry.js => pluginUiRegistry.ts} (100%) rename meshchatx/src/frontend/js/plugins/{pluginWorker.js => pluginWorker.ts} (96%) rename meshchatx/src/frontend/js/{postInstallPromptState.js => postInstallPromptState.ts} (98%) rename meshchatx/src/frontend/js/pwa/{swCachePolicy.js => swCachePolicy.ts} (100%) rename meshchatx/src/frontend/js/pwa/{swClientRegister.js => swClientRegister.ts} (100%) rename meshchatx/src/frontend/js/pwa/{swShellRuntime.js => swShellRuntime.ts} (99%) rename meshchatx/src/frontend/js/{qrScannerUtils.js => qrScannerUtils.ts} (100%) rename meshchatx/src/frontend/js/registries/{commandRegistry.js => commandRegistry.ts} (100%) rename meshchatx/src/frontend/js/registries/{coreCommandEntries.js => coreCommandEntries.ts} (100%) rename meshchatx/src/frontend/js/registries/{coreNavEntries.js => coreNavEntries.ts} (100%) rename meshchatx/src/frontend/js/registries/{corePostInstallPromptEntries.js => corePostInstallPromptEntries.ts} (100%) rename meshchatx/src/frontend/js/registries/{coreSettingsSectionKeywords.js => coreSettingsSectionKeywords.ts} (99%) rename meshchatx/src/frontend/js/registries/{coreToolsEntries.js => coreToolsEntries.ts} (100%) rename meshchatx/src/frontend/js/registries/{featureRegistry.js => featureRegistry.ts} (100%) rename meshchatx/src/frontend/js/registries/{navRegistry.js => navRegistry.ts} (100%) rename meshchatx/src/frontend/js/registries/{postInstallPromptRegistry.js => postInstallPromptRegistry.ts} (100%) rename meshchatx/src/frontend/js/registries/{registerCoreContributions.js => registerCoreContributions.ts} (100%) rename meshchatx/src/frontend/js/registries/{registryCore.js => registryCore.ts} (100%) rename meshchatx/src/frontend/js/registries/{routeRegistry.js => routeRegistry.ts} (100%) rename meshchatx/src/frontend/js/registries/{settingsSectionRegistry.js => settingsSectionRegistry.ts} (98%) rename meshchatx/src/frontend/js/registries/{toolsRegistry.js => toolsRegistry.ts} (94%) rename meshchatx/src/frontend/js/registries/{wsEventBridge.js => wsEventBridge.ts} (100%) rename meshchatx/src/frontend/js/registries/{wsEventRegistry.js => wsEventRegistry.ts} (100%) rename meshchatx/src/frontend/js/{relayHostModalClasses.js => relayHostModalClasses.ts} (100%) rename meshchatx/src/frontend/js/{relayHostModerationClasses.js => relayHostModerationClasses.ts} (100%) rename meshchatx/src/frontend/js/{relayLayoutStore.js => relayLayoutStore.ts} (100%) rename meshchatx/src/frontend/js/{relayLinkUtils.js => relayLinkUtils.ts} (96%) rename meshchatx/src/frontend/js/{relayMentionCount.js => relayMentionCount.ts} (100%) rename meshchatx/src/frontend/js/{relayMessageSearch.js => relayMessageSearch.ts} (100%) rename meshchatx/src/frontend/js/{relayMessageTimeline.js => relayMessageTimeline.ts} (100%) rename meshchatx/src/frontend/js/{releaseChannel.js => releaseChannel.ts} (100%) rename meshchatx/src/frontend/js/{remoteBackendUrl.js => remoteBackendUrl.ts} (100%) rename meshchatx/src/frontend/js/{resourceBreakdown.js => resourceBreakdown.ts} (100%) rename meshchatx/src/frontend/js/{reticulumDocsEntryUrl.js => reticulumDocsEntryUrl.ts} (100%) rename meshchatx/src/frontend/js/{reticulumDocsNavigation.js => reticulumDocsNavigation.ts} (100%) rename meshchatx/src/frontend/js/{reticulumPathfinding.js => reticulumPathfinding.ts} (99%) rename meshchatx/src/frontend/js/rnode/{AndroidBridge.js => AndroidBridge.ts} (98%) rename meshchatx/src/frontend/js/rnode/{Capabilities.js => Capabilities.ts} (98%) rename meshchatx/src/frontend/js/rnode/{Diagnostics.js => Diagnostics.ts} (95%) rename meshchatx/src/frontend/js/rnode/{Nrf52DfuFlasher.js => Nrf52DfuFlasher.ts} (98%) rename meshchatx/src/frontend/js/rnode/{RNode.js => RNode.ts} (98%) rename meshchatx/src/frontend/js/rnode/{RNodeUtils.js => RNodeUtils.ts} (97%) rename meshchatx/src/frontend/js/rnode/{ROM.js => ROM.ts} (97%) rename meshchatx/src/frontend/js/rnode/{products.js => products.ts} (100%) rename meshchatx/src/frontend/js/rnode/{rnodeIntegrityKey.js => rnodeIntegrityKey.ts} (100%) rename meshchatx/src/frontend/js/rnode/transports/{BluetoothTransport.js => BluetoothTransport.ts} (86%) rename meshchatx/src/frontend/js/rnode/transports/{SerialTransport.js => SerialTransport.ts} (83%) rename meshchatx/src/frontend/js/rnode/transports/{Transport.js => Transport.ts} (94%) rename meshchatx/src/frontend/js/rnode/transports/{WifiTransport.js => WifiTransport.ts} (82%) rename meshchatx/src/frontend/js/{rrcAvailableRooms.js => rrcAvailableRooms.ts} (98%) rename meshchatx/src/frontend/js/{sandboxStatus.js => sandboxStatus.ts} (99%) rename meshchatx/src/frontend/js/settings/{batterySaverBitrateApply.js => batterySaverBitrateApply.ts} (94%) rename meshchatx/src/frontend/js/settings/{batterySaverPrefs.js => batterySaverPrefs.ts} (98%) rename meshchatx/src/frontend/js/settings/{incomingDeliveryLimit.js => incomingDeliveryLimit.ts} (100%) rename meshchatx/src/frontend/js/settings/{settingsConfigService.js => settingsConfigService.ts} (100%) rename meshchatx/src/frontend/js/settings/{settingsMaintenanceClient.js => settingsMaintenanceClient.ts} (100%) rename meshchatx/src/frontend/js/settings/{settingsReticulumInstanceService.js => settingsReticulumInstanceService.ts} (100%) rename meshchatx/src/frontend/js/settings/{settingsTabs.js => settingsTabs.ts} (100%) rename meshchatx/src/frontend/js/settings/{settingsTransportService.js => settingsTransportService.ts} (100%) rename meshchatx/src/frontend/js/settings/{settingsVisualiserPrefs.js => settingsVisualiserPrefs.ts} (94%) rename meshchatx/src/frontend/js/{settingsSearchUtils.js => settingsSearchUtils.ts} (100%) rename meshchatx/src/frontend/js/{sidebarListVirtual.js => sidebarListVirtual.ts} (100%) rename meshchatx/src/frontend/js/svelte/{appState.svelte.js => appState.svelte.ts} (100%) rename meshchatx/src/frontend/js/{telemetryBatteryChartSpec.js => telemetryBatteryChartSpec.ts} (97%) rename meshchatx/src/frontend/js/{telephone-pcm-capture.worklet.js => telephone-pcm-capture.worklet.ts} (73%) rename meshchatx/src/frontend/js/{terminalRender.js => terminalRender.ts} (100%) rename meshchatx/src/frontend/js/{tgsDecode.js => tgsDecode.ts} (100%) rename meshchatx/src/frontend/js/{webAudioMicPermission.js => webAudioMicPermission.ts} (98%) rename meshchatx/src/frontend/js/{wsConnectionSupport.js => wsConnectionSupport.ts} (100%) rename meshchatx/src/frontend/js/{wsLiveSync.js => wsLiveSync.ts} (99%) rename meshchatx/src/frontend/js/{wtJsonFraming.js => wtJsonFraming.ts} (100%) rename meshchatx/src/frontend/libs/{clickOutside.js => clickOutside.ts} (99%) rename meshchatx/src/frontend/libs/{datetime.js => datetime.ts} (100%) rename meshchatx/src/frontend/libs/{emitter.js => emitter.ts} (100%) rename meshchatx/src/frontend/libs/{index.js => index.ts} (100%) rename meshchatx/src/frontend/libs/{uuid.js => uuid.ts} (51%) rename meshchatx/src/frontend/{main.js => main.ts} (93%) rename meshchatx/src/frontend/shell/{buildRouterRoutes.js => buildRouterRoutes.ts} (67%) rename meshchatx/src/frontend/theme/{designTokens.js => designTokens.ts} (86%) rename meshchatx/src/frontend/theme/{themeEngine.js => themeEngine.ts} (84%) create mode 100644 meshchatx/src/frontend/ui/svelte/VueSfcHost.svelte create mode 100644 meshchatx/src/frontend/vite-env.d.ts delete mode 100644 tests/backend/test_altcha_oracle.py delete mode 100644 tests/frontend/ConversationDropDownMenu.shareApk.test.js delete mode 100644 tests/frontend/altchaWidget.test.js diff --git a/.agents/conventions/frontend.md b/.agents/conventions/frontend.md index 119c3fe47..3c1768e52 100644 --- a/.agents/conventions/frontend.md +++ b/.agents/conventions/frontend.md @@ -1,9 +1,10 @@ # Frontend conventions -Applies when editing `meshchatx/src/frontend/**/*.{vue,js,svelte}`. +Applies when editing `meshchatx/src/frontend/**/*.{vue,js,ts,svelte}`. - Vue 3 Options API remains the pattern for existing `.vue` files. Match the file you edit. - New greenfield pages and shared UI go Svelte 5 (runes only) under `ui/svelte/` or `features//`. +- Feature modules, `ui/svelte/`, and the `js/` kernel use TypeScript (`.ts`, `lang="ts"` in `.svelte`). Keep `allowJs` only for remaining Vue SFC scripts and third-party bits. Do not grow new plain `.js` under `js/` or `features/`. - New routes use `registerFeature` / `routeRegistry`. Do not add one-off routes to the hardcoded table in `main.js`. - API calls go through `window.api` (not ad-hoc axios imports in pages). - Toasts: `ToastUtils.success|error|warning|info|loading|dismiss`. @@ -19,7 +20,7 @@ Applies when editing `meshchatx/src/frontend/**/*.{vue,js,svelte}`. | Layer | Location | May import | | -------- | -------------------------------------------------- | ----------------------------------- | -| Kernel | `js/` (api, registries, toast, theme, state, i18n) | other kernel only | +| Kernel | `js/` (api, registries, toast, theme, state, i18n) as `.ts` | other kernel only | | UI | `ui/svelte/`, shared Vue primitives | kernel | | Features | `features//` | kernel, ui, own files | | Shell | `App.vue`, boot (`main.js`) | kernel, registries, page mount host | @@ -39,6 +40,7 @@ Cross-feature UI imports are forbidden. Share through kernel events, registries, - `pnpm run svelte-check` and `pnpm run format:check:svelte` in `task lint:frontend`. Runes mode only. - Prefer small `features//components/` and `lib/` pieces. Do not grow Svelte god pages. +- Feature `lib/` and `index` files are TypeScript. Svelte scripts use `lang="ts"`. - Dual mount: route meta `mount: "vue" | "svelte"` with a lazy `load`. - Skill: `.agents/skills/svelte-feature-modules/SKILL.md`. diff --git a/.agents/module-ownership.md b/.agents/module-ownership.md index e8466de1a..c5f474e0d 100644 --- a/.agents/module-ownership.md +++ b/.agents/module-ownership.md @@ -12,7 +12,7 @@ Schema contracts live under `tests/backend/` (not production route modules). | Identity | `identity_context.py`, `identity_manager.py` | `http/routes/identities.py` | `http/ws/dispatch.py` | `test_identity_*`, identity-switch skills | Identities / tutorial | | Status / startup | `lifecycle/deferred_network.py`, `rns_startup_recovery.py` | `http/routes/status.py` | (status via REST) | `test_deferred_network_startup.py`, `test_api_json_contracts.py` | App shell banners | | Auth / CSRF | `csrf.py`, `app_security_settings.py`, `ip_allowlist.py` | `http/routes/auth.py` | `http/ws/dispatch.py` | `test_csrf*`, `test_access_attempts_*` | Auth gate in `App.vue` | -| Messages / LXMF | `message_handler.py`, `lxmf_utils.py`, `database/messages.py` | `http/routes/messages.py`, `lxmf.py` | `http/ws/dispatch.py` | LXMF messaging skill tests | `messages/MessagesPage.vue`, `ConversationViewer.vue` | +| Messages / LXMF | `message_handler.py`, `lxmf_utils.py`, `database/messages.py` | `http/routes/messages.py`, `lxmf.py` | `http/ws/dispatch.py` | LXMF messaging skill tests | `features/messages/MessagesPage.svelte`, `components/ConversationViewer.svelte` | | Telephone / LXST | `telephone_manager.py`, `database/telephone.py` | `http/routes/telephone.py`, `contacts.py` | `http/ws/dispatch.py` | telephone contract tests | `call/CallPage.vue` | | RRC | `rrc/manager.py`, `rrc/server.py` | `http/routes/rrc.py` | `http/ws/dispatch.py` | RRC skill tests | `relay/RelayChatPage.vue` | | Map | `map_manager.py`, `map_overlay_manager.py`, `map_data_manager.py` | `http/routes/map.py` | marker updates via broadcast | map manager tests | `map/MapPage.vue` | @@ -63,7 +63,7 @@ Do not invent folders outside this table. | Page shell | On-disk child dirs | Planned child dirs | Shared JS | Primary tests | | ------------------------------------------ | ------------------------------------------------------------------- | -------------------------------------------- | ------------------------------- | -------------------------------------------------------------------------------- | | `settings/SettingsPage.vue` | `settings/sections/` (incl. Battery), toggle/block helpers | more sections from remaining inline template | `js/settings/` | `SettingsPage*.test.js`, `settingsSections.test.js`, `behaviorContracts.test.js` | -| `messages/ConversationViewer.vue` | `composer/`, `modals/`, `outbound/`, `lxmf/`, telemetry, helpers | further script slices | `conversationMessageHelpers.js` | ConversationViewer tests | +| `features/messages/components/ConversationViewer.svelte` | `composer/`, `modals/`, `outbound/`, `lib/lxmf/`, telemetry, helpers | further script slices | `lib/conversationMessageHelpers.ts` | ConversationViewer tests | | `call/CallPage.vue` | `call/tabs/`, `call/audio/`, `CallOverlay.vue` | more tabs and audio panels | call helpers | `CallPage*.test.js` | | `map/MapPage.vue` | `map/internal/` (panels + helpers, incl. MapSaveDrawingModal) | more overlays and modals into `internal/` | `js/map*` | map component tests | | `relay/RelayChatPage.vue` | relay view components | further relay panels as needed | `js/relay*` | relay tests | diff --git a/.agents/skills/svelte-feature-modules/SKILL.md b/.agents/skills/svelte-feature-modules/SKILL.md index 479cc296e..9de9bf1d5 100644 --- a/.agents/skills/svelte-feature-modules/SKILL.md +++ b/.agents/skills/svelte-feature-modules/SKILL.md @@ -26,6 +26,7 @@ Gradual Vue to Svelte 5 migration with pluggable feature modules. 8. One feature or primitive per PR series. No god-file rewrites. 9. SPDX on new project files (`0BSD` unless the file already differs). 10. Run `pnpm run svelte-check` for touched Svelte surfaces. +11. Feature modules, `ui/svelte/`, and the `js/` kernel use TypeScript: `index.ts`, `lib/*.ts`, kernel `.ts`, and ` diff --git a/meshchatx/src/frontend/components/auth/AuthPage.vue b/meshchatx/src/frontend/components/auth/AuthPage.vue index 9a5bb3bea..807fbdf52 100644 --- a/meshchatx/src/frontend/components/auth/AuthPage.vue +++ b/meshchatx/src/frontend/components/auth/AuthPage.vue @@ -58,16 +58,6 @@ /> -
- -
-
- - diff --git a/meshchatx/src/frontend/components/messages/ConversationDropDownMenu.vue b/meshchatx/src/frontend/components/messages/ConversationDropDownMenu.vue deleted file mode 100644 index e68f94336..000000000 --- a/meshchatx/src/frontend/components/messages/ConversationDropDownMenu.vue +++ /dev/null @@ -1,453 +0,0 @@ - - - - - diff --git a/meshchatx/src/frontend/components/messages/ConversationMessageEntry.vue b/meshchatx/src/frontend/components/messages/ConversationMessageEntry.vue deleted file mode 100644 index ffa3ac7ef..000000000 --- a/meshchatx/src/frontend/components/messages/ConversationMessageEntry.vue +++ /dev/null @@ -1,1451 +0,0 @@ - - - - - diff --git a/meshchatx/src/frontend/components/messages/ConversationMessageListVirtual.vue b/meshchatx/src/frontend/components/messages/ConversationMessageListVirtual.vue deleted file mode 100644 index afe3a27e6..000000000 --- a/meshchatx/src/frontend/components/messages/ConversationMessageListVirtual.vue +++ /dev/null @@ -1,82 +0,0 @@ - - - - - diff --git a/meshchatx/src/frontend/components/messages/ConversationPeerHeader.vue b/meshchatx/src/frontend/components/messages/ConversationPeerHeader.vue deleted file mode 100644 index a7da806cc..000000000 --- a/meshchatx/src/frontend/components/messages/ConversationPeerHeader.vue +++ /dev/null @@ -1,351 +0,0 @@ - - - - - diff --git a/meshchatx/src/frontend/components/messages/ConversationViewer.vue b/meshchatx/src/frontend/components/messages/ConversationViewer.vue deleted file mode 100644 index a770eb76f..000000000 --- a/meshchatx/src/frontend/components/messages/ConversationViewer.vue +++ /dev/null @@ -1,7642 +0,0 @@ - - - - - - - diff --git a/meshchatx/src/frontend/components/messages/InViewAnimatedImg.vue b/meshchatx/src/frontend/components/messages/InViewAnimatedImg.vue deleted file mode 100644 index bac46e4d9..000000000 --- a/meshchatx/src/frontend/components/messages/InViewAnimatedImg.vue +++ /dev/null @@ -1,68 +0,0 @@ - - - diff --git a/meshchatx/src/frontend/components/messages/MessageReactionsOverlay.vue b/meshchatx/src/frontend/components/messages/MessageReactionsOverlay.vue deleted file mode 100644 index 99b00043c..000000000 --- a/meshchatx/src/frontend/components/messages/MessageReactionsOverlay.vue +++ /dev/null @@ -1,111 +0,0 @@ - - - - - diff --git a/meshchatx/src/frontend/components/messages/MessagesPage.vue b/meshchatx/src/frontend/components/messages/MessagesPage.vue deleted file mode 100644 index 3780b845e..000000000 --- a/meshchatx/src/frontend/components/messages/MessagesPage.vue +++ /dev/null @@ -1,1965 +0,0 @@ - - - - - diff --git a/meshchatx/src/frontend/components/messages/MessagesSidebar.vue b/meshchatx/src/frontend/components/messages/MessagesSidebar.vue deleted file mode 100644 index 2350032d6..000000000 --- a/meshchatx/src/frontend/components/messages/MessagesSidebar.vue +++ /dev/null @@ -1,1668 +0,0 @@ - - - - - diff --git a/meshchatx/src/frontend/components/messages/composer/AddAudioButton.vue b/meshchatx/src/frontend/components/messages/composer/AddAudioButton.vue deleted file mode 100644 index f9d401843..000000000 --- a/meshchatx/src/frontend/components/messages/composer/AddAudioButton.vue +++ /dev/null @@ -1,111 +0,0 @@ - - - - - diff --git a/meshchatx/src/frontend/components/messages/composer/AddImageButton.vue b/meshchatx/src/frontend/components/messages/composer/AddImageButton.vue deleted file mode 100644 index 68c2b1f0a..000000000 --- a/meshchatx/src/frontend/components/messages/composer/AddImageButton.vue +++ /dev/null @@ -1,172 +0,0 @@ - - - - - diff --git a/meshchatx/src/frontend/components/messages/composer/SendMessageButton.vue b/meshchatx/src/frontend/components/messages/composer/SendMessageButton.vue deleted file mode 100644 index d6f81f4a8..000000000 --- a/meshchatx/src/frontend/components/messages/composer/SendMessageButton.vue +++ /dev/null @@ -1,269 +0,0 @@ - - - - - diff --git a/meshchatx/src/frontend/components/messages/conversationScroll.js b/meshchatx/src/frontend/components/messages/conversationScroll.js deleted file mode 100644 index f3f1b655e..000000000 --- a/meshchatx/src/frontend/components/messages/conversationScroll.js +++ /dev/null @@ -1,123 +0,0 @@ -/* SPDX-License-Identifier: 0BSD */ - -export const SCROLL_BOTTOM_EPS_PX = 8; - -export const LOAD_PREVIOUS_SCROLL_EDGE_PX = 500; - -/** - * True only when inverted scrollTop math applies: scrollTop 0 is the visual bottom (newest). - * - * That is the classic pattern where the *scrolling element* (or its direct child) is - * `flex-direction: column-reverse`. ConversationViewer now uses normal column flow for both - * virtual and non-virtual lists (oldest at top, newest at bottom), so this returns false there. - * @param {Element} container - * @returns {boolean} - */ -export function isScrollColumnReverse(container) { - if (!container) { - return false; - } - try { - if (getComputedStyle(container).flexDirection === "column-reverse") { - return true; - } - const inner = container.firstElementChild; - if (!inner) { - return false; - } - return getComputedStyle(inner).flexDirection === "column-reverse"; - } catch { - return false; - } -} - -/** - * Maximum valid scrollTop for a scroll container. - * @param {Element} container - * @returns {number} - */ -export function maxScrollTop(container) { - if (!container) { - return 0; - } - return Math.max(0, container.scrollHeight - container.clientHeight); -} - -/** - * Whether the viewport is within thresholdPx of the visual bottom (newest messages). - * @param {Element} container - * @param {number} [thresholdPx] - * @returns {boolean} - */ -export function isNearBottom(container, thresholdPx = SCROLL_BOTTOM_EPS_PX) { - if (!container) { - return true; - } - if (isScrollColumnReverse(container)) { - return container.scrollTop <= thresholdPx; - } - const max = maxScrollTop(container); - return max - container.scrollTop <= thresholdPx; -} - -/** - * Sets scroll position to the visual bottom (newest messages). - * @param {Element} container - */ -export function scrollContainerToBottom(container) { - if (!container) { - return; - } - if (isScrollColumnReverse(container)) { - container.scrollTop = 0; - } else { - container.scrollTop = maxScrollTop(container); - } -} - -/** - * Clears stale scroll position when reusing the same scroll element for another thread. - * `scrollMessagesToBottom` / `scrollContainerToBottom` run after content is mounted. - * @param {Element | null | undefined} container - */ -export function resetMessagesScrollSurface(container) { - if (!container) { - return; - } - container.scrollTop = 0; -} - -/** - * When the scroll area has no inner content yet, `isScrollColumnReverse` is false and - * `isNearBottom` is misleading (empty scroller looks "at bottom"). Do not use it to settle. - * @param {Element | null | undefined} container - * @returns {boolean} - */ -export function canTrustScrollNearBottomHeuristic(container) { - return Boolean(container?.firstElementChild); -} - -/** - * Whether the user has scrolled into the region where older messages should be loaded. - * @param {Element} container - * @returns {boolean} - */ -export function shouldLoadPreviousMessages(container) { - if (!container) { - return false; - } - if (isScrollColumnReverse(container)) { - const max = maxScrollTop(container); - if (max <= 0) { - return false; - } - const st = container.scrollTop; - if (max - st > LOAD_PREVIOUS_SCROLL_EDGE_PX) { - return false; - } - // Short threads: `max - st` is small even at the visual bottom (newest), because `max` itself - // is small. Require leaving the bottom band so we do not auto-load in a loop while pinned there. - return st > SCROLL_BOTTOM_EPS_PX; - } - return container.scrollTop <= LOAD_PREVIOUS_SCROLL_EDGE_PX; -} diff --git a/meshchatx/src/frontend/components/messages/modals/ConversationImageModal.vue b/meshchatx/src/frontend/components/messages/modals/ConversationImageModal.vue deleted file mode 100644 index 39d9ed8c5..000000000 --- a/meshchatx/src/frontend/components/messages/modals/ConversationImageModal.vue +++ /dev/null @@ -1,104 +0,0 @@ - - - - - diff --git a/meshchatx/src/frontend/components/messages/modals/PaperMessageModal.vue b/meshchatx/src/frontend/components/messages/modals/PaperMessageModal.vue deleted file mode 100644 index e49a296ae..000000000 --- a/meshchatx/src/frontend/components/messages/modals/PaperMessageModal.vue +++ /dev/null @@ -1,372 +0,0 @@ - - - - - - - diff --git a/meshchatx/src/frontend/components/messages/modals/ShareContactModal.vue b/meshchatx/src/frontend/components/messages/modals/ShareContactModal.vue deleted file mode 100644 index 5b0e4fa09..000000000 --- a/meshchatx/src/frontend/components/messages/modals/ShareContactModal.vue +++ /dev/null @@ -1,111 +0,0 @@ - - - - - diff --git a/meshchatx/src/frontend/components/messages/outbound/OutboundTransferProgressFooter.vue b/meshchatx/src/frontend/components/messages/outbound/OutboundTransferProgressFooter.vue deleted file mode 100644 index f7442967d..000000000 --- a/meshchatx/src/frontend/components/messages/outbound/OutboundTransferProgressFooter.vue +++ /dev/null @@ -1,69 +0,0 @@ - - - - - diff --git a/meshchatx/src/frontend/components/messages/telemetry/TelemetryBatteryChart.vue b/meshchatx/src/frontend/components/messages/telemetry/TelemetryBatteryChart.vue deleted file mode 100644 index f4ef0f29f..000000000 --- a/meshchatx/src/frontend/components/messages/telemetry/TelemetryBatteryChart.vue +++ /dev/null @@ -1,244 +0,0 @@ - - - - - diff --git a/meshchatx/src/frontend/components/messages/telemetry/TelemetryHistoryListItem.vue b/meshchatx/src/frontend/components/messages/telemetry/TelemetryHistoryListItem.vue deleted file mode 100644 index 16f2c809f..000000000 --- a/meshchatx/src/frontend/components/messages/telemetry/TelemetryHistoryListItem.vue +++ /dev/null @@ -1,69 +0,0 @@ - - - - - diff --git a/meshchatx/src/frontend/components/messages/telemetry/TelemetryHistoryModal.vue b/meshchatx/src/frontend/components/messages/telemetry/TelemetryHistoryModal.vue deleted file mode 100644 index 45796b5b6..000000000 --- a/meshchatx/src/frontend/components/messages/telemetry/TelemetryHistoryModal.vue +++ /dev/null @@ -1,131 +0,0 @@ - - - - - diff --git a/meshchatx/src/frontend/components/network-visualiser/internal/hopMaxFilterSliderMap.js b/meshchatx/src/frontend/components/network-visualiser/internal/hopMaxFilterSliderMap.ts similarity index 70% rename from meshchatx/src/frontend/components/network-visualiser/internal/hopMaxFilterSliderMap.js rename to meshchatx/src/frontend/components/network-visualiser/internal/hopMaxFilterSliderMap.ts index 03c2ea680..580613ef9 100644 --- a/meshchatx/src/frontend/components/network-visualiser/internal/hopMaxFilterSliderMap.js +++ b/meshchatx/src/frontend/components/network-visualiser/internal/hopMaxFilterSliderMap.ts @@ -1,17 +1,12 @@ // SPDX-License-Identifier: 0BSD AND MIT -/** Rightmost slider index: no hop limit (show all paths). */ export const HOP_SLIDER_POS_ALL = 2047; const LOW_MAX_POS = 1599; const TRAN_POS = 1600; const HIGH_END_POS = 2046; -/** - * @param {number} pos - * @returns {number|null} Inclusive max hops, or null when unlimited. - */ -export function hopSliderPosToMaxHops(pos) { +export function hopSliderPosToMaxHops(pos: number): number | null { const p = Math.round(Number(pos)); if (!Number.isFinite(p) || p >= HOP_SLIDER_POS_ALL) return null; if (p <= LOW_MAX_POS) { @@ -23,11 +18,7 @@ export function hopSliderPosToMaxHops(pos) { return Math.min(128, Math.max(33, Math.round(33 + frac * (128 - 33)))); } -/** - * @param {number|null|undefined} maxHops null = unlimited - * @returns {number} Slider position 0 .. HOP_SLIDER_POS_ALL - */ -export function hopMaxHopsToSliderPos(maxHops) { +export function hopMaxHopsToSliderPos(maxHops: number | null | undefined): number { if (maxHops === null || maxHops === undefined) return HOP_SLIDER_POS_ALL; const h = Math.max(0, Math.min(128, Math.round(Number(maxHops)))); if (h <= 32) { diff --git a/meshchatx/src/frontend/components/relay/relayMessageListVirtual.js b/meshchatx/src/frontend/components/relay/relayMessageListVirtual.ts similarity index 52% rename from meshchatx/src/frontend/components/relay/relayMessageListVirtual.js rename to meshchatx/src/frontend/components/relay/relayMessageListVirtual.ts index 387e51fb3..6cf161737 100644 --- a/meshchatx/src/frontend/components/relay/relayMessageListVirtual.js +++ b/meshchatx/src/frontend/components/relay/relayMessageListVirtual.ts @@ -1,21 +1,30 @@ +// SPDX-License-Identifier: 0BSD + export const MIN_VIRTUAL_RELAY_ENTRIES = 150; -/** - * Initial row height guess before measureElement runs (variable-height rows). - * @param {unknown} entry - * @returns {number} - */ -export function estimateRelayEntryHeight(entry) { +interface RelayMessage { + text?: string; + [key: string]: unknown; +} + +interface RelayEntry { + type?: string; + msg?: RelayMessage; + [key: string]: unknown; +} + +export function estimateRelayEntryHeight(entry: unknown): number { if (!entry || typeof entry !== "object") { return 28; } - if (entry.type === "dateDivider") { + const relayEntry = entry as RelayEntry; + if (relayEntry.type === "dateDivider") { return 44; } - if (entry.type === "presenceGroup") { + if (relayEntry.type === "presenceGroup") { return 28; } - const text = typeof entry.msg?.text === "string" ? entry.msg.text : ""; + const text = typeof relayEntry.msg?.text === "string" ? relayEntry.msg.text : ""; let height = 32; if (text) { const lines = text.split("\n").reduce((sum, line) => sum + Math.max(1, Math.ceil(line.length / 72)), 0); @@ -24,13 +33,11 @@ export function estimateRelayEntryHeight(entry) { return height; } -/** - * @param {unknown[]} entries - * @param {string} key - * @param {(msg: object) => string} keyFn - * @returns {number} - */ -export function findRelayEntryIndexForMessageKey(entries, key, keyFn) { +export function findRelayEntryIndexForMessageKey( + entries: RelayEntry[], + key: string, + keyFn: (msg: RelayMessage) => string +): number { if (!entries?.length || !key || typeof keyFn !== "function") { return -1; } diff --git a/meshchatx/src/frontend/components/tools/internal/botLxmfConfigForm.js b/meshchatx/src/frontend/components/tools/internal/botLxmfConfigForm.ts similarity index 71% rename from meshchatx/src/frontend/components/tools/internal/botLxmfConfigForm.js rename to meshchatx/src/frontend/components/tools/internal/botLxmfConfigForm.ts index 90c9a829b..02e26286c 100644 --- a/meshchatx/src/frontend/components/tools/internal/botLxmfConfigForm.js +++ b/meshchatx/src/frontend/components/tools/internal/botLxmfConfigForm.ts @@ -1,6 +1,36 @@ // SPDX-License-Identifier: 0BSD -export function defaultLxmfConfigDraft() { +export interface LxmfConfigDraft { + propagation_mode: string; + propagation_node: string; + propagation_fallback_enabled: string; + direct_delivery_retries: string; + opportunistic_sending: string; + announce_interval_seconds: string; + stamp_cost: string; +} + +export interface BotLxmfConfig { + propagation_mode?: string; + propagation_node?: string; + propagation_fallback_enabled?: boolean | null; + direct_delivery_retries?: number | null; + opportunistic_sending?: boolean | null; + announce_interval_seconds?: number | null; + stamp_cost?: number | null; +} + +export type LxmfConfigPatch = { + propagation_mode?: string; + propagation_node?: string | null; + propagation_fallback_enabled?: boolean | null; + direct_delivery_retries?: number | null; + opportunistic_sending?: boolean | null; + announce_interval_seconds?: number | null; + stamp_cost?: number | null; +}; + +export function defaultLxmfConfigDraft(): LxmfConfigDraft { return { propagation_mode: "inherit", propagation_node: "", @@ -12,9 +42,9 @@ export function defaultLxmfConfigDraft() { }; } -export function draftFromBotLxmfConfig(lxmfConfig) { +export function draftFromBotLxmfConfig(lxmfConfig: BotLxmfConfig | null | undefined): LxmfConfigDraft { const draft = defaultLxmfConfigDraft(); - const cfg = lxmfConfig && typeof lxmfConfig === "object" ? lxmfConfig : {}; + const cfg: BotLxmfConfig = lxmfConfig && typeof lxmfConfig === "object" ? lxmfConfig : {}; if (cfg.propagation_mode) { draft.propagation_mode = cfg.propagation_mode; } @@ -39,9 +69,9 @@ export function draftFromBotLxmfConfig(lxmfConfig) { return draft; } -export function buildLxmfConfigPatch(draft, options = {}) { +export function buildLxmfConfigPatch(draft: LxmfConfigDraft, options: { clearEmpty?: boolean } = {}): LxmfConfigPatch { const clearEmpty = Boolean(options.clearEmpty); - const patch = {}; + const patch: LxmfConfigPatch = {}; const mode = (draft.propagation_mode || "inherit").trim(); if (mode && mode !== "inherit") { patch.propagation_mode = mode; diff --git a/meshchatx/src/frontend/features/blocked/index.js b/meshchatx/src/frontend/features/blocked/index.ts similarity index 90% rename from meshchatx/src/frontend/features/blocked/index.js rename to meshchatx/src/frontend/features/blocked/index.ts index 17418db4b..3db1e6ed8 100644 --- a/meshchatx/src/frontend/features/blocked/index.js +++ b/meshchatx/src/frontend/features/blocked/index.ts @@ -5,7 +5,7 @@ import { registerFeature } from "../../js/registries/featureRegistry.js"; /** * Quiet leaf feature used to prove registerFeature + Svelte mount. */ -export function registerBlockedFeature() { +export function registerBlockedFeature(): void { registerFeature({ id: "blocked", routes: [ diff --git a/meshchatx/src/frontend/features/blocked/lib/blockedList.js b/meshchatx/src/frontend/features/blocked/lib/blockedList.ts similarity index 72% rename from meshchatx/src/frontend/features/blocked/lib/blockedList.js rename to meshchatx/src/frontend/features/blocked/lib/blockedList.ts index 65f207c72..9d9d1f8c9 100644 --- a/meshchatx/src/frontend/features/blocked/lib/blockedList.js +++ b/meshchatx/src/frontend/features/blocked/lib/blockedList.ts @@ -4,24 +4,34 @@ * Pure helpers for the blocked / banished identities list. */ -/** - * @param {object} identity - * @returns {string | null} - */ -export function identityBlockedAt(identity) { - const dates = (identity.blocked_destinations || []).map((dest) => dest.created_at).filter(Boolean); +export type BlockedDestination = { + destination_hash: string; + created_at?: string; +}; + +export type BlockedIdentity = { + identity_hash: string; + display_name?: string; + is_node?: boolean; + is_rns_blackholed?: boolean; + blocked_destinations?: BlockedDestination[]; +}; + +export type FilterBlockedOpts = { + searchQuery?: string; + typeFilter?: string; + dateSort?: string; +}; + +export function identityBlockedAt(identity: BlockedIdentity): string | null { + const dates = (identity.blocked_destinations || []).map((dest) => dest.created_at).filter(Boolean) as string[]; if (dates.length === 0) { return null; } return dates.sort().reverse()[0]; } -/** - * @param {object} a - * @param {object} b - * @returns {number} - */ -export function compareBlockedAt(a, b) { +export function compareBlockedAt(a: BlockedIdentity, b: BlockedIdentity): number { const atA = identityBlockedAt(a) || ""; const atB = identityBlockedAt(b) || ""; if (atA === atB) { @@ -32,12 +42,10 @@ export function compareBlockedAt(a, b) { return atA.localeCompare(atB); } -/** - * @param {object[]} identities - * @param {{ searchQuery?: string, typeFilter?: string, dateSort?: string }} opts - * @returns {object[]} - */ -export function filterBlockedIdentities(identities, opts = {}) { +export function filterBlockedIdentities( + identities: BlockedIdentity[], + opts: FilterBlockedOpts = {} +): BlockedIdentity[] { let list = [...identities]; const query = String(opts.searchQuery || "") .trim() diff --git a/meshchatx/src/frontend/features/contacts/index.js b/meshchatx/src/frontend/features/contacts/index.ts similarity index 88% rename from meshchatx/src/frontend/features/contacts/index.js rename to meshchatx/src/frontend/features/contacts/index.ts index e67650759..a98202634 100644 --- a/meshchatx/src/frontend/features/contacts/index.js +++ b/meshchatx/src/frontend/features/contacts/index.ts @@ -2,7 +2,7 @@ import { registerFeature } from "../../js/registries/featureRegistry.js"; -export function registerContactsFeature() { +export function registerContactsFeature(): void { registerFeature({ id: "contacts", routes: [ diff --git a/meshchatx/src/frontend/features/contacts/lib/contactUri.js b/meshchatx/src/frontend/features/contacts/lib/contactUri.ts similarity index 69% rename from meshchatx/src/frontend/features/contacts/lib/contactUri.js rename to meshchatx/src/frontend/features/contacts/lib/contactUri.ts index 4fa9a8c4d..16e468183 100644 --- a/meshchatx/src/frontend/features/contacts/lib/contactUri.js +++ b/meshchatx/src/frontend/features/contacts/lib/contactUri.ts @@ -1,10 +1,22 @@ // SPDX-License-Identifier: 0BSD -/** - * @param {string} input - * @returns {{ destinationHash: string, publicKeyHex: string, normalizedUri: string } | null} - */ -export function parseLxmaUri(input) { +export type LxmaUriParts = { + destinationHash: string; + publicKeyHex: string; + normalizedUri: string; +}; + +export type IdentityConfig = { + lxmf_address_hash?: string; + identity_public_key?: string; +}; + +export type ContactUriFields = { + lxmf_address?: string; + remote_identity_hash?: string; +}; + +export function parseLxmaUri(input: string): LxmaUriParts | null { const normalized = input.trim(); const match = normalized.match(/^lxma:\/\/([0-9a-f]{32}):([0-9a-f]{64}|[0-9a-f]{128})$/i); if (!match) { @@ -17,11 +29,7 @@ export function parseLxmaUri(input) { }; } -/** - * @param {string} input - * @returns {string | null} - */ -export function extractDestinationHash(input) { +export function extractDestinationHash(input: string): string | null { const raw = input.trim().toLowerCase(); if (/^[0-9a-f]{32}$/.test(raw)) { return raw; @@ -37,11 +45,7 @@ export function extractDestinationHash(input) { return null; } -/** - * @param {{ lxmf_address_hash?: string, identity_public_key?: string } | null | undefined} config - * @returns {string | null} - */ -export function buildMyIdentityUri(config) { +export function buildMyIdentityUri(config: IdentityConfig | null | undefined): string | null { if (!config?.lxmf_address_hash) { return null; } @@ -53,10 +57,8 @@ export function buildMyIdentityUri(config) { /** * Decode announce identity_public_key (base64) to hex for lxma URIs. - * @param {string | null | undefined} publicKeyBase64 - * @returns {string | null} */ -export function publicKeyFromAnnounce(publicKeyBase64) { +export function publicKeyFromAnnounce(publicKeyBase64: string | null | undefined): string | null { if (!publicKeyBase64) { return null; } @@ -74,12 +76,7 @@ export function publicKeyFromAnnounce(publicKeyBase64) { } } -/** - * @param {{ lxmf_address?: string, remote_identity_hash?: string }} contact - * @param {string | null} publicKeyHex - * @returns {string | null} - */ -export function buildContactUri(contact, publicKeyHex) { +export function buildContactUri(contact: ContactUriFields, publicKeyHex: string | null): string | null { const destinationHash = (contact?.lxmf_address || contact?.remote_identity_hash || "").toLowerCase(); if (!/^[0-9a-f]{32}$/.test(destinationHash)) { return null; diff --git a/meshchatx/src/frontend/features/contacts/lib/contactsActions.js b/meshchatx/src/frontend/features/contacts/lib/contactsActions.ts similarity index 61% rename from meshchatx/src/frontend/features/contacts/lib/contactsActions.js rename to meshchatx/src/frontend/features/contacts/lib/contactsActions.ts index 24586047d..57d2415bf 100644 --- a/meshchatx/src/frontend/features/contacts/lib/contactsActions.js +++ b/meshchatx/src/frontend/features/contacts/lib/contactsActions.ts @@ -1,5 +1,6 @@ // SPDX-License-Identifier: 0BSD + import ToastUtils from "../../../js/ToastUtils.js"; import DownloadUtils from "../../../js/DownloadUtils.js"; import DialogUtils from "../../../js/DialogUtils.js"; @@ -7,11 +8,35 @@ import WebSocketConnection from "../../../js/WebSocketConnection.js"; import { t } from "../../../js/i18n.js"; import { buildContactUri, extractDestinationHash, parseLxmaUri, publicKeyFromAnnounce } from "./contactUri.js"; -/** - * @param {string} value - * @param {string} [successMessage] - */ -export async function copyToClipboard(value, successMessage) { +type ApiResponse = { data?: any; response?: { data?: { message?: string } } }; +type ApiError = { response?: { data?: { message?: string } } }; + +type WindowApi = { + get: (url: string, config?: { params?: Record }) => Promise; + post: (url: string, body?: unknown) => Promise; + patch: (url: string, body?: unknown) => Promise; + delete: (url: string) => Promise; +}; + +function api(): WindowApi { + return (window as unknown as { api: WindowApi }).api; +} + +export type ContactActionRecord = Record & { + id?: string | number; + name?: string; + lxmf_address?: string; + remote_identity_hash?: string; + remote_destination_hash?: string; + remote_telephony_hash?: string; +}; + +export type AddContactHooks = { + setPendingLxma: (v: boolean) => void; + onAdded: () => Promise; +}; + +export async function copyToClipboard(value: string, successMessage?: string): Promise { try { await navigator.clipboard.writeText(value); ToastUtils.success(successMessage || t("common.copied")); @@ -20,10 +45,7 @@ export async function copyToClipboard(value, successMessage) { } } -/** - * @param {string} uri - */ -export async function shareUri(uri) { +export async function shareUri(uri: string): Promise { try { if (navigator.share) { await navigator.share({ title: t("contacts.share"), text: uri }); @@ -35,15 +57,11 @@ export async function shareUri(uri) { await copyToClipboard(uri, t("contacts.contact_uri_copied")); } -/** - * @param {Record} contact - * @returns {Promise} - */ -export async function fetchContactLxmaUri(contact) { +export async function fetchContactLxmaUri(contact: ContactActionRecord): Promise { const destinationHash = String(contact?.lxmf_address || contact?.remote_identity_hash || "").toLowerCase(); if (!/^[0-9a-f]{32}$/.test(destinationHash)) return null; try { - const response = await window.api.get("/api/v1/announces", { + const response = await api().get("/api/v1/announces", { params: { destination_hash: destinationHash, limit: 1 }, }); const publicKeyHex = publicKeyFromAnnounce(response.data?.announces?.[0]?.identity_public_key); @@ -53,15 +71,11 @@ export async function fetchContactLxmaUri(contact) { } } -/** - * @param {string} input - * @param {string} name - * @param {{ - * setPendingLxma: (v: boolean) => void, - * onAdded: () => Promise, - * }} hooks - */ -export async function addContactFromInput(input, name, hooks) { +export async function addContactFromInput( + input: string, + name: string, + hooks: AddContactHooks +): Promise<{ pending: boolean; added?: boolean }> { const lxmaData = parseLxmaUri(input); if (lxmaData) { hooks.setPendingLxma(true); @@ -74,12 +88,12 @@ export async function addContactFromInput(input, name, hooks) { ToastUtils.error(t("contacts.invalid_contact_input")); return { pending: false }; } - const existing = await window.api.get(`/api/v1/telephone/contacts/check/${destinationHash}`); + const existing = await api().get(`/api/v1/telephone/contacts/check/${destinationHash}`); if (existing.data?.id) { ToastUtils.info(t("contacts.contact_already_exists")); return { pending: false }; } - await window.api.post("/api/v1/telephone/contacts", { + await api().post("/api/v1/telephone/contacts", { name: name?.trim() || `Contact ${destinationHash.slice(0, 8)}`, lxmf_address: destinationHash, }); @@ -88,12 +102,11 @@ export async function addContactFromInput(input, name, hooks) { return { pending: false, added: true }; } -/** - * @param {Record} contact - * @param {Array>} contacts - * @param {() => Promise} onDone - */ -export async function removeContactWithDuplicates(contact, contacts, onDone) { +export async function removeContactWithDuplicates( + contact: ContactActionRecord, + contacts: ContactActionRecord[], + onDone: () => Promise +): Promise { if (!contact?.id) return; const duplicates = contacts.filter((c) => c.name === contact.name && c.id !== contact.id); const confirmMsg = @@ -103,7 +116,7 @@ export async function removeContactWithDuplicates(contact, contacts, onDone) { if (!(await DialogUtils.confirm(confirmMsg))) return; try { for (const id of [contact.id, ...duplicates.map((c) => c.id)]) { - await window.api.delete(`/api/v1/telephone/contacts/${id}`); + await api().delete(`/api/v1/telephone/contacts/${id}`); } ToastUtils.success(t("contacts.contact_removed")); await onDone(); @@ -112,22 +125,21 @@ export async function removeContactWithDuplicates(contact, contacts, onDone) { } } -/** - * @param {Record} contact - * @param {Array>} contacts - * @param {() => Promise} onDone - */ -export async function editContactNameWithDuplicates(contact, contacts, onDone) { +export async function editContactNameWithDuplicates( + contact: ContactActionRecord, + contacts: ContactActionRecord[], + onDone: () => Promise +): Promise { if (!contact?.id) return; const name = await DialogUtils.prompt(t("contacts.enter_contact_name"), String(contact.name || "")); if (name == null || name === contact.name) return; try { const duplicates = contacts.filter((c) => c.name === contact.name && c.id !== contact.id); for (const c of [contact, ...duplicates]) { - await window.api.patch(`/api/v1/telephone/contacts/${c.id}`, { name }); + await api().patch(`/api/v1/telephone/contacts/${c.id}`, { name }); const destHash = c.remote_destination_hash || c.lxmf_address || c.remote_identity_hash; if (destHash && name.length > 0) { - await window.api.post(`/api/v1/destination/${destHash}/custom-display-name/update`, { + await api().post(`/api/v1/destination/${destHash}/custom-display-name/update`, { display_name: name, }); } @@ -139,47 +151,38 @@ export async function editContactNameWithDuplicates(contact, contacts, onDone) { } } -export async function exportContactsFile() { +export async function exportContactsFile(): Promise { try { - const response = await window.api.get("/api/v1/telephone/contacts/export"); + const response = await api().get("/api/v1/telephone/contacts/export"); const blob = new Blob([JSON.stringify({ contacts: response.data?.contacts ?? [] }, null, 2)], { type: "application/json", }); await DownloadUtils.downloadFile("contacts_export.json", blob); ToastUtils.success(t("contacts.export_success")); } catch (e) { - ToastUtils.error(e?.response?.data?.message || t("contacts.export_failed")); + const err = e as ApiError; + ToastUtils.error(err?.response?.data?.message || t("contacts.export_failed")); } } -/** - * @param {unknown[]} list - * @param {() => Promise} onDone - * @returns {Promise} error message or null - */ -export async function importContactsList(list, onDone) { +export async function importContactsList(list: unknown[], onDone: () => Promise): Promise { try { - const response = await window.api.post("/api/v1/telephone/contacts/import", { contacts: list }); + const response = await api().post("/api/v1/telephone/contacts/import", { contacts: list }); ToastUtils.success(t("contacts.import_success", { added: response.data?.added ?? 0 })); await onDone(); return null; } catch (err) { - return err?.response?.data?.message || t("contacts.import_failed"); + const e = err as ApiError; + return e?.response?.data?.message || t("contacts.import_failed"); } } -/** - * @param {Record} contact - */ -export function messagesHashHref(contact) { +export function messagesHashHref(contact: ContactActionRecord): string { const hash = String(contact.remote_destination_hash || contact.lxmf_address || contact.remote_identity_hash || ""); return hash ? `#/messages/${hash}` : ""; } -/** - * @param {Record} contact - */ -export function callHashHref(contact) { +export function callHashHref(contact: ContactActionRecord): string { const hash = String( contact.remote_telephony_hash || contact.remote_destination_hash || contact.remote_identity_hash || "" ); diff --git a/meshchatx/src/frontend/features/contacts/lib/mergeContacts.js b/meshchatx/src/frontend/features/contacts/lib/mergeContacts.ts similarity index 66% rename from meshchatx/src/frontend/features/contacts/lib/mergeContacts.js rename to meshchatx/src/frontend/features/contacts/lib/mergeContacts.ts index 72e04ae6a..da44fae56 100644 --- a/meshchatx/src/frontend/features/contacts/lib/mergeContacts.js +++ b/meshchatx/src/frontend/features/contacts/lib/mergeContacts.ts @@ -2,17 +2,24 @@ /** * Merge contacts that share the same display name so LXMF and LXST hashes show on one row. - * @param {Array>} contacts - * @returns {Array>} */ -export function mergeContactsByName(contacts) { - const map = new Map(); +export type ContactRecord = Record & { + name?: string; + lxmf_address?: string; + lxst_address?: string; + remote_destination_hash?: string; + remote_telephony_hash?: string; + remote_identity_hash?: string; +}; + +export function mergeContactsByName(contacts: ContactRecord[]): ContactRecord[] { + const map = new Map(); for (const c of contacts) { const key = String(c.name || "").toLowerCase(); if (!map.has(key)) { map.set(key, { ...c }); } else { - const existing = map.get(key); + const existing = map.get(key)!; existing.lxmf_address = existing.lxmf_address || c.lxmf_address; existing.lxst_address = existing.lxst_address || c.lxst_address; existing.remote_destination_hash = existing.remote_destination_hash || c.remote_destination_hash; diff --git a/meshchatx/src/frontend/features/debug-logs/DebugLogsPage.svelte b/meshchatx/src/frontend/features/debug-logs/DebugLogsPage.svelte index 67322407a..2e6845089 100644 --- a/meshchatx/src/frontend/features/debug-logs/DebugLogsPage.svelte +++ b/meshchatx/src/frontend/features/debug-logs/DebugLogsPage.svelte @@ -207,16 +207,14 @@
- {#snippet children()} - - - {/snippet} + +
diff --git a/meshchatx/src/frontend/features/debug-logs/index.js b/meshchatx/src/frontend/features/debug-logs/index.ts similarity index 88% rename from meshchatx/src/frontend/features/debug-logs/index.js rename to meshchatx/src/frontend/features/debug-logs/index.ts index 4ede2b39e..8a1d1bdb5 100644 --- a/meshchatx/src/frontend/features/debug-logs/index.js +++ b/meshchatx/src/frontend/features/debug-logs/index.ts @@ -2,7 +2,7 @@ import { registerFeature } from "../../js/registries/featureRegistry.js"; -export function registerDebugLogsFeature() { +export function registerDebugLogsFeature(): void { registerFeature({ id: "debug-logs", routes: [ diff --git a/meshchatx/src/frontend/features/debug-logs/lib/debugFormat.js b/meshchatx/src/frontend/features/debug-logs/lib/debugFormat.ts similarity index 59% rename from meshchatx/src/frontend/features/debug-logs/lib/debugFormat.js rename to meshchatx/src/frontend/features/debug-logs/lib/debugFormat.ts index 25c51a8e6..0105cff5c 100644 --- a/meshchatx/src/frontend/features/debug-logs/lib/debugFormat.js +++ b/meshchatx/src/frontend/features/debug-logs/lib/debugFormat.ts @@ -1,23 +1,24 @@ // SPDX-License-Identifier: 0BSD -/** - * @param {unknown} timestamp - * @returns {string} - */ -export function formatDebugTime(timestamp) { +export type DebugLogLine = { + timestamp?: unknown; + level?: string; + module?: string; + message?: string; + is_anomaly?: boolean; + anomaly_type?: string; +}; + +export function formatDebugTime(timestamp: unknown): string { try { - const ts = typeof timestamp === "number" ? timestamp * 1000 : timestamp; + const ts = typeof timestamp === "number" ? timestamp * 1000 : (timestamp as string | number | Date); return new Date(ts).toLocaleString(); } catch { return String(timestamp); } } -/** - * @param {string} level - * @returns {string} - */ -export function debugLevelClass(level) { +export function debugLevelClass(level: string): string { const l = String(level || "").toUpperCase(); if (l === "ERROR" || l === "CRITICAL") return "text-red-500"; if (l === "WARNING") return "text-orange-500"; @@ -26,11 +27,6 @@ export function debugLevelClass(level) { return "text-gray-400"; } -/** - * @param {object} log - * @param {(ts: unknown) => string} formatTime - * @returns {string} - */ -export function formatLogLine(log, formatTime = formatDebugTime) { +export function formatLogLine(log: DebugLogLine, formatTime: (ts: unknown) => string = formatDebugTime): string { return `${formatTime(log.timestamp)} [${log.level}] [${log.module}] ${log.message}${log.is_anomaly ? " [ANOMALY:" + (log.anomaly_type || "unknown") + "]" : ""}`; } diff --git a/meshchatx/src/frontend/features/forwarder/index.js b/meshchatx/src/frontend/features/forwarder/index.ts similarity index 89% rename from meshchatx/src/frontend/features/forwarder/index.js rename to meshchatx/src/frontend/features/forwarder/index.ts index 06cda4fb7..d45e11339 100644 --- a/meshchatx/src/frontend/features/forwarder/index.js +++ b/meshchatx/src/frontend/features/forwarder/index.ts @@ -5,7 +5,7 @@ import { registerFeature } from "../../js/registries/featureRegistry.js"; /** * LXMF message forwarder rules UI. */ -export function registerForwarderFeature() { +export function registerForwarderFeature(): void { registerFeature({ id: "forwarder", routes: [ diff --git a/meshchatx/src/frontend/features/forwarder/lib/forwarderHash.js b/meshchatx/src/frontend/features/forwarder/lib/forwarderHash.ts similarity index 55% rename from meshchatx/src/frontend/features/forwarder/lib/forwarderHash.js rename to meshchatx/src/frontend/features/forwarder/lib/forwarderHash.ts index 13511b199..6df73973d 100644 --- a/meshchatx/src/frontend/features/forwarder/lib/forwarderHash.js +++ b/meshchatx/src/frontend/features/forwarder/lib/forwarderHash.ts @@ -1,10 +1,6 @@ // SPDX-License-Identifier: 0BSD -/** - * @param {unknown} value - * @returns {boolean} - */ -export function isValidForwarderDestinationHash(value) { +export function isValidForwarderDestinationHash(value: unknown): boolean { const hash = String(value || "").trim(); return hash.length === 32 && /^[0-9a-fA-F]+$/.test(hash); } diff --git a/meshchatx/src/frontend/features/licenses/index.js b/meshchatx/src/frontend/features/licenses/index.ts similarity index 88% rename from meshchatx/src/frontend/features/licenses/index.js rename to meshchatx/src/frontend/features/licenses/index.ts index f918c2be3..37a55f212 100644 --- a/meshchatx/src/frontend/features/licenses/index.js +++ b/meshchatx/src/frontend/features/licenses/index.ts @@ -2,7 +2,7 @@ import { registerFeature } from "../../js/registries/featureRegistry.js"; -export function registerLicensesFeature() { +export function registerLicensesFeature(): void { registerFeature({ id: "licenses", routes: [ diff --git a/meshchatx/src/frontend/features/licenses/lib/licenseFilter.js b/meshchatx/src/frontend/features/licenses/lib/licenseFilter.ts similarity index 60% rename from meshchatx/src/frontend/features/licenses/lib/licenseFilter.js rename to meshchatx/src/frontend/features/licenses/lib/licenseFilter.ts index 8e86fe1cf..1764a9edf 100644 --- a/meshchatx/src/frontend/features/licenses/lib/licenseFilter.js +++ b/meshchatx/src/frontend/features/licenses/lib/licenseFilter.ts @@ -1,11 +1,13 @@ // SPDX-License-Identifier: 0BSD -/** - * @param {Array<{ name?: string, version?: string, author?: string, license?: string }>} rows - * @param {string} query - * @returns {typeof rows} - */ -export function filterLicenseRows(rows, query) { +export type LicenseRow = { + name?: string; + version?: string; + author?: string; + license?: string; +}; + +export function filterLicenseRows(rows: LicenseRow[], query: string): LicenseRow[] { const q = String(query || "") .trim() .toLowerCase(); diff --git a/meshchatx/src/frontend/features/messages/MessagesPage.svelte b/meshchatx/src/frontend/features/messages/MessagesPage.svelte new file mode 100644 index 000000000..b62a28dd0 --- /dev/null +++ b/meshchatx/src/frontend/features/messages/MessagesPage.svelte @@ -0,0 +1,1357 @@ + + + + +
+ + + {#if !isPopoutMode} + { + conversationSearchTerm = term; + requestConversationsRefresh(); + }} + onconversationFilterChanged={(filterKey) => { + if (filterKey === "unread") filterUnreadOnly = !filterUnreadOnly; + else if (filterKey === "failed") filterFailedOnly = !filterFailedOnly; + else if (filterKey === "attachments") filterHasAttachmentsOnly = !filterHasAttachmentsOnly; + requestConversationsRefresh(); + }} + onpeersSearchChanged={(term) => { + peersSearchTerm = term; + isSearchingAnnounces = true; + if (peersRefreshTimeout) clearTimeout(peersRefreshTimeout); + peersRefreshTimeout = setTimeout(() => { + void getLxmfDeliveryAnnounces(); + }, 500); + }} + oningestPaperMessage={() => { + ingestUri = ""; + isIngestModalOpen = true; + }} + onloadMore={() => void loadMoreConversations()} + onloadMoreAnnounces={() => void loadMoreAnnounces()} + onannouncesTabActivated={() => { + if (!announcesLoaded) { + announcesLoaded = true; + void getLxmfDeliveryAnnounces(); + } + }} + onfolderClick={(folderId) => { + selectedFolderId = folderId; + requestConversationsRefresh(); + }} + oncreateFolder={(name) => void onCreateFolder(name)} + onrenameFolder={(payload) => void onRenameFolder(payload)} + ondeleteFolder={(id) => void onDeleteFolder(id)} + onmoveToFolder={(payload) => void onMoveToFolder(payload)} + onbulkMarkAsRead={(hashes) => void onBulkMarkAsRead(hashes)} + onmarkAllAsRead={() => void onMarkAllAsRead()} + onbulkDelete={(hashes) => void onBulkDelete(hashes)} + onexportFolders={() => void onExportFolders()} + onimportFolders={onImportFolders} + onmessagesImported={() => void getConversations()} + ontoggleConversationPin={(hash) => void onToggleConversationPin(hash)} + ontoggleCollapse={() => { + messagesListSidebarCollapsed = !messagesListSidebarCollapsed; + }} + /> + {/if} + +
+ { + if (isWideViewport) dragOverPaneId = paneId; + }} + onpaneDragLeave={(paneId) => { + if (dragOverPaneId === paneId) dragOverPaneId = null; + }} + onpaneDrop={(paneId, event) => { + dragOverPaneId = null; + const hash = event?.dataTransfer?.getData("text/plain"); + if (hash) openConversationInPane(paneId, hash); + }} + onaddZoneDragOver={() => { + if (canAddPane) isDragOverAddZone = true; + }} + onaddZoneDragLeave={() => { + isDragOverAddZone = false; + }} + onaddZoneDrop={(event) => { + isDragOverAddZone = false; + const hash = event?.dataTransfer?.getData("text/plain"); + if (!hash || panes.length >= maxPanes) return; + const id = nextPaneId++; + panes = [...panes, { id, peer: null }]; + openConversationInPane(id, hash); + }} + onpanePeerUpdate={(paneId, peer) => { + focusPane(paneId); + if (peer) onPeerClick(peer); + }} + onpaneClose={onPaneClose} + onupdatePeerTracking={({ destination_hash, is_tracking }) => { + if (peers[destination_hash]) { + peers = { + ...peers, + [destination_hash]: { ...peers[destination_hash], is_tracking }, + }; + } + panes = applyPatchToPanePeers(panes, destination_hash, { is_tracking }); + }} + onreloadConversations={requestConversationsRefresh} + onoutboundComposeEnqueued={onOutboundComposeEnqueued} + onregisterPaneViewer={registerPaneViewer} + /> +
+ + {#if !isPopoutMode && !destinationHash} + + {/if} + + { + isMobileComposeModalOpen = false; + }} + onupdateAddress={(v) => { + mobileComposeAddress = v; + }} + onsubmit={() => { + const raw = mobileComposeAddress.trim(); + if (!raw) return; + isMobileComposeModalOpen = false; + mobileComposeAddress = ""; + void onComposeNewMessage(raw); + }} + oningest={() => { + isMobileComposeModalOpen = false; + ingestUri = ""; + isIngestModalOpen = true; + }} + /> + + { + isIngestModalOpen = false; + }} + onupdateIngestUri={(v) => { + ingestUri = v; + }} + oningest={() => void ingestPaperMessage()} + /> +
diff --git a/meshchatx/src/frontend/features/messages/components/AudioWaveformPlayer.svelte b/meshchatx/src/frontend/features/messages/components/AudioWaveformPlayer.svelte new file mode 100644 index 000000000..c4f972cda --- /dev/null +++ b/meshchatx/src/frontend/features/messages/components/AudioWaveformPlayer.svelte @@ -0,0 +1,275 @@ + + + + +
+ + +
+ {#if loading} +
+
+
+ {/if} + + {#if !loading && (isPlaying || progressPercent > 0)} +
+ {/if} +
+
+ +
+ {formatTime(currentTime)} / {formatTime(totalDuration)} +
+
+ + diff --git a/meshchatx/src/frontend/features/messages/components/ComposerAttachmentStrip.svelte b/meshchatx/src/frontend/features/messages/components/ComposerAttachmentStrip.svelte new file mode 100644 index 000000000..f7f8c5d1b --- /dev/null +++ b/meshchatx/src/frontend/features/messages/components/ComposerAttachmentStrip.svelte @@ -0,0 +1,136 @@ + + + + +
+ {#if imageCount > 0} +
+ {#if imageCount === 1} +
+ + +
+ {:else} +
+ {#each imageUrls.slice(0, 4) as url, index (url + index)} +
+ + +
+ {/each} +
+ {/if} +
+ {/if} + + {#if audio} +
+
+
{t("messages.voice_note")}
+
{Utils.formatBytes(audio.audio_blob.size)}
+ +
+ +
+ {/if} + + {#if files.length > 0} +
+ {#each files as file (file.name + file.size)} +
+
+ +
{file.name}
+ {Utils.formatBytes(file.size)} +
+ +
+ {/each} +
+ {/if} +
diff --git a/meshchatx/src/frontend/features/messages/components/ComposerEmojiStickerGifPicker.svelte b/meshchatx/src/frontend/features/messages/components/ComposerEmojiStickerGifPicker.svelte new file mode 100644 index 000000000..abeb75bac --- /dev/null +++ b/meshchatx/src/frontend/features/messages/components/ComposerEmojiStickerGifPicker.svelte @@ -0,0 +1,361 @@ + + + + +{#if open} +
event.stopPropagation()} + role="presentation" + > +
+ + + +
+ + + + + + +
+{/if} diff --git a/meshchatx/src/frontend/features/messages/components/ConversationComposer.svelte b/meshchatx/src/frontend/features/messages/components/ConversationComposer.svelte new file mode 100644 index 000000000..110fe8f80 --- /dev/null +++ b/meshchatx/src/frontend/features/messages/components/ConversationComposer.svelte @@ -0,0 +1,235 @@ + + + + +
+
+ {#if isPeerBlocked} +
+ + {t("messages.banished_peer_notice")} +
+ {/if} + + + +
+
+ +
+ onstartrecording?.(args)} + onstoprecording={() => onstoprecording?.()} + > + + {t("messages.recording", { duration: audioAttachmentRecordingDuration })} + + + +
+
+
+ onsend?.()} + ondeliverymethodchanged={(m) => { + deliveryMethod = m; + }} + onsendcommandorrequest={() => onsendcommandorrequest?.()} + onsendpapercompose={() => onsendpapercompose?.()} + /> +
+
+ + {#if replyingTo} +
+
+
+ + {t("messages.replying_to")} +
+
+ {replyingTo.lxmf_message?.content || t("messages.attachment_placeholder")} +
+
+ +
+ {/if} + +
+ + onaddimage?.(file)} /> + + {#if hasTranslator && text} + + {/if} +
+
+
diff --git a/meshchatx/src/frontend/features/messages/components/ConversationEmptyState.svelte b/meshchatx/src/frontend/features/messages/components/ConversationEmptyState.svelte new file mode 100644 index 000000000..48f109e55 --- /dev/null +++ b/meshchatx/src/frontend/features/messages/components/ConversationEmptyState.svelte @@ -0,0 +1,246 @@ + + + + +
+
+ + +
+ + + + +
+ + {#if latestConversations.length > 0} +
+

{t("messages.latest_conversations")}

+
+ {#each latestConversations as chat (chat.destination_hash)} + + {/each} +
+
+ {/if} + +
+
+
+ +
+ { + isComposeInputFocused = true; + }} + onblur={() => oncomposebblur?.()} + /> + + {#if isComposeInputFocused && composeSuggestions.length > 0} +
+
+ {#each composeSuggestions as suggestion, index (suggestion.hash)} + + {/each} +
+
+ {/if} +
+
+
+
diff --git a/meshchatx/src/frontend/features/messages/components/ConversationImageLightbox.svelte b/meshchatx/src/frontend/features/messages/components/ConversationImageLightbox.svelte new file mode 100644 index 000000000..1ae228978 --- /dev/null +++ b/meshchatx/src/frontend/features/messages/components/ConversationImageLightbox.svelte @@ -0,0 +1,25 @@ + + + + + diff --git a/meshchatx/src/frontend/features/messages/components/ConversationMessageContextMenu.svelte b/meshchatx/src/frontend/features/messages/components/ConversationMessageContextMenu.svelte new file mode 100644 index 000000000..ade99378d --- /dev/null +++ b/meshchatx/src/frontend/features/messages/components/ConversationMessageContextMenu.svelte @@ -0,0 +1,228 @@ + + + + +{#if show} + +{/if} diff --git a/meshchatx/src/frontend/features/messages/components/ConversationMessageEntry.svelte b/meshchatx/src/frontend/features/messages/components/ConversationMessageEntry.svelte new file mode 100644 index 000000000..617f70804 --- /dev/null +++ b/meshchatx/src/frontend/features/messages/components/ConversationMessageEntry.svelte @@ -0,0 +1,743 @@ + + + + +{#snippet outboundStatus(chatItem: MessageChatItem)} + {@const message = chatItem.lxmf_message} + {#if message.state === "delivered"} + + {:else if ["sent", "propagated", "unknown"].includes(message.state || "")} + + {:else if actions.showRichOutboundPendingUi(chatItem) && actions.isOutboundPendingForUi(chatItem)} + + + + + {:else if actions.isOutboundPendingForUi(chatItem)} + + {:else if actions.isOpportunisticDeferredDelivery(message)} +
+ +
+ {:else if ["failed", "cancelled", "rejected"].includes(message.state || "")} + + + + {/if} +{/snippet} + +{#snippet messageFooter(chatItem: MessageChatItem, showTimestamp = true)} + {@const message = chatItem.lxmf_message} + {#if showTimestamp || chatItem.is_outbound} +
+ {#if showTimestamp} + + {actions.formatTimeAgo(message.created_at)} + + {/if} + {#if chatItem.is_outbound} +
+ {#if actions.isOpportunisticDeferredDelivery(message)} + + {t("messages.opportunistic_deferred_label")} + + {:else if ["failed", "cancelled", "rejected"].includes(message.state || "")} + + {message.state === "rejected" ? "Rejected" : t("messages.failed_waiting_announce")} + + {/if} + {#if ["failed", "cancelled"].includes(message.state || "")} + + {/if} + {@render outboundStatus(chatItem)} +
+ {/if} +
+ {/if} +{/snippet} + +{#snippet expandedActions(chatItem: MessageChatItem)} + {#if chatItem.is_actions_expanded} +
+
+ {#if actions.canCancelOutboundSend(chatItem)} + + {/if} + + + +
+
+ {/if} +{/snippet} + +{#snippet imageAttachment(chatItem: MessageChatItem)} + {@const message = chatItem.lxmf_message} + {@const imageType = attachmentImageType(chatItem)} +
+
contextMenu(event, chatItem, true)} + > + {#if imageType === "webm"} + + {:else if imageType === "tgs"} +
+ +
+ {:else} + + {#if isAnimatedRasterType(imageType)} + { + event.stopPropagation(); + actions.onOutboundImageClick(chatItem); + }} + /> + {:else} + + {/if} + {/if} +
+ {imageType.toUpperCase() || "IMAGE"} · {actions.formatAttachmentSize(message.fields?.image, "image")} +
+ {#if chatItem.is_outbound} + + {/if} +
+
+{/snippet} + +{#snippet bubbleContent(chatItem: MessageChatItem)} + {@const message = chatItem.lxmf_message} + {@const model = actions.bubbleViewModel(chatItem)} + {@const parsedItems = parsed(chatItem)} +
+ {#if message.reply_to_hash} + + {/if} + {#if message.is_spam} +
+ + Marked as Spam +
+ {/if} + {#if message.content && !parsedItems.isOnlyPaperMessage && !parsedItems.isOnlyMapLink && !parsedItems.isOnlyRelayLink && !actions.shouldHideAutoImageCaption(chatItem)} + {#if actions.isMessageBodyTooLargeForDisplay(chatItem)} +
+

+ {t("messages.oversized_body_notice", { count: actions.messageBodyCharCount(chatItem) })} +

+ +
+ {:else if model.kind === "loading"} +
+ {t("messages.translating_message")} +
+ {:else} +
+ {#if model.showFooter} +
+ {#if model.showOriginalLink} + {t("messages.translated_from_to", { + source: String(model.fromCode || "").toUpperCase(), + target: String(model.toCode || "").toUpperCase(), + })} + + {:else if model.showTranslationLink} + + {/if} +
+ {/if} + {/if} + {/if} + {#if parsedItems.contact} +
+
+ + Contact Shared +
+
{parsedItems.contact.name}
+
{parsedItems.contact.hash}
+ {#if !chatItem.is_outbound} + + {/if} +
+ {/if} + {#if parsedItems.paperMessage} +
+
+ + + {t( + actions.isPaperMessageIngested(chatItem) + ? "messages.paper_message_ingested" + : "messages.paper_message_detected" + )} + +
+ {#if !chatItem.is_outbound && !actions.isPaperMessageIngested(chatItem)} + + {/if} +
+ {/if} + {#if parsedItems.mapLink} +
+
+ + + {t( + parsedItems.mapLink.kind === "ping" + ? "messages.map_link_ping_title" + : "messages.map_link_share_title" + )} + +
+
+ {Number(parsedItems.mapLink.parsed?.lat).toFixed(5)}, + {Number(parsedItems.mapLink.parsed?.lon).toFixed(5)} + (z{parsedItems.mapLink.parsed?.zoom}) +
+ + +
+ {/if} + {#if parsedItems.relayLink} +
+
+ + + {t( + parsedItems.relayLink.parsed?.room + ? "messages.relay_link_room_title" + : "messages.relay_link_hub_title" + )} + +
+ {#if parsedItems.relayLink.parsed?.name} +
{parsedItems.relayLink.parsed.name}
+ {/if} + {#if parsedItems.relayLink.parsed?.room} +
#{parsedItems.relayLink.parsed.room}
+ {/if} +
{parsedItems.relayLink.parsed?.hub}
+ + +
+ {/if} + {#if message.fields?.audio} +
+ {#if message.hash && actions.audioAttachmentUrls?.[message.hash]} + + {:else} +
+ {t("messages.downloading")} +
+ {/if} +
+ {t("messages.voice_note")} · {actions.formatAttachmentSize(message.fields.audio, "audio")} +
+
+ {/if} + {#if actions.hasFileAttachments(message)} +
+ {#each message.fields?.file_attachments || [] as attachment, index (attachment.file_name || index)} + + {/each} +
+ {/if} + {#if message.fields?.commands} +
+ {#each message.fields.commands as command, index (`${index}-${Object.keys(command).join("-")}`)} + {#if command["0x01"] || command["1"] || command["0x1"]} +
+ +
+
+ {t("messages.location_requested")} +
+
+
+ {/if} + {/each} +
+ {/if} + {#if message.fields?.telemetry?.location} + + {/if} + {@render messageFooter(chatItem, showTimestamp)} +
+{/snippet} + +{#if entry.type === "dateDivider"} + +{:else if entry.type === "imageGroup"} + {@const first = entry.items[0]} + {#if first} +
contextMenu(event, first)} + > +
+
+
+ {#each sortedImages.slice(0, 4) as image, index (image.lxmf_message.hash)} + + {/each} +
+ {#if first.is_outbound} + + {/if} +
+ +
+ {@render messageFooter(first, showTimestamp)} + {@render expandedActions(first)} +
+ {/if} +{:else if singleItem} + {@const chatItem = singleItem} + {@const message = chatItem.lxmf_message} +
contextMenu(event, chatItem)} + > + {#if message.fields?.image} + {@render imageAttachment(chatItem)} + {/if} + {#if actions.isImageOnlyMessage(chatItem)} + {@render messageFooter(chatItem, showTimestamp)} + {/if} + {#if actions.hasMessageBubble(chatItem)} +
+
actions.onChatItemClick(chatItem)} + onkeydown={(event) => { + if (event.key === "Enter" || event.key === " ") { + actions.onChatItemClick(chatItem); + } + }} + oncontextmenu={(event) => contextMenu(event, chatItem, true)} + > + + {@render bubbleContent(chatItem)} + {#if chatItem.is_outbound && !message.fields?.image} + + {/if} + {@render expandedActions(chatItem)} +
+ +
+ {/if} + {#if actions.expandedMessageInfo === message.hash} +
+ {#each actions.getMessageInfoLines(message, chatItem.is_outbound) as line, index (`${index}-${line}`)} +
{line}
+ {/each} +
+ {/if} +
+{/if} diff --git a/meshchatx/src/frontend/features/messages/components/ConversationMessageListVirtual.svelte b/meshchatx/src/frontend/features/messages/components/ConversationMessageListVirtual.svelte new file mode 100644 index 000000000..6974f7ba1 --- /dev/null +++ b/meshchatx/src/frontend/features/messages/components/ConversationMessageListVirtual.svelte @@ -0,0 +1,120 @@ + + + + +
+ {#each virtualRows as row (row.key)} +
+ +
+ {/each} +
diff --git a/meshchatx/src/frontend/features/messages/components/ConversationPeerHeader.svelte b/meshchatx/src/frontend/features/messages/components/ConversationPeerHeader.svelte new file mode 100644 index 000000000..9a1c95844 --- /dev/null +++ b/meshchatx/src/frontend/features/messages/components/ConversationPeerHeader.svelte @@ -0,0 +1,482 @@ + + + + +
+
+ +
+ +
+ +
+ + + {#if showPeerPathRow || selectedPeerSignalMetrics?.snr != null || selectedPeerLxmfStampInfo?.stamp_cost || lxmfHasOutboundTicket} + + {/if} +
+
+ +
+ {#if !compactPeerActions} +
+ + {#if pathMenuOpen} +
+ + + +
+ {/if} +
+ {/if} + +
+ + {#if moreMenuOpen} +
+ + + + + + + {#if hasFailedOrCancelledMessages} + + {/if} +
+ {#if isPeerBlocked} + + {:else} + + {/if} + +
+ {/if} +
+ + +
+
diff --git a/meshchatx/src/frontend/features/messages/components/ConversationRawMessageModal.svelte b/meshchatx/src/frontend/features/messages/components/ConversationRawMessageModal.svelte new file mode 100644 index 000000000..d73dccec8 --- /dev/null +++ b/meshchatx/src/frontend/features/messages/components/ConversationRawMessageModal.svelte @@ -0,0 +1,279 @@ + + + + +{#if open} +
{ + if (e.target === e.currentTarget) onclose?.(); + }} + onkeydown={(e) => { + if (e.key === "Escape") onclose?.(); + }} + role="presentation" + > + +
+{/if} diff --git a/meshchatx/src/frontend/features/messages/components/ConversationReactionPicker.svelte b/meshchatx/src/frontend/features/messages/components/ConversationReactionPicker.svelte new file mode 100644 index 000000000..c8d8f05a3 --- /dev/null +++ b/meshchatx/src/frontend/features/messages/components/ConversationReactionPicker.svelte @@ -0,0 +1,70 @@ + + + + +{#if open} +
{ + if (e.target === e.currentTarget) onclose?.(); + }} + onkeydown={(e) => { + if (e.key === "Escape") onclose?.(); + }} + role="presentation" + > +
+
{ + e.preventDefault(); + ondragstart?.(e); + }} + ontouchstart={(e) => { + e.preventDefault(); + ondragstart?.(e); + }} + role="presentation" + > + {t("messages.react")} + +
+ + onemojiclick?.(e)} + > +
+
+{/if} diff --git a/meshchatx/src/frontend/features/messages/components/ConversationStrangerBanner.svelte b/meshchatx/src/frontend/features/messages/components/ConversationStrangerBanner.svelte new file mode 100644 index 000000000..8f3cf8ced --- /dev/null +++ b/meshchatx/src/frontend/features/messages/components/ConversationStrangerBanner.svelte @@ -0,0 +1,44 @@ + + + + +
+
+ + + {t("messages.stranger_banner_text")} + +
+
+ + +
+
diff --git a/meshchatx/src/frontend/features/messages/components/ConversationTranslateBars.svelte b/meshchatx/src/frontend/features/messages/components/ConversationTranslateBars.svelte new file mode 100644 index 000000000..70c388047 --- /dev/null +++ b/meshchatx/src/frontend/features/messages/components/ConversationTranslateBars.svelte @@ -0,0 +1,149 @@ + + + + +{#if open && mode === "compose"} +
+ + + + + + {#if !options.length} +

+ {t("messages.translate_no_languages")} +

+ {/if} +
+{:else if open && mode === "bubble"} +
+
+
+ + {t("messages.translate_select_target")} +
+
+ +
+ + +
+
+ {#if !options.length} +

+ {t("messages.translate_no_languages")} +

+ {/if} +
+
+{/if} diff --git a/meshchatx/src/frontend/features/messages/components/ConversationViewer.svelte b/meshchatx/src/frontend/features/messages/components/ConversationViewer.svelte new file mode 100644 index 000000000..5ad1f7146 --- /dev/null +++ b/meshchatx/src/frontend/features/messages/components/ConversationViewer.svelte @@ -0,0 +1,1328 @@ + + + + +{#if selectedPeer} +
+ void copyTextToClipboard(hash)} + ondestinationpathclick={(path) => { + const hops = Number(path?.hops); + const hopsText = + hops === 0 || hops === 1 + ? `1 ${t("app.hop")}` + : `${Number.isFinite(hops) ? hops : "?"} ${t("app.hops_plural")}`; + const iface = + String(path?.next_hop_interface || "").trim() || t("messages.path_hops_unknown_iface"); + ToastUtils.info(t("messages.path_hops_via", { hops: hopsText, iface })); + }} + onsignalmetricsclick={(metrics) => { + const detail = metrics as Record | null; + void DialogUtils.alert(`RSSI: ${detail?.rssi ?? "?"}dBm\nSNR: ${detail?.snr ?? "?"}dB`); + }} + onstampinfoclick={(info) => void DialogUtils.alert(`Stamp cost: ${info?.stamp_cost ?? "?"}`)} + onconversationdeleted={() => { + onreloadConversations?.(); + onclose?.(); + }} + onpopout={() => { + if (!isPopout) location.hash = `#/popout/messages/${selectedHash}`; + }} + onretryfailed={() => { + const failed = selectedMessages.filter((item) => + ["failed", "cancelled"].includes(String(item.lxmf_message.state || "")) + ); + for (const item of failed) { + contextMenu.chatItem = item; + void retryMessage(); + } + }} + onstartcall={() => void window.api.post(`/api/v1/telephone/call/${selectedHash}`)} + onping={() => void pingSelectedPeer()} + onbanish={() => void banishSelectedPeer()} + onunbanish={() => void unbanishSelectedPeer()} + isPeerBlocked={isSelectedPeerBlocked} + onclose={() => onclose?.()} + onpathfinderquick={() => void runPathAction("quick")} + onpathfinderforce={() => void runPathAction("force")} + onpathfinderdrop={() => void runPathAction("drop_then_request")} + /> + + {#if isStrangerPeer && !strangerBannerDismissed && config?.show_unknown_contact_banner !== false} + void addStrangerAsContact()} + ondismiss={() => { + strangerBannerDismissed = true; + }} + /> + {/if} + +
+
+
+ {#if hasMorePrevious} + + {/if} + {#if useVirtualMessageList} + messagesScroll} + {actions} + /> + {:else} +
+ {#each groups as entry (entry.key)} + + {/each} +
+ {/if} +
+
+ + {#if !autoScrollOnNewMessage && messagesViewportReady} + + {/if} +
+ + void sendMessage()} + onaddfiles={() => fileInput?.click()} + onaddimage={addImage} + onstartrecording={(args) => void startAudioRecording(args)} + onstoprecording={() => void stopAudioRecording()} + onremovefile={(file) => { + newMessageFiles = newMessageFiles.filter((candidate) => candidate !== file); + }} + onremoveimage={removeImage} + onremoveaudio={() => { + if (newMessageAudio?.audio_preview_url) URL.revokeObjectURL(newMessageAudio.audio_preview_url); + newMessageAudio = null; + }} + oncancelreply={() => { + replyingTo = null; + }} + onpaste={onPaste} + onenter={composeEnter} + onshiftenter={() => { + newMessageText += "\n"; + }} + onsendpapercompose={generatePaperMessage} + /> + + { + newMessageFiles = newMessageFiles.concat(Array.from(event.currentTarget.files || [])); + }} + /> +
+{:else} + document.getElementById("compose-input")?.focus()} + onsync={() => GlobalEmitter.emit("sync-propagation-node")} + oncopyaddress={() => void copyTextToClipboard(myLxmfAddressHash)} + onidentities={() => { + location.hash = "#/identities"; + }} + onselectpeer={(peer) => onupdateSelectedPeer?.(peer as Peer)} + oncomposeenter={handleComposeAddress} + oncomposeup={() => { + selectedComposeSuggestionIndex = + selectedComposeSuggestionIndex > 0 ? selectedComposeSuggestionIndex - 1 : composeSuggestions.length - 1; + }} + oncomposedown={() => { + selectedComposeSuggestionIndex = + selectedComposeSuggestionIndex < composeSuggestions.length - 1 ? selectedComposeSuggestionIndex + 1 : 0; + }} + oncomposebblur={() => + setTimeout(() => { + isComposeInputFocused = false; + selectedComposeSuggestionIndex = -1; + }, 200)} + onselectsuggestion={(suggestion) => { + composeAddress = suggestion.hash; + isComposeInputFocused = false; + selectedComposeSuggestionIndex = -1; + handleComposeAddress(); + }} + /> +{/if} + + { + const item = imageLightboxItems?.[imageLightboxIndex] || imageLightboxItems?.[0]; + if (item) void downloadMessageImage(item); + }} +/> + + { + replyingTo = contextMenu.chatItem; + contextMenu.show = false; + }} + oncopy={() => { + void copyTextToClipboard(String(contextMenu.chatItem?.lxmf_message.content || "")); + contextMenu.show = false; + }} + onreact={(emoji) => void sendReaction(emoji)} + onviewraw={() => { + rawMessageData = contextMenu.chatItem?.lxmf_message || {}; + isRawMessageModalOpen = true; + contextMenu.show = false; + }} + oncancelsend={() => void cancelSending()} + onretry={() => void retryMessage()} + ondelete={() => void deleteMessage()} + onclose={() => { + contextMenu.show = false; + }} +/> + + MESSAGE_BODY_MAX_DISPLAY_CHARS} + bodyCharCount={String(rawMessageData.content || "").length} + hasStoredPath={rawMessageData.path_hops_at_send != null || Boolean(rawMessageData.path_interface_at_send)} + onclose={() => { + isRawMessageModalOpen = false; + }} + oncopyhash={(hash) => void copyTextToClipboard(hash)} + oncopycontent={() => void copyTextToClipboard(String(rawMessageData.content || ""))} +/> + +{#if isPaperMessageResultModalOpen} + { + isPaperMessageResultModalOpen = false; + generatedPaperMessageUri = null; + }} + /> +{/if} diff --git a/meshchatx/src/frontend/features/messages/components/InViewAnimatedImg.svelte b/meshchatx/src/frontend/features/messages/components/InViewAnimatedImg.svelte new file mode 100644 index 000000000..c2e126e0d --- /dev/null +++ b/meshchatx/src/frontend/features/messages/components/InViewAnimatedImg.svelte @@ -0,0 +1,62 @@ + + + + +
+ {#if show} + {#if onclick} + + {:else} + + {/if} + {:else} + + {/if} +
diff --git a/meshchatx/src/frontend/features/messages/components/MessagePanesHost.svelte b/meshchatx/src/frontend/features/messages/components/MessagePanesHost.svelte new file mode 100644 index 000000000..d80c5b4a0 --- /dev/null +++ b/meshchatx/src/frontend/features/messages/components/MessagePanesHost.svelte @@ -0,0 +1,161 @@ + + + + +{#each visiblePanes as pane, paneIndex (pane.id)} + {#if paneIndex > 0} + + {/if} +
onfocusPane?.(pane.id)} + ondragover={(e) => { + e.preventDefault(); + onpaneDragOver?.(pane.id); + }} + ondragleave={() => onpaneDragLeave?.(pane.id)} + ondrop={(e) => { + e.preventDefault(); + onpaneDrop?.(pane.id, e); + }} + role="presentation" + > + onpanePeerUpdate?.(pane.id, peer)} + onupdatePeerTracking={(payload) => onupdatePeerTracking?.(payload)} + onclose={() => onpaneClose?.(pane.id)} + onreloadConversations={() => onreloadConversations?.()} + onoutboundComposeEnqueued={(payload) => onoutboundComposeEnqueued?.(payload)} + onviewerReady={(api) => onregisterPaneViewer?.(pane.id, api)} + /> + {#if !pane.peer} +
+ + {t("messages.select_conversation_for_pane")} +
+ {/if} +
+{/each} + +{#if canAddPane} +
{ + e.preventDefault(); + onaddZoneDragOver?.(); + }} + ondragleave={() => onaddZoneDragLeave?.()} + ondrop={(e) => { + e.preventDefault(); + onaddZoneDrop?.(e); + }} + role="presentation" + > + +
+{/if} diff --git a/meshchatx/src/frontend/features/messages/components/MessageReactionsOverlay.svelte b/meshchatx/src/frontend/features/messages/components/MessageReactionsOverlay.svelte new file mode 100644 index 000000000..390d41ff3 --- /dev/null +++ b/meshchatx/src/frontend/features/messages/components/MessageReactionsOverlay.svelte @@ -0,0 +1,85 @@ + + + + +{#if reactions.length > 0 || showReactButton} +
+ {#each reactionChips as chip, chipIndex (chip.key)} + + {chip.kind === "more" ? `+${hiddenReactionCount}` : chip.reaction?.emoji || ""} + + {/each} + {#if showReactButton} + + {/if} +
+{/if} diff --git a/meshchatx/src/frontend/features/messages/components/MessagesIngestDialog.svelte b/meshchatx/src/frontend/features/messages/components/MessagesIngestDialog.svelte new file mode 100644 index 000000000..7f0aee24e --- /dev/null +++ b/meshchatx/src/frontend/features/messages/components/MessagesIngestDialog.svelte @@ -0,0 +1,240 @@ + + + + +{#if open} +
{ + if (e.target === e.currentTarget) onclose?.(); + }} + role="presentation" + > + +
+{/if} + +{#if isScannerOpen} +
{ + if (e.target === e.currentTarget) closeScanner(); + }} + role="presentation" + > + +
+{/if} diff --git a/meshchatx/src/frontend/features/messages/components/MessagesMobileCompose.svelte b/meshchatx/src/frontend/features/messages/components/MessagesMobileCompose.svelte new file mode 100644 index 000000000..db3259bab --- /dev/null +++ b/meshchatx/src/frontend/features/messages/components/MessagesMobileCompose.svelte @@ -0,0 +1,87 @@ + + + + +{#if open} +
{ + if (e.target === e.currentTarget) onclose?.(); + }} + role="presentation" + > + +
+{/if} diff --git a/meshchatx/src/frontend/features/messages/components/MessagesSidebar.svelte b/meshchatx/src/frontend/features/messages/components/MessagesSidebar.svelte new file mode 100644 index 000000000..fd542a44e --- /dev/null +++ b/meshchatx/src/frontend/features/messages/components/MessagesSidebar.svelte @@ -0,0 +1,637 @@ + + + + + diff --git a/meshchatx/src/frontend/features/messages/components/composer/AddAudioButton.svelte b/meshchatx/src/frontend/features/messages/components/composer/AddAudioButton.svelte new file mode 100644 index 000000000..6e8f3bac9 --- /dev/null +++ b/meshchatx/src/frontend/features/messages/components/composer/AddAudioButton.svelte @@ -0,0 +1,97 @@ + + + + +
+ {#if isRecordingAudioAttachment} + + {:else} + + {/if} + + {#if isShowingMenu} +
+
+ + + +
+
+ {/if} +
diff --git a/meshchatx/src/frontend/features/messages/components/composer/AddImageButton.svelte b/meshchatx/src/frontend/features/messages/components/composer/AddImageButton.svelte new file mode 100644 index 000000000..66d08a839 --- /dev/null +++ b/meshchatx/src/frontend/features/messages/components/composer/AddImageButton.svelte @@ -0,0 +1,144 @@ + + + + +
+ + +
+ {#if isShowingMenu} +
+
+ + + + +
+
+ {/if} +
+ + +
diff --git a/meshchatx/src/frontend/features/messages/components/composer/SendMessageButton.svelte b/meshchatx/src/frontend/features/messages/components/composer/SendMessageButton.svelte new file mode 100644 index 000000000..bb69e1aff --- /dev/null +++ b/meshchatx/src/frontend/features/messages/components/composer/SendMessageButton.svelte @@ -0,0 +1,232 @@ + + + + +
+ {#if compact} + + {:else} + +
+ +
+ {/if} + + {#if isShowingMenu} +
+
+ + + + +
+ {t("messages.send_menu_more_label")} +
+ + +
+
+ {/if} +
diff --git a/meshchatx/src/frontend/features/messages/components/modals/ConversationImageModal.svelte b/meshchatx/src/frontend/features/messages/components/modals/ConversationImageModal.svelte new file mode 100644 index 000000000..f73eb4b1f --- /dev/null +++ b/meshchatx/src/frontend/features/messages/components/modals/ConversationImageModal.svelte @@ -0,0 +1,123 @@ + + + + +{#if url} +
onclose?.()} + onkeydown={onKeydown} + role="dialog" + aria-modal="true" + > +
e.stopPropagation()} + onkeydown={(e) => e.stopPropagation()} + role="presentation" + > + + + {#if hasGallery} + + +
+ {index + 1} / {gallery!.length} +
+ {/if} + { + e.preventDefault(); + e.stopPropagation(); + oncontextmenu?.(e); + }} + /> +
+
+{/if} diff --git a/meshchatx/src/frontend/features/messages/components/modals/PaperMessageModal.svelte b/meshchatx/src/frontend/features/messages/components/modals/PaperMessageModal.svelte new file mode 100644 index 000000000..ded10da64 --- /dev/null +++ b/meshchatx/src/frontend/features/messages/components/modals/PaperMessageModal.svelte @@ -0,0 +1,315 @@ + + + + +
{ + if (e.target === e.currentTarget) close(); + }} + onkeydown={(e) => { + if (e.key === "Escape") close(); + }} + role="presentation" +> + +
diff --git a/meshchatx/src/frontend/features/messages/components/modals/ShareContactModal.svelte b/meshchatx/src/frontend/features/messages/components/modals/ShareContactModal.svelte new file mode 100644 index 000000000..4c9940cbf --- /dev/null +++ b/meshchatx/src/frontend/features/messages/components/modals/ShareContactModal.svelte @@ -0,0 +1,127 @@ + + + + +{#if show} +
{ + if (e.target === e.currentTarget) onclose?.(); + }} + onkeydown={(e) => { + if (e.key === "Escape") onclose?.(); + }} + role="presentation" + > + +
+{/if} diff --git a/meshchatx/src/frontend/features/messages/components/outbound/OutboundTransferProgressFooter.svelte b/meshchatx/src/frontend/features/messages/components/outbound/OutboundTransferProgressFooter.svelte new file mode 100644 index 000000000..4868024ec --- /dev/null +++ b/meshchatx/src/frontend/features/messages/components/outbound/OutboundTransferProgressFooter.svelte @@ -0,0 +1,49 @@ + + + + +{#if actions.showOutboundTransferProgress(lxmfMessage)} +
+
+
+
+
+ + {actions.outboundSendingProgressLabel(lxmfMessage)} + +
+ {#if statsLabel} +
+ {statsLabel} +
+ {/if} +
+{/if} diff --git a/meshchatx/src/frontend/features/messages/components/telemetry/TelemetryBatteryChart.svelte b/meshchatx/src/frontend/features/messages/components/telemetry/TelemetryBatteryChart.svelte new file mode 100644 index 000000000..f3889f2a0 --- /dev/null +++ b/meshchatx/src/frontend/features/messages/components/telemetry/TelemetryBatteryChart.svelte @@ -0,0 +1,216 @@ + + + + +{#if spec} +
+
+
+ + + {t("messages.telemetry_battery_trend")} + +
+ + {t("messages.telemetry_battery_range", { from: firstPct, to: lastPct })} + +
+
+
+ 100 + 75 + 50 + 25 + 0 +
+
(hover = null)} + role="presentation" + > + + + + + + + + + + + + + + {#each spec.gridLines as gridLine (gridLine.label)} + + {/each} + + + + + + {#if hover} + + + {/if} + + {#if hover} +
+
{hover.pct}%
+
{hover.timeLabel}
+
+ {/if} +
+ {startLabel} + {endLabel} +
+
+
+
+{/if} diff --git a/meshchatx/src/frontend/features/messages/components/telemetry/TelemetryHistoryListItem.svelte b/meshchatx/src/frontend/features/messages/components/telemetry/TelemetryHistoryListItem.svelte new file mode 100644 index 000000000..d0f7997cb --- /dev/null +++ b/meshchatx/src/frontend/features/messages/components/telemetry/TelemetryHistoryListItem.svelte @@ -0,0 +1,101 @@ + + + + +
+
+ + {item.is_outbound ? t("messages.telemetry_label_sent") : t("messages.telemetry_label_received")} + + {formatTimeAgo(item.lxmf_message.created_at)} +
+ + {#if location} +
+ +
+ {/if} + + {#if telemetry} +
+ {#if telemetry.battery} + + + {t("messages.telemetry_battery_level", { + percent: telemetry.battery.charge_percent, + })} + + {/if} + {#if telemetry.physical_link} + + + {t("messages.telemetry_snr_db", { snr: telemetry.physical_link.snr })} + + {/if} +
+ {/if} + + {#if hasLocationRequest} +
+ + {t("messages.telemetry_location_request")} +
+ {/if} +
diff --git a/meshchatx/src/frontend/features/messages/components/telemetry/TelemetryHistoryModal.svelte b/meshchatx/src/frontend/features/messages/components/telemetry/TelemetryHistoryModal.svelte new file mode 100644 index 000000000..c8b646097 --- /dev/null +++ b/meshchatx/src/frontend/features/messages/components/telemetry/TelemetryHistoryModal.svelte @@ -0,0 +1,130 @@ + + + + + { + if (open && event.key === "Escape") close(); + }} +/> + +{#if open} +
{ + if (event.target === event.currentTarget) close(); + }} + role="presentation" + > + +
+{/if} diff --git a/meshchatx/src/frontend/features/messages/index.ts b/meshchatx/src/frontend/features/messages/index.ts new file mode 100644 index 000000000..22b72c72f --- /dev/null +++ b/meshchatx/src/frontend/features/messages/index.ts @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: 0BSD + +import { registerFeature } from "../../js/registries/featureRegistry.js"; + +/** + * Messages feature: Svelte MessagesPage and ConversationViewer. + * Route meta is supported by routeRegistry (popoutType / isPopout / stableKey). + */ +export function registerMessagesFeature() { + registerFeature({ + id: "messages", + routes: [ + { + name: "messages", + path: "/messages/:destinationHash?", + mount: "svelte", + load: () => import("./MessagesPage.svelte"), + meta: { stableKey: true }, + }, + { + name: "messages-popout", + path: "/popout/messages/:destinationHash?", + mount: "svelte", + load: () => import("./MessagesPage.svelte"), + meta: { popoutType: "conversation", isPopout: true }, + }, + ], + }); +} diff --git a/meshchatx/src/frontend/features/messages/lib/constants.ts b/meshchatx/src/frontend/features/messages/lib/constants.ts new file mode 100644 index 000000000..ccd7c6f3a --- /dev/null +++ b/meshchatx/src/frontend/features/messages/lib/constants.ts @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: 0BSD + +import { MESSAGE_BODY_MAX_DISPLAY_CHARS } from "../../../js/messageDisplayLimits.js"; + +/** Conversation history page size for /api/v1/lxmf-messages/conversation. */ +export const CONVERSATION_MESSAGES_PAGE_SIZE = 50; + +/** Use the virtual message list once display groups reach this count. */ +export const MIN_VIRTUAL_DISPLAY_GROUPS = 48; + +/** Near-bottom epsilon for auto-scroll decisions (px). */ +export const SCROLL_BOTTOM_EPS_PX = 8; + +/** Scroll position from top that triggers load-previous (px). */ +export const LOAD_PREVIOUS_SCROLL_EDGE_PX = 500; + +/** Max passes when settling scroll-to-bottom after open. */ +export const SCROLL_SETTLE_MAX_PASSES = 24; + +/** Keep pinning scroll to bottom this long after opening a conversation (ms). */ +export const OPEN_CONVERSATION_SCROLL_PIN_MS = 900; + +/** Cached decoded audio object URLs kept in memory. */ +export const MAX_CACHED_AUDIO_ATTACHMENTS = 24; + +/** Sticker attachment size cap (bytes). */ +export const STICKER_MAX_BYTES = 512 * 1024; + +/** GIF attachment size cap (bytes). */ +export const GIF_MAX_BYTES = 2 * 1024 * 1024; + +/** Confirm before enqueue when total outbound payload exceeds this (bytes). */ +export const OUTBOUND_OVERSIZED_CONFIRM_BYTES = 900_000; + +/** Max reaction chips shown before a "+N" overflow affordance. */ +export const MAX_VISIBLE_REACTION_CHIPS = 4; + +/** Auto-image caption is skipped above this content length. */ +export const AUTO_IMAGE_CAPTION_MAX_CHARS = 240; + +/** localStorage prefix for per-peer compose drafts. */ +export const COMPOSE_DRAFT_STORAGE_PREFIX = "meshchatx.compose_draft."; + +/** localStorage key for ingested paper-message hashes. */ +export const PAPER_INGESTED_HASHES_STORAGE_KEY = "meshchatx.paper_ingested_hashes"; + +/** Cap stored paper-ingest hashes per identity. */ +export const PAPER_INGESTED_HASHES_MAX_PER_IDENTITY = 500; + +/** Fallback display name when peer announce has no name. */ +export const ANONYMOUS_PEER_DISPLAY_NAME = "Anonymous Peer"; + +/** Height estimates for virtual list rows (px). */ +export const VIRTUAL_ROW_HEIGHT = { + default: 96, + dateDivider: 44, + imageGroup: 340, + imageOnly: 280, + base: 88, +} as const; + +/** Re-export kernel inline body limit used by MessageEntry and raw modal. */ +export { MESSAGE_BODY_MAX_DISPLAY_CHARS }; diff --git a/meshchatx/src/frontend/features/messages/lib/conversationAudioDecode.ts b/meshchatx/src/frontend/features/messages/lib/conversationAudioDecode.ts new file mode 100644 index 000000000..bc1e26151 --- /dev/null +++ b/meshchatx/src/frontend/features/messages/lib/conversationAudioDecode.ts @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: 0BSD + +import { MAX_CACHED_AUDIO_ATTACHMENTS } from "./constants.js"; + +const MAX_CACHED_AUDIO = MAX_CACHED_AUDIO_ATTACHMENTS; + +export type AudioCacheEntry = { hash: string; objectUrl: string }; + +export function rememberAudioAttachment( + order: string[], + cache: Record, + hash: string, + objectUrl: string +): { order: string[]; cache: Record } { + const nextCache = { ...cache, [hash]: objectUrl }; + const nextOrder = order.filter((h) => h !== hash).concat(hash); + while (nextOrder.length > MAX_CACHED_AUDIO) { + const drop = nextOrder.shift(); + if (drop && nextCache[drop]) { + try { + URL.revokeObjectURL(nextCache[drop]); + } catch { + /* ignore */ + } + delete nextCache[drop]; + } + } + return { order: nextOrder, cache: nextCache }; +} + +export function clearAudioAttachmentCache(order: string[], cache: Record): void { + for (const hash of order) { + const url = cache[hash]; + if (url) { + try { + URL.revokeObjectURL(url); + } catch { + /* ignore */ + } + } + } +} diff --git a/meshchatx/src/frontend/components/messages/conversationDisplayGroups.js b/meshchatx/src/frontend/features/messages/lib/conversationDisplayGroups.ts similarity index 61% rename from meshchatx/src/frontend/components/messages/conversationDisplayGroups.js rename to meshchatx/src/frontend/features/messages/lib/conversationDisplayGroups.ts index 3edfb9d1c..7d4d4649a 100644 --- a/meshchatx/src/frontend/components/messages/conversationDisplayGroups.js +++ b/meshchatx/src/frontend/features/messages/lib/conversationDisplayGroups.ts @@ -1,36 +1,46 @@ // SPDX-License-Identifier: 0BSD -/** Messages fetched per incremental history page. */ -export const CONVERSATION_MESSAGES_PAGE_SIZE = 50; +import { CONVERSATION_MESSAGES_PAGE_SIZE } from "./constants.js"; +import type { ChatItemLike } from "./conversationMessageHelpers.js"; -/** - * @param {unknown} group - * @returns {unknown[]} - */ -export function itemsFromDisplayGroup(group) { +export { CONVERSATION_MESSAGES_PAGE_SIZE }; + +export type DisplayGroupSingle = { + type: "single"; + chatItem: ChatItemLike; + key: string; +}; + +export type DisplayGroupImage = { + type: "imageGroup"; + items: ChatItemLike[]; + key: string; +}; + +export type DisplayGroup = + DisplayGroupSingle | DisplayGroupImage | { type: string; key?: string; [k: string]: unknown }; + +export function itemsFromDisplayGroup(group: unknown): ChatItemLike[] { if (!group || typeof group !== "object") { return []; } - if (group.type === "single") { - return group.chatItem ? [group.chatItem] : []; + const g = group as DisplayGroup; + if (g.type === "single") { + return (g as DisplayGroupSingle).chatItem ? [(g as DisplayGroupSingle).chatItem] : []; } - if (group.type === "imageGroup" && Array.isArray(group.items)) { - return group.items.slice(); + if (g.type === "imageGroup" && Array.isArray((g as DisplayGroupImage).items)) { + return (g as DisplayGroupImage).items.slice(); } return []; } -/** - * Build display groups newest-first from peer chat items oldest-first. - * - * @param {unknown[]} itemsOldestFirst - * @param {(chatItem: unknown) => boolean} canMergeImageIntoImageStrip - * @returns {unknown[]} - */ -export function buildDisplayGroupsNewestFirst(itemsOldestFirst, canMergeImageIntoImageStrip) { +export function buildDisplayGroupsNewestFirst( + itemsOldestFirst: ChatItemLike[], + canMergeImageIntoImageStrip: (chatItem: ChatItemLike) => boolean +): DisplayGroup[] { const items = itemsOldestFirst || []; const n = items.length; - const groups = []; + const groups: DisplayGroup[] = []; let r = 0; while (r < n) { const item = items[n - 1 - r]; @@ -52,7 +62,7 @@ export function buildDisplayGroupsNewestFirst(itemsOldestFirst, canMergeImageInt groups.push({ type: "imageGroup", items: run, - key: run.map((x) => x.lxmf_message.hash).join("-"), + key: run.map((x) => x.lxmf_message?.hash).join("-"), }); r = j; continue; @@ -61,26 +71,18 @@ export function buildDisplayGroupsNewestFirst(itemsOldestFirst, canMergeImageInt groups.push({ type: "single", chatItem: item, - key: item.lxmf_message.hash, + key: String(item.lxmf_message?.hash || ""), }); r++; } return groups; } -/** - * Extend cached newest-first groups after prepending older peer chat items. - * - * @param {unknown[]} existingGroupsNewestFirst - * @param {unknown[]} prependedItemsOldestFirst - * @param {(chatItem: unknown) => boolean} canMergeImageIntoImageStrip - * @returns {unknown[]} - */ export function prependDisplayGroupsNewestFirst( - existingGroupsNewestFirst, - prependedItemsOldestFirst, - canMergeImageIntoImageStrip -) { + existingGroupsNewestFirst: DisplayGroup[], + prependedItemsOldestFirst: ChatItemLike[], + canMergeImageIntoImageStrip: (chatItem: ChatItemLike) => boolean +): DisplayGroup[] { const prepended = prependedItemsOldestFirst || []; const existing = existingGroupsNewestFirst || []; if (prepended.length === 0) { @@ -99,14 +101,8 @@ export function prependDisplayGroupsNewestFirst( return headGroups.concat(boundaryGroups); } -/** - * Flatten display groups to message hashes oldest-first (oracle helper). - * - * @param {unknown[]} groupsNewestFirst - * @returns {string[]} - */ -export function displayGroupHashesOldestFirst(groupsNewestFirst) { - const hashes = []; +export function displayGroupHashesOldestFirst(groupsNewestFirst: DisplayGroup[]): string[] { + const hashes: string[] = []; const groups = groupsNewestFirst || []; for (let gi = groups.length - 1; gi >= 0; gi--) { const group = groups[gi]; @@ -114,15 +110,16 @@ export function displayGroupHashesOldestFirst(groupsNewestFirst) { continue; } if (group.type === "single") { - const hash = group.chatItem?.lxmf_message?.hash; + const hash = (group as DisplayGroupSingle).chatItem?.lxmf_message?.hash; if (hash) { hashes.push(hash); } continue; } - if (group.type === "imageGroup" && Array.isArray(group.items)) { - for (let ii = group.items.length - 1; ii >= 0; ii--) { - const hash = group.items[ii]?.lxmf_message?.hash; + if (group.type === "imageGroup" && Array.isArray((group as DisplayGroupImage).items)) { + const items = (group as DisplayGroupImage).items; + for (let ii = items.length - 1; ii >= 0; ii--) { + const hash = items[ii]?.lxmf_message?.hash; if (hash) { hashes.push(hash); } @@ -132,24 +129,15 @@ export function displayGroupHashesOldestFirst(groupsNewestFirst) { return hashes; } -/** - * Simulate incremental page prepends from the newest end (matches ConversationViewer). - * pageSizesFromNewest: cumulative counts from the newest message backward. - * - * @param {unknown[]} allItemsOldestFirst - * @param {number[]} pageSizesFromNewest - * @param {(chatItem: unknown) => boolean} canMergeImageIntoImageStrip - * @returns {boolean} - */ export function incrementalPrependMatchesFullRebuild( - allItemsOldestFirst, - pageSizesFromNewest, - canMergeImageIntoImageStrip -) { + allItemsOldestFirst: ChatItemLike[], + pageSizesFromNewest: number[], + canMergeImageIntoImageStrip: (chatItem: ChatItemLike) => boolean +): boolean { const items = allItemsOldestFirst || []; const n = items.length; - let loaded = []; - let cached = null; + let loaded: ChatItemLike[] = []; + let cached: DisplayGroup[] | null = null; for (const count of pageSizesFromNewest) { if (count > n) { return false; @@ -174,7 +162,7 @@ export function incrementalPrependMatchesFullRebuild( return false; } } - const incHashes = displayGroupHashesOldestFirst(cached); + const incHashes = displayGroupHashesOldestFirst(cached || []); const fullHashes = displayGroupHashesOldestFirst(full); return incHashes.join("\u241e") === fullHashes.join("\u241e"); } diff --git a/meshchatx/src/frontend/features/messages/lib/conversationDrafts.ts b/meshchatx/src/frontend/features/messages/lib/conversationDrafts.ts new file mode 100644 index 000000000..60d99b0bf --- /dev/null +++ b/meshchatx/src/frontend/features/messages/lib/conversationDrafts.ts @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: 0BSD + +import { COMPOSE_DRAFT_STORAGE_PREFIX } from "./constants.js"; + +export function draftStorageKey(identityKey: string, destinationHash: string): string { + return `${COMPOSE_DRAFT_STORAGE_PREFIX}${identityKey || "_"}:${(destinationHash || "").toLowerCase()}`; +} + +export function loadDraft(destinationHash: string, identityKey: string): string { + if (!destinationHash) { + return ""; + } + try { + return localStorage.getItem(draftStorageKey(identityKey, destinationHash)) || ""; + } catch { + return ""; + } +} + +export function saveDraft(destinationHash: string, identityKey: string, text: string): void { + if (!destinationHash) { + return; + } + const key = draftStorageKey(identityKey, destinationHash); + try { + if (!text) { + localStorage.removeItem(key); + } else { + localStorage.setItem(key, text); + } + } catch { + /* ignore */ + } +} diff --git a/meshchatx/src/frontend/features/messages/lib/conversationListApply.ts b/meshchatx/src/frontend/features/messages/lib/conversationListApply.ts new file mode 100644 index 000000000..6436567ad --- /dev/null +++ b/meshchatx/src/frontend/features/messages/lib/conversationListApply.ts @@ -0,0 +1,235 @@ +// SPDX-License-Identifier: 0BSD + +import { lxmfConversationListPreview } from "../../../js/lxmfConversationPreview.js"; +import { ANONYMOUS_PEER_DISPLAY_NAME } from "./peerAnnounce.js"; +import type { Conversation, Peer } from "./types.js"; + +export type LxmfMessageLike = { + is_incoming?: boolean; + source_hash?: string; + destination_hash?: string; + content?: string; + title?: string; + fields?: Record; + hash?: string; + timestamp?: number; + state?: string | null; + [key: string]: unknown; +}; + +export type ComposeEnqueuePayload = { + peerHash: string; + previewText?: string; + title?: string; + fields?: Record; + pendingHash?: string; +}; + +export type PreviewContext = { + myLxmfAddressHash?: string; + peerDisplayName: string; + t?: (key: string, values?: Record) => string; +}; + +/** + * Peer hash for an LXMF message row (MessagesPage peerHashFromMessage). + */ +export function peerHashFromMessage(msg: LxmfMessageLike | null | undefined): string | null { + if (!msg) { + return null; + } + return msg.is_incoming ? (msg.source_hash ?? null) : (msg.destination_hash ?? null); +} + +/** + * Sidebar display name for a peer from conversations then peers map. + */ +export function peerDisplayNameForConversationSidebar( + peerHash: string, + conversations: Conversation[] | null | undefined, + peers: Record | null | undefined +): string { + const conv = (conversations || []).find((c) => c.destination_hash === peerHash); + if (conv) { + return conv.custom_display_name ?? conv.display_name ?? ANONYMOUS_PEER_DISPLAY_NAME; + } + const peer = peers?.[peerHash]; + return peer?.custom_display_name ?? peer?.display_name ?? ANONYMOUS_PEER_DISPLAY_NAME; +} + +/** + * Adjust failed_messages_count when outbound message state changes. + */ +export function nextFailedMessagesCount( + currentCount: number | null | undefined, + oldState: string | null | undefined, + newState: string | null | undefined +): number { + let count = typeof currentCount === "number" ? currentCount : 0; + if (newState === "failed" && oldState !== "failed") { + return count + 1; + } + if (oldState === "failed" && newState !== "failed") { + return Math.max(0, (count || 1) - 1); + } + return count; +} + +/** + * Mutate conversation failed count + _lastKnownState from lxmf_message_state_updated. + * Returns false when conversation is missing. + */ +export function applyFailedCountFromStateUpdate( + conversation: Conversation | null | undefined, + msg: LxmfMessageLike | null | undefined +): boolean { + if (!conversation || !msg) { + return false; + } + const oldState = conversation._lastKnownState; + const newState = msg.state ?? null; + conversation._lastKnownState = newState; + conversation.failed_messages_count = nextFailedMessagesCount( + conversation.failed_messages_count, + oldState, + newState + ); + return true; +} + +export type ApplyPreviewInput = { + conversations: Conversation[]; + peerHash: string; + preview: string; + title?: string | null; + timestampSec: number; + peers?: Record | null; + selectedPeer?: Peer | null; +}; + +/** + * Optimistic sidebar bump for outbound compose enqueue or lxmf_message_created. + * Mutates conversations in place (Vue array). Returns "updated" | "created". + */ +export function applyOutboundPreviewToConversations(input: ApplyPreviewInput): "updated" | "created" { + const { conversations, peerHash, preview, title, timestampSec, peers, selectedPeer } = input; + const updatedAt = new Date(timestampSec * 1000).toISOString(); + const idx = conversations.findIndex((c) => c.destination_hash === peerHash); + if (idx !== -1) { + const conv = conversations[idx]; + conv.latest_message_preview = preview; + conv.latest_message_title = title ?? ""; + conv.latest_message_created_at = timestampSec; + conv.updated_at = updatedAt; + return "updated"; + } + + const peer = peers?.[peerHash]; + conversations.unshift({ + destination_hash: peerHash, + display_name: peer?.display_name ?? selectedPeer?.display_name ?? ANONYMOUS_PEER_DISPLAY_NAME, + custom_display_name: peer?.custom_display_name ?? selectedPeer?.custom_display_name ?? null, + contact_image: peer?.contact_image ?? null, + lxmf_user_icon: peer?.lxmf_user_icon ?? null, + is_unread: false, + is_tracking: peer?.is_tracking ?? false, + failed_messages_count: 0, + has_attachments: false, + latest_message_preview: preview, + latest_message_title: title ?? "", + latest_message_created_at: timestampSec, + updated_at: updatedAt, + is_contact: false, + }); + return "created"; +} + +/** + * Build preview text + apply outbound compose enqueue stub to the conversation list. + */ +export function applyOutboundComposeEnqueued( + conversations: Conversation[], + payload: ComposeEnqueuePayload, + ctx: { + peers?: Record | null; + selectedPeer?: Peer | null; + myLxmfAddressHash?: string; + t?: PreviewContext["t"]; + } +): "updated" | "created" | null { + if (!payload?.peerHash) { + return null; + } + const peerHash = payload.peerHash; + const nowSec = Math.floor(Date.now() / 1000); + const stub: LxmfMessageLike = { + content: payload.previewText || "", + title: payload.title || "", + fields: payload.fields || {}, + hash: payload.pendingHash || "", + timestamp: nowSec, + is_incoming: false, + }; + const peerDisplay = peerDisplayNameForConversationSidebar(peerHash, conversations, ctx.peers ?? null); + const preview = lxmfConversationListPreview(stub, { + myLxmfAddressHash: ctx.myLxmfAddressHash || "", + peerDisplayName: peerDisplay, + t: ctx.t, + }); + return applyOutboundPreviewToConversations({ + conversations, + peerHash, + preview, + title: stub.title, + timestampSec: nowSec, + peers: ctx.peers, + selectedPeer: ctx.selectedPeer, + }); +} + +/** + * Apply lxmf_message_created preview bump to the conversation list. + */ +export function applyOutboundMessageCreated( + conversations: Conversation[], + msg: LxmfMessageLike, + ctx: { + peers?: Record | null; + selectedPeer?: Peer | null; + myLxmfAddressHash?: string; + t?: PreviewContext["t"]; + } +): "updated" | "created" | null { + const peerHash = peerHashFromMessage(msg); + if (!peerHash) { + return null; + } + const peerDisplay = peerDisplayNameForConversationSidebar(peerHash, conversations, ctx.peers ?? null); + const preview = lxmfConversationListPreview(msg, { + myLxmfAddressHash: ctx.myLxmfAddressHash || "", + peerDisplayName: peerDisplay, + t: ctx.t, + }); + const timestampSec = typeof msg.timestamp === "number" ? msg.timestamp : Math.floor(Date.now() / 1000); + return applyOutboundPreviewToConversations({ + conversations, + peerHash, + preview, + title: msg.title, + timestampSec, + peers: ctx.peers, + selectedPeer: ctx.selectedPeer, + }); +} + +/** + * Apply lxmf_message_state_updated failed-count logic for a peer hash. + */ +export function applyOutboundMessageStateUpdated(conversations: Conversation[], msg: LxmfMessageLike): boolean { + const peerHash = peerHashFromMessage(msg); + if (!peerHash) { + return false; + } + const conv = conversations.find((c) => c.destination_hash === peerHash); + return applyFailedCountFromStateUpdate(conv, msg); +} diff --git a/meshchatx/src/frontend/components/messages/conversationMessageHelpers.js b/meshchatx/src/frontend/features/messages/lib/conversationMessageHelpers.ts similarity index 59% rename from meshchatx/src/frontend/components/messages/conversationMessageHelpers.js rename to meshchatx/src/frontend/features/messages/lib/conversationMessageHelpers.ts index 4f5e0ff77..e2b8c872d 100644 --- a/meshchatx/src/frontend/components/messages/conversationMessageHelpers.js +++ b/meshchatx/src/frontend/features/messages/lib/conversationMessageHelpers.ts @@ -1,34 +1,42 @@ -/** - * Pure helpers for LXMF message display predicates and clipboard/drag image extraction. - */ +// SPDX-License-Identifier: 0BSD -/** - * @param {object} msg - * @returns {boolean} - */ -export function hasFileAttachments(msg) { +export type LxmfFields = { + file_attachments?: unknown[]; + image?: unknown; + audio?: unknown; + telemetry?: unknown; + telemetry_stream?: unknown; + commands?: Array>; +}; + +export type LxmfMessageLike = { + content?: string; + reply_to_hash?: string; + fields?: LxmfFields; + hash?: string; +}; + +export type ChatItemLike = { + lxmf_message?: LxmfMessageLike; + is_outbound?: boolean; +}; + +export function hasFileAttachments(msg: LxmfMessageLike | null | undefined): boolean { const files = msg?.fields?.file_attachments; return Array.isArray(files) && files.length > 0; } -/** - * @param {object} msg - * @returns {boolean} - */ -export function isTelemetryOnly(msg) { - const hasContent = msg.content && msg.content.trim() !== ""; - const hasAttachments = msg.fields?.image || msg.fields?.audio || hasFileAttachments(msg); - const hasTelemetry = msg.fields?.telemetry || msg.fields?.telemetry_stream; - const hasCommands = msg.fields?.commands && msg.fields.commands.some((c) => c["0x01"]); +export function isTelemetryOnly(msg: LxmfMessageLike | null | undefined): boolean { + const hasContent = msg?.content && msg.content.trim() !== ""; + const hasAttachments = msg?.fields?.image || msg?.fields?.audio || hasFileAttachments(msg); + const hasTelemetry = msg?.fields?.telemetry || msg?.fields?.telemetry_stream; + const hasCommands = msg?.fields?.commands && msg.fields.commands.some((c) => c["0x01"]); return !hasContent && !hasAttachments && !!(hasTelemetry || hasCommands); } -/** - * @param {object} msg - * @returns {boolean} - */ -export function hasRenderableContent(msg) { +export function hasRenderableContent(msg: LxmfMessageLike | null | undefined): boolean { + if (!msg) return false; if (msg.content && msg.content.trim() !== "") return true; if (msg.fields?.image) return true; if (msg.fields?.audio) return true; @@ -38,14 +46,12 @@ export function hasRenderableContent(msg) { return false; } -/** - * @param {object} chatItem - * @param {(item: object) => boolean} shouldHideAutoImageCaption - * @returns {boolean} - */ -export function isFileOnlyMessage(chatItem, shouldHideAutoImageCaption) { +export function isFileOnlyMessage( + chatItem: ChatItemLike, + shouldHideAutoImageCaption: (item: ChatItemLike) => boolean +): boolean { const msg = chatItem.lxmf_message; - if (!hasFileAttachments(msg)) return false; + if (!msg || !hasFileAttachments(msg)) return false; if (msg.fields?.image || msg.fields?.audio) return false; const content = (msg.content || "").trim(); if (content && !shouldHideAutoImageCaption(chatItem)) return false; @@ -55,29 +61,12 @@ export function isFileOnlyMessage(chatItem, shouldHideAutoImageCaption) { return true; } -/** - * @param {object} chatItem - * @param {(item: object) => boolean} shouldHideAutoImageCaption - * @returns {boolean} - */ -export function hasMessageBubble(chatItem, shouldHideAutoImageCaption) { - if (!chatItem?.lxmf_message) { - return false; - } - if (isImageOnlyMessage(chatItem, shouldHideAutoImageCaption)) { - return false; - } - return hasRenderableContent(chatItem.lxmf_message); -} - -/** - * @param {object} chatItem - * @param {(item: object) => boolean} shouldHideAutoImageCaption - * @returns {boolean} - */ -export function isImageOnlyMessage(chatItem, shouldHideAutoImageCaption) { +export function isImageOnlyMessage( + chatItem: ChatItemLike, + shouldHideAutoImageCaption: (item: ChatItemLike) => boolean +): boolean { const msg = chatItem.lxmf_message; - if (!msg.fields?.image) return false; + if (!msg?.fields?.image) return false; if (msg.fields?.audio || hasFileAttachments(msg)) return false; const content = (msg.content || "").trim(); if (content && !shouldHideAutoImageCaption(chatItem)) return false; @@ -87,17 +76,26 @@ export function isImageOnlyMessage(chatItem, shouldHideAutoImageCaption) { return true; } -/** - * @param {DataTransfer | null | undefined} dt - * @returns {File[]} - */ -export function collectImageFilesFromDataTransfer(dt) { +export function hasMessageBubble( + chatItem: ChatItemLike, + shouldHideAutoImageCaption: (item: ChatItemLike) => boolean +): boolean { + if (!chatItem?.lxmf_message) { + return false; + } + if (isImageOnlyMessage(chatItem, shouldHideAutoImageCaption)) { + return false; + } + return hasRenderableContent(chatItem.lxmf_message); +} + +export function collectImageFilesFromDataTransfer(dt: DataTransfer | null | undefined): File[] { if (!dt) { return []; } - const out = []; - const seen = new Set(); - const pushIfImage = (f) => { + const out: File[] = []; + const seen = new Set(); + const pushIfImage = (f: File | null) => { if (!f?.type?.startsWith("image/")) { return; } @@ -120,24 +118,19 @@ export function collectImageFilesFromDataTransfer(dt) { for (let i = 0; i < dt.items.length; i++) { const item = dt.items[i]; if (item.kind === "file" && item.type?.startsWith("image/")) { - const f = item.getAsFile(); - pushIfImage(f); + pushIfImage(item.getAsFile()); } } } return out; } -/** - * @param {ClipboardEvent} event - * @returns {File[]} - */ -export function extractClipboardImageFiles(event) { +export function extractClipboardImageFiles(event: ClipboardEvent): File[] { const cd = event.clipboardData; if (!cd?.items?.length) { return []; } - const imageBlobs = []; + const imageBlobs: File[] = []; for (let i = 0; i < cd.items.length; i++) { const item = cd.items[i]; if (item.kind === "file" && item.type.startsWith("image/")) { diff --git a/meshchatx/src/frontend/features/messages/lib/conversationOutboundUi.ts b/meshchatx/src/frontend/features/messages/lib/conversationOutboundUi.ts new file mode 100644 index 000000000..619a96246 --- /dev/null +++ b/meshchatx/src/frontend/features/messages/lib/conversationOutboundUi.ts @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: 0BSD + +/** Pure outbound / transfer UI label helpers used by conversation bubbles. */ + +export function transferProgressPercent(loaded: number, total: number): number { + if (!total || total <= 0) { + return 0; + } + return Math.max(0, Math.min(100, Math.round((loaded / total) * 100))); +} + +export function outboundStateTitle(state: string | null | undefined): string { + const s = String(state || "").toLowerCase(); + if (s === "delivered" || s === "received") return "delivered"; + if (s === "sent" || s === "outbound") return "sent"; + if (s === "failed" || s === "cancelled") return s; + if (s === "sending") return "sending"; + return s || "unknown"; +} + +export function outboundStateIconName(state: string | null | undefined): string { + const s = String(state || "").toLowerCase(); + if (s === "delivered" || s === "received") return "check-all"; + if (s === "sent" || s === "outbound") return "check"; + if (s === "failed") return "alert-circle"; + if (s === "cancelled") return "cancel"; + if (s === "sending") return "loading"; + return "clock-outline"; +} diff --git a/meshchatx/src/frontend/components/messages/conversationPaperIngest.js b/meshchatx/src/frontend/features/messages/lib/conversationPaperIngest.ts similarity index 56% rename from meshchatx/src/frontend/components/messages/conversationPaperIngest.js rename to meshchatx/src/frontend/features/messages/lib/conversationPaperIngest.ts index c7ecd414c..aa00373ed 100644 --- a/meshchatx/src/frontend/components/messages/conversationPaperIngest.js +++ b/meshchatx/src/frontend/features/messages/lib/conversationPaperIngest.ts @@ -1,43 +1,31 @@ -/* SPDX-License-Identifier: 0BSD */ +// SPDX-License-Identifier: 0BSD -const STORAGE_KEY = "meshchatx.paper_ingested_hashes"; -const MAX_HASHES_PER_IDENTITY = 500; +import { PAPER_INGESTED_HASHES_MAX_PER_IDENTITY, PAPER_INGESTED_HASHES_STORAGE_KEY } from "./constants.js"; -/** - * @param {unknown} identityKey - * @returns {string} - */ -export function normalizePaperIngestIdentityKey(identityKey) { +const STORAGE_KEY = PAPER_INGESTED_HASHES_STORAGE_KEY; +const MAX_HASHES_PER_IDENTITY = PAPER_INGESTED_HASHES_MAX_PER_IDENTITY; + +export function normalizePaperIngestIdentityKey(identityKey: unknown): string { return typeof identityKey === "string" && identityKey ? identityKey : "_"; } -/** - * @param {unknown} messageHash - * @returns {string} - */ -export function normalizePaperIngestMessageHash(messageHash) { +export function normalizePaperIngestMessageHash(messageHash: unknown): string { return typeof messageHash === "string" ? messageHash.trim().toLowerCase() : ""; } -/** - * @returns {Record} - */ -function readRoot() { +function readRoot(): Record { try { const raw = JSON.parse(localStorage.getItem(STORAGE_KEY) || "{}"); if (!raw || typeof raw !== "object" || Array.isArray(raw)) { return {}; } - return raw; + return raw as Record; } catch { return {}; } } -/** - * @param {Record} root - */ -function writeRoot(root) { +function writeRoot(root: Record): void { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(root)); } catch { @@ -45,11 +33,7 @@ function writeRoot(root) { } } -/** - * @param {unknown} identityKey - * @returns {string[]} - */ -export function listIngestedPaperMessageHashes(identityKey) { +export function listIngestedPaperMessageHashes(identityKey: unknown): string[] { const key = normalizePaperIngestIdentityKey(identityKey); const root = readRoot(); const list = root[key]; @@ -59,12 +43,7 @@ export function listIngestedPaperMessageHashes(identityKey) { return list.filter((h) => typeof h === "string" && h); } -/** - * @param {unknown} identityKey - * @param {unknown} messageHash - * @returns {boolean} - */ -export function isPaperMessageIngested(identityKey, messageHash) { +export function isPaperMessageIngested(identityKey: unknown, messageHash: unknown): boolean { const hash = normalizePaperIngestMessageHash(messageHash); if (!hash) { return false; @@ -72,12 +51,7 @@ export function isPaperMessageIngested(identityKey, messageHash) { return listIngestedPaperMessageHashes(identityKey).includes(hash); } -/** - * @param {unknown} identityKey - * @param {unknown} messageHash - * @returns {string[]} updated list for that identity - */ -export function markPaperMessageIngested(identityKey, messageHash) { +export function markPaperMessageIngested(identityKey: unknown, messageHash: unknown): string[] { const key = normalizePaperIngestIdentityKey(identityKey); const hash = normalizePaperIngestMessageHash(messageHash); if (!hash) { @@ -97,11 +71,6 @@ export function markPaperMessageIngested(identityKey, messageHash) { return next; } -/** - * Whether an ingest_uri result should mark the source bubble as ingested. - * @param {unknown} status - * @returns {boolean} - */ -export function shouldMarkPaperIngestFromResultStatus(status) { +export function shouldMarkPaperIngestFromResultStatus(status: unknown): boolean { return status === "success" || status === "info"; } diff --git a/meshchatx/src/frontend/features/messages/lib/conversationPathActions.ts b/meshchatx/src/frontend/features/messages/lib/conversationPathActions.ts new file mode 100644 index 000000000..088183f98 --- /dev/null +++ b/meshchatx/src/frontend/features/messages/lib/conversationPathActions.ts @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: 0BSD + +/** + * Pathfinder / stamp / signal helpers. Heavy RNS calls stay on the viewer shell via window.api. + */ + +export type PeerPathSnapshot = { + hops?: number | null; + next_hop?: string | null; + interface_name?: string | null; + updated_at?: number | null; +}; + +export function emptyPeerPathSnapshot(): PeerPathSnapshot { + return { hops: null, next_hop: null, interface_name: null, updated_at: null }; +} + +export function pathHopsLabel(snapshot: PeerPathSnapshot | null | undefined): string { + if (snapshot?.hops == null) { + return ""; + } + return String(snapshot.hops); +} diff --git a/meshchatx/src/frontend/features/messages/lib/conversationPeer.ts b/meshchatx/src/frontend/features/messages/lib/conversationPeer.ts new file mode 100644 index 000000000..4ba0c7e1f --- /dev/null +++ b/meshchatx/src/frontend/features/messages/lib/conversationPeer.ts @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: 0BSD + +function normalizeHash(value: unknown): string { + return typeof value === "string" ? value.trim().toLowerCase() : ""; +} + +export function isSelfLxmfDestination(peerHash: unknown, myLxmfAddressHash: unknown, identityHash: unknown): boolean { + const peer = normalizeHash(peerHash); + if (!peer) { + return false; + } + return peer === normalizeHash(myLxmfAddressHash) || peer === normalizeHash(identityHash); +} diff --git a/meshchatx/src/frontend/features/messages/lib/conversationQuery.ts b/meshchatx/src/frontend/features/messages/lib/conversationQuery.ts new file mode 100644 index 000000000..bea1eedf8 --- /dev/null +++ b/meshchatx/src/frontend/features/messages/lib/conversationQuery.ts @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: 0BSD + +import type { ConversationQueryInput, ConversationQueryParams } from "./types.js"; + +/** + * Build GET /api/v1/lxmf/conversations query params from sidebar filter state. + */ +export function buildConversationQueryParams(input: ConversationQueryInput = {}): ConversationQueryParams { + const params: ConversationQueryParams = {}; + const search = input.conversationSearchTerm; + if (search && String(search).trim() !== "") { + params.search = String(search).trim(); + } + if (input.filterUnreadOnly) { + params.filter_unread = true; + } + if (input.filterFailedOnly) { + params.filter_failed = true; + } + if (input.filterHasAttachmentsOnly) { + params.filter_has_attachments = true; + } + if (input.selectedFolderId !== null && input.selectedFolderId !== undefined) { + params.folder_id = input.selectedFolderId; + } + return params; +} diff --git a/meshchatx/src/frontend/features/messages/lib/conversationScroll.ts b/meshchatx/src/frontend/features/messages/lib/conversationScroll.ts new file mode 100644 index 000000000..75e8d1ef2 --- /dev/null +++ b/meshchatx/src/frontend/features/messages/lib/conversationScroll.ts @@ -0,0 +1,87 @@ +// SPDX-License-Identifier: 0BSD + +import { LOAD_PREVIOUS_SCROLL_EDGE_PX, SCROLL_BOTTOM_EPS_PX } from "./constants.js"; + +export { LOAD_PREVIOUS_SCROLL_EDGE_PX, SCROLL_BOTTOM_EPS_PX }; + +/** + * True only when inverted scrollTop math applies: scrollTop 0 is the visual bottom (newest). + */ +export function isScrollColumnReverse(container: Element | null | undefined): boolean { + if (!container) { + return false; + } + try { + if (getComputedStyle(container).flexDirection === "column-reverse") { + return true; + } + const inner = container.firstElementChild; + if (!inner) { + return false; + } + return getComputedStyle(inner).flexDirection === "column-reverse"; + } catch { + return false; + } +} + +export function maxScrollTop(container: Element | null | undefined): number { + if (!container) { + return 0; + } + return Math.max(0, (container as HTMLElement).scrollHeight - (container as HTMLElement).clientHeight); +} + +export function isNearBottom(container: Element | null | undefined, thresholdPx = SCROLL_BOTTOM_EPS_PX): boolean { + if (!container) { + return true; + } + const el = container as HTMLElement; + if (isScrollColumnReverse(container)) { + return el.scrollTop <= thresholdPx; + } + const max = maxScrollTop(container); + return max - el.scrollTop <= thresholdPx; +} + +export function scrollContainerToBottom(container: Element | null | undefined): void { + if (!container) { + return; + } + const el = container as HTMLElement; + if (isScrollColumnReverse(container)) { + el.scrollTop = 0; + } else { + el.scrollTop = maxScrollTop(container); + } +} + +export function resetMessagesScrollSurface(container: Element | null | undefined): void { + if (!container) { + return; + } + (container as HTMLElement).scrollTop = 0; +} + +export function canTrustScrollNearBottomHeuristic(container: Element | null | undefined): boolean { + return Boolean(container?.firstElementChild); +} + +export function shouldLoadPreviousMessages(container: Element | null | undefined): boolean { + if (!container) { + return false; + } + const el = container as HTMLElement; + if (isScrollColumnReverse(container)) { + const max = maxScrollTop(container); + if (max <= 0) { + return false; + } + const st = el.scrollTop; + if (max - st > LOAD_PREVIOUS_SCROLL_EDGE_PX) { + return false; + } + return st > SCROLL_BOTTOM_EPS_PX; + } + return el.scrollTop <= LOAD_PREVIOUS_SCROLL_EDGE_PX; +} diff --git a/meshchatx/src/frontend/features/messages/lib/conversationStickersGifs.ts b/meshchatx/src/frontend/features/messages/lib/conversationStickersGifs.ts new file mode 100644 index 000000000..7f80cc4b4 --- /dev/null +++ b/meshchatx/src/frontend/features/messages/lib/conversationStickersGifs.ts @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: 0BSD + +import { GIF_MAX_BYTES, STICKER_MAX_BYTES } from "./constants.js"; + +export { GIF_MAX_BYTES, STICKER_MAX_BYTES }; + +export function isAllowedStickerMime(mime: string): boolean { + return /^image\/(png|webp|jpeg|jpg|gif)$/i.test(mime || ""); +} + +export function isAllowedGifMime(mime: string): boolean { + return /^image\/gif$/i.test(mime || ""); +} diff --git a/meshchatx/src/frontend/features/messages/lib/conversationViewerActions.ts b/meshchatx/src/frontend/features/messages/lib/conversationViewerActions.ts new file mode 100644 index 000000000..eef72ccc8 --- /dev/null +++ b/meshchatx/src/frontend/features/messages/lib/conversationViewerActions.ts @@ -0,0 +1,351 @@ +// SPDX-License-Identifier: 0BSD + +import MarkdownRenderer from "../../../js/MarkdownRenderer.js"; +import Utils from "../../../js/Utils.js"; +import { t } from "../../../js/i18n.js"; +import { + isOpportunisticDeferredDelivery, + outboundBubbleStatusIconName, + outboundBubbleStatusTitleKey, +} from "../../../js/outboundMessageStatus.js"; +import { formatDate, fromNow } from "../../../libs/datetime.js"; +import { AUTO_IMAGE_CAPTION_MAX_CHARS, MESSAGE_BODY_MAX_DISPLAY_CHARS } from "./constants.js"; +import { + hasFileAttachments, + hasMessageBubble, + isImageOnlyMessage, + type ChatItemLike, +} from "./conversationMessageHelpers.js"; +import { outboundStateTitle, transferProgressPercent } from "./conversationOutboundUi.js"; +import type { + ConversationViewerActions, + LxmfMessageLike, + MessageBubbleViewModel, + MessageChatItem, + ParsedMessageItems, +} from "./viewerActions.js"; + +export type ConversationViewerActionDeps = { + chatItems: MessageChatItem[]; + selectedPeer?: { + destination_hash?: string; + display_name?: string; + custom_display_name?: string; + is_tracking?: boolean; + } | null; + conversations?: Array<{ destination_hash?: string; display_name?: string; custom_display_name?: string }>; + myLxmfAddressHash?: string; + messageFontSize?: number; + expandedMessageInfo?: string | null; + audioAttachmentUrls?: Readonly>; + openImage: (src: string, gallery?: string[], items?: MessageChatItem[]) => void; + onMessageContextMenu: (event: MouseEvent, chatItem: MessageChatItem, suppressToggle?: boolean) => void; + onChatItemClick: (chatItem: MessageChatItem) => void; + openReactionPicker: (chatItem: MessageChatItem) => void; + replyToMessage: (chatItem: MessageChatItem) => void; + retrySendingMessage: (chatItem: MessageChatItem) => void; + cancelSendingMessage: (chatItem: MessageChatItem) => void; + deleteChatItem: (chatItem: MessageChatItem) => void; + showRawMessage: (chatItem: MessageChatItem) => void; + scrollToMessage: (hash: string) => void; + copyText: (text: string) => unknown; + downloadMessageImage: (chatItem: MessageChatItem) => void | Promise; + downloadLxmfFileAttachment: (chatItem: MessageChatItem, index: number, name: string) => void | Promise; + addContact: (name?: string, hash?: string, lxmfAddress?: string, lxstAddress?: string) => void | Promise; +}; + +function imageDataUrl(message: LxmfMessageLike): string { + const image = message.fields?.image; + if (typeof image?.image_bytes !== "string") { + return ""; + } + const rawType = String(image.image_type || "png") + .toLowerCase() + .replace(/^image\//, ""); + const mime = rawType === "jpg" ? "image/jpeg" : rawType === "webm" ? "video/webm" : `image/${rawType}`; + return `data:${mime};base64,${image.image_bytes}`; +} + +function shouldHideAutoImageCaption(chatItem: MessageChatItem): boolean { + const message = chatItem.lxmf_message; + const text = String(message.content || "").trim(); + if (!message.fields?.image || !text || text.includes("\n") || text.length > AUTO_IMAGE_CAPTION_MAX_CHARS) { + return false; + } + if (/[\\/<>[\]{}]/.test(text)) { + return false; + } + return /^[\w.\- ()#@%&!+,;=']+\.(png|jpe?g|gif|webp|bmp|heif|heic|avif|svg|ico)$/i.test(text); +} + +function canUseImageStrip(chatItem: MessageChatItem): boolean { + const message = chatItem.lxmf_message; + return Boolean( + message.fields?.image && + !message.is_spam && + !["cancelled", "failed", "rejected"].includes(String(message.state || "")) && + !message.reply_to_hash && + !message.fields.audio && + !message.fields.file_attachments?.length && + !message.fields.telemetry && + !message.fields.telemetry_stream && + !message.fields.commands?.length && + (!String(message.content || "").trim() || shouldHideAutoImageCaption(chatItem)) + ); +} + +function attachmentSize(attachment: unknown, kind: string): string { + if (!attachment || typeof attachment !== "object") { + return "0 Bytes"; + } + const value = attachment as Record; + const explicit = Number(value[`${kind}_size`] ?? value.size ?? 0); + if (explicit > 0) { + return Utils.formatBytes(explicit); + } + const encoded = value[`${kind}_bytes`]; + if (typeof encoded === "string") { + return Utils.formatBytes(Math.max(0, Math.floor((encoded.length * 3) / 4))); + } + return "0 Bytes"; +} + +function parseBasicItems(chatItem: MessageChatItem): ParsedMessageItems { + const content = String(chatItem.lxmf_message.content || ""); + if (!content) { + return {}; + } + const parsed: ParsedMessageItems = {}; + const contact = content.match( + /^Contact:\s+(.+?)\s+<([a-fA-F0-9]{32})>(?:\s+\[LXMF:\s+([a-fA-F0-9]{32})\])?(?:\s+\[LXST:\s+([a-fA-F0-9]{32})\])?/i + ); + if (contact) { + parsed.contact = { + name: contact[1], + hash: contact[2], + lxmf_address: contact[3], + lxst_address: contact[4], + }; + } + const paper = content.match(/(lxm|lxmf):\/\/[a-zA-Z0-9+/=._-]+/i)?.[0]; + if (paper) { + parsed.paperMessage = paper; + parsed.isOnlyPaperMessage = content.trim() === paper; + } + return parsed; +} + +function pending(chatItem: MessageChatItem): boolean { + const message = chatItem.lxmf_message; + return Boolean( + chatItem.is_outbound && + (message._pendingPathfinding || ["outbound", "sending", "generating"].includes(String(message.state || ""))) + ); +} + +function themedOutbound(chatItem: MessageChatItem): boolean { + return Boolean( + chatItem.is_outbound && + !chatItem.lxmf_message.is_spam && + !chatItem.lxmf_message._pendingPathfinding && + !["cancelled", "failed", "rejected"].includes(String(chatItem.lxmf_message.state || "")) + ); +} + +function bubbleClass(chatItem: MessageChatItem): string { + if (!chatItem.is_outbound) { + return ""; + } + if (["cancelled", "failed", "rejected"].includes(String(chatItem.lxmf_message.state || ""))) { + return "bg-red-600 text-white border border-red-500 shadow-xs"; + } + if (chatItem.lxmf_message._pendingPathfinding) { + return "bg-sem-surface-muted text-sem-fg border border-sem-border shadow-xs"; + } + return "bg-sem-action-primary text-white border border-sem-action-primary shadow-xs"; +} + +function classForOutbound(chatItem: MessageChatItem, themed: string, defaultClass: string, inbound = ""): string { + if (!chatItem.is_outbound) { + return inbound; + } + return themedOutbound(chatItem) ? themed : defaultClass; +} + +export function createConversationViewerActions( + deps: ConversationViewerActionDeps +): ConversationViewerActions & { canMergeImageIntoImageStrip: (item: MessageChatItem) => boolean } { + const lxmfImageUrl = (hash?: string) => (hash ? `/api/v1/lxmf-messages/attachment/${hash}/image` : ""); + const progress = (message: LxmfMessageLike) => { + if (message._pendingPathfinding) { + return 0; + } + const value = Number(message.progress ?? 0); + return ["sending", "outbound", "generating"].includes(String(message.state || "")) && value > 0 + ? transferProgressPercent(value, 100) + : 0; + }; + const bubbleViewModel = (chatItem: MessageChatItem): MessageBubbleViewModel => { + const content = String(chatItem.lxmf_message.content || ""); + return { + kind: "html", + textForRender: content, + singleEmoji: content.length < 64 && MarkdownRenderer.isSingleEmojiMessage(content), + showFooter: false, + messageHash: chatItem.lxmf_message.hash, + }; + }; + + return { + expandedMessageInfo: deps.expandedMessageInfo, + audioAttachmentUrls: deps.audioAttachmentUrls || {}, + selectedPeer: deps.selectedPeer, + canMergeImageIntoImageStrip: canUseImageStrip, + imageGroupSortedChron: (items) => + items.slice().sort((a, b) => { + const aTime = Number( + a.lxmf_message.timestamp || new Date(a.lxmf_message.created_at as string).getTime() + ); + const bTime = Number( + b.lxmf_message.timestamp || new Date(b.lxmf_message.created_at as string).getTime() + ); + return aTime - bTime; + }), + imageGroupGalleryUrls: (items) => + items + .slice() + .sort((a, b) => Number(a.lxmf_message.timestamp || 0) - Number(b.lxmf_message.timestamp || 0)) + .map((item) => imageDataUrl(item.lxmf_message) || lxmfImageUrl(item.lxmf_message.hash)), + lxmfImageUrl, + pendingOutboundImageSrc: (item) => imageDataUrl(item.lxmf_message) || lxmfImageUrl(item.lxmf_message.hash), + openImage: deps.openImage, + onOutboundImageClick: (item) => + deps.openImage(imageDataUrl(item.lxmf_message) || lxmfImageUrl(item.lxmf_message.hash), undefined, [item]), + onChatItemClick: deps.onChatItemClick, + onMessageContextMenu: deps.onMessageContextMenu, + downloadMessageImage: (item) => void deps.downloadMessageImage(item), + openReactionPicker: deps.openReactionPicker, + reactionReactorLabel: (sender) => { + const hash = String(sender || ""); + if (!hash) return ""; + if (hash.toLowerCase() === String(deps.myLxmfAddressHash || "").toLowerCase()) { + return t("messages.reaction_you"); + } + const peer = deps.selectedPeer; + if (hash.toLowerCase() === String(peer?.destination_hash || "").toLowerCase()) { + return peer?.custom_display_name || peer?.display_name || Utils.formatDestinationHash(hash); + } + const conversation = deps.conversations?.find( + (item) => String(item.destination_hash || "").toLowerCase() === hash.toLowerCase() + ); + return conversation?.custom_display_name || conversation?.display_name || Utils.formatDestinationHash(hash); + }, + replyToMessage: deps.replyToMessage, + retrySendingMessage: deps.retrySendingMessage, + cancelSendingMessage: deps.cancelSendingMessage, + deleteChatItem: deps.deleteChatItem, + showRawMessage: deps.showRawMessage, + scrollToMessage: deps.scrollToMessage, + getRepliedMessage: (hash) => + deps.chatItems.find((item) => item.lxmf_message.hash === hash)?.lxmf_message || null, + handleMessageClick: (event) => { + const target = event.target as HTMLElement | null; + const anchor = target?.closest("a"); + if (anchor) { + event.stopPropagation(); + } + }, + setBubbleMessageShowOriginal: () => {}, + copyOversizedMessageBody: (item) => void deps.copyText(String(item.lxmf_message.content || "")), + downloadLxmfFileAttachment: (item, index) => { + const name = String(item.lxmf_message.fields?.file_attachments?.[index]?.file_name || "download"); + void deps.downloadLxmfFileAttachment(item, index, name); + }, + addContact: (name, hash, lxmfAddress, lxstAddress) => + void deps.addContact(name, hash, lxmfAddress, lxstAddress), + ingestPaperMessage: () => {}, + openMapShareFromParsed: () => {}, + copyMapShareUri: (uri) => { + if (uri) void deps.copyText(uri); + }, + openRelayShareFromParsed: () => {}, + copyRelayShareUri: (uri) => { + if (uri) void deps.copyText(uri); + }, + viewLocationOnMap: () => {}, + toggleTracking: () => {}, + bubbleViewModel, + renderMarkdown: (text) => MarkdownRenderer.render(text || ""), + getParsedItems: parseBasicItems, + formatTimeAgo: (value) => fromNow(value), + formatDateDividerLabel: (value) => formatDate(value, "MMM D, YYYY"), + formatAttachmentSize: attachmentSize, + getMessageInfoLines: (message, isOutbound) => { + const lines = [fromNow(message.created_at)]; + if (isOutbound && message.state) lines.push(outboundStateTitle(message.state)); + if (message.hash) lines.push(message.hash); + return lines.filter(Boolean); + }, + messageBodyCharCount: (item) => String(item.lxmf_message.content || "").length, + bubbleMessageBodyFontSizePx: (model) => + Math.round((Number(deps.messageFontSize) || 14) * (model.singleEmoji ? 2.75 : 1)), + bubbleStyles: () => "", + isImageOnlyMessage: (item) => + isImageOnlyMessage(item as ChatItemLike, shouldHideAutoImageCaption as (item: ChatItemLike) => boolean), + hasMessageBubble: (item) => + hasMessageBubble(item as ChatItemLike, shouldHideAutoImageCaption as (item: ChatItemLike) => boolean), + hasFileAttachments, + shouldHideAutoImageCaption, + isMessageBodyTooLargeForDisplay: (item) => + String(item.lxmf_message.content || "").length > MESSAGE_BODY_MAX_DISPLAY_CHARS, + isPaperMessageIngested: () => false, + isThemeOutboundBubble: themedOutbound, + isOutboundWaitingBubble: (item) => Boolean(item.is_outbound && item.lxmf_message._pendingPathfinding), + isOutboundPendingForUi: pending, + isOpportunisticDeferredDelivery, + showRichOutboundPendingUi: pending, + canCancelOutboundSend: (item) => Boolean(item.lxmf_message.hash && pending(item)), + showOutboundTransferProgress: (message) => progress(message) > 0, + outboundTransferProgressPercent: progress, + outboundSendingProgressLabel: (message) => `${progress(message)}%`, + outboundTransferStatsLabel: () => "", + outboundBubbleStatusIconName, + outboundBubbleStatusTitle: (message) => { + const key = outboundBubbleStatusTitleKey(message); + return key ? t(key) : outboundStateTitle(message.state); + }, + outboundBubbleStatusHoverTitle: (message) => outboundStateTitle(message.state), + outboundBubbleFailedTitle: (message) => outboundStateTitle(message.state), + outboundBubbleSurfaceClass: bubbleClass, + outboundBubbleFooterTimeClass: (item) => + classForOutbound(item, "text-white/85", "text-sem-fg-muted", "text-sem-fg-muted"), + outboundBubbleDeliveredIconClass: (item) => classForOutbound(item, "text-blue-200", "text-sem-fg-muted"), + outboundBubbleSentCheckIconClass: (item) => classForOutbound(item, "text-white", "text-sem-fg-muted"), + outboundBubblePendingCheckIconClass: (item) => + classForOutbound(item, "text-white opacity-50", "text-sem-fg-muted opacity-50"), + outboundSendingStatusIconClass: (item) => classForOutbound(item, "text-white", "text-sem-fg-muted"), + outboundExpandedActionsShellClass: (item) => + classForOutbound( + item, + "border-white/20 bg-white/10", + "border-sem-border bg-sem-surface-muted", + "border-sem-border bg-sem-surface-muted" + ), + outboundMessageMenuButtonClass: (item) => + classForOutbound(item, "text-white/90 hover:text-white", "text-sem-fg-muted", "text-sem-fg-muted"), + outboundMessageMenuButtonHoverClass: (item) => + classForOutbound(item, "hover:bg-white/20", "hover:bg-sem-surface-muted", "hover:bg-sem-surface-muted"), + outboundReplySnippetTitleClass: (item) => + classForOutbound(item, "text-white/80", "text-sem-fg-muted", "text-indigo-500/80"), + outboundAttachmentCaptionClass: (item) => + classForOutbound(item, "text-white/85", "text-sem-fg-muted", "text-sem-fg-muted"), + outboundEmbeddedCardClass: (item) => + classForOutbound( + item, + "bg-white/20 text-white border-white/20 hover:bg-white/30", + "bg-sem-surface-muted text-sem-fg border-sem-border", + "" + ), + outboundEmbeddedSecondaryTextClass: (item) => classForOutbound(item, "text-white/60", "text-sem-fg-muted", ""), + }; +} diff --git a/meshchatx/src/frontend/features/messages/lib/conversationViewerCtx.ts b/meshchatx/src/frontend/features/messages/lib/conversationViewerCtx.ts new file mode 100644 index 000000000..f15b50887 --- /dev/null +++ b/meshchatx/src/frontend/features/messages/lib/conversationViewerCtx.ts @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: 0BSD + +import type { Conversation, MessagesConfig, Peer } from "./types.js"; + +export type LxmfFields = Record & { + image?: Record; + audio?: Record; + file_attachments?: Array>; +}; + +export type LxmfMessage = Record & { + id?: number; + hash?: string; + source_hash?: string; + destination_hash?: string; + content?: string; + state?: string; + timestamp?: number; + created_at?: string; + fields?: LxmfFields; + is_reaction?: boolean; + reaction_to?: string; + reply_to_hash?: string | null; +}; + +export type ViewerChatItem = { + type: "lxmf_message"; + is_outbound: boolean; + lxmf_message: LxmfMessage; +}; + +export type ViewerPathSnapshot = Record & { + path?: { + hops?: number; + next_hop?: string; + next_hop_interface?: string; + } | null; + path_stale?: boolean; + path_unresponsive?: boolean; +}; + +export type ViewerState = { + config: MessagesConfig | null; + myLxmfAddressHash: string; + selectedPeer: Peer | null; + conversations: Conversation[]; + chatItems: ViewerChatItem[]; + hasMorePrevious: boolean; + isLoadingPrevious: boolean; + requestSequence: number; +}; + +export type ApiError = Error & { + response?: { + data?: { message?: string; diagnostics?: unknown }; + status?: number; + }; +}; + +export function sameHash(a: unknown, b: unknown): boolean { + return String(a || "").toLowerCase() === String(b || "").toLowerCase(); +} + +export function messageKey(message: LxmfMessage): string { + return String(message.hash || message.id || ""); +} diff --git a/meshchatx/src/frontend/features/messages/lib/conversationViewerGlobals.ts b/meshchatx/src/frontend/features/messages/lib/conversationViewerGlobals.ts new file mode 100644 index 000000000..a9726f625 --- /dev/null +++ b/meshchatx/src/frontend/features/messages/lib/conversationViewerGlobals.ts @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: 0BSD + +/** + * Side-effect import so Vite/TS pull browser globals for the messages viewer. + * Window.api and Codec2* are declared in js/browser-globals.d.ts. + */ +export {}; diff --git a/meshchatx/src/frontend/features/messages/lib/conversationViewerMessages.ts b/meshchatx/src/frontend/features/messages/lib/conversationViewerMessages.ts new file mode 100644 index 000000000..4c9994336 --- /dev/null +++ b/meshchatx/src/frontend/features/messages/lib/conversationViewerMessages.ts @@ -0,0 +1,166 @@ +// SPDX-License-Identifier: 0BSD + +import { mergeLxmfReactionRowsIntoMessages } from "../../../js/lxmfReactions.js"; +import { CONVERSATION_MESSAGES_PAGE_SIZE } from "./conversationDisplayGroups.js"; +import { hasRenderableContent, isTelemetryOnly } from "./conversationMessageHelpers.js"; +import { normalizeLxmfMessage } from "./lxmf/normalize.js"; +import type { LxmfMessage, ViewerChatItem } from "./conversationViewerCtx.js"; +import { messageKey, sameHash } from "./conversationViewerCtx.js"; + +type ApiClient = { + get: (url: string, options?: Record) => Promise<{ data?: Record }>; +}; + +export type ConversationPage = { + items: ViewerChatItem[]; + hasMore: boolean; +}; + +function asMessages(value: unknown): LxmfMessage[] { + return Array.isArray(value) ? (value as LxmfMessage[]) : []; +} + +function toChatItem(message: LxmfMessage, myHash: string): ViewerChatItem { + const outbound = Boolean(myHash) && sameHash(message.source_hash, myHash); + return { + type: "lxmf_message", + is_outbound: outbound, + lxmf_message: normalizeLxmfMessage(message, outbound) as LxmfMessage, + }; +} + +export async function fetchConversationPage( + api: ApiClient, + peerHash: string, + myHash: string, + afterId: number | null +): Promise { + const response = await api.get(`/api/v1/lxmf-messages/conversation/${peerHash}`, { + params: { + count: CONVERSATION_MESSAGES_PAGE_SIZE, + order: "desc", + after_id: afterId, + }, + }); + const raw = asMessages(response.data?.lxmf_messages); + const merged = mergeLxmfReactionRowsIntoMessages(raw) as LxmfMessage[]; + return { + items: merged.map((message) => toChatItem(message, myHash)).reverse(), + hasMore: raw.length >= CONVERSATION_MESSAGES_PAGE_SIZE, + }; +} + +export function prependConversationPage( + current: ViewerChatItem[], + page: ViewerChatItem[] +): { items: ViewerChatItem[]; added: number } { + const seen = new Set(current.map((item) => messageKey(item.lxmf_message)).filter(Boolean)); + const older = page.filter((item) => { + const key = messageKey(item.lxmf_message); + if (!key || seen.has(key)) { + return false; + } + seen.add(key); + return true; + }); + return { items: older.concat(current), added: older.length }; +} + +export function oldestMessageId(items: ViewerChatItem[], peerHash: string): number | null { + let oldest: number | null = null; + for (const item of items) { + const message = item.lxmf_message; + if (!sameHash(message.source_hash, peerHash) && !sameHash(message.destination_hash, peerHash)) { + continue; + } + if (typeof message.id === "number" && (oldest == null || message.id < oldest)) { + oldest = message.id; + } + } + return oldest; +} + +export function visibleConversationItems( + items: ViewerChatItem[], + peerHash: string, + showTelemetry: boolean +): ViewerChatItem[] { + return items.filter((item) => { + const message = item.lxmf_message; + if (!sameHash(message.source_hash, peerHash) && !sameHash(message.destination_hash, peerHash)) { + return false; + } + if (message.is_reaction) { + return false; + } + if (!showTelemetry && isTelemetryOnly(message)) { + return false; + } + return hasRenderableContent(message); + }); +} + +export function applyWsMessage( + current: ViewerChatItem[], + message: LxmfMessage, + peerHash: string, + myHash: string +): { items: ViewerChatItem[]; changed: boolean; incoming: boolean } { + const incoming = sameHash(message.source_hash, peerHash); + const outbound = sameHash(message.destination_hash, peerHash) && sameHash(message.source_hash, myHash); + if (!incoming && !outbound) { + return { items: current, changed: false, incoming: false }; + } + + if (message.is_reaction && message.reaction_to) { + const merged = mergeLxmfReactionRowsIntoMessages( + current.map((item) => item.lxmf_message).concat(message) + ) as LxmfMessage[]; + return { + items: merged + .filter((candidate) => !candidate.is_reaction) + .map((candidate) => toChatItem(candidate, myHash)), + changed: true, + incoming, + }; + } + + const index = current.findIndex((item) => sameHash(item.lxmf_message.hash, message.hash)); + if (index >= 0) { + const items = current.slice(); + items[index] = { + ...items[index], + lxmf_message: { ...items[index].lxmf_message, ...message }, + }; + return { items, changed: true, incoming }; + } + + let items = current; + if (outbound) { + items = items.filter( + (item) => + !( + String(item.lxmf_message.hash || "").startsWith("pending-") && + sameHash(item.lxmf_message.destination_hash, peerHash) + ) + ); + } + return { items: items.concat(toChatItem(message, myHash)), changed: true, incoming }; +} + +export function updateWsMessage(current: ViewerChatItem[], message: LxmfMessage): ViewerChatItem[] { + const index = current.findIndex((item) => sameHash(item.lxmf_message.hash, message.hash)); + if (index < 0) { + return current; + } + const items = current.slice(); + items[index] = { + ...items[index], + lxmf_message: { ...items[index].lxmf_message, ...message }, + }; + return items; +} + +export function deleteWsMessage(current: ViewerChatItem[], hash: unknown): ViewerChatItem[] { + return current.filter((item) => !sameHash(item.lxmf_message.hash, hash)); +} diff --git a/meshchatx/src/frontend/features/messages/lib/conversationViewerPath.ts b/meshchatx/src/frontend/features/messages/lib/conversationViewerPath.ts new file mode 100644 index 000000000..0cdb38798 --- /dev/null +++ b/meshchatx/src/frontend/features/messages/lib/conversationViewerPath.ts @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: 0BSD + +import { + fetchPeerPathSnapshot, + normalizePathSnapshot, + pathNeedsRefresh, + runDestinationPathFinder, + warmPathIfNeeded, +} from "../../../js/reticulumPathfinding.js"; +import type { ViewerPathSnapshot } from "./conversationViewerCtx.js"; + +type ApiClient = { + get: (url: string, options?: Record) => Promise<{ data?: Record }>; + post: (url: string, body?: Record) => Promise<{ data?: Record }>; +}; + +export type PeerNetworkInfo = { + path: ViewerPathSnapshot; + stampInfo: Record | null; + signalMetrics: Record | null; +}; + +export async function loadPeerNetworkInfo(api: ApiClient, hash: string, warm: boolean): Promise { + let path = (await fetchPeerPathSnapshot(api, hash)) as ViewerPathSnapshot; + if (warm) { + const result = await warmPathIfNeeded(api, hash, path as never); + if (result?.requested) { + path = (await fetchPeerPathSnapshot(api, hash)) as ViewerPathSnapshot; + } + } + const [stamp, signal] = await Promise.allSettled([ + api.get(`/api/v1/destination/${hash}/lxmf-stamp-info`), + api.get(`/api/v1/destination/${hash}/signal-metrics`), + ]); + return { + path: (path || normalizePathSnapshot(null)) as ViewerPathSnapshot, + stampInfo: + stamp.status === "fulfilled" + ? ((stamp.value.data?.lxmf_stamp_info as Record) ?? null) + : null, + signalMetrics: + signal.status === "fulfilled" + ? ((signal.value.data?.signal_metrics as Record) ?? null) + : null, + }; +} + +export async function warmOutboundPath( + api: ApiClient, + destinationHash: string, + deliveryMethod: string | null, + current: ViewerPathSnapshot | null, + propagationHash?: unknown +): Promise { + if (deliveryMethod === "propagated") { + if (typeof propagationHash === "string" && propagationHash.length === 32) { + await warmPathIfNeeded(api, propagationHash, null); + } + return; + } + await warmPathIfNeeded(api, destinationHash, current as never); +} + +export function peerPathNeedsRefresh(snapshot: ViewerPathSnapshot | null): boolean { + return pathNeedsRefresh(snapshot as never); +} + +export async function runPeerPathAction( + api: ApiClient, + hash: string, + action: "quick" | "force" | "drop_then_request" +): Promise { + if (action === "force") { + const result = await runDestinationPathFinder(api, hash, action, { forceTimeout: 15 }); + if (result?.path) { + return normalizePathSnapshot({ + path: result.path, + path_stale: false, + path_unresponsive: false, + }) as ViewerPathSnapshot; + } + } else { + await runDestinationPathFinder(api, hash, action, { + onDropPathError: (error: unknown) => console.warn("drop-path failed", error), + }); + } + return (await fetchPeerPathSnapshot(api, hash)) as ViewerPathSnapshot; +} diff --git a/meshchatx/src/frontend/features/messages/lib/conversationViewerSend.ts b/meshchatx/src/frontend/features/messages/lib/conversationViewerSend.ts new file mode 100644 index 000000000..10b007b6a --- /dev/null +++ b/meshchatx/src/frontend/features/messages/lib/conversationViewerSend.ts @@ -0,0 +1,170 @@ +// SPDX-License-Identifier: 0BSD + +import Utils from "../../../js/Utils.js"; +import { uuidv4 } from "../../../libs/uuid.js"; +import { OUTBOUND_OVERSIZED_CONFIRM_BYTES } from "./constants.js"; +import type { LxmfFields, LxmfMessage, ViewerPathSnapshot } from "./conversationViewerCtx.js"; +import { warmOutboundPath } from "./conversationViewerPath.js"; + +type ApiClient = { + get: (url: string, options?: Record) => Promise<{ data?: Record }>; + post: (url: string, body?: Record) => Promise<{ data?: Record }>; +}; + +export type ComposeAudio = { + audio_blob: Blob; + audio_preview_url: string; + audio_mode?: number; +}; + +export type OutboundJob = { + destinationHash: string; + deliveryMethod: string | null; + text: string; + fields: LxmfFields; + images: Array<{ image_type: string; image_bytes: string; image_size: number }>; + replyToHash: string | null; + replyQuotedContent: string | null; + myLxmfAddressHash: string; + pendingHash: string | null; + cancelled?: boolean; + messageHash?: string; +}; + +export async function buildOutboundJob(input: { + destinationHash: string; + deliveryMethod: string | null; + text: string; + files: File[]; + images: File[]; + audio: ComposeAudio | null; + telemetry?: Record | null; + replyToHash?: string | null; + replyQuotedContent?: string | null; + myLxmfAddressHash: string; + confirmOversized: (size: number) => Promise; +}): Promise { + const fields: LxmfFields = {}; + let totalSize = input.text.length; + if (input.telemetry) { + fields.telemetry = input.telemetry; + } + if (input.files.length > 0) { + fields.file_attachments = []; + for (const file of input.files) { + totalSize += file.size; + fields.file_attachments.push({ + file_name: file.name, + file_size: file.size, + file_bytes: Utils.arrayBufferToBase64(await file.arrayBuffer()), + }); + } + } + if (input.audio) { + totalSize += input.audio.audio_blob.size; + fields.audio = { + audio_mode: input.audio.audio_mode ?? 0x10, + audio_size: input.audio.audio_blob.size, + audio_bytes: Utils.arrayBufferToBase64(await input.audio.audio_blob.arrayBuffer()), + }; + } + const images: OutboundJob["images"] = []; + for (const image of input.images) { + totalSize += image.size; + images.push({ + image_type: image.type.replace(/^image\//, "") || "png", + image_size: image.size, + image_bytes: Utils.arrayBufferToBase64(await image.arrayBuffer()), + }); + } + if (totalSize > OUTBOUND_OVERSIZED_CONFIRM_BYTES && !(await input.confirmOversized(totalSize))) { + return null; + } + return { + destinationHash: input.destinationHash, + deliveryMethod: input.deliveryMethod, + text: input.text, + fields, + images, + replyToHash: input.replyToHash ?? null, + replyQuotedContent: input.replyQuotedContent ?? null, + myLxmfAddressHash: input.myLxmfAddressHash, + pendingHash: null, + }; +} + +export function optimisticMessage(job: OutboundJob, needsPath: boolean): LxmfMessage { + job.pendingHash = `pending-${uuidv4()}`; + const fields: LxmfFields = {}; + if (job.images[0]) { + fields.image = { + image_type: job.images[0].image_type, + image_size: job.images[0].image_size, + image_bytes: job.images[0].image_bytes, + }; + } + return { + hash: job.pendingHash, + content: job.text, + state: "sending", + progress: 0, + created_at: new Date().toISOString(), + destination_hash: job.destinationHash, + source_hash: job.myLxmfAddressHash, + fields: Object.keys(fields).length > 0 ? fields : undefined, + reply_to_hash: job.replyToHash, + _pendingPathfinding: needsPath, + }; +} + +export async function executeOutboundJob(input: { + api: ApiClient; + job: OutboundJob; + pathSnapshot: ViewerPathSnapshot | null; + propagationHash?: unknown; +}): Promise { + const { api, job } = input; + if (job.cancelled) { + return []; + } + await warmOutboundPath(api, job.destinationHash, job.deliveryMethod, input.pathSnapshot, input.propagationHash); + const images = job.images.length > 0 ? job.images : [null]; + const sent: LxmfMessage[] = []; + for (let index = 0; index < images.length; index++) { + if (job.cancelled) { + break; + } + const image = images[index]; + const fields: LxmfFields = index === 0 ? { ...job.fields } : {}; + if (image) { + fields.image = { + image_type: image.image_type, + image_bytes: image.image_bytes, + }; + } + const response = await api.post("/api/v1/lxmf-messages/send", { + delivery_method: job.deliveryMethod, + lxmf_message: { + destination_hash: job.destinationHash, + content: index === 0 ? job.text : "", + reply_to_hash: index === 0 ? job.replyToHash : null, + reply_quoted_content: index === 0 ? job.replyQuotedContent : null, + fields, + }, + }); + const message = response.data?.lxmf_message as LxmfMessage | undefined; + if (message) { + job.messageHash = message.hash; + sent.push(message); + } + } + return sent; +} + +export async function cancelOutbound(api: ApiClient, hash: string): Promise { + if (!hash || hash.startsWith("pending-")) { + return null; + } + const response = await api.post(`/api/v1/lxmf-messages/${hash}/cancel`); + return (response.data?.lxmf_message as LxmfMessage | undefined) ?? null; +} diff --git a/meshchatx/src/frontend/components/messages/lxmf/contactDisplay.js b/meshchatx/src/frontend/features/messages/lib/lxmf/contactDisplay.ts similarity index 61% rename from meshchatx/src/frontend/components/messages/lxmf/contactDisplay.js rename to meshchatx/src/frontend/features/messages/lib/lxmf/contactDisplay.ts index be62af38a..047d077d0 100644 --- a/meshchatx/src/frontend/components/messages/lxmf/contactDisplay.js +++ b/meshchatx/src/frontend/features/messages/lib/lxmf/contactDisplay.ts @@ -1,8 +1,20 @@ // SPDX-License-Identifier: 0BSD -import Utils from "../../../js/Utils.js"; +import Utils from "../../../../js/Utils.js"; -export function lxmfDeliveryDestinationHexFromContact(contact) { +export type ContactLike = { + remote_destination_hash?: string; + lxmf_address?: string; + remote_identity_hash?: string; + remote_icon?: { icon_name?: string; foreground_colour?: string; background_colour?: string }; +}; + +export type ConversationLike = { + destination_hash?: string; + lxmf_user_icon?: { icon_name?: string; foreground_colour?: string; background_colour?: string }; +}; + +export function lxmfDeliveryDestinationHexFromContact(contact: ContactLike | null | undefined): string { if (!contact) return ""; const order = [contact.remote_destination_hash, contact.lxmf_address, contact.remote_identity_hash]; for (const c of order) { @@ -14,7 +26,10 @@ export function lxmfDeliveryDestinationHexFromContact(contact) { return ""; } -export function lxmfContactResolvedIcon(contact, conversations = []) { +export function lxmfContactResolvedIcon( + contact: ContactLike | null | undefined, + conversations: ConversationLike[] = [] +): { iconName: string; foreground: string; background: string } { const empty = { iconName: "", foreground: "", background: "" }; if (!contact) { return empty; diff --git a/meshchatx/src/frontend/components/messages/lxmf/normalize.js b/meshchatx/src/frontend/features/messages/lib/lxmf/normalize.ts similarity index 74% rename from meshchatx/src/frontend/components/messages/lxmf/normalize.js rename to meshchatx/src/frontend/features/messages/lib/lxmf/normalize.ts index 2b07e4f77..bc7fc3a6a 100644 --- a/meshchatx/src/frontend/components/messages/lxmf/normalize.js +++ b/meshchatx/src/frontend/features/messages/lib/lxmf/normalize.ts @@ -1,6 +1,12 @@ // SPDX-License-Identifier: 0BSD -export function normalizeLxmfMessage(msg, isOutbound) { +export type LxmfMessageNormalized = Record & { + created_at?: string; + timestamp?: number; + state?: string; +}; + +export function normalizeLxmfMessage(msg: LxmfMessageNormalized, isOutbound: boolean): LxmfMessageNormalized { const normalized = { ...msg }; if (!normalized.created_at && normalized.timestamp) { normalized.created_at = new Date(normalized.timestamp * 1000).toISOString(); @@ -11,12 +17,12 @@ export function normalizeLxmfMessage(msg, isOutbound) { return normalized; } -export function normalizeSidebandCommandKey(key) { +export function normalizeSidebandCommandKey(key: unknown): string | null { const raw = String(key ?? "").trim(); if (!raw) { return null; } - const known = { + const known: Record = { plugin: "0x00", telemetry_request: "0x01", request: "0x01", diff --git a/meshchatx/src/frontend/components/messages/messageListVirtual.js b/meshchatx/src/frontend/features/messages/lib/messageListVirtual.ts similarity index 51% rename from meshchatx/src/frontend/components/messages/messageListVirtual.js rename to meshchatx/src/frontend/features/messages/lib/messageListVirtual.ts index 2c000bc0b..3a9e34564 100644 --- a/meshchatx/src/frontend/components/messages/messageListVirtual.js +++ b/meshchatx/src/frontend/features/messages/lib/messageListVirtual.ts @@ -1,39 +1,45 @@ -export const MIN_VIRTUAL_DISPLAY_GROUPS = 48; +// SPDX-License-Identifier: 0BSD -/** - * Display groups from {@link selectedPeerChatDisplayGroups} are newest-first. - * Virtual + normal document flow use oldest at the top (index 0). - * @param {unknown[]} displayGroups - * @returns {unknown[]} - */ -export function displayGroupsOldestFirst(displayGroups) { +import { MIN_VIRTUAL_DISPLAY_GROUPS, VIRTUAL_ROW_HEIGHT } from "./constants.js"; + +export { MIN_VIRTUAL_DISPLAY_GROUPS }; + +export function displayGroupsOldestFirst(displayGroups: unknown[] | null | undefined): unknown[] { if (!displayGroups?.length) { return []; } return displayGroups.slice().reverse(); } -/** - * Initial row height guess before measureElement runs (variable-height rows). - * @param {unknown} entry - * @returns {number} - */ -export function estimateGroupHeight(entry) { +export function estimateGroupHeight(entry: unknown): number { if (!entry || typeof entry !== "object") { - return 96; + return VIRTUAL_ROW_HEIGHT.default; } - if (entry.type === "dateDivider") { - return 44; + const e = entry as { + type?: string; + chatItem?: { lxmf_message?: { content?: string; fields?: Record } }; + }; + if (e.type === "dateDivider") { + return VIRTUAL_ROW_HEIGHT.dateDivider; } - if (entry.type === "imageGroup") { - return 340; + if (e.type === "imageGroup") { + return VIRTUAL_ROW_HEIGHT.imageGroup; } - const msg = entry.chatItem?.lxmf_message; - const fields = msg?.fields; + const msg = e.chatItem?.lxmf_message; + const fields = msg?.fields as + | { + image?: unknown; + file_attachments?: unknown[]; + audio?: unknown; + telemetry?: unknown; + telemetry_stream?: unknown; + commands?: unknown[]; + } + | undefined; if (fields?.image && !fields?.file_attachments?.length && !fields?.audio) { - return 280; + return VIRTUAL_ROW_HEIGHT.imageOnly; } - let height = 88; + let height = VIRTUAL_ROW_HEIGHT.base; const content = (msg?.content || "").trim(); if (content) { height += Math.min(160, Math.ceil(content.length / 40) * 18); @@ -51,17 +57,16 @@ export function estimateGroupHeight(entry) { return height; } -/** - * @param {unknown[]} groupsOldestFirst - * @param {string} hash - * @returns {number} - */ -export function findDisplayGroupIndexForMessageHash(groupsOldestFirst, hash) { +export function findDisplayGroupIndexForMessageHash(groupsOldestFirst: unknown[], hash: string): number { if (!groupsOldestFirst?.length || !hash) { return -1; } for (let i = 0; i < groupsOldestFirst.length; i++) { - const g = groupsOldestFirst[i]; + const g = groupsOldestFirst[i] as { + type?: string; + items?: Array<{ lxmf_message?: { hash?: string } }>; + chatItem?: { lxmf_message?: { hash?: string } }; + }; if (!g || typeof g !== "object") { continue; } diff --git a/meshchatx/src/frontend/features/messages/lib/paneLayout.ts b/meshchatx/src/frontend/features/messages/lib/paneLayout.ts new file mode 100644 index 000000000..5819b4e42 --- /dev/null +++ b/meshchatx/src/frontend/features/messages/lib/paneLayout.ts @@ -0,0 +1,190 @@ +// SPDX-License-Identifier: 0BSD + +import { loadMessagePanes, saveMessagePanes } from "../../../js/browserLayoutStore.js"; +import type { Pane, Peer, PersistedPanePeer, PersistedPaneState } from "./types.js"; + +export type MaxPanesInput = { + isPopoutMode?: boolean; + isWideViewport?: boolean; + multiPaneEnabled?: boolean; + isWideEnoughForThreePanes?: boolean; +}; + +/** + * Max concurrent conversation panes from viewport and config (MessagesPage maxPanes). + */ +export function maxPanesFromWidth(input: MaxPanesInput = {}): number { + if (input.isPopoutMode || !input.isWideViewport || input.multiPaneEnabled === false) { + return 1; + } + return input.isWideEnoughForThreePanes ? 3 : 2; +} + +/** + * Persistable peer slice for browserLayoutStore message panes. + */ +export function slimPeer(peer: Peer | null | undefined): PersistedPanePeer | null { + if (!peer || !peer.destination_hash) { + return null; + } + return { + destination_hash: peer.destination_hash, + display_name: peer.display_name ?? null, + custom_display_name: peer.custom_display_name ?? null, + }; +} + +/** + * Signature used to watch layout changes and trigger persist. + */ +export function paneLayoutSignature(panes: Pane[], focusedPaneId: number): string { + const hashes = (panes || []).map((pane) => pane.peer?.destination_hash || "").join("\u241f"); + const focusedIndex = (panes || []).findIndex((pane) => pane.id === focusedPaneId); + return `${focusedIndex}\u241e${hashes}`; +} + +export type VisiblePanesInput = { + panes: Pane[]; + focusedPaneId: number; + maxPanes: number; +}; + +/** + * Which panes are shown given maxPanes and empty-pane filtering (MessagesPage visiblePanes). + */ +export function selectVisiblePanes(input: VisiblePanesInput): Pane[] { + const panes = input.panes || []; + const focused = panes.find((pane) => pane.id === input.focusedPaneId) || panes[0] || null; + let visible: Pane[]; + if (input.maxPanes <= 1) { + visible = focused ? [focused] : panes.slice(0, 1); + } else { + visible = panes.slice(0, input.maxPanes); + } + if (visible.length <= 1) { + return visible; + } + const hasEmptyPane = visible.some((pane) => !pane.peer); + if (!hasEmptyPane) { + return visible; + } + return visible.filter((pane) => pane.peer || pane.id === input.focusedPaneId); +} + +export function paneFlexValue(paneId: number, paneFlex: Record, visiblePanes: Pane[]): number { + if (!visiblePanes || visiblePanes.length <= 1) { + return 1; + } + const pane = visiblePanes.find((entry) => entry.id === paneId); + if (!pane?.peer) { + return 1; + } + const value = paneFlex?.[paneId]; + return typeof value === "number" && value > 0 ? value : 1; +} + +export type BuildPersistInput = { + panes: Pane[]; + focusedPaneId: number; + paneFlex: Record; + visiblePanes: Pane[]; +}; + +/** + * Pure shape for saveMessagePanes (no I/O). + */ +export function buildPersistedPaneState(input: BuildPersistInput): PersistedPaneState { + const focusedIndex = (input.panes || []).findIndex((pane) => pane.id === input.focusedPaneId); + return { + panes: (input.panes || []).map((pane) => slimPeer(pane.peer)), + sizes: (input.panes || []).map((pane) => + paneFlexValue(pane.id, input.paneFlex || {}, input.visiblePanes || []) + ), + focusedIndex: focusedIndex < 0 ? 0 : focusedIndex, + }; +} + +export type RestorePanesResult = { + panes: Pane[]; + paneFlex: Record; + focusedPaneId: number; + nextPaneId: number; +}; + +/** + * Pure restore from a saved layout into pane objects and flex map. + * Caller supplies nextPaneId seed (MessagesPage starts at 2 after initial pane id 1). + */ +export function restorePanesFromSaved( + saved: PersistedPaneState | null | undefined, + routeHash: string | null | undefined, + nextPaneIdSeed: number +): RestorePanesResult | null { + if (!saved || !Array.isArray(saved.panes) || saved.panes.length === 0) { + return null; + } + + let nextPaneId = nextPaneIdSeed; + const panes: Pane[] = saved.panes.map((peer) => ({ + id: nextPaneId++, + peer: peer && peer.destination_hash ? { ...peer } : null, + })); + + const paneFlex: Record = {}; + panes.forEach((pane, index) => { + const size = Array.isArray(saved.sizes) ? saved.sizes[index] : null; + paneFlex[pane.id] = typeof size === "number" && size > 0 ? size : 1; + }); + + let focusedIndex = + Number.isInteger(saved.focusedIndex) && + (saved.focusedIndex as number) >= 0 && + (saved.focusedIndex as number) < panes.length + ? (saved.focusedIndex as number) + : 0; + let focusedPaneId = panes[focusedIndex].id; + + if (routeHash) { + const match = panes.find((pane) => pane.peer?.destination_hash === routeHash); + if (match) { + focusedPaneId = match.id; + } + } + + return { panes, paneFlex, focusedPaneId, nextPaneId }; +} + +/** + * Thin I/O wrapper around loadMessagePanes + restorePanesFromSaved. + */ +export function loadAndRestorePanes( + routeHash: string | null | undefined, + nextPaneIdSeed: number +): RestorePanesResult | null { + return restorePanesFromSaved(loadMessagePanes() as PersistedPaneState | null, routeHash, nextPaneIdSeed); +} + +/** + * Thin I/O wrapper: build persist shape then saveMessagePanes. + * sizes is persisted by MessagesPage even though the store JSDoc omits it. + */ +export function persistPanesState(input: BuildPersistInput): void { + const state = buildPersistedPaneState(input); + saveMessagePanes({ + panes: state.panes, + focusedIndex: state.focusedIndex ?? 0, + sizes: state.sizes, + } as { panes: unknown[]; focusedIndex: number }); +} + +/** + * Patch peer fields on any pane that matches destination_hash (pure list transform). + */ +export function applyPatchToPanePeers(panes: Pane[], destinationHash: string, patch: Partial): Pane[] { + return (panes || []).map((pane) => { + if (pane.peer && pane.peer.destination_hash === destinationHash) { + return { ...pane, peer: { ...pane.peer, ...patch } }; + } + return pane; + }); +} diff --git a/meshchatx/src/frontend/features/messages/lib/peerAnnounce.ts b/meshchatx/src/frontend/features/messages/lib/peerAnnounce.ts new file mode 100644 index 000000000..58dc62913 --- /dev/null +++ b/meshchatx/src/frontend/features/messages/lib/peerAnnounce.ts @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: 0BSD + +import { ANONYMOUS_PEER_DISPLAY_NAME } from "./constants.js"; +import type { Conversation, Peer } from "./types.js"; + +export { ANONYMOUS_PEER_DISPLAY_NAME }; + +export function preferKnownDisplayName( + incomingName: string | null | undefined, + existingName: string | null | undefined +): string | null | undefined { + if (incomingName === ANONYMOUS_PEER_DISPLAY_NAME && existingName && existingName !== ANONYMOUS_PEER_DISPLAY_NAME) { + return existingName; + } + return incomingName; +} + +/** + * Merge announce fields into an existing peer without wiping a known display_name. + */ +export function mergePeerFromAnnounce(existing: Peer | null | undefined, announce: Peer): Peer { + const base = existing && typeof existing === "object" ? existing : {}; + const merged: Peer = { ...base, ...announce }; + const kept = preferKnownDisplayName(announce?.display_name, (base as Peer).display_name); + if (kept !== undefined) { + merged.display_name = kept; + } + return merged; +} + +/** + * Merge conversation list row into an existing peers map entry (getConversations loop). + */ +export function mergePeerFromConversation(existing: Peer | null | undefined, conversation: Conversation): Peer { + const base = existing && typeof existing === "object" ? existing : {}; + const displayName = preferKnownDisplayName( + conversation.display_name ?? (base as Peer).display_name, + (base as Peer).display_name + ); + return { + ...base, + destination_hash: conversation.destination_hash, + display_name: displayName, + custom_display_name: conversation.custom_display_name ?? (base as Peer).custom_display_name, + contact_image: conversation.contact_image ?? (base as Peer).contact_image, + lxmf_user_icon: conversation.lxmf_user_icon ?? (base as Peer).lxmf_user_icon, + updated_at: conversation.updated_at ?? (base as Peer).updated_at, + is_tracking: conversation.is_tracking ?? (base as Peer).is_tracking, + }; +} + +/** + * Apply announce merge into a peers map keyed by destination_hash. Mutates map (Vue-friendly). + */ +export function updatePeerFromAnnounce(peers: Record, announce: Peer | null | undefined): Peer | null { + if (!announce?.destination_hash || !peers || typeof peers !== "object") { + return null; + } + const existing = peers[announce.destination_hash] || {}; + const merged = mergePeerFromAnnounce(existing, announce); + peers[announce.destination_hash] = merged; + return merged; +} + +/** + * Apply conversation-row merge into a peers map. Mutates map (Vue-friendly). + */ +export function updatePeerFromConversation( + peers: Record, + conversation: Conversation | null | undefined +): Peer | null { + if (!conversation?.destination_hash || !peers || typeof peers !== "object") { + return null; + } + const existing = peers[conversation.destination_hash] || {}; + const merged = mergePeerFromConversation(existing, conversation); + peers[conversation.destination_hash] = merged; + return merged; +} + +/** + * Whether a resolved conversation/peer display_name should replace a pane peer name. + */ +export function shouldUpdatePanePeerDisplayName( + incomingName: string | null | undefined, + currentName: string | null | undefined +): boolean { + return Boolean(incomingName && incomingName !== ANONYMOUS_PEER_DISPLAY_NAME && incomingName !== currentName); +} diff --git a/meshchatx/src/frontend/features/messages/lib/types.ts b/meshchatx/src/frontend/features/messages/lib/types.ts new file mode 100644 index 000000000..ab88c4ff9 --- /dev/null +++ b/meshchatx/src/frontend/features/messages/lib/types.ts @@ -0,0 +1,98 @@ +// SPDX-License-Identifier: 0BSD + +/** + * Shared Messages page types. Fields stay optional and Record-friendly to match Vue usage. + */ + +export type LxmfUserIconInfo = { + icon_name?: string; + foreground_colour?: string; + background_colour?: string; +}; + +export type Peer = { + destination_hash?: string; + display_name?: string | null; + custom_display_name?: string | null; + contact_image?: string | null; + lxmf_user_icon?: LxmfUserIconInfo | null; + is_tracking?: boolean; + is_unread?: boolean; + updated_at?: string | null; + [key: string]: unknown; +}; + +export type Conversation = { + destination_hash?: string; + display_name?: string | null; + custom_display_name?: string | null; + contact_image?: string | null; + lxmf_user_icon?: LxmfUserIconInfo | null; + is_unread?: boolean; + is_tracking?: boolean; + failed_messages_count?: number; + has_attachments?: boolean; + latest_message_preview?: string | null; + latest_message_title?: string | null; + latest_message_created_at?: number | null; + updated_at?: string | null; + is_contact?: boolean; + _lastKnownState?: string | null; + [key: string]: unknown; +}; + +/** Loose chat row / message carrier used by viewers and list helpers. */ +export type ChatItem = { + lxmf_message?: Record; + is_outbound?: boolean; + [key: string]: unknown; +}; + +export type Pane = { + id: number; + peer: Peer | null; +}; + +export type Folder = { + id?: number | string | null; + name?: string | null; + [key: string]: unknown; +}; + +/** Config slice read by MessagesPage (not the full backend config object). */ +export type MessagesConfig = { + messages_multi_pane_enabled?: boolean; + messages_sidebar_position?: string; + lxmf_address_hash?: string; + do_not_disturb_enabled?: boolean; + display_name?: string; + [key: string]: unknown; +}; + +export type PersistedPanePeer = { + destination_hash: string; + display_name: string | null; + custom_display_name: string | null; +}; + +export type PersistedPaneState = { + panes: Array; + sizes?: number[]; + focusedIndex?: number; +}; + +export type ConversationQueryInput = { + conversationSearchTerm?: string | null; + filterUnreadOnly?: boolean; + filterFailedOnly?: boolean; + filterHasAttachmentsOnly?: boolean; + selectedFolderId?: number | string | null; +}; + +export type ConversationQueryParams = { + search?: string; + filter_unread?: boolean; + filter_failed?: boolean; + filter_has_attachments?: boolean; + folder_id?: number | string; +}; diff --git a/meshchatx/src/frontend/features/messages/lib/unreadDismiss.ts b/meshchatx/src/frontend/features/messages/lib/unreadDismiss.ts new file mode 100644 index 000000000..fb91cebd2 --- /dev/null +++ b/meshchatx/src/frontend/features/messages/lib/unreadDismiss.ts @@ -0,0 +1,128 @@ +// SPDX-License-Identifier: 0BSD + +import Utils from "../../../js/Utils.js"; +import type { Conversation, Pane, Peer } from "./types.js"; + +export type UnreadTarget = Conversation | Peer; + +/** + * Unique normalized destination hashes for open panes (dismissUnreadForVisiblePanes). + */ +export function collectOpenPaneDestinationHashes(panes: Pane[] | null | undefined): string[] { + const seen = new Set(); + const out: string[] = []; + for (const pane of panes || []) { + const normalized = Utils.normalizeMeshchatHashHex(pane?.peer?.destination_hash || ""); + if (!normalized || seen.has(normalized)) { + continue; + } + seen.add(normalized); + out.push(normalized); + } + return out; +} + +/** + * Find a conversation or fall back to a matching pane peer / selected peer. + */ +export function findUnreadTarget( + destinationHash: string | null | undefined, + conversations: Conversation[] | null | undefined, + panes: Pane[] | null | undefined, + selectedPeer?: Peer | null +): UnreadTarget | null { + const normalized = Utils.normalizeMeshchatHashHex(destinationHash || ""); + if (!normalized) { + return null; + } + const fromList = (conversations || []).find( + (c) => Utils.normalizeMeshchatHashHex(c.destination_hash) === normalized + ); + if (fromList) { + return fromList; + } + const fromPane = (panes || []).find( + (p) => Utils.normalizeMeshchatHashHex(p?.peer?.destination_hash) === normalized + )?.peer; + if (fromPane) { + return fromPane; + } + if (selectedPeer && Utils.normalizeMeshchatHashHex(selectedPeer.destination_hash) === normalized) { + return selectedPeer; + } + return null; +} + +/** + * Whether dismissUnread should run for this target. + */ +export function shouldDismissUnread(target: UnreadTarget | null | undefined, force = false): boolean { + if (!target) { + return false; + } + if (force) { + return true; + } + return target.is_unread === true; +} + +/** + * Destinations among open panes that should clear unread. + */ +export function destinationsNeedingUnreadDismiss( + panes: Pane[] | null | undefined, + conversations: Conversation[] | null | undefined, + force = false +): string[] { + const hashes = collectOpenPaneDestinationHashes(panes); + const out: string[] = []; + for (const normalized of hashes) { + const conversation = + (conversations || []).find((c) => Utils.normalizeMeshchatHashHex(c.destination_hash) === normalized) || + (panes || []).find((p) => Utils.normalizeMeshchatHashHex(p?.peer?.destination_hash) === normalized)?.peer; + if (!shouldDismissUnread(conversation, force)) { + continue; + } + out.push(normalized); + } + return out; +} + +/** + * Optimistically clear is_unread on a local conversation/peer object. + * Returns whether it was unread before (for counter / rollback). + * API / GlobalState / viewer mark-as-read stay in the page layer. + */ +export function applyOptimisticUnreadClear(target: UnreadTarget | null | undefined): boolean { + if (!target) { + return false; + } + const wasUnread = target.is_unread === true; + if (wasUnread) { + target.is_unread = false; + } + return wasUnread; +} + +/** + * Restore is_unread after a failed mark-as-read. + */ +export function revertOptimisticUnreadClear(target: UnreadTarget | null | undefined, wasUnread: boolean): void { + if (!target || !wasUnread) { + return; + } + target.is_unread = true; +} + +/** + * Next GlobalState.unreadConversationsCount after a successful mark-as-read. + */ +export function nextUnreadConversationsCount(current: number, wasUnread: boolean): number { + if (!wasUnread) { + return current; + } + if (typeof current !== "number" || current <= 0) { + return 0; + } + return current - 1; +} diff --git a/meshchatx/src/frontend/features/messages/lib/viewerActions.ts b/meshchatx/src/frontend/features/messages/lib/viewerActions.ts new file mode 100644 index 000000000..6b26ff8e4 --- /dev/null +++ b/meshchatx/src/frontend/features/messages/lib/viewerActions.ts @@ -0,0 +1,144 @@ +// SPDX-License-Identifier: 0BSD + +export type MessageReaction = { + reactionHash?: string; + emoji?: string; + sender?: string; + [key: string]: unknown; +}; + +export type MessageAttachment = { + file_name?: string; + size?: number; + [key: string]: unknown; +}; + +export type LxmfMessageLike = { + hash?: string; + content?: string; + created_at?: unknown; + state?: string; + is_spam?: boolean; + is_reaction?: boolean; + reply_to_hash?: string; + reactions?: MessageReaction[]; + fields?: { + image?: Record & { image_type?: string }; + audio?: Record; + file_attachments?: MessageAttachment[]; + commands?: Array>; + telemetry?: Record; + telemetry_stream?: unknown[]; + reply_quoted_content?: string; + [key: string]: unknown; + }; + [key: string]: unknown; +}; + +export type MessageChatItem = { + lxmf_message: LxmfMessageLike; + is_outbound?: boolean; + is_actions_expanded?: boolean; + [key: string]: unknown; +}; + +export type MessageBubbleViewModel = { + kind?: string; + singleEmoji?: boolean; + textForRender?: string; + showFooter?: boolean; + showOriginalLink?: boolean; + showTranslationLink?: boolean; + fromCode?: string; + toCode?: string; + messageHash?: string; +}; + +export type ParsedMessageItems = { + isOnlyPaperMessage?: boolean; + isOnlyMapLink?: boolean; + isOnlyRelayLink?: boolean; + contact?: Record; + paperMessage?: unknown; + mapLink?: Record; + relayLink?: Record; +}; + +export type ConversationViewerActions = { + expandedMessageInfo?: string | null; + audioAttachmentUrls?: Readonly>; + selectedPeer?: { is_tracking?: boolean } | null; + imageGroupSortedChron: (items: MessageChatItem[]) => MessageChatItem[]; + imageGroupGalleryUrls: (items: MessageChatItem[]) => string[]; + lxmfImageUrl: (hash?: string) => string; + pendingOutboundImageSrc: (chatItem: MessageChatItem) => string; + openImage: (src: string, gallery?: string[], items?: MessageChatItem[]) => void; + onOutboundImageClick: (chatItem: MessageChatItem) => void; + onChatItemClick: (chatItem: MessageChatItem) => void; + onMessageContextMenu: (event: MouseEvent, chatItem: MessageChatItem, suppressToggle?: boolean) => void; + downloadMessageImage: (chatItem: MessageChatItem) => void; + openReactionPicker: (chatItem: MessageChatItem) => void; + reactionReactorLabel: (sender?: string) => string; + replyToMessage: (chatItem: MessageChatItem) => void; + retrySendingMessage: (chatItem: MessageChatItem) => void; + cancelSendingMessage: (chatItem: MessageChatItem) => void; + deleteChatItem: (chatItem: MessageChatItem) => void; + showRawMessage: (chatItem: MessageChatItem) => void; + scrollToMessage: (hash: string) => void; + getRepliedMessage: (hash: string) => { content?: string } | null | undefined; + handleMessageClick: (event: MouseEvent) => void; + setBubbleMessageShowOriginal: (hash?: string, showOriginal?: boolean) => void; + copyOversizedMessageBody: (chatItem: MessageChatItem) => void; + downloadLxmfFileAttachment: (chatItem: MessageChatItem, index: number) => void; + addContact: (name?: string, hash?: string, lxmfAddress?: string, lxstAddress?: string) => void; + ingestPaperMessage: (paperMessage: unknown, hash?: string) => void; + openMapShareFromParsed: (parsed: unknown) => void; + copyMapShareUri: (uri?: string) => void; + openRelayShareFromParsed: (parsed: unknown) => void; + copyRelayShareUri: (uri?: string) => void; + viewLocationOnMap: (location: unknown) => void; + toggleTracking: () => void; + bubbleViewModel: (chatItem: MessageChatItem) => MessageBubbleViewModel; + renderMarkdown: (text?: string) => string; + getParsedItems: (chatItem: MessageChatItem) => ParsedMessageItems | null | undefined; + formatTimeAgo: (ts: unknown) => string; + formatDateDividerLabel: (dayKey: unknown) => string; + formatAttachmentSize: (attachment: unknown, kind: string) => string; + getMessageInfoLines: (message: LxmfMessageLike, isOutbound?: boolean) => string[]; + messageBodyCharCount: (chatItem: MessageChatItem) => number; + bubbleMessageBodyFontSizePx: (model: MessageBubbleViewModel) => number; + bubbleStyles: (chatItem: MessageChatItem) => string | Record; + isImageOnlyMessage: (chatItem: MessageChatItem) => boolean; + hasMessageBubble: (chatItem: MessageChatItem) => boolean; + hasFileAttachments: (message: LxmfMessageLike) => boolean; + shouldHideAutoImageCaption: (chatItem: MessageChatItem) => boolean; + isMessageBodyTooLargeForDisplay: (chatItem: MessageChatItem) => boolean; + isPaperMessageIngested: (chatItem: MessageChatItem) => boolean; + isThemeOutboundBubble: (chatItem: MessageChatItem) => boolean; + isOutboundWaitingBubble: (chatItem: MessageChatItem) => boolean; + isOutboundPendingForUi: (chatItem: MessageChatItem) => boolean; + isOpportunisticDeferredDelivery: (message: LxmfMessageLike) => boolean; + showRichOutboundPendingUi: (chatItem: MessageChatItem) => boolean; + canCancelOutboundSend: (chatItem: MessageChatItem) => boolean; + showOutboundTransferProgress: (message: LxmfMessageLike) => boolean; + outboundTransferProgressPercent: (message: LxmfMessageLike) => number; + outboundSendingProgressLabel: (message: LxmfMessageLike) => string; + outboundTransferStatsLabel: (message: LxmfMessageLike, chatItem: MessageChatItem) => string; + outboundBubbleStatusIconName: (message: LxmfMessageLike) => string; + outboundBubbleStatusTitle: (message: LxmfMessageLike) => string; + outboundBubbleStatusHoverTitle: (message: LxmfMessageLike) => string; + outboundBubbleFailedTitle: (message: LxmfMessageLike) => string; + outboundBubbleSurfaceClass: (chatItem: MessageChatItem) => string; + outboundBubbleFooterTimeClass: (chatItem: MessageChatItem) => string; + outboundBubbleDeliveredIconClass: (chatItem: MessageChatItem) => string; + outboundBubbleSentCheckIconClass: (chatItem: MessageChatItem) => string; + outboundBubblePendingCheckIconClass: (chatItem: MessageChatItem) => string; + outboundSendingStatusIconClass: (chatItem: MessageChatItem) => string; + outboundExpandedActionsShellClass: (chatItem: MessageChatItem) => string; + outboundMessageMenuButtonClass: (chatItem: MessageChatItem) => string; + outboundMessageMenuButtonHoverClass: (chatItem: MessageChatItem) => string; + outboundReplySnippetTitleClass: (chatItem: MessageChatItem) => string; + outboundAttachmentCaptionClass: (chatItem: MessageChatItem) => string; + outboundEmbeddedCardClass: (chatItem: MessageChatItem) => string; + outboundEmbeddedSecondaryTextClass: (chatItem: MessageChatItem) => string; +}; diff --git a/meshchatx/src/frontend/features/ping/PingPage.svelte b/meshchatx/src/frontend/features/ping/PingPage.svelte index 253ef2c2c..2d6567fe3 100644 --- a/meshchatx/src/frontend/features/ping/PingPage.svelte +++ b/meshchatx/src/frontend/features/ping/PingPage.svelte @@ -141,9 +141,7 @@
- {@html t("ping.description", { - code: "lxmf.delivery", - })} + {t("ping.description")}
diff --git a/meshchatx/src/frontend/features/ping/index.js b/meshchatx/src/frontend/features/ping/index.ts similarity index 88% rename from meshchatx/src/frontend/features/ping/index.js rename to meshchatx/src/frontend/features/ping/index.ts index acf0caaa4..454be1e66 100644 --- a/meshchatx/src/frontend/features/ping/index.js +++ b/meshchatx/src/frontend/features/ping/index.ts @@ -2,7 +2,7 @@ import { registerFeature } from "../../js/registries/featureRegistry.js"; -export function registerPingFeature() { +export function registerPingFeature(): void { registerFeature({ id: "ping", routes: [ diff --git a/meshchatx/src/frontend/features/ping/lib/pingFormat.js b/meshchatx/src/frontend/features/ping/lib/pingFormat.ts similarity index 63% rename from meshchatx/src/frontend/features/ping/lib/pingFormat.js rename to meshchatx/src/frontend/features/ping/lib/pingFormat.ts index b7e6ef2a5..db8e4b715 100644 --- a/meshchatx/src/frontend/features/ping/lib/pingFormat.js +++ b/meshchatx/src/frontend/features/ping/lib/pingFormat.ts @@ -1,28 +1,35 @@ // SPDX-License-Identifier: 0BSD -/** - * @param {string | null | undefined} hash - * @returns {boolean} - */ -export function isValidPingDestinationHash(hash) { +export type PingResult = { + rtt: number; + hops_there: number; + hops_back: number; + rssi?: number | null; + snr?: number | null; + quality?: number | null; + receiving_interface: string; +}; + +export type PingSuccessSummary = { + duration: string; + hopsThere: number; + hopsBack: number; + rssi?: number | null; + snr?: number | null; + quality?: number | null; + via: string; +}; + +export function isValidPingDestinationHash(hash: string | null | undefined): boolean { return hash != null && String(hash).length === 32; } -/** - * @param {unknown} timeout - * @returns {boolean} - */ -export function isValidPingTimeout(timeout) { +export function isValidPingTimeout(timeout: unknown): boolean { const n = Number(timeout); return Number.isFinite(n) && n >= 1 && n <= 600; } -/** - * @param {object} pingResult - * @param {number} seq - * @returns {{ line: string, summary: object }} - */ -export function formatPingSuccess(pingResult, seq) { +export function formatPingSuccess(pingResult: PingResult, seq: number): { line: string; summary: PingSuccessSummary } { const rttMilliseconds = (pingResult.rtt * 1000).toFixed(3); const rttDurationString = `${rttMilliseconds}ms`; const info = [ diff --git a/meshchatx/src/frontend/features/registerAllFeatures.js b/meshchatx/src/frontend/features/registerAllFeatures.ts similarity index 84% rename from meshchatx/src/frontend/features/registerAllFeatures.js rename to meshchatx/src/frontend/features/registerAllFeatures.ts index b05161e07..f8c844c51 100644 --- a/meshchatx/src/frontend/features/registerAllFeatures.js +++ b/meshchatx/src/frontend/features/registerAllFeatures.ts @@ -5,6 +5,7 @@ import { registerContactsFeature } from "./contacts/index.js"; import { registerDebugLogsFeature } from "./debug-logs/index.js"; import { registerForwarderFeature } from "./forwarder/index.js"; import { registerLicensesFeature } from "./licenses/index.js"; +import { registerMessagesFeature } from "./messages/index.js"; import { registerPingFeature } from "./ping/index.js"; import { registerToolsFeature } from "./tools/index.js"; @@ -13,7 +14,7 @@ let featuresRegistered = false; /** * Reset for tests. */ -export function resetAllFeaturesForTests() { +export function resetAllFeaturesForTests(): void { featuresRegistered = false; } @@ -22,7 +23,7 @@ export function resetAllFeaturesForTests() { * Call once at boot after registerCoreContributions. * Lives under features/ so kernel js/ stays free of feature imports. */ -export function registerAllFeatures() { +export function registerAllFeatures(): void { if (featuresRegistered) { return; } @@ -32,6 +33,7 @@ export function registerAllFeatures() { registerDebugLogsFeature(); registerForwarderFeature(); registerLicensesFeature(); + registerMessagesFeature(); registerPingFeature(); registerToolsFeature(); } diff --git a/meshchatx/src/frontend/features/tools/ToolListRow.svelte b/meshchatx/src/frontend/features/tools/ToolListRow.svelte index 49fc37bbb..8026a1449 100644 --- a/meshchatx/src/frontend/features/tools/ToolListRow.svelte +++ b/meshchatx/src/frontend/features/tools/ToolListRow.svelte @@ -6,18 +6,29 @@ /** @type {{ tool: Record }} */ let { tool } = $props(); + + const iconShellClass = $derived( + [ + "w-11 h-11 sm:w-12 sm:h-12 rounded-xl flex items-center justify-center shrink-0", + String(tool.iconBg || "") + .replace(/\btool-card__icon\b/g, "") + .trim(), + ] + .filter(Boolean) + .join(" ") + ); -
+
{#if tool.icon} - + {:else if tool.image} {String(tool.imageAlt {/if}
-
{String(tool.title || "")}
+
{String(tool.title || "")}
{#if tool.alpha} {/if}
-
{String(tool.description || "")}
+
+ {String(tool.description || "")} +
{#if !tool.comingSoon}
@@ -55,50 +68,10 @@ > - +
{:else} - + {/if}
{/if} - - diff --git a/meshchatx/src/frontend/features/tools/ToolsPage.svelte b/meshchatx/src/frontend/features/tools/ToolsPage.svelte index 577031dc4..f27bd714d 100644 --- a/meshchatx/src/frontend/features/tools/ToolsPage.svelte +++ b/meshchatx/src/frontend/features/tools/ToolsPage.svelte @@ -23,6 +23,7 @@ const toolsWithTranslations = $derived(translateTools(t)); const filteredTools = $derived(filterTools(toolsWithTranslations, searchQuery)); const groupedToolSections = $derived(searchQuery.trim() ? null : groupTools(filteredTools)); + const toolsCount = $derived(toolsWithTranslations.length); /** * @param {string} sectionId @@ -36,20 +37,20 @@
-
-
-
-
+
+
+
+
{t("tools.power_tools")}
-
+
{t("tools.diagnostics_description")}
-
+
-
+
{#if searchQuery}
- + diff --git a/meshchatx/src/frontend/js/AndroidStorageBridge.js b/meshchatx/src/frontend/js/AndroidStorageBridge.ts similarity index 99% rename from meshchatx/src/frontend/js/AndroidStorageBridge.js rename to meshchatx/src/frontend/js/AndroidStorageBridge.ts index 5110d14e4..a27f7b73f 100644 --- a/meshchatx/src/frontend/js/AndroidStorageBridge.js +++ b/meshchatx/src/frontend/js/AndroidStorageBridge.ts @@ -26,6 +26,7 @@ function safeParseJson(raw, fallback) { } export default class AndroidStorageBridge { + declare android: any; constructor(bridge = null, env = null) { this.android = new AndroidBridge(bridge, env || pickEnv()); } diff --git a/meshchatx/src/frontend/js/Codec2Loader.js b/meshchatx/src/frontend/js/Codec2Loader.ts similarity index 94% rename from meshchatx/src/frontend/js/Codec2Loader.js rename to meshchatx/src/frontend/js/Codec2Loader.ts index 4e181af41..2af29c61a 100644 --- a/meshchatx/src/frontend/js/Codec2Loader.js +++ b/meshchatx/src/frontend/js/Codec2Loader.ts @@ -56,7 +56,7 @@ async function injectScript(src) { if (existing.getAttribute(loadedAttr) === "true") { return Promise.resolve(); } - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { existing.addEventListener("load", () => resolve(), { once: true }); existing.addEventListener("error", () => reject(new Error(`Failed to load ${src}`)), { once: true }); }); @@ -78,7 +78,7 @@ async function injectScript(src) { const blob = new Blob([buf], { type: "application/javascript" }); const blobUrl = URL.createObjectURL(blob); - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { const script = document.createElement("script"); script.src = blobUrl; script.async = false; @@ -107,7 +107,7 @@ function loadChain() { * @param {() => Promise} fn * @param {{ maxAttempts?: number, baseDelayMs?: number, maxDelayMs?: number }} options */ -export async function withRetries(fn, options = {}) { +export async function withRetries(fn, options: any = {}) { const maxAttempts = options.maxAttempts ?? 12; let delayMs = options.baseDelayMs ?? 400; const maxDelayMs = options.maxDelayMs ?? 8000; @@ -121,7 +121,7 @@ export async function withRetries(fn, options = {}) { if (attempt === maxAttempts - 1) { break; } - await new Promise((r) => setTimeout(r, delayMs)); + await new Promise((r) => setTimeout(r, delayMs)); delayMs = Math.min(maxDelayMs, Math.floor(delayMs * 1.5)); } } @@ -158,7 +158,7 @@ export function ensureCodec2ScriptsLoaded() { * * @param {{ maxAttempts?: number, baseDelayMs?: number, maxDelayMs?: number }} options */ -export async function startCodec2ScriptsBackgroundLoad(options = {}) { +export async function startCodec2ScriptsBackgroundLoad(options: any = {}) { if (typeof window === "undefined") { return; } diff --git a/meshchatx/src/frontend/js/DialogUtils.js b/meshchatx/src/frontend/js/DialogUtils.ts similarity index 57% rename from meshchatx/src/frontend/js/DialogUtils.js rename to meshchatx/src/frontend/js/DialogUtils.ts index dfd5fb135..4e05f1ee4 100644 --- a/meshchatx/src/frontend/js/DialogUtils.js +++ b/meshchatx/src/frontend/js/DialogUtils.ts @@ -1,35 +1,40 @@ -import GlobalEmitter from "./GlobalEmitter"; +// SPDX-License-Identifier: 0BSD -function dialogMessage(message) { +import GlobalEmitter from "./GlobalEmitter.js"; + +function dialogMessage(message: unknown): string { if (message == null) { return ""; } return typeof message === "string" ? message : String(message); } -function hostListenerCount(event) { - if (typeof GlobalEmitter.listenerCount !== "function") { +function hostListenerCount(event: string): number | null { + const emitter = GlobalEmitter as { listenerCount?: (e: string) => number }; + if (typeof emitter.listenerCount !== "function") { return null; } - return GlobalEmitter.listenerCount(event); + return emitter.listenerCount(event); } class DialogUtils { - static alert(message, type = "info") { - if (window.electron) { + static alert(message: unknown, type = "info"): void { + if (window.electron?.alert) { window.electron.alert(message); } - GlobalEmitter.emit("toast", { message, type }); } - static confirm(message, title) { + static confirm(message: unknown, title?: string): Promise { return new Promise((resolve) => { if (hostListenerCount("confirm") === 0) { resolve(false); return; } - const payload = { message: dialogMessage(message), resolve }; + const payload: { message: string; resolve: (v: boolean) => void; title?: string } = { + message: dialogMessage(message), + resolve, + }; if (typeof title === "string" && title.trim()) { payload.title = title.trim(); } @@ -37,11 +42,15 @@ class DialogUtils { }); } - static confirmCustom(message, title) { + static confirmCustom(message: unknown, title?: string): Promise { return DialogUtils.confirm(message, title); } - static prompt(message, defaultValue = "", options = {}) { + static prompt( + message: unknown, + defaultValue: unknown = "", + options: { inputType?: string } | null = {} + ): Promise { const inputType = options && typeof options === "object" && options.inputType ? String(options.inputType) : "text"; return new Promise((resolve) => { diff --git a/meshchatx/src/frontend/js/DownloadUtils.js b/meshchatx/src/frontend/js/DownloadUtils.ts similarity index 98% rename from meshchatx/src/frontend/js/DownloadUtils.js rename to meshchatx/src/frontend/js/DownloadUtils.ts index 1f28394e8..88e2f286c 100644 --- a/meshchatx/src/frontend/js/DownloadUtils.js +++ b/meshchatx/src/frontend/js/DownloadUtils.ts @@ -70,7 +70,7 @@ class DownloadUtils { } static _blobToBase64(blob) { - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onloadend = () => { const s = reader.result; diff --git a/meshchatx/src/frontend/js/ElectronUtils.js b/meshchatx/src/frontend/js/ElectronUtils.ts similarity index 100% rename from meshchatx/src/frontend/js/ElectronUtils.js rename to meshchatx/src/frontend/js/ElectronUtils.ts diff --git a/meshchatx/src/frontend/js/GeoWasmLoader.js b/meshchatx/src/frontend/js/GeoWasmLoader.ts similarity index 99% rename from meshchatx/src/frontend/js/GeoWasmLoader.js rename to meshchatx/src/frontend/js/GeoWasmLoader.ts index f93742a2c..9dd21f432 100644 --- a/meshchatx/src/frontend/js/GeoWasmLoader.js +++ b/meshchatx/src/frontend/js/GeoWasmLoader.ts @@ -86,7 +86,7 @@ async function injectScript(src, expectedHash) { await verifySri(buf, expectedHash, "wasm_exec.js"); const blob = new Blob([buf], { type: "application/javascript" }); const blobUrl = URL.createObjectURL(blob); - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { const s = document.createElement("script"); s.id = id; s.async = true; diff --git a/meshchatx/src/frontend/js/GlobalEmitter.js b/meshchatx/src/frontend/js/GlobalEmitter.ts similarity index 68% rename from meshchatx/src/frontend/js/GlobalEmitter.js rename to meshchatx/src/frontend/js/GlobalEmitter.ts index 9af860140..0c2258725 100644 --- a/meshchatx/src/frontend/js/GlobalEmitter.js +++ b/meshchatx/src/frontend/js/GlobalEmitter.ts @@ -3,26 +3,27 @@ import { createEmitter } from "../libs/emitter.js"; class GlobalEmitter { + declare emitter: ReturnType; constructor() { this.emitter = createEmitter(); } // add event listener - on(event, handler) { + on(event: string, handler: (...args: any[]) => void) { this.emitter.on(event, handler); } // remove event listener - off(event, handler) { + off(event: string, handler: (...args: any[]) => void) { this.emitter.off(event, handler); } // emit event - emit(type, event) { + emit(type: string, event?: unknown) { this.emitter.emit(type, event); } - listenerCount(event) { + listenerCount(event: string) { const list = this.emitter?.all?.get(event); return Array.isArray(list) ? list.length : 0; } diff --git a/meshchatx/src/frontend/js/GlobalState.js b/meshchatx/src/frontend/js/GlobalState.ts similarity index 100% rename from meshchatx/src/frontend/js/GlobalState.js rename to meshchatx/src/frontend/js/GlobalState.ts diff --git a/meshchatx/src/frontend/js/HeapMonitor.js b/meshchatx/src/frontend/js/HeapMonitor.ts similarity index 100% rename from meshchatx/src/frontend/js/HeapMonitor.js rename to meshchatx/src/frontend/js/HeapMonitor.ts diff --git a/meshchatx/src/frontend/js/HelptipUtils.js b/meshchatx/src/frontend/js/HelptipUtils.ts similarity index 100% rename from meshchatx/src/frontend/js/HelptipUtils.js rename to meshchatx/src/frontend/js/HelptipUtils.ts diff --git a/meshchatx/src/frontend/js/KeyboardShortcuts.js b/meshchatx/src/frontend/js/KeyboardShortcuts.ts similarity index 89% rename from meshchatx/src/frontend/js/KeyboardShortcuts.js rename to meshchatx/src/frontend/js/KeyboardShortcuts.ts index 0baf239b4..b4853e25e 100644 --- a/meshchatx/src/frontend/js/KeyboardShortcuts.js +++ b/meshchatx/src/frontend/js/KeyboardShortcuts.ts @@ -2,6 +2,10 @@ import GlobalEmitter from "./GlobalEmitter"; import WebSocketConnection from "./WebSocketConnection"; class KeyboardShortcuts { + declare activeKeys: Set; + declare isRecording: boolean; + declare onRecordCallback: any; + declare shortcuts: any; constructor() { this.shortcuts = this.getDefaultShortcuts(); this.activeKeys = new Set(); @@ -54,10 +58,12 @@ class KeyboardShortcuts { continue; } // Check if we should ignore because we're in an input + const activeElement = document.activeElement as HTMLElement | null; const isInput = - ["INPUT", "TEXTAREA"].includes(document.activeElement.tagName) || - document.activeElement.isContentEditable || - document.activeElement.closest(".v-input"); // Better Vuetify detection + !!activeElement && + (["INPUT", "TEXTAREA"].includes(activeElement.tagName) || + activeElement.isContentEditable || + activeElement.closest(".v-input")); // Better Vuetify detection const hasModifier = shortcut.keys.some((k) => ["control", "alt", "meta"].includes(k)); @@ -141,9 +147,9 @@ class KeyboardShortcuts { // Layout independence: check e.code as well (handles Alt+key layout changes) // e.g. Alt+1 might be captured as Digit1 regardless of layout const codeMatch = - e.code === `Digit${mainKey}` || - e.code === `Key${mainKey.toUpperCase()}` || - e.code === mainKey.toUpperCase(); // For keys like 'Enter', 'Escape' + (e as Error & { code?: string; status?: number; body?: unknown }).code === `Digit${mainKey}` || + (e as Error & { code?: string; status?: number; body?: unknown }).code === `Key${mainKey.toUpperCase()}` || + (e as Error & { code?: string; status?: number; body?: unknown }).code === mainKey.toUpperCase(); // For keys like 'Enter', 'Escape' if (codeMatch) return true; diff --git a/meshchatx/src/frontend/js/LinkUtils.js b/meshchatx/src/frontend/js/LinkUtils.ts similarity index 100% rename from meshchatx/src/frontend/js/LinkUtils.js rename to meshchatx/src/frontend/js/LinkUtils.ts diff --git a/meshchatx/src/frontend/js/MarkdownRenderer.js b/meshchatx/src/frontend/js/MarkdownRenderer.ts similarity index 100% rename from meshchatx/src/frontend/js/MarkdownRenderer.js rename to meshchatx/src/frontend/js/MarkdownRenderer.ts diff --git a/meshchatx/src/frontend/js/MicronParser.js b/meshchatx/src/frontend/js/MicronParser.ts similarity index 96% rename from meshchatx/src/frontend/js/MicronParser.js rename to meshchatx/src/frontend/js/MicronParser.ts index c9a5541ca..595c41b2d 100644 --- a/meshchatx/src/frontend/js/MicronParser.js +++ b/meshchatx/src/frontend/js/MicronParser.ts @@ -20,6 +20,13 @@ function escapeHtmlForFallback(text) { * partial includes, overlay style stripping, wide CJK monospace cells, and lxmf/nomadnetwork in DOMPurify. */ export default class MicronParser extends BaseMicronParser { + declare DEFAULT_BG: any; + declare DEFAULT_FG_DARK: any; + declare SELECTED_STYLES: any; + declare colorToCss: any; + declare darkTheme: any; + declare enableForceMonospace: any; + declare parseHeaderTags: any; constructor(darkTheme = true, enableForceMonospace = true) { super(darkTheme, enableForceMonospace); if (this.enableForceMonospace) { @@ -326,7 +333,7 @@ export default class MicronParser extends BaseMicronParser { if (node.nodeType === Node.TEXT_NODE) { out += node.nodeValue || ""; } else if (node.nodeType === Node.ELEMENT_NODE) { - const tag = node.tagName; + const tag = (node as Element).tagName; if (tag === "BR" || tag === "DIV" || tag === "P" || tag === "PRE") { if (out.length && !out.endsWith("\n")) { out += "\n"; @@ -347,7 +354,7 @@ export default class MicronParser extends BaseMicronParser { }); } - convertMicronToHtmlWasmHybrid(markup, partialContents = {}) { + convertMicronToHtmlWasmHybrid(markup, partialContents: any = {}) { const mc = globalThis.micronConvert; const segments = MicronParser.splitMicronMarkupWasmSegments(markup); let html = ""; @@ -369,7 +376,7 @@ export default class MicronParser extends BaseMicronParser { * Also wraps with default theme fg (e.g. #ddd) like upstream. */ _wrapMicronPageShell(markup, innerHtml) { - let headerColors = { fg: null, bg: null }; + let headerColors: any = { fg: null, bg: null }; try { headerColors = this.parseHeaderTags(markup); } catch (e) { @@ -395,9 +402,9 @@ export default class MicronParser extends BaseMicronParser { return MicronParser.sanitizeRenderedMicronHtml(wrap.outerHTML); } - _convertMicronToHtmlJs(markup, partialContents = {}) { + _convertMicronToHtmlJs(markup, partialContents: any = {}) { const build = () => { - let headerColors = { fg: null, bg: null }; + let headerColors: any = { fg: null, bg: null }; try { headerColors = this.parseHeaderTags(markup); } catch (e) { @@ -408,7 +415,7 @@ export default class MicronParser extends BaseMicronParser { const defaultFg = headerColors.fg || plainStyle.fg; const defaultBg = headerColors.bg || plainStyle.bg; - let state = { + let state: any = { literal: false, depth: 0, fg_color: defaultFg, @@ -491,8 +498,8 @@ export default class MicronParser extends BaseMicronParser { } try { - if (typeof MicronParser._resolveEmptyAnchors === "function") { - MicronParser._resolveEmptyAnchors(tempContainer); + if (typeof (MicronParser as any)._resolveEmptyAnchors === "function") { + (MicronParser as any)._resolveEmptyAnchors(tempContainer); } } catch (e) { console.warn("MicronParser: resolveEmptyAnchors failed", e); @@ -522,7 +529,7 @@ export default class MicronParser extends BaseMicronParser { } } - convertMicronToHtml(markup, partialContents = {}, options = {}) { + convertMicronToHtml(markup, partialContents: any = {}, options: any = {}) { if (markup == null) return ""; if (typeof markup !== "string") markup = String(markup); @@ -547,7 +554,7 @@ export default class MicronParser extends BaseMicronParser { try { const fragment = document.createDocumentFragment(); - let headerColors = { fg: null, bg: null }; + let headerColors: any = { fg: null, bg: null }; try { headerColors = this.parseHeaderTags(markup); } catch (e) { @@ -558,7 +565,7 @@ export default class MicronParser extends BaseMicronParser { const defaultFg = headerColors.fg || plainStyle.fg; const defaultBg = headerColors.bg || plainStyle.bg; - let state = { + let state: any = { literal: false, depth: 0, fg_color: defaultFg, diff --git a/meshchatx/src/frontend/js/MicronStorage.js b/meshchatx/src/frontend/js/MicronStorage.ts similarity index 93% rename from meshchatx/src/frontend/js/MicronStorage.js rename to meshchatx/src/frontend/js/MicronStorage.ts index 5e4ef442d..0ed939506 100644 --- a/meshchatx/src/frontend/js/MicronStorage.js +++ b/meshchatx/src/frontend/js/MicronStorage.ts @@ -6,6 +6,9 @@ const DB_VERSION = 1; const STORE_NAME = "tabs"; class MicronStorage { + declare db: any; + declare initPromise: any; + declare unavailable: boolean; constructor() { this.db = null; this.initPromise = null; @@ -49,7 +52,7 @@ class MicronStorage { if (!this.db) { return; } - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { const transaction = this.db.transaction([STORE_NAME], "readwrite"); const store = transaction.objectStore(STORE_NAME); @@ -81,7 +84,7 @@ class MicronStorage { if (!this.db) { return []; } - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { const transaction = this.db.transaction([STORE_NAME], "readonly"); const store = transaction.objectStore(STORE_NAME); const request = store.getAll(); @@ -101,7 +104,7 @@ class MicronStorage { if (!this.db) { return; } - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { const transaction = this.db.transaction([STORE_NAME], "readwrite"); const store = transaction.objectStore(STORE_NAME); const request = store.clear(); diff --git a/meshchatx/src/frontend/js/MicronWasmLoader.js b/meshchatx/src/frontend/js/MicronWasmLoader.ts similarity index 99% rename from meshchatx/src/frontend/js/MicronWasmLoader.js rename to meshchatx/src/frontend/js/MicronWasmLoader.ts index e2499f46e..fe5f5dbea 100644 --- a/meshchatx/src/frontend/js/MicronWasmLoader.js +++ b/meshchatx/src/frontend/js/MicronWasmLoader.ts @@ -161,7 +161,7 @@ async function injectScript(src, expectedHash) { await verifySri(buf, expectedHash, "wasm_exec.js"); const blob = new Blob([buf], { type: "application/javascript" }); const blobUrl = URL.createObjectURL(blob); - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { const s = document.createElement("script"); s.id = id; s.async = true; diff --git a/meshchatx/src/frontend/js/MicronWasmRuntimeOverride.js b/meshchatx/src/frontend/js/MicronWasmRuntimeOverride.ts similarity index 96% rename from meshchatx/src/frontend/js/MicronWasmRuntimeOverride.js rename to meshchatx/src/frontend/js/MicronWasmRuntimeOverride.ts index 52e4d70c0..890d7b82a 100644 --- a/meshchatx/src/frontend/js/MicronWasmRuntimeOverride.js +++ b/meshchatx/src/frontend/js/MicronWasmRuntimeOverride.ts @@ -81,7 +81,7 @@ export async function setMicronWasmRuntimeOverride(record) { }, KEY ); - await new Promise((resolve, reject) => { + await new Promise((resolve, reject) => { tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error || new Error("IndexedDB write failed")); tx.onabort = () => reject(tx.error || new Error("IndexedDB write aborted")); @@ -97,7 +97,7 @@ export async function getMicronWasmRuntimeOverride() { try { const tx = db.transaction(STORE, "readonly"); const st = tx.objectStore(STORE); - const raw = await new Promise((resolve, reject) => { + const raw = await new Promise((resolve, reject) => { const r = st.get(KEY); r.onsuccess = () => resolve(r.result || null); r.onerror = () => reject(r.error || new Error("IndexedDB read failed")); @@ -123,7 +123,7 @@ export async function clearMicronWasmRuntimeOverride() { const tx = db.transaction(STORE, "readwrite"); const st = tx.objectStore(STORE); st.delete(KEY); - await new Promise((resolve, reject) => { + await new Promise((resolve, reject) => { tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error || new Error("IndexedDB delete failed")); tx.onabort = () => reject(tx.error || new Error("IndexedDB delete aborted")); diff --git a/meshchatx/src/frontend/js/MicrophoneRecorder.js b/meshchatx/src/frontend/js/MicrophoneRecorder.ts similarity index 96% rename from meshchatx/src/frontend/js/MicrophoneRecorder.js rename to meshchatx/src/frontend/js/MicrophoneRecorder.ts index b16ac5c2b..c31b8f515 100644 --- a/meshchatx/src/frontend/js/MicrophoneRecorder.js +++ b/meshchatx/src/frontend/js/MicrophoneRecorder.ts @@ -9,6 +9,16 @@ import microphoneRecorderWorkletSource from "./MicrophoneRecorder.worklet.js?raw"; class MicrophoneRecorder { + declare _scriptProcessorCapture: boolean; + declare _workletBlobUrl: any; + declare audioContext: any; + declare channels: number; + declare microphoneMediaStream: any; + declare pcmChunks: any[]; + declare processorNode: any; + declare sampleRate: number; + declare silentGainNode: any; + declare sourceNode: any; constructor() { this.microphoneMediaStream = null; this.audioContext = null; diff --git a/meshchatx/src/frontend/js/MicrophoneRecorder.worklet.js b/meshchatx/src/frontend/js/MicrophoneRecorder.worklet.ts similarity index 69% rename from meshchatx/src/frontend/js/MicrophoneRecorder.worklet.js rename to meshchatx/src/frontend/js/MicrophoneRecorder.worklet.ts index aac7f2f37..93c4a300d 100644 --- a/meshchatx/src/frontend/js/MicrophoneRecorder.worklet.js +++ b/meshchatx/src/frontend/js/MicrophoneRecorder.worklet.ts @@ -1,7 +1,18 @@ // SPDX-License-Identifier: 0BSD +export {}; + +declare class AudioWorkletProcessor { + readonly port: MessagePort; +} + +declare function registerProcessor( + name: string, + processorCtor: new () => AudioWorkletProcessor +): void; + class MicrophonePcmFloatProcessor extends AudioWorkletProcessor { - process(inputs) { + process(inputs: Float32Array[][]) { const input = inputs[0]; if (!input || input.length === 0) { return true; diff --git a/meshchatx/src/frontend/js/NomadPageRenderer.js b/meshchatx/src/frontend/js/NomadPageRenderer.ts similarity index 97% rename from meshchatx/src/frontend/js/NomadPageRenderer.js rename to meshchatx/src/frontend/js/NomadPageRenderer.ts index 6ec955437..5d7140dfb 100644 --- a/meshchatx/src/frontend/js/NomadPageRenderer.js +++ b/meshchatx/src/frontend/js/NomadPageRenderer.ts @@ -290,7 +290,7 @@ export function sanitizeNomadHtmlDocument(html) { return MicronParser.stripOverlayStyles(sanitized); } -export function renderNomadMarkdown(markdown, options = {}) { +export function renderNomadMarkdown(markdown, options: any = {}) { const { destinationHash } = options; const raw = marked.parse(normalizeMarkdownInput(markdown ?? "")); let inner = sanitizeNomadHtmlFragment(raw); @@ -300,7 +300,7 @@ export function renderNomadMarkdown(markdown, options = {}) { return `
${inner}
`; } -export function renderNomadHtmlPage(html, options = {}) { +export function renderNomadHtmlPage(html, options: any = {}) { const { destinationHash } = options; let out = sanitizeNomadHtmlDocument(html); if (destinationHash) { @@ -351,14 +351,14 @@ export function renderNomadPageByPath( content, pagePartials, MicronParserClass, - renderOptions = {} + renderOptions: any = {} ) { const renderMarkdown = renderOptions.renderMarkdown !== false; const renderHtml = renderOptions.renderHtml !== false; const renderPlaintext = renderOptions.renderPlaintext !== false; const nomadDestinationHash = renderOptions.nomadDestinationHash || null; - const linkOpts = { destinationHash: nomadDestinationHash }; - const micronOpts = { + const linkOpts: any = { destinationHash: nomadDestinationHash }; + const micronOpts: any = { useWasm: renderOptions.nomad_micron_wasm_use === true && typeof globalThis.micronConvert === "function", }; const p = (pagePathWithoutData || "").toLowerCase(); diff --git a/meshchatx/src/frontend/js/NomadRichHtmlLinks.js b/meshchatx/src/frontend/js/NomadRichHtmlLinks.ts similarity index 98% rename from meshchatx/src/frontend/js/NomadRichHtmlLinks.js rename to meshchatx/src/frontend/js/NomadRichHtmlLinks.ts index 55640d732..93de3dd01 100644 --- a/meshchatx/src/frontend/js/NomadRichHtmlLinks.js +++ b/meshchatx/src/frontend/js/NomadRichHtmlLinks.ts @@ -44,7 +44,7 @@ function scrollToElementId(id, scrollRoot) { * @param {boolean} [options.blockUnhandledAnchors=true] * @returns {boolean} true when the click was handled */ -export function handleRichHtmlLinkClick(event, options = {}) { +export function handleRichHtmlLinkClick(event, options: any = {}) { const { scrollRoot = null, onNomadUrl, diff --git a/meshchatx/src/frontend/js/NotificationSoundUtils.js b/meshchatx/src/frontend/js/NotificationSoundUtils.ts similarity index 100% rename from meshchatx/src/frontend/js/NotificationSoundUtils.js rename to meshchatx/src/frontend/js/NotificationSoundUtils.ts diff --git a/meshchatx/src/frontend/js/NotificationUtils.js b/meshchatx/src/frontend/js/NotificationUtils.ts similarity index 100% rename from meshchatx/src/frontend/js/NotificationUtils.js rename to meshchatx/src/frontend/js/NotificationUtils.ts diff --git a/meshchatx/src/frontend/js/TileCache.js b/meshchatx/src/frontend/js/TileCache.ts similarity index 94% rename from meshchatx/src/frontend/js/TileCache.js rename to meshchatx/src/frontend/js/TileCache.ts index 941dbcd25..075e2d6f4 100644 --- a/meshchatx/src/frontend/js/TileCache.js +++ b/meshchatx/src/frontend/js/TileCache.ts @@ -15,6 +15,12 @@ const ACCESS_FLUSH_HITS = 32; const ACCESS_FLUSH_MS = 5000; class TileCache { + declare _accessFlushTimer: any; + declare _memCache: Map; + declare _pendingAccess: Map; + declare db: any; + declare initPromise: any; + declare unavailable: boolean; constructor() { this.db = null; this._memCache = new Map(); @@ -120,7 +126,7 @@ class TileCache { if (!this.db) { return; } - await new Promise((resolve, reject) => { + await new Promise((resolve, reject) => { const transaction = this.db.transaction([META_STORE], "readwrite"); const metaStore = transaction.objectStore(META_STORE); for (const [key, ts] of pending.entries()) { @@ -156,7 +162,7 @@ class TileCache { this._queueAccess(key); return mem; } - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { const transaction = this.db.transaction([STORE_NAME], "readonly"); const store = transaction.objectStore(STORE_NAME); const request = store.get(key); @@ -180,7 +186,7 @@ class TileCache { return; } await this._evictIfNeeded(this._blobSize(data)); - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { const transaction = this.db.transaction([STORE_NAME, META_STORE], "readwrite"); const store = transaction.objectStore(STORE_NAME); const metaStore = transaction.objectStore(META_STORE); @@ -201,7 +207,7 @@ class TileCache { } async _readAllMeta() { - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { const transaction = this.db.transaction([META_STORE], "readonly"); const store = transaction.objectStore(META_STORE); const request = store.openCursor(); @@ -256,7 +262,7 @@ class TileCache { async _deleteKeys(keys) { if (!keys.length) return; - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { const transaction = this.db.transaction([STORE_NAME, META_STORE], "readwrite"); const store = transaction.objectStore(STORE_NAME); const metaStore = transaction.objectStore(META_STORE); @@ -277,7 +283,7 @@ class TileCache { if (!this.db) { return undefined; } - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { const transaction = this.db.transaction([STATE_STORE], "readonly"); const store = transaction.objectStore(STATE_STORE); const request = store.get(key); @@ -292,7 +298,7 @@ class TileCache { if (!this.db) { return; } - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { const transaction = this.db.transaction([STATE_STORE], "readwrite"); const store = transaction.objectStore(STATE_STORE); store.put(data, key); @@ -310,7 +316,7 @@ class TileCache { if (!this.db) { return; } - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { const stores = [STORE_NAME, STATE_STORE]; if (this.db.objectStoreNames.contains(META_STORE)) { stores.push(META_STORE); diff --git a/meshchatx/src/frontend/js/ToastUtils.js b/meshchatx/src/frontend/js/ToastUtils.js deleted file mode 100644 index 0393ca2c0..000000000 --- a/meshchatx/src/frontend/js/ToastUtils.js +++ /dev/null @@ -1,46 +0,0 @@ -import GlobalEmitter from "./GlobalEmitter"; - -class ToastUtils { - static show(message, type = "info", duration = 5000, key = null) { - GlobalEmitter.emit("toast", { message, type, duration, key }); - } - - static success(message, duration = 5000, key = null) { - this.show(message, "success", duration, key); - } - - static error(message, duration = 5000, key = null) { - this.show(message, "error", duration, key); - } - - static warning(message, duration = 5000, key = null) { - this.show(message, "warning", duration, key); - } - - static info(message, duration = 5000, key = null) { - this.show(message, "info", duration, key); - } - - static loading(message, duration = 0, key = null) { - this.show(message, "loading", duration, key); - } - - static helptips({ title, details, type = "warning", duration = 11000, key = null }) { - GlobalEmitter.emit("toast", { - message: title, - details: Array.isArray(details) ? details : [], - type, - duration, - key, - }); - } - - static dismiss(key) { - if (key == null) { - return; - } - GlobalEmitter.emit("toast-dismiss", { key }); - } -} - -export default ToastUtils; diff --git a/meshchatx/src/frontend/js/ToastUtils.ts b/meshchatx/src/frontend/js/ToastUtils.ts new file mode 100644 index 000000000..5911a124e --- /dev/null +++ b/meshchatx/src/frontend/js/ToastUtils.ts @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: 0BSD + +import GlobalEmitter from "./GlobalEmitter.js"; + +class ToastUtils { + static show(message: unknown, type = "info", duration = 5000, key: string | null = null): void { + GlobalEmitter.emit("toast", { message, type, duration, key }); + } + + static success(message: unknown, duration = 5000, key: string | null = null): void { + this.show(message, "success", duration, key); + } + + static error(message: unknown, duration = 5000, key: string | null = null): void { + this.show(message, "error", duration, key); + } + + static warning(message: unknown, duration = 5000, key: string | null = null): void { + this.show(message, "warning", duration, key); + } + + static info(message: unknown, duration = 5000, key: string | null = null): void { + this.show(message, "info", duration, key); + } + + static loading(message: unknown, duration = 0, key: string | null = null): void { + this.show(message, "loading", duration, key); + } + + static helptips({ + title, + details, + type = "warning", + duration = 11000, + key = null, + }: { + title: unknown; + details?: unknown; + type?: string; + duration?: number; + key?: string | null; + }): void { + GlobalEmitter.emit("toast", { + message: title, + details: Array.isArray(details) ? details : [], + type, + duration, + key, + }); + } + + static dismiss(key: string | null | undefined): void { + if (key == null) { + return; + } + GlobalEmitter.emit("toast-dismiss", { key }); + } +} + +export default ToastUtils; diff --git a/meshchatx/src/frontend/js/ToneGenerator.js b/meshchatx/src/frontend/js/ToneGenerator.ts similarity index 97% rename from meshchatx/src/frontend/js/ToneGenerator.js rename to meshchatx/src/frontend/js/ToneGenerator.ts index 18b588edf..dc3a91c12 100644 --- a/meshchatx/src/frontend/js/ToneGenerator.js +++ b/meshchatx/src/frontend/js/ToneGenerator.ts @@ -1,4 +1,12 @@ export default class ToneGenerator { + declare audioCtx: any; + declare currentTone: any; + declare gainNode: any; + declare masterCompressor: any; + declare masterFilter: any; + declare oscillator: any; + declare timeoutId: any; + declare volume: number; constructor() { this.audioCtx = null; this.oscillator = null; diff --git a/meshchatx/src/frontend/js/Utils.js b/meshchatx/src/frontend/js/Utils.ts similarity index 99% rename from meshchatx/src/frontend/js/Utils.js rename to meshchatx/src/frontend/js/Utils.ts index 4b64e26f0..1c6007035 100644 --- a/meshchatx/src/frontend/js/Utils.js +++ b/meshchatx/src/frontend/js/Utils.ts @@ -312,7 +312,7 @@ class Utils { static escapeHtml(text) { if (text == null) return ""; text = String(text); - const map = { + const map: any = { "&": "&", "<": "<", ">": ">", diff --git a/meshchatx/src/frontend/js/VisualiserWasmLoader.js b/meshchatx/src/frontend/js/VisualiserWasmLoader.ts similarity index 99% rename from meshchatx/src/frontend/js/VisualiserWasmLoader.js rename to meshchatx/src/frontend/js/VisualiserWasmLoader.ts index e765ecb1a..8cbc8e043 100644 --- a/meshchatx/src/frontend/js/VisualiserWasmLoader.js +++ b/meshchatx/src/frontend/js/VisualiserWasmLoader.ts @@ -75,7 +75,7 @@ async function injectScript(src, expectedHash) { await verifySri(buf, expectedHash, "wasm_exec.js"); const blob = new Blob([buf], { type: "application/javascript" }); const blobUrl = URL.createObjectURL(blob); - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { const s = document.createElement("script"); s.id = id; s.async = true; diff --git a/meshchatx/src/frontend/js/WebSocketConnection.js b/meshchatx/src/frontend/js/WebSocketConnection.ts similarity index 95% rename from meshchatx/src/frontend/js/WebSocketConnection.js rename to meshchatx/src/frontend/js/WebSocketConnection.ts index de7daf6f7..166740768 100644 --- a/meshchatx/src/frontend/js/WebSocketConnection.js +++ b/meshchatx/src/frontend/js/WebSocketConnection.ts @@ -13,6 +13,22 @@ const OUTBOUND_QUEUE_MAX = 32; const OUTBOUND_QUEUE_TTL_MS = 30000; class WebSocketConnection { + declare _hadSuccessfulOpen: boolean; + declare _hasEventListeners: boolean; + declare _heartbeatInterval: any; + declare _isForcedReconnect: boolean; + declare _lastReceivedTime: any; + declare _liveSendBridge: any; + declare _outboundQueue: any[]; + declare _pendingReconnectUi: boolean; + declare _pongTimeout: any; + declare _reconnectAttempt: number; + declare _reconnectTimeout: any; + declare _sessionReady: boolean; + declare destroyed: boolean; + declare emitter: any; + declare initialized: boolean; + declare ws: any; constructor() { this.emitter = createEmitter(); this.ws = null; @@ -79,7 +95,7 @@ class WebSocketConnection { this.emitter.off(event, handler); } - emit(type, event) { + emit(type, event?) { this.emitter.emit(type, event); } diff --git a/meshchatx/src/frontend/js/activeConversationStore.js b/meshchatx/src/frontend/js/activeConversationStore.ts similarity index 91% rename from meshchatx/src/frontend/js/activeConversationStore.js rename to meshchatx/src/frontend/js/activeConversationStore.ts index 42c47ed39..83400fb0a 100644 --- a/meshchatx/src/frontend/js/activeConversationStore.js +++ b/meshchatx/src/frontend/js/activeConversationStore.ts @@ -6,11 +6,9 @@ import { normalizeDestinationHash } from "./notificationPolicy.js"; -/** @type {Set} */ -const openHashes = new Set(); +const openHashes = new Set(); -/** @type {Set<(hashes: string[]) => void>} */ -const listeners = new Set(); +const listeners = new Set<(hashes: string[]) => void>(); function notifyListeners() { const snapshot = listOpenDestinationHashes(); @@ -27,8 +25,8 @@ function notifyListeners() { * Replace the full set of open conversation destination hashes. * @param {Iterable|string[]|null|undefined} hashes */ -export function setOpenDestinationHashes(hashes) { - const next = new Set(); +export function setOpenDestinationHashes(hashes: Iterable | null | undefined) { + const next = new Set(); if (hashes) { for (const h of hashes) { const n = normalizeDestinationHash(h); diff --git a/meshchatx/src/frontend/js/activeSessions.js b/meshchatx/src/frontend/js/activeSessions.ts similarity index 100% rename from meshchatx/src/frontend/js/activeSessions.js rename to meshchatx/src/frontend/js/activeSessions.ts diff --git a/meshchatx/src/frontend/js/altchaWidget.js b/meshchatx/src/frontend/js/altchaWidget.js deleted file mode 100644 index 94f7ebb46..000000000 --- a/meshchatx/src/frontend/js/altchaWidget.js +++ /dev/null @@ -1,84 +0,0 @@ -// SPDX-License-Identifier: 0BSD - -/** - * Read a solved ALTCHA payload from the widget DOM or in-memory value. - * ALTCHA widget v3 does not expose getPayload. The solved token lives in a - * hidden input named after the widget name attribute (default "altcha"). - * @param {HTMLElement | null | undefined} widget - * @param {string} [inputName="altcha"] - * @returns {string | null} - */ -export function readAltchaPayloadFromWidget(widget, inputName = "altcha") { - if (!widget) { - return null; - } - const name = typeof inputName === "string" && inputName ? inputName : "altcha"; - const roots = [widget]; - if (widget.shadowRoot) { - roots.push(widget.shadowRoot); - } - for (const root of roots) { - if (typeof root.querySelectorAll !== "function") { - continue; - } - const inputs = root.querySelectorAll("input"); - for (const input of inputs) { - if (input.getAttribute("name") === name && typeof input.value === "string" && input.value.trim()) { - return input.value.trim(); - } - } - for (const input of inputs) { - if (input.getAttribute("type") === "hidden" && typeof input.value === "string" && input.value.trim()) { - return input.value.trim(); - } - } - } - return null; -} - -/** - * Payload from an ALTCHA verified or statechange CustomEvent. - * @param {Event | { detail?: { payload?: unknown, state?: unknown } } | null | undefined} event - * @returns {string | null} - */ -export function altchaPayloadFromEvent(event) { - const detail = event && typeof event === "object" ? event.detail : null; - if (!detail || typeof detail !== "object") { - return null; - } - const payload = detail.payload; - if (typeof payload === "string" && payload.trim()) { - return payload.trim(); - } - return null; -} - -/** - * Ensure a payload exists, running widget.verify() when needed. - * @param {HTMLElement | null | undefined} widget - * @param {string | null | undefined} existingPayload - * @param {{ inputName?: string }} [options] - * @returns {Promise} - */ -export async function ensureAltchaPayload(widget, existingPayload, options = {}) { - if (typeof existingPayload === "string" && existingPayload.trim()) { - return existingPayload.trim(); - } - const inputName = options.inputName || "altcha"; - const fromDom = readAltchaPayloadFromWidget(widget, inputName); - if (fromDom) { - return fromDom; - } - if (!widget || typeof widget.verify !== "function") { - return null; - } - try { - const result = await widget.verify(); - if (result && typeof result.payload === "string" && result.payload.trim()) { - return result.payload.trim(); - } - } catch { - return readAltchaPayloadFromWidget(widget, inputName); - } - return readAltchaPayloadFromWidget(widget, inputName); -} diff --git a/meshchatx/src/frontend/js/announceIntervalSliderMap.js b/meshchatx/src/frontend/js/announceIntervalSliderMap.ts similarity index 100% rename from meshchatx/src/frontend/js/announceIntervalSliderMap.js rename to meshchatx/src/frontend/js/announceIntervalSliderMap.ts diff --git a/meshchatx/src/frontend/js/apiClient.js b/meshchatx/src/frontend/js/apiClient.ts similarity index 95% rename from meshchatx/src/frontend/js/apiClient.js rename to meshchatx/src/frontend/js/apiClient.ts index 26fc63bfd..8385ef858 100644 --- a/meshchatx/src/frontend/js/apiClient.js +++ b/meshchatx/src/frontend/js/apiClient.ts @@ -138,10 +138,10 @@ function tryDemoConfigPatch(data) { /** * @param {{ onAuthError?: (err: Error & { response?: { status: number, data: unknown } }) => void }} options */ -export function createApiClient(options = {}) { +export function createApiClient(options: any = {}) { const { onAuthError } = options; - async function request(method, path, config = {}, csrfRetry = false) { + async function request(method, path, config: any = {}, csrfRetry = false) { const { params, data, signal, headers = {}, responseType } = config; const pathname = apiPathname(path); @@ -160,7 +160,7 @@ export function createApiClient(options = {}) { hdrs.set("X-CSRF-Token", csrf); } } - const init = { method, signal, headers: hdrs }; + const init: any = { method, signal, headers: hdrs }; if (data !== undefined && method !== "GET" && method !== "HEAD") { if (data instanceof FormData) { @@ -227,7 +227,7 @@ export function createApiClient(options = {}) { return { data: dataOut, status: response.status, headers: response.headers }; } - const api = { + const api: any = { get(path, config) { const cfg = config || {}; return withRetryableHttp(() => request("GET", path, cfg), { @@ -240,16 +240,16 @@ export function createApiClient(options = {}) { signal: cfg.signal, }); }, - post(path, data, config = {}) { + post(path, data, config: any = {}) { return request("POST", path, { ...config, data }); }, - patch(path, data, config = {}) { + patch(path, data, config: any = {}) { return request("PATCH", path, { ...config, data }); }, - put(path, data, config = {}) { + put(path, data, config: any = {}) { return request("PUT", path, { ...config, data }); }, - delete(path, config = {}) { + delete(path, config: any = {}) { return request("DELETE", path, config || {}); }, isCancel, diff --git a/meshchatx/src/frontend/js/appSidebarNavEditHold.js b/meshchatx/src/frontend/js/appSidebarNavEditHold.ts similarity index 99% rename from meshchatx/src/frontend/js/appSidebarNavEditHold.js rename to meshchatx/src/frontend/js/appSidebarNavEditHold.ts index 2e0645c69..7262988be 100644 --- a/meshchatx/src/frontend/js/appSidebarNavEditHold.js +++ b/meshchatx/src/frontend/js/appSidebarNavEditHold.ts @@ -4,7 +4,7 @@ import { NAV_EDIT_HOLD_MS, NAV_EDIT_HOLD_MOVE_PX } from "./appSidebarNavLayout.j export const NAV_EDIT_CLICK_GUARD_MS = 400; -export const navEditHoldMixin = { +export const navEditHoldMixin: any = { data() { return { navHoldTimer: null, diff --git a/meshchatx/src/frontend/js/appSidebarNavLayout.js b/meshchatx/src/frontend/js/appSidebarNavLayout.ts similarity index 99% rename from meshchatx/src/frontend/js/appSidebarNavLayout.js rename to meshchatx/src/frontend/js/appSidebarNavLayout.ts index 44eee2dbf..06bfdfa1e 100644 --- a/meshchatx/src/frontend/js/appSidebarNavLayout.js +++ b/meshchatx/src/frontend/js/appSidebarNavLayout.ts @@ -207,7 +207,7 @@ export function orderItemsByLayout(items, layout) { * @param {{ includeEmptyGroups?: boolean }} [options] * @returns {{ primaryGroups: Array<{ id: string, items: typeof items }>, moreItems: typeof items }} */ -export function applyNavLayout(items, layout, options = {}) { +export function applyNavLayout(items, layout, options: any = {}) { const includeEmptyGroups = options.includeEmptyGroups === true; const orderedItems = orderItemsByLayout(items, layout); const primaryByGroup = Object.create(null); @@ -357,7 +357,7 @@ function layoutOrDefault(layout, items) { * @param {{ preservePlacement?: boolean }} [options] * @returns {NavLayout | null} */ -export function moveNavItem(layout, itemId, target, items, options = {}) { +export function moveNavItem(layout, itemId, target, items, options: any = {}) { const next = layoutOrDefault(layout, items); const movingId = cleanId(itemId); if (!movingId || !target || typeof target !== "object") { @@ -462,7 +462,7 @@ export function moveNavItem(layout, itemId, target, items, options = {}) { * @param {{ preservePlacement?: boolean }} [options] * @returns {NavLayout | null} */ -export function moveNavItemByOffset(layout, itemId, delta, items, options = {}) { +export function moveNavItemByOffset(layout, itemId, delta, items, options: any = {}) { const movingId = cleanId(itemId); const step = delta > 0 ? 1 : delta < 0 ? -1 : 0; if (!movingId || step === 0) { diff --git a/meshchatx/src/frontend/js/appState.js b/meshchatx/src/frontend/js/appState.ts similarity index 96% rename from meshchatx/src/frontend/js/appState.js rename to meshchatx/src/frontend/js/appState.ts index b651aaef7..0d55b8a28 100644 --- a/meshchatx/src/frontend/js/appState.js +++ b/meshchatx/src/frontend/js/appState.ts @@ -5,8 +5,7 @@ * Vue keeps using the reactive object. Svelte and plain JS use this API. */ -/** @type {Set<(snapshot: object) => void>} */ -const listeners = new Set(); +const listeners = new Set<(snapshot: object) => void>(); /** * @returns {object} diff --git a/meshchatx/src/frontend/js/assets.d.ts b/meshchatx/src/frontend/js/assets.d.ts new file mode 100644 index 000000000..4cf3a41fa --- /dev/null +++ b/meshchatx/src/frontend/js/assets.d.ts @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: 0BSD + +declare module "*.css"; + +declare module "*?raw" { + const content: string; + export default content; +} diff --git a/meshchatx/src/frontend/js/authSessionSync.js b/meshchatx/src/frontend/js/authSessionSync.ts similarity index 97% rename from meshchatx/src/frontend/js/authSessionSync.js rename to meshchatx/src/frontend/js/authSessionSync.ts index ad153e7b0..09dde1cb2 100644 --- a/meshchatx/src/frontend/js/authSessionSync.js +++ b/meshchatx/src/frontend/js/authSessionSync.ts @@ -25,7 +25,7 @@ export function applyAuthStatusToGlobalState(status) { * @param {{ timeoutMs?: number }} [options] * @returns {Promise>} */ -export async function fetchAuthStatus(api, options = {}) { +export async function fetchAuthStatus(api, options: any = {}) { const timeoutMs = options.timeoutMs ?? AUTH_STATUS_TIMEOUT_MS; const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); diff --git a/meshchatx/src/frontend/js/bootSplashError.js b/meshchatx/src/frontend/js/bootSplashError.ts similarity index 91% rename from meshchatx/src/frontend/js/bootSplashError.js rename to meshchatx/src/frontend/js/bootSplashError.ts index df52dfa1c..ce0f68277 100644 --- a/meshchatx/src/frontend/js/bootSplashError.js +++ b/meshchatx/src/frontend/js/bootSplashError.ts @@ -25,13 +25,13 @@ export function showBootSplashFatalError(payload) { line.textContent = record.message; } - const title = splash.querySelector("[data-boot-title]"); + const title = splash.querySelector("[data-boot-title]"); if (title && record.title) { title.textContent = record.title; title.hidden = false; } - const details = splash.querySelector("[data-boot-details]"); + const details = splash.querySelector("[data-boot-details]"); if (details) { const detailText = [record.details, record.stack].filter(Boolean).join("\n\n"); if (detailText) { @@ -40,7 +40,7 @@ export function showBootSplashFatalError(payload) { } } - const actions = splash.querySelector("[data-boot-actions]"); + const actions = splash.querySelector("[data-boot-actions]"); if (actions) { actions.hidden = false; } diff --git a/meshchatx/src/frontend/js/browser-globals.d.ts b/meshchatx/src/frontend/js/browser-globals.d.ts new file mode 100644 index 000000000..4d12f5f11 --- /dev/null +++ b/meshchatx/src/frontend/js/browser-globals.d.ts @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: 0BSD + +interface Window { + api: { + get: (url: string, config?: Record) => Promise<{ data?: any }>; + post: (url: string, body?: unknown, config?: Record) => Promise<{ data?: any }>; + patch: (url: string, body?: unknown, config?: Record) => Promise<{ data?: any }>; + put?: (url: string, body?: unknown, config?: Record) => Promise<{ data?: any }>; + delete: (url: string, config?: Record) => Promise<{ data?: any }>; + isCancel?: (e: unknown) => boolean; + }; + electron?: any; + MeshChatXAndroid?: any; + webkitAudioContext?: typeof AudioContext; + DOMPurify?: any; + BarcodeDetector?: any; + zip?: any; + CryptoJS?: any; + enableHeapMonitor?: (intervalMs?: number) => void; + disableHeapMonitor?: () => void; + heapSnapshot?: () => unknown; + __meshchatxMicronCopyFix?: boolean; +} + +interface ImportMeta { + env: Record; + glob: (pattern: string, options?: Record) => Record; +} + +interface Performance { + memory?: { + usedJSHeapSize: number; + totalJSHeapSize: number; + jsHeapSizeLimit: number; + }; +} + +interface Navigator { + brave?: unknown; + getBattery?: () => Promise; +} + +interface MediaTrackCapabilities { + focusMode?: string[]; +} + +interface MediaTrackConstraintSet { + focusMode?: string; +} + +declare const __GEO_WASM_SRI_WASM__: string; +declare const __GEO_WASM_SRI_EXEC__: string; +declare const __MICRON_WASM_SRI_WASM__: string; +declare const __MICRON_WASM_SRI_EXEC__: string; +declare const __VISUALISER_WASM_SRI_WASM__: string; +declare const __VISUALISER_WASM_SRI_EXEC__: string; + +declare const Codec2MicrophoneRecorder: { + new (): { + codec2Mode: string; + start: () => Promise; + stop: () => Promise; + }; +}; + +declare const Codec2Lib: { + runDecode: (mode: string, bytes: Uint8Array) => Promise; + rawToWav: (bytes: ArrayBuffer) => Promise; +}; + +declare module "*.css"; + +declare module "*?raw" { + const content: string; + export default content; +} diff --git a/meshchatx/src/frontend/js/browserLayoutStore.js b/meshchatx/src/frontend/js/browserLayoutStore.ts similarity index 99% rename from meshchatx/src/frontend/js/browserLayoutStore.js rename to meshchatx/src/frontend/js/browserLayoutStore.ts index d9c44818e..7e8391f77 100644 --- a/meshchatx/src/frontend/js/browserLayoutStore.js +++ b/meshchatx/src/frontend/js/browserLayoutStore.ts @@ -3,7 +3,7 @@ const MAP_TABS_KEY = "meshchatx.map.tabs"; const MESSAGE_PANES_KEY = "meshchatx.messages.panes"; const RNSH_LAYOUT_KEY = "meshchatx.rnsh.layout"; const RNX_LAYOUT_KEY = "meshchatx.rnx.layout"; -const FEATURE_SIDEBAR_COLLAPSED_KEYS = { +const FEATURE_SIDEBAR_COLLAPSED_KEYS: any = { app: "meshchatx.sidebar.app", messages: "meshchatx.sidebar.messages", nomadnetwork: "meshchatx.sidebar.nomadnetwork", diff --git a/meshchatx/src/frontend/js/clampFloatingToViewport.js b/meshchatx/src/frontend/js/clampFloatingToViewport.ts similarity index 96% rename from meshchatx/src/frontend/js/clampFloatingToViewport.js rename to meshchatx/src/frontend/js/clampFloatingToViewport.ts index 44b936656..90941c67e 100644 --- a/meshchatx/src/frontend/js/clampFloatingToViewport.js +++ b/meshchatx/src/frontend/js/clampFloatingToViewport.ts @@ -10,7 +10,7 @@ * @param {{ margin?: number }} [options] * @returns {{ left: number, top: number, maxHeight: number | null }} */ -export function clampFloatingToViewport(preferredLeft, preferredTop, width, height, options = {}) { +export function clampFloatingToViewport(preferredLeft, preferredTop, width, height, options: any = {}) { const margin = options.margin ?? 8; const vw = window.innerWidth; const vh = window.innerHeight; diff --git a/meshchatx/src/frontend/js/clipboardUtils.js b/meshchatx/src/frontend/js/clipboardUtils.ts similarity index 96% rename from meshchatx/src/frontend/js/clipboardUtils.js rename to meshchatx/src/frontend/js/clipboardUtils.ts index c9c5d76f3..bcb5f88ea 100644 --- a/meshchatx/src/frontend/js/clipboardUtils.js +++ b/meshchatx/src/frontend/js/clipboardUtils.ts @@ -129,7 +129,7 @@ async function convertImageBlobToPng(blob) { } ctx.drawImage(bitmap, 0, 0); bitmap.close?.(); - return await new Promise((resolve) => { + return await new Promise((resolve) => { canvas.toBlob((out) => resolve(out), "image/png"); }); } catch { @@ -141,7 +141,7 @@ async function convertImageBlobToPng(blob) { } const objectUrl = URL.createObjectURL(blob); try { - const img = await new Promise((resolve, reject) => { + const img = await new Promise((resolve, reject) => { const el = new Image(); el.onload = () => resolve(el); el.onerror = () => reject(new Error("image_load_failed")); @@ -155,7 +155,7 @@ async function convertImageBlobToPng(blob) { return null; } ctx.drawImage(img, 0, 0); - return await new Promise((resolve) => { + return await new Promise((resolve) => { canvas.toBlob((out) => resolve(out), "image/png"); }); } catch { diff --git a/meshchatx/src/frontend/js/codec2DecodeLimits.js b/meshchatx/src/frontend/js/codec2DecodeLimits.ts similarity index 100% rename from meshchatx/src/frontend/js/codec2DecodeLimits.js rename to meshchatx/src/frontend/js/codec2DecodeLimits.ts diff --git a/meshchatx/src/frontend/js/contextMenuUtils.js b/meshchatx/src/frontend/js/contextMenuUtils.ts similarity index 98% rename from meshchatx/src/frontend/js/contextMenuUtils.js rename to meshchatx/src/frontend/js/contextMenuUtils.ts index 622e896a3..c0c458aba 100644 --- a/meshchatx/src/frontend/js/contextMenuUtils.js +++ b/meshchatx/src/frontend/js/contextMenuUtils.ts @@ -54,7 +54,7 @@ export function preferNativeTextSelectionMenu(event) { * @param {{ stopPropagation?: boolean }} [options] * @returns {boolean} */ -export function openAppContextMenuUnlessTextSelection(event, openFn, options = {}) { +export function openAppContextMenuUnlessTextSelection(event, openFn, options: any = {}) { if (preferNativeTextSelectionMenu(event)) { return false; } diff --git a/meshchatx/src/frontend/js/csrfToken.js b/meshchatx/src/frontend/js/csrfToken.ts similarity index 100% rename from meshchatx/src/frontend/js/csrfToken.js rename to meshchatx/src/frontend/js/csrfToken.ts diff --git a/meshchatx/src/frontend/js/databaseHealthWarning.js b/meshchatx/src/frontend/js/databaseHealthWarning.ts similarity index 100% rename from meshchatx/src/frontend/js/databaseHealthWarning.js rename to meshchatx/src/frontend/js/databaseHealthWarning.ts diff --git a/meshchatx/src/frontend/js/deliveryHelptips.js b/meshchatx/src/frontend/js/deliveryHelptips.ts similarity index 99% rename from meshchatx/src/frontend/js/deliveryHelptips.js rename to meshchatx/src/frontend/js/deliveryHelptips.ts index f74ab47ff..edcee7fed 100644 --- a/meshchatx/src/frontend/js/deliveryHelptips.js +++ b/meshchatx/src/frontend/js/deliveryHelptips.ts @@ -1,6 +1,6 @@ // SPDX-License-Identifier: 0BSD -export const DELIVERY_HELPTIP_THRESHOLDS = { +export const DELIVERY_HELPTIP_THRESHOLDS: any = { selfAnnounceStaleSeconds: 86400, peerAnnounceStaleSeconds: 86400, }; diff --git a/meshchatx/src/frontend/js/demoMode.js b/meshchatx/src/frontend/js/demoMode.ts similarity index 55% rename from meshchatx/src/frontend/js/demoMode.js rename to meshchatx/src/frontend/js/demoMode.ts index bb0a0cee9..a94964553 100644 --- a/meshchatx/src/frontend/js/demoMode.js +++ b/meshchatx/src/frontend/js/demoMode.ts @@ -3,7 +3,3 @@ export function isDemoReadonly(status) { return Boolean(status && status.demo_mode === true); } - -export function isAltchaEnabled(status) { - return Boolean(status && status.altcha_enabled === true); -} diff --git a/meshchatx/src/frontend/js/demoUiPrefs.js b/meshchatx/src/frontend/js/demoUiPrefs.ts similarity index 98% rename from meshchatx/src/frontend/js/demoUiPrefs.js rename to meshchatx/src/frontend/js/demoUiPrefs.ts index cd7c3dfe2..3d727fed3 100644 --- a/meshchatx/src/frontend/js/demoUiPrefs.js +++ b/meshchatx/src/frontend/js/demoUiPrefs.ts @@ -46,7 +46,7 @@ export function sanitizeDemoUiPrefs(value) { return {}; } /** @type {Record} */ - const out = {}; + const out: any = {}; for (const key of DEMO_UI_PREF_KEYS) { if (Object.prototype.hasOwnProperty.call(value, key)) { out[key] = value[key]; @@ -122,7 +122,7 @@ export function saveDemoUiPrefs(prefs, storage = typeof localStorage !== "undefi * @returns {Record} */ export function mergeAndSaveDemoUiPrefs(partial, storage = typeof localStorage !== "undefined" ? localStorage : null) { - const merged = { + const merged: any = { ...loadDemoUiPrefs(storage), ...pickDemoUiPrefs(partial), }; diff --git a/meshchatx/src/frontend/js/deviceBattery.js b/meshchatx/src/frontend/js/deviceBattery.ts similarity index 98% rename from meshchatx/src/frontend/js/deviceBattery.js rename to meshchatx/src/frontend/js/deviceBattery.ts index 497c95942..cccf22b7c 100644 --- a/meshchatx/src/frontend/js/deviceBattery.js +++ b/meshchatx/src/frontend/js/deviceBattery.ts @@ -24,7 +24,7 @@ import ElectronUtils from "./ElectronUtils.js"; * @param {{ unitFraction?: boolean }} [options] * @returns {number|null} */ -export function normalizeBatteryPercent(value, options = {}) { +export function normalizeBatteryPercent(value, options: any = {}) { if (value == null || value === "") { return null; } @@ -169,7 +169,7 @@ async function probeWebBattery() { * @param {{ allowWeb?: boolean }} [options] * @returns {Promise} */ -export async function getDeviceBatteryStatus(options = {}) { +export async function getDeviceBatteryStatus(options: any = {}) { const allowWeb = Boolean(options.allowWeb); const androidStatus = await probeAndroidBattery(); if (androidStatus) { diff --git a/meshchatx/src/frontend/js/fatalErrorState.js b/meshchatx/src/frontend/js/fatalErrorState.ts similarity index 100% rename from meshchatx/src/frontend/js/fatalErrorState.js rename to meshchatx/src/frontend/js/fatalErrorState.ts diff --git a/meshchatx/src/frontend/js/healthMemoryWarning.js b/meshchatx/src/frontend/js/healthMemoryWarning.ts similarity index 98% rename from meshchatx/src/frontend/js/healthMemoryWarning.js rename to meshchatx/src/frontend/js/healthMemoryWarning.ts index b8dc0193a..639d08884 100644 --- a/meshchatx/src/frontend/js/healthMemoryWarning.js +++ b/meshchatx/src/frontend/js/healthMemoryWarning.ts @@ -56,7 +56,7 @@ export function isMemoryRecoveredPayload(payload) { * @param {{ fromHealthWs?: boolean, fromClientHeap?: boolean }} options * @returns {boolean} */ -export function shouldShowMemoryWarningToast(options = {}) { +export function shouldShowMemoryWarningToast(options: any = {}) { if (dismissedThisEpisode || toastVisible) { return false; } @@ -120,7 +120,7 @@ export function evaluateClientHeapSample(memoryInfo) { * @param {{ fromHealthWs?: boolean, fromClientHeap?: boolean }} options * @returns {boolean} true when a toast was shown */ -export function showMemoryWarningToastIfNeeded(toastUtils, options = {}) { +export function showMemoryWarningToastIfNeeded(toastUtils, options: any = {}) { if (!shouldShowMemoryWarningToast(options)) { return false; } diff --git a/meshchatx/src/frontend/js/helptipPolicy.js b/meshchatx/src/frontend/js/helptipPolicy.ts similarity index 100% rename from meshchatx/src/frontend/js/helptipPolicy.js rename to meshchatx/src/frontend/js/helptipPolicy.ts diff --git a/meshchatx/src/frontend/js/httpRetry.js b/meshchatx/src/frontend/js/httpRetry.ts similarity index 95% rename from meshchatx/src/frontend/js/httpRetry.js rename to meshchatx/src/frontend/js/httpRetry.ts index d3827093c..ac65912ff 100644 --- a/meshchatx/src/frontend/js/httpRetry.js +++ b/meshchatx/src/frontend/js/httpRetry.ts @@ -46,7 +46,7 @@ export function isRetryableHttpError(error) { * }} [options] * @returns {Promise} */ -export async function withRetryableHttp(requestFn, options = {}) { +export async function withRetryableHttp(requestFn, options: any = {}) { // Long enough to cover identity switch and modest DB migrate windows. // Pages that mount on early ui_ready should still gate on identity ready. const maxAttempts = options.maxAttempts ?? 12; @@ -54,7 +54,7 @@ export async function withRetryableHttp(requestFn, options = {}) { const sleep = options.sleep ?? ((ms) => - new Promise((resolve) => { + new Promise((resolve) => { setTimeout(resolve, ms); })); diff --git a/meshchatx/src/frontend/js/i18n.js b/meshchatx/src/frontend/js/i18n.ts similarity index 51% rename from meshchatx/src/frontend/js/i18n.js rename to meshchatx/src/frontend/js/i18n.ts index 40045fde0..84f3b1b7a 100644 --- a/meshchatx/src/frontend/js/i18n.js +++ b/meshchatx/src/frontend/js/i18n.ts @@ -5,42 +5,31 @@ * Svelte pages use t() from here. Vue keeps $t. */ -/** @type {((key: string, values?: Record) => string) | null} */ -let translateFn = null; +type TranslateFn = (key: string, values?: Record) => string; -/** @type {Record | null} */ -let fallbackMessages = null; +let translateFn: TranslateFn | null = null; +let fallbackMessages: Record | null = null; -/** - * @param {string} key - * @param {Record} [messages] - * @returns {unknown} - */ -function lookupPath(key, messages) { +function lookupPath(key: string, messages: Record | null): unknown { if (!messages || typeof messages !== "object") { return undefined; } const parts = String(key).split("."); - let cur = messages; + let cur: unknown = messages; for (const part of parts) { if (cur == null || typeof cur !== "object") { return undefined; } - cur = cur[part]; + cur = (cur as Record)[part]; } return cur; } -/** - * @param {string} template - * @param {Record} [values] - * @returns {string} - */ -function interpolate(template, values) { +function interpolate(template: string, values?: Record): string { if (!values || typeof values !== "object") { return template; } - return template.replace(/\{(\w+)\}/g, (_, name) => { + return template.replace(/\{(\w+)\}/g, (_, name: string) => { if (Object.prototype.hasOwnProperty.call(values, name)) { return String(values[name]); } @@ -48,29 +37,15 @@ function interpolate(template, values) { }); } -/** - * Register the app translator (usually vue-i18n global.t). - * @param {((key: string, values?: Record) => string) | null | undefined} fn - */ -export function registerTranslator(fn) { +export function registerTranslator(fn: TranslateFn | null | undefined): void { translateFn = typeof fn === "function" ? fn : null; } -/** - * Optional plain message tree used when no vue-i18n translator is registered (tests / early boot). - * @param {Record | null | undefined} messages - */ -export function registerFallbackMessages(messages) { +export function registerFallbackMessages(messages: Record | null | undefined): void { fallbackMessages = messages && typeof messages === "object" ? messages : null; } -/** - * Translate a key. Returns the key when missing. - * @param {string} key - * @param {Record} [values] - * @returns {string} - */ -export function t(key, values) { +export function t(key: string, values?: Record): string { if (translateFn) { try { const out = translateFn(key, values); diff --git a/meshchatx/src/frontend/js/idbOpen.js b/meshchatx/src/frontend/js/idbOpen.ts similarity index 93% rename from meshchatx/src/frontend/js/idbOpen.js rename to meshchatx/src/frontend/js/idbOpen.ts index 3fdd5682b..b672a19d2 100644 --- a/meshchatx/src/frontend/js/idbOpen.js +++ b/meshchatx/src/frontend/js/idbOpen.ts @@ -33,8 +33,8 @@ export function getIndexedDbFactory() { * @param {{ onUpgrade?: (db: IDBDatabase, event: IDBVersionChangeEvent) => void }} [opts] * @returns {Promise} */ -export function openIndexedDb(name, version, opts = {}) { - return new Promise((resolve, reject) => { +export function openIndexedDb(name, version, opts: any = {}) { + return new Promise((resolve, reject) => { const idb = getIndexedDbFactory(); if (!idb) { reject(new Error("IndexedDB unavailable")); diff --git a/meshchatx/src/frontend/js/identityHttpReady.js b/meshchatx/src/frontend/js/identityHttpReady.ts similarity index 100% rename from meshchatx/src/frontend/js/identityHttpReady.js rename to meshchatx/src/frontend/js/identityHttpReady.ts diff --git a/meshchatx/src/frontend/js/inViewObserver.js b/meshchatx/src/frontend/js/inViewObserver.ts similarity index 93% rename from meshchatx/src/frontend/js/inViewObserver.js rename to meshchatx/src/frontend/js/inViewObserver.ts index c42237b3c..d3c972e0b 100644 --- a/meshchatx/src/frontend/js/inViewObserver.js +++ b/meshchatx/src/frontend/js/inViewObserver.ts @@ -9,7 +9,7 @@ export function isAnimatedRasterType(imageType) { * @param {IntersectionObserverInit} [options] * @returns {() => void} disconnect */ -export function attachInView(el, callback, options = {}) { +export function attachInView(el, callback, options: any = {}) { if (!el) { return () => {}; } diff --git a/meshchatx/src/frontend/js/ingestUriResultNavigation.js b/meshchatx/src/frontend/js/ingestUriResultNavigation.ts similarity index 98% rename from meshchatx/src/frontend/js/ingestUriResultNavigation.js rename to meshchatx/src/frontend/js/ingestUriResultNavigation.ts index b6b2e69f7..2ac6a5fce 100644 --- a/meshchatx/src/frontend/js/ingestUriResultNavigation.js +++ b/meshchatx/src/frontend/js/ingestUriResultNavigation.ts @@ -9,7 +9,7 @@ export async function handleLxmIngestUriResult(json, { router, toast = null }) { if (json.ingest_type === "map_view" && json.map_query) { const mq = json.map_query; - const query = { + const query: any = { lat: String(mq.lat), lon: String(mq.lon), zoom: String(mq.zoom), diff --git a/meshchatx/src/frontend/js/interfaceDiscoveryUtils.js b/meshchatx/src/frontend/js/interfaceDiscoveryUtils.ts similarity index 97% rename from meshchatx/src/frontend/js/interfaceDiscoveryUtils.js rename to meshchatx/src/frontend/js/interfaceDiscoveryUtils.ts index d31e6572b..f179a413e 100644 --- a/meshchatx/src/frontend/js/interfaceDiscoveryUtils.js +++ b/meshchatx/src/frontend/js/interfaceDiscoveryUtils.ts @@ -21,7 +21,7 @@ export function parseRNodeFrequencyHz(value) { } let s = String(value).trim().toLowerCase().replace(/_/g, ""); let mult = 1; - const pairs = [ + const pairs: Array<[string, number]> = [ ["ghz", 1e9], ["mhz", 1e6], ["khz", 1e3], diff --git a/meshchatx/src/frontend/js/keepAlivePoll.js b/meshchatx/src/frontend/js/keepAlivePoll.ts similarity index 100% rename from meshchatx/src/frontend/js/keepAlivePoll.js rename to meshchatx/src/frontend/js/keepAlivePoll.ts diff --git a/meshchatx/src/frontend/js/lanBindWarning.js b/meshchatx/src/frontend/js/lanBindWarning.ts similarity index 98% rename from meshchatx/src/frontend/js/lanBindWarning.js rename to meshchatx/src/frontend/js/lanBindWarning.ts index cbbb393d3..c02031905 100644 --- a/meshchatx/src/frontend/js/lanBindWarning.js +++ b/meshchatx/src/frontend/js/lanBindWarning.ts @@ -30,7 +30,7 @@ export function shouldShowLanBindNoAuthBanner({ isLoopbackBind = true, routeName = "", dismissed = false, -} = {}) { +}: any = {}) { if (dismissed || isLanBindNoAuthBannerDismissed()) { return false; } diff --git a/meshchatx/src/frontend/js/liveTransport.js b/meshchatx/src/frontend/js/liveTransport.ts similarity index 93% rename from meshchatx/src/frontend/js/liveTransport.js rename to meshchatx/src/frontend/js/liveTransport.ts index e2e12542c..6df2741c9 100644 --- a/meshchatx/src/frontend/js/liveTransport.js +++ b/meshchatx/src/frontend/js/liveTransport.ts @@ -37,6 +37,13 @@ function b64ToBytes(b64) { * Experimental WebTransport live session (newline JSON on one bidi stream). */ class WebTransportLiveSession { + declare _buffer: string; + declare _reader: any; + declare _sessionReady: boolean; + declare _transport: any; + declare _writer: any; + declare destroyed: boolean; + declare emitter: any; constructor() { this.emitter = createEmitter(); this._transport = null; @@ -55,7 +62,7 @@ class WebTransportLiveSession { this.emitter.off(event, handler); } - emit(type, event) { + emit(type, event?) { this.emitter.emit(type, event); } @@ -74,7 +81,7 @@ class WebTransportLiveSession { throw new Error("missing_url"); } /** @type {Record} */ - const wtOpts = {}; + const wtOpts: any = {}; if (opts.certSha256B64) { const value = b64ToBytes(opts.certSha256B64); if (value) { @@ -84,7 +91,7 @@ class WebTransportLiveSession { const transport = new WebTransport(url, wtOpts); const readyRace = Promise.race([ transport.ready, - new Promise((_, reject) => { + new Promise((_, reject) => { setTimeout(() => reject(new Error("webtransport_timeout")), WT_CONNECT_BUDGET_MS); }), ]); @@ -171,6 +178,15 @@ class WebTransportLiveSession { * Facade: try experimental WebTransport then fall back to WebSocketConnection. */ class LiveTransport { + declare _active: any; + declare _boundWs: boolean; + declare _fallbackNotified: boolean; + declare _forwardCleanups: any[]; + declare _mode: string; + declare _serverInfo: any; + declare _usingWt: boolean; + declare _wt: any; + declare emitter: any; constructor() { this.emitter = createEmitter(); this._active = WebSocketConnection; @@ -191,14 +207,14 @@ class LiveTransport { this.emitter.off(event, handler); } - emit(type, event) { + emit(type, event?) { this.emitter.emit(type, event); } /** * @param {{ mode?: string, webtransport?: object }} opts */ - configure(opts = {}) { + configure(opts: any = {}) { if (opts.mode) { this._mode = opts.mode; } diff --git a/meshchatx/src/frontend/js/localMessageRetention.js b/meshchatx/src/frontend/js/localMessageRetention.ts similarity index 100% rename from meshchatx/src/frontend/js/localMessageRetention.js rename to meshchatx/src/frontend/js/localMessageRetention.ts diff --git a/meshchatx/src/frontend/js/localeLoader.js b/meshchatx/src/frontend/js/localeLoader.ts similarity index 98% rename from meshchatx/src/frontend/js/localeLoader.js rename to meshchatx/src/frontend/js/localeLoader.ts index c45f9182a..a2421a6e6 100644 --- a/meshchatx/src/frontend/js/localeLoader.js +++ b/meshchatx/src/frontend/js/localeLoader.ts @@ -94,7 +94,7 @@ export function listLocaleCodes() { }); } -const UI_LOCALE_ALIASES = { +const UI_LOCALE_ALIASES: any = { "zh-cn": "zh", zh_cn: "zh", }; @@ -156,7 +156,7 @@ export async function ensureLocaleMessages(i18nOrComposer, code) { if (!loader) { return false; } - const mod = await loader(); + const mod = (await loader()) as { default?: unknown }; composer.setLocaleMessage(code, mod.default || mod); return true; } diff --git a/meshchatx/src/frontend/js/localeThemeOracles.js b/meshchatx/src/frontend/js/localeThemeOracles.ts similarity index 100% rename from meshchatx/src/frontend/js/localeThemeOracles.js rename to meshchatx/src/frontend/js/localeThemeOracles.ts diff --git a/meshchatx/src/frontend/js/lxmfConversationListSync.js b/meshchatx/src/frontend/js/lxmfConversationListSync.ts similarity index 100% rename from meshchatx/src/frontend/js/lxmfConversationListSync.js rename to meshchatx/src/frontend/js/lxmfConversationListSync.ts diff --git a/meshchatx/src/frontend/js/lxmfConversationPreview.js b/meshchatx/src/frontend/js/lxmfConversationPreview.ts similarity index 100% rename from meshchatx/src/frontend/js/lxmfConversationPreview.js rename to meshchatx/src/frontend/js/lxmfConversationPreview.ts diff --git a/meshchatx/src/frontend/js/lxmfReactions.js b/meshchatx/src/frontend/js/lxmfReactions.ts similarity index 100% rename from meshchatx/src/frontend/js/lxmfReactions.js rename to meshchatx/src/frontend/js/lxmfReactions.ts diff --git a/meshchatx/src/frontend/js/mapExchange/constants.js b/meshchatx/src/frontend/js/mapExchange/constants.ts similarity index 100% rename from meshchatx/src/frontend/js/mapExchange/constants.js rename to meshchatx/src/frontend/js/mapExchange/constants.ts diff --git a/meshchatx/src/frontend/js/mapExchange/descriptionFlatten.js b/meshchatx/src/frontend/js/mapExchange/descriptionFlatten.ts similarity index 100% rename from meshchatx/src/frontend/js/mapExchange/descriptionFlatten.js rename to meshchatx/src/frontend/js/mapExchange/descriptionFlatten.ts diff --git a/meshchatx/src/frontend/js/mapExchange/geoJsonCodec.js b/meshchatx/src/frontend/js/mapExchange/geoJsonCodec.ts similarity index 100% rename from meshchatx/src/frontend/js/mapExchange/geoJsonCodec.js rename to meshchatx/src/frontend/js/mapExchange/geoJsonCodec.ts diff --git a/meshchatx/src/frontend/js/mapExchange/gpxCodec.js b/meshchatx/src/frontend/js/mapExchange/gpxCodec.ts similarity index 100% rename from meshchatx/src/frontend/js/mapExchange/gpxCodec.js rename to meshchatx/src/frontend/js/mapExchange/gpxCodec.ts diff --git a/meshchatx/src/frontend/js/mapExchange/kmlCodec.js b/meshchatx/src/frontend/js/mapExchange/kmlCodec.ts similarity index 98% rename from meshchatx/src/frontend/js/mapExchange/kmlCodec.js rename to meshchatx/src/frontend/js/mapExchange/kmlCodec.ts index dfafa2a50..b9709e0a7 100644 --- a/meshchatx/src/frontend/js/mapExchange/kmlCodec.js +++ b/meshchatx/src/frontend/js/mapExchange/kmlCodec.ts @@ -16,7 +16,7 @@ export function readKmlToFeatures(text, featureProjection) { extractStyles: true, showNetworkLinks: false, showPointNames: false, - }); + } as any); const features = format.readFeatures(sanitized.text, { dataProjection: "EPSG:4326", featureProjection, diff --git a/meshchatx/src/frontend/js/mapExchange/kmlSanitize.js b/meshchatx/src/frontend/js/mapExchange/kmlSanitize.ts similarity index 97% rename from meshchatx/src/frontend/js/mapExchange/kmlSanitize.js rename to meshchatx/src/frontend/js/mapExchange/kmlSanitize.ts index 348509d1c..f9d4d101b 100644 --- a/meshchatx/src/frontend/js/mapExchange/kmlSanitize.js +++ b/meshchatx/src/frontend/js/mapExchange/kmlSanitize.ts @@ -13,7 +13,9 @@ const ALLOWED_DATA_PREFIXES = [ const ALLOWED_KMZ_EXT = /\.(kml|png|jpe?g|gif|webp)$/i; export class KmlSanitizeError extends Error { - constructor(code, message) { + declare code: any; + declare name: string; + constructor(code, message?) { super(message || code); this.name = "KmlSanitizeError"; this.code = code; @@ -135,7 +137,7 @@ function stripDescriptionHtml(text) { * @param {{ zipLocalOk?: boolean }} [opts] * @returns {{ text: string, stripped: string[] }} */ -export function sanitizeKmlText(text, opts = {}) { +export function sanitizeKmlText(text, opts: any = {}) { const zipLocalOk = Boolean(opts.zipLocalOk); if (looksLikeDtd(text)) { throw new KmlSanitizeError("dtd_forbidden"); diff --git a/meshchatx/src/frontend/js/mapExchange/kmzCodec.js b/meshchatx/src/frontend/js/mapExchange/kmzCodec.ts similarity index 100% rename from meshchatx/src/frontend/js/mapExchange/kmzCodec.js rename to meshchatx/src/frontend/js/mapExchange/kmzCodec.ts diff --git a/meshchatx/src/frontend/js/mapExchange/metadataUtils.js b/meshchatx/src/frontend/js/mapExchange/metadataUtils.ts similarity index 100% rename from meshchatx/src/frontend/js/mapExchange/metadataUtils.js rename to meshchatx/src/frontend/js/mapExchange/metadataUtils.ts diff --git a/meshchatx/src/frontend/js/mapExchange/styleFromProperties.js b/meshchatx/src/frontend/js/mapExchange/styleFromProperties.ts similarity index 99% rename from meshchatx/src/frontend/js/mapExchange/styleFromProperties.js rename to meshchatx/src/frontend/js/mapExchange/styleFromProperties.ts index f87e15e4d..e07fbad9b 100644 --- a/meshchatx/src/frontend/js/mapExchange/styleFromProperties.js +++ b/meshchatx/src/frontend/js/mapExchange/styleFromProperties.ts @@ -106,7 +106,7 @@ export function applyCappedMcxIconStyleIfNeeded(feature) { }); } -export function styleFromMcxProperties(feature, resolution) { +export function styleFromMcxProperties(feature, resolution?) { const geom = feature.getGeometry(); if (!geom) { return null; diff --git a/meshchatx/src/frontend/js/mapGeoCoords.js b/meshchatx/src/frontend/js/mapGeoCoords.ts similarity index 96% rename from meshchatx/src/frontend/js/mapGeoCoords.js rename to meshchatx/src/frontend/js/mapGeoCoords.ts index 15c8ad3db..1bccacd00 100644 --- a/meshchatx/src/frontend/js/mapGeoCoords.js +++ b/meshchatx/src/frontend/js/mapGeoCoords.ts @@ -30,7 +30,7 @@ export async function ensureGeoCoordsReady() { * Format a WGS84 point for display/copy. * @returns {{ text: string, format: string, ok: boolean, error?: string, full?: string, compact?: string }} */ -export function formatCoordinate(lon, lat, format, options = {}) { +export function formatCoordinate(lon, lat, format, options: any = {}) { const fmt = normalizeFormat(format); const latN = Number(lat); const lonN = Number(lon); @@ -57,7 +57,7 @@ export function formatCoordinate(lon, lat, format, options = {}) { lon: lonN, }; } - const payload = { + const payload: any = { lat: latN, lon: lonN, format: fmt, @@ -85,7 +85,7 @@ export function formatCoordinate(lon, lat, format, options = {}) { * Parse pasted coordinate text into WGS84. * @returns {{ ok: boolean, lat?: number, lon?: number, kind?: string, error?: string }} */ -export function parseCoordinateQuery(text, options = {}) { +export function parseCoordinateQuery(text, options: any = {}) { const raw = String(text || "").trim(); if (!raw) { return { ok: false, error: "empty" }; diff --git a/meshchatx/src/frontend/js/mapGeodesy.js b/meshchatx/src/frontend/js/mapGeodesy.ts similarity index 100% rename from meshchatx/src/frontend/js/mapGeodesy.js rename to meshchatx/src/frontend/js/mapGeodesy.ts diff --git a/meshchatx/src/frontend/js/mapLinkUtils.js b/meshchatx/src/frontend/js/mapLinkUtils.ts similarity index 100% rename from meshchatx/src/frontend/js/mapLinkUtils.js rename to meshchatx/src/frontend/js/mapLinkUtils.ts diff --git a/meshchatx/src/frontend/js/mapLocalUrl.js b/meshchatx/src/frontend/js/mapLocalUrl.ts similarity index 100% rename from meshchatx/src/frontend/js/mapLocalUrl.js rename to meshchatx/src/frontend/js/mapLocalUrl.ts diff --git a/meshchatx/src/frontend/js/mapStateKeys.js b/meshchatx/src/frontend/js/mapStateKeys.ts similarity index 100% rename from meshchatx/src/frontend/js/mapStateKeys.js rename to meshchatx/src/frontend/js/mapStateKeys.ts diff --git a/meshchatx/src/frontend/js/mapTileNetwork.js b/meshchatx/src/frontend/js/mapTileNetwork.ts similarity index 91% rename from meshchatx/src/frontend/js/mapTileNetwork.js rename to meshchatx/src/frontend/js/mapTileNetwork.ts index c38262cfa..3121e0257 100644 --- a/meshchatx/src/frontend/js/mapTileNetwork.js +++ b/meshchatx/src/frontend/js/mapTileNetwork.ts @@ -1,4 +1,4 @@ -const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); +const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); export const TILE_FETCH_TIMEOUT_MS = 8000; export const TILE_FETCH_RETRIES = 2; @@ -56,7 +56,7 @@ export function expandTileUrl(template, z, x, y) { .replaceAll("{r}", ""); } -export async function fetchWithTimeout(resource, init = {}, timeoutMs = TILE_FETCH_TIMEOUT_MS) { +export async function fetchWithTimeout(resource, init: any = {}, timeoutMs = TILE_FETCH_TIMEOUT_MS) { const controller = new AbortController(); const id = setTimeout(() => controller.abort(), timeoutMs); try { @@ -66,7 +66,7 @@ export async function fetchWithTimeout(resource, init = {}, timeoutMs = TILE_FET } } -export async function fetchTileBlobWithRetry(url, init = {}, options = {}) { +export async function fetchTileBlobWithRetry(url, init: any = {}, options: any = {}) { const timeoutMs = options.timeoutMs ?? TILE_FETCH_TIMEOUT_MS; const retries = options.retries ?? TILE_FETCH_RETRIES; const baseDelay = options.retryBaseDelayMs ?? TILE_FETCH_RETRY_BASE_DELAY_MS; @@ -87,7 +87,7 @@ export async function fetchTileBlobWithRetry(url, init = {}, options = {}) { return { ok: false, error: lastErr }; } -export async function fetchJsonWithRetry(url, init = {}, options = {}) { +export async function fetchJsonWithRetry(url, init: any = {}, options: any = {}) { const timeoutMs = options.timeoutMs ?? NOMINATIM_FETCH_TIMEOUT_MS; const retries = options.retries ?? NOMINATIM_FETCH_RETRIES; const baseDelay = options.retryBaseDelayMs ?? NOMINATIM_FETCH_RETRY_BASE_DELAY_MS; diff --git a/meshchatx/src/frontend/js/mapTileProviders.js b/meshchatx/src/frontend/js/mapTileProviders.ts similarity index 97% rename from meshchatx/src/frontend/js/mapTileProviders.js rename to meshchatx/src/frontend/js/mapTileProviders.ts index 0f89585fa..f2334d57d 100644 --- a/meshchatx/src/frontend/js/mapTileProviders.js +++ b/meshchatx/src/frontend/js/mapTileProviders.ts @@ -4,7 +4,7 @@ export const DEFAULT_TILE_SERVER_URL = "https://tile.openstreetmap.org/{z}/{x}/{ /** Raster basemap providers tried in order when tiles fail to load. */ export const RASTER_TILE_PROVIDER_ORDER = ["osm", "openfreemap"]; -export const TILE_PROVIDER_URLS = { +export const TILE_PROVIDER_URLS: any = { osm: DEFAULT_TILE_SERVER_URL, openfreemap: "https://tiles.openfreemap.org/styles/bright", "carto-dark": "https://basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png", @@ -60,7 +60,7 @@ export function detectRasterTileProviderId(tileServerUrl) { return null; } -export function nextRasterTileProviderId(currentId, attemptedIds = []) { +export function nextRasterTileProviderId(currentId, attemptedIds: any[] = []) { const order = RASTER_TILE_PROVIDER_ORDER; const start = currentId ? order.indexOf(currentId) : -1; for (let i = 1; i <= order.length; i++) { diff --git a/meshchatx/src/frontend/js/mapVectorWebFonts.js b/meshchatx/src/frontend/js/mapVectorWebFonts.ts similarity index 100% rename from meshchatx/src/frontend/js/mapVectorWebFonts.js rename to meshchatx/src/frontend/js/mapVectorWebFonts.ts diff --git a/meshchatx/src/frontend/js/mdiIconNames.js b/meshchatx/src/frontend/js/mdiIconNames.ts similarity index 98% rename from meshchatx/src/frontend/js/mdiIconNames.js rename to meshchatx/src/frontend/js/mdiIconNames.ts index a604a6a88..aaa8b9b6d 100644 --- a/meshchatx/src/frontend/js/mdiIconNames.js +++ b/meshchatx/src/frontend/js/mdiIconNames.ts @@ -4,12 +4,12 @@ import * as mdi from "@mdi/js"; export const DEFAULT_RRC_HUB_ICON = "forum-outline"; -const MDI_KEY_ALIASES = { +const MDI_KEY_ALIASES: any = { mdiRoute: "mdiRoutes", mdiEmailSendOutline: "mdiSendOutline", }; -const MATERIAL_SYMBOL_ALIASES = { +const MATERIAL_SYMBOL_ALIASES: any = { "bug-report": "bug-outline", "smart-toy": "robot-outline", "robot-2": "robot-outline", diff --git a/meshchatx/src/frontend/js/messageDisplayLimits.js b/meshchatx/src/frontend/js/messageDisplayLimits.ts similarity index 54% rename from meshchatx/src/frontend/js/messageDisplayLimits.js rename to meshchatx/src/frontend/js/messageDisplayLimits.ts index 81b21aa0d..01905845c 100644 --- a/meshchatx/src/frontend/js/messageDisplayLimits.js +++ b/meshchatx/src/frontend/js/messageDisplayLimits.ts @@ -1,13 +1,11 @@ +// SPDX-License-Identifier: 0BSD + /** * Inline message body and raw modal use this limit so huge LXMF bodies - * do not lock the UI (markdown/DOM cost). Copy still sends full text. + * do not lock the UI (markdown and DOM cost). Copy still sends full text. */ export const MESSAGE_BODY_MAX_DISPLAY_CHARS = 32000; -/** - * @param {unknown} content - * @returns {boolean} - */ -export function isStringTooLargeForInlineDisplay(content) { +export function isStringTooLargeForInlineDisplay(content: unknown): boolean { return typeof content === "string" && content.length > MESSAGE_BODY_MAX_DISPLAY_CHARS; } diff --git a/meshchatx/src/frontend/js/messageImport.js b/meshchatx/src/frontend/js/messageImport.ts similarity index 100% rename from meshchatx/src/frontend/js/messageImport.js rename to meshchatx/src/frontend/js/messageImport.ts diff --git a/meshchatx/src/frontend/js/messageTimestampGrouping.js b/meshchatx/src/frontend/js/messageTimestampGrouping.ts similarity index 99% rename from meshchatx/src/frontend/js/messageTimestampGrouping.js rename to meshchatx/src/frontend/js/messageTimestampGrouping.ts index 1a9d7f633..34e6cb7fc 100644 --- a/meshchatx/src/frontend/js/messageTimestampGrouping.js +++ b/meshchatx/src/frontend/js/messageTimestampGrouping.ts @@ -83,7 +83,7 @@ export function displayGroupIsOutbound(group) { * @param {{ groupingEnabled?: boolean }} [options] * @returns {unknown[]} */ -export function buildTimestampGroupedOldestFirst(groupsOldestFirst, options = {}) { +export function buildTimestampGroupedOldestFirst(groupsOldestFirst, options: any = {}) { const groupingEnabled = options.groupingEnabled !== false; if (!groupsOldestFirst?.length) { return []; diff --git a/meshchatx/src/frontend/js/micronWasmUpload.js b/meshchatx/src/frontend/js/micronWasmUpload.ts similarity index 100% rename from meshchatx/src/frontend/js/micronWasmUpload.js rename to meshchatx/src/frontend/js/micronWasmUpload.ts diff --git a/meshchatx/src/frontend/js/micronWasmVersion.js b/meshchatx/src/frontend/js/micronWasmVersion.ts similarity index 96% rename from meshchatx/src/frontend/js/micronWasmVersion.js rename to meshchatx/src/frontend/js/micronWasmVersion.ts index 5c93346b9..5ca1db4a2 100644 --- a/meshchatx/src/frontend/js/micronWasmVersion.js +++ b/meshchatx/src/frontend/js/micronWasmVersion.ts @@ -40,7 +40,7 @@ export function bundledMicronWasmReleaseTag() { * @param {{ overrideReleaseTag?: string|null }} [opts] * @returns {string|null} */ -export function resolveMicronWasmReleaseLabel(opts = {}) { +export function resolveMicronWasmReleaseLabel(opts: any = {}) { const overrideLabel = normalizeMicronWasmReleaseTag(opts.overrideReleaseTag); if (overrideLabel) { return overrideLabel; diff --git a/meshchatx/src/frontend/js/navRouteActive.js b/meshchatx/src/frontend/js/navRouteActive.ts similarity index 100% rename from meshchatx/src/frontend/js/navRouteActive.js rename to meshchatx/src/frontend/js/navRouteActive.ts diff --git a/meshchatx/src/frontend/js/networkRecovery.js b/meshchatx/src/frontend/js/networkRecovery.ts similarity index 100% rename from meshchatx/src/frontend/js/networkRecovery.js rename to meshchatx/src/frontend/js/networkRecovery.ts diff --git a/meshchatx/src/frontend/js/networkStartupWait.js b/meshchatx/src/frontend/js/networkStartupWait.ts similarity index 95% rename from meshchatx/src/frontend/js/networkStartupWait.js rename to meshchatx/src/frontend/js/networkStartupWait.ts index 20eb1e69a..dc5f0a611 100644 --- a/meshchatx/src/frontend/js/networkStartupWait.js +++ b/meshchatx/src/frontend/js/networkStartupWait.ts @@ -1,6 +1,6 @@ // SPDX-License-Identifier: 0BSD AND MIT -export const STARTUP_STAGE_LABELS = { +export const STARTUP_STAGE_LABELS: any = { http: "Getting things ready…", starting: "Getting RNS ready…", rns: "Starting RNS…", @@ -81,10 +81,10 @@ export function interpretStartupStatus(data) { * }} [options] * @returns {Promise<"ready" | "ui" | "degraded" | false>} */ -export async function waitForNetworkReady(options = {}) { +export async function waitForNetworkReady(options: any = {}) { const fetchImpl = options.fetchImpl || fetch; const now = options.now || Date.now; - const sleep = options.sleep || ((ms) => new Promise((resolve) => setTimeout(resolve, ms))); + const sleep = options.sleep || ((ms) => new Promise((resolve) => setTimeout(resolve, ms))); const timeoutMs = options.timeoutMs ?? 120000; const onLine = options.onLine || (() => {}); const onErrorState = options.onErrorState || (() => {}); @@ -147,7 +147,7 @@ export async function waitForNetworkReady(options = {}) { * }} [options] * @returns {Promise<"ready" | "degraded" | false>} */ -export async function waitForMeshReady(options = {}) { +export async function waitForMeshReady(options: any = {}) { return waitForNetworkReady({ ...options, mountOnUiReady: false, diff --git a/meshchatx/src/frontend/js/networkVisualiserCache.js b/meshchatx/src/frontend/js/networkVisualiserCache.ts similarity index 95% rename from meshchatx/src/frontend/js/networkVisualiserCache.js rename to meshchatx/src/frontend/js/networkVisualiserCache.ts index d103bbba9..86721cb87 100644 --- a/meshchatx/src/frontend/js/networkVisualiserCache.js +++ b/meshchatx/src/frontend/js/networkVisualiserCache.ts @@ -49,7 +49,7 @@ export async function loadVisualiserCache(identityHash) { } try { const db = await openDb(); - const row = await new Promise((resolve, reject) => { + const row = await new Promise((resolve, reject) => { const tx = db.transaction(STORE_NAME, "readonly"); const req = tx.objectStore(STORE_NAME).get(identityHash); req.onsuccess = () => resolve(req.result || null); @@ -102,7 +102,7 @@ export async function saveVisualiserCache(snapshot) { } const positions = snapshot.positions && typeof snapshot.positions === "object" ? snapshot.positions : {}; - const row = { + const row: any = { identityHash, version: CACHE_VERSION, savedAt: Date.now(), @@ -112,7 +112,7 @@ export async function saveVisualiserCache(snapshot) { }; const db = await openDb(); - await new Promise((resolve, reject) => { + await new Promise((resolve, reject) => { const tx = db.transaction(STORE_NAME, "readwrite"); tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); @@ -133,7 +133,7 @@ export async function clearVisualiserCache(identityHash) { } try { const db = await openDb(); - await new Promise((resolve, reject) => { + await new Promise((resolve, reject) => { const tx = db.transaction(STORE_NAME, "readwrite"); tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); diff --git a/meshchatx/src/frontend/js/networkVisualiserPerf.js b/meshchatx/src/frontend/js/networkVisualiserPerf.ts similarity index 99% rename from meshchatx/src/frontend/js/networkVisualiserPerf.js rename to meshchatx/src/frontend/js/networkVisualiserPerf.ts index 5c5490fb2..d4fd1734d 100644 --- a/meshchatx/src/frontend/js/networkVisualiserPerf.js +++ b/meshchatx/src/frontend/js/networkVisualiserPerf.ts @@ -203,7 +203,7 @@ export function buildPathGraphJs(req) { const announces = req?.announces && typeof req.announces === "object" ? req.announces : {}; const conversations = req?.conversations && typeof req.conversations === "object" ? req.conversations : {}; const iconCache = req?.icon_cache && typeof req.icon_cache === "object" ? req.icon_cache : {}; - const positions = { ...(req?.positions && typeof req.positions === "object" ? req.positions : {}) }; + const positions: any = { ...(req?.positions && typeof req.positions === "object" ? req.positions : {}) }; const hopMax = req?.hop_max; const searchLower = String(req?.search || "").toLowerCase(); const darkMode = !!req?.dark_mode; @@ -261,7 +261,7 @@ export function buildPathGraphJs(req) { const direct = entry.hops === 1; const conversation = conversations[announce.destination_hash]; - let node = { + let node: any = { id: entry.hash, group: "announce", size: 25, @@ -413,7 +413,7 @@ export function settleLayout(req) { return got; } } - const positions = {}; + const positions: any = {}; for (const n of req?.nodes || []) { if (n?.id) positions[n.id] = { x: n.x || 0, y: n.y || 0 }; } diff --git a/meshchatx/src/frontend/js/networkVisualiserPlanet.js b/meshchatx/src/frontend/js/networkVisualiserPlanet.ts similarity index 99% rename from meshchatx/src/frontend/js/networkVisualiserPlanet.js rename to meshchatx/src/frontend/js/networkVisualiserPlanet.ts index fa525eb4c..15f769957 100644 --- a/meshchatx/src/frontend/js/networkVisualiserPlanet.js +++ b/meshchatx/src/frontend/js/networkVisualiserPlanet.ts @@ -59,8 +59,8 @@ const globeGridCache = new Map(); let nodeScratch = new Float32Array(0); let edgeScratch = new Float32Array(0); -const clipA = { x: 0, y: 0, z: 0, w: 0 }; -const clipB = { x: 0, y: 0, z: 0, w: 0 }; +const clipA: any = { x: 0, y: 0, z: 0, w: 0 }; +const clipB: any = { x: 0, y: 0, z: 0, w: 0 }; let spriteScratch = []; let worldScratch = []; @@ -178,7 +178,7 @@ export function orbitEye(yaw, pitch, dist) { export function cameraBasis(eye) { const fl = Math.hypot(eye.x, eye.y, eye.z) || 1; - const forward = { x: -eye.x / fl, y: -eye.y / fl, z: -eye.z / fl }; + const forward: any = { x: -eye.x / fl, y: -eye.y / fl, z: -eye.z / fl }; let cx = forward.y * 0 - forward.z * 1; let cy = forward.z * 0 - forward.x * 0; let cz = forward.x * 1 - forward.y * 0; @@ -189,8 +189,8 @@ export function cameraBasis(eye) { cz = 0; rl = 1; } - const right = { x: cx / rl, y: cy / rl, z: cz / rl }; - const up = { + const right: any = { x: cx / rl, y: cy / rl, z: cz / rl }; + const up: any = { x: right.y * forward.z - right.z * forward.y, y: right.z * forward.x - right.x * forward.z, z: right.x * forward.y - right.y * forward.x, @@ -1067,7 +1067,7 @@ export function projectPlanetScene(opts) { srcNodes[o + 8], srcNodes[o + 9] ); - const rec = { + const rec: any = { sx: spr ? spr.sx : 0, sy: spr ? spr.sy : 0, size, @@ -1153,7 +1153,7 @@ export function projectPlanetScene(opts) { let prevRing = null; for (let s = 0; s <= ringSegs; s++) { const ang = (s / ringSegs) * Math.PI * 2; - const pt = { x: ringR * Math.cos(ang), y: 0, z: ringR * Math.sin(ang) }; + const pt: any = { x: ringR * Math.cos(ang), y: 0, z: ringR * Math.sin(ang) }; if (prevRing) { writeWorldSeg( prevRing.x, diff --git a/meshchatx/src/frontend/js/networkVisualiserWebGL.js b/meshchatx/src/frontend/js/networkVisualiserWebGL.ts similarity index 99% rename from meshchatx/src/frontend/js/networkVisualiserWebGL.js rename to meshchatx/src/frontend/js/networkVisualiserWebGL.ts index 4be511334..16a53550f 100644 --- a/meshchatx/src/frontend/js/networkVisualiserWebGL.js +++ b/meshchatx/src/frontend/js/networkVisualiserWebGL.ts @@ -457,7 +457,7 @@ function createIconAtlas(gl) { // Fall through to HTMLImageElement. } } - const img = await new Promise((resolve, reject) => { + const img = await new Promise((resolve, reject) => { const el = new Image(); el.decoding = "sync"; el.onload = () => resolve(el); diff --git a/meshchatx/src/frontend/js/networkVisualiserWebGLEngine.js b/meshchatx/src/frontend/js/networkVisualiserWebGLEngine.ts similarity index 99% rename from meshchatx/src/frontend/js/networkVisualiserWebGLEngine.js rename to meshchatx/src/frontend/js/networkVisualiserWebGLEngine.ts index 3c72478a0..faf11526a 100644 --- a/meshchatx/src/frontend/js/networkVisualiserWebGLEngine.js +++ b/meshchatx/src/frontend/js/networkVisualiserWebGLEngine.ts @@ -101,7 +101,7 @@ export function collectWebGLLabels(opts) { return out; } -const DEFAULT_ICON_BY_KIND = { +const DEFAULT_ICON_BY_KIND: any = { [KIND_ME]: "/assets/images/reticulum_logo_512.png", [KIND_IFACE_ON]: "/assets/images/network-visualiser/interface_connected.png", [KIND_IFACE_OFF]: "/assets/images/network-visualiser/interface_disconnected.png", @@ -285,7 +285,7 @@ export function graphToSceneRequest(graphNodes, graphEdges, view) { * onHover?: (id: string|null, meta: object|null, cssX: number, cssY: number) => void, * }} hooks */ -export function createVisualiserWebGLEngine(canvas, hooks = {}) { +export function createVisualiserWebGLEngine(canvas, hooks: any = {}) { const gl = tryCreateWebGL2Context(canvas); if (!gl) { throw new Error("WebGL2 unavailable"); @@ -377,7 +377,7 @@ export function createVisualiserWebGLEngine(canvas, hooks = {}) { dirty = true; } - function setGraph(graphNodes, graphEdges, viewOpts = {}) { + function setGraph(graphNodes, graphEdges, viewOpts: any = {}) { metaById.clear(); indexById.clear(); imageByIndex = []; @@ -527,7 +527,7 @@ export function createVisualiserWebGLEngine(canvas, hooks = {}) { drawNodeScratch = new Float32Array(need); } const drawNodes = mergeSceneNodesWithTextures(buf.nodes, texMeta, drawNodeScratch); - let camera = { + let camera: any = { x: buf.camX || 0, y: buf.camY || 0, zoom: buf.zoom > 0 ? buf.zoom : 1, diff --git a/meshchatx/src/frontend/js/nomadCrashTabMain.js b/meshchatx/src/frontend/js/nomadCrashTabMain.ts similarity index 97% rename from meshchatx/src/frontend/js/nomadCrashTabMain.js rename to meshchatx/src/frontend/js/nomadCrashTabMain.ts index e860bc7fb..29398c06b 100644 --- a/meshchatx/src/frontend/js/nomadCrashTabMain.js +++ b/meshchatx/src/frontend/js/nomadCrashTabMain.ts @@ -106,8 +106,8 @@ function setupMultilineForMicron(MicronParser, pagePath, renderOptions) { } function collectFields(filterNames) { - const inputs = root.querySelectorAll("input,textarea"); - const out = {}; + const inputs = root.querySelectorAll("input,textarea"); + const out: any = {}; let allow = null; if (filterNames && filterNames !== "*") { allow = {}; @@ -310,15 +310,16 @@ async function renderPage(msg, seq, api) { if (seq !== renderSeq) { return; } - const opts = { ...renderOptions }; + const opts: any = { ...renderOptions }; let html = ""; try { html = api.renderNomadPageByPath(pagePathWithoutData, content, pagePartials, api.MicronParser, opts); } catch (err) { if (seq === renderSeq) { + const error = err as Error; post({ type: "render-error", - message: err && err.message ? String(err.message) : "render_failed", + message: error.message ? String(error.message) : "render_failed", }); } return; @@ -327,7 +328,7 @@ async function renderPage(msg, seq, api) { return; } root.innerHTML = html || ""; - const pageShell = root.querySelector(".mu-page"); + const pageShell = root.querySelector(".mu-page"); if (pageShell) { const pageBg = pageShell.style.backgroundColor; const pageFg = pageShell.style.color; diff --git a/meshchatx/src/frontend/js/nomadCrashTabShell.js b/meshchatx/src/frontend/js/nomadCrashTabShell.ts similarity index 100% rename from meshchatx/src/frontend/js/nomadCrashTabShell.js rename to meshchatx/src/frontend/js/nomadCrashTabShell.ts diff --git a/meshchatx/src/frontend/js/nomadCssSecurity.js b/meshchatx/src/frontend/js/nomadCssSecurity.ts similarity index 100% rename from meshchatx/src/frontend/js/nomadCssSecurity.js rename to meshchatx/src/frontend/js/nomadCssSecurity.ts diff --git a/meshchatx/src/frontend/js/nomadFavouritesLayoutStore.js b/meshchatx/src/frontend/js/nomadFavouritesLayoutStore.ts similarity index 100% rename from meshchatx/src/frontend/js/nomadFavouritesLayoutStore.js rename to meshchatx/src/frontend/js/nomadFavouritesLayoutStore.ts diff --git a/meshchatx/src/frontend/js/nomadUnknownNodeName.js b/meshchatx/src/frontend/js/nomadUnknownNodeName.ts similarity index 100% rename from meshchatx/src/frontend/js/nomadUnknownNodeName.js rename to meshchatx/src/frontend/js/nomadUnknownNodeName.ts diff --git a/meshchatx/src/frontend/js/notificationPolicy.js b/meshchatx/src/frontend/js/notificationPolicy.ts similarity index 99% rename from meshchatx/src/frontend/js/notificationPolicy.js rename to meshchatx/src/frontend/js/notificationPolicy.ts index 318044f52..74ffa4415 100644 --- a/meshchatx/src/frontend/js/notificationPolicy.js +++ b/meshchatx/src/frontend/js/notificationPolicy.ts @@ -25,7 +25,7 @@ export function shouldShowOsMessageNotification({ dnd = false, hasFocus = true, userFacing = true, -} = {}) { +}: any = {}) { if (dnd || sieveSuppress) { return false; } @@ -61,7 +61,7 @@ export function shouldPlayMessageSound({ openDestinationHashes = null, sourceHash = null, userFacing = true, -} = {}) { +}: any = {}) { if (dnd || sieveSuppress) { return false; } diff --git a/meshchatx/src/frontend/js/outboundMessageStatus.js b/meshchatx/src/frontend/js/outboundMessageStatus.ts similarity index 100% rename from meshchatx/src/frontend/js/outboundMessageStatus.js rename to meshchatx/src/frontend/js/outboundMessageStatus.ts diff --git a/meshchatx/src/frontend/js/outboundSendQueue.js b/meshchatx/src/frontend/js/outboundSendQueue.ts similarity index 100% rename from meshchatx/src/frontend/js/outboundSendQueue.js rename to meshchatx/src/frontend/js/outboundSendQueue.ts diff --git a/meshchatx/src/frontend/js/plugins/PluginHost.js b/meshchatx/src/frontend/js/plugins/PluginHost.ts similarity index 99% rename from meshchatx/src/frontend/js/plugins/PluginHost.js rename to meshchatx/src/frontend/js/plugins/PluginHost.ts index 285d1c9f9..ad9085328 100644 --- a/meshchatx/src/frontend/js/plugins/PluginHost.js +++ b/meshchatx/src/frontend/js/plugins/PluginHost.ts @@ -35,6 +35,9 @@ export function pluginRoutePath(pluginId) { } export class PluginHost { + declare _themeListener: any; + declare instances: Map; + declare router: any; constructor() { /** @type {Map void>, manifest: PluginManifest, lastDescriptor: object | null, lastUiError: string, apiClient: ReturnType | null, allowHtmlFrame: boolean, allowedWidgets: string[], routeName: string | null }>} */ this.instances = new Map(); diff --git a/meshchatx/src/frontend/js/plugins/pluginHostWidgets.js b/meshchatx/src/frontend/js/plugins/pluginHostWidgets.ts similarity index 96% rename from meshchatx/src/frontend/js/plugins/pluginHostWidgets.js rename to meshchatx/src/frontend/js/plugins/pluginHostWidgets.ts index 603a48a9d..2b5426bff 100644 --- a/meshchatx/src/frontend/js/plugins/pluginHostWidgets.js +++ b/meshchatx/src/frontend/js/plugins/pluginHostWidgets.ts @@ -14,7 +14,7 @@ export function isKnownHostWidget(name) { return HOST_WIDGET_NAMES.includes(name); } -const IssueStackView = { +const IssueStackView: any = { name: "IssueStackView", props: { stack: { type: String, default: "" }, @@ -33,7 +33,7 @@ const IssueStackView = { }, }; -const HashBadge = { +const HashBadge: any = { name: "HashBadge", props: { hash: { type: String, default: "" }, @@ -55,7 +55,7 @@ const HashBadge = { }, }; -const WIDGET_MAP = { +const WIDGET_MAP: any = { IssueStackView, HashBadge, }; diff --git a/meshchatx/src/frontend/js/plugins/pluginLabels.js b/meshchatx/src/frontend/js/plugins/pluginLabels.ts similarity index 95% rename from meshchatx/src/frontend/js/plugins/pluginLabels.js rename to meshchatx/src/frontend/js/plugins/pluginLabels.ts index b788db6e3..8a4ad287a 100644 --- a/meshchatx/src/frontend/js/plugins/pluginLabels.js +++ b/meshchatx/src/frontend/js/plugins/pluginLabels.ts @@ -9,7 +9,7 @@ */ export function flattenLocaleMessages(messages, prefix = "") { /** @type {Record} */ - const flat = {}; + const flat: any = {}; if (!messages || typeof messages !== "object") { return flat; } @@ -34,7 +34,7 @@ export function flattenLocaleMessages(messages, prefix = "") { * @param {Record} [manifest] * @returns {Promise>} */ -export async function loadPluginLabelMap(apiClient, pluginId, locale, manifest = {}) { +export async function loadPluginLabelMap(apiClient, pluginId, locale, manifest: any = {}) { const i18n = manifest.i18n || {}; const directory = i18n.directory || "locales"; const defaultLocale = i18n.defaultLocale || "en"; @@ -70,7 +70,7 @@ export async function loadPluginLabelMap(apiClient, pluginId, locale, manifest = * @param {Record} [manifest] * @returns {string} */ -export function resolvePluginUiString(labels, key, manifest = {}) { +export function resolvePluginUiString(labels, key, manifest: any = {}) { if (labels[key]) { return labels[key]; } diff --git a/meshchatx/src/frontend/js/plugins/pluginManifest.js b/meshchatx/src/frontend/js/plugins/pluginManifest.ts similarity index 100% rename from meshchatx/src/frontend/js/plugins/pluginManifest.js rename to meshchatx/src/frontend/js/plugins/pluginManifest.ts diff --git a/meshchatx/src/frontend/js/plugins/pluginPermissions.js b/meshchatx/src/frontend/js/plugins/pluginPermissions.ts similarity index 100% rename from meshchatx/src/frontend/js/plugins/pluginPermissions.js rename to meshchatx/src/frontend/js/plugins/pluginPermissions.ts diff --git a/meshchatx/src/frontend/js/plugins/pluginUiDescriptor.js b/meshchatx/src/frontend/js/plugins/pluginUiDescriptor.ts similarity index 98% rename from meshchatx/src/frontend/js/plugins/pluginUiDescriptor.js rename to meshchatx/src/frontend/js/plugins/pluginUiDescriptor.ts index 67d65096d..664bae153 100644 --- a/meshchatx/src/frontend/js/plugins/pluginUiDescriptor.js +++ b/meshchatx/src/frontend/js/plugins/pluginUiDescriptor.ts @@ -73,7 +73,7 @@ export function sanitizePluginAssetSrc(pluginId, src) { * @param {{ pluginId?: string, allowHtmlFrame?: boolean, allowedWidgets?: string[] }} [options] * @returns {UiValidateResult} */ -export function validateUiDescriptor(descriptor, options = {}) { +export function validateUiDescriptor(descriptor, options: any = {}) { if (descriptor == null) { return { ok: true, descriptor: null }; } diff --git a/meshchatx/src/frontend/js/plugins/pluginUiRegistry.js b/meshchatx/src/frontend/js/plugins/pluginUiRegistry.ts similarity index 100% rename from meshchatx/src/frontend/js/plugins/pluginUiRegistry.js rename to meshchatx/src/frontend/js/plugins/pluginUiRegistry.ts diff --git a/meshchatx/src/frontend/js/plugins/pluginWorker.js b/meshchatx/src/frontend/js/plugins/pluginWorker.ts similarity index 96% rename from meshchatx/src/frontend/js/plugins/pluginWorker.js rename to meshchatx/src/frontend/js/plugins/pluginWorker.ts index c60450eaf..a3004cffb 100644 --- a/meshchatx/src/frontend/js/plugins/pluginWorker.js +++ b/meshchatx/src/frontend/js/plugins/pluginWorker.ts @@ -11,7 +11,7 @@ function handleWorkerMessage(event, post) { } if (message.type === "init") { - const state = { + const state: any = { pluginId: message.pluginId, permissions: message.permissions || {}, labels: message.labels || {}, @@ -28,7 +28,7 @@ function handleWorkerMessage(event, post) { }; function postRequest(kind, payload) { - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { const requestId = `${Date.now()}-${Math.random()}`; const onReply = (replyEvent) => { const reply = replyEvent.data; @@ -47,11 +47,11 @@ function handleWorkerMessage(event, post) { }); } - const api = { + const api: any = { t(key) { return state.labels[key] || key; }, - async invoke(method, args = {}) { + async invoke(method, args: any = {}) { if (method === "readPaths") { return postRequest("manager", { capability: "destinationPath.read", @@ -60,7 +60,7 @@ function handleWorkerMessage(event, post) { } return postRequest("invoke", { method, args }); }, - async callManager(capability, args = {}) { + async callManager(capability, args: any = {}) { return postRequest("manager", { capability, args }); }, async clipboardWrite(text) { diff --git a/meshchatx/src/frontend/js/postInstallPromptState.js b/meshchatx/src/frontend/js/postInstallPromptState.ts similarity index 98% rename from meshchatx/src/frontend/js/postInstallPromptState.js rename to meshchatx/src/frontend/js/postInstallPromptState.ts index 8466cb184..fcc6a0ad8 100644 --- a/meshchatx/src/frontend/js/postInstallPromptState.js +++ b/meshchatx/src/frontend/js/postInstallPromptState.ts @@ -25,7 +25,7 @@ export function readSeenMap() { return {}; } /** @type {Record} */ - const out = {}; + const out: any = {}; for (const [id, revision] of Object.entries(parsed)) { const n = Number(revision); if (Number.isFinite(n) && n >= 0) { diff --git a/meshchatx/src/frontend/js/pwa/swCachePolicy.js b/meshchatx/src/frontend/js/pwa/swCachePolicy.ts similarity index 100% rename from meshchatx/src/frontend/js/pwa/swCachePolicy.js rename to meshchatx/src/frontend/js/pwa/swCachePolicy.ts diff --git a/meshchatx/src/frontend/js/pwa/swClientRegister.js b/meshchatx/src/frontend/js/pwa/swClientRegister.ts similarity index 100% rename from meshchatx/src/frontend/js/pwa/swClientRegister.js rename to meshchatx/src/frontend/js/pwa/swClientRegister.ts diff --git a/meshchatx/src/frontend/js/pwa/swShellRuntime.js b/meshchatx/src/frontend/js/pwa/swShellRuntime.ts similarity index 99% rename from meshchatx/src/frontend/js/pwa/swShellRuntime.js rename to meshchatx/src/frontend/js/pwa/swShellRuntime.ts index aefa0787b..08ca70e64 100644 --- a/meshchatx/src/frontend/js/pwa/swShellRuntime.js +++ b/meshchatx/src/frontend/js/pwa/swShellRuntime.ts @@ -199,7 +199,7 @@ export function createShellRuntime(options) { * @param {{ preloadResponse?: Promise }} [eventExtras] * @returns {Promise|null} null means network bypass (do not respondWith) */ - function resolveFetch(request, eventExtras = {}) { + function resolveFetch(request, eventExtras: any = {}) { let url; try { url = new URL(request.url); @@ -285,7 +285,7 @@ export function shouldAttachNavigationPreload(request, origin) { * @returns {boolean} true when respondWith was called */ export function handleFetchEvent(event, runtime, origin) { - const extras = {}; + const extras: any = {}; if (shouldAttachNavigationPreload(event.request, origin)) { extras.preloadResponse = event.preloadResponse; } diff --git a/meshchatx/src/frontend/js/qrScannerUtils.js b/meshchatx/src/frontend/js/qrScannerUtils.ts similarity index 100% rename from meshchatx/src/frontend/js/qrScannerUtils.js rename to meshchatx/src/frontend/js/qrScannerUtils.ts diff --git a/meshchatx/src/frontend/js/registries/commandRegistry.js b/meshchatx/src/frontend/js/registries/commandRegistry.ts similarity index 100% rename from meshchatx/src/frontend/js/registries/commandRegistry.js rename to meshchatx/src/frontend/js/registries/commandRegistry.ts diff --git a/meshchatx/src/frontend/js/registries/coreCommandEntries.js b/meshchatx/src/frontend/js/registries/coreCommandEntries.ts similarity index 100% rename from meshchatx/src/frontend/js/registries/coreCommandEntries.js rename to meshchatx/src/frontend/js/registries/coreCommandEntries.ts diff --git a/meshchatx/src/frontend/js/registries/coreNavEntries.js b/meshchatx/src/frontend/js/registries/coreNavEntries.ts similarity index 100% rename from meshchatx/src/frontend/js/registries/coreNavEntries.js rename to meshchatx/src/frontend/js/registries/coreNavEntries.ts diff --git a/meshchatx/src/frontend/js/registries/corePostInstallPromptEntries.js b/meshchatx/src/frontend/js/registries/corePostInstallPromptEntries.ts similarity index 100% rename from meshchatx/src/frontend/js/registries/corePostInstallPromptEntries.js rename to meshchatx/src/frontend/js/registries/corePostInstallPromptEntries.ts diff --git a/meshchatx/src/frontend/js/registries/coreSettingsSectionKeywords.js b/meshchatx/src/frontend/js/registries/coreSettingsSectionKeywords.ts similarity index 99% rename from meshchatx/src/frontend/js/registries/coreSettingsSectionKeywords.js rename to meshchatx/src/frontend/js/registries/coreSettingsSectionKeywords.ts index 0bb3b510d..5a6db3846 100644 --- a/meshchatx/src/frontend/js/registries/coreSettingsSectionKeywords.js +++ b/meshchatx/src/frontend/js/registries/coreSettingsSectionKeywords.ts @@ -1,7 +1,7 @@ // SPDX-License-Identifier: 0BSD /** @type {Record} */ -export const CORE_SETTINGS_SECTION_KEYWORDS = { +export const CORE_SETTINGS_SECTION_KEYWORDS: any = { telephony: ["Telephony", "Telephone", "LXST", "Enable Telephone", "voice", "calling", "call", "mesh network"], strangerProtection: [ "Security", diff --git a/meshchatx/src/frontend/js/registries/coreToolsEntries.js b/meshchatx/src/frontend/js/registries/coreToolsEntries.ts similarity index 100% rename from meshchatx/src/frontend/js/registries/coreToolsEntries.js rename to meshchatx/src/frontend/js/registries/coreToolsEntries.ts diff --git a/meshchatx/src/frontend/js/registries/featureRegistry.js b/meshchatx/src/frontend/js/registries/featureRegistry.ts similarity index 100% rename from meshchatx/src/frontend/js/registries/featureRegistry.js rename to meshchatx/src/frontend/js/registries/featureRegistry.ts diff --git a/meshchatx/src/frontend/js/registries/navRegistry.js b/meshchatx/src/frontend/js/registries/navRegistry.ts similarity index 100% rename from meshchatx/src/frontend/js/registries/navRegistry.js rename to meshchatx/src/frontend/js/registries/navRegistry.ts diff --git a/meshchatx/src/frontend/js/registries/postInstallPromptRegistry.js b/meshchatx/src/frontend/js/registries/postInstallPromptRegistry.ts similarity index 100% rename from meshchatx/src/frontend/js/registries/postInstallPromptRegistry.js rename to meshchatx/src/frontend/js/registries/postInstallPromptRegistry.ts diff --git a/meshchatx/src/frontend/js/registries/registerCoreContributions.js b/meshchatx/src/frontend/js/registries/registerCoreContributions.ts similarity index 100% rename from meshchatx/src/frontend/js/registries/registerCoreContributions.js rename to meshchatx/src/frontend/js/registries/registerCoreContributions.ts diff --git a/meshchatx/src/frontend/js/registries/registryCore.js b/meshchatx/src/frontend/js/registries/registryCore.ts similarity index 100% rename from meshchatx/src/frontend/js/registries/registryCore.js rename to meshchatx/src/frontend/js/registries/registryCore.ts diff --git a/meshchatx/src/frontend/js/registries/routeRegistry.js b/meshchatx/src/frontend/js/registries/routeRegistry.ts similarity index 100% rename from meshchatx/src/frontend/js/registries/routeRegistry.js rename to meshchatx/src/frontend/js/registries/routeRegistry.ts diff --git a/meshchatx/src/frontend/js/registries/settingsSectionRegistry.js b/meshchatx/src/frontend/js/registries/settingsSectionRegistry.ts similarity index 98% rename from meshchatx/src/frontend/js/registries/settingsSectionRegistry.js rename to meshchatx/src/frontend/js/registries/settingsSectionRegistry.ts index f36ed2f64..f65b98c1a 100644 --- a/meshchatx/src/frontend/js/registries/settingsSectionRegistry.js +++ b/meshchatx/src/frontend/js/registries/settingsSectionRegistry.ts @@ -47,7 +47,7 @@ export function getSettingsSectionKeywords(sectionKey) { */ export function getAllSettingsSectionKeywords() { /** @type {Record} */ - const map = {}; + const map: any = {}; for (const entry of settingsSectionRegistry.list()) { map[entry.id] = entry.keywords; } diff --git a/meshchatx/src/frontend/js/registries/toolsRegistry.js b/meshchatx/src/frontend/js/registries/toolsRegistry.ts similarity index 94% rename from meshchatx/src/frontend/js/registries/toolsRegistry.js rename to meshchatx/src/frontend/js/registries/toolsRegistry.ts index 9bbc1dd33..684263b7c 100644 --- a/meshchatx/src/frontend/js/registries/toolsRegistry.js +++ b/meshchatx/src/frontend/js/registries/toolsRegistry.ts @@ -26,7 +26,7 @@ export function unregisterTool(name) { */ export function listTools() { return toolsRegistry.list().map((entry) => { - const tool = { ...entry }; + const tool: any = { ...entry }; delete tool.id; return tool; }); diff --git a/meshchatx/src/frontend/js/registries/wsEventBridge.js b/meshchatx/src/frontend/js/registries/wsEventBridge.ts similarity index 100% rename from meshchatx/src/frontend/js/registries/wsEventBridge.js rename to meshchatx/src/frontend/js/registries/wsEventBridge.ts diff --git a/meshchatx/src/frontend/js/registries/wsEventRegistry.js b/meshchatx/src/frontend/js/registries/wsEventRegistry.ts similarity index 100% rename from meshchatx/src/frontend/js/registries/wsEventRegistry.js rename to meshchatx/src/frontend/js/registries/wsEventRegistry.ts diff --git a/meshchatx/src/frontend/js/relayHostModalClasses.js b/meshchatx/src/frontend/js/relayHostModalClasses.ts similarity index 100% rename from meshchatx/src/frontend/js/relayHostModalClasses.js rename to meshchatx/src/frontend/js/relayHostModalClasses.ts diff --git a/meshchatx/src/frontend/js/relayHostModerationClasses.js b/meshchatx/src/frontend/js/relayHostModerationClasses.ts similarity index 100% rename from meshchatx/src/frontend/js/relayHostModerationClasses.js rename to meshchatx/src/frontend/js/relayHostModerationClasses.ts diff --git a/meshchatx/src/frontend/js/relayLayoutStore.js b/meshchatx/src/frontend/js/relayLayoutStore.ts similarity index 100% rename from meshchatx/src/frontend/js/relayLayoutStore.js rename to meshchatx/src/frontend/js/relayLayoutStore.ts diff --git a/meshchatx/src/frontend/js/relayLinkUtils.js b/meshchatx/src/frontend/js/relayLinkUtils.ts similarity index 96% rename from meshchatx/src/frontend/js/relayLinkUtils.js rename to meshchatx/src/frontend/js/relayLinkUtils.ts index b80e1a7ae..7f22e78e3 100644 --- a/meshchatx/src/frontend/js/relayLinkUtils.js +++ b/meshchatx/src/frontend/js/relayLinkUtils.ts @@ -50,7 +50,7 @@ export function parseMeshchatRelayUri(uri) { } } -export function buildMeshchatRelayUri({ hub, room = "", name = "", aspect = "" } = {}) { +export function buildMeshchatRelayUri({ hub, room = "", name = "", aspect = "" }: any = {}) { const h = String(hub || "") .trim() .toLowerCase(); @@ -73,7 +73,7 @@ export function buildMeshchatRelayUri({ hub, room = "", name = "", aspect = "" } return `meshchatx://relay?${parts.join("&")}`; } -export function buildRelayShareMessage({ hub, room = "", name = "", aspect = "" } = {}) { +export function buildRelayShareMessage({ hub, room = "", name = "", aspect = "" }: any = {}) { const uri = buildMeshchatRelayUri({ hub, room, name, aspect }); if (!uri) { return null; @@ -88,7 +88,7 @@ export function buildRelayShareMessage({ hub, room = "", name = "", aspect = "" * Add (or reuse) a client hub from a parsed relay URI and optionally join a room. * Returns { hub_hash, room } on success. */ -export async function applyRelayShareLink(parsed, { api = typeof window !== "undefined" ? window.api : null } = {}) { +export async function applyRelayShareLink(parsed, { api = typeof window !== "undefined" ? window.api : null }: any = {}) { if (!parsed?.hub || !api) { throw new Error("invalid relay share"); } diff --git a/meshchatx/src/frontend/js/relayMentionCount.js b/meshchatx/src/frontend/js/relayMentionCount.ts similarity index 100% rename from meshchatx/src/frontend/js/relayMentionCount.js rename to meshchatx/src/frontend/js/relayMentionCount.ts diff --git a/meshchatx/src/frontend/js/relayMessageSearch.js b/meshchatx/src/frontend/js/relayMessageSearch.ts similarity index 100% rename from meshchatx/src/frontend/js/relayMessageSearch.js rename to meshchatx/src/frontend/js/relayMessageSearch.ts diff --git a/meshchatx/src/frontend/js/relayMessageTimeline.js b/meshchatx/src/frontend/js/relayMessageTimeline.ts similarity index 100% rename from meshchatx/src/frontend/js/relayMessageTimeline.js rename to meshchatx/src/frontend/js/relayMessageTimeline.ts diff --git a/meshchatx/src/frontend/js/releaseChannel.js b/meshchatx/src/frontend/js/releaseChannel.ts similarity index 100% rename from meshchatx/src/frontend/js/releaseChannel.js rename to meshchatx/src/frontend/js/releaseChannel.ts diff --git a/meshchatx/src/frontend/js/remoteBackendUrl.js b/meshchatx/src/frontend/js/remoteBackendUrl.ts similarity index 100% rename from meshchatx/src/frontend/js/remoteBackendUrl.js rename to meshchatx/src/frontend/js/remoteBackendUrl.ts diff --git a/meshchatx/src/frontend/js/resourceBreakdown.js b/meshchatx/src/frontend/js/resourceBreakdown.ts similarity index 100% rename from meshchatx/src/frontend/js/resourceBreakdown.js rename to meshchatx/src/frontend/js/resourceBreakdown.ts diff --git a/meshchatx/src/frontend/js/reticulumDocsEntryUrl.js b/meshchatx/src/frontend/js/reticulumDocsEntryUrl.ts similarity index 100% rename from meshchatx/src/frontend/js/reticulumDocsEntryUrl.js rename to meshchatx/src/frontend/js/reticulumDocsEntryUrl.ts diff --git a/meshchatx/src/frontend/js/reticulumDocsNavigation.js b/meshchatx/src/frontend/js/reticulumDocsNavigation.ts similarity index 100% rename from meshchatx/src/frontend/js/reticulumDocsNavigation.js rename to meshchatx/src/frontend/js/reticulumDocsNavigation.ts diff --git a/meshchatx/src/frontend/js/reticulumPathfinding.js b/meshchatx/src/frontend/js/reticulumPathfinding.ts similarity index 99% rename from meshchatx/src/frontend/js/reticulumPathfinding.js rename to meshchatx/src/frontend/js/reticulumPathfinding.ts index 43013b91a..b4624f85e 100644 --- a/meshchatx/src/frontend/js/reticulumPathfinding.js +++ b/meshchatx/src/frontend/js/reticulumPathfinding.ts @@ -81,7 +81,7 @@ export async function warmPathIfNeeded(api, hash, snapshot) { * @param {Record} [params] */ export function getDestinationPath(api, hash, params) { - const q = { ...params }; + const q: any = { ...params }; delete q.request; return api.get(destinationPath(hash), { params: q }); } diff --git a/meshchatx/src/frontend/js/rnode/AndroidBridge.js b/meshchatx/src/frontend/js/rnode/AndroidBridge.ts similarity index 98% rename from meshchatx/src/frontend/js/rnode/AndroidBridge.js rename to meshchatx/src/frontend/js/rnode/AndroidBridge.ts index 154a30b85..6e95ba85c 100644 --- a/meshchatx/src/frontend/js/rnode/AndroidBridge.js +++ b/meshchatx/src/frontend/js/rnode/AndroidBridge.ts @@ -33,6 +33,10 @@ function safeCall(fn, fallback) { } export default class AndroidBridge { + static PERM_BLUETOOTH = PERM_BLUETOOTH; + static PERM_USB = PERM_USB; + declare bridge: any; + declare env: any; constructor(bridge = null, env = null) { this.env = env || pickEnv(); this.bridge = bridge || this.env.MeshChatXAndroid || null; @@ -259,5 +263,3 @@ export default class AndroidBridge { } } -AndroidBridge.PERM_BLUETOOTH = PERM_BLUETOOTH; -AndroidBridge.PERM_USB = PERM_USB; diff --git a/meshchatx/src/frontend/js/rnode/Capabilities.js b/meshchatx/src/frontend/js/rnode/Capabilities.ts similarity index 98% rename from meshchatx/src/frontend/js/rnode/Capabilities.js rename to meshchatx/src/frontend/js/rnode/Capabilities.ts index 47f8dafb9..416e0c722 100644 --- a/meshchatx/src/frontend/js/rnode/Capabilities.js +++ b/meshchatx/src/frontend/js/rnode/Capabilities.ts @@ -166,10 +166,10 @@ function detectWifi() { * anyAvailable: boolean, * }} */ -export function detectCapabilities(overrides = {}) { +export function detectCapabilities(overrides: any = {}) { const env = pickGlobal(overrides.env); const platform = detectPlatform(env); - const transports = { + const transports: any = { [TRANSPORT_SERIAL]: detectSerial(env, platform), [TRANSPORT_BLUETOOTH]: detectBluetooth(env, platform), [TRANSPORT_WIFI]: detectWifi(), diff --git a/meshchatx/src/frontend/js/rnode/Diagnostics.js b/meshchatx/src/frontend/js/rnode/Diagnostics.ts similarity index 95% rename from meshchatx/src/frontend/js/rnode/Diagnostics.js rename to meshchatx/src/frontend/js/rnode/Diagnostics.ts index 84554004a..2d44c1dfb 100644 --- a/meshchatx/src/frontend/js/rnode/Diagnostics.js +++ b/meshchatx/src/frontend/js/rnode/Diagnostics.ts @@ -36,10 +36,10 @@ function suggestionKeysFor(issue) { function withTimeout(promise, ms, code) { let timer = null; - const timeoutPromise = new Promise((_, reject) => { + const timeoutPromise = new Promise((_, reject) => { timer = setTimeout(() => { const err = new Error(code || "timeout"); - err.code = code || "TIMEOUT"; + (err as Error & { code?: string; status?: number; body?: unknown }).code = code || "TIMEOUT"; reject(err); }, ms); }); @@ -85,9 +85,9 @@ function safeArrayEqual(a, b) { * summary: object, * }>} */ -export async function diagnose(rnode, options = {}) { +export async function diagnose(rnode, options: any = {}) { const timeoutMs = options.timeoutMs || 4000; - const result = { + const result: any = { firmwareVersion: null, platform: null, board: null, @@ -124,7 +124,7 @@ export async function diagnose(rnode, options = {}) { try { result.firmwareVersion = await withTimeout(rnode.getFirmwareVersion(), timeoutMs, "READ_TIMEOUT"); } catch (err) { - if (err?.code === "READ_TIMEOUT") { + if ((err as Error & { code?: string; status?: number; body?: unknown })?.code === "READ_TIMEOUT") { addIssue(ISSUE_READ_TIMEOUT); } else { addIssue(ISSUE_NO_FIRMWARE_VERSION); diff --git a/meshchatx/src/frontend/js/rnode/Nrf52DfuFlasher.js b/meshchatx/src/frontend/js/rnode/Nrf52DfuFlasher.ts similarity index 98% rename from meshchatx/src/frontend/js/rnode/Nrf52DfuFlasher.js rename to meshchatx/src/frontend/js/rnode/Nrf52DfuFlasher.ts index a2672cf6b..578f14160 100644 --- a/meshchatx/src/frontend/js/rnode/Nrf52DfuFlasher.js +++ b/meshchatx/src/frontend/js/rnode/Nrf52DfuFlasher.ts @@ -1,4 +1,8 @@ export default class Nrf52DfuFlasher { + declare sd_size: number; + declare sequenceNumber: number; + declare serialPort: any; + declare total_size: number; DFU_TOUCH_BAUD = 1200; SERIAL_PORT_OPEN_WAIT_TIME = 0.1; TOUCH_RESET_WAIT_TIME = 1.5; @@ -37,7 +41,7 @@ export default class Nrf52DfuFlasher { * @returns {Promise} */ async sleepMillis(millis) { - await new Promise((resolve) => { + await new Promise((resolve) => { setTimeout(resolve, millis); }); } diff --git a/meshchatx/src/frontend/js/rnode/RNode.js b/meshchatx/src/frontend/js/rnode/RNode.ts similarity index 98% rename from meshchatx/src/frontend/js/rnode/RNode.js rename to meshchatx/src/frontend/js/rnode/RNode.ts index c1b290361..9cca3a269 100644 --- a/meshchatx/src/frontend/js/rnode/RNode.js +++ b/meshchatx/src/frontend/js/rnode/RNode.ts @@ -2,6 +2,10 @@ import RNodeUtils from "./RNodeUtils.js"; import ROM from "./ROM.js"; export default class RNode { + declare callbacks: any; + declare reader: any; + declare serialPort: any; + declare writable: any; KISS_FEND = 0xc0; KISS_FESC = 0xdb; KISS_TFEND = 0xdc; @@ -215,7 +219,7 @@ export default class RNode { } async sendCommand(command, data) { - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { this.callbacks[command] = (response) => { resolve(response); }; @@ -231,7 +235,7 @@ export default class RNode { } async detect() { - return new Promise((resolve) => { + return new Promise((resolve) => { const timeout = setTimeout(() => { resolve(false); }, 2000); diff --git a/meshchatx/src/frontend/js/rnode/RNodeUtils.js b/meshchatx/src/frontend/js/rnode/RNodeUtils.ts similarity index 97% rename from meshchatx/src/frontend/js/rnode/RNodeUtils.js rename to meshchatx/src/frontend/js/rnode/RNodeUtils.ts index 931fcfed5..3b806aa1a 100644 --- a/meshchatx/src/frontend/js/rnode/RNodeUtils.js +++ b/meshchatx/src/frontend/js/rnode/RNodeUtils.ts @@ -5,7 +5,7 @@ export default class RNodeUtils { * @returns {Promise} */ static async sleepMillis(millis) { - await new Promise((resolve) => { + await new Promise((resolve) => { setTimeout(resolve, millis); }); } diff --git a/meshchatx/src/frontend/js/rnode/ROM.js b/meshchatx/src/frontend/js/rnode/ROM.ts similarity index 97% rename from meshchatx/src/frontend/js/rnode/ROM.js rename to meshchatx/src/frontend/js/rnode/ROM.ts index 5631f45e8..7e2e0fd4b 100644 --- a/meshchatx/src/frontend/js/rnode/ROM.js +++ b/meshchatx/src/frontend/js/rnode/ROM.ts @@ -1,6 +1,7 @@ import RNodeUtils from "./RNodeUtils.js"; export default class ROM { + declare eeprom: any; static PLATFORM_AVR = 0x90; static PLATFORM_ESP32 = 0x80; static PLATFORM_NRF52 = 0x70; @@ -49,6 +50,9 @@ export default class ROM { static MODEL_C5 = 0xc5; static MODEL_CA = 0xca; + static declare PRODUCT_H32_V4: number; + static declare MODEL_C8: number; + static PRODUCT_HELTEC_T114 = 0xc2; static MODEL_C6 = 0xc6; static MODEL_C7 = 0xc7; @@ -211,7 +215,7 @@ export default class ROM { const calculatedChecksumHex = RNodeUtils.bytesToHex(this.getCalculatedChecksum()); const signatureHex = RNodeUtils.bytesToHex(this.getSignature()); - var details = { + var details: any = { is_provisioned: true, is_configured: this.isConfigured(), product: this.getProduct(), diff --git a/meshchatx/src/frontend/js/rnode/products.js b/meshchatx/src/frontend/js/rnode/products.ts similarity index 100% rename from meshchatx/src/frontend/js/rnode/products.js rename to meshchatx/src/frontend/js/rnode/products.ts diff --git a/meshchatx/src/frontend/js/rnode/rnodeIntegrityKey.js b/meshchatx/src/frontend/js/rnode/rnodeIntegrityKey.ts similarity index 100% rename from meshchatx/src/frontend/js/rnode/rnodeIntegrityKey.js rename to meshchatx/src/frontend/js/rnode/rnodeIntegrityKey.ts diff --git a/meshchatx/src/frontend/js/rnode/transports/BluetoothTransport.js b/meshchatx/src/frontend/js/rnode/transports/BluetoothTransport.ts similarity index 86% rename from meshchatx/src/frontend/js/rnode/transports/BluetoothTransport.js rename to meshchatx/src/frontend/js/rnode/transports/BluetoothTransport.ts index 01a0d1fc3..efb7a6314 100644 --- a/meshchatx/src/frontend/js/rnode/transports/BluetoothTransport.js +++ b/meshchatx/src/frontend/js/rnode/transports/BluetoothTransport.ts @@ -23,11 +23,22 @@ export const NUS_TX_CHARACTERISTIC_UUID = "6e400003-b5a3-f393-e0a9-e50e24dcca9e" const DEFAULT_WRITE_CHUNK_SIZE = 20; export default class BluetoothTransport extends Transport { + declare _disconnectedHandler: any; + declare _notifyHandler: any; + declare _readableController: any; + declare device: any; + declare env: any; + declare gattServer: any; + declare readable: any; + declare rxCharacteristic: any; + declare txCharacteristic: any; + declare writable: any; + declare writeChunkSize: any; /** * @param {BluetoothDevice} device * @param {{ writeChunkSize?: number, env?: object }} [options] */ - constructor(device, options = {}) { + constructor(device, options: any = {}) { super("bluetooth"); if (!device) { throw new Error("BluetoothTransport requires a BluetoothDevice"); @@ -48,11 +59,11 @@ export default class BluetoothTransport extends Transport { * Returns a connected BluetoothTransport (with .open() still required to * attach streams). */ - static async request(options = {}) { + static async request(options: any = {}) { const env = options.env || (typeof window !== "undefined" ? window : globalThis); if (!env.navigator?.bluetooth) { const err = new Error("web_bluetooth_unavailable"); - err.code = "WEB_BLUETOOTH_UNAVAILABLE"; + (err as Error & { code?: string; status?: number; body?: unknown }).code = "WEB_BLUETOOTH_UNAVAILABLE"; throw err; } let device; @@ -65,12 +76,12 @@ export default class BluetoothTransport extends Transport { const message = cause?.message || String(cause); if (cause?.name === "NotFoundError" || /User cancelled/i.test(message)) { const err = new Error("no_device_selected"); - err.code = "NO_DEVICE_SELECTED"; + (err as Error & { code?: string; status?: number; body?: unknown }).code = "NO_DEVICE_SELECTED"; err.cause = cause; throw err; } const err = new Error(message); - err.code = "DEVICE_REQUEST_FAILED"; + (err as Error & { code?: string; status?: number; body?: unknown }).code = "DEVICE_REQUEST_FAILED"; err.cause = cause; throw err; } @@ -80,7 +91,7 @@ export default class BluetoothTransport extends Transport { async open() { if (!this.device.gatt) { const err = new Error("bluetooth_gatt_unavailable"); - err.code = "GATT_UNAVAILABLE"; + (err as Error & { code?: string; status?: number; body?: unknown }).code = "GATT_UNAVAILABLE"; throw err; } @@ -91,7 +102,7 @@ export default class BluetoothTransport extends Transport { service = await this.gattServer.getPrimaryService(NUS_SERVICE_UUID); } catch (cause) { const err = new Error("nus_service_not_found"); - err.code = "NUS_SERVICE_NOT_FOUND"; + (err as Error & { code?: string; status?: number; body?: unknown }).code = "NUS_SERVICE_NOT_FOUND"; err.cause = cause; await this._safeDisconnect(); throw err; @@ -102,7 +113,7 @@ export default class BluetoothTransport extends Transport { this.txCharacteristic = await service.getCharacteristic(NUS_TX_CHARACTERISTIC_UUID); } catch (cause) { const err = new Error("nus_characteristics_missing"); - err.code = "NUS_CHARACTERISTICS_MISSING"; + (err as Error & { code?: string; status?: number; body?: unknown }).code = "NUS_CHARACTERISTICS_MISSING"; err.cause = cause; await this._safeDisconnect(); throw err; diff --git a/meshchatx/src/frontend/js/rnode/transports/SerialTransport.js b/meshchatx/src/frontend/js/rnode/transports/SerialTransport.ts similarity index 83% rename from meshchatx/src/frontend/js/rnode/transports/SerialTransport.js rename to meshchatx/src/frontend/js/rnode/transports/SerialTransport.ts index 4bbdb565f..d4ca4c0c6 100644 --- a/meshchatx/src/frontend/js/rnode/transports/SerialTransport.js +++ b/meshchatx/src/frontend/js/rnode/transports/SerialTransport.ts @@ -10,11 +10,17 @@ import Transport from "./Transport.js"; * surface area is centralised. */ export default class SerialTransport extends Transport { + declare env: any; + declare opened: boolean; + declare polyfilled: any; + declare port: any; + declare readable: any; + declare writable: any; /** * @param {SerialPort} port wrapped serial port * @param {{ polyfilled?: boolean, env?: object }} [options] */ - constructor(port, options = {}) { + constructor(port, options: any = {}) { super("serial"); if (!port) { throw new Error("SerialTransport requires a SerialPort instance"); @@ -31,11 +37,11 @@ export default class SerialTransport extends Transport { * * @param {{ filters?: Array, env?: object }} [options] */ - static async request(options = {}) { + static async request(options: any = {}) { const env = options.env || (typeof window !== "undefined" ? window : globalThis); if (!env.navigator?.serial) { const err = new Error("web_serial_unavailable"); - err.code = "WEB_SERIAL_UNAVAILABLE"; + (err as Error & { code?: string; status?: number; body?: unknown }).code = "WEB_SERIAL_UNAVAILABLE"; throw err; } let port; @@ -47,12 +53,12 @@ export default class SerialTransport extends Transport { const message = cause?.message || String(cause); if (cause?.name === "NotFoundError" || /No port selected/i.test(message)) { const err = new Error("no_port_selected"); - err.code = "NO_PORT_SELECTED"; + (err as Error & { code?: string; status?: number; body?: unknown }).code = "NO_PORT_SELECTED"; err.cause = cause; throw err; } const err = new Error(message); - err.code = "PORT_REQUEST_FAILED"; + (err as Error & { code?: string; status?: number; body?: unknown }).code = "PORT_REQUEST_FAILED"; err.cause = cause; throw err; } @@ -60,7 +66,7 @@ export default class SerialTransport extends Transport { return new SerialTransport(port, { polyfilled, env }); } - async open(opts = {}) { + async open(opts: any = {}) { const baudRate = typeof opts.baudRate === "number" ? opts.baudRate : 115200; await this.port.open({ baudRate }); this.readable = this.port.readable; diff --git a/meshchatx/src/frontend/js/rnode/transports/Transport.js b/meshchatx/src/frontend/js/rnode/transports/Transport.ts similarity index 94% rename from meshchatx/src/frontend/js/rnode/transports/Transport.js rename to meshchatx/src/frontend/js/rnode/transports/Transport.ts index bf6dffe99..c563d8af1 100644 --- a/meshchatx/src/frontend/js/rnode/transports/Transport.js +++ b/meshchatx/src/frontend/js/rnode/transports/Transport.ts @@ -20,6 +20,9 @@ */ export default class Transport { + declare kind: any; + declare readable: any; + declare writable: any; constructor(kind) { this.kind = kind; this.readable = null; diff --git a/meshchatx/src/frontend/js/rnode/transports/WifiTransport.js b/meshchatx/src/frontend/js/rnode/transports/WifiTransport.ts similarity index 82% rename from meshchatx/src/frontend/js/rnode/transports/WifiTransport.js rename to meshchatx/src/frontend/js/rnode/transports/WifiTransport.ts index 876570219..a4c6891c5 100644 --- a/meshchatx/src/frontend/js/rnode/transports/WifiTransport.js +++ b/meshchatx/src/frontend/js/rnode/transports/WifiTransport.ts @@ -63,15 +63,20 @@ function isValidHostname(trimmed) { } export default class WifiTransport extends Transport { + declare env: any; + declare host: any; + declare opened: boolean; + declare scheme: any; + declare timeoutMs: any; /** * @param {string} ipAddressOrHost * @param {{ timeoutMs?: number, env?: object, scheme?: string }} [options] */ - constructor(ipAddressOrHost, options = {}) { + constructor(ipAddressOrHost, options: any = {}) { super("wifi"); if (!WifiTransport.isValidHost(ipAddressOrHost)) { const err = new Error("invalid_host"); - err.code = "INVALID_HOST"; + (err as Error & { code?: string; status?: number; body?: unknown }).code = "INVALID_HOST"; throw err; } this.host = ipAddressOrHost; @@ -109,16 +114,16 @@ export default class WifiTransport extends Transport { async upload(blob, onProgress) { if (!blob) { const err = new Error("no_payload"); - err.code = "NO_PAYLOAD"; + (err as Error & { code?: string; status?: number; body?: unknown }).code = "NO_PAYLOAD"; throw err; } const Xhr = this.env.XMLHttpRequest; if (!Xhr) { const err = new Error("xhr_unavailable"); - err.code = "XHR_UNAVAILABLE"; + (err as Error & { code?: string; status?: number; body?: unknown }).code = "XHR_UNAVAILABLE"; throw err; } - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { const xhr = new Xhr(); const url = `${this.scheme}://${this.host}/update`; xhr.open("POST", url, true); @@ -135,12 +140,12 @@ export default class WifiTransport extends Transport { xhr.ontimeout = () => { const err = new Error("upload_timeout"); - err.code = "UPLOAD_TIMEOUT"; + (err as Error & { code?: string; status?: number; body?: unknown }).code = "UPLOAD_TIMEOUT"; reject(err); }; xhr.onerror = () => { const err = new Error("network_error"); - err.code = "NETWORK_ERROR"; + (err as Error & { code?: string; status?: number; body?: unknown }).code = "NETWORK_ERROR"; reject(err); }; xhr.onload = () => { @@ -149,9 +154,9 @@ export default class WifiTransport extends Transport { return; } const err = new Error(`http_${xhr.status}`); - err.code = "HTTP_ERROR"; - err.status = xhr.status; - err.body = xhr.responseText; + (err as Error & { code?: string; status?: number; body?: unknown }).code = "HTTP_ERROR"; + (err as Error & { code?: string; status?: number; body?: unknown }).status = xhr.status; + (err as Error & { code?: string; status?: number; body?: unknown }).body = xhr.responseText; reject(err); }; diff --git a/meshchatx/src/frontend/js/rrcAvailableRooms.js b/meshchatx/src/frontend/js/rrcAvailableRooms.ts similarity index 98% rename from meshchatx/src/frontend/js/rrcAvailableRooms.js rename to meshchatx/src/frontend/js/rrcAvailableRooms.ts index 7bb0ce080..ebe429369 100644 --- a/meshchatx/src/frontend/js/rrcAvailableRooms.js +++ b/meshchatx/src/frontend/js/rrcAvailableRooms.ts @@ -63,7 +63,7 @@ export function applyAvailableRoomsSnapshot(_previous, next) { if (!next || typeof next !== "object" || Array.isArray(next)) { return {}; } - const out = {}; + const out: any = {}; for (const [name, topic] of Object.entries(next)) { if (typeof name !== "string" || !name) { continue; diff --git a/meshchatx/src/frontend/js/sandboxStatus.js b/meshchatx/src/frontend/js/sandboxStatus.ts similarity index 99% rename from meshchatx/src/frontend/js/sandboxStatus.js rename to meshchatx/src/frontend/js/sandboxStatus.ts index 342472163..155f4b0ef 100644 --- a/meshchatx/src/frontend/js/sandboxStatus.js +++ b/meshchatx/src/frontend/js/sandboxStatus.ts @@ -1,6 +1,6 @@ // SPDX-License-Identifier: 0BSD -const FEATURES = { +const FEATURES: any = { landlock: { active: "landlock_active", supported: "landlock_kernel_supported", diff --git a/meshchatx/src/frontend/js/settings/batterySaverBitrateApply.js b/meshchatx/src/frontend/js/settings/batterySaverBitrateApply.ts similarity index 94% rename from meshchatx/src/frontend/js/settings/batterySaverBitrateApply.js rename to meshchatx/src/frontend/js/settings/batterySaverBitrateApply.ts index d569bfe40..4ea607e7e 100644 --- a/meshchatx/src/frontend/js/settings/batterySaverBitrateApply.js +++ b/meshchatx/src/frontend/js/settings/batterySaverBitrateApply.ts @@ -7,7 +7,7 @@ import { buildBitrateApplyPayload, loadBatterySaverPrefs, saveBatterySaverPrefs * @param {{ api?: { get: Function, post: Function }, reload?: boolean }} [opts] * @returns {Promise<{ updated: string[], reloaded: boolean }>} */ -export async function applyBatterySaverBitrateLimits(opts = {}) { +export async function applyBatterySaverBitrateLimits(opts: any = {}) { const api = opts.api || (typeof window !== "undefined" ? window.api : null); if (!api) { throw new Error("API client unavailable"); @@ -49,7 +49,7 @@ export async function applyBatterySaverBitrateLimits(opts = {}) { * Restore bitrates saved before the last apply, then reload RNS. * @param {{ api?: { post: Function }, reload?: boolean }} [opts] */ -export async function restoreBatterySaverBitrateLimits(opts = {}) { +export async function restoreBatterySaverBitrateLimits(opts: any = {}) { const api = opts.api || (typeof window !== "undefined" ? window.api : null); if (!api) { throw new Error("API client unavailable"); diff --git a/meshchatx/src/frontend/js/settings/batterySaverPrefs.js b/meshchatx/src/frontend/js/settings/batterySaverPrefs.ts similarity index 98% rename from meshchatx/src/frontend/js/settings/batterySaverPrefs.js rename to meshchatx/src/frontend/js/settings/batterySaverPrefs.ts index 9cb375397..587fedc51 100644 --- a/meshchatx/src/frontend/js/settings/batterySaverPrefs.js +++ b/meshchatx/src/frontend/js/settings/batterySaverPrefs.ts @@ -54,7 +54,7 @@ export function normalizeBitrateLimitsMap(raw) { return {}; } /** @type {Record} */ - const out = {}; + const out: any = {}; for (const [name, value] of Object.entries(raw)) { const key = String(name || "").trim(); if (!key) continue; @@ -74,7 +74,7 @@ export function normalizeBitratePreviousMap(raw) { return {}; } /** @type {Record} */ - const out = {}; + const out: any = {}; for (const [name, value] of Object.entries(raw)) { const key = String(name || "").trim(); if (!key) continue; @@ -224,8 +224,8 @@ export function activeBatterySaverMeasures(prefs = loadBatterySaverPrefs()) { * @returns {{ bitrates: Record, previous: Record }} */ export function buildBitrateApplyPayload(interfacesMap, limits) { - const bitrates = {}; - const previous = {}; + const bitrates: any = {}; + const previous: any = {}; const src = interfacesMap && typeof interfacesMap === "object" ? interfacesMap : {}; const lim = normalizeBitrateLimitsMap(limits); for (const [name, bps] of Object.entries(lim)) { diff --git a/meshchatx/src/frontend/js/settings/incomingDeliveryLimit.js b/meshchatx/src/frontend/js/settings/incomingDeliveryLimit.ts similarity index 100% rename from meshchatx/src/frontend/js/settings/incomingDeliveryLimit.js rename to meshchatx/src/frontend/js/settings/incomingDeliveryLimit.ts diff --git a/meshchatx/src/frontend/js/settings/settingsConfigService.js b/meshchatx/src/frontend/js/settings/settingsConfigService.ts similarity index 100% rename from meshchatx/src/frontend/js/settings/settingsConfigService.js rename to meshchatx/src/frontend/js/settings/settingsConfigService.ts diff --git a/meshchatx/src/frontend/js/settings/settingsMaintenanceClient.js b/meshchatx/src/frontend/js/settings/settingsMaintenanceClient.ts similarity index 100% rename from meshchatx/src/frontend/js/settings/settingsMaintenanceClient.js rename to meshchatx/src/frontend/js/settings/settingsMaintenanceClient.ts diff --git a/meshchatx/src/frontend/js/settings/settingsReticulumInstanceService.js b/meshchatx/src/frontend/js/settings/settingsReticulumInstanceService.ts similarity index 100% rename from meshchatx/src/frontend/js/settings/settingsReticulumInstanceService.js rename to meshchatx/src/frontend/js/settings/settingsReticulumInstanceService.ts diff --git a/meshchatx/src/frontend/js/settings/settingsTabs.js b/meshchatx/src/frontend/js/settings/settingsTabs.ts similarity index 100% rename from meshchatx/src/frontend/js/settings/settingsTabs.js rename to meshchatx/src/frontend/js/settings/settingsTabs.ts diff --git a/meshchatx/src/frontend/js/settings/settingsTransportService.js b/meshchatx/src/frontend/js/settings/settingsTransportService.ts similarity index 100% rename from meshchatx/src/frontend/js/settings/settingsTransportService.js rename to meshchatx/src/frontend/js/settings/settingsTransportService.ts diff --git a/meshchatx/src/frontend/js/settings/settingsVisualiserPrefs.js b/meshchatx/src/frontend/js/settings/settingsVisualiserPrefs.ts similarity index 94% rename from meshchatx/src/frontend/js/settings/settingsVisualiserPrefs.js rename to meshchatx/src/frontend/js/settings/settingsVisualiserPrefs.ts index f9857d0d9..14c32fc5b 100644 --- a/meshchatx/src/frontend/js/settings/settingsVisualiserPrefs.js +++ b/meshchatx/src/frontend/js/settings/settingsVisualiserPrefs.ts @@ -139,7 +139,7 @@ export function persistVisualiserShowDiscovered(val) { * @param {boolean} val * @param {{ emit?: boolean }} [opts] */ -export function persistVisualiserLiveLayout(val, opts = {}) { +export function persistVisualiserLiveLayout(val, opts: any = {}) { writeBool(KEY_LIVE_LAYOUT, val === true); if (opts.emit !== false) { GlobalEmitter.emit(VISUALISER_DISPLAY_PREFS_CHANGED); @@ -150,7 +150,7 @@ export function persistVisualiserLiveLayout(val, opts = {}) { * @param {boolean} val * @param {{ emit?: boolean }} [opts] */ -export function persistVisualiserAutoReload(val, opts = {}) { +export function persistVisualiserAutoReload(val, opts: any = {}) { writeBool(KEY_AUTO_RELOAD, val === true); if (opts.emit !== false) { GlobalEmitter.emit(VISUALISER_DISPLAY_PREFS_CHANGED); @@ -161,7 +161,7 @@ export function persistVisualiserAutoReload(val, opts = {}) { * @param {unknown} val * @param {{ emit?: boolean }} [opts] */ -export function persistVisualiserRenderer(val, opts = {}) { +export function persistVisualiserRenderer(val, opts: any = {}) { const next = normalizeVisualiserRenderer(val); try { if (typeof localStorage !== "undefined") { @@ -179,7 +179,7 @@ export function persistVisualiserRenderer(val, opts = {}) { * @param {unknown} val * @param {{ emit?: boolean }} [opts] */ -export function persistVisualiserViewMode(val, opts = {}) { +export function persistVisualiserViewMode(val, opts: any = {}) { const next = normalizeVisualiserViewMode(val); try { if (typeof localStorage !== "undefined") { diff --git a/meshchatx/src/frontend/js/settingsSearchUtils.js b/meshchatx/src/frontend/js/settingsSearchUtils.ts similarity index 100% rename from meshchatx/src/frontend/js/settingsSearchUtils.js rename to meshchatx/src/frontend/js/settingsSearchUtils.ts diff --git a/meshchatx/src/frontend/js/sidebarListVirtual.js b/meshchatx/src/frontend/js/sidebarListVirtual.ts similarity index 100% rename from meshchatx/src/frontend/js/sidebarListVirtual.js rename to meshchatx/src/frontend/js/sidebarListVirtual.ts diff --git a/meshchatx/src/frontend/js/svelte/appState.svelte.js b/meshchatx/src/frontend/js/svelte/appState.svelte.ts similarity index 100% rename from meshchatx/src/frontend/js/svelte/appState.svelte.js rename to meshchatx/src/frontend/js/svelte/appState.svelte.ts diff --git a/meshchatx/src/frontend/js/telemetryBatteryChartSpec.js b/meshchatx/src/frontend/js/telemetryBatteryChartSpec.ts similarity index 97% rename from meshchatx/src/frontend/js/telemetryBatteryChartSpec.js rename to meshchatx/src/frontend/js/telemetryBatteryChartSpec.ts index f1d04588d..8f7c1f8d9 100644 --- a/meshchatx/src/frontend/js/telemetryBatteryChartSpec.js +++ b/meshchatx/src/frontend/js/telemetryBatteryChartSpec.ts @@ -4,9 +4,9 @@ * without leaving a short square plot. */ -export const BATTERY_CHART_VIEWBOX = { w: 100, h: 46 }; +export const BATTERY_CHART_VIEWBOX: any = { w: 100, h: 46 }; -export const BATTERY_CHART_BOUNDS = { +export const BATTERY_CHART_BOUNDS: any = { PL: 10, PR: 99, PT: 5, diff --git a/meshchatx/src/frontend/js/telephone-pcm-capture.worklet.js b/meshchatx/src/frontend/js/telephone-pcm-capture.worklet.ts similarity index 73% rename from meshchatx/src/frontend/js/telephone-pcm-capture.worklet.js rename to meshchatx/src/frontend/js/telephone-pcm-capture.worklet.ts index 3d096542e..0c273eac8 100644 --- a/meshchatx/src/frontend/js/telephone-pcm-capture.worklet.js +++ b/meshchatx/src/frontend/js/telephone-pcm-capture.worklet.ts @@ -1,7 +1,18 @@ // SPDX-License-Identifier: 0BSD +export {}; + +declare class AudioWorkletProcessor { + readonly port: MessagePort; +} + +declare function registerProcessor( + name: string, + processorCtor: new () => AudioWorkletProcessor +): void; + class TelephonePcmCaptureProcessor extends AudioWorkletProcessor { - process(inputs) { + process(inputs: Float32Array[][]) { const input = inputs[0]; if (!input || input.length === 0) { return true; diff --git a/meshchatx/src/frontend/js/terminalRender.js b/meshchatx/src/frontend/js/terminalRender.ts similarity index 100% rename from meshchatx/src/frontend/js/terminalRender.js rename to meshchatx/src/frontend/js/terminalRender.ts diff --git a/meshchatx/src/frontend/js/tgsDecode.js b/meshchatx/src/frontend/js/tgsDecode.ts similarity index 100% rename from meshchatx/src/frontend/js/tgsDecode.js rename to meshchatx/src/frontend/js/tgsDecode.ts diff --git a/meshchatx/src/frontend/js/webAudioMicPermission.js b/meshchatx/src/frontend/js/webAudioMicPermission.ts similarity index 98% rename from meshchatx/src/frontend/js/webAudioMicPermission.js rename to meshchatx/src/frontend/js/webAudioMicPermission.ts index 381896a85..9e323b617 100644 --- a/meshchatx/src/frontend/js/webAudioMicPermission.js +++ b/meshchatx/src/frontend/js/webAudioMicPermission.ts @@ -41,7 +41,7 @@ export async function queryMicrophonePermissionState(nav = globalThis.navigator) } } -export function classifyGetUserMediaError(error, { permissionState = null, isBrave = false } = {}) { +export function classifyGetUserMediaError(error, { permissionState = null, isBrave = false }: any = {}) { const name = error?.name; if (name === "NotAllowedError" || name === "SecurityError") { return "call.microphone_permission_denied"; diff --git a/meshchatx/src/frontend/js/wsConnectionSupport.js b/meshchatx/src/frontend/js/wsConnectionSupport.ts similarity index 100% rename from meshchatx/src/frontend/js/wsConnectionSupport.js rename to meshchatx/src/frontend/js/wsConnectionSupport.ts diff --git a/meshchatx/src/frontend/js/wsLiveSync.js b/meshchatx/src/frontend/js/wsLiveSync.ts similarity index 99% rename from meshchatx/src/frontend/js/wsLiveSync.js rename to meshchatx/src/frontend/js/wsLiveSync.ts index c6ffe3b9f..677d9020b 100644 --- a/meshchatx/src/frontend/js/wsLiveSync.js +++ b/meshchatx/src/frontend/js/wsLiveSync.ts @@ -10,7 +10,7 @@ const STORAGE_PREFIX = "meshchatx_ws_last_seq:"; * @param {string} [origin] * @returns {string} */ -export function seqStorageKey(origin) { +export function seqStorageKey(origin?: string) { const o = origin || (typeof window !== "undefined" ? window.location.origin : ""); return `${STORAGE_PREFIX}${o || "default"}`; } diff --git a/meshchatx/src/frontend/js/wtJsonFraming.js b/meshchatx/src/frontend/js/wtJsonFraming.ts similarity index 100% rename from meshchatx/src/frontend/js/wtJsonFraming.js rename to meshchatx/src/frontend/js/wtJsonFraming.ts diff --git a/meshchatx/src/frontend/libs/clickOutside.js b/meshchatx/src/frontend/libs/clickOutside.ts similarity index 99% rename from meshchatx/src/frontend/libs/clickOutside.js rename to meshchatx/src/frontend/libs/clickOutside.ts index 2f77b7670..21037dd9c 100644 --- a/meshchatx/src/frontend/libs/clickOutside.js +++ b/meshchatx/src/frontend/libs/clickOutside.ts @@ -188,7 +188,7 @@ export const clickOutsideDirective = HAS_WINDOW } : {}; -const plugin = { +const plugin: any = { install(app) { app.directive("click-outside", clickOutsideDirective); }, diff --git a/meshchatx/src/frontend/libs/datetime.js b/meshchatx/src/frontend/libs/datetime.ts similarity index 100% rename from meshchatx/src/frontend/libs/datetime.js rename to meshchatx/src/frontend/libs/datetime.ts diff --git a/meshchatx/src/frontend/libs/emitter.js b/meshchatx/src/frontend/libs/emitter.ts similarity index 100% rename from meshchatx/src/frontend/libs/emitter.js rename to meshchatx/src/frontend/libs/emitter.ts diff --git a/meshchatx/src/frontend/libs/index.js b/meshchatx/src/frontend/libs/index.ts similarity index 100% rename from meshchatx/src/frontend/libs/index.js rename to meshchatx/src/frontend/libs/index.ts diff --git a/meshchatx/src/frontend/libs/uuid.js b/meshchatx/src/frontend/libs/uuid.ts similarity index 51% rename from meshchatx/src/frontend/libs/uuid.js rename to meshchatx/src/frontend/libs/uuid.ts index fdb833818..298916e36 100644 --- a/meshchatx/src/frontend/libs/uuid.js +++ b/meshchatx/src/frontend/libs/uuid.ts @@ -2,45 +2,30 @@ const UUID_V4_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; -/** - * @typedef {object} UuidCrypto - * @property {(array: Uint8Array) => Uint8Array} [getRandomValues] - * @property {() => string} [randomUUID] - */ +type UuidCrypto = { + getRandomValues?: (array: Uint8Array) => Uint8Array; + randomUUID?: () => string; +}; -/** - * @returns {UuidCrypto | undefined} - */ -function getCrypto() { +type UuidOptions = { + crypto?: UuidCrypto | null; +}; + +function getCrypto(): UuidCrypto | undefined { if (typeof globalThis !== "undefined" && globalThis.crypto) { - return /** @type {UuidCrypto} */ (globalThis.crypto); + return globalThis.crypto as UuidCrypto; } return undefined; } -/** - * Resolve crypto source. Pass `crypto: null` to force the non-crypto fallback. - * - * @param {{ crypto?: UuidCrypto | null }} [options] - * @returns {UuidCrypto | null | undefined} - */ -export function resolveCrypto(options = {}) { +export function resolveCrypto(options: UuidOptions = {}): UuidCrypto | null | undefined { if (Object.prototype.hasOwnProperty.call(options, "crypto")) { return options.crypto; } return getCrypto(); } -/** - * Fill a Uint8Array with cryptographically strong random values when possible. - * Falls back to Math.random only when Web Crypto is unavailable. - * Do not use the Math.random path for secrets. - * - * @param {Uint8Array} bytes - * @param {{ crypto?: UuidCrypto | null }} [options] - * @returns {Uint8Array} - */ -export function fillRandomBytes(bytes, options = {}) { +export function fillRandomBytes(bytes: Uint8Array, options: UuidOptions = {}): Uint8Array { if (!(bytes instanceof Uint8Array)) { throw new TypeError("fillRandomBytes expects a Uint8Array"); } @@ -55,13 +40,7 @@ export function fillRandomBytes(bytes, options = {}) { return bytes; } -/** - * RFC 4122 version 4 UUID string. - * - * @param {{ crypto?: UuidCrypto | null }} [options] - * @returns {string} - */ -export function randomUuidV4(options = {}) { +export function randomUuidV4(options: UuidOptions = {}): string { const c = resolveCrypto(options); if (c && typeof c.randomUUID === "function") { return c.randomUUID(); @@ -73,21 +52,11 @@ export function randomUuidV4(options = {}) { return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; } -/** - * Alias matching the previous `uuid` package import style. - * - * @param {{ crypto?: UuidCrypto | null }} [options] - * @returns {string} - */ -export function uuidv4(options) { +export function uuidv4(options: UuidOptions = {}): string { return randomUuidV4(options); } -/** - * @param {unknown} value - * @returns {boolean} - */ -export function isUuidV4(value) { +export function isUuidV4(value: unknown): boolean { return typeof value === "string" && UUID_V4_RE.test(value); } diff --git a/meshchatx/src/frontend/locales/de.json b/meshchatx/src/frontend/locales/de.json index 008374d9a..8811b94f5 100644 --- a/meshchatx/src/frontend/locales/de.json +++ b/meshchatx/src/frontend/locales/de.json @@ -2573,9 +2573,9 @@ "col_license": "Lizenz" }, "tools": { - "utilities": "Dienstprogramme", - "power_tools": "Dienstprogramme", - "diagnostics_description": "Mesh- und Reticulum-Werkzeuge an einem Ort: Erreichbarkeit, Pfade, Dateitransfer, Übersetzung, Dokumentation und Hardware-Hilfen. Öffnen Sie eine Zeile für die vollständige Ansicht.", + "utilities": "Werkzeuge", + "power_tools": "Werkzeuge", + "diagnostics_description": "Erreichbarkeit, Pfade, Dateitransfer, Übersetzung, Docs und Hardware-Hilfen.", "back_to_tools": "Zurück zu Werkzeugen", "beta_badge": "Beta", "alpha_badge": "Alpha", @@ -3242,7 +3242,8 @@ "rnx": { "title": "RNX", "description": "Fernausführung von Befehlen über Reticulum" - } + }, + "search_placeholder": "{count} Werkzeuge durchsuchen" }, "ping": { "title": "Mesh-Peers anpingen", @@ -4619,7 +4620,6 @@ "set_password": "Passwort festlegen", "login": "Anmelden", "passwords_mismatch": "Passwörter stimmen nicht überein", - "altcha_required": "Schließen Sie zuerst die Verifizierungsaufgabe ab", "status_check_failed": "Authentifizierungsstatus konnte nicht geprüft werden", "failed": "Authentifizierung fehlgeschlagen" }, diff --git a/meshchatx/src/frontend/locales/en.json b/meshchatx/src/frontend/locales/en.json index 1b7e317ad..3f005cf2a 100644 --- a/meshchatx/src/frontend/locales/en.json +++ b/meshchatx/src/frontend/locales/en.json @@ -1946,6 +1946,8 @@ "downloading": "Downloading", "hops": "{count} hops", "hops_away": "{count} hops away", + "path_hops_via": "{hops} via {iface}", + "path_hops_unknown_iface": "unknown interface", "snr": "SNR {snr}", "stamp_cost": "Stamp Cost {cost}", "stamp_ticket_valid": "Outbound stamp ticket active (instant send). Expires {expires}.", @@ -2821,9 +2823,10 @@ "col_license": "License" }, "tools": { - "utilities": "Utilities", - "power_tools": "Utilities", - "diagnostics_description": "Mesh and Reticulum utilities in one place: reachability, paths, file transfer, translation, documentation, and hardware helpers. Open any row for the full tool.", + "utilities": "Tools", + "power_tools": "Tools", + "diagnostics_description": "Reachability, paths, file transfer, translation, docs, and hardware helpers.", + "search_placeholder": "Search {count} tools", "back_to_tools": "Back to tools", "beta_badge": "Beta", "alpha_badge": "Alpha", @@ -4723,7 +4726,6 @@ "set_password": "Set Password", "login": "Login", "passwords_mismatch": "Passwords do not match", - "altcha_required": "Complete the verification challenge first", "status_check_failed": "Failed to check authentication status", "failed": "Authentication failed" }, diff --git a/meshchatx/src/frontend/locales/es.json b/meshchatx/src/frontend/locales/es.json index 6b0bbe988..7c131103e 100644 --- a/meshchatx/src/frontend/locales/es.json +++ b/meshchatx/src/frontend/locales/es.json @@ -2817,9 +2817,9 @@ "col_license": "Licencia" }, "tools": { - "utilities": "Utilidades", - "power_tools": "Utilidades", - "diagnostics_description": "Utilidades de malla y Reticulum en un mismo sitio: alcance, rutas, transferencia de archivos, traducción, documentación y ayudas de hardware. Abra una fila para la herramienta completa.", + "utilities": "Herramientas", + "power_tools": "Herramientas", + "diagnostics_description": "Alcance, rutas, transferencia de archivos, traducción, docs y hardware.", "back_to_tools": "Volver a herramientas", "beta_badge": "Beta", "alpha_badge": "Alfa", @@ -3486,7 +3486,8 @@ "rnx": { "title": "RNX", "description": "Ejecución remota de comandos sobre Reticulum" - } + }, + "search_placeholder": "Buscar {count} herramientas" }, "ping": { "title": "Hacer ping a pares de Mesh", @@ -4619,7 +4620,6 @@ "set_password": "Establecer contraseña", "login": "Iniciar sesión", "passwords_mismatch": "Las contraseñas no coinciden", - "altcha_required": "Complete primero el desafío de verificación", "status_check_failed": "No se pudo comprobar el estado de autenticación", "failed": "Error de autenticación" }, diff --git a/meshchatx/src/frontend/locales/fi.json b/meshchatx/src/frontend/locales/fi.json index e60c1310a..454222013 100644 --- a/meshchatx/src/frontend/locales/fi.json +++ b/meshchatx/src/frontend/locales/fi.json @@ -2819,7 +2819,7 @@ "tools": { "utilities": "Työkalut", "power_tools": "Työkalut", - "diagnostics_description": "Mesh- ja Reticulum-apuohjelmat yhdessä paikassa: saavutettavuus, polut, tiedostonsiirto, käännös, dokumentaatio ja laiteapu. Avaa mikä tahansa rivi saadaksesi koko työkalun.", + "diagnostics_description": "Saavutettavuus, polut, tiedostonsiirto, käännös, docs ja laiteapu.", "back_to_tools": "Takaisin työkaluihin", "beta_badge": "Beta", "alpha_badge": "Alpha", @@ -3486,7 +3486,8 @@ "rnx": { "title": "RNX", "description": "Etäkomentojen suoritus Reticulumissa" - } + }, + "search_placeholder": "Hae {count} työkalua" }, "ping": { "title": "Luotaa verkon vertaisia", @@ -4619,7 +4620,6 @@ "set_password": "Aseta salasana", "login": "Kirjaudu", "passwords_mismatch": "Salasanat eivät täsmää", - "altcha_required": "Suorita ensin vahvistustehtävä", "status_check_failed": "Tunnistautumistilan tarkistus epäonnistui", "failed": "Tunnistautuminen epäonnistui" }, diff --git a/meshchatx/src/frontend/locales/fr.json b/meshchatx/src/frontend/locales/fr.json index 810e66399..ccf2a359b 100644 --- a/meshchatx/src/frontend/locales/fr.json +++ b/meshchatx/src/frontend/locales/fr.json @@ -2817,9 +2817,9 @@ "col_license": "Licence" }, "tools": { - "utilities": "Services publics", - "power_tools": "Services publics", - "diagnostics_description": "Utilitaires mesh et Reticulum au même endroit : joignabilité, chemins, transfert de fichiers, traduction, documentation et aides matérielles. Ouvrez une ligne pour l'outil complet.", + "utilities": "Outils", + "power_tools": "Outils", + "diagnostics_description": "Joignabilité, chemins, transfert de fichiers, traduction, docs et matériel.", "back_to_tools": "Retour aux outils", "beta_badge": "Bêta", "alpha_badge": "Alpha", @@ -3486,7 +3486,8 @@ "rnx": { "title": "RNX", "description": "Exécution distante de commandes sur Reticulum" - } + }, + "search_placeholder": "Rechercher {count} outils" }, "ping": { "title": "Ping des pairs Mesh", @@ -4619,7 +4620,6 @@ "set_password": "Définir le mot de passe", "login": "Connexion", "passwords_mismatch": "Les mots de passe ne correspondent pas", - "altcha_required": "Terminez d'abord le défi de vérification", "status_check_failed": "Impossible de vérifier l'état d'authentification", "failed": "Échec de l'authentification" }, diff --git a/meshchatx/src/frontend/locales/it.json b/meshchatx/src/frontend/locales/it.json index f6725562e..0cd10a169 100644 --- a/meshchatx/src/frontend/locales/it.json +++ b/meshchatx/src/frontend/locales/it.json @@ -2871,9 +2871,9 @@ "col_license": "Licenza" }, "tools": { - "utilities": "Utilità", - "power_tools": "Utilità", - "diagnostics_description": "Strumenti mesh e Reticulum in un unico posto: raggiungibilità, percorsi, trasferimento file, traduzione, documentazione e assistenza hardware. Apri una riga per lo strumento completo.", + "utilities": "Strumenti", + "power_tools": "Strumenti", + "diagnostics_description": "Raggiungibilità, percorsi, trasferimento file, traduzione, docs e hardware.", "back_to_tools": "Torna agli strumenti", "beta_badge": "Beta", "alpha_badge": "Alpha", @@ -3540,7 +3540,8 @@ "rnx": { "title": "RNX", "description": "Esecuzione remota di comandi su Reticulum" - } + }, + "search_placeholder": "Cerca {count} strumenti" }, "ping": { "title": "Ping Peer della Mesh", @@ -4619,7 +4620,6 @@ "set_password": "Imposta password", "login": "Accedi", "passwords_mismatch": "Le password non corrispondono", - "altcha_required": "Completa prima la verifica", "status_check_failed": "Impossibile verificare lo stato di autenticazione", "failed": "Autenticazione non riuscita" }, diff --git a/meshchatx/src/frontend/locales/nl.json b/meshchatx/src/frontend/locales/nl.json index 0096bfe5e..3ce680821 100644 --- a/meshchatx/src/frontend/locales/nl.json +++ b/meshchatx/src/frontend/locales/nl.json @@ -2819,7 +2819,7 @@ "tools": { "utilities": "Hulpmiddelen", "power_tools": "Hulpmiddelen", - "diagnostics_description": "Mesh- en Reticulumhulpprogramma's op één plek: bereikbaarheid, paden, bestandsoverdracht, vertaling, documentatie en hardwarehulpen. Open een rij voor de volledige tool.", + "diagnostics_description": "Bereikbaarheid, paden, bestandsoverdracht, vertaling, docs en hardware.", "back_to_tools": "Terug naar gereedschap", "beta_badge": "Bèta", "alpha_badge": "Alpha", @@ -3486,7 +3486,8 @@ "rnx": { "title": "RNX", "description": "Externe opdrachtuitvoering over Reticulum" - } + }, + "search_placeholder": "Zoek {count} hulpmiddelen" }, "ping": { "title": "Ping Mesh Peers", @@ -4619,7 +4620,6 @@ "set_password": "Wachtwoord instellen", "login": "Inloggen", "passwords_mismatch": "Wachtwoorden komen niet overeen", - "altcha_required": "Voltooi eerst de verificatie-uitdaging", "status_check_failed": "Authenticatiestatus kon niet worden gecontroleerd", "failed": "Authenticatie mislukt" }, diff --git a/meshchatx/src/frontend/locales/ru.json b/meshchatx/src/frontend/locales/ru.json index 3f64c8849..f5d433e0e 100644 --- a/meshchatx/src/frontend/locales/ru.json +++ b/meshchatx/src/frontend/locales/ru.json @@ -2573,9 +2573,9 @@ "col_license": "Лицензия" }, "tools": { - "utilities": "Утилиты", - "power_tools": "Утилиты", - "diagnostics_description": "Сетевые и Reticulum-утилиты в одном месте: доступность, маршруты, передача файлов, перевод, документация и помощь с оборудованием. Откройте строку для полного инструмента.", + "utilities": "Инструменты", + "power_tools": "Инструменты", + "diagnostics_description": "Доступность, маршруты, передача файлов, перевод, docs и оборудование.", "back_to_tools": "Назад к инструментам", "beta_badge": "Beta", "alpha_badge": "Alpha", @@ -3242,7 +3242,8 @@ "rnx": { "title": "RNX", "description": "Удалённое выполнение команд через Reticulum" - } + }, + "search_placeholder": "Поиск по {count} инструментам" }, "ping": { "title": "Пинг узлов сети", @@ -4619,7 +4620,6 @@ "set_password": "Задать пароль", "login": "Войти", "passwords_mismatch": "Пароли не совпадают", - "altcha_required": "Сначала завершите проверку", "status_check_failed": "Не удалось проверить состояние аутентификации", "failed": "Ошибка аутентификации" }, diff --git a/meshchatx/src/frontend/locales/zh.json b/meshchatx/src/frontend/locales/zh.json index 4bbb61f8e..23d140a44 100644 --- a/meshchatx/src/frontend/locales/zh.json +++ b/meshchatx/src/frontend/locales/zh.json @@ -2817,9 +2817,9 @@ "col_license": "许可证" }, "tools": { - "utilities": "公用事业", - "power_tools": "公用事业", - "diagnostics_description": "网格与 Reticulum 常用工具集中在一处:可达性、路径、文件传输、翻译、文档与硬件辅助。点击任一行打开完整工具。", + "utilities": "工具", + "power_tools": "工具", + "diagnostics_description": "可达性、路径、文件传输、翻译、文档与硬件辅助。", "back_to_tools": "返回工具", "beta_badge": "Beta", "alpha_badge": "Alpha", @@ -3486,7 +3486,8 @@ "rnx": { "title": "RNX", "description": "通过 Reticulum 远程执行命令" - } + }, + "search_placeholder": "搜索 {count} 个工具" }, "ping": { "title": "Ping 网络端点", @@ -4619,7 +4620,6 @@ "set_password": "设置密码", "login": "登录", "passwords_mismatch": "两次输入的密码不一致", - "altcha_required": "请先完成验证挑战", "status_check_failed": "无法检查身份验证状态", "failed": "身份验证失败" }, diff --git a/meshchatx/src/frontend/main.js b/meshchatx/src/frontend/main.ts similarity index 93% rename from meshchatx/src/frontend/main.js rename to meshchatx/src/frontend/main.ts index c3dc67ab7..8b6db3cc4 100644 --- a/meshchatx/src/frontend/main.js +++ b/meshchatx/src/frontend/main.ts @@ -1,3 +1,5 @@ +// SPDX-License-Identifier: 0BSD + import { createApp } from "vue"; import { createRouter, createWebHashHistory } from "vue-router"; import { createI18n } from "vue-i18n"; @@ -10,7 +12,7 @@ import { registerTranslator, registerFallbackMessages } from "./js/i18n.js"; injectMeshchatThemeVariables(); -window.DOMPurify = DOMPurify; +(window as unknown as Window & { DOMPurify: typeof DOMPurify }).DOMPurify = DOMPurify; import "./fonts/RobotoMonoNerdFont/font.css"; import { startCodec2ScriptsBackgroundLoad } from "./js/Codec2Loader"; import { createApiClient } from "./js/apiClient.js"; @@ -93,13 +95,6 @@ const router = createRouter({ interface_name: String, }, }, - { - name: "messages", - path: "/messages/:destinationHash?", - props: true, - meta: { stableKey: true }, - component: () => import("./components/messages/MessagesPage.vue"), - }, { name: "map", path: "/map", @@ -112,13 +107,6 @@ const router = createRouter({ meta: { popoutType: "map", isPopout: true }, component: () => import("./components/map/MapPage.vue"), }, - { - name: "messages-popout", - path: "/popout/messages/:destinationHash?", - props: true, - meta: { popoutType: "conversation", isPopout: true }, - component: () => import("./components/messages/MessagesPage.vue"), - }, { name: "network-visualiser", path: "/network-visualiser", @@ -304,7 +292,7 @@ const router = createRouter({ pluginHost.attachRouter(router); -window.api = createApiClient({ +const apiClient = createApiClient({ onAuthError() { GlobalState.authenticated = false; GlobalState.authEnabled = true; @@ -314,13 +302,14 @@ window.api = createApiClient({ } }, }); +(window as unknown as { api: typeof apiClient }).api = apiClient; import { waitForMeshReady, waitForNetworkReady } from "./js/networkStartupWait.js"; import { resolveAuthNavigation } from "./js/authSessionSync.js"; import { reportFatalError } from "./js/fatalErrorState.js"; import { showBootSplashFatalError } from "./js/bootSplashError.js"; -function setBootSplashLine(text) { +function setBootSplashLine(text: string): void { const splash = typeof document !== "undefined" ? document.getElementById("meshchatx-boot-splash") : null; const line = splash?.querySelector("[data-boot-line]"); if (line && text) { @@ -328,7 +317,7 @@ function setBootSplashLine(text) { } } -function markBootSplashError() { +function markBootSplashError(): void { const splash = typeof document !== "undefined" ? document.getElementById("meshchatx-boot-splash") : null; if (splash) { splash.setAttribute("data-state", "error"); @@ -358,7 +347,7 @@ if (networkReady) { GlobalState.networkReady = true; } try { - const statusResponse = await window.api.get("/api/v1/status"); + const statusResponse = await apiClient.get("/api/v1/status"); GlobalState.demoMode = !!statusResponse.data?.demo_mode; if (typeof statusResponse.data?.is_loopback_bind === "boolean") { GlobalState.isLoopbackBind = statusResponse.data.is_loopback_bind; @@ -380,13 +369,13 @@ if (networkReady) { } } try { - await fetchCsrfToken(window.api); + await fetchCsrfToken(apiClient); } catch { // CSRF token will be retried on the next mutating request if needed. } router.beforeEach(async (to, _from, next) => { - const decision = await resolveAuthNavigation(to, window.api); + const decision = await resolveAuthNavigation(to, apiClient); if (decision.allow) { next(); return; @@ -394,7 +383,7 @@ if (networkReady) { next(decision.redirect); }); - function registerMeshchatServiceWorker() { + function registerMeshchatServiceWorker(): void { if (typeof navigator === "undefined" || !("serviceWorker" in navigator)) { return; } @@ -417,7 +406,7 @@ if (networkReady) { } }); navigator.serviceWorker - .register("/service-worker.js", serviceWorkerRegisterOptions()) + .register("/service-worker.js", serviceWorkerRegisterOptions() as RegistrationOptions) .then((registration) => { const requestUpdate = () => { try { @@ -441,7 +430,7 @@ if (networkReady) { }); } - function removeBootSplash(splash) { + function removeBootSplash(splash: HTMLElement | null): void { if (!splash || !splash.isConnected) { return; } @@ -455,22 +444,23 @@ if (networkReady) { }, 160); } - function preloadCriticalRouteChunks() { - void import("./components/messages/MessagesPage.vue"); + function preloadCriticalRouteChunks(): void { + void import("./features/messages/MessagesPage.svelte"); void import("./features/contacts/ContactsPage.svelte"); void import("./components/interfaces/InterfacesPage.vue"); } - function bootstrap() { + function bootstrap(): void { registerMeshchatServiceWorker(); const splash = typeof document !== "undefined" ? document.getElementById("meshchatx-boot-splash") : null; const app = createApp(App); app.config.errorHandler = (err, _instance, info) => { console.error("MeshChatX render error:", err, info); + const renderError = err instanceof Error ? err : new Error(String(err)); reportFatalError({ kind: "frontend", - message: err?.message || String(err), - stack: err?.stack, + message: renderError.message, + stack: renderError.stack, context: typeof info === "string" ? info : "", }); }; @@ -557,17 +547,17 @@ if (networkReady) { } } - async function loadPluginsIfEnabled() { + async function loadPluginsIfEnabled(): Promise { if (!(GlobalState.authenticated || !GlobalState.authEnabled)) { return; } try { - const response = await window.api.get("/api/v1/plugins"); + const response = await apiClient.get("/api/v1/plugins"); GlobalState.pluginsEnabled = response.data?.plugins_enabled !== false; if (!GlobalState.pluginsEnabled) { return; } - await pluginHost.loadEnabledPlugins(window.api, i18n.global.locale.value); + await pluginHost.loadEnabledPlugins(apiClient, i18n.global.locale.value); } catch (error) { console.debug("Plugin host bootstrap failed:", error); } diff --git a/meshchatx/src/frontend/public/assets/css/electron-shell.css b/meshchatx/src/frontend/public/assets/css/electron-shell.css index fa78903c4..b270f8644 100644 --- a/meshchatx/src/frontend/public/assets/css/electron-shell.css +++ b/meshchatx/src/frontend/public/assets/css/electron-shell.css @@ -1,2 +1,2 @@ /*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */ -@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1;--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-pan-x:initial;--tw-pan-y:initial;--tw-pinch-zoom:initial;--tw-scroll-snap-strictness:proximity;--tw-space-y-reverse:0;--tw-space-x-reverse:0;--tw-divide-x-reverse:0;--tw-border-style:solid;--tw-divide-y-reverse:0;--tw-gradient-position:initial;--tw-gradient-from:#0000;--tw-gradient-via:#0000;--tw-gradient-to:#0000;--tw-gradient-stops:initial;--tw-gradient-via-stops:initial;--tw-gradient-from-position:0%;--tw-gradient-via-position:50%;--tw-gradient-to-position:100%;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial;--tw-ease:initial;--tw-contain-size:initial;--tw-contain-layout:initial;--tw-contain-paint:initial;--tw-contain-style:initial;--tw-content:""}}}@layer theme{:root,:host{--font-sans:-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-red-50:oklch(97.1% .013 17.38);--color-red-100:oklch(93.6% .032 17.717);--color-red-200:oklch(88.5% .062 18.334);--color-red-300:oklch(80.8% .114 19.571);--color-red-400:oklch(70.4% .191 22.216);--color-red-500:oklch(63.7% .237 25.331);--color-red-600:oklch(57.7% .245 27.325);--color-red-700:oklch(50.5% .213 27.518);--color-red-800:oklch(44.4% .177 26.899);--color-red-900:oklch(39.6% .141 25.723);--color-red-950:oklch(25.8% .092 26.042);--color-orange-50:oklch(98% .016 73.684);--color-orange-100:oklch(95.4% .038 75.164);--color-orange-200:oklch(90.1% .076 70.697);--color-orange-300:oklch(83.7% .128 66.29);--color-orange-400:oklch(75% .183 55.934);--color-orange-500:oklch(70.5% .213 47.604);--color-orange-600:oklch(64.6% .222 41.116);--color-orange-700:oklch(55.3% .195 38.402);--color-orange-800:oklch(47% .157 37.304);--color-orange-900:oklch(40.8% .123 38.172);--color-amber-50:oklch(98.7% .022 95.277);--color-amber-100:oklch(96.2% .059 95.617);--color-amber-200:oklch(92.4% .12 95.746);--color-amber-300:oklch(87.9% .169 91.605);--color-amber-400:oklch(82.8% .189 84.429);--color-amber-500:oklch(76.9% .188 70.08);--color-amber-600:oklch(66.6% .179 58.318);--color-amber-700:oklch(55.5% .163 48.998);--color-amber-800:oklch(47.3% .137 46.201);--color-amber-900:oklch(41.4% .112 45.904);--color-amber-950:oklch(27.9% .077 45.635);--color-yellow-50:oklch(98.7% .026 102.212);--color-yellow-100:oklch(97.3% .071 103.193);--color-yellow-200:oklch(94.5% .129 101.54);--color-yellow-300:oklch(90.5% .182 98.111);--color-yellow-400:oklch(85.2% .199 91.936);--color-yellow-500:oklch(79.5% .184 86.047);--color-yellow-600:oklch(68.1% .162 75.834);--color-yellow-700:oklch(55.4% .135 66.442);--color-yellow-800:oklch(47.6% .114 61.907);--color-yellow-900:oklch(42.1% .095 57.708);--color-green-50:oklch(98.2% .018 155.826);--color-green-100:oklch(96.2% .044 156.743);--color-green-200:oklch(92.5% .084 155.995);--color-green-300:oklch(87.1% .15 154.449);--color-green-400:oklch(79.2% .209 151.711);--color-green-500:oklch(72.3% .219 149.579);--color-green-600:oklch(62.7% .194 149.214);--color-green-700:oklch(52.7% .154 150.069);--color-green-800:oklch(44.8% .119 151.328);--color-green-900:oklch(39.3% .095 152.535);--color-emerald-50:oklch(97.9% .021 166.113);--color-emerald-100:oklch(95% .052 163.051);--color-emerald-200:oklch(90.5% .093 164.15);--color-emerald-300:oklch(84.5% .143 164.978);--color-emerald-400:oklch(76.5% .177 163.223);--color-emerald-500:oklch(69.6% .17 162.48);--color-emerald-600:oklch(59.6% .145 163.225);--color-emerald-700:oklch(50.8% .118 165.612);--color-emerald-800:oklch(43.2% .095 166.913);--color-emerald-900:oklch(37.8% .077 168.94);--color-emerald-950:oklch(26.2% .051 172.552);--color-teal-50:oklch(98.4% .014 180.72);--color-teal-100:oklch(95.3% .051 180.801);--color-teal-200:oklch(91% .096 180.426);--color-teal-300:oklch(85.5% .138 181.071);--color-teal-400:oklch(77.7% .152 181.912);--color-teal-500:oklch(70.4% .14 182.503);--color-teal-600:oklch(60% .118 184.704);--color-teal-700:oklch(51.1% .096 186.391);--color-teal-800:oklch(43.7% .078 188.216);--color-teal-900:oklch(38.6% .063 188.416);--color-cyan-50:oklch(98.4% .019 200.873);--color-cyan-100:oklch(95.6% .045 203.388);--color-cyan-200:oklch(91.7% .08 205.041);--color-cyan-300:oklch(86.5% .127 207.078);--color-cyan-400:oklch(78.9% .154 211.53);--color-cyan-500:oklch(71.5% .143 215.221);--color-cyan-600:oklch(60.9% .126 221.723);--color-cyan-700:oklch(52% .105 223.128);--color-cyan-800:oklch(45% .085 224.283);--color-cyan-900:oklch(39.8% .07 227.392);--color-cyan-950:oklch(30.2% .056 229.695);--color-sky-50:oklch(97.7% .013 236.62);--color-sky-100:oklch(95.1% .026 236.824);--color-sky-200:oklch(90.1% .058 230.902);--color-sky-300:oklch(82.8% .111 230.318);--color-sky-400:oklch(74.6% .16 232.661);--color-sky-500:oklch(68.5% .169 237.323);--color-sky-600:oklch(58.8% .158 241.966);--color-sky-700:oklch(50% .134 242.749);--color-sky-800:oklch(44.3% .11 240.79);--color-sky-900:oklch(39.1% .09 240.876);--color-sky-950:oklch(29.3% .066 243.157);--color-blue-50:oklch(97% .014 254.604);--color-blue-100:oklch(93.2% .032 255.585);--color-blue-200:oklch(88.2% .059 254.128);--color-blue-300:oklch(80.9% .105 251.813);--color-blue-400:oklch(70.7% .165 254.624);--color-blue-500:oklch(62.3% .214 259.815);--color-blue-600:oklch(54.6% .245 262.881);--color-blue-700:oklch(48.8% .243 264.376);--color-blue-800:oklch(42.4% .199 265.638);--color-blue-900:oklch(37.9% .146 265.522);--color-blue-950:oklch(28.2% .091 267.935);--color-indigo-50:oklch(96.2% .018 272.314);--color-indigo-100:oklch(93% .034 272.788);--color-indigo-200:oklch(87% .065 274.039);--color-indigo-300:oklch(78.5% .115 274.713);--color-indigo-400:oklch(67.3% .182 276.935);--color-indigo-500:oklch(58.5% .233 277.117);--color-indigo-600:oklch(51.1% .262 276.966);--color-indigo-700:oklch(45.7% .24 277.023);--color-indigo-800:oklch(39.8% .195 277.366);--color-indigo-900:oklch(35.9% .144 278.697);--color-indigo-950:oklch(25.7% .09 281.288);--color-violet-50:oklch(96.9% .016 293.756);--color-violet-100:oklch(94.3% .029 294.588);--color-violet-200:oklch(89.4% .057 293.283);--color-violet-300:oklch(81.1% .111 293.571);--color-violet-400:oklch(70.2% .183 293.541);--color-violet-600:oklch(54.1% .281 293.009);--color-violet-700:oklch(49.1% .27 292.581);--color-violet-800:oklch(43.2% .232 292.759);--color-violet-900:oklch(38% .189 293.745);--color-violet-950:oklch(28.3% .141 291.089);--color-purple-50:oklch(97.7% .014 308.299);--color-purple-100:oklch(94.6% .033 307.174);--color-purple-200:oklch(90.2% .063 306.703);--color-purple-300:oklch(82.7% .119 306.383);--color-purple-400:oklch(71.4% .203 305.504);--color-purple-500:oklch(62.7% .265 303.9);--color-purple-600:oklch(55.8% .288 302.321);--color-purple-800:oklch(43.8% .218 303.724);--color-purple-900:oklch(38.1% .176 304.987);--color-pink-50:oklch(97.1% .014 343.198);--color-pink-100:oklch(94.8% .028 342.258);--color-pink-200:oklch(89.9% .061 343.231);--color-pink-300:oklch(82.3% .12 346.018);--color-pink-400:oklch(71.8% .202 349.761);--color-pink-500:oklch(65.6% .241 354.308);--color-pink-600:oklch(59.2% .249 .584);--color-pink-700:oklch(52.5% .223 3.958);--color-pink-800:oklch(45.9% .187 3.815);--color-pink-900:oklch(40.8% .153 2.432);--color-rose-50:oklch(96.9% .015 12.422);--color-rose-100:oklch(94.1% .03 12.58);--color-rose-200:oklch(89.2% .058 10.001);--color-rose-400:oklch(71.2% .194 13.428);--color-rose-500:oklch(64.5% .246 16.439);--color-rose-600:oklch(58.6% .253 17.585);--color-rose-700:oklch(51.4% .222 16.935);--color-rose-900:oklch(41% .159 10.272);--color-slate-50:oklch(98.4% .003 247.858);--color-slate-100:oklch(96.8% .007 247.896);--color-slate-200:oklch(92.9% .013 255.508);--color-slate-300:oklch(86.9% .022 252.894);--color-slate-400:oklch(70.4% .04 256.788);--color-slate-500:oklch(55.4% .046 257.417);--color-slate-600:oklch(44.6% .043 257.281);--color-slate-700:oklch(37.2% .044 257.287);--color-slate-800:oklch(27.9% .041 260.031);--color-slate-900:oklch(20.8% .042 265.755);--color-gray-50:oklch(98.5% .002 247.839);--color-gray-100:oklch(96.7% .003 264.542);--color-gray-200:oklch(92.8% .006 264.531);--color-gray-300:oklch(87.2% .01 258.338);--color-gray-400:oklch(70.7% .022 261.325);--color-gray-500:oklch(55.1% .027 264.364);--color-gray-600:oklch(44.6% .03 256.802);--color-gray-700:oklch(37.3% .034 259.733);--color-gray-800:oklch(27.8% .033 256.848);--color-gray-900:oklch(21% .034 264.665);--color-zinc-50:oklch(98.5% 0 none);--color-zinc-100:oklch(96.7% .001 286.375);--color-zinc-200:oklch(92% .004 286.32);--color-zinc-300:oklch(87.1% .006 286.286);--color-zinc-400:oklch(70.5% .015 286.067);--color-zinc-500:oklch(55.2% .016 285.938);--color-zinc-600:oklch(44.2% .017 285.786);--color-zinc-700:oklch(37% .013 285.805);--color-zinc-800:oklch(27.4% .006 286.033);--color-zinc-900:oklch(21% .006 285.885);--color-zinc-950:oklch(14.1% .005 285.823);--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-xs:20rem;--container-sm:24rem;--container-md:28rem;--container-lg:32rem;--container-xl:36rem;--container-2xl:42rem;--container-3xl:48rem;--container-4xl:56rem;--container-5xl:64rem;--container-6xl:72rem;--container-7xl:80rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5 / 2.25);--text-5xl:3rem;--text-5xl--line-height:1;--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--font-weight-black:900;--tracking-tighter:-.05em;--tracking-tight:-.025em;--tracking-normal:0em;--tracking-wide:.025em;--tracking-wider:.05em;--tracking-widest:.1em;--leading-tight:1.25;--leading-snug:1.375;--leading-normal:1.5;--leading-relaxed:1.625;--radius-xs:.125rem;--radius-sm:.25rem;--radius-md:.375rem;--radius-lg:.5rem;--radius-xl:.75rem;--radius-2xl:1rem;--radius-3xl:1.5rem;--radius-4xl:2rem;--ease-in:cubic-bezier(.4, 0, 1, 1);--ease-out:cubic-bezier(0, 0, .2, 1);--ease-in-out:cubic-bezier(.4, 0, .2, 1);--animate-spin:spin 1s linear infinite;--animate-ping:ping 1s cubic-bezier(0, 0, .2, 1) infinite;--animate-pulse:pulse 2s cubic-bezier(.4, 0, .6, 1) infinite;--animate-bounce:bounce 1s infinite;--blur-xs:4px;--blur-sm:8px;--blur-md:12px;--aspect-video:16 / 9;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}input:where([type=text]),input:where(:not([type])),input:where([type=email]),input:where([type=url]),input:where([type=password]),input:where([type=number]),input:where([type=date]),input:where([type=datetime-local]),input:where([type=month]),input:where([type=search]),input:where([type=tel]),input:where([type=time]),input:where([type=week]),select:where([multiple]),textarea,select{appearance:none;--tw-shadow:0 0 #0000;background-color:#fff;border-width:1px;border-color:oklch(55.1% .027 264.364);border-radius:0;padding:.5rem .75rem;font-size:1rem;line-height:1.5rem}:is(input:where([type=text]),input:where(:not([type])),input:where([type=email]),input:where([type=url]),input:where([type=password]),input:where([type=number]),input:where([type=date]),input:where([type=datetime-local]),input:where([type=month]),input:where([type=search]),input:where([type=tel]),input:where([type=time]),input:where([type=week]),select:where([multiple]),textarea,select):focus{outline-offset:2px;--tw-ring-inset:var(--tw-empty, );--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:oklch(54.6% .245 262.881);--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);border-color:oklch(54.6% .245 262.881);outline:2px solid #0000}input::placeholder,textarea::placeholder{color:oklch(55.1% .027 264.364);opacity:1}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-date-and-time-value{min-height:1.5em;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit{padding-top:0;padding-bottom:0}::-webkit-datetime-edit-year-field{padding-top:0;padding-bottom:0}::-webkit-datetime-edit-month-field{padding-top:0;padding-bottom:0}::-webkit-datetime-edit-day-field{padding-top:0;padding-bottom:0}::-webkit-datetime-edit-hour-field{padding-top:0;padding-bottom:0}::-webkit-datetime-edit-minute-field{padding-top:0;padding-bottom:0}::-webkit-datetime-edit-second-field{padding-top:0;padding-bottom:0}::-webkit-datetime-edit-millisecond-field{padding-top:0;padding-bottom:0}::-webkit-datetime-edit-meridiem-field{padding-top:0;padding-bottom:0}select{-webkit-print-color-adjust:exact;print-color-adjust:exact;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='oklch(55.1%25 0.027 264.364)' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e");background-position:right .5rem center;background-repeat:no-repeat;background-size:1.5em 1.5em;padding-right:2.5rem}select:where([multiple]),select:where([size]:not([size="1"])){background-image:initial;background-position:initial;background-repeat:unset;background-size:initial;print-color-adjust:unset;padding-right:.75rem}input:where([type=checkbox]),input:where([type=radio]){appearance:none;-webkit-print-color-adjust:exact;print-color-adjust:exact;vertical-align:middle;-webkit-user-select:none;user-select:none;color:oklch(54.6% .245 262.881);--tw-shadow:0 0 #0000;background-color:#fff;background-origin:border-box;border-width:1px;border-color:oklch(55.1% .027 264.364);flex-shrink:0;width:1rem;height:1rem;padding:0;display:inline-block}input:where([type=checkbox]){border-radius:0}input:where([type=radio]){border-radius:100%}input:where([type=checkbox]):focus,input:where([type=radio]):focus{outline-offset:2px;--tw-ring-inset:var(--tw-empty, );--tw-ring-offset-width:2px;--tw-ring-offset-color:#fff;--tw-ring-color:oklch(54.6% .245 262.881);--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);outline:2px solid #0000}input:where([type=checkbox]):checked,input:where([type=radio]):checked{background-color:currentColor;background-position:50%;background-repeat:no-repeat;background-size:100% 100%;border-color:#0000}input:where([type=checkbox]):checked{background-image:url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e")}@media (forced-colors:active){input:where([type=checkbox]):checked{appearance:auto}}input:where([type=radio]):checked{background-image:url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3ccircle cx='8' cy='8' r='3'/%3e%3c/svg%3e")}@media (forced-colors:active){input:where([type=radio]):checked{appearance:auto}}input:where([type=checkbox]):checked:hover,input:where([type=checkbox]):checked:focus,input:where([type=radio]):checked:hover,input:where([type=radio]):checked:focus{background-color:currentColor;border-color:#0000}input:where([type=checkbox]):indeterminate{background-color:currentColor;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 16 16'%3e%3cpath stroke='white' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M4 8h8'/%3e%3c/svg%3e");background-position:50%;background-repeat:no-repeat;background-size:100% 100%;border-color:#0000}@media (forced-colors:active){input:where([type=checkbox]):indeterminate{appearance:auto}}input:where([type=checkbox]):indeterminate:hover,input:where([type=checkbox]):indeterminate:focus{background-color:currentColor;border-color:#0000}input:where([type=file]){background:unset;border-color:inherit;font-size:unset;line-height:inherit;border-width:0;border-radius:0;padding:0}input:where([type=file]):focus{outline:1px solid buttontext;outline:1px auto -webkit-focus-ring-color}}@layer components;@layer utilities{.pointer-events-auto{pointer-events:auto}.pointer-events-none{pointer-events:none}.collapse{visibility:collapse}.invisible{visibility:hidden}.visible{visibility:visible}.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.not-sr-only{clip-path:none;white-space:normal;width:auto;height:auto;margin:0;padding:0;position:static;overflow:visible}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.sticky{position:sticky}.-inset-2{inset:calc(var(--spacing) * -2)}.inset-0{inset:0}.inset-x-0{inset-inline:0}.inset-y-0{inset-block:0}.-top-0\.5{top:calc(var(--spacing) * -.5)}.-top-1{top:calc(var(--spacing) * -1)}.-top-2{top:calc(var(--spacing) * -2)}.-top-10{top:calc(var(--spacing) * -10)}.-top-12{top:calc(var(--spacing) * -12)}.top-0{top:0}.top-1{top:var(--spacing)}.top-1\.5{top:calc(var(--spacing) * 1.5)}.top-1\/2{top:50%}.top-2{top:calc(var(--spacing) * 2)}.top-3{top:calc(var(--spacing) * 3)}.top-4{top:calc(var(--spacing) * 4)}.top-14{top:calc(var(--spacing) * 14)}.top-\[calc\(0\.5rem\+2\.75rem\+0\.5rem\)\]{top:3.75rem}.top-\[calc\(0\.5rem\+2\.75rem\+0\.5rem\+2\.75rem\)\]{top:6.5rem}.top-full{top:100%}.-right-0\.5{right:calc(var(--spacing) * -.5)}.-right-1{right:calc(var(--spacing) * -1)}.-right-1\.5{right:calc(var(--spacing) * -1.5)}.-right-2{right:calc(var(--spacing) * -2)}.-right-3{right:calc(var(--spacing) * -3)}.right-0{right:0}.right-1{right:var(--spacing)}.right-1\.5{right:calc(var(--spacing) * 1.5)}.right-2{right:calc(var(--spacing) * 2)}.right-3{right:calc(var(--spacing) * 3)}.right-4{right:calc(var(--spacing) * 4)}.right-\[max\(0\.75rem\,env\(safe-area-inset-right\)\)\]{right:max(.75rem, env(safe-area-inset-right))}.right-\[max\(1rem\,env\(safe-area-inset-right\,0px\)\)\]{right:max(1rem, env(safe-area-inset-right,0px))}.-bottom-0\.5{bottom:calc(var(--spacing) * -.5)}.-bottom-1{bottom:calc(var(--spacing) * -1)}.-bottom-2{bottom:calc(var(--spacing) * -2)}.bottom-0{bottom:0}.bottom-1{bottom:var(--spacing)}.bottom-2{bottom:calc(var(--spacing) * 2)}.bottom-3{bottom:calc(var(--spacing) * 3)}.bottom-4{bottom:calc(var(--spacing) * 4)}.bottom-5{bottom:calc(var(--spacing) * 5)}.bottom-9{bottom:calc(var(--spacing) * 9)}.bottom-\[max\(0\.75rem\,env\(safe-area-inset-bottom\)\)\]{bottom:max(.75rem, env(safe-area-inset-bottom))}.bottom-\[max\(1\.25rem\,env\(safe-area-inset-bottom\,0px\)\)\]{bottom:max(1.25rem, env(safe-area-inset-bottom,0px))}.bottom-\[max\(1rem\,env\(safe-area-inset-bottom\,0px\)\)\]{bottom:max(1rem, env(safe-area-inset-bottom,0px))}.bottom-full{bottom:100%}.-left-1\.5{left:calc(var(--spacing) * -1.5)}.-left-3{left:calc(var(--spacing) * -3)}.left-0{left:0}.left-1{left:var(--spacing)}.left-1\/2{left:50%}.left-2{left:calc(var(--spacing) * 2)}.left-2\.5{left:calc(var(--spacing) * 2.5)}.left-3{left:calc(var(--spacing) * 3)}.left-3\.5{left:calc(var(--spacing) * 3.5)}.left-4{left:calc(var(--spacing) * 4)}.left-\[-2px\]{left:-2px}.isolate{isolation:isolate}.isolation-auto{isolation:auto}.z-0{z-index:0}.z-1{z-index:1}.z-5{z-index:5}.z-10{z-index:10}.z-19{z-index:19}.z-20{z-index:20}.z-30{z-index:30}.z-40{z-index:40}.z-45{z-index:45}.z-50{z-index:50}.z-60{z-index:60}.z-65{z-index:65}.z-70{z-index:70}.z-90{z-index:90}.z-95{z-index:95}.z-100{z-index:100}.z-120{z-index:120}.z-150{z-index:150}.z-180{z-index:180}.z-190{z-index:190}.z-200{z-index:200}.z-210{z-index:210}.z-220{z-index:220}.z-9999{z-index:9999}.z-\[60\]{z-index:60}.z-\[200\]{z-index:200}.z-\[220\]{z-index:220}.z-\[250\]{z-index:250}.z-\[500\]{z-index:500}.order-1{order:1}.order-2{order:2}.order-first{order:-9999}.col-span-2{grid-column:span 2/span 2}.float-end{float:inline-end}.float-left{float:left}.float-none{float:none}.float-right{float:right}.float-start{float:inline-start}.clear-both{clear:both}.clear-end{clear:inline-end}.clear-left{clear:left}.clear-none{clear:none}.clear-right{clear:right}.clear-start{clear:inline-start}.container{width:100%}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.-m-px{margin:-1px}.m-0{margin:0}.m-4{margin:calc(var(--spacing) * 4)}.m-\[-1px\]{margin:-1px}.-mx-3{margin-inline:calc(var(--spacing) * -3)}.-mx-4{margin-inline:calc(var(--spacing) * -4)}.mx-0\.5{margin-inline:calc(var(--spacing) * .5)}.mx-1{margin-inline:var(--spacing)}.mx-3{margin-inline:calc(var(--spacing) * 3)}.mx-4{margin-inline:calc(var(--spacing) * 4)}.mx-auto{margin-inline:auto}.-my-2{margin-block:calc(var(--spacing) * -2)}.my-1{margin-block:var(--spacing)}.my-2{margin-block:calc(var(--spacing) * 2)}.my-3{margin-block:calc(var(--spacing) * 3)}.my-4{margin-block:calc(var(--spacing) * 4)}.my-6{margin-block:calc(var(--spacing) * 6)}.my-8{margin-block:calc(var(--spacing) * 8)}.my-10{margin-block:calc(var(--spacing) * 10)}.my-auto{margin-block:auto}.-mt-0\.5{margin-top:calc(var(--spacing) * -.5)}.-mt-1{margin-top:calc(var(--spacing) * -1)}.mt-0\.5{margin-top:calc(var(--spacing) * .5)}.mt-1{margin-top:var(--spacing)}.mt-1\.5{margin-top:calc(var(--spacing) * 1.5)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-5{margin-top:calc(var(--spacing) * 5)}.mt-6{margin-top:calc(var(--spacing) * 6)}.mt-7{margin-top:calc(var(--spacing) * 7)}.mt-8{margin-top:calc(var(--spacing) * 8)}.mt-10{margin-top:calc(var(--spacing) * 10)}.mt-12{margin-top:calc(var(--spacing) * 12)}.mt-14{margin-top:calc(var(--spacing) * 14)}.mt-auto{margin-top:auto}.-mr-2{margin-right:calc(var(--spacing) * -2)}.mr-0{margin-right:0}.mr-1{margin-right:var(--spacing)}.mr-1\.5{margin-right:calc(var(--spacing) * 1.5)}.mr-2{margin-right:calc(var(--spacing) * 2)}.mr-3{margin-right:calc(var(--spacing) * 3)}.mr-4{margin-right:calc(var(--spacing) * 4)}.mr-auto{margin-right:auto}.-mb-0\.5{margin-bottom:calc(var(--spacing) * -.5)}.-mb-px{margin-bottom:-1px}.mb-0{margin-bottom:0}.mb-0\!{margin-bottom:0!important}.mb-0\.5{margin-bottom:calc(var(--spacing) * .5)}.mb-1{margin-bottom:var(--spacing)}.mb-1\.5{margin-bottom:calc(var(--spacing) * 1.5)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.mb-6{margin-bottom:calc(var(--spacing) * 6)}.mb-8{margin-bottom:calc(var(--spacing) * 8)}.mb-10{margin-bottom:calc(var(--spacing) * 10)}.-ml-1{margin-left:calc(var(--spacing) * -1)}.-ml-11{margin-left:calc(var(--spacing) * -11)}.ml-0\.5{margin-left:calc(var(--spacing) * .5)}.ml-1{margin-left:var(--spacing)}.ml-1\.5{margin-left:calc(var(--spacing) * 1.5)}.ml-2{margin-left:calc(var(--spacing) * 2)}.ml-3{margin-left:calc(var(--spacing) * 3)}.ml-4{margin-left:calc(var(--spacing) * 4)}.ml-6{margin-left:calc(var(--spacing) * 6)}.ml-auto{margin-left:auto}.box-border{box-sizing:border-box}.box-content{box-sizing:content-box}.form-checkbox{appearance:none;-webkit-print-color-adjust:exact;print-color-adjust:exact;vertical-align:middle;-webkit-user-select:none;user-select:none;color:oklch(54.6% .245 262.881);--tw-shadow:0 0 #0000;background-color:#fff;background-origin:border-box;border-width:1px;border-color:oklch(55.1% .027 264.364);border-radius:0;flex-shrink:0;width:1rem;height:1rem;padding:0;display:inline-block}.form-checkbox:focus{outline-offset:2px;--tw-ring-inset:var(--tw-empty, );--tw-ring-offset-width:2px;--tw-ring-offset-color:#fff;--tw-ring-color:oklch(54.6% .245 262.881);--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);outline:2px solid #0000}.form-checkbox:checked{background-color:currentColor;background-image:url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e");background-position:50%;background-repeat:no-repeat;background-size:100% 100%;border-color:#0000}@media (forced-colors:active){.form-checkbox:checked{appearance:auto}}.form-checkbox:checked:hover,.form-checkbox:checked:focus{background-color:currentColor;border-color:#0000}.form-checkbox:indeterminate{background-color:currentColor;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 16 16'%3e%3cpath stroke='white' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M4 8h8'/%3e%3c/svg%3e");background-position:50%;background-repeat:no-repeat;background-size:100% 100%;border-color:#0000}@media (forced-colors:active){.form-checkbox:indeterminate{appearance:auto}}.form-checkbox:indeterminate:hover,.form-checkbox:indeterminate:focus{background-color:currentColor;border-color:#0000}.form-radio{appearance:none;-webkit-print-color-adjust:exact;print-color-adjust:exact;vertical-align:middle;-webkit-user-select:none;user-select:none;color:oklch(54.6% .245 262.881);--tw-shadow:0 0 #0000;background-color:#fff;background-origin:border-box;border-width:1px;border-color:oklch(55.1% .027 264.364);border-radius:100%;flex-shrink:0;width:1rem;height:1rem;padding:0;display:inline-block}.form-radio:focus{outline-offset:2px;--tw-ring-inset:var(--tw-empty, );--tw-ring-offset-width:2px;--tw-ring-offset-color:#fff;--tw-ring-color:oklch(54.6% .245 262.881);--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);outline:2px solid #0000}.form-radio:checked{background-color:currentColor;background-image:url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3ccircle cx='8' cy='8' r='3'/%3e%3c/svg%3e");background-position:50%;background-repeat:no-repeat;background-size:100% 100%;border-color:#0000}@media (forced-colors:active){.form-radio:checked{appearance:auto}}.form-radio:checked:hover,.form-radio:checked:focus{background-color:currentColor;border-color:#0000}.form-input{appearance:none;--tw-shadow:0 0 #0000;background-color:#fff;border-width:1px;border-color:oklch(55.1% .027 264.364);border-radius:0;padding:.5rem .75rem;font-size:1rem;line-height:1.5rem}.form-input:focus{outline-offset:2px;--tw-ring-inset:var(--tw-empty, );--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:oklch(54.6% .245 262.881);--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);border-color:oklch(54.6% .245 262.881);outline:2px solid #0000}.form-input::placeholder{color:oklch(55.1% .027 264.364);opacity:1}.form-input::-webkit-datetime-edit-fields-wrapper{padding:0}.form-input::-webkit-date-and-time-value{min-height:1.5em}.form-input::-webkit-date-and-time-value{text-align:inherit}.form-input::-webkit-datetime-edit{display:inline-flex}.form-input::-webkit-datetime-edit{padding-top:0;padding-bottom:0}.form-input::-webkit-datetime-edit-year-field{padding-top:0;padding-bottom:0}.form-input::-webkit-datetime-edit-month-field{padding-top:0;padding-bottom:0}.form-input::-webkit-datetime-edit-day-field{padding-top:0;padding-bottom:0}.form-input::-webkit-datetime-edit-hour-field{padding-top:0;padding-bottom:0}.form-input::-webkit-datetime-edit-minute-field{padding-top:0;padding-bottom:0}.form-input::-webkit-datetime-edit-second-field{padding-top:0;padding-bottom:0}.form-input::-webkit-datetime-edit-millisecond-field{padding-top:0;padding-bottom:0}.form-input::-webkit-datetime-edit-meridiem-field{padding-top:0;padding-bottom:0}.line-clamp-2{-webkit-line-clamp:2;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.line-clamp-3{-webkit-line-clamp:3;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.line-clamp-4{-webkit-line-clamp:4;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.line-clamp-none{-webkit-line-clamp:unset;-webkit-box-orient:horizontal;display:block;overflow:visible}.block{display:block}.contents{display:contents}.flex{display:flex}.flow-root{display:flow-root}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-block{display:inline-block}.inline-flex{display:inline-flex}.inline-grid{display:inline-grid}.inline-table{display:inline-table}.list-item{display:list-item}.table{display:table}.table-caption{display:table-caption}.table-cell{display:table-cell}.table-column{display:table-column}.table-column-group{display:table-column-group}.table-footer-group{display:table-footer-group}.table-header-group{display:table-header-group}.table-row{display:table-row}.table-row-group{display:table-row-group}.aspect-2\/1{aspect-ratio:2}.aspect-square{aspect-ratio:1}.aspect-video{aspect-ratio:var(--aspect-video)}.size-1\.5{width:calc(var(--spacing) * 1.5);height:calc(var(--spacing) * 1.5)}.size-2{width:calc(var(--spacing) * 2);height:calc(var(--spacing) * 2)}.size-2\.5{width:calc(var(--spacing) * 2.5);height:calc(var(--spacing) * 2.5)}.size-3{width:calc(var(--spacing) * 3);height:calc(var(--spacing) * 3)}.size-3\.5{width:calc(var(--spacing) * 3.5);height:calc(var(--spacing) * 3.5)}.size-4{width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.size-5{width:calc(var(--spacing) * 5);height:calc(var(--spacing) * 5)}.size-6{width:calc(var(--spacing) * 6);height:calc(var(--spacing) * 6)}.size-7{width:calc(var(--spacing) * 7);height:calc(var(--spacing) * 7)}.size-8{width:calc(var(--spacing) * 8);height:calc(var(--spacing) * 8)}.size-9{width:calc(var(--spacing) * 9);height:calc(var(--spacing) * 9)}.size-10{width:calc(var(--spacing) * 10);height:calc(var(--spacing) * 10)}.size-12{width:calc(var(--spacing) * 12);height:calc(var(--spacing) * 12)}.size-14{width:calc(var(--spacing) * 14);height:calc(var(--spacing) * 14)}.size-16{width:calc(var(--spacing) * 16);height:calc(var(--spacing) * 16)}.size-20{width:calc(var(--spacing) * 20);height:calc(var(--spacing) * 20)}.size-24{width:calc(var(--spacing) * 24);height:calc(var(--spacing) * 24)}.size-28{width:calc(var(--spacing) * 28);height:calc(var(--spacing) * 28)}.size-32{width:calc(var(--spacing) * 32);height:calc(var(--spacing) * 32)}.size-40{width:calc(var(--spacing) * 40);height:calc(var(--spacing) * 40)}.size-\[14px\]{width:14px;height:14px}.size-\[18px\]{width:18px;height:18px}.size-\[22px\]{width:22px;height:22px}.size-\[34px\]{width:34px;height:34px}.size-\[52px\]{width:52px;height:52px}.size-\[120px\]{width:120px;height:120px}.size-full{width:100%;height:100%}.h-0{height:0}.h-0\.5{height:calc(var(--spacing) * .5)}.h-1{height:var(--spacing)}.h-1\.5{height:calc(var(--spacing) * 1.5)}.h-2{height:calc(var(--spacing) * 2)}.h-2\.5{height:calc(var(--spacing) * 2.5)}.h-3{height:calc(var(--spacing) * 3)}.h-3\.5{height:calc(var(--spacing) * 3.5)}.h-4{height:calc(var(--spacing) * 4)}.h-5{height:calc(var(--spacing) * 5)}.h-6{height:calc(var(--spacing) * 6)}.h-7{height:calc(var(--spacing) * 7)}.h-8{height:calc(var(--spacing) * 8)}.h-9{height:calc(var(--spacing) * 9)}.h-10{height:calc(var(--spacing) * 10)}.h-10\!{height:calc(var(--spacing) * 10)!important}.h-11{height:calc(var(--spacing) * 11)}.h-12{height:calc(var(--spacing) * 12)}.h-14{height:calc(var(--spacing) * 14)}.h-16{height:calc(var(--spacing) * 16)}.h-20{height:calc(var(--spacing) * 20)}.h-24{height:calc(var(--spacing) * 24)}.h-28{height:calc(var(--spacing) * 28)}.h-32{height:calc(var(--spacing) * 32)}.h-40{height:calc(var(--spacing) * 40)}.h-44{height:calc(var(--spacing) * 44)}.h-48{height:calc(var(--spacing) * 48)}.h-52{height:calc(var(--spacing) * 52)}.h-64{height:calc(var(--spacing) * 64)}.h-\[42\%\]{height:42%}.h-auto{height:auto}.h-dvh{height:100dvh}.h-fit{height:fit-content}.h-full{height:100%}.h-px{height:1px}.max-h-12{max-height:calc(var(--spacing) * 12)}.max-h-20{max-height:calc(var(--spacing) * 20)}.max-h-36{max-height:calc(var(--spacing) * 36)}.max-h-44{max-height:calc(var(--spacing) * 44)}.max-h-48{max-height:calc(var(--spacing) * 48)}.max-h-52{max-height:calc(var(--spacing) * 52)}.max-h-56{max-height:calc(var(--spacing) * 56)}.max-h-60{max-height:calc(var(--spacing) * 60)}.max-h-64{max-height:calc(var(--spacing) * 64)}.max-h-72{max-height:calc(var(--spacing) * 72)}.max-h-80{max-height:calc(var(--spacing) * 80)}.max-h-\[36vh\]{max-height:36vh}.max-h-\[60vh\]{max-height:60vh}.max-h-\[80vh\]{max-height:80vh}.max-h-\[90dvh\]{max-height:90dvh}.max-h-\[90vh\]{max-height:90vh}.max-h-\[200px\]{max-height:200px}.max-h-\[220px\]{max-height:220px}.max-h-\[260px\]{max-height:260px}.max-h-\[400px\]{max-height:400px}.max-h-\[480px\]{max-height:480px}.max-h-\[500px\]{max-height:500px}.max-h-\[min\(22rem\,calc\(100vh-6rem\)\)\]{max-height:min(22rem,100vh - 6rem)}.max-h-\[min\(36rem\,90vh\)\]{max-height:min(36rem,90vh)}.max-h-\[min\(36rem\,calc\(100\%-4rem\)\)\]{max-height:min(36rem,100% - 4rem)}.max-h-\[min\(50vh\,28rem\)\]{max-height:min(50vh,28rem)}.max-h-\[min\(65vh\,32rem\)\]{max-height:min(65vh,32rem)}.max-h-\[min\(70dvh\,70vh\)\]{max-height:min(70dvh,70vh)}.max-h-\[min\(90dvh\,40rem\)\]{max-height:min(90dvh,40rem)}.max-h-\[min\(90dvh\,48rem\)\]{max-height:min(90dvh,48rem)}.max-h-\[min\(320px\,55vh\)\]{max-height:min(320px,55vh)}.max-h-\[min\(420px\,70vh\)\]{max-height:min(420px,70vh)}.max-h-dvh{max-height:100dvh}.max-h-full{max-height:100%}.min-h-0{min-height:0}.min-h-4\.5{min-height:calc(var(--spacing) * 4.5)}.min-h-9{min-height:calc(var(--spacing) * 9)}.min-h-10{min-height:calc(var(--spacing) * 10)}.min-h-11{min-height:calc(var(--spacing) * 11)}.min-h-12{min-height:calc(var(--spacing) * 12)}.min-h-17{min-height:calc(var(--spacing) * 17)}.min-h-32{min-height:calc(var(--spacing) * 32)}.min-h-40{min-height:calc(var(--spacing) * 40)}.min-h-\[1\.25rem\]{min-height:1.25rem}.min-h-\[2\.5rem\]{min-height:2.5rem}.min-h-\[2\.25rem\]{min-height:2.25rem}.min-h-\[2rem\]{min-height:2rem}.min-h-\[4\.5rem\]{min-height:4.5rem}.min-h-\[5\.5rem\]{min-height:5.5rem}.min-h-\[5rem\]{min-height:5rem}.min-h-\[6\.5rem\]{min-height:6.5rem}.min-h-\[6rem\]{min-height:6rem}.min-h-\[9\.5rem\]{min-height:9.5rem}.min-h-\[10rem\]{min-height:10rem}.min-h-\[12rem\]{min-height:12rem}.min-h-\[28px\]{min-height:28px}.min-h-\[40px\]{min-height:40px}.min-h-\[44px\]{min-height:44px}.min-h-\[48px\]{min-height:48px}.min-h-\[52px\]{min-height:52px}.min-h-\[54px\]{min-height:54px}.min-h-\[72px\]{min-height:72px}.min-h-\[80px\]{min-height:80px}.min-h-\[88px\]{min-height:88px}.min-h-\[96px\]{min-height:96px}.min-h-\[120px\]{min-height:120px}.min-h-\[140px\]{min-height:140px}.min-h-\[200px\]{min-height:200px}.min-h-\[240px\]{min-height:240px}.min-h-\[320px\]{min-height:320px}.min-h-\[min\(50vh\,18rem\)\]{min-height:min(50vh,18rem)}.min-h-dvh{min-height:100dvh}.min-h-full{min-height:100%}.min-h-screen{min-height:100vh}.w-0\.5{width:calc(var(--spacing) * .5)}.w-1{width:var(--spacing)}.w-1\/2{width:50%}.w-1\/3{width:33.3333%}.w-2{width:calc(var(--spacing) * 2)}.w-2\.5{width:calc(var(--spacing) * 2.5)}.w-2\/3{width:66.6667%}.w-3{width:calc(var(--spacing) * 3)}.w-3\.5{width:calc(var(--spacing) * 3.5)}.w-4{width:calc(var(--spacing) * 4)}.w-5{width:calc(var(--spacing) * 5)}.w-6{width:calc(var(--spacing) * 6)}.w-7{width:calc(var(--spacing) * 7)}.w-8{width:calc(var(--spacing) * 8)}.w-9{width:calc(var(--spacing) * 9)}.w-10{width:calc(var(--spacing) * 10)}.w-11{width:calc(var(--spacing) * 11)}.w-12{width:calc(var(--spacing) * 12)}.w-13{width:calc(var(--spacing) * 13)}.w-14{width:calc(var(--spacing) * 14)}.w-16{width:calc(var(--spacing) * 16)}.w-20{width:calc(var(--spacing) * 20)}.w-24{width:calc(var(--spacing) * 24)}.w-28{width:calc(var(--spacing) * 28)}.w-32{width:calc(var(--spacing) * 32)}.w-40{width:calc(var(--spacing) * 40)}.w-44{width:calc(var(--spacing) * 44)}.w-48{width:calc(var(--spacing) * 48)}.w-52{width:calc(var(--spacing) * 52)}.w-56{width:calc(var(--spacing) * 56)}.w-64{width:calc(var(--spacing) * 64)}.w-72{width:calc(var(--spacing) * 72)}.w-80{width:calc(var(--spacing) * 80)}.w-96{width:calc(var(--spacing) * 96)}.w-\[2px\]{width:2px}.w-\[4\.5rem\]{width:4.5rem}.w-\[42\%\]{width:42%}.w-\[calc\(100\%-2rem\)\]{width:calc(100% - 2rem)}.w-\[min\(20rem\,calc\(100\%-1\.5rem\)\)\]{width:min(20rem,100% - 1.5rem)}.w-\[min\(24rem\,calc\(100\%-1rem\)\)\]{width:min(24rem,100% - 1rem)}.w-\[min\(100vw-2rem\,24rem\)\]{width:min(100vw - 2rem,24rem)}.w-\[min\(320px\,85vw\)\]{width:min(320px,85vw)}.w-\[min\(calc\(100\%-1\.25rem\)\,24rem\)\]{width:min(100% - 1.25rem,24rem)}.w-auto{width:auto}.w-fit{width:fit-content}.w-full{width:100%}.w-full\!{width:100%!important}.w-max{width:max-content}.w-px{width:1px}.max-w-2xl{max-width:var(--container-2xl)}.max-w-3xl{max-width:var(--container-3xl)}.max-w-4xl{max-width:var(--container-4xl)}.max-w-5xl{max-width:var(--container-5xl)}.max-w-6xl{max-width:var(--container-6xl)}.max-w-7xl{max-width:var(--container-7xl)}.max-w-16{max-width:calc(var(--spacing) * 16)}.max-w-18{max-width:calc(var(--spacing) * 18)}.max-w-32{max-width:calc(var(--spacing) * 32)}.max-w-40{max-width:calc(var(--spacing) * 40)}.max-w-\[5rem\]{max-width:5rem}.max-w-\[7\.5rem\]{max-width:7.5rem}.max-w-\[8\.5rem\]{max-width:8.5rem}.max-w-\[8rem\]{max-width:8rem}.max-w-\[12rem\]{max-width:12rem}.max-w-\[14rem\]{max-width:14rem}.max-w-\[16rem\]{max-width:16rem}.max-w-\[18rem\]{max-width:18rem}.max-w-\[20rem\]{max-width:20rem}.max-w-\[45\%\]{max-width:45%}.max-w-\[70\%\]{max-width:70%}.max-w-\[85\%\]{max-width:85%}.max-w-\[90\%\]{max-width:90%}.max-w-\[98vw\]{max-width:98vw}.max-w-\[100px\]{max-width:100px}.max-w-\[120px\]{max-width:120px}.max-w-\[160px\]{max-width:160px}.max-w-\[200px\]{max-width:200px}.max-w-\[260px\]{max-width:260px}.max-w-\[280px\]{max-width:280px}.max-w-\[1920px\]{max-width:1920px}.max-w-\[calc\(100\%-0\.75rem\)\]{max-width:calc(100% - .75rem)}.max-w-\[min\(18rem\,100\%\)\]{max-width:min(18rem,100%)}.max-w-\[min\(20rem\,85vw\)\]{max-width:min(20rem,85vw)}.max-w-\[min\(20rem\,100\%\)\]{max-width:min(20rem,100%)}.max-w-\[min\(20rem\,calc\(100vw-1\.5rem\)\)\]{max-width:min(20rem,100vw - 1.5rem)}.max-w-\[min\(22rem\,calc\(100vw-2rem\)\)\]{max-width:min(22rem,100vw - 2rem)}.max-w-\[min\(40vw\,12rem\)\]{max-width:min(40vw,12rem)}.max-w-\[min\(55vw\,14rem\)\]{max-width:min(55vw,14rem)}.max-w-\[min\(100\%\,12rem\)\]{max-width:min(100%,12rem)}.max-w-\[min\(100\%\,14rem\)\]{max-width:min(100%,14rem)}.max-w-\[min\(100\%\,18rem\)\]{max-width:min(100%,18rem)}.max-w-\[min\(100\%\,42rem\)\]{max-width:min(100%,42rem)}.max-w-\[min\(100\%\,calc\(100\%-2rem\)\)\]{max-width:min(100%,100% - 2rem)}.max-w-\[min\(100vw-2rem\,22rem\)\]{max-width:min(100vw - 2rem,22rem)}.max-w-\[min\(100vw-2rem\,28rem\)\]{max-width:min(100vw - 2rem,28rem)}.max-w-\[min\(100vw-2rem\,36rem\)\]{max-width:min(100vw - 2rem,36rem)}.max-w-\[min\(280px\,85vw\)\]{max-width:min(280px,85vw)}.max-w-\[min\(280px\,100\%\)\]{max-width:min(280px,100%)}.max-w-full{max-width:100%}.max-w-lg{max-width:var(--container-lg)}.max-w-md{max-width:var(--container-md)}.max-w-none{max-width:none}.max-w-sm{max-width:var(--container-sm)}.max-w-xl{max-width:var(--container-xl)}.max-w-xs{max-width:var(--container-xs)}.min-w-0{min-width:0}.min-w-4{min-width:calc(var(--spacing) * 4)}.min-w-4\.5{min-width:calc(var(--spacing) * 4.5)}.min-w-5{min-width:calc(var(--spacing) * 5)}.min-w-9{min-width:calc(var(--spacing) * 9)}.min-w-11{min-width:calc(var(--spacing) * 11)}.min-w-16{min-width:calc(var(--spacing) * 16)}.min-w-40{min-width:calc(var(--spacing) * 40)}.min-w-44{min-width:calc(var(--spacing) * 44)}.min-w-48{min-width:calc(var(--spacing) * 48)}.min-w-52{min-width:calc(var(--spacing) * 52)}.min-w-56{min-width:calc(var(--spacing) * 56)}.min-w-\[1\.25rem\]{min-width:1.25rem}.min-w-\[1\.125rem\]{min-width:1.125rem}.min-w-\[5\.5rem\]{min-width:5.5rem}.min-w-\[6\.5rem\]{min-width:6.5rem}.min-w-\[7rem\]{min-width:7rem}.min-w-\[10rem\]{min-width:10rem}.min-w-\[11rem\]{min-width:11rem}.min-w-\[14px\]{min-width:14px}.min-w-\[16rem\]{min-width:16rem}.min-w-\[28px\]{min-width:28px}.min-w-\[40px\]{min-width:40px}.min-w-\[44px\]{min-width:44px}.min-w-\[80px\]{min-width:80px}.min-w-\[120px\]{min-width:120px}.min-w-\[125px\]{min-width:125px}.min-w-\[160px\]{min-width:160px}.min-w-\[200px\]{min-width:200px}.min-w-\[220px\]{min-width:220px}.min-w-fit{min-width:fit-content}.min-w-full{min-width:100%}.flex-1{flex:1}.flex-2{flex:2}.flex-shrink{flex-shrink:1}.flex-shrink-0{flex-shrink:0}.shrink{flex-shrink:1}.shrink-0{flex-shrink:0}.flex-grow,.grow{flex-grow:1}.basis-0{flex-basis:0}.table-auto{table-layout:auto}.table-fixed{table-layout:fixed}.caption-bottom{caption-side:bottom}.caption-top{caption-side:top}.border-collapse{border-collapse:collapse}.border-separate{border-collapse:separate}.origin-center{transform-origin:50%}.-translate-x-1\/2{--tw-translate-x:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.-translate-x-full{--tw-translate-x:-100%;translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-x-0{--tw-translate-x:0px;translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-x-5{--tw-translate-x:calc(var(--spacing) * 5);translate:var(--tw-translate-x) var(--tw-translate-y)}.-translate-y-1\/2{--tw-translate-y:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.-translate-y-full{--tw-translate-y:-100%;translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-y-0{--tw-translate-y:0px;translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-y-1\/2{--tw-translate-y:calc(1 / 2 * 100%);translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-y-2{--tw-translate-y:calc(var(--spacing) * 2);translate:var(--tw-translate-x) var(--tw-translate-y)}.scale-95{--tw-scale-x:95%;--tw-scale-y:95%;--tw-scale-z:95%;scale:var(--tw-scale-x) var(--tw-scale-y)}.scale-100{--tw-scale-x:100%;--tw-scale-y:100%;--tw-scale-z:100%;scale:var(--tw-scale-x) var(--tw-scale-y)}.scale-\[1\.02\]{scale:1.02}.rotate-12{rotate:12deg}.rotate-90{rotate:90deg}.rotate-135{rotate:135deg}.rotate-180{rotate:180deg}.transform,.transform-cpu{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}.transform-gpu{transform:translateZ(0) var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}.transform-none{transform:none}.animate-bounce{animation:var(--animate-bounce)}.animate-ping{animation:var(--animate-ping)}.animate-pulse{animation:var(--animate-pulse)}.animate-spin{animation:var(--animate-spin)}.cursor-col-resize{cursor:col-resize}.cursor-copy{cursor:copy}.cursor-crosshair{cursor:crosshair}.cursor-default{cursor:default}.cursor-ew-resize{cursor:ew-resize}.cursor-grab{cursor:grab}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.touch-pan-left{--tw-pan-x:pan-left;touch-action:var(--tw-pan-x,) var(--tw-pan-y,) var(--tw-pinch-zoom,)}.touch-pan-right{--tw-pan-x:pan-right;touch-action:var(--tw-pan-x,) var(--tw-pan-y,) var(--tw-pinch-zoom,)}.touch-pan-x{--tw-pan-x:pan-x;touch-action:var(--tw-pan-x,) var(--tw-pan-y,) var(--tw-pinch-zoom,)}.touch-pan-down{--tw-pan-y:pan-down;touch-action:var(--tw-pan-x,) var(--tw-pan-y,) var(--tw-pinch-zoom,)}.touch-pan-up{--tw-pan-y:pan-up;touch-action:var(--tw-pan-x,) var(--tw-pan-y,) var(--tw-pinch-zoom,)}.touch-pan-y{--tw-pan-y:pan-y;touch-action:var(--tw-pan-x,) var(--tw-pan-y,) var(--tw-pinch-zoom,)}.touch-pinch-zoom{--tw-pinch-zoom:pinch-zoom;touch-action:var(--tw-pan-x,) var(--tw-pan-y,) var(--tw-pinch-zoom,)}.touch-auto{touch-action:auto}.touch-manipulation{touch-action:manipulation}.touch-none{touch-action:none}.resize{resize:both}.resize-none{resize:none}.resize-x{resize:horizontal}.resize-y{resize:vertical}.snap-both{scroll-snap-type:both var(--tw-scroll-snap-strictness)}.snap-none{scroll-snap-type:none}.snap-x{scroll-snap-type:x var(--tw-scroll-snap-strictness)}.snap-y{scroll-snap-type:y var(--tw-scroll-snap-strictness)}.snap-mandatory{--tw-scroll-snap-strictness:mandatory}.snap-proximity{--tw-scroll-snap-strictness:proximity}.snap-align-none{scroll-snap-align:none}.snap-center{scroll-snap-align:center}.snap-end{scroll-snap-align:end}.snap-start{scroll-snap-align:start}.snap-always{scroll-snap-stop:always}.snap-normal{scroll-snap-stop:normal}.scroll-mt-24{scroll-margin-top:calc(var(--spacing) * 24)}.scrollbar-thin{scrollbar-width:thin}.list-inside{list-style-position:inside}.list-outside{list-style-position:outside}.list-decimal{list-style-type:decimal}.list-disc{list-style-type:disc}.list-none{list-style-type:none}.form-select{appearance:none;--tw-shadow:0 0 #0000;background-color:#fff;border-width:1px;border-color:oklch(55.1% .027 264.364);border-radius:0;padding:.5rem .75rem;font-size:1rem;line-height:1.5rem}.form-select:focus{outline-offset:2px;--tw-ring-inset:var(--tw-empty, );--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:oklch(54.6% .245 262.881);--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);border-color:oklch(54.6% .245 262.881);outline:2px solid #0000}.form-select{-webkit-print-color-adjust:exact;print-color-adjust:exact;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='oklch(55.1%25 0.027 264.364)' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e");background-position:right .5rem center;background-repeat:no-repeat;background-size:1.5em 1.5em;padding-right:2.5rem}.form-select:where([size]:not([size="1"])){background-image:initial;background-position:initial;background-repeat:unset;background-size:initial;print-color-adjust:unset;padding-right:.75rem}.form-textarea{appearance:none;--tw-shadow:0 0 #0000;background-color:#fff;border-width:1px;border-color:oklch(55.1% .027 264.364);border-radius:0;padding:.5rem .75rem;font-size:1rem;line-height:1.5rem}.form-textarea:focus{outline-offset:2px;--tw-ring-inset:var(--tw-empty, );--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:oklch(54.6% .245 262.881);--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);border-color:oklch(54.6% .245 262.881);outline:2px solid #0000}.form-textarea::placeholder{color:oklch(55.1% .027 264.364);opacity:1}.form-multiselect{appearance:none;--tw-shadow:0 0 #0000;background-color:#fff;border-width:1px;border-color:oklch(55.1% .027 264.364);border-radius:0;padding:.5rem .75rem;font-size:1rem;line-height:1.5rem}.form-multiselect:focus{outline-offset:2px;--tw-ring-inset:var(--tw-empty, );--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:oklch(54.6% .245 262.881);--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);border-color:oklch(54.6% .245 262.881);outline:2px solid #0000}.appearance-auto{appearance:auto}.appearance-none{appearance:none}.break-before-all{break-before:all}.break-before-auto{break-before:auto}.break-before-avoid{break-before:avoid}.break-before-avoid-page{break-before:avoid-page}.break-before-column{break-before:column}.break-before-left{break-before:left}.break-before-page{break-before:page}.break-before-right{break-before:right}.break-inside-auto{break-inside:auto}.break-inside-avoid{break-inside:avoid}.break-inside-avoid-column{break-inside:avoid-column}.break-inside-avoid-page{break-inside:avoid-page}.break-after-all{break-after:all}.break-after-auto{break-after:auto}.break-after-avoid{break-after:avoid}.break-after-avoid-page{break-after:avoid-page}.break-after-column{break-after:column}.break-after-left{break-after:left}.break-after-page{break-after:page}.break-after-right{break-after:right}.grid-flow-col{grid-auto-flow:column}.grid-flow-col-dense{grid-auto-flow:column dense}.grid-flow-dense{grid-auto-flow:dense}.grid-flow-row{grid-auto-flow:row}.grid-flow-row-dense{grid-auto-flow:dense}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.grid-cols-5{grid-template-columns:repeat(5,minmax(0,1fr))}.grid-cols-6{grid-template-columns:repeat(6,minmax(0,1fr))}.grid-cols-\[minmax\(0\,38\%\)_1fr\]{grid-template-columns:minmax(0,38%) 1fr}.grid-rows-4{grid-template-rows:repeat(4,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-col-reverse{flex-direction:column-reverse}.flex-row{flex-direction:row}.flex-row-reverse{flex-direction:row-reverse}.flex-nowrap{flex-wrap:nowrap}.flex-wrap{flex-wrap:wrap}.flex-wrap-reverse{flex-wrap:wrap-reverse}.place-content-around{place-content:space-around}.place-content-baseline{place-content:baseline start}.place-content-between{place-content:space-between}.place-content-center{place-content:center}.place-content-end{place-content:end}.place-content-evenly{place-content:space-evenly}.place-content-start{place-content:start}.place-content-stretch{place-content:stretch}.place-items-baseline{place-items:baseline}.place-items-center{place-items:center}.place-items-end{place-items:end}.place-items-start{place-items:start}.place-items-stretch{place-items:stretch stretch}.content-around{align-content:space-around}.content-baseline{align-content:baseline}.content-between{align-content:space-between}.content-center{align-content:center}.content-end{align-content:flex-end}.content-evenly{align-content:space-evenly}.content-normal{align-content:normal}.content-start{align-content:flex-start}.content-stretch{align-content:stretch}.items-baseline{align-items:baseline}.items-center{align-items:center}.items-end{align-items:flex-end}.items-start{align-items:flex-start}.items-stretch{align-items:stretch}.justify-around{justify-content:space-around}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.justify-evenly{justify-content:space-evenly}.justify-normal{justify-content:normal}.justify-start{justify-content:flex-start}.justify-stretch{justify-content:stretch}.justify-items-center{justify-items:center}.justify-items-end{justify-items:end}.justify-items-start{justify-items:start}.justify-items-stretch{justify-items:stretch}.gap-0{gap:0}.gap-0\.5{gap:calc(var(--spacing) * .5)}.gap-1{gap:var(--spacing)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-2\.5{gap:calc(var(--spacing) * 2.5)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-5{gap:calc(var(--spacing) * 5)}.gap-6{gap:calc(var(--spacing) * 6)}.gap-8{gap:calc(var(--spacing) * 8)}.gap-10{gap:calc(var(--spacing) * 10)}:where(.space-y-0>:not(:last-child)){--tw-space-y-reverse:0;margin-block:0}:where(.space-y-0\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * .5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * .5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-1>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(var(--spacing) * var(--tw-space-y-reverse));margin-block-end:calc(var(--spacing) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-1\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 1.5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 1.5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2.5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2.5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-8>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 8) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 8) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-10>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 10) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 10) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-reverse>:not(:last-child)){--tw-space-y-reverse:1}.gap-x-1{column-gap:var(--spacing)}.gap-x-1\.5{column-gap:calc(var(--spacing) * 1.5)}.gap-x-2{column-gap:calc(var(--spacing) * 2)}.gap-x-3{column-gap:calc(var(--spacing) * 3)}.gap-x-4{column-gap:calc(var(--spacing) * 4)}.gap-x-6{column-gap:calc(var(--spacing) * 6)}:where(.space-x-1>:not(:last-child)){--tw-space-x-reverse:0;margin-inline-start:calc(var(--spacing) * var(--tw-space-x-reverse));margin-inline-end:calc(var(--spacing) * calc(1 - var(--tw-space-x-reverse)))}:where(.space-x-1\.5>:not(:last-child)){--tw-space-x-reverse:0;margin-inline-start:calc(calc(var(--spacing) * 1.5) * var(--tw-space-x-reverse));margin-inline-end:calc(calc(var(--spacing) * 1.5) * calc(1 - var(--tw-space-x-reverse)))}:where(.space-x-2>:not(:last-child)){--tw-space-x-reverse:0;margin-inline-start:calc(calc(var(--spacing) * 2) * var(--tw-space-x-reverse));margin-inline-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-x-reverse)))}:where(.space-x-3>:not(:last-child)){--tw-space-x-reverse:0;margin-inline-start:calc(calc(var(--spacing) * 3) * var(--tw-space-x-reverse));margin-inline-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-x-reverse)))}:where(.space-x-4>:not(:last-child)){--tw-space-x-reverse:0;margin-inline-start:calc(calc(var(--spacing) * 4) * var(--tw-space-x-reverse));margin-inline-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-x-reverse)))}:where(.space-x-reverse>:not(:last-child)){--tw-space-x-reverse:1}.gap-y-0\.5{row-gap:calc(var(--spacing) * .5)}.gap-y-1{row-gap:var(--spacing)}.gap-y-2{row-gap:calc(var(--spacing) * 2)}.gap-y-3{row-gap:calc(var(--spacing) * 3)}.gap-y-4{row-gap:calc(var(--spacing) * 4)}:where(.divide-x>:not(:last-child)){--tw-divide-x-reverse:0;border-inline-style:var(--tw-border-style);border-inline-start-width:calc(1px * var(--tw-divide-x-reverse));border-inline-end-width:calc(1px * calc(1 - var(--tw-divide-x-reverse)))}:where(.divide-x-0>:not(:last-child)){--tw-divide-x-reverse:0;border-inline-style:var(--tw-border-style);border-inline-start-width:calc(0px * var(--tw-divide-x-reverse));border-inline-end-width:calc(0px * calc(1 - var(--tw-divide-x-reverse)))}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-y-reverse>:not(:last-child)){--tw-divide-y-reverse:1}:where(.divide-dashed>:not(:last-child)){--tw-border-style:dashed;border-style:dashed}:where(.divide-dotted>:not(:last-child)){--tw-border-style:dotted;border-style:dotted}:where(.divide-double>:not(:last-child)){--tw-border-style:double;border-style:double}:where(.divide-none>:not(:last-child)){--tw-border-style:none;border-style:none}:where(.divide-solid>:not(:last-child)){--tw-border-style:solid;border-style:solid}:where(.divide-gray-100>:not(:last-child)){border-color:var(--color-gray-100)}:where(.divide-gray-200>:not(:last-child)){border-color:var(--color-gray-200)}:where(.divide-gray-200\/60>:not(:last-child)){border-color:#e5e7eb99}@supports (color:color-mix(in lab, red, red)){:where(.divide-gray-200\/60>:not(:last-child)){border-color:color-mix(in oklab, var(--color-gray-200) 60%, transparent)}}:where(.divide-gray-300>:not(:last-child)){border-color:var(--color-gray-300)}.place-self-auto{place-self:auto}.place-self-center{place-self:center}.place-self-end{place-self:end}.place-self-start{place-self:start}.place-self-stretch{place-self:stretch stretch}.self-auto{align-self:auto}.self-baseline{align-self:baseline}.self-center{align-self:center}.self-end{align-self:flex-end}.self-start{align-self:flex-start}.self-stretch{align-self:stretch}.justify-self-auto{justify-self:auto}.justify-self-center{justify-self:center}.justify-self-end{justify-self:flex-end}.justify-self-start{justify-self:flex-start}.justify-self-stretch{justify-self:stretch}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-auto{overflow:auto}.overflow-clip{overflow:clip}.overflow-hidden{overflow:hidden}.overflow-scroll{overflow:scroll}.overflow-visible{overflow:visible}.overflow-x-auto{overflow-x:auto}.overflow-x-clip{overflow-x:clip}.overflow-x-hidden{overflow-x:hidden}.overflow-x-scroll{overflow-x:scroll}.overflow-x-visible{overflow-x:visible}.overflow-y-auto{overflow-y:auto}.overflow-y-clip{overflow-y:clip}.overflow-y-hidden{overflow-y:hidden}.overflow-y-scroll{overflow-y:scroll}.overflow-y-visible{overflow-y:visible}.overscroll-auto{overscroll-behavior:auto}.overscroll-contain{overscroll-behavior:contain}.overscroll-none{overscroll-behavior:none}.overscroll-x-auto{overscroll-behavior-x:auto}.overscroll-x-contain{overscroll-behavior-x:contain}.overscroll-x-none{overscroll-behavior-x:none}.overscroll-y-auto{overscroll-behavior-y:auto}.overscroll-y-contain{overscroll-behavior-y:contain}.overscroll-y-none{overscroll-behavior-y:none}.scroll-auto{scroll-behavior:auto}.scroll-smooth{scroll-behavior:smooth}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:var(--radius-2xl)}.rounded-2xl\!{border-radius:var(--radius-2xl)!important}.rounded-3xl{border-radius:var(--radius-3xl)}.rounded-4xl{border-radius:var(--radius-4xl)}.rounded-\[3rem\]{border-radius:3rem}.rounded-\[10px\]{border-radius:10px}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-lg\!{border-radius:var(--radius-lg)!important}.rounded-md{border-radius:var(--radius-md)}.rounded-none{border-radius:0}.rounded-sm{border-radius:var(--radius-sm)}.rounded-xl{border-radius:var(--radius-xl)}.rounded-xl\!{border-radius:var(--radius-xl)!important}.rounded-xs{border-radius:var(--radius-xs)}.rounded-s{border-start-start-radius:.25rem;border-end-start-radius:.25rem}.rounded-ss{border-start-start-radius:.25rem}.rounded-e{border-start-end-radius:.25rem;border-end-end-radius:.25rem}.rounded-se{border-start-end-radius:.25rem}.rounded-ee{border-end-end-radius:.25rem}.rounded-es{border-end-start-radius:.25rem}.rounded-t{border-top-left-radius:.25rem;border-top-right-radius:.25rem}.rounded-t-2xl{border-top-left-radius:var(--radius-2xl);border-top-right-radius:var(--radius-2xl)}.rounded-t-lg{border-top-left-radius:var(--radius-lg);border-top-right-radius:var(--radius-lg)}.rounded-t-md{border-top-left-radius:var(--radius-md);border-top-right-radius:var(--radius-md)}.rounded-l{border-top-left-radius:.25rem;border-bottom-left-radius:.25rem}.rounded-l-xl{border-top-left-radius:var(--radius-xl);border-bottom-left-radius:var(--radius-xl)}.rounded-tl{border-top-left-radius:.25rem}.rounded-r{border-top-right-radius:.25rem;border-bottom-right-radius:.25rem}.rounded-r-full{border-top-right-radius:3.40282e38px;border-bottom-right-radius:3.40282e38px}.rounded-r-lg{border-top-right-radius:var(--radius-lg);border-bottom-right-radius:var(--radius-lg)}.rounded-r-xl{border-top-right-radius:var(--radius-xl);border-bottom-right-radius:var(--radius-xl)}.rounded-tr{border-top-right-radius:.25rem}.rounded-b{border-bottom-right-radius:.25rem;border-bottom-left-radius:.25rem}.rounded-b-2xl{border-bottom-right-radius:var(--radius-2xl);border-bottom-left-radius:var(--radius-2xl)}.rounded-br{border-bottom-right-radius:.25rem}.rounded-bl{border-bottom-left-radius:.25rem}.border{border-style:var(--tw-border-style);border-width:1px}.border\!{border-style:var(--tw-border-style)!important;border-width:1px!important}.border-0{border-style:var(--tw-border-style);border-width:0}.border-2{border-style:var(--tw-border-style);border-width:2px}.border-2\!{border-style:var(--tw-border-style)!important;border-width:2px!important}.border-4{border-style:var(--tw-border-style);border-width:4px}.border-x{border-inline-style:var(--tw-border-style);border-inline-width:1px}.border-x-2{border-inline-style:var(--tw-border-style);border-inline-width:2px}.border-y{border-block-style:var(--tw-border-style);border-block-width:1px}.border-s{border-inline-start-style:var(--tw-border-style);border-inline-start-width:1px}.border-e{border-inline-end-style:var(--tw-border-style);border-inline-end-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-t-2{border-top-style:var(--tw-border-style);border-top-width:2px}.border-r{border-right-style:var(--tw-border-style);border-right-width:1px}.border-r-2{border-right-style:var(--tw-border-style);border-right-width:2px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-b-2{border-bottom-style:var(--tw-border-style);border-bottom-width:2px}.border-l{border-left-style:var(--tw-border-style);border-left-width:1px}.border-l-2{border-left-style:var(--tw-border-style);border-left-width:2px}.border-l-4{border-left-style:var(--tw-border-style);border-left-width:4px}.border-dashed{--tw-border-style:dashed;border-style:dashed}.border-dotted{--tw-border-style:dotted;border-style:dotted}.border-double{--tw-border-style:double;border-style:double}.border-hidden{--tw-border-style:hidden;border-style:hidden}.border-none{--tw-border-style:none;border-style:none}.border-none\!{--tw-border-style:none!important;border-style:none!important}.border-solid{--tw-border-style:solid;border-style:solid}.border-\[var\(--mc-border-strong\)\]{border-color:var(--mc-border-strong)}.border-amber-100{border-color:var(--color-amber-100)}.border-amber-200{border-color:var(--color-amber-200)}.border-amber-200\/50{border-color:#fee68580}@supports (color:color-mix(in lab, red, red)){.border-amber-200\/50{border-color:color-mix(in oklab, var(--color-amber-200) 50%, transparent)}}.border-amber-200\/70{border-color:#fee685b3}@supports (color:color-mix(in lab, red, red)){.border-amber-200\/70{border-color:color-mix(in oklab, var(--color-amber-200) 70%, transparent)}}.border-amber-200\/80{border-color:#fee685cc}@supports (color:color-mix(in lab, red, red)){.border-amber-200\/80{border-color:color-mix(in oklab, var(--color-amber-200) 80%, transparent)}}.border-amber-200\/85{border-color:#fee685d9}@supports (color:color-mix(in lab, red, red)){.border-amber-200\/85{border-color:color-mix(in oklab, var(--color-amber-200) 85%, transparent)}}.border-amber-200\/90{border-color:#fee685e6}@supports (color:color-mix(in lab, red, red)){.border-amber-200\/90{border-color:color-mix(in oklab, var(--color-amber-200) 90%, transparent)}}.border-amber-300{border-color:var(--color-amber-300)}.border-amber-400{border-color:var(--color-amber-400)}.border-amber-500{border-color:var(--color-amber-500)}.border-amber-500\/30{border-color:#f99c004d}@supports (color:color-mix(in lab, red, red)){.border-amber-500\/30{border-color:color-mix(in oklab, var(--color-amber-500) 30%, transparent)}}.border-amber-500\/35{border-color:#f99c0059}@supports (color:color-mix(in lab, red, red)){.border-amber-500\/35{border-color:color-mix(in oklab, var(--color-amber-500) 35%, transparent)}}.border-amber-500\/40{border-color:#f99c0066}@supports (color:color-mix(in lab, red, red)){.border-amber-500\/40{border-color:color-mix(in oklab, var(--color-amber-500) 40%, transparent)}}.border-amber-700\/80{border-color:#b75000cc}@supports (color:color-mix(in lab, red, red)){.border-amber-700\/80{border-color:color-mix(in oklab, var(--color-amber-700) 80%, transparent)}}.border-amber-800\/80{border-color:#953d00cc}@supports (color:color-mix(in lab, red, red)){.border-amber-800\/80{border-color:color-mix(in oklab, var(--color-amber-800) 80%, transparent)}}.border-black\/5{border-color:#0000000d}@supports (color:color-mix(in lab, red, red)){.border-black\/5{border-color:color-mix(in oklab, var(--color-black) 5%, transparent)}}.border-black\/8{border-color:#00000014}@supports (color:color-mix(in lab, red, red)){.border-black\/8{border-color:color-mix(in oklab, var(--color-black) 8%, transparent)}}.border-black\/10{border-color:#0000001a}@supports (color:color-mix(in lab, red, red)){.border-black\/10{border-color:color-mix(in oklab, var(--color-black) 10%, transparent)}}.border-blue-100{border-color:var(--color-blue-100)}.border-blue-100\/50{border-color:#dbeafe80}@supports (color:color-mix(in lab, red, red)){.border-blue-100\/50{border-color:color-mix(in oklab, var(--color-blue-100) 50%, transparent)}}.border-blue-200{border-color:var(--color-blue-200)}.border-blue-200\/50{border-color:#bedbff80}@supports (color:color-mix(in lab, red, red)){.border-blue-200\/50{border-color:color-mix(in oklab, var(--color-blue-200) 50%, transparent)}}.border-blue-200\/60{border-color:#bedbff99}@supports (color:color-mix(in lab, red, red)){.border-blue-200\/60{border-color:color-mix(in oklab, var(--color-blue-200) 60%, transparent)}}.border-blue-300{border-color:var(--color-blue-300)}.border-blue-400{border-color:var(--color-blue-400)}.border-blue-400\/70{border-color:#54a2ffb3}@supports (color:color-mix(in lab, red, red)){.border-blue-400\/70{border-color:color-mix(in oklab, var(--color-blue-400) 70%, transparent)}}.border-blue-500{border-color:var(--color-blue-500)}.border-blue-500\/10{border-color:#3080ff1a}@supports (color:color-mix(in lab, red, red)){.border-blue-500\/10{border-color:color-mix(in oklab, var(--color-blue-500) 10%, transparent)}}.border-blue-500\/20{border-color:#3080ff33}@supports (color:color-mix(in lab, red, red)){.border-blue-500\/20{border-color:color-mix(in oklab, var(--color-blue-500) 20%, transparent)}}.border-blue-500\/30{border-color:#3080ff4d}@supports (color:color-mix(in lab, red, red)){.border-blue-500\/30{border-color:color-mix(in oklab, var(--color-blue-500) 30%, transparent)}}.border-blue-500\/50{border-color:#3080ff80}@supports (color:color-mix(in lab, red, red)){.border-blue-500\/50{border-color:color-mix(in oklab, var(--color-blue-500) 50%, transparent)}}.border-blue-600{border-color:var(--color-blue-600)}.border-current,.border-current\/20{border-color:currentColor}@supports (color:color-mix(in lab, red, red)){.border-current\/20{border-color:color-mix(in oklab, currentcolor 20%, transparent)}}.border-cyan-200{border-color:var(--color-cyan-200)}.border-cyan-500\/50{border-color:#00b7d780}@supports (color:color-mix(in lab, red, red)){.border-cyan-500\/50{border-color:color-mix(in oklab, var(--color-cyan-500) 50%, transparent)}}.border-emerald-100{border-color:var(--color-emerald-100)}.border-emerald-100\/50{border-color:#d0fae580}@supports (color:color-mix(in lab, red, red)){.border-emerald-100\/50{border-color:color-mix(in oklab, var(--color-emerald-100) 50%, transparent)}}.border-emerald-200{border-color:var(--color-emerald-200)}.border-emerald-200\/60{border-color:#a4f4cf99}@supports (color:color-mix(in lab, red, red)){.border-emerald-200\/60{border-color:color-mix(in oklab, var(--color-emerald-200) 60%, transparent)}}.border-emerald-500{border-color:var(--color-emerald-500)}.border-emerald-500\/20{border-color:#00bb7f33}@supports (color:color-mix(in lab, red, red)){.border-emerald-500\/20{border-color:color-mix(in oklab, var(--color-emerald-500) 20%, transparent)}}.border-emerald-500\/35{border-color:#00bb7f59}@supports (color:color-mix(in lab, red, red)){.border-emerald-500\/35{border-color:color-mix(in oklab, var(--color-emerald-500) 35%, transparent)}}.border-emerald-800\/80{border-color:#005f46cc}@supports (color:color-mix(in lab, red, red)){.border-emerald-800\/80{border-color:color-mix(in oklab, var(--color-emerald-800) 80%, transparent)}}.border-gray-100{border-color:var(--color-gray-100)}.border-gray-100\/20{border-color:#f3f4f633}@supports (color:color-mix(in lab, red, red)){.border-gray-100\/20{border-color:color-mix(in oklab, var(--color-gray-100) 20%, transparent)}}.border-gray-100\/50{border-color:#f3f4f680}@supports (color:color-mix(in lab, red, red)){.border-gray-100\/50{border-color:color-mix(in oklab, var(--color-gray-100) 50%, transparent)}}.border-gray-100\/80{border-color:#f3f4f6cc}@supports (color:color-mix(in lab, red, red)){.border-gray-100\/80{border-color:color-mix(in oklab, var(--color-gray-100) 80%, transparent)}}.border-gray-200{border-color:var(--color-gray-200)}.border-gray-200\!{border-color:var(--color-gray-200)!important}.border-gray-200\/50{border-color:#e5e7eb80}@supports (color:color-mix(in lab, red, red)){.border-gray-200\/50{border-color:color-mix(in oklab, var(--color-gray-200) 50%, transparent)}}.border-gray-200\/60{border-color:#e5e7eb99}@supports (color:color-mix(in lab, red, red)){.border-gray-200\/60{border-color:color-mix(in oklab, var(--color-gray-200) 60%, transparent)}}.border-gray-200\/70{border-color:#e5e7ebb3}@supports (color:color-mix(in lab, red, red)){.border-gray-200\/70{border-color:color-mix(in oklab, var(--color-gray-200) 70%, transparent)}}.border-gray-200\/80{border-color:#e5e7ebcc}@supports (color:color-mix(in lab, red, red)){.border-gray-200\/80{border-color:color-mix(in oklab, var(--color-gray-200) 80%, transparent)}}.border-gray-200\/90{border-color:#e5e7ebe6}@supports (color:color-mix(in lab, red, red)){.border-gray-200\/90{border-color:color-mix(in oklab, var(--color-gray-200) 90%, transparent)}}.border-gray-300{border-color:var(--color-gray-300)}.border-gray-400{border-color:var(--color-gray-400)}.border-gray-500{border-color:var(--color-gray-500)}.border-gray-700{border-color:var(--color-gray-700)}.border-green-200{border-color:var(--color-green-200)}.border-green-500\/30{border-color:#00c7584d}@supports (color:color-mix(in lab, red, red)){.border-green-500\/30{border-color:color-mix(in oklab, var(--color-green-500) 30%, transparent)}}.border-indigo-200{border-color:var(--color-indigo-200)}.border-indigo-200\/60{border-color:#c7d2ff99}@supports (color:color-mix(in lab, red, red)){.border-indigo-200\/60{border-color:color-mix(in oklab, var(--color-indigo-200) 60%, transparent)}}.border-indigo-300{border-color:var(--color-indigo-300)}.border-indigo-500{border-color:var(--color-indigo-500)}.border-indigo-500\/20{border-color:#625fff33}@supports (color:color-mix(in lab, red, red)){.border-indigo-500\/20{border-color:color-mix(in oklab, var(--color-indigo-500) 20%, transparent)}}.border-orange-200{border-color:var(--color-orange-200)}.border-pink-100{border-color:var(--color-pink-100)}.border-pink-200{border-color:var(--color-pink-200)}.border-purple-200{border-color:var(--color-purple-200)}.border-purple-500\/20{border-color:#ac4bff33}@supports (color:color-mix(in lab, red, red)){.border-purple-500\/20{border-color:color-mix(in oklab, var(--color-purple-500) 20%, transparent)}}.border-purple-500\/50{border-color:#ac4bff80}@supports (color:color-mix(in lab, red, red)){.border-purple-500\/50{border-color:color-mix(in oklab, var(--color-purple-500) 50%, transparent)}}.border-purple-500\/60{border-color:#ac4bff99}@supports (color:color-mix(in lab, red, red)){.border-purple-500\/60{border-color:color-mix(in oklab, var(--color-purple-500) 60%, transparent)}}.border-red-100{border-color:var(--color-red-100)}.border-red-200{border-color:var(--color-red-200)}.border-red-200\!{border-color:var(--color-red-200)!important}.border-red-200\/60{border-color:#ffcaca99}@supports (color:color-mix(in lab, red, red)){.border-red-200\/60{border-color:color-mix(in oklab, var(--color-red-200) 60%, transparent)}}.border-red-200\/70{border-color:#ffcacab3}@supports (color:color-mix(in lab, red, red)){.border-red-200\/70{border-color:color-mix(in oklab, var(--color-red-200) 70%, transparent)}}.border-red-200\/80{border-color:#ffcacacc}@supports (color:color-mix(in lab, red, red)){.border-red-200\/80{border-color:color-mix(in oklab, var(--color-red-200) 80%, transparent)}}.border-red-200\/90{border-color:#ffcacae6}@supports (color:color-mix(in lab, red, red)){.border-red-200\/90{border-color:color-mix(in oklab, var(--color-red-200) 90%, transparent)}}.border-red-300{border-color:var(--color-red-300)}.border-red-400{border-color:var(--color-red-400)}.border-red-500{border-color:var(--color-red-500)}.border-red-500\/20{border-color:#fb2c3633}@supports (color:color-mix(in lab, red, red)){.border-red-500\/20{border-color:color-mix(in oklab, var(--color-red-500) 20%, transparent)}}.border-red-500\/30{border-color:#fb2c364d}@supports (color:color-mix(in lab, red, red)){.border-red-500\/30{border-color:color-mix(in oklab, var(--color-red-500) 30%, transparent)}}.border-red-800\/80{border-color:#9f0712cc}@supports (color:color-mix(in lab, red, red)){.border-red-800\/80{border-color:color-mix(in oklab, var(--color-red-800) 80%, transparent)}}.border-rose-500\/20{border-color:#ff235733}@supports (color:color-mix(in lab, red, red)){.border-rose-500\/20{border-color:color-mix(in oklab, var(--color-rose-500) 20%, transparent)}}.border-sky-200{border-color:var(--color-sky-200)}.border-sky-200\/70{border-color:#b8e6feb3}@supports (color:color-mix(in lab, red, red)){.border-sky-200\/70{border-color:color-mix(in oklab, var(--color-sky-200) 70%, transparent)}}.border-sky-200\/90{border-color:#b8e6fee6}@supports (color:color-mix(in lab, red, red)){.border-sky-200\/90{border-color:color-mix(in oklab, var(--color-sky-200) 90%, transparent)}}.border-sky-300\/45{border-color:#77d4ff73}@supports (color:color-mix(in lab, red, red)){.border-sky-300\/45{border-color:color-mix(in oklab, var(--color-sky-300) 45%, transparent)}}.border-sky-900\/80{border-color:#024a70cc}@supports (color:color-mix(in lab, red, red)){.border-sky-900\/80{border-color:color-mix(in oklab, var(--color-sky-900) 80%, transparent)}}.border-slate-200{border-color:var(--color-slate-200)}.border-slate-200\/80{border-color:#e2e8f0cc}@supports (color:color-mix(in lab, red, red)){.border-slate-200\/80{border-color:color-mix(in oklab, var(--color-slate-200) 80%, transparent)}}.border-teal-200{border-color:var(--color-teal-200)}.border-teal-300\!{border-color:var(--color-teal-300)!important}.border-teal-500{border-color:var(--color-teal-500)}.border-transparent{border-color:#0000}.border-violet-200{border-color:var(--color-violet-200)}.border-white{border-color:var(--color-white)}.border-white\/10{border-color:#ffffff1a}@supports (color:color-mix(in lab, red, red)){.border-white\/10{border-color:color-mix(in oklab, var(--color-white) 10%, transparent)}}.border-white\/20{border-color:#fff3}@supports (color:color-mix(in lab, red, red)){.border-white\/20{border-color:color-mix(in oklab, var(--color-white) 20%, transparent)}}.border-white\/40{border-color:#fff6}@supports (color:color-mix(in lab, red, red)){.border-white\/40{border-color:color-mix(in oklab, var(--color-white) 40%, transparent)}}.border-yellow-200{border-color:var(--color-yellow-200)}.border-yellow-300{border-color:var(--color-yellow-300)}.border-yellow-700\/50{border-color:#a3610080}@supports (color:color-mix(in lab, red, red)){.border-yellow-700\/50{border-color:color-mix(in oklab, var(--color-yellow-700) 50%, transparent)}}.border-zinc-100{border-color:var(--color-zinc-100)}.border-zinc-200{border-color:var(--color-zinc-200)}.border-zinc-600\/50{border-color:#52525c80}@supports (color:color-mix(in lab, red, red)){.border-zinc-600\/50{border-color:color-mix(in oklab, var(--color-zinc-600) 50%, transparent)}}.border-zinc-700{border-color:var(--color-zinc-700)}.border-zinc-700\/80{border-color:#3f3f46cc}@supports (color:color-mix(in lab, red, red)){.border-zinc-700\/80{border-color:color-mix(in oklab, var(--color-zinc-700) 80%, transparent)}}.border-zinc-800{border-color:var(--color-zinc-800)}.border-zinc-900{border-color:var(--color-zinc-900)}.border-t-blue-500{border-top-color:var(--color-blue-500)}.border-t-current{border-top-color:currentColor}.border-t-indigo-600{border-top-color:var(--color-indigo-600)}.border-t-transparent{border-top-color:#0000}.border-t-white{border-top-color:var(--color-white)}.border-b-emerald-500{border-bottom-color:var(--color-emerald-500)}.border-b-transparent{border-bottom-color:#0000}.border-l-gray-300{border-left-color:var(--color-gray-300)}.border-l-green-500{border-left-color:var(--color-green-500)}.border-l-red-500{border-left-color:var(--color-red-500)}.bg-\[\#1a0b33\]{background-color:#1a0b33}.bg-\[\#2b1065\]{background-color:#2b1065}.bg-\[var\(--mc-surface\)\]{background-color:var(--mc-surface)}.bg-\[var\(--mc-surface-hover\)\]{background-color:var(--mc-surface-hover)}.bg-amber-50{background-color:var(--color-amber-50)}.bg-amber-50\/40{background-color:#fffbeb66}@supports (color:color-mix(in lab, red, red)){.bg-amber-50\/40{background-color:color-mix(in oklab, var(--color-amber-50) 40%, transparent)}}.bg-amber-50\/50{background-color:#fffbeb80}@supports (color:color-mix(in lab, red, red)){.bg-amber-50\/50{background-color:color-mix(in oklab, var(--color-amber-50) 50%, transparent)}}.bg-amber-50\/60{background-color:#fffbeb99}@supports (color:color-mix(in lab, red, red)){.bg-amber-50\/60{background-color:color-mix(in oklab, var(--color-amber-50) 60%, transparent)}}.bg-amber-50\/70{background-color:#fffbebb3}@supports (color:color-mix(in lab, red, red)){.bg-amber-50\/70{background-color:color-mix(in oklab, var(--color-amber-50) 70%, transparent)}}.bg-amber-50\/80{background-color:#fffbebcc}@supports (color:color-mix(in lab, red, red)){.bg-amber-50\/80{background-color:color-mix(in oklab, var(--color-amber-50) 80%, transparent)}}.bg-amber-50\/90{background-color:#fffbebe6}@supports (color:color-mix(in lab, red, red)){.bg-amber-50\/90{background-color:color-mix(in oklab, var(--color-amber-50) 90%, transparent)}}.bg-amber-100{background-color:var(--color-amber-100)}.bg-amber-100\/50{background-color:#fef3c680}@supports (color:color-mix(in lab, red, red)){.bg-amber-100\/50{background-color:color-mix(in oklab, var(--color-amber-100) 50%, transparent)}}.bg-amber-300{background-color:var(--color-amber-300)}.bg-amber-500{background-color:var(--color-amber-500)}.bg-amber-500\/5{background-color:#f99c000d}@supports (color:color-mix(in lab, red, red)){.bg-amber-500\/5{background-color:color-mix(in oklab, var(--color-amber-500) 5%, transparent)}}.bg-amber-500\/10{background-color:#f99c001a}@supports (color:color-mix(in lab, red, red)){.bg-amber-500\/10{background-color:color-mix(in oklab, var(--color-amber-500) 10%, transparent)}}.bg-amber-500\/90{background-color:#f99c00e6}@supports (color:color-mix(in lab, red, red)){.bg-amber-500\/90{background-color:color-mix(in oklab, var(--color-amber-500) 90%, transparent)}}.bg-amber-600{background-color:var(--color-amber-600)}.bg-amber-600\/40{background-color:#dd740066}@supports (color:color-mix(in lab, red, red)){.bg-amber-600\/40{background-color:color-mix(in oklab, var(--color-amber-600) 40%, transparent)}}.bg-amber-700{background-color:var(--color-amber-700)}.bg-black{background-color:var(--color-black)}.bg-black\/5{background-color:#0000000d}@supports (color:color-mix(in lab, red, red)){.bg-black\/5{background-color:color-mix(in oklab, var(--color-black) 5%, transparent)}}.bg-black\/10{background-color:#0000001a}@supports (color:color-mix(in lab, red, red)){.bg-black\/10{background-color:color-mix(in oklab, var(--color-black) 10%, transparent)}}.bg-black\/20{background-color:#0003}@supports (color:color-mix(in lab, red, red)){.bg-black\/20{background-color:color-mix(in oklab, var(--color-black) 20%, transparent)}}.bg-black\/40{background-color:#0006}@supports (color:color-mix(in lab, red, red)){.bg-black\/40{background-color:color-mix(in oklab, var(--color-black) 40%, transparent)}}.bg-black\/50{background-color:#00000080}@supports (color:color-mix(in lab, red, red)){.bg-black\/50{background-color:color-mix(in oklab, var(--color-black) 50%, transparent)}}.bg-black\/55{background-color:#0000008c}@supports (color:color-mix(in lab, red, red)){.bg-black\/55{background-color:color-mix(in oklab, var(--color-black) 55%, transparent)}}.bg-black\/60{background-color:#0009}@supports (color:color-mix(in lab, red, red)){.bg-black\/60{background-color:color-mix(in oklab, var(--color-black) 60%, transparent)}}.bg-black\/70{background-color:#000000b3}@supports (color:color-mix(in lab, red, red)){.bg-black\/70{background-color:color-mix(in oklab, var(--color-black) 70%, transparent)}}.bg-black\/75{background-color:#000000bf}@supports (color:color-mix(in lab, red, red)){.bg-black\/75{background-color:color-mix(in oklab, var(--color-black) 75%, transparent)}}.bg-black\/80{background-color:#000c}@supports (color:color-mix(in lab, red, red)){.bg-black\/80{background-color:color-mix(in oklab, var(--color-black) 80%, transparent)}}.bg-black\/\[0\.04\]{background-color:#0000000a}@supports (color:color-mix(in lab, red, red)){.bg-black\/\[0\.04\]{background-color:color-mix(in oklab, var(--color-black) 4%, transparent)}}.bg-blue-50{background-color:var(--color-blue-50)}.bg-blue-50\/20{background-color:#eff6ff33}@supports (color:color-mix(in lab, red, red)){.bg-blue-50\/20{background-color:color-mix(in oklab, var(--color-blue-50) 20%, transparent)}}.bg-blue-50\/30{background-color:#eff6ff4d}@supports (color:color-mix(in lab, red, red)){.bg-blue-50\/30{background-color:color-mix(in oklab, var(--color-blue-50) 30%, transparent)}}.bg-blue-50\/40{background-color:#eff6ff66}@supports (color:color-mix(in lab, red, red)){.bg-blue-50\/40{background-color:color-mix(in oklab, var(--color-blue-50) 40%, transparent)}}.bg-blue-50\/50{background-color:#eff6ff80}@supports (color:color-mix(in lab, red, red)){.bg-blue-50\/50{background-color:color-mix(in oklab, var(--color-blue-50) 50%, transparent)}}.bg-blue-50\/60{background-color:#eff6ff99}@supports (color:color-mix(in lab, red, red)){.bg-blue-50\/60{background-color:color-mix(in oklab, var(--color-blue-50) 60%, transparent)}}.bg-blue-50\/70{background-color:#eff6ffb3}@supports (color:color-mix(in lab, red, red)){.bg-blue-50\/70{background-color:color-mix(in oklab, var(--color-blue-50) 70%, transparent)}}.bg-blue-50\/80{background-color:#eff6ffcc}@supports (color:color-mix(in lab, red, red)){.bg-blue-50\/80{background-color:color-mix(in oklab, var(--color-blue-50) 80%, transparent)}}.bg-blue-50\/90{background-color:#eff6ffe6}@supports (color:color-mix(in lab, red, red)){.bg-blue-50\/90{background-color:color-mix(in oklab, var(--color-blue-50) 90%, transparent)}}.bg-blue-100{background-color:var(--color-blue-100)}.bg-blue-100\/50{background-color:#dbeafe80}@supports (color:color-mix(in lab, red, red)){.bg-blue-100\/50{background-color:color-mix(in oklab, var(--color-blue-100) 50%, transparent)}}.bg-blue-400{background-color:var(--color-blue-400)}.bg-blue-500{background-color:var(--color-blue-500)}.bg-blue-500\/5{background-color:#3080ff0d}@supports (color:color-mix(in lab, red, red)){.bg-blue-500\/5{background-color:color-mix(in oklab, var(--color-blue-500) 5%, transparent)}}.bg-blue-500\/6{background-color:#3080ff0f}@supports (color:color-mix(in lab, red, red)){.bg-blue-500\/6{background-color:color-mix(in oklab, var(--color-blue-500) 6%, transparent)}}.bg-blue-500\/10{background-color:#3080ff1a}@supports (color:color-mix(in lab, red, red)){.bg-blue-500\/10{background-color:color-mix(in oklab, var(--color-blue-500) 10%, transparent)}}.bg-blue-500\/20{background-color:#3080ff33}@supports (color:color-mix(in lab, red, red)){.bg-blue-500\/20{background-color:color-mix(in oklab, var(--color-blue-500) 20%, transparent)}}.bg-blue-500\/30{background-color:#3080ff4d}@supports (color:color-mix(in lab, red, red)){.bg-blue-500\/30{background-color:color-mix(in oklab, var(--color-blue-500) 30%, transparent)}}.bg-blue-600{background-color:var(--color-blue-600)}.bg-current{background-color:currentColor}.bg-cyan-50{background-color:var(--color-cyan-50)}.bg-cyan-100{background-color:var(--color-cyan-100)}.bg-cyan-500\/10{background-color:#00b7d71a}@supports (color:color-mix(in lab, red, red)){.bg-cyan-500\/10{background-color:color-mix(in oklab, var(--color-cyan-500) 10%, transparent)}}.bg-emerald-50{background-color:var(--color-emerald-50)}.bg-emerald-50\/20{background-color:#ecfdf533}@supports (color:color-mix(in lab, red, red)){.bg-emerald-50\/20{background-color:color-mix(in oklab, var(--color-emerald-50) 20%, transparent)}}.bg-emerald-50\/50{background-color:#ecfdf580}@supports (color:color-mix(in lab, red, red)){.bg-emerald-50\/50{background-color:color-mix(in oklab, var(--color-emerald-50) 50%, transparent)}}.bg-emerald-50\/80{background-color:#ecfdf5cc}@supports (color:color-mix(in lab, red, red)){.bg-emerald-50\/80{background-color:color-mix(in oklab, var(--color-emerald-50) 80%, transparent)}}.bg-emerald-100{background-color:var(--color-emerald-100)}.bg-emerald-500{background-color:var(--color-emerald-500)}.bg-emerald-500\/5{background-color:#00bb7f0d}@supports (color:color-mix(in lab, red, red)){.bg-emerald-500\/5{background-color:color-mix(in oklab, var(--color-emerald-500) 5%, transparent)}}.bg-emerald-500\/10{background-color:#00bb7f1a}@supports (color:color-mix(in lab, red, red)){.bg-emerald-500\/10{background-color:color-mix(in oklab, var(--color-emerald-500) 10%, transparent)}}.bg-emerald-500\/15{background-color:#00bb7f26}@supports (color:color-mix(in lab, red, red)){.bg-emerald-500\/15{background-color:color-mix(in oklab, var(--color-emerald-500) 15%, transparent)}}.bg-emerald-500\/20{background-color:#00bb7f33}@supports (color:color-mix(in lab, red, red)){.bg-emerald-500\/20{background-color:color-mix(in oklab, var(--color-emerald-500) 20%, transparent)}}.bg-emerald-600{background-color:var(--color-emerald-600)}.bg-emerald-700{background-color:var(--color-emerald-700)}.bg-gray-50{background-color:var(--color-gray-50)}.bg-gray-50\/30{background-color:#f9fafb4d}@supports (color:color-mix(in lab, red, red)){.bg-gray-50\/30{background-color:color-mix(in oklab, var(--color-gray-50) 30%, transparent)}}.bg-gray-50\/40{background-color:#f9fafb66}@supports (color:color-mix(in lab, red, red)){.bg-gray-50\/40{background-color:color-mix(in oklab, var(--color-gray-50) 40%, transparent)}}.bg-gray-50\/50{background-color:#f9fafb80}@supports (color:color-mix(in lab, red, red)){.bg-gray-50\/50{background-color:color-mix(in oklab, var(--color-gray-50) 50%, transparent)}}.bg-gray-50\/60{background-color:#f9fafb99}@supports (color:color-mix(in lab, red, red)){.bg-gray-50\/60{background-color:color-mix(in oklab, var(--color-gray-50) 60%, transparent)}}.bg-gray-50\/70{background-color:#f9fafbb3}@supports (color:color-mix(in lab, red, red)){.bg-gray-50\/70{background-color:color-mix(in oklab, var(--color-gray-50) 70%, transparent)}}.bg-gray-50\/80{background-color:#f9fafbcc}@supports (color:color-mix(in lab, red, red)){.bg-gray-50\/80{background-color:color-mix(in oklab, var(--color-gray-50) 80%, transparent)}}.bg-gray-50\/90{background-color:#f9fafbe6}@supports (color:color-mix(in lab, red, red)){.bg-gray-50\/90{background-color:color-mix(in oklab, var(--color-gray-50) 90%, transparent)}}.bg-gray-50\/95{background-color:#f9fafbf2}@supports (color:color-mix(in lab, red, red)){.bg-gray-50\/95{background-color:color-mix(in oklab, var(--color-gray-50) 95%, transparent)}}.bg-gray-100{background-color:var(--color-gray-100)}.bg-gray-100\/50{background-color:#f3f4f680}@supports (color:color-mix(in lab, red, red)){.bg-gray-100\/50{background-color:color-mix(in oklab, var(--color-gray-100) 50%, transparent)}}.bg-gray-100\/90{background-color:#f3f4f6e6}@supports (color:color-mix(in lab, red, red)){.bg-gray-100\/90{background-color:color-mix(in oklab, var(--color-gray-100) 90%, transparent)}}.bg-gray-200{background-color:var(--color-gray-200)}.bg-gray-200\/50{background-color:#e5e7eb80}@supports (color:color-mix(in lab, red, red)){.bg-gray-200\/50{background-color:color-mix(in oklab, var(--color-gray-200) 50%, transparent)}}.bg-gray-200\/80{background-color:#e5e7ebcc}@supports (color:color-mix(in lab, red, red)){.bg-gray-200\/80{background-color:color-mix(in oklab, var(--color-gray-200) 80%, transparent)}}.bg-gray-200\/90{background-color:#e5e7ebe6}@supports (color:color-mix(in lab, red, red)){.bg-gray-200\/90{background-color:color-mix(in oklab, var(--color-gray-200) 90%, transparent)}}.bg-gray-300\/85{background-color:#d1d5dcd9}@supports (color:color-mix(in lab, red, red)){.bg-gray-300\/85{background-color:color-mix(in oklab, var(--color-gray-300) 85%, transparent)}}.bg-gray-400{background-color:var(--color-gray-400)}.bg-gray-500{background-color:var(--color-gray-500)}.bg-gray-600{background-color:var(--color-gray-600)}.bg-gray-800{background-color:var(--color-gray-800)}.bg-gray-900{background-color:var(--color-gray-900)}.bg-green-50{background-color:var(--color-green-50)}.bg-green-50\/80{background-color:#f0fdf4cc}@supports (color:color-mix(in lab, red, red)){.bg-green-50\/80{background-color:color-mix(in oklab, var(--color-green-50) 80%, transparent)}}.bg-green-100{background-color:var(--color-green-100)}.bg-green-500{background-color:var(--color-green-500)}.bg-green-500\/10{background-color:#00c7581a}@supports (color:color-mix(in lab, red, red)){.bg-green-500\/10{background-color:color-mix(in oklab, var(--color-green-500) 10%, transparent)}}.bg-green-500\/20{background-color:#00c75833}@supports (color:color-mix(in lab, red, red)){.bg-green-500\/20{background-color:color-mix(in oklab, var(--color-green-500) 20%, transparent)}}.bg-green-600{background-color:var(--color-green-600)}.bg-indigo-50{background-color:var(--color-indigo-50)}.bg-indigo-50\/80{background-color:#eef2ffcc}@supports (color:color-mix(in lab, red, red)){.bg-indigo-50\/80{background-color:color-mix(in oklab, var(--color-indigo-50) 80%, transparent)}}.bg-indigo-100{background-color:var(--color-indigo-100)}.bg-indigo-500{background-color:var(--color-indigo-500)}.bg-indigo-500\/5{background-color:#625fff0d}@supports (color:color-mix(in lab, red, red)){.bg-indigo-500\/5{background-color:color-mix(in oklab, var(--color-indigo-500) 5%, transparent)}}.bg-indigo-500\/10{background-color:#625fff1a}@supports (color:color-mix(in lab, red, red)){.bg-indigo-500\/10{background-color:color-mix(in oklab, var(--color-indigo-500) 10%, transparent)}}.bg-indigo-500\/30{background-color:#625fff4d}@supports (color:color-mix(in lab, red, red)){.bg-indigo-500\/30{background-color:color-mix(in oklab, var(--color-indigo-500) 30%, transparent)}}.bg-indigo-600{background-color:var(--color-indigo-600)}.bg-orange-50{background-color:var(--color-orange-50)}.bg-orange-100{background-color:var(--color-orange-100)}.bg-orange-500{background-color:var(--color-orange-500)}.bg-orange-600{background-color:var(--color-orange-600)}.bg-pink-50{background-color:var(--color-pink-50)}.bg-pink-50\/50{background-color:#fdf2f880}@supports (color:color-mix(in lab, red, red)){.bg-pink-50\/50{background-color:color-mix(in oklab, var(--color-pink-50) 50%, transparent)}}.bg-purple-50{background-color:var(--color-purple-50)}.bg-purple-100{background-color:var(--color-purple-100)}.bg-purple-500\/10{background-color:#ac4bff1a}@supports (color:color-mix(in lab, red, red)){.bg-purple-500\/10{background-color:color-mix(in oklab, var(--color-purple-500) 10%, transparent)}}.bg-red-50{background-color:var(--color-red-50)}.bg-red-50\/30{background-color:#fef2f24d}@supports (color:color-mix(in lab, red, red)){.bg-red-50\/30{background-color:color-mix(in oklab, var(--color-red-50) 30%, transparent)}}.bg-red-50\/50{background-color:#fef2f280}@supports (color:color-mix(in lab, red, red)){.bg-red-50\/50{background-color:color-mix(in oklab, var(--color-red-50) 50%, transparent)}}.bg-red-50\/60{background-color:#fef2f299}@supports (color:color-mix(in lab, red, red)){.bg-red-50\/60{background-color:color-mix(in oklab, var(--color-red-50) 60%, transparent)}}.bg-red-50\/70{background-color:#fef2f2b3}@supports (color:color-mix(in lab, red, red)){.bg-red-50\/70{background-color:color-mix(in oklab, var(--color-red-50) 70%, transparent)}}.bg-red-50\/80{background-color:#fef2f2cc}@supports (color:color-mix(in lab, red, red)){.bg-red-50\/80{background-color:color-mix(in oklab, var(--color-red-50) 80%, transparent)}}.bg-red-50\/90{background-color:#fef2f2e6}@supports (color:color-mix(in lab, red, red)){.bg-red-50\/90{background-color:color-mix(in oklab, var(--color-red-50) 90%, transparent)}}.bg-red-100{background-color:var(--color-red-100)}.bg-red-500{background-color:var(--color-red-500)}.bg-red-500\/10{background-color:#fb2c361a}@supports (color:color-mix(in lab, red, red)){.bg-red-500\/10{background-color:color-mix(in oklab, var(--color-red-500) 10%, transparent)}}.bg-red-500\/90{background-color:#fb2c36e6}@supports (color:color-mix(in lab, red, red)){.bg-red-500\/90{background-color:color-mix(in oklab, var(--color-red-500) 90%, transparent)}}.bg-red-600{background-color:var(--color-red-600)}.bg-red-600\!{background-color:var(--color-red-600)!important}.bg-red-700{background-color:var(--color-red-700)}.bg-rose-50{background-color:var(--color-rose-50)}.bg-rose-100{background-color:var(--color-rose-100)}.bg-rose-500\/10{background-color:#ff23571a}@supports (color:color-mix(in lab, red, red)){.bg-rose-500\/10{background-color:color-mix(in oklab, var(--color-rose-500) 10%, transparent)}}.bg-rose-600{background-color:var(--color-rose-600)}.bg-sky-50{background-color:var(--color-sky-50)}.bg-sky-50\/40{background-color:#f0f9ff66}@supports (color:color-mix(in lab, red, red)){.bg-sky-50\/40{background-color:color-mix(in oklab, var(--color-sky-50) 40%, transparent)}}.bg-sky-100{background-color:var(--color-sky-100)}.bg-sky-600{background-color:var(--color-sky-600)}.bg-sky-700{background-color:var(--color-sky-700)}.bg-sky-800{background-color:var(--color-sky-800)}.bg-sky-900\/10{background-color:#024a701a}@supports (color:color-mix(in lab, red, red)){.bg-sky-900\/10{background-color:color-mix(in oklab, var(--color-sky-900) 10%, transparent)}}.bg-slate-50{background-color:var(--color-slate-50)}.bg-slate-50\/70{background-color:#f8fafcb3}@supports (color:color-mix(in lab, red, red)){.bg-slate-50\/70{background-color:color-mix(in oklab, var(--color-slate-50) 70%, transparent)}}.bg-slate-100{background-color:var(--color-slate-100)}.bg-slate-200{background-color:var(--color-slate-200)}.bg-slate-300{background-color:var(--color-slate-300)}.bg-slate-900\/45{background-color:#0f172b73}@supports (color:color-mix(in lab, red, red)){.bg-slate-900\/45{background-color:color-mix(in oklab, var(--color-slate-900) 45%, transparent)}}.bg-teal-50{background-color:var(--color-teal-50)}.bg-teal-100{background-color:var(--color-teal-100)}.bg-teal-500\/10{background-color:#00baa71a}@supports (color:color-mix(in lab, red, red)){.bg-teal-500\/10{background-color:color-mix(in oklab, var(--color-teal-500) 10%, transparent)}}.bg-teal-600{background-color:var(--color-teal-600)}.bg-transparent{background-color:#0000}.bg-violet-50{background-color:var(--color-violet-50)}.bg-violet-100{background-color:var(--color-violet-100)}.bg-violet-600{background-color:var(--color-violet-600)}.bg-white{background-color:var(--color-white)}.bg-white\/10{background-color:#ffffff1a}@supports (color:color-mix(in lab, red, red)){.bg-white\/10{background-color:color-mix(in oklab, var(--color-white) 10%, transparent)}}.bg-white\/15{background-color:#ffffff26}@supports (color:color-mix(in lab, red, red)){.bg-white\/15{background-color:color-mix(in oklab, var(--color-white) 15%, transparent)}}.bg-white\/20{background-color:#fff3}@supports (color:color-mix(in lab, red, red)){.bg-white\/20{background-color:color-mix(in oklab, var(--color-white) 20%, transparent)}}.bg-white\/25{background-color:#ffffff40}@supports (color:color-mix(in lab, red, red)){.bg-white\/25{background-color:color-mix(in oklab, var(--color-white) 25%, transparent)}}.bg-white\/50{background-color:#ffffff80}@supports (color:color-mix(in lab, red, red)){.bg-white\/50{background-color:color-mix(in oklab, var(--color-white) 50%, transparent)}}.bg-white\/60{background-color:#fff9}@supports (color:color-mix(in lab, red, red)){.bg-white\/60{background-color:color-mix(in oklab, var(--color-white) 60%, transparent)}}.bg-white\/70{background-color:#ffffffb3}@supports (color:color-mix(in lab, red, red)){.bg-white\/70{background-color:color-mix(in oklab, var(--color-white) 70%, transparent)}}.bg-white\/80{background-color:#fffc}@supports (color:color-mix(in lab, red, red)){.bg-white\/80{background-color:color-mix(in oklab, var(--color-white) 80%, transparent)}}.bg-white\/90{background-color:#ffffffe6}@supports (color:color-mix(in lab, red, red)){.bg-white\/90{background-color:color-mix(in oklab, var(--color-white) 90%, transparent)}}.bg-white\/95{background-color:#fffffff2}@supports (color:color-mix(in lab, red, red)){.bg-white\/95{background-color:color-mix(in oklab, var(--color-white) 95%, transparent)}}.bg-yellow-50{background-color:var(--color-yellow-50)}.bg-yellow-100{background-color:var(--color-yellow-100)}.bg-yellow-700\/50{background-color:#a3610080}@supports (color:color-mix(in lab, red, red)){.bg-yellow-700\/50{background-color:color-mix(in oklab, var(--color-yellow-700) 50%, transparent)}}.bg-yellow-900\/40{background-color:#733e0a66}@supports (color:color-mix(in lab, red, red)){.bg-yellow-900\/40{background-color:color-mix(in oklab, var(--color-yellow-900) 40%, transparent)}}.bg-zinc-50{background-color:var(--color-zinc-50)}.bg-zinc-50\!{background-color:var(--color-zinc-50)!important}.bg-zinc-100{background-color:var(--color-zinc-100)}.bg-zinc-200{background-color:var(--color-zinc-200)}.bg-zinc-200\/30{background-color:#e4e4e74d}@supports (color:color-mix(in lab, red, red)){.bg-zinc-200\/30{background-color:color-mix(in oklab, var(--color-zinc-200) 30%, transparent)}}.bg-zinc-600{background-color:var(--color-zinc-600)}.bg-zinc-700{background-color:var(--color-zinc-700)}.bg-zinc-800{background-color:var(--color-zinc-800)}.bg-zinc-900{background-color:var(--color-zinc-900)}.bg-zinc-950{background-color:var(--color-zinc-950)}.bg-zinc-950\/5{background-color:#09090b0d}@supports (color:color-mix(in lab, red, red)){.bg-zinc-950\/5{background-color:color-mix(in oklab, var(--color-zinc-950) 5%, transparent)}}.bg-zinc-950\/15{background-color:#09090b26}@supports (color:color-mix(in lab, red, red)){.bg-zinc-950\/15{background-color:color-mix(in oklab, var(--color-zinc-950) 15%, transparent)}}.bg-zinc-950\/90{background-color:#09090be6}@supports (color:color-mix(in lab, red, red)){.bg-zinc-950\/90{background-color:color-mix(in oklab, var(--color-zinc-950) 90%, transparent)}}.bg-linear-to-b{--tw-gradient-position:to bottom}@supports (background-image:linear-gradient(in lab, red, red)){.bg-linear-to-b{--tw-gradient-position:to bottom in oklab}}.bg-linear-to-b{background-image:linear-gradient(var(--tw-gradient-stops))}.bg-linear-to-r{--tw-gradient-position:to right}@supports (background-image:linear-gradient(in lab, red, red)){.bg-linear-to-r{--tw-gradient-position:to right in oklab}}.bg-linear-to-r{background-image:linear-gradient(var(--tw-gradient-stops))}.bg-gradient-to-b{--tw-gradient-position:to bottom in oklab;background-image:linear-gradient(var(--tw-gradient-stops))}.from-blue-500{--tw-gradient-from:var(--color-blue-500);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.from-emerald-500{--tw-gradient-from:var(--color-emerald-500);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.from-purple-500{--tw-gradient-from:var(--color-purple-500);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.from-rose-500{--tw-gradient-from:var(--color-rose-500);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.from-slate-100{--tw-gradient-from:var(--color-slate-100);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.from-transparent{--tw-gradient-from:transparent;--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.via-gray-200{--tw-gradient-via:var(--color-gray-200);--tw-gradient-via-stops:var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-via) var(--tw-gradient-via-position), var(--tw-gradient-to) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-via-stops)}.to-emerald-500{--tw-gradient-to:var(--color-emerald-500);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.to-indigo-500{--tw-gradient-to:var(--color-indigo-500);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.to-purple-500{--tw-gradient-to:var(--color-purple-500);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.to-rose-500{--tw-gradient-to:var(--color-rose-500);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.to-slate-200{--tw-gradient-to:var(--color-slate-200);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.to-transparent{--tw-gradient-to:transparent;--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.box-decoration-clone{-webkit-box-decoration-break:clone;box-decoration-break:clone}.box-decoration-slice{-webkit-box-decoration-break:slice;box-decoration-break:slice}.decoration-clone{-webkit-box-decoration-break:clone;box-decoration-break:clone}.decoration-slice{-webkit-box-decoration-break:slice;box-decoration-break:slice}.bg-fixed{background-attachment:fixed}.bg-local{background-attachment:local}.bg-scroll{background-attachment:scroll}.bg-clip-border{background-clip:border-box}.bg-clip-content{background-clip:content-box}.bg-clip-padding{background-clip:padding-box}.bg-clip-text{-webkit-background-clip:text;background-clip:text}.bg-no-repeat{background-repeat:no-repeat}.bg-repeat{background-repeat:repeat}.bg-repeat-round{background-repeat:round}.bg-repeat-space{background-repeat:space}.bg-repeat-x{background-repeat:repeat-x}.bg-repeat-y{background-repeat:repeat-y}.bg-origin-border{background-origin:border-box}.bg-origin-content{background-origin:content-box}.bg-origin-padding{background-origin:padding-box}.mask-repeat{-webkit-mask-repeat:repeat;mask-repeat:repeat}.object-contain{object-fit:contain}.object-cover{object-fit:cover}.object-fill{object-fit:fill}.object-none{object-fit:none}.object-scale-down{object-fit:scale-down}.object-center{object-position:center}.p-0{padding:0}.p-0\!{padding:0!important}.p-0\.5{padding:calc(var(--spacing) * .5)}.p-1{padding:var(--spacing)}.p-1\.5{padding:calc(var(--spacing) * 1.5)}.p-1\.5\!{padding:calc(var(--spacing) * 1.5)!important}.p-2{padding:calc(var(--spacing) * 2)}.p-2\!{padding:calc(var(--spacing) * 2)!important}.p-2\.5{padding:calc(var(--spacing) * 2.5)}.p-2\.5\!{padding:calc(var(--spacing) * 2.5)!important}.p-3{padding:calc(var(--spacing) * 3)}.p-3\!{padding:calc(var(--spacing) * 3)!important}.p-3\.5{padding:calc(var(--spacing) * 3.5)}.p-4{padding:calc(var(--spacing) * 4)}.p-4\!{padding:calc(var(--spacing) * 4)!important}.p-5{padding:calc(var(--spacing) * 5)}.p-6{padding:calc(var(--spacing) * 6)}.p-7{padding:calc(var(--spacing) * 7)}.p-8{padding:calc(var(--spacing) * 8)}.p-8\!{padding:calc(var(--spacing) * 8)!important}.p-12{padding:calc(var(--spacing) * 12)}.p-\[10\%\]{padding:10%}.px-0{padding-inline:0}.px-0\.5{padding-inline:calc(var(--spacing) * .5)}.px-1{padding-inline:var(--spacing)}.px-1\!{padding-inline:var(--spacing)!important}.px-1\.5{padding-inline:calc(var(--spacing) * 1.5)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-2\!{padding-inline:calc(var(--spacing) * 2)!important}.px-2\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-2\.5\!{padding-inline:calc(var(--spacing) * 2.5)!important}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-3\!{padding-inline:calc(var(--spacing) * 3)!important}.px-3\.5{padding-inline:calc(var(--spacing) * 3.5)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-4\!{padding-inline:calc(var(--spacing) * 4)!important}.px-5{padding-inline:calc(var(--spacing) * 5)}.px-5\!{padding-inline:calc(var(--spacing) * 5)!important}.px-6{padding-inline:calc(var(--spacing) * 6)}.px-6\!{padding-inline:calc(var(--spacing) * 6)!important}.px-8\!{padding-inline:calc(var(--spacing) * 8)!important}.px-10\!{padding-inline:calc(var(--spacing) * 10)!important}.py-0{padding-block:0}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-0\.5\!{padding-block:calc(var(--spacing) * .5)!important}.py-1{padding-block:var(--spacing)}.py-1\!{padding-block:var(--spacing)!important}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-1\.5\!{padding-block:calc(var(--spacing) * 1.5)!important}.py-2{padding-block:calc(var(--spacing) * 2)}.py-2\!{padding-block:calc(var(--spacing) * 2)!important}.py-2\.5{padding-block:calc(var(--spacing) * 2.5)}.py-2\.5\!{padding-block:calc(var(--spacing) * 2.5)!important}.py-3{padding-block:calc(var(--spacing) * 3)}.py-3\!{padding-block:calc(var(--spacing) * 3)!important}.py-3\.5{padding-block:calc(var(--spacing) * 3.5)}.py-4{padding-block:calc(var(--spacing) * 4)}.py-5{padding-block:calc(var(--spacing) * 5)}.py-6{padding-block:calc(var(--spacing) * 6)}.py-8{padding-block:calc(var(--spacing) * 8)}.py-10{padding-block:calc(var(--spacing) * 10)}.py-12{padding-block:calc(var(--spacing) * 12)}.py-16{padding-block:calc(var(--spacing) * 16)}.py-20{padding-block:calc(var(--spacing) * 20)}.pt-0\.5{padding-top:calc(var(--spacing) * .5)}.pt-1{padding-top:var(--spacing)}.pt-1\.5{padding-top:calc(var(--spacing) * 1.5)}.pt-2{padding-top:calc(var(--spacing) * 2)}.pt-3{padding-top:calc(var(--spacing) * 3)}.pt-4{padding-top:calc(var(--spacing) * 4)}.pt-6{padding-top:calc(var(--spacing) * 6)}.pt-7{padding-top:calc(var(--spacing) * 7)}.pt-8{padding-top:calc(var(--spacing) * 8)}.pt-11{padding-top:calc(var(--spacing) * 11)}.pt-16{padding-top:calc(var(--spacing) * 16)}.pt-\[env\(safe-area-inset-top\,0px\)\]{padding-top:env(safe-area-inset-top,0px)}.pt-\[max\(0\.5rem\,env\(safe-area-inset-top\)\)\]{padding-top:max(.5rem, env(safe-area-inset-top))}.pt-\[max\(0\.75rem\,env\(safe-area-inset-top\)\)\]{padding-top:max(.75rem, env(safe-area-inset-top))}.pt-\[max\(1rem\,env\(safe-area-inset-top\)\)\]{padding-top:max(1rem, env(safe-area-inset-top))}.pr-0{padding-right:0}.pr-0\.5{padding-right:calc(var(--spacing) * .5)}.pr-1{padding-right:var(--spacing)}.pr-1\.5{padding-right:calc(var(--spacing) * 1.5)}.pr-2{padding-right:calc(var(--spacing) * 2)}.pr-2\.5{padding-right:calc(var(--spacing) * 2.5)}.pr-3{padding-right:calc(var(--spacing) * 3)}.pr-3\!{padding-right:calc(var(--spacing) * 3)!important}.pr-4{padding-right:calc(var(--spacing) * 4)}.pr-5{padding-right:calc(var(--spacing) * 5)}.pr-7{padding-right:calc(var(--spacing) * 7)}.pr-8{padding-right:calc(var(--spacing) * 8)}.pr-9{padding-right:calc(var(--spacing) * 9)}.pr-10{padding-right:calc(var(--spacing) * 10)}.pr-12{padding-right:calc(var(--spacing) * 12)}.pr-12\!{padding-right:calc(var(--spacing) * 12)!important}.pr-16{padding-right:calc(var(--spacing) * 16)}.pr-28{padding-right:calc(var(--spacing) * 28)}.pb-1{padding-bottom:var(--spacing)}.pb-1\.5{padding-bottom:calc(var(--spacing) * 1.5)}.pb-2{padding-bottom:calc(var(--spacing) * 2)}.pb-3{padding-bottom:calc(var(--spacing) * 3)}.pb-4{padding-bottom:calc(var(--spacing) * 4)}.pb-5{padding-bottom:calc(var(--spacing) * 5)}.pb-6{padding-bottom:calc(var(--spacing) * 6)}.pb-7{padding-bottom:calc(var(--spacing) * 7)}.pb-16{padding-bottom:calc(var(--spacing) * 16)}.pb-20{padding-bottom:calc(var(--spacing) * 20)}.pb-\[env\(safe-area-inset-bottom\)\]{padding-bottom:env(safe-area-inset-bottom)}.pb-\[max\(0\.5rem\,env\(safe-area-inset-bottom\)\)\]{padding-bottom:max(.5rem, env(safe-area-inset-bottom))}.pb-\[max\(0\.25rem\,env\(safe-area-inset-bottom\)\)\]{padding-bottom:max(.25rem, env(safe-area-inset-bottom))}.pb-\[max\(0\.75rem\,env\(safe-area-inset-bottom\)\)\]{padding-bottom:max(.75rem, env(safe-area-inset-bottom))}.pb-\[max\(0px\,env\(safe-area-inset-bottom\)\)\]{padding-bottom:max(0px, env(safe-area-inset-bottom))}.pb-\[max\(1\.5rem\,env\(safe-area-inset-bottom\)\)\]{padding-bottom:max(1.5rem, env(safe-area-inset-bottom))}.pb-\[max\(1rem\,env\(safe-area-inset-bottom\)\)\]{padding-bottom:max(1rem, env(safe-area-inset-bottom))}.pl-0{padding-left:0}.pl-0\.5{padding-left:calc(var(--spacing) * .5)}.pl-1\.5{padding-left:calc(var(--spacing) * 1.5)}.pl-2{padding-left:calc(var(--spacing) * 2)}.pl-2\.5{padding-left:calc(var(--spacing) * 2.5)}.pl-3{padding-left:calc(var(--spacing) * 3)}.pl-4{padding-left:calc(var(--spacing) * 4)}.pl-5{padding-left:calc(var(--spacing) * 5)}.pl-6{padding-left:calc(var(--spacing) * 6)}.pl-8{padding-left:calc(var(--spacing) * 8)}.pl-9{padding-left:calc(var(--spacing) * 9)}.pl-9\!{padding-left:calc(var(--spacing) * 9)!important}.pl-10{padding-left:calc(var(--spacing) * 10)}.pl-10\!{padding-left:calc(var(--spacing) * 10)!important}.pl-11{padding-left:calc(var(--spacing) * 11)}.pl-11\!{padding-left:calc(var(--spacing) * 11)!important}.pl-12{padding-left:calc(var(--spacing) * 12)}.text-center{text-align:center}.text-end{text-align:end}.text-justify{text-align:justify}.text-left{text-align:left}.text-right{text-align:right}.text-start{text-align:start}.align-\[-0\.1em\]{vertical-align:-.1em}.align-baseline{vertical-align:baseline}.align-bottom{vertical-align:bottom}.align-middle{vertical-align:middle}.align-sub{vertical-align:sub}.align-super{vertical-align:super}.align-text-bottom{vertical-align:text-bottom}.align-text-top{vertical-align:text-top}.align-top{vertical-align:top}.font-mono{font-family:var(--font-mono)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.text-4xl{font-size:var(--text-4xl);line-height:var(--tw-leading,var(--text-4xl--line-height))}.text-5xl{font-size:var(--text-5xl);line-height:var(--tw-leading,var(--text-5xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-sm\!{font-size:var(--text-sm)!important;line-height:var(--tw-leading,var(--text-sm--line-height))!important}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-xs\!{font-size:var(--text-xs)!important;line-height:var(--tw-leading,var(--text-xs--line-height))!important}.text-\[0\.9em\]{font-size:.9em}.text-\[0\.85em\]{font-size:.85em}.text-\[8px\]{font-size:8px}.text-\[9px\]{font-size:9px}.text-\[9px\]\!{font-size:9px!important}.text-\[10px\]{font-size:10px}.text-\[10px\]\!{font-size:10px!important}.text-\[11px\]{font-size:11px}.text-\[11px\]\!{font-size:11px!important}.text-\[12px\]{font-size:12px}.leading-4{--tw-leading:calc(var(--spacing) * 4);line-height:calc(var(--spacing) * 4)}.leading-5{--tw-leading:calc(var(--spacing) * 5);line-height:calc(var(--spacing) * 5)}.leading-none{--tw-leading:1;line-height:1}.leading-normal{--tw-leading:var(--leading-normal);line-height:var(--leading-normal)}.leading-relaxed{--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}.leading-snug{--tw-leading:var(--leading-snug);line-height:var(--leading-snug)}.leading-tight{--tw-leading:var(--leading-tight);line-height:var(--leading-tight)}.font-black{--tw-font-weight:var(--font-weight-black);font-weight:var(--font-weight-black)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-\[0\.2em\]{--tw-tracking:.2em;letter-spacing:.2em}.tracking-\[0\.3em\]{--tw-tracking:.3em;letter-spacing:.3em}.tracking-normal{--tw-tracking:var(--tracking-normal);letter-spacing:var(--tracking-normal)}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.tracking-tighter{--tw-tracking:var(--tracking-tighter);letter-spacing:var(--tracking-tighter)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.tracking-wider{--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider)}.tracking-widest{--tw-tracking:var(--tracking-widest);letter-spacing:var(--tracking-widest)}.tracking-widest\!{--tw-tracking:var(--tracking-widest)!important;letter-spacing:var(--tracking-widest)!important}.text-balance{text-wrap:balance}.text-nowrap{text-wrap:nowrap}.text-pretty{text-wrap:pretty}.text-wrap{text-wrap:wrap}.break-normal{overflow-wrap:normal;word-break:normal}.break-words,.wrap-break-word{overflow-wrap:break-word}.\[word-break\:break-word\]{word-break:break-word}.break-all{word-break:break-all}.break-keep{word-break:keep-all}.overflow-ellipsis{text-overflow:ellipsis}.text-clip{text-overflow:clip}.text-ellipsis{text-overflow:ellipsis}.hyphens-auto{-webkit-hyphens:auto;hyphens:auto}.hyphens-manual{-webkit-hyphens:manual;hyphens:manual}.hyphens-none{-webkit-hyphens:none;hyphens:none}.whitespace-break-spaces{white-space:break-spaces}.whitespace-normal{white-space:normal}.whitespace-nowrap{white-space:nowrap}.whitespace-pre{white-space:pre}.whitespace-pre-line{white-space:pre-line}.whitespace-pre-wrap{white-space:pre-wrap}.tab-1{tab-size:1}.text-\[inherit\]{color:inherit}.text-\[var\(--mc-text-secondary\)\]{color:var(--mc-text-secondary)}.text-amber-200{color:var(--color-amber-200)}.text-amber-200\/95{color:#fee685f2}@supports (color:color-mix(in lab, red, red)){.text-amber-200\/95{color:color-mix(in oklab, var(--color-amber-200) 95%, transparent)}}.text-amber-400{color:var(--color-amber-400)}.text-amber-500{color:var(--color-amber-500)}.text-amber-600{color:var(--color-amber-600)}.text-amber-700{color:var(--color-amber-700)}.text-amber-700\/90{color:#b75000e6}@supports (color:color-mix(in lab, red, red)){.text-amber-700\/90{color:color-mix(in oklab, var(--color-amber-700) 90%, transparent)}}.text-amber-800{color:var(--color-amber-800)}.text-amber-800\/80{color:#953d00cc}@supports (color:color-mix(in lab, red, red)){.text-amber-800\/80{color:color-mix(in oklab, var(--color-amber-800) 80%, transparent)}}.text-amber-800\/90{color:#953d00e6}@supports (color:color-mix(in lab, red, red)){.text-amber-800\/90{color:color-mix(in oklab, var(--color-amber-800) 90%, transparent)}}.text-amber-900{color:var(--color-amber-900)}.text-amber-900\/80{color:#7b3306cc}@supports (color:color-mix(in lab, red, red)){.text-amber-900\/80{color:color-mix(in oklab, var(--color-amber-900) 80%, transparent)}}.text-amber-950{color:var(--color-amber-950)}.text-amber-950\/90{color:#461901e6}@supports (color:color-mix(in lab, red, red)){.text-amber-950\/90{color:color-mix(in oklab, var(--color-amber-950) 90%, transparent)}}.text-black{color:var(--color-black)}.text-blue-300{color:var(--color-blue-300)}.text-blue-400{color:var(--color-blue-400)}.text-blue-500{color:var(--color-blue-500)}.text-blue-600{color:var(--color-blue-600)}.text-blue-600\/80{color:#155dfccc}@supports (color:color-mix(in lab, red, red)){.text-blue-600\/80{color:color-mix(in oklab, var(--color-blue-600) 80%, transparent)}}.text-blue-700{color:var(--color-blue-700)}.text-blue-800{color:var(--color-blue-800)}.text-blue-800\/80{color:#193cb8cc}@supports (color:color-mix(in lab, red, red)){.text-blue-800\/80{color:color-mix(in oklab, var(--color-blue-800) 80%, transparent)}}.text-blue-900{color:var(--color-blue-900)}.text-blue-950{color:var(--color-blue-950)}.text-blue-950\/90{color:#162456e6}@supports (color:color-mix(in lab, red, red)){.text-blue-950\/90{color:color-mix(in oklab, var(--color-blue-950) 90%, transparent)}}.text-cyan-500{color:var(--color-cyan-500)}.text-cyan-600{color:var(--color-cyan-600)}.text-cyan-700{color:var(--color-cyan-700)}.text-emerald-300{color:var(--color-emerald-300)}.text-emerald-400{color:var(--color-emerald-400)}.text-emerald-500{color:var(--color-emerald-500)}.text-emerald-500\/80{color:#00bb7fcc}@supports (color:color-mix(in lab, red, red)){.text-emerald-500\/80{color:color-mix(in oklab, var(--color-emerald-500) 80%, transparent)}}.text-emerald-600{color:var(--color-emerald-600)}.text-emerald-600\/80{color:#009767cc}@supports (color:color-mix(in lab, red, red)){.text-emerald-600\/80{color:color-mix(in oklab, var(--color-emerald-600) 80%, transparent)}}.text-emerald-700{color:var(--color-emerald-700)}.text-emerald-800{color:var(--color-emerald-800)}.text-emerald-800\/80{color:#005f46cc}@supports (color:color-mix(in lab, red, red)){.text-emerald-800\/80{color:color-mix(in oklab, var(--color-emerald-800) 80%, transparent)}}.text-gray-100{color:var(--color-gray-100)}.text-gray-200{color:var(--color-gray-200)}.text-gray-200\/90{color:#e5e7ebe6}@supports (color:color-mix(in lab, red, red)){.text-gray-200\/90{color:color-mix(in oklab, var(--color-gray-200) 90%, transparent)}}.text-gray-300{color:var(--color-gray-300)}.text-gray-400{color:var(--color-gray-400)}.text-gray-500{color:var(--color-gray-500)}.text-gray-500\/95{color:#6a7282f2}@supports (color:color-mix(in lab, red, red)){.text-gray-500\/95{color:color-mix(in oklab, var(--color-gray-500) 95%, transparent)}}.text-gray-600{color:var(--color-gray-600)}.text-gray-700{color:var(--color-gray-700)}.text-gray-800{color:var(--color-gray-800)}.text-gray-900{color:var(--color-gray-900)}.text-green-400{color:var(--color-green-400)}.text-green-500{color:var(--color-green-500)}.text-green-600{color:var(--color-green-600)}.text-green-700{color:var(--color-green-700)}.text-green-800{color:var(--color-green-800)}.text-indigo-500{color:var(--color-indigo-500)}.text-indigo-500\/80{color:#625fffcc}@supports (color:color-mix(in lab, red, red)){.text-indigo-500\/80{color:color-mix(in oklab, var(--color-indigo-500) 80%, transparent)}}.text-indigo-600{color:var(--color-indigo-600)}.text-indigo-600\/90{color:#4f39f6e6}@supports (color:color-mix(in lab, red, red)){.text-indigo-600\/90{color:color-mix(in oklab, var(--color-indigo-600) 90%, transparent)}}.text-indigo-700{color:var(--color-indigo-700)}.text-indigo-700\/90{color:#432dd7e6}@supports (color:color-mix(in lab, red, red)){.text-indigo-700\/90{color:color-mix(in oklab, var(--color-indigo-700) 90%, transparent)}}.text-indigo-900\/90{color:#312c85e6}@supports (color:color-mix(in lab, red, red)){.text-indigo-900\/90{color:color-mix(in oklab, var(--color-indigo-900) 90%, transparent)}}.text-indigo-950{color:var(--color-indigo-950)}.text-inherit{color:inherit}.text-orange-200{color:var(--color-orange-200)}.text-orange-500{color:var(--color-orange-500)}.text-orange-600{color:var(--color-orange-600)}.text-orange-700{color:var(--color-orange-700)}.text-orange-800{color:var(--color-orange-800)}.text-pink-500{color:var(--color-pink-500)}.text-pink-600{color:var(--color-pink-600)}.text-pink-700{color:var(--color-pink-700)}.text-pink-800{color:var(--color-pink-800)}.text-purple-100{color:var(--color-purple-100)}.text-purple-200{color:var(--color-purple-200)}.text-purple-300{color:var(--color-purple-300)}.text-purple-300\/90{color:#d9b3ffe6}@supports (color:color-mix(in lab, red, red)){.text-purple-300\/90{color:color-mix(in oklab, var(--color-purple-300) 90%, transparent)}}.text-purple-400{color:var(--color-purple-400)}.text-purple-500{color:var(--color-purple-500)}.text-purple-600{color:var(--color-purple-600)}.text-red-400{color:var(--color-red-400)}.text-red-500{color:var(--color-red-500)}.text-red-500\!{color:var(--color-red-500)!important}.text-red-500\/60{color:#fb2c3699}@supports (color:color-mix(in lab, red, red)){.text-red-500\/60{color:color-mix(in oklab, var(--color-red-500) 60%, transparent)}}.text-red-600{color:var(--color-red-600)}.text-red-600\!{color:var(--color-red-600)!important}.text-red-700{color:var(--color-red-700)}.text-red-800{color:var(--color-red-800)}.text-red-800\/95{color:#9f0712f2}@supports (color:color-mix(in lab, red, red)){.text-red-800\/95{color:color-mix(in oklab, var(--color-red-800) 95%, transparent)}}.text-red-900{color:var(--color-red-900)}.text-rose-500{color:var(--color-rose-500)}.text-rose-600{color:var(--color-rose-600)}.text-sky-500{color:var(--color-sky-500)}.text-sky-600{color:var(--color-sky-600)}.text-sky-600\/70{color:#0084ccb3}@supports (color:color-mix(in lab, red, red)){.text-sky-600\/70{color:color-mix(in oklab, var(--color-sky-600) 70%, transparent)}}.text-sky-700{color:var(--color-sky-700)}.text-sky-700\/90{color:#0069a4e6}@supports (color:color-mix(in lab, red, red)){.text-sky-700\/90{color:color-mix(in oklab, var(--color-sky-700) 90%, transparent)}}.text-sky-800{color:var(--color-sky-800)}.text-sky-800\/75{color:#005986bf}@supports (color:color-mix(in lab, red, red)){.text-sky-800\/75{color:color-mix(in oklab, var(--color-sky-800) 75%, transparent)}}.text-sky-900{color:var(--color-sky-900)}.text-sky-900\/80{color:#024a70cc}@supports (color:color-mix(in lab, red, red)){.text-sky-900\/80{color:color-mix(in oklab, var(--color-sky-900) 80%, transparent)}}.text-slate-400{color:var(--color-slate-400)}.text-slate-500{color:var(--color-slate-500)}.text-slate-600{color:var(--color-slate-600)}.text-slate-700{color:var(--color-slate-700)}.text-slate-800{color:var(--color-slate-800)}.text-slate-900{color:var(--color-slate-900)}.text-teal-500{color:var(--color-teal-500)}.text-teal-600{color:var(--color-teal-600)}.text-teal-600\!{color:var(--color-teal-600)!important}.text-teal-700{color:var(--color-teal-700)}.text-teal-800{color:var(--color-teal-800)}.text-violet-600{color:var(--color-violet-600)}.text-violet-700{color:var(--color-violet-700)}.text-violet-800{color:var(--color-violet-800)}.text-violet-900{color:var(--color-violet-900)}.text-violet-900\/80{color:#4d179acc}@supports (color:color-mix(in lab, red, red)){.text-violet-900\/80{color:color-mix(in oklab, var(--color-violet-900) 80%, transparent)}}.text-violet-900\/90{color:#4d179ae6}@supports (color:color-mix(in lab, red, red)){.text-violet-900\/90{color:color-mix(in oklab, var(--color-violet-900) 90%, transparent)}}.text-violet-950{color:var(--color-violet-950)}.text-white{color:var(--color-white)}.text-white\!{color:var(--color-white)!important}.text-white\/60{color:#fff9}@supports (color:color-mix(in lab, red, red)){.text-white\/60{color:color-mix(in oklab, var(--color-white) 60%, transparent)}}.text-white\/70{color:#ffffffb3}@supports (color:color-mix(in lab, red, red)){.text-white\/70{color:color-mix(in oklab, var(--color-white) 70%, transparent)}}.text-white\/75{color:#ffffffbf}@supports (color:color-mix(in lab, red, red)){.text-white\/75{color:color-mix(in oklab, var(--color-white) 75%, transparent)}}.text-white\/80{color:#fffc}@supports (color:color-mix(in lab, red, red)){.text-white\/80{color:color-mix(in oklab, var(--color-white) 80%, transparent)}}.text-white\/90{color:#ffffffe6}@supports (color:color-mix(in lab, red, red)){.text-white\/90{color:color-mix(in oklab, var(--color-white) 90%, transparent)}}.text-white\/95{color:#fffffff2}@supports (color:color-mix(in lab, red, red)){.text-white\/95{color:color-mix(in oklab, var(--color-white) 95%, transparent)}}.text-yellow-200{color:var(--color-yellow-200)}.text-yellow-400{color:var(--color-yellow-400)}.text-yellow-500{color:var(--color-yellow-500)}.text-yellow-600{color:var(--color-yellow-600)}.text-yellow-800{color:var(--color-yellow-800)}.text-yellow-900{color:var(--color-yellow-900)}.text-zinc-100{color:var(--color-zinc-100)}.text-zinc-300{color:var(--color-zinc-300)}.text-zinc-400{color:var(--color-zinc-400)}.text-zinc-500{color:var(--color-zinc-500)}.text-zinc-600{color:var(--color-zinc-600)}.text-zinc-700{color:var(--color-zinc-700)}.text-zinc-900{color:var(--color-zinc-900)}.capitalize{text-transform:capitalize}.lowercase{text-transform:lowercase}.normal-case{text-transform:none}.uppercase{text-transform:uppercase}.italic{font-style:italic}.not-italic{font-style:normal}.diagonal-fractions{--tw-numeric-fraction:diagonal-fractions;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.lining-nums{--tw-numeric-figure:lining-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.oldstyle-nums{--tw-numeric-figure:oldstyle-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.ordinal{--tw-ordinal:ordinal;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.proportional-nums{--tw-numeric-spacing:proportional-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.slashed-zero{--tw-slashed-zero:slashed-zero;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.stacked-fractions{--tw-numeric-fraction:stacked-fractions;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.normal-nums{font-variant-numeric:normal}.line-through{text-decoration-line:line-through}.no-underline{text-decoration-line:none}.overline{text-decoration-line:overline}.underline{text-decoration-line:underline}.decoration-dashed{text-decoration-style:dashed}.decoration-dotted{text-decoration-style:dotted}.decoration-double{text-decoration-style:double}.decoration-solid{text-decoration-style:solid}.decoration-wavy{text-decoration-style:wavy}.decoration-2{text-decoration-thickness:2px}.underline-offset-2{text-underline-offset:2px}.underline-offset-4{text-underline-offset:4px}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.subpixel-antialiased{-webkit-font-smoothing:auto;-moz-osx-font-smoothing:auto}.placeholder-gray-400::placeholder{color:var(--color-gray-400)}.placeholder-gray-500::placeholder{color:var(--color-gray-500)}.accent-blue-500{accent-color:var(--color-blue-500)}.accent-blue-600{accent-color:var(--color-blue-600)}.accent-emerald-500{accent-color:var(--color-emerald-500)}.opacity-0{opacity:0}.opacity-20{opacity:.2}.opacity-25{opacity:.25}.opacity-30{opacity:.3}.opacity-40{opacity:.4}.opacity-50{opacity:.5}.opacity-60{opacity:.6}.opacity-70{opacity:.7}.opacity-75{opacity:.75}.opacity-80{opacity:.8}.opacity-85{opacity:.85}.opacity-90{opacity:.9}.opacity-100{opacity:1}.opacity-100\!{opacity:1!important}.bg-blend-color{background-blend-mode:color}.bg-blend-color-burn{background-blend-mode:color-burn}.bg-blend-color-dodge{background-blend-mode:color-dodge}.bg-blend-darken{background-blend-mode:darken}.bg-blend-difference{background-blend-mode:difference}.bg-blend-exclusion{background-blend-mode:exclusion}.bg-blend-hard-light{background-blend-mode:hard-light}.bg-blend-hue{background-blend-mode:hue}.bg-blend-lighten{background-blend-mode:lighten}.bg-blend-luminosity{background-blend-mode:luminosity}.bg-blend-multiply{background-blend-mode:multiply}.bg-blend-normal{background-blend-mode:normal}.bg-blend-overlay{background-blend-mode:overlay}.bg-blend-saturation{background-blend-mode:saturation}.bg-blend-screen{background-blend-mode:screen}.bg-blend-soft-light{background-blend-mode:soft-light}.mix-blend-color{mix-blend-mode:color}.mix-blend-color-burn{mix-blend-mode:color-burn}.mix-blend-color-dodge{mix-blend-mode:color-dodge}.mix-blend-darken{mix-blend-mode:darken}.mix-blend-difference{mix-blend-mode:difference}.mix-blend-exclusion{mix-blend-mode:exclusion}.mix-blend-hard-light{mix-blend-mode:hard-light}.mix-blend-hue{mix-blend-mode:hue}.mix-blend-lighten{mix-blend-mode:lighten}.mix-blend-luminosity{mix-blend-mode:luminosity}.mix-blend-multiply{mix-blend-mode:multiply}.mix-blend-normal{mix-blend-mode:normal}.mix-blend-overlay{mix-blend-mode:overlay}.mix-blend-plus-darker{mix-blend-mode:plus-darker}.mix-blend-plus-lighter{mix-blend-mode:plus-lighter}.mix-blend-saturation{mix-blend-mode:saturation}.mix-blend-screen{mix-blend-mode:screen}.mix-blend-soft-light{mix-blend-mode:soft-light}.shadow{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-2xl{--tw-shadow:0 25px 50px -12px var(--tw-shadow-color,#00000040);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-\[0_0_8px_rgba\(59\,130\,246\,0\.5\)\]{--tw-shadow:0 0 8px var(--tw-shadow-color,#3b82f680);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-\[inset_2px_2px_5px_rgba\(0\,0\,0\,0\.12\)\,inset_-1px_-1px_2px_rgba\(255\,255\,255\,0\.6\)\]{--tw-shadow:inset 2px 2px 5px var(--tw-shadow-color,#0000001f), inset -1px -1px 2px var(--tw-shadow-color,#fff9);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-inner{--tw-shadow:inset 0 2px 4px 0 var(--tw-shadow-color,#0000000d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-lg\!{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a)!important;box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)!important}.shadow-md{--tw-shadow:0 4px 6px -1px var(--tw-shadow-color,#0000001a), 0 2px 4px -2px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-none{--tw-shadow:0 0 #0000;box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-xl{--tw-shadow:0 20px 25px -5px var(--tw-shadow-color,#0000001a), 0 8px 10px -6px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-xs{--tw-shadow:0 1px 2px 0 var(--tw-shadow-color,#0000000d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-0{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-1{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-2{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-4{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(4px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-amber-500\/30{--tw-shadow-color:#f99c004d}@supports (color:color-mix(in lab, red, red)){.shadow-amber-500\/30{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-amber-500) 30%, transparent) var(--tw-shadow-alpha), transparent)}}.shadow-amber-500\/40{--tw-shadow-color:#f99c0066}@supports (color:color-mix(in lab, red, red)){.shadow-amber-500\/40{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-amber-500) 40%, transparent) var(--tw-shadow-alpha), transparent)}}.shadow-blue-500\/10{--tw-shadow-color:#3080ff1a}@supports (color:color-mix(in lab, red, red)){.shadow-blue-500\/10{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-blue-500) 10%, transparent) var(--tw-shadow-alpha), transparent)}}.shadow-blue-500\/20{--tw-shadow-color:#3080ff33}@supports (color:color-mix(in lab, red, red)){.shadow-blue-500\/20{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-blue-500) 20%, transparent) var(--tw-shadow-alpha), transparent)}}.shadow-blue-500\/25{--tw-shadow-color:#3080ff40}@supports (color:color-mix(in lab, red, red)){.shadow-blue-500\/25{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-blue-500) 25%, transparent) var(--tw-shadow-alpha), transparent)}}.shadow-blue-500\/30{--tw-shadow-color:#3080ff4d}@supports (color:color-mix(in lab, red, red)){.shadow-blue-500\/30{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-blue-500) 30%, transparent) var(--tw-shadow-alpha), transparent)}}.shadow-blue-600\/20{--tw-shadow-color:#155dfc33}@supports (color:color-mix(in lab, red, red)){.shadow-blue-600\/20{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-blue-600) 20%, transparent) var(--tw-shadow-alpha), transparent)}}.shadow-blue-600\/30{--tw-shadow-color:#155dfc4d}@supports (color:color-mix(in lab, red, red)){.shadow-blue-600\/30{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-blue-600) 30%, transparent) var(--tw-shadow-alpha), transparent)}}.shadow-emerald-500\/20{--tw-shadow-color:#00bb7f33}@supports (color:color-mix(in lab, red, red)){.shadow-emerald-500\/20{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-emerald-500) 20%, transparent) var(--tw-shadow-alpha), transparent)}}.shadow-gray-200\/20{--tw-shadow-color:#e5e7eb33}@supports (color:color-mix(in lab, red, red)){.shadow-gray-200\/20{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-gray-200) 20%, transparent) var(--tw-shadow-alpha), transparent)}}.shadow-green-600\/20{--tw-shadow-color:#00a54433}@supports (color:color-mix(in lab, red, red)){.shadow-green-600\/20{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-green-600) 20%, transparent) var(--tw-shadow-alpha), transparent)}}.shadow-green-600\/30{--tw-shadow-color:#00a5444d}@supports (color:color-mix(in lab, red, red)){.shadow-green-600\/30{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-green-600) 30%, transparent) var(--tw-shadow-alpha), transparent)}}.shadow-indigo-500\/30{--tw-shadow-color:#625fff4d}@supports (color:color-mix(in lab, red, red)){.shadow-indigo-500\/30{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-indigo-500) 30%, transparent) var(--tw-shadow-alpha), transparent)}}.shadow-indigo-500\/50{--tw-shadow-color:#625fff80}@supports (color:color-mix(in lab, red, red)){.shadow-indigo-500\/50{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-indigo-500) 50%, transparent) var(--tw-shadow-alpha), transparent)}}.shadow-indigo-900\/5{--tw-shadow-color:#312c850d}@supports (color:color-mix(in lab, red, red)){.shadow-indigo-900\/5{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-indigo-900) 5%, transparent) var(--tw-shadow-alpha), transparent)}}.shadow-orange-500\/20{--tw-shadow-color:#fe6e0033}@supports (color:color-mix(in lab, red, red)){.shadow-orange-500\/20{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-orange-500) 20%, transparent) var(--tw-shadow-alpha), transparent)}}.shadow-red-500\/20{--tw-shadow-color:#fb2c3633}@supports (color:color-mix(in lab, red, red)){.shadow-red-500\/20{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-red-500) 20%, transparent) var(--tw-shadow-alpha), transparent)}}.shadow-red-500\/30{--tw-shadow-color:#fb2c364d}@supports (color:color-mix(in lab, red, red)){.shadow-red-500\/30{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-red-500) 30%, transparent) var(--tw-shadow-alpha), transparent)}}.shadow-red-600\/20{--tw-shadow-color:#e4001433}@supports (color:color-mix(in lab, red, red)){.shadow-red-600\/20{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-red-600) 20%, transparent) var(--tw-shadow-alpha), transparent)}}.shadow-red-600\/30{--tw-shadow-color:#e400144d}@supports (color:color-mix(in lab, red, red)){.shadow-red-600\/30{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-red-600) 30%, transparent) var(--tw-shadow-alpha), transparent)}}.shadow-slate-200\/50{--tw-shadow-color:#e2e8f080}@supports (color:color-mix(in lab, red, red)){.shadow-slate-200\/50{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-slate-200) 50%, transparent) var(--tw-shadow-alpha), transparent)}}.shadow-teal-600\/30{--tw-shadow-color:#0095884d}@supports (color:color-mix(in lab, red, red)){.shadow-teal-600\/30{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-teal-600) 30%, transparent) var(--tw-shadow-alpha), transparent)}}.ring-amber-400{--tw-ring-color:var(--color-amber-400)}.ring-amber-500{--tw-ring-color:var(--color-amber-500)}.ring-black\/10{--tw-ring-color:#0000001a}@supports (color:color-mix(in lab, red, red)){.ring-black\/10{--tw-ring-color:color-mix(in oklab, var(--color-black) 10%, transparent)}}.ring-blue-300{--tw-ring-color:var(--color-blue-300)}.ring-blue-400{--tw-ring-color:var(--color-blue-400)}.ring-blue-400\/30{--tw-ring-color:#54a2ff4d}@supports (color:color-mix(in lab, red, red)){.ring-blue-400\/30{--tw-ring-color:color-mix(in oklab, var(--color-blue-400) 30%, transparent)}}.ring-blue-400\/60{--tw-ring-color:#54a2ff99}@supports (color:color-mix(in lab, red, red)){.ring-blue-400\/60{--tw-ring-color:color-mix(in oklab, var(--color-blue-400) 60%, transparent)}}.ring-blue-500{--tw-ring-color:var(--color-blue-500)}.ring-blue-500\/20{--tw-ring-color:#3080ff33}@supports (color:color-mix(in lab, red, red)){.ring-blue-500\/20{--tw-ring-color:color-mix(in oklab, var(--color-blue-500) 20%, transparent)}}.ring-blue-500\/30{--tw-ring-color:#3080ff4d}@supports (color:color-mix(in lab, red, red)){.ring-blue-500\/30{--tw-ring-color:color-mix(in oklab, var(--color-blue-500) 30%, transparent)}}.ring-blue-500\/50{--tw-ring-color:#3080ff80}@supports (color:color-mix(in lab, red, red)){.ring-blue-500\/50{--tw-ring-color:color-mix(in oklab, var(--color-blue-500) 50%, transparent)}}.ring-blue-700\/10{--tw-ring-color:#1447e61a}@supports (color:color-mix(in lab, red, red)){.ring-blue-700\/10{--tw-ring-color:color-mix(in oklab, var(--color-blue-700) 10%, transparent)}}.ring-cyan-200\/80{--tw-ring-color:#a2f4fdcc}@supports (color:color-mix(in lab, red, red)){.ring-cyan-200\/80{--tw-ring-color:color-mix(in oklab, var(--color-cyan-200) 80%, transparent)}}.ring-emerald-500\/30{--tw-ring-color:#00bb7f4d}@supports (color:color-mix(in lab, red, red)){.ring-emerald-500\/30{--tw-ring-color:color-mix(in oklab, var(--color-emerald-500) 30%, transparent)}}.ring-gray-200{--tw-ring-color:var(--color-gray-200)}.ring-gray-200\/80{--tw-ring-color:#e5e7ebcc}@supports (color:color-mix(in lab, red, red)){.ring-gray-200\/80{--tw-ring-color:color-mix(in oklab, var(--color-gray-200) 80%, transparent)}}.ring-gray-300{--tw-ring-color:var(--color-gray-300)}.ring-gray-500\/30{--tw-ring-color:#6a72824d}@supports (color:color-mix(in lab, red, red)){.ring-gray-500\/30{--tw-ring-color:color-mix(in oklab, var(--color-gray-500) 30%, transparent)}}.ring-green-600\/20{--tw-ring-color:#00a54433}@supports (color:color-mix(in lab, red, red)){.ring-green-600\/20{--tw-ring-color:color-mix(in oklab, var(--color-green-600) 20%, transparent)}}.ring-indigo-400\/60{--tw-ring-color:#7d87ff99}@supports (color:color-mix(in lab, red, red)){.ring-indigo-400\/60{--tw-ring-color:color-mix(in oklab, var(--color-indigo-400) 60%, transparent)}}.ring-indigo-500{--tw-ring-color:var(--color-indigo-500)}.ring-indigo-500\/30{--tw-ring-color:#625fff4d}@supports (color:color-mix(in lab, red, red)){.ring-indigo-500\/30{--tw-ring-color:color-mix(in oklab, var(--color-indigo-500) 30%, transparent)}}.ring-red-200{--tw-ring-color:var(--color-red-200)}.ring-red-500{--tw-ring-color:var(--color-red-500)}.ring-white\/10{--tw-ring-color:#ffffff1a}@supports (color:color-mix(in lab, red, red)){.ring-white\/10{--tw-ring-color:color-mix(in oklab, var(--color-white) 10%, transparent)}}.ring-white\/90{--tw-ring-color:#ffffffe6}@supports (color:color-mix(in lab, red, red)){.ring-white\/90{--tw-ring-color:color-mix(in oklab, var(--color-white) 90%, transparent)}}.ring-offset-1{--tw-ring-offset-width:1px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.ring-offset-2{--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.ring-offset-white{--tw-ring-offset-color:var(--color-white)}.outline-hidden{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.outline-hidden{outline-offset:2px;outline:2px solid #0000}}.outline{outline-style:var(--tw-outline-style);outline-width:1px}.blur{--tw-blur:blur(8px);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.brightness-4{--tw-brightness:brightness(4%);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.brightness-6{--tw-brightness:brightness(6%);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.drop-shadow{--tw-drop-shadow-size:drop-shadow(0 1px 2px var(--tw-drop-shadow-color,#0000001a)) drop-shadow(0 1px 1px var(--tw-drop-shadow-color,#0000000f));--tw-drop-shadow:drop-shadow(0 1px 2px #0000001a) drop-shadow(0 1px 1px #0000000f);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.grayscale{--tw-grayscale:grayscale(100%);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.grayscale-\[0\.5\]{--tw-grayscale:grayscale(.5);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.invert{--tw-invert:invert(100%);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.sepia{--tw-sepia:sepia(100%);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter-none{filter:none}.backdrop-blur{--tw-backdrop-blur:blur(8px);-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-blur-\[0\.5px\]{--tw-backdrop-blur:blur(.5px);-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-blur-\[2px\]{--tw-backdrop-blur:blur(2px);-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-blur-md{--tw-backdrop-blur:blur(var(--blur-md));-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-blur-sm{--tw-backdrop-blur:blur(var(--blur-sm));-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-blur-xs{--tw-backdrop-blur:blur(var(--blur-xs));-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-grayscale{--tw-backdrop-grayscale:grayscale(100%);-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-invert{--tw-backdrop-invert:invert(100%);-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-sepia{--tw-backdrop-sepia:sepia(100%);-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-filter{-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-filter-none{-webkit-backdrop-filter:none;backdrop-filter:none}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[box-shadow\]{transition-property:box-shadow;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[left\]{transition-property:left;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[opacity\,background-color\,border-color\]{transition-property:opacity,background-color,border-color;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[width\]{transition-property:width;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-opacity{transition-property:opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-shadow{transition-property:box-shadow;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.duration-75{--tw-duration:75ms;transition-duration:75ms}.duration-100{--tw-duration:.1s;transition-duration:.1s}.duration-150{--tw-duration:.15s;transition-duration:.15s}.duration-200{--tw-duration:.2s;transition-duration:.2s}.duration-300{--tw-duration:.3s;transition-duration:.3s}.duration-500{--tw-duration:.5s;transition-duration:.5s}.duration-1000{--tw-duration:1s;transition-duration:1s}.ease-in{--tw-ease:var(--ease-in);transition-timing-function:var(--ease-in)}.ease-in-out{--tw-ease:var(--ease-in-out);transition-timing-function:var(--ease-in-out)}.ease-linear{--tw-ease:linear;transition-timing-function:linear}.ease-out{--tw-ease:var(--ease-out);transition-timing-function:var(--ease-out)}.contain-inline-size{--tw-contain-size:inline-size;contain:var(--tw-contain-size,) var(--tw-contain-layout,) var(--tw-contain-paint,) var(--tw-contain-style,)}.contain-layout{--tw-contain-layout:layout;contain:var(--tw-contain-size,) var(--tw-contain-layout,) var(--tw-contain-paint,) var(--tw-contain-style,)}.contain-paint{--tw-contain-paint:paint;contain:var(--tw-contain-size,) var(--tw-contain-layout,) var(--tw-contain-paint,) var(--tw-contain-style,)}.contain-size{--tw-contain-size:size;contain:var(--tw-contain-size,) var(--tw-contain-layout,) var(--tw-contain-paint,) var(--tw-contain-style,)}.contain-style{--tw-contain-style:style;contain:var(--tw-contain-size,) var(--tw-contain-layout,) var(--tw-contain-paint,) var(--tw-contain-style,)}.contain-\[layout_paint\]{contain:layout paint}.contain-content{contain:content}.contain-none{contain:none}.contain-strict{contain:strict}.forced-color-adjust-auto{forced-color-adjust:auto}.forced-color-adjust-none{forced-color-adjust:none}.outline-dashed{--tw-outline-style:dashed;outline-style:dashed}.outline-dotted{--tw-outline-style:dotted;outline-style:dotted}.outline-double{--tw-outline-style:double;outline-style:double}.outline-none{--tw-outline-style:none;outline-style:none}.select-all{-webkit-user-select:all;user-select:all}.select-auto{-webkit-user-select:auto;user-select:auto}.select-none{-webkit-user-select:none;user-select:none}.select-text{-webkit-user-select:text;user-select:text}.\[android\:prepare\]{android:prepare}.\[bench\:viz-wasm\]{bench:viz-wasm}.\[build-docs\:rns\]{build-docs:rns}.\[build\:all\]{build:all}.\[build\:fe\]{build:fe}.\[build\:frontend\]{build:frontend}.\[build\:viz-wasm\]{build:viz-wasm}.\[deps\:be\]{deps:be}.\[deps\:fe\]{deps:fe}.\[deps\:pins\]{deps:pins}.\[image\:tag\]{image:tag}.\[lint\:all\]{lint:all}.\[lint\:be\]{lint:be}.\[lint\:fe\]{lint:fe}.\[overflow-anchor\:none\]{overflow-anchor:none}.\[test\:all\]{test:all}.\[test\:fe\]{test:fe}.\[test\:viz-wasm\]{test:viz-wasm}:where(.divide-x-reverse>:not(:last-child)){--tw-divide-x-reverse:1}.ring-inset{--tw-ring-inset:inset}.group-open\:rotate-90:is(:where(.group):is([open],:popover-open,:open) *){rotate:90deg}.group-open\:rotate-180:is(:where(.group):is([open],:popover-open,:open) *){rotate:180deg}.group-focus-within\:opacity-100:is(:where(.group):focus-within *){opacity:1}@media (hover:hover){.group-hover\:flex:is(:where(.group):hover *){display:flex}.group-hover\:h-4:is(:where(.group):hover *){height:calc(var(--spacing) * 4)}.group-hover\:scale-110:is(:where(.group):hover *){--tw-scale-x:110%;--tw-scale-y:110%;--tw-scale-z:110%;scale:var(--tw-scale-x) var(--tw-scale-y)}.group-hover\:text-blue-600:is(:where(.group):hover *){color:var(--color-blue-600)}.group-hover\:text-gray-600:is(:where(.group):hover *){color:var(--color-gray-600)}.group-hover\:text-gray-700:is(:where(.group):hover *){color:var(--color-gray-700)}.group-hover\:text-gray-900:is(:where(.group):hover *){color:var(--color-gray-900)}.group-hover\:opacity-20:is(:where(.group):hover *){opacity:.2}.group-hover\:opacity-100:is(:where(.group):hover *){opacity:1}.group-hover\:shadow-indigo-500\/20:is(:where(.group):hover *){--tw-shadow-color:#625fff33}@supports (color:color-mix(in lab, red, red)){.group-hover\:shadow-indigo-500\/20:is(:where(.group):hover *){--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-indigo-500) 20%, transparent) var(--tw-shadow-alpha), transparent)}}.group-hover\/image-modal\:opacity-100:is(:where(.group\/image-modal):hover *),.group-hover\/img\:opacity-100:is(:where(.group\/img):hover *){opacity:1}}.peer-checked\:bg-blue-600:is(:where(.peer):checked~*){background-color:var(--color-blue-600)}.peer-focus\:ring-4:is(:where(.peer):focus~*){--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(4px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.peer-focus\:ring-blue-300:is(:where(.peer):focus~*){--tw-ring-color:var(--color-blue-300)}.peer-focus\:outline-hidden:is(:where(.peer):focus~*){--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.peer-focus\:outline-hidden:is(:where(.peer):focus~*){outline-offset:2px;outline:2px solid #0000}}.file\:mr-4::file-selector-button{margin-right:calc(var(--spacing) * 4)}.file\:rounded-l-xl::file-selector-button{border-top-left-radius:var(--radius-xl);border-bottom-left-radius:var(--radius-xl)}.file\:border-0::file-selector-button{border-style:var(--tw-border-style);border-width:0}.file\:bg-blue-600::file-selector-button{background-color:var(--color-blue-600)}.file\:bg-zinc-200::file-selector-button{background-color:var(--color-zinc-200)}.file\:px-4::file-selector-button{padding-inline:calc(var(--spacing) * 4)}.file\:py-2::file-selector-button{padding-block:calc(var(--spacing) * 2)}.file\:py-2\.5::file-selector-button{padding-block:calc(var(--spacing) * 2.5)}.file\:text-sm::file-selector-button{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.file\:font-bold::file-selector-button{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.file\:font-semibold::file-selector-button{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.file\:text-white::file-selector-button{color:var(--color-white)}.file\:text-zinc-700::file-selector-button{color:var(--color-zinc-700)}.placeholder\:text-gray-400::placeholder{color:var(--color-gray-400)}.after\:absolute:after{content:var(--tw-content);position:absolute}.after\:top-\[2px\]:after{content:var(--tw-content);top:2px}.after\:left-\[2px\]:after{content:var(--tw-content);left:2px}.after\:h-5:after{content:var(--tw-content);height:calc(var(--spacing) * 5)}.after\:w-5:after{content:var(--tw-content);width:calc(var(--spacing) * 5)}.after\:rounded-full:after{content:var(--tw-content);border-radius:3.40282e38px}.after\:border:after{content:var(--tw-content);border-style:var(--tw-border-style);border-width:1px}.after\:border-gray-300:after{content:var(--tw-content);border-color:var(--color-gray-300)}.after\:bg-white:after{content:var(--tw-content);background-color:var(--color-white)}.after\:transition-all:after{content:var(--tw-content);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.after\:content-\[\'\'\]:after{--tw-content:"";content:var(--tw-content)}.peer-checked\:after\:translate-x-full:is(:where(.peer):checked~*):after{content:var(--tw-content);--tw-translate-x:100%;translate:var(--tw-translate-x) var(--tw-translate-y)}.peer-checked\:after\:border-white:is(:where(.peer):checked~*):after{content:var(--tw-content);border-color:var(--color-white)}.first\:mt-0:first-child{margin-top:0}.first\:pt-0:first-child{padding-top:0}.last\:border-0:last-child{border-style:var(--tw-border-style);border-width:0}.last\:border-b-0:last-child{border-bottom-style:var(--tw-border-style);border-bottom-width:0}.open\:bg-white:is([open],:popover-open,:open){background-color:var(--color-white)}.focus-within\:ring-2:focus-within{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}@media (hover:hover){.hover\:z-10:hover{z-index:10}.hover\:scale-105:hover{--tw-scale-x:105%;--tw-scale-y:105%;--tw-scale-z:105%;scale:var(--tw-scale-x) var(--tw-scale-y)}.hover\:scale-110:hover{--tw-scale-x:110%;--tw-scale-y:110%;--tw-scale-z:110%;scale:var(--tw-scale-x) var(--tw-scale-y)}.hover\:scale-\[1\.01\]:hover{scale:1.01}.hover\:scale-\[1\.02\]:hover{scale:1.02}.hover\:scale-\[1\.03\]:hover{scale:1.03}.hover\:border-amber-300:hover{border-color:var(--color-amber-300)}.hover\:border-amber-400:hover{border-color:var(--color-amber-400)}.hover\:border-amber-500:hover{border-color:var(--color-amber-500)}.hover\:border-blue-200:hover{border-color:var(--color-blue-200)}.hover\:border-blue-300:hover{border-color:var(--color-blue-300)}.hover\:border-blue-400:hover{border-color:var(--color-blue-400)}.hover\:border-blue-400\/60:hover{border-color:#54a2ff99}@supports (color:color-mix(in lab, red, red)){.hover\:border-blue-400\/60:hover{border-color:color-mix(in oklab, var(--color-blue-400) 60%, transparent)}}.hover\:border-blue-400\/70:hover{border-color:#54a2ffb3}@supports (color:color-mix(in lab, red, red)){.hover\:border-blue-400\/70:hover{border-color:color-mix(in oklab, var(--color-blue-400) 70%, transparent)}}.hover\:border-blue-500:hover{border-color:var(--color-blue-500)}.hover\:border-cyan-500:hover{border-color:var(--color-cyan-500)}.hover\:border-emerald-400:hover{border-color:var(--color-emerald-400)}.hover\:border-emerald-500:hover{border-color:var(--color-emerald-500)}.hover\:border-gray-300:hover{border-color:var(--color-gray-300)}.hover\:border-gray-400:hover{border-color:var(--color-gray-400)}.hover\:border-gray-500:hover{border-color:var(--color-gray-500)}.hover\:border-indigo-500:hover{border-color:var(--color-indigo-500)}.hover\:border-purple-500:hover{border-color:var(--color-purple-500)}.hover\:border-red-400:hover{border-color:var(--color-red-400)}.hover\:border-teal-500:hover{border-color:var(--color-teal-500)}.hover\:bg-\[var\(--mc-border-strong\)\]:hover{background-color:var(--mc-border-strong)}.hover\:bg-amber-50:hover{background-color:var(--color-amber-50)}.hover\:bg-amber-100:hover{background-color:var(--color-amber-100)}.hover\:bg-amber-200:hover{background-color:var(--color-amber-200)}.hover\:bg-amber-600:hover{background-color:var(--color-amber-600)}.hover\:bg-amber-700:hover{background-color:var(--color-amber-700)}.hover\:bg-amber-800:hover{background-color:var(--color-amber-800)}.hover\:bg-black\/5:hover{background-color:#0000000d}@supports (color:color-mix(in lab, red, red)){.hover\:bg-black\/5:hover{background-color:color-mix(in oklab, var(--color-black) 5%, transparent)}}.hover\:bg-black\/10:hover{background-color:#0000001a}@supports (color:color-mix(in lab, red, red)){.hover\:bg-black\/10:hover{background-color:color-mix(in oklab, var(--color-black) 10%, transparent)}}.hover\:bg-black\/55:hover{background-color:#0000008c}@supports (color:color-mix(in lab, red, red)){.hover\:bg-black\/55:hover{background-color:color-mix(in oklab, var(--color-black) 55%, transparent)}}.hover\:bg-black\/70:hover{background-color:#000000b3}@supports (color:color-mix(in lab, red, red)){.hover\:bg-black\/70:hover{background-color:color-mix(in oklab, var(--color-black) 70%, transparent)}}.hover\:bg-blue-50:hover{background-color:var(--color-blue-50)}.hover\:bg-blue-50\/30:hover{background-color:#eff6ff4d}@supports (color:color-mix(in lab, red, red)){.hover\:bg-blue-50\/30:hover{background-color:color-mix(in oklab, var(--color-blue-50) 30%, transparent)}}.hover\:bg-blue-50\/60:hover{background-color:#eff6ff99}@supports (color:color-mix(in lab, red, red)){.hover\:bg-blue-50\/60:hover{background-color:color-mix(in oklab, var(--color-blue-50) 60%, transparent)}}.hover\:bg-blue-100:hover{background-color:var(--color-blue-100)}.hover\:bg-blue-200:hover{background-color:var(--color-blue-200)}.hover\:bg-blue-500:hover{background-color:var(--color-blue-500)}.hover\:bg-blue-500\/15:hover{background-color:#3080ff26}@supports (color:color-mix(in lab, red, red)){.hover\:bg-blue-500\/15:hover{background-color:color-mix(in oklab, var(--color-blue-500) 15%, transparent)}}.hover\:bg-blue-500\/20:hover{background-color:#3080ff33}@supports (color:color-mix(in lab, red, red)){.hover\:bg-blue-500\/20:hover{background-color:color-mix(in oklab, var(--color-blue-500) 20%, transparent)}}.hover\:bg-blue-600:hover{background-color:var(--color-blue-600)}.hover\:bg-blue-700:hover{background-color:var(--color-blue-700)}.hover\:bg-emerald-50:hover{background-color:var(--color-emerald-50)}.hover\:bg-emerald-100:hover{background-color:var(--color-emerald-100)}.hover\:bg-emerald-500:hover{background-color:var(--color-emerald-500)}.hover\:bg-emerald-500\/20:hover{background-color:#00bb7f33}@supports (color:color-mix(in lab, red, red)){.hover\:bg-emerald-500\/20:hover{background-color:color-mix(in oklab, var(--color-emerald-500) 20%, transparent)}}.hover\:bg-emerald-600:hover{background-color:var(--color-emerald-600)}.hover\:bg-emerald-700:hover{background-color:var(--color-emerald-700)}.hover\:bg-gray-50:hover{background-color:var(--color-gray-50)}.hover\:bg-gray-50\/30:hover{background-color:#f9fafb4d}@supports (color:color-mix(in lab, red, red)){.hover\:bg-gray-50\/30:hover{background-color:color-mix(in oklab, var(--color-gray-50) 30%, transparent)}}.hover\:bg-gray-50\/50:hover{background-color:#f9fafb80}@supports (color:color-mix(in lab, red, red)){.hover\:bg-gray-50\/50:hover{background-color:color-mix(in oklab, var(--color-gray-50) 50%, transparent)}}.hover\:bg-gray-50\/80:hover{background-color:#f9fafbcc}@supports (color:color-mix(in lab, red, red)){.hover\:bg-gray-50\/80:hover{background-color:color-mix(in oklab, var(--color-gray-50) 80%, transparent)}}.hover\:bg-gray-100:hover{background-color:var(--color-gray-100)}.hover\:bg-gray-100\/80:hover{background-color:#f3f4f6cc}@supports (color:color-mix(in lab, red, red)){.hover\:bg-gray-100\/80:hover{background-color:color-mix(in oklab, var(--color-gray-100) 80%, transparent)}}.hover\:bg-gray-200:hover{background-color:var(--color-gray-200)}.hover\:bg-gray-200\/50:hover{background-color:#e5e7eb80}@supports (color:color-mix(in lab, red, red)){.hover\:bg-gray-200\/50:hover{background-color:color-mix(in oklab, var(--color-gray-200) 50%, transparent)}}.hover\:bg-gray-300:hover{background-color:var(--color-gray-300)}.hover\:bg-gray-400:hover{background-color:var(--color-gray-400)}.hover\:bg-gray-700:hover{background-color:var(--color-gray-700)}.hover\:bg-green-100:hover{background-color:var(--color-green-100)}.hover\:bg-green-500:hover{background-color:var(--color-green-500)}.hover\:bg-green-500\/20:hover{background-color:#00c75833}@supports (color:color-mix(in lab, red, red)){.hover\:bg-green-500\/20:hover{background-color:color-mix(in oklab, var(--color-green-500) 20%, transparent)}}.hover\:bg-green-700:hover{background-color:var(--color-green-700)}.hover\:bg-indigo-100:hover{background-color:var(--color-indigo-100)}.hover\:bg-indigo-200:hover{background-color:var(--color-indigo-200)}.hover\:bg-indigo-500:hover{background-color:var(--color-indigo-500)}.hover\:bg-orange-100:hover{background-color:var(--color-orange-100)}.hover\:bg-orange-500:hover{background-color:var(--color-orange-500)}.hover\:bg-orange-700:hover{background-color:var(--color-orange-700)}.hover\:bg-pink-100:hover{background-color:var(--color-pink-100)}.hover\:bg-purple-800\/60:hover{background-color:#6e11b099}@supports (color:color-mix(in lab, red, red)){.hover\:bg-purple-800\/60:hover{background-color:color-mix(in oklab, var(--color-purple-800) 60%, transparent)}}.hover\:bg-purple-900\/40:hover{background-color:#59168b66}@supports (color:color-mix(in lab, red, red)){.hover\:bg-purple-900\/40:hover{background-color:color-mix(in oklab, var(--color-purple-900) 40%, transparent)}}.hover\:bg-red-50:hover{background-color:var(--color-red-50)}.hover\:bg-red-50\!:hover{background-color:var(--color-red-50)!important}.hover\:bg-red-100:hover{background-color:var(--color-red-100)}.hover\:bg-red-200:hover{background-color:var(--color-red-200)}.hover\:bg-red-500:hover{background-color:var(--color-red-500)}.hover\:bg-red-600:hover{background-color:var(--color-red-600)}.hover\:bg-red-700:hover{background-color:var(--color-red-700)}.hover\:bg-rose-700:hover{background-color:var(--color-rose-700)}.hover\:bg-sky-700:hover{background-color:var(--color-sky-700)}.hover\:bg-sky-900\/10:hover{background-color:#024a701a}@supports (color:color-mix(in lab, red, red)){.hover\:bg-sky-900\/10:hover{background-color:color-mix(in oklab, var(--color-sky-900) 10%, transparent)}}.hover\:bg-sky-900\/14:hover{background-color:#024a7024}@supports (color:color-mix(in lab, red, red)){.hover\:bg-sky-900\/14:hover{background-color:color-mix(in oklab, var(--color-sky-900) 14%, transparent)}}.hover\:bg-slate-50:hover{background-color:var(--color-slate-50)}.hover\:bg-slate-100\/80:hover{background-color:#f1f5f9cc}@supports (color:color-mix(in lab, red, red)){.hover\:bg-slate-100\/80:hover{background-color:color-mix(in oklab, var(--color-slate-100) 80%, transparent)}}.hover\:bg-teal-100:hover{background-color:var(--color-teal-100)}.hover\:bg-teal-500:hover{background-color:var(--color-teal-500)}.hover\:bg-violet-100:hover{background-color:var(--color-violet-100)}.hover\:bg-violet-700:hover{background-color:var(--color-violet-700)}.hover\:bg-white:hover{background-color:var(--color-white)}.hover\:bg-white\/10:hover{background-color:#ffffff1a}@supports (color:color-mix(in lab, red, red)){.hover\:bg-white\/10:hover{background-color:color-mix(in oklab, var(--color-white) 10%, transparent)}}.hover\:bg-white\/20:hover{background-color:#fff3}@supports (color:color-mix(in lab, red, red)){.hover\:bg-white\/20:hover{background-color:color-mix(in oklab, var(--color-white) 20%, transparent)}}.hover\:bg-white\/25:hover{background-color:#ffffff40}@supports (color:color-mix(in lab, red, red)){.hover\:bg-white\/25:hover{background-color:color-mix(in oklab, var(--color-white) 25%, transparent)}}.hover\:bg-white\/30:hover{background-color:#ffffff4d}@supports (color:color-mix(in lab, red, red)){.hover\:bg-white\/30:hover{background-color:color-mix(in oklab, var(--color-white) 30%, transparent)}}.hover\:bg-white\/50:hover{background-color:#ffffff80}@supports (color:color-mix(in lab, red, red)){.hover\:bg-white\/50:hover{background-color:color-mix(in oklab, var(--color-white) 50%, transparent)}}.hover\:bg-white\/90:hover{background-color:#ffffffe6}@supports (color:color-mix(in lab, red, red)){.hover\:bg-white\/90:hover{background-color:color-mix(in oklab, var(--color-white) 90%, transparent)}}.hover\:bg-yellow-700:hover{background-color:var(--color-yellow-700)}.hover\:bg-zinc-600:hover{background-color:var(--color-zinc-600)}.hover\:bg-zinc-700:hover{background-color:var(--color-zinc-700)}.hover\:bg-zinc-800:hover{background-color:var(--color-zinc-800)}.hover\:text-amber-500:hover{color:var(--color-amber-500)}.hover\:text-amber-600:hover{color:var(--color-amber-600)}.hover\:text-amber-800:hover{color:var(--color-amber-800)}.hover\:text-blue-300:hover{color:var(--color-blue-300)}.hover\:text-blue-500:hover{color:var(--color-blue-500)}.hover\:text-blue-600:hover{color:var(--color-blue-600)}.hover\:text-blue-700:hover{color:var(--color-blue-700)}.hover\:text-cyan-600:hover{color:var(--color-cyan-600)}.hover\:text-emerald-600:hover{color:var(--color-emerald-600)}.hover\:text-gray-500:hover{color:var(--color-gray-500)}.hover\:text-gray-600:hover{color:var(--color-gray-600)}.hover\:text-gray-700:hover{color:var(--color-gray-700)}.hover\:text-gray-800:hover{color:var(--color-gray-800)}.hover\:text-gray-900:hover{color:var(--color-gray-900)}.hover\:text-green-500:hover{color:var(--color-green-500)}.hover\:text-green-600:hover{color:var(--color-green-600)}.hover\:text-indigo-500:hover{color:var(--color-indigo-500)}.hover\:text-purple-100:hover{color:var(--color-purple-100)}.hover\:text-purple-200:hover{color:var(--color-purple-200)}.hover\:text-red-400:hover{color:var(--color-red-400)}.hover\:text-red-500:hover{color:var(--color-red-500)}.hover\:text-red-600:hover{color:var(--color-red-600)}.hover\:text-teal-500:hover{color:var(--color-teal-500)}.hover\:text-violet-600:hover{color:var(--color-violet-600)}.hover\:text-white:hover{color:var(--color-white)}.hover\:text-zinc-200:hover{color:var(--color-zinc-200)}.hover\:underline:hover{text-decoration-line:underline}.hover\:opacity-70:hover{opacity:.7}.hover\:opacity-90:hover{opacity:.9}.hover\:opacity-100:hover{opacity:1}.hover\:shadow-2xl:hover{--tw-shadow:0 25px 50px -12px var(--tw-shadow-color,#00000040);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.hover\:shadow-md:hover{--tw-shadow:0 4px 6px -1px var(--tw-shadow-color,#0000001a), 0 2px 4px -2px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.hover\:shadow-xl:hover{--tw-shadow:0 20px 25px -5px var(--tw-shadow-color,#0000001a), 0 8px 10px -6px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.hover\:ring-2:hover{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.hover\:ring-blue-500\/50:hover{--tw-ring-color:#3080ff80}@supports (color:color-mix(in lab, red, red)){.hover\:ring-blue-500\/50:hover{--tw-ring-color:color-mix(in oklab, var(--color-blue-500) 50%, transparent)}}.hover\:file\:bg-blue-700:hover::file-selector-button{background-color:var(--color-blue-700)}.hover\:file\:bg-zinc-300:hover::file-selector-button{background-color:var(--color-zinc-300)}}.focus\:border-blue-400:focus{border-color:var(--color-blue-400)}.focus\:border-blue-500:focus{border-color:var(--color-blue-500)}.focus\:border-indigo-400:focus{border-color:var(--color-indigo-400)}.focus\:border-indigo-500:focus{border-color:var(--color-indigo-500)}.focus\:border-transparent:focus{border-color:#0000}.focus\:opacity-100:focus{opacity:1}.focus\:ring-0:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\:ring-1:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\:ring-2:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\:ring-amber-500:focus{--tw-ring-color:var(--color-amber-500)}.focus\:ring-blue-400:focus{--tw-ring-color:var(--color-blue-400)}.focus\:ring-blue-500:focus{--tw-ring-color:var(--color-blue-500)}.focus\:ring-blue-500\/20:focus{--tw-ring-color:#3080ff33}@supports (color:color-mix(in lab, red, red)){.focus\:ring-blue-500\/20:focus{--tw-ring-color:color-mix(in oklab, var(--color-blue-500) 20%, transparent)}}.focus\:ring-blue-500\/30:focus{--tw-ring-color:#3080ff4d}@supports (color:color-mix(in lab, red, red)){.focus\:ring-blue-500\/30:focus{--tw-ring-color:color-mix(in oklab, var(--color-blue-500) 30%, transparent)}}.focus\:ring-blue-500\/40:focus{--tw-ring-color:#3080ff66}@supports (color:color-mix(in lab, red, red)){.focus\:ring-blue-500\/40:focus{--tw-ring-color:color-mix(in oklab, var(--color-blue-500) 40%, transparent)}}.focus\:ring-blue-500\/50:focus{--tw-ring-color:#3080ff80}@supports (color:color-mix(in lab, red, red)){.focus\:ring-blue-500\/50:focus{--tw-ring-color:color-mix(in oklab, var(--color-blue-500) 50%, transparent)}}.focus\:ring-blue-600:focus{--tw-ring-color:var(--color-blue-600)}.focus\:ring-emerald-500:focus{--tw-ring-color:var(--color-emerald-500)}.focus\:ring-indigo-400:focus{--tw-ring-color:var(--color-indigo-400)}.focus\:ring-indigo-500:focus{--tw-ring-color:var(--color-indigo-500)}.focus\:ring-red-500:focus{--tw-ring-color:var(--color-red-500)}.focus\:ring-white\/50:focus{--tw-ring-color:#ffffff80}@supports (color:color-mix(in lab, red, red)){.focus\:ring-white\/50:focus{--tw-ring-color:color-mix(in oklab, var(--color-white) 50%, transparent)}}.focus\:ring-offset-2:focus{--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.focus\:outline-hidden:focus{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.focus\:outline-hidden:focus{outline-offset:2px;outline:2px solid #0000}}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}.focus\:ring-inset:focus{--tw-ring-inset:inset}.focus-visible\:ring-1:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus-visible\:ring-2:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus-visible\:ring-blue-500:focus-visible{--tw-ring-color:var(--color-blue-500)}.focus-visible\:ring-blue-500\/50:focus-visible{--tw-ring-color:#3080ff80}@supports (color:color-mix(in lab, red, red)){.focus-visible\:ring-blue-500\/50:focus-visible{--tw-ring-color:color-mix(in oklab, var(--color-blue-500) 50%, transparent)}}.focus-visible\:ring-blue-500\/60:focus-visible{--tw-ring-color:#3080ff99}@supports (color:color-mix(in lab, red, red)){.focus-visible\:ring-blue-500\/60:focus-visible{--tw-ring-color:color-mix(in oklab, var(--color-blue-500) 60%, transparent)}}.focus-visible\:ring-indigo-500:focus-visible{--tw-ring-color:var(--color-indigo-500)}.focus-visible\:ring-white\/80:focus-visible{--tw-ring-color:#fffc}@supports (color:color-mix(in lab, red, red)){.focus-visible\:ring-white\/80:focus-visible{--tw-ring-color:color-mix(in oklab, var(--color-white) 80%, transparent)}}.focus-visible\:outline-2:focus-visible{outline-style:var(--tw-outline-style);outline-width:2px}.focus-visible\:outline-offset-2:focus-visible{outline-offset:2px}.focus-visible\:outline-gray-500:focus-visible{outline-color:var(--color-gray-500)}.focus-visible\:outline-solid:focus-visible{--tw-outline-style:solid;outline-style:solid}.active\:scale-90:active{--tw-scale-x:90%;--tw-scale-y:90%;--tw-scale-z:90%;scale:var(--tw-scale-x) var(--tw-scale-y)}.active\:scale-95:active{--tw-scale-x:95%;--tw-scale-y:95%;--tw-scale-z:95%;scale:var(--tw-scale-x) var(--tw-scale-y)}.active\:scale-\[0\.98\]:active{scale:.98}.active\:cursor-grabbing:active{cursor:grabbing}.disabled\:pointer-events-none:disabled{pointer-events:none}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:bg-blue-300:disabled{background-color:var(--color-blue-300)}.disabled\:bg-gray-100:disabled{background-color:var(--color-gray-100)}.disabled\:bg-gray-300:disabled{background-color:var(--color-gray-300)}.disabled\:bg-gray-400:disabled{background-color:var(--color-gray-400)}.disabled\:text-gray-300:disabled{color:var(--color-gray-300)}.disabled\:opacity-30:disabled{opacity:.3}.disabled\:opacity-40:disabled{opacity:.4}.disabled\:opacity-50:disabled{opacity:.5}.disabled\:opacity-60:disabled{opacity:.6}@media (hover:hover){.disabled\:hover\:scale-100:disabled:hover{--tw-scale-x:100%;--tw-scale-y:100%;--tw-scale-z:100%;scale:var(--tw-scale-x) var(--tw-scale-y)}.disabled\:hover\:bg-violet-50:disabled:hover{background-color:var(--color-violet-50)}}@media (prefers-reduced-motion:reduce){.motion-reduce\:animate-none{animation:none}}@media not all and (min-width:40rem){.max-sm\:right-auto{right:auto}.max-sm\:bottom-22{bottom:calc(var(--spacing) * 22)}.max-sm\:bottom-\[calc\(1rem\+env\(safe-area-inset-bottom\,0px\)\)\]{bottom:calc(1rem + env(safe-area-inset-bottom,0px))}.max-sm\:bottom-\[calc\(5\.5rem\+env\(safe-area-inset-bottom\,0px\)\)\]{bottom:calc(5.5rem + env(safe-area-inset-bottom,0px))}.max-sm\:left-1\/2{left:50%}.max-sm\:h-\[92dvh\]{height:92dvh}.max-sm\:max-h-\[92dvh\]{max-height:92dvh}.max-sm\:min-h-\[55dvh\]{min-height:55dvh}.max-sm\:min-w-max{min-width:max-content}.max-sm\:-translate-x-1\/2{--tw-translate-x:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.max-sm\:flex-nowrap{flex-wrap:nowrap}.max-sm\:text-\[8px\]{font-size:8px}.max-sm\:text-\[9px\]{font-size:9px}.max-sm\:leading-snug{--tw-leading:var(--leading-snug);line-height:var(--leading-snug)}.max-sm\:tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.max-sm\:break-all{word-break:break-all}.max-sm\:whitespace-nowrap{white-space:nowrap}}@media (min-width:900px){.min-\[900px\]\:size-4{width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.min-\[900px\]\:size-5{width:calc(var(--spacing) * 5);height:calc(var(--spacing) * 5)}.min-\[900px\]\:h-9{height:calc(var(--spacing) * 9)}.min-\[900px\]\:w-9{width:calc(var(--spacing) * 9)}.min-\[900px\]\:max-w-\[14rem\]{max-width:14rem}.min-\[900px\]\:min-w-\[7rem\]{min-width:7rem}.min-\[900px\]\:px-3{padding-inline:calc(var(--spacing) * 3)}.min-\[900px\]\:text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}}@media (min-width:40rem){.sm\:relative{position:relative}.sm\:inset-auto{inset:auto}.sm\:top-2{top:calc(var(--spacing) * 2)}.sm\:top-4{top:calc(var(--spacing) * 4)}.sm\:right-0{right:0}.sm\:right-4{right:calc(var(--spacing) * 4)}.sm\:right-auto{right:auto}.sm\:bottom-4{bottom:calc(var(--spacing) * 4)}.sm\:bottom-5{bottom:calc(var(--spacing) * 5)}.sm\:left-4{left:calc(var(--spacing) * 4)}.sm\:left-auto{left:auto}.sm\:z-0{z-index:0}.sm\:z-auto{z-index:auto}.sm\:col-span-1{grid-column:span 1/span 1}.sm\:col-span-2{grid-column:span 2/span 2}.sm\:m-0{margin:0}.sm\:-mx-4{margin-inline:calc(var(--spacing) * -4)}.sm\:mx-0{margin-inline:0}.sm\:mx-1{margin-inline:var(--spacing)}.sm\:my-auto{margin-block:auto}.sm\:mt-0{margin-top:0}.sm\:mt-2{margin-top:calc(var(--spacing) * 2)}.sm\:mt-8{margin-top:calc(var(--spacing) * 8)}.sm\:mr-2{margin-right:calc(var(--spacing) * 2)}.sm\:mb-4{margin-bottom:calc(var(--spacing) * 4)}.sm\:ml-0{margin-left:0}.sm\:ml-1\.5{margin-left:calc(var(--spacing) * 1.5)}.sm\:ml-auto{margin-left:auto}.sm\:line-clamp-none{-webkit-line-clamp:unset;-webkit-box-orient:horizontal;display:block;overflow:visible}.sm\:block{display:block}.sm\:flex{display:flex}.sm\:hidden{display:none}.sm\:inline{display:inline}.sm\:inline-block{display:inline-block}.sm\:inline-flex{display:inline-flex}.sm\:size-5{width:calc(var(--spacing) * 5);height:calc(var(--spacing) * 5)}.sm\:size-5\!{width:calc(var(--spacing) * 5)!important;height:calc(var(--spacing) * 5)!important}.sm\:size-6{width:calc(var(--spacing) * 6);height:calc(var(--spacing) * 6)}.sm\:size-7{width:calc(var(--spacing) * 7);height:calc(var(--spacing) * 7)}.sm\:size-12{width:calc(var(--spacing) * 12);height:calc(var(--spacing) * 12)}.sm\:size-48{width:calc(var(--spacing) * 48);height:calc(var(--spacing) * 48)}.sm\:h-4{height:calc(var(--spacing) * 4)}.sm\:h-5{height:calc(var(--spacing) * 5)}.sm\:h-6{height:calc(var(--spacing) * 6)}.sm\:h-9{height:calc(var(--spacing) * 9)}.sm\:h-11{height:calc(var(--spacing) * 11)}.sm\:h-12{height:calc(var(--spacing) * 12)}.sm\:h-14{height:calc(var(--spacing) * 14)}.sm\:h-28{height:calc(var(--spacing) * 28)}.sm\:h-\[320px\]{height:320px}.sm\:max-h-\[90vh\]{max-height:90vh}.sm\:min-h-0{min-height:0}.sm\:min-h-14{min-height:calc(var(--spacing) * 14)}.sm\:min-h-\[12rem\]{min-height:12rem}.sm\:min-h-\[280px\]{min-height:280px}.sm\:w-4{width:calc(var(--spacing) * 4)}.sm\:w-5{width:calc(var(--spacing) * 5)}.sm\:w-6{width:calc(var(--spacing) * 6)}.sm\:w-9{width:calc(var(--spacing) * 9)}.sm\:w-11{width:calc(var(--spacing) * 11)}.sm\:w-12{width:calc(var(--spacing) * 12)}.sm\:w-14{width:calc(var(--spacing) * 14)}.sm\:w-24{width:calc(var(--spacing) * 24)}.sm\:w-40{width:calc(var(--spacing) * 40)}.sm\:w-64{width:calc(var(--spacing) * 64)}.sm\:w-80{width:calc(var(--spacing) * 80)}.sm\:w-\[280px\]{width:280px}.sm\:w-auto{width:auto}.sm\:max-w-3xl{max-width:var(--container-3xl)}.sm\:max-w-48{max-width:calc(var(--spacing) * 48)}.sm\:max-w-56{max-width:calc(var(--spacing) * 56)}.sm\:max-w-\[16rem\]{max-width:16rem}.sm\:max-w-\[75\%\]{max-width:75%}.sm\:max-w-\[280px\]{max-width:280px}.sm\:max-w-lg{max-width:var(--container-lg)}.sm\:max-w-md{max-width:var(--container-md)}.sm\:max-w-none{max-width:none}.sm\:max-w-sm{max-width:var(--container-sm)}.sm\:max-w-xs{max-width:var(--container-xs)}.sm\:min-w-0{min-width:0}.sm\:min-w-12{min-width:calc(var(--spacing) * 12)}.sm\:min-w-80{min-width:calc(var(--spacing) * 80)}.sm\:min-w-\[280px\]{min-width:280px}.sm\:min-w-\[300px\]{min-width:300px}.sm\:min-w-\[400px\]{min-width:400px}.sm\:flex-1{flex:1}.sm\:flex-initial{flex:0 auto}.sm\:flex-none{flex:none}.sm\:shrink-0{flex-shrink:0}.sm\:translate-x-0{--tw-translate-x:0px;translate:var(--tw-translate-x) var(--tw-translate-y)}.sm\:grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.sm\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.sm\:grid-cols-5{grid-template-columns:repeat(5,minmax(0,1fr))}.sm\:grid-cols-\[1fr_10rem\]{grid-template-columns:1fr 10rem}.sm\:grid-cols-\[1fr_auto\]{grid-template-columns:1fr auto}.sm\:grid-cols-\[auto_minmax\(0\,0\.75fr\)_minmax\(0\,1fr\)_minmax\(0\,1\.1fr\)\]{grid-template-columns:auto minmax(0,.75fr) minmax(0,1fr) minmax(0,1.1fr)}.sm\:grid-cols-\[minmax\(0\,1\.15fr\)_auto_minmax\(0\,1\.35fr\)_auto\]{grid-template-columns:minmax(0,1.15fr) auto minmax(0,1.35fr) auto}.sm\:flex-col{flex-direction:column}.sm\:flex-row{flex-direction:row}.sm\:flex-nowrap{flex-wrap:nowrap}.sm\:flex-wrap{flex-wrap:wrap}.sm\:items-baseline{align-items:baseline}.sm\:items-center{align-items:center}.sm\:items-end{align-items:flex-end}.sm\:items-start{align-items:flex-start}.sm\:justify-between{justify-content:space-between}.sm\:justify-center{justify-content:center}.sm\:justify-end{justify-content:flex-end}.sm\:justify-start{justify-content:flex-start}.sm\:gap-0\.5{gap:calc(var(--spacing) * .5)}.sm\:gap-1{gap:var(--spacing)}.sm\:gap-1\.5{gap:calc(var(--spacing) * 1.5)}.sm\:gap-2{gap:calc(var(--spacing) * 2)}.sm\:gap-3{gap:calc(var(--spacing) * 3)}.sm\:gap-4{gap:calc(var(--spacing) * 4)}.sm\:gap-5{gap:calc(var(--spacing) * 5)}.sm\:gap-6{gap:calc(var(--spacing) * 6)}:where(.sm\:space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}:where(.sm\:space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}:where(.sm\:space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}.sm\:gap-x-3{column-gap:calc(var(--spacing) * 3)}:where(.sm\:space-x-2>:not(:last-child)){--tw-space-x-reverse:0;margin-inline-start:calc(calc(var(--spacing) * 2) * var(--tw-space-x-reverse));margin-inline-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-x-reverse)))}.sm\:gap-y-0{row-gap:0}.sm\:gap-y-1{row-gap:var(--spacing)}.sm\:self-auto{align-self:auto}.sm\:rounded-2xl{border-radius:var(--radius-2xl)}.sm\:rounded-3xl{border-radius:var(--radius-3xl)}.sm\:rounded-\[2rem\]{border-radius:2rem}.sm\:rounded-lg{border-radius:var(--radius-lg)}.sm\:rounded-xl{border-radius:var(--radius-xl)}.sm\:border{border-style:var(--tw-border-style);border-width:1px}.sm\:border-0{border-style:var(--tw-border-style);border-width:0}.sm\:bg-black\/2{background-color:#00000005}@supports (color:color-mix(in lab, red, red)){.sm\:bg-black\/2{background-color:color-mix(in oklab, var(--color-black) 2%, transparent)}}.sm\:bg-black\/50{background-color:#00000080}@supports (color:color-mix(in lab, red, red)){.sm\:bg-black\/50{background-color:color-mix(in oklab, var(--color-black) 50%, transparent)}}.sm\:p-1{padding:var(--spacing)}.sm\:p-1\.5{padding:calc(var(--spacing) * 1.5)}.sm\:p-2{padding:calc(var(--spacing) * 2)}.sm\:p-3{padding:calc(var(--spacing) * 3)}.sm\:p-4{padding:calc(var(--spacing) * 4)}.sm\:p-5{padding:calc(var(--spacing) * 5)}.sm\:p-6{padding:calc(var(--spacing) * 6)}.sm\:p-8{padding:calc(var(--spacing) * 8)}.sm\:p-12{padding:calc(var(--spacing) * 12)}.sm\:px-0{padding-inline:0}.sm\:px-1{padding-inline:var(--spacing)}.sm\:px-2{padding-inline:calc(var(--spacing) * 2)}.sm\:px-3{padding-inline:calc(var(--spacing) * 3)}.sm\:px-4{padding-inline:calc(var(--spacing) * 4)}.sm\:px-5{padding-inline:calc(var(--spacing) * 5)}.sm\:px-6{padding-inline:calc(var(--spacing) * 6)}.sm\:py-1{padding-block:var(--spacing)}.sm\:py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.sm\:py-2{padding-block:calc(var(--spacing) * 2)}.sm\:py-2\.5{padding-block:calc(var(--spacing) * 2.5)}.sm\:py-3{padding-block:calc(var(--spacing) * 3)}.sm\:py-4{padding-block:calc(var(--spacing) * 4)}.sm\:py-6{padding-block:calc(var(--spacing) * 6)}.sm\:py-8{padding-block:calc(var(--spacing) * 8)}.sm\:py-10{padding-block:calc(var(--spacing) * 10)}.sm\:py-12{padding-block:calc(var(--spacing) * 12)}.sm\:py-20{padding-block:calc(var(--spacing) * 20)}.sm\:ps-0{padding-inline-start:0}.sm\:pe-3{padding-inline-end:calc(var(--spacing) * 3)}.sm\:pt-0{padding-top:0}.sm\:pt-0\.5{padding-top:calc(var(--spacing) * .5)}.sm\:pt-1{padding-top:var(--spacing)}.sm\:pt-1\.5{padding-top:calc(var(--spacing) * 1.5)}.sm\:pt-2{padding-top:calc(var(--spacing) * 2)}.sm\:pr-1{padding-right:var(--spacing)}.sm\:pr-2{padding-right:calc(var(--spacing) * 2)}.sm\:pr-4{padding-right:calc(var(--spacing) * 4)}.sm\:pr-12{padding-right:calc(var(--spacing) * 12)}.sm\:pr-40{padding-right:calc(var(--spacing) * 40)}.sm\:pb-4{padding-bottom:calc(var(--spacing) * 4)}.sm\:pb-24{padding-bottom:calc(var(--spacing) * 24)}.sm\:pl-0{padding-left:0}.sm\:pl-1{padding-left:var(--spacing)}.sm\:pl-2{padding-left:calc(var(--spacing) * 2)}.sm\:pl-4{padding-left:calc(var(--spacing) * 4)}.sm\:pl-14{padding-left:calc(var(--spacing) * 14)}.sm\:text-left{text-align:left}.sm\:text-right{text-align:right}.sm\:text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.sm\:text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.sm\:text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.sm\:text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.sm\:text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.sm\:text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.sm\:text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.sm\:text-\[10px\]{font-size:10px}.sm\:text-\[11px\]{font-size:11px}.sm\:leading-6{--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6)}.sm\:leading-snug{--tw-leading:var(--leading-snug);line-height:var(--leading-snug)}.sm\:wrap-break-word{overflow-wrap:break-word}.sm\:opacity-100{opacity:1}.sm\:focus\:w-80:focus{width:calc(var(--spacing) * 80)}}@media (min-width:48rem){.md\:static{position:static}.md\:z-auto{z-index:auto}.md\:flex{display:flex}.md\:hidden{display:none}.md\:inline{display:inline}.md\:size-6{width:calc(var(--spacing) * 6);height:calc(var(--spacing) * 6)}.md\:w-72{width:calc(var(--spacing) * 72)}.md\:w-80{width:calc(var(--spacing) * 80)}.md\:w-96{width:calc(var(--spacing) * 96)}.md\:w-auto{width:auto}.md\:max-w-\[9rem\]{max-width:9rem}.md\:max-w-none{max-width:none}.md\:max-w-sm{max-width:var(--container-sm)}.md\:max-w-xs{max-width:var(--container-xs)}.md\:min-w-\[200px\]{min-width:200px}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.md\:rounded-xl{border-radius:var(--radius-xl)}.md\:border{border-style:var(--tw-border-style);border-width:1px}.md\:border-r{border-right-style:var(--tw-border-style);border-right-width:1px}.md\:border-b-0{border-bottom-style:var(--tw-border-style);border-bottom-width:0}.md\:p-4{padding:calc(var(--spacing) * 4)}.md\:p-6{padding:calc(var(--spacing) * 6)}.md\:p-8{padding:calc(var(--spacing) * 8)}.md\:p-10{padding:calc(var(--spacing) * 10)}.md\:px-4{padding-inline:calc(var(--spacing) * 4)}.md\:px-5{padding-inline:calc(var(--spacing) * 5)}.md\:px-6{padding-inline:calc(var(--spacing) * 6)}.md\:px-10{padding-inline:calc(var(--spacing) * 10)}.md\:px-12{padding-inline:calc(var(--spacing) * 12)}.md\:py-5{padding-block:calc(var(--spacing) * 5)}.md\:py-6{padding-block:calc(var(--spacing) * 6)}.md\:py-10{padding-block:calc(var(--spacing) * 10)}.md\:py-12{padding-block:calc(var(--spacing) * 12)}.md\:pr-6{padding-right:calc(var(--spacing) * 6)}.md\:pl-4{padding-left:calc(var(--spacing) * 4)}.md\:text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.md\:text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.md\:text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.md\:text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.md\:text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.md\:leading-relaxed{--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}.md\:opacity-70{opacity:.7}.md\:shadow-none{--tw-shadow:0 0 #0000;box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.md\:grayscale-\[0\.3\]{--tw-grayscale:grayscale(.3);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.md\:backdrop-blur-\[0\.5px\]{--tw-backdrop-blur:blur(.5px);-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}@media not all and (min-width:64rem){.md\:max-lg\:w-64{width:calc(var(--spacing) * 64)}.md\:max-lg\:w-72{width:calc(var(--spacing) * 72)}.md\:max-lg\:max-w-\[70\%\]{max-width:70%}.md\:max-lg\:min-w-64{min-width:calc(var(--spacing) * 64)}.md\:max-lg\:focus\:w-72:focus{width:calc(var(--spacing) * 72)}}}@media (min-width:64rem){.lg\:sticky{position:sticky}.lg\:top-20{top:calc(var(--spacing) * 20)}.lg\:col-span-1{grid-column:span 1/span 1}.lg\:col-span-2{grid-column:span 2/span 2}.lg\:col-span-3{grid-column:span 3/span 3}.lg\:block{display:block}.lg\:flex{display:flex}.lg\:hidden{display:none}.lg\:inline{display:inline}.lg\:inline-flex{display:inline-flex}.lg\:max-h-none{max-height:none}.lg\:w-52{width:calc(var(--spacing) * 52)}.lg\:w-80{width:calc(var(--spacing) * 80)}.lg\:w-auto{width:auto}.lg\:max-w-\[65\%\]{max-width:65%}.lg\:max-w-md{max-width:var(--container-md)}.lg\:max-w-sm{max-width:var(--container-sm)}.lg\:min-w-80{min-width:calc(var(--spacing) * 80)}.lg\:min-w-\[120px\]{min-width:120px}.lg\:flex-none{flex:none}.lg\:shrink-0{flex-shrink:0}.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.lg\:grid-cols-5{grid-template-columns:repeat(5,minmax(0,1fr))}.lg\:flex-col{flex-direction:column}.lg\:flex-row{flex-direction:row}.lg\:items-center{align-items:center}.lg\:items-end{align-items:flex-end}.lg\:items-start{align-items:flex-start}.lg\:justify-between{justify-content:space-between}.lg\:gap-0\.5{gap:calc(var(--spacing) * .5)}.lg\:gap-6{gap:calc(var(--spacing) * 6)}.lg\:gap-8{gap:calc(var(--spacing) * 8)}.lg\:gap-12{gap:calc(var(--spacing) * 12)}:where(.lg\:divide-x>:not(:last-child)){--tw-divide-x-reverse:0;border-inline-style:var(--tw-border-style);border-inline-start-width:calc(1px * var(--tw-divide-x-reverse));border-inline-end-width:calc(1px * calc(1 - var(--tw-divide-x-reverse)))}:where(.lg\:divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)))}.lg\:overflow-x-visible{overflow-x:visible}.lg\:border-t-0{border-top-style:var(--tw-border-style);border-top-width:0}.lg\:border-r{border-right-style:var(--tw-border-style);border-right-width:1px}.lg\:border-b-0{border-bottom-style:var(--tw-border-style);border-bottom-width:0}.lg\:p-16{padding:calc(var(--spacing) * 16)}.lg\:px-8{padding-inline:calc(var(--spacing) * 8)}.lg\:py-8{padding-block:calc(var(--spacing) * 8)}.lg\:pb-0{padding-bottom:0}.lg\:opacity-0{opacity:0}@media (hover:hover){.lg\:group-hover\:opacity-100:is(:where(.group):hover *){opacity:1}}.lg\:focus\:w-80:focus{width:calc(var(--spacing) * 80)}}@media (min-width:80rem){.xl\:sticky{position:sticky}.xl\:top-4{top:calc(var(--spacing) * 4)}.xl\:order-1{order:1}.xl\:order-2{order:2}.xl\:col-span-2{grid-column:span 2/span 2}.xl\:flex{display:flex}.xl\:max-h-\[min\(calc\(100dvh-6rem\)\,920px\)\]{max-height:min(100dvh - 6rem,920px)}.xl\:max-h-\[min\(calc\(100dvh-14rem\)\,44rem\)\]{max-height:min(100dvh - 14rem,44rem)}.xl\:w-96{width:calc(var(--spacing) * 96)}.xl\:max-w-4xl{max-width:var(--container-4xl)}.xl\:max-w-7xl{max-width:var(--container-7xl)}.xl\:max-w-\[min\(100\%\,96rem\)\]{max-width:min(100%,96rem)}.xl\:max-w-lg{max-width:var(--container-lg)}.xl\:max-w-sm{max-width:var(--container-sm)}.xl\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.xl\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.xl\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.xl\:flex-row{flex-direction:row}.xl\:items-start{align-items:flex-start}.xl\:gap-6{gap:calc(var(--spacing) * 6)}.xl\:gap-10{gap:calc(var(--spacing) * 10)}.xl\:self-start{align-self:flex-start}.xl\:overflow-y-auto{overflow-y:auto}.xl\:border-l{border-left-style:var(--tw-border-style);border-left-width:1px}.xl\:p-8{padding:calc(var(--spacing) * 8)}.xl\:pl-8{padding-left:calc(var(--spacing) * 8)}}@media (min-width:96rem){.\32 xl\:max-w-5xl{max-width:var(--container-5xl)}.\32 xl\:max-w-360{max-width:calc(var(--spacing) * 360)}.\32 xl\:max-w-384{max-width:calc(var(--spacing) * 384)}.\32 xl\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.\32 xl\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}:where(.dark\:divide-zinc-700:where(.dark,.dark *)>:not(:last-child)){border-color:var(--color-zinc-700)}:where(.dark\:divide-zinc-800:where(.dark,.dark *)>:not(:last-child)){border-color:var(--color-zinc-800)}:where(.dark\:divide-zinc-800\/50:where(.dark,.dark *)>:not(:last-child)){border-color:#27272a80}@supports (color:color-mix(in lab, red, red)){:where(.dark\:divide-zinc-800\/50:where(.dark,.dark *)>:not(:last-child)){border-color:color-mix(in oklab, var(--color-zinc-800) 50%, transparent)}}:where(.dark\:divide-zinc-800\/60:where(.dark,.dark *)>:not(:last-child)){border-color:#27272a99}@supports (color:color-mix(in lab, red, red)){:where(.dark\:divide-zinc-800\/60:where(.dark,.dark *)>:not(:last-child)){border-color:color-mix(in oklab, var(--color-zinc-800) 60%, transparent)}}.dark\:border-amber-600:where(.dark,.dark *){border-color:var(--color-amber-600)}.dark\:border-amber-700:where(.dark,.dark *){border-color:var(--color-amber-700)}.dark\:border-amber-800:where(.dark,.dark *){border-color:var(--color-amber-800)}.dark\:border-amber-800\/30:where(.dark,.dark *){border-color:#953d004d}@supports (color:color-mix(in lab, red, red)){.dark\:border-amber-800\/30:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-amber-800) 30%, transparent)}}.dark\:border-amber-800\/40:where(.dark,.dark *){border-color:#953d0066}@supports (color:color-mix(in lab, red, red)){.dark\:border-amber-800\/40:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-amber-800) 40%, transparent)}}.dark\:border-amber-800\/50:where(.dark,.dark *){border-color:#953d0080}@supports (color:color-mix(in lab, red, red)){.dark\:border-amber-800\/50:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-amber-800) 50%, transparent)}}.dark\:border-amber-800\/60:where(.dark,.dark *){border-color:#953d0099}@supports (color:color-mix(in lab, red, red)){.dark\:border-amber-800\/60:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-amber-800) 60%, transparent)}}.dark\:border-amber-900:where(.dark,.dark *){border-color:var(--color-amber-900)}.dark\:border-amber-900\/30:where(.dark,.dark *){border-color:#7b33064d}@supports (color:color-mix(in lab, red, red)){.dark\:border-amber-900\/30:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-amber-900) 30%, transparent)}}.dark\:border-amber-900\/40:where(.dark,.dark *){border-color:#7b330666}@supports (color:color-mix(in lab, red, red)){.dark\:border-amber-900\/40:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-amber-900) 40%, transparent)}}.dark\:border-amber-900\/50:where(.dark,.dark *){border-color:#7b330680}@supports (color:color-mix(in lab, red, red)){.dark\:border-amber-900\/50:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-amber-900) 50%, transparent)}}.dark\:border-blue-400:where(.dark,.dark *){border-color:var(--color-blue-400)}.dark\:border-blue-500:where(.dark,.dark *){border-color:var(--color-blue-500)}.dark\:border-blue-700:where(.dark,.dark *){border-color:var(--color-blue-700)}.dark\:border-blue-800:where(.dark,.dark *){border-color:var(--color-blue-800)}.dark\:border-blue-800\/30:where(.dark,.dark *){border-color:#193cb84d}@supports (color:color-mix(in lab, red, red)){.dark\:border-blue-800\/30:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-blue-800) 30%, transparent)}}.dark\:border-blue-900\/20:where(.dark,.dark *){border-color:#1c398e33}@supports (color:color-mix(in lab, red, red)){.dark\:border-blue-900\/20:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-blue-900) 20%, transparent)}}.dark\:border-blue-900\/30:where(.dark,.dark *){border-color:#1c398e4d}@supports (color:color-mix(in lab, red, red)){.dark\:border-blue-900\/30:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-blue-900) 30%, transparent)}}.dark\:border-blue-900\/40:where(.dark,.dark *){border-color:#1c398e66}@supports (color:color-mix(in lab, red, red)){.dark\:border-blue-900\/40:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-blue-900) 40%, transparent)}}.dark\:border-blue-900\/50:where(.dark,.dark *){border-color:#1c398e80}@supports (color:color-mix(in lab, red, red)){.dark\:border-blue-900\/50:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-blue-900) 50%, transparent)}}.dark\:border-emerald-500\/30:where(.dark,.dark *){border-color:#00bb7f4d}@supports (color:color-mix(in lab, red, red)){.dark\:border-emerald-500\/30:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-emerald-500) 30%, transparent)}}.dark\:border-emerald-800\/40:where(.dark,.dark *){border-color:#005f4666}@supports (color:color-mix(in lab, red, red)){.dark\:border-emerald-800\/40:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-emerald-800) 40%, transparent)}}.dark\:border-emerald-900\/20:where(.dark,.dark *){border-color:#004e3b33}@supports (color:color-mix(in lab, red, red)){.dark\:border-emerald-900\/20:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-emerald-900) 20%, transparent)}}.dark\:border-emerald-900\/30:where(.dark,.dark *){border-color:#004e3b4d}@supports (color:color-mix(in lab, red, red)){.dark\:border-emerald-900\/30:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-emerald-900) 30%, transparent)}}.dark\:border-emerald-900\/40:where(.dark,.dark *){border-color:#004e3b66}@supports (color:color-mix(in lab, red, red)){.dark\:border-emerald-900\/40:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-emerald-900) 40%, transparent)}}.dark\:border-gray-600:where(.dark,.dark *){border-color:var(--color-gray-600)}.dark\:border-gray-700:where(.dark,.dark *){border-color:var(--color-gray-700)}.dark\:border-green-800:where(.dark,.dark *){border-color:var(--color-green-800)}.dark\:border-indigo-400:where(.dark,.dark *){border-color:var(--color-indigo-400)}.dark\:border-indigo-500\/30:where(.dark,.dark *){border-color:#625fff4d}@supports (color:color-mix(in lab, red, red)){.dark\:border-indigo-500\/30:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-indigo-500) 30%, transparent)}}.dark\:border-indigo-800:where(.dark,.dark *){border-color:var(--color-indigo-800)}.dark\:border-indigo-900\/30:where(.dark,.dark *){border-color:#312c854d}@supports (color:color-mix(in lab, red, red)){.dark\:border-indigo-900\/30:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-indigo-900) 30%, transparent)}}.dark\:border-orange-900\/30:where(.dark,.dark *){border-color:#7e2a0c4d}@supports (color:color-mix(in lab, red, red)){.dark\:border-orange-900\/30:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-orange-900) 30%, transparent)}}.dark\:border-pink-900\/20:where(.dark,.dark *){border-color:#86104333}@supports (color:color-mix(in lab, red, red)){.dark\:border-pink-900\/20:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-pink-900) 20%, transparent)}}.dark\:border-pink-900\/30:where(.dark,.dark *){border-color:#8610434d}@supports (color:color-mix(in lab, red, red)){.dark\:border-pink-900\/30:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-pink-900) 30%, transparent)}}.dark\:border-red-500\/40:where(.dark,.dark *){border-color:#fb2c3666}@supports (color:color-mix(in lab, red, red)){.dark\:border-red-500\/40:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-red-500) 40%, transparent)}}.dark\:border-red-500\/50:where(.dark,.dark *){border-color:#fb2c3680}@supports (color:color-mix(in lab, red, red)){.dark\:border-red-500\/50:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-red-500) 50%, transparent)}}.dark\:border-red-500\/50\!:where(.dark,.dark *){border-color:#fb2c3680!important}@supports (color:color-mix(in lab, red, red)){.dark\:border-red-500\/50\!:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-red-500) 50%, transparent)!important}}.dark\:border-red-700:where(.dark,.dark *){border-color:var(--color-red-700)}.dark\:border-red-800:where(.dark,.dark *){border-color:var(--color-red-800)}.dark\:border-red-800\/40:where(.dark,.dark *){border-color:#9f071266}@supports (color:color-mix(in lab, red, red)){.dark\:border-red-800\/40:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-red-800) 40%, transparent)}}.dark\:border-red-900\/30:where(.dark,.dark *){border-color:#82181a4d}@supports (color:color-mix(in lab, red, red)){.dark\:border-red-900\/30:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-red-900) 30%, transparent)}}.dark\:border-red-900\/40:where(.dark,.dark *){border-color:#82181a66}@supports (color:color-mix(in lab, red, red)){.dark\:border-red-900\/40:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-red-900) 40%, transparent)}}.dark\:border-red-900\/50:where(.dark,.dark *){border-color:#82181a80}@supports (color:color-mix(in lab, red, red)){.dark\:border-red-900\/50:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-red-900) 50%, transparent)}}.dark\:border-red-900\/70:where(.dark,.dark *){border-color:#82181ab3}@supports (color:color-mix(in lab, red, red)){.dark\:border-red-900\/70:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-red-900) 70%, transparent)}}.dark\:border-sky-700\/45:where(.dark,.dark *){border-color:#0069a473}@supports (color:color-mix(in lab, red, red)){.dark\:border-sky-700\/45:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-sky-700) 45%, transparent)}}.dark\:border-sky-800:where(.dark,.dark *){border-color:var(--color-sky-800)}.dark\:border-sky-800\/50:where(.dark,.dark *){border-color:#00598680}@supports (color:color-mix(in lab, red, red)){.dark\:border-sky-800\/50:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-sky-800) 50%, transparent)}}.dark\:border-sky-800\/55:where(.dark,.dark *){border-color:#0059868c}@supports (color:color-mix(in lab, red, red)){.dark\:border-sky-800\/55:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-sky-800) 55%, transparent)}}.dark\:border-teal-700\!:where(.dark,.dark *){border-color:var(--color-teal-700)!important}.dark\:border-teal-900\/30:where(.dark,.dark *){border-color:#0b4f4a4d}@supports (color:color-mix(in lab, red, red)){.dark\:border-teal-900\/30:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-teal-900) 30%, transparent)}}.dark\:border-violet-800:where(.dark,.dark *){border-color:var(--color-violet-800)}.dark\:border-violet-800\/50:where(.dark,.dark *){border-color:#5d0ec080}@supports (color:color-mix(in lab, red, red)){.dark\:border-violet-800\/50:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-violet-800) 50%, transparent)}}.dark\:border-violet-900\/30:where(.dark,.dark *){border-color:#4d179a4d}@supports (color:color-mix(in lab, red, red)){.dark\:border-violet-900\/30:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-violet-900) 30%, transparent)}}.dark\:border-violet-900\/60:where(.dark,.dark *){border-color:#4d179a99}@supports (color:color-mix(in lab, red, red)){.dark\:border-violet-900\/60:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-violet-900) 60%, transparent)}}.dark\:border-white\/5:where(.dark,.dark *){border-color:#ffffff0d}@supports (color:color-mix(in lab, red, red)){.dark\:border-white\/5:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-white) 5%, transparent)}}.dark\:border-white\/10:where(.dark,.dark *){border-color:#ffffff1a}@supports (color:color-mix(in lab, red, red)){.dark\:border-white\/10:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-white) 10%, transparent)}}.dark\:border-yellow-700:where(.dark,.dark *){border-color:var(--color-yellow-700)}.dark\:border-yellow-800:where(.dark,.dark *){border-color:var(--color-yellow-800)}.dark\:border-zinc-600:where(.dark,.dark *){border-color:var(--color-zinc-600)}.dark\:border-zinc-600\/90:where(.dark,.dark *){border-color:#52525ce6}@supports (color:color-mix(in lab, red, red)){.dark\:border-zinc-600\/90:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-zinc-600) 90%, transparent)}}.dark\:border-zinc-700:where(.dark,.dark *){border-color:var(--color-zinc-700)}.dark\:border-zinc-700\/50:where(.dark,.dark *){border-color:#3f3f4680}@supports (color:color-mix(in lab, red, red)){.dark\:border-zinc-700\/50:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-zinc-700) 50%, transparent)}}.dark\:border-zinc-700\/70:where(.dark,.dark *){border-color:#3f3f46b3}@supports (color:color-mix(in lab, red, red)){.dark\:border-zinc-700\/70:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-zinc-700) 70%, transparent)}}.dark\:border-zinc-700\/80:where(.dark,.dark *){border-color:#3f3f46cc}@supports (color:color-mix(in lab, red, red)){.dark\:border-zinc-700\/80:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-zinc-700) 80%, transparent)}}.dark\:border-zinc-700\/90:where(.dark,.dark *){border-color:#3f3f46e6}@supports (color:color-mix(in lab, red, red)){.dark\:border-zinc-700\/90:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-zinc-700) 90%, transparent)}}.dark\:border-zinc-800:where(.dark,.dark *){border-color:var(--color-zinc-800)}.dark\:border-zinc-800\!:where(.dark,.dark *){border-color:var(--color-zinc-800)!important}.dark\:border-zinc-800\/50:where(.dark,.dark *){border-color:#27272a80}@supports (color:color-mix(in lab, red, red)){.dark\:border-zinc-800\/50:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-zinc-800) 50%, transparent)}}.dark\:border-zinc-800\/60:where(.dark,.dark *){border-color:#27272a99}@supports (color:color-mix(in lab, red, red)){.dark\:border-zinc-800\/60:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-zinc-800) 60%, transparent)}}.dark\:border-zinc-800\/80:where(.dark,.dark *){border-color:#27272acc}@supports (color:color-mix(in lab, red, red)){.dark\:border-zinc-800\/80:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-zinc-800) 80%, transparent)}}.dark\:border-zinc-900:where(.dark,.dark *){border-color:var(--color-zinc-900)}.dark\:border-l-zinc-700:where(.dark,.dark *){border-left-color:var(--color-zinc-700)}.dark\:bg-amber-700:where(.dark,.dark *){background-color:var(--color-amber-700)}.dark\:bg-amber-900\/10:where(.dark,.dark *){background-color:#7b33061a}@supports (color:color-mix(in lab, red, red)){.dark\:bg-amber-900\/10:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-amber-900) 10%, transparent)}}.dark\:bg-amber-900\/15:where(.dark,.dark *){background-color:#7b330626}@supports (color:color-mix(in lab, red, red)){.dark\:bg-amber-900\/15:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-amber-900) 15%, transparent)}}.dark\:bg-amber-900\/20:where(.dark,.dark *){background-color:#7b330633}@supports (color:color-mix(in lab, red, red)){.dark\:bg-amber-900\/20:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-amber-900) 20%, transparent)}}.dark\:bg-amber-900\/30:where(.dark,.dark *){background-color:#7b33064d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-amber-900\/30:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-amber-900) 30%, transparent)}}.dark\:bg-amber-900\/40:where(.dark,.dark *){background-color:#7b330666}@supports (color:color-mix(in lab, red, red)){.dark\:bg-amber-900\/40:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-amber-900) 40%, transparent)}}.dark\:bg-amber-900\/45:where(.dark,.dark *){background-color:#7b330673}@supports (color:color-mix(in lab, red, red)){.dark\:bg-amber-900\/45:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-amber-900) 45%, transparent)}}.dark\:bg-amber-950\/20:where(.dark,.dark *){background-color:#46190133}@supports (color:color-mix(in lab, red, red)){.dark\:bg-amber-950\/20:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-amber-950) 20%, transparent)}}.dark\:bg-amber-950\/25:where(.dark,.dark *){background-color:#46190140}@supports (color:color-mix(in lab, red, red)){.dark\:bg-amber-950\/25:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-amber-950) 25%, transparent)}}.dark\:bg-amber-950\/30:where(.dark,.dark *){background-color:#4619014d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-amber-950\/30:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-amber-950) 30%, transparent)}}.dark\:bg-amber-950\/40:where(.dark,.dark *){background-color:#46190166}@supports (color:color-mix(in lab, red, red)){.dark\:bg-amber-950\/40:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-amber-950) 40%, transparent)}}.dark\:bg-amber-950\/50:where(.dark,.dark *){background-color:#46190180}@supports (color:color-mix(in lab, red, red)){.dark\:bg-amber-950\/50:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-amber-950) 50%, transparent)}}.dark\:bg-black:where(.dark,.dark *){background-color:var(--color-black)}.dark\:bg-black\/20:where(.dark,.dark *){background-color:#0003}@supports (color:color-mix(in lab, red, red)){.dark\:bg-black\/20:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-black) 20%, transparent)}}.dark\:bg-black\/30:where(.dark,.dark *){background-color:#0000004d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-black\/30:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-black) 30%, transparent)}}.dark\:bg-black\/50:where(.dark,.dark *){background-color:#00000080}@supports (color:color-mix(in lab, red, red)){.dark\:bg-black\/50:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-black) 50%, transparent)}}.dark\:bg-black\/55:where(.dark,.dark *){background-color:#0000008c}@supports (color:color-mix(in lab, red, red)){.dark\:bg-black\/55:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-black) 55%, transparent)}}.dark\:bg-black\/60:where(.dark,.dark *){background-color:#0009}@supports (color:color-mix(in lab, red, red)){.dark\:bg-black\/60:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-black) 60%, transparent)}}.dark\:bg-black\/70:where(.dark,.dark *){background-color:#000000b3}@supports (color:color-mix(in lab, red, red)){.dark\:bg-black\/70:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-black) 70%, transparent)}}.dark\:bg-black\/90:where(.dark,.dark *){background-color:#000000e6}@supports (color:color-mix(in lab, red, red)){.dark\:bg-black\/90:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-black) 90%, transparent)}}.dark\:bg-blue-400:where(.dark,.dark *){background-color:var(--color-blue-400)}.dark\:bg-blue-400\/8:where(.dark,.dark *){background-color:#54a2ff14}@supports (color:color-mix(in lab, red, red)){.dark\:bg-blue-400\/8:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-blue-400) 8%, transparent)}}.dark\:bg-blue-500:where(.dark,.dark *){background-color:var(--color-blue-500)}.dark\:bg-blue-500\/10:where(.dark,.dark *){background-color:#3080ff1a}@supports (color:color-mix(in lab, red, red)){.dark\:bg-blue-500\/10:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-blue-500) 10%, transparent)}}.dark\:bg-blue-500\/20:where(.dark,.dark *){background-color:#3080ff33}@supports (color:color-mix(in lab, red, red)){.dark\:bg-blue-500\/20:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-blue-500) 20%, transparent)}}.dark\:bg-blue-600:where(.dark,.dark *){background-color:var(--color-blue-600)}.dark\:bg-blue-700:where(.dark,.dark *){background-color:var(--color-blue-700)}.dark\:bg-blue-900\/5:where(.dark,.dark *){background-color:#1c398e0d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-blue-900\/5:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-blue-900) 5%, transparent)}}.dark\:bg-blue-900\/10:where(.dark,.dark *){background-color:#1c398e1a}@supports (color:color-mix(in lab, red, red)){.dark\:bg-blue-900\/10:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-blue-900) 10%, transparent)}}.dark\:bg-blue-900\/20:where(.dark,.dark *){background-color:#1c398e33}@supports (color:color-mix(in lab, red, red)){.dark\:bg-blue-900\/20:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-blue-900) 20%, transparent)}}.dark\:bg-blue-900\/30:where(.dark,.dark *){background-color:#1c398e4d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-blue-900\/30:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-blue-900) 30%, transparent)}}.dark\:bg-blue-900\/40:where(.dark,.dark *){background-color:#1c398e66}@supports (color:color-mix(in lab, red, red)){.dark\:bg-blue-900\/40:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-blue-900) 40%, transparent)}}.dark\:bg-blue-900\/50:where(.dark,.dark *){background-color:#1c398e80}@supports (color:color-mix(in lab, red, red)){.dark\:bg-blue-900\/50:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-blue-900) 50%, transparent)}}.dark\:bg-blue-950\/20:where(.dark,.dark *){background-color:#16245633}@supports (color:color-mix(in lab, red, red)){.dark\:bg-blue-950\/20:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-blue-950) 20%, transparent)}}.dark\:bg-blue-950\/30:where(.dark,.dark *){background-color:#1624564d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-blue-950\/30:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-blue-950) 30%, transparent)}}.dark\:bg-blue-950\/40:where(.dark,.dark *){background-color:#16245666}@supports (color:color-mix(in lab, red, red)){.dark\:bg-blue-950\/40:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-blue-950) 40%, transparent)}}.dark\:bg-blue-950\/60:where(.dark,.dark *){background-color:#16245699}@supports (color:color-mix(in lab, red, red)){.dark\:bg-blue-950\/60:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-blue-950) 60%, transparent)}}.dark\:bg-cyan-900\/30:where(.dark,.dark *){background-color:#104e644d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-cyan-900\/30:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-cyan-900) 30%, transparent)}}.dark\:bg-cyan-950\/40:where(.dark,.dark *){background-color:#05334566}@supports (color:color-mix(in lab, red, red)){.dark\:bg-cyan-950\/40:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-cyan-950) 40%, transparent)}}.dark\:bg-emerald-500\/10:where(.dark,.dark *){background-color:#00bb7f1a}@supports (color:color-mix(in lab, red, red)){.dark\:bg-emerald-500\/10:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-emerald-500) 10%, transparent)}}.dark\:bg-emerald-900\/5:where(.dark,.dark *){background-color:#004e3b0d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-emerald-900\/5:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-emerald-900) 5%, transparent)}}.dark\:bg-emerald-900\/10:where(.dark,.dark *){background-color:#004e3b1a}@supports (color:color-mix(in lab, red, red)){.dark\:bg-emerald-900\/10:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-emerald-900) 10%, transparent)}}.dark\:bg-emerald-900\/20:where(.dark,.dark *){background-color:#004e3b33}@supports (color:color-mix(in lab, red, red)){.dark\:bg-emerald-900\/20:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-emerald-900) 20%, transparent)}}.dark\:bg-emerald-900\/30:where(.dark,.dark *){background-color:#004e3b4d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-emerald-900\/30:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-emerald-900) 30%, transparent)}}.dark\:bg-emerald-900\/40:where(.dark,.dark *){background-color:#004e3b66}@supports (color:color-mix(in lab, red, red)){.dark\:bg-emerald-900\/40:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-emerald-900) 40%, transparent)}}.dark\:bg-emerald-900\/45:where(.dark,.dark *){background-color:#004e3b73}@supports (color:color-mix(in lab, red, red)){.dark\:bg-emerald-900\/45:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-emerald-900) 45%, transparent)}}.dark\:bg-emerald-900\/50:where(.dark,.dark *){background-color:#004e3b80}@supports (color:color-mix(in lab, red, red)){.dark\:bg-emerald-900\/50:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-emerald-900) 50%, transparent)}}.dark\:bg-emerald-950\/20:where(.dark,.dark *){background-color:#002c2233}@supports (color:color-mix(in lab, red, red)){.dark\:bg-emerald-950\/20:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-emerald-950) 20%, transparent)}}.dark\:bg-gray-500:where(.dark,.dark *){background-color:var(--color-gray-500)}.dark\:bg-gray-800:where(.dark,.dark *){background-color:var(--color-gray-800)}.dark\:bg-gray-900:where(.dark,.dark *){background-color:var(--color-gray-900)}.dark\:bg-gray-900\/40:where(.dark,.dark *){background-color:#10182866}@supports (color:color-mix(in lab, red, red)){.dark\:bg-gray-900\/40:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-gray-900) 40%, transparent)}}.dark\:bg-green-900\/15:where(.dark,.dark *){background-color:#0d542b26}@supports (color:color-mix(in lab, red, red)){.dark\:bg-green-900\/15:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-green-900) 15%, transparent)}}.dark\:bg-green-900\/20:where(.dark,.dark *){background-color:#0d542b33}@supports (color:color-mix(in lab, red, red)){.dark\:bg-green-900\/20:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-green-900) 20%, transparent)}}.dark\:bg-green-900\/30:where(.dark,.dark *){background-color:#0d542b4d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-green-900\/30:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-green-900) 30%, transparent)}}.dark\:bg-green-900\/40:where(.dark,.dark *){background-color:#0d542b66}@supports (color:color-mix(in lab, red, red)){.dark\:bg-green-900\/40:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-green-900) 40%, transparent)}}.dark\:bg-green-900\/45:where(.dark,.dark *){background-color:#0d542b73}@supports (color:color-mix(in lab, red, red)){.dark\:bg-green-900\/45:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-green-900) 45%, transparent)}}.dark\:bg-indigo-500\/10:where(.dark,.dark *){background-color:#625fff1a}@supports (color:color-mix(in lab, red, red)){.dark\:bg-indigo-500\/10:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-indigo-500) 10%, transparent)}}.dark\:bg-indigo-900\/10:where(.dark,.dark *){background-color:#312c851a}@supports (color:color-mix(in lab, red, red)){.dark\:bg-indigo-900\/10:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-indigo-900) 10%, transparent)}}.dark\:bg-indigo-900\/20:where(.dark,.dark *){background-color:#312c8533}@supports (color:color-mix(in lab, red, red)){.dark\:bg-indigo-900\/20:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-indigo-900) 20%, transparent)}}.dark\:bg-indigo-900\/30:where(.dark,.dark *){background-color:#312c854d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-indigo-900\/30:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-indigo-900) 30%, transparent)}}.dark\:bg-indigo-900\/35:where(.dark,.dark *){background-color:#312c8559}@supports (color:color-mix(in lab, red, red)){.dark\:bg-indigo-900\/35:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-indigo-900) 35%, transparent)}}.dark\:bg-indigo-950\/25:where(.dark,.dark *){background-color:#1e1a4d40}@supports (color:color-mix(in lab, red, red)){.dark\:bg-indigo-950\/25:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-indigo-950) 25%, transparent)}}.dark\:bg-indigo-950\/40:where(.dark,.dark *){background-color:#1e1a4d66}@supports (color:color-mix(in lab, red, red)){.dark\:bg-indigo-950\/40:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-indigo-950) 40%, transparent)}}.dark\:bg-orange-900\/10:where(.dark,.dark *){background-color:#7e2a0c1a}@supports (color:color-mix(in lab, red, red)){.dark\:bg-orange-900\/10:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-orange-900) 10%, transparent)}}.dark\:bg-orange-900\/30:where(.dark,.dark *){background-color:#7e2a0c4d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-orange-900\/30:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-orange-900) 30%, transparent)}}.dark\:bg-orange-900\/50:where(.dark,.dark *){background-color:#7e2a0c80}@supports (color:color-mix(in lab, red, red)){.dark\:bg-orange-900\/50:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-orange-900) 50%, transparent)}}.dark\:bg-pink-900\/10:where(.dark,.dark *){background-color:#8610431a}@supports (color:color-mix(in lab, red, red)){.dark\:bg-pink-900\/10:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-pink-900) 10%, transparent)}}.dark\:bg-purple-900\/30:where(.dark,.dark *){background-color:#59168b4d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-purple-900\/30:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-purple-900) 30%, transparent)}}.dark\:bg-red-400:where(.dark,.dark *){background-color:var(--color-red-400)}.dark\:bg-red-700:where(.dark,.dark *){background-color:var(--color-red-700)}.dark\:bg-red-900:where(.dark,.dark *){background-color:var(--color-red-900)}.dark\:bg-red-900\/10:where(.dark,.dark *){background-color:#82181a1a}@supports (color:color-mix(in lab, red, red)){.dark\:bg-red-900\/10:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-red-900) 10%, transparent)}}.dark\:bg-red-900\/20:where(.dark,.dark *){background-color:#82181a33}@supports (color:color-mix(in lab, red, red)){.dark\:bg-red-900\/20:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-red-900) 20%, transparent)}}.dark\:bg-red-900\/30:where(.dark,.dark *){background-color:#82181a4d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-red-900\/30:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-red-900) 30%, transparent)}}.dark\:bg-red-900\/40:where(.dark,.dark *){background-color:#82181a66}@supports (color:color-mix(in lab, red, red)){.dark\:bg-red-900\/40:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-red-900) 40%, transparent)}}.dark\:bg-red-900\/45:where(.dark,.dark *){background-color:#82181a73}@supports (color:color-mix(in lab, red, red)){.dark\:bg-red-900\/45:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-red-900) 45%, transparent)}}.dark\:bg-red-950\/20:where(.dark,.dark *){background-color:#46080933}@supports (color:color-mix(in lab, red, red)){.dark\:bg-red-950\/20:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-red-950) 20%, transparent)}}.dark\:bg-red-950\/30:where(.dark,.dark *){background-color:#4608094d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-red-950\/30:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-red-950) 30%, transparent)}}.dark\:bg-red-950\/40:where(.dark,.dark *){background-color:#46080966}@supports (color:color-mix(in lab, red, red)){.dark\:bg-red-950\/40:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-red-950) 40%, transparent)}}.dark\:bg-red-950\/50:where(.dark,.dark *){background-color:#46080980}@supports (color:color-mix(in lab, red, red)){.dark\:bg-red-950\/50:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-red-950) 50%, transparent)}}.dark\:bg-rose-900\/30:where(.dark,.dark *){background-color:#8b08364d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-rose-900\/30:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-rose-900) 30%, transparent)}}.dark\:bg-sky-900\/30:where(.dark,.dark *){background-color:#024a704d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-sky-900\/30:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-sky-900) 30%, transparent)}}.dark\:bg-sky-950\/30:where(.dark,.dark *){background-color:#052f4a4d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-sky-950\/30:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-sky-950) 30%, transparent)}}.dark\:bg-sky-950\/35:where(.dark,.dark *){background-color:#052f4a59}@supports (color:color-mix(in lab, red, red)){.dark\:bg-sky-950\/35:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-sky-950) 35%, transparent)}}.dark\:bg-sky-950\/45:where(.dark,.dark *){background-color:#052f4a73}@supports (color:color-mix(in lab, red, red)){.dark\:bg-sky-950\/45:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-sky-950) 45%, transparent)}}.dark\:bg-teal-900\/10:where(.dark,.dark *){background-color:#0b4f4a1a}@supports (color:color-mix(in lab, red, red)){.dark\:bg-teal-900\/10:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-teal-900) 10%, transparent)}}.dark\:bg-teal-900\/30:where(.dark,.dark *){background-color:#0b4f4a4d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-teal-900\/30:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-teal-900) 30%, transparent)}}.dark\:bg-transparent:where(.dark,.dark *){background-color:#0000}.dark\:bg-violet-900\/10:where(.dark,.dark *){background-color:#4d179a1a}@supports (color:color-mix(in lab, red, red)){.dark\:bg-violet-900\/10:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-violet-900) 10%, transparent)}}.dark\:bg-violet-900\/30:where(.dark,.dark *){background-color:#4d179a4d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-violet-900\/30:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-violet-900) 30%, transparent)}}.dark\:bg-violet-900\/40:where(.dark,.dark *){background-color:#4d179a66}@supports (color:color-mix(in lab, red, red)){.dark\:bg-violet-900\/40:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-violet-900) 40%, transparent)}}.dark\:bg-violet-950\/30:where(.dark,.dark *){background-color:#2f0d684d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-violet-950\/30:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-violet-950) 30%, transparent)}}.dark\:bg-white:where(.dark,.dark *){background-color:var(--color-white)}.dark\:bg-white\/5:where(.dark,.dark *){background-color:#ffffff0d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-white\/5:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-white) 5%, transparent)}}.dark\:bg-white\/10:where(.dark,.dark *){background-color:#ffffff1a}@supports (color:color-mix(in lab, red, red)){.dark\:bg-white\/10:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-white) 10%, transparent)}}.dark\:bg-white\/\[0\.05\]:where(.dark,.dark *){background-color:#ffffff0d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-white\/\[0\.05\]:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-white) 5%, transparent)}}.dark\:bg-yellow-900\/20:where(.dark,.dark *){background-color:#733e0a33}@supports (color:color-mix(in lab, red, red)){.dark\:bg-yellow-900\/20:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-yellow-900) 20%, transparent)}}.dark\:bg-yellow-900\/30:where(.dark,.dark *){background-color:#733e0a4d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-yellow-900\/30:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-yellow-900) 30%, transparent)}}.dark\:bg-zinc-100:where(.dark,.dark *){background-color:var(--color-zinc-100)}.dark\:bg-zinc-600\/70:where(.dark,.dark *){background-color:#52525cb3}@supports (color:color-mix(in lab, red, red)){.dark\:bg-zinc-600\/70:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-zinc-600) 70%, transparent)}}.dark\:bg-zinc-700:where(.dark,.dark *){background-color:var(--color-zinc-700)}.dark\:bg-zinc-700\/50:where(.dark,.dark *){background-color:#3f3f4680}@supports (color:color-mix(in lab, red, red)){.dark\:bg-zinc-700\/50:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-zinc-700) 50%, transparent)}}.dark\:bg-zinc-700\/90:where(.dark,.dark *){background-color:#3f3f46e6}@supports (color:color-mix(in lab, red, red)){.dark\:bg-zinc-700\/90:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-zinc-700) 90%, transparent)}}.dark\:bg-zinc-800:where(.dark,.dark *){background-color:var(--color-zinc-800)}.dark\:bg-zinc-800\/10:where(.dark,.dark *){background-color:#27272a1a}@supports (color:color-mix(in lab, red, red)){.dark\:bg-zinc-800\/10:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-zinc-800) 10%, transparent)}}.dark\:bg-zinc-800\/30:where(.dark,.dark *){background-color:#27272a4d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-zinc-800\/30:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-zinc-800) 30%, transparent)}}.dark\:bg-zinc-800\/40:where(.dark,.dark *){background-color:#27272a66}@supports (color:color-mix(in lab, red, red)){.dark\:bg-zinc-800\/40:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-zinc-800) 40%, transparent)}}.dark\:bg-zinc-800\/50:where(.dark,.dark *){background-color:#27272a80}@supports (color:color-mix(in lab, red, red)){.dark\:bg-zinc-800\/50:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-zinc-800) 50%, transparent)}}.dark\:bg-zinc-800\/60:where(.dark,.dark *){background-color:#27272a99}@supports (color:color-mix(in lab, red, red)){.dark\:bg-zinc-800\/60:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-zinc-800) 60%, transparent)}}.dark\:bg-zinc-800\/70:where(.dark,.dark *){background-color:#27272ab3}@supports (color:color-mix(in lab, red, red)){.dark\:bg-zinc-800\/70:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-zinc-800) 70%, transparent)}}.dark\:bg-zinc-800\/80:where(.dark,.dark *){background-color:#27272acc}@supports (color:color-mix(in lab, red, red)){.dark\:bg-zinc-800\/80:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-zinc-800) 80%, transparent)}}.dark\:bg-zinc-800\/90:where(.dark,.dark *){background-color:#27272ae6}@supports (color:color-mix(in lab, red, red)){.dark\:bg-zinc-800\/90:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-zinc-800) 90%, transparent)}}.dark\:bg-zinc-900:where(.dark,.dark *){background-color:var(--color-zinc-900)}.dark\:bg-zinc-900\/10:where(.dark,.dark *){background-color:#18181b1a}@supports (color:color-mix(in lab, red, red)){.dark\:bg-zinc-900\/10:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-zinc-900) 10%, transparent)}}.dark\:bg-zinc-900\/20:where(.dark,.dark *){background-color:#18181b33}@supports (color:color-mix(in lab, red, red)){.dark\:bg-zinc-900\/20:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-zinc-900) 20%, transparent)}}.dark\:bg-zinc-900\/25:where(.dark,.dark *){background-color:#18181b40}@supports (color:color-mix(in lab, red, red)){.dark\:bg-zinc-900\/25:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-zinc-900) 25%, transparent)}}.dark\:bg-zinc-900\/30:where(.dark,.dark *){background-color:#18181b4d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-zinc-900\/30:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-zinc-900) 30%, transparent)}}.dark\:bg-zinc-900\/40:where(.dark,.dark *){background-color:#18181b66}@supports (color:color-mix(in lab, red, red)){.dark\:bg-zinc-900\/40:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-zinc-900) 40%, transparent)}}.dark\:bg-zinc-900\/50:where(.dark,.dark *){background-color:#18181b80}@supports (color:color-mix(in lab, red, red)){.dark\:bg-zinc-900\/50:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-zinc-900) 50%, transparent)}}.dark\:bg-zinc-900\/60:where(.dark,.dark *){background-color:#18181b99}@supports (color:color-mix(in lab, red, red)){.dark\:bg-zinc-900\/60:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-zinc-900) 60%, transparent)}}.dark\:bg-zinc-900\/70:where(.dark,.dark *){background-color:#18181bb3}@supports (color:color-mix(in lab, red, red)){.dark\:bg-zinc-900\/70:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-zinc-900) 70%, transparent)}}.dark\:bg-zinc-900\/80:where(.dark,.dark *){background-color:#18181bcc}@supports (color:color-mix(in lab, red, red)){.dark\:bg-zinc-900\/80:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-zinc-900) 80%, transparent)}}.dark\:bg-zinc-900\/85:where(.dark,.dark *){background-color:#18181bd9}@supports (color:color-mix(in lab, red, red)){.dark\:bg-zinc-900\/85:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-zinc-900) 85%, transparent)}}.dark\:bg-zinc-900\/90:where(.dark,.dark *){background-color:#18181be6}@supports (color:color-mix(in lab, red, red)){.dark\:bg-zinc-900\/90:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-zinc-900) 90%, transparent)}}.dark\:bg-zinc-900\/95:where(.dark,.dark *){background-color:#18181bf2}@supports (color:color-mix(in lab, red, red)){.dark\:bg-zinc-900\/95:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-zinc-900) 95%, transparent)}}.dark\:bg-zinc-950:where(.dark,.dark *){background-color:var(--color-zinc-950)}.dark\:bg-zinc-950\!:where(.dark,.dark *){background-color:var(--color-zinc-950)!important}.dark\:bg-zinc-950\/40:where(.dark,.dark *){background-color:#09090b66}@supports (color:color-mix(in lab, red, red)){.dark\:bg-zinc-950\/40:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-zinc-950) 40%, transparent)}}.dark\:bg-zinc-950\/50:where(.dark,.dark *){background-color:#09090b80}@supports (color:color-mix(in lab, red, red)){.dark\:bg-zinc-950\/50:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-zinc-950) 50%, transparent)}}.dark\:bg-zinc-950\/80:where(.dark,.dark *){background-color:#09090bcc}@supports (color:color-mix(in lab, red, red)){.dark\:bg-zinc-950\/80:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-zinc-950) 80%, transparent)}}.dark\:bg-zinc-950\/95:where(.dark,.dark *){background-color:#09090bf2}@supports (color:color-mix(in lab, red, red)){.dark\:bg-zinc-950\/95:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-zinc-950) 95%, transparent)}}.dark\:from-zinc-950:where(.dark,.dark *){--tw-gradient-from:var(--color-zinc-950);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.dark\:via-zinc-800:where(.dark,.dark *){--tw-gradient-via:var(--color-zinc-800);--tw-gradient-via-stops:var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-via) var(--tw-gradient-via-position), var(--tw-gradient-to) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-via-stops)}.dark\:to-zinc-900:where(.dark,.dark *){--tw-gradient-to:var(--color-zinc-900);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.dark\:fill-zinc-900:where(.dark,.dark *){fill:var(--color-zinc-900)}.dark\:stroke-zinc-900:where(.dark,.dark *){stroke:var(--color-zinc-900)}.dark\:text-amber-100:where(.dark,.dark *){color:var(--color-amber-100)}.dark\:text-amber-100\/80:where(.dark,.dark *){color:#fef3c6cc}@supports (color:color-mix(in lab, red, red)){.dark\:text-amber-100\/80:where(.dark,.dark *){color:color-mix(in oklab, var(--color-amber-100) 80%, transparent)}}.dark\:text-amber-100\/90:where(.dark,.dark *){color:#fef3c6e6}@supports (color:color-mix(in lab, red, red)){.dark\:text-amber-100\/90:where(.dark,.dark *){color:color-mix(in oklab, var(--color-amber-100) 90%, transparent)}}.dark\:text-amber-200:where(.dark,.dark *){color:var(--color-amber-200)}.dark\:text-amber-200\/70:where(.dark,.dark *){color:#fee685b3}@supports (color:color-mix(in lab, red, red)){.dark\:text-amber-200\/70:where(.dark,.dark *){color:color-mix(in oklab, var(--color-amber-200) 70%, transparent)}}.dark\:text-amber-200\/80:where(.dark,.dark *){color:#fee685cc}@supports (color:color-mix(in lab, red, red)){.dark\:text-amber-200\/80:where(.dark,.dark *){color:color-mix(in oklab, var(--color-amber-200) 80%, transparent)}}.dark\:text-amber-200\/90:where(.dark,.dark *){color:#fee685e6}@supports (color:color-mix(in lab, red, red)){.dark\:text-amber-200\/90:where(.dark,.dark *){color:color-mix(in oklab, var(--color-amber-200) 90%, transparent)}}.dark\:text-amber-300:where(.dark,.dark *){color:var(--color-amber-300)}.dark\:text-amber-300\/90:where(.dark,.dark *){color:#ffd236e6}@supports (color:color-mix(in lab, red, red)){.dark\:text-amber-300\/90:where(.dark,.dark *){color:color-mix(in oklab, var(--color-amber-300) 90%, transparent)}}.dark\:text-amber-400:where(.dark,.dark *){color:var(--color-amber-400)}.dark\:text-amber-500:where(.dark,.dark *){color:var(--color-amber-500)}.dark\:text-blue-100:where(.dark,.dark *){color:var(--color-blue-100)}.dark\:text-blue-100\/90:where(.dark,.dark *){color:#dbeafee6}@supports (color:color-mix(in lab, red, red)){.dark\:text-blue-100\/90:where(.dark,.dark *){color:color-mix(in oklab, var(--color-blue-100) 90%, transparent)}}.dark\:text-blue-200:where(.dark,.dark *){color:var(--color-blue-200)}.dark\:text-blue-300:where(.dark,.dark *){color:var(--color-blue-300)}.dark\:text-blue-300\/80:where(.dark,.dark *){color:#90c5ffcc}@supports (color:color-mix(in lab, red, red)){.dark\:text-blue-300\/80:where(.dark,.dark *){color:color-mix(in oklab, var(--color-blue-300) 80%, transparent)}}.dark\:text-blue-400:where(.dark,.dark *){color:var(--color-blue-400)}.dark\:text-blue-400\/80:where(.dark,.dark *){color:#54a2ffcc}@supports (color:color-mix(in lab, red, red)){.dark\:text-blue-400\/80:where(.dark,.dark *){color:color-mix(in oklab, var(--color-blue-400) 80%, transparent)}}.dark\:text-cyan-200:where(.dark,.dark *){color:var(--color-cyan-200)}.dark\:text-cyan-300:where(.dark,.dark *){color:var(--color-cyan-300)}.dark\:text-cyan-400:where(.dark,.dark *){color:var(--color-cyan-400)}.dark\:text-emerald-100:where(.dark,.dark *){color:var(--color-emerald-100)}.dark\:text-emerald-200:where(.dark,.dark *){color:var(--color-emerald-200)}.dark\:text-emerald-300:where(.dark,.dark *){color:var(--color-emerald-300)}.dark\:text-emerald-300\/80:where(.dark,.dark *){color:#5ee9b5cc}@supports (color:color-mix(in lab, red, red)){.dark\:text-emerald-300\/80:where(.dark,.dark *){color:color-mix(in oklab, var(--color-emerald-300) 80%, transparent)}}.dark\:text-emerald-400:where(.dark,.dark *){color:var(--color-emerald-400)}.dark\:text-emerald-500:where(.dark,.dark *){color:var(--color-emerald-500)}.dark\:text-gray-100:where(.dark,.dark *){color:var(--color-gray-100)}.dark\:text-gray-200:where(.dark,.dark *){color:var(--color-gray-200)}.dark\:text-gray-300:where(.dark,.dark *){color:var(--color-gray-300)}.dark\:text-gray-400:where(.dark,.dark *){color:var(--color-gray-400)}.dark\:text-gray-500:where(.dark,.dark *){color:var(--color-gray-500)}.dark\:text-gray-600:where(.dark,.dark *){color:var(--color-gray-600)}.dark\:text-green-100:where(.dark,.dark *){color:var(--color-green-100)}.dark\:text-green-200:where(.dark,.dark *){color:var(--color-green-200)}.dark\:text-green-300:where(.dark,.dark *){color:var(--color-green-300)}.dark\:text-green-400:where(.dark,.dark *){color:var(--color-green-400)}.dark\:text-indigo-100:where(.dark,.dark *){color:var(--color-indigo-100)}.dark\:text-indigo-200:where(.dark,.dark *){color:var(--color-indigo-200)}.dark\:text-indigo-200\/90:where(.dark,.dark *){color:#c7d2ffe6}@supports (color:color-mix(in lab, red, red)){.dark\:text-indigo-200\/90:where(.dark,.dark *){color:color-mix(in oklab, var(--color-indigo-200) 90%, transparent)}}.dark\:text-indigo-300:where(.dark,.dark *){color:var(--color-indigo-300)}.dark\:text-indigo-300\/90:where(.dark,.dark *){color:#a4b3ffe6}@supports (color:color-mix(in lab, red, red)){.dark\:text-indigo-300\/90:where(.dark,.dark *){color:color-mix(in oklab, var(--color-indigo-300) 90%, transparent)}}.dark\:text-indigo-400:where(.dark,.dark *){color:var(--color-indigo-400)}.dark\:text-orange-200:where(.dark,.dark *){color:var(--color-orange-200)}.dark\:text-orange-300:where(.dark,.dark *){color:var(--color-orange-300)}.dark\:text-orange-400:where(.dark,.dark *){color:var(--color-orange-400)}.dark\:text-pink-300:where(.dark,.dark *){color:var(--color-pink-300)}.dark\:text-pink-400:where(.dark,.dark *){color:var(--color-pink-400)}.dark\:text-purple-200:where(.dark,.dark *){color:var(--color-purple-200)}.dark\:text-purple-400:where(.dark,.dark *){color:var(--color-purple-400)}.dark\:text-red-100:where(.dark,.dark *){color:var(--color-red-100)}.dark\:text-red-100\/90:where(.dark,.dark *){color:#ffe2e2e6}@supports (color:color-mix(in lab, red, red)){.dark\:text-red-100\/90:where(.dark,.dark *){color:color-mix(in oklab, var(--color-red-100) 90%, transparent)}}.dark\:text-red-200:where(.dark,.dark *){color:var(--color-red-200)}.dark\:text-red-300:where(.dark,.dark *){color:var(--color-red-300)}.dark\:text-red-300\!:where(.dark,.dark *){color:var(--color-red-300)!important}.dark\:text-red-400:where(.dark,.dark *){color:var(--color-red-400)}.dark\:text-rose-200:where(.dark,.dark *){color:var(--color-rose-200)}.dark\:text-rose-400:where(.dark,.dark *){color:var(--color-rose-400)}.dark\:text-sky-50:where(.dark,.dark *){color:var(--color-sky-50)}.dark\:text-sky-100:where(.dark,.dark *){color:var(--color-sky-100)}.dark\:text-sky-200:where(.dark,.dark *){color:var(--color-sky-200)}.dark\:text-sky-200\/75:where(.dark,.dark *){color:#b8e6febf}@supports (color:color-mix(in lab, red, red)){.dark\:text-sky-200\/75:where(.dark,.dark *){color:color-mix(in oklab, var(--color-sky-200) 75%, transparent)}}.dark\:text-sky-200\/85:where(.dark,.dark *){color:#b8e6fed9}@supports (color:color-mix(in lab, red, red)){.dark\:text-sky-200\/85:where(.dark,.dark *){color:color-mix(in oklab, var(--color-sky-200) 85%, transparent)}}.dark\:text-sky-200\/90:where(.dark,.dark *){color:#b8e6fee6}@supports (color:color-mix(in lab, red, red)){.dark\:text-sky-200\/90:where(.dark,.dark *){color:color-mix(in oklab, var(--color-sky-200) 90%, transparent)}}.dark\:text-sky-300:where(.dark,.dark *){color:var(--color-sky-300)}.dark\:text-sky-400:where(.dark,.dark *){color:var(--color-sky-400)}.dark\:text-sky-400\/80:where(.dark,.dark *){color:#00bcfecc}@supports (color:color-mix(in lab, red, red)){.dark\:text-sky-400\/80:where(.dark,.dark *){color:color-mix(in oklab, var(--color-sky-400) 80%, transparent)}}.dark\:text-teal-200:where(.dark,.dark *){color:var(--color-teal-200)}.dark\:text-teal-300:where(.dark,.dark *){color:var(--color-teal-300)}.dark\:text-teal-300\!:where(.dark,.dark *){color:var(--color-teal-300)!important}.dark\:text-teal-400:where(.dark,.dark *){color:var(--color-teal-400)}.dark\:text-violet-100:where(.dark,.dark *){color:var(--color-violet-100)}.dark\:text-violet-200:where(.dark,.dark *){color:var(--color-violet-200)}.dark\:text-violet-200\/90:where(.dark,.dark *){color:#ddd6ffe6}@supports (color:color-mix(in lab, red, red)){.dark\:text-violet-200\/90:where(.dark,.dark *){color:color-mix(in oklab, var(--color-violet-200) 90%, transparent)}}.dark\:text-violet-300:where(.dark,.dark *){color:var(--color-violet-300)}.dark\:text-violet-400:where(.dark,.dark *){color:var(--color-violet-400)}.dark\:text-white:where(.dark,.dark *){color:var(--color-white)}.dark\:text-yellow-100:where(.dark,.dark *){color:var(--color-yellow-100)}.dark\:text-yellow-200:where(.dark,.dark *){color:var(--color-yellow-200)}.dark\:text-yellow-300:where(.dark,.dark *){color:var(--color-yellow-300)}.dark\:text-yellow-400:where(.dark,.dark *){color:var(--color-yellow-400)}.dark\:text-zinc-100:where(.dark,.dark *){color:var(--color-zinc-100)}.dark\:text-zinc-200:where(.dark,.dark *){color:var(--color-zinc-200)}.dark\:text-zinc-300:where(.dark,.dark *){color:var(--color-zinc-300)}.dark\:text-zinc-400:where(.dark,.dark *){color:var(--color-zinc-400)}.dark\:text-zinc-500:where(.dark,.dark *){color:var(--color-zinc-500)}.dark\:text-zinc-600:where(.dark,.dark *){color:var(--color-zinc-600)}.dark\:text-zinc-700:where(.dark,.dark *){color:var(--color-zinc-700)}.dark\:text-zinc-700\/90:where(.dark,.dark *){color:#3f3f46e6}@supports (color:color-mix(in lab, red, red)){.dark\:text-zinc-700\/90:where(.dark,.dark *){color:color-mix(in oklab, var(--color-zinc-700) 90%, transparent)}}.dark\:text-zinc-800:where(.dark,.dark *){color:var(--color-zinc-800)}.dark\:text-zinc-900:where(.dark,.dark *){color:var(--color-zinc-900)}.dark\:placeholder-zinc-500:where(.dark,.dark *)::placeholder{color:var(--color-zinc-500)}.dark\:accent-blue-500:where(.dark,.dark *){accent-color:var(--color-blue-500)}.dark\:shadow-\[inset_2px_2px_6px_rgba\(0\,0\,0\,0\.72\)\,inset_-1px_-1px_1px_rgba\(255\,255\,255\,0\.07\)\]:where(.dark,.dark *){--tw-shadow:inset 2px 2px 6px var(--tw-shadow-color,#000000b8), inset -1px -1px 1px var(--tw-shadow-color,#ffffff12);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.dark\:shadow-black\/20:where(.dark,.dark *){--tw-shadow-color:#0003}@supports (color:color-mix(in lab, red, red)){.dark\:shadow-black\/20:where(.dark,.dark *){--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-black) 20%, transparent) var(--tw-shadow-alpha), transparent)}}.dark\:shadow-black\/30:where(.dark,.dark *){--tw-shadow-color:#0000004d}@supports (color:color-mix(in lab, red, red)){.dark\:shadow-black\/30:where(.dark,.dark *){--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-black) 30%, transparent) var(--tw-shadow-alpha), transparent)}}.dark\:shadow-black\/40:where(.dark,.dark *){--tw-shadow-color:#0006}@supports (color:color-mix(in lab, red, red)){.dark\:shadow-black\/40:where(.dark,.dark *){--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-black) 40%, transparent) var(--tw-shadow-alpha), transparent)}}.dark\:ring-blue-500\/50:where(.dark,.dark *){--tw-ring-color:#3080ff80}@supports (color:color-mix(in lab, red, red)){.dark\:ring-blue-500\/50:where(.dark,.dark *){--tw-ring-color:color-mix(in oklab, var(--color-blue-500) 50%, transparent)}}.dark\:ring-blue-600:where(.dark,.dark *){--tw-ring-color:var(--color-blue-600)}.dark\:ring-blue-700:where(.dark,.dark *){--tw-ring-color:var(--color-blue-700)}.dark\:ring-cyan-800\/60:where(.dark,.dark *){--tw-ring-color:#005f7899}@supports (color:color-mix(in lab, red, red)){.dark\:ring-cyan-800\/60:where(.dark,.dark *){--tw-ring-color:color-mix(in oklab, var(--color-cyan-800) 60%, transparent)}}.dark\:ring-indigo-500\/40:where(.dark,.dark *){--tw-ring-color:#625fff66}@supports (color:color-mix(in lab, red, red)){.dark\:ring-indigo-500\/40:where(.dark,.dark *){--tw-ring-color:color-mix(in oklab, var(--color-indigo-500) 40%, transparent)}}.dark\:ring-red-900\/60:where(.dark,.dark *){--tw-ring-color:#82181a99}@supports (color:color-mix(in lab, red, red)){.dark\:ring-red-900\/60:where(.dark,.dark *){--tw-ring-color:color-mix(in oklab, var(--color-red-900) 60%, transparent)}}.dark\:ring-white\/10:where(.dark,.dark *){--tw-ring-color:#ffffff1a}@supports (color:color-mix(in lab, red, red)){.dark\:ring-white\/10:where(.dark,.dark *){--tw-ring-color:color-mix(in oklab, var(--color-white) 10%, transparent)}}.dark\:ring-zinc-700:where(.dark,.dark *){--tw-ring-color:var(--color-zinc-700)}.dark\:ring-zinc-800:where(.dark,.dark *){--tw-ring-color:var(--color-zinc-800)}.dark\:ring-zinc-800\/90:where(.dark,.dark *){--tw-ring-color:#27272ae6}@supports (color:color-mix(in lab, red, red)){.dark\:ring-zinc-800\/90:where(.dark,.dark *){--tw-ring-color:color-mix(in oklab, var(--color-zinc-800) 90%, transparent)}}.dark\:ring-offset-zinc-900:where(.dark,.dark *){--tw-ring-offset-color:var(--color-zinc-900)}.dark\:ring-offset-zinc-950:where(.dark,.dark *){--tw-ring-offset-color:var(--color-zinc-950)}@media (hover:hover){.dark\:group-hover\:text-blue-400:where(.dark,.dark *):is(:where(.group):hover *){color:var(--color-blue-400)}.dark\:group-hover\:text-zinc-200:where(.dark,.dark *):is(:where(.group):hover *){color:var(--color-zinc-200)}.dark\:group-hover\:text-zinc-300:where(.dark,.dark *):is(:where(.group):hover *){color:var(--color-zinc-300)}}.dark\:peer-checked\:bg-blue-600:where(.dark,.dark *):is(:where(.peer):checked~*){background-color:var(--color-blue-600)}.dark\:peer-focus\:ring-blue-800:where(.dark,.dark *):is(:where(.peer):focus~*){--tw-ring-color:var(--color-blue-800)}.dark\:file\:bg-blue-600:where(.dark,.dark *)::file-selector-button{background-color:var(--color-blue-600)}.dark\:file\:bg-zinc-700:where(.dark,.dark *)::file-selector-button{background-color:var(--color-zinc-700)}.dark\:file\:text-zinc-200:where(.dark,.dark *)::file-selector-button{color:var(--color-zinc-200)}.dark\:placeholder\:text-zinc-500:where(.dark,.dark *)::placeholder{color:var(--color-zinc-500)}.dark\:open\:bg-zinc-950:where(.dark,.dark *):is([open],:popover-open,:open){background-color:var(--color-zinc-950)}@media (hover:hover){.dark\:hover\:border-amber-500\/60:where(.dark,.dark *):hover{border-color:#f99c0099}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:border-amber-500\/60:where(.dark,.dark *):hover{border-color:color-mix(in oklab, var(--color-amber-500) 60%, transparent)}}.dark\:hover\:border-amber-600:where(.dark,.dark *):hover{border-color:var(--color-amber-600)}.dark\:hover\:border-blue-500:where(.dark,.dark *):hover{border-color:var(--color-blue-500)}.dark\:hover\:border-blue-600:where(.dark,.dark *):hover{border-color:var(--color-blue-600)}.dark\:hover\:border-blue-800:where(.dark,.dark *):hover{border-color:var(--color-blue-800)}.dark\:hover\:border-blue-900\/30:where(.dark,.dark *):hover{border-color:#1c398e4d}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:border-blue-900\/30:where(.dark,.dark *):hover{border-color:color-mix(in oklab, var(--color-blue-900) 30%, transparent)}}.dark\:hover\:border-zinc-500:where(.dark,.dark *):hover{border-color:var(--color-zinc-500)}.dark\:hover\:border-zinc-600:where(.dark,.dark *):hover{border-color:var(--color-zinc-600)}.dark\:hover\:bg-amber-600:where(.dark,.dark *):hover{background-color:var(--color-amber-600)}.dark\:hover\:bg-amber-900\/20:where(.dark,.dark *):hover{background-color:#7b330633}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-amber-900\/20:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-amber-900) 20%, transparent)}}.dark\:hover\:bg-amber-900\/40:where(.dark,.dark *):hover{background-color:#7b330666}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-amber-900\/40:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-amber-900) 40%, transparent)}}.dark\:hover\:bg-amber-900\/50:where(.dark,.dark *):hover{background-color:#7b330680}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-amber-900\/50:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-amber-900) 50%, transparent)}}.dark\:hover\:bg-blue-500:where(.dark,.dark *):hover{background-color:var(--color-blue-500)}.dark\:hover\:bg-blue-500\/20:where(.dark,.dark *):hover{background-color:#3080ff33}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-blue-500\/20:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-blue-500) 20%, transparent)}}.dark\:hover\:bg-blue-600:where(.dark,.dark *):hover{background-color:var(--color-blue-600)}.dark\:hover\:bg-blue-700:where(.dark,.dark *):hover{background-color:var(--color-blue-700)}.dark\:hover\:bg-blue-800:where(.dark,.dark *):hover{background-color:var(--color-blue-800)}.dark\:hover\:bg-blue-900\/10:where(.dark,.dark *):hover{background-color:#1c398e1a}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-blue-900\/10:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-blue-900) 10%, transparent)}}.dark\:hover\:bg-blue-900\/20:where(.dark,.dark *):hover{background-color:#1c398e33}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-blue-900\/20:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-blue-900) 20%, transparent)}}.dark\:hover\:bg-blue-900\/40:where(.dark,.dark *):hover{background-color:#1c398e66}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-blue-900\/40:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-blue-900) 40%, transparent)}}.dark\:hover\:bg-blue-900\/50:where(.dark,.dark *):hover{background-color:#1c398e80}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-blue-900\/50:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-blue-900) 50%, transparent)}}.dark\:hover\:bg-blue-950\/30:where(.dark,.dark *):hover{background-color:#1624564d}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-blue-950\/30:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-blue-950) 30%, transparent)}}.dark\:hover\:bg-emerald-900\/20:where(.dark,.dark *):hover{background-color:#004e3b33}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-emerald-900\/20:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-emerald-900) 20%, transparent)}}.dark\:hover\:bg-gray-800:where(.dark,.dark *):hover{background-color:var(--color-gray-800)}.dark\:hover\:bg-green-900\/40:where(.dark,.dark *):hover{background-color:#0d542b66}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-green-900\/40:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-green-900) 40%, transparent)}}.dark\:hover\:bg-indigo-900\/20:where(.dark,.dark *):hover{background-color:#312c8533}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-indigo-900\/20:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-indigo-900) 20%, transparent)}}.dark\:hover\:bg-indigo-900\/50:where(.dark,.dark *):hover{background-color:#312c8580}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-indigo-900\/50:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-indigo-900) 50%, transparent)}}.dark\:hover\:bg-orange-900\/20:where(.dark,.dark *):hover{background-color:#7e2a0c33}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-orange-900\/20:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-orange-900) 20%, transparent)}}.dark\:hover\:bg-pink-900\/20:where(.dark,.dark *):hover{background-color:#86104333}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-pink-900\/20:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-pink-900) 20%, transparent)}}.dark\:hover\:bg-red-800:where(.dark,.dark *):hover{background-color:var(--color-red-800)}.dark\:hover\:bg-red-900\/20:where(.dark,.dark *):hover{background-color:#82181a33}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-red-900\/20:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-red-900) 20%, transparent)}}.dark\:hover\:bg-red-900\/20\!:where(.dark,.dark *):hover{background-color:#82181a33!important}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-red-900\/20\!:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-red-900) 20%, transparent)!important}}.dark\:hover\:bg-red-900\/30:where(.dark,.dark *):hover{background-color:#82181a4d}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-red-900\/30:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-red-900) 30%, transparent)}}.dark\:hover\:bg-red-900\/40:where(.dark,.dark *):hover{background-color:#82181a66}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-red-900\/40:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-red-900) 40%, transparent)}}.dark\:hover\:bg-red-900\/50:where(.dark,.dark *):hover{background-color:#82181a80}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-red-900\/50:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-red-900) 50%, transparent)}}.dark\:hover\:bg-red-950\/30:where(.dark,.dark *):hover{background-color:#4608094d}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-red-950\/30:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-red-950) 30%, transparent)}}.dark\:hover\:bg-red-950\/40:where(.dark,.dark *):hover{background-color:#46080966}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-red-950\/40:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-red-950) 40%, transparent)}}.dark\:hover\:bg-teal-900\/20:where(.dark,.dark *):hover{background-color:#0b4f4a33}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-teal-900\/20:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-teal-900) 20%, transparent)}}.dark\:hover\:bg-violet-900\/20:where(.dark,.dark *):hover{background-color:#4d179a33}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-violet-900\/20:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-violet-900) 20%, transparent)}}.dark\:hover\:bg-white\/5:where(.dark,.dark *):hover{background-color:#ffffff0d}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-white\/5:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-white) 5%, transparent)}}.dark\:hover\:bg-white\/10:where(.dark,.dark *):hover{background-color:#ffffff1a}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-white\/10:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-white) 10%, transparent)}}.dark\:hover\:bg-white\/15:where(.dark,.dark *):hover{background-color:#ffffff26}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-white\/15:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-white) 15%, transparent)}}.dark\:hover\:bg-zinc-600:where(.dark,.dark *):hover{background-color:var(--color-zinc-600)}.dark\:hover\:bg-zinc-700:where(.dark,.dark *):hover{background-color:var(--color-zinc-700)}.dark\:hover\:bg-zinc-800:where(.dark,.dark *):hover{background-color:var(--color-zinc-800)}.dark\:hover\:bg-zinc-800\/20:where(.dark,.dark *):hover{background-color:#27272a33}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-zinc-800\/20:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-zinc-800) 20%, transparent)}}.dark\:hover\:bg-zinc-800\/50:where(.dark,.dark *):hover{background-color:#27272a80}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-zinc-800\/50:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-zinc-800) 50%, transparent)}}.dark\:hover\:bg-zinc-800\/80:where(.dark,.dark *):hover{background-color:#27272acc}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-zinc-800\/80:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-zinc-800) 80%, transparent)}}.dark\:hover\:bg-zinc-900:where(.dark,.dark *):hover{background-color:var(--color-zinc-900)}.dark\:hover\:bg-zinc-900\/20:where(.dark,.dark *):hover{background-color:#18181b33}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-zinc-900\/20:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-zinc-900) 20%, transparent)}}.dark\:hover\:bg-zinc-900\/40:where(.dark,.dark *):hover{background-color:#18181b66}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-zinc-900\/40:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-zinc-900) 40%, transparent)}}.dark\:hover\:bg-zinc-900\/50:where(.dark,.dark *):hover{background-color:#18181b80}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-zinc-900\/50:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-zinc-900) 50%, transparent)}}.dark\:hover\:bg-zinc-900\/60:where(.dark,.dark *):hover{background-color:#18181b99}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-zinc-900\/60:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-zinc-900) 60%, transparent)}}.dark\:hover\:bg-zinc-900\/70:where(.dark,.dark *):hover{background-color:#18181bb3}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-zinc-900\/70:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-zinc-900) 70%, transparent)}}.dark\:hover\:text-amber-200:where(.dark,.dark *):hover{color:var(--color-amber-200)}.dark\:hover\:text-amber-400:where(.dark,.dark *):hover{color:var(--color-amber-400)}.dark\:hover\:text-blue-300:where(.dark,.dark *):hover{color:var(--color-blue-300)}.dark\:hover\:text-blue-400:where(.dark,.dark *):hover{color:var(--color-blue-400)}.dark\:hover\:text-cyan-400:where(.dark,.dark *):hover{color:var(--color-cyan-400)}.dark\:hover\:text-emerald-400:where(.dark,.dark *):hover{color:var(--color-emerald-400)}.dark\:hover\:text-gray-200:where(.dark,.dark *):hover{color:var(--color-gray-200)}.dark\:hover\:text-gray-300:where(.dark,.dark *):hover{color:var(--color-gray-300)}.dark\:hover\:text-green-400:where(.dark,.dark *):hover{color:var(--color-green-400)}.dark\:hover\:text-indigo-400:where(.dark,.dark *):hover{color:var(--color-indigo-400)}.dark\:hover\:text-red-400:where(.dark,.dark *):hover{color:var(--color-red-400)}.dark\:hover\:text-violet-400:where(.dark,.dark *):hover{color:var(--color-violet-400)}.dark\:hover\:text-white:where(.dark,.dark *):hover{color:var(--color-white)}.dark\:hover\:text-zinc-100:where(.dark,.dark *):hover{color:var(--color-zinc-100)}.dark\:hover\:text-zinc-300:where(.dark,.dark *):hover{color:var(--color-zinc-300)}.dark\:hover\:file\:bg-blue-700:where(.dark,.dark *):hover::file-selector-button{background-color:var(--color-blue-700)}.dark\:hover\:file\:bg-zinc-600:where(.dark,.dark *):hover::file-selector-button{background-color:var(--color-zinc-600)}}.dark\:focus\:border-blue-400:where(.dark,.dark *):focus{border-color:var(--color-blue-400)}.dark\:focus\:border-blue-500:where(.dark,.dark *):focus{border-color:var(--color-blue-500)}.dark\:focus\:border-indigo-500:where(.dark,.dark *):focus{border-color:var(--color-indigo-500)}.dark\:focus\:ring-blue-50:where(.dark,.dark *):focus{--tw-ring-color:var(--color-blue-50)}.dark\:focus\:ring-blue-400:where(.dark,.dark *):focus{--tw-ring-color:var(--color-blue-400)}.dark\:focus\:ring-blue-500:where(.dark,.dark *):focus{--tw-ring-color:var(--color-blue-500)}.dark\:focus\:ring-indigo-500:where(.dark,.dark *):focus{--tw-ring-color:var(--color-indigo-500)}.dark\:focus\:ring-offset-zinc-900:where(.dark,.dark *):focus{--tw-ring-offset-color:var(--color-zinc-900)}.dark\:focus-visible\:ring-blue-400:where(.dark,.dark *):focus-visible{--tw-ring-color:var(--color-blue-400)}.dark\:focus-visible\:outline-zinc-500:where(.dark,.dark *):focus-visible{outline-color:var(--color-zinc-500)}.dark\:disabled\:bg-zinc-700:where(.dark,.dark *):disabled{background-color:var(--color-zinc-700)}.dark\:disabled\:bg-zinc-800:where(.dark,.dark *):disabled{background-color:var(--color-zinc-800)}@media (hover:hover){.dark\:disabled\:hover\:bg-violet-900\/10:where(.dark,.dark *):disabled:hover{background-color:#4d179a1a}@supports (color:color-mix(in lab, red, red)){.dark\:disabled\:hover\:bg-violet-900\/10:where(.dark,.dark *):disabled:hover{background-color:color-mix(in oklab, var(--color-violet-900) 10%, transparent)}}}@media (min-width:40rem){.dark\:sm\:bg-white\/2:where(.dark,.dark *){background-color:#ffffff05}@supports (color:color-mix(in lab, red, red)){.dark\:sm\:bg-white\/2:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-white) 2%, transparent)}}}.\[\&_\*\]\:bg-transparent\! *{background-color:#0000!important}.\[\&_\*\]\:text-inherit\! *{color:inherit!important}}@property --tw-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-z{syntax:"*";inherits:false;initial-value:0}@property --tw-scale-x{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-y{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-z{syntax:"*";inherits:false;initial-value:1}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-pan-x{syntax:"*";inherits:false}@property --tw-pan-y{syntax:"*";inherits:false}@property --tw-pinch-zoom{syntax:"*";inherits:false}@property --tw-scroll-snap-strictness{syntax:"*";inherits:false;initial-value:proximity}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-space-x-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-divide-x-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-gradient-position{syntax:"*";inherits:false}@property --tw-gradient-from{syntax:"";inherits:false;initial-value:#0000}@property --tw-gradient-via{syntax:"";inherits:false;initial-value:#0000}@property --tw-gradient-to{syntax:"";inherits:false;initial-value:#0000}@property --tw-gradient-stops{syntax:"*";inherits:false}@property --tw-gradient-via-stops{syntax:"*";inherits:false}@property --tw-gradient-from-position{syntax:"";inherits:false;initial-value:0%}@property --tw-gradient-via-position{syntax:"";inherits:false;initial-value:50%}@property --tw-gradient-to-position{syntax:"";inherits:false;initial-value:100%}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-ordinal{syntax:"*";inherits:false}@property --tw-slashed-zero{syntax:"*";inherits:false}@property --tw-numeric-figure{syntax:"*";inherits:false}@property --tw-numeric-spacing{syntax:"*";inherits:false}@property --tw-numeric-fraction{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-outline-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-backdrop-blur{syntax:"*";inherits:false}@property --tw-backdrop-brightness{syntax:"*";inherits:false}@property --tw-backdrop-contrast{syntax:"*";inherits:false}@property --tw-backdrop-grayscale{syntax:"*";inherits:false}@property --tw-backdrop-hue-rotate{syntax:"*";inherits:false}@property --tw-backdrop-invert{syntax:"*";inherits:false}@property --tw-backdrop-opacity{syntax:"*";inherits:false}@property --tw-backdrop-saturate{syntax:"*";inherits:false}@property --tw-backdrop-sepia{syntax:"*";inherits:false}@property --tw-duration{syntax:"*";inherits:false}@property --tw-ease{syntax:"*";inherits:false}@property --tw-contain-size{syntax:"*";inherits:false}@property --tw-contain-layout{syntax:"*";inherits:false}@property --tw-contain-paint{syntax:"*";inherits:false}@property --tw-contain-style{syntax:"*";inherits:false}@property --tw-content{syntax:"*";inherits:false;initial-value:""}@keyframes spin{to{transform:rotate(360deg)}}@keyframes ping{75%,to{opacity:0;transform:scale(2)}}@keyframes pulse{50%{opacity:.5}}@keyframes bounce{0%,to{animation-timing-function:cubic-bezier(.8,0,1,1);transform:translateY(-25%)}50%{animation-timing-function:cubic-bezier(0,0,.2,1);transform:none}} \ No newline at end of file +@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1;--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-pan-x:initial;--tw-pan-y:initial;--tw-pinch-zoom:initial;--tw-scroll-snap-strictness:proximity;--tw-space-y-reverse:0;--tw-space-x-reverse:0;--tw-divide-x-reverse:0;--tw-border-style:solid;--tw-divide-y-reverse:0;--tw-gradient-position:initial;--tw-gradient-from:#0000;--tw-gradient-via:#0000;--tw-gradient-to:#0000;--tw-gradient-stops:initial;--tw-gradient-via-stops:initial;--tw-gradient-from-position:0%;--tw-gradient-via-position:50%;--tw-gradient-to-position:100%;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial;--tw-ease:initial;--tw-contain-size:initial;--tw-contain-layout:initial;--tw-contain-paint:initial;--tw-contain-style:initial;--tw-content:""}}}@layer theme{:root,:host{--font-sans:-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-red-50:oklch(97.1% .013 17.38);--color-red-100:oklch(93.6% .032 17.717);--color-red-200:oklch(88.5% .062 18.334);--color-red-300:oklch(80.8% .114 19.571);--color-red-400:oklch(70.4% .191 22.216);--color-red-500:oklch(63.7% .237 25.331);--color-red-600:oklch(57.7% .245 27.325);--color-red-700:oklch(50.5% .213 27.518);--color-red-800:oklch(44.4% .177 26.899);--color-red-900:oklch(39.6% .141 25.723);--color-red-950:oklch(25.8% .092 26.042);--color-orange-50:oklch(98% .016 73.684);--color-orange-100:oklch(95.4% .038 75.164);--color-orange-200:oklch(90.1% .076 70.697);--color-orange-300:oklch(83.7% .128 66.29);--color-orange-400:oklch(75% .183 55.934);--color-orange-500:oklch(70.5% .213 47.604);--color-orange-600:oklch(64.6% .222 41.116);--color-orange-700:oklch(55.3% .195 38.402);--color-orange-900:oklch(40.8% .123 38.172);--color-amber-50:oklch(98.7% .022 95.277);--color-amber-100:oklch(96.2% .059 95.617);--color-amber-200:oklch(92.4% .12 95.746);--color-amber-300:oklch(87.9% .169 91.605);--color-amber-400:oklch(82.8% .189 84.429);--color-amber-500:oklch(76.9% .188 70.08);--color-amber-600:oklch(66.6% .179 58.318);--color-amber-700:oklch(55.5% .163 48.998);--color-amber-800:oklch(47.3% .137 46.201);--color-amber-900:oklch(41.4% .112 45.904);--color-amber-950:oklch(27.9% .077 45.635);--color-yellow-50:oklch(98.7% .026 102.212);--color-yellow-100:oklch(97.3% .071 103.193);--color-yellow-200:oklch(94.5% .129 101.54);--color-yellow-300:oklch(90.5% .182 98.111);--color-yellow-400:oklch(85.2% .199 91.936);--color-yellow-500:oklch(79.5% .184 86.047);--color-yellow-600:oklch(68.1% .162 75.834);--color-yellow-700:oklch(55.4% .135 66.442);--color-yellow-800:oklch(47.6% .114 61.907);--color-yellow-900:oklch(42.1% .095 57.708);--color-green-50:oklch(98.2% .018 155.826);--color-green-100:oklch(96.2% .044 156.743);--color-green-200:oklch(92.5% .084 155.995);--color-green-300:oklch(87.1% .15 154.449);--color-green-400:oklch(79.2% .209 151.711);--color-green-500:oklch(72.3% .219 149.579);--color-green-600:oklch(62.7% .194 149.214);--color-green-700:oklch(52.7% .154 150.069);--color-green-800:oklch(44.8% .119 151.328);--color-green-900:oklch(39.3% .095 152.535);--color-emerald-50:oklch(97.9% .021 166.113);--color-emerald-100:oklch(95% .052 163.051);--color-emerald-200:oklch(90.5% .093 164.15);--color-emerald-300:oklch(84.5% .143 164.978);--color-emerald-400:oklch(76.5% .177 163.223);--color-emerald-500:oklch(69.6% .17 162.48);--color-emerald-600:oklch(59.6% .145 163.225);--color-emerald-700:oklch(50.8% .118 165.612);--color-emerald-800:oklch(43.2% .095 166.913);--color-emerald-900:oklch(37.8% .077 168.94);--color-emerald-950:oklch(26.2% .051 172.552);--color-teal-50:oklch(98.4% .014 180.72);--color-teal-100:oklch(95.3% .051 180.801);--color-teal-200:oklch(91% .096 180.426);--color-teal-300:oklch(85.5% .138 181.071);--color-teal-400:oklch(77.7% .152 181.912);--color-teal-500:oklch(70.4% .14 182.503);--color-teal-600:oklch(60% .118 184.704);--color-teal-700:oklch(51.1% .096 186.391);--color-teal-800:oklch(43.7% .078 188.216);--color-teal-900:oklch(38.6% .063 188.416);--color-cyan-50:oklch(98.4% .019 200.873);--color-cyan-100:oklch(95.6% .045 203.388);--color-cyan-200:oklch(91.7% .08 205.041);--color-cyan-300:oklch(86.5% .127 207.078);--color-cyan-400:oklch(78.9% .154 211.53);--color-cyan-500:oklch(71.5% .143 215.221);--color-cyan-600:oklch(60.9% .126 221.723);--color-cyan-700:oklch(52% .105 223.128);--color-cyan-800:oklch(45% .085 224.283);--color-cyan-900:oklch(39.8% .07 227.392);--color-cyan-950:oklch(30.2% .056 229.695);--color-sky-50:oklch(97.7% .013 236.62);--color-sky-100:oklch(95.1% .026 236.824);--color-sky-200:oklch(90.1% .058 230.902);--color-sky-300:oklch(82.8% .111 230.318);--color-sky-400:oklch(74.6% .16 232.661);--color-sky-500:oklch(68.5% .169 237.323);--color-sky-600:oklch(58.8% .158 241.966);--color-sky-700:oklch(50% .134 242.749);--color-sky-800:oklch(44.3% .11 240.79);--color-sky-900:oklch(39.1% .09 240.876);--color-sky-950:oklch(29.3% .066 243.157);--color-blue-50:oklch(97% .014 254.604);--color-blue-100:oklch(93.2% .032 255.585);--color-blue-200:oklch(88.2% .059 254.128);--color-blue-300:oklch(80.9% .105 251.813);--color-blue-400:oklch(70.7% .165 254.624);--color-blue-500:oklch(62.3% .214 259.815);--color-blue-600:oklch(54.6% .245 262.881);--color-blue-700:oklch(48.8% .243 264.376);--color-blue-800:oklch(42.4% .199 265.638);--color-blue-900:oklch(37.9% .146 265.522);--color-blue-950:oklch(28.2% .091 267.935);--color-indigo-50:oklch(96.2% .018 272.314);--color-indigo-100:oklch(93% .034 272.788);--color-indigo-200:oklch(87% .065 274.039);--color-indigo-300:oklch(78.5% .115 274.713);--color-indigo-400:oklch(67.3% .182 276.935);--color-indigo-500:oklch(58.5% .233 277.117);--color-indigo-600:oklch(51.1% .262 276.966);--color-indigo-700:oklch(45.7% .24 277.023);--color-indigo-800:oklch(39.8% .195 277.366);--color-indigo-900:oklch(35.9% .144 278.697);--color-indigo-950:oklch(25.7% .09 281.288);--color-violet-50:oklch(96.9% .016 293.756);--color-violet-100:oklch(94.3% .029 294.588);--color-violet-200:oklch(89.4% .057 293.283);--color-violet-300:oklch(81.1% .111 293.571);--color-violet-400:oklch(70.2% .183 293.541);--color-violet-600:oklch(54.1% .281 293.009);--color-violet-700:oklch(49.1% .27 292.581);--color-violet-800:oklch(43.2% .232 292.759);--color-violet-900:oklch(38% .189 293.745);--color-violet-950:oklch(28.3% .141 291.089);--color-purple-50:oklch(97.7% .014 308.299);--color-purple-100:oklch(94.6% .033 307.174);--color-purple-200:oklch(90.2% .063 306.703);--color-purple-300:oklch(82.7% .119 306.383);--color-purple-400:oklch(71.4% .203 305.504);--color-purple-500:oklch(62.7% .265 303.9);--color-purple-600:oklch(55.8% .288 302.321);--color-purple-800:oklch(43.8% .218 303.724);--color-purple-900:oklch(38.1% .176 304.987);--color-pink-50:oklch(97.1% .014 343.198);--color-pink-100:oklch(94.8% .028 342.258);--color-pink-200:oklch(89.9% .061 343.231);--color-pink-300:oklch(82.3% .12 346.018);--color-pink-400:oklch(71.8% .202 349.761);--color-pink-500:oklch(65.6% .241 354.308);--color-pink-600:oklch(59.2% .249 .584);--color-pink-700:oklch(52.5% .223 3.958);--color-pink-800:oklch(45.9% .187 3.815);--color-pink-900:oklch(40.8% .153 2.432);--color-rose-50:oklch(96.9% .015 12.422);--color-rose-100:oklch(94.1% .03 12.58);--color-rose-200:oklch(89.2% .058 10.001);--color-rose-400:oklch(71.2% .194 13.428);--color-rose-500:oklch(64.5% .246 16.439);--color-rose-600:oklch(58.6% .253 17.585);--color-rose-700:oklch(51.4% .222 16.935);--color-rose-900:oklch(41% .159 10.272);--color-slate-50:oklch(98.4% .003 247.858);--color-slate-100:oklch(96.8% .007 247.896);--color-slate-200:oklch(92.9% .013 255.508);--color-slate-300:oklch(86.9% .022 252.894);--color-slate-400:oklch(70.4% .04 256.788);--color-slate-500:oklch(55.4% .046 257.417);--color-slate-600:oklch(44.6% .043 257.281);--color-slate-700:oklch(37.2% .044 257.287);--color-slate-800:oklch(27.9% .041 260.031);--color-slate-900:oklch(20.8% .042 265.755);--color-gray-50:oklch(98.5% .002 247.839);--color-gray-100:oklch(96.7% .003 264.542);--color-gray-200:oklch(92.8% .006 264.531);--color-gray-300:oklch(87.2% .01 258.338);--color-gray-400:oklch(70.7% .022 261.325);--color-gray-500:oklch(55.1% .027 264.364);--color-gray-600:oklch(44.6% .03 256.802);--color-gray-700:oklch(37.3% .034 259.733);--color-gray-800:oklch(27.8% .033 256.848);--color-gray-900:oklch(21% .034 264.665);--color-zinc-50:oklch(98.5% 0 none);--color-zinc-100:oklch(96.7% .001 286.375);--color-zinc-200:oklch(92% .004 286.32);--color-zinc-300:oklch(87.1% .006 286.286);--color-zinc-400:oklch(70.5% .015 286.067);--color-zinc-500:oklch(55.2% .016 285.938);--color-zinc-600:oklch(44.2% .017 285.786);--color-zinc-700:oklch(37% .013 285.805);--color-zinc-800:oklch(27.4% .006 286.033);--color-zinc-900:oklch(21% .006 285.885);--color-zinc-950:oklch(14.1% .005 285.823);--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-xs:20rem;--container-sm:24rem;--container-md:28rem;--container-lg:32rem;--container-xl:36rem;--container-2xl:42rem;--container-3xl:48rem;--container-4xl:56rem;--container-5xl:64rem;--container-6xl:72rem;--container-7xl:80rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5 / 2.25);--text-5xl:3rem;--text-5xl--line-height:1;--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--font-weight-black:900;--tracking-tighter:-.05em;--tracking-tight:-.025em;--tracking-normal:0em;--tracking-wide:.025em;--tracking-wider:.05em;--tracking-widest:.1em;--leading-tight:1.25;--leading-snug:1.375;--leading-normal:1.5;--leading-relaxed:1.625;--radius-xs:.125rem;--radius-sm:.25rem;--radius-md:.375rem;--radius-lg:.5rem;--radius-xl:.75rem;--radius-2xl:1rem;--radius-3xl:1.5rem;--radius-4xl:2rem;--ease-in:cubic-bezier(.4, 0, 1, 1);--ease-out:cubic-bezier(0, 0, .2, 1);--ease-in-out:cubic-bezier(.4, 0, .2, 1);--animate-spin:spin 1s linear infinite;--animate-ping:ping 1s cubic-bezier(0, 0, .2, 1) infinite;--animate-pulse:pulse 2s cubic-bezier(.4, 0, .6, 1) infinite;--animate-bounce:bounce 1s infinite;--blur-xs:4px;--blur-sm:8px;--blur-md:12px;--aspect-video:16 / 9;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}input:where([type=text]),input:where(:not([type])),input:where([type=email]),input:where([type=url]),input:where([type=password]),input:where([type=number]),input:where([type=date]),input:where([type=datetime-local]),input:where([type=month]),input:where([type=search]),input:where([type=tel]),input:where([type=time]),input:where([type=week]),select:where([multiple]),textarea,select{appearance:none;--tw-shadow:0 0 #0000;background-color:#fff;border-width:1px;border-color:oklch(55.1% .027 264.364);border-radius:0;padding:.5rem .75rem;font-size:1rem;line-height:1.5rem}:is(input:where([type=text]),input:where(:not([type])),input:where([type=email]),input:where([type=url]),input:where([type=password]),input:where([type=number]),input:where([type=date]),input:where([type=datetime-local]),input:where([type=month]),input:where([type=search]),input:where([type=tel]),input:where([type=time]),input:where([type=week]),select:where([multiple]),textarea,select):focus{outline-offset:2px;--tw-ring-inset:var(--tw-empty, );--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:oklch(54.6% .245 262.881);--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);border-color:oklch(54.6% .245 262.881);outline:2px solid #0000}input::placeholder,textarea::placeholder{color:oklch(55.1% .027 264.364);opacity:1}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-date-and-time-value{min-height:1.5em;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit{padding-top:0;padding-bottom:0}::-webkit-datetime-edit-year-field{padding-top:0;padding-bottom:0}::-webkit-datetime-edit-month-field{padding-top:0;padding-bottom:0}::-webkit-datetime-edit-day-field{padding-top:0;padding-bottom:0}::-webkit-datetime-edit-hour-field{padding-top:0;padding-bottom:0}::-webkit-datetime-edit-minute-field{padding-top:0;padding-bottom:0}::-webkit-datetime-edit-second-field{padding-top:0;padding-bottom:0}::-webkit-datetime-edit-millisecond-field{padding-top:0;padding-bottom:0}::-webkit-datetime-edit-meridiem-field{padding-top:0;padding-bottom:0}select{-webkit-print-color-adjust:exact;print-color-adjust:exact;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='oklch(55.1%25 0.027 264.364)' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e");background-position:right .5rem center;background-repeat:no-repeat;background-size:1.5em 1.5em;padding-right:2.5rem}select:where([multiple]),select:where([size]:not([size="1"])){background-image:initial;background-position:initial;background-repeat:unset;background-size:initial;print-color-adjust:unset;padding-right:.75rem}input:where([type=checkbox]),input:where([type=radio]){appearance:none;-webkit-print-color-adjust:exact;print-color-adjust:exact;vertical-align:middle;-webkit-user-select:none;user-select:none;color:oklch(54.6% .245 262.881);--tw-shadow:0 0 #0000;background-color:#fff;background-origin:border-box;border-width:1px;border-color:oklch(55.1% .027 264.364);flex-shrink:0;width:1rem;height:1rem;padding:0;display:inline-block}input:where([type=checkbox]){border-radius:0}input:where([type=radio]){border-radius:100%}input:where([type=checkbox]):focus,input:where([type=radio]):focus{outline-offset:2px;--tw-ring-inset:var(--tw-empty, );--tw-ring-offset-width:2px;--tw-ring-offset-color:#fff;--tw-ring-color:oklch(54.6% .245 262.881);--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);outline:2px solid #0000}input:where([type=checkbox]):checked,input:where([type=radio]):checked{background-color:currentColor;background-position:50%;background-repeat:no-repeat;background-size:100% 100%;border-color:#0000}input:where([type=checkbox]):checked{background-image:url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e")}@media (forced-colors:active){input:where([type=checkbox]):checked{appearance:auto}}input:where([type=radio]):checked{background-image:url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3ccircle cx='8' cy='8' r='3'/%3e%3c/svg%3e")}@media (forced-colors:active){input:where([type=radio]):checked{appearance:auto}}input:where([type=checkbox]):checked:hover,input:where([type=checkbox]):checked:focus,input:where([type=radio]):checked:hover,input:where([type=radio]):checked:focus{background-color:currentColor;border-color:#0000}input:where([type=checkbox]):indeterminate{background-color:currentColor;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 16 16'%3e%3cpath stroke='white' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M4 8h8'/%3e%3c/svg%3e");background-position:50%;background-repeat:no-repeat;background-size:100% 100%;border-color:#0000}@media (forced-colors:active){input:where([type=checkbox]):indeterminate{appearance:auto}}input:where([type=checkbox]):indeterminate:hover,input:where([type=checkbox]):indeterminate:focus{background-color:currentColor;border-color:#0000}input:where([type=file]){background:unset;border-color:inherit;font-size:unset;line-height:inherit;border-width:0;border-radius:0;padding:0}input:where([type=file]):focus{outline:1px solid buttontext;outline:1px auto -webkit-focus-ring-color}}@layer components;@layer utilities{.pointer-events-auto{pointer-events:auto}.pointer-events-none{pointer-events:none}.collapse{visibility:collapse}.invisible{visibility:hidden}.visible{visibility:visible}.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.not-sr-only{clip-path:none;white-space:normal;width:auto;height:auto;margin:0;padding:0;position:static;overflow:visible}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.sticky{position:sticky}.-inset-2{inset:calc(var(--spacing) * -2)}.inset-0{inset:0}.inset-x-0{inset-inline:0}.inset-y-0{inset-block:0}.-top-0\.5{top:calc(var(--spacing) * -.5)}.-top-1{top:calc(var(--spacing) * -1)}.-top-2{top:calc(var(--spacing) * -2)}.-top-10{top:calc(var(--spacing) * -10)}.-top-12{top:calc(var(--spacing) * -12)}.top-0{top:0}.top-1{top:var(--spacing)}.top-1\.5{top:calc(var(--spacing) * 1.5)}.top-1\/2{top:50%}.top-2{top:calc(var(--spacing) * 2)}.top-3{top:calc(var(--spacing) * 3)}.top-4{top:calc(var(--spacing) * 4)}.top-14{top:calc(var(--spacing) * 14)}.top-\[calc\(0\.5rem\+2\.75rem\+0\.5rem\)\]{top:3.75rem}.top-\[calc\(0\.5rem\+2\.75rem\+0\.5rem\+2\.75rem\)\]{top:6.5rem}.top-full{top:100%}.-right-0\.5{right:calc(var(--spacing) * -.5)}.-right-1{right:calc(var(--spacing) * -1)}.-right-1\.5{right:calc(var(--spacing) * -1.5)}.-right-2{right:calc(var(--spacing) * -2)}.-right-3{right:calc(var(--spacing) * -3)}.right-0{right:0}.right-1{right:var(--spacing)}.right-1\.5{right:calc(var(--spacing) * 1.5)}.right-2{right:calc(var(--spacing) * 2)}.right-3{right:calc(var(--spacing) * 3)}.right-4{right:calc(var(--spacing) * 4)}.right-\[max\(0\.75rem\,env\(safe-area-inset-right\)\)\]{right:max(.75rem, env(safe-area-inset-right))}.right-\[max\(1rem\,env\(safe-area-inset-right\,0px\)\)\]{right:max(1rem, env(safe-area-inset-right,0px))}.-bottom-0\.5{bottom:calc(var(--spacing) * -.5)}.-bottom-1{bottom:calc(var(--spacing) * -1)}.-bottom-2{bottom:calc(var(--spacing) * -2)}.bottom-0{bottom:0}.bottom-1{bottom:var(--spacing)}.bottom-2{bottom:calc(var(--spacing) * 2)}.bottom-3{bottom:calc(var(--spacing) * 3)}.bottom-4{bottom:calc(var(--spacing) * 4)}.bottom-5{bottom:calc(var(--spacing) * 5)}.bottom-9{bottom:calc(var(--spacing) * 9)}.bottom-\[max\(0\.75rem\,env\(safe-area-inset-bottom\)\)\]{bottom:max(.75rem, env(safe-area-inset-bottom))}.bottom-\[max\(1\.25rem\,env\(safe-area-inset-bottom\,0px\)\)\]{bottom:max(1.25rem, env(safe-area-inset-bottom,0px))}.bottom-\[max\(1rem\,env\(safe-area-inset-bottom\,0px\)\)\]{bottom:max(1rem, env(safe-area-inset-bottom,0px))}.bottom-full{bottom:100%}.-left-1\.5{left:calc(var(--spacing) * -1.5)}.-left-3{left:calc(var(--spacing) * -3)}.left-0{left:0}.left-1{left:var(--spacing)}.left-1\/2{left:50%}.left-2{left:calc(var(--spacing) * 2)}.left-2\.5{left:calc(var(--spacing) * 2.5)}.left-3{left:calc(var(--spacing) * 3)}.left-3\.5{left:calc(var(--spacing) * 3.5)}.left-4{left:calc(var(--spacing) * 4)}.left-\[-2px\]{left:-2px}.isolate{isolation:isolate}.isolation-auto{isolation:auto}.z-0{z-index:0}.z-1{z-index:1}.z-10{z-index:10}.z-19{z-index:19}.z-20{z-index:20}.z-30{z-index:30}.z-40{z-index:40}.z-45{z-index:45}.z-50{z-index:50}.z-60{z-index:60}.z-65{z-index:65}.z-70{z-index:70}.z-90{z-index:90}.z-95{z-index:95}.z-100{z-index:100}.z-120{z-index:120}.z-150{z-index:150}.z-180{z-index:180}.z-190{z-index:190}.z-200{z-index:200}.z-210{z-index:210}.z-220{z-index:220}.z-9999{z-index:9999}.z-\[60\]{z-index:60}.z-\[200\]{z-index:200}.z-\[220\]{z-index:220}.z-\[250\]{z-index:250}.z-\[500\]{z-index:500}.order-1{order:1}.order-2{order:2}.order-first{order:-9999}.col-span-2{grid-column:span 2/span 2}.float-end{float:inline-end}.float-left{float:left}.float-none{float:none}.float-right{float:right}.float-start{float:inline-start}.clear-both{clear:both}.clear-end{clear:inline-end}.clear-left{clear:left}.clear-none{clear:none}.clear-right{clear:right}.clear-start{clear:inline-start}.container{width:100%}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.-m-px{margin:-1px}.m-0{margin:0}.m-4{margin:calc(var(--spacing) * 4)}.m-\[-1px\]{margin:-1px}.-mx-3{margin-inline:calc(var(--spacing) * -3)}.-mx-4{margin-inline:calc(var(--spacing) * -4)}.mx-0\.5{margin-inline:calc(var(--spacing) * .5)}.mx-1{margin-inline:var(--spacing)}.mx-3{margin-inline:calc(var(--spacing) * 3)}.mx-4{margin-inline:calc(var(--spacing) * 4)}.mx-auto{margin-inline:auto}.-my-2{margin-block:calc(var(--spacing) * -2)}.my-1{margin-block:var(--spacing)}.my-2{margin-block:calc(var(--spacing) * 2)}.my-3{margin-block:calc(var(--spacing) * 3)}.my-4{margin-block:calc(var(--spacing) * 4)}.my-6{margin-block:calc(var(--spacing) * 6)}.my-8{margin-block:calc(var(--spacing) * 8)}.my-10{margin-block:calc(var(--spacing) * 10)}.my-auto{margin-block:auto}.-mt-0\.5{margin-top:calc(var(--spacing) * -.5)}.-mt-1{margin-top:calc(var(--spacing) * -1)}.mt-0\.5{margin-top:calc(var(--spacing) * .5)}.mt-1{margin-top:var(--spacing)}.mt-1\.5{margin-top:calc(var(--spacing) * 1.5)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-5{margin-top:calc(var(--spacing) * 5)}.mt-6{margin-top:calc(var(--spacing) * 6)}.mt-7{margin-top:calc(var(--spacing) * 7)}.mt-8{margin-top:calc(var(--spacing) * 8)}.mt-10{margin-top:calc(var(--spacing) * 10)}.mt-12{margin-top:calc(var(--spacing) * 12)}.mt-14{margin-top:calc(var(--spacing) * 14)}.mt-auto{margin-top:auto}.-mr-2{margin-right:calc(var(--spacing) * -2)}.mr-0{margin-right:0}.mr-1{margin-right:var(--spacing)}.mr-1\.5{margin-right:calc(var(--spacing) * 1.5)}.mr-2{margin-right:calc(var(--spacing) * 2)}.mr-3{margin-right:calc(var(--spacing) * 3)}.mr-4{margin-right:calc(var(--spacing) * 4)}.mr-auto{margin-right:auto}.-mb-0\.5{margin-bottom:calc(var(--spacing) * -.5)}.-mb-px{margin-bottom:-1px}.mb-0{margin-bottom:0}.mb-0\!{margin-bottom:0!important}.mb-0\.5{margin-bottom:calc(var(--spacing) * .5)}.mb-1{margin-bottom:var(--spacing)}.mb-1\.5{margin-bottom:calc(var(--spacing) * 1.5)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.mb-6{margin-bottom:calc(var(--spacing) * 6)}.mb-8{margin-bottom:calc(var(--spacing) * 8)}.mb-10{margin-bottom:calc(var(--spacing) * 10)}.-ml-1{margin-left:calc(var(--spacing) * -1)}.-ml-11{margin-left:calc(var(--spacing) * -11)}.ml-0\.5{margin-left:calc(var(--spacing) * .5)}.ml-1{margin-left:var(--spacing)}.ml-1\.5{margin-left:calc(var(--spacing) * 1.5)}.ml-2{margin-left:calc(var(--spacing) * 2)}.ml-3{margin-left:calc(var(--spacing) * 3)}.ml-4{margin-left:calc(var(--spacing) * 4)}.ml-6{margin-left:calc(var(--spacing) * 6)}.ml-auto{margin-left:auto}.box-border{box-sizing:border-box}.box-content{box-sizing:content-box}.form-checkbox{appearance:none;-webkit-print-color-adjust:exact;print-color-adjust:exact;vertical-align:middle;-webkit-user-select:none;user-select:none;color:oklch(54.6% .245 262.881);--tw-shadow:0 0 #0000;background-color:#fff;background-origin:border-box;border-width:1px;border-color:oklch(55.1% .027 264.364);border-radius:0;flex-shrink:0;width:1rem;height:1rem;padding:0;display:inline-block}.form-checkbox:focus{outline-offset:2px;--tw-ring-inset:var(--tw-empty, );--tw-ring-offset-width:2px;--tw-ring-offset-color:#fff;--tw-ring-color:oklch(54.6% .245 262.881);--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);outline:2px solid #0000}.form-checkbox:checked{background-color:currentColor;background-image:url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e");background-position:50%;background-repeat:no-repeat;background-size:100% 100%;border-color:#0000}@media (forced-colors:active){.form-checkbox:checked{appearance:auto}}.form-checkbox:checked:hover,.form-checkbox:checked:focus{background-color:currentColor;border-color:#0000}.form-checkbox:indeterminate{background-color:currentColor;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 16 16'%3e%3cpath stroke='white' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M4 8h8'/%3e%3c/svg%3e");background-position:50%;background-repeat:no-repeat;background-size:100% 100%;border-color:#0000}@media (forced-colors:active){.form-checkbox:indeterminate{appearance:auto}}.form-checkbox:indeterminate:hover,.form-checkbox:indeterminate:focus{background-color:currentColor;border-color:#0000}.form-radio{appearance:none;-webkit-print-color-adjust:exact;print-color-adjust:exact;vertical-align:middle;-webkit-user-select:none;user-select:none;color:oklch(54.6% .245 262.881);--tw-shadow:0 0 #0000;background-color:#fff;background-origin:border-box;border-width:1px;border-color:oklch(55.1% .027 264.364);border-radius:100%;flex-shrink:0;width:1rem;height:1rem;padding:0;display:inline-block}.form-radio:focus{outline-offset:2px;--tw-ring-inset:var(--tw-empty, );--tw-ring-offset-width:2px;--tw-ring-offset-color:#fff;--tw-ring-color:oklch(54.6% .245 262.881);--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);outline:2px solid #0000}.form-radio:checked{background-color:currentColor;background-image:url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3ccircle cx='8' cy='8' r='3'/%3e%3c/svg%3e");background-position:50%;background-repeat:no-repeat;background-size:100% 100%;border-color:#0000}@media (forced-colors:active){.form-radio:checked{appearance:auto}}.form-radio:checked:hover,.form-radio:checked:focus{background-color:currentColor;border-color:#0000}.form-input{appearance:none;--tw-shadow:0 0 #0000;background-color:#fff;border-width:1px;border-color:oklch(55.1% .027 264.364);border-radius:0;padding:.5rem .75rem;font-size:1rem;line-height:1.5rem}.form-input:focus{outline-offset:2px;--tw-ring-inset:var(--tw-empty, );--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:oklch(54.6% .245 262.881);--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);border-color:oklch(54.6% .245 262.881);outline:2px solid #0000}.form-input::placeholder{color:oklch(55.1% .027 264.364);opacity:1}.form-input::-webkit-datetime-edit-fields-wrapper{padding:0}.form-input::-webkit-date-and-time-value{min-height:1.5em}.form-input::-webkit-date-and-time-value{text-align:inherit}.form-input::-webkit-datetime-edit{display:inline-flex}.form-input::-webkit-datetime-edit{padding-top:0;padding-bottom:0}.form-input::-webkit-datetime-edit-year-field{padding-top:0;padding-bottom:0}.form-input::-webkit-datetime-edit-month-field{padding-top:0;padding-bottom:0}.form-input::-webkit-datetime-edit-day-field{padding-top:0;padding-bottom:0}.form-input::-webkit-datetime-edit-hour-field{padding-top:0;padding-bottom:0}.form-input::-webkit-datetime-edit-minute-field{padding-top:0;padding-bottom:0}.form-input::-webkit-datetime-edit-second-field{padding-top:0;padding-bottom:0}.form-input::-webkit-datetime-edit-millisecond-field{padding-top:0;padding-bottom:0}.form-input::-webkit-datetime-edit-meridiem-field{padding-top:0;padding-bottom:0}.line-clamp-2{-webkit-line-clamp:2;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.line-clamp-3{-webkit-line-clamp:3;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.line-clamp-4{-webkit-line-clamp:4;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.line-clamp-none{-webkit-line-clamp:unset;-webkit-box-orient:horizontal;display:block;overflow:visible}.block{display:block}.contents{display:contents}.flex{display:flex}.flow-root{display:flow-root}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-block{display:inline-block}.inline-flex{display:inline-flex}.inline-grid{display:inline-grid}.inline-table{display:inline-table}.list-item{display:list-item}.table{display:table}.table-caption{display:table-caption}.table-cell{display:table-cell}.table-column{display:table-column}.table-column-group{display:table-column-group}.table-footer-group{display:table-footer-group}.table-header-group{display:table-header-group}.table-row{display:table-row}.table-row-group{display:table-row-group}.aspect-2\/1{aspect-ratio:2}.aspect-square{aspect-ratio:1}.aspect-video{aspect-ratio:var(--aspect-video)}.size-1\.5{width:calc(var(--spacing) * 1.5);height:calc(var(--spacing) * 1.5)}.size-2{width:calc(var(--spacing) * 2);height:calc(var(--spacing) * 2)}.size-2\.5{width:calc(var(--spacing) * 2.5);height:calc(var(--spacing) * 2.5)}.size-3{width:calc(var(--spacing) * 3);height:calc(var(--spacing) * 3)}.size-3\.5{width:calc(var(--spacing) * 3.5);height:calc(var(--spacing) * 3.5)}.size-4{width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.size-5{width:calc(var(--spacing) * 5);height:calc(var(--spacing) * 5)}.size-6{width:calc(var(--spacing) * 6);height:calc(var(--spacing) * 6)}.size-7{width:calc(var(--spacing) * 7);height:calc(var(--spacing) * 7)}.size-8{width:calc(var(--spacing) * 8);height:calc(var(--spacing) * 8)}.size-9{width:calc(var(--spacing) * 9);height:calc(var(--spacing) * 9)}.size-10{width:calc(var(--spacing) * 10);height:calc(var(--spacing) * 10)}.size-11{width:calc(var(--spacing) * 11);height:calc(var(--spacing) * 11)}.size-12{width:calc(var(--spacing) * 12);height:calc(var(--spacing) * 12)}.size-14{width:calc(var(--spacing) * 14);height:calc(var(--spacing) * 14)}.size-16{width:calc(var(--spacing) * 16);height:calc(var(--spacing) * 16)}.size-20{width:calc(var(--spacing) * 20);height:calc(var(--spacing) * 20)}.size-24{width:calc(var(--spacing) * 24);height:calc(var(--spacing) * 24)}.size-28{width:calc(var(--spacing) * 28);height:calc(var(--spacing) * 28)}.size-32{width:calc(var(--spacing) * 32);height:calc(var(--spacing) * 32)}.size-40{width:calc(var(--spacing) * 40);height:calc(var(--spacing) * 40)}.size-\[14px\]{width:14px;height:14px}.size-\[18px\]{width:18px;height:18px}.size-\[22px\]{width:22px;height:22px}.size-\[34px\]{width:34px;height:34px}.size-\[52px\]{width:52px;height:52px}.size-\[120px\]{width:120px;height:120px}.size-full{width:100%;height:100%}.h-0{height:0}.h-0\.5{height:calc(var(--spacing) * .5)}.h-1{height:var(--spacing)}.h-1\.5{height:calc(var(--spacing) * 1.5)}.h-2{height:calc(var(--spacing) * 2)}.h-2\.5{height:calc(var(--spacing) * 2.5)}.h-3{height:calc(var(--spacing) * 3)}.h-3\.5{height:calc(var(--spacing) * 3.5)}.h-4{height:calc(var(--spacing) * 4)}.h-5{height:calc(var(--spacing) * 5)}.h-6{height:calc(var(--spacing) * 6)}.h-7{height:calc(var(--spacing) * 7)}.h-8{height:calc(var(--spacing) * 8)}.h-9{height:calc(var(--spacing) * 9)}.h-10{height:calc(var(--spacing) * 10)}.h-10\!{height:calc(var(--spacing) * 10)!important}.h-11{height:calc(var(--spacing) * 11)}.h-12{height:calc(var(--spacing) * 12)}.h-14{height:calc(var(--spacing) * 14)}.h-16{height:calc(var(--spacing) * 16)}.h-20{height:calc(var(--spacing) * 20)}.h-24{height:calc(var(--spacing) * 24)}.h-28{height:calc(var(--spacing) * 28)}.h-32{height:calc(var(--spacing) * 32)}.h-40{height:calc(var(--spacing) * 40)}.h-44{height:calc(var(--spacing) * 44)}.h-48{height:calc(var(--spacing) * 48)}.h-52{height:calc(var(--spacing) * 52)}.h-64{height:calc(var(--spacing) * 64)}.h-\[42\%\]{height:42%}.h-\[42px\]{height:42px}.h-auto{height:auto}.h-dvh{height:100dvh}.h-fit{height:fit-content}.h-full{height:100%}.h-px{height:1px}.max-h-12{max-height:calc(var(--spacing) * 12)}.max-h-20{max-height:calc(var(--spacing) * 20)}.max-h-36{max-height:calc(var(--spacing) * 36)}.max-h-44{max-height:calc(var(--spacing) * 44)}.max-h-48{max-height:calc(var(--spacing) * 48)}.max-h-52{max-height:calc(var(--spacing) * 52)}.max-h-56{max-height:calc(var(--spacing) * 56)}.max-h-60{max-height:calc(var(--spacing) * 60)}.max-h-64{max-height:calc(var(--spacing) * 64)}.max-h-72{max-height:calc(var(--spacing) * 72)}.max-h-80{max-height:calc(var(--spacing) * 80)}.max-h-\[36vh\]{max-height:36vh}.max-h-\[60vh\]{max-height:60vh}.max-h-\[80vh\]{max-height:80vh}.max-h-\[90dvh\]{max-height:90dvh}.max-h-\[90vh\]{max-height:90vh}.max-h-\[200px\]{max-height:200px}.max-h-\[220px\]{max-height:220px}.max-h-\[260px\]{max-height:260px}.max-h-\[400px\]{max-height:400px}.max-h-\[480px\]{max-height:480px}.max-h-\[500px\]{max-height:500px}.max-h-\[min\(22rem\,calc\(100vh-6rem\)\)\]{max-height:min(22rem,100vh - 6rem)}.max-h-\[min\(36rem\,90vh\)\]{max-height:min(36rem,90vh)}.max-h-\[min\(36rem\,calc\(100\%-4rem\)\)\]{max-height:min(36rem,100% - 4rem)}.max-h-\[min\(50vh\,28rem\)\]{max-height:min(50vh,28rem)}.max-h-\[min\(65vh\,32rem\)\]{max-height:min(65vh,32rem)}.max-h-\[min\(70dvh\,70vh\)\]{max-height:min(70dvh,70vh)}.max-h-\[min\(90dvh\,40rem\)\]{max-height:min(90dvh,40rem)}.max-h-\[min\(90dvh\,48rem\)\]{max-height:min(90dvh,48rem)}.max-h-\[min\(320px\,55vh\)\]{max-height:min(320px,55vh)}.max-h-\[min\(420px\,70vh\)\]{max-height:min(420px,70vh)}.max-h-dvh{max-height:100dvh}.max-h-full{max-height:100%}.min-h-0{min-height:0}.min-h-4\.5{min-height:calc(var(--spacing) * 4.5)}.min-h-9{min-height:calc(var(--spacing) * 9)}.min-h-10{min-height:calc(var(--spacing) * 10)}.min-h-11{min-height:calc(var(--spacing) * 11)}.min-h-12{min-height:calc(var(--spacing) * 12)}.min-h-32{min-height:calc(var(--spacing) * 32)}.min-h-40{min-height:calc(var(--spacing) * 40)}.min-h-\[1\.25rem\]{min-height:1.25rem}.min-h-\[2\.5rem\]{min-height:2.5rem}.min-h-\[2\.25rem\]{min-height:2.25rem}.min-h-\[2rem\]{min-height:2rem}.min-h-\[4\.5rem\]{min-height:4.5rem}.min-h-\[5\.5rem\]{min-height:5.5rem}.min-h-\[5rem\]{min-height:5rem}.min-h-\[6\.5rem\]{min-height:6.5rem}.min-h-\[6rem\]{min-height:6rem}.min-h-\[9\.5rem\]{min-height:9.5rem}.min-h-\[10rem\]{min-height:10rem}.min-h-\[12rem\]{min-height:12rem}.min-h-\[28px\]{min-height:28px}.min-h-\[40px\]{min-height:40px}.min-h-\[44px\]{min-height:44px}.min-h-\[48px\]{min-height:48px}.min-h-\[54px\]{min-height:54px}.min-h-\[72px\]{min-height:72px}.min-h-\[88px\]{min-height:88px}.min-h-\[96px\]{min-height:96px}.min-h-\[120px\]{min-height:120px}.min-h-\[140px\]{min-height:140px}.min-h-\[180px\]{min-height:180px}.min-h-\[200px\]{min-height:200px}.min-h-\[240px\]{min-height:240px}.min-h-\[320px\]{min-height:320px}.min-h-\[400px\]{min-height:400px}.min-h-\[min\(50vh\,18rem\)\]{min-height:min(50vh,18rem)}.min-h-dvh{min-height:100dvh}.min-h-full{min-height:100%}.min-h-screen{min-height:100vh}.w-0\.5{width:calc(var(--spacing) * .5)}.w-1{width:var(--spacing)}.w-1\/2{width:50%}.w-1\/3{width:33.3333%}.w-2{width:calc(var(--spacing) * 2)}.w-3{width:calc(var(--spacing) * 3)}.w-3\.5{width:calc(var(--spacing) * 3.5)}.w-4{width:calc(var(--spacing) * 4)}.w-5{width:calc(var(--spacing) * 5)}.w-6{width:calc(var(--spacing) * 6)}.w-7{width:calc(var(--spacing) * 7)}.w-8{width:calc(var(--spacing) * 8)}.w-9{width:calc(var(--spacing) * 9)}.w-10{width:calc(var(--spacing) * 10)}.w-11{width:calc(var(--spacing) * 11)}.w-12{width:calc(var(--spacing) * 12)}.w-13{width:calc(var(--spacing) * 13)}.w-14{width:calc(var(--spacing) * 14)}.w-16{width:calc(var(--spacing) * 16)}.w-20{width:calc(var(--spacing) * 20)}.w-24{width:calc(var(--spacing) * 24)}.w-28{width:calc(var(--spacing) * 28)}.w-32{width:calc(var(--spacing) * 32)}.w-40{width:calc(var(--spacing) * 40)}.w-44{width:calc(var(--spacing) * 44)}.w-48{width:calc(var(--spacing) * 48)}.w-52{width:calc(var(--spacing) * 52)}.w-56{width:calc(var(--spacing) * 56)}.w-64{width:calc(var(--spacing) * 64)}.w-72{width:calc(var(--spacing) * 72)}.w-80{width:calc(var(--spacing) * 80)}.w-96{width:calc(var(--spacing) * 96)}.w-\[2px\]{width:2px}.w-\[42\%\]{width:42%}.w-\[calc\(100\%-2rem\)\]{width:calc(100% - 2rem)}.w-\[min\(20rem\,calc\(100\%-1\.5rem\)\)\]{width:min(20rem,100% - 1.5rem)}.w-\[min\(24rem\,calc\(100\%-1rem\)\)\]{width:min(24rem,100% - 1rem)}.w-\[min\(100vw-2rem\,24rem\)\]{width:min(100vw - 2rem,24rem)}.w-\[min\(320px\,85vw\)\]{width:min(320px,85vw)}.w-\[min\(calc\(100\%-1\.25rem\)\,24rem\)\]{width:min(100% - 1.25rem,24rem)}.w-auto{width:auto}.w-fit{width:fit-content}.w-full{width:100%}.w-full\!{width:100%!important}.w-max{width:max-content}.w-px{width:1px}.max-w-2xl{max-width:var(--container-2xl)}.max-w-3xl{max-width:var(--container-3xl)}.max-w-4xl{max-width:var(--container-4xl)}.max-w-5xl{max-width:var(--container-5xl)}.max-w-6xl{max-width:var(--container-6xl)}.max-w-7xl{max-width:var(--container-7xl)}.max-w-16{max-width:calc(var(--spacing) * 16)}.max-w-18{max-width:calc(var(--spacing) * 18)}.max-w-32{max-width:calc(var(--spacing) * 32)}.max-w-40{max-width:calc(var(--spacing) * 40)}.max-w-\[5rem\]{max-width:5rem}.max-w-\[7\.5rem\]{max-width:7.5rem}.max-w-\[8\.5rem\]{max-width:8.5rem}.max-w-\[8rem\]{max-width:8rem}.max-w-\[12rem\]{max-width:12rem}.max-w-\[14rem\]{max-width:14rem}.max-w-\[16rem\]{max-width:16rem}.max-w-\[18rem\]{max-width:18rem}.max-w-\[20rem\]{max-width:20rem}.max-w-\[45\%\]{max-width:45%}.max-w-\[70\%\]{max-width:70%}.max-w-\[85\%\]{max-width:85%}.max-w-\[90\%\]{max-width:90%}.max-w-\[98vw\]{max-width:98vw}.max-w-\[100px\]{max-width:100px}.max-w-\[120px\]{max-width:120px}.max-w-\[160px\]{max-width:160px}.max-w-\[200px\]{max-width:200px}.max-w-\[260px\]{max-width:260px}.max-w-\[280px\]{max-width:280px}.max-w-\[1920px\]{max-width:1920px}.max-w-\[calc\(100\%-0\.75rem\)\]{max-width:calc(100% - .75rem)}.max-w-\[min\(18rem\,100\%\)\]{max-width:min(18rem,100%)}.max-w-\[min\(20rem\,85vw\)\]{max-width:min(20rem,85vw)}.max-w-\[min\(20rem\,100\%\)\]{max-width:min(20rem,100%)}.max-w-\[min\(20rem\,calc\(100vw-1\.5rem\)\)\]{max-width:min(20rem,100vw - 1.5rem)}.max-w-\[min\(22rem\,calc\(100vw-2rem\)\)\]{max-width:min(22rem,100vw - 2rem)}.max-w-\[min\(40vw\,12rem\)\]{max-width:min(40vw,12rem)}.max-w-\[min\(55vw\,14rem\)\]{max-width:min(55vw,14rem)}.max-w-\[min\(100\%\,12rem\)\]{max-width:min(100%,12rem)}.max-w-\[min\(100\%\,14rem\)\]{max-width:min(100%,14rem)}.max-w-\[min\(100\%\,18rem\)\]{max-width:min(100%,18rem)}.max-w-\[min\(100\%\,42rem\)\]{max-width:min(100%,42rem)}.max-w-\[min\(100\%\,calc\(100\%-2rem\)\)\]{max-width:min(100%,100% - 2rem)}.max-w-\[min\(100vw-2rem\,22rem\)\]{max-width:min(100vw - 2rem,22rem)}.max-w-\[min\(100vw-2rem\,28rem\)\]{max-width:min(100vw - 2rem,28rem)}.max-w-\[min\(100vw-2rem\,36rem\)\]{max-width:min(100vw - 2rem,36rem)}.max-w-\[min\(280px\,85vw\)\]{max-width:min(280px,85vw)}.max-w-\[min\(280px\,100\%\)\]{max-width:min(280px,100%)}.max-w-full{max-width:100%}.max-w-lg{max-width:var(--container-lg)}.max-w-md{max-width:var(--container-md)}.max-w-none{max-width:none}.max-w-sm{max-width:var(--container-sm)}.max-w-xl{max-width:var(--container-xl)}.max-w-xs{max-width:var(--container-xs)}.min-w-0{min-width:0}.min-w-4{min-width:calc(var(--spacing) * 4)}.min-w-4\.5{min-width:calc(var(--spacing) * 4.5)}.min-w-5{min-width:calc(var(--spacing) * 5)}.min-w-9{min-width:calc(var(--spacing) * 9)}.min-w-11{min-width:calc(var(--spacing) * 11)}.min-w-16{min-width:calc(var(--spacing) * 16)}.min-w-40{min-width:calc(var(--spacing) * 40)}.min-w-44{min-width:calc(var(--spacing) * 44)}.min-w-48{min-width:calc(var(--spacing) * 48)}.min-w-52{min-width:calc(var(--spacing) * 52)}.min-w-56{min-width:calc(var(--spacing) * 56)}.min-w-\[1\.25rem\]{min-width:1.25rem}.min-w-\[1\.125rem\]{min-width:1.125rem}.min-w-\[5\.5rem\]{min-width:5.5rem}.min-w-\[6\.5rem\]{min-width:6.5rem}.min-w-\[7rem\]{min-width:7rem}.min-w-\[10rem\]{min-width:10rem}.min-w-\[11rem\]{min-width:11rem}.min-w-\[12rem\]{min-width:12rem}.min-w-\[14px\]{min-width:14px}.min-w-\[14rem\]{min-width:14rem}.min-w-\[16rem\]{min-width:16rem}.min-w-\[28px\]{min-width:28px}.min-w-\[40px\]{min-width:40px}.min-w-\[44px\]{min-width:44px}.min-w-\[80px\]{min-width:80px}.min-w-\[120px\]{min-width:120px}.min-w-\[125px\]{min-width:125px}.min-w-\[160px\]{min-width:160px}.min-w-\[200px\]{min-width:200px}.min-w-fit{min-width:fit-content}.min-w-full{min-width:100%}.flex-1{flex:1}.flex-2{flex:2}.flex-shrink{flex-shrink:1}.flex-shrink-0{flex-shrink:0}.shrink{flex-shrink:1}.shrink-0{flex-shrink:0}.flex-grow,.grow{flex-grow:1}.basis-0{flex-basis:0}.table-auto{table-layout:auto}.table-fixed{table-layout:fixed}.caption-bottom{caption-side:bottom}.caption-top{caption-side:top}.border-collapse{border-collapse:collapse}.border-separate{border-collapse:separate}.origin-center{transform-origin:50%}.-translate-x-1\/2{--tw-translate-x:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.-translate-x-full{--tw-translate-x:-100%;translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-x-0{--tw-translate-x:0px;translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-x-5{--tw-translate-x:calc(var(--spacing) * 5);translate:var(--tw-translate-x) var(--tw-translate-y)}.-translate-y-1\/2{--tw-translate-y:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.-translate-y-full{--tw-translate-y:-100%;translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-y-0{--tw-translate-y:0px;translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-y-1\/2{--tw-translate-y:calc(1 / 2 * 100%);translate:var(--tw-translate-x) var(--tw-translate-y)}.scale-95{--tw-scale-x:95%;--tw-scale-y:95%;--tw-scale-z:95%;scale:var(--tw-scale-x) var(--tw-scale-y)}.scale-100{--tw-scale-x:100%;--tw-scale-y:100%;--tw-scale-z:100%;scale:var(--tw-scale-x) var(--tw-scale-y)}.scale-\[1\.02\]{scale:1.02}.rotate-12{rotate:12deg}.rotate-90{rotate:90deg}.rotate-135{rotate:135deg}.rotate-180{rotate:180deg}.transform,.transform-cpu{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}.transform-gpu{transform:translateZ(0) var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}.transform-none{transform:none}.animate-bounce{animation:var(--animate-bounce)}.animate-ping{animation:var(--animate-ping)}.animate-pulse{animation:var(--animate-pulse)}.animate-spin{animation:var(--animate-spin)}.cursor-col-resize{cursor:col-resize}.cursor-copy{cursor:copy}.cursor-crosshair{cursor:crosshair}.cursor-default{cursor:default}.cursor-ew-resize{cursor:ew-resize}.cursor-grab{cursor:grab}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.touch-pan-left{--tw-pan-x:pan-left;touch-action:var(--tw-pan-x,) var(--tw-pan-y,) var(--tw-pinch-zoom,)}.touch-pan-right{--tw-pan-x:pan-right;touch-action:var(--tw-pan-x,) var(--tw-pan-y,) var(--tw-pinch-zoom,)}.touch-pan-x{--tw-pan-x:pan-x;touch-action:var(--tw-pan-x,) var(--tw-pan-y,) var(--tw-pinch-zoom,)}.touch-pan-down{--tw-pan-y:pan-down;touch-action:var(--tw-pan-x,) var(--tw-pan-y,) var(--tw-pinch-zoom,)}.touch-pan-up{--tw-pan-y:pan-up;touch-action:var(--tw-pan-x,) var(--tw-pan-y,) var(--tw-pinch-zoom,)}.touch-pan-y{--tw-pan-y:pan-y;touch-action:var(--tw-pan-x,) var(--tw-pan-y,) var(--tw-pinch-zoom,)}.touch-pinch-zoom{--tw-pinch-zoom:pinch-zoom;touch-action:var(--tw-pan-x,) var(--tw-pan-y,) var(--tw-pinch-zoom,)}.touch-auto{touch-action:auto}.touch-manipulation{touch-action:manipulation}.touch-none{touch-action:none}.resize{resize:both}.resize-none{resize:none}.resize-x{resize:horizontal}.resize-y{resize:vertical}.snap-both{scroll-snap-type:both var(--tw-scroll-snap-strictness)}.snap-none{scroll-snap-type:none}.snap-x{scroll-snap-type:x var(--tw-scroll-snap-strictness)}.snap-y{scroll-snap-type:y var(--tw-scroll-snap-strictness)}.snap-mandatory{--tw-scroll-snap-strictness:mandatory}.snap-proximity{--tw-scroll-snap-strictness:proximity}.snap-align-none{scroll-snap-align:none}.snap-center{scroll-snap-align:center}.snap-end{scroll-snap-align:end}.snap-start{scroll-snap-align:start}.snap-always{scroll-snap-stop:always}.snap-normal{scroll-snap-stop:normal}.scroll-mt-24{scroll-margin-top:calc(var(--spacing) * 24)}.scrollbar-thin{scrollbar-width:thin}.list-inside{list-style-position:inside}.list-outside{list-style-position:outside}.list-decimal{list-style-type:decimal}.list-disc{list-style-type:disc}.list-none{list-style-type:none}.form-select{appearance:none;--tw-shadow:0 0 #0000;background-color:#fff;border-width:1px;border-color:oklch(55.1% .027 264.364);border-radius:0;padding:.5rem .75rem;font-size:1rem;line-height:1.5rem}.form-select:focus{outline-offset:2px;--tw-ring-inset:var(--tw-empty, );--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:oklch(54.6% .245 262.881);--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);border-color:oklch(54.6% .245 262.881);outline:2px solid #0000}.form-select{-webkit-print-color-adjust:exact;print-color-adjust:exact;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='oklch(55.1%25 0.027 264.364)' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e");background-position:right .5rem center;background-repeat:no-repeat;background-size:1.5em 1.5em;padding-right:2.5rem}.form-select:where([size]:not([size="1"])){background-image:initial;background-position:initial;background-repeat:unset;background-size:initial;print-color-adjust:unset;padding-right:.75rem}.form-textarea{appearance:none;--tw-shadow:0 0 #0000;background-color:#fff;border-width:1px;border-color:oklch(55.1% .027 264.364);border-radius:0;padding:.5rem .75rem;font-size:1rem;line-height:1.5rem}.form-textarea:focus{outline-offset:2px;--tw-ring-inset:var(--tw-empty, );--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:oklch(54.6% .245 262.881);--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);border-color:oklch(54.6% .245 262.881);outline:2px solid #0000}.form-textarea::placeholder{color:oklch(55.1% .027 264.364);opacity:1}.form-multiselect{appearance:none;--tw-shadow:0 0 #0000;background-color:#fff;border-width:1px;border-color:oklch(55.1% .027 264.364);border-radius:0;padding:.5rem .75rem;font-size:1rem;line-height:1.5rem}.form-multiselect:focus{outline-offset:2px;--tw-ring-inset:var(--tw-empty, );--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:oklch(54.6% .245 262.881);--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);border-color:oklch(54.6% .245 262.881);outline:2px solid #0000}.appearance-auto{appearance:auto}.appearance-none{appearance:none}.break-before-all{break-before:all}.break-before-auto{break-before:auto}.break-before-avoid{break-before:avoid}.break-before-avoid-page{break-before:avoid-page}.break-before-column{break-before:column}.break-before-left{break-before:left}.break-before-page{break-before:page}.break-before-right{break-before:right}.break-inside-auto{break-inside:auto}.break-inside-avoid{break-inside:avoid}.break-inside-avoid-column{break-inside:avoid-column}.break-inside-avoid-page{break-inside:avoid-page}.break-after-all{break-after:all}.break-after-auto{break-after:auto}.break-after-avoid{break-after:avoid}.break-after-avoid-page{break-after:avoid-page}.break-after-column{break-after:column}.break-after-left{break-after:left}.break-after-page{break-after:page}.break-after-right{break-after:right}.grid-flow-col{grid-auto-flow:column}.grid-flow-col-dense{grid-auto-flow:column dense}.grid-flow-dense{grid-auto-flow:dense}.grid-flow-row{grid-auto-flow:row}.grid-flow-row-dense{grid-auto-flow:dense}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.grid-cols-5{grid-template-columns:repeat(5,minmax(0,1fr))}.grid-cols-6{grid-template-columns:repeat(6,minmax(0,1fr))}.grid-cols-\[minmax\(0\,38\%\)_1fr\]{grid-template-columns:minmax(0,38%) 1fr}.grid-rows-4{grid-template-rows:repeat(4,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-col-reverse{flex-direction:column-reverse}.flex-row{flex-direction:row}.flex-row-reverse{flex-direction:row-reverse}.flex-nowrap{flex-wrap:nowrap}.flex-wrap{flex-wrap:wrap}.flex-wrap-reverse{flex-wrap:wrap-reverse}.place-content-around{place-content:space-around}.place-content-baseline{place-content:baseline start}.place-content-between{place-content:space-between}.place-content-center{place-content:center}.place-content-end{place-content:end}.place-content-evenly{place-content:space-evenly}.place-content-start{place-content:start}.place-content-stretch{place-content:stretch}.place-items-baseline{place-items:baseline}.place-items-center{place-items:center}.place-items-end{place-items:end}.place-items-start{place-items:start}.place-items-stretch{place-items:stretch stretch}.content-around{align-content:space-around}.content-baseline{align-content:baseline}.content-between{align-content:space-between}.content-center{align-content:center}.content-end{align-content:flex-end}.content-evenly{align-content:space-evenly}.content-normal{align-content:normal}.content-start{align-content:flex-start}.content-stretch{align-content:stretch}.items-baseline{align-items:baseline}.items-center{align-items:center}.items-end{align-items:flex-end}.items-start{align-items:flex-start}.items-stretch{align-items:stretch}.justify-around{justify-content:space-around}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.justify-evenly{justify-content:space-evenly}.justify-normal{justify-content:normal}.justify-start{justify-content:flex-start}.justify-stretch{justify-content:stretch}.justify-items-center{justify-items:center}.justify-items-end{justify-items:end}.justify-items-start{justify-items:start}.justify-items-stretch{justify-items:stretch}.gap-0{gap:0}.gap-0\.5{gap:calc(var(--spacing) * .5)}.gap-1{gap:var(--spacing)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-2\.5{gap:calc(var(--spacing) * 2.5)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-5{gap:calc(var(--spacing) * 5)}.gap-6{gap:calc(var(--spacing) * 6)}.gap-8{gap:calc(var(--spacing) * 8)}.gap-10{gap:calc(var(--spacing) * 10)}:where(.space-y-0>:not(:last-child)){--tw-space-y-reverse:0;margin-block:0}:where(.space-y-0\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * .5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * .5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-1>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(var(--spacing) * var(--tw-space-y-reverse));margin-block-end:calc(var(--spacing) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-1\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 1.5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 1.5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2.5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2.5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-8>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 8) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 8) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-10>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 10) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 10) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-reverse>:not(:last-child)){--tw-space-y-reverse:1}.gap-x-1{column-gap:var(--spacing)}.gap-x-1\.5{column-gap:calc(var(--spacing) * 1.5)}.gap-x-2{column-gap:calc(var(--spacing) * 2)}.gap-x-3{column-gap:calc(var(--spacing) * 3)}.gap-x-4{column-gap:calc(var(--spacing) * 4)}.gap-x-6{column-gap:calc(var(--spacing) * 6)}:where(.space-x-1>:not(:last-child)){--tw-space-x-reverse:0;margin-inline-start:calc(var(--spacing) * var(--tw-space-x-reverse));margin-inline-end:calc(var(--spacing) * calc(1 - var(--tw-space-x-reverse)))}:where(.space-x-1\.5>:not(:last-child)){--tw-space-x-reverse:0;margin-inline-start:calc(calc(var(--spacing) * 1.5) * var(--tw-space-x-reverse));margin-inline-end:calc(calc(var(--spacing) * 1.5) * calc(1 - var(--tw-space-x-reverse)))}:where(.space-x-2>:not(:last-child)){--tw-space-x-reverse:0;margin-inline-start:calc(calc(var(--spacing) * 2) * var(--tw-space-x-reverse));margin-inline-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-x-reverse)))}:where(.space-x-3>:not(:last-child)){--tw-space-x-reverse:0;margin-inline-start:calc(calc(var(--spacing) * 3) * var(--tw-space-x-reverse));margin-inline-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-x-reverse)))}:where(.space-x-4>:not(:last-child)){--tw-space-x-reverse:0;margin-inline-start:calc(calc(var(--spacing) * 4) * var(--tw-space-x-reverse));margin-inline-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-x-reverse)))}:where(.space-x-reverse>:not(:last-child)){--tw-space-x-reverse:1}.gap-y-0\.5{row-gap:calc(var(--spacing) * .5)}.gap-y-1{row-gap:var(--spacing)}.gap-y-2{row-gap:calc(var(--spacing) * 2)}.gap-y-3{row-gap:calc(var(--spacing) * 3)}.gap-y-4{row-gap:calc(var(--spacing) * 4)}:where(.divide-x>:not(:last-child)){--tw-divide-x-reverse:0;border-inline-style:var(--tw-border-style);border-inline-start-width:calc(1px * var(--tw-divide-x-reverse));border-inline-end-width:calc(1px * calc(1 - var(--tw-divide-x-reverse)))}:where(.divide-x-0>:not(:last-child)){--tw-divide-x-reverse:0;border-inline-style:var(--tw-border-style);border-inline-start-width:calc(0px * var(--tw-divide-x-reverse));border-inline-end-width:calc(0px * calc(1 - var(--tw-divide-x-reverse)))}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-y-reverse>:not(:last-child)){--tw-divide-y-reverse:1}:where(.divide-dashed>:not(:last-child)){--tw-border-style:dashed;border-style:dashed}:where(.divide-dotted>:not(:last-child)){--tw-border-style:dotted;border-style:dotted}:where(.divide-double>:not(:last-child)){--tw-border-style:double;border-style:double}:where(.divide-none>:not(:last-child)){--tw-border-style:none;border-style:none}:where(.divide-solid>:not(:last-child)){--tw-border-style:solid;border-style:solid}:where(.divide-gray-100>:not(:last-child)){border-color:var(--color-gray-100)}:where(.divide-gray-200>:not(:last-child)){border-color:var(--color-gray-200)}:where(.divide-gray-200\/60>:not(:last-child)){border-color:#e5e7eb99}@supports (color:color-mix(in lab, red, red)){:where(.divide-gray-200\/60>:not(:last-child)){border-color:color-mix(in oklab, var(--color-gray-200) 60%, transparent)}}:where(.divide-gray-300>:not(:last-child)){border-color:var(--color-gray-300)}.place-self-auto{place-self:auto}.place-self-center{place-self:center}.place-self-end{place-self:end}.place-self-start{place-self:start}.place-self-stretch{place-self:stretch stretch}.self-auto{align-self:auto}.self-baseline{align-self:baseline}.self-center{align-self:center}.self-end{align-self:flex-end}.self-start{align-self:flex-start}.self-stretch{align-self:stretch}.justify-self-auto{justify-self:auto}.justify-self-center{justify-self:center}.justify-self-end{justify-self:flex-end}.justify-self-start{justify-self:flex-start}.justify-self-stretch{justify-self:stretch}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-auto{overflow:auto}.overflow-clip{overflow:clip}.overflow-hidden{overflow:hidden}.overflow-scroll{overflow:scroll}.overflow-visible{overflow:visible}.overflow-x-auto{overflow-x:auto}.overflow-x-clip{overflow-x:clip}.overflow-x-hidden{overflow-x:hidden}.overflow-x-scroll{overflow-x:scroll}.overflow-x-visible{overflow-x:visible}.overflow-y-auto{overflow-y:auto}.overflow-y-clip{overflow-y:clip}.overflow-y-hidden{overflow-y:hidden}.overflow-y-scroll{overflow-y:scroll}.overflow-y-visible{overflow-y:visible}.overscroll-auto{overscroll-behavior:auto}.overscroll-contain{overscroll-behavior:contain}.overscroll-none{overscroll-behavior:none}.overscroll-x-auto{overscroll-behavior-x:auto}.overscroll-x-contain{overscroll-behavior-x:contain}.overscroll-x-none{overscroll-behavior-x:none}.overscroll-y-auto{overscroll-behavior-y:auto}.overscroll-y-contain{overscroll-behavior-y:contain}.overscroll-y-none{overscroll-behavior-y:none}.scroll-auto{scroll-behavior:auto}.scroll-smooth{scroll-behavior:smooth}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:var(--radius-2xl)}.rounded-2xl\!{border-radius:var(--radius-2xl)!important}.rounded-3xl{border-radius:var(--radius-3xl)}.rounded-4xl{border-radius:var(--radius-4xl)}.rounded-\[3rem\]{border-radius:3rem}.rounded-\[10px\]{border-radius:10px}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-lg\!{border-radius:var(--radius-lg)!important}.rounded-md{border-radius:var(--radius-md)}.rounded-none{border-radius:0}.rounded-sm{border-radius:var(--radius-sm)}.rounded-xl{border-radius:var(--radius-xl)}.rounded-xl\!{border-radius:var(--radius-xl)!important}.rounded-xs{border-radius:var(--radius-xs)}.rounded-s{border-start-start-radius:.25rem;border-end-start-radius:.25rem}.rounded-ss{border-start-start-radius:.25rem}.rounded-e{border-start-end-radius:.25rem;border-end-end-radius:.25rem}.rounded-se{border-start-end-radius:.25rem}.rounded-ee{border-end-end-radius:.25rem}.rounded-es{border-end-start-radius:.25rem}.rounded-t{border-top-left-radius:.25rem;border-top-right-radius:.25rem}.rounded-t-2xl{border-top-left-radius:var(--radius-2xl);border-top-right-radius:var(--radius-2xl)}.rounded-t-lg{border-top-left-radius:var(--radius-lg);border-top-right-radius:var(--radius-lg)}.rounded-l{border-top-left-radius:.25rem;border-bottom-left-radius:.25rem}.rounded-l-xl{border-top-left-radius:var(--radius-xl);border-bottom-left-radius:var(--radius-xl)}.rounded-tl{border-top-left-radius:.25rem}.rounded-r{border-top-right-radius:.25rem;border-bottom-right-radius:.25rem}.rounded-r-full{border-top-right-radius:3.40282e38px;border-bottom-right-radius:3.40282e38px}.rounded-r-lg{border-top-right-radius:var(--radius-lg);border-bottom-right-radius:var(--radius-lg)}.rounded-r-xl{border-top-right-radius:var(--radius-xl);border-bottom-right-radius:var(--radius-xl)}.rounded-tr{border-top-right-radius:.25rem}.rounded-b{border-bottom-right-radius:.25rem;border-bottom-left-radius:.25rem}.rounded-br{border-bottom-right-radius:.25rem}.rounded-bl{border-bottom-left-radius:.25rem}.border{border-style:var(--tw-border-style);border-width:1px}.border\!{border-style:var(--tw-border-style)!important;border-width:1px!important}.border-0{border-style:var(--tw-border-style);border-width:0}.border-2{border-style:var(--tw-border-style);border-width:2px}.border-2\!{border-style:var(--tw-border-style)!important;border-width:2px!important}.border-4{border-style:var(--tw-border-style);border-width:4px}.border-x{border-inline-style:var(--tw-border-style);border-inline-width:1px}.border-x-2{border-inline-style:var(--tw-border-style);border-inline-width:2px}.border-y{border-block-style:var(--tw-border-style);border-block-width:1px}.border-s{border-inline-start-style:var(--tw-border-style);border-inline-start-width:1px}.border-e{border-inline-end-style:var(--tw-border-style);border-inline-end-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-t-2{border-top-style:var(--tw-border-style);border-top-width:2px}.border-r{border-right-style:var(--tw-border-style);border-right-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-b-2{border-bottom-style:var(--tw-border-style);border-bottom-width:2px}.border-l{border-left-style:var(--tw-border-style);border-left-width:1px}.border-l-2{border-left-style:var(--tw-border-style);border-left-width:2px}.border-l-4{border-left-style:var(--tw-border-style);border-left-width:4px}.border-dashed{--tw-border-style:dashed;border-style:dashed}.border-dotted{--tw-border-style:dotted;border-style:dotted}.border-double{--tw-border-style:double;border-style:double}.border-hidden{--tw-border-style:hidden;border-style:hidden}.border-none{--tw-border-style:none;border-style:none}.border-none\!{--tw-border-style:none!important;border-style:none!important}.border-solid{--tw-border-style:solid;border-style:solid}.border-\[var\(--mc-border-strong\)\]{border-color:var(--mc-border-strong)}.border-amber-100{border-color:var(--color-amber-100)}.border-amber-200{border-color:var(--color-amber-200)}.border-amber-200\/50{border-color:#fee68580}@supports (color:color-mix(in lab, red, red)){.border-amber-200\/50{border-color:color-mix(in oklab, var(--color-amber-200) 50%, transparent)}}.border-amber-200\/70{border-color:#fee685b3}@supports (color:color-mix(in lab, red, red)){.border-amber-200\/70{border-color:color-mix(in oklab, var(--color-amber-200) 70%, transparent)}}.border-amber-200\/80{border-color:#fee685cc}@supports (color:color-mix(in lab, red, red)){.border-amber-200\/80{border-color:color-mix(in oklab, var(--color-amber-200) 80%, transparent)}}.border-amber-200\/85{border-color:#fee685d9}@supports (color:color-mix(in lab, red, red)){.border-amber-200\/85{border-color:color-mix(in oklab, var(--color-amber-200) 85%, transparent)}}.border-amber-200\/90{border-color:#fee685e6}@supports (color:color-mix(in lab, red, red)){.border-amber-200\/90{border-color:color-mix(in oklab, var(--color-amber-200) 90%, transparent)}}.border-amber-300{border-color:var(--color-amber-300)}.border-amber-400{border-color:var(--color-amber-400)}.border-amber-500{border-color:var(--color-amber-500)}.border-amber-500\/30{border-color:#f99c004d}@supports (color:color-mix(in lab, red, red)){.border-amber-500\/30{border-color:color-mix(in oklab, var(--color-amber-500) 30%, transparent)}}.border-amber-500\/35{border-color:#f99c0059}@supports (color:color-mix(in lab, red, red)){.border-amber-500\/35{border-color:color-mix(in oklab, var(--color-amber-500) 35%, transparent)}}.border-amber-500\/40{border-color:#f99c0066}@supports (color:color-mix(in lab, red, red)){.border-amber-500\/40{border-color:color-mix(in oklab, var(--color-amber-500) 40%, transparent)}}.border-amber-700\/80{border-color:#b75000cc}@supports (color:color-mix(in lab, red, red)){.border-amber-700\/80{border-color:color-mix(in oklab, var(--color-amber-700) 80%, transparent)}}.border-amber-800\/80{border-color:#953d00cc}@supports (color:color-mix(in lab, red, red)){.border-amber-800\/80{border-color:color-mix(in oklab, var(--color-amber-800) 80%, transparent)}}.border-black\/5{border-color:#0000000d}@supports (color:color-mix(in lab, red, red)){.border-black\/5{border-color:color-mix(in oklab, var(--color-black) 5%, transparent)}}.border-black\/8{border-color:#00000014}@supports (color:color-mix(in lab, red, red)){.border-black\/8{border-color:color-mix(in oklab, var(--color-black) 8%, transparent)}}.border-blue-100{border-color:var(--color-blue-100)}.border-blue-100\/50{border-color:#dbeafe80}@supports (color:color-mix(in lab, red, red)){.border-blue-100\/50{border-color:color-mix(in oklab, var(--color-blue-100) 50%, transparent)}}.border-blue-200{border-color:var(--color-blue-200)}.border-blue-200\/50{border-color:#bedbff80}@supports (color:color-mix(in lab, red, red)){.border-blue-200\/50{border-color:color-mix(in oklab, var(--color-blue-200) 50%, transparent)}}.border-blue-200\/60{border-color:#bedbff99}@supports (color:color-mix(in lab, red, red)){.border-blue-200\/60{border-color:color-mix(in oklab, var(--color-blue-200) 60%, transparent)}}.border-blue-300{border-color:var(--color-blue-300)}.border-blue-400{border-color:var(--color-blue-400)}.border-blue-500{border-color:var(--color-blue-500)}.border-blue-500\/10{border-color:#3080ff1a}@supports (color:color-mix(in lab, red, red)){.border-blue-500\/10{border-color:color-mix(in oklab, var(--color-blue-500) 10%, transparent)}}.border-blue-500\/20{border-color:#3080ff33}@supports (color:color-mix(in lab, red, red)){.border-blue-500\/20{border-color:color-mix(in oklab, var(--color-blue-500) 20%, transparent)}}.border-blue-500\/30{border-color:#3080ff4d}@supports (color:color-mix(in lab, red, red)){.border-blue-500\/30{border-color:color-mix(in oklab, var(--color-blue-500) 30%, transparent)}}.border-blue-500\/50{border-color:#3080ff80}@supports (color:color-mix(in lab, red, red)){.border-blue-500\/50{border-color:color-mix(in oklab, var(--color-blue-500) 50%, transparent)}}.border-blue-600{border-color:var(--color-blue-600)}.border-current,.border-current\/20{border-color:currentColor}@supports (color:color-mix(in lab, red, red)){.border-current\/20{border-color:color-mix(in oklab, currentcolor 20%, transparent)}}.border-cyan-200{border-color:var(--color-cyan-200)}.border-cyan-500\/50{border-color:#00b7d780}@supports (color:color-mix(in lab, red, red)){.border-cyan-500\/50{border-color:color-mix(in oklab, var(--color-cyan-500) 50%, transparent)}}.border-emerald-100{border-color:var(--color-emerald-100)}.border-emerald-100\/50{border-color:#d0fae580}@supports (color:color-mix(in lab, red, red)){.border-emerald-100\/50{border-color:color-mix(in oklab, var(--color-emerald-100) 50%, transparent)}}.border-emerald-200{border-color:var(--color-emerald-200)}.border-emerald-200\/60{border-color:#a4f4cf99}@supports (color:color-mix(in lab, red, red)){.border-emerald-200\/60{border-color:color-mix(in oklab, var(--color-emerald-200) 60%, transparent)}}.border-emerald-500{border-color:var(--color-emerald-500)}.border-emerald-500\/20{border-color:#00bb7f33}@supports (color:color-mix(in lab, red, red)){.border-emerald-500\/20{border-color:color-mix(in oklab, var(--color-emerald-500) 20%, transparent)}}.border-emerald-500\/35{border-color:#00bb7f59}@supports (color:color-mix(in lab, red, red)){.border-emerald-500\/35{border-color:color-mix(in oklab, var(--color-emerald-500) 35%, transparent)}}.border-emerald-800\/80{border-color:#005f46cc}@supports (color:color-mix(in lab, red, red)){.border-emerald-800\/80{border-color:color-mix(in oklab, var(--color-emerald-800) 80%, transparent)}}.border-gray-100{border-color:var(--color-gray-100)}.border-gray-100\/50{border-color:#f3f4f680}@supports (color:color-mix(in lab, red, red)){.border-gray-100\/50{border-color:color-mix(in oklab, var(--color-gray-100) 50%, transparent)}}.border-gray-100\/80{border-color:#f3f4f6cc}@supports (color:color-mix(in lab, red, red)){.border-gray-100\/80{border-color:color-mix(in oklab, var(--color-gray-100) 80%, transparent)}}.border-gray-200{border-color:var(--color-gray-200)}.border-gray-200\!{border-color:var(--color-gray-200)!important}.border-gray-200\/50{border-color:#e5e7eb80}@supports (color:color-mix(in lab, red, red)){.border-gray-200\/50{border-color:color-mix(in oklab, var(--color-gray-200) 50%, transparent)}}.border-gray-200\/60{border-color:#e5e7eb99}@supports (color:color-mix(in lab, red, red)){.border-gray-200\/60{border-color:color-mix(in oklab, var(--color-gray-200) 60%, transparent)}}.border-gray-200\/70{border-color:#e5e7ebb3}@supports (color:color-mix(in lab, red, red)){.border-gray-200\/70{border-color:color-mix(in oklab, var(--color-gray-200) 70%, transparent)}}.border-gray-200\/80{border-color:#e5e7ebcc}@supports (color:color-mix(in lab, red, red)){.border-gray-200\/80{border-color:color-mix(in oklab, var(--color-gray-200) 80%, transparent)}}.border-gray-200\/90{border-color:#e5e7ebe6}@supports (color:color-mix(in lab, red, red)){.border-gray-200\/90{border-color:color-mix(in oklab, var(--color-gray-200) 90%, transparent)}}.border-gray-300{border-color:var(--color-gray-300)}.border-gray-400{border-color:var(--color-gray-400)}.border-gray-500{border-color:var(--color-gray-500)}.border-gray-700{border-color:var(--color-gray-700)}.border-green-200{border-color:var(--color-green-200)}.border-green-500\/30{border-color:#00c7584d}@supports (color:color-mix(in lab, red, red)){.border-green-500\/30{border-color:color-mix(in oklab, var(--color-green-500) 30%, transparent)}}.border-indigo-200{border-color:var(--color-indigo-200)}.border-indigo-200\/60{border-color:#c7d2ff99}@supports (color:color-mix(in lab, red, red)){.border-indigo-200\/60{border-color:color-mix(in oklab, var(--color-indigo-200) 60%, transparent)}}.border-indigo-300{border-color:var(--color-indigo-300)}.border-indigo-500{border-color:var(--color-indigo-500)}.border-indigo-500\/20{border-color:#625fff33}@supports (color:color-mix(in lab, red, red)){.border-indigo-500\/20{border-color:color-mix(in oklab, var(--color-indigo-500) 20%, transparent)}}.border-orange-200{border-color:var(--color-orange-200)}.border-pink-100{border-color:var(--color-pink-100)}.border-pink-200{border-color:var(--color-pink-200)}.border-purple-200{border-color:var(--color-purple-200)}.border-purple-500\/20{border-color:#ac4bff33}@supports (color:color-mix(in lab, red, red)){.border-purple-500\/20{border-color:color-mix(in oklab, var(--color-purple-500) 20%, transparent)}}.border-purple-500\/50{border-color:#ac4bff80}@supports (color:color-mix(in lab, red, red)){.border-purple-500\/50{border-color:color-mix(in oklab, var(--color-purple-500) 50%, transparent)}}.border-purple-500\/60{border-color:#ac4bff99}@supports (color:color-mix(in lab, red, red)){.border-purple-500\/60{border-color:color-mix(in oklab, var(--color-purple-500) 60%, transparent)}}.border-red-100{border-color:var(--color-red-100)}.border-red-200{border-color:var(--color-red-200)}.border-red-200\!{border-color:var(--color-red-200)!important}.border-red-200\/60{border-color:#ffcaca99}@supports (color:color-mix(in lab, red, red)){.border-red-200\/60{border-color:color-mix(in oklab, var(--color-red-200) 60%, transparent)}}.border-red-200\/70{border-color:#ffcacab3}@supports (color:color-mix(in lab, red, red)){.border-red-200\/70{border-color:color-mix(in oklab, var(--color-red-200) 70%, transparent)}}.border-red-200\/80{border-color:#ffcacacc}@supports (color:color-mix(in lab, red, red)){.border-red-200\/80{border-color:color-mix(in oklab, var(--color-red-200) 80%, transparent)}}.border-red-200\/90{border-color:#ffcacae6}@supports (color:color-mix(in lab, red, red)){.border-red-200\/90{border-color:color-mix(in oklab, var(--color-red-200) 90%, transparent)}}.border-red-300{border-color:var(--color-red-300)}.border-red-400{border-color:var(--color-red-400)}.border-red-500{border-color:var(--color-red-500)}.border-red-500\/20{border-color:#fb2c3633}@supports (color:color-mix(in lab, red, red)){.border-red-500\/20{border-color:color-mix(in oklab, var(--color-red-500) 20%, transparent)}}.border-red-500\/30{border-color:#fb2c364d}@supports (color:color-mix(in lab, red, red)){.border-red-500\/30{border-color:color-mix(in oklab, var(--color-red-500) 30%, transparent)}}.border-red-800\/80{border-color:#9f0712cc}@supports (color:color-mix(in lab, red, red)){.border-red-800\/80{border-color:color-mix(in oklab, var(--color-red-800) 80%, transparent)}}.border-rose-500\/20{border-color:#ff235733}@supports (color:color-mix(in lab, red, red)){.border-rose-500\/20{border-color:color-mix(in oklab, var(--color-rose-500) 20%, transparent)}}.border-sky-200{border-color:var(--color-sky-200)}.border-sky-200\/90{border-color:#b8e6fee6}@supports (color:color-mix(in lab, red, red)){.border-sky-200\/90{border-color:color-mix(in oklab, var(--color-sky-200) 90%, transparent)}}.border-sky-900\/80{border-color:#024a70cc}@supports (color:color-mix(in lab, red, red)){.border-sky-900\/80{border-color:color-mix(in oklab, var(--color-sky-900) 80%, transparent)}}.border-slate-200{border-color:var(--color-slate-200)}.border-slate-200\/80{border-color:#e2e8f0cc}@supports (color:color-mix(in lab, red, red)){.border-slate-200\/80{border-color:color-mix(in oklab, var(--color-slate-200) 80%, transparent)}}.border-teal-200{border-color:var(--color-teal-200)}.border-teal-300\!{border-color:var(--color-teal-300)!important}.border-teal-500{border-color:var(--color-teal-500)}.border-transparent{border-color:#0000}.border-violet-200{border-color:var(--color-violet-200)}.border-white{border-color:var(--color-white)}.border-white\/10{border-color:#ffffff1a}@supports (color:color-mix(in lab, red, red)){.border-white\/10{border-color:color-mix(in oklab, var(--color-white) 10%, transparent)}}.border-white\/20{border-color:#fff3}@supports (color:color-mix(in lab, red, red)){.border-white\/20{border-color:color-mix(in oklab, var(--color-white) 20%, transparent)}}.border-white\/40{border-color:#fff6}@supports (color:color-mix(in lab, red, red)){.border-white\/40{border-color:color-mix(in oklab, var(--color-white) 40%, transparent)}}.border-yellow-200{border-color:var(--color-yellow-200)}.border-yellow-300{border-color:var(--color-yellow-300)}.border-yellow-700\/50{border-color:#a3610080}@supports (color:color-mix(in lab, red, red)){.border-yellow-700\/50{border-color:color-mix(in oklab, var(--color-yellow-700) 50%, transparent)}}.border-zinc-100{border-color:var(--color-zinc-100)}.border-zinc-200{border-color:var(--color-zinc-200)}.border-zinc-600\/50{border-color:#52525c80}@supports (color:color-mix(in lab, red, red)){.border-zinc-600\/50{border-color:color-mix(in oklab, var(--color-zinc-600) 50%, transparent)}}.border-zinc-700{border-color:var(--color-zinc-700)}.border-zinc-700\/80{border-color:#3f3f46cc}@supports (color:color-mix(in lab, red, red)){.border-zinc-700\/80{border-color:color-mix(in oklab, var(--color-zinc-700) 80%, transparent)}}.border-zinc-800{border-color:var(--color-zinc-800)}.border-zinc-900{border-color:var(--color-zinc-900)}.border-t-blue-500{border-top-color:var(--color-blue-500)}.border-t-current{border-top-color:currentColor}.border-t-indigo-600{border-top-color:var(--color-indigo-600)}.border-t-transparent{border-top-color:#0000}.border-t-white{border-top-color:var(--color-white)}.border-b-emerald-500{border-bottom-color:var(--color-emerald-500)}.border-b-transparent{border-bottom-color:#0000}.border-l-gray-300{border-left-color:var(--color-gray-300)}.border-l-green-500{border-left-color:var(--color-green-500)}.border-l-red-500{border-left-color:var(--color-red-500)}.bg-\[\#1a0b33\]{background-color:#1a0b33}.bg-\[\#2b1065\]{background-color:#2b1065}.bg-\[var\(--mc-surface\)\]{background-color:var(--mc-surface)}.bg-\[var\(--mc-surface-hover\)\]{background-color:var(--mc-surface-hover)}.bg-amber-50{background-color:var(--color-amber-50)}.bg-amber-50\/40{background-color:#fffbeb66}@supports (color:color-mix(in lab, red, red)){.bg-amber-50\/40{background-color:color-mix(in oklab, var(--color-amber-50) 40%, transparent)}}.bg-amber-50\/50{background-color:#fffbeb80}@supports (color:color-mix(in lab, red, red)){.bg-amber-50\/50{background-color:color-mix(in oklab, var(--color-amber-50) 50%, transparent)}}.bg-amber-50\/60{background-color:#fffbeb99}@supports (color:color-mix(in lab, red, red)){.bg-amber-50\/60{background-color:color-mix(in oklab, var(--color-amber-50) 60%, transparent)}}.bg-amber-50\/70{background-color:#fffbebb3}@supports (color:color-mix(in lab, red, red)){.bg-amber-50\/70{background-color:color-mix(in oklab, var(--color-amber-50) 70%, transparent)}}.bg-amber-50\/80{background-color:#fffbebcc}@supports (color:color-mix(in lab, red, red)){.bg-amber-50\/80{background-color:color-mix(in oklab, var(--color-amber-50) 80%, transparent)}}.bg-amber-50\/90{background-color:#fffbebe6}@supports (color:color-mix(in lab, red, red)){.bg-amber-50\/90{background-color:color-mix(in oklab, var(--color-amber-50) 90%, transparent)}}.bg-amber-100{background-color:var(--color-amber-100)}.bg-amber-100\/50{background-color:#fef3c680}@supports (color:color-mix(in lab, red, red)){.bg-amber-100\/50{background-color:color-mix(in oklab, var(--color-amber-100) 50%, transparent)}}.bg-amber-300{background-color:var(--color-amber-300)}.bg-amber-500{background-color:var(--color-amber-500)}.bg-amber-500\/5{background-color:#f99c000d}@supports (color:color-mix(in lab, red, red)){.bg-amber-500\/5{background-color:color-mix(in oklab, var(--color-amber-500) 5%, transparent)}}.bg-amber-500\/10{background-color:#f99c001a}@supports (color:color-mix(in lab, red, red)){.bg-amber-500\/10{background-color:color-mix(in oklab, var(--color-amber-500) 10%, transparent)}}.bg-amber-500\/90{background-color:#f99c00e6}@supports (color:color-mix(in lab, red, red)){.bg-amber-500\/90{background-color:color-mix(in oklab, var(--color-amber-500) 90%, transparent)}}.bg-amber-600{background-color:var(--color-amber-600)}.bg-amber-600\/40{background-color:#dd740066}@supports (color:color-mix(in lab, red, red)){.bg-amber-600\/40{background-color:color-mix(in oklab, var(--color-amber-600) 40%, transparent)}}.bg-amber-700{background-color:var(--color-amber-700)}.bg-black{background-color:var(--color-black)}.bg-black\/5{background-color:#0000000d}@supports (color:color-mix(in lab, red, red)){.bg-black\/5{background-color:color-mix(in oklab, var(--color-black) 5%, transparent)}}.bg-black\/10{background-color:#0000001a}@supports (color:color-mix(in lab, red, red)){.bg-black\/10{background-color:color-mix(in oklab, var(--color-black) 10%, transparent)}}.bg-black\/20{background-color:#0003}@supports (color:color-mix(in lab, red, red)){.bg-black\/20{background-color:color-mix(in oklab, var(--color-black) 20%, transparent)}}.bg-black\/40{background-color:#0006}@supports (color:color-mix(in lab, red, red)){.bg-black\/40{background-color:color-mix(in oklab, var(--color-black) 40%, transparent)}}.bg-black\/50{background-color:#00000080}@supports (color:color-mix(in lab, red, red)){.bg-black\/50{background-color:color-mix(in oklab, var(--color-black) 50%, transparent)}}.bg-black\/55{background-color:#0000008c}@supports (color:color-mix(in lab, red, red)){.bg-black\/55{background-color:color-mix(in oklab, var(--color-black) 55%, transparent)}}.bg-black\/60{background-color:#0009}@supports (color:color-mix(in lab, red, red)){.bg-black\/60{background-color:color-mix(in oklab, var(--color-black) 60%, transparent)}}.bg-black\/70{background-color:#000000b3}@supports (color:color-mix(in lab, red, red)){.bg-black\/70{background-color:color-mix(in oklab, var(--color-black) 70%, transparent)}}.bg-black\/75{background-color:#000000bf}@supports (color:color-mix(in lab, red, red)){.bg-black\/75{background-color:color-mix(in oklab, var(--color-black) 75%, transparent)}}.bg-black\/80{background-color:#000c}@supports (color:color-mix(in lab, red, red)){.bg-black\/80{background-color:color-mix(in oklab, var(--color-black) 80%, transparent)}}.bg-black\/\[0\.04\]{background-color:#0000000a}@supports (color:color-mix(in lab, red, red)){.bg-black\/\[0\.04\]{background-color:color-mix(in oklab, var(--color-black) 4%, transparent)}}.bg-blue-50{background-color:var(--color-blue-50)}.bg-blue-50\/20{background-color:#eff6ff33}@supports (color:color-mix(in lab, red, red)){.bg-blue-50\/20{background-color:color-mix(in oklab, var(--color-blue-50) 20%, transparent)}}.bg-blue-50\/30{background-color:#eff6ff4d}@supports (color:color-mix(in lab, red, red)){.bg-blue-50\/30{background-color:color-mix(in oklab, var(--color-blue-50) 30%, transparent)}}.bg-blue-50\/40{background-color:#eff6ff66}@supports (color:color-mix(in lab, red, red)){.bg-blue-50\/40{background-color:color-mix(in oklab, var(--color-blue-50) 40%, transparent)}}.bg-blue-50\/50{background-color:#eff6ff80}@supports (color:color-mix(in lab, red, red)){.bg-blue-50\/50{background-color:color-mix(in oklab, var(--color-blue-50) 50%, transparent)}}.bg-blue-50\/60{background-color:#eff6ff99}@supports (color:color-mix(in lab, red, red)){.bg-blue-50\/60{background-color:color-mix(in oklab, var(--color-blue-50) 60%, transparent)}}.bg-blue-50\/70{background-color:#eff6ffb3}@supports (color:color-mix(in lab, red, red)){.bg-blue-50\/70{background-color:color-mix(in oklab, var(--color-blue-50) 70%, transparent)}}.bg-blue-50\/80{background-color:#eff6ffcc}@supports (color:color-mix(in lab, red, red)){.bg-blue-50\/80{background-color:color-mix(in oklab, var(--color-blue-50) 80%, transparent)}}.bg-blue-50\/90{background-color:#eff6ffe6}@supports (color:color-mix(in lab, red, red)){.bg-blue-50\/90{background-color:color-mix(in oklab, var(--color-blue-50) 90%, transparent)}}.bg-blue-100{background-color:var(--color-blue-100)}.bg-blue-100\/50{background-color:#dbeafe80}@supports (color:color-mix(in lab, red, red)){.bg-blue-100\/50{background-color:color-mix(in oklab, var(--color-blue-100) 50%, transparent)}}.bg-blue-400{background-color:var(--color-blue-400)}.bg-blue-500{background-color:var(--color-blue-500)}.bg-blue-500\/5{background-color:#3080ff0d}@supports (color:color-mix(in lab, red, red)){.bg-blue-500\/5{background-color:color-mix(in oklab, var(--color-blue-500) 5%, transparent)}}.bg-blue-500\/10{background-color:#3080ff1a}@supports (color:color-mix(in lab, red, red)){.bg-blue-500\/10{background-color:color-mix(in oklab, var(--color-blue-500) 10%, transparent)}}.bg-blue-500\/20{background-color:#3080ff33}@supports (color:color-mix(in lab, red, red)){.bg-blue-500\/20{background-color:color-mix(in oklab, var(--color-blue-500) 20%, transparent)}}.bg-blue-500\/30{background-color:#3080ff4d}@supports (color:color-mix(in lab, red, red)){.bg-blue-500\/30{background-color:color-mix(in oklab, var(--color-blue-500) 30%, transparent)}}.bg-blue-600{background-color:var(--color-blue-600)}.bg-current{background-color:currentColor}.bg-cyan-50{background-color:var(--color-cyan-50)}.bg-cyan-100{background-color:var(--color-cyan-100)}.bg-cyan-500\/10{background-color:#00b7d71a}@supports (color:color-mix(in lab, red, red)){.bg-cyan-500\/10{background-color:color-mix(in oklab, var(--color-cyan-500) 10%, transparent)}}.bg-emerald-50{background-color:var(--color-emerald-50)}.bg-emerald-50\/20{background-color:#ecfdf533}@supports (color:color-mix(in lab, red, red)){.bg-emerald-50\/20{background-color:color-mix(in oklab, var(--color-emerald-50) 20%, transparent)}}.bg-emerald-50\/50{background-color:#ecfdf580}@supports (color:color-mix(in lab, red, red)){.bg-emerald-50\/50{background-color:color-mix(in oklab, var(--color-emerald-50) 50%, transparent)}}.bg-emerald-100{background-color:var(--color-emerald-100)}.bg-emerald-500{background-color:var(--color-emerald-500)}.bg-emerald-500\/5{background-color:#00bb7f0d}@supports (color:color-mix(in lab, red, red)){.bg-emerald-500\/5{background-color:color-mix(in oklab, var(--color-emerald-500) 5%, transparent)}}.bg-emerald-500\/10{background-color:#00bb7f1a}@supports (color:color-mix(in lab, red, red)){.bg-emerald-500\/10{background-color:color-mix(in oklab, var(--color-emerald-500) 10%, transparent)}}.bg-emerald-500\/15{background-color:#00bb7f26}@supports (color:color-mix(in lab, red, red)){.bg-emerald-500\/15{background-color:color-mix(in oklab, var(--color-emerald-500) 15%, transparent)}}.bg-emerald-500\/20{background-color:#00bb7f33}@supports (color:color-mix(in lab, red, red)){.bg-emerald-500\/20{background-color:color-mix(in oklab, var(--color-emerald-500) 20%, transparent)}}.bg-emerald-600{background-color:var(--color-emerald-600)}.bg-emerald-700{background-color:var(--color-emerald-700)}.bg-gray-50{background-color:var(--color-gray-50)}.bg-gray-50\/30{background-color:#f9fafb4d}@supports (color:color-mix(in lab, red, red)){.bg-gray-50\/30{background-color:color-mix(in oklab, var(--color-gray-50) 30%, transparent)}}.bg-gray-50\/40{background-color:#f9fafb66}@supports (color:color-mix(in lab, red, red)){.bg-gray-50\/40{background-color:color-mix(in oklab, var(--color-gray-50) 40%, transparent)}}.bg-gray-50\/50{background-color:#f9fafb80}@supports (color:color-mix(in lab, red, red)){.bg-gray-50\/50{background-color:color-mix(in oklab, var(--color-gray-50) 50%, transparent)}}.bg-gray-50\/60{background-color:#f9fafb99}@supports (color:color-mix(in lab, red, red)){.bg-gray-50\/60{background-color:color-mix(in oklab, var(--color-gray-50) 60%, transparent)}}.bg-gray-50\/70{background-color:#f9fafbb3}@supports (color:color-mix(in lab, red, red)){.bg-gray-50\/70{background-color:color-mix(in oklab, var(--color-gray-50) 70%, transparent)}}.bg-gray-50\/80{background-color:#f9fafbcc}@supports (color:color-mix(in lab, red, red)){.bg-gray-50\/80{background-color:color-mix(in oklab, var(--color-gray-50) 80%, transparent)}}.bg-gray-50\/90{background-color:#f9fafbe6}@supports (color:color-mix(in lab, red, red)){.bg-gray-50\/90{background-color:color-mix(in oklab, var(--color-gray-50) 90%, transparent)}}.bg-gray-50\/95{background-color:#f9fafbf2}@supports (color:color-mix(in lab, red, red)){.bg-gray-50\/95{background-color:color-mix(in oklab, var(--color-gray-50) 95%, transparent)}}.bg-gray-100{background-color:var(--color-gray-100)}.bg-gray-100\/50{background-color:#f3f4f680}@supports (color:color-mix(in lab, red, red)){.bg-gray-100\/50{background-color:color-mix(in oklab, var(--color-gray-100) 50%, transparent)}}.bg-gray-100\/90{background-color:#f3f4f6e6}@supports (color:color-mix(in lab, red, red)){.bg-gray-100\/90{background-color:color-mix(in oklab, var(--color-gray-100) 90%, transparent)}}.bg-gray-200{background-color:var(--color-gray-200)}.bg-gray-200\/50{background-color:#e5e7eb80}@supports (color:color-mix(in lab, red, red)){.bg-gray-200\/50{background-color:color-mix(in oklab, var(--color-gray-200) 50%, transparent)}}.bg-gray-200\/80{background-color:#e5e7ebcc}@supports (color:color-mix(in lab, red, red)){.bg-gray-200\/80{background-color:color-mix(in oklab, var(--color-gray-200) 80%, transparent)}}.bg-gray-200\/90{background-color:#e5e7ebe6}@supports (color:color-mix(in lab, red, red)){.bg-gray-200\/90{background-color:color-mix(in oklab, var(--color-gray-200) 90%, transparent)}}.bg-gray-300\/85{background-color:#d1d5dcd9}@supports (color:color-mix(in lab, red, red)){.bg-gray-300\/85{background-color:color-mix(in oklab, var(--color-gray-300) 85%, transparent)}}.bg-gray-400{background-color:var(--color-gray-400)}.bg-gray-500{background-color:var(--color-gray-500)}.bg-gray-600{background-color:var(--color-gray-600)}.bg-gray-800{background-color:var(--color-gray-800)}.bg-gray-900{background-color:var(--color-gray-900)}.bg-green-50{background-color:var(--color-green-50)}.bg-green-50\/80{background-color:#f0fdf4cc}@supports (color:color-mix(in lab, red, red)){.bg-green-50\/80{background-color:color-mix(in oklab, var(--color-green-50) 80%, transparent)}}.bg-green-100{background-color:var(--color-green-100)}.bg-green-500{background-color:var(--color-green-500)}.bg-green-500\/10{background-color:#00c7581a}@supports (color:color-mix(in lab, red, red)){.bg-green-500\/10{background-color:color-mix(in oklab, var(--color-green-500) 10%, transparent)}}.bg-green-500\/20{background-color:#00c75833}@supports (color:color-mix(in lab, red, red)){.bg-green-500\/20{background-color:color-mix(in oklab, var(--color-green-500) 20%, transparent)}}.bg-green-600{background-color:var(--color-green-600)}.bg-indigo-50{background-color:var(--color-indigo-50)}.bg-indigo-50\/80{background-color:#eef2ffcc}@supports (color:color-mix(in lab, red, red)){.bg-indigo-50\/80{background-color:color-mix(in oklab, var(--color-indigo-50) 80%, transparent)}}.bg-indigo-100{background-color:var(--color-indigo-100)}.bg-indigo-500{background-color:var(--color-indigo-500)}.bg-indigo-500\/5{background-color:#625fff0d}@supports (color:color-mix(in lab, red, red)){.bg-indigo-500\/5{background-color:color-mix(in oklab, var(--color-indigo-500) 5%, transparent)}}.bg-indigo-500\/10{background-color:#625fff1a}@supports (color:color-mix(in lab, red, red)){.bg-indigo-500\/10{background-color:color-mix(in oklab, var(--color-indigo-500) 10%, transparent)}}.bg-indigo-500\/30{background-color:#625fff4d}@supports (color:color-mix(in lab, red, red)){.bg-indigo-500\/30{background-color:color-mix(in oklab, var(--color-indigo-500) 30%, transparent)}}.bg-indigo-600{background-color:var(--color-indigo-600)}.bg-orange-50{background-color:var(--color-orange-50)}.bg-orange-100{background-color:var(--color-orange-100)}.bg-orange-500{background-color:var(--color-orange-500)}.bg-orange-600{background-color:var(--color-orange-600)}.bg-pink-50{background-color:var(--color-pink-50)}.bg-pink-50\/50{background-color:#fdf2f880}@supports (color:color-mix(in lab, red, red)){.bg-pink-50\/50{background-color:color-mix(in oklab, var(--color-pink-50) 50%, transparent)}}.bg-purple-50{background-color:var(--color-purple-50)}.bg-purple-100{background-color:var(--color-purple-100)}.bg-purple-500\/10{background-color:#ac4bff1a}@supports (color:color-mix(in lab, red, red)){.bg-purple-500\/10{background-color:color-mix(in oklab, var(--color-purple-500) 10%, transparent)}}.bg-red-50{background-color:var(--color-red-50)}.bg-red-50\/50{background-color:#fef2f280}@supports (color:color-mix(in lab, red, red)){.bg-red-50\/50{background-color:color-mix(in oklab, var(--color-red-50) 50%, transparent)}}.bg-red-50\/60{background-color:#fef2f299}@supports (color:color-mix(in lab, red, red)){.bg-red-50\/60{background-color:color-mix(in oklab, var(--color-red-50) 60%, transparent)}}.bg-red-50\/70{background-color:#fef2f2b3}@supports (color:color-mix(in lab, red, red)){.bg-red-50\/70{background-color:color-mix(in oklab, var(--color-red-50) 70%, transparent)}}.bg-red-50\/80{background-color:#fef2f2cc}@supports (color:color-mix(in lab, red, red)){.bg-red-50\/80{background-color:color-mix(in oklab, var(--color-red-50) 80%, transparent)}}.bg-red-50\/90{background-color:#fef2f2e6}@supports (color:color-mix(in lab, red, red)){.bg-red-50\/90{background-color:color-mix(in oklab, var(--color-red-50) 90%, transparent)}}.bg-red-100{background-color:var(--color-red-100)}.bg-red-500{background-color:var(--color-red-500)}.bg-red-500\/10{background-color:#fb2c361a}@supports (color:color-mix(in lab, red, red)){.bg-red-500\/10{background-color:color-mix(in oklab, var(--color-red-500) 10%, transparent)}}.bg-red-500\/90{background-color:#fb2c36e6}@supports (color:color-mix(in lab, red, red)){.bg-red-500\/90{background-color:color-mix(in oklab, var(--color-red-500) 90%, transparent)}}.bg-red-600{background-color:var(--color-red-600)}.bg-red-600\!{background-color:var(--color-red-600)!important}.bg-red-700{background-color:var(--color-red-700)}.bg-rose-50{background-color:var(--color-rose-50)}.bg-rose-100{background-color:var(--color-rose-100)}.bg-rose-500\/10{background-color:#ff23571a}@supports (color:color-mix(in lab, red, red)){.bg-rose-500\/10{background-color:color-mix(in oklab, var(--color-rose-500) 10%, transparent)}}.bg-rose-600{background-color:var(--color-rose-600)}.bg-sky-50{background-color:var(--color-sky-50)}.bg-sky-100{background-color:var(--color-sky-100)}.bg-sky-600{background-color:var(--color-sky-600)}.bg-sky-700{background-color:var(--color-sky-700)}.bg-sky-800{background-color:var(--color-sky-800)}.bg-slate-50{background-color:var(--color-slate-50)}.bg-slate-50\/70{background-color:#f8fafcb3}@supports (color:color-mix(in lab, red, red)){.bg-slate-50\/70{background-color:color-mix(in oklab, var(--color-slate-50) 70%, transparent)}}.bg-slate-100{background-color:var(--color-slate-100)}.bg-slate-200{background-color:var(--color-slate-200)}.bg-slate-300{background-color:var(--color-slate-300)}.bg-slate-900\/45{background-color:#0f172b73}@supports (color:color-mix(in lab, red, red)){.bg-slate-900\/45{background-color:color-mix(in oklab, var(--color-slate-900) 45%, transparent)}}.bg-teal-50{background-color:var(--color-teal-50)}.bg-teal-100{background-color:var(--color-teal-100)}.bg-teal-500\/10{background-color:#00baa71a}@supports (color:color-mix(in lab, red, red)){.bg-teal-500\/10{background-color:color-mix(in oklab, var(--color-teal-500) 10%, transparent)}}.bg-teal-600{background-color:var(--color-teal-600)}.bg-transparent{background-color:#0000}.bg-violet-50{background-color:var(--color-violet-50)}.bg-violet-100{background-color:var(--color-violet-100)}.bg-violet-600{background-color:var(--color-violet-600)}.bg-white{background-color:var(--color-white)}.bg-white\/10{background-color:#ffffff1a}@supports (color:color-mix(in lab, red, red)){.bg-white\/10{background-color:color-mix(in oklab, var(--color-white) 10%, transparent)}}.bg-white\/15{background-color:#ffffff26}@supports (color:color-mix(in lab, red, red)){.bg-white\/15{background-color:color-mix(in oklab, var(--color-white) 15%, transparent)}}.bg-white\/20{background-color:#fff3}@supports (color:color-mix(in lab, red, red)){.bg-white\/20{background-color:color-mix(in oklab, var(--color-white) 20%, transparent)}}.bg-white\/25{background-color:#ffffff40}@supports (color:color-mix(in lab, red, red)){.bg-white\/25{background-color:color-mix(in oklab, var(--color-white) 25%, transparent)}}.bg-white\/50{background-color:#ffffff80}@supports (color:color-mix(in lab, red, red)){.bg-white\/50{background-color:color-mix(in oklab, var(--color-white) 50%, transparent)}}.bg-white\/60{background-color:#fff9}@supports (color:color-mix(in lab, red, red)){.bg-white\/60{background-color:color-mix(in oklab, var(--color-white) 60%, transparent)}}.bg-white\/70{background-color:#ffffffb3}@supports (color:color-mix(in lab, red, red)){.bg-white\/70{background-color:color-mix(in oklab, var(--color-white) 70%, transparent)}}.bg-white\/80{background-color:#fffc}@supports (color:color-mix(in lab, red, red)){.bg-white\/80{background-color:color-mix(in oklab, var(--color-white) 80%, transparent)}}.bg-white\/90{background-color:#ffffffe6}@supports (color:color-mix(in lab, red, red)){.bg-white\/90{background-color:color-mix(in oklab, var(--color-white) 90%, transparent)}}.bg-white\/95{background-color:#fffffff2}@supports (color:color-mix(in lab, red, red)){.bg-white\/95{background-color:color-mix(in oklab, var(--color-white) 95%, transparent)}}.bg-yellow-50{background-color:var(--color-yellow-50)}.bg-yellow-100{background-color:var(--color-yellow-100)}.bg-yellow-700\/50{background-color:#a3610080}@supports (color:color-mix(in lab, red, red)){.bg-yellow-700\/50{background-color:color-mix(in oklab, var(--color-yellow-700) 50%, transparent)}}.bg-yellow-900\/40{background-color:#733e0a66}@supports (color:color-mix(in lab, red, red)){.bg-yellow-900\/40{background-color:color-mix(in oklab, var(--color-yellow-900) 40%, transparent)}}.bg-zinc-50{background-color:var(--color-zinc-50)}.bg-zinc-50\!{background-color:var(--color-zinc-50)!important}.bg-zinc-100{background-color:var(--color-zinc-100)}.bg-zinc-200{background-color:var(--color-zinc-200)}.bg-zinc-200\/30{background-color:#e4e4e74d}@supports (color:color-mix(in lab, red, red)){.bg-zinc-200\/30{background-color:color-mix(in oklab, var(--color-zinc-200) 30%, transparent)}}.bg-zinc-600{background-color:var(--color-zinc-600)}.bg-zinc-700{background-color:var(--color-zinc-700)}.bg-zinc-800{background-color:var(--color-zinc-800)}.bg-zinc-900{background-color:var(--color-zinc-900)}.bg-zinc-950{background-color:var(--color-zinc-950)}.bg-zinc-950\/5{background-color:#09090b0d}@supports (color:color-mix(in lab, red, red)){.bg-zinc-950\/5{background-color:color-mix(in oklab, var(--color-zinc-950) 5%, transparent)}}.bg-zinc-950\/15{background-color:#09090b26}@supports (color:color-mix(in lab, red, red)){.bg-zinc-950\/15{background-color:color-mix(in oklab, var(--color-zinc-950) 15%, transparent)}}.bg-zinc-950\/90{background-color:#09090be6}@supports (color:color-mix(in lab, red, red)){.bg-zinc-950\/90{background-color:color-mix(in oklab, var(--color-zinc-950) 90%, transparent)}}.bg-linear-to-b{--tw-gradient-position:to bottom}@supports (background-image:linear-gradient(in lab, red, red)){.bg-linear-to-b{--tw-gradient-position:to bottom in oklab}}.bg-linear-to-b{background-image:linear-gradient(var(--tw-gradient-stops))}.bg-linear-to-r{--tw-gradient-position:to right}@supports (background-image:linear-gradient(in lab, red, red)){.bg-linear-to-r{--tw-gradient-position:to right in oklab}}.bg-linear-to-r{background-image:linear-gradient(var(--tw-gradient-stops))}.bg-gradient-to-b{--tw-gradient-position:to bottom in oklab;background-image:linear-gradient(var(--tw-gradient-stops))}.from-blue-500{--tw-gradient-from:var(--color-blue-500);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.from-emerald-500{--tw-gradient-from:var(--color-emerald-500);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.from-purple-500{--tw-gradient-from:var(--color-purple-500);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.from-rose-500{--tw-gradient-from:var(--color-rose-500);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.from-slate-100{--tw-gradient-from:var(--color-slate-100);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.from-transparent{--tw-gradient-from:transparent;--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.via-gray-200{--tw-gradient-via:var(--color-gray-200);--tw-gradient-via-stops:var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-via) var(--tw-gradient-via-position), var(--tw-gradient-to) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-via-stops)}.to-emerald-500{--tw-gradient-to:var(--color-emerald-500);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.to-indigo-500{--tw-gradient-to:var(--color-indigo-500);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.to-purple-500{--tw-gradient-to:var(--color-purple-500);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.to-rose-500{--tw-gradient-to:var(--color-rose-500);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.to-slate-200{--tw-gradient-to:var(--color-slate-200);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.to-transparent{--tw-gradient-to:transparent;--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.box-decoration-clone{-webkit-box-decoration-break:clone;box-decoration-break:clone}.box-decoration-slice{-webkit-box-decoration-break:slice;box-decoration-break:slice}.decoration-clone{-webkit-box-decoration-break:clone;box-decoration-break:clone}.decoration-slice{-webkit-box-decoration-break:slice;box-decoration-break:slice}.bg-fixed{background-attachment:fixed}.bg-local{background-attachment:local}.bg-scroll{background-attachment:scroll}.bg-clip-border{background-clip:border-box}.bg-clip-content{background-clip:content-box}.bg-clip-padding{background-clip:padding-box}.bg-clip-text{-webkit-background-clip:text;background-clip:text}.bg-no-repeat{background-repeat:no-repeat}.bg-repeat{background-repeat:repeat}.bg-repeat-round{background-repeat:round}.bg-repeat-space{background-repeat:space}.bg-repeat-x{background-repeat:repeat-x}.bg-repeat-y{background-repeat:repeat-y}.bg-origin-border{background-origin:border-box}.bg-origin-content{background-origin:content-box}.bg-origin-padding{background-origin:padding-box}.mask-repeat{-webkit-mask-repeat:repeat;mask-repeat:repeat}.object-contain{object-fit:contain}.object-cover{object-fit:cover}.object-fill{object-fit:fill}.object-none{object-fit:none}.object-scale-down{object-fit:scale-down}.object-center{object-position:center}.p-0{padding:0}.p-0\!{padding:0!important}.p-0\.5{padding:calc(var(--spacing) * .5)}.p-1{padding:var(--spacing)}.p-1\.5{padding:calc(var(--spacing) * 1.5)}.p-1\.5\!{padding:calc(var(--spacing) * 1.5)!important}.p-2{padding:calc(var(--spacing) * 2)}.p-2\!{padding:calc(var(--spacing) * 2)!important}.p-2\.5{padding:calc(var(--spacing) * 2.5)}.p-2\.5\!{padding:calc(var(--spacing) * 2.5)!important}.p-3{padding:calc(var(--spacing) * 3)}.p-3\!{padding:calc(var(--spacing) * 3)!important}.p-3\.5{padding:calc(var(--spacing) * 3.5)}.p-4{padding:calc(var(--spacing) * 4)}.p-4\!{padding:calc(var(--spacing) * 4)!important}.p-5{padding:calc(var(--spacing) * 5)}.p-6{padding:calc(var(--spacing) * 6)}.p-7{padding:calc(var(--spacing) * 7)}.p-8{padding:calc(var(--spacing) * 8)}.p-8\!{padding:calc(var(--spacing) * 8)!important}.p-12{padding:calc(var(--spacing) * 12)}.p-\[10\%\]{padding:10%}.px-0{padding-inline:0}.px-0\.5{padding-inline:calc(var(--spacing) * .5)}.px-1{padding-inline:var(--spacing)}.px-1\!{padding-inline:var(--spacing)!important}.px-1\.5{padding-inline:calc(var(--spacing) * 1.5)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-2\!{padding-inline:calc(var(--spacing) * 2)!important}.px-2\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-2\.5\!{padding-inline:calc(var(--spacing) * 2.5)!important}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-3\!{padding-inline:calc(var(--spacing) * 3)!important}.px-3\.5{padding-inline:calc(var(--spacing) * 3.5)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-4\!{padding-inline:calc(var(--spacing) * 4)!important}.px-5{padding-inline:calc(var(--spacing) * 5)}.px-5\!{padding-inline:calc(var(--spacing) * 5)!important}.px-6{padding-inline:calc(var(--spacing) * 6)}.px-6\!{padding-inline:calc(var(--spacing) * 6)!important}.px-8\!{padding-inline:calc(var(--spacing) * 8)!important}.px-10\!{padding-inline:calc(var(--spacing) * 10)!important}.py-0{padding-block:0}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-0\.5\!{padding-block:calc(var(--spacing) * .5)!important}.py-1{padding-block:var(--spacing)}.py-1\!{padding-block:var(--spacing)!important}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-1\.5\!{padding-block:calc(var(--spacing) * 1.5)!important}.py-2{padding-block:calc(var(--spacing) * 2)}.py-2\!{padding-block:calc(var(--spacing) * 2)!important}.py-2\.5{padding-block:calc(var(--spacing) * 2.5)}.py-2\.5\!{padding-block:calc(var(--spacing) * 2.5)!important}.py-3{padding-block:calc(var(--spacing) * 3)}.py-3\!{padding-block:calc(var(--spacing) * 3)!important}.py-3\.5{padding-block:calc(var(--spacing) * 3.5)}.py-4{padding-block:calc(var(--spacing) * 4)}.py-5{padding-block:calc(var(--spacing) * 5)}.py-6{padding-block:calc(var(--spacing) * 6)}.py-8{padding-block:calc(var(--spacing) * 8)}.py-10{padding-block:calc(var(--spacing) * 10)}.py-12{padding-block:calc(var(--spacing) * 12)}.py-16{padding-block:calc(var(--spacing) * 16)}.py-20{padding-block:calc(var(--spacing) * 20)}.pt-0\.5{padding-top:calc(var(--spacing) * .5)}.pt-1{padding-top:var(--spacing)}.pt-1\.5{padding-top:calc(var(--spacing) * 1.5)}.pt-2{padding-top:calc(var(--spacing) * 2)}.pt-3{padding-top:calc(var(--spacing) * 3)}.pt-4{padding-top:calc(var(--spacing) * 4)}.pt-6{padding-top:calc(var(--spacing) * 6)}.pt-7{padding-top:calc(var(--spacing) * 7)}.pt-8{padding-top:calc(var(--spacing) * 8)}.pt-11{padding-top:calc(var(--spacing) * 11)}.pt-16{padding-top:calc(var(--spacing) * 16)}.pt-\[env\(safe-area-inset-top\,0px\)\]{padding-top:env(safe-area-inset-top,0px)}.pt-\[max\(0\.5rem\,env\(safe-area-inset-top\)\)\]{padding-top:max(.5rem, env(safe-area-inset-top))}.pt-\[max\(0\.75rem\,env\(safe-area-inset-top\)\)\]{padding-top:max(.75rem, env(safe-area-inset-top))}.pt-\[max\(1rem\,env\(safe-area-inset-top\)\)\]{padding-top:max(1rem, env(safe-area-inset-top))}.pr-0{padding-right:0}.pr-0\.5{padding-right:calc(var(--spacing) * .5)}.pr-1{padding-right:var(--spacing)}.pr-1\.5{padding-right:calc(var(--spacing) * 1.5)}.pr-2{padding-right:calc(var(--spacing) * 2)}.pr-2\.5{padding-right:calc(var(--spacing) * 2.5)}.pr-3{padding-right:calc(var(--spacing) * 3)}.pr-3\!{padding-right:calc(var(--spacing) * 3)!important}.pr-4{padding-right:calc(var(--spacing) * 4)}.pr-5{padding-right:calc(var(--spacing) * 5)}.pr-7{padding-right:calc(var(--spacing) * 7)}.pr-8{padding-right:calc(var(--spacing) * 8)}.pr-9{padding-right:calc(var(--spacing) * 9)}.pr-10{padding-right:calc(var(--spacing) * 10)}.pr-12{padding-right:calc(var(--spacing) * 12)}.pr-12\!{padding-right:calc(var(--spacing) * 12)!important}.pr-16{padding-right:calc(var(--spacing) * 16)}.pr-28{padding-right:calc(var(--spacing) * 28)}.pb-1{padding-bottom:var(--spacing)}.pb-2{padding-bottom:calc(var(--spacing) * 2)}.pb-3{padding-bottom:calc(var(--spacing) * 3)}.pb-4{padding-bottom:calc(var(--spacing) * 4)}.pb-5{padding-bottom:calc(var(--spacing) * 5)}.pb-6{padding-bottom:calc(var(--spacing) * 6)}.pb-7{padding-bottom:calc(var(--spacing) * 7)}.pb-16{padding-bottom:calc(var(--spacing) * 16)}.pb-20{padding-bottom:calc(var(--spacing) * 20)}.pb-\[env\(safe-area-inset-bottom\)\]{padding-bottom:env(safe-area-inset-bottom)}.pb-\[max\(0\.5rem\,env\(safe-area-inset-bottom\)\)\]{padding-bottom:max(.5rem, env(safe-area-inset-bottom))}.pb-\[max\(0\.25rem\,env\(safe-area-inset-bottom\)\)\]{padding-bottom:max(.25rem, env(safe-area-inset-bottom))}.pb-\[max\(0\.75rem\,env\(safe-area-inset-bottom\)\)\]{padding-bottom:max(.75rem, env(safe-area-inset-bottom))}.pb-\[max\(0px\,env\(safe-area-inset-bottom\)\)\]{padding-bottom:max(0px, env(safe-area-inset-bottom))}.pb-\[max\(1\.5rem\,env\(safe-area-inset-bottom\)\)\]{padding-bottom:max(1.5rem, env(safe-area-inset-bottom))}.pb-\[max\(1rem\,env\(safe-area-inset-bottom\)\)\]{padding-bottom:max(1rem, env(safe-area-inset-bottom))}.pl-0{padding-left:0}.pl-0\.5{padding-left:calc(var(--spacing) * .5)}.pl-1\.5{padding-left:calc(var(--spacing) * 1.5)}.pl-2{padding-left:calc(var(--spacing) * 2)}.pl-2\.5{padding-left:calc(var(--spacing) * 2.5)}.pl-3{padding-left:calc(var(--spacing) * 3)}.pl-4{padding-left:calc(var(--spacing) * 4)}.pl-5{padding-left:calc(var(--spacing) * 5)}.pl-6{padding-left:calc(var(--spacing) * 6)}.pl-8{padding-left:calc(var(--spacing) * 8)}.pl-9{padding-left:calc(var(--spacing) * 9)}.pl-9\!{padding-left:calc(var(--spacing) * 9)!important}.pl-10{padding-left:calc(var(--spacing) * 10)}.pl-10\!{padding-left:calc(var(--spacing) * 10)!important}.pl-11{padding-left:calc(var(--spacing) * 11)}.pl-11\!{padding-left:calc(var(--spacing) * 11)!important}.pl-12{padding-left:calc(var(--spacing) * 12)}.text-center{text-align:center}.text-end{text-align:end}.text-justify{text-align:justify}.text-left{text-align:left}.text-right{text-align:right}.text-start{text-align:start}.align-\[-0\.1em\]{vertical-align:-.1em}.align-baseline{vertical-align:baseline}.align-bottom{vertical-align:bottom}.align-middle{vertical-align:middle}.align-sub{vertical-align:sub}.align-super{vertical-align:super}.align-text-bottom{vertical-align:text-bottom}.align-text-top{vertical-align:text-top}.align-top{vertical-align:top}.font-mono{font-family:var(--font-mono)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.text-4xl{font-size:var(--text-4xl);line-height:var(--tw-leading,var(--text-4xl--line-height))}.text-5xl{font-size:var(--text-5xl);line-height:var(--tw-leading,var(--text-5xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-sm\!{font-size:var(--text-sm)!important;line-height:var(--tw-leading,var(--text-sm--line-height))!important}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-xs\!{font-size:var(--text-xs)!important;line-height:var(--tw-leading,var(--text-xs--line-height))!important}.text-\[0\.9em\]{font-size:.9em}.text-\[0\.85em\]{font-size:.85em}.text-\[8px\]{font-size:8px}.text-\[9px\]{font-size:9px}.text-\[9px\]\!{font-size:9px!important}.text-\[10px\]{font-size:10px}.text-\[10px\]\!{font-size:10px!important}.text-\[11px\]{font-size:11px}.text-\[11px\]\!{font-size:11px!important}.text-\[12px\]{font-size:12px}.leading-4{--tw-leading:calc(var(--spacing) * 4);line-height:calc(var(--spacing) * 4)}.leading-5{--tw-leading:calc(var(--spacing) * 5);line-height:calc(var(--spacing) * 5)}.leading-none{--tw-leading:1;line-height:1}.leading-normal{--tw-leading:var(--leading-normal);line-height:var(--leading-normal)}.leading-relaxed{--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}.leading-snug{--tw-leading:var(--leading-snug);line-height:var(--leading-snug)}.leading-tight{--tw-leading:var(--leading-tight);line-height:var(--leading-tight)}.font-black{--tw-font-weight:var(--font-weight-black);font-weight:var(--font-weight-black)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-\[0\.2em\]{--tw-tracking:.2em;letter-spacing:.2em}.tracking-\[0\.3em\]{--tw-tracking:.3em;letter-spacing:.3em}.tracking-normal{--tw-tracking:var(--tracking-normal);letter-spacing:var(--tracking-normal)}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.tracking-tighter{--tw-tracking:var(--tracking-tighter);letter-spacing:var(--tracking-tighter)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.tracking-wider{--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider)}.tracking-widest{--tw-tracking:var(--tracking-widest);letter-spacing:var(--tracking-widest)}.tracking-widest\!{--tw-tracking:var(--tracking-widest)!important;letter-spacing:var(--tracking-widest)!important}.text-balance{text-wrap:balance}.text-nowrap{text-wrap:nowrap}.text-pretty{text-wrap:pretty}.text-wrap{text-wrap:wrap}.break-normal{overflow-wrap:normal;word-break:normal}.break-words,.wrap-break-word{overflow-wrap:break-word}.\[word-break\:break-word\]{word-break:break-word}.break-all{word-break:break-all}.break-keep{word-break:keep-all}.overflow-ellipsis{text-overflow:ellipsis}.text-clip{text-overflow:clip}.text-ellipsis{text-overflow:ellipsis}.hyphens-auto{-webkit-hyphens:auto;hyphens:auto}.hyphens-manual{-webkit-hyphens:manual;hyphens:manual}.hyphens-none{-webkit-hyphens:none;hyphens:none}.whitespace-break-spaces{white-space:break-spaces}.whitespace-normal{white-space:normal}.whitespace-nowrap{white-space:nowrap}.whitespace-pre{white-space:pre}.whitespace-pre-line{white-space:pre-line}.whitespace-pre-wrap{white-space:pre-wrap}.tab-1{tab-size:1}.text-\[inherit\]{color:inherit}.text-\[var\(--mc-text-secondary\)\]{color:var(--mc-text-secondary)}.text-amber-200{color:var(--color-amber-200)}.text-amber-200\/95{color:#fee685f2}@supports (color:color-mix(in lab, red, red)){.text-amber-200\/95{color:color-mix(in oklab, var(--color-amber-200) 95%, transparent)}}.text-amber-400{color:var(--color-amber-400)}.text-amber-500{color:var(--color-amber-500)}.text-amber-600{color:var(--color-amber-600)}.text-amber-700{color:var(--color-amber-700)}.text-amber-700\/90{color:#b75000e6}@supports (color:color-mix(in lab, red, red)){.text-amber-700\/90{color:color-mix(in oklab, var(--color-amber-700) 90%, transparent)}}.text-amber-800{color:var(--color-amber-800)}.text-amber-800\/80{color:#953d00cc}@supports (color:color-mix(in lab, red, red)){.text-amber-800\/80{color:color-mix(in oklab, var(--color-amber-800) 80%, transparent)}}.text-amber-800\/90{color:#953d00e6}@supports (color:color-mix(in lab, red, red)){.text-amber-800\/90{color:color-mix(in oklab, var(--color-amber-800) 90%, transparent)}}.text-amber-900{color:var(--color-amber-900)}.text-amber-900\/80{color:#7b3306cc}@supports (color:color-mix(in lab, red, red)){.text-amber-900\/80{color:color-mix(in oklab, var(--color-amber-900) 80%, transparent)}}.text-amber-950{color:var(--color-amber-950)}.text-amber-950\/90{color:#461901e6}@supports (color:color-mix(in lab, red, red)){.text-amber-950\/90{color:color-mix(in oklab, var(--color-amber-950) 90%, transparent)}}.text-black{color:var(--color-black)}.text-blue-200{color:var(--color-blue-200)}.text-blue-400{color:var(--color-blue-400)}.text-blue-500{color:var(--color-blue-500)}.text-blue-600{color:var(--color-blue-600)}.text-blue-700{color:var(--color-blue-700)}.text-blue-800{color:var(--color-blue-800)}.text-blue-800\/80{color:#193cb8cc}@supports (color:color-mix(in lab, red, red)){.text-blue-800\/80{color:color-mix(in oklab, var(--color-blue-800) 80%, transparent)}}.text-blue-900{color:var(--color-blue-900)}.text-blue-950{color:var(--color-blue-950)}.text-blue-950\/90{color:#162456e6}@supports (color:color-mix(in lab, red, red)){.text-blue-950\/90{color:color-mix(in oklab, var(--color-blue-950) 90%, transparent)}}.text-cyan-500{color:var(--color-cyan-500)}.text-cyan-600{color:var(--color-cyan-600)}.text-cyan-700{color:var(--color-cyan-700)}.text-emerald-300{color:var(--color-emerald-300)}.text-emerald-400{color:var(--color-emerald-400)}.text-emerald-500{color:var(--color-emerald-500)}.text-emerald-500\/80{color:#00bb7fcc}@supports (color:color-mix(in lab, red, red)){.text-emerald-500\/80{color:color-mix(in oklab, var(--color-emerald-500) 80%, transparent)}}.text-emerald-600{color:var(--color-emerald-600)}.text-emerald-700{color:var(--color-emerald-700)}.text-emerald-800{color:var(--color-emerald-800)}.text-emerald-800\/80{color:#005f46cc}@supports (color:color-mix(in lab, red, red)){.text-emerald-800\/80{color:color-mix(in oklab, var(--color-emerald-800) 80%, transparent)}}.text-gray-100{color:var(--color-gray-100)}.text-gray-200{color:var(--color-gray-200)}.text-gray-200\/90{color:#e5e7ebe6}@supports (color:color-mix(in lab, red, red)){.text-gray-200\/90{color:color-mix(in oklab, var(--color-gray-200) 90%, transparent)}}.text-gray-300{color:var(--color-gray-300)}.text-gray-400{color:var(--color-gray-400)}.text-gray-500{color:var(--color-gray-500)}.text-gray-600{color:var(--color-gray-600)}.text-gray-700{color:var(--color-gray-700)}.text-gray-800{color:var(--color-gray-800)}.text-gray-900{color:var(--color-gray-900)}.text-green-400{color:var(--color-green-400)}.text-green-500{color:var(--color-green-500)}.text-green-600{color:var(--color-green-600)}.text-green-700{color:var(--color-green-700)}.text-green-800{color:var(--color-green-800)}.text-indigo-500{color:var(--color-indigo-500)}.text-indigo-500\/80{color:#625fffcc}@supports (color:color-mix(in lab, red, red)){.text-indigo-500\/80{color:color-mix(in oklab, var(--color-indigo-500) 80%, transparent)}}.text-indigo-600{color:var(--color-indigo-600)}.text-indigo-600\/90{color:#4f39f6e6}@supports (color:color-mix(in lab, red, red)){.text-indigo-600\/90{color:color-mix(in oklab, var(--color-indigo-600) 90%, transparent)}}.text-indigo-700{color:var(--color-indigo-700)}.text-indigo-700\/90{color:#432dd7e6}@supports (color:color-mix(in lab, red, red)){.text-indigo-700\/90{color:color-mix(in oklab, var(--color-indigo-700) 90%, transparent)}}.text-indigo-900\/90{color:#312c85e6}@supports (color:color-mix(in lab, red, red)){.text-indigo-900\/90{color:color-mix(in oklab, var(--color-indigo-900) 90%, transparent)}}.text-indigo-950{color:var(--color-indigo-950)}.text-inherit{color:inherit}.text-orange-500{color:var(--color-orange-500)}.text-orange-600{color:var(--color-orange-600)}.text-orange-700{color:var(--color-orange-700)}.text-pink-500{color:var(--color-pink-500)}.text-pink-600{color:var(--color-pink-600)}.text-pink-700{color:var(--color-pink-700)}.text-pink-800{color:var(--color-pink-800)}.text-purple-100{color:var(--color-purple-100)}.text-purple-200{color:var(--color-purple-200)}.text-purple-300{color:var(--color-purple-300)}.text-purple-300\/90{color:#d9b3ffe6}@supports (color:color-mix(in lab, red, red)){.text-purple-300\/90{color:color-mix(in oklab, var(--color-purple-300) 90%, transparent)}}.text-purple-400{color:var(--color-purple-400)}.text-purple-500{color:var(--color-purple-500)}.text-purple-600{color:var(--color-purple-600)}.text-red-400{color:var(--color-red-400)}.text-red-500{color:var(--color-red-500)}.text-red-500\!{color:var(--color-red-500)!important}.text-red-500\/60{color:#fb2c3699}@supports (color:color-mix(in lab, red, red)){.text-red-500\/60{color:color-mix(in oklab, var(--color-red-500) 60%, transparent)}}.text-red-600{color:var(--color-red-600)}.text-red-600\!{color:var(--color-red-600)!important}.text-red-700{color:var(--color-red-700)}.text-red-800{color:var(--color-red-800)}.text-red-800\/95{color:#9f0712f2}@supports (color:color-mix(in lab, red, red)){.text-red-800\/95{color:color-mix(in oklab, var(--color-red-800) 95%, transparent)}}.text-red-900{color:var(--color-red-900)}.text-rose-500{color:var(--color-rose-500)}.text-rose-600{color:var(--color-rose-600)}.text-sky-500{color:var(--color-sky-500)}.text-sky-600{color:var(--color-sky-600)}.text-sky-600\/70{color:#0084ccb3}@supports (color:color-mix(in lab, red, red)){.text-sky-600\/70{color:color-mix(in oklab, var(--color-sky-600) 70%, transparent)}}.text-sky-700{color:var(--color-sky-700)}.text-sky-800{color:var(--color-sky-800)}.text-sky-900{color:var(--color-sky-900)}.text-sky-900\/80{color:#024a70cc}@supports (color:color-mix(in lab, red, red)){.text-sky-900\/80{color:color-mix(in oklab, var(--color-sky-900) 80%, transparent)}}.text-slate-400{color:var(--color-slate-400)}.text-slate-500{color:var(--color-slate-500)}.text-slate-600{color:var(--color-slate-600)}.text-slate-700{color:var(--color-slate-700)}.text-slate-800{color:var(--color-slate-800)}.text-slate-900{color:var(--color-slate-900)}.text-teal-500{color:var(--color-teal-500)}.text-teal-600{color:var(--color-teal-600)}.text-teal-600\!{color:var(--color-teal-600)!important}.text-teal-700{color:var(--color-teal-700)}.text-teal-800{color:var(--color-teal-800)}.text-violet-600{color:var(--color-violet-600)}.text-violet-700{color:var(--color-violet-700)}.text-violet-800{color:var(--color-violet-800)}.text-violet-900{color:var(--color-violet-900)}.text-violet-950{color:var(--color-violet-950)}.text-white{color:var(--color-white)}.text-white\!{color:var(--color-white)!important}.text-white\/60{color:#fff9}@supports (color:color-mix(in lab, red, red)){.text-white\/60{color:color-mix(in oklab, var(--color-white) 60%, transparent)}}.text-white\/75{color:#ffffffbf}@supports (color:color-mix(in lab, red, red)){.text-white\/75{color:color-mix(in oklab, var(--color-white) 75%, transparent)}}.text-white\/80{color:#fffc}@supports (color:color-mix(in lab, red, red)){.text-white\/80{color:color-mix(in oklab, var(--color-white) 80%, transparent)}}.text-white\/85{color:#ffffffd9}@supports (color:color-mix(in lab, red, red)){.text-white\/85{color:color-mix(in oklab, var(--color-white) 85%, transparent)}}.text-white\/90{color:#ffffffe6}@supports (color:color-mix(in lab, red, red)){.text-white\/90{color:color-mix(in oklab, var(--color-white) 90%, transparent)}}.text-white\/95{color:#fffffff2}@supports (color:color-mix(in lab, red, red)){.text-white\/95{color:color-mix(in oklab, var(--color-white) 95%, transparent)}}.text-yellow-200{color:var(--color-yellow-200)}.text-yellow-400{color:var(--color-yellow-400)}.text-yellow-500{color:var(--color-yellow-500)}.text-yellow-600{color:var(--color-yellow-600)}.text-yellow-800{color:var(--color-yellow-800)}.text-yellow-900{color:var(--color-yellow-900)}.text-zinc-100{color:var(--color-zinc-100)}.text-zinc-300{color:var(--color-zinc-300)}.text-zinc-400{color:var(--color-zinc-400)}.text-zinc-500{color:var(--color-zinc-500)}.text-zinc-600{color:var(--color-zinc-600)}.text-zinc-700{color:var(--color-zinc-700)}.text-zinc-900{color:var(--color-zinc-900)}.capitalize{text-transform:capitalize}.lowercase{text-transform:lowercase}.normal-case{text-transform:none}.uppercase{text-transform:uppercase}.italic{font-style:italic}.not-italic{font-style:normal}.diagonal-fractions{--tw-numeric-fraction:diagonal-fractions;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.lining-nums{--tw-numeric-figure:lining-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.oldstyle-nums{--tw-numeric-figure:oldstyle-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.ordinal{--tw-ordinal:ordinal;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.proportional-nums{--tw-numeric-spacing:proportional-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.slashed-zero{--tw-slashed-zero:slashed-zero;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.stacked-fractions{--tw-numeric-fraction:stacked-fractions;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.normal-nums{font-variant-numeric:normal}.line-through{text-decoration-line:line-through}.no-underline{text-decoration-line:none}.overline{text-decoration-line:overline}.underline{text-decoration-line:underline}.decoration-dashed{text-decoration-style:dashed}.decoration-dotted{text-decoration-style:dotted}.decoration-double{text-decoration-style:double}.decoration-solid{text-decoration-style:solid}.decoration-wavy{text-decoration-style:wavy}.decoration-2{text-decoration-thickness:2px}.underline-offset-2{text-underline-offset:2px}.underline-offset-4{text-underline-offset:4px}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.subpixel-antialiased{-webkit-font-smoothing:auto;-moz-osx-font-smoothing:auto}.placeholder-gray-400::placeholder{color:var(--color-gray-400)}.accent-blue-500{accent-color:var(--color-blue-500)}.accent-blue-600{accent-color:var(--color-blue-600)}.accent-emerald-500{accent-color:var(--color-emerald-500)}.opacity-0{opacity:0}.opacity-20{opacity:.2}.opacity-25{opacity:.25}.opacity-30{opacity:.3}.opacity-40{opacity:.4}.opacity-50{opacity:.5}.opacity-60{opacity:.6}.opacity-70{opacity:.7}.opacity-75{opacity:.75}.opacity-80{opacity:.8}.opacity-85{opacity:.85}.opacity-90{opacity:.9}.opacity-100{opacity:1}.opacity-100\!{opacity:1!important}.bg-blend-color{background-blend-mode:color}.bg-blend-color-burn{background-blend-mode:color-burn}.bg-blend-color-dodge{background-blend-mode:color-dodge}.bg-blend-darken{background-blend-mode:darken}.bg-blend-difference{background-blend-mode:difference}.bg-blend-exclusion{background-blend-mode:exclusion}.bg-blend-hard-light{background-blend-mode:hard-light}.bg-blend-hue{background-blend-mode:hue}.bg-blend-lighten{background-blend-mode:lighten}.bg-blend-luminosity{background-blend-mode:luminosity}.bg-blend-multiply{background-blend-mode:multiply}.bg-blend-normal{background-blend-mode:normal}.bg-blend-overlay{background-blend-mode:overlay}.bg-blend-saturation{background-blend-mode:saturation}.bg-blend-screen{background-blend-mode:screen}.bg-blend-soft-light{background-blend-mode:soft-light}.mix-blend-color{mix-blend-mode:color}.mix-blend-color-burn{mix-blend-mode:color-burn}.mix-blend-color-dodge{mix-blend-mode:color-dodge}.mix-blend-darken{mix-blend-mode:darken}.mix-blend-difference{mix-blend-mode:difference}.mix-blend-exclusion{mix-blend-mode:exclusion}.mix-blend-hard-light{mix-blend-mode:hard-light}.mix-blend-hue{mix-blend-mode:hue}.mix-blend-lighten{mix-blend-mode:lighten}.mix-blend-luminosity{mix-blend-mode:luminosity}.mix-blend-multiply{mix-blend-mode:multiply}.mix-blend-normal{mix-blend-mode:normal}.mix-blend-overlay{mix-blend-mode:overlay}.mix-blend-plus-darker{mix-blend-mode:plus-darker}.mix-blend-plus-lighter{mix-blend-mode:plus-lighter}.mix-blend-saturation{mix-blend-mode:saturation}.mix-blend-screen{mix-blend-mode:screen}.mix-blend-soft-light{mix-blend-mode:soft-light}.shadow{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-2xl{--tw-shadow:0 25px 50px -12px var(--tw-shadow-color,#00000040);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-\[0_0_8px_rgba\(59\,130\,246\,0\.5\)\]{--tw-shadow:0 0 8px var(--tw-shadow-color,#3b82f680);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-\[inset_2px_2px_5px_rgba\(0\,0\,0\,0\.12\)\,inset_-1px_-1px_2px_rgba\(255\,255\,255\,0\.6\)\]{--tw-shadow:inset 2px 2px 5px var(--tw-shadow-color,#0000001f), inset -1px -1px 2px var(--tw-shadow-color,#fff9);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-inner{--tw-shadow:inset 0 2px 4px 0 var(--tw-shadow-color,#0000000d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-lg\!{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a)!important;box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)!important}.shadow-md{--tw-shadow:0 4px 6px -1px var(--tw-shadow-color,#0000001a), 0 2px 4px -2px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-none{--tw-shadow:0 0 #0000;box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-xl{--tw-shadow:0 20px 25px -5px var(--tw-shadow-color,#0000001a), 0 8px 10px -6px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-xs{--tw-shadow:0 1px 2px 0 var(--tw-shadow-color,#0000000d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-0{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-1{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-2{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-4{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(4px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-amber-500\/30{--tw-shadow-color:#f99c004d}@supports (color:color-mix(in lab, red, red)){.shadow-amber-500\/30{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-amber-500) 30%, transparent) var(--tw-shadow-alpha), transparent)}}.shadow-amber-500\/40{--tw-shadow-color:#f99c0066}@supports (color:color-mix(in lab, red, red)){.shadow-amber-500\/40{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-amber-500) 40%, transparent) var(--tw-shadow-alpha), transparent)}}.shadow-blue-500\/10{--tw-shadow-color:#3080ff1a}@supports (color:color-mix(in lab, red, red)){.shadow-blue-500\/10{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-blue-500) 10%, transparent) var(--tw-shadow-alpha), transparent)}}.shadow-blue-500\/20{--tw-shadow-color:#3080ff33}@supports (color:color-mix(in lab, red, red)){.shadow-blue-500\/20{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-blue-500) 20%, transparent) var(--tw-shadow-alpha), transparent)}}.shadow-blue-500\/25{--tw-shadow-color:#3080ff40}@supports (color:color-mix(in lab, red, red)){.shadow-blue-500\/25{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-blue-500) 25%, transparent) var(--tw-shadow-alpha), transparent)}}.shadow-blue-500\/30{--tw-shadow-color:#3080ff4d}@supports (color:color-mix(in lab, red, red)){.shadow-blue-500\/30{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-blue-500) 30%, transparent) var(--tw-shadow-alpha), transparent)}}.shadow-blue-600\/20{--tw-shadow-color:#155dfc33}@supports (color:color-mix(in lab, red, red)){.shadow-blue-600\/20{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-blue-600) 20%, transparent) var(--tw-shadow-alpha), transparent)}}.shadow-blue-600\/30{--tw-shadow-color:#155dfc4d}@supports (color:color-mix(in lab, red, red)){.shadow-blue-600\/30{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-blue-600) 30%, transparent) var(--tw-shadow-alpha), transparent)}}.shadow-emerald-500\/20{--tw-shadow-color:#00bb7f33}@supports (color:color-mix(in lab, red, red)){.shadow-emerald-500\/20{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-emerald-500) 20%, transparent) var(--tw-shadow-alpha), transparent)}}.shadow-gray-200\/20{--tw-shadow-color:#e5e7eb33}@supports (color:color-mix(in lab, red, red)){.shadow-gray-200\/20{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-gray-200) 20%, transparent) var(--tw-shadow-alpha), transparent)}}.shadow-green-600\/20{--tw-shadow-color:#00a54433}@supports (color:color-mix(in lab, red, red)){.shadow-green-600\/20{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-green-600) 20%, transparent) var(--tw-shadow-alpha), transparent)}}.shadow-green-600\/30{--tw-shadow-color:#00a5444d}@supports (color:color-mix(in lab, red, red)){.shadow-green-600\/30{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-green-600) 30%, transparent) var(--tw-shadow-alpha), transparent)}}.shadow-indigo-500\/30{--tw-shadow-color:#625fff4d}@supports (color:color-mix(in lab, red, red)){.shadow-indigo-500\/30{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-indigo-500) 30%, transparent) var(--tw-shadow-alpha), transparent)}}.shadow-indigo-500\/50{--tw-shadow-color:#625fff80}@supports (color:color-mix(in lab, red, red)){.shadow-indigo-500\/50{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-indigo-500) 50%, transparent) var(--tw-shadow-alpha), transparent)}}.shadow-indigo-900\/5{--tw-shadow-color:#312c850d}@supports (color:color-mix(in lab, red, red)){.shadow-indigo-900\/5{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-indigo-900) 5%, transparent) var(--tw-shadow-alpha), transparent)}}.shadow-orange-500\/20{--tw-shadow-color:#fe6e0033}@supports (color:color-mix(in lab, red, red)){.shadow-orange-500\/20{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-orange-500) 20%, transparent) var(--tw-shadow-alpha), transparent)}}.shadow-red-500\/20{--tw-shadow-color:#fb2c3633}@supports (color:color-mix(in lab, red, red)){.shadow-red-500\/20{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-red-500) 20%, transparent) var(--tw-shadow-alpha), transparent)}}.shadow-red-500\/30{--tw-shadow-color:#fb2c364d}@supports (color:color-mix(in lab, red, red)){.shadow-red-500\/30{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-red-500) 30%, transparent) var(--tw-shadow-alpha), transparent)}}.shadow-red-600\/20{--tw-shadow-color:#e4001433}@supports (color:color-mix(in lab, red, red)){.shadow-red-600\/20{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-red-600) 20%, transparent) var(--tw-shadow-alpha), transparent)}}.shadow-red-600\/30{--tw-shadow-color:#e400144d}@supports (color:color-mix(in lab, red, red)){.shadow-red-600\/30{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-red-600) 30%, transparent) var(--tw-shadow-alpha), transparent)}}.shadow-slate-200\/50{--tw-shadow-color:#e2e8f080}@supports (color:color-mix(in lab, red, red)){.shadow-slate-200\/50{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-slate-200) 50%, transparent) var(--tw-shadow-alpha), transparent)}}.shadow-teal-600\/30{--tw-shadow-color:#0095884d}@supports (color:color-mix(in lab, red, red)){.shadow-teal-600\/30{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-teal-600) 30%, transparent) var(--tw-shadow-alpha), transparent)}}.ring-amber-400{--tw-ring-color:var(--color-amber-400)}.ring-amber-500{--tw-ring-color:var(--color-amber-500)}.ring-black\/10{--tw-ring-color:#0000001a}@supports (color:color-mix(in lab, red, red)){.ring-black\/10{--tw-ring-color:color-mix(in oklab, var(--color-black) 10%, transparent)}}.ring-blue-300{--tw-ring-color:var(--color-blue-300)}.ring-blue-400{--tw-ring-color:var(--color-blue-400)}.ring-blue-400\/30{--tw-ring-color:#54a2ff4d}@supports (color:color-mix(in lab, red, red)){.ring-blue-400\/30{--tw-ring-color:color-mix(in oklab, var(--color-blue-400) 30%, transparent)}}.ring-blue-400\/60{--tw-ring-color:#54a2ff99}@supports (color:color-mix(in lab, red, red)){.ring-blue-400\/60{--tw-ring-color:color-mix(in oklab, var(--color-blue-400) 60%, transparent)}}.ring-blue-500{--tw-ring-color:var(--color-blue-500)}.ring-blue-500\/20{--tw-ring-color:#3080ff33}@supports (color:color-mix(in lab, red, red)){.ring-blue-500\/20{--tw-ring-color:color-mix(in oklab, var(--color-blue-500) 20%, transparent)}}.ring-blue-500\/30{--tw-ring-color:#3080ff4d}@supports (color:color-mix(in lab, red, red)){.ring-blue-500\/30{--tw-ring-color:color-mix(in oklab, var(--color-blue-500) 30%, transparent)}}.ring-blue-500\/50{--tw-ring-color:#3080ff80}@supports (color:color-mix(in lab, red, red)){.ring-blue-500\/50{--tw-ring-color:color-mix(in oklab, var(--color-blue-500) 50%, transparent)}}.ring-blue-700\/10{--tw-ring-color:#1447e61a}@supports (color:color-mix(in lab, red, red)){.ring-blue-700\/10{--tw-ring-color:color-mix(in oklab, var(--color-blue-700) 10%, transparent)}}.ring-cyan-200\/80{--tw-ring-color:#a2f4fdcc}@supports (color:color-mix(in lab, red, red)){.ring-cyan-200\/80{--tw-ring-color:color-mix(in oklab, var(--color-cyan-200) 80%, transparent)}}.ring-emerald-500\/30{--tw-ring-color:#00bb7f4d}@supports (color:color-mix(in lab, red, red)){.ring-emerald-500\/30{--tw-ring-color:color-mix(in oklab, var(--color-emerald-500) 30%, transparent)}}.ring-gray-200{--tw-ring-color:var(--color-gray-200)}.ring-gray-200\/80{--tw-ring-color:#e5e7ebcc}@supports (color:color-mix(in lab, red, red)){.ring-gray-200\/80{--tw-ring-color:color-mix(in oklab, var(--color-gray-200) 80%, transparent)}}.ring-gray-300{--tw-ring-color:var(--color-gray-300)}.ring-gray-500\/30{--tw-ring-color:#6a72824d}@supports (color:color-mix(in lab, red, red)){.ring-gray-500\/30{--tw-ring-color:color-mix(in oklab, var(--color-gray-500) 30%, transparent)}}.ring-green-600\/20{--tw-ring-color:#00a54433}@supports (color:color-mix(in lab, red, red)){.ring-green-600\/20{--tw-ring-color:color-mix(in oklab, var(--color-green-600) 20%, transparent)}}.ring-indigo-500\/30{--tw-ring-color:#625fff4d}@supports (color:color-mix(in lab, red, red)){.ring-indigo-500\/30{--tw-ring-color:color-mix(in oklab, var(--color-indigo-500) 30%, transparent)}}.ring-red-200{--tw-ring-color:var(--color-red-200)}.ring-red-500{--tw-ring-color:var(--color-red-500)}.ring-white\/10{--tw-ring-color:#ffffff1a}@supports (color:color-mix(in lab, red, red)){.ring-white\/10{--tw-ring-color:color-mix(in oklab, var(--color-white) 10%, transparent)}}.ring-white\/90{--tw-ring-color:#ffffffe6}@supports (color:color-mix(in lab, red, red)){.ring-white\/90{--tw-ring-color:color-mix(in oklab, var(--color-white) 90%, transparent)}}.ring-offset-1{--tw-ring-offset-width:1px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.ring-offset-2{--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.ring-offset-white{--tw-ring-offset-color:var(--color-white)}.outline-hidden{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.outline-hidden{outline-offset:2px;outline:2px solid #0000}}.outline{outline-style:var(--tw-outline-style);outline-width:1px}.blur{--tw-blur:blur(8px);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.brightness-4{--tw-brightness:brightness(4%);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.brightness-6{--tw-brightness:brightness(6%);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.drop-shadow{--tw-drop-shadow-size:drop-shadow(0 1px 2px var(--tw-drop-shadow-color,#0000001a)) drop-shadow(0 1px 1px var(--tw-drop-shadow-color,#0000000f));--tw-drop-shadow:drop-shadow(0 1px 2px #0000001a) drop-shadow(0 1px 1px #0000000f);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.grayscale{--tw-grayscale:grayscale(100%);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.grayscale-\[0\.5\]{--tw-grayscale:grayscale(.5);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.invert{--tw-invert:invert(100%);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.sepia{--tw-sepia:sepia(100%);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter-none{filter:none}.backdrop-blur{--tw-backdrop-blur:blur(8px);-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-blur-\[0\.5px\]{--tw-backdrop-blur:blur(.5px);-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-blur-\[2px\]{--tw-backdrop-blur:blur(2px);-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-blur-md{--tw-backdrop-blur:blur(var(--blur-md));-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-blur-sm{--tw-backdrop-blur:blur(var(--blur-sm));-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-blur-xs{--tw-backdrop-blur:blur(var(--blur-xs));-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-grayscale{--tw-backdrop-grayscale:grayscale(100%);-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-invert{--tw-backdrop-invert:invert(100%);-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-sepia{--tw-backdrop-sepia:sepia(100%);-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-filter{-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-filter-none{-webkit-backdrop-filter:none;backdrop-filter:none}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[box-shadow\]{transition-property:box-shadow;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[left\]{transition-property:left;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[opacity\,background-color\,border-color\]{transition-property:opacity,background-color,border-color;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[width\]{transition-property:width;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-opacity{transition-property:opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-shadow{transition-property:box-shadow;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.duration-75{--tw-duration:75ms;transition-duration:75ms}.duration-100{--tw-duration:.1s;transition-duration:.1s}.duration-150{--tw-duration:.15s;transition-duration:.15s}.duration-200{--tw-duration:.2s;transition-duration:.2s}.duration-300{--tw-duration:.3s;transition-duration:.3s}.duration-500{--tw-duration:.5s;transition-duration:.5s}.duration-1000{--tw-duration:1s;transition-duration:1s}.ease-in{--tw-ease:var(--ease-in);transition-timing-function:var(--ease-in)}.ease-in-out{--tw-ease:var(--ease-in-out);transition-timing-function:var(--ease-in-out)}.ease-linear{--tw-ease:linear;transition-timing-function:linear}.ease-out{--tw-ease:var(--ease-out);transition-timing-function:var(--ease-out)}.contain-inline-size{--tw-contain-size:inline-size;contain:var(--tw-contain-size,) var(--tw-contain-layout,) var(--tw-contain-paint,) var(--tw-contain-style,)}.contain-layout{--tw-contain-layout:layout;contain:var(--tw-contain-size,) var(--tw-contain-layout,) var(--tw-contain-paint,) var(--tw-contain-style,)}.contain-paint{--tw-contain-paint:paint;contain:var(--tw-contain-size,) var(--tw-contain-layout,) var(--tw-contain-paint,) var(--tw-contain-style,)}.contain-size{--tw-contain-size:size;contain:var(--tw-contain-size,) var(--tw-contain-layout,) var(--tw-contain-paint,) var(--tw-contain-style,)}.contain-style{--tw-contain-style:style;contain:var(--tw-contain-size,) var(--tw-contain-layout,) var(--tw-contain-paint,) var(--tw-contain-style,)}.contain-\[layout_paint\]{contain:layout paint}.contain-content{contain:content}.contain-none{contain:none}.contain-strict{contain:strict}.forced-color-adjust-auto{forced-color-adjust:auto}.forced-color-adjust-none{forced-color-adjust:none}.outline-dashed{--tw-outline-style:dashed;outline-style:dashed}.outline-dotted{--tw-outline-style:dotted;outline-style:dotted}.outline-double{--tw-outline-style:double;outline-style:double}.outline-none{--tw-outline-style:none;outline-style:none}.select-all{-webkit-user-select:all;user-select:all}.select-auto{-webkit-user-select:auto;user-select:auto}.select-none{-webkit-user-select:none;user-select:none}.select-text{-webkit-user-select:text;user-select:text}.\[android\:prepare\]{android:prepare}.\[bench\:viz-wasm\]{bench:viz-wasm}.\[build-docs\:rns\]{build-docs:rns}.\[build\:all\]{build:all}.\[build\:fe\]{build:fe}.\[build\:frontend\]{build:frontend}.\[build\:viz-wasm\]{build:viz-wasm}.\[deps\:be\]{deps:be}.\[deps\:fe\]{deps:fe}.\[deps\:pins\]{deps:pins}.\[image\:tag\]{image:tag}.\[lint\:all\]{lint:all}.\[lint\:be\]{lint:be}.\[lint\:fe\]{lint:fe}.\[overflow-anchor\:none\]{overflow-anchor:none}.\[test\:all\]{test:all}.\[test\:fe\]{test:fe}.\[test\:viz-wasm\]{test:viz-wasm}:where(.divide-x-reverse>:not(:last-child)){--tw-divide-x-reverse:1}.ring-inset{--tw-ring-inset:inset}.group-open\:rotate-90:is(:where(.group):is([open],:popover-open,:open) *){rotate:90deg}.group-open\:rotate-180:is(:where(.group):is([open],:popover-open,:open) *){rotate:180deg}.group-focus-within\:opacity-100:is(:where(.group):focus-within *){opacity:1}@media (hover:hover){.group-hover\:flex:is(:where(.group):hover *){display:flex}.group-hover\:h-4:is(:where(.group):hover *){height:calc(var(--spacing) * 4)}.group-hover\:scale-110:is(:where(.group):hover *){--tw-scale-x:110%;--tw-scale-y:110%;--tw-scale-z:110%;scale:var(--tw-scale-x) var(--tw-scale-y)}.group-hover\:text-blue-600:is(:where(.group):hover *){color:var(--color-blue-600)}.group-hover\:text-gray-600:is(:where(.group):hover *){color:var(--color-gray-600)}.group-hover\:text-gray-700:is(:where(.group):hover *){color:var(--color-gray-700)}.group-hover\:text-gray-900:is(:where(.group):hover *){color:var(--color-gray-900)}.group-hover\:opacity-20:is(:where(.group):hover *){opacity:.2}.group-hover\:opacity-100:is(:where(.group):hover *){opacity:1}.group-hover\:shadow-indigo-500\/20:is(:where(.group):hover *){--tw-shadow-color:#625fff33}@supports (color:color-mix(in lab, red, red)){.group-hover\:shadow-indigo-500\/20:is(:where(.group):hover *){--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-indigo-500) 20%, transparent) var(--tw-shadow-alpha), transparent)}}.group-hover\/image-modal\:opacity-100:is(:where(.group\/image-modal):hover *){opacity:1}}.peer-checked\:bg-blue-600:is(:where(.peer):checked~*){background-color:var(--color-blue-600)}.peer-focus\:ring-4:is(:where(.peer):focus~*){--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(4px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.peer-focus\:ring-blue-300:is(:where(.peer):focus~*){--tw-ring-color:var(--color-blue-300)}.peer-focus\:outline-hidden:is(:where(.peer):focus~*){--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.peer-focus\:outline-hidden:is(:where(.peer):focus~*){outline-offset:2px;outline:2px solid #0000}}.file\:mr-4::file-selector-button{margin-right:calc(var(--spacing) * 4)}.file\:rounded-l-xl::file-selector-button{border-top-left-radius:var(--radius-xl);border-bottom-left-radius:var(--radius-xl)}.file\:border-0::file-selector-button{border-style:var(--tw-border-style);border-width:0}.file\:bg-blue-600::file-selector-button{background-color:var(--color-blue-600)}.file\:bg-zinc-200::file-selector-button{background-color:var(--color-zinc-200)}.file\:px-4::file-selector-button{padding-inline:calc(var(--spacing) * 4)}.file\:py-2::file-selector-button{padding-block:calc(var(--spacing) * 2)}.file\:py-2\.5::file-selector-button{padding-block:calc(var(--spacing) * 2.5)}.file\:text-sm::file-selector-button{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.file\:font-bold::file-selector-button{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.file\:font-semibold::file-selector-button{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.file\:text-white::file-selector-button{color:var(--color-white)}.file\:text-zinc-700::file-selector-button{color:var(--color-zinc-700)}.placeholder\:text-gray-400::placeholder{color:var(--color-gray-400)}.after\:absolute:after{content:var(--tw-content);position:absolute}.after\:top-\[2px\]:after{content:var(--tw-content);top:2px}.after\:left-\[2px\]:after{content:var(--tw-content);left:2px}.after\:h-5:after{content:var(--tw-content);height:calc(var(--spacing) * 5)}.after\:w-5:after{content:var(--tw-content);width:calc(var(--spacing) * 5)}.after\:rounded-full:after{content:var(--tw-content);border-radius:3.40282e38px}.after\:border:after{content:var(--tw-content);border-style:var(--tw-border-style);border-width:1px}.after\:border-gray-300:after{content:var(--tw-content);border-color:var(--color-gray-300)}.after\:bg-white:after{content:var(--tw-content);background-color:var(--color-white)}.after\:transition-all:after{content:var(--tw-content);transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.after\:content-\[\'\'\]:after{--tw-content:"";content:var(--tw-content)}.peer-checked\:after\:translate-x-full:is(:where(.peer):checked~*):after{content:var(--tw-content);--tw-translate-x:100%;translate:var(--tw-translate-x) var(--tw-translate-y)}.peer-checked\:after\:border-white:is(:where(.peer):checked~*):after{content:var(--tw-content);border-color:var(--color-white)}.first\:mt-0:first-child{margin-top:0}.first\:pt-0:first-child{padding-top:0}.last\:border-0:last-child{border-style:var(--tw-border-style);border-width:0}.last\:border-b-0:last-child{border-bottom-style:var(--tw-border-style);border-bottom-width:0}.open\:bg-white:is([open],:popover-open,:open){background-color:var(--color-white)}.focus-within\:ring-2:focus-within{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}@media (hover:hover){.hover\:z-10:hover{z-index:10}.hover\:scale-105:hover{--tw-scale-x:105%;--tw-scale-y:105%;--tw-scale-z:105%;scale:var(--tw-scale-x) var(--tw-scale-y)}.hover\:scale-110:hover{--tw-scale-x:110%;--tw-scale-y:110%;--tw-scale-z:110%;scale:var(--tw-scale-x) var(--tw-scale-y)}.hover\:scale-\[1\.02\]:hover{scale:1.02}.hover\:scale-\[1\.03\]:hover{scale:1.03}.hover\:border-amber-300:hover{border-color:var(--color-amber-300)}.hover\:border-amber-400:hover{border-color:var(--color-amber-400)}.hover\:border-amber-500:hover{border-color:var(--color-amber-500)}.hover\:border-blue-200:hover{border-color:var(--color-blue-200)}.hover\:border-blue-300:hover{border-color:var(--color-blue-300)}.hover\:border-blue-400:hover{border-color:var(--color-blue-400)}.hover\:border-blue-400\/60:hover{border-color:#54a2ff99}@supports (color:color-mix(in lab, red, red)){.hover\:border-blue-400\/60:hover{border-color:color-mix(in oklab, var(--color-blue-400) 60%, transparent)}}.hover\:border-blue-400\/70:hover{border-color:#54a2ffb3}@supports (color:color-mix(in lab, red, red)){.hover\:border-blue-400\/70:hover{border-color:color-mix(in oklab, var(--color-blue-400) 70%, transparent)}}.hover\:border-blue-500:hover{border-color:var(--color-blue-500)}.hover\:border-cyan-500:hover{border-color:var(--color-cyan-500)}.hover\:border-emerald-400:hover{border-color:var(--color-emerald-400)}.hover\:border-emerald-500:hover{border-color:var(--color-emerald-500)}.hover\:border-gray-300:hover{border-color:var(--color-gray-300)}.hover\:border-gray-400:hover{border-color:var(--color-gray-400)}.hover\:border-gray-500:hover{border-color:var(--color-gray-500)}.hover\:border-indigo-500:hover{border-color:var(--color-indigo-500)}.hover\:border-purple-500:hover{border-color:var(--color-purple-500)}.hover\:border-red-400:hover{border-color:var(--color-red-400)}.hover\:border-teal-500:hover{border-color:var(--color-teal-500)}.hover\:bg-\[var\(--mc-border-strong\)\]:hover{background-color:var(--mc-border-strong)}.hover\:bg-amber-50:hover{background-color:var(--color-amber-50)}.hover\:bg-amber-100:hover{background-color:var(--color-amber-100)}.hover\:bg-amber-200:hover{background-color:var(--color-amber-200)}.hover\:bg-amber-600:hover{background-color:var(--color-amber-600)}.hover\:bg-amber-700:hover{background-color:var(--color-amber-700)}.hover\:bg-amber-800:hover{background-color:var(--color-amber-800)}.hover\:bg-black\/5:hover{background-color:#0000000d}@supports (color:color-mix(in lab, red, red)){.hover\:bg-black\/5:hover{background-color:color-mix(in oklab, var(--color-black) 5%, transparent)}}.hover\:bg-black\/55:hover{background-color:#0000008c}@supports (color:color-mix(in lab, red, red)){.hover\:bg-black\/55:hover{background-color:color-mix(in oklab, var(--color-black) 55%, transparent)}}.hover\:bg-black\/70:hover{background-color:#000000b3}@supports (color:color-mix(in lab, red, red)){.hover\:bg-black\/70:hover{background-color:color-mix(in oklab, var(--color-black) 70%, transparent)}}.hover\:bg-blue-50:hover{background-color:var(--color-blue-50)}.hover\:bg-blue-50\/30:hover{background-color:#eff6ff4d}@supports (color:color-mix(in lab, red, red)){.hover\:bg-blue-50\/30:hover{background-color:color-mix(in oklab, var(--color-blue-50) 30%, transparent)}}.hover\:bg-blue-50\/60:hover{background-color:#eff6ff99}@supports (color:color-mix(in lab, red, red)){.hover\:bg-blue-50\/60:hover{background-color:color-mix(in oklab, var(--color-blue-50) 60%, transparent)}}.hover\:bg-blue-100:hover{background-color:var(--color-blue-100)}.hover\:bg-blue-200:hover{background-color:var(--color-blue-200)}.hover\:bg-blue-500:hover{background-color:var(--color-blue-500)}.hover\:bg-blue-500\/15:hover{background-color:#3080ff26}@supports (color:color-mix(in lab, red, red)){.hover\:bg-blue-500\/15:hover{background-color:color-mix(in oklab, var(--color-blue-500) 15%, transparent)}}.hover\:bg-blue-500\/20:hover{background-color:#3080ff33}@supports (color:color-mix(in lab, red, red)){.hover\:bg-blue-500\/20:hover{background-color:color-mix(in oklab, var(--color-blue-500) 20%, transparent)}}.hover\:bg-blue-600:hover{background-color:var(--color-blue-600)}.hover\:bg-blue-700:hover{background-color:var(--color-blue-700)}.hover\:bg-emerald-50:hover{background-color:var(--color-emerald-50)}.hover\:bg-emerald-100:hover{background-color:var(--color-emerald-100)}.hover\:bg-emerald-500:hover{background-color:var(--color-emerald-500)}.hover\:bg-emerald-500\/20:hover{background-color:#00bb7f33}@supports (color:color-mix(in lab, red, red)){.hover\:bg-emerald-500\/20:hover{background-color:color-mix(in oklab, var(--color-emerald-500) 20%, transparent)}}.hover\:bg-emerald-600:hover{background-color:var(--color-emerald-600)}.hover\:bg-emerald-700:hover{background-color:var(--color-emerald-700)}.hover\:bg-gray-50:hover{background-color:var(--color-gray-50)}.hover\:bg-gray-50\/30:hover{background-color:#f9fafb4d}@supports (color:color-mix(in lab, red, red)){.hover\:bg-gray-50\/30:hover{background-color:color-mix(in oklab, var(--color-gray-50) 30%, transparent)}}.hover\:bg-gray-50\/50:hover{background-color:#f9fafb80}@supports (color:color-mix(in lab, red, red)){.hover\:bg-gray-50\/50:hover{background-color:color-mix(in oklab, var(--color-gray-50) 50%, transparent)}}.hover\:bg-gray-50\/80:hover{background-color:#f9fafbcc}@supports (color:color-mix(in lab, red, red)){.hover\:bg-gray-50\/80:hover{background-color:color-mix(in oklab, var(--color-gray-50) 80%, transparent)}}.hover\:bg-gray-100:hover{background-color:var(--color-gray-100)}.hover\:bg-gray-100\/80:hover{background-color:#f3f4f6cc}@supports (color:color-mix(in lab, red, red)){.hover\:bg-gray-100\/80:hover{background-color:color-mix(in oklab, var(--color-gray-100) 80%, transparent)}}.hover\:bg-gray-200:hover{background-color:var(--color-gray-200)}.hover\:bg-gray-300:hover{background-color:var(--color-gray-300)}.hover\:bg-gray-400:hover{background-color:var(--color-gray-400)}.hover\:bg-green-100:hover{background-color:var(--color-green-100)}.hover\:bg-green-500:hover{background-color:var(--color-green-500)}.hover\:bg-green-500\/20:hover{background-color:#00c75833}@supports (color:color-mix(in lab, red, red)){.hover\:bg-green-500\/20:hover{background-color:color-mix(in oklab, var(--color-green-500) 20%, transparent)}}.hover\:bg-green-700:hover{background-color:var(--color-green-700)}.hover\:bg-indigo-100:hover{background-color:var(--color-indigo-100)}.hover\:bg-indigo-200:hover{background-color:var(--color-indigo-200)}.hover\:bg-indigo-500:hover{background-color:var(--color-indigo-500)}.hover\:bg-orange-100:hover{background-color:var(--color-orange-100)}.hover\:bg-orange-500:hover{background-color:var(--color-orange-500)}.hover\:bg-orange-700:hover{background-color:var(--color-orange-700)}.hover\:bg-pink-100:hover{background-color:var(--color-pink-100)}.hover\:bg-purple-800\/60:hover{background-color:#6e11b099}@supports (color:color-mix(in lab, red, red)){.hover\:bg-purple-800\/60:hover{background-color:color-mix(in oklab, var(--color-purple-800) 60%, transparent)}}.hover\:bg-purple-900\/40:hover{background-color:#59168b66}@supports (color:color-mix(in lab, red, red)){.hover\:bg-purple-900\/40:hover{background-color:color-mix(in oklab, var(--color-purple-900) 40%, transparent)}}.hover\:bg-red-50:hover{background-color:var(--color-red-50)}.hover\:bg-red-50\!:hover{background-color:var(--color-red-50)!important}.hover\:bg-red-100:hover{background-color:var(--color-red-100)}.hover\:bg-red-200:hover{background-color:var(--color-red-200)}.hover\:bg-red-500:hover{background-color:var(--color-red-500)}.hover\:bg-red-600:hover{background-color:var(--color-red-600)}.hover\:bg-red-700:hover{background-color:var(--color-red-700)}.hover\:bg-rose-700:hover{background-color:var(--color-rose-700)}.hover\:bg-sky-700:hover{background-color:var(--color-sky-700)}.hover\:bg-slate-50:hover{background-color:var(--color-slate-50)}.hover\:bg-slate-100\/80:hover{background-color:#f1f5f9cc}@supports (color:color-mix(in lab, red, red)){.hover\:bg-slate-100\/80:hover{background-color:color-mix(in oklab, var(--color-slate-100) 80%, transparent)}}.hover\:bg-teal-100:hover{background-color:var(--color-teal-100)}.hover\:bg-teal-500:hover{background-color:var(--color-teal-500)}.hover\:bg-violet-100:hover{background-color:var(--color-violet-100)}.hover\:bg-violet-700:hover{background-color:var(--color-violet-700)}.hover\:bg-white:hover{background-color:var(--color-white)}.hover\:bg-white\/10:hover{background-color:#ffffff1a}@supports (color:color-mix(in lab, red, red)){.hover\:bg-white\/10:hover{background-color:color-mix(in oklab, var(--color-white) 10%, transparent)}}.hover\:bg-white\/20:hover{background-color:#fff3}@supports (color:color-mix(in lab, red, red)){.hover\:bg-white\/20:hover{background-color:color-mix(in oklab, var(--color-white) 20%, transparent)}}.hover\:bg-white\/25:hover{background-color:#ffffff40}@supports (color:color-mix(in lab, red, red)){.hover\:bg-white\/25:hover{background-color:color-mix(in oklab, var(--color-white) 25%, transparent)}}.hover\:bg-white\/30:hover{background-color:#ffffff4d}@supports (color:color-mix(in lab, red, red)){.hover\:bg-white\/30:hover{background-color:color-mix(in oklab, var(--color-white) 30%, transparent)}}.hover\:bg-white\/50:hover{background-color:#ffffff80}@supports (color:color-mix(in lab, red, red)){.hover\:bg-white\/50:hover{background-color:color-mix(in oklab, var(--color-white) 50%, transparent)}}.hover\:bg-white\/90:hover{background-color:#ffffffe6}@supports (color:color-mix(in lab, red, red)){.hover\:bg-white\/90:hover{background-color:color-mix(in oklab, var(--color-white) 90%, transparent)}}.hover\:bg-yellow-700:hover{background-color:var(--color-yellow-700)}.hover\:bg-zinc-600:hover{background-color:var(--color-zinc-600)}.hover\:bg-zinc-700:hover{background-color:var(--color-zinc-700)}.hover\:bg-zinc-800:hover{background-color:var(--color-zinc-800)}.hover\:text-amber-500:hover{color:var(--color-amber-500)}.hover\:text-amber-600:hover{color:var(--color-amber-600)}.hover\:text-amber-800:hover{color:var(--color-amber-800)}.hover\:text-blue-300:hover{color:var(--color-blue-300)}.hover\:text-blue-500:hover{color:var(--color-blue-500)}.hover\:text-blue-600:hover{color:var(--color-blue-600)}.hover\:text-blue-700:hover{color:var(--color-blue-700)}.hover\:text-cyan-600:hover{color:var(--color-cyan-600)}.hover\:text-emerald-600:hover{color:var(--color-emerald-600)}.hover\:text-gray-500:hover{color:var(--color-gray-500)}.hover\:text-gray-600:hover{color:var(--color-gray-600)}.hover\:text-gray-700:hover{color:var(--color-gray-700)}.hover\:text-gray-800:hover{color:var(--color-gray-800)}.hover\:text-gray-900:hover{color:var(--color-gray-900)}.hover\:text-green-500:hover{color:var(--color-green-500)}.hover\:text-green-600:hover{color:var(--color-green-600)}.hover\:text-indigo-500:hover{color:var(--color-indigo-500)}.hover\:text-purple-100:hover{color:var(--color-purple-100)}.hover\:text-purple-200:hover{color:var(--color-purple-200)}.hover\:text-red-400:hover{color:var(--color-red-400)}.hover\:text-red-500:hover{color:var(--color-red-500)}.hover\:text-red-600:hover{color:var(--color-red-600)}.hover\:text-teal-500:hover{color:var(--color-teal-500)}.hover\:text-violet-600:hover{color:var(--color-violet-600)}.hover\:text-white:hover{color:var(--color-white)}.hover\:text-zinc-200:hover{color:var(--color-zinc-200)}.hover\:underline:hover{text-decoration-line:underline}.hover\:opacity-70:hover{opacity:.7}.hover\:opacity-90:hover{opacity:.9}.hover\:opacity-100:hover{opacity:1}.hover\:shadow-2xl:hover{--tw-shadow:0 25px 50px -12px var(--tw-shadow-color,#00000040);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.hover\:shadow-md:hover{--tw-shadow:0 4px 6px -1px var(--tw-shadow-color,#0000001a), 0 2px 4px -2px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.hover\:shadow-xl:hover{--tw-shadow:0 20px 25px -5px var(--tw-shadow-color,#0000001a), 0 8px 10px -6px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.hover\:ring-2:hover{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.hover\:ring-blue-500\/50:hover{--tw-ring-color:#3080ff80}@supports (color:color-mix(in lab, red, red)){.hover\:ring-blue-500\/50:hover{--tw-ring-color:color-mix(in oklab, var(--color-blue-500) 50%, transparent)}}.hover\:file\:bg-blue-700:hover::file-selector-button{background-color:var(--color-blue-700)}.hover\:file\:bg-zinc-300:hover::file-selector-button{background-color:var(--color-zinc-300)}}.focus\:border-blue-400:focus{border-color:var(--color-blue-400)}.focus\:border-blue-500:focus{border-color:var(--color-blue-500)}.focus\:border-indigo-400:focus{border-color:var(--color-indigo-400)}.focus\:border-indigo-500:focus{border-color:var(--color-indigo-500)}.focus\:border-transparent:focus{border-color:#0000}.focus\:opacity-100:focus{opacity:1}.focus\:ring-0:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\:ring-1:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\:ring-2:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\:ring-amber-500:focus{--tw-ring-color:var(--color-amber-500)}.focus\:ring-blue-400:focus{--tw-ring-color:var(--color-blue-400)}.focus\:ring-blue-500:focus{--tw-ring-color:var(--color-blue-500)}.focus\:ring-blue-500\/20:focus{--tw-ring-color:#3080ff33}@supports (color:color-mix(in lab, red, red)){.focus\:ring-blue-500\/20:focus{--tw-ring-color:color-mix(in oklab, var(--color-blue-500) 20%, transparent)}}.focus\:ring-blue-500\/30:focus{--tw-ring-color:#3080ff4d}@supports (color:color-mix(in lab, red, red)){.focus\:ring-blue-500\/30:focus{--tw-ring-color:color-mix(in oklab, var(--color-blue-500) 30%, transparent)}}.focus\:ring-blue-500\/40:focus{--tw-ring-color:#3080ff66}@supports (color:color-mix(in lab, red, red)){.focus\:ring-blue-500\/40:focus{--tw-ring-color:color-mix(in oklab, var(--color-blue-500) 40%, transparent)}}.focus\:ring-blue-500\/50:focus{--tw-ring-color:#3080ff80}@supports (color:color-mix(in lab, red, red)){.focus\:ring-blue-500\/50:focus{--tw-ring-color:color-mix(in oklab, var(--color-blue-500) 50%, transparent)}}.focus\:ring-blue-600:focus{--tw-ring-color:var(--color-blue-600)}.focus\:ring-emerald-500:focus{--tw-ring-color:var(--color-emerald-500)}.focus\:ring-indigo-400:focus{--tw-ring-color:var(--color-indigo-400)}.focus\:ring-indigo-500:focus{--tw-ring-color:var(--color-indigo-500)}.focus\:ring-red-500:focus{--tw-ring-color:var(--color-red-500)}.focus\:ring-white\/50:focus{--tw-ring-color:#ffffff80}@supports (color:color-mix(in lab, red, red)){.focus\:ring-white\/50:focus{--tw-ring-color:color-mix(in oklab, var(--color-white) 50%, transparent)}}.focus\:ring-offset-2:focus{--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.focus\:outline-hidden:focus{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.focus\:outline-hidden:focus{outline-offset:2px;outline:2px solid #0000}}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}.focus\:ring-inset:focus{--tw-ring-inset:inset}.focus-visible\:opacity-100:focus-visible{opacity:1}.focus-visible\:ring-1:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus-visible\:ring-2:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus-visible\:ring-blue-500:focus-visible{--tw-ring-color:var(--color-blue-500)}.focus-visible\:ring-blue-500\/50:focus-visible{--tw-ring-color:#3080ff80}@supports (color:color-mix(in lab, red, red)){.focus-visible\:ring-blue-500\/50:focus-visible{--tw-ring-color:color-mix(in oklab, var(--color-blue-500) 50%, transparent)}}.focus-visible\:outline-2:focus-visible{outline-style:var(--tw-outline-style);outline-width:2px}.focus-visible\:outline-offset-2:focus-visible{outline-offset:2px}.focus-visible\:outline-gray-500:focus-visible{outline-color:var(--color-gray-500)}.focus-visible\:outline-solid:focus-visible{--tw-outline-style:solid;outline-style:solid}.active\:scale-90:active{--tw-scale-x:90%;--tw-scale-y:90%;--tw-scale-z:90%;scale:var(--tw-scale-x) var(--tw-scale-y)}.active\:scale-95:active{--tw-scale-x:95%;--tw-scale-y:95%;--tw-scale-z:95%;scale:var(--tw-scale-x) var(--tw-scale-y)}.active\:scale-\[0\.98\]:active{scale:.98}.active\:cursor-grabbing:active{cursor:grabbing}.disabled\:pointer-events-none:disabled{pointer-events:none}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:bg-blue-300:disabled{background-color:var(--color-blue-300)}.disabled\:bg-gray-100:disabled{background-color:var(--color-gray-100)}.disabled\:bg-gray-300:disabled{background-color:var(--color-gray-300)}.disabled\:bg-gray-400:disabled{background-color:var(--color-gray-400)}.disabled\:text-gray-300:disabled{color:var(--color-gray-300)}.disabled\:opacity-30:disabled{opacity:.3}.disabled\:opacity-40:disabled{opacity:.4}.disabled\:opacity-50:disabled{opacity:.5}.disabled\:opacity-60:disabled{opacity:.6}@media (hover:hover){.disabled\:hover\:scale-100:disabled:hover{--tw-scale-x:100%;--tw-scale-y:100%;--tw-scale-z:100%;scale:var(--tw-scale-x) var(--tw-scale-y)}.disabled\:hover\:bg-violet-50:disabled:hover{background-color:var(--color-violet-50)}}@media (prefers-reduced-motion:reduce){.motion-reduce\:animate-none{animation:none}}@media not all and (min-width:40rem){.max-sm\:right-auto{right:auto}.max-sm\:bottom-22{bottom:calc(var(--spacing) * 22)}.max-sm\:bottom-\[calc\(1rem\+env\(safe-area-inset-bottom\,0px\)\)\]{bottom:calc(1rem + env(safe-area-inset-bottom,0px))}.max-sm\:bottom-\[calc\(5\.5rem\+env\(safe-area-inset-bottom\,0px\)\)\]{bottom:calc(5.5rem + env(safe-area-inset-bottom,0px))}.max-sm\:left-1\/2{left:50%}.max-sm\:h-\[92dvh\]{height:92dvh}.max-sm\:max-h-\[92dvh\]{max-height:92dvh}.max-sm\:min-h-\[55dvh\]{min-height:55dvh}.max-sm\:-translate-x-1\/2{--tw-translate-x:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.max-sm\:text-\[8px\]{font-size:8px}}@media (min-width:900px){.min-\[900px\]\:size-4{width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.min-\[900px\]\:size-5{width:calc(var(--spacing) * 5);height:calc(var(--spacing) * 5)}.min-\[900px\]\:h-9{height:calc(var(--spacing) * 9)}.min-\[900px\]\:w-9{width:calc(var(--spacing) * 9)}.min-\[900px\]\:max-w-\[14rem\]{max-width:14rem}.min-\[900px\]\:min-w-\[7rem\]{min-width:7rem}.min-\[900px\]\:px-3{padding-inline:calc(var(--spacing) * 3)}.min-\[900px\]\:text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}}@media (min-width:40rem){.sm\:relative{position:relative}.sm\:inset-auto{inset:auto}.sm\:top-2{top:calc(var(--spacing) * 2)}.sm\:top-4{top:calc(var(--spacing) * 4)}.sm\:right-0{right:0}.sm\:right-4{right:calc(var(--spacing) * 4)}.sm\:right-auto{right:auto}.sm\:bottom-4{bottom:calc(var(--spacing) * 4)}.sm\:bottom-5{bottom:calc(var(--spacing) * 5)}.sm\:left-4{left:calc(var(--spacing) * 4)}.sm\:left-auto{left:auto}.sm\:z-0{z-index:0}.sm\:z-auto{z-index:auto}.sm\:col-span-1{grid-column:span 1/span 1}.sm\:col-span-2{grid-column:span 2/span 2}.sm\:m-0{margin:0}.sm\:-mx-4{margin-inline:calc(var(--spacing) * -4)}.sm\:mx-0{margin-inline:0}.sm\:mx-1{margin-inline:var(--spacing)}.sm\:my-auto{margin-block:auto}.sm\:mt-0{margin-top:0}.sm\:mt-2{margin-top:calc(var(--spacing) * 2)}.sm\:mt-8{margin-top:calc(var(--spacing) * 8)}.sm\:mr-2{margin-right:calc(var(--spacing) * 2)}.sm\:mb-4{margin-bottom:calc(var(--spacing) * 4)}.sm\:ml-0{margin-left:0}.sm\:ml-1\.5{margin-left:calc(var(--spacing) * 1.5)}.sm\:ml-auto{margin-left:auto}.sm\:line-clamp-none{-webkit-line-clamp:unset;-webkit-box-orient:horizontal;display:block;overflow:visible}.sm\:block{display:block}.sm\:flex{display:flex}.sm\:hidden{display:none}.sm\:inline{display:inline}.sm\:inline-block{display:inline-block}.sm\:inline-flex{display:inline-flex}.sm\:size-5{width:calc(var(--spacing) * 5);height:calc(var(--spacing) * 5)}.sm\:size-5\!{width:calc(var(--spacing) * 5)!important;height:calc(var(--spacing) * 5)!important}.sm\:size-6{width:calc(var(--spacing) * 6);height:calc(var(--spacing) * 6)}.sm\:size-7{width:calc(var(--spacing) * 7);height:calc(var(--spacing) * 7)}.sm\:size-12{width:calc(var(--spacing) * 12);height:calc(var(--spacing) * 12)}.sm\:size-48{width:calc(var(--spacing) * 48);height:calc(var(--spacing) * 48)}.sm\:h-4{height:calc(var(--spacing) * 4)}.sm\:h-5{height:calc(var(--spacing) * 5)}.sm\:h-6{height:calc(var(--spacing) * 6)}.sm\:h-9{height:calc(var(--spacing) * 9)}.sm\:h-11{height:calc(var(--spacing) * 11)}.sm\:h-12{height:calc(var(--spacing) * 12)}.sm\:h-14{height:calc(var(--spacing) * 14)}.sm\:h-28{height:calc(var(--spacing) * 28)}.sm\:h-\[320px\]{height:320px}.sm\:max-h-\[90vh\]{max-height:90vh}.sm\:min-h-0{min-height:0}.sm\:min-h-14{min-height:calc(var(--spacing) * 14)}.sm\:min-h-\[12rem\]{min-height:12rem}.sm\:min-h-\[280px\]{min-height:280px}.sm\:w-4{width:calc(var(--spacing) * 4)}.sm\:w-5{width:calc(var(--spacing) * 5)}.sm\:w-6{width:calc(var(--spacing) * 6)}.sm\:w-9{width:calc(var(--spacing) * 9)}.sm\:w-11{width:calc(var(--spacing) * 11)}.sm\:w-12{width:calc(var(--spacing) * 12)}.sm\:w-14{width:calc(var(--spacing) * 14)}.sm\:w-20{width:calc(var(--spacing) * 20)}.sm\:w-24{width:calc(var(--spacing) * 24)}.sm\:w-32{width:calc(var(--spacing) * 32)}.sm\:w-40{width:calc(var(--spacing) * 40)}.sm\:w-64{width:calc(var(--spacing) * 64)}.sm\:w-80{width:calc(var(--spacing) * 80)}.sm\:w-\[280px\]{width:280px}.sm\:w-auto{width:auto}.sm\:max-w-3xl{max-width:var(--container-3xl)}.sm\:max-w-48{max-width:calc(var(--spacing) * 48)}.sm\:max-w-56{max-width:calc(var(--spacing) * 56)}.sm\:max-w-\[16rem\]{max-width:16rem}.sm\:max-w-\[75\%\]{max-width:75%}.sm\:max-w-\[280px\]{max-width:280px}.sm\:max-w-lg{max-width:var(--container-lg)}.sm\:max-w-md{max-width:var(--container-md)}.sm\:max-w-none{max-width:none}.sm\:max-w-sm{max-width:var(--container-sm)}.sm\:max-w-xs{max-width:var(--container-xs)}.sm\:min-w-0{min-width:0}.sm\:min-w-12{min-width:calc(var(--spacing) * 12)}.sm\:min-w-80{min-width:calc(var(--spacing) * 80)}.sm\:min-w-\[280px\]{min-width:280px}.sm\:min-w-\[300px\]{min-width:300px}.sm\:min-w-\[400px\]{min-width:400px}.sm\:flex-1{flex:1}.sm\:flex-initial{flex:0 auto}.sm\:flex-none{flex:none}.sm\:shrink-0{flex-shrink:0}.sm\:translate-x-0{--tw-translate-x:0px;translate:var(--tw-translate-x) var(--tw-translate-y)}.sm\:grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.sm\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.sm\:grid-cols-5{grid-template-columns:repeat(5,minmax(0,1fr))}.sm\:grid-cols-\[1fr_10rem\]{grid-template-columns:1fr 10rem}.sm\:grid-cols-\[1fr_auto\]{grid-template-columns:1fr auto}.sm\:grid-cols-\[auto_minmax\(0\,0\.75fr\)_minmax\(0\,1fr\)_minmax\(0\,1\.1fr\)\]{grid-template-columns:auto minmax(0,.75fr) minmax(0,1fr) minmax(0,1.1fr)}.sm\:grid-cols-\[minmax\(0\,1\.15fr\)_auto_minmax\(0\,1\.35fr\)_auto\]{grid-template-columns:minmax(0,1.15fr) auto minmax(0,1.35fr) auto}.sm\:flex-col{flex-direction:column}.sm\:flex-row{flex-direction:row}.sm\:flex-nowrap{flex-wrap:nowrap}.sm\:flex-wrap{flex-wrap:wrap}.sm\:items-baseline{align-items:baseline}.sm\:items-center{align-items:center}.sm\:items-end{align-items:flex-end}.sm\:items-start{align-items:flex-start}.sm\:justify-between{justify-content:space-between}.sm\:justify-center{justify-content:center}.sm\:justify-end{justify-content:flex-end}.sm\:justify-start{justify-content:flex-start}.sm\:gap-0\.5{gap:calc(var(--spacing) * .5)}.sm\:gap-1{gap:var(--spacing)}.sm\:gap-1\.5{gap:calc(var(--spacing) * 1.5)}.sm\:gap-2{gap:calc(var(--spacing) * 2)}.sm\:gap-3{gap:calc(var(--spacing) * 3)}.sm\:gap-4{gap:calc(var(--spacing) * 4)}.sm\:gap-5{gap:calc(var(--spacing) * 5)}.sm\:gap-6{gap:calc(var(--spacing) * 6)}:where(.sm\:space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}:where(.sm\:space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}:where(.sm\:space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}.sm\:gap-x-3{column-gap:calc(var(--spacing) * 3)}:where(.sm\:space-x-2>:not(:last-child)){--tw-space-x-reverse:0;margin-inline-start:calc(calc(var(--spacing) * 2) * var(--tw-space-x-reverse));margin-inline-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-x-reverse)))}.sm\:gap-y-0{row-gap:0}.sm\:self-auto{align-self:auto}.sm\:rounded-2xl{border-radius:var(--radius-2xl)}.sm\:rounded-3xl{border-radius:var(--radius-3xl)}.sm\:rounded-\[2rem\]{border-radius:2rem}.sm\:rounded-lg{border-radius:var(--radius-lg)}.sm\:rounded-xl{border-radius:var(--radius-xl)}.sm\:border{border-style:var(--tw-border-style);border-width:1px}.sm\:border-0{border-style:var(--tw-border-style);border-width:0}.sm\:bg-black\/2{background-color:#00000005}@supports (color:color-mix(in lab, red, red)){.sm\:bg-black\/2{background-color:color-mix(in oklab, var(--color-black) 2%, transparent)}}.sm\:bg-black\/50{background-color:#00000080}@supports (color:color-mix(in lab, red, red)){.sm\:bg-black\/50{background-color:color-mix(in oklab, var(--color-black) 50%, transparent)}}.sm\:p-1{padding:var(--spacing)}.sm\:p-1\.5{padding:calc(var(--spacing) * 1.5)}.sm\:p-2{padding:calc(var(--spacing) * 2)}.sm\:p-3{padding:calc(var(--spacing) * 3)}.sm\:p-4{padding:calc(var(--spacing) * 4)}.sm\:p-5{padding:calc(var(--spacing) * 5)}.sm\:p-6{padding:calc(var(--spacing) * 6)}.sm\:p-8{padding:calc(var(--spacing) * 8)}.sm\:p-12{padding:calc(var(--spacing) * 12)}.sm\:px-0{padding-inline:0}.sm\:px-1{padding-inline:var(--spacing)}.sm\:px-2{padding-inline:calc(var(--spacing) * 2)}.sm\:px-3{padding-inline:calc(var(--spacing) * 3)}.sm\:px-4{padding-inline:calc(var(--spacing) * 4)}.sm\:px-5{padding-inline:calc(var(--spacing) * 5)}.sm\:px-6{padding-inline:calc(var(--spacing) * 6)}.sm\:py-1{padding-block:var(--spacing)}.sm\:py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.sm\:py-2{padding-block:calc(var(--spacing) * 2)}.sm\:py-2\.5{padding-block:calc(var(--spacing) * 2.5)}.sm\:py-3{padding-block:calc(var(--spacing) * 3)}.sm\:py-4{padding-block:calc(var(--spacing) * 4)}.sm\:py-6{padding-block:calc(var(--spacing) * 6)}.sm\:py-8{padding-block:calc(var(--spacing) * 8)}.sm\:py-10{padding-block:calc(var(--spacing) * 10)}.sm\:py-12{padding-block:calc(var(--spacing) * 12)}.sm\:py-20{padding-block:calc(var(--spacing) * 20)}.sm\:ps-0{padding-inline-start:0}.sm\:pe-3{padding-inline-end:calc(var(--spacing) * 3)}.sm\:pt-0{padding-top:0}.sm\:pt-0\.5{padding-top:calc(var(--spacing) * .5)}.sm\:pt-1{padding-top:var(--spacing)}.sm\:pt-1\.5{padding-top:calc(var(--spacing) * 1.5)}.sm\:pt-2{padding-top:calc(var(--spacing) * 2)}.sm\:pr-1{padding-right:var(--spacing)}.sm\:pr-2{padding-right:calc(var(--spacing) * 2)}.sm\:pr-4{padding-right:calc(var(--spacing) * 4)}.sm\:pr-12{padding-right:calc(var(--spacing) * 12)}.sm\:pr-40{padding-right:calc(var(--spacing) * 40)}.sm\:pb-4{padding-bottom:calc(var(--spacing) * 4)}.sm\:pb-24{padding-bottom:calc(var(--spacing) * 24)}.sm\:pl-0{padding-left:0}.sm\:pl-1{padding-left:var(--spacing)}.sm\:pl-2{padding-left:calc(var(--spacing) * 2)}.sm\:pl-4{padding-left:calc(var(--spacing) * 4)}.sm\:pl-14{padding-left:calc(var(--spacing) * 14)}.sm\:text-left{text-align:left}.sm\:text-right{text-align:right}.sm\:text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.sm\:text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.sm\:text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.sm\:text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.sm\:text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.sm\:text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.sm\:text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.sm\:text-\[10px\]{font-size:10px}.sm\:text-\[11px\]{font-size:11px}.sm\:leading-6{--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6)}.sm\:opacity-100{opacity:1}.sm\:focus\:w-80:focus{width:calc(var(--spacing) * 80)}}@media (min-width:48rem){.md\:static{position:static}.md\:z-auto{z-index:auto}.md\:flex{display:flex}.md\:hidden{display:none}.md\:inline{display:inline}.md\:size-6{width:calc(var(--spacing) * 6);height:calc(var(--spacing) * 6)}.md\:w-48{width:calc(var(--spacing) * 48)}.md\:w-56{width:calc(var(--spacing) * 56)}.md\:w-72{width:calc(var(--spacing) * 72)}.md\:w-80{width:calc(var(--spacing) * 80)}.md\:w-96{width:calc(var(--spacing) * 96)}.md\:w-auto{width:auto}.md\:max-w-\[9rem\]{max-width:9rem}.md\:max-w-none{max-width:none}.md\:max-w-sm{max-width:var(--container-sm)}.md\:max-w-xs{max-width:var(--container-xs)}.md\:min-w-\[200px\]{min-width:200px}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.md\:flex-row{flex-direction:row}.md\:rounded-xl{border-radius:var(--radius-xl)}.md\:border{border-style:var(--tw-border-style);border-width:1px}.md\:border-r{border-right-style:var(--tw-border-style);border-right-width:1px}.md\:border-b-0{border-bottom-style:var(--tw-border-style);border-bottom-width:0}.md\:p-4{padding:calc(var(--spacing) * 4)}.md\:p-6{padding:calc(var(--spacing) * 6)}.md\:p-8{padding:calc(var(--spacing) * 8)}.md\:p-10{padding:calc(var(--spacing) * 10)}.md\:px-4{padding-inline:calc(var(--spacing) * 4)}.md\:px-5{padding-inline:calc(var(--spacing) * 5)}.md\:px-6{padding-inline:calc(var(--spacing) * 6)}.md\:px-10{padding-inline:calc(var(--spacing) * 10)}.md\:px-12{padding-inline:calc(var(--spacing) * 12)}.md\:py-3{padding-block:calc(var(--spacing) * 3)}.md\:py-5{padding-block:calc(var(--spacing) * 5)}.md\:py-6{padding-block:calc(var(--spacing) * 6)}.md\:py-10{padding-block:calc(var(--spacing) * 10)}.md\:py-12{padding-block:calc(var(--spacing) * 12)}.md\:pr-6{padding-right:calc(var(--spacing) * 6)}.md\:pl-4{padding-left:calc(var(--spacing) * 4)}.md\:text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.md\:text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.md\:text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.md\:text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.md\:text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.md\:opacity-70{opacity:.7}.md\:shadow-none{--tw-shadow:0 0 #0000;box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.md\:grayscale-\[0\.3\]{--tw-grayscale:grayscale(.3);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.md\:backdrop-blur-\[0\.5px\]{--tw-backdrop-blur:blur(.5px);-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}@media not all and (min-width:64rem){.md\:max-lg\:w-64{width:calc(var(--spacing) * 64)}.md\:max-lg\:w-72{width:calc(var(--spacing) * 72)}.md\:max-lg\:min-w-64{min-width:calc(var(--spacing) * 64)}.md\:max-lg\:focus\:w-72:focus{width:calc(var(--spacing) * 72)}}}@media (min-width:64rem){.lg\:sticky{position:sticky}.lg\:top-20{top:calc(var(--spacing) * 20)}.lg\:col-span-1{grid-column:span 1/span 1}.lg\:col-span-2{grid-column:span 2/span 2}.lg\:col-span-3{grid-column:span 3/span 3}.lg\:block{display:block}.lg\:flex{display:flex}.lg\:hidden{display:none}.lg\:inline{display:inline}.lg\:inline-flex{display:inline-flex}.lg\:max-h-none{max-height:none}.lg\:w-52{width:calc(var(--spacing) * 52)}.lg\:w-80{width:calc(var(--spacing) * 80)}.lg\:w-auto{width:auto}.lg\:max-w-\[65\%\]{max-width:65%}.lg\:max-w-md{max-width:var(--container-md)}.lg\:min-w-80{min-width:calc(var(--spacing) * 80)}.lg\:min-w-\[120px\]{min-width:120px}.lg\:flex-none{flex:none}.lg\:shrink-0{flex-shrink:0}.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.lg\:grid-cols-5{grid-template-columns:repeat(5,minmax(0,1fr))}.lg\:flex-col{flex-direction:column}.lg\:flex-row{flex-direction:row}.lg\:items-center{align-items:center}.lg\:items-end{align-items:flex-end}.lg\:items-start{align-items:flex-start}.lg\:justify-between{justify-content:space-between}.lg\:gap-0\.5{gap:calc(var(--spacing) * .5)}.lg\:gap-6{gap:calc(var(--spacing) * 6)}.lg\:gap-8{gap:calc(var(--spacing) * 8)}.lg\:gap-12{gap:calc(var(--spacing) * 12)}:where(.lg\:divide-x>:not(:last-child)){--tw-divide-x-reverse:0;border-inline-style:var(--tw-border-style);border-inline-start-width:calc(1px * var(--tw-divide-x-reverse));border-inline-end-width:calc(1px * calc(1 - var(--tw-divide-x-reverse)))}:where(.lg\:divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)))}.lg\:overflow-x-visible{overflow-x:visible}.lg\:border-t-0{border-top-style:var(--tw-border-style);border-top-width:0}.lg\:border-r{border-right-style:var(--tw-border-style);border-right-width:1px}.lg\:border-b-0{border-bottom-style:var(--tw-border-style);border-bottom-width:0}.lg\:p-16{padding:calc(var(--spacing) * 16)}.lg\:px-8{padding-inline:calc(var(--spacing) * 8)}.lg\:py-8{padding-block:calc(var(--spacing) * 8)}.lg\:pb-0{padding-bottom:0}.lg\:opacity-0{opacity:0}@media (hover:hover){.lg\:group-hover\:opacity-100:is(:where(.group):hover *){opacity:1}}.lg\:focus\:w-80:focus{width:calc(var(--spacing) * 80)}}@media (min-width:80rem){.xl\:sticky{position:sticky}.xl\:top-4{top:calc(var(--spacing) * 4)}.xl\:order-1{order:1}.xl\:order-2{order:2}.xl\:col-span-2{grid-column:span 2/span 2}.xl\:flex{display:flex}.xl\:max-h-\[min\(calc\(100dvh-6rem\)\,920px\)\]{max-height:min(100dvh - 6rem,920px)}.xl\:max-h-\[min\(calc\(100dvh-14rem\)\,44rem\)\]{max-height:min(100dvh - 14rem,44rem)}.xl\:w-96{width:calc(var(--spacing) * 96)}.xl\:max-w-4xl{max-width:var(--container-4xl)}.xl\:max-w-7xl{max-width:var(--container-7xl)}.xl\:max-w-\[min\(100\%\,96rem\)\]{max-width:min(100%,96rem)}.xl\:max-w-lg{max-width:var(--container-lg)}.xl\:max-w-sm{max-width:var(--container-sm)}.xl\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.xl\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.xl\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.xl\:flex-row{flex-direction:row}.xl\:items-start{align-items:flex-start}.xl\:gap-6{gap:calc(var(--spacing) * 6)}.xl\:gap-10{gap:calc(var(--spacing) * 10)}.xl\:self-start{align-self:flex-start}.xl\:overflow-y-auto{overflow-y:auto}.xl\:border-l{border-left-style:var(--tw-border-style);border-left-width:1px}.xl\:p-8{padding:calc(var(--spacing) * 8)}.xl\:pl-8{padding-left:calc(var(--spacing) * 8)}}@media (min-width:96rem){.\32 xl\:max-w-5xl{max-width:var(--container-5xl)}.\32 xl\:max-w-360{max-width:calc(var(--spacing) * 360)}.\32 xl\:max-w-384{max-width:calc(var(--spacing) * 384)}.\32 xl\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.\32 xl\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}:where(.dark\:divide-zinc-700:where(.dark,.dark *)>:not(:last-child)){border-color:var(--color-zinc-700)}:where(.dark\:divide-zinc-800:where(.dark,.dark *)>:not(:last-child)){border-color:var(--color-zinc-800)}:where(.dark\:divide-zinc-800\/50:where(.dark,.dark *)>:not(:last-child)){border-color:#27272a80}@supports (color:color-mix(in lab, red, red)){:where(.dark\:divide-zinc-800\/50:where(.dark,.dark *)>:not(:last-child)){border-color:color-mix(in oklab, var(--color-zinc-800) 50%, transparent)}}:where(.dark\:divide-zinc-800\/60:where(.dark,.dark *)>:not(:last-child)){border-color:#27272a99}@supports (color:color-mix(in lab, red, red)){:where(.dark\:divide-zinc-800\/60:where(.dark,.dark *)>:not(:last-child)){border-color:color-mix(in oklab, var(--color-zinc-800) 60%, transparent)}}.dark\:border-amber-600:where(.dark,.dark *){border-color:var(--color-amber-600)}.dark\:border-amber-700:where(.dark,.dark *){border-color:var(--color-amber-700)}.dark\:border-amber-800:where(.dark,.dark *){border-color:var(--color-amber-800)}.dark\:border-amber-800\/30:where(.dark,.dark *){border-color:#953d004d}@supports (color:color-mix(in lab, red, red)){.dark\:border-amber-800\/30:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-amber-800) 30%, transparent)}}.dark\:border-amber-800\/40:where(.dark,.dark *){border-color:#953d0066}@supports (color:color-mix(in lab, red, red)){.dark\:border-amber-800\/40:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-amber-800) 40%, transparent)}}.dark\:border-amber-800\/50:where(.dark,.dark *){border-color:#953d0080}@supports (color:color-mix(in lab, red, red)){.dark\:border-amber-800\/50:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-amber-800) 50%, transparent)}}.dark\:border-amber-800\/60:where(.dark,.dark *){border-color:#953d0099}@supports (color:color-mix(in lab, red, red)){.dark\:border-amber-800\/60:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-amber-800) 60%, transparent)}}.dark\:border-amber-900:where(.dark,.dark *){border-color:var(--color-amber-900)}.dark\:border-amber-900\/30:where(.dark,.dark *){border-color:#7b33064d}@supports (color:color-mix(in lab, red, red)){.dark\:border-amber-900\/30:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-amber-900) 30%, transparent)}}.dark\:border-amber-900\/40:where(.dark,.dark *){border-color:#7b330666}@supports (color:color-mix(in lab, red, red)){.dark\:border-amber-900\/40:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-amber-900) 40%, transparent)}}.dark\:border-amber-900\/50:where(.dark,.dark *){border-color:#7b330680}@supports (color:color-mix(in lab, red, red)){.dark\:border-amber-900\/50:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-amber-900) 50%, transparent)}}.dark\:border-blue-400:where(.dark,.dark *){border-color:var(--color-blue-400)}.dark\:border-blue-500:where(.dark,.dark *){border-color:var(--color-blue-500)}.dark\:border-blue-700:where(.dark,.dark *){border-color:var(--color-blue-700)}.dark\:border-blue-800:where(.dark,.dark *){border-color:var(--color-blue-800)}.dark\:border-blue-800\/30:where(.dark,.dark *){border-color:#193cb84d}@supports (color:color-mix(in lab, red, red)){.dark\:border-blue-800\/30:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-blue-800) 30%, transparent)}}.dark\:border-blue-900\/20:where(.dark,.dark *){border-color:#1c398e33}@supports (color:color-mix(in lab, red, red)){.dark\:border-blue-900\/20:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-blue-900) 20%, transparent)}}.dark\:border-blue-900\/30:where(.dark,.dark *){border-color:#1c398e4d}@supports (color:color-mix(in lab, red, red)){.dark\:border-blue-900\/30:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-blue-900) 30%, transparent)}}.dark\:border-blue-900\/40:where(.dark,.dark *){border-color:#1c398e66}@supports (color:color-mix(in lab, red, red)){.dark\:border-blue-900\/40:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-blue-900) 40%, transparent)}}.dark\:border-blue-900\/50:where(.dark,.dark *){border-color:#1c398e80}@supports (color:color-mix(in lab, red, red)){.dark\:border-blue-900\/50:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-blue-900) 50%, transparent)}}.dark\:border-emerald-500\/30:where(.dark,.dark *){border-color:#00bb7f4d}@supports (color:color-mix(in lab, red, red)){.dark\:border-emerald-500\/30:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-emerald-500) 30%, transparent)}}.dark\:border-emerald-900\/20:where(.dark,.dark *){border-color:#004e3b33}@supports (color:color-mix(in lab, red, red)){.dark\:border-emerald-900\/20:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-emerald-900) 20%, transparent)}}.dark\:border-emerald-900\/30:where(.dark,.dark *){border-color:#004e3b4d}@supports (color:color-mix(in lab, red, red)){.dark\:border-emerald-900\/30:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-emerald-900) 30%, transparent)}}.dark\:border-emerald-900\/40:where(.dark,.dark *){border-color:#004e3b66}@supports (color:color-mix(in lab, red, red)){.dark\:border-emerald-900\/40:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-emerald-900) 40%, transparent)}}.dark\:border-gray-600:where(.dark,.dark *){border-color:var(--color-gray-600)}.dark\:border-gray-700:where(.dark,.dark *){border-color:var(--color-gray-700)}.dark\:border-green-800:where(.dark,.dark *){border-color:var(--color-green-800)}.dark\:border-indigo-400:where(.dark,.dark *){border-color:var(--color-indigo-400)}.dark\:border-indigo-500\/30:where(.dark,.dark *){border-color:#625fff4d}@supports (color:color-mix(in lab, red, red)){.dark\:border-indigo-500\/30:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-indigo-500) 30%, transparent)}}.dark\:border-indigo-800:where(.dark,.dark *){border-color:var(--color-indigo-800)}.dark\:border-indigo-900\/30:where(.dark,.dark *){border-color:#312c854d}@supports (color:color-mix(in lab, red, red)){.dark\:border-indigo-900\/30:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-indigo-900) 30%, transparent)}}.dark\:border-orange-900\/30:where(.dark,.dark *){border-color:#7e2a0c4d}@supports (color:color-mix(in lab, red, red)){.dark\:border-orange-900\/30:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-orange-900) 30%, transparent)}}.dark\:border-pink-900\/20:where(.dark,.dark *){border-color:#86104333}@supports (color:color-mix(in lab, red, red)){.dark\:border-pink-900\/20:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-pink-900) 20%, transparent)}}.dark\:border-pink-900\/30:where(.dark,.dark *){border-color:#8610434d}@supports (color:color-mix(in lab, red, red)){.dark\:border-pink-900\/30:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-pink-900) 30%, transparent)}}.dark\:border-red-500\/40:where(.dark,.dark *){border-color:#fb2c3666}@supports (color:color-mix(in lab, red, red)){.dark\:border-red-500\/40:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-red-500) 40%, transparent)}}.dark\:border-red-500\/50:where(.dark,.dark *){border-color:#fb2c3680}@supports (color:color-mix(in lab, red, red)){.dark\:border-red-500\/50:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-red-500) 50%, transparent)}}.dark\:border-red-500\/50\!:where(.dark,.dark *){border-color:#fb2c3680!important}@supports (color:color-mix(in lab, red, red)){.dark\:border-red-500\/50\!:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-red-500) 50%, transparent)!important}}.dark\:border-red-700:where(.dark,.dark *){border-color:var(--color-red-700)}.dark\:border-red-800:where(.dark,.dark *){border-color:var(--color-red-800)}.dark\:border-red-800\/40:where(.dark,.dark *){border-color:#9f071266}@supports (color:color-mix(in lab, red, red)){.dark\:border-red-800\/40:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-red-800) 40%, transparent)}}.dark\:border-red-900\/30:where(.dark,.dark *){border-color:#82181a4d}@supports (color:color-mix(in lab, red, red)){.dark\:border-red-900\/30:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-red-900) 30%, transparent)}}.dark\:border-red-900\/40:where(.dark,.dark *){border-color:#82181a66}@supports (color:color-mix(in lab, red, red)){.dark\:border-red-900\/40:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-red-900) 40%, transparent)}}.dark\:border-red-900\/50:where(.dark,.dark *){border-color:#82181a80}@supports (color:color-mix(in lab, red, red)){.dark\:border-red-900\/50:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-red-900) 50%, transparent)}}.dark\:border-red-900\/70:where(.dark,.dark *){border-color:#82181ab3}@supports (color:color-mix(in lab, red, red)){.dark\:border-red-900\/70:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-red-900) 70%, transparent)}}.dark\:border-sky-800:where(.dark,.dark *){border-color:var(--color-sky-800)}.dark\:border-sky-800\/50:where(.dark,.dark *){border-color:#00598680}@supports (color:color-mix(in lab, red, red)){.dark\:border-sky-800\/50:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-sky-800) 50%, transparent)}}.dark\:border-teal-700\!:where(.dark,.dark *){border-color:var(--color-teal-700)!important}.dark\:border-teal-900\/30:where(.dark,.dark *){border-color:#0b4f4a4d}@supports (color:color-mix(in lab, red, red)){.dark\:border-teal-900\/30:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-teal-900) 30%, transparent)}}.dark\:border-violet-800:where(.dark,.dark *){border-color:var(--color-violet-800)}.dark\:border-violet-800\/50:where(.dark,.dark *){border-color:#5d0ec080}@supports (color:color-mix(in lab, red, red)){.dark\:border-violet-800\/50:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-violet-800) 50%, transparent)}}.dark\:border-violet-900\/30:where(.dark,.dark *){border-color:#4d179a4d}@supports (color:color-mix(in lab, red, red)){.dark\:border-violet-900\/30:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-violet-900) 30%, transparent)}}.dark\:border-violet-900\/60:where(.dark,.dark *){border-color:#4d179a99}@supports (color:color-mix(in lab, red, red)){.dark\:border-violet-900\/60:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-violet-900) 60%, transparent)}}.dark\:border-white\/5:where(.dark,.dark *){border-color:#ffffff0d}@supports (color:color-mix(in lab, red, red)){.dark\:border-white\/5:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-white) 5%, transparent)}}.dark\:border-white\/10:where(.dark,.dark *){border-color:#ffffff1a}@supports (color:color-mix(in lab, red, red)){.dark\:border-white\/10:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-white) 10%, transparent)}}.dark\:border-yellow-700:where(.dark,.dark *){border-color:var(--color-yellow-700)}.dark\:border-yellow-800:where(.dark,.dark *){border-color:var(--color-yellow-800)}.dark\:border-zinc-600:where(.dark,.dark *){border-color:var(--color-zinc-600)}.dark\:border-zinc-600\/90:where(.dark,.dark *){border-color:#52525ce6}@supports (color:color-mix(in lab, red, red)){.dark\:border-zinc-600\/90:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-zinc-600) 90%, transparent)}}.dark\:border-zinc-700:where(.dark,.dark *){border-color:var(--color-zinc-700)}.dark\:border-zinc-700\/50:where(.dark,.dark *){border-color:#3f3f4680}@supports (color:color-mix(in lab, red, red)){.dark\:border-zinc-700\/50:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-zinc-700) 50%, transparent)}}.dark\:border-zinc-700\/70:where(.dark,.dark *){border-color:#3f3f46b3}@supports (color:color-mix(in lab, red, red)){.dark\:border-zinc-700\/70:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-zinc-700) 70%, transparent)}}.dark\:border-zinc-700\/80:where(.dark,.dark *){border-color:#3f3f46cc}@supports (color:color-mix(in lab, red, red)){.dark\:border-zinc-700\/80:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-zinc-700) 80%, transparent)}}.dark\:border-zinc-700\/90:where(.dark,.dark *){border-color:#3f3f46e6}@supports (color:color-mix(in lab, red, red)){.dark\:border-zinc-700\/90:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-zinc-700) 90%, transparent)}}.dark\:border-zinc-800:where(.dark,.dark *){border-color:var(--color-zinc-800)}.dark\:border-zinc-800\!:where(.dark,.dark *){border-color:var(--color-zinc-800)!important}.dark\:border-zinc-800\/50:where(.dark,.dark *){border-color:#27272a80}@supports (color:color-mix(in lab, red, red)){.dark\:border-zinc-800\/50:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-zinc-800) 50%, transparent)}}.dark\:border-zinc-800\/60:where(.dark,.dark *){border-color:#27272a99}@supports (color:color-mix(in lab, red, red)){.dark\:border-zinc-800\/60:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-zinc-800) 60%, transparent)}}.dark\:border-zinc-800\/80:where(.dark,.dark *){border-color:#27272acc}@supports (color:color-mix(in lab, red, red)){.dark\:border-zinc-800\/80:where(.dark,.dark *){border-color:color-mix(in oklab, var(--color-zinc-800) 80%, transparent)}}.dark\:border-zinc-900:where(.dark,.dark *){border-color:var(--color-zinc-900)}.dark\:border-l-zinc-700:where(.dark,.dark *){border-left-color:var(--color-zinc-700)}.dark\:bg-amber-700:where(.dark,.dark *){background-color:var(--color-amber-700)}.dark\:bg-amber-900\/10:where(.dark,.dark *){background-color:#7b33061a}@supports (color:color-mix(in lab, red, red)){.dark\:bg-amber-900\/10:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-amber-900) 10%, transparent)}}.dark\:bg-amber-900\/15:where(.dark,.dark *){background-color:#7b330626}@supports (color:color-mix(in lab, red, red)){.dark\:bg-amber-900\/15:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-amber-900) 15%, transparent)}}.dark\:bg-amber-900\/20:where(.dark,.dark *){background-color:#7b330633}@supports (color:color-mix(in lab, red, red)){.dark\:bg-amber-900\/20:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-amber-900) 20%, transparent)}}.dark\:bg-amber-900\/30:where(.dark,.dark *){background-color:#7b33064d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-amber-900\/30:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-amber-900) 30%, transparent)}}.dark\:bg-amber-900\/40:where(.dark,.dark *){background-color:#7b330666}@supports (color:color-mix(in lab, red, red)){.dark\:bg-amber-900\/40:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-amber-900) 40%, transparent)}}.dark\:bg-amber-900\/45:where(.dark,.dark *){background-color:#7b330673}@supports (color:color-mix(in lab, red, red)){.dark\:bg-amber-900\/45:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-amber-900) 45%, transparent)}}.dark\:bg-amber-950\/20:where(.dark,.dark *){background-color:#46190133}@supports (color:color-mix(in lab, red, red)){.dark\:bg-amber-950\/20:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-amber-950) 20%, transparent)}}.dark\:bg-amber-950\/25:where(.dark,.dark *){background-color:#46190140}@supports (color:color-mix(in lab, red, red)){.dark\:bg-amber-950\/25:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-amber-950) 25%, transparent)}}.dark\:bg-amber-950\/30:where(.dark,.dark *){background-color:#4619014d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-amber-950\/30:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-amber-950) 30%, transparent)}}.dark\:bg-amber-950\/40:where(.dark,.dark *){background-color:#46190166}@supports (color:color-mix(in lab, red, red)){.dark\:bg-amber-950\/40:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-amber-950) 40%, transparent)}}.dark\:bg-amber-950\/50:where(.dark,.dark *){background-color:#46190180}@supports (color:color-mix(in lab, red, red)){.dark\:bg-amber-950\/50:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-amber-950) 50%, transparent)}}.dark\:bg-black:where(.dark,.dark *){background-color:var(--color-black)}.dark\:bg-black\/20:where(.dark,.dark *){background-color:#0003}@supports (color:color-mix(in lab, red, red)){.dark\:bg-black\/20:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-black) 20%, transparent)}}.dark\:bg-black\/30:where(.dark,.dark *){background-color:#0000004d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-black\/30:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-black) 30%, transparent)}}.dark\:bg-black\/50:where(.dark,.dark *){background-color:#00000080}@supports (color:color-mix(in lab, red, red)){.dark\:bg-black\/50:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-black) 50%, transparent)}}.dark\:bg-black\/55:where(.dark,.dark *){background-color:#0000008c}@supports (color:color-mix(in lab, red, red)){.dark\:bg-black\/55:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-black) 55%, transparent)}}.dark\:bg-black\/60:where(.dark,.dark *){background-color:#0009}@supports (color:color-mix(in lab, red, red)){.dark\:bg-black\/60:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-black) 60%, transparent)}}.dark\:bg-black\/70:where(.dark,.dark *){background-color:#000000b3}@supports (color:color-mix(in lab, red, red)){.dark\:bg-black\/70:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-black) 70%, transparent)}}.dark\:bg-black\/90:where(.dark,.dark *){background-color:#000000e6}@supports (color:color-mix(in lab, red, red)){.dark\:bg-black\/90:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-black) 90%, transparent)}}.dark\:bg-blue-400:where(.dark,.dark *){background-color:var(--color-blue-400)}.dark\:bg-blue-500:where(.dark,.dark *){background-color:var(--color-blue-500)}.dark\:bg-blue-500\/10:where(.dark,.dark *){background-color:#3080ff1a}@supports (color:color-mix(in lab, red, red)){.dark\:bg-blue-500\/10:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-blue-500) 10%, transparent)}}.dark\:bg-blue-500\/20:where(.dark,.dark *){background-color:#3080ff33}@supports (color:color-mix(in lab, red, red)){.dark\:bg-blue-500\/20:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-blue-500) 20%, transparent)}}.dark\:bg-blue-600:where(.dark,.dark *){background-color:var(--color-blue-600)}.dark\:bg-blue-700:where(.dark,.dark *){background-color:var(--color-blue-700)}.dark\:bg-blue-900\/5:where(.dark,.dark *){background-color:#1c398e0d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-blue-900\/5:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-blue-900) 5%, transparent)}}.dark\:bg-blue-900\/10:where(.dark,.dark *){background-color:#1c398e1a}@supports (color:color-mix(in lab, red, red)){.dark\:bg-blue-900\/10:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-blue-900) 10%, transparent)}}.dark\:bg-blue-900\/20:where(.dark,.dark *){background-color:#1c398e33}@supports (color:color-mix(in lab, red, red)){.dark\:bg-blue-900\/20:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-blue-900) 20%, transparent)}}.dark\:bg-blue-900\/30:where(.dark,.dark *){background-color:#1c398e4d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-blue-900\/30:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-blue-900) 30%, transparent)}}.dark\:bg-blue-900\/40:where(.dark,.dark *){background-color:#1c398e66}@supports (color:color-mix(in lab, red, red)){.dark\:bg-blue-900\/40:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-blue-900) 40%, transparent)}}.dark\:bg-blue-900\/50:where(.dark,.dark *){background-color:#1c398e80}@supports (color:color-mix(in lab, red, red)){.dark\:bg-blue-900\/50:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-blue-900) 50%, transparent)}}.dark\:bg-blue-950\/20:where(.dark,.dark *){background-color:#16245633}@supports (color:color-mix(in lab, red, red)){.dark\:bg-blue-950\/20:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-blue-950) 20%, transparent)}}.dark\:bg-blue-950\/30:where(.dark,.dark *){background-color:#1624564d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-blue-950\/30:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-blue-950) 30%, transparent)}}.dark\:bg-blue-950\/40:where(.dark,.dark *){background-color:#16245666}@supports (color:color-mix(in lab, red, red)){.dark\:bg-blue-950\/40:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-blue-950) 40%, transparent)}}.dark\:bg-blue-950\/60:where(.dark,.dark *){background-color:#16245699}@supports (color:color-mix(in lab, red, red)){.dark\:bg-blue-950\/60:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-blue-950) 60%, transparent)}}.dark\:bg-cyan-900\/30:where(.dark,.dark *){background-color:#104e644d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-cyan-900\/30:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-cyan-900) 30%, transparent)}}.dark\:bg-cyan-950\/40:where(.dark,.dark *){background-color:#05334566}@supports (color:color-mix(in lab, red, red)){.dark\:bg-cyan-950\/40:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-cyan-950) 40%, transparent)}}.dark\:bg-emerald-500\/10:where(.dark,.dark *){background-color:#00bb7f1a}@supports (color:color-mix(in lab, red, red)){.dark\:bg-emerald-500\/10:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-emerald-500) 10%, transparent)}}.dark\:bg-emerald-900\/5:where(.dark,.dark *){background-color:#004e3b0d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-emerald-900\/5:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-emerald-900) 5%, transparent)}}.dark\:bg-emerald-900\/10:where(.dark,.dark *){background-color:#004e3b1a}@supports (color:color-mix(in lab, red, red)){.dark\:bg-emerald-900\/10:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-emerald-900) 10%, transparent)}}.dark\:bg-emerald-900\/20:where(.dark,.dark *){background-color:#004e3b33}@supports (color:color-mix(in lab, red, red)){.dark\:bg-emerald-900\/20:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-emerald-900) 20%, transparent)}}.dark\:bg-emerald-900\/30:where(.dark,.dark *){background-color:#004e3b4d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-emerald-900\/30:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-emerald-900) 30%, transparent)}}.dark\:bg-emerald-900\/40:where(.dark,.dark *){background-color:#004e3b66}@supports (color:color-mix(in lab, red, red)){.dark\:bg-emerald-900\/40:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-emerald-900) 40%, transparent)}}.dark\:bg-emerald-900\/45:where(.dark,.dark *){background-color:#004e3b73}@supports (color:color-mix(in lab, red, red)){.dark\:bg-emerald-900\/45:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-emerald-900) 45%, transparent)}}.dark\:bg-emerald-900\/50:where(.dark,.dark *){background-color:#004e3b80}@supports (color:color-mix(in lab, red, red)){.dark\:bg-emerald-900\/50:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-emerald-900) 50%, transparent)}}.dark\:bg-emerald-950\/20:where(.dark,.dark *){background-color:#002c2233}@supports (color:color-mix(in lab, red, red)){.dark\:bg-emerald-950\/20:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-emerald-950) 20%, transparent)}}.dark\:bg-gray-500:where(.dark,.dark *){background-color:var(--color-gray-500)}.dark\:bg-gray-800:where(.dark,.dark *){background-color:var(--color-gray-800)}.dark\:bg-gray-900:where(.dark,.dark *){background-color:var(--color-gray-900)}.dark\:bg-gray-900\/40:where(.dark,.dark *){background-color:#10182866}@supports (color:color-mix(in lab, red, red)){.dark\:bg-gray-900\/40:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-gray-900) 40%, transparent)}}.dark\:bg-green-900\/15:where(.dark,.dark *){background-color:#0d542b26}@supports (color:color-mix(in lab, red, red)){.dark\:bg-green-900\/15:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-green-900) 15%, transparent)}}.dark\:bg-green-900\/20:where(.dark,.dark *){background-color:#0d542b33}@supports (color:color-mix(in lab, red, red)){.dark\:bg-green-900\/20:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-green-900) 20%, transparent)}}.dark\:bg-green-900\/30:where(.dark,.dark *){background-color:#0d542b4d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-green-900\/30:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-green-900) 30%, transparent)}}.dark\:bg-green-900\/40:where(.dark,.dark *){background-color:#0d542b66}@supports (color:color-mix(in lab, red, red)){.dark\:bg-green-900\/40:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-green-900) 40%, transparent)}}.dark\:bg-green-900\/45:where(.dark,.dark *){background-color:#0d542b73}@supports (color:color-mix(in lab, red, red)){.dark\:bg-green-900\/45:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-green-900) 45%, transparent)}}.dark\:bg-indigo-500\/10:where(.dark,.dark *){background-color:#625fff1a}@supports (color:color-mix(in lab, red, red)){.dark\:bg-indigo-500\/10:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-indigo-500) 10%, transparent)}}.dark\:bg-indigo-900\/10:where(.dark,.dark *){background-color:#312c851a}@supports (color:color-mix(in lab, red, red)){.dark\:bg-indigo-900\/10:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-indigo-900) 10%, transparent)}}.dark\:bg-indigo-900\/20:where(.dark,.dark *){background-color:#312c8533}@supports (color:color-mix(in lab, red, red)){.dark\:bg-indigo-900\/20:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-indigo-900) 20%, transparent)}}.dark\:bg-indigo-900\/30:where(.dark,.dark *){background-color:#312c854d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-indigo-900\/30:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-indigo-900) 30%, transparent)}}.dark\:bg-indigo-900\/35:where(.dark,.dark *){background-color:#312c8559}@supports (color:color-mix(in lab, red, red)){.dark\:bg-indigo-900\/35:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-indigo-900) 35%, transparent)}}.dark\:bg-indigo-950\/25:where(.dark,.dark *){background-color:#1e1a4d40}@supports (color:color-mix(in lab, red, red)){.dark\:bg-indigo-950\/25:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-indigo-950) 25%, transparent)}}.dark\:bg-indigo-950\/40:where(.dark,.dark *){background-color:#1e1a4d66}@supports (color:color-mix(in lab, red, red)){.dark\:bg-indigo-950\/40:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-indigo-950) 40%, transparent)}}.dark\:bg-orange-900\/10:where(.dark,.dark *){background-color:#7e2a0c1a}@supports (color:color-mix(in lab, red, red)){.dark\:bg-orange-900\/10:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-orange-900) 10%, transparent)}}.dark\:bg-orange-900\/30:where(.dark,.dark *){background-color:#7e2a0c4d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-orange-900\/30:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-orange-900) 30%, transparent)}}.dark\:bg-orange-900\/50:where(.dark,.dark *){background-color:#7e2a0c80}@supports (color:color-mix(in lab, red, red)){.dark\:bg-orange-900\/50:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-orange-900) 50%, transparent)}}.dark\:bg-pink-900\/10:where(.dark,.dark *){background-color:#8610431a}@supports (color:color-mix(in lab, red, red)){.dark\:bg-pink-900\/10:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-pink-900) 10%, transparent)}}.dark\:bg-purple-900\/30:where(.dark,.dark *){background-color:#59168b4d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-purple-900\/30:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-purple-900) 30%, transparent)}}.dark\:bg-red-700:where(.dark,.dark *){background-color:var(--color-red-700)}.dark\:bg-red-900:where(.dark,.dark *){background-color:var(--color-red-900)}.dark\:bg-red-900\/10:where(.dark,.dark *){background-color:#82181a1a}@supports (color:color-mix(in lab, red, red)){.dark\:bg-red-900\/10:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-red-900) 10%, transparent)}}.dark\:bg-red-900\/20:where(.dark,.dark *){background-color:#82181a33}@supports (color:color-mix(in lab, red, red)){.dark\:bg-red-900\/20:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-red-900) 20%, transparent)}}.dark\:bg-red-900\/30:where(.dark,.dark *){background-color:#82181a4d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-red-900\/30:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-red-900) 30%, transparent)}}.dark\:bg-red-900\/40:where(.dark,.dark *){background-color:#82181a66}@supports (color:color-mix(in lab, red, red)){.dark\:bg-red-900\/40:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-red-900) 40%, transparent)}}.dark\:bg-red-900\/45:where(.dark,.dark *){background-color:#82181a73}@supports (color:color-mix(in lab, red, red)){.dark\:bg-red-900\/45:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-red-900) 45%, transparent)}}.dark\:bg-red-950\/20:where(.dark,.dark *){background-color:#46080933}@supports (color:color-mix(in lab, red, red)){.dark\:bg-red-950\/20:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-red-950) 20%, transparent)}}.dark\:bg-red-950\/30:where(.dark,.dark *){background-color:#4608094d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-red-950\/30:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-red-950) 30%, transparent)}}.dark\:bg-red-950\/40:where(.dark,.dark *){background-color:#46080966}@supports (color:color-mix(in lab, red, red)){.dark\:bg-red-950\/40:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-red-950) 40%, transparent)}}.dark\:bg-red-950\/50:where(.dark,.dark *){background-color:#46080980}@supports (color:color-mix(in lab, red, red)){.dark\:bg-red-950\/50:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-red-950) 50%, transparent)}}.dark\:bg-rose-900\/30:where(.dark,.dark *){background-color:#8b08364d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-rose-900\/30:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-rose-900) 30%, transparent)}}.dark\:bg-sky-900\/30:where(.dark,.dark *){background-color:#024a704d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-sky-900\/30:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-sky-900) 30%, transparent)}}.dark\:bg-sky-950\/30:where(.dark,.dark *){background-color:#052f4a4d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-sky-950\/30:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-sky-950) 30%, transparent)}}.dark\:bg-teal-900\/10:where(.dark,.dark *){background-color:#0b4f4a1a}@supports (color:color-mix(in lab, red, red)){.dark\:bg-teal-900\/10:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-teal-900) 10%, transparent)}}.dark\:bg-teal-900\/30:where(.dark,.dark *){background-color:#0b4f4a4d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-teal-900\/30:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-teal-900) 30%, transparent)}}.dark\:bg-transparent:where(.dark,.dark *){background-color:#0000}.dark\:bg-violet-900\/10:where(.dark,.dark *){background-color:#4d179a1a}@supports (color:color-mix(in lab, red, red)){.dark\:bg-violet-900\/10:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-violet-900) 10%, transparent)}}.dark\:bg-violet-900\/30:where(.dark,.dark *){background-color:#4d179a4d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-violet-900\/30:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-violet-900) 30%, transparent)}}.dark\:bg-violet-900\/40:where(.dark,.dark *){background-color:#4d179a66}@supports (color:color-mix(in lab, red, red)){.dark\:bg-violet-900\/40:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-violet-900) 40%, transparent)}}.dark\:bg-violet-950\/30:where(.dark,.dark *){background-color:#2f0d684d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-violet-950\/30:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-violet-950) 30%, transparent)}}.dark\:bg-white:where(.dark,.dark *){background-color:var(--color-white)}.dark\:bg-white\/5:where(.dark,.dark *){background-color:#ffffff0d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-white\/5:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-white) 5%, transparent)}}.dark\:bg-white\/10:where(.dark,.dark *){background-color:#ffffff1a}@supports (color:color-mix(in lab, red, red)){.dark\:bg-white\/10:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-white) 10%, transparent)}}.dark\:bg-white\/\[0\.05\]:where(.dark,.dark *){background-color:#ffffff0d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-white\/\[0\.05\]:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-white) 5%, transparent)}}.dark\:bg-yellow-900\/20:where(.dark,.dark *){background-color:#733e0a33}@supports (color:color-mix(in lab, red, red)){.dark\:bg-yellow-900\/20:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-yellow-900) 20%, transparent)}}.dark\:bg-yellow-900\/30:where(.dark,.dark *){background-color:#733e0a4d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-yellow-900\/30:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-yellow-900) 30%, transparent)}}.dark\:bg-zinc-100:where(.dark,.dark *){background-color:var(--color-zinc-100)}.dark\:bg-zinc-600\/70:where(.dark,.dark *){background-color:#52525cb3}@supports (color:color-mix(in lab, red, red)){.dark\:bg-zinc-600\/70:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-zinc-600) 70%, transparent)}}.dark\:bg-zinc-700:where(.dark,.dark *){background-color:var(--color-zinc-700)}.dark\:bg-zinc-700\/50:where(.dark,.dark *){background-color:#3f3f4680}@supports (color:color-mix(in lab, red, red)){.dark\:bg-zinc-700\/50:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-zinc-700) 50%, transparent)}}.dark\:bg-zinc-700\/90:where(.dark,.dark *){background-color:#3f3f46e6}@supports (color:color-mix(in lab, red, red)){.dark\:bg-zinc-700\/90:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-zinc-700) 90%, transparent)}}.dark\:bg-zinc-800:where(.dark,.dark *){background-color:var(--color-zinc-800)}.dark\:bg-zinc-800\/10:where(.dark,.dark *){background-color:#27272a1a}@supports (color:color-mix(in lab, red, red)){.dark\:bg-zinc-800\/10:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-zinc-800) 10%, transparent)}}.dark\:bg-zinc-800\/30:where(.dark,.dark *){background-color:#27272a4d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-zinc-800\/30:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-zinc-800) 30%, transparent)}}.dark\:bg-zinc-800\/40:where(.dark,.dark *){background-color:#27272a66}@supports (color:color-mix(in lab, red, red)){.dark\:bg-zinc-800\/40:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-zinc-800) 40%, transparent)}}.dark\:bg-zinc-800\/50:where(.dark,.dark *){background-color:#27272a80}@supports (color:color-mix(in lab, red, red)){.dark\:bg-zinc-800\/50:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-zinc-800) 50%, transparent)}}.dark\:bg-zinc-800\/60:where(.dark,.dark *){background-color:#27272a99}@supports (color:color-mix(in lab, red, red)){.dark\:bg-zinc-800\/60:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-zinc-800) 60%, transparent)}}.dark\:bg-zinc-800\/70:where(.dark,.dark *){background-color:#27272ab3}@supports (color:color-mix(in lab, red, red)){.dark\:bg-zinc-800\/70:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-zinc-800) 70%, transparent)}}.dark\:bg-zinc-800\/80:where(.dark,.dark *){background-color:#27272acc}@supports (color:color-mix(in lab, red, red)){.dark\:bg-zinc-800\/80:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-zinc-800) 80%, transparent)}}.dark\:bg-zinc-900:where(.dark,.dark *){background-color:var(--color-zinc-900)}.dark\:bg-zinc-900\/10:where(.dark,.dark *){background-color:#18181b1a}@supports (color:color-mix(in lab, red, red)){.dark\:bg-zinc-900\/10:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-zinc-900) 10%, transparent)}}.dark\:bg-zinc-900\/20:where(.dark,.dark *){background-color:#18181b33}@supports (color:color-mix(in lab, red, red)){.dark\:bg-zinc-900\/20:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-zinc-900) 20%, transparent)}}.dark\:bg-zinc-900\/25:where(.dark,.dark *){background-color:#18181b40}@supports (color:color-mix(in lab, red, red)){.dark\:bg-zinc-900\/25:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-zinc-900) 25%, transparent)}}.dark\:bg-zinc-900\/30:where(.dark,.dark *){background-color:#18181b4d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-zinc-900\/30:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-zinc-900) 30%, transparent)}}.dark\:bg-zinc-900\/40:where(.dark,.dark *){background-color:#18181b66}@supports (color:color-mix(in lab, red, red)){.dark\:bg-zinc-900\/40:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-zinc-900) 40%, transparent)}}.dark\:bg-zinc-900\/50:where(.dark,.dark *){background-color:#18181b80}@supports (color:color-mix(in lab, red, red)){.dark\:bg-zinc-900\/50:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-zinc-900) 50%, transparent)}}.dark\:bg-zinc-900\/60:where(.dark,.dark *){background-color:#18181b99}@supports (color:color-mix(in lab, red, red)){.dark\:bg-zinc-900\/60:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-zinc-900) 60%, transparent)}}.dark\:bg-zinc-900\/70:where(.dark,.dark *){background-color:#18181bb3}@supports (color:color-mix(in lab, red, red)){.dark\:bg-zinc-900\/70:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-zinc-900) 70%, transparent)}}.dark\:bg-zinc-900\/80:where(.dark,.dark *){background-color:#18181bcc}@supports (color:color-mix(in lab, red, red)){.dark\:bg-zinc-900\/80:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-zinc-900) 80%, transparent)}}.dark\:bg-zinc-900\/85:where(.dark,.dark *){background-color:#18181bd9}@supports (color:color-mix(in lab, red, red)){.dark\:bg-zinc-900\/85:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-zinc-900) 85%, transparent)}}.dark\:bg-zinc-900\/90:where(.dark,.dark *){background-color:#18181be6}@supports (color:color-mix(in lab, red, red)){.dark\:bg-zinc-900\/90:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-zinc-900) 90%, transparent)}}.dark\:bg-zinc-900\/95:where(.dark,.dark *){background-color:#18181bf2}@supports (color:color-mix(in lab, red, red)){.dark\:bg-zinc-900\/95:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-zinc-900) 95%, transparent)}}.dark\:bg-zinc-950:where(.dark,.dark *){background-color:var(--color-zinc-950)}.dark\:bg-zinc-950\!:where(.dark,.dark *){background-color:var(--color-zinc-950)!important}.dark\:bg-zinc-950\/40:where(.dark,.dark *){background-color:#09090b66}@supports (color:color-mix(in lab, red, red)){.dark\:bg-zinc-950\/40:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-zinc-950) 40%, transparent)}}.dark\:bg-zinc-950\/50:where(.dark,.dark *){background-color:#09090b80}@supports (color:color-mix(in lab, red, red)){.dark\:bg-zinc-950\/50:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-zinc-950) 50%, transparent)}}.dark\:bg-zinc-950\/80:where(.dark,.dark *){background-color:#09090bcc}@supports (color:color-mix(in lab, red, red)){.dark\:bg-zinc-950\/80:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-zinc-950) 80%, transparent)}}.dark\:from-zinc-950:where(.dark,.dark *){--tw-gradient-from:var(--color-zinc-950);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.dark\:via-zinc-800:where(.dark,.dark *){--tw-gradient-via:var(--color-zinc-800);--tw-gradient-via-stops:var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-via) var(--tw-gradient-via-position), var(--tw-gradient-to) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-via-stops)}.dark\:to-zinc-900:where(.dark,.dark *){--tw-gradient-to:var(--color-zinc-900);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.dark\:fill-zinc-900:where(.dark,.dark *){fill:var(--color-zinc-900)}.dark\:stroke-zinc-900:where(.dark,.dark *){stroke:var(--color-zinc-900)}.dark\:text-amber-100:where(.dark,.dark *){color:var(--color-amber-100)}.dark\:text-amber-100\/80:where(.dark,.dark *){color:#fef3c6cc}@supports (color:color-mix(in lab, red, red)){.dark\:text-amber-100\/80:where(.dark,.dark *){color:color-mix(in oklab, var(--color-amber-100) 80%, transparent)}}.dark\:text-amber-100\/90:where(.dark,.dark *){color:#fef3c6e6}@supports (color:color-mix(in lab, red, red)){.dark\:text-amber-100\/90:where(.dark,.dark *){color:color-mix(in oklab, var(--color-amber-100) 90%, transparent)}}.dark\:text-amber-200:where(.dark,.dark *){color:var(--color-amber-200)}.dark\:text-amber-200\/70:where(.dark,.dark *){color:#fee685b3}@supports (color:color-mix(in lab, red, red)){.dark\:text-amber-200\/70:where(.dark,.dark *){color:color-mix(in oklab, var(--color-amber-200) 70%, transparent)}}.dark\:text-amber-200\/80:where(.dark,.dark *){color:#fee685cc}@supports (color:color-mix(in lab, red, red)){.dark\:text-amber-200\/80:where(.dark,.dark *){color:color-mix(in oklab, var(--color-amber-200) 80%, transparent)}}.dark\:text-amber-200\/90:where(.dark,.dark *){color:#fee685e6}@supports (color:color-mix(in lab, red, red)){.dark\:text-amber-200\/90:where(.dark,.dark *){color:color-mix(in oklab, var(--color-amber-200) 90%, transparent)}}.dark\:text-amber-300:where(.dark,.dark *){color:var(--color-amber-300)}.dark\:text-amber-300\/90:where(.dark,.dark *){color:#ffd236e6}@supports (color:color-mix(in lab, red, red)){.dark\:text-amber-300\/90:where(.dark,.dark *){color:color-mix(in oklab, var(--color-amber-300) 90%, transparent)}}.dark\:text-amber-400:where(.dark,.dark *){color:var(--color-amber-400)}.dark\:text-amber-500:where(.dark,.dark *){color:var(--color-amber-500)}.dark\:text-blue-100:where(.dark,.dark *){color:var(--color-blue-100)}.dark\:text-blue-100\/90:where(.dark,.dark *){color:#dbeafee6}@supports (color:color-mix(in lab, red, red)){.dark\:text-blue-100\/90:where(.dark,.dark *){color:color-mix(in oklab, var(--color-blue-100) 90%, transparent)}}.dark\:text-blue-200:where(.dark,.dark *){color:var(--color-blue-200)}.dark\:text-blue-300:where(.dark,.dark *){color:var(--color-blue-300)}.dark\:text-blue-300\/80:where(.dark,.dark *){color:#90c5ffcc}@supports (color:color-mix(in lab, red, red)){.dark\:text-blue-300\/80:where(.dark,.dark *){color:color-mix(in oklab, var(--color-blue-300) 80%, transparent)}}.dark\:text-blue-400:where(.dark,.dark *){color:var(--color-blue-400)}.dark\:text-cyan-200:where(.dark,.dark *){color:var(--color-cyan-200)}.dark\:text-cyan-300:where(.dark,.dark *){color:var(--color-cyan-300)}.dark\:text-cyan-400:where(.dark,.dark *){color:var(--color-cyan-400)}.dark\:text-emerald-100:where(.dark,.dark *){color:var(--color-emerald-100)}.dark\:text-emerald-200:where(.dark,.dark *){color:var(--color-emerald-200)}.dark\:text-emerald-300:where(.dark,.dark *){color:var(--color-emerald-300)}.dark\:text-emerald-300\/80:where(.dark,.dark *){color:#5ee9b5cc}@supports (color:color-mix(in lab, red, red)){.dark\:text-emerald-300\/80:where(.dark,.dark *){color:color-mix(in oklab, var(--color-emerald-300) 80%, transparent)}}.dark\:text-emerald-400:where(.dark,.dark *){color:var(--color-emerald-400)}.dark\:text-emerald-500:where(.dark,.dark *){color:var(--color-emerald-500)}.dark\:text-gray-100:where(.dark,.dark *){color:var(--color-gray-100)}.dark\:text-gray-200:where(.dark,.dark *){color:var(--color-gray-200)}.dark\:text-gray-300:where(.dark,.dark *){color:var(--color-gray-300)}.dark\:text-gray-400:where(.dark,.dark *){color:var(--color-gray-400)}.dark\:text-gray-500:where(.dark,.dark *){color:var(--color-gray-500)}.dark\:text-gray-600:where(.dark,.dark *){color:var(--color-gray-600)}.dark\:text-green-100:where(.dark,.dark *){color:var(--color-green-100)}.dark\:text-green-200:where(.dark,.dark *){color:var(--color-green-200)}.dark\:text-green-300:where(.dark,.dark *){color:var(--color-green-300)}.dark\:text-green-400:where(.dark,.dark *){color:var(--color-green-400)}.dark\:text-indigo-100:where(.dark,.dark *){color:var(--color-indigo-100)}.dark\:text-indigo-200:where(.dark,.dark *){color:var(--color-indigo-200)}.dark\:text-indigo-200\/90:where(.dark,.dark *){color:#c7d2ffe6}@supports (color:color-mix(in lab, red, red)){.dark\:text-indigo-200\/90:where(.dark,.dark *){color:color-mix(in oklab, var(--color-indigo-200) 90%, transparent)}}.dark\:text-indigo-300:where(.dark,.dark *){color:var(--color-indigo-300)}.dark\:text-indigo-300\/90:where(.dark,.dark *){color:#a4b3ffe6}@supports (color:color-mix(in lab, red, red)){.dark\:text-indigo-300\/90:where(.dark,.dark *){color:color-mix(in oklab, var(--color-indigo-300) 90%, transparent)}}.dark\:text-indigo-400:where(.dark,.dark *){color:var(--color-indigo-400)}.dark\:text-orange-200:where(.dark,.dark *){color:var(--color-orange-200)}.dark\:text-orange-300:where(.dark,.dark *){color:var(--color-orange-300)}.dark\:text-orange-400:where(.dark,.dark *){color:var(--color-orange-400)}.dark\:text-pink-300:where(.dark,.dark *){color:var(--color-pink-300)}.dark\:text-pink-400:where(.dark,.dark *){color:var(--color-pink-400)}.dark\:text-purple-200:where(.dark,.dark *){color:var(--color-purple-200)}.dark\:text-purple-400:where(.dark,.dark *){color:var(--color-purple-400)}.dark\:text-red-100:where(.dark,.dark *){color:var(--color-red-100)}.dark\:text-red-100\/90:where(.dark,.dark *){color:#ffe2e2e6}@supports (color:color-mix(in lab, red, red)){.dark\:text-red-100\/90:where(.dark,.dark *){color:color-mix(in oklab, var(--color-red-100) 90%, transparent)}}.dark\:text-red-200:where(.dark,.dark *){color:var(--color-red-200)}.dark\:text-red-300:where(.dark,.dark *){color:var(--color-red-300)}.dark\:text-red-300\!:where(.dark,.dark *){color:var(--color-red-300)!important}.dark\:text-red-400:where(.dark,.dark *){color:var(--color-red-400)}.dark\:text-rose-200:where(.dark,.dark *){color:var(--color-rose-200)}.dark\:text-rose-400:where(.dark,.dark *){color:var(--color-rose-400)}.dark\:text-sky-100:where(.dark,.dark *){color:var(--color-sky-100)}.dark\:text-sky-200:where(.dark,.dark *){color:var(--color-sky-200)}.dark\:text-sky-200\/90:where(.dark,.dark *){color:#b8e6fee6}@supports (color:color-mix(in lab, red, red)){.dark\:text-sky-200\/90:where(.dark,.dark *){color:color-mix(in oklab, var(--color-sky-200) 90%, transparent)}}.dark\:text-sky-300:where(.dark,.dark *){color:var(--color-sky-300)}.dark\:text-sky-400:where(.dark,.dark *){color:var(--color-sky-400)}.dark\:text-sky-400\/80:where(.dark,.dark *){color:#00bcfecc}@supports (color:color-mix(in lab, red, red)){.dark\:text-sky-400\/80:where(.dark,.dark *){color:color-mix(in oklab, var(--color-sky-400) 80%, transparent)}}.dark\:text-teal-200:where(.dark,.dark *){color:var(--color-teal-200)}.dark\:text-teal-300:where(.dark,.dark *){color:var(--color-teal-300)}.dark\:text-teal-300\!:where(.dark,.dark *){color:var(--color-teal-300)!important}.dark\:text-teal-400:where(.dark,.dark *){color:var(--color-teal-400)}.dark\:text-violet-100:where(.dark,.dark *){color:var(--color-violet-100)}.dark\:text-violet-200:where(.dark,.dark *){color:var(--color-violet-200)}.dark\:text-violet-300:where(.dark,.dark *){color:var(--color-violet-300)}.dark\:text-violet-400:where(.dark,.dark *){color:var(--color-violet-400)}.dark\:text-white:where(.dark,.dark *){color:var(--color-white)}.dark\:text-yellow-100:where(.dark,.dark *){color:var(--color-yellow-100)}.dark\:text-yellow-200:where(.dark,.dark *){color:var(--color-yellow-200)}.dark\:text-yellow-300:where(.dark,.dark *){color:var(--color-yellow-300)}.dark\:text-yellow-400:where(.dark,.dark *){color:var(--color-yellow-400)}.dark\:text-zinc-100:where(.dark,.dark *){color:var(--color-zinc-100)}.dark\:text-zinc-200:where(.dark,.dark *){color:var(--color-zinc-200)}.dark\:text-zinc-300:where(.dark,.dark *){color:var(--color-zinc-300)}.dark\:text-zinc-400:where(.dark,.dark *){color:var(--color-zinc-400)}.dark\:text-zinc-500:where(.dark,.dark *){color:var(--color-zinc-500)}.dark\:text-zinc-600:where(.dark,.dark *){color:var(--color-zinc-600)}.dark\:text-zinc-700:where(.dark,.dark *){color:var(--color-zinc-700)}.dark\:text-zinc-700\/90:where(.dark,.dark *){color:#3f3f46e6}@supports (color:color-mix(in lab, red, red)){.dark\:text-zinc-700\/90:where(.dark,.dark *){color:color-mix(in oklab, var(--color-zinc-700) 90%, transparent)}}.dark\:text-zinc-800:where(.dark,.dark *){color:var(--color-zinc-800)}.dark\:text-zinc-900:where(.dark,.dark *){color:var(--color-zinc-900)}.dark\:placeholder-zinc-500:where(.dark,.dark *)::placeholder{color:var(--color-zinc-500)}.dark\:accent-blue-500:where(.dark,.dark *){accent-color:var(--color-blue-500)}.dark\:shadow-\[inset_2px_2px_6px_rgba\(0\,0\,0\,0\.72\)\,inset_-1px_-1px_1px_rgba\(255\,255\,255\,0\.07\)\]:where(.dark,.dark *){--tw-shadow:inset 2px 2px 6px var(--tw-shadow-color,#000000b8), inset -1px -1px 1px var(--tw-shadow-color,#ffffff12);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.dark\:shadow-black\/20:where(.dark,.dark *){--tw-shadow-color:#0003}@supports (color:color-mix(in lab, red, red)){.dark\:shadow-black\/20:where(.dark,.dark *){--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-black) 20%, transparent) var(--tw-shadow-alpha), transparent)}}.dark\:shadow-black\/30:where(.dark,.dark *){--tw-shadow-color:#0000004d}@supports (color:color-mix(in lab, red, red)){.dark\:shadow-black\/30:where(.dark,.dark *){--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-black) 30%, transparent) var(--tw-shadow-alpha), transparent)}}.dark\:shadow-black\/40:where(.dark,.dark *){--tw-shadow-color:#0006}@supports (color:color-mix(in lab, red, red)){.dark\:shadow-black\/40:where(.dark,.dark *){--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-black) 40%, transparent) var(--tw-shadow-alpha), transparent)}}.dark\:ring-blue-500\/50:where(.dark,.dark *){--tw-ring-color:#3080ff80}@supports (color:color-mix(in lab, red, red)){.dark\:ring-blue-500\/50:where(.dark,.dark *){--tw-ring-color:color-mix(in oklab, var(--color-blue-500) 50%, transparent)}}.dark\:ring-blue-600:where(.dark,.dark *){--tw-ring-color:var(--color-blue-600)}.dark\:ring-blue-700:where(.dark,.dark *){--tw-ring-color:var(--color-blue-700)}.dark\:ring-cyan-800\/60:where(.dark,.dark *){--tw-ring-color:#005f7899}@supports (color:color-mix(in lab, red, red)){.dark\:ring-cyan-800\/60:where(.dark,.dark *){--tw-ring-color:color-mix(in oklab, var(--color-cyan-800) 60%, transparent)}}.dark\:ring-red-900\/60:where(.dark,.dark *){--tw-ring-color:#82181a99}@supports (color:color-mix(in lab, red, red)){.dark\:ring-red-900\/60:where(.dark,.dark *){--tw-ring-color:color-mix(in oklab, var(--color-red-900) 60%, transparent)}}.dark\:ring-white\/10:where(.dark,.dark *){--tw-ring-color:#ffffff1a}@supports (color:color-mix(in lab, red, red)){.dark\:ring-white\/10:where(.dark,.dark *){--tw-ring-color:color-mix(in oklab, var(--color-white) 10%, transparent)}}.dark\:ring-zinc-700:where(.dark,.dark *){--tw-ring-color:var(--color-zinc-700)}.dark\:ring-zinc-800:where(.dark,.dark *){--tw-ring-color:var(--color-zinc-800)}.dark\:ring-zinc-800\/90:where(.dark,.dark *){--tw-ring-color:#27272ae6}@supports (color:color-mix(in lab, red, red)){.dark\:ring-zinc-800\/90:where(.dark,.dark *){--tw-ring-color:color-mix(in oklab, var(--color-zinc-800) 90%, transparent)}}.dark\:ring-offset-zinc-900:where(.dark,.dark *){--tw-ring-offset-color:var(--color-zinc-900)}.dark\:ring-offset-zinc-950:where(.dark,.dark *){--tw-ring-offset-color:var(--color-zinc-950)}@media (hover:hover){.dark\:group-hover\:text-blue-400:where(.dark,.dark *):is(:where(.group):hover *){color:var(--color-blue-400)}.dark\:group-hover\:text-zinc-200:where(.dark,.dark *):is(:where(.group):hover *){color:var(--color-zinc-200)}.dark\:group-hover\:text-zinc-300:where(.dark,.dark *):is(:where(.group):hover *){color:var(--color-zinc-300)}}.dark\:peer-checked\:bg-blue-600:where(.dark,.dark *):is(:where(.peer):checked~*){background-color:var(--color-blue-600)}.dark\:peer-focus\:ring-blue-800:where(.dark,.dark *):is(:where(.peer):focus~*){--tw-ring-color:var(--color-blue-800)}.dark\:file\:bg-blue-600:where(.dark,.dark *)::file-selector-button{background-color:var(--color-blue-600)}.dark\:file\:bg-zinc-700:where(.dark,.dark *)::file-selector-button{background-color:var(--color-zinc-700)}.dark\:file\:text-zinc-200:where(.dark,.dark *)::file-selector-button{color:var(--color-zinc-200)}.dark\:placeholder\:text-zinc-500:where(.dark,.dark *)::placeholder{color:var(--color-zinc-500)}.dark\:open\:bg-zinc-950:where(.dark,.dark *):is([open],:popover-open,:open){background-color:var(--color-zinc-950)}@media (hover:hover){.dark\:hover\:border-amber-500\/60:where(.dark,.dark *):hover{border-color:#f99c0099}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:border-amber-500\/60:where(.dark,.dark *):hover{border-color:color-mix(in oklab, var(--color-amber-500) 60%, transparent)}}.dark\:hover\:border-amber-600:where(.dark,.dark *):hover{border-color:var(--color-amber-600)}.dark\:hover\:border-blue-500:where(.dark,.dark *):hover{border-color:var(--color-blue-500)}.dark\:hover\:border-blue-600:where(.dark,.dark *):hover{border-color:var(--color-blue-600)}.dark\:hover\:border-blue-800:where(.dark,.dark *):hover{border-color:var(--color-blue-800)}.dark\:hover\:border-blue-900\/30:where(.dark,.dark *):hover{border-color:#1c398e4d}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:border-blue-900\/30:where(.dark,.dark *):hover{border-color:color-mix(in oklab, var(--color-blue-900) 30%, transparent)}}.dark\:hover\:border-zinc-500:where(.dark,.dark *):hover{border-color:var(--color-zinc-500)}.dark\:hover\:border-zinc-600:where(.dark,.dark *):hover{border-color:var(--color-zinc-600)}.dark\:hover\:bg-amber-600:where(.dark,.dark *):hover{background-color:var(--color-amber-600)}.dark\:hover\:bg-amber-900\/20:where(.dark,.dark *):hover{background-color:#7b330633}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-amber-900\/20:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-amber-900) 20%, transparent)}}.dark\:hover\:bg-amber-900\/40:where(.dark,.dark *):hover{background-color:#7b330666}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-amber-900\/40:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-amber-900) 40%, transparent)}}.dark\:hover\:bg-amber-900\/50:where(.dark,.dark *):hover{background-color:#7b330680}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-amber-900\/50:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-amber-900) 50%, transparent)}}.dark\:hover\:bg-blue-500:where(.dark,.dark *):hover{background-color:var(--color-blue-500)}.dark\:hover\:bg-blue-500\/20:where(.dark,.dark *):hover{background-color:#3080ff33}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-blue-500\/20:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-blue-500) 20%, transparent)}}.dark\:hover\:bg-blue-600:where(.dark,.dark *):hover{background-color:var(--color-blue-600)}.dark\:hover\:bg-blue-700:where(.dark,.dark *):hover{background-color:var(--color-blue-700)}.dark\:hover\:bg-blue-800:where(.dark,.dark *):hover{background-color:var(--color-blue-800)}.dark\:hover\:bg-blue-900\/10:where(.dark,.dark *):hover{background-color:#1c398e1a}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-blue-900\/10:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-blue-900) 10%, transparent)}}.dark\:hover\:bg-blue-900\/20:where(.dark,.dark *):hover{background-color:#1c398e33}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-blue-900\/20:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-blue-900) 20%, transparent)}}.dark\:hover\:bg-blue-900\/40:where(.dark,.dark *):hover{background-color:#1c398e66}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-blue-900\/40:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-blue-900) 40%, transparent)}}.dark\:hover\:bg-blue-900\/50:where(.dark,.dark *):hover{background-color:#1c398e80}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-blue-900\/50:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-blue-900) 50%, transparent)}}.dark\:hover\:bg-blue-950\/30:where(.dark,.dark *):hover{background-color:#1624564d}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-blue-950\/30:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-blue-950) 30%, transparent)}}.dark\:hover\:bg-emerald-900\/20:where(.dark,.dark *):hover{background-color:#004e3b33}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-emerald-900\/20:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-emerald-900) 20%, transparent)}}.dark\:hover\:bg-gray-800:where(.dark,.dark *):hover{background-color:var(--color-gray-800)}.dark\:hover\:bg-green-900\/40:where(.dark,.dark *):hover{background-color:#0d542b66}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-green-900\/40:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-green-900) 40%, transparent)}}.dark\:hover\:bg-indigo-900\/20:where(.dark,.dark *):hover{background-color:#312c8533}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-indigo-900\/20:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-indigo-900) 20%, transparent)}}.dark\:hover\:bg-indigo-900\/50:where(.dark,.dark *):hover{background-color:#312c8580}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-indigo-900\/50:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-indigo-900) 50%, transparent)}}.dark\:hover\:bg-orange-900\/20:where(.dark,.dark *):hover{background-color:#7e2a0c33}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-orange-900\/20:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-orange-900) 20%, transparent)}}.dark\:hover\:bg-pink-900\/20:where(.dark,.dark *):hover{background-color:#86104333}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-pink-900\/20:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-pink-900) 20%, transparent)}}.dark\:hover\:bg-red-800:where(.dark,.dark *):hover{background-color:var(--color-red-800)}.dark\:hover\:bg-red-900\/20:where(.dark,.dark *):hover{background-color:#82181a33}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-red-900\/20:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-red-900) 20%, transparent)}}.dark\:hover\:bg-red-900\/20\!:where(.dark,.dark *):hover{background-color:#82181a33!important}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-red-900\/20\!:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-red-900) 20%, transparent)!important}}.dark\:hover\:bg-red-900\/30:where(.dark,.dark *):hover{background-color:#82181a4d}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-red-900\/30:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-red-900) 30%, transparent)}}.dark\:hover\:bg-red-900\/40:where(.dark,.dark *):hover{background-color:#82181a66}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-red-900\/40:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-red-900) 40%, transparent)}}.dark\:hover\:bg-red-900\/50:where(.dark,.dark *):hover{background-color:#82181a80}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-red-900\/50:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-red-900) 50%, transparent)}}.dark\:hover\:bg-red-950\/30:where(.dark,.dark *):hover{background-color:#4608094d}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-red-950\/30:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-red-950) 30%, transparent)}}.dark\:hover\:bg-red-950\/40:where(.dark,.dark *):hover{background-color:#46080966}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-red-950\/40:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-red-950) 40%, transparent)}}.dark\:hover\:bg-teal-900\/20:where(.dark,.dark *):hover{background-color:#0b4f4a33}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-teal-900\/20:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-teal-900) 20%, transparent)}}.dark\:hover\:bg-violet-900\/20:where(.dark,.dark *):hover{background-color:#4d179a33}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-violet-900\/20:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-violet-900) 20%, transparent)}}.dark\:hover\:bg-white\/5:where(.dark,.dark *):hover{background-color:#ffffff0d}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-white\/5:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-white) 5%, transparent)}}.dark\:hover\:bg-white\/10:where(.dark,.dark *):hover{background-color:#ffffff1a}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-white\/10:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-white) 10%, transparent)}}.dark\:hover\:bg-zinc-600:where(.dark,.dark *):hover{background-color:var(--color-zinc-600)}.dark\:hover\:bg-zinc-700:where(.dark,.dark *):hover{background-color:var(--color-zinc-700)}.dark\:hover\:bg-zinc-800:where(.dark,.dark *):hover{background-color:var(--color-zinc-800)}.dark\:hover\:bg-zinc-800\/20:where(.dark,.dark *):hover{background-color:#27272a33}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-zinc-800\/20:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-zinc-800) 20%, transparent)}}.dark\:hover\:bg-zinc-800\/50:where(.dark,.dark *):hover{background-color:#27272a80}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-zinc-800\/50:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-zinc-800) 50%, transparent)}}.dark\:hover\:bg-zinc-800\/80:where(.dark,.dark *):hover{background-color:#27272acc}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-zinc-800\/80:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-zinc-800) 80%, transparent)}}.dark\:hover\:bg-zinc-900:where(.dark,.dark *):hover{background-color:var(--color-zinc-900)}.dark\:hover\:bg-zinc-900\/20:where(.dark,.dark *):hover{background-color:#18181b33}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-zinc-900\/20:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-zinc-900) 20%, transparent)}}.dark\:hover\:bg-zinc-900\/40:where(.dark,.dark *):hover{background-color:#18181b66}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-zinc-900\/40:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-zinc-900) 40%, transparent)}}.dark\:hover\:bg-zinc-900\/60:where(.dark,.dark *):hover{background-color:#18181b99}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-zinc-900\/60:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-zinc-900) 60%, transparent)}}.dark\:hover\:bg-zinc-900\/70:where(.dark,.dark *):hover{background-color:#18181bb3}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-zinc-900\/70:where(.dark,.dark *):hover{background-color:color-mix(in oklab, var(--color-zinc-900) 70%, transparent)}}.dark\:hover\:text-amber-200:where(.dark,.dark *):hover{color:var(--color-amber-200)}.dark\:hover\:text-amber-400:where(.dark,.dark *):hover{color:var(--color-amber-400)}.dark\:hover\:text-blue-300:where(.dark,.dark *):hover{color:var(--color-blue-300)}.dark\:hover\:text-blue-400:where(.dark,.dark *):hover{color:var(--color-blue-400)}.dark\:hover\:text-cyan-400:where(.dark,.dark *):hover{color:var(--color-cyan-400)}.dark\:hover\:text-emerald-400:where(.dark,.dark *):hover{color:var(--color-emerald-400)}.dark\:hover\:text-gray-200:where(.dark,.dark *):hover{color:var(--color-gray-200)}.dark\:hover\:text-gray-300:where(.dark,.dark *):hover{color:var(--color-gray-300)}.dark\:hover\:text-green-400:where(.dark,.dark *):hover{color:var(--color-green-400)}.dark\:hover\:text-indigo-400:where(.dark,.dark *):hover{color:var(--color-indigo-400)}.dark\:hover\:text-red-400:where(.dark,.dark *):hover{color:var(--color-red-400)}.dark\:hover\:text-violet-400:where(.dark,.dark *):hover{color:var(--color-violet-400)}.dark\:hover\:text-white:where(.dark,.dark *):hover{color:var(--color-white)}.dark\:hover\:text-zinc-100:where(.dark,.dark *):hover{color:var(--color-zinc-100)}.dark\:hover\:text-zinc-300:where(.dark,.dark *):hover{color:var(--color-zinc-300)}.dark\:hover\:file\:bg-blue-700:where(.dark,.dark *):hover::file-selector-button{background-color:var(--color-blue-700)}.dark\:hover\:file\:bg-zinc-600:where(.dark,.dark *):hover::file-selector-button{background-color:var(--color-zinc-600)}}.dark\:focus\:border-blue-400:where(.dark,.dark *):focus{border-color:var(--color-blue-400)}.dark\:focus\:border-blue-500:where(.dark,.dark *):focus{border-color:var(--color-blue-500)}.dark\:focus\:border-indigo-500:where(.dark,.dark *):focus{border-color:var(--color-indigo-500)}.dark\:focus\:ring-blue-50:where(.dark,.dark *):focus{--tw-ring-color:var(--color-blue-50)}.dark\:focus\:ring-blue-400:where(.dark,.dark *):focus{--tw-ring-color:var(--color-blue-400)}.dark\:focus\:ring-blue-500:where(.dark,.dark *):focus{--tw-ring-color:var(--color-blue-500)}.dark\:focus\:ring-indigo-500:where(.dark,.dark *):focus{--tw-ring-color:var(--color-indigo-500)}.dark\:focus\:ring-offset-zinc-900:where(.dark,.dark *):focus{--tw-ring-offset-color:var(--color-zinc-900)}.dark\:focus-visible\:ring-blue-400:where(.dark,.dark *):focus-visible{--tw-ring-color:var(--color-blue-400)}.dark\:focus-visible\:outline-zinc-500:where(.dark,.dark *):focus-visible{outline-color:var(--color-zinc-500)}.dark\:disabled\:bg-zinc-700:where(.dark,.dark *):disabled{background-color:var(--color-zinc-700)}.dark\:disabled\:bg-zinc-800:where(.dark,.dark *):disabled{background-color:var(--color-zinc-800)}@media (hover:hover){.dark\:disabled\:hover\:bg-violet-900\/10:where(.dark,.dark *):disabled:hover{background-color:#4d179a1a}@supports (color:color-mix(in lab, red, red)){.dark\:disabled\:hover\:bg-violet-900\/10:where(.dark,.dark *):disabled:hover{background-color:color-mix(in oklab, var(--color-violet-900) 10%, transparent)}}}@media (min-width:40rem){.dark\:sm\:bg-white\/2:where(.dark,.dark *){background-color:#ffffff05}@supports (color:color-mix(in lab, red, red)){.dark\:sm\:bg-white\/2:where(.dark,.dark *){background-color:color-mix(in oklab, var(--color-white) 2%, transparent)}}}.\[\&_\*\]\:bg-transparent\! *{background-color:#0000!important}.\[\&_\*\]\:text-inherit\! *{color:inherit!important}}@property --tw-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-z{syntax:"*";inherits:false;initial-value:0}@property --tw-scale-x{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-y{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-z{syntax:"*";inherits:false;initial-value:1}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-pan-x{syntax:"*";inherits:false}@property --tw-pan-y{syntax:"*";inherits:false}@property --tw-pinch-zoom{syntax:"*";inherits:false}@property --tw-scroll-snap-strictness{syntax:"*";inherits:false;initial-value:proximity}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-space-x-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-divide-x-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-gradient-position{syntax:"*";inherits:false}@property --tw-gradient-from{syntax:"";inherits:false;initial-value:#0000}@property --tw-gradient-via{syntax:"";inherits:false;initial-value:#0000}@property --tw-gradient-to{syntax:"";inherits:false;initial-value:#0000}@property --tw-gradient-stops{syntax:"*";inherits:false}@property --tw-gradient-via-stops{syntax:"*";inherits:false}@property --tw-gradient-from-position{syntax:"";inherits:false;initial-value:0%}@property --tw-gradient-via-position{syntax:"";inherits:false;initial-value:50%}@property --tw-gradient-to-position{syntax:"";inherits:false;initial-value:100%}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-ordinal{syntax:"*";inherits:false}@property --tw-slashed-zero{syntax:"*";inherits:false}@property --tw-numeric-figure{syntax:"*";inherits:false}@property --tw-numeric-spacing{syntax:"*";inherits:false}@property --tw-numeric-fraction{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-outline-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-backdrop-blur{syntax:"*";inherits:false}@property --tw-backdrop-brightness{syntax:"*";inherits:false}@property --tw-backdrop-contrast{syntax:"*";inherits:false}@property --tw-backdrop-grayscale{syntax:"*";inherits:false}@property --tw-backdrop-hue-rotate{syntax:"*";inherits:false}@property --tw-backdrop-invert{syntax:"*";inherits:false}@property --tw-backdrop-opacity{syntax:"*";inherits:false}@property --tw-backdrop-saturate{syntax:"*";inherits:false}@property --tw-backdrop-sepia{syntax:"*";inherits:false}@property --tw-duration{syntax:"*";inherits:false}@property --tw-ease{syntax:"*";inherits:false}@property --tw-contain-size{syntax:"*";inherits:false}@property --tw-contain-layout{syntax:"*";inherits:false}@property --tw-contain-paint{syntax:"*";inherits:false}@property --tw-contain-style{syntax:"*";inherits:false}@property --tw-content{syntax:"*";inherits:false;initial-value:""}@keyframes spin{to{transform:rotate(360deg)}}@keyframes ping{75%,to{opacity:0;transform:scale(2)}}@keyframes pulse{50%{opacity:.5}}@keyframes bounce{0%,to{animation-timing-function:cubic-bezier(.8,0,1,1);transform:translateY(-25%)}50%{animation-timing-function:cubic-bezier(0,0,.2,1);transform:none}} \ No newline at end of file diff --git a/meshchatx/src/frontend/public/meshchatx-docs/en/building.md b/meshchatx/src/frontend/public/meshchatx-docs/en/building.md index 59337aa97..5cc2e0624 100644 --- a/meshchatx/src/frontend/public/meshchatx-docs/en/building.md +++ b/meshchatx/src/frontend/public/meshchatx-docs/en/building.md @@ -62,13 +62,13 @@ Windows (x64 and arm64) and macOS (arm64 and universal) scripts are in package.j ## Container build (wheel, AppImage, deb, rpm) -[Dockerfile.build](../../Dockerfile.build) runs the same shell steps CI uses (Poetry, pnpm, task, packaging APT deps). It is aimed at linux/amd64 (NodeSource amd64 tarball, Task amd64 binary). +[Dockerfile.build](../../docker/Dockerfile.build) runs the same shell steps CI uses (Poetry, pnpm, task, packaging APT deps). It is aimed at linux/amd64 (NodeSource amd64 tarball, Task amd64 binary). MESHCHATX_BUILD_TARGETS defaults to all. Other values: wheel, or electron (AppImage + deb for x64 and arm64, best-effort RPM, no wheel). ```bash -docker build -f Dockerfile.build -t meshchatx-build:local . -docker build -f Dockerfile.build --build-arg MESHCHATX_BUILD_TARGETS=wheel -t meshchatx-build:wheel . +docker build -f docker/Dockerfile.build -t meshchatx-build:local . +docker build -f docker/Dockerfile.build --build-arg MESHCHATX_BUILD_TARGETS=wheel -t meshchatx-build:wheel . ``` Copy artifacts off the image: @@ -81,7 +81,7 @@ docker rm "${cid}" ## Container Windows build (optional, Wine) -Tagged releases still build Windows on GitHub windows-latest. For a local cross-build without that runner, use [Dockerfile.electron-wine](../../Dockerfile.electron-wine) on top of electronuserland/builder:24-wine. It installs embeddable Windows Python under Wine for cx_Freeze (same role as task setup:wine), downloads win_amd64 wheels on the Linux side (Wine HTTPS to PyPI is flaky), then runs electron-builder for portable and NSIS. +Tagged releases still build Windows on GitHub windows-latest. For a local cross-build without that runner, use [Dockerfile.electron-wine](../../docker/Dockerfile.electron-wine) on top of electronuserland/builder:24-wine. It installs embeddable Windows Python under Wine for cx_Freeze (same role as task setup:wine), downloads win_amd64 wheels on the Linux side (Wine HTTPS to PyPI is flaky), then runs electron-builder for portable and NSIS. ```bash task docker:dist:win:smoke @@ -91,7 +91,7 @@ task docker:dist:win Or: ```bash -docker build -f Dockerfile.electron-wine -t meshchatx-electron-wine:local . +docker build -f docker/Dockerfile.electron-wine -t meshchatx-electron-wine:local . cid=$(docker create meshchatx-electron-wine:local) docker cp "${cid}:/artifacts/." ./meshchatx-artifacts-win docker rm "${cid}" diff --git a/meshchatx/src/frontend/public/meshchatx-docs/en/identity-and-security.md b/meshchatx/src/frontend/public/meshchatx-docs/en/identity-and-security.md index 64b45628d..df102ad07 100644 --- a/meshchatx/src/frontend/public/meshchatx-docs/en/identity-and-security.md +++ b/meshchatx/src/frontend/public/meshchatx-docs/en/identity-and-security.md @@ -37,12 +37,10 @@ Access attempts are logged. Repeated failures can trigger lockout when auth is e Reset a forgotten password with --reset-password or MESHCHAT_RESET_PASSWORD=true, then set a new password in the UI. -### Demo mode and ALTCHA +### Demo mode MESHCHAT_DEMO_MODE=1 (or --demo) enables a public showcase profile: privacy mode on, plugins off, no outbound announces, and a default-deny HTTP mutation policy with mesh send blocked. Status reports demo_mode: true. -When MESHCHAT_ALTCHA_ENABLED=1, login and setup require a valid [ALTCHA](https://altcha.org/docs/v2/widget-v3/) proof-of-work payload (widget v3, server challenges use PBKDF2/SHA-256 by default). Set MESHCHAT_ALTCHA_HMAC_KEY to a long random secret on the server. Optional MESHCHAT_ALTCHA_COST tunes PoW difficulty. The widget loads from the bundled altcha npm package and fetches challenges from /api/v1/auth/altcha/challenge. - MESHCHAT_AUTH_PAGE_HINT sets optional plain text on the login page (independent of demo mode). Demo Docker compose defaults to username and password hints for the showcase account. MESHCHAT_AUTH_BYPASS=1 skips session auth for local testing only. Do not use it on internet-facing deployments. diff --git a/meshchatx/src/frontend/public/meshchatx-docs/en/installation.md b/meshchatx/src/frontend/public/meshchatx-docs/en/installation.md index 042aef3dd..bd9fb5e3f 100644 --- a/meshchatx/src/frontend/public/meshchatx-docs/en/installation.md +++ b/meshchatx/src/frontend/public/meshchatx-docs/en/installation.md @@ -35,7 +35,7 @@ Release images are published to Docker Hub (quad4io/meshchatx) and GHCR (ghcr.io Quick start with Compose: ```bash -docker compose up -d +docker compose -f docker/docker-compose.yml up -d ``` Basic run: @@ -88,7 +88,7 @@ Run only **one** MeshChatX instance per /config volume. Startup takes an exclusi ### Public demo instance (Coolify) -For a read-only mesh showcase on [Coolify](https://coolify.io/docs/knowledge-base/docker/compose), deploy [docker-compose.demo.yml](../../docker-compose.demo.yml). For a normal (non-demo) Coolify deployment, use [docker-compose.coolify.yml](../../docker-compose.coolify.yml). +For a read-only mesh showcase on [Coolify](https://coolify.io/docs/knowledge-base/docker/compose), deploy [docker/docker-compose.demo.yml](../../docker/docker-compose.demo.yml). For a normal (non-demo) Coolify deployment, use [docker/docker-compose.coolify.yml](../../docker/docker-compose.coolify.yml). Demo compose expects settings like: @@ -97,11 +97,9 @@ MESHCHAT_DEMO_MODE=1 MESHCHAT_AUTH=1 MESHCHAT_DEMO_AUTH_PASSWORD=demo # default showcase password MESHCHAT_AUTH_PAGE_HINT=... # optional login-page text -MESHCHAT_ALTCHA_ENABLED=1 -MESHCHAT_ALTCHA_HMAC_KEY=... # strong secret (compose can require via :?) ``` -MESHCHAT_DEMO_MODE=1 blocks outbound mesh actions and almost all API mutations. Optional MESHCHAT_AUTH_PAGE_HINT shows custom text on the login page (for example Username: demo and Password: demo). The UI uses ALTCHA widget v3 with PBKDF2/SHA-256 challenges from /api/v1/auth/altcha/challenge. Assign a domain with container port **8000**, for example https://meshchatx.example.com:8000. Do not set MESHCHAT_AUTH_BYPASS=1 on a public host. +MESHCHAT_DEMO_MODE=1 blocks outbound mesh actions and almost all API mutations. Optional MESHCHAT_AUTH_PAGE_HINT shows custom text on the login page (for example Username: demo and Password: demo). Assign a domain with container port **8000**, for example https://meshchatx.example.com:8000. Do not set MESHCHAT_AUTH_BYPASS=1 on a public host. ## Python package (PyPI or release wheel) diff --git a/meshchatx/src/frontend/public/service-worker.js b/meshchatx/src/frontend/public/service-worker.js index 78bd1d1cf..8fbc128cf 100644 --- a/meshchatx/src/frontend/public/service-worker.js +++ b/meshchatx/src/frontend/public/service-worker.js @@ -328,7 +328,7 @@ function createShellRuntime(options) { * @param {{ preloadResponse?: Promise }} [eventExtras] * @returns {Promise|null} null means network bypass (do not respondWith) */ - function resolveFetch(request, eventExtras = {}) { + function resolveFetch(request, eventExtras: any = {}) { let url; try { url = new URL(request.url); @@ -414,7 +414,7 @@ function shouldAttachNavigationPreload(request, origin) { * @returns {boolean} true when respondWith was called */ function handleFetchEvent(event, runtime, origin) { - const extras = {}; + const extras: any = {}; if (shouldAttachNavigationPreload(event.request, origin)) { extras.preloadResponse = event.preloadResponse; } @@ -430,7 +430,7 @@ function handleFetchEvent(event, runtime, origin) { /* global ["/","/boot-theme.js","/manifest.json","/favicons/favicon-512x512.png"], cacheNameForBuild, createShellRuntime, handleFetchEvent, SHELL_FALLBACK_URL, NAV_NETWORK_TIMEOUT_MS, UPDATE_MESSAGE_TYPE */ /** * MeshChatX app-shell service worker bootstrap. - * Preceded at build time by inlined swCachePolicy.js + swShellRuntime.js. + * Preceded at build time by inlined swCachePolicy.ts + swShellRuntime.ts. * Placeholders dev and ["/","/boot-theme.js","/manifest.json","/favicons/favicon-512x512.png"] are replaced. */ diff --git a/meshchatx/src/frontend/shell/buildRouterRoutes.js b/meshchatx/src/frontend/shell/buildRouterRoutes.ts similarity index 67% rename from meshchatx/src/frontend/shell/buildRouterRoutes.js rename to meshchatx/src/frontend/shell/buildRouterRoutes.ts index 447fab520..1d5a10099 100644 --- a/meshchatx/src/frontend/shell/buildRouterRoutes.js +++ b/meshchatx/src/frontend/shell/buildRouterRoutes.ts @@ -1,17 +1,13 @@ // SPDX-License-Identifier: 0BSD +// @ts-nocheck import { listRoutes } from "../js/registries/routeRegistry.js"; import FeaturePageHost from "./FeaturePageHost.vue"; +import type { Component } from "vue"; +import type { RouteRecordRaw } from "vue-router"; -/** - * Build vue-router route records from the route registry. - * Vue mounts resolve the SFC directly. Svelte mounts use FeaturePageHost. - * Lives in shell/ so kernel js/ stays free of Vue imports. - * @returns {import('vue-router').RouteRecordRaw[]} - */ -export function buildRouterRoutesFromRegistry() { +export function buildRouterRoutesFromRegistry(): RouteRecordRaw[] { return listRoutes().map((entry) => { - /** @type {import('vue-router').RouteRecordRaw} */ const record = { name: entry.name, path: entry.path, @@ -19,7 +15,7 @@ export function buildRouterRoutesFromRegistry() { ...(entry.meta || {}), featureMount: entry.mount, }, - }; + } as RouteRecordRaw; if (entry.props === true) { record.props = true; } else if (entry.routeProps && typeof entry.routeProps === "object") { @@ -34,7 +30,11 @@ export function buildRouterRoutesFromRegistry() { record.component = () => entry.load().then((mod) => { - const Comp = mod && (mod.default || mod); + const moduleValue = mod as { default?: Component } | Component | null; + const Comp = + moduleValue && typeof moduleValue === "object" && "default" in moduleValue + ? moduleValue.default + : moduleValue; if (!Comp) { throw new Error(`routeRegistry: vue load for "${entry.name}" has no default export`); } diff --git a/meshchatx/src/frontend/sw/service-worker.template.js b/meshchatx/src/frontend/sw/service-worker.template.js index d6b7c821b..503852c24 100644 --- a/meshchatx/src/frontend/sw/service-worker.template.js +++ b/meshchatx/src/frontend/sw/service-worker.template.js @@ -2,7 +2,7 @@ /* global __MESHCHATX_SW_PRECACHE_JSON__, cacheNameForBuild, createShellRuntime, handleFetchEvent, SHELL_FALLBACK_URL, NAV_NETWORK_TIMEOUT_MS, UPDATE_MESSAGE_TYPE */ /** * MeshChatX app-shell service worker bootstrap. - * Preceded at build time by inlined swCachePolicy.js + swShellRuntime.js. + * Preceded at build time by inlined swCachePolicy.ts + swShellRuntime.ts. * Placeholders __MESHCHATX_SW_BUILD_ID__ and __MESHCHATX_SW_PRECACHE_JSON__ are replaced. */ diff --git a/meshchatx/src/frontend/theme/designTokens.js b/meshchatx/src/frontend/theme/designTokens.ts similarity index 86% rename from meshchatx/src/frontend/theme/designTokens.js rename to meshchatx/src/frontend/theme/designTokens.ts index dbb0d38ce..8d0c45b74 100644 --- a/meshchatx/src/frontend/theme/designTokens.js +++ b/meshchatx/src/frontend/theme/designTokens.ts @@ -1,13 +1,8 @@ -/** - * Semantic design tokens for MeshChatX. CSS custom properties (--mc-*) are the - * single source of truth for colors shared by Tailwind (`sem-*`), global CSS in - * style.css, and Tailwind semantic colors. - */ +// SPDX-License-Identifier: 0BSD const MESHCHAT_THEME_STYLE_ID = "meshchat-design-tokens"; -/** @type {Record} */ -export const MESHCHAT_THEME_VARIABLES_LIGHT = { +export const MESHCHAT_THEME_VARIABLES_LIGHT: Record = { "--mc-canvas": "#f8fafc", "--mc-surface": "#ffffff", "--mc-border": "#e5e7eb", @@ -63,8 +58,7 @@ export const MESHCHAT_THEME_VARIABLES_LIGHT = { "--mc-error": "#dc2626", }; -/** @type {Record} */ -export const MESHCHAT_THEME_VARIABLES_DARK = { +export const MESHCHAT_THEME_VARIABLES_DARK: Record = { "--mc-canvas": "#09090b", "--mc-surface": "#09090b", "--mc-border": "#27272a", @@ -120,11 +114,7 @@ export const MESHCHAT_THEME_VARIABLES_DARK = { "--mc-error": "#f87171", }; -/** - * Tailwind semantic colors (`sem-*` in class names). `@theme` in style.css mirrors these keys. Same --mc-* variables as global CSS. - * Keep keys in sync with usages in style.css and Vue (e.g. bg-sem-canvas). - */ -export function tailwindSemanticColorExtend() { +export function tailwindSemanticColorExtend(): Record { return { canvas: "var(--mc-canvas)", surface: "var(--mc-surface)", @@ -153,17 +143,15 @@ export function tailwindSemanticColorExtend() { }; } -function serializeVarsBlock(vars) { +function serializeVarsBlock(vars: Record): string { return Object.entries(vars) .map(([k, v]) => ` ${k}: ${v};`) .join("\n"); } -/** - * Injects :root and .dark variable blocks. Idempotent. Call once from main.js before mount. - * @param {Document} [doc] - */ -export function injectMeshchatThemeVariables(doc = typeof document !== "undefined" ? document : null) { +export function injectMeshchatThemeVariables( + doc: Document | null = typeof document !== "undefined" ? document : null +): void { if (!doc?.head) { return; } diff --git a/meshchatx/src/frontend/theme/themeEngine.js b/meshchatx/src/frontend/theme/themeEngine.ts similarity index 84% rename from meshchatx/src/frontend/theme/themeEngine.js rename to meshchatx/src/frontend/theme/themeEngine.ts index 1c9dddc54..3c10494aa 100644 --- a/meshchatx/src/frontend/theme/themeEngine.js +++ b/meshchatx/src/frontend/theme/themeEngine.ts @@ -22,8 +22,48 @@ export const THEME_PRESET_IDS = [ "custom", ]; -/** Settings UI order and i18n keys for color presets. */ -export const THEME_PRESET_CATALOG = [ +type ThemePreference = "light" | "dark" | "system"; +type EffectiveTheme = "light" | "dark"; +type CssVariables = Record; + +interface ThemeConfig extends Record { + theme?: unknown; + theme_preset?: unknown; + accent_color?: unknown; + custom_canvas_color?: unknown; + custom_surface_color?: unknown; + ui_transparency?: unknown; +} + +interface ThemeWindow { + matchMedia?: Window["matchMedia"]; + localStorage?: Storage; + dispatchEvent?: Window["dispatchEvent"]; + electron?: unknown; + MeshChatXAndroid?: unknown; +} + +interface ThemeOptions { + doc?: Document | null; + windowObj?: ThemeWindow | null; + prefersDark?: boolean; +} + +interface ThemeSnapshot { + preference: ThemePreference; + effective: EffectiveTheme; + preset: string; + accent: string; +} + +interface PreviewColors { + canvas: string; + surface: string; + accent: string; + border: string; +} + +export const THEME_PRESET_CATALOG: Array<{ id: string; labelKey: string }> = [ { id: "default", labelKey: "app.theme_preset_default" }, { id: "high_contrast", labelKey: "app.theme_preset_high_contrast" }, { id: "oled", labelKey: "app.theme_preset_oled" }, @@ -45,22 +85,14 @@ export const THEME_PRESET_CATALOG = [ const MESHCHAT_THEME_OVERRIDES_STYLE_ID = "meshchat-theme-overrides"; const MESHCHAT_THEME_COLOR_META_ID = "meshchat-theme-color-meta"; -/** - * @param {unknown} value - * @returns {"light" | "dark" | "system"} - */ -export function normalizeThemePreference(value) { +export function normalizeThemePreference(value: unknown): ThemePreference { if (value === "dark" || value === "system") { return value; } return "light"; } -/** - * @param {unknown} value - * @returns {string} - */ -export function normalizeThemePreset(value) { +export function normalizeThemePreset(value: unknown): string { if (value === "hister") { return "neo_brutalist"; } @@ -70,23 +102,16 @@ export function normalizeThemePreset(value) { return "default"; } -/** - * @param {Window | null | undefined} windowObj - * @returns {boolean} - */ -export function systemPrefersDark(windowObj = typeof window !== "undefined" ? window : null) { +export function systemPrefersDark( + windowObj: ThemeWindow | null = typeof window !== "undefined" ? window : null +): boolean { if (!windowObj?.matchMedia) { return false; } return windowObj.matchMedia("(prefers-color-scheme: dark)").matches; } -/** - * @param {unknown} themePreference - * @param {boolean} [prefersDark] - * @returns {"light" | "dark"} - */ -export function resolveEffectiveTheme(themePreference, prefersDark = systemPrefersDark()) { +export function resolveEffectiveTheme(themePreference: unknown, prefersDark = systemPrefersDark()): EffectiveTheme { const pref = normalizeThemePreference(themePreference); if (pref === "dark") { return "dark"; @@ -97,11 +122,7 @@ export function resolveEffectiveTheme(themePreference, prefersDark = systemPrefe return "light"; } -/** - * @param {unknown} value - * @returns {string | null} - */ -export function normalizeOptionalHexColor(value) { +export function normalizeOptionalHexColor(value: unknown): string | null { if (value == null || value === "") { return null; } @@ -111,11 +132,7 @@ export function normalizeOptionalHexColor(value) { return null; } -/** - * @param {string} hex - * @returns {{ r: number, g: number, b: number } | null} - */ -export function parseHexColor(hex) { +export function parseHexColor(hex: string): { r: number; g: number; b: number } | null { const normalized = normalizeOptionalHexColor(hex); if (!normalized) { return null; @@ -127,24 +144,13 @@ export function parseHexColor(hex) { }; } -/** - * @param {number} r - * @param {number} g - * @param {number} b - * @returns {string} - */ -export function rgbToHex(r, g, b) { - const clamp = (n) => Math.max(0, Math.min(255, Math.round(n))); - const part = (n) => clamp(n).toString(16).padStart(2, "0"); +export function rgbToHex(r: number, g: number, b: number): string { + const clamp = (n: number): number => Math.max(0, Math.min(255, Math.round(n))); + const part = (n: number): string => clamp(n).toString(16).padStart(2, "0"); return `#${part(r)}${part(g)}${part(b)}`; } -/** - * @param {string} hex - * @param {number} amount - * @returns {string} - */ -export function adjustHex(hex, amount) { +export function adjustHex(hex: string, amount: number): string { const rgb = parseHexColor(hex); if (!rgb) { return hex; @@ -152,13 +158,7 @@ export function adjustHex(hex, amount) { return rgbToHex(rgb.r + amount, rgb.g + amount, rgb.b + amount); } -/** - * @param {string} a - * @param {string} b - * @param {number} ratio - * @returns {string} - */ -export function mixHex(a, b, ratio = 0.5) { +export function mixHex(a: string, b: string, ratio = 0.5): string { const left = parseHexColor(a); const right = parseHexColor(b); if (!left || !right) { @@ -171,12 +171,7 @@ export function mixHex(a, b, ratio = 0.5) { ); } -/** - * @param {string} accentHex - * @param {boolean} isDark - * @returns {Record} - */ -export function accentDerivativeVars(accentHex, isDark) { +export function accentDerivativeVars(accentHex: string, isDark: boolean): CssVariables { const hover = isDark ? adjustHex(accentHex, 20) : adjustHex(accentHex, 15); return { "--mc-accent": isDark ? mixHex(accentHex, "#ffffff", 0.25) : accentHex, @@ -190,8 +185,7 @@ export function accentDerivativeVars(accentHex, isDark) { }; } -/** @type {Record, dark: Record }>} */ -export const THEME_PRESETS = { +export const THEME_PRESETS: Record = { default: { light: {}, dark: {}, @@ -582,13 +576,11 @@ export const THEME_PRESETS = { }, }; -/** - * @param {string | null | undefined} canvasHex - * @param {string | null | undefined} surfaceHex - * @param {"light" | "dark"} effectiveMode - * @returns {Record} - */ -export function customThemeColorOverrides(canvasHex, surfaceHex, effectiveMode) { +export function customThemeColorOverrides( + canvasHex: unknown, + surfaceHex: unknown, + effectiveMode: EffectiveTheme +): CssVariables { const canvas = normalizeOptionalHexColor(canvasHex); const surfaceInput = normalizeOptionalHexColor(surfaceHex); if (!canvas && !surfaceInput) { @@ -596,7 +588,7 @@ export function customThemeColorOverrides(canvasHex, surfaceHex, effectiveMode) } const isDark = effectiveMode === "dark"; - const overrides = {}; + const overrides: CssVariables = {}; if (canvas) { overrides["--mc-canvas"] = canvas; } @@ -625,12 +617,10 @@ export function customThemeColorOverrides(canvasHex, surfaceHex, effectiveMode) return overrides; } -/** - * @param {Record | null | undefined} config - * @param {"light" | "dark"} effectiveMode - * @returns {Record} - */ -export function buildThemeVariableOverrides(config, effectiveMode) { +export function buildThemeVariableOverrides( + config: ThemeConfig | null | undefined, + effectiveMode: EffectiveTheme +): CssVariables { const presetId = normalizeThemePreset(config?.theme_preset); const preset = THEME_PRESETS[presetId] || THEME_PRESETS.default; const overrides = { ...(preset[effectiveMode] || {}) }; @@ -650,12 +640,7 @@ export function buildThemeVariableOverrides(config, effectiveMode) { return overrides; } -/** - * @param {Record | null | undefined} config - * @param {"light" | "dark"} effectiveMode - * @returns {string} - */ -export function resolveCanvasColor(config, effectiveMode) { +export function resolveCanvasColor(config: ThemeConfig | null | undefined, effectiveMode: EffectiveTheme): string { const overrides = buildThemeVariableOverrides(config, effectiveMode); if (overrides["--mc-canvas"]) { return overrides["--mc-canvas"]; @@ -664,11 +649,7 @@ export function resolveCanvasColor(config, effectiveMode) { return base["--mc-canvas"]; } -/** - * @param {string} hex - * @returns {{ r: number, g: number, b: number, a: number }} - */ -export function hexToRgba(hex, alpha = 1) { +export function hexToRgba(hex: string, alpha = 1): { r: number; g: number; b: number; a: number } { const rgb = parseHexColor(hex); if (!rgb) { return { r: 248, g: 250, b: 252, a: alpha }; @@ -676,12 +657,10 @@ export function hexToRgba(hex, alpha = 1) { return { r: rgb.r, g: rgb.g, b: rgb.b, a: alpha }; } -/** - * @param {Record | null | undefined} config - * @param {"light" | "dark"} effectiveMode - * @returns {string} - */ -export function shellCanvasBackgroundStyle(config, effectiveMode) { +export function shellCanvasBackgroundStyle( + config: ThemeConfig | null | undefined, + effectiveMode: EffectiveTheme +): string { const raw = Number(config?.ui_transparency ?? 0); const t = Number.isFinite(raw) ? Math.max(0, Math.min(100, raw)) : 0; const factor = t / 100; @@ -691,22 +670,17 @@ export function shellCanvasBackgroundStyle(config, effectiveMode) { return `rgba(${r}, ${g}, ${b}, ${alpha})`; } -function serializeVarsBlock(vars) { +function serializeVarsBlock(vars: CssVariables): string { return Object.entries(vars) .map(([k, v]) => ` ${k}: ${v};`) .join("\n"); } -/** - * @param {Document | null | undefined} doc - * @param {Record} overrides - * @param {"light" | "dark"} effectiveMode - */ export function injectThemeOverrideVariables( - doc = typeof document !== "undefined" ? document : null, - overrides = {}, - effectiveMode = "light" -) { + doc: Document | null = typeof document !== "undefined" ? document : null, + overrides: CssVariables = {}, + effectiveMode: EffectiveTheme = "light" +): void { if (!doc?.head) { return; } @@ -721,15 +695,14 @@ export function injectThemeOverrideVariables( doc.head.appendChild(el); } -/** - * @param {Document | null | undefined} doc - * @param {string} canvasHex - */ -export function updateThemeColorMeta(doc = typeof document !== "undefined" ? document : null, canvasHex) { +export function updateThemeColorMeta( + doc: Document | null = typeof document !== "undefined" ? document : null, + canvasHex: string +): void { if (!doc?.head) { return; } - let meta = doc.getElementById(MESHCHAT_THEME_COLOR_META_ID); + let meta: HTMLElement | null = doc.getElementById(MESHCHAT_THEME_COLOR_META_ID); if (!meta) { meta = doc.querySelector('meta[name="theme-color"]'); } @@ -744,16 +717,10 @@ export function updateThemeColorMeta(doc = typeof document !== "undefined" ? doc meta.setAttribute("content", canvasHex); } -/** - * @param {Record | null | undefined} config - * @param {object | null | undefined} options - */ -/** - * Read-only theme snapshot for plugin workers and host APIs. - * @param {Record | null | undefined} config - * @param {{ prefersDark?: boolean }} [options] - */ -export function getThemeSnapshot(config, options = {}) { +export function getThemeSnapshot( + config: ThemeConfig | null | undefined, + options: { prefersDark?: boolean } = {} +): ThemeSnapshot { const prefersDark = options.prefersDark !== undefined ? Boolean(options.prefersDark) : systemPrefersDark(); return { preference: normalizeThemePreference(config?.theme), @@ -763,7 +730,16 @@ export function getThemeSnapshot(config, options = {}) { }; } -export function applyAppearanceTheme(config, options = {}) { +export function applyAppearanceTheme( + config: ThemeConfig | null | undefined, + options: ThemeOptions = {} +): { + effectiveMode: EffectiveTheme; + preference: ThemePreference; + canvasHex: string; + overrides: CssVariables; + snapshot: ThemeSnapshot; +} { const doc = options.doc ?? (typeof document !== "undefined" ? document : null); const windowObj = options.windowObj ?? (typeof window !== "undefined" ? window : null); const prefersDark = options.prefersDark !== undefined ? Boolean(options.prefersDark) : systemPrefersDark(windowObj); @@ -791,7 +767,9 @@ export function applyAppearanceTheme(config, options = {}) { } try { - const electronBridge = windowObj?.electron; + const electronBridge = windowObj?.electron as + | { setUiTheme?: (theme: string) => unknown } + | undefined; if (electronBridge && typeof electronBridge.setUiTheme === "function") { void electronBridge.setUiTheme(persistMode); } @@ -800,7 +778,9 @@ export function applyAppearanceTheme(config, options = {}) { } try { - const bridge = windowObj?.MeshChatXAndroid; + const bridge = windowObj?.MeshChatXAndroid as + | { setUiTheme?: (theme: string) => unknown } + | undefined; if (bridge && typeof bridge.setUiTheme === "function") { bridge.setUiTheme(effectiveMode); } @@ -833,17 +813,15 @@ export function applyAppearanceTheme(config, options = {}) { }; } -/** - * @param {Window | null | undefined} windowObj - * @param {(prefersDark: boolean) => void} callback - * @returns {(() => void) | null} - */ -export function subscribeSystemTheme(windowObj = typeof window !== "undefined" ? window : null, callback) { +export function subscribeSystemTheme( + windowObj: ThemeWindow | null = typeof window !== "undefined" ? window : null, + callback: (prefersDark: boolean) => void +): (() => void) | null { if (!windowObj?.matchMedia) { return null; } const media = windowObj.matchMedia("(prefers-color-scheme: dark)"); - const handler = (event) => { + const handler = (event: MediaQueryListEvent): void => { callback(Boolean(event.matches)); }; if (typeof media.addEventListener === "function") { @@ -854,10 +832,7 @@ export function subscribeSystemTheme(windowObj = typeof window !== "undefined" ? return () => media.removeListener(handler); } -/** - * @param {Record} config - */ -export function sanitizeThemeConfigFields(config) { +export function sanitizeThemeConfigFields(config: ThemeConfig): void { if (!config) { return; } @@ -868,12 +843,10 @@ export function sanitizeThemeConfigFields(config) { config.custom_surface_color = normalizeOptionalHexColor(config.custom_surface_color); } -/** - * @param {Record | null | undefined} config - * @param {"light" | "dark"} effectiveMode - * @returns {{ canvas: string, surface: string, accent: string, border: string }} - */ -export function getThemePresetPreviewColors(config, effectiveMode = "light") { +export function getThemePresetPreviewColors( + config: ThemeConfig | null | undefined, + effectiveMode: EffectiveTheme = "light" +): PreviewColors { const overrides = buildThemeVariableOverrides(config, effectiveMode); const base = effectiveMode === "dark" ? MESHCHAT_THEME_VARIABLES_DARK : MESHCHAT_THEME_VARIABLES_LIGHT; return { @@ -888,11 +861,7 @@ export function getThemePresetPreviewColors(config, effectiveMode = "light") { }; } -/** - * @param {{ canvas: string, surface: string, accent: string, border: string }} colors - * @returns {string} - */ -export function themePresetPreviewStripStyle(colors) { +export function themePresetPreviewStripStyle(colors: PreviewColors): string { const canvas = colors.canvas || "#f8fafc"; const surface = colors.surface || "#ffffff"; const accent = colors.accent || "#2563eb"; diff --git a/meshchatx/src/frontend/ui/svelte/LxmfUserIcon.svelte b/meshchatx/src/frontend/ui/svelte/LxmfUserIcon.svelte index 201afd8ae..34121ed1c 100644 --- a/meshchatx/src/frontend/ui/svelte/LxmfUserIcon.svelte +++ b/meshchatx/src/frontend/ui/svelte/LxmfUserIcon.svelte @@ -1,7 +1,7 @@ {#if customImage} -
+
-{:else if iconName} -
- -
{:else} -
- +
+ + +
{/if} diff --git a/meshchatx/src/frontend/ui/svelte/MaterialDesignIcon.svelte b/meshchatx/src/frontend/ui/svelte/MaterialDesignIcon.svelte index 7bc06df92..31b1bf403 100644 --- a/meshchatx/src/frontend/ui/svelte/MaterialDesignIcon.svelte +++ b/meshchatx/src/frontend/ui/svelte/MaterialDesignIcon.svelte @@ -3,8 +3,8 @@ @@ -17,7 +17,7 @@ fill="currentColor" width="20" height="20" - style="display: block" + style="display: block; {style}" class="antialiased shrink-0 {className}" > diff --git a/meshchatx/src/frontend/ui/svelte/VueSfcHost.svelte b/meshchatx/src/frontend/ui/svelte/VueSfcHost.svelte new file mode 100644 index 000000000..b075b1cca --- /dev/null +++ b/meshchatx/src/frontend/ui/svelte/VueSfcHost.svelte @@ -0,0 +1,49 @@ + + + +
diff --git a/meshchatx/src/frontend/vite-env.d.ts b/meshchatx/src/frontend/vite-env.d.ts new file mode 100644 index 000000000..caf2f84d2 --- /dev/null +++ b/meshchatx/src/frontend/vite-env.d.ts @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: 0BSD + +/// + +declare module "*.vue" { + import type { DefineComponent } from "vue"; + const component: DefineComponent, Record, unknown>; + export default component; +} diff --git a/package.json b/package.json index 8c696c9c0..1aa303a7a 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "knip": "knip", "format": "prettier --write .", "format:check": "prettier --check .", - "format:check:svelte": "prettier --check \"meshchatx/src/frontend/**/*.svelte\" \"meshchatx/src/frontend/features/**/*.js\" \"svelte.config.js\"", + "format:check:svelte": "prettier --check \"meshchatx/src/frontend/**/*.svelte\" \"meshchatx/src/frontend/features/**/*.{js,ts}\" \"svelte.config.js\"", "commitlint": "commitlint --edit", "test": "vitest run --exclude tests/frontend/LoadTimePerformance.test.js && vitest run --config vitest.electron.config.js", "test:coverage": "vitest run --coverage --exclude tests/frontend/LoadTimePerformance.test.js && vitest run --config vitest.electron.config.js --coverage", @@ -117,6 +117,7 @@ "tailwindcss": "^4.3.3", "terser": "^5.51.2", "typescript": "^6.0.3", + "typescript-eslint": "^8.69.0", "vite": "^8.2.2", "vite-plugin-vue-devtools": "^8.2.1", "vitest": "^4.1.11", @@ -316,7 +317,6 @@ "@mdi/js": "^7.4.47", "@tailwindcss/forms": "^0.5.11", "@tanstack/vue-virtual": "^3.13.36", - "altcha": "3.2.2", "compressorjs": "^1.3.0", "dompurify": "^3.4.14", "electron-prompt": "^1.7.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e7729e29d..da56170ae 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -81,9 +81,6 @@ importers: '@tanstack/vue-virtual': specifier: ^3.13.36 version: 3.13.36(vue@3.5.42(typescript@6.0.3)) - altcha: - specifier: 3.2.2 - version: 3.2.2 compressorjs: specifier: ^1.3.0 version: 1.3.0 @@ -204,7 +201,7 @@ importers: version: 3.23.0(eslint@9.39.5(jiti@2.7.0))(svelte@5.0.0) eslint-plugin-vue: specifier: ^10.10.0 - version: 10.10.0(eslint@9.39.5(jiti@2.7.0))(vue-eslint-parser@10.4.1(eslint@9.39.5(jiti@2.7.0))) + version: 10.10.0(@typescript-eslint/parser@8.69.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.5(jiti@2.7.0))(vue-eslint-parser@10.4.1(eslint@9.39.5(jiti@2.7.0))) fake-indexeddb: specifier: ^6.2.5 version: 6.2.5 @@ -244,6 +241,9 @@ importers: typescript: specifier: ^6.0.3 version: 6.0.3 + typescript-eslint: + specifier: ^8.69.0 + version: 8.69.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3) vite: specifier: ^8.2.2 version: 8.2.2(@types/node@24.13.3)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0) @@ -1799,6 +1799,65 @@ packages: '@types/trusted-types@2.0.7': resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@typescript-eslint/eslint-plugin@8.69.0': + resolution: {integrity: sha512-t5jQTKPIgVW1PE6dR6H6Qz5gm8zjMlX5/2gRaOGd9eO6V7J+tQc6iWKukEe7dY8u9HyYasQ0yfF0/FSSTEO2gA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.69.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/parser@8.69.0': + resolution: {integrity: sha512-l4b0DhWioGg6Gt2ebGlvfkFMOjRsauxtsnDRwUSRX1qHq3HdTfQHV8wW9zEXeciai6HfeaKOedQn2Zoofx3WBw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/project-service@8.69.0': + resolution: {integrity: sha512-yi4obFrHMmnsesWehHbkg9zMA7Jt8cXT+mKM08G999pH1yT6nqgsHx7MYm0uY1wAj8CqiBXYRJ7WAT0QdQHQXg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/scope-manager@8.69.0': + resolution: {integrity: sha512-ewfspqWvSxKSOaplqAUNbaSFO0eB6w1EtQ+esfYFRm3614Ty4uNtExkcbgd6nWsXphbqKyf9ZYdbZdv2xEoWEQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.69.0': + resolution: {integrity: sha512-xNqK7YTDZsLniQMV/4rpFR8Z5JlqeRvVjuG1YgF/mdPVH84HSD19L8CczMA0qg2RfwEV231GHH3VnToJDo4MfQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/type-utils@8.69.0': + resolution: {integrity: sha512-ZfoJAVg3JZndQEpEl9petVlxau3lRuElc4HRMuAlLCf8to04/iHz692RUSNmXKDjEuJmIL+KZ2/BsOcBc16dsA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/types@8.69.0': + resolution: {integrity: sha512-K3VrubUPhlo9VDBS6QdI8YB5j7ClpqLRdefcz6PFrhnwicehBweqQ9Evhl4l+FYz0HdDmMqIiSX0aldGRYtDCA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.69.0': + resolution: {integrity: sha512-AdFkgqck3Vudb/kWnxlyafU/4aBhHrbQ9locP2N4psXTy5mOBg0SHJumnLvx7r6g1gV4DKvUFwV2nJZBoqOD8w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/utils@8.69.0': + resolution: {integrity: sha512-tUbx60BBqQa31kXF5MCsOOLL5E/WzUuxIn7YpAvq+eaUlqvk8/NXnXMBNAdLCr0icjkzem7iUA5QqWHe/hJ1aw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/visitor-keys@8.69.0': + resolution: {integrity: sha512-+rmdgPA+EXkNgKYvHvFfhrs35utXbwaC5PGpDquSXcoXQDKUA5UjV0LmTucG/4JXkM31BTu4TilHtrN8IVBe8w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@vitejs/plugin-vue@6.0.8': resolution: {integrity: sha512-0ZjgOg7oO6farnNGup7yvoM/YXZV84OZxHAwtflItNa/6zzQyVb5LNxyea3FEKEX2XlagIKzrlH7wwxkKgtiew==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1997,9 +2056,6 @@ packages: alien-signals@3.2.1: resolution: {integrity: sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g==} - altcha@3.2.2: - resolution: {integrity: sha512-yNITQaRSkRe0u/hMxdzoXFFqxqn9iDt1rJ0reREU3D3uDD3w1oFz6v5C2PMww2XnGThtYUwe8MW+PxsiBglNqw==} - ansi-colors@4.1.3: resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} engines: {node: '>=6'} @@ -2794,6 +2850,10 @@ packages: resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + eslint@9.39.5: resolution: {integrity: sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -3181,9 +3241,6 @@ packages: resolution: {integrity: sha512-qdSAmqLF6209RFj4VVItywPMbm3vWylknmB3nvNiUIs72xAimcM8nVYxYr7ncvZq5qzk9MKIZR8ijqD/1QuYjQ==} engines: {node: '>= 0.4.0'} - hash-wasm@4.12.0: - resolution: {integrity: sha512-+/2B2rYLb48I/evdOIhP+K/DD2ca2fgBjp6O+GBEnCDk2e4rpeXIK8GvIyRPjTezgmWn9gmKwkQjjx6BtqDHVQ==} - hasown@2.0.0: resolution: {integrity: sha512-vUptKVTpIJhcczKBbgnS+RtcuYMB8+oNzPK2/Hp3hanz8JmpATdmmgLgSaadVREkDm+e2giHwY3ZRkyjSIDDFA==} engines: {node: '>= 0.4'} @@ -3252,6 +3309,10 @@ packages: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} + image-ssim@0.2.0: resolution: {integrity: sha512-W7+sO6/yhxy83L0G7xR8YAc5Z5QFtYEXXRV6EaE8tuYBZJnA3gVgp3q7X7muhLZVodeb9UfvjSbwt9VJwjIYAg==} @@ -4990,6 +5051,12 @@ packages: truncate-utf8-bytes@1.0.2: resolution: {integrity: sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ==} + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + tslib@2.4.0: resolution: {integrity: sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==} @@ -5031,6 +5098,13 @@ packages: typed-query-selector@2.12.2: resolution: {integrity: sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ==} + typescript-eslint@8.69.0: + resolution: {integrity: sha512-B3MltX0VqjUBNEe3b3sSuiRbfa6XrfHFtBiPamjT5AsW/dfq+y+bc0wyuS9DxAS1LyzCxRp2+rxzpLUvqM2BvA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + typescript@6.0.3: resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} engines: {node: '>=14.17'} @@ -6153,7 +6227,7 @@ snapshots: '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 - string-width-cjs: string-width@4.2.0 + string-width-cjs: string-width@4.2.3 strip-ansi: 6.0.1 strip-ansi-cjs: strip-ansi@6.0.1 wrap-ansi: 8.1.0 @@ -7101,6 +7175,97 @@ snapshots: '@types/trusted-types@2.0.7': optional: true + '@typescript-eslint/eslint-plugin@8.69.0(@typescript-eslint/parser@8.69.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.69.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/scope-manager': 8.69.0 + '@typescript-eslint/type-utils': 8.69.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/utils': 8.69.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.69.0 + eslint: 9.39.5(jiti@2.7.0) + ignore: 7.0.5 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.69.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.69.0 + '@typescript-eslint/types': 8.69.0 + '@typescript-eslint/typescript-estree': 8.69.0(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.69.0 + debug: 4.3.1 + eslint: 9.39.5(jiti@2.7.0) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.69.0(typescript@6.0.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.69.0(typescript@6.0.3) + '@typescript-eslint/types': 8.69.0 + debug: 4.3.1 + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.69.0': + dependencies: + '@typescript-eslint/types': 8.69.0 + '@typescript-eslint/visitor-keys': 8.69.0 + + '@typescript-eslint/tsconfig-utils@8.69.0(typescript@6.0.3)': + dependencies: + typescript: 6.0.3 + + '@typescript-eslint/type-utils@8.69.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3)': + dependencies: + '@typescript-eslint/types': 8.69.0 + '@typescript-eslint/typescript-estree': 8.69.0(typescript@6.0.3) + '@typescript-eslint/utils': 8.69.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3) + debug: 4.3.1 + eslint: 9.39.5(jiti@2.7.0) + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.69.0': {} + + '@typescript-eslint/typescript-estree@8.69.0(typescript@6.0.3)': + dependencies: + '@typescript-eslint/project-service': 8.69.0(typescript@6.0.3) + '@typescript-eslint/tsconfig-utils': 8.69.0(typescript@6.0.3) + '@typescript-eslint/types': 8.69.0 + '@typescript-eslint/visitor-keys': 8.69.0 + debug: 4.3.1 + minimatch: 10.2.3 + semver: 7.5.2 + tinyglobby: 0.2.15 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.69.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3)': + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5(jiti@2.7.0)) + '@typescript-eslint/scope-manager': 8.69.0 + '@typescript-eslint/types': 8.69.0 + '@typescript-eslint/typescript-estree': 8.69.0(typescript@6.0.3) + eslint: 9.39.5(jiti@2.7.0) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.69.0': + dependencies: + '@typescript-eslint/types': 8.69.0 + eslint-visitor-keys: 5.0.1 + '@vitejs/plugin-vue@6.0.8(vite@8.2.2(@types/node@24.13.3)(jiti@2.7.0)(terser@5.51.2)(yaml@2.9.0))(vue@3.5.42(typescript@6.0.3))': dependencies: '@rolldown/pluginutils': 1.0.1 @@ -7369,10 +7534,6 @@ snapshots: alien-signals@3.2.1: {} - altcha@3.2.2: - dependencies: - hash-wasm: 4.12.0 - ansi-colors@4.1.3: {} ansi-regex@5.0.1: {} @@ -8237,7 +8398,7 @@ snapshots: transitivePeerDependencies: - ts-node - eslint-plugin-vue@10.10.0(eslint@9.39.5(jiti@2.7.0))(vue-eslint-parser@10.4.1(eslint@9.39.5(jiti@2.7.0))): + eslint-plugin-vue@10.10.0(@typescript-eslint/parser@8.69.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.5(jiti@2.7.0))(vue-eslint-parser@10.4.1(eslint@9.39.5(jiti@2.7.0))): dependencies: '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5(jiti@2.7.0)) eslint: 9.39.5(jiti@2.7.0) @@ -8247,6 +8408,8 @@ snapshots: semver: 7.8.5 vue-eslint-parser: 10.4.1(eslint@9.39.5(jiti@2.7.0)) xml-name-validator: 5.0.0 + optionalDependencies: + '@typescript-eslint/parser': 8.69.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3) eslint-scope@8.2.0: dependencies: @@ -8264,6 +8427,8 @@ snapshots: eslint-visitor-keys@4.2.1: {} + eslint-visitor-keys@5.0.1: {} + eslint@9.39.5(jiti@2.7.0): dependencies: '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5(jiti@2.7.0)) @@ -8702,8 +8867,6 @@ snapshots: has@1.0.4: {} - hash-wasm@4.12.0: {} - hasown@2.0.0: dependencies: function-bind: 1.1.2 @@ -8780,6 +8943,8 @@ snapshots: ignore@5.3.2: {} + ignore@7.0.5: {} + image-ssim@0.2.0: {} immediate@3.0.6: {} @@ -10546,6 +10711,10 @@ snapshots: dependencies: utf8-byte-length: 1.0.5 + ts-api-utils@2.5.0(typescript@6.0.3): + dependencies: + typescript: 6.0.3 + tslib@2.4.0: optional: true @@ -10599,6 +10768,17 @@ snapshots: typed-query-selector@2.12.2: {} + typescript-eslint@8.69.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.69.0(@typescript-eslint/parser@8.69.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/parser': 8.69.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/typescript-estree': 8.69.0(typescript@6.0.3) + '@typescript-eslint/utils': 8.69.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3) + eslint: 9.39.5(jiti@2.7.0) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + typescript@6.0.3: {} uint8array-extras@1.5.0: {} diff --git a/pyproject.toml b/pyproject.toml index f07553bdb..e2403158d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,7 +39,6 @@ dependencies = [ "wasmtime>=48.0.0", "bleak>=3.0.2,<4.0", "httpx[http2]>=0.28.1,<1.0", - "altcha>=2.1.0", ] [project.scripts] diff --git a/requirements.txt b/requirements.txt index f965faa73..d5dd66727 100644 --- a/requirements.txt +++ b/requirements.txt @@ -132,9 +132,6 @@ aiosignal==1.4.0 \ --hash=sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e \ --hash=sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7 # via aiohttp -altcha==2.1.0 \ - --hash=sha256:5d4d15d89f7833f0f32bb89a1dd650923d3cb113ee9f1178d8a69b2172d087ce \ - --hash=sha256:843de4146e14c604ceca7e563eb245e0cfb96936323ecac2fd898fab6e034cd2 # via reticulum-meshchatx anyio==4.14.2 \ --hash=sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494 \ diff --git a/scripts/build-android-wheels-local.sh b/scripts/build-android-wheels-local.sh index 653c427e7..c7f2a1380 100755 --- a/scripts/build-android-wheels-local.sh +++ b/scripts/build-android-wheels-local.sh @@ -65,7 +65,6 @@ NUMPY_VERSION="1.26.2" LXST_VERSION="0.5.1" BLEAK_VERSION="3.0.2" HTTPX_VERSION="0.28.1" -ALTCHA_VERSION="2.0.2" PYOPENSSL_VERSION="26.4.0" SERVICE_IDENTITY_VERSION="26.1.0" ATTRS_VERSION="26.1.0" @@ -920,21 +919,6 @@ PY fi done - echo "Fetching altcha ${ALTCHA_VERSION} pure-python wheel" - ALTCHA_TMP_DIR="$(mktemp -d)" - "${VENV_DIR}/bin/pip" download \ - --only-binary=:all: \ - --no-deps \ - "altcha==${ALTCHA_VERSION}" \ - --dest "${ALTCHA_TMP_DIR}" \ - --index-url https://pypi.org/simple - cp -f "${ALTCHA_TMP_DIR}"/altcha-"${ALTCHA_VERSION}"-py3-none-any.whl "${OUT_DIR}/" - rm -rf "${ALTCHA_TMP_DIR}" - if ! ls "${OUT_DIR}/altcha-${ALTCHA_VERSION}-py3-none-any.whl" >/dev/null 2>&1; then - echo "Expected altcha-${ALTCHA_VERSION}-py3-none-any.whl in ${OUT_DIR}" >&2 - exit 1 - fi - vendor_aioquic_pure_python_deps fi diff --git a/scripts/build/generate_service_worker.mjs b/scripts/build/generate_service_worker.mjs index 1cf52a6bf..2ec22f7ef 100644 --- a/scripts/build/generate_service_worker.mjs +++ b/scripts/build/generate_service_worker.mjs @@ -26,7 +26,7 @@ export const SW_POLICY_PATH = path.join( "frontend", "js", "pwa", - "swCachePolicy.js" + "swCachePolicy.ts" ); export const SW_RUNTIME_PATH = path.join( @@ -36,7 +36,7 @@ export const SW_RUNTIME_PATH = path.join( "frontend", "js", "pwa", - "swShellRuntime.js" + "swShellRuntime.ts" ); export const SW_PUBLIC_PATH = path.join( @@ -68,6 +68,7 @@ export function stripEsmForServiceWorker(source) { .replace(/^export\s+const\s+/gm, "const ") .replace(/^export\s+\{[^}]*\}\s*;?\s*$/gm, "") .replace(/^\/\/ SPDX-License-Identifier:.*$/gm, "") + .replace(/^\/\/ @ts-nocheck\s*$/gm, "") .trim(); } diff --git a/scripts/ci/docker-build-entry.sh b/scripts/ci/docker-build-entry.sh index a76aed9e1..8a2eeeb47 100755 --- a/scripts/ci/docker-build-entry.sh +++ b/scripts/ci/docker-build-entry.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Run inside Dockerfile.build after COPY. Writes outputs to /artifacts. +# Run inside docker/Dockerfile.build after COPY. Writes outputs to /artifacts. # Env: MESHCHATX_BUILD_TARGETS = all | wheel | electron (electron = Linux packages, no wheel) # Env: MESHCHATX_LINUX_FORMATS = comma list of appimage,deb,rpm,apk (default: all four) set -euo pipefail diff --git a/scripts/ci/docker-demo-smoke.sh b/scripts/ci/docker-demo-smoke.sh index 430d08821..f5dfcfbc5 100755 --- a/scripts/ci/docker-demo-smoke.sh +++ b/scripts/ci/docker-demo-smoke.sh @@ -1,18 +1,17 @@ #!/usr/bin/env bash -# Smoke-test docker-compose.demo.yml (Coolify-shaped demo stack). +# Smoke-test docker/docker-compose.demo.yml (Coolify-shaped demo stack). set -euo pipefail ROOT="$(cd "$(dirname "$0")/../.." && pwd)" cd "$ROOT" IMAGE="${MESHCHAT_DOCKER_DEMO_IMAGE:-reticulum-meshchatx:local}" -COMPOSE_FILE="docker-compose.demo.yml" +COMPOSE_FILE="docker/docker-compose.demo.yml" OVERRIDE_FILE="${TMPDIR:-/tmp}/meshchatx-demo-smoke-ports.yml" -KEY="${MESHCHAT_ALTCHA_HMAC_KEY:-demo-smoke-hmac-key-change-me}" STATUS_JSON="${TMPDIR:-/tmp}/meshchatx-demo-smoke-status.json" cleanup() { - MESHCHAT_IMAGE="$IMAGE" MESHCHAT_ALTCHA_HMAC_KEY="$KEY" \ + MESHCHAT_IMAGE="$IMAGE" \ docker compose -f "$COMPOSE_FILE" -f "$OVERRIDE_FILE" down >/dev/null 2>&1 || true rm -f "$OVERRIDE_FILE" "$STATUS_JSON" } @@ -27,10 +26,10 @@ EOF if ! docker image inspect "$IMAGE" >/dev/null 2>&1; then echo "Building $IMAGE..." - docker build -f Dockerfile -t "$IMAGE" . + docker build -f docker/Dockerfile -t "$IMAGE" . fi -MESHCHAT_IMAGE="$IMAGE" MESHCHAT_ALTCHA_HMAC_KEY="$KEY" \ +MESHCHAT_IMAGE="$IMAGE" \ docker compose -f "$COMPOSE_FILE" -f "$OVERRIDE_FILE" up -d --pull never demo_status_ready() { @@ -38,7 +37,7 @@ demo_status_ready() { python3 -c ' import json,sys d=json.load(open(sys.argv[1], encoding="utf-8")) -sys.exit(0 if d.get("demo_mode") and d.get("altcha_enabled") else 1) +sys.exit(0 if d.get("demo_mode") else 1) ' "$STATUS_JSON" } @@ -55,7 +54,6 @@ python3 -c ' import json,sys d=json.load(open(sys.argv[1], encoding="utf-8")) assert d.get("demo_mode") is True -assert d.get("altcha_enabled") is True print("demo status ok", d.get("status")) ' "$STATUS_JSON" diff --git a/scripts/ci/docker-electron-wine-entry.sh b/scripts/ci/docker-electron-wine-entry.sh index 1a81f5304..a766dd722 100755 --- a/scripts/ci/docker-electron-wine-entry.sh +++ b/scripts/ci/docker-electron-wine-entry.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Run inside Dockerfile.electron-wine (electronuserland/builder + Wine Python). +# Run inside docker/Dockerfile.electron-wine (electronuserland/builder + Wine Python). # Builds Windows Electron portable/NSIS via electron-builder under Wine. # # Env: diff --git a/scripts/ci/docker-hardened-smoke.sh b/scripts/ci/docker-hardened-smoke.sh index 2c8809e5e..441d321f9 100755 --- a/scripts/ci/docker-hardened-smoke.sh +++ b/scripts/ci/docker-hardened-smoke.sh @@ -10,11 +10,14 @@ RUN_CONTAINER="${MESHCHAT_DOCKER_RUN_SMOKE_CONTAINER:-meshchatx-hardened-run-tes RUN_PORT="${MESHCHAT_DOCKER_RUN_SMOKE_PORT:-18081}" TIMEOUT_SEC="${MESHCHAT_DOCKER_SMOKE_TIMEOUT:-180}" CONFIG_DIR="${MESHCHAT_DOCKER_SMOKE_CONFIG:-$(mktemp -d)}" +COMPOSE_FILE="docker/docker-compose.yml" +COMPOSE_DEV_FILE="docker/docker-compose.dev.yml" +COMPOSE_COOLIFY_FILE="docker/docker-compose.coolify.yml" cleanup() { - docker compose -f docker-compose.yml down >/dev/null 2>&1 || true - docker compose -f docker-compose.dev.yml down >/dev/null 2>&1 || true - docker compose -f docker-compose.coolify.yml down >/dev/null 2>&1 || true + docker compose -f "$COMPOSE_FILE" down >/dev/null 2>&1 || true + docker compose -f "$COMPOSE_DEV_FILE" down >/dev/null 2>&1 || true + docker compose -f "$COMPOSE_COOLIFY_FILE" down >/dev/null 2>&1 || true docker rm -f "$RUN_CONTAINER" >/dev/null 2>&1 || true if [ "${MESHCHAT_DOCKER_SMOKE_KEEP_CONFIG:-0}" != "1" ] && [ -n "${TMP_CONFIG_CREATED:-}" ]; then rm -rf "$CONFIG_DIR" @@ -62,26 +65,26 @@ wait_container_healthy() { } echo "Building Docker image ${IMAGE}..." -docker build -f Dockerfile -t "$IMAGE" . +docker build -f docker/Dockerfile -t "$IMAGE" . -echo "=== docker-compose.yml (hardened) ===" -MESHCHAT_IMAGE="$IMAGE" docker compose -f docker-compose.yml up -d --pull never +echo "=== docker/docker-compose.yml (hardened) ===" +MESHCHAT_IMAGE="$IMAGE" docker compose -f "$COMPOSE_FILE" up -d --pull never wait_container_healthy reticulum-meshchatx wait_https 8000 >/dev/null -docker compose -f docker-compose.yml down +docker compose -f "$COMPOSE_FILE" down -echo "=== docker-compose.dev.yml (hardened) ===" -docker compose -f docker-compose.dev.yml up -d --pull never +echo "=== docker/docker-compose.dev.yml (hardened) ===" +docker compose -f "$COMPOSE_DEV_FILE" up -d --pull never wait_container_healthy reticulum-meshchatx wait_https 8000 >/dev/null -docker compose -f docker-compose.dev.yml down +docker compose -f "$COMPOSE_DEV_FILE" down -echo "=== docker-compose.coolify.yml (hardened) ===" -MESHCHAT_IMAGE="$IMAGE" docker compose -f docker-compose.coolify.yml up -d --pull never -coolify_cid=$(docker compose -f docker-compose.coolify.yml ps -q meshchatx) +echo "=== docker/docker-compose.coolify.yml (hardened) ===" +MESHCHAT_IMAGE="$IMAGE" docker compose -f "$COMPOSE_COOLIFY_FILE" up -d --pull never +coolify_cid=$(docker compose -f "$COMPOSE_COOLIFY_FILE" ps -q meshchatx) wait_container_healthy "$coolify_cid" docker exec "$coolify_cid" python -c "import urllib.request; print(urllib.request.urlopen('http://127.0.0.1:8000/api/v1/status').read().decode())" >/dev/null -docker compose -f docker-compose.coolify.yml down +docker compose -f "$COMPOSE_COOLIFY_FILE" down echo "=== docker run (hardened) ===" docker run -d --name "$RUN_CONTAINER" \ diff --git a/scripts/ci/docker-runtime-smoke.sh b/scripts/ci/docker-runtime-smoke.sh index 2937c9f2b..b5c47eacb 100755 --- a/scripts/ci/docker-runtime-smoke.sh +++ b/scripts/ci/docker-runtime-smoke.sh @@ -27,7 +27,7 @@ if [ -z "${MESHCHAT_DOCKER_SMOKE_CONFIG:-}" ]; then fi echo "Building Docker image ${IMAGE}..." -docker build -f Dockerfile -t "$IMAGE" . +docker build -f docker/Dockerfile -t "$IMAGE" . cleanup mkdir -p "$CONFIG_DIR" diff --git a/scripts/ci/github-apt-linux-packaging.sh b/scripts/ci/github-apt-linux-packaging.sh index 616c8c8e1..c9725544b 100755 --- a/scripts/ci/github-apt-linux-packaging.sh +++ b/scripts/ci/github-apt-linux-packaging.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# APT packages needed for Linux Electron packaging (AppImage, deb, rpm, apk) on Debian/Ubuntu or in Dockerfile.build (root). +# APT packages needed for Linux Electron packaging (AppImage, deb, rpm, apk) on Debian/Ubuntu or in docker/Dockerfile.build (root). set -euo pipefail # shellcheck source=priv.sh disable=SC1091 diff --git a/scripts/ci/wine-python.sh b/scripts/ci/wine-python.sh index e17efcb7d..e43e373d0 100644 --- a/scripts/ci/wine-python.sh +++ b/scripts/ci/wine-python.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # Run Windows Python under Wine with a PATH that cx_Freeze can read. -# Used by Dockerfile.electron-wine / task docker:dist:win. +# Used by docker/Dockerfile.electron-wine / task docker:dist:win. set -euo pipefail export WINEDEBUG="${WINEDEBUG:--all}" diff --git a/scripts/docker/electron-wine-build.sh b/scripts/docker/electron-wine-build.sh index bf2a44538..189fc6486 100755 --- a/scripts/docker/electron-wine-build.sh +++ b/scripts/docker/electron-wine-build.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Host helper: build MeshChatX Windows Electron artifacts with Dockerfile.electron-wine. +# Host helper: build MeshChatX Windows Electron artifacts with docker/Dockerfile.electron-wine. # Optional path. GitHub Actions windows-latest remains the release default. set -euo pipefail @@ -7,7 +7,7 @@ ROOT="$(cd "$(dirname "$0")/../.." && pwd)" cd "$ROOT" IMAGE="${MESHCHATX_ELECTRON_WINE_IMAGE:-meshchatx-electron-wine:local}" -FILE="${MESHCHATX_ELECTRON_WINE_DOCKERFILE:-Dockerfile.electron-wine}" +FILE="${MESHCHATX_ELECTRON_WINE_DOCKERFILE:-docker/Dockerfile.electron-wine}" TARGETS="${MESHCHATX_ELECTRON_WINE_TARGETS:-win}" OUT_DIR="${MESHCHATX_ELECTRON_WINE_OUT:-./meshchatx-artifacts-win}" BASE_IMAGE="${ELECTRON_BUILDER_IMAGE:-electronuserland/builder:24-wine-05.26}" diff --git a/tests/backend/api_json_contract_schemas.py b/tests/backend/api_json_contract_schemas.py index a21e53219..c6481b03f 100644 --- a/tests/backend/api_json_contract_schemas.py +++ b/tests/backend/api_json_contract_schemas.py @@ -201,7 +201,6 @@ _DEMO_PUBLIC_STATUS_FIELDS: dict = { "demo_mode": {"type": "boolean"}, - "altcha_enabled": {"type": "boolean"}, "auth_page_hint": {"type": ["string", "null"]}, } @@ -381,23 +380,6 @@ "additionalProperties": False, } -ALTCHA_CHALLENGE_SCHEMA: dict = { - "type": "object", - "required": ["parameters", "signature"], - "properties": { - "parameters": { - "type": "object", - "required": ["algorithm"], - "properties": { - "algorithm": {"type": "string"}, - }, - "additionalProperties": True, - }, - "signature": {"type": "string"}, - }, - "additionalProperties": True, -} - TELEPHONE_VOICEMAIL_STATUS_SCHEMA: dict = { "type": "object", "required": [ diff --git a/tests/backend/conftest.py b/tests/backend/conftest.py index 2dc909057..c2ba77304 100644 --- a/tests/backend/conftest.py +++ b/tests/backend/conftest.py @@ -335,7 +335,6 @@ def __init__(self, *args, **kwargs): app.rrc_manager.set_database(app.database) app.websocket_broadcast = MagicMock(side_effect=lambda data: None) app.demo_mode = False - app.altcha_enabled = False _stub_map_data_manager(app) yield app diff --git a/tests/backend/fixtures/backend_module_ownership.json b/tests/backend/fixtures/backend_module_ownership.json index f080944e9..98ea832f5 100644 --- a/tests/backend/fixtures/backend_module_ownership.json +++ b/tests/backend/fixtures/backend_module_ownership.json @@ -31,7 +31,7 @@ "http_route_module": ["http/routes/messages.py", "lxmf.py"], "ws_module": ["http/ws/dispatch.py"], "primary_tests": [], - "frontend_page": ["messages/MessagesPage.vue", "ConversationViewer.vue"] + "frontend_page": ["features/messages/MessagesPage.svelte", "components/ConversationViewer.svelte"] }, { "domain": "Telephone / LXST", diff --git a/tests/backend/fixtures/http_api_routes.json b/tests/backend/fixtures/http_api_routes.json index 9d5494f56..9799f559f 100644 --- a/tests/backend/fixtures/http_api_routes.json +++ b/tests/backend/fixtures/http_api_routes.json @@ -48,10 +48,6 @@ "method": "POST", "path": "/api/v1/app/tutorial/seen" }, - { - "method": "GET", - "path": "/api/v1/auth/altcha/challenge" - }, { "method": "GET", "path": "/api/v1/auth/csrf" diff --git a/tests/backend/http_api_response_registry.py b/tests/backend/http_api_response_registry.py index e65ed06d9..79da9daf7 100644 --- a/tests/backend/http_api_response_registry.py +++ b/tests/backend/http_api_response_registry.py @@ -7,7 +7,6 @@ import re from tests.backend.api_json_contract_schemas import ( - ALTCHA_CHALLENGE_SCHEMA, API_V1_APP_INFO_ENVELOPE_SCHEMA, API_V1_STATUS_SCHEMA, AUTH_STATUS_SCHEMA, @@ -155,13 +154,6 @@ HttpJsonContract("GET", "/api/v1/app/changelog", CHANGELOG_SCHEMA), HttpJsonContract("GET", "/api/v1/auth/status", AUTH_STATUS_SCHEMA), HttpJsonContract("GET", "/api/v1/auth/csrf", CSRF_ENVELOPE_SCHEMA), - HttpJsonContract( - "GET", - "/api/v1/auth/altcha/challenge", - ALTCHA_CHALLENGE_SCHEMA, - allow_statuses=(200, 404, 503), - alt_schemas=(ERROR_ENVELOPE_SCHEMA,), - ), HttpJsonContract("GET", "/api/v1/server/security", SERVER_SECURITY_SCHEMA), HttpJsonContract("GET", "/api/v1/config", CONFIG_ENVELOPE_SCHEMA), HttpJsonContract( diff --git a/tests/backend/test_altcha_oracle.py b/tests/backend/test_altcha_oracle.py deleted file mode 100644 index 3124bba15..000000000 --- a/tests/backend/test_altcha_oracle.py +++ /dev/null @@ -1,108 +0,0 @@ -# SPDX-License-Identifier: 0BSD - -"""Oracle: ALTCHA challenge round-trip and login rejection without payload.""" - -from __future__ import annotations - -import base64 -import json -import os -import time -from unittest.mock import patch - -import altcha -import bcrypt -import pytest -from aiohttp.test_utils import TestClient, TestServer - -from meshchatx.src.backend.altcha_auth import ( - ALTCHA_INVALID_CODE, - verify_altcha_submission, -) -from tests.backend.conftest import fetch_api_csrf_headers -from tests.backend.demo_http_support import build_test_aio_app - -_TEST_SECRET = "test-secret-key-32chars-minimum!!" - - -def _solved_payload_b64(secret: str) -> str: - challenge = altcha.create_challenge( - "PBKDF2/SHA-256", - 200, - hmac_secret=secret, - expires_at=int(time.time()) + 300, - ) - solution = altcha.solve_challenge(challenge, counter_step=1) - assert solution is not None - payload = altcha.Payload(challenge, solution) - raw = json.dumps(payload.to_dict()) - return base64.b64encode(raw.encode()).decode() - - -def test_altcha_verify_round_trip(): - with patch.dict( - os.environ, - {"MESHCHAT_ALTCHA_HMAC_KEY": _TEST_SECRET}, - clear=False, - ): - ok, code = verify_altcha_submission(_solved_payload_b64(_TEST_SECRET)) - assert ok is True - assert code is None - - -def test_altcha_rejects_tampered_payload(): - with patch.dict( - os.environ, - {"MESHCHAT_ALTCHA_HMAC_KEY": _TEST_SECRET}, - clear=False, - ): - bad = _solved_payload_b64(_TEST_SECRET)[:-4] + "XXXX" - ok, code = verify_altcha_submission(bad) - assert ok is False - assert code is not None - - -@pytest.mark.asyncio -async def test_login_without_altcha_when_enabled(mock_app): - mock_app.altcha_enabled = True - mock_app.demo_mode = False - mock_app.current_context.running = True - mock_app.config.auth_enabled.set(True) - password_hash = bcrypt.hashpw(b"secretpass", bcrypt.gensalt()).decode("utf-8") - mock_app.config.auth_password_hash.set(password_hash) - - env = { - "MESHCHAT_ALTCHA_ENABLED": "1", - "MESHCHAT_ALTCHA_HMAC_KEY": _TEST_SECRET, - } - aio_app = build_test_aio_app(mock_app) - async with TestClient(TestServer(aio_app)) as client: - with patch.dict(os.environ, env, clear=False): - headers = await fetch_api_csrf_headers(client) - response = await client.post( - "/api/v1/auth/login", - json={"password": "secretpass"}, - headers=headers, - ) - assert response.status == 400 - body = await response.json() - assert body.get("code") == ALTCHA_INVALID_CODE - - -@pytest.mark.asyncio -async def test_altcha_challenge_endpoint(mock_app): - mock_app.altcha_enabled = True - mock_app.current_context.running = True - env = { - "MESHCHAT_ALTCHA_ENABLED": "1", - "MESHCHAT_ALTCHA_HMAC_KEY": _TEST_SECRET, - } - aio_app = build_test_aio_app(mock_app) - async with TestClient(TestServer(aio_app)) as client: - with patch.dict(os.environ, env, clear=False): - response = await client.get("/api/v1/auth/altcha/challenge") - assert response.status == 200 - data = await response.json() - assert "parameters" in data - assert "signature" in data - assert data["parameters"].get("algorithm") == "PBKDF2/SHA-256" diff --git a/tests/backend/test_demo_mode_http_oracle.py b/tests/backend/test_demo_mode_http_oracle.py index cf33f6200..696ff6f65 100644 --- a/tests/backend/test_demo_mode_http_oracle.py +++ b/tests/backend/test_demo_mode_http_oracle.py @@ -44,12 +44,10 @@ async def test_demo_mode_blocks_disallowed_mutations(mock_app, method, path): @pytest.mark.asyncio async def test_demo_mode_status_includes_flags(mock_app): mock_app.demo_mode = True - mock_app.altcha_enabled = True mock_app.current_context.running = True mock_app._network_ready = True payload = mock_app._startup_status_payload() assert payload["demo_mode"] is True - assert payload["altcha_enabled"] is True @given( diff --git a/tests/electron/electronShellAssets.test.js b/tests/electron/electronShellAssets.test.js index c902334ab..8ce119939 100644 --- a/tests/electron/electronShellAssets.test.js +++ b/tests/electron/electronShellAssets.test.js @@ -47,7 +47,7 @@ describe("electron shell static assets", () => { }); it("Dockerfile frontend stage copies electron shell CSS build inputs", () => { - for (const dockerfile of ["Dockerfile", "Dockerfile.hardened"]) { + for (const dockerfile of ["docker/Dockerfile", "docker/Dockerfile.hardened"]) { const docker = readRepo(dockerfile); expect(docker, dockerfile).toContain("scripts/build-electron-shell-css.mjs"); expect(docker, dockerfile).toContain("electron/assets/css/electron-shell.src.css"); diff --git a/tests/frontend/AudioWaveformPlayer.test.js b/tests/frontend/AudioWaveformPlayer.test.js index 689dd559e..e6215b7e6 100644 --- a/tests/frontend/AudioWaveformPlayer.test.js +++ b/tests/frontend/AudioWaveformPlayer.test.js @@ -1,8 +1,7 @@ -import { mount } from "@vue/test-utils"; -import { describe, it, expect, vi, beforeEach } from "vitest"; -import AudioWaveformPlayer from "../../meshchatx/src/frontend/components/messages/AudioWaveformPlayer.vue"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/svelte"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import AudioWaveformPlayer from "../../meshchatx/src/frontend/features/messages/components/AudioWaveformPlayer.svelte"; -// Mock AudioContext class MockAudioContext { constructor() { this.state = "suspended"; @@ -35,7 +34,6 @@ class MockAudioContext { } } -// Mock fetch global.fetch = vi.fn(() => Promise.resolve({ ok: true, @@ -43,7 +41,6 @@ global.fetch = vi.fn(() => }) ); -// Mock Canvas HTMLCanvasElement.prototype.getContext = vi.fn(() => ({ scale: vi.fn(), clearRect: vi.fn(), @@ -53,65 +50,36 @@ HTMLCanvasElement.prototype.getContext = vi.fn(() => ({ stroke: vi.fn(), })); -describe("AudioWaveformPlayer.vue", () => { +describe("AudioWaveformPlayer.svelte", () => { beforeEach(() => { vi.stubGlobal("AudioContext", MockAudioContext); vi.stubGlobal("webkitAudioContext", MockAudioContext); }); - it("renders and loads audio", async () => { - const wrapper = mount(AudioWaveformPlayer, { - props: { - src: "test-audio.wav", - }, - global: { - stubs: { - MaterialDesignIcon: true, - }, - }, - }); - - expect(wrapper.find(".audio-waveform-player").exists()).toBe(true); - - // Wait for audio to load - await vi.waitFor(() => expect(wrapper.vm.loading).toBe(false)); - - expect(wrapper.vm.totalDuration).toBe(10); - expect(wrapper.find("canvas").isVisible()).toBe(true); + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + vi.unstubAllGlobals(); }); - it("toggles playback", async () => { - const wrapper = mount(AudioWaveformPlayer, { - props: { - src: "test-audio.wav", - }, - global: { - stubs: { - MaterialDesignIcon: true, - }, - }, - }); - - await vi.waitFor(() => expect(wrapper.vm.loading).toBe(false)); - - const playButton = wrapper.find("button"); - await playButton.trigger("click"); - - expect(wrapper.vm.isPlaying).toBe(true); - expect(wrapper.emitted("play")).toBeTruthy(); - - await playButton.trigger("click"); - expect(wrapper.vm.isPlaying).toBe(false); + it("renders and loads audio", async () => { + const { container } = render(AudioWaveformPlayer, { props: { src: "test-audio.wav" } }); + expect(container.querySelector(".audio-waveform-player")).toBeTruthy(); + await waitFor(() => expect(screen.getByText("0:00 / 0:10")).toBeTruthy()); + expect(container.querySelector("canvas")?.hidden).toBe(false); }); - it("formats time correctly", () => { - const wrapper = mount(AudioWaveformPlayer, { - props: { src: "" }, - global: { stubs: { MaterialDesignIcon: true } }, - }); + it("starts playback", async () => { + const onplay = vi.fn(); + render(AudioWaveformPlayer, { props: { src: "test-audio.wav", onplay } }); + await waitFor(() => expect(screen.getByText("0:00 / 0:10")).toBeTruthy()); + const playButton = screen.getByRole("button"); + await fireEvent.click(playButton); + expect(onplay).toHaveBeenCalledOnce(); + }); - expect(wrapper.vm.formatTime(65)).toBe("1:05"); - expect(wrapper.vm.formatTime(10)).toBe("0:10"); - expect(wrapper.vm.formatTime(3600)).toBe("60:00"); + it("formats unloaded time", () => { + render(AudioWaveformPlayer, { props: { src: "" } }); + expect(screen.getByText("0:00 / 0:00")).toBeTruthy(); }); }); diff --git a/tests/frontend/AuthPage.test.js b/tests/frontend/AuthPage.test.js index e46a8d289..d54b0c984 100644 --- a/tests/frontend/AuthPage.test.js +++ b/tests/frontend/AuthPage.test.js @@ -16,7 +16,6 @@ const authI18n = { "auth.set_password": "Set Password", "auth.login": "Login", "auth.passwords_mismatch": "Passwords do not match", - "auth.altcha_required": "Complete the verification challenge first", "auth.status_check_failed": "Failed to check authentication status", "auth.failed": "Authentication failed", "app.demo_mode_active": "Demo mode active", @@ -63,11 +62,6 @@ describe("AuthPage.vue", () => { $router: routerMock, $t: (key) => authI18n[key] || key, }, - config: { - compilerOptions: { - isCustomElement: (tag) => tag === "altcha-widget", - }, - }, }, }); }; @@ -238,33 +232,6 @@ describe("AuthPage.vue", () => { }); }); - it("submits login with ALTCHA payload from the widget", async () => { - axiosMock.get.mockResolvedValue({ - data: { - auth_enabled: true, - authenticated: false, - password_set: true, - altcha_enabled: true, - }, - }); - - const wrapper = mountAuthPage(); - await wrapper.vm.$nextTick(); - await new Promise((resolve) => setTimeout(resolve, 0)); - await wrapper.vm.$nextTick(); - - expect(wrapper.vm.altchaEnabled).toBe(true); - wrapper.vm.password = "password123"; - wrapper.vm.altchaPayload = "altcha-token"; - await wrapper.vm.handleSubmit(); - await wrapper.vm.$nextTick(); - - expect(axiosMock.post).toHaveBeenCalledWith("/api/v1/auth/login", { - password: "password123", - altcha: "altcha-token", - }); - }); - it("reloads page after successful login", async () => { axiosMock.get.mockResolvedValueOnce({ data: { diff --git a/tests/frontend/BootLoadSmoothness.test.js b/tests/frontend/BootLoadSmoothness.test.js index 5dee61331..822f36856 100644 --- a/tests/frontend/BootLoadSmoothness.test.js +++ b/tests/frontend/BootLoadSmoothness.test.js @@ -44,11 +44,11 @@ describe("boot and load smoothness", () => { }); it("main.js defers splash removal and preloads critical routes", () => { - const main = readFileSync(resolve(ROOT, "meshchatx/src/frontend/main.js"), "utf8"); + const main = readFileSync(resolve(ROOT, "meshchatx/src/frontend/main.ts"), "utf8"); expect(main).toContain("removeBootSplash"); expect(main).toContain("requestAnimationFrame"); expect(main).toContain("preloadCriticalRouteChunks"); - expect(main).toContain('import("./components/messages/MessagesPage.vue")'); + expect(main).toContain('import("./features/messages/MessagesPage.svelte")'); expect(main).toContain("ElectronUtils.isElectron()"); expect(main).toContain("serviceWorkerRegisterOptions"); expect(main).toContain("decideControllerChangeReload"); @@ -58,7 +58,7 @@ describe("boot and load smoothness", () => { it("App.vue fades non-keepAlive route swaps on canvas background", () => { const app = readFileSync(resolve(ROOT, "meshchatx/src/frontend/components/App.vue"), "utf8"); - const themeEngine = readFileSync(resolve(ROOT, "meshchatx/src/frontend/theme/themeEngine.js"), "utf8"); + const themeEngine = readFileSync(resolve(ROOT, "meshchatx/src/frontend/theme/themeEngine.ts"), "utf8"); expect(app).toContain('name="route-view-fade"'); expect(app).toContain("bg-sem-canvas"); expect(themeEngine).toContain("setUiTheme"); diff --git a/tests/frontend/CJKTextOverflow.test.js b/tests/frontend/CJKTextOverflow.test.js index c729babef..3faae6138 100644 --- a/tests/frontend/CJKTextOverflow.test.js +++ b/tests/frontend/CJKTextOverflow.test.js @@ -28,7 +28,7 @@ describe("CJK Text Overflow UI CSS Tests", () => { const content = wrapper.find(".content"); - // Ensure the class attributes exactly match how we configure ConversationMessageEntry.vue + // Match the classes used by ConversationMessageEntry.svelte. expect(content.classes()).toContain("wrap-break-word"); expect(content.classes()).toContain("[word-break:break-word]"); expect(content.classes()).toContain("min-w-0"); diff --git a/tests/frontend/ContactsPage.test.js b/tests/frontend/ContactsPage.test.js index fc5f4ded0..7c644d8e8 100644 --- a/tests/frontend/ContactsPage.test.js +++ b/tests/frontend/ContactsPage.test.js @@ -1,13 +1,13 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { render, cleanup, waitFor, screen } from "@testing-library/svelte"; import ContactsPage from "@/features/contacts/ContactsPage.svelte"; -import { parseLxmaUri, extractDestinationHash } from "@/features/contacts/lib/contactUri.js"; -import { mergeContactsByName } from "@/features/contacts/lib/mergeContacts.js"; +import { parseLxmaUri, extractDestinationHash } from "@/features/contacts/lib/contactUri.ts"; +import { mergeContactsByName } from "@/features/contacts/lib/mergeContacts.ts"; import { addContactFromInput, exportContactsFile, importContactsList, -} from "@/features/contacts/lib/contactsActions.js"; +} from "@/features/contacts/lib/contactsActions.ts"; import WebSocketConnection from "@/js/WebSocketConnection"; import ToastUtils from "@/js/ToastUtils"; import { registerFallbackMessages, registerTranslator } from "@/js/i18n.js"; diff --git a/tests/frontend/ConversationDropDownMenu.shareApk.test.js b/tests/frontend/ConversationDropDownMenu.shareApk.test.js deleted file mode 100644 index e2e643e9e..000000000 --- a/tests/frontend/ConversationDropDownMenu.shareApk.test.js +++ /dev/null @@ -1,108 +0,0 @@ -// SPDX-License-Identifier: 0BSD - -import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; -import { mount } from "@vue/test-utils"; -import ConversationDropDownMenu from "../../meshchatx/src/frontend/components/messages/ConversationDropDownMenu.vue"; - -vi.mock("../../meshchatx/src/frontend/js/DialogUtils", () => ({ - default: { - confirm: vi.fn(async () => true), - alert: vi.fn(), - }, -})); - -vi.mock("../../meshchatx/src/frontend/js/GlobalEmitter", () => ({ - default: { - on: vi.fn(), - off: vi.fn(), - emit: vi.fn(), - }, -})); - -vi.mock("../../meshchatx/src/frontend/js/GlobalState", () => ({ - default: { - blockedDestinations: [], - config: { telemetry_enabled: false }, - }, -})); - -const shareApkMock = vi.fn(() => true); - -vi.mock("../../meshchatx/src/frontend/js/rnode/AndroidBridge.js", () => ({ - default: class AndroidBridge { - shareApk() { - return shareApkMock(); - } - }, -})); - -vi.mock("../../meshchatx/src/frontend/js/ToastUtils", () => ({ - default: { - error: vi.fn(), - success: vi.fn(), - }, -})); - -const peer = { - destination_hash: "a".repeat(32), - display_name: "Peer", -}; - -function mountMenu(compact = true) { - return mount(ConversationDropDownMenu, { - props: { peer, compact, hasFailedMessages: false }, - global: { - mocks: { $t: (k) => k }, - stubs: { - DropDownMenu: { - template: "
", - }, - DropDownMenuItem: { template: "" }, - IconButton: { - props: ["title"], - template: "", - }, - MaterialDesignIcon: true, - }, - }, - }); -} - -describe("ConversationDropDownMenu share APK", () => { - beforeEach(() => { - shareApkMock.mockReset().mockReturnValue(true); - delete window.MeshChatXAndroid; - }); - - afterEach(() => { - delete window.MeshChatXAndroid; - }); - - it("hides share APK when not on Android", () => { - const wrapper = mountMenu(true); - expect(wrapper.text()).not.toContain("messages.share_apk"); - wrapper.unmount(); - }); - - it("shows share APK on Android and opens share sheet", async () => { - window.MeshChatXAndroid = { - getPlatform: () => "android", - shareApk: vi.fn(), - }; - const wrapper = mountMenu(true); - expect(wrapper.text()).toContain("messages.share_apk"); - await wrapper.vm.onShareApk(); - expect(shareApkMock).toHaveBeenCalled(); - wrapper.unmount(); - }); - - it("shows share APK in non-compact Android overflow menu", () => { - window.MeshChatXAndroid = { - getPlatform: () => "android", - shareApk: vi.fn(), - }; - const wrapper = mountMenu(false); - expect(wrapper.text()).toContain("messages.share_apk"); - wrapper.unmount(); - }); -}); diff --git a/tests/frontend/ConversationMessageEntry.test.js b/tests/frontend/ConversationMessageEntry.test.js index 6ed498ec1..87398e892 100644 --- a/tests/frontend/ConversationMessageEntry.test.js +++ b/tests/frontend/ConversationMessageEntry.test.js @@ -1,262 +1,199 @@ -import { mount } from "@vue/test-utils"; -import { describe, it, expect, vi } from "vitest"; -import ConversationMessageEntry from "@/components/messages/ConversationMessageEntry.vue"; +// SPDX-License-Identifier: 0BSD -function makeCv(overrides = {}) { +import { afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen } from "@testing-library/svelte"; +import ConversationMessageEntry from "@/features/messages/components/ConversationMessageEntry.svelte"; + +function makeActions(overrides = {}) { return { - hasMessageBubble: () => true, - hasFileAttachments: () => false, - getParsedItems: () => null, + expandedMessageInfo: null, + audioAttachmentUrls: {}, + selectedPeer: null, + imageGroupSortedChron: (items) => items, + imageGroupGalleryUrls: () => [], + lxmfImageUrl: () => "", + pendingOutboundImageSrc: () => "", + openImage: vi.fn(), + onOutboundImageClick: vi.fn(), + onChatItemClick: vi.fn(), + onMessageContextMenu: vi.fn(), + downloadMessageImage: vi.fn(), + openReactionPicker: vi.fn(), + reactionReactorLabel: () => "", + replyToMessage: vi.fn(), + retrySendingMessage: vi.fn(), + cancelSendingMessage: vi.fn(), + deleteChatItem: vi.fn(), + showRawMessage: vi.fn(), + scrollToMessage: vi.fn(), + getRepliedMessage: () => null, + handleMessageClick: vi.fn(), + setBubbleMessageShowOriginal: vi.fn(), + copyOversizedMessageBody: vi.fn(), + downloadLxmfFileAttachment: vi.fn(), + addContact: vi.fn(), + ingestPaperMessage: vi.fn(), + openMapShareFromParsed: vi.fn(), + copyMapShareUri: vi.fn(), + openRelayShareFromParsed: vi.fn(), + copyRelayShareUri: vi.fn(), + viewLocationOnMap: vi.fn(), + toggleTracking: vi.fn(), bubbleViewModel: (item) => ({ kind: "html", textForRender: item?.lxmf_message?.content || "", singleEmoji: false, showFooter: false, }), - renderMarkdown: (text) => text, + renderMarkdown: (text) => text || "", + getParsedItems: () => ({}), + formatTimeAgo: () => "now", + formatDateDividerLabel: () => "", + formatAttachmentSize: () => "1 B", + getMessageInfoLines: () => [], + messageBodyCharCount: () => 0, bubbleMessageBodyFontSizePx: () => 14, + bubbleStyles: () => ({}), + isImageOnlyMessage: () => false, + hasMessageBubble: () => true, + hasFileAttachments: () => false, shouldHideAutoImageCaption: () => false, isMessageBodyTooLargeForDisplay: () => false, - messageBodyCharCount: () => 0, - formatTimeAgo: () => "now", - getMessageInfoLines: () => [], + isPaperMessageIngested: () => false, + isThemeOutboundBubble: () => false, + isOutboundWaitingBubble: () => false, + isOutboundPendingForUi: (item) => item?.lxmf_message?.state === "sending", + isOpportunisticDeferredDelivery: () => false, + showRichOutboundPendingUi: () => false, + canCancelOutboundSend: (item) => + Boolean(item?.is_outbound && ["sending", "outbound", "generating"].includes(item?.lxmf_message?.state)), + showOutboundTransferProgress: () => false, + outboundTransferProgressPercent: () => 0, + outboundSendingProgressLabel: () => "", + outboundTransferStatsLabel: () => "", + outboundBubbleStatusIconName: () => "check", + outboundBubbleStatusTitle: () => "", + outboundBubbleStatusHoverTitle: () => "", + outboundBubbleFailedTitle: () => "", outboundBubbleSurfaceClass: () => "bubble", outboundBubbleFooterTimeClass: () => "", - outboundEmbeddedCardClass: () => "", - outboundEmbeddedSecondaryTextClass: () => "", - outboundReplySnippetTitleClass: () => "", - outboundExpandedActionsShellClass: () => "", - outboundMessageMenuButtonClass: () => "", - outboundMessageMenuButtonHoverClass: () => "", outboundBubbleDeliveredIconClass: () => "", outboundBubbleSentCheckIconClass: () => "", - outboundSendingStatusIconClass: () => "", outboundBubblePendingCheckIconClass: () => "", - outboundSentStatusTitle: () => "", - outboundBubbleFailedTitle: () => "", - outboundBubbleStatusHoverTitle: () => "", - isOutboundPendingForUi: (item) => item?.lxmf_message?.state === "sending", - isOutboundWaitingBubble: () => false, - isOpportunisticDeferredDelivery: () => false, - isThemeOutboundBubble: () => false, - showRichOutboundPendingUi: () => false, - showOutboundTransferProgress: () => false, - canCancelOutboundSend: (item) => - item?.is_outbound && ["sending", "outbound", "generating"].includes(item?.lxmf_message?.state), - onChatItemClick: vi.fn((item) => { - item.is_actions_expanded = !item.is_actions_expanded; - }), - onMessageContextMenu: vi.fn(), - replyToMessage: vi.fn(), - deleteChatItem: vi.fn(), - showRawMessage: vi.fn(), - cancelSendingMessage: vi.fn(), - downloadLxmfFileAttachment: vi.fn(), - scrollToMessage: vi.fn(), - copyOversizedMessageBody: vi.fn(), - formatAttachmentSize: () => "1 B", - bubbleStyles: () => ({}), - isImageOnlyMessage: () => false, - isPaperMessageIngested: () => false, - ingestPaperMessage: vi.fn(), - pendingOutboundImageSrc: () => "", - onOutboundImageClick: vi.fn(), - openImage: vi.fn(), - imageGroupSortedChron: (items) => items, - imageGroupGalleryUrls: () => [], - lxmfImageUrl: () => "", - expandedMessageInfo: null, + outboundSendingStatusIconClass: () => "", + outboundExpandedActionsShellClass: () => "", + outboundMessageMenuButtonClass: () => "", + outboundMessageMenuButtonHoverClass: () => "", + outboundReplySnippetTitleClass: () => "", + outboundAttachmentCaptionClass: () => "", + outboundEmbeddedCardClass: () => "", + outboundEmbeddedSecondaryTextClass: () => "", ...overrides, }; } -describe("ConversationMessageEntry wiring", () => { - it("shows Cancel send in expanded actions while outbound message is sending", async () => { - const chatItem = { - type: "lxmf_message", - is_outbound: true, - is_actions_expanded: true, - lxmf_message: { - hash: "aa".repeat(16), - state: "sending", - progress: 10, - content: "hello", - destination_hash: "bb".repeat(16), - source_hash: "cc".repeat(16), - fields: {}, - }, - }; - const cv = makeCv(); - - const wrapper = mount(ConversationMessageEntry, { - props: { - entry: { type: "message", key: "m1", chatItem, showTimestamp: true }, - cv, - }, - global: { - mocks: { $t: (key) => key }, - stubs: { - MaterialDesignIcon: { template: "" }, - MessageReactionsOverlay: true, - OutboundTransferProgressFooter: true, - }, - }, - }); +function makeChatItem(overrides = {}) { + return { + type: "lxmf_message", + is_outbound: false, + is_actions_expanded: false, + lxmf_message: { + hash: "aa".repeat(16), + state: "delivered", + content: "hello", + destination_hash: "bb".repeat(16), + source_hash: "cc".repeat(16), + fields: {}, + }, + ...overrides, + }; +} - expect(wrapper.text()).toContain("messages.cancel_send"); +function renderEntry(chatItem, actions) { + return render(ConversationMessageEntry, { + entry: { type: "single", key: "message", chatItem, showTimestamp: true }, + actions, }); +} - it("clicking Cancel send calls cv.cancelSendingMessage", async () => { - const chatItem = { - type: "lxmf_message", +afterEach(cleanup); + +describe("ConversationMessageEntry.svelte", () => { + it("cancels an expanded outbound send", async () => { + const chatItem = makeChatItem({ is_outbound: true, is_actions_expanded: true, lxmf_message: { hash: "aa".repeat(16), state: "sending", content: "cancel me", - destination_hash: "bb".repeat(16), - source_hash: "cc".repeat(16), fields: {}, }, - }; - const cv = makeCv(); - - const wrapper = mount(ConversationMessageEntry, { - props: { - entry: { type: "message", key: "m2", chatItem, showTimestamp: true }, - cv, - }, - global: { - mocks: { $t: (key) => key }, - stubs: { - MaterialDesignIcon: { template: "" }, - MessageReactionsOverlay: true, - OutboundTransferProgressFooter: true, - }, - }, }); + const actions = makeActions(); + renderEntry(chatItem, actions); - const cancelBtn = wrapper.findAll("button").find((b) => b.text().includes("messages.cancel_send")); - expect(cancelBtn).toBeDefined(); - await cancelBtn.trigger("click"); - expect(cv.cancelSendingMessage).toHaveBeenCalledWith(chatItem); + await fireEvent.click(screen.getByRole("button", { name: "messages.cancel_send" })); + expect(actions.cancelSendingMessage).toHaveBeenCalledWith(chatItem); }); - it("file attachment row calls downloadLxmfFileAttachment instead of navigating", async () => { - const chatItem = { - type: "lxmf_message", - is_outbound: false, + it("downloads a file attachment through the viewer action", async () => { + const chatItem = makeChatItem({ lxmf_message: { hash: "dd".repeat(16), state: "delivered", content: "", - destination_hash: "bb".repeat(16), - source_hash: "cc".repeat(16), fields: { file_attachments: [{ file_name: "photo.jpg", file_size: 100 }], }, }, - }; - const cv = makeCv({ - hasFileAttachments: () => true, - }); - - const wrapper = mount(ConversationMessageEntry, { - props: { - entry: { type: "message", key: "m3", chatItem, showTimestamp: true }, - cv, - }, - global: { - mocks: { $t: (key) => key }, - stubs: { - MaterialDesignIcon: { template: "" }, - MessageReactionsOverlay: true, - OutboundTransferProgressFooter: true, - }, - }, }); + const actions = makeActions({ hasFileAttachments: () => true }); + renderEntry(chatItem, actions); - const fileBtn = wrapper.findAll("button").find((b) => b.text().includes("photo.jpg")); - expect(fileBtn).toBeDefined(); - expect(fileBtn.attributes("href")).toBeUndefined(); - await fileBtn.trigger("click"); - expect(cv.downloadLxmfFileAttachment).toHaveBeenCalledWith(chatItem, 0); + const button = screen.getByText("photo.jpg").closest("button"); + expect(button).toBeTruthy(); + expect(button.getAttribute("href")).toBeNull(); + await fireEvent.click(button); + expect(actions.downloadLxmfFileAttachment).toHaveBeenCalledWith(chatItem, 0); }); - it("shows ingest button for paper message and passes hash on click", async () => { + it("ingests a detected paper message with its message hash", async () => { const uri = "lxmf://deadbeefpaperpayload"; - const chatItem = { - type: "lxmf_message", - is_outbound: false, + const chatItem = makeChatItem({ lxmf_message: { hash: "ee".repeat(16), state: "delivered", content: uri, - destination_hash: "bb".repeat(16), - source_hash: "cc".repeat(16), fields: {}, }, - }; - const cv = makeCv({ - getParsedItems: () => ({ paperMessage: uri, isOnlyPaperMessage: true }), - isPaperMessageIngested: () => false, }); - const wrapper = mount(ConversationMessageEntry, { - props: { - entry: { type: "message", key: "m4", chatItem, showTimestamp: true }, - cv, - }, - global: { - mocks: { $t: (key) => key }, - stubs: { - MaterialDesignIcon: { template: "" }, - MessageReactionsOverlay: true, - OutboundTransferProgressFooter: true, - }, - }, + const actions = makeActions({ + getParsedItems: () => ({ paperMessage: uri, isOnlyPaperMessage: true }), }); - expect(wrapper.text()).toContain("messages.paper_message_detected"); - const ingestBtn = wrapper.findAll("button").find((b) => b.text().includes("messages.paper_message_ingest")); - expect(ingestBtn).toBeDefined(); - await ingestBtn.trigger("click"); - expect(cv.ingestPaperMessage).toHaveBeenCalledWith(uri, chatItem.lxmf_message.hash); + renderEntry(chatItem, actions); + + await fireEvent.click(screen.getByRole("button", { name: "messages.paper_message_ingest" })); + expect(actions.ingestPaperMessage).toHaveBeenCalledWith(uri, chatItem.lxmf_message.hash); }); - it("shows ingested paper state without ingest button", () => { + it("shows the ingested paper state without an ingest action", () => { const uri = "lxmf://alreadyingested"; - const chatItem = { - type: "lxmf_message", - is_outbound: false, + const chatItem = makeChatItem({ lxmf_message: { hash: "ff".repeat(16), state: "delivered", content: uri, - destination_hash: "bb".repeat(16), - source_hash: "cc".repeat(16), fields: {}, }, - }; - const cv = makeCv({ + }); + const actions = makeActions({ getParsedItems: () => ({ paperMessage: uri, isOnlyPaperMessage: true }), isPaperMessageIngested: () => true, }); - const wrapper = mount(ConversationMessageEntry, { - props: { - entry: { type: "message", key: "m5", chatItem, showTimestamp: true }, - cv, - }, - global: { - mocks: { $t: (key) => key }, - stubs: { - MaterialDesignIcon: { template: "" }, - MessageReactionsOverlay: true, - OutboundTransferProgressFooter: true, - }, - }, - }); - expect(wrapper.text()).toContain("messages.paper_message_ingested"); - const ingestBtn = wrapper.findAll("button").find((b) => b.text().trim() === "messages.paper_message_ingest"); - expect(ingestBtn).toBeUndefined(); + renderEntry(chatItem, actions); + + expect(screen.getByText("messages.paper_message_ingested")).toBeTruthy(); + expect(screen.queryByRole("button", { name: "messages.paper_message_ingest" })).toBeNull(); }); }); diff --git a/tests/frontend/ConversationMobileChrome.test.js b/tests/frontend/ConversationMobileChrome.test.js index 276a91f8d..666b40792 100644 --- a/tests/frontend/ConversationMobileChrome.test.js +++ b/tests/frontend/ConversationMobileChrome.test.js @@ -1,142 +1,30 @@ -import { mount } from "@vue/test-utils"; -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import ConversationPeerHeader from "../../meshchatx/src/frontend/components/messages/ConversationPeerHeader.vue"; -import ConversationDropDownMenu from "../../meshchatx/src/frontend/components/messages/ConversationDropDownMenu.vue"; -import GlobalState from "../../meshchatx/src/frontend/js/GlobalState.js"; +// SPDX-License-Identifier: 0BSD -const peer = { - destination_hash: "a".repeat(32), - display_name: "Test Peer", -}; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; -function mountHeader(props = {}) { - return mount(ConversationPeerHeader, { - props: { - selectedPeer: peer, - ...props, - }, - global: { - mocks: { $t: (key) => key }, - stubs: { - MaterialDesignIcon: true, - IconButton: { - template: '', - }, - DropDownMenu: { - template: - '
', - }, - DropDownMenuItem: { - template: '
', - }, - LxmfUserIcon: true, - ConversationDropDownMenu: { - name: "ConversationDropDownMenu", - props: ["peer", "compact", "hasFailedMessages", "pathfinderInProgress"], - template: - "
", - emits: [ - "path-finder-quick", - "path-finder-force", - "path-finder-drop", - "popout", - "conversation-deleted", - "set-custom-display-name", - "retry-failed", - "open-telemetry-history", - "start-call", - "share-contact", - ], - }, - }, - }, - }); -} - -function mountMenu(props = {}) { - return mount(ConversationDropDownMenu, { - props: { - peer, - ...props, - }, - global: { - mocks: { $t: (key) => key }, - stubs: { - MaterialDesignIcon: { - props: ["iconName"], - template: '', - }, - IconButton: { - template: '', - }, - DropDownMenu: { - template: - '
', - }, - DropDownMenuItem: { - emits: ["click"], - template: '
', - }, - }, - }, - }); +function source(relativePath) { + return readFileSync(resolve(process.cwd(), "meshchatx/src/frontend/features/messages", relativePath), "utf8"); } describe("conversation mobile chrome", () => { - beforeEach(() => { - GlobalState.blockedDestinations = []; - GlobalState.config = { telemetry_enabled: false }; - window.api = { - get: vi.fn().mockResolvedValue({ data: { is_contact: false } }), - }; - }); - - afterEach(() => { - delete window.api; - }); - - it("hides standalone path-ops icon on mobile compact header", () => { - const wrapper = mountHeader({ compactPeerActions: true }); - expect(wrapper.find('[data-testid="conversation-path-ops"]').exists()).toBe(false); - expect(wrapper.find('[data-testid="conversation-menu"]').attributes("data-compact")).toBe("1"); + it("keeps peer header actions compact on narrow layouts", () => { + const header = source("components/ConversationPeerHeader.svelte"); + expect(header).toContain("compactPeerActions"); + expect(header).toContain("sm:max-w-sm"); + expect(header).toContain("messages.more_actions"); }); - it("shows standalone path-ops icon on desktop header", () => { - const wrapper = mountHeader({ compactPeerActions: false }); - expect(wrapper.find('[data-testid="conversation-path-ops"]').exists()).toBe(true); - expect(wrapper.find('[data-testid="conversation-menu"]').attributes("data-compact")).toBe("0"); + it("keeps the mobile composer above the safe-area inset", () => { + const composer = source("components/ConversationComposer.svelte"); + expect(composer).toContain("env(safe-area-inset-bottom)"); + expect(composer).toContain("min-h-[44px]"); }); - it("puts path finder actions in compact 3-dots menu and hides popout", async () => { - const wrapper = mountMenu({ compact: true }); - await wrapper.vm.$nextTick(); - - expect(wrapper.find('[data-testid="path-finder-quick"]').exists()).toBe(true); - expect(wrapper.find('[data-testid="path-finder-force"]').exists()).toBe(true); - expect(wrapper.find('[data-testid="path-finder-drop"]').exists()).toBe(true); - expect(wrapper.text()).toContain("nomadnet.path_finder_quick_request"); - expect(wrapper.text()).not.toContain("messages.pop_out_chat"); - expect(wrapper.find('[data-testid="conversation-popout"]').exists()).toBe(false); - }); - - it("emits path finder events from compact menu", async () => { - const wrapper = mountMenu({ compact: true }); - await wrapper.vm.$nextTick(); - - await wrapper.find('[data-testid="path-finder-quick"]').trigger("click"); - await wrapper.find('[data-testid="path-finder-force"]').trigger("click"); - await wrapper.find('[data-testid="path-finder-drop"]').trigger("click"); - - expect(wrapper.emitted("path-finder-quick")).toHaveLength(1); - expect(wrapper.emitted("path-finder-force")).toHaveLength(1); - expect(wrapper.emitted("path-finder-drop")).toHaveLength(1); - }); - - it("keeps popout on desktop non-compact menu", async () => { - const wrapper = mountMenu({ compact: false }); - await wrapper.vm.$nextTick(); - - expect(wrapper.find('[data-testid="conversation-popout"]').exists()).toBe(true); - expect(wrapper.find('[data-testid="path-finder-quick"]').exists()).toBe(false); + it("uses a dynamic viewport-height compose sheet", () => { + const compose = source("components/MessagesMobileCompose.svelte"); + expect(compose).toContain("max-h-[90dvh]"); + expect(compose).not.toContain("max-h-[90vh]"); }); }); diff --git a/tests/frontend/ConversationPeerHeader.test.js b/tests/frontend/ConversationPeerHeader.test.js index 8df689de2..e01dbff5e 100644 --- a/tests/frontend/ConversationPeerHeader.test.js +++ b/tests/frontend/ConversationPeerHeader.test.js @@ -1,77 +1,39 @@ -import { mount } from "@vue/test-utils"; -import { describe, it, expect } from "vitest"; -import ConversationPeerHeader from "../../meshchatx/src/frontend/components/messages/ConversationPeerHeader.vue"; +// SPDX-License-Identifier: 0BSD -const peer = { - destination_hash: "a".repeat(32), - display_name: "Test Peer", -}; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen } from "@testing-library/svelte"; +import ConversationPeerHeader from "@/features/messages/components/ConversationPeerHeader.svelte"; -function mountHeader(props = {}) { - return mount(ConversationPeerHeader, { - props: { - selectedPeer: peer, - ...props, - }, - global: { - mocks: { $t: (key) => key }, - stubs: { - MaterialDesignIcon: true, - IconButton: true, - DropDownMenu: { - template: '
', - }, - DropDownMenuItem: true, - LxmfUserIcon: true, - ConversationDropDownMenu: { - name: "ConversationDropDownMenu", - props: ["peer", "compact", "hasFailedMessages", "pathfinderInProgress"], - template: '
', - }, - }, - }, - }); -} +afterEach(cleanup); -describe("ConversationPeerHeader.vue path row", () => { - it("shows hop count for a fresh path", () => { - const wrapper = mountHeader({ - selectedPeerPath: { hops: 2, next_hop: "b".repeat(32), next_hop_interface: "UDP" }, - peerPathSnapshot: { path: { hops: 2 }, path_stale: false, path_unresponsive: false }, - }); - expect(wrapper.text()).toContain("messages.hops_away"); - }); +describe("ConversationPeerHeader.svelte", () => { + const selectedPeer = { + destination_hash: "aa".repeat(16), + display_name: "Peer Name", + custom_display_name: null, + }; - it("shows no path when snapshot has no route", () => { - const wrapper = mountHeader({ - selectedPeerPath: null, - peerPathSnapshot: { path: null, path_stale: true, path_unresponsive: false }, - }); - expect(wrapper.text()).toContain("messages.path_no_route"); + it("renders the peer name and formatted destination", () => { + render(ConversationPeerHeader, { selectedPeer }); + expect(screen.getByText("Peer Name")).toBeTruthy(); + expect(document.body.textContent).toContain("aaaa"); }); - it("shows finding path while warming", () => { - const wrapper = mountHeader({ - peerPathWarming: true, - }); - expect(wrapper.text()).toContain("messages.outbound_pathfinding_short"); - }); + it("opens display-name editing through its callback", async () => { + const oneditdisplayname = vi.fn(); + render(ConversationPeerHeader, { selectedPeer, oneditdisplayname }); - it("marks stale paths in the label", () => { - const wrapper = mountHeader({ - selectedPeerPath: { hops: 1, next_hop: "b".repeat(32), next_hop_interface: "UDP" }, - peerPathSnapshot: { path: { hops: 1 }, path_stale: true, path_unresponsive: false }, - }); - expect(wrapper.text()).toContain("messages.path_stale_label"); + await fireEvent.click(screen.getByRole("button", { name: /Peer Name/ })); + expect(oneditdisplayname).toHaveBeenCalledOnce(); }); - it("hides path-ops icon when compactPeerActions is true", () => { - const wrapper = mountHeader({ compactPeerActions: true }); - expect(wrapper.find('[data-testid="conversation-path-ops"]').exists()).toBe(false); - }); + it("shows missing path state", () => { + render(ConversationPeerHeader, { + selectedPeer, + peerPathSnapshot: { path_stale: true, path_unresponsive: false }, + selectedPeerPath: null, + }); - it("shows path-ops icon when compactPeerActions is false", () => { - const wrapper = mountHeader({ compactPeerActions: false }); - expect(wrapper.find('[data-testid="conversation-path-ops"]').exists()).toBe(true); + expect(screen.getByText("messages.path_no_route")).toBeTruthy(); }); }); diff --git a/tests/frontend/ConversationViewer.outboundPropagationStatus.test.js b/tests/frontend/ConversationViewer.outboundPropagationStatus.test.js index 76c7a9149..5884841a3 100644 --- a/tests/frontend/ConversationViewer.outboundPropagationStatus.test.js +++ b/tests/frontend/ConversationViewer.outboundPropagationStatus.test.js @@ -1,299 +1,66 @@ -import { mount } from "@vue/test-utils"; -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import ConversationViewer from "@/components/messages/ConversationViewer.vue"; -import WebSocketConnection from "@/js/WebSocketConnection"; -import GlobalState from "@/js/GlobalState"; +// SPDX-License-Identifier: 0BSD + +import { describe, expect, it } from "vitest"; +import { + outboundBubbleStatusIconName, + outboundBubbleStatusTitleKey, +} from "@/js/outboundMessageStatus.js"; +import { + outboundStateIconName, + outboundStateTitle, + transferProgressPercent, +} from "@/features/messages/lib/conversationOutboundUi.ts"; +import { updateWsMessage } from "@/features/messages/lib/conversationViewerMessages.ts"; describe("ConversationViewer outbound propagation status", () => { - let axiosMock; - - beforeEach(() => { - GlobalState.config.theme = "light"; - GlobalState.config.message_outbound_bubble_color = "#4f46e5"; - GlobalState.config.message_waiting_bubble_color = "#e5e7eb"; - GlobalState.config.warn_on_stranger_links = true; - GlobalState.detailedOutboundSendStatus = true; - GlobalState.outboundTransferProgressEnabled = true; - - WebSocketConnection.connect(); - axiosMock = { - get: vi.fn().mockImplementation((url) => { - if (url.includes("/path")) return Promise.resolve({ data: { path: [] } }); - if (url.includes("/stamp-info")) return Promise.resolve({ data: { lxmf_stamp_info: {} } }); - if (url.includes("/signal-metrics")) return Promise.resolve({ data: { signal_metrics: {} } }); - return Promise.resolve({ data: {} }); - }), - post: vi.fn().mockResolvedValue({ data: {} }), - }; - window.api = axiosMock; - }); - - afterEach(() => { - GlobalState.detailedOutboundSendStatus = false; - GlobalState.outboundTransferProgressEnabled = true; - delete window.api; - WebSocketConnection.destroy(); - }); - - const mountViewer = (extraMocks = {}) => - mount(ConversationViewer, { - props: { - selectedPeer: { destination_hash: "peerhash111111111111111111111111", display_name: "Peer" }, - myLxmfAddressHash: "myhash11111111111111111111111111", - conversations: [], - }, - global: { - directives: { "click-outside": { mounted: () => {}, unmounted: () => {} } }, - mocks: { - $t: (key, params) => { - if (params && Object.keys(params).length) { - return `${key}:${JSON.stringify(params)}`; - } - return key; - }, - $route: { meta: {} }, - $router: { push: vi.fn() }, - ...extraMocks, - }, - stubs: { - MaterialDesignIcon: true, - AddImageButton: true, - AddAudioButton: true, - SendMessageButton: true, - ConversationDropDownMenu: true, - PaperMessageModal: true, - AudioWaveformPlayer: true, - LxmfUserIcon: true, - }, - }, - }); - - it("outboundSentStatusTitle uses propagation copy when method is propagated", () => { - const wrapper = mountViewer(); - expect(wrapper.vm.outboundSentStatusTitle({ method: "propagated", state: "sent" })).toBe( - "messages.outbound_on_propagation_node" - ); - expect(wrapper.vm.outboundSentStatusTitle({ method: "direct", state: "sent" })).toBe( - "messages.outbound_sent_network" + it("maps delivery method and state to status icons", () => { + expect(outboundBubbleStatusIconName({ method: "direct", state: "sent" })).toBe("check"); + expect(outboundBubbleStatusIconName({ method: "direct", state: "delivered" })).toBe("check-all"); + expect(outboundBubbleStatusIconName({ method: "propagated", state: "sent" })).toBe("email-outline"); + expect(outboundBubbleStatusIconName({ method: "paper", state: "delivered" })).toBe( + "note-check-outline" ); - expect(wrapper.vm.outboundSentStatusTitle(null)).toBe(""); }); - it("outboundBubbleStatusTitle uses delivered and method-aware copy", () => { - const wrapper = mountViewer(); - expect(wrapper.vm.outboundBubbleStatusTitle({ method: "direct", state: "delivered" })).toBe( - "messages.outbound_delivered" - ); - expect(wrapper.vm.outboundBubbleStatusTitle({ method: "propagated", state: "delivered" })).toBe( - "messages.outbound_delivered_propagated" - ); - expect(wrapper.vm.outboundBubbleStatusTitle({ method: "propagated", state: "sent" })).toBe( + it("maps propagation handoff to method-aware copy", () => { + expect(outboundBubbleStatusTitleKey({ method: "propagated", state: "sent" })).toBe( "messages.outbound_on_propagation_node" ); - expect(wrapper.vm.outboundBubbleStatusTitle({ method: "paper", state: "sent" })).toBe( - "messages.outbound_sent_network" - ); - expect(wrapper.vm.outboundBubbleStatusTitle(null)).toBe(""); - }); - - it("outboundBubbleStatusIconName maps method and state to icons", () => { - const wrapper = mountViewer(); - expect(wrapper.vm.outboundBubbleStatusIconName({ method: "direct", state: "sent" })).toBe("check"); - expect(wrapper.vm.outboundBubbleStatusIconName({ method: "direct", state: "delivered" })).toBe("check-all"); - expect(wrapper.vm.outboundBubbleStatusIconName({ method: "opportunistic", state: "sent" })).toBe("check"); - expect(wrapper.vm.outboundBubbleStatusIconName({ method: "opportunistic", state: "delivered" })).toBe( - "check-all" - ); - expect(wrapper.vm.outboundBubbleStatusIconName({ method: "propagated", state: "sent" })).toBe("email-outline"); - expect(wrapper.vm.outboundBubbleStatusIconName({ method: "propagated", state: "delivered" })).toBe( - "email-check-outline" - ); - expect(wrapper.vm.outboundBubbleStatusIconName({ method: "paper", state: "sent" })).toBe("note-outline"); - expect(wrapper.vm.outboundBubbleStatusIconName({ method: "paper", state: "delivered" })).toBe( - "note-check-outline" - ); - expect(wrapper.vm.outboundBubbleStatusIconName({ method: "direct", state: "unknown" })).toBe("check"); - expect(wrapper.vm.outboundBubbleStatusIconName(null)).toBe("check"); - }); - - it("outboundTransferProgressPercent and label track resource transfer", () => { - const wrapper = mountViewer(); - expect(wrapper.vm.outboundTransferProgressPercent({ state: "sending", progress: 42.5 })).toBe(43); - expect(wrapper.vm.outboundSendingProgressLabel({ state: "sending", progress: 42.5 })).toBe("43%"); - expect(wrapper.vm.outboundTransferProgressPercent({ state: "sending", progress: 0 })).toBe(0); - expect(wrapper.vm.outboundTransferProgressPercent({ state: "outbound", progress: 50 })).toBe(50); - expect(wrapper.vm.outboundTransferProgressPercent({ state: "outbound", progress: 0 })).toBeNull(); - expect(wrapper.vm.outboundTransferProgressPercent({ state: "sending", _pendingPathfinding: true })).toBeNull(); - expect(wrapper.vm.outboundSendingProgressLabel(null)).toBeNull(); - }); - - it("showOutboundTransferProgress respects the settings toggle", () => { - const wrapper = mountViewer(); - const message = { state: "sending", progress: 25 }; - expect(wrapper.vm.showOutboundTransferProgress(message)).toBe(true); - GlobalState.outboundTransferProgressEnabled = false; - expect(wrapper.vm.showOutboundTransferProgress(message)).toBe(false); - }); - - it("outboundTransferStatsLabel includes speed, hops, and elapsed time", () => { - const wrapper = mountViewer(); - const createdAt = new Date(Date.now() - 65000).toISOString(); - wrapper.vm.sendStatusUiMs = Date.now(); - const stats = wrapper.vm.outboundTransferStatsLabel( - { - state: "sending", - progress: 50, - content: "hello", - path_hops_at_send: 3, - created_at: createdAt, - }, - { - created_at: createdAt, - } + expect(outboundBubbleStatusTitleKey({ method: "direct", state: "delivered" })).toBe( + "messages.outbound_delivered" ); - expect(stats).toContain("B/s"); - expect(stats).not.toContain("bps"); - expect(stats).toContain("messages.transfer_progress_hops"); - expect(stats).toMatch(/1:0[45]/); - }); - - it("outboundSendingStatusTooltip uses propagation pending strings for propagated method", () => { - const wrapper = mountViewer(); - const withProgress = wrapper.vm.outboundSendingStatusTooltip({ - method: "propagated", - state: "sending", - progress: 40, - }); - expect(withProgress).toContain("messages.outbound_pending_propagation_with_progress"); - expect(withProgress).toContain('"progress":"40"'); - - const pending = wrapper.vm.outboundSendingStatusTooltip({ - method: "propagated", - state: "sending", - progress: 0, - }); - expect(pending).toBe("messages.outbound_pending_propagation"); - - expect( - wrapper.vm.outboundSendingStatusTooltip({ - method: "propagated", - state: "outbound", - }) - ).toBe("messages.outbound_pending_propagation"); }); - it("outboundBubbleStatusHoverTitle uses propagation pending for outbound state", () => { - const wrapper = mountViewer(); - expect( - wrapper.vm.outboundBubbleStatusHoverTitle({ - method: "propagated", - state: "outbound", - }) - ).toBe("messages.outbound_pending_propagation"); - }); - - it("outboundSendingStatusTooltip uses solving stamps copy when solving_stamps is set", () => { - const wrapper = mountViewer(); - expect( - wrapper.vm.outboundSendingStatusTooltip({ - state: "outbound", - solving_stamps: true, - }) - ).toBe("messages.outbound_solving_stamps"); - }); - - it("outboundBubbleStatusHoverTitle uses solving stamps short copy when solving_stamps is set", () => { - const wrapper = mountViewer(); - expect( - wrapper.vm.outboundBubbleStatusHoverTitle({ - state: "outbound", - solving_stamps: true, - }) - ).toBe("messages.outbound_solving_stamps_short"); - }); - - it("onLxmfMessageUpdated preserves solving_stamps when websocket omits the field", () => { - const wrapper = mountViewer(); - const hash = "abc123def456789012345678901234ab"; - wrapper.vm.chatItems = [ - { - type: "lxmf_message", - is_outbound: true, - lxmf_message: { - hash, - destination_hash: "peerhash111111111111111111111111", - state: "outbound", - solving_stamps: true, - content: "hi", - fields: {}, - }, - }, - ]; - - wrapper.vm.onLxmfMessageUpdated({ - hash, - state: "sending", - }); - - expect(wrapper.vm.chatItems[0].lxmf_message.solving_stamps).toBe(true); + it("normalizes generic progress and state labels", () => { + expect(transferProgressPercent(42.5, 100)).toBe(43); + expect(outboundStateTitle("failed")).toBeTruthy(); + expect(outboundStateIconName("delivered")).toBeTruthy(); }); - it("onLxmfMessageUpdated clears solving_stamps when websocket sets it false", () => { - const wrapper = mountViewer(); - const hash = "abc123def456789012345678901234ab"; - wrapper.vm.chatItems = [ + it("preserves existing websocket fields that an update omits", () => { + const current = [ { type: "lxmf_message", is_outbound: true, lxmf_message: { - hash, - destination_hash: "peerhash111111111111111111111111", + hash: "message", state: "outbound", solving_stamps: true, - content: "hi", - fields: {}, - }, - }, - ]; - - wrapper.vm.onLxmfMessageUpdated({ - hash, - state: "sending", - solving_stamps: false, - }); - - expect(wrapper.vm.chatItems[0].lxmf_message.solving_stamps).toBe(false); - }); - - it("onLxmfMessageUpdated preserves merged method when websocket sends propagated handoff", () => { - const wrapper = mountViewer(); - const hash = "abc123def456789012345678901234ab"; - wrapper.vm.chatItems = [ - { - type: "lxmf_message", - is_outbound: true, - lxmf_message: { - hash, - destination_hash: "peerhash111111111111111111111111", - state: "sending", method: "direct", - progress: 10, - content: "hi", - fields: {}, }, }, ]; - wrapper.vm.onLxmfMessageUpdated({ - hash, + const updated = updateWsMessage(current, { + hash: "message", state: "sent", method: "propagated", - progress: 100, }); - const row = wrapper.vm.chatItems[0].lxmf_message; - expect(row.state).toBe("sent"); - expect(row.method).toBe("propagated"); - expect(row.progress).toBe(100); + expect(updated[0].lxmf_message).toMatchObject({ + state: "sent", + method: "propagated", + solving_stamps: true, + }); }); }); diff --git a/tests/frontend/ConversationViewer.scroll.test.js b/tests/frontend/ConversationViewer.scroll.test.js index 579d54a73..c06e23cfe 100644 --- a/tests/frontend/ConversationViewer.scroll.test.js +++ b/tests/frontend/ConversationViewer.scroll.test.js @@ -1,273 +1,36 @@ -import { mount, flushPromises } from "@vue/test-utils"; -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import ConversationViewer from "@/components/messages/ConversationViewer.vue"; -import WebSocketConnection from "@/js/WebSocketConnection"; -import GlobalState from "@/js/GlobalState"; - -vi.mock("@/js/DialogUtils", () => ({ - default: { - confirm: vi.fn(() => Promise.resolve(true)), - alert: vi.fn(), - prompt: vi.fn(() => Promise.resolve(null)), - }, -})); - -function makeMessagesScrollTarget({ reverse, scrollTop, scrollHeight, clientHeight }) { - const outer = document.createElement("div"); - outer.setAttribute("data-message-list-mode", reverse ? "reverse" : "virtual"); - const inner = document.createElement("div"); - inner.style.flexDirection = reverse ? "column-reverse" : "column"; - outer.appendChild(inner); - document.body.appendChild(outer); - Object.defineProperty(outer, "scrollHeight", { value: scrollHeight, configurable: true }); - Object.defineProperty(outer, "clientHeight", { value: clientHeight, configurable: true }); - outer.scrollTop = scrollTop; - return outer; +// SPDX-License-Identifier: 0BSD + +import { describe, expect, it } from "vitest"; +import { + canTrustScrollNearBottomHeuristic, + isNearBottom, + shouldLoadPreviousMessages, +} from "@/features/messages/lib/conversationScroll.ts"; + +function scrollSurface(scrollTop, scrollHeight = 1000, clientHeight = 200) { + const element = document.createElement("div"); + element.appendChild(document.createElement("div")); + Object.defineProperty(element, "scrollHeight", { value: scrollHeight }); + Object.defineProperty(element, "clientHeight", { value: clientHeight }); + element.scrollTop = scrollTop; + return element; } -describe("ConversationViewer.vue scroll behavior", () => { - beforeEach(() => { - GlobalState.config.theme = "light"; - WebSocketConnection.connect(); - window.api = { - get: vi.fn().mockImplementation((url) => { - if (url.includes("/path")) return Promise.resolve({ data: { path: [] } }); - if (url.includes("/stamp-info")) return Promise.resolve({ data: { stamp_info: {} } }); - if (url.includes("/signal-metrics")) return Promise.resolve({ data: { signal_metrics: {} } }); - return Promise.resolve({ data: {} }); - }), - post: vi.fn().mockResolvedValue({ data: {} }), - }; - }); - - afterEach(() => { - delete window.api; - WebSocketConnection.destroy(); - }); - - const mountViewer = () => - mount(ConversationViewer, { - props: { - selectedPeer: { destination_hash: "abcdabcdabcdabcdabcdabcdabcdabcd", display_name: "Peer" }, - myLxmfAddressHash: "myhashmyhashmyhashmyhashmyhashmyha", - conversations: [], - }, - global: { - directives: { "click-outside": { mounted: () => {}, unmounted: () => {} } }, - mocks: { $t: (key) => key }, - stubs: { - MaterialDesignIcon: true, - AddImageButton: true, - AddAudioButton: true, - SendMessageButton: true, - ConversationDropDownMenu: true, - PaperMessageModal: true, - AudioWaveformPlayer: true, - LxmfUserIcon: true, - }, - }, - }); - - it("onMessagesScroll sets autoScrollOnNewMessage when near bottom (column-reverse)", () => { - const wrapper = mountViewer(); - const el = makeMessagesScrollTarget({ - reverse: true, - scrollTop: 0, - scrollHeight: 5000, - clientHeight: 100, - }); - wrapper.vm.onMessagesScroll({ target: el }); - expect(wrapper.vm.autoScrollOnNewMessage).toBe(true); - el.remove(); - }); - - it("onMessagesScroll clears autoScroll when not near bottom (column-reverse)", () => { - const wrapper = mountViewer(); - const el = makeMessagesScrollTarget({ - reverse: true, - scrollTop: 2000, - scrollHeight: 5000, - clientHeight: 100, - }); - wrapper.vm.onMessagesScroll({ target: el }); - expect(wrapper.vm.autoScrollOnNewMessage).toBe(false); - el.remove(); - }); - - it("onMessagesScroll calls loadPrevious when near older-history edge (column-reverse)", () => { - const wrapper = mountViewer(); - const spy = vi.spyOn(wrapper.vm, "loadPrevious").mockImplementation(() => {}); - const el = makeMessagesScrollTarget({ - reverse: true, - scrollTop: 4450, - scrollHeight: 5000, - clientHeight: 100, - }); - wrapper.vm.onMessagesScroll({ target: el }); - expect(spy).toHaveBeenCalledTimes(1); - wrapper.vm.onMessagesScroll({ target: el }); - expect(spy).toHaveBeenCalledTimes(1); - el.remove(); - }); - - it("onMessagesScroll does not hammer loadPrevious while held at older-history edge", () => { - const wrapper = mountViewer(); - const spy = vi.spyOn(wrapper.vm, "loadPrevious").mockImplementation(() => {}); - const el = makeMessagesScrollTarget({ - reverse: true, - scrollTop: 4450, - scrollHeight: 5000, - clientHeight: 100, - }); - wrapper.vm.onMessagesScroll({ target: el }); - wrapper.vm.onMessagesScroll({ target: el }); - wrapper.vm.onMessagesScroll({ target: el }); - expect(spy).toHaveBeenCalledTimes(1); - el.remove(); - }); - - it("runs resetStaleConversationScrollSurface after selectedPeer changes", async () => { - const peerA = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; - const peerB = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; - const wrapper = mount(ConversationViewer, { - props: { - selectedPeer: { destination_hash: peerA, display_name: "A" }, - myLxmfAddressHash: "myhashmyhashmyhashmyhashmyhashmyha", - conversations: [], - }, - global: { - directives: { "click-outside": { mounted: () => {}, unmounted: () => {} } }, - mocks: { $t: (key) => key }, - stubs: { - MaterialDesignIcon: true, - AddImageButton: true, - AddAudioButton: true, - SendMessageButton: true, - ConversationDropDownMenu: true, - PaperMessageModal: true, - AudioWaveformPlayer: true, - LxmfUserIcon: true, - ConversationPeerHeader: true, - ConversationMessageEntry: true, - ConversationMessageListVirtual: true, - TelemetryHistoryModal: true, - }, - }, - }); - const spy = vi.spyOn(wrapper.vm, "resetStaleConversationScrollSurface"); - await wrapper.setProps({ - selectedPeer: { destination_hash: peerB, display_name: "B" }, - }); - await flushPromises(); - await wrapper.vm.$nextTick(); - expect(spy).toHaveBeenCalled(); - }); - - it("onMessagesScroll away from bottom cancels pending scroll-to-bottom settle", async () => { - const wrapper = mountViewer(); - await flushPromises(); - - const el = makeMessagesScrollTarget({ - reverse: true, - scrollTop: 0, - scrollHeight: 5000, - clientHeight: 400, - }); - Object.defineProperty(wrapper.vm.$refs, "messagesScroll", { - configurable: true, - writable: true, - value: el, - }); - Object.defineProperty(wrapper.vm, "selectedPeerChatItems", { - configurable: true, - get: () => [{ type: "lxmf_message", lxmf_message: { hash: "aa".repeat(16) } }], - }); - - let currentScrollTop = 0; - Object.defineProperty(el, "scrollTop", { - configurable: true, - get: () => currentScrollTop, - set: (v) => { - currentScrollTop = v; - }, - }); - - // Drop any open-conversation settle/pin left from mount initialLoad. - wrapper.vm.cancelPendingScrollToBottom(); - - const cancelSpy = vi.spyOn(wrapper.vm, "cancelPendingScrollToBottom"); - wrapper.vm.autoScrollOnNewMessage = true; - const genAtStart = wrapper.vm.scrollBottomGen; - wrapper.vm.scrollMessagesToBottom({ pinAfter: true }); - expect(wrapper.vm.scrollBottomGen).toBeGreaterThan(genAtStart); - - // User leaves the newest edge while settle/pin is still active (attachment decode pending). - currentScrollTop = 1800; - wrapper.vm.onMessagesScroll({ target: el }); - - expect(wrapper.vm.autoScrollOnNewMessage).toBe(false); - expect(cancelSpy).toHaveBeenCalled(); - expect(wrapper.vm.conversationOpenPinUntil).toBe(0); - expect(wrapper.vm.openConversationScrollObserver).toBeNull(); - - const genAfterCancel = wrapper.vm.scrollBottomGen; - currentScrollTop = 1800; - await flushPromises(); - await wrapper.vm.$nextTick(); - await wrapper.vm.$nextTick(); - // Stale settle must not revive a newer generation or yank scrollTop back to 0. - expect(wrapper.vm.scrollBottomGen).toBe(genAfterCancel); - expect(currentScrollTop).toBe(1800); - - el.remove(); +describe("ConversationViewer scroll policy", () => { + it("loads previous messages near the top of normal flow", () => { + expect(shouldLoadPreviousMessages(scrollSurface(100))).toBe(true); + expect(shouldLoadPreviousMessages(scrollSurface(700))).toBe(false); }); - it("open pin refuses to start when user has left the newest edge", async () => { - const wrapper = mountViewer(); - await flushPromises(); - wrapper.vm.cancelPendingScrollToBottom(); - const el = makeMessagesScrollTarget({ - reverse: true, - scrollTop: 2200, - scrollHeight: 5000, - clientHeight: 400, - }); - Object.defineProperty(wrapper.vm.$refs, "messagesScroll", { - configurable: true, - writable: true, - value: el, - }); - wrapper.vm.autoScrollOnNewMessage = false; - const gen = wrapper.vm.scrollBottomGen; - wrapper.vm._startOpenConversationScrollPin(gen, () => gen !== wrapper.vm.scrollBottomGen); - expect(wrapper.vm.openConversationScrollObserver).toBeNull(); - expect(wrapper.vm.conversationOpenPinUntil).toBe(0); - el.remove(); + it("treats only the visual bottom as near bottom", () => { + expect(isNearBottom(scrollSurface(800))).toBe(true); + expect(isNearBottom(scrollSurface(100))).toBe(false); }); - it("cancelPendingScrollToBottom stales settle generation and disconnects open pin", async () => { - const wrapper = mountViewer(); - await flushPromises(); - const el = makeMessagesScrollTarget({ - reverse: true, - scrollTop: 0, - scrollHeight: 2000, - clientHeight: 400, - }); - Object.defineProperty(wrapper.vm.$refs, "messagesScroll", { - configurable: true, - writable: true, - value: el, - }); - const genBefore = wrapper.vm.scrollBottomGen; - wrapper.vm.conversationOpenPinUntil = Date.now() + 5000; - wrapper.vm.openConversationScrollObserver = { - disconnect: vi.fn(), - }; - wrapper.vm.cancelPendingScrollToBottom(); - expect(wrapper.vm.scrollBottomGen).toBeGreaterThan(genBefore); - expect(wrapper.vm.openConversationScrollObserver).toBeNull(); - expect(wrapper.vm.conversationOpenPinUntil).toBe(0); - el.remove(); + it("requires rendered content before trusting bottom detection", () => { + const empty = document.createElement("div"); + expect(canTrustScrollNearBottomHeuristic(empty)).toBe(false); + empty.appendChild(document.createElement("div")); + expect(canTrustScrollNearBottomHeuristic(empty)).toBe(true); }); }); diff --git a/tests/frontend/ConversationViewer.test.js b/tests/frontend/ConversationViewer.test.js index 8146037e7..9d3c12bbc 100644 --- a/tests/frontend/ConversationViewer.test.js +++ b/tests/frontend/ConversationViewer.test.js @@ -1,2746 +1,114 @@ -import { mount, flushPromises } from "@vue/test-utils"; -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import ConversationViewer from "@/components/messages/ConversationViewer.vue"; -import WebSocketConnection from "@/js/WebSocketConnection"; -import GlobalState from "@/js/GlobalState"; -import DialogUtils from "@/js/DialogUtils"; -import ToastUtils from "@/js/ToastUtils"; -import { MESSAGE_BODY_MAX_DISPLAY_CHARS } from "../../meshchatx/src/frontend/js/messageDisplayLimits.js"; -import DownloadUtils from "@/js/DownloadUtils"; -import GlobalEmitter from "@/js/GlobalEmitter"; -import NotificationUtils from "@/js/NotificationUtils"; - -vi.mock("@/js/DialogUtils", () => ({ - default: { - confirm: vi.fn(() => Promise.resolve(true)), - alert: vi.fn(), - prompt: vi.fn(() => Promise.resolve(null)), - }, -})); - -vi.mock("@/js/NotificationUtils", () => ({ - default: { - clearMessageNotifications: vi.fn(), - clearAllMessageNotifications: vi.fn(), - showNewMessageNotification: vi.fn(), - syncAndroidNotificationContext: vi.fn(), - }, -})); - -vi.mock("@/js/GlobalEmitter", () => ({ - default: { - on: vi.fn(), - off: vi.fn(), - emit: vi.fn(), - }, -})); - -describe("ConversationViewer.vue", () => { - let axiosMock; - - beforeEach(() => { - GlobalState.config.theme = "light"; - GlobalState.config.message_outbound_bubble_color = "#4f46e5"; - GlobalState.config.message_waiting_bubble_color = "#e5e7eb"; - GlobalState.config.warn_on_stranger_links = true; - WebSocketConnection.connect(); - axiosMock = { - get: vi.fn().mockImplementation((url) => { - if (url.includes("/path")) return Promise.resolve({ data: { path: [] } }); - if (url.includes("/stamp-info")) return Promise.resolve({ data: { stamp_info: {} } }); - if (url.includes("/signal-metrics")) return Promise.resolve({ data: { signal_metrics: {} } }); - return Promise.resolve({ data: {} }); - }), - post: vi.fn().mockResolvedValue({ data: {} }), - }; - window.api = axiosMock; - - // Mock localStorage - const localStorageMock = { - getItem: vi.fn(), - setItem: vi.fn(), - removeItem: vi.fn(), - }; - vi.stubGlobal("localStorage", localStorageMock); - - // Mock URL.createObjectURL - window.URL.createObjectURL = vi.fn(() => "mock-url"); - vi.spyOn(window, "open").mockImplementation(() => null); - - // Mock FileReader - const mockFileReader = { - readAsDataURL: vi.fn(function (blob) { - this.result = "data:image/png;base64,mock"; - this.onload({ target: { result: this.result } }); - }), - }; - vi.stubGlobal( - "FileReader", - vi.fn(function () { - return mockFileReader; - }) - ); - }); - - afterEach(() => { - delete window.api; - if (window.open?.mockRestore) { - window.open.mockRestore(); - } - vi.unstubAllGlobals(); - WebSocketConnection.destroy(); - }); - - const mountConversationViewer = (props = {}, extraMocks = {}) => { - return mount(ConversationViewer, { - props: { - selectedPeer: { destination_hash: "test-hash", display_name: "Test Peer" }, - myLxmfAddressHash: "my-hash", - conversations: [], - ...props, - }, - global: { - directives: { "click-outside": { mounted: () => {}, unmounted: () => {} } }, - mocks: { - $t: (key) => key, - $route: { meta: {} }, - $router: { push: vi.fn() }, - ...extraMocks, - }, - stubs: { - MaterialDesignIcon: true, - AddImageButton: true, - AddAudioButton: true, - SendMessageButton: true, - ConversationDropDownMenu: true, - PaperMessageModal: true, - AudioWaveformPlayer: true, - LxmfUserIcon: true, - }, - }, - }); +// SPDX-License-Identifier: 0BSD + +import { describe, expect, it } from "vitest"; +import { + applyWsMessage, + deleteWsMessage, + updateWsMessage, + visibleConversationItems, +} from "@/features/messages/lib/conversationViewerMessages.ts"; +import { + collectImageFilesFromDataTransfer, + hasMessageBubble, + isTelemetryOnly, +} from "@/features/messages/lib/conversationMessageHelpers.ts"; +import { loadDraft, saveDraft } from "@/features/messages/lib/conversationDrafts.ts"; + +const peerHash = "aa".repeat(16); +const myHash = "bb".repeat(16); + +function item(message) { + return { + type: "lxmf_message", + is_outbound: message.source_hash === myHash, + lxmf_message: { + hash: "message", + source_hash: peerHash, + destination_hash: myHash, + content: "hello", + fields: {}, + ...message, + }, }; +} - it("accepts a null selectedPeer without Vue Invalid prop warnings", async () => { - const warnings = []; - const warn = vi.spyOn(console, "warn").mockImplementation((...args) => { - warnings.push(args.map(String).join(" ")); - }); - const wrapper = mount(ConversationViewer, { - props: { - selectedPeer: null, - myLxmfAddressHash: "my-hash", - conversations: [], - }, - global: { - config: { - warnHandler(msg) { - warnings.push(String(msg)); - }, - }, - directives: { "click-outside": { mounted: () => {}, unmounted: () => {} } }, - mocks: { - $t: (key) => key, - $route: { meta: {} }, - $router: { push: vi.fn() }, - }, - stubs: { - MaterialDesignIcon: true, - AddImageButton: true, - AddAudioButton: true, - SendMessageButton: true, - ConversationDropDownMenu: true, - PaperMessageModal: true, - AudioWaveformPlayer: true, - LxmfUserIcon: true, - }, - }, - }); - await flushPromises(); - expect(wrapper.text()).toContain("messages.no_active_chat"); - const propWarns = warnings.filter((msg) => /Invalid prop/i.test(msg) && /selectedPeer/.test(msg)); - expect(propWarns).toEqual([]); - warn.mockRestore(); - wrapper.unmount(); - }); - - it("clears the thread and keeps the empty state when selectedPeer becomes null", async () => { - const wrapper = mountConversationViewer(); - await flushPromises(); - expect(wrapper.text()).not.toContain("messages.no_active_chat"); - await wrapper.setProps({ selectedPeer: null }); - await flushPromises(); - expect(wrapper.text()).toContain("messages.no_active_chat"); - wrapper.unmount(); - }); - - it("markConversationAsRead skips server call and reload when conversation is already read", async () => { - const wrapper = mountConversationViewer(); - await flushPromises(); - axiosMock.post.mockClear(); - - const conversation = { destination_hash: "read-hash", is_unread: false }; - await wrapper.vm.markConversationAsRead(conversation); - - const markCalls = axiosMock.post.mock.calls.filter((c) => String(c[0]).includes("/mark-as-read")); - expect(markCalls).toHaveLength(0); - expect(wrapper.emitted("reload-conversations")).toBeFalsy(); - }); - - it("markConversationAsRead force marks read even when local conversation looks already read", async () => { - const wrapper = mountConversationViewer(); - await flushPromises(); - axiosMock.post.mockClear(); - GlobalEmitter.emit.mockClear(); - NotificationUtils.clearMessageNotifications.mockClear(); - - const conversation = { destination_hash: "open-hash", is_unread: false }; - await wrapper.vm.markConversationAsRead(conversation, { force: true }); - await flushPromises(); - - expect(conversation.is_unread).toBe(false); - const markCalls = axiosMock.post.mock.calls.filter((c) => String(c[0]).includes("/mark-as-read")); - expect(markCalls).toHaveLength(1); - expect(GlobalEmitter.emit).toHaveBeenCalledWith("notifications-changed"); - expect(NotificationUtils.clearMessageNotifications).toHaveBeenCalledWith("open-hash"); - }); - - it("initialLoad force marks a slim restored peer that has no is_unread flag", async () => { - axiosMock.post.mockClear(); - NotificationUtils.clearMessageNotifications.mockClear(); - const slimPeer = { destination_hash: "restored-peer-hash", display_name: "Restored" }; - const wrapper = mountConversationViewer({ - selectedPeer: slimPeer, - conversations: [], +describe("ConversationViewer message contracts", () => { + it("shows selected-peer messages and excludes unrelated rows", () => { + const selected = item({ hash: "selected" }); + const unrelated = item({ + hash: "unrelated", + source_hash: "cc".repeat(16), + destination_hash: "dd".repeat(16), }); - await flushPromises(); - const markCalls = axiosMock.post.mock.calls.filter((c) => String(c[0]).includes("/mark-as-read")); - expect(markCalls.length).toBeGreaterThanOrEqual(1); - expect(markCalls[0][0]).toContain("restored-peer-hash"); - expect(NotificationUtils.clearMessageNotifications).toHaveBeenCalledWith("restored-peer-hash"); - wrapper.unmount(); + expect(visibleConversationItems([selected, unrelated], peerHash, true)).toEqual([selected]); }); - it("onLxmfMessageReceived force marks the open conversation as read", async () => { - const conversations = [{ destination_hash: "open-peer", is_unread: false }]; - const wrapper = mountConversationViewer({ - selectedPeer: { destination_hash: "open-peer", display_name: "Open" }, - conversations, + it("hides telemetry-only rows when telemetry display is disabled", () => { + const telemetry = item({ + content: "", + fields: { telemetry: { location: { latitude: 1, longitude: 2 } } }, }); - await flushPromises(); - axiosMock.post.mockClear(); - NotificationUtils.clearMessageNotifications.mockClear(); - - wrapper.vm.onLxmfMessageReceived({ - source_hash: "open-peer", - hash: "msg-1", - content: "hello", - timestamp: 1, - }); - await flushPromises(); - - const markCalls = axiosMock.post.mock.calls.filter((c) => String(c[0]).includes("/mark-as-read")); - expect(markCalls).toHaveLength(1); - expect(conversations[0].is_unread).toBe(false); - expect(NotificationUtils.clearMessageNotifications).toHaveBeenCalledWith("open-peer"); - }); - - it("markConversationAsRead marks read without reloading conversations when conversation is unread", async () => { - const wrapper = mountConversationViewer(); - await flushPromises(); - axiosMock.post.mockClear(); - GlobalEmitter.emit.mockClear(); - NotificationUtils.clearMessageNotifications.mockClear(); - const conversation = { destination_hash: "unread-hash", is_unread: true }; - await wrapper.vm.markConversationAsRead(conversation); - await flushPromises(); - - expect(conversation.is_unread).toBe(false); - const markCalls = axiosMock.post.mock.calls.filter((c) => String(c[0]).includes("/mark-as-read")); - expect(markCalls).toHaveLength(1); - expect(wrapper.emitted("reload-conversations")).toBeFalsy(); - expect(GlobalEmitter.emit).toHaveBeenCalledWith("notifications-changed"); - expect(NotificationUtils.clearMessageNotifications).toHaveBeenCalledWith("unread-hash"); + expect(isTelemetryOnly(telemetry.lxmf_message)).toBe(true); + expect(visibleConversationItems([telemetry], peerHash, false)).toEqual([]); }); - it("markConversationAsRead does not notify bell when server mark-as-read fails", async () => { - const wrapper = mountConversationViewer(); - await flushPromises(); - axiosMock.post.mockImplementation((url) => { - if (String(url).includes("/mark-as-read")) { - return Promise.reject(new Error("offline")); - } - return Promise.resolve({ data: {} }); - }); - GlobalEmitter.emit.mockClear(); - NotificationUtils.clearMessageNotifications.mockClear(); - - const conversation = { destination_hash: "unread-hash", is_unread: true }; - await wrapper.vm.markConversationAsRead(conversation); - await flushPromises(); + it("replaces a matching websocket row and deletes by hash", () => { + const current = [item({ hash: "target", state: "sending" })]; + const updated = updateWsMessage(current, { hash: "TARGET", state: "delivered" }); - expect(GlobalEmitter.emit).not.toHaveBeenCalledWith("notifications-changed"); - expect(NotificationUtils.clearMessageNotifications).not.toHaveBeenCalled(); - expect(conversation.is_unread).toBe(true); + expect(updated).not.toBe(current); + expect(updated[0].lxmf_message.state).toBe("delivered"); + expect(deleteWsMessage(updated, "target")).toEqual([]); }); - it("onMessagePaste adds images from clipboard and prevents default", async () => { - const wrapper = mountConversationViewer(); - const file = new File([""], "clip.png", { type: "image/png" }); - const items = [ - { - kind: "file", - type: "image/png", - getAsFile: () => file, - }, + it("replaces a pending outbound placeholder with its server message", () => { + const current = [ + item({ + hash: "pending-1", + source_hash: myHash, + destination_hash: peerHash, + state: "sending", + }), ]; - const event = { - preventDefault: vi.fn(), - clipboardData: { items }, - }; - wrapper.vm.onMessagePaste(event); - expect(event.preventDefault).toHaveBeenCalled(); - expect(wrapper.vm.newMessageImages).toHaveLength(1); - }); - - it("onMessagePaste ignores non-image clipboard files (e.g. PDF) and does not prevent default", () => { - const wrapper = mountConversationViewer(); - const file = new File([""], "doc.pdf", { type: "application/pdf" }); - const event = { - preventDefault: vi.fn(), - clipboardData: { - items: [ - { - kind: "file", - type: "application/pdf", - getAsFile: () => file, - }, - ], - }, - }; - wrapper.vm.onMessagePaste(event); - expect(event.preventDefault).not.toHaveBeenCalled(); - expect(wrapper.vm.newMessageImages).toHaveLength(0); - }); - - it("onMessagePaste does nothing when clipboard has no image file items", () => { - const wrapper = mountConversationViewer(); - const event = { - preventDefault: vi.fn(), - clipboardData: { - items: [{ kind: "string", type: "text/plain", getAsString: () => "hi" }], - }, - }; - wrapper.vm.onMessagePaste(event); - expect(event.preventDefault).not.toHaveBeenCalled(); - expect(wrapper.vm.newMessageImages).toHaveLength(0); - }); - - it("updates windowWidth on visualViewport resize and removes listeners on unmount", async () => { - const vvListeners = { resize: [], scroll: [] }; - const vv = { - addEventListener(ev, fn) { - if (ev === "resize") { - vvListeners.resize.push(fn); - } - if (ev === "scroll") { - vvListeners.scroll.push(fn); - } - }, - removeEventListener(ev, fn) { - if (ev === "resize") { - vvListeners.resize = vvListeners.resize.filter((f) => f !== fn); - } - if (ev === "scroll") { - vvListeners.scroll = vvListeners.scroll.filter((f) => f !== fn); - } - }, - }; - vi.stubGlobal("visualViewport", vv); - - Object.defineProperty(window, "innerWidth", { configurable: true, value: 300 }); - const wrapper = mountConversationViewer(); - await wrapper.vm.$nextTick(); - - Object.defineProperty(window, "innerWidth", { configurable: true, value: 700 }); - expect(vvListeners.resize.length).toBeGreaterThan(0); - vvListeners.resize[0](); - expect(wrapper.vm.windowWidth).toBe(700); - - vvListeners.scroll[0](); - expect(wrapper.vm.windowWidth).toBe(700); - - wrapper.unmount(); - expect(vvListeners.resize).toHaveLength(0); - expect(vvListeners.scroll).toHaveLength(0); - }); - - it("onMessagePaste adds multiple images from a single paste event", () => { - const wrapper = mountConversationViewer(); - const f1 = new File([""], "a.png", { type: "image/png" }); - const f2 = new File([""], "b.png", { type: "image/png" }); - const event = { - preventDefault: vi.fn(), - clipboardData: { - items: [ - { kind: "file", type: "image/png", getAsFile: () => f1 }, - { kind: "file", type: "image/png", getAsFile: () => f2 }, - ], - }, - }; - wrapper.vm.onMessagePaste(event); - expect(event.preventDefault).toHaveBeenCalled(); - expect(wrapper.vm.newMessageImages).toHaveLength(2); - }); - - it("onComposerImageDrop adds images from dataTransfer.files", () => { - const wrapper = mountConversationViewer(); - const file = new File([""], "photo.png", { type: "image/png" }); - const dataTransfer = { - files: [file], - items: [], - }; - const event = { - dataTransfer, - }; - wrapper.vm.onComposerImageDrop(event); - expect(wrapper.vm.newMessageImages).toHaveLength(1); - expect(wrapper.vm.composerImageDropActive).toBe(false); - }); - - it("warns before opening http links from strangers when enabled", async () => { - const wrapper = mountConversationViewer(); - wrapper.vm.isStrangerPeer = true; - GlobalState.config.warn_on_stranger_links = true; - DialogUtils.confirm.mockClear(); - window.open.mockClear(); - DialogUtils.confirm.mockResolvedValueOnce(true); - - const anchor = document.createElement("a"); - anchor.setAttribute("href", "https://example.com/path"); - const event = { target: anchor, preventDefault: vi.fn() }; - - await wrapper.vm.handleMessageClick(event); - - expect(event.preventDefault).toHaveBeenCalled(); - expect(DialogUtils.confirm).toHaveBeenCalledWith("messages.stranger_link_open_confirm"); - expect(window.open).toHaveBeenCalledWith("https://example.com/path", "_blank", "noopener,noreferrer"); - }); - - it("does not open stranger http link when warning confirm is rejected", async () => { - const wrapper = mountConversationViewer(); - wrapper.vm.isStrangerPeer = true; - GlobalState.config.warn_on_stranger_links = true; - DialogUtils.confirm.mockClear(); - window.open.mockClear(); - DialogUtils.confirm.mockResolvedValueOnce(false); - - const anchor = document.createElement("a"); - anchor.setAttribute("href", "https://example.com/path"); - const event = { target: anchor, preventDefault: vi.fn() }; - - await wrapper.vm.handleMessageClick(event); - - expect(event.preventDefault).toHaveBeenCalled(); - expect(DialogUtils.confirm).toHaveBeenCalled(); - expect(window.open).not.toHaveBeenCalled(); - }); - - it("opens stranger http link without prompt when warning is disabled", async () => { - const wrapper = mountConversationViewer(); - wrapper.vm.isStrangerPeer = true; - GlobalState.config.warn_on_stranger_links = false; - DialogUtils.confirm.mockClear(); - window.open.mockClear(); - - const anchor = document.createElement("a"); - anchor.setAttribute("href", "https://example.com/path"); - const event = { target: anchor, preventDefault: vi.fn() }; - - await wrapper.vm.handleMessageClick(event); - - expect(event.preventDefault).toHaveBeenCalled(); - expect(DialogUtils.confirm).not.toHaveBeenCalled(); - expect(window.open).toHaveBeenCalledWith("https://example.com/path", "_blank", "noopener,noreferrer"); - }); - - it("addStrangerAsContact uses window.api.post with CSRF-aware client", async () => { - const wrapper = mountConversationViewer({ - selectedPeer: { destination_hash: "abc123", display_name: "Stranger" }, - }); - axiosMock.get.mockImplementation((url) => { - if (url.includes("/telephone/contacts/check/")) { - return Promise.resolve({ data: {} }); - } - if (url.includes("/path")) return Promise.resolve({ data: { path: [] } }); - if (url.includes("/stamp-info")) return Promise.resolve({ data: { stamp_info: {} } }); - if (url.includes("/signal-metrics")) return Promise.resolve({ data: { signal_metrics: {} } }); - return Promise.resolve({ data: {} }); - }); - axiosMock.post.mockClear(); - vi.spyOn(ToastUtils, "success").mockImplementation(() => {}); - const fetchSpy = vi.spyOn(globalThis, "fetch"); - - await wrapper.vm.addStrangerAsContact(); - - expect(axiosMock.post).toHaveBeenCalledWith("/api/v1/telephone/contacts", { - name: "Stranger", - lxmf_address: "abc123", - }); - expect(fetchSpy).not.toHaveBeenCalledWith( - "/api/v1/telephone/contacts", - expect.objectContaining({ method: "POST" }) - ); - expect(wrapper.vm.isStrangerPeer).toBe(false); - expect(wrapper.vm.strangerBannerDismissed).toBe(true); - fetchSpy.mockRestore(); - }); - - it("blocks non-http href payloads like data urls in message anchors", async () => { - const wrapper = mountConversationViewer(); - wrapper.vm.isStrangerPeer = true; - GlobalState.config.warn_on_stranger_links = true; - DialogUtils.confirm.mockClear(); - window.open.mockClear(); - - const anchor = document.createElement("a"); - anchor.setAttribute("href", "data:image/png;base64,AAAA"); - const event = { target: anchor, preventDefault: vi.fn() }; - - await wrapper.vm.handleMessageClick(event); - - expect(event.preventDefault).toHaveBeenCalled(); - expect(DialogUtils.confirm).not.toHaveBeenCalled(); - expect(window.open).not.toHaveBeenCalled(); - }); - - it("blocks https-looking anchors that are not a single safe http(s) URL (LinkUtils)", async () => { - const wrapper = mountConversationViewer(); - wrapper.vm.isStrangerPeer = false; - window.open.mockClear(); - - const anchor = document.createElement("a"); - anchor.setAttribute("href", "https://example.com javascript:alert(1)"); - const event = { target: anchor, preventDefault: vi.fn() }; - - await wrapper.vm.handleMessageClick(event); - - expect(event.preventDefault).toHaveBeenCalled(); - expect(window.open).not.toHaveBeenCalled(); - }); - - it("blocks javascript: href in rendered message anchors", async () => { - const wrapper = mountConversationViewer(); - window.open.mockClear(); - - const anchor = document.createElement("a"); - anchor.setAttribute("href", "javascript:alert(1)"); - const event = { target: anchor, preventDefault: vi.fn() }; - - await wrapper.vm.handleMessageClick(event); - - expect(event.preventDefault).toHaveBeenCalled(); - expect(window.open).not.toHaveBeenCalled(); - }); - - it("does not router.push for nomadnet link when destination hash is not 32 hex chars", async () => { - const push = vi.fn(); - const wrapper = mountConversationViewer( - {}, - { - $router: { push }, - $route: { meta: { isPopout: false } }, - } - ); - - const a = document.createElement("a"); - a.className = "nomadnet-link"; - a.setAttribute("data-nomadnet-url", "not32hexchars:/page/index.mu"); - const event = { target: a, preventDefault: vi.fn() }; - - await wrapper.vm.handleMessageClick(event); - - expect(event.preventDefault).toHaveBeenCalled(); - expect(push).not.toHaveBeenCalled(); - }); - - it("router.push for valid nomadnet data-nomadnet-url", async () => { - const push = vi.fn(); - const wrapper = mountConversationViewer( - {}, - { - $router: { push }, - $route: { meta: { isPopout: false } }, - } - ); - const hash = "1dfeb0d794963579bd21ac8f153c77a4"; - const a = document.createElement("a"); - a.className = "nomadnet-link"; - a.setAttribute("data-nomadnet-url", `${hash}:/page/index.mu`); - const event = { target: a, preventDefault: vi.fn() }; - - await wrapper.vm.handleMessageClick(event); - - expect(push).toHaveBeenCalledWith({ - name: "nomadnetwork", - params: { destinationHash: hash }, - query: { path: "/page/index.mu" }, - }); - }); - - it("does not router.push for lxmf link when address is not 32 hex chars", async () => { - const push = vi.fn(); - const wrapper = mountConversationViewer( - {}, - { - $router: { push }, - $route: { meta: {} }, - } - ); - - const a = document.createElement("a"); - a.className = "lxmf-link"; - a.setAttribute("data-lxmf-address", "abcdabcdabcdabcdabcdabcdabcdab"); - const event = { target: a, preventDefault: vi.fn() }; - - await wrapper.vm.handleMessageClick(event); - - expect(event.preventDefault).toHaveBeenCalled(); - expect(push).not.toHaveBeenCalled(); - }); - it("router.push for valid lxmf data-lxmf-address", async () => { - const push = vi.fn(); - const wrapper = mountConversationViewer( - {}, + const result = applyWsMessage( + current, { - $router: { push }, - $route: { meta: {} }, - } - ); - const hash = "1dfeb0d794963579bd21ac8f153c77a4"; - const a = document.createElement("a"); - a.className = "lxmf-link"; - a.setAttribute("data-lxmf-address", hash); - const event = { target: a, preventDefault: vi.fn() }; - - await wrapper.vm.handleMessageClick(event); - - expect(push).toHaveBeenCalledWith({ - name: "messages", - params: { destinationHash: hash }, - }); - }); - - it("onComposerImageDrop ignores non-image files", () => { - const wrapper = mountConversationViewer(); - const pdf = new File([""], "doc.pdf", { type: "application/pdf" }); - const dataTransfer = { - files: [pdf], - items: [], - }; - const event = { dataTransfer }; - wrapper.vm.onComposerImageDrop(event); - expect(wrapper.vm.newMessageImages).toHaveLength(0); - }); - - it("collectImageFilesFromDataTransfer uses items when files has no images", () => { - const wrapper = mountConversationViewer(); - const file = new File([""], "x.png", { type: "image/png" }); - const pdf = new File([""], "only.pdf", { type: "application/pdf" }); - const dt = { - files: [pdf], - items: [ - { - kind: "file", - type: "image/png", - getAsFile: () => file, - }, - ], - }; - const got = wrapper.vm.collectImageFilesFromDataTransfer(dt); - expect(got).toHaveLength(1); - expect(got[0].name).toBe("x.png"); - }); - - it("onComposerImageDragOver sets highlight when not translating", () => { - const wrapper = mountConversationViewer(); - wrapper.vm.isTranslatingMessage = false; - const event = { - preventDefault: vi.fn(), - dataTransfer: {}, - }; - wrapper.vm.onComposerImageDragOver(event); - expect(wrapper.vm.composerImageDropActive).toBe(true); - }); - - it("onComposerImageDragOver does not highlight while translating", () => { - const wrapper = mountConversationViewer(); - wrapper.vm.isTranslatingMessage = true; - const event = { - preventDefault: vi.fn(), - dataTransfer: {}, - }; - wrapper.vm.onComposerImageDragOver(event); - expect(wrapper.vm.composerImageDropActive).toBe(false); - }); - - it("onComposerImageDragLeave clears highlight when leaving composer", () => { - const wrapper = mountConversationViewer(); - wrapper.vm.composerImageDropActive = true; - const outer = document.createElement("div"); - const inner = document.createElement("span"); - outer.appendChild(inner); - const event = { - currentTarget: outer, - relatedTarget: null, - }; - wrapper.vm.onComposerImageDragLeave(event); - expect(wrapper.vm.composerImageDropActive).toBe(false); - }); - - it("pasteFromClipboard inserts text at the message input selection", async () => { - const readText = vi.fn(() => Promise.resolve("pasted-text")); - vi.stubGlobal("navigator", { - ...navigator, - clipboard: { readText }, - }); - const prevSc = window.isSecureContext; - Object.defineProperty(window, "isSecureContext", { configurable: true, value: true }); - try { - const wrapper = mountConversationViewer(); - const ta = wrapper.find("#message-input").element; - ta.selectionStart = 0; - ta.selectionEnd = 0; - wrapper.vm.newMessageText = ""; - await wrapper.vm.pasteFromClipboard(); - expect(wrapper.vm.newMessageText).toBe("pasted-text"); - } finally { - Object.defineProperty(window, "isSecureContext", { configurable: true, value: prevSc }); - } - }); - - it("pasteFromClipboard toasts insecure context and does not call readText", async () => { - const readText = vi.fn(() => Promise.resolve("never")); - vi.stubGlobal("navigator", { - ...navigator, - clipboard: { readText }, - }); - const prevSc = window.isSecureContext; - Object.defineProperty(window, "isSecureContext", { configurable: true, value: false }); - const errSpy = vi.spyOn(ToastUtils, "error").mockImplementation(() => {}); - try { - const wrapper = mountConversationViewer(); - await wrapper.vm.pasteFromClipboard(); - expect(readText).not.toHaveBeenCalled(); - expect(errSpy).toHaveBeenCalledWith("messages.clipboard_read_requires_secure_context"); - } finally { - errSpy.mockRestore(); - Object.defineProperty(window, "isSecureContext", { configurable: true, value: prevSc }); - } - }); - - it("showRawMessage loads raw uri and keeps stored path fields from the message", async () => { - const peer = "a".repeat(32); - const msgHash = "b".repeat(32); - axiosMock.get.mockImplementation((url) => { - if (url.includes("/lxmf-messages/") && url.includes("/uri")) { - return Promise.resolve({ data: { uri: "lxmf://packed-uri" } }); - } - if (url.includes("/destination/") && url.includes("/path")) { - return Promise.resolve({ - data: { path: { hops: 99, next_hop_interface: "Should not use" } }, - }); - } - if (url.includes("/path")) return Promise.resolve({ data: { path: [] } }); - if (url.includes("/stamp-info")) return Promise.resolve({ data: { stamp_info: {} } }); - if (url.includes("/signal-metrics")) return Promise.resolve({ data: { signal_metrics: {} } }); - return Promise.resolve({ data: {} }); - }); - const wrapper = mountConversationViewer({ - selectedPeer: { destination_hash: peer, display_name: "Peer" }, - myLxmfAddressHash: "c".repeat(32), - }); - let guard = 0; - while (wrapper.vm.initialLoadActive && guard < 200) { - await flushPromises(); - await wrapper.vm.$nextTick(); - guard += 1; - } - const getCallsBefore = axiosMock.get.mock.calls.length; - await wrapper.vm.showRawMessage({ - lxmf_message: { - hash: msgHash, - source_hash: "d".repeat(32), - destination_hash: peer, - state: "delivered", - method: "direct", - content: "hi", - fields: {}, - id: 42, - path_hops_at_send: 3, - path_interface_at_send: "Default Interface", - }, - }); - expect(wrapper.vm.isRawMessageModalOpen).toBe(true); - expect(wrapper.vm.rawMessageData.raw_uri).toBe("lxmf://packed-uri"); - expect(wrapper.vm.rawMessageData.path_hops_at_send).toBe(3); - expect(wrapper.vm.rawMessageData.path_interface_at_send).toBe("Default Interface"); - const callsDuringRaw = axiosMock.get.mock.calls.slice(getCallsBefore); - const destinationPathCalls = callsDuringRaw.filter( - (c) => typeof c[0] === "string" && c[0].includes("/destination/") && c[0].includes("/path") - ); - expect(destinationPathCalls.length).toBe(0); - expect(callsDuringRaw.some((c) => c[0].includes("/uri"))).toBe(true); - }); - - it("isMessageBodyTooLargeForDisplay is true only above display limit", () => { - const wrapper = mountConversationViewer(); - const atLimit = { lxmf_message: { content: "x".repeat(MESSAGE_BODY_MAX_DISPLAY_CHARS) } }; - const over = { lxmf_message: { content: "x".repeat(MESSAGE_BODY_MAX_DISPLAY_CHARS + 1) } }; - expect(wrapper.vm.isMessageBodyTooLargeForDisplay(atLimit)).toBe(false); - expect(wrapper.vm.isMessageBodyTooLargeForDisplay(over)).toBe(true); - }); - - it("rawMessageJsonPreviewPretty replaces huge content for JSON details", async () => { - const wrapper = mountConversationViewer(); - const huge = "z".repeat(MESSAGE_BODY_MAX_DISPLAY_CHARS + 5000); - await wrapper.setData({ - rawMessageData: { id: 1, content: huge, hash: "b".repeat(32) }, - }); - const s = wrapper.vm.rawMessageJsonPreviewPretty; - expect(s).not.toContain(huge); - expect(s).toContain("Omitted"); - expect(s).toContain(String(huge.length)); - }); - - it("adds multiple images and renders previews", async () => { - const wrapper = mountConversationViewer(); - - const image1 = new File([""], "image1.png", { type: "image/png" }); - const image2 = new File([""], "image2.png", { type: "image/png" }); - - await wrapper.vm.onImageSelected(image1); - await wrapper.vm.onImageSelected(image2); - - expect(wrapper.vm.newMessageImages).toHaveLength(2); - expect(wrapper.vm.newMessageImageUrls).toHaveLength(2); - - // Check if previews are rendered - const previews = wrapper.findAll("img"); - expect(previews).toHaveLength(2); - }); - - it("removes an image attachment", async () => { - const wrapper = mountConversationViewer(); - - const image1 = new File([""], "image1.png", { type: "image/png" }); - await wrapper.vm.onImageSelected(image1); - - expect(wrapper.vm.newMessageImages).toHaveLength(1); - - await wrapper.vm.removeImageAttachment(0); - expect(wrapper.vm.newMessageImages).toHaveLength(0); - }); - - it("sends multiple images as separate messages", async () => { - const wrapper = mountConversationViewer(); - wrapper.vm.peerPathSnapshot = { - path: { hops: 1 }, - path_stale: false, - path_unresponsive: false, - }; - wrapper.vm.newMessageText = "Hello"; - - const image1 = new File([""], "image1.png", { type: "image/png" }); - const image2 = new File([""], "image2.png", { type: "image/png" }); - - // Mock arrayBuffer for files - image1.arrayBuffer = vi.fn(() => Promise.resolve(new ArrayBuffer(8))); - image2.arrayBuffer = vi.fn(() => Promise.resolve(new ArrayBuffer(8))); - - await wrapper.vm.onImageSelected(image1); - await wrapper.vm.onImageSelected(image2); - - axiosMock.post.mockImplementation((url) => { - if (typeof url === "string" && (url.includes("/request-path") || url.includes("/drop-path"))) { - return Promise.resolve({ data: {} }); - } - return Promise.resolve({ data: { lxmf_message: { hash: "mock-hash" } } }); - }); - - await wrapper.vm.sendMessage(); - await vi.waitFor(() => { - const sendCalls = axiosMock.post.mock.calls.filter((c) => c[0] === "/api/v1/lxmf-messages/send"); - expect(sendCalls.length).toBe(2); - }); - - const sendCalls = axiosMock.post.mock.calls.filter((c) => c[0] === "/api/v1/lxmf-messages/send"); - expect(sendCalls.length).toBe(2); - - expect(sendCalls[0][1]).toEqual( - expect.objectContaining({ - lxmf_message: expect.objectContaining({ - content: "Hello", - }), - }) - ); - - expect(sendCalls[1][1]).toEqual( - expect.objectContaining({ - lxmf_message: expect.objectContaining({ - content: "", - }), - }) - ); - }); - - it("auto-loads audio attachments on mount", async () => { - const chatItems = [ - { - lxmf_message: { - hash: "audio-hash", - fields: { - audio: { audio_mode: 0x10, audio_bytes: "base64-data" }, - }, - }, - }, - ]; - - axiosMock.get.mockResolvedValue({ - data: { lxmf_messages: chatItems.map((i) => i.lxmf_message) }, - }); - - const wrapper = mountConversationViewer({ - conversations: [], - }); - - // initialLoad is called on mount - await vi.waitFor(() => expect(axiosMock.get).toHaveBeenCalled()); - - // downloadAndDecodeAudio should be triggered by autoLoadAudioAttachments - await vi.waitFor(() => - expect(axiosMock.get).toHaveBeenCalledWith(expect.stringContaining("/audio"), expect.any(Object)) - ); - }); - - it("shows retry button in context menu for failed outbound messages", async () => { - const wrapper = mountConversationViewer(); - const failedChatItem = { - type: "lxmf_message", - is_outbound: true, - lxmf_message: { - hash: "failed-hash", - state: "failed", - content: "failed message", - destination_hash: "test-hash", - source_hash: "my-hash", - fields: {}, - }, - }; - wrapper.vm.chatItems = [failedChatItem]; - await wrapper.vm.$nextTick(); - - wrapper.vm.messageContextMenu.chatItem = failedChatItem; - wrapper.vm.messageContextMenu.show = true; - await wrapper.vm.$nextTick(); - - const retryInMenu = Array.from(document.body.querySelectorAll("button")).some((b) => - b.textContent.includes("messages.retry") - ); - expect(retryInMenu).toBe(true); - }); - - it("does not show retry in context menu for delivered messages", async () => { - const wrapper = mountConversationViewer(); - const deliveredItem = { - type: "lxmf_message", - is_outbound: true, - lxmf_message: { - hash: "delivered-hash", - state: "delivered", - content: "delivered message", - destination_hash: "test-hash", - source_hash: "my-hash", - fields: {}, - }, - }; - wrapper.vm.chatItems = [deliveredItem]; - await wrapper.vm.$nextTick(); - - wrapper.vm.messageContextMenu.chatItem = deliveredItem; - wrapper.vm.messageContextMenu.show = true; - await wrapper.vm.$nextTick(); - - const retryButtons = wrapper.findAll("button").filter((b) => b.text().includes("messages.retry")); - expect(retryButtons).toHaveLength(0); - }); - - it("canCancelOutboundSend is true while outbound message is still sending", () => { - const wrapper = mountConversationViewer(); - const sendingItem = { - type: "lxmf_message", - is_outbound: true, - lxmf_message: { - hash: "sending-hash", - state: "sending", - progress: 12, - content: "still going", - destination_hash: "test-hash", - source_hash: "my-hash", - fields: {}, - }, - }; - expect(wrapper.vm.canCancelOutboundSend(sendingItem)).toBe(true); - expect(wrapper.vm.canCancelOutboundSend({ ...sendingItem, is_outbound: false })).toBe(false); - }); - - it("cancelSendingMessage calls cancel API for in-flight outbound hash", async () => { - const wrapper = mountConversationViewer(); - const sendingItem = { - type: "lxmf_message", - is_outbound: true, - is_actions_expanded: true, - lxmf_message: { - hash: "aa".repeat(16), - state: "sending", - progress: 40, - content: "cancel me", - destination_hash: "test-hash", - source_hash: "my-hash", - fields: {}, - }, - }; - wrapper.vm.chatItems = [sendingItem]; - const hash = sendingItem.lxmf_message.hash; - axiosMock.post.mockResolvedValueOnce({ - data: { lxmf_message: { ...sendingItem.lxmf_message, state: "cancelled" } }, - }); - - await wrapper.vm.cancelSendingMessage(sendingItem); - - expect(axiosMock.post).toHaveBeenCalledWith(expect.stringContaining(`/lxmf-messages/${hash}/cancel`)); - expect(sendingItem.is_actions_expanded).toBe(false); - expect(wrapper.vm.messageContextMenu.show).toBe(false); - }); - - it("cancelSendingMessage removes optimistic pending placeholder without API call", async () => { - const wrapper = mountConversationViewer(); - await flushPromises(); - axiosMock.post.mockClear(); - const pendingItem = { - type: "lxmf_message", - is_outbound: true, - lxmf_message: { - hash: "pending-abc", - state: "sending", - content: "queued", - destination_hash: "test-hash", - source_hash: "my-hash", - fields: {}, - }, - }; - wrapper.vm.chatItems = [pendingItem]; - - await wrapper.vm.cancelSendingMessage(pendingItem); - - expect(axiosMock.post).not.toHaveBeenCalled(); - expect(wrapper.vm.chatItems.some((i) => i.lxmf_message?.hash === "pending-abc")).toBe(false); - }); - - it("downloadLxmfFileAttachment fetches attachment bytes and saves through DownloadUtils", async () => { - const saveSpy = vi.spyOn(DownloadUtils, "downloadFromApiResponse").mockResolvedValue(undefined); - - const wrapper = mountConversationViewer(); - const hash = "ff".repeat(16); - const chatItem = { - type: "lxmf_message", - is_outbound: false, - lxmf_message: { - hash, - state: "delivered", - content: "", - destination_hash: "test-hash", - source_hash: "peer-hash", - fields: { - file_attachments: [{ file_name: "doc.pdf", file_size: 42 }], - }, - }, - }; - - axiosMock.get.mockResolvedValueOnce({ - data: new ArrayBuffer(3), - headers: { "content-type": "application/pdf" }, - }); - - await wrapper.vm.downloadLxmfFileAttachment(chatItem, 0); - - expect(axiosMock.get).toHaveBeenCalledWith( - `/api/v1/lxmf-messages/attachment/${hash}/file`, - expect.objectContaining({ - params: { file_index: 0 }, - responseType: "arraybuffer", - }) - ); - expect(saveSpy).toHaveBeenCalledWith(expect.objectContaining({ data: expect.any(ArrayBuffer) }), "doc.pdf"); - saveSpy.mockRestore(); - }); - - it("calls retrySendingMessage when retry context menu clicked", async () => { - const wrapper = mountConversationViewer(); - const failedChatItem = { - type: "lxmf_message", - is_outbound: true, - lxmf_message: { - hash: "retry-hash", - state: "failed", - content: "retry me", - destination_hash: "test-hash", - source_hash: "my-hash", + hash: "server-hash", + source_hash: myHash, + destination_hash: peerHash, + content: "sent", fields: {}, - reply_to_hash: null, }, - }; - - axiosMock.post.mockResolvedValue({ - data: { lxmf_message: { hash: "new-hash", state: "outbound" } }, - }); - - wrapper.vm.messageContextMenu.chatItem = failedChatItem; - wrapper.vm.messageContextMenu.show = true; - wrapper.vm.messageContextMenu.x = 0; - wrapper.vm.messageContextMenu.y = 0; - await wrapper.vm.$nextTick(); - - const retryButtonEl = Array.from(document.body.querySelectorAll("button")).find((b) => - b.textContent.includes("messages.retry") + peerHash, + myHash ); - expect(retryButtonEl).toBeDefined(); - await wrapper.vm.retrySendingMessage(failedChatItem); - expect(axiosMock.post).toHaveBeenCalledWith( - expect.stringContaining("/lxmf-messages/send"), - expect.objectContaining({ - lxmf_message: expect.objectContaining({ - destination_hash: "test-hash", - content: "retry me", - }), - }) - ); + expect(result.changed).toBe(true); + expect(result.items).toHaveLength(1); + expect(result.items[0].lxmf_message.hash).toBe("server-hash"); }); - it("marks received messages as not outbound", async () => { - const wrapper = mountConversationViewer(); - - const incomingMessage = { - hash: "incoming-hash", - source_hash: "test-hash", - destination_hash: "my-hash", - content: "hello", - state: "delivered", - fields: {}, - }; + it("collects only image files from a drop", () => { + const image = new File(["image"], "photo.png", { type: "image/png" }); + const text = new File(["text"], "note.txt", { type: "text/plain" }); + const transfer = { files: [image, text], items: [] }; - wrapper.vm.onLxmfMessageReceived(incomingMessage); - - const addedItem = wrapper.vm.chatItems.find((i) => i.lxmf_message?.hash === "incoming-hash"); - expect(addedItem).toBeDefined(); - expect(addedItem.is_outbound).toBe(false); + expect(collectImageFilesFromDataTransfer(transfer)).toEqual([image]); }); - it("generates created_at from timestamp when missing", async () => { - const wrapper = mountConversationViewer(); - - const liveMsg = { - hash: "live-hash", - source_hash: "test-hash", - destination_hash: "my-hash", - content: "hello", - state: "delivered", - timestamp: 1700000000, - fields: {}, - }; + it("keeps drafts isolated by identity and destination", () => { + localStorage.clear(); + saveDraft(peerHash, "identity-a", "draft a"); + saveDraft(peerHash, "identity-b", "draft b"); - wrapper.vm.onLxmfMessageReceived(liveMsg); - - const addedItem = wrapper.vm.chatItems.find((i) => i.lxmf_message?.hash === "live-hash"); - expect(addedItem.lxmf_message.created_at).toBe(new Date(1700000000 * 1000).toISOString()); + expect(loadDraft(peerHash, "identity-a")).toBe("draft a"); + expect(loadDraft(peerHash, "identity-b")).toBe("draft b"); }); - it("converts unknown state to outbound for outgoing messages", async () => { - const wrapper = mountConversationViewer(); - - const outMsg = { - hash: "out-hash", - source_hash: "my-hash", - destination_hash: "test-hash", - content: "hello", - state: "unknown", - timestamp: 1700000000, - fields: {}, - }; - - wrapper.vm.onLxmfMessageCreated(outMsg); - - const addedItem = wrapper.vm.chatItems.find((i) => i.lxmf_message?.hash === "out-hash"); - expect(addedItem).toBeDefined(); - expect(addedItem.lxmf_message.state).toBe("outbound"); - expect(addedItem.is_outbound).toBe(true); - }); - - it("absorb response then created event do not double-insert the same outbound hash", async () => { - const wrapper = mountConversationViewer(); - await flushPromises(); - const lxmfMessage = { - hash: "same-outbound-hash", - source_hash: "my-hash", - destination_hash: "test-hash", - content: "once", - state: "sent", - method: "opportunistic", - timestamp: 1700000000, - fields: {}, - }; - wrapper.vm._absorbOutboundSendResponse({ pendingHash: "pending-x", destinationHash: "test-hash" }, lxmfMessage); - wrapper.vm.onLxmfMessageCreated(lxmfMessage); - const matches = wrapper.vm.chatItems.filter((i) => i.lxmf_message?.hash === "same-outbound-hash"); - expect(matches).toHaveLength(1); - }); - - it("created event then absorb response still keep a single outbound bubble", async () => { - const wrapper = mountConversationViewer(); - await flushPromises(); - const lxmfMessage = { - hash: "same-outbound-hash-2", - source_hash: "my-hash", - destination_hash: "test-hash", - content: "once", - state: "sent", - method: "opportunistic", - timestamp: 1700000000, - fields: {}, - }; - wrapper.vm.onLxmfMessageCreated(lxmfMessage); - wrapper.vm._absorbOutboundSendResponse({ pendingHash: "pending-y", destinationHash: "test-hash" }, lxmfMessage); - const matches = wrapper.vm.chatItems.filter((i) => i.lxmf_message?.hash === "same-outbound-hash-2"); - expect(matches).toHaveLength(1); - }); - - it("preserves unknown state for incoming messages", async () => { - const wrapper = mountConversationViewer(); - - const inMsg = { - hash: "in-unknown-hash", - source_hash: "test-hash", - destination_hash: "my-hash", - content: "hello", - state: "unknown", - timestamp: 1700000000, - fields: {}, - }; - - wrapper.vm.onLxmfMessageReceived(inMsg); - - const addedItem = wrapper.vm.chatItems.find((i) => i.lxmf_message?.hash === "in-unknown-hash"); - expect(addedItem.lxmf_message.state).toBe("unknown"); - }); - - it("does not overwrite existing created_at", async () => { - const wrapper = mountConversationViewer(); - - const dbMsg = { - hash: "db-hash", - source_hash: "test-hash", - destination_hash: "my-hash", - content: "hello", - state: "delivered", - timestamp: 1700000000, - created_at: "2023-11-14T22:13:20.000Z", - fields: {}, - }; - - wrapper.vm.onLxmfMessageReceived(dbMsg); - - const addedItem = wrapper.vm.chatItems.find((i) => i.lxmf_message?.hash === "db-hash"); - expect(addedItem.lxmf_message.created_at).toBe("2023-11-14T22:13:20.000Z"); - }); - - it("uses theme outbound bubble: no inline background for default indigo config", () => { - GlobalState.config.message_outbound_bubble_color = "#4f46e5"; - const wrapper = mountConversationViewer(); - const chatItem = { - type: "lxmf_message", - is_outbound: true, - lxmf_message: { - hash: "h1", - state: "delivered", - content: "hi", - destination_hash: "test-hash", - source_hash: "my-hash", - fields: {}, - }, - }; - const styles = wrapper.vm.bubbleStyles(chatItem); - expect(styles["background-color"]).toBeUndefined(); - expect(wrapper.vm.outboundBubbleSurfaceClass(chatItem)).toContain("bg-sky-100"); - expect(wrapper.vm.isThemeOutboundBubble(chatItem)).toBe(true); - }); - - it("uses solid outbound bubble when custom color is set", () => { - GlobalState.config.message_outbound_bubble_color = "#ff0000"; - const wrapper = mountConversationViewer(); - const chatItem = { - type: "lxmf_message", - is_outbound: true, - lxmf_message: { - hash: "h2", - state: "delivered", - content: "hi", - destination_hash: "test-hash", - source_hash: "my-hash", - fields: {}, - }, - }; - expect(wrapper.vm.bubbleStyles(chatItem)).toMatchObject({ - "background-color": "#ff0000", - color: "#ffffff", - }); - expect(wrapper.vm.outboundBubbleSurfaceClass(chatItem)).toBe("shadow-xs"); - expect(wrapper.vm.isThemeOutboundBubble(chatItem)).toBe(false); - }); - - it("applies waiting bubble color when pathfinding", () => { - GlobalState.config.message_waiting_bubble_color = "#ccddff"; - const wrapper = mountConversationViewer(); - const chatItem = { - type: "lxmf_message", - is_outbound: true, - lxmf_message: { - hash: "h-wait", - state: "sending", - content: "hi", - destination_hash: "test-hash", - source_hash: "my-hash", - fields: {}, - _pendingPathfinding: true, - }, - }; - expect(wrapper.vm.bubbleStyles(chatItem)).toMatchObject({ - "background-color": "#ccddff", - color: "#111827", - }); - expect(wrapper.vm.outboundBubbleSurfaceClass(chatItem)).toBe(""); - }); - - it("uses dark neutral waiting bubble when pathfinding in dark theme with default gray", () => { - GlobalState.config.theme = "dark"; - GlobalState.config.message_waiting_bubble_color = "#e5e7eb"; - const wrapper = mountConversationViewer(); - const chatItem = { - type: "lxmf_message", - is_outbound: true, - lxmf_message: { - hash: "h-wait-dark", - state: "sending", - content: "hi", - destination_hash: "test-hash", - source_hash: "my-hash", - fields: {}, - _pendingPathfinding: true, - }, - }; - expect(wrapper.vm.bubbleStyles(chatItem)).toMatchObject({ - "background-color": "#3f3f46", - color: "#ffffff", - }); - }); - - it("marks inbound messages with markdown-content--inbound for link styling", async () => { - GlobalState.config.message_outbound_bubble_color = "#4f46e5"; - const wrapper = mountConversationViewer(); - const chatItem = { - type: "lxmf_message", - is_outbound: false, - lxmf_message: { - hash: "in1", - state: "delivered", - content: "https://example.com", - destination_hash: "my-hash", - source_hash: "test-hash", - fields: {}, - }, - }; - wrapper.vm.chatItems = [chatItem]; - await wrapper.vm.$nextTick(); - await vi.waitFor(() => { - expect(wrapper.find(".markdown-content--inbound").exists()).toBe(true); - }); - }); - - it("sets reply state and includes reply_to_hash in sendMessage", async () => { - const wrapper = mountConversationViewer(); - wrapper.vm.peerPathSnapshot = { - path: { hops: 1 }, - path_stale: false, - path_unresponsive: false, - }; - const chatItem = { - lxmf_message: { hash: "original-hash", content: "Original message" }, - }; - - // Add to chatItems - wrapper.vm.chatItems = [chatItem]; - - await wrapper.vm.replyToMessage(chatItem); - expect(wrapper.vm.replyingTo.lxmf_message.hash).toBe(chatItem.lxmf_message.hash); - - wrapper.vm.newMessageText = "My reply"; - axiosMock.post.mockImplementation((url) => { - if (typeof url === "string" && (url.includes("/request-path") || url.includes("/drop-path"))) { - return Promise.resolve({ data: {} }); - } - return Promise.resolve({ data: { lxmf_message: { hash: "reply-hash" } } }); - }); - - await wrapper.vm.sendMessage(); - await vi.waitFor(() => { - expect(axiosMock.post).toHaveBeenCalledWith( - "/api/v1/lxmf-messages/send", - expect.objectContaining({ - lxmf_message: expect.objectContaining({ - content: "My reply", - reply_to_hash: "original-hash", - }), - }) - ); - }); - expect(wrapper.vm.replyingTo).toBeNull(); - }); - - describe("conversation history loading", () => { - const deferredConversationGet = () => { - const deferredResolvers = []; - axiosMock.get.mockImplementation((url) => { - if (url.includes("/lxmf-messages/conversation/")) { - return new Promise((resolve) => { - deferredResolvers.push(resolve); - }); - } - if (url.includes("/path")) return Promise.resolve({ data: { path: [] } }); - if (url.includes("/stamp-info")) return Promise.resolve({ data: { stamp_info: {} } }); - if (url.includes("/signal-metrics")) return Promise.resolve({ data: { signal_metrics: {} } }); - if (url.includes("/contacts/check/")) return Promise.resolve({ data: {} }); - return Promise.resolve({ data: {} }); - }); - return deferredResolvers; - }; - - let inboundMsgId = 1000; - const inboundFrom = (hash, content) => ({ - id: inboundMsgId++, - hash: `msg-${hash.slice(0, 4)}-${content}`, - source_hash: hash, - destination_hash: "my-hash", - content, - state: "delivered", - timestamp: 1700000000, - fields: {}, - }); - - it("loads the current peer after switching while a prior fetch was still in flight", async () => { - const deferredResolvers = deferredConversationGet(); - - const peerA = { destination_hash: "a".repeat(32), display_name: "A" }; - const peerB = { destination_hash: "b".repeat(32), display_name: "B" }; - - const wrapper = mountConversationViewer({ - selectedPeer: peerA, - }); - - await vi.waitFor(() => expect(deferredResolvers.length).toBeGreaterThanOrEqual(1)); - - await wrapper.setProps({ selectedPeer: peerB }); - await wrapper.vm.$nextTick(); - - await vi.waitFor(() => expect(deferredResolvers.length).toBeGreaterThanOrEqual(2)); - - deferredResolvers[0]({ data: { lxmf_messages: [] } }); - await wrapper.vm.$nextTick(); - await Promise.resolve(); - expect(wrapper.vm.chatItems).toHaveLength(0); - - deferredResolvers[1]({ - data: { - lxmf_messages: [inboundFrom("b".repeat(32), "hello")], - }, - }); - await vi.waitFor(() => expect(wrapper.vm.chatItems.length).toBe(1)); - expect(wrapper.vm.chatItems[0].lxmf_message.content).toBe("hello"); - }); - - it("applies only the latest peer response when multiple requests resolve out of order", async () => { - const deferredResolvers = deferredConversationGet(); - - const peerA = { destination_hash: "a".repeat(32), display_name: "A" }; - const peerB = { destination_hash: "b".repeat(32), display_name: "B" }; - const peerC = { destination_hash: "c".repeat(32), display_name: "C" }; - - const wrapper = mountConversationViewer({ selectedPeer: peerA }); - await vi.waitFor(() => expect(deferredResolvers.length).toBeGreaterThanOrEqual(1)); - - await wrapper.setProps({ selectedPeer: peerB }); - await wrapper.vm.$nextTick(); - await vi.waitFor(() => expect(deferredResolvers.length).toBeGreaterThanOrEqual(2)); - - await wrapper.setProps({ selectedPeer: peerC }); - await wrapper.vm.$nextTick(); - await vi.waitFor(() => expect(deferredResolvers.length).toBeGreaterThanOrEqual(3)); - - deferredResolvers[0]({ - data: { lxmf_messages: [inboundFrom("a".repeat(32), "stale-a")] }, - }); - deferredResolvers[1]({ - data: { lxmf_messages: [inboundFrom("b".repeat(32), "stale-b")] }, - }); - await wrapper.vm.$nextTick(); - await Promise.resolve(); - expect(wrapper.vm.chatItems).toHaveLength(0); - - deferredResolvers[2]({ - data: { lxmf_messages: [inboundFrom("c".repeat(32), "current")] }, - }); - await vi.waitFor(() => expect(wrapper.vm.chatItems.length).toBe(1)); - expect(wrapper.vm.chatItems[0].lxmf_message.content).toBe("current"); - }); - - it("does not start another page fetch while pagination is already in flight", async () => { - const baseMsg = { - id: 42, - hash: "page1-msg", - source_hash: "test-hash", - destination_hash: "my-hash", - content: "first page", - state: "delivered", - timestamp: 1700000000, - fields: {}, - }; - axiosMock.get.mockImplementation((url) => { - if (url.includes("/lxmf-messages/conversation/")) { - return Promise.resolve({ data: { lxmf_messages: [baseMsg] } }); - } - if (url.includes("/path")) return Promise.resolve({ data: { path: [] } }); - if (url.includes("/stamp-info")) return Promise.resolve({ data: { stamp_info: {} } }); - if (url.includes("/signal-metrics")) return Promise.resolve({ data: { signal_metrics: {} } }); - if (url.includes("/contacts/check/")) return Promise.resolve({ data: {} }); - return Promise.resolve({ data: {} }); - }); - - const wrapper = mountConversationViewer(); - await vi.waitFor(() => expect(wrapper.vm.chatItems.length).toBe(1)); - - const conversationGets = () => - axiosMock.get.mock.calls.filter((c) => String(c[0]).includes("/lxmf-messages/conversation/")); - const countBefore = conversationGets().length; - - wrapper.vm.isLoadingPrevious = true; - await wrapper.vm.loadPrevious(); - - expect(conversationGets().length).toBe(countBefore); - }); - - it("does not fetch when hasMorePrevious is already false", async () => { - const wrapper = mountConversationViewer(); - await vi.waitFor(() => expect(wrapper.vm.chatItems.length).toBeGreaterThanOrEqual(0)); - - const conversationGets = () => - axiosMock.get.mock.calls.filter((c) => String(c[0]).includes("/lxmf-messages/conversation/")); - const countBefore = conversationGets().length; - - wrapper.vm.hasMorePrevious = false; - wrapper.vm.chatItems = [ - { - type: "lxmf_message", - is_outbound: false, - lxmf_message: { - id: 10, - hash: "only-msg".padEnd(32, "0"), - source_hash: "test-hash", - destination_hash: "my-hash", - content: "hello", - state: "delivered", - timestamp: 1700000000, - fields: {}, - }, - }, - ]; - await wrapper.vm.loadPrevious(); - expect(conversationGets().length).toBe(countBefore); - }); - - it("uses min loaded peer message id for pagination when telemetry-only rows are hidden from the list", async () => { - const deferredResolvers = deferredConversationGet(); - const peerHash = "a".repeat(32); - const wrapper = mountConversationViewer({ - selectedPeer: { destination_hash: peerHash, display_name: "A" }, - }); - await vi.waitFor(() => expect(deferredResolvers.length).toBeGreaterThanOrEqual(1)); - deferredResolvers[0]({ data: { lxmf_messages: [] } }); - await wrapper.vm.$nextTick(); - - expect(wrapper.vm.showTelemetryInChat).toBe(false); - - wrapper.vm.chatItems = [ - { - type: "lxmf_message", - is_outbound: false, - lxmf_message: { - id: 100, - hash: "h100", - source_hash: peerHash, - destination_hash: "my-hash", - content: "", - state: "delivered", - timestamp: 1700000000, - fields: { commands: [{ "0x01": [1, true] }] }, - }, - }, - { - type: "lxmf_message", - is_outbound: false, - lxmf_message: { - id: 200, - hash: "h200", - source_hash: peerHash, - destination_hash: "my-hash", - content: "hello", - state: "delivered", - timestamp: 1700000001, - fields: {}, - }, - }, - ]; - await wrapper.vm.$nextTick(); - - expect(wrapper.vm.selectedPeerChatItems).toHaveLength(1); - expect(wrapper.vm.oldestMessageId).toBe(100); - - axiosMock.get.mockImplementation((url, config) => { - if (url.includes("/lxmf-messages/conversation/")) { - expect(config.params.after_id).toBe(100); - return Promise.resolve({ data: { lxmf_messages: [] } }); - } - if (url.includes("/path")) return Promise.resolve({ data: { path: [] } }); - if (url.includes("/stamp-info")) return Promise.resolve({ data: { stamp_info: {} } }); - if (url.includes("/signal-metrics")) return Promise.resolve({ data: { signal_metrics: {} } }); - if (url.includes("/contacts/check/")) return Promise.resolve({ data: {} }); - return Promise.resolve({ data: {} }); - }); - - await wrapper.vm.loadPrevious(); - }); - }); - - describe("compose draft persistence", () => { - let draftStore; - - beforeEach(() => { - draftStore = {}; - vi.stubGlobal("localStorage", { - getItem: (key) => (Object.prototype.hasOwnProperty.call(draftStore, key) ? draftStore[key] : null), - setItem: (key, value) => { - draftStore[key] = String(value); - }, - removeItem: (key) => { - delete draftStore[key]; - }, - }); - }); - - it("persists the previous peer draft in localStorage when switching peers", async () => { - const peerA = { destination_hash: "a".repeat(32), display_name: "A" }; - const peerB = { destination_hash: "b".repeat(32), display_name: "B" }; - - const wrapper = mountConversationViewer({ selectedPeer: peerA }); - await wrapper.vm.$nextTick(); - - wrapper.vm.newMessageText = "draft for A"; - await wrapper.setProps({ selectedPeer: peerB }); - await wrapper.vm.$nextTick(); - - const drafts = JSON.parse(draftStore["meshchat.drafts"] || "{}"); - expect(drafts["my-hash"]["a".repeat(32)]).toBe("draft for A"); - }); - - it("loads the stored draft when opening a peer", async () => { - draftStore["meshchat.drafts"] = JSON.stringify({ - "my-hash": { - ["b".repeat(32)]: "remembered", - }, - }); - - const wrapper = mountConversationViewer({ - selectedPeer: { destination_hash: "b".repeat(32), display_name: "B" }, - }); - await wrapper.vm.$nextTick(); - - expect(wrapper.vm.newMessageText).toBe("remembered"); - }); - - it("loads legacy flat drafts and re-saves under the active identity", async () => { - draftStore["meshchat.drafts"] = JSON.stringify({ - ["b".repeat(32)]: "legacy", - }); - - const wrapper = mountConversationViewer({ - selectedPeer: { destination_hash: "b".repeat(32), display_name: "B" }, - }); - await wrapper.vm.$nextTick(); - expect(wrapper.vm.newMessageText).toBe("legacy"); - - wrapper.vm.saveDraft("b".repeat(32)); - const drafts = JSON.parse(draftStore["meshchat.drafts"] || "{}"); - expect(drafts["my-hash"]["b".repeat(32)]).toBe("legacy"); - expect(drafts["b".repeat(32)]).toBeUndefined(); - }); - - it("keeps drafts isolated per local identity hash", async () => { - draftStore["meshchat.drafts"] = JSON.stringify({ - "identity-a": { ["p".repeat(32)]: "from-a" }, - "identity-b": { ["p".repeat(32)]: "from-b" }, - }); - - const wrapper = mountConversationViewer({ - selectedPeer: { destination_hash: "p".repeat(32), display_name: "P" }, - config: { identity_hash: "identity-b" }, - }); - await wrapper.vm.$nextTick(); - expect(wrapper.vm.newMessageText).toBe("from-b"); - }); - - it("does not load a legacy flat draft when identity-scoped buckets already exist", async () => { - const peer = "p".repeat(32); - draftStore["meshchat.drafts"] = JSON.stringify({ - "identity-a": { [peer]: "from-a" }, - [peer]: "legacy-flat", - }); - - const wrapper = mountConversationViewer({ - selectedPeer: { destination_hash: peer, display_name: "P" }, - config: { identity_hash: "identity-b" }, - }); - await wrapper.vm.$nextTick(); - expect(wrapper.vm.newMessageText).toBe(""); - }); - - it("keeps in-progress compose under the previous identity when switching", async () => { - const peer = "p".repeat(32); - draftStore["meshchat.drafts"] = JSON.stringify({ - "identity-a": {}, - "identity-b": { [peer]: "from-b" }, - }); - - const wrapper = mountConversationViewer({ - selectedPeer: { destination_hash: peer, display_name: "P" }, - config: { identity_hash: "identity-a" }, - }); - await wrapper.vm.$nextTick(); - wrapper.vm.newMessageText = "unsent-from-a"; - wrapper.vm.lastDraftIdentityKey = "identity-a"; - wrapper.vm.onIdentitySwitched({ identity_hash: "identity-b" }); - - const drafts = JSON.parse(draftStore["meshchat.drafts"] || "{}"); - expect(drafts["identity-a"][peer]).toBe("unsent-from-a"); - expect(drafts["identity-b"][peer]).toBe("from-b"); - expect(wrapper.vm.newMessageText).toBe("from-b"); - }); - - it("does not write the previous identity's compose into the new identity bucket", async () => { - const peer = "q".repeat(32); - draftStore["meshchat.drafts"] = JSON.stringify({ - "identity-a": { [peer]: "kept-a" }, - "identity-b": {}, - }); - - const wrapper = mountConversationViewer({ - selectedPeer: { destination_hash: peer, display_name: "Q" }, - config: { identity_hash: "identity-b" }, - }); - await wrapper.vm.$nextTick(); - wrapper.vm.newMessageText = "typed-as-a"; - wrapper.vm.lastDraftIdentityKey = "identity-a"; - wrapper.vm.onIdentitySwitched({ identity_hash: "identity-b" }); - - const drafts = JSON.parse(draftStore["meshchat.drafts"] || "{}"); - expect(drafts["identity-a"][peer]).toBe("typed-as-a"); - expect(drafts["identity-b"][peer]).toBeUndefined(); - expect(wrapper.vm.newMessageText).toBe(""); - }); - - it("round-trips drafts for A then B then back to A", async () => { - const peerA = { destination_hash: "a".repeat(32), display_name: "A" }; - const peerB = { destination_hash: "b".repeat(32), display_name: "B" }; - - const wrapper = mountConversationViewer({ selectedPeer: peerA }); - await wrapper.vm.$nextTick(); - wrapper.vm.newMessageText = "text-a"; - await wrapper.setProps({ selectedPeer: peerB }); - await wrapper.vm.$nextTick(); - expect(wrapper.vm.newMessageText).toBe(""); - - wrapper.vm.newMessageText = "text-b"; - await wrapper.setProps({ selectedPeer: peerA }); - await wrapper.vm.$nextTick(); - expect(wrapper.vm.newMessageText).toBe("text-a"); - - await wrapper.setProps({ selectedPeer: peerB }); - await wrapper.vm.$nextTick(); - expect(wrapper.vm.newMessageText).toBe("text-b"); - }); - - it("removes the draft key when saving an empty compose box for that peer", async () => { - draftStore["meshchat.drafts"] = JSON.stringify({ - "my-hash": { - ["a".repeat(32)]: "will clear", - }, - }); - - const wrapper = mountConversationViewer({ - selectedPeer: { destination_hash: "a".repeat(32), display_name: "A" }, - }); - await wrapper.vm.$nextTick(); - - wrapper.vm.newMessageText = ""; - wrapper.vm.saveDraft("a".repeat(32)); - - const drafts = JSON.parse(draftStore["meshchat.drafts"] || "{}"); - expect(drafts["my-hash"]["a".repeat(32)]).toBeUndefined(); - }); - - it("persists the current compose text when the component unmounts", async () => { - const peer = { destination_hash: "a".repeat(32), display_name: "A" }; - const wrapper = mountConversationViewer({ selectedPeer: peer }); - await wrapper.vm.$nextTick(); - - wrapper.vm.newMessageText = "save on leave"; - await wrapper.unmount(); - - const drafts = JSON.parse(draftStore["meshchat.drafts"] || "{}"); - expect(drafts["my-hash"]["a".repeat(32)]).toBe("save on leave"); - }); - - it("clears chat items and audio cache on identity-switched", async () => { - const wrapper = mountConversationViewer({ - selectedPeer: { destination_hash: "a".repeat(32), display_name: "A" }, - }); - await wrapper.vm.$nextTick(); - wrapper.vm.chatItems = [{ lxmf_message: { hash: "deadbeef" } }]; - wrapper.vm.lxmfMessageAudioAttachmentCache = { x: "blob:fake" }; - const clearSpy = vi.spyOn(wrapper.vm, "clearAudioAttachmentCache"); - const loadSpy = vi.spyOn(wrapper.vm, "initialLoad").mockResolvedValue(undefined); - - wrapper.vm.onIdentitySwitched(); - - expect(wrapper.vm.chatItems).toEqual([]); - expect(clearSpy).toHaveBeenCalled(); - expect(loadSpy).toHaveBeenCalled(); - }); - }); - - describe("message visibility and empty bubble prevention", () => { - const makeChatItem = (overrides = {}) => ({ - type: "lxmf_message", - is_outbound: false, - lxmf_message: { - hash: `hash-${Math.random().toString(36).slice(2, 10)}`, - state: "delivered", - content: "", - destination_hash: "my-hash", - source_hash: "test-hash", - fields: {}, - timestamp: 1700000000, - created_at: "2023-11-14T22:13:20.000Z", - ...overrides, - }, - }); - - describe("hasRenderableContent", () => { - it("returns true for message with text content", () => { - const wrapper = mountConversationViewer(); - expect(wrapper.vm.hasRenderableContent({ content: "hello", fields: {} })).toBe(true); - }); - - it("returns true for whitespace-padded text", () => { - const wrapper = mountConversationViewer(); - expect(wrapper.vm.hasRenderableContent({ content: " hi ", fields: {} })).toBe(true); - }); - - it("returns false for empty string content and no fields", () => { - const wrapper = mountConversationViewer(); - expect(wrapper.vm.hasRenderableContent({ content: "", fields: {} })).toBe(false); - }); - - it("returns false for whitespace-only content and no fields", () => { - const wrapper = mountConversationViewer(); - expect(wrapper.vm.hasRenderableContent({ content: " ", fields: {} })).toBe(false); - }); - - it("returns false for null content and no fields", () => { - const wrapper = mountConversationViewer(); - expect(wrapper.vm.hasRenderableContent({ content: null, fields: {} })).toBe(false); - }); - - it("returns false for undefined content and no fields", () => { - const wrapper = mountConversationViewer(); - expect(wrapper.vm.hasRenderableContent({ fields: {} })).toBe(false); - }); - - it("returns false for undefined fields", () => { - const wrapper = mountConversationViewer(); - expect(wrapper.vm.hasRenderableContent({ content: "" })).toBe(false); - }); - - it("returns false for null fields", () => { - const wrapper = mountConversationViewer(); - expect(wrapper.vm.hasRenderableContent({ content: "", fields: null })).toBe(false); - }); - - it("returns true for image field", () => { - const wrapper = mountConversationViewer(); - expect(wrapper.vm.hasRenderableContent({ content: "", fields: { image: { image_type: "png" } } })).toBe( - true - ); - }); - - it("returns true for audio field", () => { - const wrapper = mountConversationViewer(); - expect(wrapper.vm.hasRenderableContent({ content: "", fields: { audio: { audio_mode: 0x10 } } })).toBe( - true - ); - }); - - it("returns true for file attachments", () => { - const wrapper = mountConversationViewer(); - expect( - wrapper.vm.hasRenderableContent({ - content: "", - fields: { file_attachments: [{ file_name: "doc.pdf" }] }, - }) - ).toBe(true); - }); - - it("returns true for telemetry", () => { - const wrapper = mountConversationViewer(); - expect( - wrapper.vm.hasRenderableContent({ - content: "", - fields: { telemetry: { location: { latitude: 0, longitude: 0 } } }, - }) - ).toBe(true); - }); - - it("returns true for telemetry stream", () => { - const wrapper = mountConversationViewer(); - expect(wrapper.vm.hasRenderableContent({ content: "", fields: { telemetry_stream: [{}] } })).toBe(true); - }); - - it("returns true for location request command", () => { - const wrapper = mountConversationViewer(); - expect( - wrapper.vm.hasRenderableContent({ - content: "", - fields: { commands: [{ "0x01": true }] }, - }) - ).toBe(true); - }); - - it("returns true for location request via string key '1'", () => { - const wrapper = mountConversationViewer(); - expect( - wrapper.vm.hasRenderableContent({ - content: "", - fields: { commands: [{ 1: true }] }, - }) - ).toBe(true); - }); - - it("returns false for commands with no location request", () => { - const wrapper = mountConversationViewer(); - expect( - wrapper.vm.hasRenderableContent({ - content: "", - fields: { commands: [{ "0x99": true }] }, - }) - ).toBe(false); - }); - - it("returns false for empty commands array", () => { - const wrapper = mountConversationViewer(); - expect(wrapper.vm.hasRenderableContent({ content: "", fields: { commands: [] } })).toBe(false); - }); - - it("returns false for unknown/unsupported fields only", () => { - const wrapper = mountConversationViewer(); - expect( - wrapper.vm.hasRenderableContent({ - content: "", - fields: { some_future_field: { data: 42 }, another_unknown: "value" }, - }) - ).toBe(false); - }); - }); - - describe("isImageOnlyMessage", () => { - it("returns true for image with no text", () => { - const wrapper = mountConversationViewer(); - const item = makeChatItem({ fields: { image: { image_type: "png" } } }); - expect(wrapper.vm.isImageOnlyMessage(item)).toBe(true); - }); - - it("returns true for image with null content", () => { - const wrapper = mountConversationViewer(); - const item = makeChatItem({ content: null, fields: { image: { image_type: "jpg" } } }); - expect(wrapper.vm.isImageOnlyMessage(item)).toBe(true); - }); - - it("returns true for image with whitespace-only content", () => { - const wrapper = mountConversationViewer(); - const item = makeChatItem({ content: " ", fields: { image: { image_type: "png" } } }); - expect(wrapper.vm.isImageOnlyMessage(item)).toBe(true); - }); - - it("returns true for image with auto-generated filename caption", () => { - const wrapper = mountConversationViewer(); - const item = makeChatItem({ - content: "photo_2024.jpg", - fields: { image: { image_type: "jpg" } }, - }); - expect(wrapper.vm.isImageOnlyMessage(item)).toBe(true); - }); - - it("returns false for image with real text caption", () => { - const wrapper = mountConversationViewer(); - const item = makeChatItem({ - content: "Look at this sunset!", - fields: { image: { image_type: "jpg" } }, - }); - expect(wrapper.vm.isImageOnlyMessage(item)).toBe(false); - }); - - it("returns false for image with reply", () => { - const wrapper = mountConversationViewer(); - const item = makeChatItem({ - reply_to_hash: "abc123", - fields: { image: { image_type: "png" } }, - }); - expect(wrapper.vm.isImageOnlyMessage(item)).toBe(false); - }); - - it("returns false for image + audio", () => { - const wrapper = mountConversationViewer(); - const item = makeChatItem({ - fields: { image: { image_type: "png" }, audio: { audio_mode: 0x10 } }, - }); - expect(wrapper.vm.isImageOnlyMessage(item)).toBe(false); - }); - - it("returns false for image + file attachments", () => { - const wrapper = mountConversationViewer(); - const item = makeChatItem({ - fields: { - image: { image_type: "png" }, - file_attachments: [{ file_name: "data.csv" }], - }, - }); - expect(wrapper.vm.isImageOnlyMessage(item)).toBe(false); - }); - - it("returns false for image + telemetry", () => { - const wrapper = mountConversationViewer(); - const item = makeChatItem({ - fields: { - image: { image_type: "png" }, - telemetry: { location: { latitude: 1, longitude: 2 } }, - }, - }); - expect(wrapper.vm.isImageOnlyMessage(item)).toBe(false); - }); - - it("returns false for image + telemetry stream", () => { - const wrapper = mountConversationViewer(); - const item = makeChatItem({ - fields: { image: { image_type: "png" }, telemetry_stream: [{}] }, - }); - expect(wrapper.vm.isImageOnlyMessage(item)).toBe(false); - }); - - it("returns false for image + location request command", () => { - const wrapper = mountConversationViewer(); - const item = makeChatItem({ - fields: { image: { image_type: "png" }, commands: [{ "0x01": true }] }, - }); - expect(wrapper.vm.isImageOnlyMessage(item)).toBe(false); - }); - - it("returns false when there is no image", () => { - const wrapper = mountConversationViewer(); - const item = makeChatItem({ content: "just text", fields: {} }); - expect(wrapper.vm.isImageOnlyMessage(item)).toBe(false); - }); - - it("returns true for outbound image-only", () => { - const wrapper = mountConversationViewer(); - const item = makeChatItem({ - source_hash: "my-hash", - destination_hash: "test-hash", - fields: { image: { image_type: "png" } }, - }); - item.is_outbound = true; - expect(wrapper.vm.isImageOnlyMessage(item)).toBe(true); - }); - }); - - describe("selectedPeerChatItems filtering", () => { - it("filters out messages with no renderable content", async () => { - const wrapper = mountConversationViewer(); - wrapper.vm.chatItems = [ - makeChatItem({ content: "visible" }), - makeChatItem({ content: "", fields: {} }), - makeChatItem({ content: "", fields: { some_unknown: true } }), - ]; - await wrapper.vm.$nextTick(); - - expect(wrapper.vm.selectedPeerChatItems).toHaveLength(1); - expect(wrapper.vm.selectedPeerChatItems[0].lxmf_message.content).toBe("visible"); - }); - - it("keeps image-only messages visible", async () => { - const wrapper = mountConversationViewer(); - wrapper.vm.chatItems = [makeChatItem({ fields: { image: { image_type: "png" } } })]; - await wrapper.vm.$nextTick(); - - expect(wrapper.vm.selectedPeerChatItems).toHaveLength(1); - }); - - it("keeps audio-only messages visible", async () => { - const wrapper = mountConversationViewer(); - wrapper.vm.chatItems = [makeChatItem({ fields: { audio: { audio_mode: 0x10 } } })]; - await wrapper.vm.$nextTick(); - - expect(wrapper.vm.selectedPeerChatItems).toHaveLength(1); - }); - - it("keeps telemetry messages visible when showTelemetryInChat is true", async () => { - const wrapper = mountConversationViewer(); - wrapper.vm.showTelemetryInChat = true; - wrapper.vm.chatItems = [ - makeChatItem({ - fields: { telemetry: { location: { latitude: 0, longitude: 0 } } }, - }), - ]; - await wrapper.vm.$nextTick(); - - expect(wrapper.vm.selectedPeerChatItems).toHaveLength(1); - }); - - it("filters out reactions", async () => { - const wrapper = mountConversationViewer(); - wrapper.vm.chatItems = [makeChatItem({ content: "reaction", is_reaction: true })]; - await wrapper.vm.$nextTick(); - - expect(wrapper.vm.selectedPeerChatItems).toHaveLength(0); - }); - - it("filters out empty messages even if state is delivered", async () => { - const wrapper = mountConversationViewer(); - wrapper.vm.chatItems = [ - makeChatItem({ content: "", fields: {}, state: "delivered" }), - makeChatItem({ content: null, fields: {}, state: "delivered" }), - makeChatItem({ content: " ", fields: {}, state: "delivered" }), - ]; - await wrapper.vm.$nextTick(); - - expect(wrapper.vm.selectedPeerChatItems).toHaveLength(0); - }); - - it("filters out empty outbound messages", async () => { - const wrapper = mountConversationViewer(); - const item = makeChatItem({ - content: "", - fields: {}, - source_hash: "my-hash", - destination_hash: "test-hash", - }); - item.is_outbound = true; - wrapper.vm.chatItems = [item]; - await wrapper.vm.$nextTick(); - - expect(wrapper.vm.selectedPeerChatItems).toHaveLength(0); - }); - - it("keeps file-only messages", async () => { - const wrapper = mountConversationViewer(); - wrapper.vm.chatItems = [ - makeChatItem({ - fields: { file_attachments: [{ file_name: "readme.txt" }] }, - }), - ]; - await wrapper.vm.$nextTick(); - - expect(wrapper.vm.selectedPeerChatItems).toHaveLength(1); - }); - - it("handles mixed visible and invisible messages correctly", async () => { - const wrapper = mountConversationViewer(); - wrapper.vm.chatItems = [ - makeChatItem({ content: "text message" }), - makeChatItem({ content: "", fields: {} }), - makeChatItem({ fields: { image: { image_type: "png" } } }), - makeChatItem({ content: "", fields: { weird_field: true } }), - makeChatItem({ fields: { audio: { audio_mode: 0x10 } } }), - makeChatItem({ content: "", fields: {} }), - ]; - await wrapper.vm.$nextTick(); - - expect(wrapper.vm.selectedPeerChatItems).toHaveLength(3); - }); - }); - - describe("image strip grouping", () => { - it("groups 2+ consecutive image-only messages from same sender into imageGroup", async () => { - const wrapper = mountConversationViewer(); - wrapper.vm.chatItems = [ - makeChatItem({ hash: "img1", fields: { image: { image_type: "png" } } }), - makeChatItem({ hash: "img2", fields: { image: { image_type: "jpg" } } }), - ]; - await wrapper.vm.$nextTick(); - - const groups = wrapper.vm.selectedPeerChatDisplayGroups; - expect(groups).toHaveLength(1); - expect(groups[0].type).toBe("imageGroup"); - expect(groups[0].items).toHaveLength(2); - }); - - it("does not group a single image-only message", async () => { - const wrapper = mountConversationViewer(); - wrapper.vm.chatItems = [makeChatItem({ hash: "solo-img", fields: { image: { image_type: "png" } } })]; - await wrapper.vm.$nextTick(); - - const groups = wrapper.vm.selectedPeerChatDisplayGroups; - expect(groups).toHaveLength(1); - expect(groups[0].type).toBe("single"); - }); - - it("does not merge images with real captions into strip", async () => { - const wrapper = mountConversationViewer(); - wrapper.vm.chatItems = [ - makeChatItem({ - hash: "captioned1", - content: "Beautiful landscape", - fields: { image: { image_type: "png" } }, - }), - makeChatItem({ - hash: "captioned2", - content: "Another beautiful shot", - fields: { image: { image_type: "png" } }, - }), - ]; - await wrapper.vm.$nextTick(); - - const groups = wrapper.vm.selectedPeerChatDisplayGroups; - expect(groups).toHaveLength(2); - expect(groups.every((g) => g.type === "single")).toBe(true); - }); - - it("groups images with filename captions into strip", async () => { - const wrapper = mountConversationViewer(); - wrapper.vm.chatItems = [ - makeChatItem({ - hash: "fn1", - content: "photo_001.jpg", - fields: { image: { image_type: "jpg" } }, - }), - makeChatItem({ - hash: "fn2", - content: "photo_002.png", - fields: { image: { image_type: "png" } }, - }), - ]; - await wrapper.vm.$nextTick(); - - const groups = wrapper.vm.selectedPeerChatDisplayGroups; - expect(groups).toHaveLength(1); - expect(groups[0].type).toBe("imageGroup"); - }); - - it("does not merge images from different senders", async () => { - const wrapper = mountConversationViewer(); - const inbound = makeChatItem({ - hash: "in-img", - fields: { image: { image_type: "png" } }, - }); - inbound.is_outbound = false; - const outbound = makeChatItem({ - hash: "out-img", - source_hash: "my-hash", - destination_hash: "test-hash", - fields: { image: { image_type: "png" } }, - }); - outbound.is_outbound = true; - wrapper.vm.chatItems = [inbound, outbound]; - await wrapper.vm.$nextTick(); - - const groups = wrapper.vm.selectedPeerChatDisplayGroups; - expect(groups).toHaveLength(2); - expect(groups.every((g) => g.type === "single")).toBe(true); - }); - - it("does not merge failed images into strip", async () => { - const wrapper = mountConversationViewer(); - wrapper.vm.chatItems = [ - makeChatItem({ - hash: "fail-img", - state: "failed", - fields: { image: { image_type: "png" } }, - }), - makeChatItem({ - hash: "ok-img", - fields: { image: { image_type: "png" } }, - }), - ]; - await wrapper.vm.$nextTick(); - - const groups = wrapper.vm.selectedPeerChatDisplayGroups; - expect(groups).toHaveLength(2); - expect(groups.every((g) => g.type === "single")).toBe(true); - }); - - it("does not merge spam images into strip", async () => { - const wrapper = mountConversationViewer(); - wrapper.vm.chatItems = [ - makeChatItem({ - hash: "spam-img", - is_spam: true, - fields: { image: { image_type: "png" } }, - }), - makeChatItem({ hash: "ok-img2", fields: { image: { image_type: "png" } } }), - ]; - await wrapper.vm.$nextTick(); - - const groups = wrapper.vm.selectedPeerChatDisplayGroups; - expect(groups).toHaveLength(2); - }); - - it("does not merge image with reply into strip", async () => { - const wrapper = mountConversationViewer(); - wrapper.vm.chatItems = [ - makeChatItem({ - hash: "reply-img", - reply_to_hash: "some-hash", - fields: { image: { image_type: "png" } }, - }), - makeChatItem({ hash: "plain-img", fields: { image: { image_type: "png" } } }), - ]; - await wrapper.vm.$nextTick(); - - const groups = wrapper.vm.selectedPeerChatDisplayGroups; - expect(groups).toHaveLength(2); - }); - - it("caps image strip at 12 images", async () => { - const wrapper = mountConversationViewer(); - const items = []; - for (let i = 0; i < 15; i++) { - items.push(makeChatItem({ hash: `strip-${i}`, fields: { image: { image_type: "png" } } })); - } - wrapper.vm.chatItems = items; - await wrapper.vm.$nextTick(); - - const groups = wrapper.vm.selectedPeerChatDisplayGroups; - const imgGroup = groups.find((g) => g.type === "imageGroup"); - expect(imgGroup).toBeDefined(); - expect(imgGroup.items.length).toBeLessThanOrEqual(12); - }); - }); - - describe("isLikelyMultiImagePlaceholderCaption", () => { - let wrapper; - beforeEach(() => { - wrapper = mountConversationViewer(); - }); - - it("recognizes standard image filenames", () => { - expect(wrapper.vm.isLikelyMultiImagePlaceholderCaption("photo.jpg")).toBe(true); - expect(wrapper.vm.isLikelyMultiImagePlaceholderCaption("IMG_20240101.png")).toBe(true); - expect(wrapper.vm.isLikelyMultiImagePlaceholderCaption("screenshot.webp")).toBe(true); - expect(wrapper.vm.isLikelyMultiImagePlaceholderCaption("image.heic")).toBe(true); - expect(wrapper.vm.isLikelyMultiImagePlaceholderCaption("cat.gif")).toBe(true); - }); - - it("rejects non-image filenames", () => { - expect(wrapper.vm.isLikelyMultiImagePlaceholderCaption("document.pdf")).toBe(false); - expect(wrapper.vm.isLikelyMultiImagePlaceholderCaption("video.mp4")).toBe(false); - expect(wrapper.vm.isLikelyMultiImagePlaceholderCaption("archive.zip")).toBe(false); - }); - - it("rejects normal text messages", () => { - expect(wrapper.vm.isLikelyMultiImagePlaceholderCaption("hello world")).toBe(false); - expect(wrapper.vm.isLikelyMultiImagePlaceholderCaption("check out this image")).toBe(false); - expect(wrapper.vm.isLikelyMultiImagePlaceholderCaption("I sent you a .png file")).toBe(false); - }); - - it("rejects multiline text", () => { - expect(wrapper.vm.isLikelyMultiImagePlaceholderCaption("photo.jpg\nmore text")).toBe(false); - }); - - it("rejects text with path separators", () => { - expect(wrapper.vm.isLikelyMultiImagePlaceholderCaption("/home/user/photo.jpg")).toBe(false); - expect(wrapper.vm.isLikelyMultiImagePlaceholderCaption("C:\\Users\\photo.jpg")).toBe(false); - }); - - it("rejects text with special characters", () => { - expect(wrapper.vm.isLikelyMultiImagePlaceholderCaption(" diff --git a/meshchatx/src/frontend/components/rnstatus/RNStatusPage.vue b/meshchatx/src/frontend/components/rnstatus/RNStatusPage.vue deleted file mode 100644 index 92669fd45..000000000 --- a/meshchatx/src/frontend/components/rnstatus/RNStatusPage.vue +++ /dev/null @@ -1,725 +0,0 @@ - - - - - diff --git a/meshchatx/src/frontend/components/tools/BotsPage.vue b/meshchatx/src/frontend/components/tools/BotsPage.vue index 8392caa63..369ddcc67 100644 --- a/meshchatx/src/frontend/components/tools/BotsPage.vue +++ b/meshchatx/src/frontend/components/tools/BotsPage.vue @@ -475,6 +475,7 @@ import DialogUtils from "../../js/DialogUtils"; import DownloadUtils from "../../js/DownloadUtils"; import GlobalEmitter from "../../js/GlobalEmitter"; import GlobalState from "../../js/GlobalState"; +import { isDestinationHash } from "../../js/meshValidate.js"; import MaterialDesignIcon from "../MaterialDesignIcon.vue"; import ToolsPageHeader from "./ToolsPageHeader.vue"; import LxmfConfigFields from "./internal/BotLxmfConfigFields.vue"; @@ -740,7 +741,7 @@ export default { return ""; } const h = raw.trim().toLowerCase(); - return h.length === 32 && /^[0-9a-f]+$/.test(h) ? h : ""; + return isDestinationHash(h) ? h : ""; }, openChatWithBot(bot) { const h = this.lxmfAddressFor(bot); diff --git a/meshchatx/src/frontend/components/tools/MessageBlocklistPage.vue b/meshchatx/src/frontend/components/tools/MessageBlocklistPage.vue deleted file mode 100644 index 91c156923..000000000 --- a/meshchatx/src/frontend/components/tools/MessageBlocklistPage.vue +++ /dev/null @@ -1,383 +0,0 @@ - - - - - diff --git a/meshchatx/src/frontend/components/tools/PaperMessagePage.vue b/meshchatx/src/frontend/components/tools/PaperMessagePage.vue deleted file mode 100644 index 9551754d4..000000000 --- a/meshchatx/src/frontend/components/tools/PaperMessagePage.vue +++ /dev/null @@ -1,568 +0,0 @@ - - - - - - - diff --git a/meshchatx/src/frontend/components/tools/RNPathPage.vue b/meshchatx/src/frontend/components/tools/RNPathPage.vue deleted file mode 100644 index 477858850..000000000 --- a/meshchatx/src/frontend/components/tools/RNPathPage.vue +++ /dev/null @@ -1,603 +0,0 @@ - - - - - - - diff --git a/meshchatx/src/frontend/components/tools/RNPathTracePage.vue b/meshchatx/src/frontend/components/tools/RNPathTracePage.vue deleted file mode 100644 index 9ab34b923..000000000 --- a/meshchatx/src/frontend/components/tools/RNPathTracePage.vue +++ /dev/null @@ -1,365 +0,0 @@ - - - - - diff --git a/meshchatx/src/frontend/components/tools/ReticulumConfigEditorPage.vue b/meshchatx/src/frontend/components/tools/ReticulumConfigEditorPage.vue deleted file mode 100644 index 0aff7e1f8..000000000 --- a/meshchatx/src/frontend/components/tools/ReticulumConfigEditorPage.vue +++ /dev/null @@ -1,253 +0,0 @@ - - - - - diff --git a/meshchatx/src/frontend/components/tools/SieveFiltersPage.vue b/meshchatx/src/frontend/components/tools/SieveFiltersPage.vue deleted file mode 100644 index e8564aaf7..000000000 --- a/meshchatx/src/frontend/components/tools/SieveFiltersPage.vue +++ /dev/null @@ -1,418 +0,0 @@ - - - - - diff --git a/meshchatx/src/frontend/features/contacts/lib/contactUri.ts b/meshchatx/src/frontend/features/contacts/lib/contactUri.ts index 16e468183..d98956df8 100644 --- a/meshchatx/src/frontend/features/contacts/lib/contactUri.ts +++ b/meshchatx/src/frontend/features/contacts/lib/contactUri.ts @@ -1,5 +1,7 @@ // SPDX-License-Identifier: 0BSD +import { isDestinationHash } from "../../../js/meshValidate.js"; + export type LxmaUriParts = { destinationHash: string; publicKeyHex: string; @@ -31,7 +33,7 @@ export function parseLxmaUri(input: string): LxmaUriParts | null { export function extractDestinationHash(input: string): string | null { const raw = input.trim().toLowerCase(); - if (/^[0-9a-f]{32}$/.test(raw)) { + if (isDestinationHash(raw)) { return raw; } const lxmfMatch = raw.match(/^lxmf:\/\/([0-9a-f]{32})$/); @@ -78,7 +80,7 @@ export function publicKeyFromAnnounce(publicKeyBase64: string | null | undefined export function buildContactUri(contact: ContactUriFields, publicKeyHex: string | null): string | null { const destinationHash = (contact?.lxmf_address || contact?.remote_identity_hash || "").toLowerCase(); - if (!/^[0-9a-f]{32}$/.test(destinationHash)) { + if (!isDestinationHash(destinationHash)) { return null; } if (publicKeyHex) { diff --git a/meshchatx/src/frontend/features/contacts/lib/contactsActions.ts b/meshchatx/src/frontend/features/contacts/lib/contactsActions.ts index 57d2415bf..592d73f9a 100644 --- a/meshchatx/src/frontend/features/contacts/lib/contactsActions.ts +++ b/meshchatx/src/frontend/features/contacts/lib/contactsActions.ts @@ -1,6 +1,5 @@ // SPDX-License-Identifier: 0BSD - import ToastUtils from "../../../js/ToastUtils.js"; import DownloadUtils from "../../../js/DownloadUtils.js"; import DialogUtils from "../../../js/DialogUtils.js"; diff --git a/meshchatx/src/frontend/features/debug-logs/DebugLogsPage.svelte b/meshchatx/src/frontend/features/debug-logs/DebugLogsPage.svelte index 2e6845089..a5f3e5cdc 100644 --- a/meshchatx/src/frontend/features/debug-logs/DebugLogsPage.svelte +++ b/meshchatx/src/frontend/features/debug-logs/DebugLogsPage.svelte @@ -206,7 +206,7 @@
- + + + + +
+
+ +
+
+ + +
+
+
+ {t("tools.message_blocklist.match_in_label")} +
+ + +
+
+ + {#if blocklist.entries.length === 0} +
+ {t("tools.message_blocklist.empty_entries")} +
+ {:else} +
+ {#each blocklist.entries as entry, index (entry.id)} +
+
+ +
+ +
+
+
+ + +
+
+ {/each} +
+ {/if} + +
+ + +
+
+
+
+
diff --git a/meshchatx/src/frontend/features/message-blocklist/index.ts b/meshchatx/src/frontend/features/message-blocklist/index.ts new file mode 100644 index 000000000..43d8a0ffe --- /dev/null +++ b/meshchatx/src/frontend/features/message-blocklist/index.ts @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: 0BSD + +import { registerFeature } from "../../js/registries/featureRegistry.js"; + +/** + * Register the LXMF message blocklist tool feature + */ +export function registerMessageBlocklistFeature(): void { + registerFeature({ + id: "message-blocklist", + routes: [ + { + name: "message-blocklist", + path: "/tools/message-blocklist", + mount: "svelte", + load: () => import("./MessageBlocklistPage.svelte"), + }, + ], + }); +} diff --git a/meshchatx/src/frontend/features/message-blocklist/lib/blocklistRules.ts b/meshchatx/src/frontend/features/message-blocklist/lib/blocklistRules.ts new file mode 100644 index 000000000..55d45d919 --- /dev/null +++ b/meshchatx/src/frontend/features/message-blocklist/lib/blocklistRules.ts @@ -0,0 +1,110 @@ +// SPDX-License-Identifier: 0BSD + +import { + DEFAULT_BLOCKLIST_MATCH_MESSAGE, + DEFAULT_BLOCKLIST_MATCH_MODE, + DEFAULT_BLOCKLIST_MATCH_PEER_FIELDS, + DEFAULT_BLOCKLIST_SCOPE, + ID_RANDOM_RADIX, + ID_SLICE_END, + ID_SLICE_START, +} from "./constants.js"; +import type { BlocklistConfig, BlocklistEntry, BlocklistMatchMode, BlocklistScope } from "./types.js"; + +/** + * Generate a random entry identifier matching Vue implementation + */ +export function newEntryId(): string { + return Math.random().toString(ID_RANDOM_RADIX).slice(ID_SLICE_START, ID_SLICE_END); +} + +/** + * Create a new blank blocklist entry + */ +export function createNewBlocklistEntry(): BlocklistEntry { + return { + id: newEntryId(), + enabled: true, + text: "", + match_mode: DEFAULT_BLOCKLIST_MATCH_MODE, + }; +} + +/** + * Validate and sanitize scope string + */ +export function sanitizeBlocklistScope(rawScope: unknown): BlocklistScope { + if (rawScope === "contacts" || rawScope === "non_contacts") { + return rawScope; + } + return "everyone"; +} + +/** + * Validate and sanitize match mode string + */ +export function sanitizeBlocklistMatchMode(rawMode: unknown): BlocklistMatchMode { + return rawMode === "regex" ? "regex" : "substring"; +} + +/** + * Map raw backend or file payload into sanitized BlocklistConfig + */ +export function mapBlocklistFromApi(raw: unknown): BlocklistConfig { + const rawObj = (raw && typeof raw === "object" ? raw : {}) as Partial & { + entries?: Partial[]; + }; + + const scope = sanitizeBlocklistScope(rawObj.scope); + const match_peer_fields = Boolean(rawObj.match_peer_fields); + const match_message = rawObj.match_message !== false; + + const entries: BlocklistEntry[] = Array.isArray(rawObj.entries) + ? rawObj.entries.map((entry) => ({ + id: entry?.id || newEntryId(), + enabled: entry?.enabled !== false, + text: entry?.text || "", + match_mode: sanitizeBlocklistMatchMode(entry?.match_mode), + })) + : []; + + return { + scope, + match_peer_fields, + match_message: match_peer_fields || match_message ? match_message : DEFAULT_BLOCKLIST_MATCH_MESSAGE, + entries, + }; +} + +/** + * Create default blocklist configuration + */ +export function createDefaultBlocklistConfig(): BlocklistConfig { + return { + scope: DEFAULT_BLOCKLIST_SCOPE, + match_peer_fields: DEFAULT_BLOCKLIST_MATCH_PEER_FIELDS, + match_message: DEFAULT_BLOCKLIST_MATCH_MESSAGE, + entries: [], + }; +} + +/** + * Normalize blocklist config for saving to the backend + */ +export function normalizeBlocklistForSave(blocklist: BlocklistConfig): BlocklistConfig { + const match_peer_fields = Boolean(blocklist.match_peer_fields); + const match_message = Boolean(blocklist.match_message); + const targets_ok = match_peer_fields || match_message; + + return { + scope: sanitizeBlocklistScope(blocklist.scope), + match_peer_fields: targets_ok ? match_peer_fields : DEFAULT_BLOCKLIST_MATCH_PEER_FIELDS, + match_message: targets_ok ? match_message : DEFAULT_BLOCKLIST_MATCH_MESSAGE, + entries: (blocklist.entries || []).map((entry) => ({ + id: entry.id, + enabled: Boolean(entry.enabled), + text: String(entry.text || "").trim(), + match_mode: sanitizeBlocklistMatchMode(entry.match_mode), + })), + }; +} diff --git a/meshchatx/src/frontend/features/message-blocklist/lib/constants.ts b/meshchatx/src/frontend/features/message-blocklist/lib/constants.ts new file mode 100644 index 000000000..469f4c765 --- /dev/null +++ b/meshchatx/src/frontend/features/message-blocklist/lib/constants.ts @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: 0BSD + +export const API_MESSAGE_BLOCKLIST = "/api/v1/lxmf/message-blocklist"; +export const API_MESSAGE_BLOCKLIST_EXPORT = "/api/v1/lxmf/message-blocklist/export"; +export const API_MESSAGE_BLOCKLIST_IMPORT = "/api/v1/lxmf/message-blocklist/import"; + +export const MESSAGE_BLOCKLIST_EXPORT_FILENAME = "meshchatx_message_blocklist.json"; + +export const DEFAULT_BLOCKLIST_SCOPE = "non_contacts" as const; +export const DEFAULT_BLOCKLIST_MATCH_MODE = "substring" as const; +export const DEFAULT_BLOCKLIST_MATCH_PEER_FIELDS = false; +export const DEFAULT_BLOCKLIST_MATCH_MESSAGE = true; + +export const ID_RANDOM_RADIX = 16; +export const ID_SLICE_START = 2; +export const ID_SLICE_END = 18; +export const JSON_INDENT_SPACES = 2; diff --git a/meshchatx/src/frontend/features/message-blocklist/lib/types.ts b/meshchatx/src/frontend/features/message-blocklist/lib/types.ts new file mode 100644 index 000000000..af3ed60ef --- /dev/null +++ b/meshchatx/src/frontend/features/message-blocklist/lib/types.ts @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: 0BSD + +export type BlocklistScope = "everyone" | "contacts" | "non_contacts"; + +export type BlocklistMatchMode = "substring" | "regex"; + +export type BlocklistEntry = { + id: string; + enabled: boolean; + text: string; + match_mode: BlocklistMatchMode; +}; + +export type BlocklistConfig = { + scope: BlocklistScope; + match_peer_fields: boolean; + match_message: boolean; + entries: BlocklistEntry[]; +}; + +export type MessageBlocklistApiResponse = { + enabled?: boolean; + blocklist?: Partial & { + entries?: Partial[]; + }; + message?: string; +}; + +export type MessageBlocklistImportPayload = { + document: unknown; + merge: boolean; +}; + +export type MessageBlocklistSavePayload = { + enabled: boolean; + blocklist: BlocklistConfig; +}; diff --git a/meshchatx/src/frontend/features/messages/MessagesPage.svelte b/meshchatx/src/frontend/features/messages/MessagesPage.svelte index b62a28dd0..27b88a208 100644 --- a/meshchatx/src/frontend/features/messages/MessagesPage.svelte +++ b/meshchatx/src/frontend/features/messages/MessagesPage.svelte @@ -22,6 +22,7 @@ import { isRetryableHttpError } from "../../js/httpRetry.js"; import { runWhenIdentityHttpReady } from "../../js/identityHttpReady.js"; import { t } from "../../js/i18n.js"; + import { isDestinationHash } from "../../js/meshValidate.js"; import { applyOutboundComposeEnqueued, applyOutboundMessageCreated, @@ -794,7 +795,7 @@ dismissUnreadForOpenDestination(destinationHashNorm); return; } - if (destinationHashNorm.length !== 32) { + if (!isDestinationHash(destinationHashNorm)) { DialogUtils.alert(t("common.invalid_address")); return; } @@ -861,7 +862,7 @@ function openConversationInPane(paneId: number, dest: string) { const normalized = Utils.normalizeMeshchatHashHex(dest || ""); - if (normalized.length !== 32) { + if (!isDestinationHash(normalized)) { return; } focusedPaneId = paneId; diff --git a/meshchatx/src/frontend/features/messages/components/ConversationEmptyState.svelte b/meshchatx/src/frontend/features/messages/components/ConversationEmptyState.svelte index 48f109e55..6eabc7624 100644 --- a/meshchatx/src/frontend/features/messages/components/ConversationEmptyState.svelte +++ b/meshchatx/src/frontend/features/messages/components/ConversationEmptyState.svelte @@ -9,17 +9,18 @@ type ConversationPreview = { destination_hash: string; - display_name?: string; + display_name?: string | null; custom_display_name?: string | null; - contact_image?: string; + contact_image?: string | null; lxmf_user_icon?: { icon_name?: string; foreground_colour?: string; background_colour?: string; } | null; updated_at?: unknown; - latest_message_preview?: string; - latest_message_title?: string; + latest_message_preview?: string | null; + latest_message_title?: string | null; + [key: string]: unknown; }; type ComposeSuggestion = { diff --git a/meshchatx/src/frontend/features/messages/components/ConversationViewerComposerHost.svelte b/meshchatx/src/frontend/features/messages/components/ConversationViewerComposerHost.svelte new file mode 100644 index 000000000..6967a1a43 --- /dev/null +++ b/meshchatx/src/frontend/features/messages/components/ConversationViewerComposerHost.svelte @@ -0,0 +1,453 @@ + + + + +
+ { + activeStickerPackId = packId; + }} + onemojiclick={onEmojiClick} + onstickerselect={selectSticker} + ongifselect={selectGif} + onstickerfiles={handleStickerFiles} + ongiffiles={handleGifFiles} + onstickerdragactivechange={(act) => { + stickerDropActive = act; + }} + ongifdragactivechange={(act) => { + gifDropActive = act; + }} + /> + + { + isComposeTranslateOpen = false; + }} + /> + + void sendMessage()} + onaddfiles={() => fileInput?.click()} + onaddimage={addImage} + onstartrecording={(args) => void startAudioRecording(args)} + onstoprecording={() => void stopAudioRecording()} + onremovefile={(f) => { + files = files.filter((candidate) => candidate !== f); + }} + onremoveimage={removeImage} + onremoveaudio={() => { + if (audio?.audio_preview_url) URL.revokeObjectURL(audio.audio_preview_url); + audio = null; + }} + oncancelreply={() => { + replyingTo = null; + }} + onpaste={onPaste} + ontoggleemojipicker={toggleEmojiPicker} + ontogglelocation={toggleLocation} + ontoggletranslate={() => { + isComposeTranslateOpen = !isComposeTranslateOpen; + }} + onsendpapercompose={() => onsendpapercompose?.()} + onenter={composeEnter} + onshiftenter={() => { + text += "\n"; + }} + /> + + { + files = files.concat(Array.from(event.currentTarget.files || [])); + }} + /> +
diff --git a/meshchatx/src/frontend/features/messages/components/ConversationViewerListPane.svelte b/meshchatx/src/frontend/features/messages/components/ConversationViewerListPane.svelte new file mode 100644 index 000000000..4c4a47eff --- /dev/null +++ b/meshchatx/src/frontend/features/messages/components/ConversationViewerListPane.svelte @@ -0,0 +1,101 @@ + + + + +
+
onscroll?.(e)} + > +
+ {#if hasMorePrevious} + + {/if} + {#if useVirtualMessageList} + messagesScroll} + {actions} + /> + {:else} +
+ {#each groups as entry (entry.key)} + + {/each} +
+ {/if} +
+
+ + {#if !autoScrollOnNewMessage && messagesViewportReady} + + {/if} +
diff --git a/meshchatx/src/frontend/features/messages/components/ConversationViewerModalsHost.svelte b/meshchatx/src/frontend/features/messages/components/ConversationViewerModalsHost.svelte new file mode 100644 index 000000000..c738e6705 --- /dev/null +++ b/meshchatx/src/frontend/features/messages/components/ConversationViewerModalsHost.svelte @@ -0,0 +1,273 @@ + + + + + oncloselightbox?.()} + onnavigate={(delta) => onnavigatelightbox?.(delta)} + ondownload={() => ondownloadlightbox?.()} +/> + + 0)} + canReact={!contextMenu.chatItem?.lxmf_message.is_reaction} + hasImage={Boolean(contextMenu.chatItem?.lxmf_message.fields?.image)} + canSaveAsGif={Boolean( + contextMenu.chatItem?.lxmf_message.fields?.image?.image_type?.toLowerCase() === "gif" + )} + canCancelSend={Boolean( + contextMenu.chatItem?.is_outbound && + ["sending", "generating", "outbound"].includes(String(contextMenu.chatItem?.lxmf_message.state || "")) + )} + canRetry={Boolean( + contextMenu.chatItem?.is_outbound && + ["failed", "cancelled"].includes(String(contextMenu.chatItem?.lxmf_message.state || "")) + )} + {canLiftBanishment} + {reactionEmojis} + onreply={() => onreplycontextmenu?.()} + oncopy={() => oncopycontextmenu?.()} + ontranslate={() => ontranslatecontextmenu?.()} + onreact={(emoji) => onreactcontextmenu?.(emoji)} + onopenreactionpicker={() => onopenreactionpickercontextmenu?.()} + onviewraw={() => onviewrawcontextmenu?.()} + ondownloadimage={() => ondownloadimagecontextmenu?.()} + oncopyimage={() => oncopyimagecontextmenu?.()} + onsavesticker={() => onsavestickercontextmenu?.()} + onsavegif={() => onsavegifcontextmenu?.()} + oncancelsend={() => oncancelsendcontextmenu?.()} + onretry={() => onretrycontextmenu?.()} + onliftbanishment={() => onliftbanishmentcontextmenu?.()} + ondelete={() => ondeletecontextmenu?.()} + onclose={() => onclosecontextmenu?.()} +/> + + onclosereactionpicker?.()} + onemojiclick={(e) => onemojiclickreactionpicker?.(e)} + ondragstart={(e) => ondragstartreactionpicker?.(e)} +/> + + MESSAGE_BODY_MAX_DISPLAY_CHARS} + bodyCharCount={rawContent.length} + hasStoredPath={rawMessageData.path_hops_at_send != null || Boolean(rawMessageData.path_interface_at_send)} + onclose={() => oncloserawmessage?.()} + oncopyhash={(hash) => oncopyhashrawmessage?.(hash)} + oncopycontent={() => oncopycontentrawmessage?.()} +/> + +{#if isPaperMessageResultModalOpen} + onclosepapermessage?.()} + /> +{/if} + + resolveContactIcon?.(contact) as never} + destinationHex={(contact) => destinationHexFromContact?.(contact) || ""} + onclose={() => onclosesharecontact?.()} + onshare={(contact) => onsharecontact?.(contact as Record)} +/> + + fromNow(value as never)} + gradientIdSuffix={selectedHash || "peer"} + onopenchange={(open) => onopentelemetrychange?.(open)} + onclose={() => onclosetelemetry?.()} + onshowtelemetrychange={(show) => onshowtelemetrychange?.(show)} + onlocationclick={(coords) => onlocationclicktelemetry?.(coords)} +/> + + onconfirmbubbletranslate?.()} + onclose={() => onclosebubbletranslate?.()} + onoutside={() => onclosebubbletranslate?.()} +/> diff --git a/meshchatx/src/frontend/features/messages/components/composer/SendMessageButton.svelte b/meshchatx/src/frontend/features/messages/components/composer/SendMessageButton.svelte index bb69e1aff..66bc7318c 100644 --- a/meshchatx/src/frontend/features/messages/components/composer/SendMessageButton.svelte +++ b/meshchatx/src/frontend/features/messages/components/composer/SendMessageButton.svelte @@ -180,7 +180,10 @@ class="w-full flex items-center gap-2 px-4 py-2 text-sm text-sem-fg hover:bg-sem-surface-muted whitespace-nowrap" onclick={() => setDeliveryMethod("direct")} > - + {t("messages.send_over_direct_link")} +
+ diff --git a/meshchatx/src/frontend/features/paper-message/components/PaperIngestSection.svelte b/meshchatx/src/frontend/features/paper-message/components/PaperIngestSection.svelte new file mode 100644 index 000000000..74d44a4e5 --- /dev/null +++ b/meshchatx/src/frontend/features/paper-message/components/PaperIngestSection.svelte @@ -0,0 +1,76 @@ + + + + +
+
+

+ + Ingest Paper Message +

+
+
+

+ Paste an LXMF, LXMA, or LXM URI to decode and ingest. +

+
+ { + if (e.key === "Enter") oningest?.(); + }} + /> + + {#if cameraSupported} + + {/if} +
+ + {#if !cameraSupported} +

+ {t("messages.camera_not_supported")} +

+ {/if} +
+
diff --git a/meshchatx/src/frontend/features/paper-message/components/PaperPreviewSection.svelte b/meshchatx/src/frontend/features/paper-message/components/PaperPreviewSection.svelte new file mode 100644 index 000000000..a0441254a --- /dev/null +++ b/meshchatx/src/frontend/features/paper-message/components/PaperPreviewSection.svelte @@ -0,0 +1,100 @@ + + + + +{#if generatedUri} +
+
+

Generated QR Code

+
+
+
+
+ +
+
+ +
+
+ +
+
+ {generatedUri} +
+ +
+
+ +
+ + +
+
+
+
+{:else} +
+
+ +
+

No QR Code Generated

+

+ Fill out the message details and click generate to create a signed paper message. +

+
+{/if} diff --git a/meshchatx/src/frontend/features/paper-message/components/PaperScannerModal.svelte b/meshchatx/src/frontend/features/paper-message/components/PaperScannerModal.svelte new file mode 100644 index 000000000..eab78a82f --- /dev/null +++ b/meshchatx/src/frontend/features/paper-message/components/PaperScannerModal.svelte @@ -0,0 +1,52 @@ + + + + +{#if open} +
{ + if (e.target === e.currentTarget) onclose?.(); + }} + onkeydown={(e) => { + if (e.key === "Escape") onclose?.(); + }} + role="presentation" + > + +
+{/if} diff --git a/meshchatx/src/frontend/features/paper-message/index.ts b/meshchatx/src/frontend/features/paper-message/index.ts new file mode 100644 index 000000000..ccae5628f --- /dev/null +++ b/meshchatx/src/frontend/features/paper-message/index.ts @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: 0BSD + +import { registerFeature } from "../../js/registries/featureRegistry.js"; +import { PAPER_MESSAGE_TOOL_ROUTE_NAME, PAPER_MESSAGE_TOOL_ROUTE_PATH } from "./lib/constants.js"; + +/** + * Register paper message feature + */ +export function registerPaperMessageFeature(): void { + registerFeature({ + id: "paper-message", + routes: [ + { + name: PAPER_MESSAGE_TOOL_ROUTE_NAME, + path: PAPER_MESSAGE_TOOL_ROUTE_PATH, + mount: "svelte", + load: () => import("./PaperMessagePage.svelte"), + }, + ], + }); +} diff --git a/meshchatx/src/frontend/features/paper-message/lib/constants.ts b/meshchatx/src/frontend/features/paper-message/lib/constants.ts new file mode 100644 index 000000000..8ce9108aa --- /dev/null +++ b/meshchatx/src/frontend/features/paper-message/lib/constants.ts @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: 0BSD + +export const PAPER_MESSAGE_STORAGE_KEY = "meshchatx_paper_ingested_hashes"; +export const PAPER_MESSAGE_HASH_MAX_LEN = 32; +export const QR_CODE_WIDTH = 256; +export const QR_CODE_MARGIN = 2; +export const QR_ERROR_CORRECTION_LEVEL = "L" as const; +export const LXM_URI_PATTERN = /^lxm(a|f)?:\/\//i; +export const PAPER_MESSAGE_TOOL_ROUTE_NAME = "paper-message"; +export const PAPER_MESSAGE_TOOL_ROUTE_PATH = "/tools/paper-message"; diff --git a/meshchatx/src/frontend/features/paper-message/lib/paperPrint.ts b/meshchatx/src/frontend/features/paper-message/lib/paperPrint.ts new file mode 100644 index 000000000..d8c5ecf9a --- /dev/null +++ b/meshchatx/src/frontend/features/paper-message/lib/paperPrint.ts @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: 0BSD + +import ToastUtils from "../../../js/ToastUtils.js"; +import Utils from "../../../js/Utils.js"; +import type { PaperPrintOptions } from "./types.js"; + +/** + * Open print window and trigger print for the generated QR code canvas + */ +export function printPaperQr({ canvas, destinationHash = "" }: PaperPrintOptions): boolean { + if (!canvas) { + return false; + } + const dataUrl = canvas.toDataURL("image/png"); + if (!dataUrl) { + return false; + } + const printWindow = window.open("", "_blank"); + if (!printWindow) { + ToastUtils.error("Pop-up blocked. Please allow pop-ups to print."); + return false; + } + const safeDataUrl = Utils.escapeHtml(dataUrl); + const safeRecipient = Utils.escapeHtml(destinationHash); + printWindow.document.write(` + + + LXMF Paper Message + + + +

LXMF Paper Message

+ +
Recipient: ${safeRecipient}
+

Scan this code with an LXMF-compatible app to read the message.

+ + +
+ + + + + + + +
+
+ {#if configPath} +

+ {configPath} +

+ {/if} + {#if showRestartReminder} +
+
+ +
+
+ {t("tools.reticulum_config_editor.restart_required")} +
+
+ {t("tools.reticulum_config_editor.restart_description")} +
+
+
+ +
+ {/if} + +
+
+ + + {t("tools.reticulum_config_editor.info")} + + {#if isDirty} + + {t("tools.reticulum_config_editor.unsaved")} + + {/if} +
+
+ +
+
+
+
+
diff --git a/meshchatx/src/frontend/features/reticulum-config-editor/index.ts b/meshchatx/src/frontend/features/reticulum-config-editor/index.ts new file mode 100644 index 000000000..3d0b5a66c --- /dev/null +++ b/meshchatx/src/frontend/features/reticulum-config-editor/index.ts @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: 0BSD + +import { registerFeature } from "../../js/registries/featureRegistry.js"; +import { + RETICULUM_CONFIG_EDITOR_FEATURE_ID, + RETICULUM_CONFIG_EDITOR_ROUTE_NAME, + RETICULUM_CONFIG_EDITOR_ROUTE_PATH, +} from "./lib/constants.js"; + +/** + * Reticulum config raw editor UI. + */ +export function registerReticulumConfigEditorFeature(): void { + registerFeature({ + id: RETICULUM_CONFIG_EDITOR_FEATURE_ID, + routes: [ + { + name: RETICULUM_CONFIG_EDITOR_ROUTE_NAME, + path: RETICULUM_CONFIG_EDITOR_ROUTE_PATH, + mount: "svelte", + load: () => import("./ReticulumConfigEditorPage.svelte"), + }, + ], + }); +} diff --git a/meshchatx/src/frontend/features/reticulum-config-editor/lib/configFormat.ts b/meshchatx/src/frontend/features/reticulum-config-editor/lib/configFormat.ts new file mode 100644 index 000000000..6d5a62f30 --- /dev/null +++ b/meshchatx/src/frontend/features/reticulum-config-editor/lib/configFormat.ts @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: 0BSD + +import { DEFAULT_TAB_INDENT } from "./constants.js"; +import type { TabInsertionResult } from "./types.js"; + +/** + * Inserts tab spaces at the active selection range in text content. + */ +export function insertTabAtSelection( + content: string, + start: number, + end: number, + indent: string = DEFAULT_TAB_INDENT +): TabInsertionResult { + const safeStart = Math.max(0, Math.min(start, content.length)); + const safeEnd = Math.max(safeStart, Math.min(end, content.length)); + const before = content.substring(0, safeStart); + const after = content.substring(safeEnd); + return { + content: `${before}${indent}${after}`, + newCursor: safeStart + indent.length, + }; +} + +/** + * Determines whether editor content has uncommitted changes compared to original content. + */ +export function isConfigDirty(content: string, originalContent: string): boolean { + return content !== originalContent; +} + +/** + * Determines whether the restart reminder banner should be shown. + */ +export function shouldShowRestartReminder( + hasSavedChanges: boolean, + hasPendingInterfaceChanges: boolean +): boolean { + return Boolean(hasSavedChanges || hasPendingInterfaceChanges); +} + +/** + * Extracts a human-readable error message from an API response or error object. + */ +export function extractErrorMessage(error: unknown, fallback: string): string { + if (error && typeof error === "object") { + const err = error as { + response?: { + data?: { + error?: string; + message?: string; + }; + }; + message?: string; + }; + return err.response?.data?.error || err.response?.data?.message || err.message || fallback; + } + if (typeof error === "string" && error.length > 0) { + return error; + } + return fallback; +} diff --git a/meshchatx/src/frontend/features/reticulum-config-editor/lib/constants.ts b/meshchatx/src/frontend/features/reticulum-config-editor/lib/constants.ts new file mode 100644 index 000000000..48437d60d --- /dev/null +++ b/meshchatx/src/frontend/features/reticulum-config-editor/lib/constants.ts @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: 0BSD + +export const RETICULUM_CONFIG_RAW_ENDPOINT = "/api/v1/reticulum/config/raw"; +export const RETICULUM_CONFIG_RESET_ENDPOINT = "/api/v1/reticulum/config/reset"; +export const RETICULUM_RELOAD_ENDPOINT = "/api/v1/reticulum/reload"; + +export const TOAST_ID_CONFIG_SAVE = "rns-config-save"; +export const TOAST_ID_CONFIG_RESTORE = "rns-config-restore"; +export const TOAST_ID_CONFIG_RELOAD = "rns-config-reload"; + +export const DEFAULT_TAB_INDENT = " "; + +export const RETICULUM_CONFIG_EDITOR_FEATURE_ID = "reticulum-config-editor"; +export const RETICULUM_CONFIG_EDITOR_ROUTE_NAME = "reticulum-config-editor"; +export const RETICULUM_CONFIG_EDITOR_ROUTE_PATH = "/tools/reticulum-config-editor"; diff --git a/meshchatx/src/frontend/features/reticulum-config-editor/lib/types.ts b/meshchatx/src/frontend/features/reticulum-config-editor/lib/types.ts new file mode 100644 index 000000000..8aedbb02e --- /dev/null +++ b/meshchatx/src/frontend/features/reticulum-config-editor/lib/types.ts @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: 0BSD + +export type ReticulumConfigRawResponse = { + content?: string; + path?: string; + message?: string; + error?: string; +}; + +export type ReticulumConfigResetResponse = { + content?: string; + path?: string; + message?: string; + error?: string; +}; + +export type ReticulumReloadResponse = { + message?: string; + error?: string; +}; + +export type TabInsertionResult = { + content: string; + newCursor: number; +}; diff --git a/meshchatx/src/frontend/features/rnpath-trace/RNPathTracePage.svelte b/meshchatx/src/frontend/features/rnpath-trace/RNPathTracePage.svelte new file mode 100644 index 000000000..b93fc5831 --- /dev/null +++ b/meshchatx/src/frontend/features/rnpath-trace/RNPathTracePage.svelte @@ -0,0 +1,330 @@ + + + + +
+ + {#if traceResult} + + {/if} + + +
+
+
+
+
+ { + if (e.key === "Enter") runTrace(); + }} + /> +
+ +
+
+ +
+
+ + {#if traceResult || isLoading || error} +
+ {#if isLoading} +
+
+
+
+
+ {t("tools.rnpath_trace.tracing")} +
+
+ {:else if error} +
+
+ +
+
Trace Error
+
+ {error} +
+
+
+
+ {:else if traceResult} +
+
+
+
+
+ {t("tools.rnpath_trace.total_hops")} +
+
+ {traceResult.hops} +
+
+
+
+ {t("tools.rnpath_trace.interface")} +
+
+ {traceResult.interface || "None"} +
+
+ +
+
+ +
+ + +
+ {#each traceResult.path as node, idx (`mobile-${idx}-${node.hash || node.type}`)} +
+
+
+ +
+ {#if idx < traceResult.path.length - 1} +
+ {/if} +
+ +
+
+ {node.name || + (node.type === "unknown" + ? t("tools.rnpath_trace.unknown_hops", { count: node.count }) + : formatTraceHash(node.hash))} +
+ {#if node.hash} +
+ {node.hash} +
+ {/if} + {#if node.interface} +
+ + {node.interface} +
+ {/if} +
+
+ {/each} +
+
+ +
+ + +
+
+ {/if} +
+ {/if} +
+
+
diff --git a/meshchatx/src/frontend/features/rnpath-trace/index.ts b/meshchatx/src/frontend/features/rnpath-trace/index.ts new file mode 100644 index 000000000..69c9b1467 --- /dev/null +++ b/meshchatx/src/frontend/features/rnpath-trace/index.ts @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: 0BSD + +import { registerFeature } from "../../js/registries/featureRegistry.js"; + +/** + * Reticulum network path trace feature registration + */ +export function registerRnpathTraceFeature(): void { + registerFeature({ + id: "rnpath-trace", + routes: [ + { + name: "rnpath-trace", + path: "/rnpath-trace", + mount: "svelte", + load: () => import("./RNPathTracePage.svelte"), + }, + ], + }); +} diff --git a/meshchatx/src/frontend/features/rnpath-trace/lib/constants.ts b/meshchatx/src/frontend/features/rnpath-trace/lib/constants.ts new file mode 100644 index 000000000..2397818b3 --- /dev/null +++ b/meshchatx/src/frontend/features/rnpath-trace/lib/constants.ts @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: 0BSD + +/** API base path for rnpath trace endpoint */ +export const RNPATH_TRACE_API_BASE = "/api/v1/rnpath/trace"; + +/** Node types in path trace */ +export const TRACE_NODE_TYPE = { + LOCAL: "local", + DESTINATION: "destination", + HOP: "hop", + UNKNOWN: "unknown", +} as const; diff --git a/meshchatx/src/frontend/features/rnpath-trace/lib/traceFormat.ts b/meshchatx/src/frontend/features/rnpath-trace/lib/traceFormat.ts new file mode 100644 index 000000000..d4d0fdbe7 --- /dev/null +++ b/meshchatx/src/frontend/features/rnpath-trace/lib/traceFormat.ts @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: 0BSD + +import { isDestinationHash } from "../../../js/meshValidate.js"; +import type { TraceNode } from "./types.js"; + +/** + * Validate destination hash for path trace + */ +export function isValidTraceHash(hash: unknown): boolean { + return isDestinationHash(hash); +} + +/** + * Truncate hash for compact visual display + */ +export function formatTraceHash(hash?: string | null): string { + if (!hash) { + return ""; + } + return `${hash.substring(0, 8)}...`; +} + +/** + * CSS classes for trace node icon container + */ +export function getNodeClass(node: TraceNode): string { + if (node.type === "local") return "bg-blue-600 text-white"; + if (node.type === "destination") return "bg-emerald-600 text-white"; + if (node.type === "unknown") { + return "bg-sem-surface-muted text-gray-400 dark:text-gray-600 border-2 border-dashed border-sem-border shadow-none"; + } + return "bg-indigo-600 text-white"; +} + +/** + * Icon name for trace node + */ +export function getNodeIcon(node: TraceNode): string { + if (node.type === "local") return "home"; + if (node.type === "destination") return "flag-variant"; + if (node.type === "unknown") return "dots-horizontal"; + return "router-wireless"; +} + +/** + * Check if node is unknown hop type + */ +export function isUnknownTraceNode(node?: TraceNode | null): boolean { + return node?.type === "unknown"; +} diff --git a/meshchatx/src/frontend/features/rnpath-trace/lib/types.ts b/meshchatx/src/frontend/features/rnpath-trace/lib/types.ts new file mode 100644 index 000000000..cd0c52ad0 --- /dev/null +++ b/meshchatx/src/frontend/features/rnpath-trace/lib/types.ts @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: 0BSD + +export type TraceNodeType = "local" | "destination" | "hop" | "unknown" | string; + +export type TraceNode = { + type: TraceNodeType; + hash?: string | null; + name?: string | null; + interface?: string | null; + hops?: number; + count?: number; + hop_number?: number; +}; + +export type PathTraceResult = { + destination: string; + hops: number; + path: TraceNode[]; + interface?: string | null; + next_hop?: string | null; + error?: string; +}; diff --git a/meshchatx/src/frontend/features/rnpath/RNPathPage.svelte b/meshchatx/src/frontend/features/rnpath/RNPathPage.svelte new file mode 100644 index 000000000..52391a116 --- /dev/null +++ b/meshchatx/src/frontend/features/rnpath/RNPathPage.svelte @@ -0,0 +1,249 @@ + + + + +
+ + + + +
+ +
+
+ {#each RNPATH_TABS as tName (tName)} + + {/each} +
+
+ + {#if tab === "table"} + + {:else if tab === "rates"} + + {:else if tab === "actions"} + + {/if} +
+
diff --git a/meshchatx/src/frontend/features/rnpath/components/ManagementIdentityPicker.svelte b/meshchatx/src/frontend/features/rnpath/components/ManagementIdentityPicker.svelte new file mode 100644 index 000000000..17cb38caf --- /dev/null +++ b/meshchatx/src/frontend/features/rnpath/components/ManagementIdentityPicker.svelte @@ -0,0 +1,118 @@ + + + + +
+
+ + + +
+ {#if selectedHash} +

+ {selectedHash} +

+ {/if} +
diff --git a/meshchatx/src/frontend/features/rnpath/components/RNPathActionsTab.svelte b/meshchatx/src/frontend/features/rnpath/components/RNPathActionsTab.svelte new file mode 100644 index 000000000..e5371fdfd --- /dev/null +++ b/meshchatx/src/frontend/features/rnpath/components/RNPathActionsTab.svelte @@ -0,0 +1,93 @@ + + + + +
+ +
+

Request Path

+

Broadcast a path request for a destination hash.

+
+ + +
+
+ + +
+

Drop All Via

+

Remove all known paths routed through a specific transport instance.

+
+ + +
+
+ + +
+

Drop Announce Queues

+

Clear all outbound announce packets currently queued on all interfaces.

+ +
+
diff --git a/meshchatx/src/frontend/features/rnpath/components/RNPathRatesTab.svelte b/meshchatx/src/frontend/features/rnpath/components/RNPathRatesTab.svelte new file mode 100644 index 000000000..e1401dbc0 --- /dev/null +++ b/meshchatx/src/frontend/features/rnpath/components/RNPathRatesTab.svelte @@ -0,0 +1,59 @@ + + + + +
+ {#if rateTable.length === 0} +
+ No announce rate data available. +
+ {:else} +
+ {#each rateTable as rate (rate.hash)} +
+
+ + {rate.hash} + + {#if rate.blocked_until > Date.now() / 1000} + + RATE LIMITED + + {/if} +
+
+
+
Last Heard
+
{formatTimeAgo(rate.last)}
+
+
+
Announces
+
{rate.timestamps ? rate.timestamps.length : 0}
+
+
+
Violations
+
+ {rate.rate_violations} +
+
+
+
Rate
+
{calculateRate(rate)} / hr
+
+
+
+ {/each} +
+ {/if} +
diff --git a/meshchatx/src/frontend/features/rnpath/components/RNPathTableTab.svelte b/meshchatx/src/frontend/features/rnpath/components/RNPathTableTab.svelte new file mode 100644 index 000000000..88865ef15 --- /dev/null +++ b/meshchatx/src/frontend/features/rnpath/components/RNPathTableTab.svelte @@ -0,0 +1,283 @@ + + + + +
+
+
{t("rnstatus.remote_query")}
+

{t("rnstatus.remote_query_hint")}

+
+ + +
+ + {#if activeRemoteHash} +
+ + {t("rnstatus.remote_active", { hash: activeRemoteHash })} + + +
+ {/if} +
+ + +
+
+ + +
+ +
+ Hops: + +
+
+
+ Total + {totalItems} +
+
+ Responsive + {responsiveItems} +
+
+ Unresponsive + {unresponsiveItems} +
+
+
+ + {#if pathTable.length === 0} +
+ No paths found matching your criteria. +
+ {:else} +
+ {#each pathTable as path (path.hash)} +
+
+
+ + {path.hash} + + + {path.hops} + {path.hops === 1 ? "hop" : "hops"} + + + {getStateText(path.state)} + +
+
+ via {path.via} on {path.interface} +
+
+
+ Last Updated: + {path.timestamp ? formatDate(path.timestamp) : "Unknown"} +
+
+ Expires: + {formatDate(path.expires)} +
+ {#if path.announce_hash} +
+ Announce Hash: + {path.announce_hash} +
+ {/if} +
+
+ +
+ {/each} +
+ {/if} + + + {#if totalPages > 1} +
+
+ + Page {currentPage} of {totalPages} + +
+
+ Show: + +
+
+ {/if} +
diff --git a/meshchatx/src/frontend/features/rnpath/index.ts b/meshchatx/src/frontend/features/rnpath/index.ts new file mode 100644 index 000000000..1a18af0c1 --- /dev/null +++ b/meshchatx/src/frontend/features/rnpath/index.ts @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: 0BSD + +import { registerFeature } from "../../js/registries/featureRegistry.js"; + +export function registerRNPathFeature(): void { + registerFeature({ + id: "rnpath", + routes: [ + { + name: "rnpath", + path: "/rnpath", + mount: "svelte", + load: () => import("./RNPathPage.svelte"), + }, + ], + }); +} + +export const registerRnpathFeature = registerRNPathFeature; diff --git a/meshchatx/src/frontend/features/rnpath/lib/constants.ts b/meshchatx/src/frontend/features/rnpath/lib/constants.ts new file mode 100644 index 000000000..96e2d28a4 --- /dev/null +++ b/meshchatx/src/frontend/features/rnpath/lib/constants.ts @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: 0BSD + +/** Default timeout in seconds for remote queries */ +export const DEFAULT_REMOTE_TIMEOUT = 15; + +/** Default number of path items per page */ +export const DEFAULT_ITEMS_PER_PAGE = 50; + +/** Allowed items per page options */ +export const ITEMS_PER_PAGE_OPTIONS = [20, 50, 100, 250] as const; + +/** Path state representing responsive */ +export const PATH_STATE_RESPONSIVE = 2; + +/** Path state representing unresponsive */ +export const PATH_STATE_UNRESPONSIVE = 1; + +/** Hex character length for 16 byte Reticulum destination hashes */ +export const DESTINATION_HASH_HEX_LENGTH = 32; + +/** Available navigation tabs for RNPath page */ +export const RNPATH_TABS = ["table", "rates", "actions"] as const; diff --git a/meshchatx/src/frontend/features/rnpath/lib/pathQuery.ts b/meshchatx/src/frontend/features/rnpath/lib/pathQuery.ts new file mode 100644 index 000000000..2e0218735 --- /dev/null +++ b/meshchatx/src/frontend/features/rnpath/lib/pathQuery.ts @@ -0,0 +1,164 @@ +// SPDX-License-Identifier: 0BSD + +import Utils from "../../../js/Utils.js"; +import { + DEFAULT_REMOTE_TIMEOUT, + PATH_STATE_RESPONSIVE, + PATH_STATE_UNRESPONSIVE, + DESTINATION_HASH_HEX_LENGTH, +} from "./constants.js"; +import type { PathEntry, RateEntry, RemoteQueryParams, PathQueryOptions } from "./types.js"; + +/** Build remote query params if a remote hash is provided */ +export function buildRemoteQueryParams( + remoteHash?: string, + identityPath?: string, + remoteTimeout?: number +): RemoteQueryParams { + const remote = (remoteHash || "").trim(); + if (!remote) { + return {}; + } + const params: RemoteQueryParams = { remote }; + if (identityPath) { + params.identity_path = identityPath; + } + if (remoteTimeout != null && remoteTimeout > 0) { + params.timeout = remoteTimeout; + } + return params; +} + +/** Parse and validate hops filter value */ +export function parseHops(filterHops: unknown): number | undefined { + if (filterHops === null || filterHops === undefined || filterHops === "") { + return undefined; + } + const parsed = Number(filterHops); + if (!Number.isFinite(parsed)) { + throw new Error("Invalid hops"); + } + return parsed; +} + +/** Build full path table query parameters */ +export function buildPathQueryParams(options: PathQueryOptions): Record { + const hops = parseHops(options.filterHops); + const remoteParams = buildRemoteQueryParams( + options.remoteHash, + options.identityPath, + options.remoteTimeout ?? DEFAULT_REMOTE_TIMEOUT + ); + const params: Record = { + page: options.currentPage, + limit: options.itemsPerPage, + ...remoteParams, + }; + if (options.searchQuery && options.searchQuery.trim()) { + params.search = options.searchQuery.trim(); + } + if (options.filterInterface) { + params.interface = options.filterInterface; + } + if (hops !== undefined) { + params.hops = hops; + } + return params; +} + +/** Extract sorted unique interface names from local and discovered interfaces */ +export function extractInterfaceNames( + interfacesRes?: { interfaces?: Record } | null, + discRes?: { active?: Array<{ name?: string }>; interfaces?: Array<{ name?: string } | string> } | null +): string[] { + const nameSet = new Set(); + const ifaces = interfacesRes?.interfaces; + if (ifaces && typeof ifaces === "object") { + for (const name of Object.keys(ifaces)) { + if (name) { + nameSet.add(name); + } + } + } + for (const row of discRes?.active || []) { + if (row?.name) { + nameSet.add(String(row.name)); + } + } + for (const d of discRes?.interfaces || []) { + if (d && typeof d === "object" && d.name) { + nameSet.add(String(d.name)); + } else if (typeof d === "string" && d) { + nameSet.add(d); + } + } + return Array.from(nameSet).sort(); +} + +/** Calculate hourly announce rate */ +export function calculateRate(rate?: Partial | null): string { + const timestamps = rate?.timestamps || []; + if (timestamps.length === 0) { + return "0.00"; + } + const startTs = timestamps[0]; + const span = Math.max(Date.now() / 1000 - startTs, 3600.0); + const spanHours = span / 3600.0; + return (timestamps.length / spanHours).toFixed(2); +} + +/** Return CSS badge classes for path responsive state */ +export function getStateColor(state?: number): string { + if (state === PATH_STATE_RESPONSIVE) { + return "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300"; + } + if (state === PATH_STATE_UNRESPONSIVE) { + return "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300"; + } + return "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-400"; +} + +/** Return text label for path responsive state */ +export function getStateText(state?: number): string { + if (state === PATH_STATE_RESPONSIVE) { + return "RESPONSIVE"; + } + if (state === PATH_STATE_UNRESPONSIVE) { + return "UNRESPONSIVE"; + } + return "UNKNOWN"; +} + +/** Format unix timestamp in seconds as local date string */ +export function formatDate(ts?: number): string { + if (!ts) { + return "Unknown"; + } + return new Date(ts * 1000).toLocaleString(); +} + +/** Format unix timestamp in seconds as relative elapsed time */ +export function formatTimeAgo(ts?: number): string { + if (!ts) { + return "Unknown"; + } + const secondsAgo = Math.max(0, Math.floor(Date.now() / 1000 - ts)); + return Utils.formatSeconds(secondsAgo); +} + +/** Truncate hash for compact display */ +export function shortHash(hash?: string): string { + if (!hash || hash.length < 10) { + return hash || ""; + } + return `${hash.slice(0, 8)}…`; +} + +/** Validate whether string is a 32 char hex hash */ +export function isValidDestinationHash(hash?: string): boolean { + if (!hash || typeof hash !== "string") { + return false; + } + const trimmed = hash.trim(); + return trimmed.length === DESTINATION_HASH_HEX_LENGTH && /^[0-9a-fA-F]{32}$/.test(trimmed); +} diff --git a/meshchatx/src/frontend/features/rnpath/lib/types.ts b/meshchatx/src/frontend/features/rnpath/lib/types.ts new file mode 100644 index 000000000..b638b8d60 --- /dev/null +++ b/meshchatx/src/frontend/features/rnpath/lib/types.ts @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: 0BSD + +/** Path entry in the Reticulum path table */ +export type PathEntry = { + hash: string; + hops: number; + via: string; + interface: string; + timestamp?: number; + expires?: number; + announce_hash?: string; + state?: number; + [key: string]: unknown; +}; + +/** Announce rate tracking entry */ +export type RateEntry = { + hash: string; + last: number; + timestamps: number[]; + rate_violations: number; + blocked_until: number; + [key: string]: unknown; +}; + +/** Management identity record */ +export type ManagementIdentity = { + name: string; + hash: string; + path: string; + [key: string]: unknown; +}; + +/** Remote query parameters */ +export type RemoteQueryParams = { + remote?: string; + identity_path?: string; + timeout?: number; +}; + +/** Options for querying path table */ +export type PathQueryOptions = { + searchQuery?: string; + filterInterface?: string; + filterHops?: number | string | null; + currentPage: number; + itemsPerPage: number; + remoteHash?: string; + identityPath?: string; + remoteTimeout?: number; +}; + +/** Path table API response */ +export type PathTableResponse = { + table: PathEntry[]; + total: number; + responsive: number; + unresponsive: number; + remote?: string; + [key: string]: unknown; +}; + +/** Announce rates API response */ +export type RateTableResponse = { + rates: RateEntry[]; + remote?: string; + [key: string]: unknown; +}; + +/** Active tab selection */ +export type RNPathTab = "table" | "rates" | "actions"; diff --git a/meshchatx/src/frontend/features/rnprobe/RNProbePage.svelte b/meshchatx/src/frontend/features/rnprobe/RNProbePage.svelte new file mode 100644 index 000000000..976895965 --- /dev/null +++ b/meshchatx/src/frontend/features/rnprobe/RNProbePage.svelte @@ -0,0 +1,294 @@ + + + + +
+ +
+
+
+
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ {#if !isRunning} + + {:else} + + {/if} + +
+ + {#if summary} +
+
{t("rnprobe.summary")}:
+
+ {t("rnprobe.sent")}: {summary.sent}, {t("rnprobe.delivered")}: {summary.delivered}, {t("rnprobe.timeouts")}: {summary.timeouts}, {t("rnprobe.failed")}: {summary.failed} +
+
+ {/if} +
+ +
+
+
+
+ {t("rnprobe.probe_results")} +
+
+ {t("rnprobe.probe_responses_realtime")} +
+
+
+ +
+ {#if results.length === 0} +
+ {t("rnprobe.no_probes_yet")} +
+ {:else} + {#each results as result, index (`${index}-${result.probe_number}`)} +
+
+ + {t("rnprobe.probe_number", { number: result.probe_number })} + + ({result.size} {t("rnprobe.bytes")}) + -> + {result.destination} +
+ {#if result.via || result.interface} +
+ {result.via || ""}{result.interface || ""} +
+ {/if} + {#if isProbeDelivered(result)} +
+
{t("rnprobe.summary")}: {t("rnprobe.delivered")}
+
{t("rnprobe.hops")}: {result.hops}
+
{t("rnprobe.rtt")}: {result.rtt_string}
+ {#if result.reception_stats} +
+ {#if result.reception_stats.rssi != null} + {t("rnprobe.rssi")}: {result.reception_stats.rssi} dBm + {/if} + {#if result.reception_stats.snr != null} + {t("rnprobe.snr")}: {result.reception_stats.snr} dB + {/if} + {#if result.reception_stats.quality != null} + {t("rnprobe.quality")}: {result.reception_stats.quality}% + {/if} +
+ {/if} +
+ {:else if isProbeTimeout(result)} +
+ {t("rnprobe.summary")}: {t("rnprobe.timeout")} +
+ {:else} +
+ {t("rnprobe.summary")}: {t("rnprobe.failed")} +
+ {/if} +
+ {/each} + {/if} +
+
+
+
+
diff --git a/meshchatx/src/frontend/features/rnprobe/index.ts b/meshchatx/src/frontend/features/rnprobe/index.ts new file mode 100644 index 000000000..2bb3b092a --- /dev/null +++ b/meshchatx/src/frontend/features/rnprobe/index.ts @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: 0BSD + +import { registerFeature } from "../../js/registries/featureRegistry.js"; + +/** + * Reticulum network probe feature registration + */ +export function registerRnprobeFeature(): void { + registerFeature({ + id: "rnprobe", + routes: [ + { + name: "rnprobe", + path: "/rnprobe", + mount: "svelte", + load: () => import("./RNProbePage.svelte"), + }, + ], + }); +} diff --git a/meshchatx/src/frontend/features/rnprobe/lib/constants.ts b/meshchatx/src/frontend/features/rnprobe/lib/constants.ts new file mode 100644 index 000000000..58168a8ef --- /dev/null +++ b/meshchatx/src/frontend/features/rnprobe/lib/constants.ts @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: 0BSD + +/** Default probe payload size in bytes */ +export const DEFAULT_PROBE_SIZE = 16; + +/** Minimum probe payload size in bytes */ +export const MIN_PROBE_SIZE = 1; + +/** Maximum probe payload size in bytes */ +export const MAX_PROBE_SIZE = 1024; + +/** Default number of probe packets */ +export const DEFAULT_PROBE_COUNT = 1; + +/** Minimum number of probe packets */ +export const MIN_PROBES = 1; + +/** Maximum number of probe packets */ +export const MAX_PROBES = 50; + +/** Default wait between probes in seconds */ +export const DEFAULT_PROBE_WAIT = 0; + +/** Default full destination aspect name */ +export const DEFAULT_PROBE_FULL_NAME = "lxmf.delivery"; + +/** API endpoint for rnprobe */ +export const RNPROBE_API_ENDPOINT = "/api/v1/rnprobe"; diff --git a/meshchatx/src/frontend/features/rnprobe/lib/probeFormat.ts b/meshchatx/src/frontend/features/rnprobe/lib/probeFormat.ts new file mode 100644 index 000000000..0d4ce12ed --- /dev/null +++ b/meshchatx/src/frontend/features/rnprobe/lib/probeFormat.ts @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: 0BSD + +import { isDestinationHash } from "../../../js/meshValidate.js"; +import type { ProbeApiResponse, ProbeResultItem, ProbeSummary } from "./types.js"; + +/** + * Validate destination hash for rnprobe + */ +export function isValidProbeDestinationHash(hash: unknown): boolean { + return isDestinationHash(hash); +} + +/** + * Validate full destination name + */ +export function isValidProbeFullName(fullName: unknown): boolean { + if (typeof fullName !== "string") { + return false; + } + return fullName.trim().length > 0; +} + +/** + * Parse probe summary from api response payload + */ +export function parseProbeSummary(response: ProbeApiResponse): ProbeSummary { + return { + sent: Number(response.sent) || 0, + delivered: Number(response.delivered) || 0, + timeouts: Number(response.timeouts) || 0, + failed: Number(response.failed) || 0, + }; +} + +/** + * Determine result item status category + */ +export function isProbeDelivered(item: ProbeResultItem): boolean { + return item.status === "delivered"; +} + +export function isProbeTimeout(item: ProbeResultItem): boolean { + return item.status === "timeout"; +} diff --git a/meshchatx/src/frontend/features/rnprobe/lib/types.ts b/meshchatx/src/frontend/features/rnprobe/lib/types.ts new file mode 100644 index 000000000..ab653582d --- /dev/null +++ b/meshchatx/src/frontend/features/rnprobe/lib/types.ts @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: 0BSD + +export type ProbeReceptionStats = { + rssi?: number | null; + snr?: number | null; + quality?: number | null; +}; + +export type ProbeResultItem = { + probe_number: number; + size: number; + destination: string; + via?: string; + interface?: string; + status: "delivered" | "timeout" | "failed" | string; + hops?: number; + rtt?: number; + rtt_string?: string; + reception_stats?: ProbeReceptionStats | null; +}; + +export type ProbeSummary = { + sent: number; + delivered: number; + timeouts: number; + failed: number; +}; + +export type ProbeRequestPayload = { + destination_hash: string; + full_name: string; + size: number; + probes: number; + wait: number; +}; + +export type ProbeApiResponse = { + results?: ProbeResultItem[]; + sent?: number; + delivered?: number; + timeouts?: number; + failed?: number; + message?: string; +}; diff --git a/meshchatx/src/frontend/features/rnstatus/RNStatusPage.svelte b/meshchatx/src/frontend/features/rnstatus/RNStatusPage.svelte new file mode 100644 index 000000000..0b0830d26 --- /dev/null +++ b/meshchatx/src/frontend/features/rnstatus/RNStatusPage.svelte @@ -0,0 +1,168 @@ + + + + +
+ + + + +
+
+ + + + + + + +
+
+
diff --git a/meshchatx/src/frontend/features/rnstatus/components/RNStatusFilters.svelte b/meshchatx/src/frontend/features/rnstatus/components/RNStatusFilters.svelte new file mode 100644 index 000000000..f46fec948 --- /dev/null +++ b/meshchatx/src/frontend/features/rnstatus/components/RNStatusFilters.svelte @@ -0,0 +1,71 @@ + + + + +
+
+ + +
+ + +
+
+
diff --git a/meshchatx/src/frontend/features/rnstatus/components/RNStatusRemoteQuery.svelte b/meshchatx/src/frontend/features/rnstatus/components/RNStatusRemoteQuery.svelte new file mode 100644 index 000000000..a9ba57a43 --- /dev/null +++ b/meshchatx/src/frontend/features/rnstatus/components/RNStatusRemoteQuery.svelte @@ -0,0 +1,174 @@ + + + + +
+

+ {t("rnstatus.remote_query")} +

+

+ {t("rnstatus.remote_query_hint")} +

+
+ + +
+ +
+
+ + + +
+ {#if selectedHash} +

+ {selectedHash} +

+ {/if} +
+ + {#if activeRemoteHash} +
+ + {t("rnstatus.remote_active", { hash: activeRemoteHash })} + + +
+ {/if} +
diff --git a/meshchatx/src/frontend/features/rnstatus/components/RNStatusSummary.svelte b/meshchatx/src/frontend/features/rnstatus/components/RNStatusSummary.svelte new file mode 100644 index 000000000..3f75dce12 --- /dev/null +++ b/meshchatx/src/frontend/features/rnstatus/components/RNStatusSummary.svelte @@ -0,0 +1,270 @@ + + + + +{#if showSummary} +
+ {#if linkCount !== null} +
+
+ {t("rnstatus.active_links_label")} +
+
+ {formatInt(linkCount)} +
+ {#if activeLinkCount !== null} +
+ {t("rnstatus.active_links_detail", { + active: formatInt(activeLinkCount), + total: formatInt(linkCount), + })} +
+ {/if} +
+ {/if} + + {#if rnsVersion} +
+
+ {t("rnstatus.rns_version")} +
+
+ {rnsVersion} +
+
+ {/if} + + {#if transportUptimeStr} +
+
+ {t("rnstatus.uptime")} +
+
+ {transportUptimeStr} +
+
+ {/if} + + {#if totals} +
+
+ {t("rnstatus.totals")} +
+
+
TX: {totals.tx_bytes_str} {totals.tx_speed_str}
+
RX: {totals.rx_bytes_str} {totals.rx_speed_str}
+
+
+ {/if} + + {#if blackholeEnabled !== null} +
+
+ {t("rnstatus.blackhole_heading")} +
+
+ {blackholeEnabled ? t("rnstatus.blackhole_publishing") : t("rnstatus.blackhole_inactive")} +
+
+ {t("rnstatus.blackhole_identities", { count: formatInt(blackholeCount) })} +
+
+ {/if} + + {#if rssStr} +
+
+ {t("rnstatus.memory_rss")} +
+
+ {rssStr} +
+
+ {/if} +
+{/if} + +{#if totalsAnnounces || totalsPathRequests} +
+ {#if totalsAnnounces} +
+

+ {t("rnstatus.transport_announces")} +

+
+
TX: {totalsAnnounces.tx_bytes_str} {totalsAnnounces.tx_speed_str}
+
RX: {totalsAnnounces.rx_bytes_str} {totalsAnnounces.rx_speed_str}
+
+
+ {/if} + {#if totalsPathRequests} +
+

+ {t("rnstatus.transport_path_requests")} +

+
+
TX: {totalsPathRequests.tx_bytes_str} {totalsPathRequests.tx_speed_str}
+
RX: {totalsPathRequests.rx_bytes_str} {totalsPathRequests.rx_speed_str}
+
+
+ {/if} +
+{/if} + +{#if queueRows.length > 0} +
+

+ {t("rnstatus.queue_pressure")} +

+
+ {#each queueRows as queue (queue.key)} +
+
+ {queue.label} +
+
+ {queue.pressure || "-"} +
+
+ {#if queue.packets} + {t("rnstatus.queue_packets", { count: queue.packets })} + {/if} + {#if queue.dropped} + {t("rnstatus.queue_dropped", { count: queue.dropped })} + {/if} +
+
+ {/each} +
+
+{/if} + +{#if transportId || networkId || probeResponder} +
+

+ {t("rnstatus.transport_instance")} +

+
+ {#if transportId} +
+
{t("rnstatus.transport_id")}
+
{transportId}
+ +
+ {/if} + {#if networkId} +
+
{t("rnstatus.network_id")}
+
{networkId}
+ +
+ {/if} + {#if probeResponder} +
+
{t("rnstatus.probe_responder")}
+
{probeResponder}
+ +
+ {/if} +
+
+{/if} + +{#if i2pInterfaces.length > 0} +
+

+ {t("rnstatus.i2p_address")} +

+ {#each i2pInterfaces as iface (`i2p-${iface.name}`)} +
+
{iface.name}
+ {#if iface.i2p_b32} +
+
{t("rnstatus.i2p_address")}
+
{iface.i2p_b32}
+ +
+ {:else if iface.i2p_connectable} +

+ {t("rnstatus.i2p_waiting")} +

+ {:else} +

+ {t("rnstatus.i2p_not_published")} +

+ {/if} +
+ + {iface.i2p_connectable ? t("rnstatus.i2p_connectable_yes") : t("rnstatus.i2p_connectable_no")} + + {#if iface.i2p_tunnel_state} + + {iface.i2p_tunnel_state} + + {/if} +
+
+ {/each} +
+{/if} + +{#if blackholeSources.length > 0} +
+

+ {t("rnstatus.blackhole_sources")} +

+
+ {#each blackholeSources as source (source)} +
+ {source} +
+ {/each} +
+
+{/if} diff --git a/meshchatx/src/frontend/features/rnstatus/components/RNStatusTable.svelte b/meshchatx/src/frontend/features/rnstatus/components/RNStatusTable.svelte new file mode 100644 index 000000000..dd2a8f46e --- /dev/null +++ b/meshchatx/src/frontend/features/rnstatus/components/RNStatusTable.svelte @@ -0,0 +1,85 @@ + + + + +{#if interfaces.length === 0 && !isLoading && !reloadingRns} +
+ {t("rnstatus.no_interfaces_found")} +
+{:else} +
+ {#each interfaces as iface (iface.name)} +
+
+
+
+

+ {iface.name} +

+ {#if iface.discovered} + + {t("rnstatus.discovered")} + + {/if} +
+ {#if iface.type} +
+ {iface.type} +
+ {/if} +
+ + {iface.status} + +
+ + {#if iface.i2p_b32} +
+
{t("rnstatus.i2p_address")}
+
{iface.i2p_b32}
+ +
+ {/if} + +
+ {#each buildInterfaceStatRows(iface) as row (`${iface.name}-${row.key}`)} +
+
{row.label}
+
+ {row.value} +
+
+ {/each} +
+
+ {/each} +
+{/if} diff --git a/meshchatx/src/frontend/features/rnstatus/index.ts b/meshchatx/src/frontend/features/rnstatus/index.ts new file mode 100644 index 000000000..12ae51992 --- /dev/null +++ b/meshchatx/src/frontend/features/rnstatus/index.ts @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: 0BSD + +import { registerFeature } from "../../js/registries/featureRegistry.js"; + +/** + * Reticulum network status feature registration. + */ +export function registerRNStatusFeature(): void { + registerFeature({ + id: "rnstatus", + routes: [ + { + name: "rnstatus", + path: "/rnstatus", + mount: "svelte", + load: () => import("./RNStatusPage.svelte"), + }, + ], + }); +} diff --git a/meshchatx/src/frontend/features/rnstatus/lib/constants.ts b/meshchatx/src/frontend/features/rnstatus/lib/constants.ts new file mode 100644 index 000000000..efd710398 --- /dev/null +++ b/meshchatx/src/frontend/features/rnstatus/lib/constants.ts @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: 0BSD + +export interface SortOption { + value: string; + labelKey: string; +} + +export const SORT_OPTIONS: readonly SortOption[] = [ + { value: "", labelKey: "rnstatus.none" }, + { value: "bitrate", labelKey: "rnstatus.bitrate" }, + { value: "rx", labelKey: "rnstatus.rx_bytes" }, + { value: "tx", labelKey: "rnstatus.tx_bytes" }, + { value: "traffic", labelKey: "rnstatus.total_traffic" }, + { value: "announces", labelKey: "rnstatus.announces" }, + { value: "prx", labelKey: "rnstatus.path_requests" }, + { value: "held", labelKey: "rnstatus.held_announces" }, + { value: "gravity", labelKey: "rnstatus.gravity" }, +] as const; + +export const DEFAULT_REMOTE_TIMEOUT = 15; + +export const QUEUE_LABEL_KEYS: Record = { + total: "rnstatus.queue_total", + data: "rnstatus.queue_data", + announce: "rnstatus.queue_announce", + path_request: "rnstatus.queue_path_request", + ingress_limiter: "rnstatus.queue_ingress_limiter", +}; diff --git a/meshchatx/src/frontend/features/rnstatus/lib/statusFormat.ts b/meshchatx/src/frontend/features/rnstatus/lib/statusFormat.ts new file mode 100644 index 000000000..10e1664d0 --- /dev/null +++ b/meshchatx/src/frontend/features/rnstatus/lib/statusFormat.ts @@ -0,0 +1,189 @@ +// SPDX-License-Identifier: 0BSD + +import { t } from "../../../js/i18n.js"; +import ToastUtils from "../../../js/ToastUtils.js"; +import { QUEUE_LABEL_KEYS } from "./constants.js"; +import type { + InterfaceStatRow, + QueueDisplayRow, + RNStatusInterface, + RNStatusQueues, + RNStatusResponse, +} from "./types.js"; + +/** + * Format integer values with commas for display. + */ +export function formatInt(value: unknown): string { + if (value === null || value === undefined || value === "") { + return ""; + } + const n = Number(value); + if (Number.isNaN(n)) { + return String(value); + } + return n.toLocaleString(); +} + +/** + * Shorten hash to 8 characters with ellipsis. + */ +export function shortHash(hash: string | null | undefined): string { + if (!hash || hash.length < 10) return hash || ""; + return `${hash.slice(0, 8)}...`; +} + +/** + * Copy text to clipboard and show toast feedback. + */ +export async function copyText(value: string | undefined): Promise { + if (!value) { + return false; + } + try { + await navigator.clipboard.writeText(value); + ToastUtils.success(t("common.copied")); + return true; + } catch { + ToastUtils.error(t("common.failed_to_copy")); + return false; + } +} + +/** + * Check whether the status payload contains any summary card data. + */ +export function hasStatusSummary(data: Partial): boolean { + return Boolean( + (data.link_count !== null && data.link_count !== undefined) || + data.transport_uptime_str || + data.totals || + (data.blackhole_enabled !== null && data.blackhole_enabled !== undefined) || + data.rns_version || + data.rss_str + ); +} + +/** + * Filter interfaces that have I2P attributes. + */ +export function filterI2pInterfaces(interfaces: RNStatusInterface[]): RNStatusInterface[] { + return (interfaces || []).filter( + (iface) => Boolean(iface.i2p_b32) || iface.i2p_connectable === true || iface.i2p_connectable === false + ); +} + +/** + * Extract queue display rows from queues status response. + */ +export function extractQueueRows(queues?: RNStatusQueues | null): QueueDisplayRow[] { + const list = queues?.queues; + if (!Array.isArray(list)) { + return []; + } + return list.map((queue) => { + const key = queue.name; + const labelKey = QUEUE_LABEL_KEYS[key]; + const label = labelKey ? t(labelKey) : key; + return { + key, + label, + pressure: queue.pressure, + packets: queue.packets, + dropped: queue.dropped, + }; + }); +} + +/** + * Build key-label-value rows for an interface card. + */ +export function buildInterfaceStatRows(iface: RNStatusInterface): InterfaceStatRow[] { + const rows: InterfaceStatRow[] = []; + const add = (key: string, value: string | number | undefined | null) => { + if (value === undefined || value === null || value === "") { + return; + } + rows.push({ key, label: t(`rnstatus.${key}`), value }); + }; + + add("mode", iface.mode); + add("bitrate", iface.bitrate); + add("rx_bytes", iface.rx_bytes_str); + add("tx_bytes", iface.tx_bytes_str); + add("rx_speed", iface.rx_speed_str); + add("tx_speed", iface.tx_speed_str); + if (iface.clients !== undefined && iface.clients !== null) { + add("clients", formatInt(iface.clients)); + } + if (iface.peers !== undefined && iface.peers !== null) { + add("peers", `${formatInt(iface.peers)} ${t("rnstatus.peers_reachable")}`); + } + add("noise_floor", iface.noise_floor); + add("interference", iface.interference); + add("interference_last", iface.interference_last); + add("cpu_load", iface.cpu_load); + add("cpu_temp", iface.cpu_temp); + add("memory_load", iface.mem_load); + if (iface.battery_percent !== undefined && iface.battery_percent !== null) { + const battery = `${formatInt(iface.battery_percent)}%`; + add("battery", iface.battery_state ? `${battery} (${iface.battery_state})` : battery); + } + add("network", iface.network_name); + if (iface.incoming_announce_frequency !== undefined && iface.incoming_announce_frequency !== null) { + add("incoming_announces", `${iface.incoming_announce_frequency}/s`); + } + if (iface.outgoing_announce_frequency !== undefined && iface.outgoing_announce_frequency !== null) { + add("outgoing_announces", `${iface.outgoing_announce_frequency}/s`); + } + if (iface.incoming_pr_frequency !== undefined && iface.incoming_pr_frequency !== null) { + add("path_requests_in", `${iface.incoming_pr_frequency}/s`); + } + if (iface.outgoing_pr_frequency !== undefined && iface.outgoing_pr_frequency !== null) { + add("path_requests_out", `${iface.outgoing_pr_frequency}/s`); + } + add("held_announces", iface.held_announces); + add("announce_queue", iface.announce_queue); + add("announce_totals", iface.announce_totals); + add("path_request_totals", iface.path_request_totals); + add("announce_rx_bytes", iface.announce_rx_bytes_str); + add("announce_tx_bytes", iface.announce_tx_bytes_str); + add("path_rx_bytes", iface.path_rx_bytes_str); + add("path_tx_bytes", iface.path_tx_bytes_str); + if (iface.announce_flow_rx_pct !== undefined && iface.announce_flow_rx_pct !== null) { + add("announce_flow_rx", t("rnstatus.flow_share", { pct: iface.announce_flow_rx_pct })); + } + if (iface.announce_flow_tx_pct !== undefined && iface.announce_flow_tx_pct !== null) { + add("announce_flow_tx", t("rnstatus.flow_share", { pct: iface.announce_flow_tx_pct })); + } + if (iface.path_flow_rx_pct !== undefined && iface.path_flow_rx_pct !== null) { + add("path_flow_rx", t("rnstatus.flow_share", { pct: iface.path_flow_rx_pct })); + } + if (iface.path_flow_tx_pct !== undefined && iface.path_flow_tx_pct !== null) { + add("path_flow_tx", t("rnstatus.flow_share", { pct: iface.path_flow_tx_pct })); + } + add("announce_rate_limits", iface.announce_rate_limits); + add("violations", iface.violations); + add("filter_hits", iface.filter_hits); + if (Array.isArray(iface.blocked_ip_list) && iface.blocked_ip_list.length > 0) { + add("blocked_ip_list", iface.blocked_ip_list.join(", ")); + } + if (iface.airtime) { + add("airtime", `${iface.airtime.short}% (15s), ${iface.airtime.long}% (1h)`); + } + if (iface.channel_load) { + add("channel_load", `${iface.channel_load.short}% (15s), ${iface.channel_load.long}% (1h)`); + } + add("i2p_tunnel", iface.i2p_tunnel_state); + add("switch_id", iface.switch_id); + add("endpoint_id", iface.endpoint_id); + add("via_switch", iface.via_switch_id); + add("access_ifac", iface.ifac_access); + add("parent_interface", iface.parent_interface); + add("autoconnect_source", iface.autoconnect_source); + add("blocked_ips", iface.blocked_ips); + add("burst", iface.burst); + add("path_burst", iface.path_burst); + + return rows; +} diff --git a/meshchatx/src/frontend/features/rnstatus/lib/statusPoller.ts b/meshchatx/src/frontend/features/rnstatus/lib/statusPoller.ts new file mode 100644 index 000000000..60a016c76 --- /dev/null +++ b/meshchatx/src/frontend/features/rnstatus/lib/statusPoller.ts @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: 0BSD + +import type { ManagementIdentityItem, RNStatusQueryParams, RNStatusResponse } from "./types.js"; + +/** + * Fetch Reticulum network status from backend API. + */ +export async function fetchRNStatus(params: RNStatusQueryParams): Promise { + const queryParams: Record = { + include_link_stats: params.include_link_stats, + show_all: params.show_all, + }; + if (params.sorting) { + queryParams.sorting = params.sorting; + } + const remote = (params.remote || "").trim(); + if (remote) { + queryParams.remote = remote; + if (params.identity_path) { + queryParams.identity_path = params.identity_path; + } + if (params.timeout) { + queryParams.timeout = params.timeout; + } + } + const response = await window.api.get("/api/v1/rnstatus", { params: queryParams }); + return (response?.data || {}) as RNStatusResponse; +} + +/** + * Fetch management identities list. + */ +export async function fetchManagementIdentities(): Promise { + const response = await window.api.get("/api/v1/reticulum/management-identities"); + const list = response?.data?.identities; + return Array.isArray(list) ? list : []; +} + +/** + * Create a new management identity. + */ +export async function createManagementIdentity(name: string): Promise { + const response = await window.api.post("/api/v1/reticulum/management-identities", { + name: name.trim(), + }); + return (response?.data?.identity as ManagementIdentityItem) || null; +} diff --git a/meshchatx/src/frontend/features/rnstatus/lib/types.ts b/meshchatx/src/frontend/features/rnstatus/lib/types.ts new file mode 100644 index 000000000..5947e3040 --- /dev/null +++ b/meshchatx/src/frontend/features/rnstatus/lib/types.ts @@ -0,0 +1,134 @@ +// SPDX-License-Identifier: 0BSD + +export interface RNStatusTrafficStats { + rx_bytes_str?: string; + tx_bytes_str?: string; + rx_speed_str?: string; + tx_speed_str?: string; +} + +export interface RNStatusTotals extends RNStatusTrafficStats { + announces?: RNStatusTrafficStats; + path_requests?: RNStatusTrafficStats; +} + +export interface RNStatusQueueItem { + name: string; + pressure?: string; + packets?: number; + dropped?: number; +} + +export interface RNStatusQueues { + queues?: RNStatusQueueItem[]; +} + +export interface RNStatusAirtime { + short: number | string; + long: number | string; +} + +export interface RNStatusInterface { + name: string; + status?: string; + discovered?: boolean; + type?: string; + mode?: string; + bitrate?: string; + rx_bytes_str?: string; + tx_bytes_str?: string; + rx_speed_str?: string; + tx_speed_str?: string; + clients?: number; + peers?: number; + noise_floor?: string | number; + interference?: string | number; + interference_last?: string | number; + cpu_load?: string | number; + cpu_temp?: string | number; + mem_load?: string | number; + battery_percent?: number; + battery_state?: string; + network_name?: string; + incoming_announce_frequency?: number; + outgoing_announce_frequency?: number; + incoming_pr_frequency?: number; + outgoing_pr_frequency?: number; + held_announces?: number | string; + announce_queue?: number | string; + announce_totals?: number | string; + path_request_totals?: number | string; + announce_rx_bytes_str?: string; + announce_tx_bytes_str?: string; + path_rx_bytes_str?: string; + path_tx_bytes_str?: string; + announce_flow_rx_pct?: number | string; + announce_flow_tx_pct?: number | string; + path_flow_rx_pct?: number | string; + path_flow_tx_pct?: number | string; + announce_rate_limits?: string; + violations?: number | string; + filter_hits?: number | string; + blocked_ip_list?: string[]; + airtime?: RNStatusAirtime; + channel_load?: RNStatusAirtime; + i2p_b32?: string; + i2p_connectable?: boolean; + i2p_tunnel_state?: string; + switch_id?: string; + endpoint_id?: string; + via_switch_id?: string; + ifac_access?: string; + parent_interface?: string; + autoconnect_source?: string; + blocked_ips?: string | number; + burst?: string | number; + path_burst?: string | number; +} + +export interface RNStatusResponse { + interfaces?: RNStatusInterface[]; + link_count?: number | null; + active_link_count?: number | null; + blackhole_enabled?: boolean | null; + blackhole_sources?: string[]; + blackhole_count?: number; + remote?: string; + transport_id?: string; + network_id?: string; + probe_responder?: string; + transport_uptime_str?: string; + totals?: RNStatusTotals; + queues?: RNStatusQueues; + rns_version?: string; + rss_str?: string; +} + +export interface RNStatusQueryParams { + include_link_stats: boolean; + show_all: boolean; + sorting?: string; + remote?: string; + identity_path?: string; + timeout?: number; +} + +export interface ManagementIdentityItem { + name: string; + path: string; + hash: string; +} + +export interface InterfaceStatRow { + key: string; + label: string; + value: string | number; +} + +export interface QueueDisplayRow { + key: string; + label: string; + pressure?: string; + packets?: number; + dropped?: number; +} diff --git a/meshchatx/src/frontend/features/sieve-filters/SieveFiltersPage.svelte b/meshchatx/src/frontend/features/sieve-filters/SieveFiltersPage.svelte new file mode 100644 index 000000000..12096f929 --- /dev/null +++ b/meshchatx/src/frontend/features/sieve-filters/SieveFiltersPage.svelte @@ -0,0 +1,201 @@ + + + + +
+ +
+
+
+
+
+
+

+ {t("tools.sieve_filters.rules_heading")} +

+ +
+

+ {t("tools.sieve_filters.order_hint")} +

+ + {#if filters.length === 0} +
+ {t("tools.sieve_filters.empty_rules")} +
+ {:else} +
+ {#each filters as rule, index (rule.id)} + + {/each} +
+ {/if} + +
+ + +
+
+
+ +
+

+ {t("tools.sieve_filters.flow_heading")} +

+ +
+
+
+
+
diff --git a/meshchatx/src/frontend/features/sieve-filters/components/SieveFlowNetwork.svelte b/meshchatx/src/frontend/features/sieve-filters/components/SieveFlowNetwork.svelte new file mode 100644 index 000000000..b9d362cc7 --- /dev/null +++ b/meshchatx/src/frontend/features/sieve-filters/components/SieveFlowNetwork.svelte @@ -0,0 +1,255 @@ + + + + +
+ + diff --git a/meshchatx/src/frontend/features/sieve-filters/components/SieveRuleCard.svelte b/meshchatx/src/frontend/features/sieve-filters/components/SieveRuleCard.svelte new file mode 100644 index 000000000..39c265adb --- /dev/null +++ b/meshchatx/src/frontend/features/sieve-filters/components/SieveRuleCard.svelte @@ -0,0 +1,201 @@ + + + + +
+
+ +
+ + + +
+
+ +
+ + +
+ +
+ + +
+ +
+
+ {t("tools.sieve_filters.match_targets_label")} +
+ + +

+ {t("tools.sieve_filters.match_targets_hint")} +

+
+ +
+ + +
+ +
+
+ + +
+ {#if rule.action === "folder"} +
+ + +
+ {/if} +
+
diff --git a/meshchatx/src/frontend/features/sieve-filters/index.ts b/meshchatx/src/frontend/features/sieve-filters/index.ts new file mode 100644 index 000000000..e17a1bba8 --- /dev/null +++ b/meshchatx/src/frontend/features/sieve-filters/index.ts @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: 0BSD + +import { registerFeature } from "../../js/registries/featureRegistry.js"; + +/** + * LXMF sieve filters feature registration + */ +export function registerSieveFiltersFeature(): void { + registerFeature({ + id: "sieve-filters", + routes: [ + { + name: "sieve-filters", + path: "/tools/sieve-filters", + mount: "svelte", + load: () => import("./SieveFiltersPage.svelte"), + }, + ], + }); +} diff --git a/meshchatx/src/frontend/features/sieve-filters/lib/constants.ts b/meshchatx/src/frontend/features/sieve-filters/lib/constants.ts new file mode 100644 index 000000000..3109be33a --- /dev/null +++ b/meshchatx/src/frontend/features/sieve-filters/lib/constants.ts @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: 0BSD + +export const DEFAULT_SIEVE_SCOPE = "everyone" as const; +export const DEFAULT_SIEVE_ACTION = "ignore" as const; +export const DEFAULT_SIEVE_MATCH_MODE = "substring" as const; + +export const SIEVE_PALETTE = { + src: { background: "#2563eb", border: "#1d4ed8", font: "#ffffff" }, + rule: { background: "#f4f4f5", border: "#a1a1aa", font: "#18181b" }, + ruleDark: { background: "#27272a", border: "#52525b", font: "#fafafa" }, + hide: { background: "#b91c1c", border: "#991b1b", font: "#ffffff" }, + ignore: { background: "#ca8a04", border: "#a16207", font: "#ffffff" }, + folder: { background: "#15803d", border: "#166534", font: "#ffffff" }, + banish: { background: "#4c1d95", border: "#5b21b6", font: "#ffffff" }, +} as const; diff --git a/meshchatx/src/frontend/features/sieve-filters/lib/sieveRules.ts b/meshchatx/src/frontend/features/sieve-filters/lib/sieveRules.ts new file mode 100644 index 000000000..3823c28de --- /dev/null +++ b/meshchatx/src/frontend/features/sieve-filters/lib/sieveRules.ts @@ -0,0 +1,140 @@ +// SPDX-License-Identifier: 0BSD + +import type { SieveFolder, SieveMatchMode, SieveRule, SieveRuleAction, SieveRuleScope } from "./types"; + +/** + * Generate a random identifier for a new rule + */ +export function newRuleId(): string { + if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { + return crypto.randomUUID(); + } + return `r-${Date.now()}-${Math.random().toString(16).slice(2)}`; +} + +/** + * Create a fresh default sieve rule + */ +export function createDefaultRule(folders: SieveFolder[] = []): SieveRule { + return { + id: newRuleId(), + enabled: true, + scope: "everyone", + terms: [], + action: "ignore", + folder_id: folders.length > 0 ? folders[0].id : null, + match_peer_fields: true, + match_message: false, + match_mode: "substring", + }; +} + +/** + * Map a raw rule object received from the API into a typed SieveRule + */ +export function mapRuleFromApi(r: Partial & { action?: string; id?: string }): SieveRule { + let action: SieveRuleAction = (r.action as SieveRuleAction) || "ignore"; + if (action === "block") { + action = "hide"; + } + const scope: SieveRuleScope = r.scope === "contacts" || r.scope === "non_contacts" ? r.scope : "everyone"; + const matchMode: SieveMatchMode = r.match_mode === "regex" ? "regex" : "substring"; + + return { + id: r.id || newRuleId(), + enabled: r.enabled !== false, + scope, + terms: Array.isArray(r.terms) ? [...r.terms] : [], + action, + folder_id: r.folder_id ?? null, + match_peer_fields: r.match_peer_fields !== false, + match_message: Boolean(r.match_message), + match_mode: matchMode, + }; +} + +/** + * Normalize a single rule for the API PUT payload + */ +export function normalizeRuleForSave(r: SieveRule): SieveRule { + const scope: SieveRuleScope = r.scope === "contacts" || r.scope === "non_contacts" ? r.scope : "everyone"; + const matchPeerFields = r.match_peer_fields !== false; + const matchMessage = Boolean(r.match_message); + const targetsOk = matchPeerFields || matchMessage; + const rawAction = r.action === "block" ? "hide" : r.action; + const action: SieveRuleAction = + rawAction === "hide" || rawAction === "folder" || rawAction === "banish" ? rawAction : "ignore"; + const matchMode: SieveMatchMode = r.match_mode === "regex" ? "regex" : "substring"; + + return { + id: r.id, + enabled: Boolean(r.enabled), + scope, + terms: Array.isArray(r.terms) ? [...r.terms] : [], + action, + folder_id: action === "folder" ? r.folder_id : null, + match_peer_fields: targetsOk ? matchPeerFields : true, + match_message: targetsOk ? matchMessage : false, + match_mode: matchMode, + }; +} + +/** + * Normalize an array of rules for saving + */ +export function normalizeFiltersForSave(filters: SieveRule[]): SieveRule[] { + return (filters || []).map(normalizeRuleForSave); +} + +/** + * Parse comma or newline separated terms text into clean strings + */ +export function parseTermsInput(raw: string): string[] { + return String(raw || "") + .split(/[\n,]+/) + .map((s) => s.trim()) + .filter(Boolean); +} + +/** + * Format an array of terms into a newline separated string + */ +export function formatTermsText(terms?: string[]): string { + return (terms || []).join("\n"); +} + +/** + * Move a rule within the filters array by a delta offset + */ +export function reorderRules(filters: SieveRule[], index: number, delta: number): SieveRule[] { + const targetIndex = index + delta; + if (targetIndex < 0 || targetIndex >= filters.length) { + return filters; + } + const copy = [...filters]; + const item = copy[index]; + copy[index] = copy[targetIndex]; + copy[targetIndex] = item; + return copy; +} + +/** + * Ensure folder id matches action requirements + */ +export function ensureValidAction(rule: SieveRule, folders: SieveFolder[] = []): void { + if (rule.action === "folder" && folders.length > 0 && (rule.folder_id == null || rule.folder_id === 0)) { + rule.folder_id = folders[0].id; + } + if (rule.action !== "folder") { + rule.folder_id = null; + } +} + +/** + * Ensure at least one match target stays enabled + */ +export function ensureValidMatchTargets(rule: SieveRule): void { + if (!rule.match_peer_fields && !rule.match_message) { + rule.match_peer_fields = true; + } +} diff --git a/meshchatx/src/frontend/features/sieve-filters/lib/types.ts b/meshchatx/src/frontend/features/sieve-filters/lib/types.ts new file mode 100644 index 000000000..9ff012e7b --- /dev/null +++ b/meshchatx/src/frontend/features/sieve-filters/lib/types.ts @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: 0BSD + +export type SieveRuleScope = "everyone" | "contacts" | "non_contacts"; + +export type SieveRuleAction = "hide" | "ignore" | "folder" | "banish" | "block"; + +export type SieveMatchMode = "substring" | "regex"; + +export interface SieveRule { + id: string; + enabled: boolean; + scope: SieveRuleScope; + terms: string[]; + action: SieveRuleAction; + folder_id: number | null; + match_peer_fields: boolean; + match_message: boolean; + match_mode: SieveMatchMode; +} + +export interface SieveFolder { + id: number; + name: string; +} + +export interface SieveFlowLabels { + sourceNode?: string; + sourceHint?: string; + rulePrefix?: string; + hide?: string; + ignore?: string; + banish?: string; + folder?: string; + noRules?: string; + graphScopeEveryone?: string; + graphScopeContacts?: string; + graphScopeNonContacts?: string; + graphMatchPeer?: string; + graphMatchMessage?: string; + graphMatchModeSubstring?: string; + graphMatchModeRegex?: string; +} diff --git a/meshchatx/src/frontend/features/translator/TranslatorPage.svelte b/meshchatx/src/frontend/features/translator/TranslatorPage.svelte new file mode 100644 index 000000000..3d2c66742 --- /dev/null +++ b/meshchatx/src/frontend/features/translator/TranslatorPage.svelte @@ -0,0 +1,476 @@ + + + + +
+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + {#if translationMode === "argos"} + + {/if} + +
+ + +
+ +
+ + + +
+ + {#if translationResult} +
+
+
+ {t("translator.translation")} +
+
+ {t("translator.source")}: {translationResult.source} +
+
+
+
+ {translationResult.translated_text} +
+
+
+ {t("translator.detected")}: {translationResult.source_lang} → {translationResult.target_lang} +
+
+ {/if} + + {#if error} +
+ {error} +
+ {/if} +
+ + +
+
+
diff --git a/meshchatx/src/frontend/features/translator/components/TranslatorArgosGuidance.svelte b/meshchatx/src/frontend/features/translator/components/TranslatorArgosGuidance.svelte new file mode 100644 index 000000000..4f4f9d0c2 --- /dev/null +++ b/meshchatx/src/frontend/features/translator/components/TranslatorArgosGuidance.svelte @@ -0,0 +1,178 @@ + + + + +{#if !hasArgos} +
+
+
+ +
+
+

{t("translator.argos_not_detected")}

+

+ {t("translator.argos_not_detected_desc")} +

+ +
+
+
+ + {t("translator.method_pip_venv")} + + +
+
+ {ARGOS_INSTALL_PIP_CMD} +
+
+ +
+
+ + {t("translator.method_pipx")} + + +
+
+ {ARGOS_INSTALL_PIPX_CMD} +
+
+
+

+ {t("translator.note_restart_required")} +

+
+
+
+{:else if !hasArgosLanguages} +
+
+
+ +
+
+

{t("translator.no_language_packages")}

+

+ {t("translator.no_language_packages_desc")} +

+ +
+
+
+ + {t("translator.install_all_languages")} + +
+ +
+
+
+ +
+ {ARGOSPM_INSTALL_ALL_CMD} +
+
+
+ +
+
+ + {t("translator.install_specific_pair")} + + +
+
+ {ARGOSPM_INSTALL_PAIR_CMD} +
+
+
+

+ {t("translator.after_install_note")} +

+
+
+
+{/if} diff --git a/meshchatx/src/frontend/features/translator/components/TranslatorBackendsCard.svelte b/meshchatx/src/frontend/features/translator/components/TranslatorBackendsCard.svelte new file mode 100644 index 000000000..0f5cd763f --- /dev/null +++ b/meshchatx/src/frontend/features/translator/components/TranslatorBackendsCard.svelte @@ -0,0 +1,162 @@ + + + + +{#if config} +
+
+ Translation backends +
+ {#if hasArgos} + + {/if} + {#if libreClientAvailable} + + {/if} + {#if libreClientAvailable && !libretranslateReachable} +

+ No response from the LibreTranslate URL yet. Check the address, start the service, and tap Refresh languages. +

+ {/if} +
+{/if} + +
+ {#if hasArgos || libreClientAvailable} +
+ {#if hasArgos} + + {/if} + {#if libreClientAvailable} + + {/if} +
+ {/if} +
+ +{#if translationMode === "libretranslate"} +
+
+ + onUrlChange(e.currentTarget.value)} + type="text" + placeholder="http://localhost:5000" + class="input-field" + /> +
+ {t("translator.api_server_description")} +
+
+
+ + onApiKeyChange(e.currentTarget.value)} + type="password" + autocomplete="off" + class="input-field" + placeholder={t("translator.api_key_placeholder")} + /> +
+ {t("translator.api_key_description")} +
+
+
+{/if} diff --git a/meshchatx/src/frontend/features/translator/components/TranslatorLanguagesList.svelte b/meshchatx/src/frontend/features/translator/components/TranslatorLanguagesList.svelte new file mode 100644 index 000000000..5929b7f95 --- /dev/null +++ b/meshchatx/src/frontend/features/translator/components/TranslatorLanguagesList.svelte @@ -0,0 +1,71 @@ + + + + +
+
+ {t("translator.available_languages")} +
+
+ {t("translator.languages_loaded_from")} +
+
+ {#each languages as lang (lang.code)} + + {lang.name} ({lang.code}) + - {lang.source} + + {/each} +
+
+ + {#if translationMode === "argos" && hasArgos} + + {/if} +
+
diff --git a/meshchatx/src/frontend/features/translator/index.ts b/meshchatx/src/frontend/features/translator/index.ts new file mode 100644 index 000000000..da421a17f --- /dev/null +++ b/meshchatx/src/frontend/features/translator/index.ts @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: 0BSD + +import { registerFeature } from "../../js/registries/featureRegistry.js"; + +/** + * Text translation UI using Argos Translate or LibreTranslate. + */ +export function registerTranslatorFeature(): void { + registerFeature({ + id: "translator", + routes: [ + { + name: "translator", + path: "/translator", + mount: "svelte", + load: () => import("./TranslatorPage.svelte"), + }, + ], + }); +} diff --git a/meshchatx/src/frontend/features/translator/lib/constants.ts b/meshchatx/src/frontend/features/translator/lib/constants.ts new file mode 100644 index 000000000..04ff5142f --- /dev/null +++ b/meshchatx/src/frontend/features/translator/lib/constants.ts @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: 0BSD + +export const DEFAULT_LIBRETRANSLATE_URL = "http://localhost:5000"; +export const LIBRE_PERSIST_DEBOUNCE_MS = 800; + +export const ARGOS_INSTALL_PIP_CMD = "pip install argostranslate"; +export const ARGOS_INSTALL_PIPX_CMD = "pipx install argostranslate"; +export const ARGOSPM_INSTALL_ALL_CMD = "argospm install translate"; +export const ARGOSPM_INSTALL_PAIR_CMD = "argospm install translate-en_de"; diff --git a/meshchatx/src/frontend/features/translator/lib/translatorEngine.ts b/meshchatx/src/frontend/features/translator/lib/translatorEngine.ts new file mode 100644 index 000000000..176a4ffa3 --- /dev/null +++ b/meshchatx/src/frontend/features/translator/lib/translatorEngine.ts @@ -0,0 +1,108 @@ +// SPDX-License-Identifier: 0BSD + +import type { TranslationMode, TranslatorConfig, TranslatorLanguage } from "./types.js"; + +/** + * Filter language pairs matching active translation backend + */ +export function filterLanguagesByMode( + languages: TranslatorLanguage[], + mode: TranslationMode +): TranslatorLanguage[] { + if (mode === "argos") { + return languages.filter((lang) => lang.source === "argos"); + } + return languages.filter((lang) => lang.source === "libretranslate"); +} + +/** + * Check whether any Argos language packages are installed + */ +export function hasArgosLanguages(languages: TranslatorLanguage[]): boolean { + return languages.some((lang) => lang.source === "argos"); +} + +/** + * Check if translation can be initiated with current configuration + */ +export function canTranslate(params: { + config: TranslatorConfig | null; + mode: TranslationMode; + inputText: string; + sourceLang: string; + targetLang: string; +}): boolean { + const { config, mode, inputText, sourceLang, targetLang } = params; + const argosEnabled = Boolean(config?.translator_argos_enabled); + const libreEnabled = Boolean(config?.translator_libretranslate_enabled); + const backendReady = (mode === "argos" && argosEnabled) || (mode === "libretranslate" && libreEnabled); + + return ( + backendReady && + inputText.trim().length > 0 && + Boolean(targetLang) && + targetLang !== sourceLang + ); +} + +/** + * Sync translation mode based on available installed backends + */ +export function computeSyncedMode(params: { + currentMode: TranslationMode; + hasArgos: boolean; + libreClientAvailable: boolean; +}): TranslationMode { + const { currentMode, hasArgos, libreClientAvailable } = params; + + if (currentMode === "argos" && !hasArgos && libreClientAvailable) { + return "libretranslate"; + } + if (currentMode === "libretranslate" && !libreClientAvailable && hasArgos) { + return "argos"; + } + if (hasArgos && !libreClientAvailable) { + return "argos"; + } + if (!hasArgos && libreClientAvailable) { + return "libretranslate"; + } + return currentMode; +} + +/** + * Calculate swapped source and target languages and optional updated text + */ +export function computeSwappedLanguages(params: { + mode: TranslationMode; + currentSource: string; + currentTarget: string; + resultSource?: string; + resultText?: string; +}): { + newSource: string; + newTarget: string; + newInputText?: string; +} { + const { mode, currentSource, currentTarget, resultSource, resultText } = params; + + if (resultSource && resultSource !== "auto") { + return { + newSource: currentTarget, + newTarget: currentSource, + newInputText: resultText || undefined, + }; + } + + if (mode === "argos") { + return { + newSource: currentTarget, + newTarget: currentSource && currentSource !== "auto" ? currentSource : "", + }; + } + + return { + newSource: currentTarget || "auto", + newTarget: currentSource && currentSource !== "auto" ? currentSource : "", + }; +} diff --git a/meshchatx/src/frontend/features/translator/lib/types.ts b/meshchatx/src/frontend/features/translator/lib/types.ts new file mode 100644 index 000000000..f48515e27 --- /dev/null +++ b/meshchatx/src/frontend/features/translator/lib/types.ts @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: 0BSD + +export type TranslationMode = "argos" | "libretranslate"; + +export interface TranslatorLanguage { + code: string; + name: string; + source: "argos" | "libretranslate"; +} + +export interface TranslatorConfig { + translator_argos_enabled?: boolean; + translator_libretranslate_enabled?: boolean; + libretranslate_url?: string | null; + libretranslate_api_key?: string | null; + [key: string]: unknown; +} + +export interface TranslationResult { + translated_text: string; + source: string; + source_lang: string; + target_lang: string; +} + +export interface TranslatorLanguagesResponse { + languages?: TranslatorLanguage[]; + has_argos?: boolean; + has_argos_lib?: boolean; + has_argos_cli?: boolean; + libre_client_available?: boolean; + libretranslate_reachable?: boolean; +} diff --git a/meshchatx/src/frontend/js/LinkUtils.ts b/meshchatx/src/frontend/js/LinkUtils.ts index 67e426ded..457752e8d 100644 --- a/meshchatx/src/frontend/js/LinkUtils.ts +++ b/meshchatx/src/frontend/js/LinkUtils.ts @@ -36,7 +36,7 @@ export default class LinkUtils { } static protectAnchors(text) { - const anchors = []; + const anchors: string[] = []; const protectedText = text.replace(/]*>[\s\S]*?<\/a>/gi, (anchor) => { const token = `[[ANCHOR_${anchors.length}]]`; anchors.push(anchor); diff --git a/meshchatx/src/frontend/js/MarkdownRenderer.ts b/meshchatx/src/frontend/js/MarkdownRenderer.ts index 2805ab688..0c478bab7 100644 --- a/meshchatx/src/frontend/js/MarkdownRenderer.ts +++ b/meshchatx/src/frontend/js/MarkdownRenderer.ts @@ -17,7 +17,7 @@ export default class MarkdownRenderer { text = Utils.escapeHtml(text); // Fenced code blocks - process these FIRST and replace with placeholders - const code_blocks = []; + const code_blocks: string[] = []; // eslint-disable-next-line security/detect-unsafe-regex -- bounded fenced block, lazy match text = text.replace(/```(\w+)?\n([\s\S]*?)\n```/g, (match, lang, code) => { const placeholder = `[[CB${code_blocks.length}]]`; @@ -28,7 +28,7 @@ export default class MarkdownRenderer { }); // Inline code before emphasis so snake_case inside `code` / code is safe. - const inline_codes = []; + const inline_codes: string[] = []; const pushInline = (code) => { const placeholder = `[[IC${inline_codes.length}]]`; inline_codes.push( @@ -67,7 +67,7 @@ export default class MarkdownRenderer { // Paragraphs - double newline to p tag const parts = text.split(/\n\n+/); - const processed_parts = []; + const processed_parts: string[] = []; for (let part of parts) { part = part.trim(); if (!part) continue; @@ -103,7 +103,7 @@ export default class MarkdownRenderer { const { protectedText, anchors } = LinkUtils.protectAnchors(text); text = protectedText; - const inline_codes = []; + const inline_codes: string[] = []; const pushInline = (code) => { const placeholder = `[[IC${inline_codes.length}]]`; inline_codes.push( diff --git a/meshchatx/src/frontend/js/MicrophoneRecorder.worklet.ts b/meshchatx/src/frontend/js/MicrophoneRecorder.worklet.ts index 93c4a300d..d4e44d1be 100644 --- a/meshchatx/src/frontend/js/MicrophoneRecorder.worklet.ts +++ b/meshchatx/src/frontend/js/MicrophoneRecorder.worklet.ts @@ -6,10 +6,7 @@ declare class AudioWorkletProcessor { readonly port: MessagePort; } -declare function registerProcessor( - name: string, - processorCtor: new () => AudioWorkletProcessor -): void; +declare function registerProcessor(name: string, processorCtor: new () => AudioWorkletProcessor): void; class MicrophonePcmFloatProcessor extends AudioWorkletProcessor { process(inputs: Float32Array[][]) { diff --git a/meshchatx/src/frontend/js/Utils.ts b/meshchatx/src/frontend/js/Utils.ts index 1c6007035..71eb93f29 100644 --- a/meshchatx/src/frontend/js/Utils.ts +++ b/meshchatx/src/frontend/js/Utils.ts @@ -226,7 +226,7 @@ class Utils { return `${minutes}:${String(remainingSeconds).padStart(2, "0")}`; } - static lxmfMessageTransferTotalBytes(lxmfMessage, base64ByteLength = null) { + static lxmfMessageTransferTotalBytes(lxmfMessage: any, base64ByteLength: any = null) { if (!lxmfMessage) { return 0; } diff --git a/meshchatx/src/frontend/js/apiClient.ts b/meshchatx/src/frontend/js/apiClient.ts index 8385ef858..0ba5073b1 100644 --- a/meshchatx/src/frontend/js/apiClient.ts +++ b/meshchatx/src/frontend/js/apiClient.ts @@ -12,9 +12,37 @@ import { import GlobalState from "./GlobalState.js"; import { withRetryableHttp } from "./httpRetry.js"; -export function isCancel(error) { +export type ApiRequestConfig = { + params?: Record; + data?: unknown; + signal?: AbortSignal; + headers?: Record; + responseType?: "json" | "blob" | "arraybuffer" | "text"; +}; + +export type ApiResponse = { + data: T; + status: number; + headers: Headers; +}; + +export type ApiClient = { + get: (path: string, config?: ApiRequestConfig) => Promise; + head: (path: string, config?: ApiRequestConfig) => Promise; + post: (path: string, data?: unknown, config?: ApiRequestConfig) => Promise; + patch: (path: string, data?: unknown, config?: ApiRequestConfig) => Promise; + put: (path: string, data?: unknown, config?: ApiRequestConfig) => Promise; + delete: (path: string, config?: ApiRequestConfig) => Promise; + isCancel: (error: unknown) => boolean; +}; + +export type CreateApiClientOptions = { + onAuthError?: (err: Error & { response?: { status: number; data: unknown } }) => void; +}; + +export function isCancel(error: unknown): boolean { if (!error) return false; - return error.name === "AbortError" || error.name === "CanceledError"; + return (error as { name?: string }).name === "AbortError" || (error as { name?: string }).name === "CanceledError"; } function buildUrl(path, params) { @@ -136,12 +164,18 @@ function tryDemoConfigPatch(data) { } /** - * @param {{ onAuthError?: (err: Error & { response?: { status: number, data: unknown } }) => void }} options + * @param {CreateApiClientOptions} [options] + * @returns {ApiClient} */ -export function createApiClient(options: any = {}) { +export function createApiClient(options: CreateApiClientOptions = {}): ApiClient { const { onAuthError } = options; - async function request(method, path, config: any = {}, csrfRetry = false) { + async function request( + method: string, + path: string, + config: ApiRequestConfig = {}, + csrfRetry = false + ): Promise { const { params, data, signal, headers = {}, responseType } = config; const pathname = apiPathname(path); @@ -160,7 +194,7 @@ export function createApiClient(options: any = {}) { hdrs.set("X-CSRF-Token", csrf); } } - const init: any = { method, signal, headers: hdrs }; + const init: RequestInit = { method, signal, headers: hdrs }; if (data !== undefined && method !== "GET" && method !== "HEAD") { if (data instanceof FormData) { @@ -168,7 +202,7 @@ export function createApiClient(options: any = {}) { hdrs.delete("content-type"); init.body = data; } else if (typeof data === "string" || data instanceof Blob || data instanceof ArrayBuffer) { - init.body = data; + init.body = data as BodyInit; } else { if (!hdrs.has("Content-Type")) { hdrs.set("Content-Type", "application/json"); @@ -177,7 +211,7 @@ export function createApiClient(options: any = {}) { } } - let response; + let response: Response; try { response = await fetch(url, init); } catch (e) { @@ -196,7 +230,7 @@ export function createApiClient(options: any = {}) { if (mutating && !csrfRetry && isCsrfRejection(response.status, errData)) { try { await fetchCsrfToken({ - get(csrfPath) { + get(csrfPath: string) { return request("GET", csrfPath, {}); }, }); @@ -227,7 +261,7 @@ export function createApiClient(options: any = {}) { return { data: dataOut, status: response.status, headers: response.headers }; } - const api: any = { + const api: ApiClient = { get(path, config) { const cfg = config || {}; return withRetryableHttp(() => request("GET", path, cfg), { @@ -240,16 +274,16 @@ export function createApiClient(options: any = {}) { signal: cfg.signal, }); }, - post(path, data, config: any = {}) { + post(path, data, config = {}) { return request("POST", path, { ...config, data }); }, - patch(path, data, config: any = {}) { + patch(path, data, config = {}) { return request("PATCH", path, { ...config, data }); }, - put(path, data, config: any = {}) { + put(path, data, config = {}) { return request("PUT", path, { ...config, data }); }, - delete(path, config: any = {}) { + delete(path, config = {}) { return request("DELETE", path, config || {}); }, isCancel, diff --git a/meshchatx/src/frontend/js/browser-globals.d.ts b/meshchatx/src/frontend/js/browser-globals.d.ts index 4d12f5f11..0458f0042 100644 --- a/meshchatx/src/frontend/js/browser-globals.d.ts +++ b/meshchatx/src/frontend/js/browser-globals.d.ts @@ -1,76 +1,84 @@ // SPDX-License-Identifier: 0BSD -interface Window { - api: { - get: (url: string, config?: Record) => Promise<{ data?: any }>; - post: (url: string, body?: unknown, config?: Record) => Promise<{ data?: any }>; - patch: (url: string, body?: unknown, config?: Record) => Promise<{ data?: any }>; - put?: (url: string, body?: unknown, config?: Record) => Promise<{ data?: any }>; - delete: (url: string, config?: Record) => Promise<{ data?: any }>; - isCancel?: (e: unknown) => boolean; - }; - electron?: any; - MeshChatXAndroid?: any; - webkitAudioContext?: typeof AudioContext; - DOMPurify?: any; - BarcodeDetector?: any; - zip?: any; - CryptoJS?: any; - enableHeapMonitor?: (intervalMs?: number) => void; - disableHeapMonitor?: () => void; - heapSnapshot?: () => unknown; - __meshchatxMicronCopyFix?: boolean; -} +declare global { + interface Window { + api: import("./apiClient.js").ApiClient; + electron?: any; + MeshChatXAndroid?: any; + webkitAudioContext?: typeof AudioContext; + DOMPurify?: any; + BarcodeDetector?: any; + zip?: any; + CryptoJS?: any; + enableHeapMonitor?: (intervalMs?: number) => void; + disableHeapMonitor?: () => void; + heapSnapshot?: () => unknown; + __meshchatxMicronCopyFix?: boolean; + } -interface ImportMeta { - env: Record; - glob: (pattern: string, options?: Record) => Record; -} + interface ImportMeta { + env: Record; + glob: (pattern: string, options?: Record) => Record; + } -interface Performance { - memory?: { - usedJSHeapSize: number; - totalJSHeapSize: number; - jsHeapSizeLimit: number; - }; -} + interface Performance { + memory?: { + usedJSHeapSize: number; + totalJSHeapSize: number; + jsHeapSizeLimit: number; + }; + } -interface Navigator { - brave?: unknown; - getBattery?: () => Promise; -} + interface Navigator { + brave?: unknown; + getBattery?: () => Promise; + } -interface MediaTrackCapabilities { - focusMode?: string[]; -} + interface MediaTrackCapabilities { + focusMode?: string[]; + } -interface MediaTrackConstraintSet { - focusMode?: string; -} + interface MediaTrackConstraintSet { + focusMode?: string; + } -declare const __GEO_WASM_SRI_WASM__: string; -declare const __GEO_WASM_SRI_EXEC__: string; -declare const __MICRON_WASM_SRI_WASM__: string; -declare const __MICRON_WASM_SRI_EXEC__: string; -declare const __VISUALISER_WASM_SRI_WASM__: string; -declare const __VISUALISER_WASM_SRI_EXEC__: string; + const __GEO_WASM_SRI_WASM__: string; + const __GEO_WASM_SRI_EXEC__: string; + const __MICRON_WASM_SRI_WASM__: string; + const __MICRON_WASM_SRI_EXEC__: string; + const __VISUALISER_WASM_SRI_WASM__: string; + const __VISUALISER_WASM_SRI_EXEC__: string; -declare const Codec2MicrophoneRecorder: { - new (): { - codec2Mode: string; - start: () => Promise; - stop: () => Promise; + const Codec2MicrophoneRecorder: { + new (): { + codec2Mode: string; + start: () => Promise; + stop: () => Promise; + }; }; -}; -declare const Codec2Lib: { - runDecode: (mode: string, bytes: Uint8Array) => Promise; - rawToWav: (bytes: ArrayBuffer) => Promise; -}; + const Codec2Lib: { + runDecode: (mode: string, bytes: Uint8Array) => Promise; + rawToWav: (bytes: ArrayBuffer) => Promise; + }; +} declare module "*.css"; +declare module "vis-network/styles/vis-network.css"; + +declare module "*.js?raw" { + const content: string; + export default content; +} + +declare module "*.worklet.js?raw" { + const content: string; + export default content; +} declare module "*?raw" { const content: string; export default content; } + +export {}; diff --git a/meshchatx/src/frontend/js/clampFloatingToViewport.ts b/meshchatx/src/frontend/js/clampFloatingToViewport.ts index 90941c67e..3a2e24945 100644 --- a/meshchatx/src/frontend/js/clampFloatingToViewport.ts +++ b/meshchatx/src/frontend/js/clampFloatingToViewport.ts @@ -26,7 +26,7 @@ export function clampFloatingToViewport(preferredLeft, preferredTop, width, heig left = margin; } - let maxHeight = null; + let maxHeight: number | null = null; if (height <= maxH) { top = Math.min(Math.max(margin, top), vh - height - margin); } else { diff --git a/meshchatx/src/frontend/js/idbOpen.ts b/meshchatx/src/frontend/js/idbOpen.ts index b672a19d2..03276e206 100644 --- a/meshchatx/src/frontend/js/idbOpen.ts +++ b/meshchatx/src/frontend/js/idbOpen.ts @@ -15,7 +15,7 @@ export function isIndexedDbAccessError(err) { if (!err || typeof err !== "object") { return false; } - const name = /** @type {{ name?: string }} */ (err).name; + const name = /** @type {{ name?: string }} */ err.name; return name === "SecurityError" || name === "InvalidStateError"; } diff --git a/meshchatx/src/frontend/js/lxmfConversationListSync.ts b/meshchatx/src/frontend/js/lxmfConversationListSync.ts index e78efee86..0a306de47 100644 --- a/meshchatx/src/frontend/js/lxmfConversationListSync.ts +++ b/meshchatx/src/frontend/js/lxmfConversationListSync.ts @@ -64,7 +64,7 @@ export function sortConversationsPinnedFirst(conversations, pinnedHashes) { if (ap !== bp) { return ap ? -1 : 1; } - return idx.get(a.destination_hash) - idx.get(b.destination_hash); + return (idx.get(a.destination_hash) ?? 0) - (idx.get(b.destination_hash) ?? 0); }); } @@ -92,7 +92,7 @@ export function syncConversationListInPlace(existing, incoming) { } const existingByHash = new Map(existing.map((conversation) => [conversation.destination_hash, conversation])); - const nextRows = []; + const nextRows: any[] = []; for (const conversation of incoming) { const hash = conversation?.destination_hash; if (!hash) { diff --git a/meshchatx/src/frontend/js/lxmfReactions.ts b/meshchatx/src/frontend/js/lxmfReactions.ts index 737e22c5a..651d797ad 100644 --- a/meshchatx/src/frontend/js/lxmfReactions.ts +++ b/meshchatx/src/frontend/js/lxmfReactions.ts @@ -24,8 +24,8 @@ export function mergeLxmfReactionRowsIntoMessages(messages) { if (messages.length === 0) { return []; } - const parents = []; - const reactions = []; + const parents: any[] = []; + const reactions: any[] = []; for (const m of messages) { if (!m || typeof m !== "object") { continue; diff --git a/meshchatx/src/frontend/js/mapExchange/metadataUtils.ts b/meshchatx/src/frontend/js/mapExchange/metadataUtils.ts index d7c05cddd..9e487d56c 100644 --- a/meshchatx/src/frontend/js/mapExchange/metadataUtils.ts +++ b/meshchatx/src/frontend/js/mapExchange/metadataUtils.ts @@ -89,27 +89,27 @@ export function getFeatureAnchorCoordinate(feature) { } const t = g.getType(); if (t === "Point") { - return /** @type {import("ol/geom/Point").default} */ (g).getCoordinates(); + return /** @type {import("ol/geom/Point").default} */ g.getCoordinates(); } if (t === "MultiPoint") { - return /** @type {import("ol/geom/MultiPoint").default} */ (g).getPoint(0).getCoordinates(); + return /** @type {import("ol/geom/MultiPoint").default} */ g.getPoint(0).getCoordinates(); } if (t === "Polygon") { - return /** @type {import("ol/geom/Polygon").default} */ (g).getInteriorPoint().getCoordinates(); + return /** @type {import("ol/geom/Polygon").default} */ g.getInteriorPoint().getCoordinates(); } if (t === "MultiPolygon") { - const mp = /** @type {import("ol/geom/MultiPolygon").default} */ (g); + const mp = /** @type {import("ol/geom/MultiPolygon").default} */ g; return mp.getPolygon(0).getInteriorPoint().getCoordinates(); } if (t === "LineString") { - const c = /** @type {import("ol/geom/LineString").default} */ (g).getCoordinates(); + const c = /** @type {import("ol/geom/LineString").default} */ g.getCoordinates(); if (!c.length) { return null; } return c[Math.floor(c.length / 2)]; } if (t === "MultiLineString") { - const ml = /** @type {import("ol/geom/MultiLineString").default} */ (g); + const ml = /** @type {import("ol/geom/MultiLineString").default} */ g; const line = ml.getLineString(0); const c = line.getCoordinates(); if (!c.length) { diff --git a/meshchatx/src/frontend/js/mapExchange/styleFromProperties.ts b/meshchatx/src/frontend/js/mapExchange/styleFromProperties.ts index e07fbad9b..a5fe7bb38 100644 --- a/meshchatx/src/frontend/js/mapExchange/styleFromProperties.ts +++ b/meshchatx/src/frontend/js/mapExchange/styleFromProperties.ts @@ -154,7 +154,7 @@ export function styleFromMcxProperties(feature, resolution?) { const fillOpacity = num(p[MCX_FILL_OPACITY] ?? p["fill-opacity"], fillRaw ? 0.35 : 0); const stroke = new Stroke({ - color: /** @type {import("ol/color").Color|string} */ (strokeRaw), + color: /** @type {import("ol/color").Color|string} */ strokeRaw, width: strokeWidth, }); @@ -170,7 +170,7 @@ export function styleFromMcxProperties(feature, resolution?) { ? new Fill({ color: hexToRgba(fillRaw, fillOpacity > 0 ? fillOpacity : 0.35), }) - : new Fill({ color: /** @type {import("ol/color").Color} */ (fillRaw) }); + : new Fill({ color: /** @type {import("ol/color").Color} */ fillRaw }); } else { fill = new Fill({ color: "rgba(59, 130, 246, 0.2)" }); } @@ -178,7 +178,7 @@ export function styleFromMcxProperties(feature, resolution?) { } if (type === "Circle") { - const g = /** @type {import("ol/geom/Circle").default} */ (geom); + const g = /** @type {import("ol/geom/Circle").default} */ geom; const center = g.getCenter(); const edge = [center[0] + g.getRadius(), center[1]]; const line = new LineString([center, edge]); diff --git a/meshchatx/src/frontend/js/mdiIconNames.ts b/meshchatx/src/frontend/js/mdiIconNames.ts index aaa8b9b6d..6b57d1bfa 100644 --- a/meshchatx/src/frontend/js/mdiIconNames.ts +++ b/meshchatx/src/frontend/js/mdiIconNames.ts @@ -16,7 +16,7 @@ const MATERIAL_SYMBOL_ALIASES: any = { "emoji-objects": "lightbulb-on", }; -let cachedIconNames = null; +let cachedIconNames: string[] | null = null; function isKebabCaseIconName(name) { if (!name || name.length > 64) { diff --git a/meshchatx/src/frontend/js/meshValidate.ts b/meshchatx/src/frontend/js/meshValidate.ts new file mode 100644 index 000000000..17ef0f005 --- /dev/null +++ b/meshchatx/src/frontend/js/meshValidate.ts @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: 0BSD + +/** + * Shared destination-hash checks for mesh UI input (LXMF, RRC hubs, tools). + */ + +import Utils from "./Utils.js"; + +/** Case-insensitive 32 hex digits (Reticulum truncated destination hash). */ +export const DESTINATION_HASH_RE = /^[0-9a-f]{32}$/i; + +/** + * True when value is exactly 32 hex digits after trim (no URI junk stripping). + */ +export function isDestinationHash(value: unknown): boolean { + if (value == null) { + return false; + } + const hash = String(value).trim(); + return hash.length === 32 && DESTINATION_HASH_RE.test(hash); +} + +/** + * Strip URI/punctuation via Utils.normalizeMeshchatHashHex, then require a valid hash. + * Returns lowercase hex or empty string. + */ +export function normalizeDestinationHash(value: unknown): string { + const hex = Utils.normalizeMeshchatHashHex(value); + if (!isDestinationHash(hex)) { + return ""; + } + return hex.toLowerCase(); +} diff --git a/meshchatx/src/frontend/js/outboundSendQueue.ts b/meshchatx/src/frontend/js/outboundSendQueue.ts index 38eb37ca4..476f0241c 100644 --- a/meshchatx/src/frontend/js/outboundSendQueue.ts +++ b/meshchatx/src/frontend/js/outboundSendQueue.ts @@ -19,9 +19,9 @@ function jobMatches(job, match) { } export function createOutboundQueue(processJob) { - const queue = []; + const queue: any[] = []; let running = false; - let currentJob = null; + let currentJob: any = null; async function run() { if (running) { diff --git a/meshchatx/src/frontend/js/plugins/pluginManifest.ts b/meshchatx/src/frontend/js/plugins/pluginManifest.ts index 55e6955f0..d26ac6170 100644 --- a/meshchatx/src/frontend/js/plugins/pluginManifest.ts +++ b/meshchatx/src/frontend/js/plugins/pluginManifest.ts @@ -26,7 +26,7 @@ export function validatePluginManifest(manifest) { if (!manifest || typeof manifest !== "object") { throw new Error("Plugin manifest must be an object"); } - const record = /** @type {Record} */ (manifest); + const record = /** @type {Record} */ manifest; if (typeof record.id !== "string" || !record.id.trim()) { throw new Error("Plugin manifest requires a non-empty id"); } @@ -38,7 +38,7 @@ export function validatePluginManifest(manifest) { throw new Error(`Plugin apiVersion must be ${SUPPORTED_API_VERSION}`); } if (record.frontend != null) { - const frontend = /** @type {Record} */ (record.frontend); + const frontend = /** @type {Record} */ record.frontend; if (typeof frontend.entry !== "string" || !frontend.entry.trim()) { throw new Error("Plugin frontend.entry is required when frontend is set"); } @@ -50,7 +50,7 @@ export function validatePluginManifest(manifest) { } } if (record.backend != null) { - const backend = /** @type {Record} */ (record.backend); + const backend = /** @type {Record} */ record.backend; if (typeof backend.entry !== "string" || !backend.entry.trim()) { throw new Error("Plugin backend.entry is required when backend is set"); } @@ -59,7 +59,7 @@ export function validatePluginManifest(manifest) { } } if (record.ui != null) { - const ui = /** @type {Record} */ (record.ui); + const ui = /** @type {Record} */ record.ui; if (ui.widgets != null) { if (!Array.isArray(ui.widgets)) { throw new Error("Plugin ui.widgets must be an array"); @@ -76,7 +76,7 @@ export function validatePluginManifest(manifest) { throw new Error("Plugin permissions must be an object"); } if (permissions && typeof permissions === "object") { - const uiPerm = /** @type {Record} */ (permissions).ui; + const uiPerm = /** @type {Record} */ permissions.ui; if (uiPerm != null && uiPerm !== "sandboxed-html" && uiPerm !== "none") { if (!Array.isArray(uiPerm) || uiPerm.some((v) => v !== "sandboxed-html")) { throw new Error("Plugin permissions.ui must be none, sandboxed-html, or an array of those values"); @@ -86,7 +86,7 @@ export function validatePluginManifest(manifest) { if (record.network != null && typeof record.network !== "object") { throw new Error("Plugin network must be an object"); } - return /** @type {PluginManifest} */ (manifest); + return /** @type {PluginManifest} */ manifest; } /** diff --git a/meshchatx/src/frontend/js/plugins/pluginUiDescriptor.ts b/meshchatx/src/frontend/js/plugins/pluginUiDescriptor.ts index 664bae153..7f16eb5a0 100644 --- a/meshchatx/src/frontend/js/plugins/pluginUiDescriptor.ts +++ b/meshchatx/src/frontend/js/plugins/pluginUiDescriptor.ts @@ -172,5 +172,5 @@ export function validateUiDescriptor(descriptor, options: any = {}) { if (error) { return { ok: false, error }; } - return { ok: true, descriptor: /** @type {object} */ (descriptor) }; + return { ok: true, descriptor: /** @type {object} */ descriptor }; } diff --git a/meshchatx/src/frontend/js/qrScannerUtils.ts b/meshchatx/src/frontend/js/qrScannerUtils.ts index d5b914c1c..2a72c083b 100644 --- a/meshchatx/src/frontend/js/qrScannerUtils.ts +++ b/meshchatx/src/frontend/js/qrScannerUtils.ts @@ -1,6 +1,6 @@ import jsQR from "jsqr"; -let decodeCanvas = null; +let decodeCanvas: HTMLCanvasElement | null = null; export function resetDecodeCanvasForTests() { decodeCanvas = null; @@ -32,7 +32,7 @@ export function describeCameraError(error, messages) { return messages.failed; } -function getDecodeCanvas() { +function getDecodeCanvas(): HTMLCanvasElement { if (!decodeCanvas) { decodeCanvas = document.createElement("canvas"); } diff --git a/meshchatx/src/frontend/js/relayLinkUtils.ts b/meshchatx/src/frontend/js/relayLinkUtils.ts index 7f22e78e3..aa365956e 100644 --- a/meshchatx/src/frontend/js/relayLinkUtils.ts +++ b/meshchatx/src/frontend/js/relayLinkUtils.ts @@ -6,8 +6,9 @@ * (meshchat://relay is accepted as an alias.) */ +import { isDestinationHash } from "./meshValidate.js"; + const RELAY_URI_IN_TEXT_RE = /(?:meshchatx|meshchat):\/\/relay\?[^\s<>]*/gi; -const HUB_HASH_RE = /^[a-fA-F0-9]{32}$/; export function findRelayUriInContent(text) { if (!text || typeof text !== "string") { @@ -30,7 +31,7 @@ export function parseMeshchatRelayUri(uri) { const hub = String(u.searchParams.get("hub") || "") .trim() .toLowerCase(); - if (!HUB_HASH_RE.test(hub)) { + if (!isDestinationHash(hub)) { return null; } const room = String(u.searchParams.get("room") || "").trim(); @@ -54,7 +55,7 @@ export function buildMeshchatRelayUri({ hub, room = "", name = "", aspect = "" } const h = String(hub || "") .trim() .toLowerCase(); - if (!HUB_HASH_RE.test(h)) { + if (!isDestinationHash(h)) { return null; } const parts = [`hub=${encodeURIComponent(h)}`]; @@ -88,15 +89,18 @@ export function buildRelayShareMessage({ hub, room = "", name = "", aspect = "" * Add (or reuse) a client hub from a parsed relay URI and optionally join a room. * Returns { hub_hash, room } on success. */ -export async function applyRelayShareLink(parsed, { api = typeof window !== "undefined" ? window.api : null }: any = {}) { +export async function applyRelayShareLink( + parsed, + { api = typeof window !== "undefined" ? window.api : null }: any = {} +) { if (!parsed?.hub || !api) { throw new Error("invalid relay share"); } const hubHash = parsed.hub; - let hubs = []; + let hubs: any[] = []; try { const list = await api.get("/api/v1/rrc/hubs"); - hubs = list.data?.hubs || []; + hubs = (list.data as any)?.hubs || []; } catch { hubs = []; } diff --git a/meshchatx/src/frontend/js/reticulumPathfinding.ts b/meshchatx/src/frontend/js/reticulumPathfinding.ts index b4624f85e..38deba84a 100644 --- a/meshchatx/src/frontend/js/reticulumPathfinding.ts +++ b/meshchatx/src/frontend/js/reticulumPathfinding.ts @@ -12,7 +12,7 @@ export function normalizePathSnapshot(data) { if (!data || typeof data !== "object") { return { path: null, path_stale: true, path_unresponsive: false }; } - const row = /** @type {{ path?: object | null, path_stale?: boolean, path_unresponsive?: boolean }} */ (data); + const row = /** @type {{ path?: object | null, path_stale?: boolean, path_unresponsive?: boolean }} */ data; const path = row.path ?? null; return { path, diff --git a/meshchatx/src/frontend/js/rnode/AndroidBridge.ts b/meshchatx/src/frontend/js/rnode/AndroidBridge.ts index 6e95ba85c..0ba8879ee 100644 --- a/meshchatx/src/frontend/js/rnode/AndroidBridge.ts +++ b/meshchatx/src/frontend/js/rnode/AndroidBridge.ts @@ -262,4 +262,3 @@ export default class AndroidBridge { }, "unsupported"); } } - diff --git a/meshchatx/src/frontend/js/rnode/ROM.ts b/meshchatx/src/frontend/js/rnode/ROM.ts index 7e2e0fd4b..32cb084fb 100644 --- a/meshchatx/src/frontend/js/rnode/ROM.ts +++ b/meshchatx/src/frontend/js/rnode/ROM.ts @@ -50,8 +50,8 @@ export default class ROM { static MODEL_C5 = 0xc5; static MODEL_CA = 0xca; - static declare PRODUCT_H32_V4: number; - static declare MODEL_C8: number; + declare static PRODUCT_H32_V4: number; + declare static MODEL_C8: number; static PRODUCT_HELTEC_T114 = 0xc2; static MODEL_C6 = 0xc6; diff --git a/meshchatx/src/frontend/js/telephone-pcm-capture.worklet.ts b/meshchatx/src/frontend/js/telephone-pcm-capture.worklet.ts index 0c273eac8..e9477763b 100644 --- a/meshchatx/src/frontend/js/telephone-pcm-capture.worklet.ts +++ b/meshchatx/src/frontend/js/telephone-pcm-capture.worklet.ts @@ -6,10 +6,7 @@ declare class AudioWorkletProcessor { readonly port: MessagePort; } -declare function registerProcessor( - name: string, - processorCtor: new () => AudioWorkletProcessor -): void; +declare function registerProcessor(name: string, processorCtor: new () => AudioWorkletProcessor): void; class TelephonePcmCaptureProcessor extends AudioWorkletProcessor { process(inputs: Float32Array[][]) { diff --git a/meshchatx/src/frontend/js/wsLiveSync.ts b/meshchatx/src/frontend/js/wsLiveSync.ts index 677d9020b..b9c7e6cc5 100644 --- a/meshchatx/src/frontend/js/wsLiveSync.ts +++ b/meshchatx/src/frontend/js/wsLiveSync.ts @@ -70,7 +70,7 @@ export function nextLastSeqFromPayload(payload, lastSeq) { if (!payload || typeof payload !== "object") { return lastSeq; } - const seq = /** @type {{ seq?: unknown }} */ (payload).seq; + const seq = /** @type {{ seq?: unknown }} */ payload.seq; if (typeof seq !== "number" || !Number.isFinite(seq) || seq < 0) { return lastSeq; } diff --git a/meshchatx/src/frontend/libs/clickOutside.ts b/meshchatx/src/frontend/libs/clickOutside.ts index 21037dd9c..d3e950334 100644 --- a/meshchatx/src/frontend/libs/clickOutside.ts +++ b/meshchatx/src/frontend/libs/clickOutside.ts @@ -25,7 +25,7 @@ export function processDirectiveArguments(bindingValue) { if (!isFunction && (bindingValue == null || typeof bindingValue !== "object")) { throw new Error("v-click-outside: Binding value must be a function or an object"); } - const value = /** @type {Record} */ (isFunction ? {} : bindingValue); + const value = /** @type {Record} */ isFunction ? {} : bindingValue; const handler = isFunction ? bindingValue : value.handler; if (typeof handler !== "function") { throw new Error("v-click-outside: handler must be a function"); @@ -57,8 +57,8 @@ export function bindingsEqual(a, b) { if (!a || !b || typeof a !== "object" || typeof b !== "object") { return false; } - const left = /** @type {Record} */ (a); - const right = /** @type {Record} */ (b); + const left = /** @type {Record} */ a; + const right = /** @type {Record} */ b; return ( left.handler === right.handler && left.middleware === right.middleware && diff --git a/meshchatx/src/frontend/libs/emitter.ts b/meshchatx/src/frontend/libs/emitter.ts index 1ae9c5225..32156fb55 100644 --- a/meshchatx/src/frontend/libs/emitter.ts +++ b/meshchatx/src/frontend/libs/emitter.ts @@ -55,7 +55,7 @@ export function createEmitter(all = new Map()) { const list = all.get(type); if (list) { for (const handler of list.slice()) { - /** @type {EmitterHandler} */ (handler)(event); + /** @type {EmitterHandler} */ handler(event); } } const wild = all.get("*"); diff --git a/meshchatx/src/frontend/locales/de.json b/meshchatx/src/frontend/locales/de.json index c78749307..b4e4e7d39 100644 --- a/meshchatx/src/frontend/locales/de.json +++ b/meshchatx/src/frontend/locales/de.json @@ -687,7 +687,9 @@ "clear": "Löschen", "prompt_title": "Wert eingeben", "optional": "Optional", - "back": "Zurück" + "back": "Zurück", + "all": "Alle", + "load_more": "Mehr laden" }, "stickers": { "settings_title": "Sticker", @@ -1903,7 +1905,11 @@ "starter_attribution": "Starter tiles are a low-zoom (z0-z4) placeholder. Upload regional MBTiles for detail.", "starter_restored": "Starter tiles restored", "failed_restore_starter": "Failed to restore starter tiles", - "export_set_active_confirm": "Use the exported MBTiles as the offline basemap?" + "export_set_active_confirm": "Use the exported MBTiles as the offline basemap?", + "copy_coords": "Koordinaten kopieren", + "copy_coords_format": "{format} kopieren", + "context_coords": "Koordinaten", + "tab_limit_reached": "Karten-Tab-Limit erreicht (8)" }, "interface": { "disable": "Deaktivieren", @@ -2281,7 +2287,8 @@ "paper_message_detected_desc": "Diese Nachricht enthält eine signierte LXMF-URI, die du in deine Unterhaltungen einlesen kannst.", "paper_message_ingest": "Nachricht einlesen", "paper_message_ingested": "Papiernachricht eingelesen", - "paper_message_ingested_desc": "Diese signierte LXMF-URI wurde zu deinen Unterhaltungen hinzugefügt." + "paper_message_ingested_desc": "Diese signierte LXMF-URI wurde zu deinen Unterhaltungen hinzugefügt.", + "scroll_to_bottom": "Nach unten scrollen" }, "nomadnet": { "remove_favourite": "Favorit entfernen", @@ -4181,7 +4188,11 @@ "logs_copied": "Protokolle auf dieser Seite in die Zwischenablage kopiert", "access_copied": "Zugriffsversuche in die Zwischenablage kopiert", "failed_copy_logs": "Protokolle konnten nicht kopiert werden", - "failed_copy_access": "Zugriffsversuche konnten nicht kopiert werden" + "failed_copy_access": "Zugriffsversuche konnten nicht kopiert werden", + "copy_logs": "Logs kopieren", + "copy_access": "Zugriffsversuche kopieren", + "level": "Stufe", + "outcome": "Ergebnis" }, "visualiser": { "title": "Netzwerk-Visualisierer", diff --git a/meshchatx/src/frontend/locales/en.json b/meshchatx/src/frontend/locales/en.json index 3f005cf2a..b1b24bb98 100644 --- a/meshchatx/src/frontend/locales/en.json +++ b/meshchatx/src/frontend/locales/en.json @@ -687,7 +687,9 @@ "clear": "Clear", "prompt_title": "Enter a value", "optional": "Optional", - "back": "Back" + "back": "Back", + "all": "All", + "load_more": "Load more" }, "stickers": { "settings_title": "Stickers", @@ -2231,7 +2233,8 @@ "conversation_file_other": "{name} sent a file", "conversation_files_you": "You sent {count} files", "conversation_files_other": "{name} sent {count} files", - "message_not_found_in_cache": "Message not found in cache" + "message_not_found_in_cache": "Message not found in cache", + "scroll_to_bottom": "Scroll to bottom" }, "settings": { "tabs": { @@ -2413,7 +2416,11 @@ "logs_copied": "Logs on this page copied to clipboard", "access_copied": "Access attempts copied to clipboard", "failed_copy_logs": "Failed to copy logs", - "failed_copy_access": "Failed to copy access attempts" + "failed_copy_access": "Failed to copy access attempts", + "copy_logs": "Copy logs", + "copy_access": "Copy access attempts", + "level": "Level", + "outcome": "Outcome" }, "visualiser": { "title": "Network Visualiser", diff --git a/meshchatx/src/frontend/locales/es.json b/meshchatx/src/frontend/locales/es.json index 5a7a7fcab..34164d145 100644 --- a/meshchatx/src/frontend/locales/es.json +++ b/meshchatx/src/frontend/locales/es.json @@ -687,7 +687,9 @@ "clear": "Limpiar", "prompt_title": "Introduce un valor", "optional": "Opcional", - "back": "Atrás" + "back": "Atrás", + "all": "Todos", + "load_more": "Cargar más" }, "stickers": { "settings_title": "Pegatinas", @@ -1849,7 +1851,11 @@ "starter_attribution": "Starter tiles are a low-zoom (z0-z4) placeholder. Upload regional MBTiles for detail.", "starter_restored": "Starter tiles restored", "failed_restore_starter": "Failed to restore starter tiles", - "export_set_active_confirm": "Use the exported MBTiles as the offline basemap?" + "export_set_active_confirm": "Use the exported MBTiles as the offline basemap?", + "copy_coords": "Copiar coordenadas", + "copy_coords_format": "Copiar {format}", + "context_coords": "Coordenadas", + "tab_limit_reached": "Límite de pestañas del mapa alcanzado (8)" }, "interface": { "disable": "Inhabilitación", @@ -2227,7 +2233,8 @@ "paper_message_detected_desc": "Este mensaje contiene un URI LXMF firmado que puedes ingerir en tus conversaciones.", "paper_message_ingest": "Ingerir mensaje", "paper_message_ingested": "Mensaje en papel ingerido", - "paper_message_ingested_desc": "Este URI LXMF firmado se añadió a tus conversaciones." + "paper_message_ingested_desc": "Este URI LXMF firmado se añadió a tus conversaciones.", + "scroll_to_bottom": "Desplazarse al final" }, "settings": { "shortcut_saved": "Guardado a mano", @@ -2409,7 +2416,11 @@ "logs_copied": "Registros en esta página copiados a portapapeles", "access_copied": "Los intentos de acceso copiados al portapapeles", "failed_copy_logs": "No se pudieron copiar los registros", - "failed_copy_access": "Sin copiar los intentos de acceso" + "failed_copy_access": "Sin copiar los intentos de acceso", + "copy_logs": "Copiar registros", + "copy_access": "Copiar intentos de acceso", + "level": "Nivel", + "outcome": "Resultado" }, "visualiser": { "title": "Visualizador de red", diff --git a/meshchatx/src/frontend/locales/fi.json b/meshchatx/src/frontend/locales/fi.json index 7db1c714f..3d7bbaee7 100644 --- a/meshchatx/src/frontend/locales/fi.json +++ b/meshchatx/src/frontend/locales/fi.json @@ -687,7 +687,9 @@ "clear": "Tyhjää", "prompt_title": "Anna arvo", "optional": "Valinnainen", - "back": "Takaisin" + "back": "Takaisin", + "all": "Kaikki", + "load_more": "Lataa lisää" }, "stickers": { "settings_title": "Tarrat", @@ -1849,7 +1851,11 @@ "starter_attribution": "Starter tiles are a low-zoom (z0-z4) placeholder. Upload regional MBTiles for detail.", "starter_restored": "Starter tiles restored", "failed_restore_starter": "Failed to restore starter tiles", - "export_set_active_confirm": "Use the exported MBTiles as the offline basemap?" + "export_set_active_confirm": "Use the exported MBTiles as the offline basemap?", + "copy_coords": "Kopioi koordinaatit", + "copy_coords_format": "Kopioi {format}", + "context_coords": "Koordinaatit", + "tab_limit_reached": "Karttavälilehtien raja täynnä (8)" }, "interface": { "disable": "Älä käytä", @@ -2227,7 +2233,8 @@ "paper_message_detected_desc": "Tämä viesti sisältää allekirjoitetun LXMF-URI:n, jonka voit lukea keskusteluihisi.", "paper_message_ingest": "Lue viesti", "paper_message_ingested": "Paperiviesti luettu", - "paper_message_ingested_desc": "Tämä allekirjoitettu LXMF-URI lisättiin keskusteluihisi." + "paper_message_ingested_desc": "Tämä allekirjoitettu LXMF-URI lisättiin keskusteluihisi.", + "scroll_to_bottom": "Vieritä alas" }, "settings": { "tabs": { @@ -2409,7 +2416,11 @@ "logs_copied": "Sivun lokit kopioitu leikepöydälle", "access_copied": "Pääsy-yritykset kopioitu leikepöydälle", "failed_copy_logs": "Lokien kopiointi epäonnistui", - "failed_copy_access": "Pääsy-yritysten kopiointi epäonnistui" + "failed_copy_access": "Pääsy-yritysten kopiointi epäonnistui", + "copy_logs": "Kopioi lokit", + "copy_access": "Kopioi käyttöyritykset", + "level": "Taso", + "outcome": "Tulos" }, "visualiser": { "title": "Verkon havainnollistaja", diff --git a/meshchatx/src/frontend/locales/fr.json b/meshchatx/src/frontend/locales/fr.json index 8c4871dfa..006f75528 100644 --- a/meshchatx/src/frontend/locales/fr.json +++ b/meshchatx/src/frontend/locales/fr.json @@ -687,7 +687,9 @@ "clear": "Effacer", "prompt_title": "Saisir une valeur", "optional": "Facultatif", - "back": "Retour" + "back": "Retour", + "all": "Tous", + "load_more": "Charger plus" }, "stickers": { "settings_title": "Autocollants", @@ -1849,7 +1851,11 @@ "starter_attribution": "Starter tiles are a low-zoom (z0-z4) placeholder. Upload regional MBTiles for detail.", "starter_restored": "Starter tiles restored", "failed_restore_starter": "Failed to restore starter tiles", - "export_set_active_confirm": "Use the exported MBTiles as the offline basemap?" + "export_set_active_confirm": "Use the exported MBTiles as the offline basemap?", + "copy_coords": "Copier les coordonnées", + "copy_coords_format": "Copier {format}", + "context_coords": "Coordonnées", + "tab_limit_reached": "Limite d'onglets carte atteinte (8)" }, "interface": { "disable": "Désactiver", @@ -2227,7 +2233,8 @@ "paper_message_detected_desc": "Ce message contient un URI LXMF signé que vous pouvez ingérer dans vos conversations.", "paper_message_ingest": "Ingérer le message", "paper_message_ingested": "Message papier ingéré", - "paper_message_ingested_desc": "Cet URI LXMF signé a été ajouté à vos conversations." + "paper_message_ingested_desc": "Cet URI LXMF signé a été ajouté à vos conversations.", + "scroll_to_bottom": "Défiler vers le bas" }, "settings": { "shortcut_saved": "Raccourci enregistré", @@ -2409,7 +2416,11 @@ "logs_copied": "Loges sur cette page copiées dans le presse-papiers", "access_copied": "Essais d'accès copiés dans le presse-papiers", "failed_copy_logs": "Impossible de copier les journaux", - "failed_copy_access": "Impossible de copier les tentatives d'accès" + "failed_copy_access": "Impossible de copier les tentatives d'accès", + "copy_logs": "Copier les journaux", + "copy_access": "Copier les tentatives d'accès", + "level": "Niveau", + "outcome": "Résultat" }, "visualiser": { "title": "Visualiseur réseau", diff --git a/meshchatx/src/frontend/locales/it.json b/meshchatx/src/frontend/locales/it.json index 8fc455b80..5200478e1 100644 --- a/meshchatx/src/frontend/locales/it.json +++ b/meshchatx/src/frontend/locales/it.json @@ -687,7 +687,9 @@ "clear": "Cancella", "prompt_title": "Inserisci un valore", "optional": "Facoltativo", - "back": "Indietro" + "back": "Indietro", + "all": "Tutti", + "load_more": "Carica altro" }, "stickers": { "settings_title": "Sticker", @@ -1903,7 +1905,11 @@ "starter_attribution": "Starter tiles are a low-zoom (z0-z4) placeholder. Upload regional MBTiles for detail.", "starter_restored": "Starter tiles restored", "failed_restore_starter": "Failed to restore starter tiles", - "export_set_active_confirm": "Use the exported MBTiles as the offline basemap?" + "export_set_active_confirm": "Use the exported MBTiles as the offline basemap?", + "copy_coords": "Copia coordinate", + "copy_coords_format": "Copia {format}", + "context_coords": "Coordinate", + "tab_limit_reached": "Limite schede mappa raggiunto (8)" }, "interface": { "disable": "Disabilita", @@ -2281,7 +2287,8 @@ "paper_message_detected_desc": "Questo messaggio contiene un URI LXMF firmato che puoi ingerire nelle conversazioni.", "paper_message_ingest": "Ingerisci messaggio", "paper_message_ingested": "Messaggio cartaceo ingerito", - "paper_message_ingested_desc": "Questo URI LXMF firmato è stato aggiunto alle tue conversazioni." + "paper_message_ingested_desc": "Questo URI LXMF firmato è stato aggiunto alle tue conversazioni.", + "scroll_to_bottom": "Scorri in basso" }, "settings": { "shortcut_saved": "Scorciatoia salvata", @@ -2463,7 +2470,11 @@ "logs_copied": "I log in questa pagina sono stati copiati negli appunti", "access_copied": "Tentativi di accesso copiati negli appunti", "failed_copy_logs": "Impossibile copiare i log", - "failed_copy_access": "Impossibile copiare i tentativi di accesso" + "failed_copy_access": "Impossibile copiare i tentativi di accesso", + "copy_logs": "Copia log", + "copy_access": "Copia tentativi di accesso", + "level": "Livello", + "outcome": "Esito" }, "visualiser": { "title": "Visualizzatore di Rete", diff --git a/meshchatx/src/frontend/locales/nl.json b/meshchatx/src/frontend/locales/nl.json index eb3141471..c946f8fbb 100644 --- a/meshchatx/src/frontend/locales/nl.json +++ b/meshchatx/src/frontend/locales/nl.json @@ -687,7 +687,9 @@ "clear": "Wissen", "prompt_title": "Voer een waarde in", "optional": "Optioneel", - "back": "Terug" + "back": "Terug", + "all": "Alles", + "load_more": "Meer laden" }, "stickers": { "settings_title": "Stickers", @@ -1849,7 +1851,11 @@ "starter_attribution": "Starter tiles are a low-zoom (z0-z4) placeholder. Upload regional MBTiles for detail.", "starter_restored": "Starter tiles restored", "failed_restore_starter": "Failed to restore starter tiles", - "export_set_active_confirm": "Use the exported MBTiles as the offline basemap?" + "export_set_active_confirm": "Use the exported MBTiles as the offline basemap?", + "copy_coords": "Coördinaten kopiëren", + "copy_coords_format": "{format} kopiëren", + "context_coords": "Coördinaten", + "tab_limit_reached": "Kaarttabbladlimiet bereikt (8)" }, "interface": { "disable": "Uitschakelen", @@ -2227,7 +2233,8 @@ "paper_message_detected_desc": "Dit bericht bevat een ondertekende LXMF-URI die je in je gesprekken kunt innemen.", "paper_message_ingest": "Bericht innemen", "paper_message_ingested": "Papierbericht ingenomen", - "paper_message_ingested_desc": "Deze ondertekende LXMF-URI is aan je gesprekken toegevoegd." + "paper_message_ingested_desc": "Deze ondertekende LXMF-URI is aan je gesprekken toegevoegd.", + "scroll_to_bottom": "Scroll naar beneden" }, "settings": { "shortcut_saved": "Sneltoets opgeslagen", @@ -2409,7 +2416,11 @@ "logs_copied": "Logs op deze pagina gekopieerd naar klembord", "access_copied": "Toegang pogingen gekopieerd naar klembord", "failed_copy_logs": "Kon logs niet kopiëren", - "failed_copy_access": "Kon toegangspogingen niet kopiëren" + "failed_copy_access": "Kon toegangspogingen niet kopiëren", + "copy_logs": "Logs kopiëren", + "copy_access": "Toegangspogingen kopiëren", + "level": "Niveau", + "outcome": "Uitkomst" }, "visualiser": { "title": "Netwerkvisualisatie", diff --git a/meshchatx/src/frontend/locales/ru.json b/meshchatx/src/frontend/locales/ru.json index 1b1f15d40..8c1afd767 100644 --- a/meshchatx/src/frontend/locales/ru.json +++ b/meshchatx/src/frontend/locales/ru.json @@ -687,7 +687,9 @@ "clear": "Очистить", "prompt_title": "Введите значение", "optional": "Необязательно", - "back": "Назад" + "back": "Назад", + "all": "Все", + "load_more": "Загрузить ещё" }, "stickers": { "settings_title": "Стикеры", @@ -1903,7 +1905,11 @@ "starter_attribution": "Starter tiles are a low-zoom (z0-z4) placeholder. Upload regional MBTiles for detail.", "starter_restored": "Starter tiles restored", "failed_restore_starter": "Failed to restore starter tiles", - "export_set_active_confirm": "Use the exported MBTiles as the offline basemap?" + "export_set_active_confirm": "Use the exported MBTiles as the offline basemap?", + "copy_coords": "Копировать координаты", + "copy_coords_format": "Копировать {format}", + "context_coords": "Координаты", + "tab_limit_reached": "Достигнут лимит вкладок карты (8)" }, "interface": { "disable": "Выключить", @@ -2281,7 +2287,8 @@ "paper_message_detected_desc": "Это сообщение содержит подписанный LXMF URI, который можно добавить в ваши беседы.", "paper_message_ingest": "Принять сообщение", "paper_message_ingested": "Бумажное сообщение принято", - "paper_message_ingested_desc": "Этот подписанный LXMF URI добавлен в ваши беседы." + "paper_message_ingested_desc": "Этот подписанный LXMF URI добавлен в ваши беседы.", + "scroll_to_bottom": "Прокрутить вниз" }, "nomadnet": { "remove_favourite": "Удалить из избранного", @@ -4181,7 +4188,11 @@ "logs_copied": "Журналы на этой странице скопированы в буфер", "access_copied": "Попытки доступа скопированы в буфер", "failed_copy_logs": "Не удалось скопировать журналы", - "failed_copy_access": "Не удалось скопировать попытки доступа" + "failed_copy_access": "Не удалось скопировать попытки доступа", + "copy_logs": "Копировать журналы", + "copy_access": "Копировать попытки доступа", + "level": "Уровень", + "outcome": "Результат" }, "visualiser": { "title": "Визуализатор сети", diff --git a/meshchatx/src/frontend/locales/zh.json b/meshchatx/src/frontend/locales/zh.json index 868f1217c..985d6ce31 100644 --- a/meshchatx/src/frontend/locales/zh.json +++ b/meshchatx/src/frontend/locales/zh.json @@ -687,7 +687,9 @@ "clear": "清除", "prompt_title": "输入值", "optional": "可选", - "back": "返回" + "back": "返回", + "all": "全部", + "load_more": "加载更多" }, "stickers": { "settings_title": "贴纸", @@ -1849,7 +1851,11 @@ "starter_attribution": "Starter tiles are a low-zoom (z0-z4) placeholder. Upload regional MBTiles for detail.", "starter_restored": "Starter tiles restored", "failed_restore_starter": "Failed to restore starter tiles", - "export_set_active_confirm": "Use the exported MBTiles as the offline basemap?" + "export_set_active_confirm": "Use the exported MBTiles as the offline basemap?", + "copy_coords": "复制坐标", + "copy_coords_format": "复制 {format}", + "context_coords": "坐标", + "tab_limit_reached": "地图标签页已达上限 (8)" }, "interface": { "disable": "禁用", @@ -2227,7 +2233,8 @@ "paper_message_detected_desc": "此消息包含可导入会话的已签名 LXMF URI。", "paper_message_ingest": "导入消息", "paper_message_ingested": "已导入纸质消息", - "paper_message_ingested_desc": "此已签名 LXMF URI 已添加到你的会话。" + "paper_message_ingested_desc": "此已签名 LXMF URI 已添加到你的会话。", + "scroll_to_bottom": "滚动到底部" }, "settings": { "shortcut_saved": "快捷键已保存", @@ -2409,7 +2416,11 @@ "logs_copied": "复制到剪贴板的页面日志", "access_copied": "访问复制到剪贴板的尝试", "failed_copy_logs": "复制日志失败", - "failed_copy_access": "复制访问尝试失败" + "failed_copy_access": "复制访问尝试失败", + "copy_logs": "复制日志", + "copy_access": "复制访问尝试", + "level": "级别", + "outcome": "结果" }, "visualiser": { "title": "网络可视化工具", diff --git a/meshchatx/src/frontend/main.ts b/meshchatx/src/frontend/main.ts index 8b6db3cc4..643f6c250 100644 --- a/meshchatx/src/frontend/main.ts +++ b/meshchatx/src/frontend/main.ts @@ -168,31 +168,6 @@ const router = createRouter({ path: "/rnx", component: () => import("./components/tools/RNXManagerPage.vue"), }, - { - name: "rnstatus", - path: "/rnstatus", - component: () => import("./components/rnstatus/RNStatusPage.vue"), - }, - { - name: "rnpath", - path: "/rnpath", - component: () => import("./components/tools/RNPathPage.vue"), - }, - { - name: "rnpath-trace", - path: "/rnpath-trace", - component: () => import("./components/tools/RNPathTracePage.vue"), - }, - { - name: "rnprobe", - path: "/rnprobe", - component: () => import("./components/rnprobe/RNProbePage.vue"), - }, - { - name: "translator", - path: "/translator", - component: () => import("./components/translator/TranslatorPage.vue"), - }, { name: "bots", path: "/bots", @@ -203,11 +178,6 @@ const router = createRouter({ path: "/micron-editor", component: () => import("./components/micron-editor/MicronEditorPage.vue"), }, - { - name: "reticulum-config-editor", - path: "/tools/reticulum-config-editor", - component: () => import("./components/tools/ReticulumConfigEditorPage.vue"), - }, { name: "mesh-server", path: "/mesh-server", @@ -233,21 +203,6 @@ const router = createRouter({ path: "/identities", component: () => import("./components/settings/IdentitiesPage.vue"), }, - { - name: "paper-message", - path: "/tools/paper-message", - component: () => import("./components/tools/PaperMessagePage.vue"), - }, - { - name: "sieve-filters", - path: "/tools/sieve-filters", - component: () => import("./components/tools/SieveFiltersPage.vue"), - }, - { - name: "message-blocklist", - path: "/tools/message-blocklist", - component: () => import("./components/tools/MessageBlocklistPage.vue"), - }, { name: "rnode-flasher", path: "/tools/rnode-flasher", diff --git a/meshchatx/src/frontend/shell/buildRouterRoutes.ts b/meshchatx/src/frontend/shell/buildRouterRoutes.ts index 1d5a10099..3da027369 100644 --- a/meshchatx/src/frontend/shell/buildRouterRoutes.ts +++ b/meshchatx/src/frontend/shell/buildRouterRoutes.ts @@ -31,10 +31,10 @@ export function buildRouterRoutesFromRegistry(): RouteRecordRaw[] { record.component = () => entry.load().then((mod) => { const moduleValue = mod as { default?: Component } | Component | null; - const Comp = - moduleValue && typeof moduleValue === "object" && "default" in moduleValue - ? moduleValue.default - : moduleValue; + const Comp = + moduleValue && typeof moduleValue === "object" && "default" in moduleValue + ? moduleValue.default + : moduleValue; if (!Comp) { throw new Error(`routeRegistry: vue load for "${entry.name}" has no default export`); } diff --git a/meshchatx/src/frontend/theme/themeEngine.ts b/meshchatx/src/frontend/theme/themeEngine.ts index 3c10494aa..1ed541fa3 100644 --- a/meshchatx/src/frontend/theme/themeEngine.ts +++ b/meshchatx/src/frontend/theme/themeEngine.ts @@ -767,9 +767,7 @@ export function applyAppearanceTheme( } try { - const electronBridge = windowObj?.electron as - | { setUiTheme?: (theme: string) => unknown } - | undefined; + const electronBridge = windowObj?.electron as { setUiTheme?: (theme: string) => unknown } | undefined; if (electronBridge && typeof electronBridge.setUiTheme === "function") { void electronBridge.setUiTheme(persistMode); } @@ -778,9 +776,7 @@ export function applyAppearanceTheme( } try { - const bridge = windowObj?.MeshChatXAndroid as - | { setUiTheme?: (theme: string) => unknown } - | undefined; + const bridge = windowObj?.MeshChatXAndroid as { setUiTheme?: (theme: string) => unknown } | undefined; if (bridge && typeof bridge.setUiTheme === "function") { bridge.setUiTheme(effectiveMode); } diff --git a/package.json b/package.json index 1aa303a7a..7aee99628 100644 --- a/package.json +++ b/package.json @@ -26,19 +26,21 @@ "lint": "eslint .", "lint:fix": "eslint . --fix", "typecheck": "vue-tsc --noEmit -p tsconfig.json", + "typecheck:features": "svelte-check --tsconfig ./tsconfig.features.json", "svelte-check": "svelte-check --tsconfig ./tsconfig.svelte.json", "knip": "knip", + "deps:cycles": "madge --circular --extensions js,ts,vue,svelte meshchatx/src/frontend", + "check:bundle-budgets": "node scripts/ci/check-frontend-chunk-budgets.mjs", "format": "prettier --write .", "format:check": "prettier --check .", "format:check:svelte": "prettier --check \"meshchatx/src/frontend/**/*.svelte\" \"meshchatx/src/frontend/features/**/*.{js,ts}\" \"svelte.config.js\"", "commitlint": "commitlint --edit", - "test": "vitest run --exclude tests/frontend/LoadTimePerformance.test.js && vitest run --config vitest.electron.config.js", - "test:coverage": "vitest run --coverage --exclude tests/frontend/LoadTimePerformance.test.js && vitest run --config vitest.electron.config.js --coverage", + "test": "vitest run && vitest run --config vitest.electron.config.js", + "test:coverage": "vitest run --coverage && vitest run --config vitest.electron.config.js --coverage", "test:fuzz": "vitest run -t fuzzing", - "test:watch": "vitest --exclude tests/frontend/LoadTimePerformance.test.js", + "test:watch": "vitest", "test:i18n": "vitest run tests/frontend/i18n.test.js", - "test:loadtime": "vitest run tests/frontend/LoadTimePerformance.test.js", - "test:ui": "vitest --ui --open --exclude tests/frontend/LoadTimePerformance.test.js", + "test:ui": "vitest --ui --open", "pretest:e2e": "playwright install chromium", "test:e2e": "playwright test", "test:e2e:ui": "playwright test --ui", diff --git a/tests/frontend/ConversationViewer.outboundPropagationStatus.test.js b/tests/frontend/ConversationViewer.outboundPropagationStatus.test.js index 5884841a3..3470f7ac3 100644 --- a/tests/frontend/ConversationViewer.outboundPropagationStatus.test.js +++ b/tests/frontend/ConversationViewer.outboundPropagationStatus.test.js @@ -1,10 +1,7 @@ // SPDX-License-Identifier: 0BSD import { describe, expect, it } from "vitest"; -import { - outboundBubbleStatusIconName, - outboundBubbleStatusTitleKey, -} from "@/js/outboundMessageStatus.js"; +import { outboundBubbleStatusIconName, outboundBubbleStatusTitleKey } from "@/js/outboundMessageStatus.js"; import { outboundStateIconName, outboundStateTitle, @@ -17,9 +14,7 @@ describe("ConversationViewer outbound propagation status", () => { expect(outboundBubbleStatusIconName({ method: "direct", state: "sent" })).toBe("check"); expect(outboundBubbleStatusIconName({ method: "direct", state: "delivered" })).toBe("check-all"); expect(outboundBubbleStatusIconName({ method: "propagated", state: "sent" })).toBe("email-outline"); - expect(outboundBubbleStatusIconName({ method: "paper", state: "delivered" })).toBe( - "note-check-outline" - ); + expect(outboundBubbleStatusIconName({ method: "paper", state: "delivered" })).toBe("note-check-outline"); }); it("maps propagation handoff to method-aware copy", () => { diff --git a/tests/frontend/ConversationViewerPerformance.baseline.test.js b/tests/frontend/ConversationViewerPerformance.baseline.test.js deleted file mode 100644 index 791b7ea2b..000000000 --- a/tests/frontend/ConversationViewerPerformance.baseline.test.js +++ /dev/null @@ -1,36 +0,0 @@ -// SPDX-License-Identifier: 0BSD - -import { describe, expect, it } from "vitest"; -import { buildDisplayGroupsNewestFirst } from "@/features/messages/lib/conversationDisplayGroups.ts"; -import { displayGroupsOldestFirst } from "@/features/messages/lib/messageListVirtual.ts"; -import { visibleConversationItems } from "@/features/messages/lib/conversationViewerMessages.ts"; - -describe("ConversationViewer performance baseline", () => { - it("groups and filters one thousand messages within the unit-test budget", () => { - const peerHash = "aa".repeat(16); - const myHash = "bb".repeat(16); - const items = Array.from({ length: 1000 }, (_, index) => ({ - type: "lxmf_message", - is_outbound: false, - lxmf_message: { - id: index, - hash: `message-${index}`, - source_hash: peerHash, - destination_hash: myHash, - content: `message ${index}`, - created_at: index, - fields: {}, - }, - })); - - const started = performance.now(); - const visible = visibleConversationItems(items, peerHash, false); - const groups = buildDisplayGroupsNewestFirst(visible, () => false); - const oldestFirst = displayGroupsOldestFirst(groups); - const elapsed = performance.now() - started; - - expect(visible).toHaveLength(1000); - expect(oldestFirst.length).toBeGreaterThan(0); - expect(elapsed).toBeLessThan(250); - }); -}); diff --git a/tests/frontend/InterfacesPerformance.test.js b/tests/frontend/InterfacesPerformance.test.js deleted file mode 100644 index 9a3524bee..000000000 --- a/tests/frontend/InterfacesPerformance.test.js +++ /dev/null @@ -1,146 +0,0 @@ -import { mount } from "@vue/test-utils"; -import { describe, it, expect, vi } from "vitest"; -import InterfacesPage from "../../meshchatx/src/frontend/components/interfaces/InterfacesPage.vue"; - -// Mock dependencies -vi.mock("../../meshchatx/src/frontend/js/GlobalState", () => ({ - default: { - config: { theme: "light" }, - hasPendingInterfaceChanges: false, - modifiedInterfaceNames: new Set(), - }, -})); - -vi.mock("../../meshchatx/src/frontend/js/Utils", () => ({ - default: { - formatBytes: (b) => `${b} B`, - isInterfaceEnabled: () => true, - }, -})); - -vi.mock("../../meshchatx/src/frontend/js/ToastUtils", () => ({ - default: { - success: vi.fn(), - error: vi.fn(), - }, -})); - -vi.mock("../../meshchatx/src/frontend/js/ElectronUtils", () => ({ - default: { - relaunch: vi.fn(), - isElectron: () => false, - isWindowsElectron: () => false, - }, -})); - -// Mock axios -global.api = { - get: vi.fn((url) => { - if (url.includes("/api/v1/reticulum/interfaces")) { - return Promise.resolve({ data: { interfaces: {} } }); - } - if (url.includes("/api/v1/app/info")) { - return Promise.resolve({ data: { app_info: { is_reticulum_running: true } } }); - } - if (url.includes("/api/v1/interface-stats")) { - return Promise.resolve({ data: { interface_stats: { interfaces: [] } } }); - } - if (url.includes("/api/v1/reticulum/discovery")) { - return Promise.resolve({ data: { discovery: { discover_interfaces: true } } }); - } - if (url.includes("/api/v1/reticulum/discovered-interfaces")) { - return Promise.resolve({ data: { interfaces: [], active: [] } }); - } - return Promise.resolve({ data: {} }); - }), - post: vi.fn(() => Promise.resolve({ data: {} })), - patch: vi.fn(() => Promise.resolve({ data: {} })), - isCancel: vi.fn().mockReturnValue(false), -}; -window.api = global.api; - -// Mock MaterialDesignIcon -const MaterialDesignIcon = { - template: '
', - props: ["iconName"], -}; - -describe("InterfacesPage Performance", () => { - it("renders InterfacesPage with 1000 disconnected discovered interfaces", async () => { - const numDiscovered = 1000; - const discoveredInterfaces = Array.from({ length: numDiscovered }, (_, i) => ({ - name: `Discovered ${i}`, - type: "UDPInterface", - reachable_on: `192.168.1.${i}`, - port: 4242, - discovery_hash: `hash_${i}`, - })); - - const start = performance.now(); - const wrapper = mount(InterfacesPage, { - global: { - components: { - MaterialDesignIcon, - Toggle: { template: "
" }, - ImportInterfacesModal: { - template: "
", - methods: { show: vi.fn() }, - }, - Interface: { template: "
" }, - }, - mocks: { - $t: (key) => key, - $router: { push: vi.fn() }, - }, - }, - }); - - await wrapper.setData({ - discoveredInterfaces, - activeTab: "overview", - }); - - const end = performance.now(); - console.log(`Rendered ${numDiscovered} discovered interfaces in ${(end - start).toFixed(2)}ms`); - - const disconnectedBadges = wrapper.findAll(".bg-red-500\\/90"); - expect(disconnectedBadges.length).toBe(numDiscovered); - expect(end - start).toBeLessThan(12000); - }, 20000); - - it("disconnected discovered interfaces render without pulse animation", async () => { - const iface = { - name: "Discovered 1", - type: "UDPInterface", - reachable_on: "192.168.1.1", - port: 4242, - discovery_hash: "hash_1", - }; - - const wrapper = mount(InterfacesPage, { - global: { - components: { - MaterialDesignIcon, - Toggle: { template: "
" }, - ImportInterfacesModal: { - template: "
", - methods: { show: vi.fn() }, - }, - Interface: { template: "
" }, - }, - mocks: { - $t: (key) => key, - $router: { push: vi.fn() }, - }, - }, - }); - - await wrapper.setData({ - discoveredInterfaces: [iface], - activeTab: "overview", - }); - - const pulsingElements = wrapper.findAll(".animate-pulse"); - expect(pulsingElements.length).toBe(0); - }); -}); diff --git a/tests/frontend/LanguageSwitcherOptionsApi.regression.test.js b/tests/frontend/LanguageSwitcherOptionsApi.regression.test.js deleted file mode 100644 index 1058557dd..000000000 --- a/tests/frontend/LanguageSwitcherOptionsApi.regression.test.js +++ /dev/null @@ -1,153 +0,0 @@ -// SPDX-License-Identifier: 0BSD - -/** - * PROVED: setLocale(this.$i18n) under vue-i18n legacy:false loads packs and switches UI. - * Bug class: incomplete-fix (Options API $i18n proxy lacks setLocaleMessage). - */ - -import { describe, it, expect, beforeEach, afterEach } from "vitest"; -import { mount, flushPromises } from "@vue/test-utils"; -import { createI18n } from "vue-i18n"; -import { nextTick } from "vue"; -import { - listLocaleCodes, - normalizeUiLocaleCode, - registerUiI18n, - setLocale, -} from "../../meshchatx/src/frontend/js/localeLoader.js"; -import LanguageSelector from "../../meshchatx/src/frontend/components/LanguageSelector.vue"; -import en from "../../meshchatx/src/frontend/locales/en.json"; -import de from "../../meshchatx/src/frontend/locales/de.json"; - -function createAppI18n() { - return createI18n({ - legacy: false, - locale: "en", - fallbackLocale: "en", - messages: { en }, - }); -} - -describe("language switcher Options API $i18n regression", () => { - beforeEach(() => { - registerUiI18n(null); - document.documentElement.lang = "en"; - }); - - afterEach(() => { - registerUiI18n(null); - }); - - it("exposes a locale-only proxy on this.$i18n under legacy:false", () => { - const i18n = createAppI18n(); - const wrapper = mount({ template: "
" }, { global: { plugins: [i18n] } }); - expect(typeof wrapper.vm.$i18n.setLocaleMessage).toBe("undefined"); - expect(typeof wrapper.vm.$i18n.locale).toBe("string"); - }); - - it("fails closed without registerUiI18n when only the Options API proxy is passed", async () => { - const i18n = createAppI18n(); - const wrapper = mount({ template: "
" }, { global: { plugins: [i18n] } }); - expect(await setLocale(wrapper.vm.$i18n, "de")).toBe(false); - expect(i18n.global.locale.value).toBe("en"); - }); - - it("setLocale(this.$i18n) switches after registerUiI18n", async () => { - const i18n = createAppI18n(); - registerUiI18n(i18n); - const Comp = { - template: `{{ $t("app.language") }}`, - methods: { - async switchTo(code) { - return setLocale(this.$i18n, code); - }, - }, - }; - const wrapper = mount(Comp, { global: { plugins: [i18n] } }); - const before = wrapper.find("#label").text(); - expect(await wrapper.vm.switchTo("de")).toBe(true); - await nextTick(); - expect(i18n.global.locale.value).toBe("de"); - expect(wrapper.vm.$i18n.locale).toBe("de"); - expect(document.documentElement.lang).toBe("de"); - expect(wrapper.find("#label").text()).toBe(de.app.language); - expect(wrapper.find("#label").text()).not.toBe(before); - }); - - it("LanguageSelector selectLanguage applies locale via Options API proxy", async () => { - const i18n = createAppI18n(); - registerUiI18n(i18n); - const Parent = { - components: { LanguageSelector }, - template: ` -
- {{ $t("app.language") }} - -
- `, - data() { - return { lastEmitted: null }; - }, - methods: { - async onLanguageChange(langCode) { - this.lastEmitted = normalizeUiLocaleCode(langCode); - // Parent still persists config. Locale already applied by LanguageSelector. - }, - }, - }; - const wrapper = mount(Parent, { - global: { - plugins: [i18n], - stubs: { MaterialDesignIcon: true, Teleport: true }, - }, - }); - const selector = wrapper.findComponent({ name: "LanguageSelector" }); - expect(selector.exists()).toBe(true); - await selector.vm.selectLanguage("de"); - await flushPromises(); - await nextTick(); - expect(i18n.global.locale.value).toBe("de"); - expect(wrapper.vm.lastEmitted).toBe("de"); - expect(wrapper.find("#msg").text()).toBe(de.app.language); - }); - - it("App-style handler recovers when only the Options API proxy is available", async () => { - const i18n = createAppI18n(); - registerUiI18n(i18n); - const Comp = { - template: `{{ $t("app.language") }}`, - methods: { - async onLanguageChange(langCode) { - const code = normalizeUiLocaleCode(langCode); - await setLocale(this.$i18n, code); - }, - }, - }; - const wrapper = mount(Comp, { global: { plugins: [i18n] } }); - await wrapper.vm.onLanguageChange("fr"); - await nextTick(); - expect(i18n.global.locale.value).toBe("fr"); - expect(wrapper.find("#msg").text()).not.toBe(en.app.language); - }); - - it("oracle: every bundled pack is reachable via Options API setLocale", async () => { - const i18n = createAppI18n(); - registerUiI18n(i18n); - const wrapper = mount({ template: "
" }, { global: { plugins: [i18n] } }); - const packs = listLocaleCodes(); - expect(packs.length).toBeGreaterThanOrEqual(8); - for (const code of packs) { - expect(await setLocale(wrapper.vm.$i18n, code)).toBe(true); - expect(i18n.global.locale.value).toBe(code); - expect(listAvailableLocalesOrThrow(i18n).includes(code)).toBe(true); - } - }); -}); - -function listAvailableLocalesOrThrow(i18n) { - const locales = i18n.global.availableLocales; - if (!Array.isArray(locales)) { - throw new Error("expected availableLocales array"); - } - return locales; -} diff --git a/tests/frontend/LoadTimePerformance.test.js b/tests/frontend/LoadTimePerformance.test.js deleted file mode 100644 index 6b5f89523..000000000 --- a/tests/frontend/LoadTimePerformance.test.js +++ /dev/null @@ -1,250 +0,0 @@ -import { mount, flushPromises } from "@vue/test-utils"; -import { cleanup, fireEvent, render } from "@testing-library/svelte"; -import { describe, it, expect, vi, beforeEach } from "vitest"; -import PropagationNodesPage from "../../meshchatx/src/frontend/components/propagation-nodes/PropagationNodesPage.vue"; -import MessagesSidebar from "../../meshchatx/src/frontend/features/messages/components/MessagesSidebar.svelte"; -import NomadNetworkSidebar from "../../meshchatx/src/frontend/components/nomadnetwork/NomadNetworkSidebar.vue"; - -const MAX_PROP_NODES_MS = 3000; -const MAX_MESSAGES_ANNOUNCES_MS = 12000; -const MAX_NOMADNET_NODES_MS = 3000; - -vi.mock("../../meshchatx/src/frontend/js/WebSocketConnection", () => ({ - default: { on: vi.fn(), off: vi.fn(), send: vi.fn() }, -})); - -vi.mock("../../meshchatx/src/frontend/js/ToastUtils", () => ({ - default: { success: vi.fn(), error: vi.fn() }, -})); - -vi.mock("../../meshchatx/src/frontend/js/GlobalState", () => ({ - default: { - config: { theme: "light", banished_effect_enabled: false }, - blockedDestinations: [], - }, -})); - -vi.mock("../../meshchatx/src/frontend/js/Utils", () => ({ - default: { - formatTimeAgo: (d) => "1h ago", - formatTimeAgoForI18n: (d) => "1h ago", - formatDestinationHash: (h) => (h && h.length >= 8 ? h.slice(0, 8) + "…" : h), - parseDestinationHash: (h) => { - let text = String(h || "").trim(); - text = text.replace(/^(lxmf|lxma|lxm|rns):\/\//i, ""); - const compact = text.replace(/[^0-9a-fA-F]/g, ""); - return compact.length >= 32 ? compact.slice(0, 32).toLowerCase() : null; - }, - }, -})); - -vi.mock("../../meshchatx/src/frontend/js/GlobalEmitter", () => ({ - default: { on: vi.fn(), off: vi.fn(), emit: vi.fn() }, -})); - -const MaterialDesignIcon = { template: '
', props: ["iconName"] }; -const LxmfUserIcon = { template: '
' }; - -function makePropagationNode(i) { - return { - destination_hash: `dest_${i}`.padEnd(32, "0").slice(0, 32), - operator_display_name: `Prop Node Operator ${i}`, - updated_at: new Date(Date.now() - i * 60000).toISOString(), - is_propagation_enabled: true, - }; -} - -function makePeer(i) { - const hash = `p${String(i).padStart(31, "0")}`; - return { - destination_hash: hash, - display_name: `Peer ${i}`, - updated_at: new Date(Date.now() - i * 60000).toISOString(), - hops: i % 3, - snr: 10 + (i % 5), - }; -} - -function makeNomadNode(i) { - const hash = `n${String(i).padStart(31, "0")}`; - return { - destination_hash: hash, - identity_hash: `i${String(i).padStart(31, "0")}`, - display_name: `Nomad Node ${i}`, - updated_at: new Date(Date.now() - i * 60000).toISOString(), - }; -} - -describe("Load time with prefilled data", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - describe("Propagation nodes section", () => { - it("loads and renders 500 propagation nodes within threshold", async () => { - const count = 500; - const nodes = Array.from({ length: count }, (_, i) => makePropagationNode(i)); - - const axiosGet = vi.fn((url) => { - if (url === "/api/v1/config") { - return Promise.resolve({ - data: { - config: { lxmf_preferred_propagation_node_destination_hash: null }, - }, - }); - } - if (url.startsWith("/api/v1/lxmf/propagation-nodes")) { - return Promise.resolve({ data: { lxmf_propagation_nodes: nodes } }); - } - return Promise.resolve({ data: {} }); - }); - window.api = { - get: axiosGet, - patch: vi.fn(() => Promise.resolve({ data: {} })), - isCancel: vi.fn().mockReturnValue(false), - }; - - const start = performance.now(); - const wrapper = mount(PropagationNodesPage, { - global: { - mocks: { $t: (key) => key }, - }, - }); - await flushPromises(); - await wrapper.vm.$nextTick(); - const end = performance.now(); - const loadMs = end - start; - - expect(wrapper.vm.propagationNodes.length).toBe(count); - expect(wrapper.vm.paginatedNodes.length).toBe(20); - expect(loadMs).toBeLessThan(MAX_PROP_NODES_MS); - if (process.env.CI !== "true") { - console.log( - `Propagation nodes: ${count} nodes loaded in ${loadMs.toFixed(0)}ms (max ${MAX_PROP_NODES_MS}ms)` - ); - } - }); - }); - - describe("Messages section", () => { - it("renders sidebar with 2000 conversations within threshold", async () => { - const count = 2000; - const conversations = Array.from({ length: count }, (_, i) => ({ - destination_hash: i.toString(16).padStart(32, "0"), - display_name: `Peer ${i}`, - updated_at: new Date().toISOString(), - latest_message_preview: `Preview ${i}`, - is_unread: i % 10 === 0, - failed_messages_count: 0, - })); - - const start = performance.now(); - const view = render(MessagesSidebar, { - props: { - conversations, - peers: {}, - selectedDestinationHash: "", - isLoading: false, - isLoadingMore: false, - hasMoreConversations: false, - hasMoreAnnounces: false, - isLoadingMoreAnnounces: false, - totalPeersCount: 0, - }, - }); - const end = performance.now(); - - expect(view.container.querySelectorAll("li")).toHaveLength(count); - expect(end - start).toBeLessThan(MAX_MESSAGES_ANNOUNCES_MS); - if (process.env.CI !== "true") { - console.log( - `Messages: ${count} conversations in ${(end - start).toFixed(0)}ms (max ${MAX_MESSAGES_ANNOUNCES_MS}ms)` - ); - } - cleanup(); - }); - }); - - describe("Announces section (messages sidebar)", () => { - it("renders announces tab with 1500 peers within threshold", async () => { - const count = 1500; - const peers = Object.fromEntries( - Array.from({ length: count }, (_, i) => { - const p = makePeer(i); - return [p.destination_hash, p]; - }) - ); - - const start = performance.now(); - const view = render(MessagesSidebar, { - props: { - conversations: [], - peers, - selectedDestinationHash: "", - isLoading: false, - isLoadingMore: false, - hasMoreConversations: false, - hasMoreAnnounces: false, - isLoadingMoreAnnounces: false, - totalPeersCount: count, - }, - }); - await fireEvent.click(view.container.querySelectorAll("button")[1]); - const end = performance.now(); - - expect(view.container.querySelectorAll("li")).toHaveLength(count); - expect(end - start).toBeLessThan(MAX_MESSAGES_ANNOUNCES_MS); - if (process.env.CI !== "true") { - console.log( - `Announces (messages): ${count} peers in ${(end - start).toFixed(0)}ms (max ${MAX_MESSAGES_ANNOUNCES_MS}ms)` - ); - } - cleanup(); - }, 20000); - }); - - describe("NomadNet nodes section", () => { - it("renders sidebar announces tab with 800 nodes within threshold", async () => { - const count = 800; - const nodes = Object.fromEntries( - Array.from({ length: count }, (_, i) => { - const n = makeNomadNode(i); - return [n.destination_hash, n]; - }) - ); - - const start = performance.now(); - const wrapper = mount(NomadNetworkSidebar, { - props: { - nodes, - favourites: [], - selectedDestinationHash: "", - nodesSearchTerm: "", - totalNodesCount: count, - isLoadingMoreNodes: false, - hasMoreNodes: false, - }, - global: { - components: { - MaterialDesignIcon, - IconButton: { template: "" }, - DropDownMenu: { template: '
' }, - DropDownMenuItem: { template: "
" }, - }, - mocks: { $t: (key) => key }, - }, - }); - wrapper.vm.tab = "announces"; - await wrapper.vm.$nextTick(); - const end = performance.now(); - - expect(wrapper.vm.searchedNodes.length).toBe(count); - expect(end - start).toBeLessThan(MAX_NOMADNET_NODES_MS); - if (process.env.CI !== "true") { - console.log( - `NomadNet nodes: ${count} nodes in ${(end - start).toFixed(0)}ms (max ${MAX_NOMADNET_NODES_MS}ms)` - ); - } - }); - }); -}); diff --git a/tests/frontend/MessageSendingFailures.test.js b/tests/frontend/MessageSendingFailures.test.js index 8bdfda98f..c143f07a8 100644 --- a/tests/frontend/MessageSendingFailures.test.js +++ b/tests/frontend/MessageSendingFailures.test.js @@ -62,8 +62,6 @@ describe("message sending failure contracts", () => { expect(peerPathNeedsRefresh(null)).toBe(true); expect(peerPathNeedsRefresh({ path: null, path_stale: false, path_unresponsive: false })).toBe(true); expect(peerPathNeedsRefresh({ path: { hops: 1 }, path_stale: true, path_unresponsive: false })).toBe(true); - expect(peerPathNeedsRefresh({ path: { hops: 1 }, path_stale: false, path_unresponsive: false })).toBe( - false - ); + expect(peerPathNeedsRefresh({ path: { hops: 1 }, path_stale: false, path_unresponsive: false })).toBe(false); }); }); diff --git a/tests/frontend/MountLoadPerformance.test.js b/tests/frontend/MountLoadPerformance.test.js deleted file mode 100644 index 258531e72..000000000 --- a/tests/frontend/MountLoadPerformance.test.js +++ /dev/null @@ -1,215 +0,0 @@ -import { mount, flushPromises } from "@vue/test-utils"; -import { cleanup, render } from "@testing-library/svelte"; -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import MessagesPage from "@/features/messages/MessagesPage.svelte"; -import NomadNetworkBrowser from "@/components/nomadnetwork/NomadNetworkBrowser.vue"; -import { conversationListSignature, syncConversationListInPlace } from "@/js/lxmfConversationListSync"; - -const MAX_MESSAGES_MOUNT_MS = 2500; -const MAX_NOMAD_BROWSER_RESTORE_MS = 1500; - -vi.mock("@/js/GlobalEmitter", () => ({ - default: { - on: vi.fn(), - off: vi.fn(), - emit: vi.fn(), - }, -})); - -vi.mock("@/js/WebSocketConnection", () => ({ - default: { on: vi.fn(), off: vi.fn() }, -})); - -vi.mock("@/components/nomadnetwork/NomadNetworkPage.vue", () => ({ - default: { - name: "NomadNetworkPage", - template: '
', - props: { - destinationHash: { type: String, default: "" }, - initialPath: { type: String, default: null }, - embedded: { type: Boolean, default: false }, - tabsEnabled: { type: Boolean, default: false }, - isActive: { type: Boolean, default: true }, - }, - emits: ["navigate", "open-node", "close-tab"], - }, -})); - -const MaterialDesignIconStub = { - name: "MaterialDesignIcon", - template: '
', - props: ["iconName"], -}; - -describe("Mount load performance regressions", () => { - describe("MessagesPage mount", () => { - let axiosMock; - - beforeEach(() => { - localStorage.clear(); - axiosMock = { - get: vi.fn(), - post: vi.fn(), - isCancel: vi.fn(() => false), - }; - window.api = axiosMock; - axiosMock.get.mockImplementation((url) => { - if (url === "/api/v1/config") { - return Promise.resolve({ data: { config: { lxmf_address_hash: "my-hash" } } }); - } - if (url === "/api/v1/lxmf/conversations") { - return Promise.resolve({ data: { conversations: [] } }); - } - if (url === "/api/v1/lxmf/conversation-pins") { - return Promise.resolve({ data: { peer_hashes: [] } }); - } - if (url === "/api/v1/lxmf/folders") { - return Promise.resolve({ data: { folders: [] } }); - } - if (url === "/api/v1/announces") { - return Promise.resolve({ data: { announces: [] } }); - } - return Promise.resolve({ data: {} }); - }); - }); - - afterEach(() => { - cleanup(); - delete window.api; - }); - - const mountMessagesPage = () => - render(MessagesPage, { - props: { destinationHash: "", routeQuery: {} }, - }); - - it("does not fetch lxmf delivery announces on initial mount", async () => { - mountMessagesPage(); - await flushPromises(); - - const announceCalls = axiosMock.get.mock.calls.filter( - (call) => call[0] === "/api/v1/announces" && call[1]?.params?.aspect === "lxmf.delivery" - ); - expect(announceCalls).toHaveLength(0); - }); - - it("mounts within the messages page budget", async () => { - const start = performance.now(); - mountMessagesPage(); - await flushPromises(); - const elapsed = performance.now() - start; - expect(elapsed).toBeLessThan(MAX_MESSAGES_MOUNT_MS); - }); - - it("skips redundant conversation list replacement when polling returns the same signature", async () => { - const conversations = Array.from({ length: 250 }, (_, index) => ({ - destination_hash: index.toString(16).padStart(32, "0"), - display_name: `Peer ${index}`, - updated_at: "2026-01-01T00:00:00Z", - is_unread: index % 7 === 0, - failed_messages_count: 0, - latest_message_created_at: index, - latest_message_preview: `Preview ${index}`, - })); - const signature = conversationListSignature(conversations); - const existing = conversations.map((conversation) => ({ ...conversation })); - const refsBefore = existing.map((conversation) => conversation); - - syncConversationListInPlace( - existing, - conversations.map((conversation) => ({ ...conversation })) - ); - - expect(conversationListSignature(existing)).toBe(signature); - expect(existing.every((conversation, index) => conversation === refsBefore[index])).toBe(true); - }); - }); - - describe("NomadNetworkBrowser restore", () => { - beforeEach(() => { - localStorage.clear(); - }); - - const mountBrowser = () => - mount(NomadNetworkBrowser, { - global: { - mocks: { - $t: (key) => key, - $route: { name: "nomadnetwork", params: {}, query: {} }, - $router: { replace: vi.fn(() => Promise.resolve()) }, - }, - stubs: { - MaterialDesignIcon: MaterialDesignIconStub, - NomadBrowserContextMenu: true, - }, - }, - }); - - it("only mounts the active tab page when multiple tabs are restored", async () => { - localStorage.setItem( - "meshchatx.nomadnet.tabs", - JSON.stringify({ - tabs: [ - { destinationHash: "a".repeat(32), path: null, title: "Alpha" }, - { destinationHash: "b".repeat(32), path: null, title: "Bravo" }, - { destinationHash: "c".repeat(32), path: null, title: "Charlie" }, - ], - activeIndex: 1, - }) - ); - - const wrapper = mountBrowser(); - await wrapper.vm.$nextTick(); - - expect(wrapper.vm.tabs).toHaveLength(3); - expect(wrapper.findAllComponents({ name: "NomadNetworkPage" })).toHaveLength(1); - expect(wrapper.vm.isTabMounted(wrapper.vm.activeTabId)).toBe(true); - }); - - it("mounts inactive tabs lazily when they are selected", async () => { - localStorage.setItem( - "meshchatx.nomadnet.tabs", - JSON.stringify({ - tabs: [ - { destinationHash: "a".repeat(32), path: null, title: "Alpha" }, - { destinationHash: "b".repeat(32), path: null, title: "Bravo" }, - ], - activeIndex: 0, - }) - ); - - const wrapper = mountBrowser(); - await wrapper.vm.$nextTick(); - const secondTabId = wrapper.vm.tabs[1].id; - - wrapper.vm.selectTab(secondTabId); - await wrapper.vm.$nextTick(); - - expect(wrapper.findAllComponents({ name: "NomadNetworkPage" })).toHaveLength(2); - expect(wrapper.vm.isTabMounted(secondTabId)).toBe(true); - }); - - it("restores multiple tabs within the browser mount budget", async () => { - localStorage.setItem( - "meshchatx.nomadnet.tabs", - JSON.stringify({ - tabs: Array.from({ length: 8 }, (_, index) => ({ - destinationHash: `${index}`.padStart(32, "a"), - path: null, - title: `Tab ${index}`, - })), - activeIndex: 3, - }) - ); - - const start = performance.now(); - const wrapper = mountBrowser(); - await wrapper.vm.$nextTick(); - const elapsed = performance.now() - start; - - expect(wrapper.vm.tabs).toHaveLength(8); - expect(wrapper.findAllComponents({ name: "NomadNetworkPage" })).toHaveLength(1); - expect(elapsed).toBeLessThan(MAX_NOMAD_BROWSER_RESTORE_MS); - }); - }); -}); diff --git a/tests/frontend/NameOverwriteFixes.test.js b/tests/frontend/NameOverwriteFixes.test.js index 16fd7e316..421fe5faa 100644 --- a/tests/frontend/NameOverwriteFixes.test.js +++ b/tests/frontend/NameOverwriteFixes.test.js @@ -102,10 +102,9 @@ describe("ContactsPage edit contact name", () => { expect(api.patch).toHaveBeenCalledWith("/api/v1/telephone/contacts/42", { name: "Renamed Alice", }); - expect(api.post).toHaveBeenCalledWith( - `/api/v1/destination/${"a".repeat(32)}/custom-display-name/update`, - { display_name: "Renamed Alice" } - ); + expect(api.post).toHaveBeenCalledWith(`/api/v1/destination/${"a".repeat(32)}/custom-display-name/update`, { + display_name: "Renamed Alice", + }); }); it("does nothing when the prompt is cancelled or unchanged", async () => { @@ -127,9 +126,8 @@ describe("ContactsPage edit contact name", () => { await editContactNameWithDuplicates(contact, [contact], async () => {}); - expect(api.post).toHaveBeenCalledWith( - `/api/v1/destination/${"l".repeat(32)}/custom-display-name/update`, - { display_name: "Updated" } - ); + expect(api.post).toHaveBeenCalledWith(`/api/v1/destination/${"l".repeat(32)}/custom-display-name/update`, { + display_name: "Updated", + }); }); }); diff --git a/tests/frontend/Performance.test.js b/tests/frontend/Performance.test.js deleted file mode 100644 index 917c64701..000000000 --- a/tests/frontend/Performance.test.js +++ /dev/null @@ -1,156 +0,0 @@ -import { cleanup, render } from "@testing-library/svelte"; -import { describe, it, expect, vi } from "vitest"; -import MessagesSidebar from "../../meshchatx/src/frontend/features/messages/components/MessagesSidebar.svelte"; -import { visibleConversationItems } from "../../meshchatx/src/frontend/features/messages/lib/conversationViewerMessages.ts"; - -// Mock dependencies -vi.mock("../../meshchatx/src/frontend/js/GlobalState", () => ({ - default: { - config: { theme: "light", banished_effect_enabled: false }, - blockedDestinations: [], - }, -})); - -vi.mock("../../meshchatx/src/frontend/js/Utils", () => ({ - default: { - formatTimeAgo: () => "1 hour ago", - formatBytes: () => "1 KB", - formatDestinationHash: (h) => h, - convertUnixMillisToLocalDateTimeString: (ms) => "2026-01-01 12:00 PM", - convertDateTimeToLocalDateTimeString: (dt) => "2026-01-01 12:00 PM", - escapeHtml: (t) => - t.replace( - /[&<>"']/g, - (m) => - ({ - "&": "&", - "<": "<", - ">": ">", - '"': """, - "'": "'", - })[m] - ), - }, -})); - -vi.mock("../../meshchatx/src/frontend/js/WebSocketConnection", () => ({ - default: { - on: vi.fn(), - off: vi.fn(), - send: vi.fn(), - }, -})); - -vi.mock("../../meshchatx/src/frontend/js/GlobalEmitter", () => ({ - default: { - on: vi.fn(), - off: vi.fn(), - emit: vi.fn(), - }, -})); - -// Mock axios -global.api = { - get: vi.fn(() => Promise.resolve({ data: {} })), - post: vi.fn(() => Promise.resolve({ data: {} })), - patch: vi.fn(() => Promise.resolve({ data: {} })), - isCancel: vi.fn().mockReturnValue(false), -}; -window.api = global.api; - -// Mock localStorage -const localStorageMock = { - getItem: vi.fn(() => null), - setItem: vi.fn(), - clear: vi.fn(), -}; -global.localStorage = localStorageMock; - -// Mock MaterialDesignIcon -const MaterialDesignIcon = { - template: '
', - props: ["iconName"], -}; - -describe("UI Performance and Memory Tests", () => { - const getMemoryUsage = () => { - if (global.process && process.memoryUsage) { - return process.memoryUsage().heapUsed / (1024 * 1024); - } - return 0; - }; - - it("renders MessagesSidebar with 2000 conversations quickly and tracks memory", async () => { - const numConvs = 2000; - const conversations = Array.from({ length: numConvs }, (_, i) => ({ - destination_hash: i.toString(16).padStart(32, "0"), - display_name: `Peer ${i}`, - updated_at: new Date().toISOString(), - latest_message_preview: `Latest message from peer ${i}`, - is_unread: i % 10 === 0, - failed_messages_count: i % 50 === 0 ? 1 : 0, - })); - - const startMem = getMemoryUsage(); - const start = performance.now(); - - const view = render(MessagesSidebar, { - props: { - conversations, - peers: {}, - selectedDestinationHash: "", - isLoading: false, - isLoadingMore: false, - hasMoreConversations: false, - }, - }); - - const end = performance.now(); - const endMem = getMemoryUsage(); - const renderTime = end - start; - const memGrowth = endMem - startMem; - - console.log( - `Rendered ${numConvs} conversations in ${renderTime.toFixed(2)}ms, Memory growth: ${memGrowth.toFixed(2)}MB` - ); - - expect(view.container.querySelectorAll("li")).toHaveLength(numConvs); - expect(renderTime).toBeLessThan(12000); - expect(memGrowth).toBeLessThan(256); - cleanup(); - }, 60_000); - - it("measures performance of data updates in ConversationViewer", async () => { - const numMsgs = 1000; - const myLxmfAddressHash = "my_hash"; - const selectedPeer = { - destination_hash: "peer_hash", - display_name: "Peer Name", - }; - - const chatItems = Array.from({ length: numMsgs }, (_, i) => ({ - type: "lxmf_message", - is_outbound: i % 2 === 0, - lxmf_message: { - hash: `msg_${i}`.padEnd(32, "0"), - source_hash: i % 2 === 0 ? myLxmfAddressHash : "peer_hash", - destination_hash: i % 2 === 0 ? "peer_hash" : myLxmfAddressHash, - content: `Message content ${i}.`.repeat(5), - created_at: new Date().toISOString(), - state: "delivered", - method: "direct", - progress: 1.0, - delivery_attempts: 1, - id: i, - }, - })); - - const start = performance.now(); - const visible = visibleConversationItems(chatItems, selectedPeer.destination_hash, true); - const end = performance.now(); - - expect(visible).toHaveLength(numMsgs); - console.log(`Filtered 1000 messages in ConversationViewer in ${(end - start).toFixed(2)}ms`); - expect(end - start).toBeLessThan(12000); - }, 30_000); -}); diff --git a/tests/frontend/__snapshots__/uiSnapshots.test.js.snap b/tests/frontend/__snapshots__/uiSnapshots.test.js.snap deleted file mode 100644 index 815dd335e..000000000 --- a/tests/frontend/__snapshots__/uiSnapshots.test.js.snap +++ /dev/null @@ -1,19 +0,0 @@ -// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html - -exports[`UI snapshot regression > ConfirmDialog.vue > visible confirm dialog 1`] = `"

common.confirm_action

Delete this item?

"`; - -exports[`UI snapshot regression > FormLabel.vue > default label 1`] = `""`; - -exports[`UI snapshot regression > FormSubLabel.vue > default sub-label 1`] = `"
Optional helper text
"`; - -exports[`UI snapshot regression > IconButton.vue > icon button 1`] = `""`; - -exports[`UI snapshot regression > SidebarLink.vue > sidebar link 1`] = `"MMessages"`; - -exports[`UI snapshot regression > Toast.vue > empty state 1`] = `"
"`; - -exports[`UI snapshot regression > Toast.vue > error toast 1`] = `"
"`; - -exports[`UI snapshot regression > Toast.vue > loading toast 1`] = `"
"`; - -exports[`UI snapshot regression > Toast.vue > success toast 1`] = `"
"`; diff --git a/tests/frontend/featureModuleLocCaps.test.js b/tests/frontend/featureModuleLocCaps.test.js new file mode 100644 index 000000000..97b9e918e --- /dev/null +++ b/tests/frontend/featureModuleLocCaps.test.js @@ -0,0 +1,108 @@ +// SPDX-License-Identifier: 0BSD + +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { join, relative, resolve } from "node:path"; +import { describe, expect, it } from "vitest"; + +const ROOT = resolve(import.meta.dirname, "../.."); +const FEATURES_ROOT = join(ROOT, "meshchatx", "src", "frontend", "features"); + +const PAGE_SHELL_HARD_CAP = 800; +const LEAF_SVELTE_HARD_CAP = 400; +const LIB_TS_HARD_CAP = 500; + +/** + * Counts total lines using wc -l newline split convention. + * Trailing empty line after final newline is not counted as extra line. + */ +function countLines(content) { + if (!content) { + return 0; + } + const lines = content.split(/\r?\n/); + if (lines.length > 0 && lines[lines.length - 1] === "") { + return lines.length - 1; + } + return lines.length; +} + +/** + * Recursively collects file paths while skipping node_modules and hidden folders. + */ +function walkFiles(dir, fileList = []) { + const entries = readdirSync(dir); + for (const entry of entries) { + if (entry === "node_modules" || entry.startsWith(".")) { + continue; + } + const fullPath = join(dir, entry); + const stat = statSync(fullPath); + if (stat.isDirectory()) { + walkFiles(fullPath, fileList); + } else { + fileList.push(fullPath); + } + } + return fileList; +} + +function getFeatureFiles() { + const allFiles = walkFiles(FEATURES_ROOT); + const pageShells = []; + const leafSvelte = []; + const libTs = []; + + for (const filePath of allFiles) { + const relPath = relative(ROOT, filePath); + const content = readFileSync(filePath, "utf8"); + const loc = countLines(content); + const item = { path: relPath, loc }; + + if (filePath.endsWith(".svelte")) { + if (filePath.endsWith("Page.svelte")) { + pageShells.push(item); + } else { + leafSvelte.push(item); + } + } else if (filePath.includes("/lib/") && filePath.endsWith(".ts")) { + libTs.push(item); + } + } + + return { pageShells, leafSvelte, libTs }; +} + +describe("feature module LOC caps oracle", () => { + it("page shell components under features stay within hard LOC cap (800)", () => { + const { pageShells } = getFeatureFiles(); + expect(pageShells.length).toBeGreaterThan(0); + + const offenders = pageShells + .filter((item) => item.loc > PAGE_SHELL_HARD_CAP) + .map((item) => `${item.path}: ${item.loc} lines (cap ${PAGE_SHELL_HARD_CAP})`); + + expect(offenders, "Page shells exceeding hard LOC cap").toEqual([]); + }); + + it("non-page svelte components under features stay within leaf LOC cap (400)", () => { + const { leafSvelte } = getFeatureFiles(); + expect(leafSvelte.length).toBeGreaterThan(0); + + const offenders = leafSvelte + .filter((item) => item.loc > LEAF_SVELTE_HARD_CAP) + .map((item) => `${item.path}: ${item.loc} lines (cap ${LEAF_SVELTE_HARD_CAP})`); + + expect(offenders, "Leaf Svelte components exceeding hard LOC cap").toEqual([]); + }); + + it("feature library modules under features/*/lib stay within LOC cap (500)", () => { + const { libTs } = getFeatureFiles(); + expect(libTs.length).toBeGreaterThan(0); + + const offenders = libTs + .filter((item) => item.loc > LIB_TS_HARD_CAP) + .map((item) => `${item.path}: ${item.loc} lines (cap ${LIB_TS_HARD_CAP})`); + + expect(offenders, "Feature lib TS files exceeding hard LOC cap").toEqual([]); + }); +}); diff --git a/tests/frontend/featureModuleOwnership.test.js b/tests/frontend/featureModuleOwnership.test.js index 4180d1593..db01dd309 100644 --- a/tests/frontend/featureModuleOwnership.test.js +++ b/tests/frontend/featureModuleOwnership.test.js @@ -10,9 +10,18 @@ import { registerContactsFeature } from "../../meshchatx/src/frontend/features/c import { registerDebugLogsFeature } from "../../meshchatx/src/frontend/features/debug-logs/index.ts"; import { registerForwarderFeature } from "../../meshchatx/src/frontend/features/forwarder/index.ts"; import { registerLicensesFeature } from "../../meshchatx/src/frontend/features/licenses/index.ts"; +import { registerMessageBlocklistFeature } from "../../meshchatx/src/frontend/features/message-blocklist/index.ts"; import { registerMessagesFeature } from "../../meshchatx/src/frontend/features/messages/index.ts"; +import { registerPaperMessageFeature } from "../../meshchatx/src/frontend/features/paper-message/index.ts"; import { registerPingFeature } from "../../meshchatx/src/frontend/features/ping/index.ts"; +import { registerReticulumConfigEditorFeature } from "../../meshchatx/src/frontend/features/reticulum-config-editor/index.ts"; +import { registerRnpathFeature } from "../../meshchatx/src/frontend/features/rnpath/index.ts"; +import { registerRnpathTraceFeature } from "../../meshchatx/src/frontend/features/rnpath-trace/index.ts"; +import { registerRnprobeFeature } from "../../meshchatx/src/frontend/features/rnprobe/index.ts"; +import { registerRNStatusFeature } from "../../meshchatx/src/frontend/features/rnstatus/index.ts"; +import { registerSieveFiltersFeature } from "../../meshchatx/src/frontend/features/sieve-filters/index.ts"; import { registerToolsFeature } from "../../meshchatx/src/frontend/features/tools/index.ts"; +import { registerTranslatorFeature } from "../../meshchatx/src/frontend/features/translator/index.ts"; import { filterBlockedIdentities } from "../../meshchatx/src/frontend/features/blocked/lib/blockedList.ts"; const repoRoot = process.cwd(); @@ -111,6 +120,114 @@ const FEATURE_MODULE_OWNERS = [ route_name: "ping", mount: "svelte", }, + { + id: "message-blocklist", + register: registerMessageBlocklistFeature, + required_paths: [ + "meshchatx/src/frontend/features/message-blocklist/index.ts", + "meshchatx/src/frontend/features/message-blocklist/MessageBlocklistPage.svelte", + "meshchatx/src/frontend/features/message-blocklist/lib/blocklistRules.ts", + "meshchatx/src/frontend/features/message-blocklist/lib/constants.ts", + ], + route_name: "message-blocklist", + mount: "svelte", + }, + { + id: "sieve-filters", + register: registerSieveFiltersFeature, + required_paths: [ + "meshchatx/src/frontend/features/sieve-filters/index.ts", + "meshchatx/src/frontend/features/sieve-filters/SieveFiltersPage.svelte", + "meshchatx/src/frontend/features/sieve-filters/lib/sieveRules.ts", + "meshchatx/src/frontend/features/sieve-filters/lib/constants.ts", + ], + route_name: "sieve-filters", + mount: "svelte", + }, + { + id: "paper-message", + register: registerPaperMessageFeature, + required_paths: [ + "meshchatx/src/frontend/features/paper-message/index.ts", + "meshchatx/src/frontend/features/paper-message/PaperMessagePage.svelte", + "meshchatx/src/frontend/features/paper-message/lib/paperPrint.ts", + "meshchatx/src/frontend/features/paper-message/lib/constants.ts", + ], + route_name: "paper-message", + mount: "svelte", + }, + { + id: "reticulum-config-editor", + register: registerReticulumConfigEditorFeature, + required_paths: [ + "meshchatx/src/frontend/features/reticulum-config-editor/index.ts", + "meshchatx/src/frontend/features/reticulum-config-editor/ReticulumConfigEditorPage.svelte", + "meshchatx/src/frontend/features/reticulum-config-editor/lib/configFormat.ts", + "meshchatx/src/frontend/features/reticulum-config-editor/lib/constants.ts", + ], + route_name: "reticulum-config-editor", + mount: "svelte", + }, + { + id: "rnprobe", + register: registerRnprobeFeature, + required_paths: [ + "meshchatx/src/frontend/features/rnprobe/index.ts", + "meshchatx/src/frontend/features/rnprobe/RNProbePage.svelte", + "meshchatx/src/frontend/features/rnprobe/lib/probeFormat.ts", + "meshchatx/src/frontend/features/rnprobe/lib/constants.ts", + ], + route_name: "rnprobe", + mount: "svelte", + }, + { + id: "rnpath-trace", + register: registerRnpathTraceFeature, + required_paths: [ + "meshchatx/src/frontend/features/rnpath-trace/index.ts", + "meshchatx/src/frontend/features/rnpath-trace/RNPathTracePage.svelte", + "meshchatx/src/frontend/features/rnpath-trace/lib/traceFormat.ts", + "meshchatx/src/frontend/features/rnpath-trace/lib/constants.ts", + ], + route_name: "rnpath-trace", + mount: "svelte", + }, + { + id: "rnpath", + register: registerRnpathFeature, + required_paths: [ + "meshchatx/src/frontend/features/rnpath/index.ts", + "meshchatx/src/frontend/features/rnpath/RNPathPage.svelte", + "meshchatx/src/frontend/features/rnpath/lib/pathQuery.ts", + "meshchatx/src/frontend/features/rnpath/lib/constants.ts", + ], + route_name: "rnpath", + mount: "svelte", + }, + { + id: "rnstatus", + register: registerRNStatusFeature, + required_paths: [ + "meshchatx/src/frontend/features/rnstatus/index.ts", + "meshchatx/src/frontend/features/rnstatus/RNStatusPage.svelte", + "meshchatx/src/frontend/features/rnstatus/lib/statusFormat.ts", + "meshchatx/src/frontend/features/rnstatus/lib/constants.ts", + ], + route_name: "rnstatus", + mount: "svelte", + }, + { + id: "translator", + register: registerTranslatorFeature, + required_paths: [ + "meshchatx/src/frontend/features/translator/index.ts", + "meshchatx/src/frontend/features/translator/TranslatorPage.svelte", + "meshchatx/src/frontend/features/translator/lib/translatorEngine.ts", + "meshchatx/src/frontend/features/translator/lib/constants.ts", + ], + route_name: "translator", + mount: "svelte", + }, { id: "tools", register: registerToolsFeature, diff --git a/tests/frontend/frontendStackOracle.test.js b/tests/frontend/frontendStackOracle.test.js deleted file mode 100644 index 80e377784..000000000 --- a/tests/frontend/frontendStackOracle.test.js +++ /dev/null @@ -1,85 +0,0 @@ -// SPDX-License-Identifier: 0BSD - -import { readdirSync, readFileSync, statSync } from "node:fs"; -import { join, resolve } from "node:path"; -import { describe, expect, it } from "vitest"; - -const ROOT = resolve(import.meta.dirname, "../.."); -const FRONTEND_ROOT = join(ROOT, "meshchatx", "src", "frontend"); - -function readRepo(relPath) { - return readFileSync(resolve(ROOT, relPath), "utf8"); -} - -function walkVueFiles(dir, out = []) { - for (const name of readdirSync(dir)) { - const filePath = join(dir, name); - const stat = statSync(filePath); - if (stat.isDirectory()) { - walkVueFiles(filePath, out); - continue; - } - if (name.endsWith(".vue")) { - out.push(filePath); - } - } - return out; -} - -describe("frontend stack migration oracle", () => { - it("package.json no longer depends on Vuetify or @mdi/font", () => { - const pkg = JSON.parse(readRepo("package.json")); - const allDeps = { ...pkg.dependencies, ...pkg.devDependencies }; - expect(allDeps.vuetify).toBeUndefined(); - expect(allDeps["vite-plugin-vuetify"]).toBeUndefined(); - expect(allDeps["@mdi/font"]).toBeUndefined(); - expect(allDeps["@mdi/js"]).toBeTruthy(); - }); - - it("vite.config.js uses Vite 8 features and excludes Vuetify bundling", () => { - const vite = readRepo("vite.config.js"); - expect(vite).toContain("rolldownOptions"); - expect(vite).toContain("chunkImportMap: false"); - expect(vite).toContain("tsconfigPaths: true"); - expect(vite).toContain('"micron-parser"'); - expect(vite).toMatch(/node_modules["'],\s*["']micron-parser["'],\s*["']js["'],\s*["']micron-parser\.js["']/); - expect(vite).toContain("MESHCHAT_VITE_BUNDLED_DEV"); - expect(vite).not.toContain("vite-plugin-vuetify"); - expect(vite).not.toContain("vendor-vuetify"); - }); - - it("main.js bootstraps Vue without Vuetify or MDI font CSS", () => { - const main = readRepo("meshchatx/src/frontend/main.ts"); - expect(main).not.toContain("createVuetify"); - expect(main).not.toContain("@mdi/font"); - expect(main).not.toContain(".use(vuetify)"); - }); - - it("Vue SFCs do not reference Vuetify components or v-icon", () => { - const vueFiles = walkVueFiles(FRONTEND_ROOT); - const offenders = []; - for (const file of vueFiles) { - const body = readFileSync(file, "utf8"); - if ( - / { - const theme = readRepo("meshchatx/src/frontend/theme/themeEngine.ts"); - expect(theme).not.toContain("syncVuetifyThemeColors"); - expect(theme).not.toContain("vuetifyTheme"); - const tokens = readRepo("meshchatx/src/frontend/theme/designTokens.ts"); - expect(tokens).not.toContain("vuetifyThemesFromTokens"); - }); - - it("shared modal and popover components exist for Vuetify replacements", () => { - expect(readRepo("meshchatx/src/frontend/components/AppModal.vue")).toContain("AppModal"); - expect(readRepo("meshchatx/src/frontend/components/ClickPopover.vue")).toContain("ClickPopover"); - }); -}); diff --git a/tests/frontend/i18n.test.js b/tests/frontend/i18n.test.js index 744fd4aab..f7e36fe48 100644 --- a/tests/frontend/i18n.test.js +++ b/tests/frontend/i18n.test.js @@ -58,18 +58,24 @@ describe("i18n Localization Tests", () => { }); }); - it("should find all $t usage in components and ensure they exist in en.json", () => { + it("should find all $t and t() usage in components and ensure they exist in en.json", () => { const frontendDir = path.resolve(__dirname, "../../meshchatx/src/frontend"); const files = []; + const skipDirs = new Set(["node_modules", "dist", "assets", "public", "locales"]); function walkDir(dir) { fs.readdirSync(dir).forEach((file) => { const fullPath = path.join(dir, file); if (fs.statSync(fullPath).isDirectory()) { - if (file !== "node_modules" && file !== "dist" && file !== "assets") { + if (!skipDirs.has(file)) { walkDir(fullPath); } - } else if (file.endsWith(".vue") || file.endsWith(".js")) { + } else if ( + file.endsWith(".vue") || + file.endsWith(".js") || + file.endsWith(".ts") || + file.endsWith(".svelte") + ) { files.push(fullPath); } }); @@ -78,9 +84,8 @@ describe("i18n Localization Tests", () => { walkDir(frontendDir); const foundKeys = new Set(); - // Regex to find $t('key') or $t("key") or $t(`key`) or $t('key', ...) - // Also supports {{ $t('key') }} - const tRegex = /\$t\s*\(\s*['"`]([^'"`]+)['"`]/g; + // Vue $t('key') and framework-free t('key') from js/i18n (Svelte / feature libs) + const tRegex = /(?:\$t|\bt)\s*\(\s*['"`]([^'"`]+)['"`]/g; files.forEach((file) => { const content = fs.readFileSync(file, "utf8"); @@ -91,7 +96,6 @@ describe("i18n Localization Tests", () => { }); const missingInEn = Array.from(foundKeys).filter((key) => { - // Check if key exists in nested object 'en' const parts = key.split("."); let current = en; for (const part of parts) { @@ -107,9 +111,6 @@ describe("i18n Localization Tests", () => { if (nonDynamicMissing.length > 0) { console.warn("Keys used in code but missing in en.json:", nonDynamicMissing); } - // Some keys might be dynamic, so we might want to be careful with this test - // But for now, let's see what it finds. - // We expect some false positives if keys are constructed dynamically. expect(nonDynamicMissing.length).toBe(0); }); diff --git a/tests/frontend/meshValidate.test.js b/tests/frontend/meshValidate.test.js new file mode 100644 index 000000000..3ac69b687 --- /dev/null +++ b/tests/frontend/meshValidate.test.js @@ -0,0 +1,29 @@ +import { describe, it, expect } from "vitest"; +import { isDestinationHash, normalizeDestinationHash } from "@/js/meshValidate.ts"; + +describe("meshValidate", () => { + it("accepts exactly 32 hex digits", () => { + expect(isDestinationHash("a".repeat(32))).toBe(true); + expect(isDestinationHash("A".repeat(32))).toBe(true); + expect(isDestinationHash("0123456789abcdef0123456789abcdef")).toBe(true); + }); + + it("rejects short, long, and non-hex", () => { + expect(isDestinationHash("short")).toBe(false); + expect(isDestinationHash("a".repeat(31))).toBe(false); + expect(isDestinationHash("a".repeat(33))).toBe(false); + expect(isDestinationHash("g".repeat(32))).toBe(false); + expect(isDestinationHash(null)).toBe(false); + expect(isDestinationHash(undefined)).toBe(false); + expect(isDestinationHash(12)).toBe(false); + }); + + it("normalizeDestinationHash strips URI junk then validates", () => { + const hex = "0123456789abcdef0123456789abcdef"; + expect(normalizeDestinationHash(`lxmf://${hex}`)).toBe(hex); + expect(normalizeDestinationHash(`LXMF@${hex}`)).toBe(hex); + expect(normalizeDestinationHash(`${hex}:extra`)).toBe(hex); + expect(normalizeDestinationHash("not-a-hash")).toBe(""); + expect(normalizeDestinationHash("")).toBe(""); + }); +}); diff --git a/tests/frontend/repositoryPaperPrintOracles.test.js b/tests/frontend/repositoryPaperPrintOracles.test.js index 0ec6baa97..2609a0e8d 100644 --- a/tests/frontend/repositoryPaperPrintOracles.test.js +++ b/tests/frontend/repositoryPaperPrintOracles.test.js @@ -79,7 +79,7 @@ describe("repository URL and print XSS oracles", () => { ), "utf8" ); - expect(source).toContain('Utils.escapeHtml(dataUrl)'); + expect(source).toContain("Utils.escapeHtml(dataUrl)"); expect(source).toContain('Utils.escapeHtml(messageHash || "")'); }); }); diff --git a/tests/frontend/rnpathTraceFeature.test.js b/tests/frontend/rnpathTraceFeature.test.js new file mode 100644 index 000000000..d4d5f40b2 --- /dev/null +++ b/tests/frontend/rnpathTraceFeature.test.js @@ -0,0 +1,172 @@ +// SPDX-License-Identifier: 0BSD + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, cleanup, fireEvent, waitFor, screen } from "@testing-library/svelte"; +import RNPathTracePage from "@/features/rnpath-trace/RNPathTracePage.svelte"; +import ToastUtils from "@/js/ToastUtils"; +import { registerFallbackMessages, registerTranslator } from "@/js/i18n.js"; +import { + isValidTraceHash, + formatTraceHash, + getNodeClass, + getNodeIcon, + isUnknownTraceNode, +} from "@/features/rnpath-trace/lib/traceFormat.ts"; +import { registerRnpathTraceFeature } from "@/features/rnpath-trace/index.ts"; +import { clearRoutes, listRoutes } from "@/js/registries/routeRegistry.js"; +import { clearFeatureIds, listFeatureIds } from "@/js/registries/featureRegistry.js"; + +vi.mock("@/js/ToastUtils", () => ({ + default: { + success: vi.fn(), + error: vi.fn(), + }, +})); + +describe("rnpath-trace lib helpers", () => { + it("validates destination hash", () => { + expect(isValidTraceHash("a".repeat(32))).toBe(true); + expect(isValidTraceHash("invalid")).toBe(false); + }); + + it("formats hash with ellipsis", () => { + expect(formatTraceHash("0123456789abcdef0123456789abcdef")).toBe("01234567..."); + expect(formatTraceHash("")).toBe(""); + expect(formatTraceHash(null)).toBe(""); + }); + + it("returns node class and icon for different node types", () => { + expect(getNodeClass({ type: "local" })).toContain("bg-blue-600"); + expect(getNodeClass({ type: "destination" })).toContain("bg-emerald-600"); + expect(getNodeClass({ type: "unknown" })).toContain("border-dashed"); + expect(getNodeClass({ type: "hop" })).toContain("bg-indigo-600"); + + expect(getNodeIcon({ type: "local" })).toBe("home"); + expect(getNodeIcon({ type: "destination" })).toBe("flag-variant"); + expect(getNodeIcon({ type: "unknown" })).toBe("dots-horizontal"); + expect(getNodeIcon({ type: "hop" })).toBe("router-wireless"); + + expect(isUnknownTraceNode({ type: "unknown" })).toBe(true); + expect(isUnknownTraceNode({ type: "local" })).toBe(false); + }); +}); + +describe("registerRnpathTraceFeature", () => { + beforeEach(() => { + clearRoutes(); + clearFeatureIds(); + }); + + afterEach(() => { + clearRoutes(); + clearFeatureIds(); + }); + + it("registers rnpath-trace route correctly", () => { + registerRnpathTraceFeature(); + expect(listFeatureIds()).toContain("rnpath-trace"); + const route = listRoutes().find((r) => r.name === "rnpath-trace"); + expect(route).toBeTruthy(); + expect(route?.path).toBe("/rnpath-trace"); + expect(route?.mount).toBe("svelte"); + }); +}); + +describe("RNPathTracePage.svelte", () => { + let axiosMock; + + beforeEach(() => { + axiosMock = { + get: vi.fn(), + post: vi.fn(), + isCancel: vi.fn(() => false), + }; + window.api = axiosMock; + registerTranslator(null); + registerFallbackMessages({ + app: { tools: "Tools" }, + common: { + copy_to_clipboard: "Copy", + copied: "Copied", + }, + tools: { + back_to_tools: "Back", + rnpath_trace: { + title: "Path Trace", + description: "Trace path to destination", + trace: "Trace", + tracing: "Tracing...", + total_hops: "Total Hops", + interface: "Interface", + next_hop: "Next Hop", + unknown_hops: "{count} Unknown Hops", + ping_test: "Ping Test", + }, + }, + }); + }); + + afterEach(() => { + cleanup(); + delete window.api; + vi.clearAllMocks(); + }); + + it("renders the path trace page", () => { + render(RNPathTracePage); + expect(screen.getByText("Path Trace")).toBeTruthy(); + }); + + it("executes trace and renders result nodes", async () => { + const hash = "a".repeat(32); + axiosMock.get.mockResolvedValue({ + data: { + destination: hash, + hops: 2, + interface: "AutoInterface[Default]", + next_hop: "b".repeat(32), + path: [ + { type: "local", hash: "c".repeat(32), name: "Local Node" }, + { type: "hop", hash: "b".repeat(32), name: "Next Hop", interface: "AutoInterface[Default]" }, + { type: "destination", hash, hops: 2 }, + ], + }, + }); + + render(RNPathTracePage); + const input = screen.getByPlaceholderText("input destination hash"); + await fireEvent.input(input, { target: { value: hash } }); + + const traceBtn = screen.getByTitle("Trace Path"); + await fireEvent.click(traceBtn); + + await waitFor(() => { + expect(axiosMock.get).toHaveBeenCalledWith(`/api/v1/rnpath/trace/${hash}`); + }); + + expect(screen.getAllByText("Local Node").length).toBeGreaterThan(0); + expect(screen.getAllByText("Next Hop").length).toBeGreaterThan(0); + expect(screen.getAllByText("AutoInterface[Default]").length).toBeGreaterThan(0); + }); + + it("handles trace error response", async () => { + const hash = "a".repeat(32); + axiosMock.get.mockResolvedValue({ + data: { + error: "Path not found after timeout", + }, + }); + + render(RNPathTracePage); + const input = screen.getByPlaceholderText("input destination hash"); + await fireEvent.input(input, { target: { value: hash } }); + + const traceBtn = screen.getByTitle("Trace Path"); + await fireEvent.click(traceBtn); + + await waitFor(() => { + expect(screen.getByText("Trace Error")).toBeTruthy(); + expect(screen.getByText("Path not found after timeout")).toBeTruthy(); + }); + }); +}); diff --git a/tests/frontend/rnprobeFeature.test.js b/tests/frontend/rnprobeFeature.test.js new file mode 100644 index 000000000..b342b5579 --- /dev/null +++ b/tests/frontend/rnprobeFeature.test.js @@ -0,0 +1,177 @@ +// SPDX-License-Identifier: 0BSD + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, cleanup, fireEvent, waitFor, screen } from "@testing-library/svelte"; +import RNProbePage from "@/features/rnprobe/RNProbePage.svelte"; +import DialogUtils from "@/js/DialogUtils"; +import { registerFallbackMessages, registerTranslator } from "@/js/i18n.js"; +import { + isValidProbeDestinationHash, + isValidProbeFullName, + parseProbeSummary, + isProbeDelivered, + isProbeTimeout, +} from "@/features/rnprobe/lib/probeFormat.ts"; +import { registerRnprobeFeature } from "@/features/rnprobe/index.ts"; +import { clearRoutes, listRoutes } from "@/js/registries/routeRegistry.js"; +import { clearFeatureIds, listFeatureIds } from "@/js/registries/featureRegistry.js"; + +vi.mock("@/js/DialogUtils", () => ({ + default: { + alert: vi.fn(), + }, +})); + +describe("rnprobe lib helpers", () => { + it("validates destination hash and full name", () => { + expect(isValidProbeDestinationHash("a".repeat(32))).toBe(true); + expect(isValidProbeDestinationHash("invalid-hash")).toBe(false); + expect(isValidProbeFullName("lxmf.delivery")).toBe(true); + expect(isValidProbeFullName("")).toBe(false); + expect(isValidProbeFullName(null)).toBe(false); + }); + + it("parses probe summary safely", () => { + const summary = parseProbeSummary({ + sent: 3, + delivered: 2, + timeouts: 1, + failed: 0, + }); + expect(summary.sent).toBe(3); + expect(summary.delivered).toBe(2); + expect(summary.timeouts).toBe(1); + expect(summary.failed).toBe(0); + }); + + it("classifies probe result status", () => { + expect(isProbeDelivered({ probe_number: 1, size: 16, destination: "dest", status: "delivered" })).toBe(true); + expect(isProbeDelivered({ probe_number: 1, size: 16, destination: "dest", status: "timeout" })).toBe(false); + expect(isProbeTimeout({ probe_number: 1, size: 16, destination: "dest", status: "timeout" })).toBe(true); + }); +}); + +describe("registerRnprobeFeature", () => { + beforeEach(() => { + clearRoutes(); + clearFeatureIds(); + }); + + afterEach(() => { + clearRoutes(); + clearFeatureIds(); + }); + + it("registers rnprobe route correctly", () => { + registerRnprobeFeature(); + expect(listFeatureIds()).toContain("rnprobe"); + const route = listRoutes().find((r) => r.name === "rnprobe"); + expect(route).toBeTruthy(); + expect(route?.path).toBe("/rnprobe"); + expect(route?.mount).toBe("svelte"); + }); +}); + +describe("RNProbePage.svelte", () => { + let axiosMock; + + beforeEach(() => { + axiosMock = { + get: vi.fn(), + post: vi.fn(), + isCancel: vi.fn(() => false), + }; + window.api = axiosMock; + registerTranslator(null); + registerFallbackMessages({ + app: { tools: "Tools" }, + tools: { + back_to_tools: "Back", + rnprobe: { description: "tools.rnprobe.description" }, + }, + rnprobe: { + title: "RNProbe", + network_diagnostics: "Network Diagnostics", + destination_hash: "Destination Hash", + full_destination_name: "Full Name", + probe_size_bytes: "Size", + number_of_probes: "Count", + wait_between_probes: "Wait", + start_probe: "Start", + stop: "Stop", + clear_results: "Clear", + summary: "Summary", + sent: "Sent", + delivered: "Delivered", + timeouts: "Timeouts", + failed: "Failed", + probe_results: "Results", + probe_responses_realtime: "Realtime", + no_probes_yet: "No probes yet", + probe_number: "Probe #{number}", + bytes: "bytes", + hops: "Hops", + rtt: "RTT", + rssi: "RSSI", + snr: "SNR", + quality: "Quality", + timeout: "Timeout", + invalid_hash: "Invalid Hash", + provide_full_name: "Provide Name", + failed_to_probe: "Failed to probe", + }, + }); + }); + + afterEach(() => { + cleanup(); + delete window.api; + vi.clearAllMocks(); + }); + + it("renders the rnprobe page", () => { + render(RNProbePage); + expect(screen.getByText("RNProbe")).toBeTruthy(); + }); + + it("alerts on invalid hash", async () => { + render(RNProbePage); + await fireEvent.click(screen.getByText("Start")); + expect(DialogUtils.alert).toHaveBeenCalledWith("Invalid Hash"); + }); + + it("runs probe and displays results", async () => { + axiosMock.post.mockResolvedValue({ + data: { + sent: 1, + delivered: 1, + timeouts: 0, + failed: 0, + results: [ + { + probe_number: 1, + size: 16, + destination: "7b746057a7294469799cd8d7d429676a", + status: "delivered", + hops: 2, + rtt_string: "45.67ms", + reception_stats: { rssi: -65, snr: 8, quality: 95 }, + }, + ], + }, + }); + + render(RNProbePage); + const hashInput = screen.getByPlaceholderText(/7b746057/); + await fireEvent.input(hashInput, { target: { value: "a".repeat(32) } }); + + await fireEvent.click(screen.getByText("Start")); + + await waitFor(() => { + expect(axiosMock.post).toHaveBeenCalled(); + }); + + expect(screen.getByText(/45.67ms/)).toBeTruthy(); + expect(screen.getByText(/RSSI: -65 dBm/)).toBeTruthy(); + }); +}); diff --git a/tests/frontend/snapshotTestUtils.js b/tests/frontend/snapshotTestUtils.js deleted file mode 100644 index fe0d17f0b..000000000 --- a/tests/frontend/snapshotTestUtils.js +++ /dev/null @@ -1,49 +0,0 @@ -import { mount } from "@vue/test-utils"; - -export const snapshotMaterialDesignIcon = { - name: "MaterialDesignIcon", - template: '', - props: ["iconName"], -}; - -export const snapshotTransitionGroup = { - template: '
', -}; - -export function snapshotI18nMock() { - return (key) => key; -} - -export function normalizeSnapshotHtml(html) { - return String(html) - .replace(/\sdata-v-[a-f0-9]+/g, "") - .replace(/\s+id="[^"]*"/g, "") - .replace(/\s+style="[^"]*"/g, "") - .replace(//g, "") - .replace(/>\s+<") - .trim(); -} - -export function mountSnapshot(component, options = {}) { - const { props, slots, global = {} } = options; - return mount(component, { - props, - slots, - global: { - mocks: { - $t: snapshotI18nMock(), - ...(global.mocks || {}), - }, - stubs: { - MaterialDesignIcon: snapshotMaterialDesignIcon, - TransitionGroup: snapshotTransitionGroup, - ...(global.stubs || {}), - }, - ...global, - }, - }); -} - -export function expectHtmlSnapshot(wrapper) { - expect(normalizeSnapshotHtml(wrapper.html())).toMatchSnapshot(); -} diff --git a/tests/frontend/uiSnapshots.test.js b/tests/frontend/uiSnapshots.test.js deleted file mode 100644 index 6eae456f2..000000000 --- a/tests/frontend/uiSnapshots.test.js +++ /dev/null @@ -1,121 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import Toast from "@/components/Toast.vue"; -import ConfirmDialog from "@/components/ConfirmDialog.vue"; -import FormLabel from "@/components/forms/FormLabel.vue"; -import FormSubLabel from "@/components/forms/FormSubLabel.vue"; -import IconButton from "@/components/IconButton.vue"; -import SidebarLink from "@/components/SidebarLink.vue"; -import GlobalEmitter from "@/js/GlobalEmitter"; -import { expectHtmlSnapshot, mountSnapshot } from "./snapshotTestUtils.js"; - -vi.mock("@/js/GlobalEmitter", () => ({ - default: { on: vi.fn(), off: vi.fn(), emit: vi.fn() }, -})); - -const RouterLinkStub = { - name: "RouterLinkStub", - props: ["to"], - template: '', -}; - -describe("UI snapshot regression", () => { - describe("Toast.vue", () => { - let wrapper; - - beforeEach(() => { - vi.useFakeTimers(); - wrapper = mountSnapshot(Toast); - }); - - afterEach(() => { - wrapper?.unmount(); - vi.useRealTimers(); - }); - - it("empty state", () => { - expectHtmlSnapshot(wrapper); - }); - - it("success toast", async () => { - GlobalEmitter.emit("toast", { message: "Saved", type: "success", duration: 0 }); - await wrapper.vm.$nextTick(); - expectHtmlSnapshot(wrapper); - }); - - it("error toast", async () => { - GlobalEmitter.emit("toast", { message: "Failed", type: "error", duration: 0 }); - await wrapper.vm.$nextTick(); - expectHtmlSnapshot(wrapper); - }); - - it("loading toast", async () => { - GlobalEmitter.emit("toast", { - message: "Working", - type: "loading", - duration: 0, - key: "job-1", - }); - await wrapper.vm.$nextTick(); - expectHtmlSnapshot(wrapper); - }); - }); - - describe("ConfirmDialog.vue", () => { - it("visible confirm dialog", async () => { - const wrapper = mountSnapshot(ConfirmDialog); - await Promise.resolve(); - await Promise.resolve(); - const showFn = vi.mocked(GlobalEmitter.on).mock.calls.find((c) => c[0] === "confirm")?.[1]; - showFn?.({ message: "Delete this item?", resolve: vi.fn() }); - await Promise.resolve(); - await Promise.resolve(); - expectHtmlSnapshot(wrapper); - wrapper.unmount(); - }); - }); - - describe("FormLabel.vue", () => { - it("default label", () => { - const wrapper = mountSnapshot(FormLabel, { - slots: { default: "Display name" }, - }); - expectHtmlSnapshot(wrapper); - wrapper.unmount(); - }); - }); - - describe("FormSubLabel.vue", () => { - it("default sub-label", () => { - const wrapper = mountSnapshot(FormSubLabel, { - slots: { default: "Optional helper text" }, - }); - expectHtmlSnapshot(wrapper); - wrapper.unmount(); - }); - }); - - describe("IconButton.vue", () => { - it("icon button", () => { - const wrapper = mountSnapshot(IconButton, { - slots: { default: '+' }, - }); - expectHtmlSnapshot(wrapper); - wrapper.unmount(); - }); - }); - - describe("SidebarLink.vue", () => { - it("sidebar link", () => { - const wrapper = mountSnapshot(SidebarLink, { - props: { to: { name: "messages" } }, - slots: { - icon: 'M', - text: "Messages", - }, - global: { stubs: { RouterLink: RouterLinkStub } }, - }); - expectHtmlSnapshot(wrapper); - wrapper.unmount(); - }); - }); -}); diff --git a/tsconfig.features.json b/tsconfig.features.json new file mode 100644 index 000000000..26e985571 --- /dev/null +++ b/tsconfig.features.json @@ -0,0 +1,18 @@ +{ + "extends": "./tsconfig.svelte.json", + "compilerOptions": { + "strict": true, + "noImplicitAny": false + }, + "include": [ + "meshchatx/src/frontend/features/**/*.ts", + "meshchatx/src/frontend/features/**/*.svelte", + "meshchatx/src/frontend/ui/svelte/**/*.ts", + "meshchatx/src/frontend/ui/svelte/**/*.svelte", + "meshchatx/src/frontend/js/browser-globals.d.ts", + "meshchatx/src/frontend/js/apiClient.ts", + "meshchatx/src/frontend/js/meshValidate.ts", + "svelte.config.js" + ], + "exclude": ["node_modules", "meshchatx/public", "meshchatx/src/frontend/public", "build", "dist", "tests"] +} From 1172f768415ef830be462b56d455675e02124e96 Mon Sep 17 00:00:00 2001 From: Ivan Date: Sat, 5 Sep 2026 10:03:56 -0500 Subject: [PATCH 009/150] feat(refactor): migrate archives feature to Svelte components, including archive listing, viewer, and export functionality --- .../features/archives/ArchivesPage.svelte | 524 ++++++++ .../archives/components/ArchiveCard.svelte | 114 ++ .../archives/components/ArchiveViewer.svelte | 168 +++ .../src/frontend/features/archives/index.ts | 20 + .../features/archives/lib/archiveExport.ts | 54 + .../archives/lib/archiveNavigation.ts | 72 + .../features/archives/lib/archiveRender.ts | 179 +++ .../features/archives/lib/constants.ts | 19 + .../frontend/features/archives/lib/types.ts | 47 + .../MessageBlocklistPage.svelte | 13 +- .../features/messages/MessagesPage.svelte | 6 +- .../components/ConversationViewer.svelte | 1185 ++++++----------- .../ConversationViewerComposerHost.svelte | 8 +- .../ConversationViewerEmptyHost.svelte | 71 + .../ConversationViewerHeaderHost.svelte | 113 ++ .../ConversationViewerModalsHost.svelte | 2 +- .../messages/lib/conversationStickersGifs.ts | 25 +- .../messages/lib/conversationTranslate.ts | 11 +- .../lib/conversationViewerLightbox.ts | 47 + .../lib/conversationViewerMutations.ts | 295 ++++ .../messages/lib/conversationViewerPeerOps.ts | 121 ++ .../messages/lib/conversationViewerSession.ts | 161 +++ .../features/page-nodes/PageNodesPage.svelte | 378 ++++++ .../PageNodeAnnounceSettings.svelte | 73 + .../components/PageNodeCreateModal.svelte | 70 + .../components/PageNodeDetail.svelte | 145 ++ .../components/PageNodeFilesTab.svelte | 65 + .../page-nodes/components/PageNodeItem.svelte | 164 +++ .../components/PageNodePagesTab.svelte | 132 ++ .../components/PageNodeRenameModal.svelte | 69 + .../components/PageNodeToggle.svelte | 35 + .../src/frontend/features/page-nodes/index.ts | 20 + .../features/page-nodes/lib/constants.ts | 21 + .../features/page-nodes/lib/pageNodesApi.ts | 123 ++ .../page-nodes/lib/pageNodesFormat.ts | 108 ++ .../frontend/features/page-nodes/lib/types.ts | 57 + .../RemoteShellFullscreenDialog.svelte | 80 ++ .../components/RemoteShellTerminal.svelte | 228 ++++ .../features/remote-shell/lib/constants.ts | 10 + .../remote-shell/lib/sessionOutput.ts | 99 ++ .../features/remote-shell/lib/types.ts | 31 + .../RepositoryServerPage.svelte | 335 +++++ .../features/repository-server/index.ts | 21 + .../repository-server/lib/constants.ts | 15 + .../repository-server/lib/repositoryServer.ts | 174 +++ .../features/repository-server/lib/types.ts | 37 + .../rnpath-trace/RNPathTracePage.svelte | 2 +- .../features/rnpath/RNPathPage.svelte | 26 +- .../ManagementIdentityPicker.svelte | 10 +- .../features/rnprobe/RNProbePage.svelte | 2 +- .../frontend/features/rnsh/RNSHPage.svelte | 484 +++++++ .../rnsh/components/RNSHConnectTab.svelte | 90 ++ .../rnsh/components/RNSHListenTab.svelte | 84 ++ .../rnsh/components/RNSHSessionsList.svelte | 66 + meshchatx/src/frontend/features/rnsh/index.ts | 22 + .../frontend/features/rnsh/lib/constants.ts | 42 + .../src/frontend/features/rnsh/lib/rnshApi.ts | 103 ++ .../src/frontend/features/rnsh/lib/types.ts | 45 + .../features/rnstatus/lib/statusPoller.ts | 6 +- .../src/frontend/features/rnx/RNXPage.svelte | 488 +++++++ .../rnx/components/RNXExecuteTab.svelte | 146 ++ .../rnx/components/RNXListenTab.svelte | 74 + .../rnx/components/RNXSessionsList.svelte | 66 + meshchatx/src/frontend/features/rnx/index.ts | 22 + .../frontend/features/rnx/lib/constants.ts | 47 + .../src/frontend/features/rnx/lib/rnxApi.ts | 114 ++ .../src/frontend/features/rnx/lib/types.ts | 54 + .../components/SieveFlowNetwork.svelte | 2 +- .../features/sieve-filters/vis-network.d.ts | 3 + meshchatx/src/frontend/js/MicronParser.ts | 4 +- meshchatx/src/frontend/js/MicronWasmLoader.ts | 20 +- .../src/frontend/js/NomadPageRenderer.ts | 4 +- .../src/frontend/js/browser-globals.d.ts | 120 +- meshchatx/src/frontend/js/terminalRender.ts | 2 +- meshchatx/src/frontend/main.ts | 1 + tests/frontend/MessageBlocklistPage.test.js | 348 +++-- tests/frontend/PaperMessagePage.test.js | 207 ++- tests/frontend/RNPathPage.test.js | 252 +++- tests/frontend/RNPathTracePage.test.js | 98 -- tests/frontend/RNProbePage.test.js | 96 -- tests/frontend/RNStatusPage.test.js | 363 +++-- .../ReticulumConfigEditorPage.test.js | 306 +++-- tests/frontend/SieveFiltersPage.test.js | 390 ++++-- tests/frontend/TranslatorPage.test.js | 335 ++++- .../repositoryPaperPrintOracles.test.js | 23 +- 85 files changed, 8658 insertions(+), 1654 deletions(-) create mode 100644 meshchatx/src/frontend/features/archives/ArchivesPage.svelte create mode 100644 meshchatx/src/frontend/features/archives/components/ArchiveCard.svelte create mode 100644 meshchatx/src/frontend/features/archives/components/ArchiveViewer.svelte create mode 100644 meshchatx/src/frontend/features/archives/index.ts create mode 100644 meshchatx/src/frontend/features/archives/lib/archiveExport.ts create mode 100644 meshchatx/src/frontend/features/archives/lib/archiveNavigation.ts create mode 100644 meshchatx/src/frontend/features/archives/lib/archiveRender.ts create mode 100644 meshchatx/src/frontend/features/archives/lib/constants.ts create mode 100644 meshchatx/src/frontend/features/archives/lib/types.ts create mode 100644 meshchatx/src/frontend/features/messages/components/ConversationViewerEmptyHost.svelte create mode 100644 meshchatx/src/frontend/features/messages/components/ConversationViewerHeaderHost.svelte create mode 100644 meshchatx/src/frontend/features/messages/lib/conversationViewerLightbox.ts create mode 100644 meshchatx/src/frontend/features/messages/lib/conversationViewerMutations.ts create mode 100644 meshchatx/src/frontend/features/messages/lib/conversationViewerPeerOps.ts create mode 100644 meshchatx/src/frontend/features/messages/lib/conversationViewerSession.ts create mode 100644 meshchatx/src/frontend/features/page-nodes/PageNodesPage.svelte create mode 100644 meshchatx/src/frontend/features/page-nodes/components/PageNodeAnnounceSettings.svelte create mode 100644 meshchatx/src/frontend/features/page-nodes/components/PageNodeCreateModal.svelte create mode 100644 meshchatx/src/frontend/features/page-nodes/components/PageNodeDetail.svelte create mode 100644 meshchatx/src/frontend/features/page-nodes/components/PageNodeFilesTab.svelte create mode 100644 meshchatx/src/frontend/features/page-nodes/components/PageNodeItem.svelte create mode 100644 meshchatx/src/frontend/features/page-nodes/components/PageNodePagesTab.svelte create mode 100644 meshchatx/src/frontend/features/page-nodes/components/PageNodeRenameModal.svelte create mode 100644 meshchatx/src/frontend/features/page-nodes/components/PageNodeToggle.svelte create mode 100644 meshchatx/src/frontend/features/page-nodes/index.ts create mode 100644 meshchatx/src/frontend/features/page-nodes/lib/constants.ts create mode 100644 meshchatx/src/frontend/features/page-nodes/lib/pageNodesApi.ts create mode 100644 meshchatx/src/frontend/features/page-nodes/lib/pageNodesFormat.ts create mode 100644 meshchatx/src/frontend/features/page-nodes/lib/types.ts create mode 100644 meshchatx/src/frontend/features/remote-shell/components/RemoteShellFullscreenDialog.svelte create mode 100644 meshchatx/src/frontend/features/remote-shell/components/RemoteShellTerminal.svelte create mode 100644 meshchatx/src/frontend/features/remote-shell/lib/constants.ts create mode 100644 meshchatx/src/frontend/features/remote-shell/lib/sessionOutput.ts create mode 100644 meshchatx/src/frontend/features/remote-shell/lib/types.ts create mode 100644 meshchatx/src/frontend/features/repository-server/RepositoryServerPage.svelte create mode 100644 meshchatx/src/frontend/features/repository-server/index.ts create mode 100644 meshchatx/src/frontend/features/repository-server/lib/constants.ts create mode 100644 meshchatx/src/frontend/features/repository-server/lib/repositoryServer.ts create mode 100644 meshchatx/src/frontend/features/repository-server/lib/types.ts create mode 100644 meshchatx/src/frontend/features/rnsh/RNSHPage.svelte create mode 100644 meshchatx/src/frontend/features/rnsh/components/RNSHConnectTab.svelte create mode 100644 meshchatx/src/frontend/features/rnsh/components/RNSHListenTab.svelte create mode 100644 meshchatx/src/frontend/features/rnsh/components/RNSHSessionsList.svelte create mode 100644 meshchatx/src/frontend/features/rnsh/index.ts create mode 100644 meshchatx/src/frontend/features/rnsh/lib/constants.ts create mode 100644 meshchatx/src/frontend/features/rnsh/lib/rnshApi.ts create mode 100644 meshchatx/src/frontend/features/rnsh/lib/types.ts create mode 100644 meshchatx/src/frontend/features/rnx/RNXPage.svelte create mode 100644 meshchatx/src/frontend/features/rnx/components/RNXExecuteTab.svelte create mode 100644 meshchatx/src/frontend/features/rnx/components/RNXListenTab.svelte create mode 100644 meshchatx/src/frontend/features/rnx/components/RNXSessionsList.svelte create mode 100644 meshchatx/src/frontend/features/rnx/index.ts create mode 100644 meshchatx/src/frontend/features/rnx/lib/constants.ts create mode 100644 meshchatx/src/frontend/features/rnx/lib/rnxApi.ts create mode 100644 meshchatx/src/frontend/features/rnx/lib/types.ts create mode 100644 meshchatx/src/frontend/features/sieve-filters/vis-network.d.ts delete mode 100644 tests/frontend/RNPathTracePage.test.js delete mode 100644 tests/frontend/RNProbePage.test.js diff --git a/meshchatx/src/frontend/features/archives/ArchivesPage.svelte b/meshchatx/src/frontend/features/archives/ArchivesPage.svelte new file mode 100644 index 000000000..58651ecb8 --- /dev/null +++ b/meshchatx/src/frontend/features/archives/ArchivesPage.svelte @@ -0,0 +1,524 @@ + + + + +
+
+
+
+
+

{t("archives.title")}

+

{t("archives.description")}

+
+ {#if viewingArchive && !isWideSplit} + + {/if} +
+ + {#if !viewingArchive || isWideSplit} +
+ + + {#if isSearching} +
+ +
+ {:else if searchQuery} + + {/if} +
+ +
+ {#if pagination.total_count > 0} + + {searchQuery + ? t("archives.matches_count", { count: pagination.total_count }) + : t("archives.showing_range", { + start: rangeStart, + end: rangeEnd, + total: pagination.total_count, + })} + + {/if} + +
+ {/if} +
+
+ +
+ {#if !viewingArchive || isWideSplit} +
+ {#if loadError} +
+ +

{t("archives.search_failed")}

+ +
+ {:else if !isLoading && archives.length === 0} +
+ +

+ {searchQuery ? t("archives.no_results") : t("archives.no_archives")} +

+

+ {searchQuery ? t("archives.adjust_filters") : t("archives.browse_to_archive")} +

+ {#if searchQuery} + + {/if} +
+ {:else} +
+
+ {#each archives as archive (archive.id)} + + {/each} +
+ + {#if pagination.total_pages > 1} +
+ + + {t("archives.page_of", { + page: pagination.page, + total_pages: pagination.total_pages, + })} + + +
+ {/if} +
+ {/if} +
+ {/if} + + {#if viewingArchive} + + {/if} +
+
diff --git a/meshchatx/src/frontend/features/archives/components/ArchiveCard.svelte b/meshchatx/src/frontend/features/archives/components/ArchiveCard.svelte new file mode 100644 index 000000000..26b2cef50 --- /dev/null +++ b/meshchatx/src/frontend/features/archives/components/ArchiveCard.svelte @@ -0,0 +1,114 @@ + + + + + +
onSelect?.(archive)} +> +
+
+

+ {archive.node_name} +

+

+ {archive.page_path || "/"} +

+
+
+
{formatArchiveDate(archive.created_at)}
+
+ {(archive.hash || "").substring(0, 8)} +
+
+
+ +
+
+ + {@html previewHtml} +
+
+ +
+ + {shortHash(archive.destination_hash)} + + + {t("archives.view")} + +
+
+ + diff --git a/meshchatx/src/frontend/features/archives/components/ArchiveViewer.svelte b/meshchatx/src/frontend/features/archives/components/ArchiveViewer.svelte new file mode 100644 index 000000000..b3870dd3d --- /dev/null +++ b/meshchatx/src/frontend/features/archives/components/ArchiveViewer.svelte @@ -0,0 +1,168 @@ + + + + +
+
+ +
+
{viewingArchive.node_name}
+
{viewingArchive.page_path || "/"}
+
+ + + + + + +
+ +
+ {#if isLoadingViewer} +
+ +
+ {:else} +
+ + {@html renderedContent} +
+ {/if} +
+
+ + diff --git a/meshchatx/src/frontend/features/archives/index.ts b/meshchatx/src/frontend/features/archives/index.ts new file mode 100644 index 000000000..630e5bce0 --- /dev/null +++ b/meshchatx/src/frontend/features/archives/index.ts @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: 0BSD + +import { registerFeature } from "../../js/registries/featureRegistry.js"; + +/** + * NomadNet page archives feature registration + */ +export function registerArchivesFeature(): void { + registerFeature({ + id: "archives", + routes: [ + { + name: "archives", + path: "/archives", + mount: "svelte", + load: () => import("./ArchivesPage.svelte"), + }, + ], + }); +} diff --git a/meshchatx/src/frontend/features/archives/lib/archiveExport.ts b/meshchatx/src/frontend/features/archives/lib/archiveExport.ts new file mode 100644 index 000000000..aa83dd6c4 --- /dev/null +++ b/meshchatx/src/frontend/features/archives/lib/archiveExport.ts @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: 0BSD + +import DownloadUtils from "../../../js/DownloadUtils.js"; +import type { ArchiveItem } from "./types.js"; + +/** Extract safe base filename from archive page path */ +export function muExportBasename(archive?: Pick | null): string { + let base = (archive?.page_path || "page").split("/").pop() || "page"; + base = base.replace(/[\\/:*?"<>|]+/g, "_").trim() || "page"; + return base; +} + +/** Determine target filename for export preserving known extensions */ +export function muExportFilename(archive?: Pick | null): string { + const base = muExportBasename(archive); + const lower = base.toLowerCase(); + const allowed = [".mu", ".md", ".txt", ".html"]; + if (allowed.some((ext) => lower.endsWith(ext))) { + return base; + } + const without = base.includes(".") ? base.replace(/\.[^.]+$/, "") : base; + return `${without || "page"}.mu`; +} + +/** Determine disambiguated export filename with snapshot hash suffix */ +export function muExportFilenameDisambiguated( + archive?: Pick | null +): string { + const name = muExportFilename(archive); + const match = name.match(/^(.+)(\.[^.]+)$/); + const stem = match ? match[1] : name.replace(/\.[^.]+$/, ""); + const ext = match ? match[2] : ".mu"; + const short = (archive?.hash || "snap").substring(0, 8); + return `${stem}_${short}${ext}`; +} + +/** Download string content as a plain text file */ +export async function downloadTextAsFile( + content?: string | null, + filename = "page.mu" +): Promise { + const blob = new Blob([content ?? ""], { type: "text/plain;charset=utf-8" }); + await DownloadUtils.downloadFile(filename, blob); +} + +/** Export archive content using its computed filename */ +export async function exportArchiveAsMu( + archive?: ArchiveItem | null +): Promise { + if (!archive) { + return; + } + await downloadTextAsFile(archive.content, muExportFilename(archive)); +} diff --git a/meshchatx/src/frontend/features/archives/lib/archiveNavigation.ts b/meshchatx/src/frontend/features/archives/lib/archiveNavigation.ts new file mode 100644 index 000000000..66b71ba83 --- /dev/null +++ b/meshchatx/src/frontend/features/archives/lib/archiveNavigation.ts @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: 0BSD + +import { handleRichHtmlLinkClick } from "../../../js/NomadRichHtmlLinks.js"; +import type { ArchiveItem } from "./types.js"; + +/** Router abstraction allowing Vue router or hash location fallback */ +export interface RouterLike { + push?: (location: { + name: string; + params?: Record; + query?: Record; + }) => void; +} + +/** Open archive in NomadNet browser view */ +export function openInNomadnet(archive: ArchiveItem, router?: RouterLike): void { + if (router?.push) { + router.push({ + name: "nomadnetwork", + params: { destinationHash: archive.destination_hash }, + query: { + path: archive.page_path, + archive_id: archive.id, + }, + }); + return; + } + const params = new URLSearchParams(); + if (archive.page_path) { + params.set("path", archive.page_path); + } + if (archive.id != null) { + params.set("archive_id", String(archive.id)); + } + const qs = params.toString() ? `?${params.toString()}` : ""; + window.location.hash = `#/nomadnetwork/${encodeURIComponent(archive.destination_hash)}${qs}`; +} + +/** Intercept clicks on rendered Nomad archive content */ +export function handleArchiveContentClick(event: MouseEvent, router?: RouterLike): void { + handleRichHtmlLinkClick(event, { + onNomadUrl: (url: string) => { + const [hash, ...pathParts] = url.split(":"); + const path = pathParts.join(":"); + if (router?.push) { + router.push({ + name: "nomadnetwork", + params: { destinationHash: hash }, + query: { path: path }, + }); + return; + } + const params = new URLSearchParams(path ? { path } : {}); + const qs = params.toString() ? `?${params.toString()}` : ""; + window.location.hash = `#/nomadnetwork/${encodeURIComponent(hash)}${qs}`; + }, + onOpenNode: (destination: string) => { + const [hash, ...pathParts] = destination.split(":"); + const path = pathParts.join(":") || "/page/index.mu"; + if (router?.push) { + router.push({ + name: "nomadnetwork", + params: { destinationHash: hash }, + query: { path: path }, + }); + return; + } + const params = new URLSearchParams({ path }); + window.location.hash = `#/nomadnetwork/${encodeURIComponent(hash)}?${params.toString()}`; + }, + }); +} diff --git a/meshchatx/src/frontend/features/archives/lib/archiveRender.ts b/meshchatx/src/frontend/features/archives/lib/archiveRender.ts new file mode 100644 index 000000000..419aaba47 --- /dev/null +++ b/meshchatx/src/frontend/features/archives/lib/archiveRender.ts @@ -0,0 +1,179 @@ +// SPDX-License-Identifier: 0BSD + +import MicronParser from "../../../js/MicronParser.js"; +import { isolateNomadLinksInHtml, renderNomadPageByPath } from "../../../js/NomadPageRenderer.js"; +import Utils from "../../../js/Utils.js"; +import type { ArchiveItem, NomadRenderOptions } from "./types.js"; + +/** Shorten destination hash for badge display */ +export function shortHash(hash?: string | null): string { + return (hash || "").substring(0, 12); +} + +/** Format date string as relative time */ +export function formatArchiveDate(dateStr?: string | null): string { + return Utils.formatTimeAgo(dateStr); +} + +/** Sanitize text for safe insertion into HTML */ +export function escapeHtml(value?: unknown): string { + return String(value ?? "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +/** Highlight query terms within snippet text */ +export function highlightMatch(snippet: string, searchQuery: string): string { + const safe = escapeHtml(snippet); + const q = (searchQuery || "").trim(); + if (!q || q.length < 2) { + return safe; + } + const escaped = q.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + try { + const re = new RegExp(`(${escaped})`, "ig"); + return safe.replace(re, '$1'); + } catch { + return safe; + } +} + +/** Compute CSS classes for rendering Nomad content by page extension */ +export function pathViewerClasses(pagePath?: string | null): string[] { + if (!pagePath) { + return ["wrap-break-word", "whitespace-pre-wrap", "text-gray-100"]; + } + const pl = (pagePath || "").split("`")[0].toLowerCase(); + const isRich = pl.endsWith(".mu") || pl.endsWith(".md") || pl.endsWith(".html"); + const isHtml = pl.endsWith(".html"); + const isMd = pl.endsWith(".md"); + const classes = ["wrap-break-word"]; + if (isRich) { + classes.push("nomad-page-rich"); + } else { + classes.push("whitespace-pre-wrap"); + } + if (isHtml) { + classes.push("nomad-page-html-host"); + } else { + classes.push("text-gray-100"); + } + if (isMd) { + classes.push("nomad-markdown-host"); + } + return classes; +} + +/** Compute CSS classes for archive card preview */ +export function previewClasses(archive?: Pick | null): string[] { + return pathViewerClasses(archive?.page_path); +} + +/** Render content by file extension and Nomad parsing options */ +export function renderContentByPath( + pagePath: string | undefined, + content: string | undefined, + destinationHash: string | undefined, + renderOptions: NomadRenderOptions, + wasmActive: boolean +): string { + const pathPart = (pagePath || "").split("`")[0]; + const pl = pathPart.toLowerCase(); + const hasKnownExt = /\.(mu|md|txt|html)$/.test(pl); + const micronOpts = { + useWasm: wasmActive, + }; + const dest = destinationHash || null; + if (!hasKnownExt && String(content).includes("`")) { + let out = new MicronParser().convertMicronToHtml(content, {}, micronOpts); + if (dest) { + out = isolateNomadLinksInHtml(out, dest); + } + return out; + } + if (!hasKnownExt) { + let out = new MicronParser().convertMicronToHtml(content, {}, micronOpts); + if (dest) { + out = isolateNomadLinksInHtml(out, dest); + } + return out; + } + return renderNomadPageByPath(pathPart, content, {}, MicronParser, { + ...renderOptions, + nomadDestinationHash: dest || renderOptions.nomadDestinationHash, + }); +} + +/** Render preview snippet HTML safely */ +export function renderPreviewHtml( + pagePath: string | undefined, + content: string | undefined, + destinationHash: string | undefined, + renderOptions: NomadRenderOptions, + wasmActive: boolean +): string { + if (!content) { + return ""; + } + try { + return renderContentByPath(pagePath, content, destinationHash, renderOptions, wasmActive); + } catch { + return escapeHtml(content).replace(/\n/g, "
"); + } +} + +/** Render full archive content with error fallback */ +export function renderFullContent( + archive: ArchiveItem | null | undefined, + renderOptions: NomadRenderOptions, + wasmActive: boolean +): string { + if (!archive?.content) { + return ""; + } + try { + return renderContentByPath( + archive.page_path, + archive.content, + archive.destination_hash, + renderOptions, + wasmActive + ); + } catch (e) { + console.error("Archive render failed", e); + return escapeHtml(archive.content); + } +} + +/** Compute and cache HTML snippet for card preview */ +export function cardPreviewHtml( + archive: ArchiveItem, + searchQuery: string, + cache: Record, + wasmActive: boolean, + renderOptions: NomadRenderOptions +): string { + const cacheKey = `${archive.id}:${archive.hash || ""}:${wasmActive ? "w" : "j"}`; + if (cache[cacheKey]) { + return cache[cacheKey]; + } + const source = archive.preview || archive.snippet || ""; + if (!source) { + return ""; + } + let html = renderPreviewHtml( + archive.page_path, + source, + archive.destination_hash, + renderOptions, + wasmActive + ); + if (searchQuery && archive.snippet && !archive.preview) { + html = highlightMatch(archive.snippet, searchQuery); + } + cache[cacheKey] = html; + return html; +} diff --git a/meshchatx/src/frontend/features/archives/lib/constants.ts b/meshchatx/src/frontend/features/archives/lib/constants.ts new file mode 100644 index 000000000..5616da2ed --- /dev/null +++ b/meshchatx/src/frontend/features/archives/lib/constants.ts @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: 0BSD + +/** Minimum viewport width in pixels for side-by-side split layout */ +export const SPLIT_MIN_WIDTH = 1024; + +/** Default pagination page size for archives list */ +export const DEFAULT_PAGE_LIMIT = 25; + +/** Search input debounce delay in milliseconds */ +export const SEARCH_DEBOUNCE_MS = 300; + +/** API endpoint for nomadnet archives list and item operations */ +export const API_NOMADNET_ARCHIVES = "/api/v1/nomadnet/archives"; + +/** API endpoint for nomadnet archive recrawl */ +export const API_NOMADNET_ARCHIVES_RECRAWL = "/api/v1/nomadnet/archives/recrawl"; + +/** API endpoint for crawl opt-outs */ +export const API_NOMADNET_OPT_OUTS = "/api/v1/nomadnet/crawl/opt-outs"; diff --git a/meshchatx/src/frontend/features/archives/lib/types.ts b/meshchatx/src/frontend/features/archives/lib/types.ts new file mode 100644 index 000000000..a31128457 --- /dev/null +++ b/meshchatx/src/frontend/features/archives/lib/types.ts @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: 0BSD + +export interface ArchiveItem { + id: number | string; + hash?: string; + destination_hash: string; + node_name: string; + page_path: string; + created_at?: string; + content?: string | null; + preview?: string; + snippet?: string; + [key: string]: unknown; +} + +export interface ArchivePagination { + page: number; + limit: number; + total_count: number; + total_pages: number; +} + +export interface NodeOption { + hash: string; + label: string; +} + +export interface NomadRenderOptions { + renderMarkdown: boolean; + renderHtml: boolean; + renderPlaintext: boolean; + nomadDestinationHash: string | null; + nomad_micron_wasm_use: boolean; +} + +export interface ArchivesApiResponse { + archives?: ArchiveItem[]; + pagination?: Partial; +} + +export interface ArchiveItemApiResponse { + archive?: ArchiveItem; +} + +export interface ArchiveRecrawlApiResponse { + archive?: ArchiveItem; +} diff --git a/meshchatx/src/frontend/features/message-blocklist/MessageBlocklistPage.svelte b/meshchatx/src/frontend/features/message-blocklist/MessageBlocklistPage.svelte index 9ce5ecff3..3ec3b6942 100644 --- a/meshchatx/src/frontend/features/message-blocklist/MessageBlocklistPage.svelte +++ b/meshchatx/src/frontend/features/message-blocklist/MessageBlocklistPage.svelte @@ -39,8 +39,9 @@ async function reload(): Promise { try { const res = await window.api.get(API_MESSAGE_BLOCKLIST); - enabled = Boolean(res.data?.enabled); - blocklist = mapBlocklistFromApi(res.data?.blocklist || {}); + const data = res.data as { enabled?: boolean; blocklist?: Record } | undefined; + enabled = Boolean(data?.enabled); + blocklist = mapBlocklistFromApi(data?.blocklist || {}); } catch (e) { console.error(e); ToastUtils.error(t("tools.message_blocklist.load_failed")); @@ -55,8 +56,9 @@ blocklist: normalizeBlocklistForSave(blocklist), }; const res = await window.api.put(API_MESSAGE_BLOCKLIST, payload); - enabled = Boolean(res.data?.enabled); - blocklist = mapBlocklistFromApi(res.data?.blocklist || {}); + const data = res.data as { enabled?: boolean; blocklist?: Record } | undefined; + enabled = Boolean(data?.enabled); + blocklist = mapBlocklistFromApi(data?.blocklist || {}); ToastUtils.success(t("tools.message_blocklist.saved")); } catch (e: any) { const msg = @@ -119,7 +121,8 @@ document, merge, }); - blocklist = mapBlocklistFromApi(res.data?.blocklist || {}); + const data = res.data as { blocklist?: Record } | undefined; + blocklist = mapBlocklistFromApi(data?.blocklist || {}); ToastUtils.success( merge ? t("tools.message_blocklist.imported_merge") diff --git a/meshchatx/src/frontend/features/messages/MessagesPage.svelte b/meshchatx/src/frontend/features/messages/MessagesPage.svelte index 27b88a208..0aeee7951 100644 --- a/meshchatx/src/frontend/features/messages/MessagesPage.svelte +++ b/meshchatx/src/frontend/features/messages/MessagesPage.svelte @@ -1130,10 +1130,10 @@ destinationHashWatchReady = true; return () => { - clearInterval(reloadInterval || undefined); + if (reloadInterval) clearInterval(reloadInterval); clearInterval(livePoll); - clearTimeout(conversationRefreshTimeout || undefined); - clearTimeout(peersRefreshTimeout || undefined); + if (conversationRefreshTimeout) clearTimeout(conversationRefreshTimeout); + if (peersRefreshTimeout) clearTimeout(peersRefreshTimeout); stopIdentityReadyLoads?.(); if (onConversationsVisibility) { document.removeEventListener("visibilitychange", onConversationsVisibility); diff --git a/meshchatx/src/frontend/features/messages/components/ConversationViewer.svelte b/meshchatx/src/frontend/features/messages/components/ConversationViewer.svelte index c35f597da..02d5cecf6 100644 --- a/meshchatx/src/frontend/features/messages/components/ConversationViewer.svelte +++ b/meshchatx/src/frontend/features/messages/components/ConversationViewer.svelte @@ -3,12 +3,10 @@ {#if selectedPeer}
- void copyTextToClipboard(hash)} ondestinationpathclick={(path) => { - const hops = Number(path?.hops); - const hopsText = - hops === 0 || hops === 1 - ? `1 ${t("app.hop")}` - : `${Number.isFinite(hops) ? hops : "?"} ${t("app.hops_plural")}`; - const iface = - String(path?.next_hop_interface || "").trim() || t("messages.path_hops_unknown_iface"); - let message = t("messages.path_hops_via", { hops: hopsText, iface }); - if (peerPathSnapshot?.path_stale) { - message = `${message}\n${t("messages.path_stale_hint")}`; - } - if (peerPathSnapshot?.path_unresponsive) { - message = `${message}\n${t("messages.path_unresponsive_hint")}`; - } - ToastUtils.info(message); + location.hash = `#/interfaces?highlight=${encodeURIComponent(String((path as { next_hop_interface?: string })?.next_hop_interface || path || ""))}`; }} - onsignalmetricsclick={(metrics) => { - const detail = metrics as Record | null; - void DialogUtils.alert(`RSSI: ${detail?.rssi ?? "?"}dBm\nSNR: ${detail?.snr ?? "?"}dB`); + onpathfinderquick={() => void runPathAction("quick")} + onpathfinderforce={() => void runPathAction("force")} + onpathfinderdrop={() => void runPathAction("drop_then_request")} + onping={() => void pingSelectedPeer()} + onstartcall={() => GlobalEmitter.emit("start-call", selectedHash)} + onsharecontact={() => void openShareContactModal()} + onopentelemetryhistory={() => { + isTelemetryHistoryModalOpen = true; }} - onstampinfoclick={(info) => void DialogUtils.alert(`Stamp cost: ${info?.stamp_cost ?? "?"}`)} + onbanish={() => void banishSelectedPeer()} + onunbanish={() => void unbanishSelectedPeer()} onconversationdeleted={() => void deleteMessageHistory()} - onpopout={() => { - if (!isPopout) location.hash = `#/popout/messages/${selectedHash}`; - }} onretryfailed={() => { - const failed = selectedMessages.filter((item) => - ["failed", "cancelled"].includes(String(item.lxmf_message.state || "")) - ); - for (const item of failed) { - contextMenu.chatItem = item; + const failed = selectedMessages.find((m) => m.is_outbound && m.lxmf_message.state === "failed"); + if (failed) { + contextMenu.chatItem = failed; void retryMessage(); } }} - onopentelemetryhistory={() => { - isTelemetryHistoryModalOpen = true; - }} - onstartcall={() => void window.api.post(`/api/v1/telephone/call/${selectedHash}`)} - onsharecontact={() => void openShareContactModal()} - onping={() => void pingSelectedPeer()} - onbanish={() => void banishSelectedPeer()} - onunbanish={() => void unbanishSelectedPeer()} - isPeerBlocked={isSelectedPeerBlocked} onclose={() => onclose?.()} - onpathfinderquick={() => void runPathAction("quick")} - onpathfinderforce={() => void runPathAction("force")} - onpathfinderdrop={() => void runPathAction("drop_then_request")} + onaddstranger={() => void addStrangerAsContact()} + ondismissstranger={() => { + isStrangerPeer = false; + }} /> - {#if isStrangerPeer && !strangerBannerDismissed && config?.show_unknown_contact_banner !== false} - void addStrangerAsContact()} - ondismiss={() => { - strangerBannerDismissed = true; - }} - /> - {/if} - -
-
-
- {#if hasMorePrevious} - - {/if} - {#if useVirtualMessageList} - messagesScroll} - {actions} - /> - {:else} -
- {#each groups as entry (entry.key)} - - {/each} -
- {/if} -
-
- - {#if !autoScrollOnNewMessage && messagesViewportReady} - - {/if} -
- - void sendMessage()} - onaddfiles={() => fileInput?.click()} - onaddimage={addImage} - onstartrecording={(args) => void startAudioRecording(args)} - onstoprecording={() => void stopAudioRecording()} - onremovefile={(file) => { - newMessageFiles = newMessageFiles.filter((candidate) => candidate !== file); - }} - onremoveimage={removeImage} - onremoveaudio={() => { - if (newMessageAudio?.audio_preview_url) URL.revokeObjectURL(newMessageAudio.audio_preview_url); - newMessageAudio = null; - }} - oncancelreply={() => { - replyingTo = null; - }} - onpaste={onPaste} - onenter={composeEnter} - onshiftenter={() => { - newMessageText += "\n"; - }} - onsendpapercompose={generatePaperMessage} + void loadPrevious()} + onscrolltobottom={() => void scrollMessagesToBottom()} + onscroll={onMessagesScroll} /> - { - newMessageFiles = newMessageFiles.concat(Array.from(event.currentTarget.files || [])); - }} + void scrollMessagesToBottom()} />
{:else} - document.getElementById("compose-input")?.focus()} - onsync={() => GlobalEmitter.emit("sync-propagation-node")} - oncopyaddress={() => void copyTextToClipboard(myLxmfAddressHash)} - onidentities={() => { - location.hash = "#/identities"; - }} - onselectpeer={(peer) => onupdateSelectedPeer?.(peer as Peer)} + {onupdateSelectedPeer} oncomposeenter={handleComposeAddress} - oncomposeup={() => { - selectedComposeSuggestionIndex = - selectedComposeSuggestionIndex > 0 ? selectedComposeSuggestionIndex - 1 : composeSuggestions.length - 1; - }} - oncomposedown={() => { - selectedComposeSuggestionIndex = - selectedComposeSuggestionIndex < composeSuggestions.length - 1 ? selectedComposeSuggestionIndex + 1 : 0; - }} - oncomposebblur={() => - setTimeout(() => { - isComposeInputFocused = false; - selectedComposeSuggestionIndex = -1; - }, 200)} - onselectsuggestion={(suggestion) => { - composeAddress = suggestion.hash; - isComposeInputFocused = false; - selectedComposeSuggestionIndex = -1; - handleComposeAddress(); - }} /> {/if} - { - const item = imageLightboxItems?.[imageLightboxIndex] || imageLightboxItems?.[0]; + { + const item = imageLightbox.items?.[imageLightbox.index] || imageLightbox.items?.[0]; if (item) void downloadMessageImage(item); }} -/> - - { + onreplycontextmenu={() => { replyingTo = contextMenu.chatItem; contextMenu.show = false; }} - oncopy={() => { + oncopycontextmenu={() => { void copyTextToClipboard(String(contextMenu.chatItem?.lxmf_message.content || "")); contextMenu.show = false; }} - onreact={(emoji) => void sendReaction(emoji)} - onviewraw={() => { + ontranslatecontextmenu={() => { + bubbleTranslate = { + open: true, + targetLang: bubbleTranslate.targetLang || "en", + chatItem: contextMenu.chatItem, + working: false, + }; + contextMenu.show = false; + }} + onreactcontextmenu={(emoji) => void sendReaction(emoji)} + onopenreactionpickercontextmenu={() => { + if (contextMenu.chatItem) openReactionPicker(contextMenu.chatItem as MessageChatItem); + contextMenu.show = false; + }} + onviewrawcontextmenu={() => { rawMessageData = contextMenu.chatItem?.lxmf_message || {}; isRawMessageModalOpen = true; contextMenu.show = false; }} - oncancelsend={() => void cancelSending()} - onretry={() => void retryMessage()} - ondelete={() => void deleteMessage()} - onclose={() => { + ondownloadimagecontextmenu={() => { + if (contextMenu.chatItem) void downloadMessageImage(contextMenu.chatItem as MessageChatItem); contextMenu.show = false; }} -/> - - { + contextMenu.show = false; + }} + onsavestickercontextmenu={() => { + contextMenu.show = false; + }} + onsavegifcontextmenu={() => { + contextMenu.show = false; + }} + oncancelsendcontextmenu={() => void cancelSending()} + onretrycontextmenu={() => void retryMessage()} + onliftbanishmentcontextmenu={() => void unbanishSelectedPeer()} + ondeletecontextmenu={() => void deleteMessage()} + onclosecontextmenu={() => { + contextMenu.show = false; + }} + {reactionPicker} + {emojiPickerDataUrl} + {emojiPickerThemeClass} + onclosereactionpicker={() => { + reactionPicker = { ...reactionPicker, open: false }; + }} + onemojiclickreactionpicker={(event) => { + const char = + (event.detail as { unicode?: string })?.unicode || + (event.detail as { emoji?: { unicode?: string } })?.emoji?.unicode || + String(event.detail || ""); + if (char && reactionPicker.chatItem) void sendReaction(char, reactionPicker.chatItem); + reactionPicker = { ...reactionPicker, open: false }; + }} + ondragstartreactionpicker={() => {}} + {isRawMessageModalOpen} {rawMessageData} - rawMessageJsonPreview={JSON.stringify(rawMessageData, null, 2)} - isBodyOversized={String(rawMessageData.content || "").length > MESSAGE_BODY_MAX_DISPLAY_CHARS} - bodyCharCount={String(rawMessageData.content || "").length} - hasStoredPath={rawMessageData.path_hops_at_send != null || Boolean(rawMessageData.path_interface_at_send)} - onclose={() => { + oncloserawmessage={() => { isRawMessageModalOpen = false; }} - oncopyhash={(hash) => void copyTextToClipboard(hash)} - oncopycontent={() => void copyTextToClipboard(String(rawMessageData.content || ""))} -/> - -{#if isPaperMessageResultModalOpen} - { - isPaperMessageResultModalOpen = false; - generatedPaperMessageUri = null; - }} - /> -{/if} - - lxmfContactResolvedIcon(contact, conversations)} - destinationHex={lxmfDeliveryDestinationHexFromContact} - onclose={() => { + oncopyhashrawmessage={(hash) => void copyTextToClipboard(hash)} + oncopycontentrawmessage={() => void copyTextToClipboard(String(rawMessageData.content || ""))} + {isPaperMessageResultModalOpen} + {generatedPaperMessageUri} + {selectedHash} + onclosepapermessage={() => { + isPaperMessageResultModalOpen = false; + generatedPaperMessageUri = null; + }} + {isShareContactModalOpen} + bind:contactsSearch + filteredContacts={filteredContacts as never} + resolveContactIcon={(contact) => lxmfContactResolvedIcon(contact as never, conversations)} + destinationHexFromContact={(contact) => lxmfDeliveryDestinationHexFromContact(contact as never)} + onclosesharecontact={() => { isShareContactModalOpen = false; }} - onshare={(contact) => shareContact(contact as Record)} -/> - - shareContact(contact)} + {isTelemetryHistoryModalOpen} + {selectedPeerTelemetryItems} {showTelemetryInChat} - formatTimeAgo={(value) => fromNow(value as never)} - gradientIdSuffix={selectedHash || "peer"} - onopenchange={(open) => { + onopentelemetrychange={(open) => { isTelemetryHistoryModalOpen = open; }} - onclose={() => { + onclosetelemetry={() => { isTelemetryHistoryModalOpen = false; }} onshowtelemetrychange={(show) => { showTelemetryInChat = show; }} - onlocationclick={viewLocationOnMap} + onlocationclicktelemetry={viewLocationOnMap} + {bubbleTranslate} + {translateOptions} + onconfirmbubbletranslate={() => void confirmBubbleTranslate()} + onclosebubbletranslate={() => { + bubbleTranslate = { ...bubbleTranslate, open: false }; + }} /> diff --git a/meshchatx/src/frontend/features/messages/components/ConversationViewerComposerHost.svelte b/meshchatx/src/frontend/features/messages/components/ConversationViewerComposerHost.svelte index 6967a1a43..8ca5173a9 100644 --- a/meshchatx/src/frontend/features/messages/components/ConversationViewerComposerHost.svelte +++ b/meshchatx/src/frontend/features/messages/components/ConversationViewerComposerHost.svelte @@ -122,6 +122,10 @@ text = text ? `${text}\n${extra}` : extra; } + export async function sendNow() { + await sendMessage(); + } + function releaseImageUrls() { for (const url of imageUrls) URL.revokeObjectURL(url); } @@ -193,7 +197,7 @@ }); const type = String(sticker.image_type || "png").toLowerCase(); const mime = type === "webm" ? "video/webm" : `image/${type}`; - const file = new File([response.data], `${sticker.name || "sticker"}.${type}`, { type: mime }); + const file = new File([response.data as BlobPart], `${sticker.name || "sticker"}.${type}`, { type: mime }); addImage(file); isEmojiPickerOpen = false; } catch { @@ -206,7 +210,7 @@ const response = await window.api.get(`/api/v1/gifs/${gif.id}/image`, { responseType: "blob", }); - const file = new File([response.data], `${gif.name || "gif"}.gif`, { type: "image/gif" }); + const file = new File([response.data as BlobPart], `${gif.name || "gif"}.gif`, { type: "image/gif" }); addImage(file); void window.api.post(`/api/v1/gifs/${gif.id}/use`); isEmojiPickerOpen = false; diff --git a/meshchatx/src/frontend/features/messages/components/ConversationViewerEmptyHost.svelte b/meshchatx/src/frontend/features/messages/components/ConversationViewerEmptyHost.svelte new file mode 100644 index 000000000..d8ec30ace --- /dev/null +++ b/meshchatx/src/frontend/features/messages/components/ConversationViewerEmptyHost.svelte @@ -0,0 +1,71 @@ + + + + + document.getElementById("compose-input")?.focus()} + onsync={() => GlobalEmitter.emit("sync-propagation-node")} + oncopyaddress={() => void copyTextToClipboard(myLxmfAddressHash)} + onidentities={() => { + location.hash = "#/identities"; + }} + onselectpeer={(peer) => onupdateSelectedPeer?.(peer as Peer)} + oncomposeenter={() => oncomposeenter?.()} + oncomposeup={() => { + selectedComposeSuggestionIndex = + selectedComposeSuggestionIndex > 0 + ? selectedComposeSuggestionIndex - 1 + : composeSuggestions.length - 1; + }} + oncomposedown={() => { + selectedComposeSuggestionIndex = + selectedComposeSuggestionIndex < composeSuggestions.length - 1 + ? selectedComposeSuggestionIndex + 1 + : 0; + }} + oncomposebblur={() => + setTimeout(() => { + isComposeInputFocused = false; + selectedComposeSuggestionIndex = -1; + }, 200)} + onselectsuggestion={(suggestion) => { + composeAddress = suggestion.hash; + isComposeInputFocused = false; + selectedComposeSuggestionIndex = -1; + oncomposeenter?.(); + }} +/> diff --git a/meshchatx/src/frontend/features/messages/components/ConversationViewerHeaderHost.svelte b/meshchatx/src/frontend/features/messages/components/ConversationViewerHeaderHost.svelte new file mode 100644 index 000000000..f9e604608 --- /dev/null +++ b/meshchatx/src/frontend/features/messages/components/ConversationViewerHeaderHost.svelte @@ -0,0 +1,113 @@ + + + + + + +{#if showStrangerBanner} + onaddstranger?.()} + ondismiss={() => ondismissstranger?.()} + /> +{/if} diff --git a/meshchatx/src/frontend/features/messages/components/ConversationViewerModalsHost.svelte b/meshchatx/src/frontend/features/messages/components/ConversationViewerModalsHost.svelte index c738e6705..8c62936d6 100644 --- a/meshchatx/src/frontend/features/messages/components/ConversationViewerModalsHost.svelte +++ b/meshchatx/src/frontend/features/messages/components/ConversationViewerModalsHost.svelte @@ -180,7 +180,7 @@ canReact={!contextMenu.chatItem?.lxmf_message.is_reaction} hasImage={Boolean(contextMenu.chatItem?.lxmf_message.fields?.image)} canSaveAsGif={Boolean( - contextMenu.chatItem?.lxmf_message.fields?.image?.image_type?.toLowerCase() === "gif" + String((contextMenu.chatItem?.lxmf_message.fields?.image as Record)?.image_type || "").toLowerCase() === "gif" )} canCancelSend={Boolean( contextMenu.chatItem?.is_outbound && diff --git a/meshchatx/src/frontend/features/messages/lib/conversationStickersGifs.ts b/meshchatx/src/frontend/features/messages/lib/conversationStickersGifs.ts index 4843a3cb2..129d3d5a3 100644 --- a/meshchatx/src/frontend/features/messages/lib/conversationStickersGifs.ts +++ b/meshchatx/src/frontend/features/messages/lib/conversationStickersGifs.ts @@ -35,30 +35,33 @@ export function isAllowedGifMime(mime: string): boolean { return /^image\/gif$/i.test(mime || ""); } -export async function loadStickers(api: { get: (url: string) => Promise<{ data?: Record }> }): Promise { +export async function loadStickers(api: { get: (url: string) => Promise<{ data?: unknown }> }): Promise { try { const response = await api.get("/api/v1/stickers"); - const list = response.data?.stickers; + const data = response.data as { stickers?: unknown } | undefined; + const list = data?.stickers; return Array.isArray(list) ? (list as StickerItem[]) : []; } catch { return []; } } -export async function loadStickerPacks(api: { get: (url: string) => Promise<{ data?: Record }> }): Promise { +export async function loadStickerPacks(api: { get: (url: string) => Promise<{ data?: unknown }> }): Promise { try { const response = await api.get("/api/v1/stickers/packs"); - const list = response.data?.packs; + const data = response.data as { packs?: unknown } | undefined; + const list = data?.packs; return Array.isArray(list) ? (list as StickerPackItem[]) : []; } catch { return []; } } -export async function loadGifs(api: { get: (url: string) => Promise<{ data?: Record }> }): Promise { +export async function loadGifs(api: { get: (url: string) => Promise<{ data?: unknown }> }): Promise { try { const response = await api.get("/api/v1/gifs"); - const list = response.data?.gifs; + const data = response.data as { gifs?: unknown } | undefined; + const list = data?.gifs; return Array.isArray(list) ? (list as GifItem[]) : []; } catch { return []; @@ -66,7 +69,7 @@ export async function loadGifs(api: { get: (url: string) => Promise<{ data?: Rec } export async function uploadStickerFile( - api: { post: (url: string, body?: unknown) => Promise<{ data?: Record }> }, + api: { post: (url: string, body?: unknown) => Promise<{ data?: unknown }> }, file: File, packId?: string | number | null ): Promise { @@ -83,11 +86,12 @@ export async function uploadStickerFile( payload.pack_id = packId; } const response = await api.post("/api/v1/stickers", payload); - return (response.data?.sticker as StickerItem) || null; + const data = response.data as { sticker?: StickerItem } | undefined; + return data?.sticker || null; } export async function uploadGifFile( - api: { post: (url: string, body?: unknown) => Promise<{ data?: Record }> }, + api: { post: (url: string, body?: unknown) => Promise<{ data?: unknown }> }, file: File ): Promise { const buffer = await file.arrayBuffer(); @@ -100,5 +104,6 @@ export async function uploadGifFile( image_type: extension, }; const response = await api.post("/api/v1/gifs", payload); - return (response.data?.gif as GifItem) || null; + const data = response.data as { gif?: GifItem } | undefined; + return data?.gif || null; } diff --git a/meshchatx/src/frontend/features/messages/lib/conversationTranslate.ts b/meshchatx/src/frontend/features/messages/lib/conversationTranslate.ts index 013a8a2b5..e0668fee5 100644 --- a/meshchatx/src/frontend/features/messages/lib/conversationTranslate.ts +++ b/meshchatx/src/frontend/features/messages/lib/conversationTranslate.ts @@ -14,7 +14,7 @@ export type BubbleTranslation = { }; export async function loadTranslatorLanguages( - api: { get: (url: string, opts?: { params?: Record }) => Promise<{ data?: Record }> }, + api: { get: (url: string, opts?: { params?: Record }) => Promise<{ data?: unknown }> }, libreUrl?: string ): Promise<{ languages: LangOption[]; hasTranslator: boolean }> { try { @@ -23,7 +23,8 @@ export async function loadTranslatorLanguages( params.libretranslate_url = libreUrl; } const response = await api.get("/api/v1/translator/languages", { params }); - const list = Array.isArray(response.data?.languages) ? response.data.languages : []; + const data = response.data as { languages?: unknown[]; has_argos?: boolean; libretranslate_reachable?: boolean } | undefined; + const list = Array.isArray(data?.languages) ? data.languages : []; const options: LangOption[] = list .map((item: unknown) => { if (typeof item === "string") { @@ -35,7 +36,7 @@ export async function loadTranslatorLanguages( return { value: code, label: name }; }) .filter((opt: LangOption) => Boolean(opt.value)); - const hasTranslator = options.length > 0 || Boolean(response.data?.has_argos || response.data?.libretranslate_reachable); + const hasTranslator = options.length > 0 || Boolean(data?.has_argos || data?.libretranslate_reachable); return { languages: options, hasTranslator }; } catch { return { languages: [], hasTranslator: false }; @@ -43,7 +44,7 @@ export async function loadTranslatorLanguages( } export async function translateText( - api: { post: (url: string, body?: unknown) => Promise<{ data?: Record }> }, + api: { post: (url: string, body?: unknown) => Promise<{ data?: unknown }> }, params: { text: string; targetLang: string; @@ -66,7 +67,7 @@ export async function translateText( payload.libretranslate_api_key = params.libreApiKey; } const response = await api.post("/api/v1/translator/translate", payload); - const data = response.data || {}; + const data = (response.data as { translated_text?: string; source_lang?: string } | undefined) || {}; return { translatedText: String(data.translated_text || ""), sourceLang: String(data.source_lang || params.sourceLang || "auto"), diff --git a/meshchatx/src/frontend/features/messages/lib/conversationViewerLightbox.ts b/meshchatx/src/frontend/features/messages/lib/conversationViewerLightbox.ts new file mode 100644 index 000000000..cac7df7bc --- /dev/null +++ b/meshchatx/src/frontend/features/messages/lib/conversationViewerLightbox.ts @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: 0BSD + +import type { MessageChatItem } from "./viewerActions.js"; + +export type ImageLightboxState = { + url: string; + gallery: string[] | null; + items: MessageChatItem[] | null; + index: number; +}; + +export function initialImageLightboxState(): ImageLightboxState { + return { + url: "", + gallery: null, + items: null, + index: 0, + }; +} + +export function openImageLightbox( + src: string, + gallery: string[] = [], + items: MessageChatItem[] = [] +): ImageLightboxState { + const nextGallery = gallery.length > 1 ? gallery.slice() : null; + const nextItems = items.length ? items.slice() : null; + const nextIndex = nextGallery ? Math.max(0, nextGallery.indexOf(src)) : 0; + return { + url: nextGallery?.[nextIndex] || src, + gallery: nextGallery, + items: nextItems, + index: nextIndex, + }; +} + +export function navigateImageLightbox(state: ImageLightboxState, delta: number): ImageLightboxState { + if (!state.gallery?.length) { + return state; + } + const nextIndex = (state.index + delta + state.gallery.length) % state.gallery.length; + return { + ...state, + index: nextIndex, + url: state.gallery[nextIndex], + }; +} diff --git a/meshchatx/src/frontend/features/messages/lib/conversationViewerMutations.ts b/meshchatx/src/frontend/features/messages/lib/conversationViewerMutations.ts new file mode 100644 index 000000000..2266e6d7a --- /dev/null +++ b/meshchatx/src/frontend/features/messages/lib/conversationViewerMutations.ts @@ -0,0 +1,295 @@ +// SPDX-License-Identifier: 0BSD + +import DialogUtils from "../../../js/DialogUtils.js"; +import DownloadUtils from "../../../js/DownloadUtils.js"; +import ToastUtils from "../../../js/ToastUtils.js"; +import WebSocketConnection from "../../../js/WebSocketConnection.js"; +import { t } from "../../../js/i18n.js"; +import { applyWsMessage, deleteWsMessage, updateWsMessage } from "./conversationViewerMessages.js"; +import { cancelOutbound } from "./conversationViewerSend.js"; +import type { ApiClient } from "../../../js/apiClient.js"; +import type { LxmfMessage, ViewerChatItem } from "./conversationViewerCtx.js"; +import { sameHash } from "./conversationViewerCtx.js"; +import type { MessageChatItem } from "./viewerActions.js"; + +type OutboundQueueLike = { + cancelJob: (predicate: { pendingHash?: string }) => void; +}; + +export async function sendReactionToMessage(opts: { + api: ApiClient; + destinationHash: string; + myLxmfAddressHash: string; + emoji: string; + targetItem: ViewerChatItem; + currentItems: ViewerChatItem[]; +}): Promise { + const { api, destinationHash, myLxmfAddressHash, emoji, targetItem, currentItems } = opts; + const targetHash = targetItem.lxmf_message.hash; + if (!targetHash || !destinationHash) { + return currentItems; + } + try { + const response = await api.post("/api/v1/lxmf-messages/reactions", { + destination_hash: destinationHash, + target_message_hash: targetHash, + emoji, + }); + const resData = response.data as { lxmf_message?: LxmfMessage } | undefined; + const reaction = resData?.lxmf_message; + if (reaction) { + return applyWsMessage(currentItems, reaction, destinationHash, myLxmfAddressHash).items; + } + return currentItems; + } catch { + ToastUtils.error(t("messages.reaction_send_failed")); + return currentItems; + } +} + +export async function deleteMessageItem(opts: { + api: ApiClient; + item: ViewerChatItem; + currentItems: ViewerChatItem[]; + skipConfirm?: boolean; +}): Promise { + const { api, item, currentItems, skipConfirm = false } = opts; + const hash = item.lxmf_message.hash; + if (!hash) { + return null; + } + if (!skipConfirm) { + const confirmed = await DialogUtils.confirm(t("messages.delete_message_confirm")); + if (!confirmed) { + return null; + } + } + if (!hash.startsWith("pending-")) { + try { + await api.delete(`/api/v1/lxmf-messages/${hash}`); + } catch { + return null; + } + } + return deleteWsMessage(currentItems, hash); +} + +export async function cancelOutboundMessageItem(opts: { + api: ApiClient; + item: ViewerChatItem; + currentItems: ViewerChatItem[]; + outboundQueue: OutboundQueueLike; +}): Promise { + const { api, item, currentItems, outboundQueue } = opts; + const hash = String(item.lxmf_message.hash || ""); + if (!hash) { + return currentItems; + } + if (hash.startsWith("pending-")) { + outboundQueue.cancelJob({ pendingHash: hash }); + return deleteWsMessage(currentItems, hash); + } + const updated = await cancelOutbound(api, hash); + if (updated) { + return updateWsMessage(currentItems, updated); + } + return currentItems; +} + +export async function downloadMessageImageAttachment(api: ApiClient, item: MessageChatItem): Promise { + const hash = item.lxmf_message.hash; + if (!hash) { + return; + } + const response = await api.get(`/api/v1/lxmf-messages/attachment/${hash}/image`, { + responseType: "arraybuffer", + }); + const type = String(item.lxmf_message.fields?.image?.image_type || "png").replace(/^image\//, ""); + await DownloadUtils.downloadFromApiResponse(response, `image-${hash.slice(0, 8)}.${type}`); +} + +export async function downloadMessageFileAttachment( + api: ApiClient, + item: ViewerChatItem, + index: number, + name: string +): Promise { + const hash = item.lxmf_message.hash; + if (!hash) { + return; + } + const response = await api.get(`/api/v1/lxmf-messages/attachment/${hash}/file`, { + params: { file_index: index }, + responseType: "arraybuffer", + }); + await DownloadUtils.downloadFromApiResponse(response, name || "download"); +} + +export async function updatePeerCustomDisplayName( + api: ApiClient, + destinationHash: string, + onSuccess: (name: string) => void +): Promise { + if (!destinationHash) { + return; + } + const displayName = await DialogUtils.prompt(t("messages.enter_display_name")); + if (displayName == null) { + return; + } + try { + await api.post(`/api/v1/destination/${destinationHash}/custom-display-name/update`, { + display_name: displayName, + }); + onSuccess(displayName); + } catch { + await DialogUtils.alert(t("messages.failed_update_display_name")); + } +} + +export async function addStrangerContact(opts: { + api: ApiClient; + destinationHash: string; + displayName?: string | null; + identityHash?: string | null; + onSuccess: () => void; +}): Promise { + const { api, destinationHash, displayName, identityHash, onSuccess } = opts; + if (!destinationHash) { + return false; + } + try { + await api.post("/api/v1/telephone/contacts", { + name: displayName || destinationHash, + remote_identity_hash: String(identityHash || destinationHash), + lxmf_address: destinationHash, + }); + onSuccess(); + ToastUtils.success(t("contacts.contact_added")); + return true; + } catch { + ToastUtils.error(t("messages.failed_add_contact")); + return false; + } +} + +export async function addSharedContactEntry(opts: { + api: ApiClient; + name?: string; + hash?: string; + lxmfAddress?: string; + lxstAddress?: string; + onSuccess: () => void; +}): Promise { + const { api, name, hash, lxmfAddress, lxstAddress, onSuccess } = opts; + const remoteHash = String(hash || ""); + if (!remoteHash) { + return false; + } + try { + await api.post("/api/v1/telephone/contacts", { + name: name || remoteHash, + remote_identity_hash: remoteHash, + lxmf_address: lxmfAddress || remoteHash, + lxst_address: lxstAddress || undefined, + }); + onSuccess(); + ToastUtils.success(t("contacts.contact_added")); + return true; + } catch { + ToastUtils.error(t("messages.failed_add_contact")); + return false; + } +} + +export function generatePaperMessagePayload(destinationHash: string, text: string): void { + if (!destinationHash || !text.trim()) { + return; + } + WebSocketConnection.send( + JSON.stringify({ + type: "lxm.generate_paper_uri", + destination_hash: destinationHash, + content: text, + }) + ); +} + +export function formatSharedContactString(contact: Record): string { + let sharedString = `Contact: ${contact.name} <${contact.remote_identity_hash}>`; + if (contact.lxmf_address) { + sharedString += ` [LXMF: ${contact.lxmf_address}]`; + } + if (contact.lxst_address) { + sharedString += ` [LXST: ${contact.lxst_address}]`; + } + return sharedString; +} + +export function buildMapLocationHash(coords: { latitude: number; longitude: number }): string { + const params = new URLSearchParams({ + lat: String(coords.latitude), + lon: String(coords.longitude), + zoom: "15", + }); + return `#/map?${params.toString()}`; +} + + +export async function executeOutboundSendJob(opts: { + api: ApiClient; + job: import("./conversationViewerSend.js").OutboundJob; + peerPathSnapshot: unknown; + chatItems: ViewerChatItem[]; + myLxmfAddressHash: string; + refreshPeerNetwork: (warm: boolean) => Promise; + DialogUtils: { alert: (message: string) => Promise | void }; + t: (key: string) => string; + applyWsMessage: typeof applyWsMessage; + getPropagationHash: () => unknown; +}): Promise { + const { + api, + job, + peerPathSnapshot, + chatItems, + DialogUtils, + t, + applyWsMessage: applyMsg, + refreshPeerNetwork, + getPropagationHash, + } = opts; + const { executeOutboundJob } = await import("./conversationViewerSend.js"); + try { + const sent = await executeOutboundJob({ + api, + job, + pathSnapshot: peerPathSnapshot as never, + propagationHash: getPropagationHash(), + }); + let next = chatItems.filter((item) => item.lxmf_message.hash !== job.pendingHash); + for (const message of sent) { + next = applyMsg(next, message, job.destinationHash, job.myLxmfAddressHash).items; + } + void refreshPeerNetwork(false); + return next; + } catch (error) { + const failed = chatItems.map((item) => + item.lxmf_message.hash === job.pendingHash + ? { + ...item, + lxmf_message: { + ...item.lxmf_message, + state: "failed", + _pendingPathfinding: false, + }, + } + : item + ); + await DialogUtils.alert( + (error as { response?: { data?: { message?: string } } }).response?.data?.message || + t("messages.failed_to_send") + ); + return failed; + } +} diff --git a/meshchatx/src/frontend/features/messages/lib/conversationViewerPeerOps.ts b/meshchatx/src/frontend/features/messages/lib/conversationViewerPeerOps.ts new file mode 100644 index 000000000..3ae23e295 --- /dev/null +++ b/meshchatx/src/frontend/features/messages/lib/conversationViewerPeerOps.ts @@ -0,0 +1,121 @@ +// SPDX-License-Identifier: 0BSD + +import type { ApiClient } from "../../../js/apiClient.js"; + +type Api = ApiClient | { + post: (url: string, body?: any, opts?: any) => Promise<{ data?: any }>; + delete: (url: string, opts?: any) => Promise; +}; + +type DialogUtilsLike = { + alert: (message: string) => Promise | void; + confirm: (message: string) => Promise | boolean; +}; + +type ToastUtilsLike = { + loading: (message: string, duration?: number, key?: string) => void; + dismiss: (key: string) => void; +}; + +type TFn = (key: string, values?: Record) => string; + +export async function pingPeerDestination(opts: { + api: Api; + destinationHash: string; + t: TFn; + DialogUtils: DialogUtilsLike; + ToastUtils: ToastUtilsLike; +}): Promise { + const { api, destinationHash, t, DialogUtils, ToastUtils } = opts; + if (destinationHash.length !== 32 || !/^[0-9a-fA-F]+$/.test(destinationHash)) { + await DialogUtils.alert(t("messages.invalid_destination_hash_format")); + return; + } + const pingToastKey = "conversation-ping"; + ToastUtils.loading(t("messages.ping_in_progress"), 0, pingToastKey); + try { + const response = await api.post( + `/api/v1/ping/${destinationHash}/lxmf.delivery`, + {}, + { params: { timeout: 30 } } + ); + const pingData = response.data as { ping_result?: Record } | undefined; + const pingResult = pingData?.ping_result || {}; + const rttMilliseconds = ((Number(pingResult.rtt) || 0) * 1000).toFixed(3); + const info = [ + t("messages.ping_reply_from", { hash: destinationHash }), + t("messages.duration", { duration: `${rttMilliseconds} ms` }), + t("messages.hops_there", { count: pingResult.hops_there }), + t("messages.hops_back", { count: pingResult.hops_back }), + ]; + if (pingResult.quality != null) { + info.push(t("messages.signal_quality", { quality: pingResult.quality })); + } + if (pingResult.rssi != null) { + info.push(t("messages.rssi_val", { rssi: pingResult.rssi })); + } + if (pingResult.snr != null) { + info.push(t("messages.snr_val", { snr: pingResult.snr })); + } + await DialogUtils.alert(info.join("\n")); + } catch (error) { + const message = + (error as { response?: { data?: { message?: string } } })?.response?.data?.message || + t("messages.ping_failed"); + await DialogUtils.alert(message); + } finally { + ToastUtils.dismiss(pingToastKey); + } +} + +export async function banishPeerDestination(opts: { + api: Api; + destinationHash: string; + t: TFn; + DialogUtils: DialogUtilsLike; + emitBlockChanged: () => void; +}): Promise { + const { api, destinationHash, t, DialogUtils, emitBlockChanged } = opts; + if (!(await DialogUtils.confirm(t("messages.banish_confirm")))) return; + try { + await api.post("/api/v1/blocked-destinations", { destination_hash: destinationHash }); + emitBlockChanged(); + await DialogUtils.alert(t("messages.user_banished")); + } catch { + await DialogUtils.alert(t("messages.failed_banish_user")); + } +} + +export async function unbanishPeerDestination(opts: { + api: Api; + destinationHash: string; + t: TFn; + DialogUtils: DialogUtilsLike; + emitBlockChanged: () => void; +}): Promise { + const { api, destinationHash, t, DialogUtils, emitBlockChanged } = opts; + try { + await api.delete(`/api/v1/blocked-destinations/${destinationHash}`); + emitBlockChanged(); + await DialogUtils.alert(t("banishment.banishment_lifted")); + } catch { + await DialogUtils.alert(t("banishment.failed_lift_banishment")); + } +} + +export async function deletePeerConversationHistory(opts: { + api: Api; + destinationHash: string; + t: TFn; + DialogUtils: DialogUtilsLike; + onDone: () => void; +}): Promise { + const { api, destinationHash, t, DialogUtils, onDone } = opts; + if (!(await DialogUtils.confirm(t("messages.delete_history_confirm")))) return; + try { + await api.delete(`/api/v1/lxmf-messages/conversation/${destinationHash}`); + onDone(); + } catch { + await DialogUtils.alert(t("messages.failed_delete_history")); + } +} diff --git a/meshchatx/src/frontend/features/messages/lib/conversationViewerSession.ts b/meshchatx/src/frontend/features/messages/lib/conversationViewerSession.ts new file mode 100644 index 000000000..4ae9021a6 --- /dev/null +++ b/meshchatx/src/frontend/features/messages/lib/conversationViewerSession.ts @@ -0,0 +1,161 @@ +// SPDX-License-Identifier: 0BSD + +import GlobalEmitter from "../../../js/GlobalEmitter.js"; +import GlobalState from "../../../js/GlobalState.js"; +import NotificationUtils from "../../../js/NotificationUtils.js"; +import { fromNow } from "../../../libs/datetime.js"; +import { isTelemetryOnly } from "./conversationMessageHelpers.js"; +import type { LxmfMessage, ViewerChatItem } from "./conversationViewerCtx.js"; +import { sameHash } from "./conversationViewerCtx.js"; +import type { Conversation, Peer } from "./types.js"; + +type ApiClient = { + get: (url: string, opts?: unknown) => Promise<{ data?: unknown }>; + post: (url: string, body?: unknown, opts?: unknown) => Promise<{ data?: unknown }>; +}; + +export async function markConversationAsRead( + api: ApiClient, + conversation: (Conversation | Peer | { destination_hash?: string; is_unread?: boolean }) | null | undefined, + opts: { force?: boolean } = {} +): Promise { + if (!conversation?.destination_hash) { + return false; + } + const wasUnread = Boolean(conversation.is_unread); + if (!wasUnread && !opts.force) { + return false; + } + conversation.is_unread = false; + try { + await api.post(`/api/v1/lxmf/conversations/${conversation.destination_hash}/mark-as-read`); + GlobalEmitter.emit("notifications-changed"); + NotificationUtils.clearMessageNotifications(conversation.destination_hash); + if (wasUnread && GlobalState.unreadConversationsCount > 0) { + GlobalState.unreadConversationsCount -= 1; + } + return true; + } catch { + conversation.is_unread = wasUnread; + return false; + } +} + +export async function fetchTelephoneContacts(api: ApiClient): Promise>> { + try { + const response = await api.get("/api/v1/telephone/contacts"); + const resData = response.data as { contacts?: unknown[] } | unknown[] | undefined; + if (resData && !Array.isArray(resData) && Array.isArray(resData.contacts)) { + return resData.contacts as Array>; + } + if (Array.isArray(resData)) { + return resData as Array>; + } + return []; + } catch { + return []; + } +} + +export function filterContactsList( + contacts: Array>, + searchQuery: string +): Array> { + const q = searchQuery.trim().toLowerCase(); + if (!q) { + return contacts; + } + return contacts.filter((contact) => { + const name = String(contact.name || "").toLowerCase(); + const hash = String(contact.remote_identity_hash || "").toLowerCase(); + const lxmf = String(contact.lxmf_address || "").toLowerCase(); + return name.includes(q) || hash.includes(q) || lxmf.includes(q); + }); +} + +export function filterSelectedPeerTelemetry( + chatItems: ViewerChatItem[], + selectedHash: string +): ViewerChatItem[] { + if (!selectedHash) { + return []; + } + const peer = selectedHash.toLowerCase(); + return chatItems + .filter((chatItem) => { + if (chatItem.type !== "lxmf_message") { + return false; + } + const src = String(chatItem.lxmf_message.source_hash || "").toLowerCase(); + const dst = String(chatItem.lxmf_message.destination_hash || "").toLowerCase(); + if (src !== peer && dst !== peer) { + return false; + } + return isTelemetryOnly(chatItem.lxmf_message); + }) + .slice() + .reverse(); +} + +export type ComposeSuggestion = { + hash: string; + name: string; + icon: string; + type: string; +}; + +export function buildComposeAddressSuggestions( + contacts: Array>, + conversations: Conversation[], + composeAddress: string, + isInputFocused: boolean +): ComposeSuggestion[] { + if (!isInputFocused) { + return []; + } + const search = composeAddress.trim().toLowerCase(); + const seen: string[] = []; + const suggestions: ComposeSuggestion[] = []; + for (const contact of contacts) { + const hash = String(contact.remote_identity_hash || ""); + const name = String(contact.name || hash); + if ( + hash && + !seen.includes(hash) && + (!search || name.toLowerCase().includes(search) || hash.includes(search)) + ) { + suggestions.push({ hash, name, icon: "account", type: "contact" }); + seen.push(hash); + } + } + for (const conversation of conversations) { + const hash = String(conversation.destination_hash || ""); + const name = String(conversation.custom_display_name || conversation.display_name || hash); + if ( + hash && + !seen.includes(hash) && + (!search || name.toLowerCase().includes(search) || hash.includes(search)) + ) { + suggestions.push({ hash, name, icon: "history", type: "recent" }); + seen.push(hash); + } + } + return suggestions.slice(0, 10); +} + +export function formatTimeAgo(value: unknown): string { + return value ? fromNow(value as string) : ""; +} + +export function isPeerBlockedInState(blockedList: unknown, targetHash: string): boolean { + if (!Array.isArray(blockedList) || !targetHash) { + return false; + } + return blockedList.some((entry: unknown) => { + const hash = + typeof entry === "string" + ? entry + : String((entry as Record)?.destination_hash || ""); + return sameHash(hash, targetHash); + }); +} diff --git a/meshchatx/src/frontend/features/page-nodes/PageNodesPage.svelte b/meshchatx/src/frontend/features/page-nodes/PageNodesPage.svelte new file mode 100644 index 000000000..49a850948 --- /dev/null +++ b/meshchatx/src/frontend/features/page-nodes/PageNodesPage.svelte @@ -0,0 +1,378 @@ + + + + +
+ + + + +
+
+ {#if loading} +
+
{t("tools.mesh_server.loading")}
+
+ {:else if nodes.length === 0} +
+ +
{t("tools.mesh_server.empty_title")}
+
+ {t("tools.mesh_server.empty_description")} +
+
+ {:else} +
+ {#each nodes as node (node.node_id)} + + {/each} +
+ {/if} + + {#if selectedNode} + (showRenameDialog = true)} + onClose={() => (selectedNode = null)} + onViewNode={viewNode} + onSaveAnnounceSettings={handleSaveAnnounceSettings} + onTabChange={(tab) => (detailTab = tab)} + onAddPage={handleAddPage} + onEditPage={handleEditPage} + onDeletePage={handleDeletePage} + onSavePage={handleSavePage} + onCancelEditPage={() => (editingPage = null)} + onUploadFile={handleUploadFile} + onDeleteFile={handleDeleteFile} + /> + {/if} +
+
+ + (showCreateDialog = false)} + onCreate={handleCreateNode} + /> + + (showRenameDialog = false)} + onRename={handleRenameNode} + /> +
diff --git a/meshchatx/src/frontend/features/page-nodes/components/PageNodeAnnounceSettings.svelte b/meshchatx/src/frontend/features/page-nodes/components/PageNodeAnnounceSettings.svelte new file mode 100644 index 000000000..38624ab25 --- /dev/null +++ b/meshchatx/src/frontend/features/page-nodes/components/PageNodeAnnounceSettings.svelte @@ -0,0 +1,73 @@ + + + + +
+
+ {t("tools.mesh_server.announce_settings")} +
+
+ +
+
+ {t("tools.mesh_server.executable_pages_warning")} +
+
+ +
+ {#if announceEnabled} +
+
+ + +
+
+ {t("tools.mesh_server.announce_interval_manual_hint")} +
+
+ {/if} +
+ + {lastAnnouncedText} + + +
+
diff --git a/meshchatx/src/frontend/features/page-nodes/components/PageNodeCreateModal.svelte b/meshchatx/src/frontend/features/page-nodes/components/PageNodeCreateModal.svelte new file mode 100644 index 000000000..fdb7fd209 --- /dev/null +++ b/meshchatx/src/frontend/features/page-nodes/components/PageNodeCreateModal.svelte @@ -0,0 +1,70 @@ + + + + +{#if open} + +{/if} diff --git a/meshchatx/src/frontend/features/page-nodes/components/PageNodeDetail.svelte b/meshchatx/src/frontend/features/page-nodes/components/PageNodeDetail.svelte new file mode 100644 index 000000000..f92878c9a --- /dev/null +++ b/meshchatx/src/frontend/features/page-nodes/components/PageNodeDetail.svelte @@ -0,0 +1,145 @@ + + + + +
+
+
+ {selectedNode.name} +
+
+ + +
+
+ + {#if selectedNode.destination_hash} +
+
+
+ {t("tools.mesh_server.destination_hash")} +
+ {#if selectedNode.running} + + {/if} +
+
{selectedNode.destination_hash}
+
+ {/if} + + + +
+ + +
+ + {#if detailTab === PAGE_NODE_TABS.PAGES} + + {:else if detailTab === PAGE_NODE_TABS.FILES} + + {/if} +
diff --git a/meshchatx/src/frontend/features/page-nodes/components/PageNodeFilesTab.svelte b/meshchatx/src/frontend/features/page-nodes/components/PageNodeFilesTab.svelte new file mode 100644 index 000000000..e2d1260f6 --- /dev/null +++ b/meshchatx/src/frontend/features/page-nodes/components/PageNodeFilesTab.svelte @@ -0,0 +1,65 @@ + + + + +
+
+ + +
+ + {#if files.length === 0} +
+ {t("tools.mesh_server.no_files")} +
+ {/if} + + {#each files as file (file.name)} +
+
+ + {file.name} + {formatFileSize(file.size)} +
+ +
+ {/each} +
diff --git a/meshchatx/src/frontend/features/page-nodes/components/PageNodeItem.svelte b/meshchatx/src/frontend/features/page-nodes/components/PageNodeItem.svelte new file mode 100644 index 000000000..4bfd4c751 --- /dev/null +++ b/meshchatx/src/frontend/features/page-nodes/components/PageNodeItem.svelte @@ -0,0 +1,164 @@ + + + + +
onSelect(node)} + onkeydown={handleRowKeydown} + role="button" + tabindex="0" +> +
+
+
+
+
+
+ {node.name} +
+ {#if !node.announce_enabled} + + {t("tools.mesh_server.announce_off_badge")} + + {/if} +
+ {#if node.destination_hash} +
+ {node.destination_hash} +
+ {/if} +
+
+
+ + {t("tools.mesh_server.stats_pages_files", { + pages: node.pages.length, + files: node.files.length, + })} + + {#if !node.running} + + {:else} + + {/if} + {#if node.running} + + {/if} + {#if node.running && node.destination_hash} + + {/if} + +
+
+ + {#if node.stats || node.running} +
+ {#if node.running} + + {t("tools.mesh_server.uptime", { + time: formatMeshUptime(node.uptime_seconds), + })} + + {/if} + + {t("tools.mesh_server.connections", { + count: node.unique_connections ?? 0, + })} + + {#if node.stats} + + {t("tools.mesh_server.pages_served", { + count: node.stats.pages_served, + })} + + + {t("tools.mesh_server.files_served", { + count: node.stats.files_served, + })} + + + {t("tools.mesh_server.links", { + count: node.stats.links_established, + })} + + {/if} + {formatLastAnnounced(node.last_announced_at)} +
+ {/if} +
diff --git a/meshchatx/src/frontend/features/page-nodes/components/PageNodePagesTab.svelte b/meshchatx/src/frontend/features/page-nodes/components/PageNodePagesTab.svelte new file mode 100644 index 000000000..9ee19fda0 --- /dev/null +++ b/meshchatx/src/frontend/features/page-nodes/components/PageNodePagesTab.svelte @@ -0,0 +1,132 @@ + + + + +
+
+ + +
+ + {#if pages.length === 0} +
+ {t("tools.mesh_server.no_pages")} +
+ {/if} + + {#each pages as page (page.name)} +
+
+ + {page.name} + {#if page.executable} + + {t("tools.mesh_server.executable_badge")} + + {/if} +
+
+ + +
+
+ {/each} + + {#if editingPage} +
+
+
+ {t("tools.mesh_server.editing_page", { + name: editingPage, + })} +
+
+ + +
+
+
+ +
+ +
+ {/if} +
diff --git a/meshchatx/src/frontend/features/page-nodes/components/PageNodeRenameModal.svelte b/meshchatx/src/frontend/features/page-nodes/components/PageNodeRenameModal.svelte new file mode 100644 index 000000000..ca470b703 --- /dev/null +++ b/meshchatx/src/frontend/features/page-nodes/components/PageNodeRenameModal.svelte @@ -0,0 +1,69 @@ + + + + +{#if open} + +{/if} diff --git a/meshchatx/src/frontend/features/page-nodes/components/PageNodeToggle.svelte b/meshchatx/src/frontend/features/page-nodes/components/PageNodeToggle.svelte new file mode 100644 index 000000000..a0d404c2e --- /dev/null +++ b/meshchatx/src/frontend/features/page-nodes/components/PageNodeToggle.svelte @@ -0,0 +1,35 @@ + + + + + diff --git a/meshchatx/src/frontend/features/page-nodes/index.ts b/meshchatx/src/frontend/features/page-nodes/index.ts new file mode 100644 index 000000000..748e64438 --- /dev/null +++ b/meshchatx/src/frontend/features/page-nodes/index.ts @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: 0BSD + +import { registerFeature } from "../../js/registries/featureRegistry.js"; + +/** + * Reticulum mesh server / page nodes feature registration + */ +export function registerPageNodesFeature(): void { + registerFeature({ + id: "page-nodes", + routes: [ + { + name: "mesh-server", + path: "/mesh-server", + mount: "svelte", + load: () => import("./PageNodesPage.svelte"), + }, + ], + }); +} diff --git a/meshchatx/src/frontend/features/page-nodes/lib/constants.ts b/meshchatx/src/frontend/features/page-nodes/lib/constants.ts new file mode 100644 index 000000000..0b36b23c2 --- /dev/null +++ b/meshchatx/src/frontend/features/page-nodes/lib/constants.ts @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: 0BSD + +/** Default periodic announce interval in seconds (15 minutes) */ +export const DEFAULT_ANNOUNCE_INTERVAL_SECONDS = 900; + +/** Minimum allowed announce interval in minutes */ +export const ANNOUNCE_INTERVAL_MIN_MINUTES = 1; + +/** Maximum allowed announce interval in minutes (24 hours) */ +export const ANNOUNCE_INTERVAL_MAX_MINUTES = 1440; + +/** API base endpoint for page nodes */ +export const PAGE_NODES_API_BASE = "/api/v1/page-nodes"; + +/** Detail sub-tabs */ +export const PAGE_NODE_TABS = { + PAGES: "pages", + FILES: "files", +} as const; + +export type PageNodeDetailTab = (typeof PAGE_NODE_TABS)[keyof typeof PAGE_NODE_TABS]; diff --git a/meshchatx/src/frontend/features/page-nodes/lib/pageNodesApi.ts b/meshchatx/src/frontend/features/page-nodes/lib/pageNodesApi.ts new file mode 100644 index 000000000..1660e9459 --- /dev/null +++ b/meshchatx/src/frontend/features/page-nodes/lib/pageNodesApi.ts @@ -0,0 +1,123 @@ +// SPDX-License-Identifier: 0BSD + +import { PAGE_NODES_API_BASE } from "./constants.js"; +import type { AnnounceSettingsForm, PageContentResponse, PageNode, PageNodeStartResponse } from "./types.js"; + +declare const window: { + api: { + get: (url: string) => Promise<{ data: unknown }>; + post: (url: string, data?: unknown, config?: unknown) => Promise<{ data: unknown }>; + put: (url: string, data?: unknown) => Promise<{ data: unknown }>; + patch: (url: string, data?: unknown) => Promise<{ data: unknown }>; + delete: (url: string) => Promise<{ data: unknown }>; + }; +}; + +/** Fetches list of all hosted page nodes */ +export async function fetchPageNodes(): Promise { + const response = await window.api.get(PAGE_NODES_API_BASE); + return response.data as PageNode[]; +} + +/** Creates a new page node */ +export async function createPageNode(name: string): Promise { + const response = await window.api.post(PAGE_NODES_API_BASE, { name }); + return response.data as PageNode; +} + +/** Deletes a page node by its unique identifier */ +export async function deletePageNode(nodeId: string): Promise { + await window.api.delete(`${PAGE_NODES_API_BASE}/${encodeURIComponent(nodeId)}`); +} + +/** Starts a page node destination on the mesh */ +export async function startPageNode(nodeId: string): Promise { + const response = await window.api.post(`${PAGE_NODES_API_BASE}/${encodeURIComponent(nodeId)}/start`); + return response.data as PageNodeStartResponse; +} + +/** Stops an active page node destination */ +export async function stopPageNode(nodeId: string): Promise { + await window.api.post(`${PAGE_NODES_API_BASE}/${encodeURIComponent(nodeId)}/stop`); +} + +/** Sends an immediate announce for a running page node */ +export async function announcePageNode(nodeId: string): Promise { + await window.api.post(`${PAGE_NODES_API_BASE}/${encodeURIComponent(nodeId)}/announce`); +} + +/** Renames a page node */ +export async function renamePageNode(nodeId: string, name: string): Promise { + await window.api.put(`${PAGE_NODES_API_BASE}/${encodeURIComponent(nodeId)}/rename`, { name }); +} + +/** Updates periodic announce and execution settings for a page node */ +export async function updateAnnounceSettings(nodeId: string, settings: AnnounceSettingsForm): Promise { + const response = await window.api.patch(`${PAGE_NODES_API_BASE}/${encodeURIComponent(nodeId)}/announce-settings`, { + announce_enabled: settings.announce_enabled, + announce_interval_seconds: settings.announce_interval_seconds, + executable_pages_enabled: settings.executable_pages_enabled, + }); + return response.data as PageNode; +} + +/** Creates an empty page file on a node */ +export async function createPage(nodeId: string, name: string): Promise { + await window.api.post(`${PAGE_NODES_API_BASE}/${encodeURIComponent(nodeId)}/pages`, { + name, + content: "", + }); +} + +/** Loads page content and metadata for editing */ +export async function fetchPage(nodeId: string, pageName: string): Promise { + const response = await window.api.get( + `${PAGE_NODES_API_BASE}/${encodeURIComponent(nodeId)}/pages/${encodeURIComponent(pageName)}` + ); + let body = response.data; + if (typeof body === "string") { + try { + body = JSON.parse(body); + } catch { + body = {}; + } + } + const typed = body as Partial; + return { + name: pageName, + content: typed.content ?? "", + executable: typed.executable === true, + }; +} + +/** Saves updated content and executable flag for a page */ +export async function savePage(nodeId: string, name: string, content: string, executable: boolean): Promise { + await window.api.post(`${PAGE_NODES_API_BASE}/${encodeURIComponent(nodeId)}/pages`, { + name, + content, + executable, + }); +} + +/** Deletes a page file from a node */ +export async function deletePage(nodeId: string, pageName: string): Promise { + await window.api.delete( + `${PAGE_NODES_API_BASE}/${encodeURIComponent(nodeId)}/pages/${encodeURIComponent(pageName)}` + ); +} + +/** Uploads a static file asset to a page node */ +export async function uploadFile(nodeId: string, file: File): Promise { + const formData = new FormData(); + formData.append("file", file); + await window.api.post(`${PAGE_NODES_API_BASE}/${encodeURIComponent(nodeId)}/files`, formData, { + headers: { "Content-Type": "multipart/form-data" }, + }); +} + +/** Deletes a static file asset from a page node */ +export async function deleteFile(nodeId: string, fileName: string): Promise { + await window.api.delete( + `${PAGE_NODES_API_BASE}/${encodeURIComponent(nodeId)}/files/${encodeURIComponent(fileName)}` + ); +} diff --git a/meshchatx/src/frontend/features/page-nodes/lib/pageNodesFormat.ts b/meshchatx/src/frontend/features/page-nodes/lib/pageNodesFormat.ts new file mode 100644 index 000000000..4ca8c5a8b --- /dev/null +++ b/meshchatx/src/frontend/features/page-nodes/lib/pageNodesFormat.ts @@ -0,0 +1,108 @@ +// SPDX-License-Identifier: 0BSD + +import { t } from "../../../js/i18n.js"; +import Utils from "../../../js/Utils.js"; +import { + ANNOUNCE_INTERVAL_MAX_MINUTES, + ANNOUNCE_INTERVAL_MIN_MINUTES, + DEFAULT_ANNOUNCE_INTERVAL_SECONDS, +} from "./constants.js"; + +/** Formats mesh uptime into human readable duration string */ +export function formatMeshUptime(seconds?: number | null): string { + if (seconds == null || seconds < 0) { + return "-"; + } + let s = Math.floor(seconds); + if (s < 60) { + return `${s}s`; + } + if (s < 3600) { + return `${Math.floor(s / 60)}m`; + } + if (s < 86400) { + return `${Math.floor(s / 3600)}h`; + } + if (s < 30 * 86400) { + return `${Math.floor(s / 86400)}d`; + } + const yearSec = 365 * 86400; + const monthSec = 30 * 86400; + const years = Math.floor(s / yearSec); + s -= years * yearSec; + const months = Math.floor(s / monthSec); + s -= months * monthSec; + const days = Math.floor(s / 86400); + const parts: string[] = []; + if (years) { + parts.push(`${years} year${years === 1 ? "" : "s"}`); + } + if (months) { + parts.push(`${months} month${months === 1 ? "" : "s"}`); + } + if (days) { + parts.push(`${days} day${days === 1 ? "" : "s"}`); + } + return parts.length ? parts.join(" ") : "0d"; +} + +/** Formats byte counts into human readable file size */ +export function formatFileSize(bytes?: number | null): string { + if (bytes == null || bytes < 0) { + return "0 B"; + } + if (bytes < 1024) { + return `${bytes} B`; + } + if (bytes < 1024 * 1024) { + return `${(bytes / 1024).toFixed(1)} KB`; + } + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +/** Resolves numerical announce interval seconds with fallback */ +export function resolveAnnounceIntervalSeconds( + seconds: unknown, + defaultSeconds: number = DEFAULT_ANNOUNCE_INTERVAL_SECONDS +): number { + if (seconds == null) { + return defaultSeconds; + } + const n = Number(seconds); + if (!Number.isFinite(n)) { + return defaultSeconds; + } + return n; +} + +/** Converts seconds to rounded announce minutes */ +export function secondsToAnnounceMinutes(seconds: unknown): number { + const sec = resolveAnnounceIntervalSeconds(seconds, DEFAULT_ANNOUNCE_INTERVAL_SECONDS); + if (sec === 0) { + return 0; + } + return Math.round(sec / 60); +} + +/** Converts announce minutes input into clamped seconds */ +export function announceMinutesToSeconds(minutes: unknown): number { + const n = Number(minutes); + if (Number.isFinite(n) && n === 0) { + return 0; + } + const clamped = Math.max( + ANNOUNCE_INTERVAL_MIN_MINUTES, + Math.min(ANNOUNCE_INTERVAL_MAX_MINUTES, Number.isFinite(n) ? n : ANNOUNCE_INTERVAL_MIN_MINUTES) + ); + return clamped * 60; +} + +/** Formats last announced timestamp using relative time translation */ +export function formatLastAnnounced(lastAnnouncedAt?: number | null): string { + if (!lastAnnouncedAt) { + return t("tools.mesh_server.never_announced"); + } + return t("tools.mesh_server.last_announced_ago", { + time: Utils.formatSecondsAgoForI18n(lastAnnouncedAt), + }); +} diff --git a/meshchatx/src/frontend/features/page-nodes/lib/types.ts b/meshchatx/src/frontend/features/page-nodes/lib/types.ts new file mode 100644 index 000000000..09b42f7e6 --- /dev/null +++ b/meshchatx/src/frontend/features/page-nodes/lib/types.ts @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: 0BSD + +/** Performance and traffic statistics for a hosted page node */ +export interface PageNodeStats { + pages_served: number; + files_served: number; + links_established: number; +} + +/** Page document descriptor */ +export interface PageNodePageItem { + name: string; + executable?: boolean; +} + +/** Static file descriptor */ +export interface PageNodeFileItem { + name: string; + size: number; +} + +/** Hosted Reticulum page node */ +export interface PageNode { + node_id: string; + name: string; + running: boolean; + destination_hash?: string | null; + announce_enabled?: boolean; + announce_interval_seconds?: number | null; + executable_pages_enabled?: boolean; + uptime_seconds?: number | null; + unique_connections?: number; + last_announced_at?: number | null; + stats?: PageNodeStats; + pages: PageNodePageItem[]; + files: PageNodeFileItem[]; +} + +/** Announce and runtime execution settings form */ +export interface AnnounceSettingsForm { + announce_enabled: boolean; + announce_interval_seconds: number; + executable_pages_enabled: boolean; +} + +/** Page content response from backend */ +export interface PageContentResponse { + name: string; + content: string; + executable?: boolean; +} + +/** Node start action response */ +export interface PageNodeStartResponse { + destination_hash?: string; + message?: string; +} diff --git a/meshchatx/src/frontend/features/remote-shell/components/RemoteShellFullscreenDialog.svelte b/meshchatx/src/frontend/features/remote-shell/components/RemoteShellFullscreenDialog.svelte new file mode 100644 index 000000000..b561791d4 --- /dev/null +++ b/meshchatx/src/frontend/features/remote-shell/components/RemoteShellFullscreenDialog.svelte @@ -0,0 +1,80 @@ + + + + + diff --git a/meshchatx/src/frontend/features/remote-shell/components/RemoteShellTerminal.svelte b/meshchatx/src/frontend/features/remote-shell/components/RemoteShellTerminal.svelte new file mode 100644 index 000000000..667a5f9bc --- /dev/null +++ b/meshchatx/src/frontend/features/remote-shell/components/RemoteShellTerminal.svelte @@ -0,0 +1,228 @@ + + + + +
+
+
+ {#if showSessionsToggle} + + {/if} +
+
+ {session?.name || tKey("session_output")} +
+ {#if !compactHeader} +
+ {session?.last_command || tKey("no_command_yet")} +
+ {/if} +
+
+ +
+ + + + + +
+
+ + {#if session && session.mode === "listen"} +
+ + {tKey("listening_on")} + + + {listenAddress || tKey("waiting_for_address")} + + {#if listenAddress} + + {/if} +
+ {/if} + +
+ {output} +
+ +
+ + +
+
+ + diff --git a/meshchatx/src/frontend/features/remote-shell/lib/constants.ts b/meshchatx/src/frontend/features/remote-shell/lib/constants.ts new file mode 100644 index 000000000..1ab444fd3 --- /dev/null +++ b/meshchatx/src/frontend/features/remote-shell/lib/constants.ts @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: 0BSD + +import type { RemoteShellLayoutState } from "./types.js"; + +export const MAX_OUTPUT_BUFFER_LENGTH = 250000; +export const NARROW_BREAKPOINT_PX = 1024; + +export const EMPTY_LAYOUT: RemoteShellLayoutState = { + selectedSessionId: null, +}; diff --git a/meshchatx/src/frontend/features/remote-shell/lib/sessionOutput.ts b/meshchatx/src/frontend/features/remote-shell/lib/sessionOutput.ts new file mode 100644 index 000000000..50a09793e --- /dev/null +++ b/meshchatx/src/frontend/features/remote-shell/lib/sessionOutput.ts @@ -0,0 +1,99 @@ +// SPDX-License-Identifier: 0BSD + +import { renderTerminalOutput } from "../../../js/terminalRender.js"; +import { MAX_OUTPUT_BUFFER_LENGTH } from "./constants.js"; +import type { RemoteShellSession } from "./types.js"; + +/** + * Ingest full or partial session output payload safely into outputs map + */ +export function ingestSessionOutput( + session: RemoteShellSession | null | undefined, + outputsBySession: Record +): void { + if (!session || !session.id) { + return; + } + const existing = outputsBySession[session.id] || ""; + const chunks = Array.isArray(session.output_chunks) ? session.output_chunks : []; + const fromChunks = chunks.length > 0 ? chunks.map((chunk) => chunk.text || "").join("") : ""; + const fromText = typeof session.output_text === "string" ? session.output_text : ""; + const incoming = fromText.length >= fromChunks.length ? fromText : fromChunks; + + if (!incoming) { + if (!Object.prototype.hasOwnProperty.call(outputsBySession, session.id)) { + outputsBySession[session.id] = ""; + } + return; + } + + if (existing.length > incoming.length) { + if ( + existing.endsWith(incoming) || + (fromChunks && existing.endsWith(fromChunks)) || + (fromChunks && existing.includes(fromChunks)) + ) { + return; + } + } + outputsBySession[session.id] = incoming; +} + +/** + * Append incoming stream text to a session buffer with boundary clamping + */ +export function appendSessionOutput( + sessionId: string | undefined, + text: string | undefined, + outputsBySession: Record +): void { + if (!sessionId || typeof text !== "string" || text.length === 0) { + return; + } + const existing = outputsBySession[sessionId] || ""; + const merged = existing + text; + outputsBySession[sessionId] = + merged.length > MAX_OUTPUT_BUFFER_LENGTH ? merged.slice(-MAX_OUTPUT_BUFFER_LENGTH) : merged; +} + +/** + * Format raw output text for terminal presentation + */ +export function formatTerminalOutput( + selectedSessionId: string | null, + outputRaw: string | undefined, + selectPrompt: string, + emptyPrompt: string +): string { + if (!selectedSessionId) { + return selectPrompt; + } + if (typeof outputRaw === "string" && outputRaw.length > 0) { + return renderTerminalOutput(outputRaw); + } + return emptyPrompt; +} + +/** + * Resolve session status badge color classes + */ +export function getSessionStatusClass(session: RemoteShellSession | null | undefined): string { + if (!session) return "text-gray-500"; + if (session.status === "running") return "text-emerald-600 dark:text-emerald-400"; + if (session.status === "failed") return "text-red-600 dark:text-red-400"; + return "text-sem-fg-muted"; +} + +/** + * Resolve session display subtitle + */ +export function getSessionSubtitle( + session: RemoteShellSession | null | undefined, + listenModeLabel: string +): string { + if (!session) return "-"; + if (session.mode === "listen") { + return session.listen_address || listenModeLabel; + } + return session.destination || "-"; +} diff --git a/meshchatx/src/frontend/features/remote-shell/lib/types.ts b/meshchatx/src/frontend/features/remote-shell/lib/types.ts new file mode 100644 index 000000000..692f7ced0 --- /dev/null +++ b/meshchatx/src/frontend/features/remote-shell/lib/types.ts @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: 0BSD + +export interface RemoteShellOutputChunk { + seq?: number; + text?: string; + ts?: number; +} + +export interface RemoteShellSession { + id: string; + name?: string; + mode: string; + status: string; + destination?: string; + listen_address?: string; + last_command?: string; + output_chunks?: RemoteShellOutputChunk[]; + output_text?: string; + created_at?: string | number; +} + +export interface RemoteShellLayoutState { + selectedSessionId: string | null; +} + +export interface RemoteShellTab { + id: string; + label: string; + shortLabel?: string; + icon: string; +} diff --git a/meshchatx/src/frontend/features/repository-server/RepositoryServerPage.svelte b/meshchatx/src/frontend/features/repository-server/RepositoryServerPage.svelte new file mode 100644 index 000000000..c7b7c6ce1 --- /dev/null +++ b/meshchatx/src/frontend/features/repository-server/RepositoryServerPage.svelte @@ -0,0 +1,335 @@ + + + + +
+ +
+
+
+

+ {t("tools.repository_server.http_heading")} +

+
+ + +
+ + + +
+
+ {#if httpRunning && status?.http?.url} +
+
+ {t("tools.repository_server.http_listen_label")} + {status.http.url} +
+ {#if browserRepoUrl} + + + {t("tools.repository_server.open_http")} + + {/if} +
+ {/if} +
+ +
+

+ {t("tools.repository_server.upload_heading")} +

+
+ + {#if lastUploadError} +

+ {lastUploadError} +

+ {/if} +
+
+ + {#if status?.last_refresh_failed && Object.keys(status.last_refresh_failed).length > 0} +
+
+
{t("tools.repository_server.refresh_partial")}
+
    + {#each Object.entries(status.last_refresh_failed) as [pkg, msg] (pkg)} +
  • + {pkg}: {msg} +
  • + {/each} +
+
+
+ {/if} + +
+
+

+ {t("tools.repository_server.files_heading")} +

+ {entries.length} +
+ {#if loading} +
+ {t("common.loading")} +
+ {:else if entries.length === 0} +
+ {t("tools.repository_server.empty")} +
+ {:else} + + + + + + + + + + + {#each entries as row (row.name + row.source)} + + + + + + + {/each} + +
{t("tools.repository_server.col_name")}{t("tools.repository_server.col_source")} + {t("tools.repository_server.col_size")} +
{row.name}{row.source}{formatBytes(row.bytes)} + {#if row.source === "upload"} + + {/if} +
+ {/if} +
+
+
+
diff --git a/meshchatx/src/frontend/features/repository-server/index.ts b/meshchatx/src/frontend/features/repository-server/index.ts new file mode 100644 index 000000000..49244cca9 --- /dev/null +++ b/meshchatx/src/frontend/features/repository-server/index.ts @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: 0BSD + +import { registerFeature } from "../../js/registries/featureRegistry.js"; +import { FEATURE_ID, ROUTE_NAME, ROUTE_PATH } from "./lib/constants.js"; + +/** + * Register the repository server tool feature + */ +export function registerRepositoryServerFeature(): void { + registerFeature({ + id: FEATURE_ID, + routes: [ + { + name: ROUTE_NAME, + path: ROUTE_PATH, + mount: "svelte", + load: () => import("./RepositoryServerPage.svelte"), + }, + ], + }); +} diff --git a/meshchatx/src/frontend/features/repository-server/lib/constants.ts b/meshchatx/src/frontend/features/repository-server/lib/constants.ts new file mode 100644 index 000000000..5ba462824 --- /dev/null +++ b/meshchatx/src/frontend/features/repository-server/lib/constants.ts @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: 0BSD + +export const FEATURE_ID = "repository-server"; +export const ROUTE_NAME = "repository-server"; +export const ROUTE_PATH = "/tools/repository-server"; + +export const API_REPOSITORY_SERVER_STATUS = "/api/v1/repository-server/status"; +export const API_REPOSITORY_SERVER_LIST = "/api/v1/repository-server/list"; +export const API_REPOSITORY_SERVER_HTTP_START = "/api/v1/repository-server/http/start"; +export const API_REPOSITORY_SERVER_HTTP_STOP = "/api/v1/repository-server/http/stop"; +export const API_REPOSITORY_SERVER_HTTP_RESTART = "/api/v1/repository-server/http/restart"; +export const API_REPOSITORY_SERVER_UPLOAD = "/api/v1/repository-server/upload"; + +export const DEFAULT_HTTP_HOST = "127.0.0.1"; +export const DEFAULT_HTTP_PORT = "8787"; diff --git a/meshchatx/src/frontend/features/repository-server/lib/repositoryServer.ts b/meshchatx/src/frontend/features/repository-server/lib/repositoryServer.ts new file mode 100644 index 000000000..16a888e5b --- /dev/null +++ b/meshchatx/src/frontend/features/repository-server/lib/repositoryServer.ts @@ -0,0 +1,174 @@ +// SPDX-License-Identifier: 0BSD + +import LinkUtils from "../../../js/LinkUtils.js"; +import { t } from "../../../js/i18n.js"; +import { + API_REPOSITORY_SERVER_HTTP_RESTART, + API_REPOSITORY_SERVER_HTTP_START, + API_REPOSITORY_SERVER_HTTP_STOP, + API_REPOSITORY_SERVER_LIST, + API_REPOSITORY_SERVER_STATUS, + API_REPOSITORY_SERVER_UPLOAD, + DEFAULT_HTTP_HOST, + DEFAULT_HTTP_PORT, +} from "./constants.js"; +import type { + BuildHttpBodyResult, + HttpActionResponse, + HttpBodyPayload, + RepositoryEntry, + RepositoryServerStatus, +} from "./types.js"; + +/** + * Format bytes into human readable binary unit strings + */ +export function formatBytes(n: number): string { + if (n < 1024) return `${n} B`; + if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; + return `${(n / (1024 * 1024)).toFixed(1)} MB`; +} + +/** + * Validate and format the browser repo URL with host replacement and allowlist check + */ +export function computeBrowserRepoUrl(raw?: string | null): string | null { + if (!raw) { + return null; + } + let candidate = String(raw).trim(); + try { + const parsed = new URL(candidate); + if (parsed.hostname === "0.0.0.0" && typeof window !== "undefined" && window.location?.hostname) { + parsed.hostname = window.location.hostname; + } + candidate = parsed.toString(); + } catch { + // Fall through to http allowlist check below + } + return LinkUtils.httpUrlHrefOrNull(candidate); +} + +/** + * Synchronize HTTP form fields host and port from server status + */ +export function syncHttpFormFromStatus( + status?: RepositoryServerStatus | null, + currentHost = DEFAULT_HTTP_HOST, + currentPort = DEFAULT_HTTP_PORT +): { host: string; port: string } { + const h = status?.http; + if (!h) { + return { host: currentHost, port: currentPort }; + } + let host = currentHost; + let port = currentPort; + if (h.running) { + if (h.host) { + host = h.host; + } + if (h.port != null) { + port = String(h.port); + } + } else if (h.last_host) { + host = h.last_host; + port = h.last_port != null ? String(h.last_port) : DEFAULT_HTTP_PORT; + } + return { host, port }; +} + +/** + * Build HTTP start/restart payload from user inputs + */ +export function buildHttpBody(host: string, port: string): BuildHttpBodyResult { + const body: HttpBodyPayload = {}; + const hostTrimmed = (host || "").trim(); + if (hostTrimmed) { + body.host = hostTrimmed; + } + const portRaw = (port || "").trim(); + if (portRaw !== "") { + const p = parseInt(portRaw, 10); + if (!Number.isFinite(p)) { + return { error: "invalid_port" }; + } + body.port = p; + } + return { body }; +} + +/** + * Resolve localized error message for HTTP server operations + */ +export function resolveHttpErrorMessage(errKey?: string, detail?: string): string { + const map: Record = { + invalid_host: t("tools.repository_server.http_err_invalid_host"), + invalid_port: t("tools.repository_server.http_err_invalid_port"), + already_running: t("tools.repository_server.http_err_already_running"), + bind_failed: t("tools.repository_server.http_err_bind_failed"), + }; + let msg = (errKey ? map[errKey] : undefined) || t("tools.repository_server.http_err_generic"); + if (errKey === "bind_failed" && detail) { + msg = `${msg}: ${detail}`; + } else if (errKey && !map[errKey] && detail) { + msg = `${msg}: ${detail}`; + } + return msg; +} + +/** + * Fetch repository server status + */ +export async function fetchRepositoryStatus(): Promise { + const res = await window.api.get(API_REPOSITORY_SERVER_STATUS); + return (res.data || {}) as RepositoryServerStatus; +} + +/** + * Fetch repository server entries list + */ +export async function fetchRepositoryList(): Promise { + const res = await window.api.get(API_REPOSITORY_SERVER_LIST); + return Array.isArray(res.data) ? (res.data as RepositoryEntry[]) : []; +} + +/** + * Start repository HTTP server + */ +export async function startHttpServer(payload: HttpBodyPayload): Promise { + const res = await window.api.post(API_REPOSITORY_SERVER_HTTP_START, payload); + return (res.data || {}) as HttpActionResponse; +} + +/** + * Stop repository HTTP server + */ +export async function stopHttpServer(): Promise { + const res = await window.api.post(API_REPOSITORY_SERVER_HTTP_STOP); + return (res.data || {}) as HttpActionResponse; +} + +/** + * Restart repository HTTP server + */ +export async function restartHttpServer(payload: HttpBodyPayload): Promise { + const res = await window.api.post(API_REPOSITORY_SERVER_HTTP_RESTART, payload); + return (res.data || {}) as HttpActionResponse; +} + +/** + * Upload package file to repository server + */ +export async function uploadRepositoryPackage(file: File): Promise { + const form = new FormData(); + form.append("file", file, file.name); + await window.api.post(API_REPOSITORY_SERVER_UPLOAD, form); +} + +/** + * Delete uploaded package from repository server + */ +export async function deleteRepositoryUpload(name: string): Promise { + const enc = encodeURIComponent(name); + await window.api.delete(`${API_REPOSITORY_SERVER_UPLOAD}/${enc}`); +} diff --git a/meshchatx/src/frontend/features/repository-server/lib/types.ts b/meshchatx/src/frontend/features/repository-server/lib/types.ts new file mode 100644 index 000000000..856f7c32b --- /dev/null +++ b/meshchatx/src/frontend/features/repository-server/lib/types.ts @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: 0BSD + +export interface RepositoryServerHttpInfo { + running?: boolean; + host?: string; + port?: number; + url?: string; + last_host?: string; + last_port?: number; +} + +export interface RepositoryServerStatus { + http?: RepositoryServerHttpInfo; + last_refresh_failed?: Record; +} + +export interface RepositoryEntry { + name: string; + source: string; + bytes: number; +} + +export interface HttpBodyPayload { + host?: string; + port?: number; +} + +export interface BuildHttpBodyResult { + error?: string; + body?: HttpBodyPayload; +} + +export interface HttpActionResponse { + ok?: boolean; + error?: string; + message?: string; +} diff --git a/meshchatx/src/frontend/features/rnpath-trace/RNPathTracePage.svelte b/meshchatx/src/frontend/features/rnpath-trace/RNPathTracePage.svelte index b93fc5831..46cf0324b 100644 --- a/meshchatx/src/frontend/features/rnpath-trace/RNPathTracePage.svelte +++ b/meshchatx/src/frontend/features/rnpath-trace/RNPathTracePage.svelte @@ -38,7 +38,7 @@ try { const res = await window.api.get(`${RNPATH_TRACE_API_BASE}/${destinationHash}`); - const data = (res as { data?: ({ error?: string } & PathTraceResult) })?.data; + const data = res.data as ({ error?: string } & PathTraceResult) | undefined; if (data?.error) { error = data.error; } else if (data) { diff --git a/meshchatx/src/frontend/features/rnpath/RNPathPage.svelte b/meshchatx/src/frontend/features/rnpath/RNPathPage.svelte index 52391a116..fa211af18 100644 --- a/meshchatx/src/frontend/features/rnpath/RNPathPage.svelte +++ b/meshchatx/src/frontend/features/rnpath/RNPathPage.svelte @@ -52,27 +52,31 @@ } catch { throw new Error(t("tools.rnpath.invalid_hops")); } - const res: any = await window.api.get("/api/v1/rnpath/table", { params }); - return (res?.data || { table: [], total: 0, responsive: 0, unresponsive: 0 }) as PathTableResponse; + const res = await window.api.get("/api/v1/rnpath/table", { params }); + const data = res.data as PathTableResponse | undefined; + return (data || { table: [], total: 0, responsive: 0, unresponsive: 0 }) as PathTableResponse; } export async function refreshAll(): Promise { isLoading = true; try { const remoteParams = buildRemoteQueryParams(remoteHash, identityPath, remoteTimeout); - const [pathRes, rateRes, ifaceRes, discRes]: any = await Promise.all([ + const [pathRes, rateRes, ifaceRes, discRes] = await Promise.all([ fetchPathTableData(), window.api.get("/api/v1/rnpath/rates", { params: remoteParams }), window.api.get("/api/v1/reticulum/interfaces"), window.api.get("/api/v1/reticulum/discovered-interfaces").catch(() => ({ data: {} })), ]); + const rateData = rateRes?.data as { remote?: string; rates?: RateEntry[] } | undefined; + const ifaceData = ifaceRes?.data as { interfaces?: Record } | undefined; + const discData = discRes?.data as { active?: Array<{ name?: string }>; interfaces?: Array<{ name?: string } | string> } | undefined; pathTable = pathRes?.table || []; totalItems = pathRes?.total || 0; responsiveItems = pathRes?.responsive || 0; unresponsiveItems = pathRes?.unresponsive || 0; - activeRemoteHash = pathRes?.remote || rateRes?.data?.remote || ""; - rateTable = rateRes?.data?.rates || []; - interfaces = extractInterfaceNames(ifaceRes?.data, discRes?.data); + activeRemoteHash = pathRes?.remote || rateData?.remote || ""; + rateTable = rateData?.rates || []; + interfaces = extractInterfaceNames(ifaceData, discData); } catch (e: any) { console.error(e); const detail = e?.response?.data?.message || e?.message || ""; @@ -121,8 +125,9 @@ return; } try { - const res: any = await window.api.post("/api/v1/rnpath/drop", { destination_hash: hash }); - if (res?.data?.success) { + const res = await window.api.post("/api/v1/rnpath/drop", { destination_hash: hash }); + const data = res.data as { success?: boolean } | undefined; + if (data?.success) { ToastUtils.success(t("tools.rnpath.path_dropped")); refreshAll(); } else { @@ -147,10 +152,11 @@ return; } try { - const res: any = await window.api.post("/api/v1/rnpath/drop-via", { + const res = await window.api.post("/api/v1/rnpath/drop-via", { transport_instance_hash: hash, }); - if (res?.data?.success) { + const data = res.data as { success?: boolean } | undefined; + if (data?.success) { ToastUtils.success(t("tools.rnpath.paths_dropped")); refreshAll(); } diff --git a/meshchatx/src/frontend/features/rnpath/components/ManagementIdentityPicker.svelte b/meshchatx/src/frontend/features/rnpath/components/ManagementIdentityPicker.svelte index 17cb38caf..09d69fcab 100644 --- a/meshchatx/src/frontend/features/rnpath/components/ManagementIdentityPicker.svelte +++ b/meshchatx/src/frontend/features/rnpath/components/ManagementIdentityPicker.svelte @@ -36,8 +36,9 @@ export async function loadIdentities(): Promise { loading = true; try { - const response: any = await window.api.get("/api/v1/reticulum/management-identities"); - identities = Array.isArray(response?.data?.identities) ? response.data.identities : []; + const response = await window.api.get("/api/v1/reticulum/management-identities"); + const data = response.data as { identities?: ManagementIdentity[] } | undefined; + identities = Array.isArray(data?.identities) ? data.identities : []; onloaded?.(identities); if (value && !identities.some((item) => item.path === value)) { value = ""; @@ -57,10 +58,11 @@ } creating = true; try { - const response: any = await window.api.post("/api/v1/reticulum/management-identities", { + const response = await window.api.post("/api/v1/reticulum/management-identities", { name: String(name).trim(), }); - const identity = response?.data?.identity; + const data = response.data as { identity?: ManagementIdentity } | undefined; + const identity = data?.identity; await loadIdentities(); if (identity?.path) { value = identity.path; diff --git a/meshchatx/src/frontend/features/rnprobe/RNProbePage.svelte b/meshchatx/src/frontend/features/rnprobe/RNProbePage.svelte index 976895965..f9f8057aa 100644 --- a/meshchatx/src/frontend/features/rnprobe/RNProbePage.svelte +++ b/meshchatx/src/frontend/features/rnprobe/RNProbePage.svelte @@ -77,7 +77,7 @@ } ); - const data = (response as { data?: ProbeApiResponse })?.data; + const data = response.data as ProbeApiResponse | undefined; results = data?.results || []; summary = parseProbeSummary(data || {}); } catch (e: any) { diff --git a/meshchatx/src/frontend/features/rnsh/RNSHPage.svelte b/meshchatx/src/frontend/features/rnsh/RNSHPage.svelte new file mode 100644 index 000000000..736a5ca19 --- /dev/null +++ b/meshchatx/src/frontend/features/rnsh/RNSHPage.svelte @@ -0,0 +1,484 @@ + + + + +
+ {#if !sessionFullscreen} + +
+ {#each RNSH_VIEW_TABS as tab (tab.id)} + + {/each} +
+ +
+ {#if activeTab === "sessions"} +
+ + +
+ (commandInput = val)} + onsend={sendCommand} + onstart={startSelected} + onstop={stopSelected} + onclear={clearSelectedOutput} + onremove={removeSelected} + oncopyAddress={copyListenAddress} + ontoggleFullscreen={toggleSessionFullscreen} + ontoggleSessions={toggleMobileSessions} + /> +
+
+ {:else if activeTab === "connect"} + + {:else if activeTab === "listen"} + + {/if} +
+ {/if} + + {#if sessionFullscreen} + + {/if} +
diff --git a/meshchatx/src/frontend/features/rnsh/components/RNSHConnectTab.svelte b/meshchatx/src/frontend/features/rnsh/components/RNSHConnectTab.svelte new file mode 100644 index 000000000..0a7efb3ed --- /dev/null +++ b/meshchatx/src/frontend/features/rnsh/components/RNSHConnectTab.svelte @@ -0,0 +1,90 @@ + + + + +
+

+ {t("rnsh.usage_hint")} +

+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +

+ {t("rnsh.config_dir_hint")} +

+
+
+ + +
+ +
diff --git a/meshchatx/src/frontend/features/rnsh/components/RNSHListenTab.svelte b/meshchatx/src/frontend/features/rnsh/components/RNSHListenTab.svelte new file mode 100644 index 000000000..adb3d46f4 --- /dev/null +++ b/meshchatx/src/frontend/features/rnsh/components/RNSHListenTab.svelte @@ -0,0 +1,84 @@ + + + + +
+

+ {t("rnsh.usage_hint")} +

+
+ + +
+
+ + +
+
+ + +
+
+ + +

+ {t("rnsh.config_dir_hint")} +

+
+
+ +
+ +
diff --git a/meshchatx/src/frontend/features/rnsh/components/RNSHSessionsList.svelte b/meshchatx/src/frontend/features/rnsh/components/RNSHSessionsList.svelte new file mode 100644 index 000000000..44b68cc1a --- /dev/null +++ b/meshchatx/src/frontend/features/rnsh/components/RNSHSessionsList.svelte @@ -0,0 +1,66 @@ + + + + +
+
+ {t("rnsh.sessions")} +
+ +
+ +
+ {#each sessions as session (session.id)} + + {/each} + {#if sessions.length === 0} +
+ {t("rnsh.no_sessions")} +
+ {/if} +
diff --git a/meshchatx/src/frontend/features/rnsh/index.ts b/meshchatx/src/frontend/features/rnsh/index.ts new file mode 100644 index 000000000..1c323af0f --- /dev/null +++ b/meshchatx/src/frontend/features/rnsh/index.ts @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: 0BSD + +import { registerFeature } from "../../js/registries/featureRegistry.js"; + +/** + * Reticulum remote shell feature registration + */ +export function registerRnshFeature(): void { + registerFeature({ + id: "rnsh", + routes: [ + { + name: "rnsh", + path: "/rnsh", + mount: "svelte", + load: () => import("./RNSHPage.svelte"), + }, + ], + }); +} + +export const registerRNSHFeature = registerRnshFeature; diff --git a/meshchatx/src/frontend/features/rnsh/lib/constants.ts b/meshchatx/src/frontend/features/rnsh/lib/constants.ts new file mode 100644 index 000000000..b8fa298fd --- /dev/null +++ b/meshchatx/src/frontend/features/rnsh/lib/constants.ts @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: 0BSD + +import type { RemoteShellTab } from "../../remote-shell/lib/types.js"; +import type { RnshConnectForm, RnshListenForm } from "./types.js"; + +export const RNSH_VIEW_TABS: RemoteShellTab[] = [ + { + id: "sessions", + label: "rnsh.tab_sessions", + shortLabel: "rnsh.tab_sessions_short", + icon: "console-line", + }, + { + id: "connect", + label: "rnsh.tab_connect", + shortLabel: "rnsh.tab_connect_short", + icon: "lan-connect", + }, + { + id: "listen", + label: "rnsh.tab_listen", + shortLabel: "rnsh.tab_listen_short", + icon: "access-point-network", + }, +]; + +export const DEFAULT_RNSH_CONNECT_FORM: RnshConnectForm = { + name: "", + destination: "", + command: "", + config_path: "", + mirror: false, + no_id: false, +}; + +export const DEFAULT_RNSH_LISTEN_FORM: RnshListenForm = { + name: "", + allowed_hashes_text: "", + command: "", + config_path: "", + no_auth: false, +}; diff --git a/meshchatx/src/frontend/features/rnsh/lib/rnshApi.ts b/meshchatx/src/frontend/features/rnsh/lib/rnshApi.ts new file mode 100644 index 000000000..b7a6faacb --- /dev/null +++ b/meshchatx/src/frontend/features/rnsh/lib/rnshApi.ts @@ -0,0 +1,103 @@ +// SPDX-License-Identifier: 0BSD + +import type { + RnshConnectForm, + RnshConnectPayload, + RnshListenForm, + RnshListenPayload, + RnshSession, +} from "./types.js"; + +/** + * Build connect session payload from form values + */ +export function buildRnshConnectPayload(form: RnshConnectForm): RnshConnectPayload { + return { + name: form.name.trim() || undefined, + mode: "connect", + destination: form.destination.trim(), + remote_command: form.command.trim() || undefined, + config_path: form.config_path.trim() || undefined, + mirror: !!form.mirror, + no_id: !!form.no_id, + autostart: true, + }; +} + +/** + * Build listen session payload from form values + */ +export function buildRnshListenPayload(form: RnshListenForm): RnshListenPayload { + return { + name: form.name.trim() || undefined, + mode: "listen", + allowed_hashes: form.allowed_hashes_text + .split("\n") + .map((value) => value.trim()) + .filter((value) => value.length > 0), + default_command: form.command.trim() || undefined, + config_path: form.config_path.trim() || undefined, + no_auth: !!form.no_auth, + autostart: true, + }; +} + +/** + * Fetch all RNSH sessions + */ +export async function fetchRnshSessions(): Promise { + const response = await window.api.get("/api/v1/rnsh/sessions"); + const data = response.data as { sessions?: RnshSession[] } | undefined; + return Array.isArray(data?.sessions) ? data.sessions : []; +} + +/** + * Create a new RNSH session + */ +export async function createRnshSession( + payload: RnshConnectPayload | RnshListenPayload +): Promise { + const response = await window.api.post("/api/v1/rnsh/sessions", payload); + const data = response.data as { session?: RnshSession } | undefined; + return data?.session || null; +} + +/** + * Start an RNSH session + */ +export async function startRnshSession(sessionId: string): Promise { + await window.api.post(`/api/v1/rnsh/sessions/${sessionId}/start`, {}); +} + +/** + * Stop an RNSH session + */ +export async function stopRnshSession(sessionId: string): Promise { + await window.api.post(`/api/v1/rnsh/sessions/${sessionId}/stop`, {}); +} + +/** + * Remove an RNSH session + */ +export async function removeRnshSession(sessionId: string): Promise { + await window.api.delete(`/api/v1/rnsh/sessions/${sessionId}`); +} + +/** + * Clear terminal output for an RNSH session + */ +export async function clearRnshSessionOutput(sessionId: string): Promise { + const response = await window.api.post(`/api/v1/rnsh/sessions/${sessionId}/clear`, {}); + const data = response.data as { session?: RnshSession } | undefined; + return data?.session || null; +} + +/** + * Send interactive text input to an RNSH session + */ +export async function sendRnshSessionInput(sessionId: string, text: string): Promise { + await window.api.post(`/api/v1/rnsh/sessions/${sessionId}/input`, { + text, + newline: true, + }); +} diff --git a/meshchatx/src/frontend/features/rnsh/lib/types.ts b/meshchatx/src/frontend/features/rnsh/lib/types.ts new file mode 100644 index 000000000..7844a44be --- /dev/null +++ b/meshchatx/src/frontend/features/rnsh/lib/types.ts @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: 0BSD + +import type { RemoteShellSession } from "../../remote-shell/lib/types.js"; + +export type RnshSession = RemoteShellSession; + +export type RnshTabId = "sessions" | "connect" | "listen"; + +export interface RnshConnectForm { + name: string; + destination: string; + command: string; + config_path: string; + mirror: boolean; + no_id: boolean; +} + +export interface RnshListenForm { + name: string; + allowed_hashes_text: string; + command: string; + config_path: string; + no_auth: boolean; +} + +export interface RnshConnectPayload { + name?: string; + mode: "connect"; + destination: string; + remote_command?: string; + config_path?: string; + mirror: boolean; + no_id: boolean; + autostart: boolean; +} + +export interface RnshListenPayload { + name?: string; + mode: "listen"; + allowed_hashes: string[]; + default_command?: string; + config_path?: string; + no_auth: boolean; + autostart: boolean; +} diff --git a/meshchatx/src/frontend/features/rnstatus/lib/statusPoller.ts b/meshchatx/src/frontend/features/rnstatus/lib/statusPoller.ts index 60a016c76..9ab874a6c 100644 --- a/meshchatx/src/frontend/features/rnstatus/lib/statusPoller.ts +++ b/meshchatx/src/frontend/features/rnstatus/lib/statusPoller.ts @@ -32,7 +32,8 @@ export async function fetchRNStatus(params: RNStatusQueryParams): Promise { const response = await window.api.get("/api/v1/reticulum/management-identities"); - const list = response?.data?.identities; + const data = response?.data as { identities?: ManagementIdentityItem[] } | undefined; + const list = data?.identities; return Array.isArray(list) ? list : []; } @@ -43,5 +44,6 @@ export async function createManagementIdentity(name: string): Promise + + + +
+ {#if !sessionFullscreen} + +
+ {#each RNX_VIEW_TABS as tab (tab.id)} + + {/each} +
+ +
+ {#if activeTab === "sessions"} +
+ + +
+ (commandInput = val)} + onsend={sendCommand} + onstart={startSelected} + onstop={stopSelected} + onclear={clearSelectedOutput} + onremove={removeSelected} + oncopyAddress={copyListenAddress} + ontoggleFullscreen={toggleSessionFullscreen} + ontoggleSessions={toggleMobileSessions} + /> +
+
+ {:else if activeTab === "execute"} + + {:else if activeTab === "listen"} + + {/if} +
+ {/if} + + {#if sessionFullscreen} + + {/if} +
diff --git a/meshchatx/src/frontend/features/rnx/components/RNXExecuteTab.svelte b/meshchatx/src/frontend/features/rnx/components/RNXExecuteTab.svelte new file mode 100644 index 000000000..d25d0d2a2 --- /dev/null +++ b/meshchatx/src/frontend/features/rnx/components/RNXExecuteTab.svelte @@ -0,0 +1,146 @@ + + + + +
+

+ {t("rnx.usage_hint")} +

+
+
+ + +
+
+ + +
+
+ {#if !form.interactive} +
+ + +
+ {/if} +
+ + +

+ {t("rnx.config_dir_hint")} +

+
+
+ + + + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
diff --git a/meshchatx/src/frontend/features/rnx/components/RNXListenTab.svelte b/meshchatx/src/frontend/features/rnx/components/RNXListenTab.svelte new file mode 100644 index 000000000..b4dd9afa2 --- /dev/null +++ b/meshchatx/src/frontend/features/rnx/components/RNXListenTab.svelte @@ -0,0 +1,74 @@ + + + + +
+

+ {t("rnx.usage_hint")} +

+
+ + +
+
+ + +
+
+ + +

+ {t("rnx.config_dir_hint")} +

+
+
+ +
+ +
diff --git a/meshchatx/src/frontend/features/rnx/components/RNXSessionsList.svelte b/meshchatx/src/frontend/features/rnx/components/RNXSessionsList.svelte new file mode 100644 index 000000000..d4dd3bead --- /dev/null +++ b/meshchatx/src/frontend/features/rnx/components/RNXSessionsList.svelte @@ -0,0 +1,66 @@ + + + + +
+
+ {t("rnx.sessions")} +
+ +
+ +
+ {#each sessions as session (session.id)} + + {/each} + {#if sessions.length === 0} +
+ {t("rnx.no_sessions")} +
+ {/if} +
diff --git a/meshchatx/src/frontend/features/rnx/index.ts b/meshchatx/src/frontend/features/rnx/index.ts new file mode 100644 index 000000000..20a18b203 --- /dev/null +++ b/meshchatx/src/frontend/features/rnx/index.ts @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: 0BSD + +import { registerFeature } from "../../js/registries/featureRegistry.js"; + +/** + * Reticulum remote execution feature registration + */ +export function registerRnxFeature(): void { + registerFeature({ + id: "rnx", + routes: [ + { + name: "rnx", + path: "/rnx", + mount: "svelte", + load: () => import("./RNXPage.svelte"), + }, + ], + }); +} + +export const registerRNXFeature = registerRnxFeature; diff --git a/meshchatx/src/frontend/features/rnx/lib/constants.ts b/meshchatx/src/frontend/features/rnx/lib/constants.ts new file mode 100644 index 000000000..5a870a691 --- /dev/null +++ b/meshchatx/src/frontend/features/rnx/lib/constants.ts @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: 0BSD + +import type { RemoteShellTab } from "../../remote-shell/lib/types.js"; +import type { RnxExecuteForm, RnxListenForm } from "./types.js"; + +export const RNX_VIEW_TABS: RemoteShellTab[] = [ + { + id: "sessions", + label: "rnx.tab_sessions", + shortLabel: "rnx.tab_sessions_short", + icon: "console-line", + }, + { + id: "execute", + label: "rnx.tab_execute", + shortLabel: "rnx.tab_execute_short", + icon: "lan-connect", + }, + { + id: "listen", + label: "rnx.tab_listen", + shortLabel: "rnx.tab_listen_short", + icon: "access-point-network", + }, +]; + +export const DEFAULT_RNX_EXECUTE_FORM: RnxExecuteForm = { + name: "", + destination: "", + command: "", + config_path: "", + mirror: false, + no_id: false, + detailed: true, + interactive: false, + timeout: "", + result_timeout: "", + stdout_limit: "", + stderr_limit: "", +}; + +export const DEFAULT_RNX_LISTEN_FORM: RnxListenForm = { + name: "", + allowed_hashes_text: "", + config_path: "", + no_auth: false, +}; diff --git a/meshchatx/src/frontend/features/rnx/lib/rnxApi.ts b/meshchatx/src/frontend/features/rnx/lib/rnxApi.ts new file mode 100644 index 000000000..8d06cb032 --- /dev/null +++ b/meshchatx/src/frontend/features/rnx/lib/rnxApi.ts @@ -0,0 +1,114 @@ +// SPDX-License-Identifier: 0BSD + +import type { + RnxExecuteForm, + RnxExecutePayload, + RnxListenForm, + RnxListenPayload, + RnxSession, +} from "./types.js"; + +/** + * Build execute or interactive session payload from form values + */ +export function buildRnxExecutePayload(form: RnxExecuteForm): RnxExecutePayload { + const interactive = !!form.interactive; + const formatLimit = (value: string | number | undefined): string | undefined => { + if (value === undefined || value === null) return undefined; + const s = String(value).trim(); + return s.length > 0 ? s : undefined; + }; + + return { + name: form.name.trim() || undefined, + mode: interactive ? "interactive" : "execute", + destination: form.destination.trim(), + remote_command: interactive ? undefined : form.command.trim() || undefined, + config_path: form.config_path.trim() || undefined, + mirror: !!form.mirror, + no_id: !!form.no_id, + detailed: !!form.detailed, + timeout: formatLimit(form.timeout), + result_timeout: formatLimit(form.result_timeout), + stdout_limit: formatLimit(form.stdout_limit), + stderr_limit: formatLimit(form.stderr_limit), + autostart: true, + }; +} + +/** + * Build listen session payload from form values + */ +export function buildRnxListenPayload(form: RnxListenForm): RnxListenPayload { + return { + name: form.name.trim() || undefined, + mode: "listen", + allowed_hashes: form.allowed_hashes_text + .split("\n") + .map((value) => value.trim()) + .filter((value) => value.length > 0), + config_path: form.config_path.trim() || undefined, + no_auth: !!form.no_auth, + autostart: true, + }; +} + +/** + * Fetch all RNX sessions + */ +export async function fetchRnxSessions(): Promise { + const response = await window.api.get("/api/v1/rnx/sessions"); + const data = response.data as { sessions?: RnxSession[] } | undefined; + return Array.isArray(data?.sessions) ? data.sessions : []; +} + +/** + * Create a new RNX session + */ +export async function createRnxSession( + payload: RnxExecutePayload | RnxListenPayload +): Promise { + const response = await window.api.post("/api/v1/rnx/sessions", payload); + const data = response.data as { session?: RnxSession } | undefined; + return data?.session || null; +} + +/** + * Start an RNX session + */ +export async function startRnxSession(sessionId: string): Promise { + await window.api.post(`/api/v1/rnx/sessions/${sessionId}/start`, {}); +} + +/** + * Stop an RNX session + */ +export async function stopRnxSession(sessionId: string): Promise { + await window.api.post(`/api/v1/rnx/sessions/${sessionId}/stop`, {}); +} + +/** + * Remove an RNX session + */ +export async function removeRnxSession(sessionId: string): Promise { + await window.api.delete(`/api/v1/rnx/sessions/${sessionId}`); +} + +/** + * Clear terminal output for an RNX session + */ +export async function clearRnxSessionOutput(sessionId: string): Promise { + const response = await window.api.post(`/api/v1/rnx/sessions/${sessionId}/clear`, {}); + const data = response.data as { session?: RnxSession } | undefined; + return data?.session || null; +} + +/** + * Send interactive text input to an RNX session + */ +export async function sendRnxSessionInput(sessionId: string, text: string): Promise { + await window.api.post(`/api/v1/rnx/sessions/${sessionId}/input`, { + text, + newline: true, + }); +} diff --git a/meshchatx/src/frontend/features/rnx/lib/types.ts b/meshchatx/src/frontend/features/rnx/lib/types.ts new file mode 100644 index 000000000..6c5e861c5 --- /dev/null +++ b/meshchatx/src/frontend/features/rnx/lib/types.ts @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: 0BSD + +import type { RemoteShellSession } from "../../remote-shell/lib/types.js"; + +export type RnxSession = RemoteShellSession; + +export type RnxTabId = "sessions" | "execute" | "listen"; + +export interface RnxExecuteForm { + name: string; + destination: string; + command: string; + config_path: string; + mirror: boolean; + no_id: boolean; + detailed: boolean; + interactive: boolean; + timeout: string | number; + result_timeout: string | number; + stdout_limit: string | number; + stderr_limit: string | number; +} + +export interface RnxListenForm { + name: string; + allowed_hashes_text: string; + config_path: string; + no_auth: boolean; +} + +export interface RnxExecutePayload { + name?: string; + mode: "execute" | "interactive"; + destination: string; + remote_command?: string; + config_path?: string; + mirror: boolean; + no_id: boolean; + detailed: boolean; + timeout?: string; + result_timeout?: string; + stdout_limit?: string; + stderr_limit?: string; + autostart: boolean; +} + +export interface RnxListenPayload { + name?: string; + mode: "listen"; + allowed_hashes: string[]; + config_path?: string; + no_auth: boolean; + autostart: boolean; +} diff --git a/meshchatx/src/frontend/features/sieve-filters/components/SieveFlowNetwork.svelte b/meshchatx/src/frontend/features/sieve-filters/components/SieveFlowNetwork.svelte index b9d362cc7..0018515cd 100644 --- a/meshchatx/src/frontend/features/sieve-filters/components/SieveFlowNetwork.svelte +++ b/meshchatx/src/frontend/features/sieve-filters/components/SieveFlowNetwork.svelte @@ -199,7 +199,7 @@ } const data = { nodes: new DataSet(nodes as any), edges: new DataSet(edges as any) }; - network = new Network(hostEl, data, { + network = new Network(hostEl, data as any, { layout: { hierarchical: { direction: "LR", diff --git a/meshchatx/src/frontend/features/sieve-filters/vis-network.d.ts b/meshchatx/src/frontend/features/sieve-filters/vis-network.d.ts new file mode 100644 index 000000000..a6717e9ac --- /dev/null +++ b/meshchatx/src/frontend/features/sieve-filters/vis-network.d.ts @@ -0,0 +1,3 @@ +// SPDX-License-Identifier: 0BSD + +declare module "vis-network/styles/vis-network.css"; diff --git a/meshchatx/src/frontend/js/MicronParser.ts b/meshchatx/src/frontend/js/MicronParser.ts index 595c41b2d..0377ba9e8 100644 --- a/meshchatx/src/frontend/js/MicronParser.ts +++ b/meshchatx/src/frontend/js/MicronParser.ts @@ -219,8 +219,8 @@ export default class MicronParser extends BaseMicronParser { return []; } const lines = String(markup).split("\n"); - const segments = []; - let buf = []; + const segments: Array<{ type: "mu"; text: string } | { type: "partial"; line: string }> = []; + let buf: string[] = []; for (const line of lines) { const trimmed = line.trim(); if (MicronParser.PARTIAL_LINE_REGEX.test(trimmed)) { diff --git a/meshchatx/src/frontend/js/MicronWasmLoader.ts b/meshchatx/src/frontend/js/MicronWasmLoader.ts index fe5f5dbea..2a6079ca5 100644 --- a/meshchatx/src/frontend/js/MicronWasmLoader.ts +++ b/meshchatx/src/frontend/js/MicronWasmLoader.ts @@ -7,10 +7,16 @@ import { getMicronWasmRuntimeOverride } from "./MicronWasmRuntimeOverride.js"; -let resolvedPromise = null; -let integrityHashes = null; +let resolvedPromise: Promise | null = null; +let integrityHashes: { wasm?: string; wasmExec?: string } | null = null; -let resolvedOverridePromise = null; +let resolvedOverridePromise: Promise<{ + source: string; + releaseTag: string; + wasmSri: string; + wasmBytes: any; + expectedSha256Hex: string | null; +} | null> | null = null; function readRuntimeOverrideCached() { if (resolvedOverridePromise) { return resolvedOverridePromise; @@ -23,9 +29,9 @@ function readRuntimeOverrideCached() { resolvedOverridePromise = (async () => { try { return await getMicronWasmRuntimeOverride(); - } catch (e) { + } catch (e: any) { // Sandboxed crash-tab iframe (opaque null Origin) cannot open IndexedDB. - const name = e && e.name; + const name = e && typeof e === "object" && "name" in e ? e.name : null; if (name === "SecurityError" || name === "InvalidStateError") { return null; } @@ -102,11 +108,11 @@ function injectMicronWasmStyles() { } /** True when WASM artifacts were present at Vite build time (not runtime probing). */ -export function isMicronWasmBundled() { +export function isMicronWasmBundled(): boolean { if (typeof globalThis !== "undefined" && typeof globalThis.__MESHCHATX_TEST_MICRON_WASM_BUNDLED__ === "boolean") { return globalThis.__MESHCHATX_TEST_MICRON_WASM_BUNDLED__; } - return import.meta.env.VITE_MICRON_WASM_BUNDLED === "true"; + return Boolean((import.meta.env.VITE_MICRON_WASM_BUNDLED as string) === "true"); } function baseUrl() { diff --git a/meshchatx/src/frontend/js/NomadPageRenderer.ts b/meshchatx/src/frontend/js/NomadPageRenderer.ts index 5d7140dfb..2fa5fd56a 100644 --- a/meshchatx/src/frontend/js/NomadPageRenderer.ts +++ b/meshchatx/src/frontend/js/NomadPageRenderer.ts @@ -169,7 +169,7 @@ export function isolateNomadLinksInHtml(html, destinationHash) { } return; } - let full = null; + let full: string | null = null; if (/^[a-f0-9]{32}:/i.test(h)) { full = h; } else if (h.startsWith("/page/") || h.startsWith("/file/")) { @@ -236,7 +236,7 @@ function ensureNomadPurifyHooks() { node.removeAttribute("src"); } } - if (node.hasAttributes) { + if (node.attributes && node.attributes.length > 0) { const attrs = node.attributes; for (let i = attrs.length - 1; i >= 0; i--) { const a = attrs[i].name; diff --git a/meshchatx/src/frontend/js/browser-globals.d.ts b/meshchatx/src/frontend/js/browser-globals.d.ts index 0458f0042..e5a5e5662 100644 --- a/meshchatx/src/frontend/js/browser-globals.d.ts +++ b/meshchatx/src/frontend/js/browser-globals.d.ts @@ -1,70 +1,75 @@ // SPDX-License-Identifier: 0BSD -declare global { - interface Window { - api: import("./apiClient.js").ApiClient; - electron?: any; - MeshChatXAndroid?: any; - webkitAudioContext?: typeof AudioContext; - DOMPurify?: any; - BarcodeDetector?: any; - zip?: any; - CryptoJS?: any; - enableHeapMonitor?: (intervalMs?: number) => void; - disableHeapMonitor?: () => void; - heapSnapshot?: () => unknown; - __meshchatxMicronCopyFix?: boolean; - } +interface Window { + api: import("./apiClient.js").ApiClient; + electron?: any; + MeshChatXAndroid?: any; + webkitAudioContext?: typeof AudioContext; + DOMPurify?: any; + BarcodeDetector?: any; + zip?: any; + CryptoJS?: any; + enableHeapMonitor?: (intervalMs?: number) => void; + disableHeapMonitor?: () => void; + heapSnapshot?: () => unknown; + __meshchatxMicronCopyFix?: boolean; +} - interface ImportMeta { - env: Record; - glob: (pattern: string, options?: Record) => Record; - } +interface ImportMeta { + env: Record; + glob: (pattern: string, options?: Record) => Record; +} - interface Performance { - memory?: { - usedJSHeapSize: number; - totalJSHeapSize: number; - jsHeapSizeLimit: number; - }; - } +interface Performance { + memory?: { + usedJSHeapSize: number; + totalJSHeapSize: number; + jsHeapSizeLimit: number; + }; +} - interface Navigator { - brave?: unknown; - getBattery?: () => Promise; - } +interface Navigator { + brave?: unknown; + getBattery?: () => Promise; +} - interface MediaTrackCapabilities { - focusMode?: string[]; - } +interface MediaTrackCapabilities { + focusMode?: string[]; +} - interface MediaTrackConstraintSet { - focusMode?: string; - } +interface MediaTrackConstraintSet { + focusMode?: string; +} - const __GEO_WASM_SRI_WASM__: string; - const __GEO_WASM_SRI_EXEC__: string; - const __MICRON_WASM_SRI_WASM__: string; - const __MICRON_WASM_SRI_EXEC__: string; - const __VISUALISER_WASM_SRI_WASM__: string; - const __VISUALISER_WASM_SRI_EXEC__: string; +declare const __GEO_WASM_SRI_WASM__: string; +declare const __GEO_WASM_SRI_EXEC__: string; +declare const __MICRON_WASM_SRI_WASM__: string; +declare const __MICRON_WASM_SRI_EXEC__: string; +declare const __VISUALISER_WASM_SRI_WASM__: string; +declare const __VISUALISER_WASM_SRI_EXEC__: string; - const Codec2MicrophoneRecorder: { - new (): { - codec2Mode: string; - start: () => Promise; - stop: () => Promise; - }; +declare const Codec2MicrophoneRecorder: { + new (): { + codec2Mode: string; + start: () => Promise; + stop: () => Promise; }; +}; - const Codec2Lib: { - runDecode: (mode: string, bytes: Uint8Array) => Promise; - rawToWav: (bytes: ArrayBuffer) => Promise; - }; +declare const Codec2Lib: { + runDecode: (mode: string, bytes: Uint8Array) => Promise; + rawToWav: (bytes: ArrayBuffer) => Promise; +}; + +declare module "*.css" { + const css: string; + export default css; } -declare module "*.css"; -declare module "vis-network/styles/vis-network.css"; +declare module "vis-network/styles/vis-network.css" { + const css: string; + export default css; +} declare module "*.js?raw" { const content: string; @@ -76,9 +81,12 @@ declare module "*.worklet.js?raw" { export default content; } -declare module "*?raw" { +declare module "*MicrophoneRecorder.worklet.js?raw" { const content: string; export default content; } -export {}; +declare module "*?raw" { + const content: string; + export default content; +} diff --git a/meshchatx/src/frontend/js/terminalRender.ts b/meshchatx/src/frontend/js/terminalRender.ts index 24563a723..1e76c045a 100644 --- a/meshchatx/src/frontend/js/terminalRender.ts +++ b/meshchatx/src/frontend/js/terminalRender.ts @@ -42,7 +42,7 @@ export function renderTerminalOutput(raw) { } const cleaned = stripAnsi(raw); - const rows = [[]]; + const rows: string[][] = [[]]; let row = 0; let col = 0; diff --git a/meshchatx/src/frontend/main.ts b/meshchatx/src/frontend/main.ts index 643f6c250..430d7fe8e 100644 --- a/meshchatx/src/frontend/main.ts +++ b/meshchatx/src/frontend/main.ts @@ -6,6 +6,7 @@ import { createI18n } from "vue-i18n"; import vClickOutside from "./libs/clickOutside.js"; import DOMPurify from "dompurify"; import "./style.css"; +import "./css/nomad-page-chrome.css"; import { injectMeshchatThemeVariables } from "./theme/designTokens.js"; import { registerUiI18n } from "./js/localeLoader.js"; import { registerTranslator, registerFallbackMessages } from "./js/i18n.js"; diff --git a/tests/frontend/MessageBlocklistPage.test.js b/tests/frontend/MessageBlocklistPage.test.js index ec7c5ddef..341319c7d 100644 --- a/tests/frontend/MessageBlocklistPage.test.js +++ b/tests/frontend/MessageBlocklistPage.test.js @@ -1,40 +1,191 @@ -import { mount } from "@vue/test-utils"; -import { describe, it, expect, vi, beforeEach } from "vitest"; -import MessageBlocklistPage from "@/components/tools/MessageBlocklistPage.vue"; -import { createRouter, createWebHistory } from "vue-router"; -import ToastUtils from "../../meshchatx/src/frontend/js/ToastUtils"; +// SPDX-License-Identifier: 0BSD -vi.mock("../../meshchatx/src/frontend/js/ToastUtils", () => ({ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, cleanup, fireEvent, waitFor, screen } from "@testing-library/svelte"; +import MessageBlocklistPage from "@/features/message-blocklist/MessageBlocklistPage.svelte"; +import ToastUtils from "@/js/ToastUtils.js"; +import DialogUtils from "@/js/DialogUtils.js"; +import DownloadUtils from "@/js/DownloadUtils.js"; +import { registerFallbackMessages, registerTranslator } from "@/js/i18n.js"; +import { + createNewBlocklistEntry, + createDefaultBlocklistConfig, + mapBlocklistFromApi, + newEntryId, + normalizeBlocklistForSave, + sanitizeBlocklistMatchMode, + sanitizeBlocklistScope, +} from "@/features/message-blocklist/lib/blocklistRules.ts"; +import { + API_MESSAGE_BLOCKLIST, + API_MESSAGE_BLOCKLIST_EXPORT, +} from "@/features/message-blocklist/lib/constants.ts"; +import { registerMessageBlocklistFeature } from "@/features/message-blocklist/index.ts"; +import { clearRoutes, listRoutes } from "@/js/registries/routeRegistry.js"; +import { clearFeatureIds, listFeatureIds } from "@/js/registries/featureRegistry.js"; + +vi.mock("@/js/ToastUtils", () => ({ default: { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn(), + loading: vi.fn(), + dismiss: vi.fn(), }, })); -vi.mock("../../meshchatx/src/frontend/js/DialogUtils", () => ({ +vi.mock("@/js/DialogUtils", () => ({ default: { confirm: vi.fn(() => Promise.resolve(false)), + alert: vi.fn(), }, })); -vi.mock("../../meshchatx/src/frontend/js/DownloadUtils", () => ({ +vi.mock("@/js/DownloadUtils", () => ({ default: { downloadFile: vi.fn(() => Promise.resolve()), }, })); -describe("MessageBlocklistPage.vue", () => { - const router = createRouter({ - history: createWebHistory(), - routes: [{ path: "/tools", name: "tools", component: { template: "
" } }], +describe("message-blocklist lib helpers", () => { + it("generates random entry id and new blank entry", () => { + const id = newEntryId(); + expect(typeof id).toBe("string"); + expect(id.length).toBeGreaterThan(0); + + const entry = createNewBlocklistEntry(); + expect(entry.enabled).toBe(true); + expect(entry.text).toBe(""); + expect(entry.match_mode).toBe("substring"); + expect(typeof entry.id).toBe("string"); + }); + + it("sanitizes scope and match mode values", () => { + expect(sanitizeBlocklistScope("contacts")).toBe("contacts"); + expect(sanitizeBlocklistScope("non_contacts")).toBe("non_contacts"); + expect(sanitizeBlocklistScope("invalid")).toBe("everyone"); + expect(sanitizeBlocklistScope(null)).toBe("everyone"); + + expect(sanitizeBlocklistMatchMode("regex")).toBe("regex"); + expect(sanitizeBlocklistMatchMode("substring")).toBe("substring"); + expect(sanitizeBlocklistMatchMode("other")).toBe("substring"); + }); + + it("maps raw api payload into sanitized BlocklistConfig", () => { + const mapped = mapBlocklistFromApi({ + scope: "non_contacts", + match_peer_fields: false, + match_message: true, + entries: [{ id: "e1", enabled: true, text: "spam", match_mode: "substring" }], + }); + expect(mapped.scope).toBe("non_contacts"); + expect(mapped.match_peer_fields).toBe(false); + expect(mapped.match_message).toBe(true); + expect(mapped.entries).toHaveLength(1); + expect(mapped.entries[0].text).toBe("spam"); }); + it("creates default config and normalizes config for save", () => { + const defaults = createDefaultBlocklistConfig(); + expect(defaults.scope).toBe("non_contacts"); + expect(defaults.entries).toHaveLength(0); + + const normalized = normalizeBlocklistForSave({ + scope: "contacts", + match_peer_fields: true, + match_message: false, + entries: [ + { id: "1", enabled: true, text: " hello ", match_mode: "substring" }, + ], + }); + expect(normalized.scope).toBe("contacts"); + expect(normalized.entries[0].text).toBe("hello"); + }); +}); + +describe("registerMessageBlocklistFeature", () => { + beforeEach(() => { + clearRoutes(); + clearFeatureIds(); + }); + + afterEach(() => { + clearRoutes(); + clearFeatureIds(); + }); + + it("registers message-blocklist route correctly", () => { + registerMessageBlocklistFeature(); + expect(listFeatureIds()).toContain("message-blocklist"); + const route = listRoutes().find((r) => r.name === "message-blocklist"); + expect(route).toBeTruthy(); + expect(route?.path).toBe("/tools/message-blocklist"); + expect(route?.mount).toBe("svelte"); + }); +}); + +describe("MessageBlocklistPage.svelte", () => { + let axiosMock; + beforeEach(() => { vi.clearAllMocks(); - global.api.get = vi.fn((url) => { - if (url.includes("message-blocklist/export")) { + axiosMock = { + get: vi.fn(), + put: vi.fn(), + post: vi.fn(), + }; + window.api = axiosMock; + + registerTranslator(null); + registerFallbackMessages({ + app: { tools: "Tools" }, + tools: { + back_to_tools: "Back", + message_blocklist: { + title: "Message Blocklist", + description: "Filter inbound messages", + beta_notice_title: "Beta Notice", + beta_notice_body: "Rules apply locally.", + enable_label: "Enable Blocklist", + enable_hint: "Drop matching messages", + entries_heading: "Blocklist Entries", + export: "Export", + import: "Import", + add_entry: "Add Entry", + scope_label: "Scope", + scope_everyone: "Everyone", + scope_contacts: "Contacts Only", + scope_non_contacts: "Non-contacts Only", + match_in_label: "Match In", + match_message: "Message Body", + match_peer_fields: "Sender Fields", + empty_entries: "No blocklist entries", + entry_enabled: "Active", + remove_entry: "Remove", + entry_placeholder: "Filter term or pattern", + match_mode_substring: "Substring", + match_mode_regex: "Regex", + save: "Save", + saving: "Saving...", + saved: "Blocklist saved", + revert: "Revert", + load_failed: "Failed to load", + save_failed: "Failed to save", + export_failed: "Failed to export", + exported: "Exported", + import_failed: "Failed to import", + import_merge_confirm: "Merge with existing entries?", + imported_merge: "Imported and merged", + imported_replace: "Imported and replaced", + enabled_toast: "Blocklist enabled", + disabled_toast: "Blocklist disabled", + }, + }, + }); + + axiosMock.get.mockImplementation((url) => { + if (url === API_MESSAGE_BLOCKLIST_EXPORT) { return Promise.resolve({ data: { schema: "meshchatx.message_blocklist", @@ -43,7 +194,7 @@ describe("MessageBlocklistPage.vue", () => { }, }); } - if (url.includes("message-blocklist")) { + if (url === API_MESSAGE_BLOCKLIST) { return Promise.resolve({ data: { enabled: false, @@ -58,105 +209,92 @@ describe("MessageBlocklistPage.vue", () => { } return Promise.resolve({ data: {} }); }); - global.api.put = vi.fn(() => - Promise.resolve({ - data: { - enabled: true, - blocklist: { - scope: "non_contacts", - match_message: true, - match_peer_fields: false, - entries: [{ id: "e1", enabled: true, text: "spam", match_mode: "substring" }], - }, - }, - }) - ); - global.api.post = vi.fn(() => - Promise.resolve({ - data: { - enabled: false, - blocklist: { - scope: "non_contacts", - match_message: true, - match_peer_fields: false, - entries: [], - }, - }, - }) - ); - window.api = global.api; - }); - it("loads blocklist from the API", async () => { - const wrapper = mount(MessageBlocklistPage, { - global: { - plugins: [router], - mocks: { $t: (k) => k }, - stubs: { - MaterialDesignIcon: { template: "", props: ["iconName"] }, - ToolsPageHeader: { template: "
" }, - RouterLink: { template: "", props: ["to"] }, + axiosMock.put.mockResolvedValue({ + data: { + enabled: true, + blocklist: { + scope: "non_contacts", + match_peer_fields: false, + match_message: true, + entries: [{ id: "e1", enabled: true, text: "spam", match_mode: "substring" }], }, }, }); - await Promise.resolve(); - await wrapper.vm.$nextTick(); - await Promise.resolve(); - expect(global.api.get).toHaveBeenCalledWith("/api/v1/lxmf/message-blocklist"); - expect(wrapper.vm.blocklist.entries.length).toBe(1); - expect(wrapper.vm.enabled).toBe(false); }); - it("saves blocklist via PUT", async () => { - const wrapper = mount(MessageBlocklistPage, { - global: { - plugins: [router], - mocks: { $t: (k) => k }, - stubs: { - MaterialDesignIcon: { template: "", props: ["iconName"] }, - ToolsPageHeader: { template: "
" }, - RouterLink: { template: "", props: ["to"] }, - }, - }, + afterEach(() => { + cleanup(); + delete window.api; + }); + + it("renders and loads blocklist from API", async () => { + render(MessageBlocklistPage); + expect(screen.getByText("Message Blocklist")).toBeTruthy(); + await waitFor(() => { + expect(axiosMock.get).toHaveBeenCalledWith(API_MESSAGE_BLOCKLIST); }); - await Promise.resolve(); - await wrapper.vm.$nextTick(); - await Promise.resolve(); - await wrapper.vm.save(); - expect(global.api.put).toHaveBeenCalledWith( - "/api/v1/lxmf/message-blocklist", - expect.objectContaining({ - enabled: false, - blocklist: expect.objectContaining({ - entries: expect.arrayContaining([ - expect.objectContaining({ text: "spam", match_mode: "substring" }), - ]), - }), - }) - ); - expect(ToastUtils.success).toHaveBeenCalled(); + expect(await screen.findByDisplayValue("spam")).toBeTruthy(); }); - it("adds an empty entry that save still posts", async () => { - const wrapper = mount(MessageBlocklistPage, { - global: { - plugins: [router], - mocks: { $t: (k) => k }, - stubs: { - MaterialDesignIcon: { template: "", props: ["iconName"] }, - ToolsPageHeader: { template: "
" }, - RouterLink: { template: "", props: ["to"] }, - }, - }, + it("saves blocklist via PUT when save button is clicked", async () => { + render(MessageBlocklistPage); + await waitFor(() => { + expect(axiosMock.get).toHaveBeenCalledWith(API_MESSAGE_BLOCKLIST); + }); + + const saveButton = screen.getByText("Save"); + await fireEvent.click(saveButton); + + await waitFor(() => { + expect(axiosMock.put).toHaveBeenCalledWith( + API_MESSAGE_BLOCKLIST, + expect.objectContaining({ + enabled: false, + blocklist: expect.objectContaining({ + entries: expect.arrayContaining([ + expect.objectContaining({ text: "spam", match_mode: "substring" }), + ]), + }), + }) + ); + }); + expect(ToastUtils.success).toHaveBeenCalledWith("Blocklist saved"); + }); + + it("adds a new entry on button click", async () => { + render(MessageBlocklistPage); + await waitFor(() => { + expect(axiosMock.get).toHaveBeenCalledWith(API_MESSAGE_BLOCKLIST); + }); + + const addButton = screen.getByText("Add Entry"); + await fireEvent.click(addButton); + + const inputs = screen.getAllByPlaceholderText("Filter term or pattern"); + expect(inputs.length).toBe(2); + + const saveButton = screen.getByText("Save"); + await fireEvent.click(saveButton); + + await waitFor(() => { + expect(axiosMock.put).toHaveBeenCalled(); + }); + }); + + it("exports blocklist via export endpoint", async () => { + render(MessageBlocklistPage); + await waitFor(() => { + expect(axiosMock.get).toHaveBeenCalledWith(API_MESSAGE_BLOCKLIST); + }); + + const exportButton = screen.getByText("Export"); + await fireEvent.click(exportButton); + + await waitFor(() => { + expect(axiosMock.get).toHaveBeenCalledWith(API_MESSAGE_BLOCKLIST_EXPORT); }); - await Promise.resolve(); - await wrapper.vm.$nextTick(); - await Promise.resolve(); - const before = wrapper.vm.blocklist.entries.length; - wrapper.vm.addEntry(); - expect(wrapper.vm.blocklist.entries.length).toBe(before + 1); - expect(wrapper.vm.blocklist.entries.at(-1).text).toBe(""); - await wrapper.vm.save(); - expect(global.api.put).toHaveBeenCalled(); + expect(DownloadUtils.downloadFile).toHaveBeenCalled(); + expect(ToastUtils.success).toHaveBeenCalledWith("Exported"); }); }); diff --git a/tests/frontend/PaperMessagePage.test.js b/tests/frontend/PaperMessagePage.test.js index eaf3712f0..4f6e75b29 100644 --- a/tests/frontend/PaperMessagePage.test.js +++ b/tests/frontend/PaperMessagePage.test.js @@ -1,8 +1,18 @@ -import { mount } from "@vue/test-utils"; +// SPDX-License-Identifier: 0BSD + import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import PaperMessagePage from "@/components/tools/PaperMessagePage.vue"; -import WebSocketConnection from "@/js/WebSocketConnection"; -import { mountToolsPageGlobals } from "./testI18n.js"; +import { render, cleanup, fireEvent, waitFor, screen } from "@testing-library/svelte"; +import PaperMessagePage from "@/features/paper-message/PaperMessagePage.svelte"; +import WebSocketConnection from "@/js/WebSocketConnection.js"; +import ToastUtils from "@/js/ToastUtils.js"; +import { registerFallbackMessages, registerTranslator } from "@/js/i18n.js"; +import { canGeneratePaperMessage, isValidLxmUri } from "@/features/paper-message/lib/paperQr.ts"; +import { printPaperQr } from "@/features/paper-message/lib/paperPrint.ts"; +import { sendPaperMessageApi } from "@/features/paper-message/lib/paperSend.ts"; +import { registerPaperMessageFeature } from "@/features/paper-message/index.ts"; +import { clearRoutes, listRoutes } from "@/js/registries/routeRegistry.js"; +import { clearFeatureIds, listFeatureIds } from "@/js/registries/featureRegistry.js"; +import { dispatchWsEvent } from "@/js/registries/wsEventRegistry.ts"; vi.mock("@/js/WebSocketConnection", () => ({ default: { @@ -12,64 +22,178 @@ vi.mock("@/js/WebSocketConnection", () => ({ }, })); +vi.mock("@/js/ToastUtils", () => ({ + default: { + success: vi.fn(), + error: vi.fn(), + warning: vi.fn(), + info: vi.fn(), + loading: vi.fn(), + dismiss: vi.fn(), + }, +})); + vi.mock("qrcode", () => ({ default: { toCanvas: vi.fn().mockResolvedValue({}), }, })); -describe("PaperMessagePage.vue", () => { +describe("paper-message lib helpers", () => { + it("validates inputs for paper message generation", () => { + expect(canGeneratePaperMessage("a".repeat(32), "Hello")).toBe(true); + expect(canGeneratePaperMessage("a".repeat(32), "")).toBe(false); + expect(canGeneratePaperMessage("short", "Hello")).toBe(false); + expect(canGeneratePaperMessage("", "Hello")).toBe(false); + }); + + it("validates LXM URI schemes", () => { + expect(isValidLxmUri("lxmf://abcdef123456")).toBe(true); + expect(isValidLxmUri("http://example.com")).toBe(false); + expect(isValidLxmUri("")).toBe(false); + }); + + it("printPaperQr returns false if canvas is missing", () => { + expect(printPaperQr({ canvas: null, destinationHash: "abc" })).toBe(false); + }); + + it("sendPaperMessageApi posts payload and shows success toast", async () => { + const postMock = vi.fn().mockResolvedValue({ + data: { lxmf_message: { destination_hash: "a".repeat(32) } }, + }); + window.api = { post: postMock }; + + const res = await sendPaperMessageApi({ + destinationHash: "a".repeat(32), + generatedUri: "lxmf://testuri", + }); + + expect(res.success).toBe(true); + expect(postMock).toHaveBeenCalledWith( + "/api/v1/lxmf-messages/send", + expect.objectContaining({ + delivery_method: "opportunistic", + lxmf_message: expect.objectContaining({ + destination_hash: "a".repeat(32), + }), + }) + ); + delete window.api; + }); +}); + +describe("registerPaperMessageFeature", () => { + beforeEach(() => { + clearRoutes(); + clearFeatureIds(); + }); + + afterEach(() => { + clearRoutes(); + clearFeatureIds(); + }); + + it("registers paper-message route correctly", () => { + registerPaperMessageFeature(); + expect(listFeatureIds()).toContain("paper-message"); + const route = listRoutes().find((r) => r.name === "paper-message"); + expect(route).toBeTruthy(); + expect(route?.path).toBe("/tools/paper-message"); + expect(route?.mount).toBe("svelte"); + }); +}); + +describe("PaperMessagePage.svelte", () => { let axiosMock; beforeEach(() => { + vi.clearAllMocks(); axiosMock = { get: vi.fn(), post: vi.fn(), }; window.api = axiosMock; + + registerTranslator(null); + registerFallbackMessages({ + app: { tools: "Tools" }, + tools: { + paper_message: { + title: "Paper Message", + description: "Offline paper QR messaging", + }, + }, + messages: { + compose: "Compose Message", + destination_hash: "Destination Hash", + destination_hash_placeholder: "Destination hash (32 hex)", + title: "Title", + title_placeholder: "Optional title", + content: "Content", + content_placeholder: "Message content", + generate_paper_message: "Generate Paper Message", + generating_paper_message: "Generating...", + generated_qr_code: "Generated QR Code", + scan_with_lxmf_app: "Scan with an LXMF app", + print_qr_code: "Print QR Code", + download_qr_code: "Download QR Code", + copy_uri: "Copy URI", + send_paper_message: "Send Paper Message", + sending: "Sending...", + ingest_paper_message: "Ingest Paper Message", + ingest_instructions: "Paste or scan a Paper Message URI", + paste_from_clipboard: "Paste", + scan_with_camera: "Scan QR", + ingest_uri_placeholder: "lxmf://...", + read_lxm: "Read LXM", + failed_read_clipboard: "Failed to read clipboard", + uri_copied: "URI copied", + failed_copy_uri: "Failed to copy URI", + paper_message_sent: "Paper message sent", + failed_send_paper: "Failed to send paper message", + }, + }); }); afterEach(() => { + cleanup(); delete window.api; - vi.clearAllMocks(); }); - const mountPaperMessagePage = () => { - return mount(PaperMessagePage, { - global: mountToolsPageGlobals(), - }); - }; - it("renders the paper message page", () => { - const wrapper = mountPaperMessagePage(); - expect(wrapper.text()).toContain("Paper Message"); - expect(wrapper.text()).toContain("Compose Message"); + render(PaperMessagePage); + expect(screen.getByText("Paper Message")).toBeTruthy(); + expect(screen.getByText("Compose Message")).toBeTruthy(); }); it("enables generate button only when inputs are valid", async () => { - const wrapper = mountPaperMessagePage(); - const generateButton = wrapper.findAll("button").find((b) => b.text().includes("Generate Paper Message")); + render(PaperMessagePage); - expect(generateButton.element.disabled).toBe(true); + const generateBtn = screen.getByText(/Generate Paper Message/); + expect(generateBtn.closest("button")?.hasAttribute("disabled")).toBe(true); - await wrapper.setData({ - destinationHash: "a".repeat(32), - content: "Hello World", - }); + const hashInput = screen.getByPlaceholderText(/Destination hash/); + const contentInput = screen.getByPlaceholderText(/Type your message/); + + await fireEvent.input(hashInput, { target: { value: "a".repeat(32) } }); + await fireEvent.input(contentInput, { target: { value: "Hello World" } }); - expect(generateButton.element.disabled).toBe(false); + expect(generateBtn.closest("button")?.hasAttribute("disabled")).toBe(false); }); it("sends websocket request to generate paper message", async () => { - const wrapper = mountPaperMessagePage(); - await wrapper.setData({ - destinationHash: "a".repeat(32), - content: "Hello World", - title: "Test Title", - }); + render(PaperMessagePage); + + const hashInput = screen.getByPlaceholderText(/Destination hash/); + const titleInput = screen.getByPlaceholderText(/Message title/); + const contentInput = screen.getByPlaceholderText(/Type your message/); + + await fireEvent.input(hashInput, { target: { value: "a".repeat(32) } }); + await fireEvent.input(titleInput, { target: { value: "Test Title" } }); + await fireEvent.input(contentInput, { target: { value: "Hello World" } }); - const generateButton = wrapper.findAll("button").find((b) => b.text().includes("Generate Paper Message")); - await generateButton.trigger("click"); + const generateBtn = screen.getByText(/Generate Paper Message/); + await fireEvent.click(generateBtn); expect(WebSocketConnection.send).toHaveBeenCalledWith( JSON.stringify({ @@ -81,25 +205,28 @@ describe("PaperMessagePage.vue", () => { ); }); - it("handles websocket result and shows QR code", async () => { - const wrapper = mountPaperMessagePage(); + it("handles websocket result and shows QR code section", async () => { + render(PaperMessagePage); - await wrapper.vm.onGeneratePaperUriResult({ + await dispatchWsEvent("lxm.generate_paper_uri.result", { type: "lxm.generate_paper_uri.result", status: "success", uri: "lxmf://testuri", }); - expect(wrapper.vm.generatedUri).toBe("lxmf://testuri"); - expect(wrapper.text()).toContain("Generated QR Code"); + await waitFor(() => { + expect(screen.getByText("Generated QR Code")).toBeTruthy(); + }); }); it("calls ingest API when ingest button is clicked", async () => { - const wrapper = mountPaperMessagePage(); - await wrapper.setData({ ingestUri: "lxmf://ingestme" }); + render(PaperMessagePage); + + const ingestInput = screen.getByPlaceholderText(/lxmf:\/\//); + await fireEvent.input(ingestInput, { target: { value: "lxmf://ingestme" } }); - const ingestButton = wrapper.findAll("button").find((b) => b.text().includes("Read LXM")); - await ingestButton.trigger("click"); + const ingestBtn = screen.getByText("Read LXM"); + await fireEvent.click(ingestBtn); expect(WebSocketConnection.send).toHaveBeenCalledWith( JSON.stringify({ diff --git a/tests/frontend/RNPathPage.test.js b/tests/frontend/RNPathPage.test.js index 657fe9da4..5b603aa41 100644 --- a/tests/frontend/RNPathPage.test.js +++ b/tests/frontend/RNPathPage.test.js @@ -1,18 +1,184 @@ -import { mount } from "@vue/test-utils"; +// SPDX-License-Identifier: 0BSD + import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import RNPathPage from "@/components/tools/RNPathPage.vue"; -import { mountToolsPageGlobals } from "./testI18n.js"; +import { render, cleanup, fireEvent, waitFor, screen } from "@testing-library/svelte"; +import RNPathPage from "@/features/rnpath/RNPathPage.svelte"; +import ToastUtils from "@/js/ToastUtils.js"; +import DialogUtils from "@/js/DialogUtils.js"; +import { registerFallbackMessages, registerTranslator } from "@/js/i18n.js"; +import { + buildPathQueryParams, + buildRemoteQueryParams, + calculateRate, + extractInterfaceNames, + formatDate, + getStateColor, + getStateText, + isValidDestinationHash, + parseHops, + shortHash, +} from "@/features/rnpath/lib/pathQuery.ts"; +import { registerRnpathFeature } from "@/features/rnpath/index.ts"; +import { clearRoutes, listRoutes } from "@/js/registries/routeRegistry.js"; +import { clearFeatureIds, listFeatureIds } from "@/js/registries/featureRegistry.js"; + +vi.mock("@/js/ToastUtils", () => ({ + default: { + success: vi.fn(), + error: vi.fn(), + warning: vi.fn(), + info: vi.fn(), + loading: vi.fn(), + dismiss: vi.fn(), + }, +})); + +vi.mock("@/js/DialogUtils", () => ({ + default: { + confirm: vi.fn(() => Promise.resolve(true)), + alert: vi.fn(), + }, +})); + +describe("rnpath lib helpers", () => { + it("builds remote query params", () => { + expect(buildRemoteQueryParams("")).toEqual({}); + expect(buildRemoteQueryParams("a".repeat(32), "/path/key", 20)).toEqual({ + remote: "a".repeat(32), + identity_path: "/path/key", + timeout: 20, + }); + }); + + it("parses hops and builds path query params", () => { + expect(parseHops(3)).toBe(3); + expect(parseHops("2")).toBe(2); + expect(parseHops("")).toBeUndefined(); + expect(() => parseHops("invalid")).toThrow(); + + const params = buildPathQueryParams({ + searchQuery: " test ", + filterInterface: "UDP", + filterHops: "1", + currentPage: 2, + itemsPerPage: 50, + }); + expect(params.page).toBe(2); + expect(params.limit).toBe(50); + expect(params.search).toBe("test"); + expect(params.interface).toBe("UDP"); + expect(params.hops).toBe(1); + }); + + it("extracts sorted interface names", () => { + const names = extractInterfaceNames( + { interfaces: { UDP: {}, TCP: {} } }, + { active: [{ name: "ActiveIF" }], interfaces: [{ name: "DiscIF" }] } + ); + expect(names).toEqual(["ActiveIF", "DiscIF", "TCP", "UDP"]); + }); + + it("calculates announce rates", () => { + expect(calculateRate(null)).toBe("0.00"); + expect(calculateRate({ timestamps: [] })).toBe("0.00"); + }); + + it("returns state color and label", () => { + expect(getStateColor(2)).toContain("bg-green-100"); + expect(getStateColor(1)).toContain("bg-red-100"); + expect(getStateText(2)).toBe("RESPONSIVE"); + expect(getStateText(1)).toBe("UNRESPONSIVE"); + expect(getStateText(0)).toBe("UNKNOWN"); + }); + + it("formats date, shortens hash, and validates hash", () => { + expect(formatDate(0)).toBe("Unknown"); + expect(shortHash("0123456789abcdef0123456789abcdef")).toBe("01234567…"); + expect(isValidDestinationHash("a".repeat(32))).toBe(true); + expect(isValidDestinationHash("xyz")).toBe(false); + }); +}); -describe("RNPathPage.vue", () => { +describe("registerRnpathFeature", () => { + beforeEach(() => { + clearRoutes(); + clearFeatureIds(); + }); + + afterEach(() => { + clearRoutes(); + clearFeatureIds(); + }); + + it("registers rnpath route correctly", () => { + registerRnpathFeature(); + expect(listFeatureIds()).toContain("rnpath"); + const route = listRoutes().find((r) => r.name === "rnpath"); + expect(route).toBeTruthy(); + expect(route?.path).toBe("/rnpath"); + expect(route?.mount).toBe("svelte"); + }); +}); + +describe("RNPathPage.svelte", () => { let axiosMock; beforeEach(() => { + vi.clearAllMocks(); axiosMock = { get: vi.fn(), post: vi.fn(), }; window.api = axiosMock; + registerTranslator(null); + registerFallbackMessages({ + app: { tools: "Tools" }, + tools: { + back_to_tools: "Back", + rnpath: { + title: "RNPath", + description: "Inspect and manage Reticulum path table", + table: "Table", + rates: "Rates", + actions: "Actions", + invalid_hops: "Invalid hops count", + failed_fetch: "Failed to fetch path data", + path_requested: "Path requested for {hash}", + failed_request: "Failed to request path", + drop_confirm: "Drop path for {hash}?", + path_dropped: "Path dropped", + failed_drop: "Failed to drop path", + error_drop: "Error dropping path", + drop_via_confirm: "Drop all paths via {hash}?", + paths_dropped: "Paths dropped", + failed_drop_paths: "Failed to drop paths", + purge_confirm: "Purge announce queues?", + queues_purged: "Queues purged", + failed_purge: "Failed to purge queues", + search_placeholder: "Search paths...", + filter_interface: "Interface", + all_interfaces: "All interfaces", + filter_hops: "Hops", + all_hops: "All hops", + clear_filters: "Clear filters", + total_paths: "Total Paths", + responsive: "Responsive", + unresponsive: "Unresponsive", + destination: "Destination", + hops: "Hops", + next_hop: "Next Hop", + interface: "Interface", + expires: "Expires", + drop: "Drop", + request_path: "Request Path", + request_button: "Request", + drop_via_button: "Drop Paths Via", + purge_queues_button: "Purge Announce Queues", + }, + }, + }); + axiosMock.get.mockImplementation((url) => { if (url.startsWith("/api/v1/rnpath/table")) { return Promise.resolve({ @@ -24,6 +190,7 @@ describe("RNPathPage.vue", () => { via: "b".repeat(32), interface: "UDP", expires: 1234567890, + state: 2, }, ], total: 1, @@ -64,52 +231,71 @@ describe("RNPathPage.vue", () => { } return Promise.resolve({ data: {} }); }); + + axiosMock.post.mockResolvedValue({ + data: { success: true }, + }); }); afterEach(() => { + cleanup(); delete window.api; }); - const mountRNPathPage = () => { - return mount(RNPathPage, { - global: mountToolsPageGlobals(), - }); - }; + it("renders and loads path table data", async () => { + render(RNPathPage); + expect(screen.getByText("RNPath")).toBeTruthy(); - it("renders and loads data", async () => { - const wrapper = mountRNPathPage(); - await vi.waitFor(() => expect(wrapper.vm.isLoading).toBe(false)); + await waitFor(() => { + expect(axiosMock.get).toHaveBeenCalledWith( + "/api/v1/rnpath/table", + expect.any(Object) + ); + expect(axiosMock.get).toHaveBeenCalledWith( + "/api/v1/rnpath/rates", + expect.any(Object) + ); + }); - expect(wrapper.text()).toContain("RNPath"); - expect(wrapper.vm.pathTable.length).toBe(1); - expect(wrapper.vm.rateTable.length).toBe(1); - expect(wrapper.vm.interfaces).toEqual(["Active-IF", "Discovered-IF", "UDP"]); + expect(await screen.findByText("Table")).toBeTruthy(); + expect(screen.getByText("Rates")).toBeTruthy(); + expect(screen.getByText("Actions")).toBeTruthy(); }); - it("switches tabs", async () => { - const wrapper = mountRNPathPage(); - await vi.waitFor(() => expect(wrapper.vm.isLoading).toBe(false)); + it("switches tabs to Rates and Actions", async () => { + render(RNPathPage); + await waitFor(() => { + expect(axiosMock.get).toHaveBeenCalledWith("/api/v1/rnpath/table", expect.any(Object)); + }); - const ratesButton = wrapper.findAll("button").find((b) => b.text() === "Rates"); - await ratesButton.trigger("click"); - expect(wrapper.vm.tab).toBe("rates"); + const ratesTab = screen.getByText("Rates"); + await fireEvent.click(ratesTab); + expect(ratesTab.className).toContain("text-indigo-600"); - const actionsButton = wrapper.findAll("button").find((b) => b.text() === "Actions"); - await actionsButton.trigger("click"); - expect(wrapper.vm.tab).toBe("actions"); + const actionsTab = screen.getByText("Actions"); + await fireEvent.click(actionsTab); + expect(actionsTab.className).toContain("text-indigo-600"); }); - it("calls request path API", async () => { - const wrapper = mountRNPathPage(); - await vi.waitFor(() => expect(wrapper.vm.isLoading).toBe(false)); + it("calls request path API on Actions tab", async () => { + render(RNPathPage); + await waitFor(() => { + expect(axiosMock.get).toHaveBeenCalledWith("/api/v1/rnpath/table", expect.any(Object)); + }); + + const actionsTab = screen.getByText("Actions"); + await fireEvent.click(actionsTab); - await wrapper.setData({ tab: "actions", requestHash: "d".repeat(32) }); + const input = screen.getByPlaceholderText(/destination hash/i); + await fireEvent.input(input, { target: { value: "d".repeat(32) } }); - const requestButton = wrapper.findAll("button").find((b) => b.text() === "Request"); - await requestButton.trigger("click"); + const requestBtn = screen.getByRole("button", { name: /Request/i }); + await fireEvent.click(requestBtn); - expect(axiosMock.post).toHaveBeenCalledWith("/api/v1/rnpath/request", { - destination_hash: "d".repeat(32), + await waitFor(() => { + expect(axiosMock.post).toHaveBeenCalledWith("/api/v1/rnpath/request", { + destination_hash: "d".repeat(32), + }); }); }); }); diff --git a/tests/frontend/RNPathTracePage.test.js b/tests/frontend/RNPathTracePage.test.js deleted file mode 100644 index 295390f06..000000000 --- a/tests/frontend/RNPathTracePage.test.js +++ /dev/null @@ -1,98 +0,0 @@ -import { mount } from "@vue/test-utils"; -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import RNPathTracePage from "@/components/tools/RNPathTracePage.vue"; - -describe("RNPathTracePage.vue", () => { - let axiosMock; - - beforeEach(() => { - axiosMock = { - get: vi.fn(), - }; - window.api = axiosMock; - }); - - afterEach(() => { - delete window.api; - vi.clearAllMocks(); - }); - - const mountRNPathTracePage = (query = {}) => { - return mount(RNPathTracePage, { - global: { - mocks: { - $t: (key) => key, - $route: { query }, - $router: { push: vi.fn() }, - }, - stubs: { - MaterialDesignIcon: { - template: '
', - props: ["iconName"], - }, - }, - }, - }); - }; - - it("renders the path trace page", () => { - const wrapper = mountRNPathTracePage(); - expect(wrapper.text()).toContain("tools.rnpath_trace.title"); - }); - - it("validates destination hash", async () => { - const wrapper = mountRNPathTracePage(); - const input = wrapper.find("input"); - const button = wrapper.find("button[title='Trace Path']"); - - await input.setValue("invalid"); - expect(button.element.disabled).toBe(true); - - await input.setValue("a".repeat(32)); - expect(button.element.disabled).toBe(false); - }); - - it("calls trace API and displays results", async () => { - axiosMock.get.mockResolvedValue({ - data: { - hops: 2, - interface: "UDP", - next_hop: "next_hop_hash", - path: [ - { type: "local", name: "Local", hash: "local_hash" }, - { type: "node", name: "Intermediate", hash: "node_hash", interface: "UDP" }, - { type: "destination", name: "Destination", hash: "dest_hash" }, - ], - }, - }); - - const wrapper = mountRNPathTracePage(); - await wrapper.find("input").setValue("a".repeat(32)); - await wrapper.find("button[title='Trace Path']").trigger("click"); - - await vi.waitFor(() => expect(wrapper.vm.isLoading).toBe(false)); - - expect(axiosMock.get).toHaveBeenCalledWith(`/api/v1/rnpath/trace/${"a".repeat(32)}`); - expect(wrapper.text()).toContain("2"); - expect(wrapper.text()).toContain("UDP"); - expect(wrapper.text()).toContain("Local"); - expect(wrapper.text()).toContain("Intermediate"); - expect(wrapper.text()).toContain("Destination"); - }); - - it("handles trace error", async () => { - axiosMock.get.mockRejectedValue({ - response: { data: { error: "Trace failed" } }, - }); - - const wrapper = mountRNPathTracePage(); - await wrapper.find("input").setValue("a".repeat(32)); - await wrapper.find("button[title='Trace Path']").trigger("click"); - - await vi.waitFor(() => expect(wrapper.vm.error).toBe("Trace failed")); - await wrapper.vm.$nextTick(); - - expect(wrapper.text()).toContain("Trace Error"); - expect(wrapper.text()).toContain("Trace failed"); - }); -}); diff --git a/tests/frontend/RNProbePage.test.js b/tests/frontend/RNProbePage.test.js deleted file mode 100644 index 571a769c2..000000000 --- a/tests/frontend/RNProbePage.test.js +++ /dev/null @@ -1,96 +0,0 @@ -import { mount } from "@vue/test-utils"; -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import RNProbePage from "@/components/rnprobe/RNProbePage.vue"; -import DialogUtils from "@/js/DialogUtils"; - -vi.mock("@/js/DialogUtils", () => ({ - default: { - alert: vi.fn(), - }, -})); - -describe("RNProbePage.vue", () => { - let axiosMock; - - beforeEach(() => { - axiosMock = { - post: vi.fn(), - isCancel: vi.fn().mockReturnValue(false), - }; - window.api = axiosMock; - }); - - afterEach(() => { - delete window.api; - vi.clearAllMocks(); - }); - - const mountRNProbePage = () => { - return mount(RNProbePage, { - global: { - mocks: { - $t: (key, params) => key + (params ? JSON.stringify(params) : ""), - }, - stubs: { - MaterialDesignIcon: { - template: '
', - props: ["iconName"], - }, - }, - }, - }); - }; - - it("renders the rnprobe page", () => { - const wrapper = mountRNProbePage(); - expect(wrapper.text()).toContain("rnprobe.title"); - }); - - it("calls probe API and displays results", async () => { - axiosMock.post.mockResolvedValue({ - data: { - sent: 1, - delivered: 1, - timeouts: 0, - failed: 0, - results: [ - { - probe_number: 1, - size: 16, - destination: "dest", - status: "delivered", - hops: 1, - rtt_string: "123ms", - reception_stats: { rssi: -50, snr: 5, quality: 100 }, - }, - ], - }, - }); - - const wrapper = mountRNProbePage(); - await wrapper.setData({ destinationHash: "a".repeat(32) }); - - await wrapper.find("button[class*='primary-chip']").trigger("click"); - - await vi.waitFor(() => expect(wrapper.vm.isRunning).toBe(false)); - - expect(axiosMock.post).toHaveBeenCalled(); - expect(wrapper.text()).toContain("rnprobe.summary"); - expect(wrapper.text()).toContain("rnprobe.delivered"); - expect(wrapper.text()).toContain("123ms"); - }); - - it("handles probe errors", async () => { - axiosMock.post.mockRejectedValue({ - response: { data: { message: "Probe failed" } }, - }); - - const wrapper = mountRNProbePage(); - await wrapper.setData({ destinationHash: "a".repeat(32) }); - - await wrapper.find("button[class*='primary-chip']").trigger("click"); - - await vi.waitFor(() => expect(wrapper.vm.isRunning).toBe(false)); - expect(DialogUtils.alert).toHaveBeenCalledWith("Probe failed"); - }); -}); diff --git a/tests/frontend/RNStatusPage.test.js b/tests/frontend/RNStatusPage.test.js index fa647a072..87fbe153f 100644 --- a/tests/frontend/RNStatusPage.test.js +++ b/tests/frontend/RNStatusPage.test.js @@ -1,9 +1,28 @@ -import { mount } from "@vue/test-utils"; +// SPDX-License-Identifier: 0BSD + import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import RNStatusPage from "@/components/rnstatus/RNStatusPage.vue"; -import ToastUtils from "@/js/ToastUtils"; -import WebSocketConnection from "@/js/WebSocketConnection"; -import { mountToolsPageGlobals } from "./testI18n.js"; +import { render, cleanup, fireEvent, waitFor, screen } from "@testing-library/svelte"; +import RNStatusPage from "@/features/rnstatus/RNStatusPage.svelte"; +import ToastUtils from "@/js/ToastUtils.js"; +import { registerFallbackMessages, registerTranslator } from "@/js/i18n.js"; +import { + buildInterfaceStatRows, + copyText, + extractQueueRows, + filterI2pInterfaces, + formatInt, + hasStatusSummary, + shortHash, +} from "@/features/rnstatus/lib/statusFormat.ts"; +import { + createManagementIdentity, + fetchManagementIdentities, + fetchRNStatus, +} from "@/features/rnstatus/lib/statusPoller.ts"; +import { registerRNStatusFeature } from "@/features/rnstatus/index.ts"; +import { clearRoutes, listRoutes } from "@/js/registries/routeRegistry.js"; +import { clearFeatureIds, listFeatureIds } from "@/js/registries/featureRegistry.js"; +import { dispatchWsEvent } from "@/js/registries/wsEventRegistry.ts"; vi.mock("@/js/ToastUtils", () => ({ default: { @@ -11,26 +30,175 @@ vi.mock("@/js/ToastUtils", () => ({ error: vi.fn(), warning: vi.fn(), info: vi.fn(), + loading: vi.fn(), dismiss: vi.fn(), }, })); -vi.mock("@/js/WebSocketConnection", () => ({ - default: { - on: vi.fn(), - off: vi.fn(), - }, -})); +describe("rnstatus lib helpers", () => { + it("formats integers and shortens hashes", () => { + expect(formatInt(1234567)).toBe((1234567).toLocaleString()); + expect(formatInt(null)).toBe(""); + expect(shortHash("0123456789abcdef0123456789abcdef")).toBe("01234567..."); + expect(shortHash("")).toBe(""); + }); + + it("checks status summary presence and filters I2P interfaces", () => { + expect(hasStatusSummary({ link_count: 5 })).toBe(true); + expect(hasStatusSummary({})).toBe(false); + + const ifaces = [ + { name: "if1", i2p_b32: "xyz.b32.i2p" }, + { name: "if2" }, + ]; + const filtered = filterI2pInterfaces(ifaces); + expect(filtered).toHaveLength(1); + expect(filtered[0].name).toBe("if1"); + }); + + it("extracts queue rows and builds stat rows", () => { + const queueRows = extractQueueRows({ + queues: [ + { name: "total", pressure: 0.1, packets: 5, dropped: 0 }, + ], + }); + expect(queueRows).toHaveLength(1); + expect(queueRows[0].key).toBe("total"); + + const statRows = buildInterfaceStatRows({ + name: "TestIF", + bitrate: "1000 bps", + rx_bytes_str: "50 B", + }); + expect(statRows.some((r) => r.key === "bitrate")).toBe(true); + }); + + it("fetches status and management identities via API", async () => { + const getMock = vi.fn().mockImplementation((url) => { + if (url === "/api/v1/rnstatus") { + return Promise.resolve({ data: { link_count: 3 } }); + } + if (url === "/api/v1/reticulum/management-identities") { + return Promise.resolve({ data: { identities: [{ name: "mgmt1" }] } }); + } + return Promise.resolve({ data: {} }); + }); + const postMock = vi.fn().mockResolvedValue({ + data: { identity: { name: "new-mgmt" } }, + }); + window.api = { get: getMock, post: postMock }; + + const status = await fetchRNStatus({ include_link_stats: true, show_all: false }); + expect(status.link_count).toBe(3); + + const list = await fetchManagementIdentities(); + expect(list).toHaveLength(1); -describe("RNStatusPage.vue", () => { + const created = await createManagementIdentity("new-mgmt"); + expect(created?.name).toBe("new-mgmt"); + + delete window.api; + }); + + it("copies text and shows toast", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.assign(navigator, { clipboard: { writeText } }); + + const ok = await copyText("copy-content"); + expect(ok).toBe(true); + expect(writeText).toHaveBeenCalledWith("copy-content"); + expect(ToastUtils.success).toHaveBeenCalled(); + }); +}); + +describe("registerRNStatusFeature", () => { + beforeEach(() => { + clearRoutes(); + clearFeatureIds(); + }); + + afterEach(() => { + clearRoutes(); + clearFeatureIds(); + }); + + it("registers rnstatus route correctly", () => { + registerRNStatusFeature(); + expect(listFeatureIds()).toContain("rnstatus"); + const route = listRoutes().find((r) => r.name === "rnstatus"); + expect(route).toBeTruthy(); + expect(route?.path).toBe("/rnstatus"); + expect(route?.mount).toBe("svelte"); + }); +}); + +describe("RNStatusPage.svelte", () => { let axiosMock; beforeEach(() => { + vi.clearAllMocks(); axiosMock = { get: vi.fn(), + post: vi.fn(), }; window.api = axiosMock; + registerTranslator(null); + registerFallbackMessages({ + app: { tools: "Tools" }, + rnprobe: { network_diagnostics: "Network Diagnostics" }, + tools: { + back_to_tools: "Back", + rnstatus: { + title: "RNStatus", + description: "Inspect Reticulum interfaces and telemetry", + }, + }, + common: { + copied: "Copied", + failed_to_copy: "Failed to copy", + copy: "Copy", + }, + rnstatus: { + refresh: "Refresh", + reloading: "Reloading...", + failed_refresh: "Failed to refresh RNStatus", + show_all_interfaces: "Show all interfaces", + all_interfaces: "All interfaces", + include_link_stats: "Include link stats", + sort_by: "Sort by", + none: "None", + bitrate: "Bitrate", + rx_bytes: "RX Bytes", + tx_bytes: "TX Bytes", + total_traffic: "Total Traffic", + announces: "Announces", + path_requests: "Path Requests", + held_announces: "Held Announces", + gravity: "Gravity", + active: "Active", + inactive: "Inactive", + active_links_label: "Active Links", + blackhole_heading: "Blackhole", + blackhole_publishing: "Publishing", + blackhole_inactive: "Inactive", + blackhole_identities: "{count} Identities", + blackhole_sources: "Blackhole Sources", + queue_total: "Total", + queue_data: "Data", + queue_announce: "Announce", + queue_path_request: "Path Request", + queue_ingress_limiter: "Ingress Limiter", + peers_reachable: "reachable", + mode: "Mode", + rx_speed: "RX Speed", + tx_speed: "TX Speed", + clients: "Clients", + peers: "Peers", + i2p_address: "I2P Address", + }, + }); + axiosMock.get.mockImplementation((url) => { if (url === "/api/v1/rnstatus") { return Promise.resolve({ @@ -71,33 +239,28 @@ describe("RNStatusPage.vue", () => { }); afterEach(() => { + cleanup(); delete window.api; - vi.clearAllMocks(); }); - const mountRNStatusPage = () => { - return mount(RNStatusPage, { - global: mountToolsPageGlobals(), - }); - }; + it("renders and loads status data on mount", async () => { + render(RNStatusPage); + expect(screen.getByText("RNStatus")).toBeTruthy(); + expect(screen.getByText("Network Diagnostics")).toBeTruthy(); - it("renders and loads status data", async () => { - const wrapper = mountRNStatusPage(); - await vi.waitFor(() => expect(wrapper.vm.isLoading).toBe(false)); + await waitFor(() => { + expect(axiosMock.get).toHaveBeenCalledWith( + "/api/v1/rnstatus", + expect.any(Object) + ); + }); - expect(wrapper.text()).toContain("RNStatus"); - expect(wrapper.text()).toContain("Network Diagnostics"); - expect(wrapper.text()).toContain("Interface 1"); - expect(wrapper.text()).toContain("Discovered"); - expect(wrapper.text()).toContain("5"); - expect(wrapper.text()).toContain("Publishing"); - expect(wrapper.text()).toContain("abc123.b32.i2p"); - expect(wrapper.vm.blackholeEnabled).toBe(true); - expect(wrapper.text()).toContain("src1"); - expect(typeof wrapper.vm.onWebsocketMessage).toBe("function"); + const ifaceElements = await screen.findAllByText("Interface 1"); + expect(ifaceElements.length).toBeGreaterThan(0); + expect(screen.getAllByText("abc123.b32.i2p").length).toBeGreaterThan(0); }); - it("labels disabled blackhole as Inactive", async () => { + it("displays inactive blackhole state when disabled", async () => { axiosMock.get.mockImplementation((url) => { if (url === "/api/v1/rnstatus") { return Promise.resolve({ @@ -115,34 +278,75 @@ describe("RNStatusPage.vue", () => { } return Promise.resolve({ data: {} }); }); - const wrapper = mountRNStatusPage(); - await vi.waitFor(() => expect(wrapper.vm.isLoading).toBe(false)); - expect(wrapper.text()).toContain("Inactive"); + + render(RNStatusPage); + await waitFor(() => { + expect(axiosMock.get).toHaveBeenCalled(); + }); + + expect(await screen.findByText("Inactive")).toBeTruthy(); }); - it("refreshes status when button is clicked", async () => { - const wrapper = mountRNStatusPage(); - await vi.waitFor(() => expect(wrapper.vm.isLoading).toBe(false)); + it("refreshes status when refresh button is clicked", async () => { + render(RNStatusPage); + await waitFor(() => { + expect(axiosMock.get).toHaveBeenCalledWith("/api/v1/rnstatus", expect.any(Object)); + }); - const refreshButton = wrapper.find("button"); - await refreshButton.trigger("click"); + const statusCallsBefore = axiosMock.get.mock.calls.filter( + (c) => c[0] === "/api/v1/rnstatus" + ).length; + const refreshBtn = screen.getByText("Refresh"); + await fireEvent.click(refreshBtn); - expect(axiosMock.get).toHaveBeenCalled(); + await waitFor(() => { + const statusCallsAfter = axiosMock.get.mock.calls.filter( + (c) => c[0] === "/api/v1/rnstatus" + ).length; + expect(statusCallsAfter).toBe(statusCallsBefore + 1); + }); }); - it("toggles link stats", async () => { - const wrapper = mountRNStatusPage(); - await vi.waitFor(() => expect(wrapper.vm.isLoading).toBe(false)); + it("toggles link stats checkbox", async () => { + render(RNStatusPage); + await waitFor(() => { + expect(axiosMock.get).toHaveBeenCalled(); + }); - const checkbox = wrapper.find("input[type='checkbox']"); - await checkbox.setValue(true); + const linkStatsCheckbox = screen.getByText("Include link stats").closest("label")?.querySelector("input"); + if (linkStatsCheckbox) { + await fireEvent.click(linkStatsCheckbox); + } - expect(axiosMock.get).toHaveBeenCalledWith( - "/api/v1/rnstatus", - expect.objectContaining({ - params: expect.objectContaining({ include_link_stats: true }), - }) - ); + await waitFor(() => { + expect(axiosMock.get).toHaveBeenCalledWith( + "/api/v1/rnstatus", + expect.objectContaining({ + params: expect.objectContaining({ include_link_stats: true }), + }) + ); + }); + }); + + it("toggles all-interfaces checkbox", async () => { + render(RNStatusPage); + await waitFor(() => { + expect(axiosMock.get).toHaveBeenCalled(); + }); + + const allIfacesCheckbox = screen.getByText("Show all interfaces").closest("label")?.querySelector("input"); + if (allIfacesCheckbox) { + await fireEvent.click(allIfacesCheckbox); + } + + await waitFor(() => { + expect(axiosMock.get).toHaveBeenCalledWith( + "/api/v1/rnstatus", + expect.objectContaining({ + params: expect.objectContaining({ show_all: true }), + }) + ); + }); }); it("toasts on refresh failure", async () => { @@ -157,53 +361,40 @@ describe("RNStatusPage.vue", () => { } return Promise.resolve({ data: {} }); }); - const wrapper = mountRNStatusPage(); - await vi.waitFor(() => expect(ToastUtils.error).toHaveBeenCalled()); + + render(RNStatusPage); + + await waitFor(() => { + expect(ToastUtils.error).toHaveBeenCalled(); + }); + const msg = ToastUtils.error.mock.calls[0][0]; expect(msg).toContain("Failed to refresh RNStatus"); expect(msg).toContain("RNS stack is reloading"); }); - it("copies the I2P address", async () => { - const writeText = vi.fn().mockResolvedValue(); - Object.assign(navigator, { clipboard: { writeText } }); - const wrapper = mountRNStatusPage(); - await vi.waitFor(() => expect(wrapper.vm.isLoading).toBe(false)); - await wrapper.vm.copyText("abc123.b32.i2p"); - expect(writeText).toHaveBeenCalledWith("abc123.b32.i2p"); - expect(ToastUtils.success).toHaveBeenCalled(); - }); - - it("sends show_all when the all-interfaces toggle is on", async () => { - const wrapper = mountRNStatusPage(); - await vi.waitFor(() => expect(wrapper.vm.isLoading).toBe(false)); - wrapper.vm.showAll = true; - await wrapper.vm.$nextTick(); - await vi.waitFor(() => - expect(axiosMock.get).toHaveBeenCalledWith( - "/api/v1/rnstatus", - expect.objectContaining({ - params: expect.objectContaining({ show_all: true }), - }) - ) - ); - }); + it("handles websocket reload status to disable refresh", async () => { + render(RNStatusPage); + await waitFor(() => { + expect(axiosMock.get).toHaveBeenCalled(); + }); - it("disables refresh while RNS reload is in progress", async () => { - const wrapper = mountRNStatusPage(); - await vi.waitFor(() => expect(wrapper.vm.isLoading).toBe(false)); const callsBefore = axiosMock.get.mock.calls.length; - wrapper.vm.onWebsocketMessage({ + await dispatchWsEvent("reticulum_reload_status", { type: "reticulum_reload_status", in_progress: true, message: "Reloading", }); - await wrapper.vm.$nextTick(); - expect(wrapper.vm.reloadingRns).toBe(true); - expect(wrapper.find("button").attributes("disabled")).toBeDefined(); - await wrapper.vm.refreshStatus(); + await waitFor(() => { + expect(screen.getByText("Reloading...")).toBeTruthy(); + }); + + const btn = screen.getByRole("button", { name: /Reloading.../i }); + expect(btn.hasAttribute("disabled")).toBe(true); + + await fireEvent.click(btn); expect(axiosMock.get.mock.calls.length).toBe(callsBefore); }); }); diff --git a/tests/frontend/ReticulumConfigEditorPage.test.js b/tests/frontend/ReticulumConfigEditorPage.test.js index 72e9c6055..f9c65ffd6 100644 --- a/tests/frontend/ReticulumConfigEditorPage.test.js +++ b/tests/frontend/ReticulumConfigEditorPage.test.js @@ -1,8 +1,25 @@ -import { mount, flushPromises } from "@vue/test-utils"; +// SPDX-License-Identifier: 0BSD + import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import ReticulumConfigEditorPage from "@/components/tools/ReticulumConfigEditorPage.vue"; -import DialogUtils from "@/js/DialogUtils"; -import GlobalState from "@/js/GlobalState"; +import { render, cleanup, fireEvent, waitFor, screen } from "@testing-library/svelte"; +import ReticulumConfigEditorPage from "@/features/reticulum-config-editor/ReticulumConfigEditorPage.svelte"; +import DialogUtils from "@/js/DialogUtils.js"; +import GlobalState from "@/js/GlobalState.js"; +import { registerFallbackMessages, registerTranslator } from "@/js/i18n.js"; +import { + extractErrorMessage, + insertTabAtSelection, + isConfigDirty, + shouldShowRestartReminder, +} from "@/features/reticulum-config-editor/lib/configFormat.ts"; +import { + RETICULUM_CONFIG_RAW_ENDPOINT, + RETICULUM_CONFIG_RESET_ENDPOINT, + RETICULUM_RELOAD_ENDPOINT, +} from "@/features/reticulum-config-editor/lib/constants.ts"; +import { registerReticulumConfigEditorFeature } from "@/features/reticulum-config-editor/index.ts"; +import { clearRoutes, listRoutes } from "@/js/registries/routeRegistry.js"; +import { clearFeatureIds, listFeatureIds } from "@/js/registries/featureRegistry.js"; vi.mock("@/js/DialogUtils", () => ({ default: { @@ -12,16 +29,79 @@ vi.mock("@/js/DialogUtils", () => ({ }, })); +vi.mock("@/js/ToastUtils", () => ({ + default: { + success: vi.fn(), + error: vi.fn(), + warning: vi.fn(), + info: vi.fn(), + loading: vi.fn(), + dismiss: vi.fn(), + }, +})); + const SAMPLE_CONFIG = "[reticulum]\n enable_transport = False\n\n[interfaces]\n [[Default Interface]]\n type = AutoInterface\n"; const DEFAULT_CONFIG = "[reticulum]\n enable_transport = False\n\n[interfaces]\n [[Default Interface]]\n type = AutoInterface\n enabled = true\n"; const CONFIG_PATH = "/tmp/.reticulum/config"; -describe("ReticulumConfigEditorPage.vue", () => { +describe("reticulum-config-editor lib helpers", () => { + it("inserts tab at selection position", () => { + const text = "helloworld"; + const result = insertTabAtSelection(text, 5, 5, " "); + expect(result.content).toBe("hello world"); + expect(result.newCursor).toBe(7); + }); + + it("checks dirty state accurately", () => { + expect(isConfigDirty("abc", "abc")).toBe(false); + expect(isConfigDirty("abc", "abd")).toBe(true); + }); + + it("evaluates restart reminder condition", () => { + expect(shouldShowRestartReminder(false, false)).toBe(false); + expect(shouldShowRestartReminder(true, false)).toBe(true); + expect(shouldShowRestartReminder(false, true)).toBe(true); + expect(shouldShowRestartReminder(true, true)).toBe(true); + }); + + it("extracts error message from various error formats", () => { + expect(extractErrorMessage({ response: { data: { error: "Custom err" } } }, "fallback")).toBe( + "Custom err" + ); + expect(extractErrorMessage({ message: "Network fail" }, "fallback")).toBe("Network fail"); + expect(extractErrorMessage("String error", "fallback")).toBe("String error"); + expect(extractErrorMessage(null, "fallback")).toBe("fallback"); + }); +}); + +describe("registerReticulumConfigEditorFeature", () => { + beforeEach(() => { + clearRoutes(); + clearFeatureIds(); + }); + + afterEach(() => { + clearRoutes(); + clearFeatureIds(); + }); + + it("registers reticulum-config-editor route correctly", () => { + registerReticulumConfigEditorFeature(); + expect(listFeatureIds()).toContain("reticulum-config-editor"); + const route = listRoutes().find((r) => r.name === "reticulum-config-editor"); + expect(route).toBeTruthy(); + expect(route?.path).toBe("/tools/reticulum-config-editor"); + expect(route?.mount).toBe("svelte"); + }); +}); + +describe("ReticulumConfigEditorPage.svelte", () => { let axiosMock; beforeEach(() => { + vi.clearAllMocks(); axiosMock = { get: vi.fn(), put: vi.fn(), @@ -29,6 +109,41 @@ describe("ReticulumConfigEditorPage.vue", () => { }; window.api = axiosMock; + registerTranslator(null); + registerFallbackMessages({ + app: { + tools: "Tools", + reloading_rns: "Reloading RNS...", + }, + tools: { + back_to_tools: "Back", + reticulum_config_editor: { + title: "Reticulum Config Editor", + description: "Edit raw reticulum configuration file", + reload: "Reload", + restore_defaults: "Restore Defaults", + discard: "Discard Changes", + save: "Save", + saving: "Saving...", + saved: "Configuration saved", + restart_required: "Restart Required", + restart_description: "Restart RNS to apply interface changes", + restart_now: "Restart Now", + restart_done: "Restart completed", + info: "Direct file editing", + unsaved: "Unsaved Changes", + loading: "Loading config...", + failed_load: "Failed to load config", + failed_save: "Failed to save config", + failed_restore: "Failed to restore defaults", + failed_restart: "Failed to restart RNS", + confirm_restore: "Are you sure you want to restore defaults?", + restoring: "Restoring...", + restored: "Restored to defaults", + }, + }, + }); + axiosMock.get.mockResolvedValue({ data: { content: SAMPLE_CONFIG, path: CONFIG_PATH }, }); @@ -36,7 +151,7 @@ describe("ReticulumConfigEditorPage.vue", () => { data: { message: "Reticulum config saved", path: CONFIG_PATH }, }); axiosMock.post.mockImplementation((url) => { - if (url === "/api/v1/reticulum/config/reset") { + if (url === RETICULUM_CONFIG_RESET_ENDPOINT) { return Promise.resolve({ data: { message: "Reticulum config restored to defaults", @@ -45,7 +160,7 @@ describe("ReticulumConfigEditorPage.vue", () => { }, }); } - if (url === "/api/v1/reticulum/reload") { + if (url === RETICULUM_RELOAD_ENDPOINT) { return Promise.resolve({ data: { message: "Reticulum reloaded successfully" }, }); @@ -61,135 +176,120 @@ describe("ReticulumConfigEditorPage.vue", () => { }); afterEach(() => { + cleanup(); delete window.api; - vi.clearAllMocks(); }); - const mountPage = () => { - return mount(ReticulumConfigEditorPage, { - global: { - mocks: { - $t: (key) => key, - }, - stubs: { - MaterialDesignIcon: { - template: '
', - props: ["iconName"], - }, - }, - }, + it("loads current config on mount and displays path", async () => { + render(ReticulumConfigEditorPage); + expect(screen.getByText("Reticulum Config Editor")).toBeTruthy(); + + await waitFor(() => { + expect(axiosMock.get).toHaveBeenCalledWith(RETICULUM_CONFIG_RAW_ENDPOINT); }); - }; - - it("loads the current config on mount and shows the path", async () => { - const wrapper = mountPage(); - await flushPromises(); - - expect(axiosMock.get).toHaveBeenCalledWith("/api/v1/reticulum/config/raw"); - expect(wrapper.vm.content).toBe(SAMPLE_CONFIG); - expect(wrapper.vm.originalContent).toBe(SAMPLE_CONFIG); - expect(wrapper.text()).toContain(CONFIG_PATH); - expect(wrapper.text()).toContain("tools.reticulum_config_editor.title"); - const editorShell = wrapper.find(".relative.flex-1"); - expect(editorShell.exists()).toBe(true); - expect(wrapper.find("textarea").classes()).toContain("absolute"); + + expect(await screen.findByText(CONFIG_PATH)).toBeTruthy(); + const textarea = screen.getByRole("textbox"); + expect(textarea.value).toBe(SAMPLE_CONFIG); }); - it("marks the editor as dirty when the textarea changes", async () => { - const wrapper = mountPage(); - await flushPromises(); - expect(wrapper.vm.isDirty).toBe(false); + it("marks the editor as dirty and saves config", async () => { + render(ReticulumConfigEditorPage); + await waitFor(() => { + expect(axiosMock.get).toHaveBeenCalledWith(RETICULUM_CONFIG_RAW_ENDPOINT); + }); - const textarea = wrapper.find("textarea"); - await textarea.setValue(SAMPLE_CONFIG + "\n# my edit\n"); + const textarea = screen.getByRole("textbox"); + const newContent = SAMPLE_CONFIG + "\n# my edit\n"; + await fireEvent.input(textarea, { target: { value: newContent } }); - expect(wrapper.vm.isDirty).toBe(true); - expect(wrapper.text()).toContain("tools.reticulum_config_editor.unsaved"); - }); + expect(screen.getByText("Unsaved Changes")).toBeTruthy(); - it("saves config and shows the restart banner after success", async () => { - const wrapper = mountPage(); - await flushPromises(); + const saveButton = screen.getByText("Save"); + await fireEvent.click(saveButton); - const newContent = SAMPLE_CONFIG + "\n# edit\n"; - await wrapper.find("textarea").setValue(newContent); - await wrapper.vm.saveConfig(); - await flushPromises(); + await waitFor(() => { + expect(axiosMock.put).toHaveBeenCalledWith(RETICULUM_CONFIG_RAW_ENDPOINT, { + content: newContent, + }); + }); - expect(axiosMock.put).toHaveBeenCalledWith("/api/v1/reticulum/config/raw", { content: newContent }); - expect(wrapper.vm.hasSavedChanges).toBe(true); - expect(wrapper.vm.showRestartReminder).toBe(true); expect(GlobalState.hasPendingInterfaceChanges).toBe(true); - expect(wrapper.text()).toContain("tools.reticulum_config_editor.restart_required"); - }); - - it("does not save when the editor is not dirty", async () => { - const wrapper = mountPage(); - await flushPromises(); - await wrapper.vm.saveConfig(); - expect(axiosMock.put).not.toHaveBeenCalled(); + expect(await screen.findByText("Restart Required")).toBeTruthy(); }); it("restores defaults after confirmation", async () => { DialogUtils.confirm.mockResolvedValue(true); - const wrapper = mountPage(); - await flushPromises(); + render(ReticulumConfigEditorPage); + await waitFor(() => { + expect(axiosMock.get).toHaveBeenCalledWith(RETICULUM_CONFIG_RAW_ENDPOINT); + }); - await wrapper.vm.restoreDefaults(); - await flushPromises(); + const restoreBtn = screen.getByText("Restore Defaults"); + await fireEvent.click(restoreBtn); - expect(DialogUtils.confirm).toHaveBeenCalled(); - expect(axiosMock.post).toHaveBeenCalledWith("/api/v1/reticulum/config/reset"); - expect(wrapper.vm.content).toBe(DEFAULT_CONFIG); - expect(wrapper.vm.originalContent).toBe(DEFAULT_CONFIG); - expect(wrapper.vm.hasSavedChanges).toBe(true); + await waitFor(() => { + expect(DialogUtils.confirm).toHaveBeenCalled(); + expect(axiosMock.post).toHaveBeenCalledWith(RETICULUM_CONFIG_RESET_ENDPOINT); + }); + + const textarea = screen.getByRole("textbox"); + expect(textarea.value).toBe(DEFAULT_CONFIG); expect(GlobalState.hasPendingInterfaceChanges).toBe(true); }); - it("does not restore defaults if the user cancels", async () => { + it("does not restore defaults if canceled", async () => { DialogUtils.confirm.mockResolvedValue(false); - const wrapper = mountPage(); - await flushPromises(); + render(ReticulumConfigEditorPage); + await waitFor(() => { + expect(axiosMock.get).toHaveBeenCalledWith(RETICULUM_CONFIG_RAW_ENDPOINT); + }); - await wrapper.vm.restoreDefaults(); - await flushPromises(); + const restoreBtn = screen.getByText("Restore Defaults"); + await fireEvent.click(restoreBtn); - expect(axiosMock.post).not.toHaveBeenCalledWith("/api/v1/reticulum/config/reset"); - expect(wrapper.vm.content).toBe(SAMPLE_CONFIG); + expect(DialogUtils.confirm).toHaveBeenCalled(); + expect(axiosMock.post).not.toHaveBeenCalledWith(RETICULUM_CONFIG_RESET_ENDPOINT); }); - it("reloads RNS and clears the restart banner", async () => { - const wrapper = mountPage(); - await flushPromises(); - wrapper.vm.hasSavedChanges = true; - await wrapper.vm.$nextTick(); + it("reloads RNS and clears restart banner", async () => { + render(ReticulumConfigEditorPage); + await waitFor(() => { + expect(axiosMock.get).toHaveBeenCalledWith(RETICULUM_CONFIG_RAW_ENDPOINT); + }); - await wrapper.vm.reloadRns(); - await flushPromises(); + const textarea = screen.getByRole("textbox"); + await fireEvent.input(textarea, { target: { value: SAMPLE_CONFIG + "\n# edit" } }); + const saveButton = screen.getByText("Save"); + await fireEvent.click(saveButton); - expect(axiosMock.post).toHaveBeenCalledWith("/api/v1/reticulum/reload"); - expect(wrapper.vm.hasSavedChanges).toBe(false); + await waitFor(() => { + expect(screen.getByText("Restart Now")).toBeTruthy(); + }); + + const restartBtn = screen.getByText("Restart Now"); + await fireEvent.click(restartBtn); + + await waitFor(() => { + expect(axiosMock.post).toHaveBeenCalledWith(RETICULUM_RELOAD_ENDPOINT); + }); expect(GlobalState.hasPendingInterfaceChanges).toBe(false); }); - it("discards unsaved changes back to the original content", async () => { - const wrapper = mountPage(); - await flushPromises(); + it("discards unsaved changes back to original content", async () => { + render(ReticulumConfigEditorPage); + await waitFor(() => { + expect(axiosMock.get).toHaveBeenCalledWith(RETICULUM_CONFIG_RAW_ENDPOINT); + }); - await wrapper.find("textarea").setValue("[reticulum]\n[interfaces]\n# changed"); - expect(wrapper.vm.isDirty).toBe(true); + const textarea = screen.getByRole("textbox"); + await fireEvent.input(textarea, { target: { value: "# altered" } }); + expect(screen.getByText("Unsaved Changes")).toBeTruthy(); - wrapper.vm.discardChanges(); - expect(wrapper.vm.content).toBe(SAMPLE_CONFIG); - expect(wrapper.vm.isDirty).toBe(false); - }); + const discardBtn = screen.getByText("Discard Changes"); + await fireEvent.click(discardBtn); - it("shows an error toast when load fails", async () => { - axiosMock.get.mockRejectedValueOnce({ - response: { data: { error: "boom" } }, - }); - const wrapper = mountPage(); - await flushPromises(); - expect(wrapper.vm.content).toBe(""); + expect(textarea.value).toBe(SAMPLE_CONFIG); + expect(screen.queryByText("Unsaved Changes")).toBeNull(); }); }); diff --git a/tests/frontend/SieveFiltersPage.test.js b/tests/frontend/SieveFiltersPage.test.js index 959b21cbf..9c2272a87 100644 --- a/tests/frontend/SieveFiltersPage.test.js +++ b/tests/frontend/SieveFiltersPage.test.js @@ -1,27 +1,211 @@ -import { mount } from "@vue/test-utils"; -import { describe, it, expect, vi, beforeEach } from "vitest"; -import SieveFiltersPage from "@/components/tools/SieveFiltersPage.vue"; -import { createRouter, createWebHistory } from "vue-router"; -import ToastUtils from "../../meshchatx/src/frontend/js/ToastUtils"; +// SPDX-License-Identifier: 0BSD -vi.mock("../../meshchatx/src/frontend/js/ToastUtils", () => ({ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, cleanup, fireEvent, waitFor, screen } from "@testing-library/svelte"; +import SieveFiltersPage from "@/features/sieve-filters/SieveFiltersPage.svelte"; +import ToastUtils from "@/js/ToastUtils.js"; +import { registerFallbackMessages, registerTranslator } from "@/js/i18n.js"; +import { + createDefaultRule, + ensureValidAction, + ensureValidMatchTargets, + formatTermsText, + mapRuleFromApi, + newRuleId, + normalizeFiltersForSave, + normalizeRuleForSave, + parseTermsInput, + reorderRules, +} from "@/features/sieve-filters/lib/sieveRules.ts"; +import { registerSieveFiltersFeature } from "@/features/sieve-filters/index.ts"; +import { clearRoutes, listRoutes } from "@/js/registries/routeRegistry.js"; +import { clearFeatureIds, listFeatureIds } from "@/js/registries/featureRegistry.js"; + +vi.mock("@/js/ToastUtils", () => ({ default: { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn(), + loading: vi.fn(), + dismiss: vi.fn(), }, })); -describe("SieveFiltersPage.vue", () => { - const router = createRouter({ - history: createWebHistory(), - routes: [{ path: "/tools", name: "tools", component: { template: "
" } }], +describe("sieve-filters lib helpers", () => { + it("generates unique rule id and default rule", () => { + const id = newRuleId(); + expect(typeof id).toBe("string"); + expect(id.length).toBeGreaterThan(0); + + const folders = [{ id: 10, name: "Spam" }]; + const rule = createDefaultRule(folders); + expect(rule.enabled).toBe(true); + expect(rule.scope).toBe("everyone"); + expect(rule.action).toBe("ignore"); + expect(rule.folder_id).toBe(10); + expect(rule.match_peer_fields).toBe(true); + expect(rule.match_message).toBe(false); + }); + + it("maps raw api payload correctly and converts block to hide", () => { + const raw = { action: "block", terms: ["spam", "ad"], scope: "contacts" }; + const mapped = mapRuleFromApi(raw); + expect(mapped.action).toBe("hide"); + expect(mapped.terms).toEqual(["spam", "ad"]); + expect(mapped.scope).toBe("contacts"); + }); + + it("normalizes rules for save payload", () => { + const rule = { + id: "r1", + enabled: true, + scope: "non_contacts", + terms: ["xyz"], + action: "hide", + folder_id: 5, + match_peer_fields: false, + match_message: false, + match_mode: "substring", + }; + const normalized = normalizeRuleForSave(rule); + expect(normalized.match_peer_fields).toBe(true); + expect(normalized.folder_id).toBeNull(); + + const batch = normalizeFiltersForSave([rule]); + expect(batch).toHaveLength(1); + }); + + it("parses and formats terms input", () => { + expect(parseTermsInput("a, b\nc\n\n")).toEqual(["a", "b", "c"]); + expect(formatTermsText(["foo", "bar"])).toBe("foo\nbar"); + }); + + it("reorders rules by delta", () => { + const r1 = createDefaultRule(); + const r2 = createDefaultRule(); + const list = [r1, r2]; + + const moved = reorderRules(list, 0, 1); + expect(moved[0].id).toBe(r2.id); + expect(moved[1].id).toBe(r1.id); + + const outOfBounds = reorderRules(list, 0, -1); + expect(outOfBounds).toBe(list); + }); + + it("ensures valid action and match targets", () => { + const folders = [{ id: 42, name: "Archive" }]; + const rule = createDefaultRule(); + rule.action = "folder"; + rule.folder_id = null; + ensureValidAction(rule, folders); + expect(rule.folder_id).toBe(42); + + rule.action = "hide"; + ensureValidAction(rule, folders); + expect(rule.folder_id).toBeNull(); + + rule.match_peer_fields = false; + rule.match_message = false; + ensureValidMatchTargets(rule); + expect(rule.match_peer_fields).toBe(true); }); +}); + +describe("registerSieveFiltersFeature", () => { + beforeEach(() => { + clearRoutes(); + clearFeatureIds(); + }); + + afterEach(() => { + clearRoutes(); + clearFeatureIds(); + }); + + it("registers sieve-filters route correctly", () => { + registerSieveFiltersFeature(); + expect(listFeatureIds()).toContain("sieve-filters"); + const route = listRoutes().find((r) => r.name === "sieve-filters"); + expect(route).toBeTruthy(); + expect(route?.path).toBe("/tools/sieve-filters"); + expect(route?.mount).toBe("svelte"); + }); +}); + +describe("SieveFiltersPage.svelte", () => { + let axiosMock; beforeEach(() => { vi.clearAllMocks(); - global.api.get = vi.fn((url) => { + axiosMock = { + get: vi.fn(), + put: vi.fn(), + post: vi.fn(), + }; + window.api = axiosMock; + + registerTranslator(null); + registerFallbackMessages({ + app: { tools: "Tools" }, + tools: { + back_to_tools: "Back", + sieve_filters: { + title: "Sieve Filters", + description: "Rule-based message filtering", + rules_heading: "Filter Rules", + add_rule: "Add Rule", + order_hint: "Rules evaluated in order", + empty_rules: "No rules configured", + save: "Save", + saving: "Saving...", + saved: "Filters saved", + revert: "Revert", + load_failed: "Failed to load filters", + save_failed: "Failed to save filters", + flow_heading: "Execution Flow", + flow_source: "Inbound LXMF Message", + flow_source_hint: "Evaluated top to bottom", + flow_if: "Rule", + flow_hide: "Hide Message", + action_ignore: "Ignore Message", + flow_banish: "Banish Sender", + flow_folder: "Move to Folder", + flow_no_rules: "No Rules Configured", + graph_scope_everyone: "Everyone", + graph_scope_contacts: "Contacts Only", + graph_scope_non_contacts: "Non-contacts Only", + graph_match_peer: "Sender Info", + graph_match_message: "Message Body", + graph_match_mode_substring: "Substring", + graph_match_mode_regex: "Regex", + terms_label: "Match Terms", + terms_placeholder: "Enter terms separated by commas or newlines", + rule_title: "Rule #{index}", + action_label: "Action", + action_hide: "Hide", + action_folder: "Move to folder", + action_banish: "Banish", + match_in_label: "Match Targets", + match_peer_fields: "Sender info", + match_message: "Message content", + scope_label: "Scope", + scope_everyone: "Everyone", + scope_contacts: "Contacts only", + scope_non_contacts: "Non-contacts only", + match_mode_label: "Mode", + match_mode_substring: "Substring", + match_mode_regex: "Regex", + folder_label: "Folder", + remove_rule: "Remove Rule", + move_up: "Move Up", + move_down: "Move Down", + }, + }, + }); + + axiosMock.get.mockImplementation((url) => { if (url.includes("sieve-filters")) { return Promise.resolve({ data: { @@ -29,9 +213,13 @@ describe("SieveFiltersPage.vue", () => { { id: "r1", enabled: true, + scope: "everyone", terms: ["spam"], action: "ignore", folder_id: null, + match_peer_fields: true, + match_message: false, + match_mode: "substring", }, ], }, @@ -42,120 +230,98 @@ describe("SieveFiltersPage.vue", () => { } return Promise.resolve({ data: {} }); }); - global.api.put = vi.fn(() => - Promise.resolve({ - data: { - filters: [ - { - id: "r1", - enabled: true, - terms: ["spam"], - action: "ignore", - folder_id: null, - }, - ], - }, - }) - ); - window.api = global.api; + + axiosMock.put.mockResolvedValue({ + data: { + filters: [ + { + id: "r1", + enabled: true, + scope: "everyone", + terms: ["spam"], + action: "ignore", + folder_id: null, + match_peer_fields: true, + match_message: false, + match_mode: "substring", + }, + ], + }, + }); + }); + + afterEach(() => { + cleanup(); + delete window.api; }); it("loads filters and folders from the API", async () => { - const wrapper = mount(SieveFiltersPage, { - global: { - plugins: [router], - mocks: { $t: (k) => k }, - stubs: { - MaterialDesignIcon: { template: "", props: ["iconName"] }, - SieveFlowNetwork: { template: "
" }, - RouterLink: { template: "", props: ["to"] }, - }, - }, + render(SieveFiltersPage); + expect(screen.getByText("Sieve Filters")).toBeTruthy(); + + await waitFor(() => { + expect(axiosMock.get).toHaveBeenCalledWith("/api/v1/lxmf/sieve-filters"); + expect(axiosMock.get).toHaveBeenCalledWith("/api/v1/lxmf/folders"); }); - await Promise.resolve(); - await wrapper.vm.$nextTick(); - await Promise.resolve(); - expect(global.api.get).toHaveBeenCalled(); - expect(wrapper.vm.filters.length).toBe(1); - expect(wrapper.vm.filters[0].terms).toEqual(["spam"]); - expect(wrapper.vm.folders.length).toBe(1); + + expect(await screen.findByDisplayValue("spam")).toBeTruthy(); }); - it("saves filters via PUT", async () => { - const wrapper = mount(SieveFiltersPage, { - global: { - plugins: [router], - mocks: { $t: (k) => k }, - stubs: { - MaterialDesignIcon: { template: "", props: ["iconName"] }, - SieveFlowNetwork: { template: "
" }, - RouterLink: { template: "", props: ["to"] }, - }, - }, + it("saves filters via PUT when save button is clicked", async () => { + render(SieveFiltersPage); + await waitFor(() => { + expect(axiosMock.get).toHaveBeenCalledWith("/api/v1/lxmf/sieve-filters"); }); - await Promise.resolve(); - await wrapper.vm.$nextTick(); - await Promise.resolve(); - await wrapper.vm.save(); - expect(global.api.put).toHaveBeenCalledWith( - "/api/v1/lxmf/sieve-filters", - expect.objectContaining({ - filters: expect.arrayContaining([ - expect.objectContaining({ - match_peer_fields: true, - match_message: false, - match_mode: "substring", - }), - ]), - }) - ); - expect(ToastUtils.success).toHaveBeenCalled(); + + const saveButton = screen.getByText("Save"); + await fireEvent.click(saveButton); + + await waitFor(() => { + expect(axiosMock.put).toHaveBeenCalledWith( + "/api/v1/lxmf/sieve-filters", + expect.objectContaining({ + filters: expect.arrayContaining([ + expect.objectContaining({ + terms: ["spam"], + match_peer_fields: true, + match_message: false, + match_mode: "substring", + }), + ]), + }) + ); + }); + expect(ToastUtils.success).toHaveBeenCalledWith("Filters saved"); }); - it("adds a rule and reorders first-match priority", async () => { - const wrapper = mount(SieveFiltersPage, { - global: { - plugins: [router], - mocks: { $t: (k) => k }, - stubs: { - MaterialDesignIcon: { template: "", props: ["iconName"] }, - SieveFlowNetwork: { template: "
" }, - RouterLink: { template: "", props: ["to"] }, - }, - }, + it("adds a rule on button click", async () => { + render(SieveFiltersPage); + await waitFor(() => { + expect(axiosMock.get).toHaveBeenCalledWith("/api/v1/lxmf/sieve-filters"); }); - await Promise.resolve(); - await wrapper.vm.$nextTick(); - await Promise.resolve(); - expect(wrapper.vm.filters.length).toBe(1); - wrapper.vm.addRule(); - expect(wrapper.vm.filters.length).toBe(2); - wrapper.vm.filters[1].terms = ["second"]; - wrapper.vm.moveRule(1, -1); - expect(wrapper.vm.filters[0].terms).toEqual(["second"]); - expect(wrapper.vm.mapRuleFromApi({ action: "block", terms: ["x"] }).action).toBe("hide"); - const bothOff = { match_peer_fields: false, match_message: false }; - wrapper.vm.onMatchTargetsChange(bothOff); - expect(bothOff.match_peer_fields).toBe(true); + + const addButton = screen.getByText("Add Rule"); + await fireEvent.click(addButton); + + const ruleCards = screen.getAllByPlaceholderText("Enter terms separated by commas or newlines"); + expect(ruleCards.length).toBe(2); }); it("toasts API errors on save", async () => { - global.api.put = vi.fn(() => Promise.reject({ response: { data: { message: "Unknown folder_id 9" } } })); - const wrapper = mount(SieveFiltersPage, { - global: { - plugins: [router], - mocks: { $t: (k) => k }, - stubs: { - MaterialDesignIcon: { template: "", props: ["iconName"] }, - SieveFlowNetwork: { template: "
" }, - RouterLink: { template: "", props: ["to"] }, - }, - }, + axiosMock.put.mockRejectedValue({ + response: { data: { message: "Unknown folder_id 9" } }, + }); + + render(SieveFiltersPage); + await waitFor(() => { + expect(axiosMock.get).toHaveBeenCalledWith("/api/v1/lxmf/sieve-filters"); + }); + + const saveButton = screen.getByText("Save"); + await fireEvent.click(saveButton); + + await waitFor(() => { + expect(ToastUtils.error).toHaveBeenCalledWith("Unknown folder_id 9"); }); - await Promise.resolve(); - await wrapper.vm.$nextTick(); - await Promise.resolve(); - await wrapper.vm.save(); - expect(ToastUtils.error).toHaveBeenCalledWith("Unknown folder_id 9"); }); }); diff --git a/tests/frontend/TranslatorPage.test.js b/tests/frontend/TranslatorPage.test.js index a18a45729..3b984375e 100644 --- a/tests/frontend/TranslatorPage.test.js +++ b/tests/frontend/TranslatorPage.test.js @@ -1,18 +1,195 @@ -import { mount } from "@vue/test-utils"; +// SPDX-License-Identifier: 0BSD + import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import TranslatorPage from "@/components/translator/TranslatorPage.vue"; -import { mountToolsPageGlobals } from "./testI18n.js"; +import { render, cleanup, fireEvent, waitFor, screen } from "@testing-library/svelte"; +import TranslatorPage from "@/features/translator/TranslatorPage.svelte"; +import ToastUtils from "@/js/ToastUtils.js"; +import { registerFallbackMessages, registerTranslator } from "@/js/i18n.js"; +import { + canTranslate, + computeSwappedLanguages, + computeSyncedMode, + filterLanguagesByMode, + hasArgosLanguages, +} from "@/features/translator/lib/translatorEngine.ts"; +import { registerTranslatorFeature } from "@/features/translator/index.ts"; +import { clearRoutes, listRoutes } from "@/js/registries/routeRegistry.js"; +import { clearFeatureIds, listFeatureIds } from "@/js/registries/featureRegistry.js"; + +vi.mock("@/js/ToastUtils", () => ({ + default: { + success: vi.fn(), + error: vi.fn(), + warning: vi.fn(), + info: vi.fn(), + loading: vi.fn(), + dismiss: vi.fn(), + }, +})); + +describe("translator lib helpers", () => { + it("filters languages by translation mode", () => { + const list = [ + { code: "en", name: "English", source: "argos" }, + { code: "de", name: "German", source: "libretranslate" }, + ]; + expect(filterLanguagesByMode(list, "argos")).toHaveLength(1); + expect(filterLanguagesByMode(list, "argos")[0].code).toBe("en"); + expect(filterLanguagesByMode(list, "libretranslate")).toHaveLength(1); + expect(filterLanguagesByMode(list, "libretranslate")[0].code).toBe("de"); + }); + + it("checks for argos language packages", () => { + expect(hasArgosLanguages([{ code: "en", name: "English", source: "argos" }])).toBe(true); + expect(hasArgosLanguages([{ code: "de", name: "German", source: "libretranslate" }])).toBe(false); + }); + + it("evaluates canTranslate conditions", () => { + const cfg = { translator_argos_enabled: true, translator_libretranslate_enabled: false }; + expect( + canTranslate({ + config: cfg, + mode: "argos", + inputText: "hello", + sourceLang: "en", + targetLang: "de", + }) + ).toBe(true); + + expect( + canTranslate({ + config: cfg, + mode: "argos", + inputText: "", + sourceLang: "en", + targetLang: "de", + }) + ).toBe(false); + + expect( + canTranslate({ + config: cfg, + mode: "argos", + inputText: "hello", + sourceLang: "en", + targetLang: "en", + }) + ).toBe(false); + }); + + it("computes synced mode according to installed backends", () => { + expect( + computeSyncedMode({ + currentMode: "argos", + hasArgos: false, + libreClientAvailable: true, + }) + ).toBe("libretranslate"); + + expect( + computeSyncedMode({ + currentMode: "libretranslate", + hasArgos: true, + libreClientAvailable: false, + }) + ).toBe("argos"); + }); + + it("swaps languages and input text", () => { + const res = computeSwappedLanguages({ + mode: "argos", + currentSource: "en", + currentTarget: "de", + resultSource: "en", + resultText: "Hallo Welt", + }); + expect(res.newSource).toBe("de"); + expect(res.newTarget).toBe("en"); + expect(res.newInputText).toBe("Hallo Welt"); + }); +}); -describe("TranslatorPage.vue", () => { +describe("registerTranslatorFeature", () => { + beforeEach(() => { + clearRoutes(); + clearFeatureIds(); + }); + + afterEach(() => { + clearRoutes(); + clearFeatureIds(); + }); + + it("registers translator route correctly", () => { + registerTranslatorFeature(); + expect(listFeatureIds()).toContain("translator"); + const route = listRoutes().find((r) => r.name === "translator"); + expect(route).toBeTruthy(); + expect(route?.path).toBe("/translator"); + expect(route?.mount).toBe("svelte"); + }); +}); + +describe("TranslatorPage.svelte", () => { let axiosMock; beforeEach(() => { + vi.clearAllMocks(); axiosMock = { get: vi.fn(), post: vi.fn(), + patch: vi.fn(), }; window.api = axiosMock; + registerTranslator(null); + registerFallbackMessages({ + app: { tools: "Tools" }, + tools: { + back_to_tools: "Back", + translator: { + title: "Translator", + description: "Translate text between languages", + }, + }, + common: { + copied: "Copied", + }, + translator: { + source_language: "Source Language", + target_language: "Target Language", + auto_detect: "Auto Detect", + select_target_language: "Select Target", + text_to_translate: "Text to translate", + enter_text_placeholder: "Enter text...", + translate: "Translate", + swap: "Swap", + clear: "Clear", + translation: "Translation", + source: "Source", + detected: "Detected", + select_languages_warning: "Select languages", + auto_detect_not_supported: "Auto detect not supported in Argos", + failed_translate: "Failed to translate", + failed_load_languages: "Failed to load languages", + installed_languages: "Installed Languages", + argos_translate: "Argos Translate", + libretranslate: "LibreTranslate", + api_server: "LibreTranslate API Server", + api_server_description: "Base URL of LibreTranslate instance", + api_key_optional: "API Key (Optional)", + api_key_placeholder: "Leave empty if not required", + api_key_description: "Only needed if required by server", + backend_libretranslate: "LibreTranslate", + backend_argos: "Argos Translate", + backend_status: "Backend Status", + libretranslate_url: "API URL", + libretranslate_api_key: "API Key", + privacy_mode_active: "Privacy Mode Active", + privacy_mode_desc: "Outbound requests restricted", + }, + }); + axiosMock.get.mockImplementation((url) => { if (url === "/api/v1/config") { return Promise.resolve({ @@ -43,28 +220,38 @@ describe("TranslatorPage.vue", () => { } return Promise.resolve({ data: {} }); }); + + axiosMock.post.mockResolvedValue({ + data: { + translated_text: "Hallo Welt", + source_lang: "en", + target_lang: "de", + source: "argos", + }, + }); }); afterEach(() => { + cleanup(); delete window.api; - vi.clearAllMocks(); }); - const mountTranslatorPage = () => { - const globals = mountToolsPageGlobals(); - globals.stubs.RouterLink = true; - return mount(TranslatorPage, { - global: globals, + it("renders the translator page and loads configuration", async () => { + render(TranslatorPage); + expect(screen.getByText("Translator")).toBeTruthy(); + + await waitFor(() => { + expect(axiosMock.get).toHaveBeenCalledWith("/api/v1/config"); + expect(axiosMock.get).toHaveBeenCalledWith( + "/api/v1/translator/languages", + expect.any(Object) + ); }); - }; - it("renders the translator page", async () => { - const wrapper = mountTranslatorPage(); - await vi.waitFor(() => expect(wrapper.vm.config).not.toBeNull()); - expect(wrapper.text()).toContain("Translator"); + expect(await screen.findByText("Source Language")).toBeTruthy(); }); - it("shows Libre tab when HTTP client is available even if server is not reachable yet", async () => { + it("shows Libre tab when HTTP client is available even if server not reachable yet", async () => { axiosMock.get.mockImplementation((url) => { if (url === "/api/v1/config") { return Promise.resolve({ @@ -90,72 +277,92 @@ describe("TranslatorPage.vue", () => { } return Promise.resolve({ data: {} }); }); - const wrapper = mountTranslatorPage(); - await vi.waitFor(() => expect(wrapper.vm.config).not.toBeNull()); - const libreTab = wrapper.findAll("button").find((b) => b.text().includes("LibreTranslate")); - expect(libreTab).toBeDefined(); - await libreTab.trigger("click"); - expect(wrapper.vm.translationMode).toBe("libretranslate"); - expect(wrapper.text()).toContain("LibreTranslate API Server"); + + render(TranslatorPage); + + await waitFor(() => { + expect(axiosMock.get).toHaveBeenCalledWith("/api/v1/config"); + }); + + expect(await screen.findByText("LibreTranslate API Server")).toBeTruthy(); }); it("switches translation modes", async () => { - const wrapper = mountTranslatorPage(); - await vi.waitFor(() => expect(wrapper.vm.config).not.toBeNull()); + render(TranslatorPage); + + await waitFor(() => { + expect(axiosMock.get).toHaveBeenCalledWith("/api/v1/config"); + }); + + const libreBtn = await screen.findByRole("button", { name: "LibreTranslate" }); + await fireEvent.click(libreBtn); + + expect(screen.getByText("LibreTranslate API Server")).toBeTruthy(); - const libreButton = wrapper.findAll("button").find((b) => b.text().includes("LibreTranslate")); - await libreButton.trigger("click"); - expect(wrapper.vm.translationMode).toBe("libretranslate"); - expect(wrapper.text()).toContain("LibreTranslate API Server"); + const argosBtn = screen.getByRole("button", { name: "Argos Translate" }); + await fireEvent.click(argosBtn); - const argosButton = wrapper.findAll("button").find((b) => b.text().includes("Argos Translate")); - await argosButton.trigger("click"); - expect(wrapper.vm.translationMode).toBe("argos"); + expect(screen.queryByText("LibreTranslate API Server")).toBeNull(); }); it("calls translate API and displays result", async () => { - window.api.post = vi.fn().mockResolvedValue({ - data: { - translated_text: "Hallo Welt", - source_lang: "en", - target_lang: "de", - }, + render(TranslatorPage); + + await waitFor(() => { + expect(axiosMock.get).toHaveBeenCalledWith("/api/v1/config"); }); - const wrapper = mountTranslatorPage(); - await vi.waitFor(() => expect(wrapper.vm.config).not.toBeNull()); + const sourceSelect = document.getElementById("translator-source-lang"); + const targetSelect = document.getElementById("translator-target-lang"); + const textarea = screen.getByPlaceholderText("Enter text..."); - await wrapper.setData({ - inputText: "Hello World", - sourceLang: "en", - targetLang: "de", + if (sourceSelect) { + await fireEvent.change(sourceSelect, { target: { value: "en" } }); + } + if (targetSelect) { + await fireEvent.change(targetSelect, { target: { value: "de" } }); + } + await fireEvent.input(textarea, { target: { value: "Hello World" } }); + + const translateBtn = screen.getByText("Translate"); + await fireEvent.click(translateBtn); + + await waitFor(() => { + expect(axiosMock.post).toHaveBeenCalledWith( + "/api/v1/translator/translate", + expect.objectContaining({ + text: "Hello World", + source_lang: "en", + target_lang: "de", + use_argos: true, + }) + ); }); - await wrapper.vm.$nextTick(); + expect(await screen.findByText("Hallo Welt")).toBeTruthy(); + }); - // Call directly to verify logic - await wrapper.vm.translateText(); + it("swaps languages when swap button is clicked", async () => { + render(TranslatorPage); - expect(window.api.post).toHaveBeenCalledWith( - "/api/v1/translator/translate", - expect.objectContaining({ - text: "Hello World", - source_lang: "en", - target_lang: "de", - }) - ); + await waitFor(() => { + expect(axiosMock.get).toHaveBeenCalledWith("/api/v1/config"); + }); - await vi.waitFor(() => expect(wrapper.text()).toContain("Hallo Welt")); - }); + const sourceSelect = document.getElementById("translator-source-lang"); + const targetSelect = document.getElementById("translator-target-lang"); - it("swaps languages", async () => { - const wrapper = mountTranslatorPage(); - await wrapper.setData({ sourceLang: "en", targetLang: "de" }); + if (sourceSelect) { + await fireEvent.change(sourceSelect, { target: { value: "en" } }); + } + if (targetSelect) { + await fireEvent.change(targetSelect, { target: { value: "de" } }); + } - const swapButton = wrapper.findAll("button").find((b) => b.text().includes("Swap")); - await swapButton.trigger("click"); + const swapBtn = screen.getByText("Swap"); + await fireEvent.click(swapBtn); - expect(wrapper.vm.sourceLang).toBe("de"); - expect(wrapper.vm.targetLang).toBe("en"); + expect(sourceSelect?.value).toBe("de"); + expect(targetSelect?.value).toBe("en"); }); }); diff --git a/tests/frontend/repositoryPaperPrintOracles.test.js b/tests/frontend/repositoryPaperPrintOracles.test.js index 2609a0e8d..38bf1f01c 100644 --- a/tests/frontend/repositoryPaperPrintOracles.test.js +++ b/tests/frontend/repositoryPaperPrintOracles.test.js @@ -8,7 +8,7 @@ import { describe, expect, it, vi } from "vitest"; import LinkUtils from "@/js/LinkUtils.js"; import Utils from "@/js/Utils.js"; import RepositoryServerPage from "@/components/tools/RepositoryServerPage.vue"; -import PaperMessagePage from "@/components/tools/PaperMessagePage.vue"; +import { printPaperQr } from "@/features/paper-message/lib/paperPrint.ts"; import { readFileSync } from "node:fs"; import { resolve } from "node:path"; @@ -43,7 +43,7 @@ describe("repository URL and print XSS oracles", () => { expect(LinkUtils.httpUrlHrefOrNull(href)).toBe(href); }); - it("PaperMessagePage printQRCode escapes destinationHash in document.write", () => { + it("printPaperQr escapes destinationHash in document.write", () => { const writes = []; const printWindow = { document: { @@ -52,20 +52,19 @@ describe("repository URL and print XSS oracles", () => { }, }; const openSpy = vi.spyOn(window, "open").mockReturnValue(printWindow); - const ctx = { - $refs: { - qrcode: { - toDataURL: () => "data:image/png;base64,abc", - }, - }, - destinationHash: '
', - $t: (k) => k, + const canvas = { + toDataURL: () => "data:image/png;base64,abc", }; - PaperMessagePage.methods.printQRCode.call(ctx); + const destinationHash = '
'; + const ok = printPaperQr({ + canvas, + destinationHash, + }); + expect(ok).toBe(true); expect(writes.length).toBe(1); const html = writes[0]; const recipient = html.match(/Recipient:\s*([^<]*)/); - expect(recipient?.[1]).toBe(Utils.escapeHtml(ctx.destinationHash)); + expect(recipient?.[1]).toBe(Utils.escapeHtml(destinationHash)); expect(html).toContain("<script>"); expect(html).not.toMatch(/Recipient:\s*<\/div> - - - - diff --git a/meshchatx/src/frontend/components/forms/FormLabel.vue b/meshchatx/src/frontend/components/forms/FormLabel.vue index afa03e460..344f53484 100644 --- a/meshchatx/src/frontend/components/forms/FormLabel.vue +++ b/meshchatx/src/frontend/components/forms/FormLabel.vue @@ -1,4 +1,4 @@ - +