diff --git a/.github/workflows/docs-validation.yml b/.github/workflows/docs-validation.yml
index a54e333..3794a58 100644
--- a/.github/workflows/docs-validation.yml
+++ b/.github/workflows/docs-validation.yml
@@ -21,6 +21,22 @@ jobs:
run: python -m unittest discover -s tests -v
- name: Check relative Markdown links
run: python scripts/check_docs.py
+ - name: Set up Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: 24
+ cache: npm
+ cache-dependency-path: dashboard/package-lock.json
+ - name: Install dashboard dependencies
+ run: npm ci --prefix dashboard
+ - name: Typecheck dashboard
+ run: npm --prefix dashboard run typecheck
+ - name: Lint dashboard
+ run: npm --prefix dashboard run lint
+ - name: Test dashboard
+ run: npm --prefix dashboard test
+ - name: Build dashboard
+ run: npm --prefix dashboard run build
# ORIGINAL (preserved, not deleted — disabled by comment):
# - name: Validate Markdown documents
# run: |
diff --git a/.gitignore b/.gitignore
index 6e08527..1fdbeef 100644
--- a/.gitignore
+++ b/.gitignore
@@ -34,3 +34,5 @@ coverage/
# Local runtime data
*.log
data/
+!dashboard/src/data/
+!dashboard/src/data/**
diff --git a/AGENTS.md b/AGENTS.md
index d88fd9e..7cc0001 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -2,7 +2,7 @@
## Mission and current scope
-OpenCluster is planned as a self-hosted control plane for cluster nodes, infrastructure, AI agents, and distributed workloads: **One dashboard. Every node. Total control.** The repository is currently in Phase 0. The dashboard, API, node agent, and deployment stack are not implemented. Do not build product features until a maintainer selects an implementation milestone.
+OpenCluster is a cluster operations platform: **One dashboard. Every node. Total control.** Phase 0 is complete. Phase 1 builds the permanent standalone React dashboard against a validated, persistent local data provider; seeded hardware telemetry remains clearly identified as sample data until backend services exist. Do not add a backend, authentication service, node agent, remote command execution, live software installation, or cloud dependency in Phase 1.
## Before changing anything
@@ -14,7 +14,8 @@ OpenCluster is planned as a self-hosted control plane for cluster nodes, infrast
## Repository organization
-- `frontend/` — planned React and TypeScript dashboard.
+- `frontend/` — existing Phase 0 placeholder; do not implement the Phase 1 dashboard here.
+- `dashboard/` — standalone production frontend in Phase 1, using IndexedDB for structured local records and localStorage for non-sensitive preferences; backend integration remains future work.
- `backend/` — planned FastAPI control plane.
- `node-agent/` — planned lightweight Linux node service.
- `infrastructure/` — deployment and operations assets.
@@ -25,7 +26,7 @@ OpenCluster is planned as a self-hosted control plane for cluster nodes, infrast
## Architecture and security rules
-- Follow `docs/ARCHITECTURE.md`, `docs/SECURITY_ARCHITECTURE.md`, `docs/API_DESIGN.md`, and `docs/DATABASE_DESIGN.md`. They describe proposals until implementation exists.
+- Follow `docs/ARCHITECTURE.md`, `docs/SECURITY_ARCHITECTURE.md`, `docs/API_DESIGN.md`, and `docs/DATABASE_DESIGN.md`. They describe backend proposals until those services exist. The Phase 1 dashboard stores validated structured records in IndexedDB, non-sensitive preferences in localStorage, and clearly labeled sample telemetry in the local provider.
- Keep module boundaries explicit. Frontend code must not access the database directly; the node agent must not trust control-plane input without authentication and authorization.
- Default to least privilege. Privileged node actions require authenticated identity, authorization, audit records, bounded operations, and explicit confirmation where destructive.
- Never expose unrestricted remote shells or arbitrary cross-node command execution. Do not add secrets to code, logs, examples, or fixtures.
@@ -51,7 +52,7 @@ Reviewers should check correctness, regressions, architecture and interface cons
## Tests and validation
- Run focused checks for changed behavior, then relevant broader checks when they exist. Do not claim commands passed unless they were run.
-- During Phase 0, CI validates documentation links and repository configuration only. Application build workflows must wait until application manifests and build tooling exist.
+- CI validates documentation and the dashboard's checked-in typecheck, lint, test, and build scripts. Add workflows only for artifacts and tools that exist.
- Add regression tests for non-trivial behavior, failure paths, and security-sensitive logic. Keep performance benchmarks reproducible and do not invent targets or results.
## Documentation requirements
diff --git a/README.md b/README.md
index da5d5ea..b252d58 100644
--- a/README.md
+++ b/README.md
@@ -3,57 +3,57 @@
**One dashboard. Every node. Total control.**
[](LICENSE)
-[](docs/ROADMAP.md)
+[](docs/ROADMAP.md)
-OpenCluster is a planned free, open-source, self-hosted platform for monitoring and managing computing clusters, infrastructure nodes, AI agents, and distributed workloads through a unified web dashboard. It is maintained by **ZYRIX DEVS**.
+OpenCluster is an open-source project by **ZYRIX DEVS**. Its long-term goal is a self-hosted platform for understanding and managing clusters, infrastructure nodes, AI agents, and distributed workloads.
-> **Project status:** repository foundation and design phase. The dashboard, backend, node agent, and deployment stack are not implemented. Feature descriptions below are plans, not available functionality.
+## Current project status
-## Planned capabilities
+Phase 0 repository foundation is complete. Phase 1 builds the permanent standalone dashboard in `dashboard/`, with validated browser-local records and a replaceable data-provider boundary. The application is still under development; the required pages and interactions are not claimed as complete until implemented and verified.
-- Cluster and node health, resource telemetry, alerts, logs, and history.
-- Secure node enrollment, labels, groups, and permission-controlled operations.
-- Distributed task scheduling and recovery policies.
-- Extensible AI agent adapters, including Codex and Hermes where supported.
-- GitHub and developer-operations visibility with auditable administrative controls.
+The Phase 1 dashboard is a **production frontend operating in local mode**. It supports persistent local cluster, node, graph, and workflow records. It does not connect to physical clusters, execute tasks, install software, or provide live telemetry until backend services and an authorized node agent exist. It does not require cloud services. Seeded hardware metrics are visibly identified as sample data.
-The initial release is expected to focus on secure node enrollment, read-only cluster and node visibility, and a small operator-managed deployment. Scope remains subject to maintainer review; see the [PRD](docs/PRD.md) and [roadmap](docs/ROADMAP.md).
+## Dashboard scope
-## Proposed architecture
+The dashboard includes Home, Mission Control, Clusters, Nodes, Agents, Automation, AI Software, Tasks, Monitoring, Activity, and Settings views. Cluster/node management and canvas editing operate on saved local records; hardware metrics are sample data, and installations or workload execution remain unavailable. The implementation requirements are in the [dashboard PRD](docs/DASHBOARD_PRD.md), [UI/UX specification](docs/DASHBOARD_UI_UX_SPEC.md), and [architecture and component map](docs/DASHBOARD_ARCHITECTURE_AND_COMPONENT_MAP.md).
-The current proposal uses a React, TypeScript, and Vite frontend; a Python/FastAPI control plane; PostgreSQL for durable state; and a lightweight Linux node agent. Docker Compose is the planned local deployment path. These choices are not implemented or locked; trade-offs are recorded in [architecture](docs/ARCHITECTURE.md) and [technology stack](docs/TECH_STACK.md).
+## Future direction
-```text
-frontend/ dashboard (planned)
-backend/ API and control plane (planned)
-node-agent/ Linux metrics and secure communication (planned)
-infrastructure/ local and future deployment assets (planned)
-docs/ requirements and design decisions
-```
+Backend services, secure node enrollment, real telemetry, authentication and authorization, audit logging, AI-agent integrations, and workload orchestration belong to later phases. They are not current capabilities. The [roadmap](docs/ROADMAP.md) and [architecture proposal](docs/ARCHITECTURE.md) distinguish future designs from implemented software.
+
+## Development
-## Development status
+The dashboard requires Node.js 20.19+ or 22.12+ and npm (Vite's supported Node versions). From the repository root, install dependencies and start the local development server:
-Phase 0 work is limited to repository structure, documentation, Codex guidance, and docs validation. There are no application prerequisites or runnable app commands yet.
+```sh
+npm --prefix dashboard install
+npm --prefix dashboard run dev
+```
-To work on the repository foundation:
+Vite prints the local URL when the server starts. The dashboard uses persistent browser-local records and sample hardware telemetry; no backend or cloud service is needed. Run the available checks and production build with:
+
+```sh
+npm --prefix dashboard run typecheck
+npm --prefix dashboard run lint
+npm --prefix dashboard test
+npm --prefix dashboard run build
+```
-1. Clone this repository and create a topic branch.
-2. Read [AGENTS.md](AGENTS.md), [development guide](docs/DEVELOPMENT.md), and the relevant design documents.
-3. Edit documentation or repository configuration and run the checks documented in `docs/DEVELOPMENT.md`.
-4. Open a pull request against `main` using the [contribution guide](CONTRIBUTING.md).
+For repository contribution setup, read [AGENTS.md](AGENTS.md), the [development guide](docs/DEVELOPMENT.md), and [CONTRIBUTING.md](CONTRIBUTING.md). Review the [testing strategy](docs/TESTING_STRATEGY.md) for current repository checks. Open a pull request against `main` following the [GitHub workflow](docs/GITHUB_WORKFLOW.md).
## Documentation
-- [Product requirements](docs/PRD.md) · [Architecture](docs/ARCHITECTURE.md) · [Roadmap](docs/ROADMAP.md)
-- [Technology stack](docs/TECH_STACK.md) · [API design](docs/API_DESIGN.md) · [Database design](docs/DATABASE_DESIGN.md)
-- [Node protocol](docs/NODE_PROTOCOL.md) · [Security architecture](docs/SECURITY_ARCHITECTURE.md)
-- [Design system](docs/DESIGN_SYSTEM.md) · [Agent orchestration](docs/AGENT_ORCHESTRATION.md)
-- [Development](docs/DEVELOPMENT.md) · [Deployment](docs/DEPLOYMENT.md) · [Testing strategy](docs/TESTING_STRATEGY.md)
-- [GitHub workflow](docs/GITHUB_WORKFLOW.md) · [Decisions](docs/DECISIONS.md)
+- [Product requirements](docs/PRD.md) · [Roadmap](docs/ROADMAP.md) · [Architecture proposal](docs/ARCHITECTURE.md)
+- [Technology stack](docs/TECH_STACK.md) · [Design system](docs/DESIGN_SYSTEM.md)
+- [Dashboard motion and performance](docs/DASHBOARD_PERFORMANCE.md)
+- [Dashboard product requirements](docs/DASHBOARD_PRD.md) · [UI/UX specification](docs/DASHBOARD_UI_UX_SPEC.md) · [Architecture and component map](docs/DASHBOARD_ARCHITECTURE_AND_COMPONENT_MAP.md)
+- [API design proposal](docs/API_DESIGN.md) · [Database design proposal](docs/DATABASE_DESIGN.md) · [Node protocol proposal](docs/NODE_PROTOCOL.md)
+- [Security architecture proposal](docs/SECURITY_ARCHITECTURE.md) · [Development](docs/DEVELOPMENT.md) · [Testing strategy](docs/TESTING_STRATEGY.md)
+- [Deployment proposal](docs/DEPLOYMENT.md) · [GitHub workflow](docs/GITHUB_WORKFLOW.md) · [Decisions](docs/DECISIONS.md)
-## Contributing
+## Contributing and security
-Contributions are welcome after reading [CONTRIBUTING.md](CONTRIBUTING.md). Please open an issue for substantial proposals before implementation. Security issues should follow [SECURITY.md](SECURITY.md), not a public issue.
+Contributions are welcome under [CONTRIBUTING.md](CONTRIBUTING.md). Please report security concerns through [SECURITY.md](SECURITY.md), not a public issue. The dashboard does not yet implement backend authentication, authorization, node enrollment, or remote-action protections because those services are not in Phase 1.
## License and attribution
diff --git a/dashboard/README.md b/dashboard/README.md
new file mode 100644
index 0000000..c29b916
--- /dev/null
+++ b/dashboard/README.md
@@ -0,0 +1,29 @@
+# OpenCluster Dashboard
+
+The dashboard is OpenCluster's permanent standalone frontend, currently operating in local mode. Cluster, node, workflow, and canvas records persist in IndexedDB; lightweight preferences use localStorage. Seeded telemetry is sample data. The app does not connect to physical nodes, dispatch tasks, install software, or require a cloud service while backend services are unavailable.
+
+## Run locally
+
+From the repository root:
+
+```sh
+npm --prefix dashboard install
+npm --prefix dashboard run dev
+```
+
+Vite prints the local development URL. Node.js 20.19+ or 22.12+ is required.
+
+## Checks
+
+```sh
+npm --prefix dashboard run typecheck
+npm --prefix dashboard run lint
+npm --prefix dashboard test
+npm --prefix dashboard run build
+```
+
+The local provider stores clusters, nodes, canvas layouts, workflows, and non-sensitive preferences in this browser. Structured records use IndexedDB. Do not enter credentials or secrets.
+
+## Views
+
+The Home page summarizes local clusters, nodes, resource use, alerts, activity, and agent records. Mission Control offers topology, orbit, and an optional 3D globe view. Agents and Automation provide editable React Flow canvases with saved local state. Software configuration is saved locally; installation and workflow execution remain unavailable until secure backend services exist. Read the [dashboard PRD](../docs/DASHBOARD_PRD.md), [UI/UX specification](../docs/DASHBOARD_UI_UX_SPEC.md), and [architecture and component map](../docs/DASHBOARD_ARCHITECTURE_AND_COMPONENT_MAP.md) for requirements. See [the dashboard performance notes](../docs/DASHBOARD_PERFORMANCE.md) for animation, reduced-motion, and rendering behavior.
diff --git a/dashboard/biome.json b/dashboard/biome.json
new file mode 100644
index 0000000..6474c9b
--- /dev/null
+++ b/dashboard/biome.json
@@ -0,0 +1,17 @@
+{
+ "$schema": "https://biomejs.dev/schemas/2.6.2/schema.json",
+ "files": { "ignoreUnknown": true },
+ "formatter": { "enabled": true, "indentStyle": "space", "indentWidth": 2 },
+ "linter": {
+ "enabled": true,
+ "rules": {
+ "preset": "recommended",
+ "a11y": { "preset": "recommended" },
+ "correctness": { "preset": "recommended" },
+ "style": { "useImportType": "error" }
+ }
+ },
+ "javascript": {
+ "formatter": { "quoteStyle": "double", "semicolons": "always" }
+ }
+}
diff --git a/dashboard/components.json b/dashboard/components.json
new file mode 100644
index 0000000..cccf3be
--- /dev/null
+++ b/dashboard/components.json
@@ -0,0 +1,20 @@
+{
+ "$schema": "https://ui.shadcn.com/schema.json",
+ "style": "new-york",
+ "rsc": false,
+ "tsx": true,
+ "tailwind": {
+ "config": "",
+ "css": "src/styles.css",
+ "baseColor": "slate",
+ "cssVariables": true,
+ "prefix": ""
+ },
+ "aliases": {
+ "components": "@/components",
+ "utils": "@/lib/utils",
+ "ui": "@/components/ui",
+ "lib": "@/lib",
+ "hooks": "@/hooks"
+ }
+}
diff --git a/dashboard/index.html b/dashboard/index.html
new file mode 100644
index 0000000..950469a
--- /dev/null
+++ b/dashboard/index.html
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+ OpenCluster — Mission Control
+
+
+
+
+
+
diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json
new file mode 100644
index 0000000..a406585
--- /dev/null
+++ b/dashboard/package-lock.json
@@ -0,0 +1,5150 @@
+{
+ "name": "@opencluster/dashboard",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "@opencluster/dashboard",
+ "version": "0.1.0",
+ "dependencies": {
+ "@fontsource-variable/inter": "^5.3.0",
+ "@fontsource-variable/sora": "^5.3.0",
+ "@radix-ui/react-dialog": "^1.1.15",
+ "@radix-ui/react-dropdown-menu": "^2.1.16",
+ "@radix-ui/react-popover": "^1.1.15",
+ "@radix-ui/react-select": "^2.2.6",
+ "@radix-ui/react-slot": "^1.2.4",
+ "@radix-ui/react-tabs": "^1.1.13",
+ "@radix-ui/react-tooltip": "^1.2.8",
+ "@tanstack/react-query": "^5.104.5",
+ "@xyflow/react": "^12.12.0",
+ "class-variance-authority": "^0.7.1",
+ "clsx": "^2.1.1",
+ "cmdk": "^1.1.1",
+ "framer-motion": "^14.6.0",
+ "gsap": "^3.15.0",
+ "idb": "^8.0.4",
+ "lenis": "^1.3.27",
+ "lucide-react": "^1.61.0",
+ "react": "^19.3.1",
+ "react-dom": "^19.3.1",
+ "react-router-dom": "^7.18.4",
+ "recharts": "^3.10.1",
+ "tailwind-merge": "^3.6.0",
+ "three": "^0.186.1",
+ "zod": "^4.6.7",
+ "zustand": "^5.0.16"
+ },
+ "devDependencies": {
+ "@biomejs/biome": "^2.4.5",
+ "@tailwindcss/vite": "^4.3.3",
+ "@testing-library/jest-dom": "^6.9.1",
+ "@testing-library/react": "^16.3.2",
+ "@testing-library/user-event": "^14.6.1",
+ "@types/node": "^26.6.5",
+ "@types/react": "^19.3.0",
+ "@types/react-dom": "^19.3.0",
+ "@types/three": "^0.186.0",
+ "@vitejs/plugin-react": "^6.1.2",
+ "fake-indexeddb": "^6.2.6",
+ "jsdom": "^30.1.3",
+ "typescript": "^7.0.2",
+ "vite": "^8.3.5",
+ "vitest": "^5.0.5"
+ }
+ },
+ "node_modules/@adobe/css-tools": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz",
+ "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@asamuzakjp/css-color": {
+ "version": "7.1.7",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-7.1.7.tgz",
+ "integrity": "sha512-Q7ioeear07HwixdIitIn9KS1LZi97CEDFCSjPNm9v+y8KNbsu9q4Lrd4zUQW4faED+E7qxvfeVnbNRW46wFKuw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@csstools/css-calc": "^3.4.3",
+ "@csstools/css-color-parser": "^4.2.6",
+ "@csstools/css-parser-algorithms": "^4.0.2",
+ "@csstools/css-tokenizer": "^4.0.2",
+ "lru-cache": "^11.5.3"
+ },
+ "engines": {
+ "node": "^22.22.2 || ^24.15.0 || >=26.0.0"
+ }
+ },
+ "node_modules/@asamuzakjp/dom-selector": {
+ "version": "10.0.4",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-10.0.4.tgz",
+ "integrity": "sha512-A6f1nKy99I6tyoK82U6kBHvQQZmdHmTmPzJYPY/0eaOW0r8/qPmEOwjGHs+hL/vCgOwQTupu4LT1XtXNUuDruA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "bidi-js": "^1.1.0",
+ "css-tree": "^3.2.1",
+ "is-potential-custom-element-name": "^1.0.1",
+ "lru-cache": "^11.5.3"
+ },
+ "engines": {
+ "node": "^22.22.2 || ^24.15.0 || >=26.0.0"
+ }
+ },
+ "node_modules/@babel/code-frame": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
+ "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@babel/helper-validator-identifier": "^7.29.7",
+ "js-tokens": "^4.0.0",
+ "picocolors": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
+ "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/runtime": {
+ "version": "7.29.10",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.10.tgz",
+ "integrity": "sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@biomejs/biome": {
+ "version": "2.6.2",
+ "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.6.2.tgz",
+ "integrity": "sha512-JLTFAFF9vfJcWwLhwuL4ZHflLg+XfTEC4IUnAlJRX1cMRt57++Xd1Ysb9Fdmlh9YUrvyCXfPEHjjEIqKJGdPKw==",
+ "dev": true,
+ "license": "MIT OR Apache-2.0",
+ "bin": {
+ "biome": "bin/biome"
+ },
+ "engines": {
+ "node": ">=14.21.3"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/biome"
+ },
+ "optionalDependencies": {
+ "@biomejs/cli-android-arm64": "2.6.2",
+ "@biomejs/cli-android-x64": "2.6.2",
+ "@biomejs/cli-darwin-arm64": "2.6.2",
+ "@biomejs/cli-darwin-x64": "2.6.2",
+ "@biomejs/cli-linux-arm64": "2.6.2",
+ "@biomejs/cli-linux-arm64-musl": "2.6.2",
+ "@biomejs/cli-linux-x64": "2.6.2",
+ "@biomejs/cli-linux-x64-musl": "2.6.2",
+ "@biomejs/cli-win32-arm64": "2.6.2",
+ "@biomejs/cli-win32-x64": "2.6.2"
+ }
+ },
+ "node_modules/@biomejs/cli-android-arm64": {
+ "version": "2.6.2",
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-android-arm64/-/cli-android-arm64-2.6.2.tgz",
+ "integrity": "sha512-dMpdpH7zFEL5FxuIA9LJW+RoNyC3oeDB7PJyjqVVwtMNoKB5uo8OZYmrVSBikn71txzYwy/3xga7UME6bJQZ6A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT OR Apache-2.0",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=14.21.3"
+ }
+ },
+ "node_modules/@biomejs/cli-android-x64": {
+ "version": "2.6.2",
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-android-x64/-/cli-android-x64-2.6.2.tgz",
+ "integrity": "sha512-XCwEhPBGYzuRA502dRkCcQ+5cefQwuxQR7f9wa4do92dvIcuBL6VMLMFB/fL9hlpZ4dGSfHdpGFSIDlp52mg0A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT OR Apache-2.0",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=14.21.3"
+ }
+ },
+ "node_modules/@biomejs/cli-darwin-arm64": {
+ "version": "2.6.2",
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.6.2.tgz",
+ "integrity": "sha512-rpXtabRgyZNz3aCjd/cRoSZGYi92R9eAXdUUe1sigIsRDyfMwgThaa98Yxt1fiZ7mxquqkAyIHIo08N/e1ToBA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT OR Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=14.21.3"
+ }
+ },
+ "node_modules/@biomejs/cli-darwin-x64": {
+ "version": "2.6.2",
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.6.2.tgz",
+ "integrity": "sha512-7GGzlm1GxUKoQ9CNV8tQZ+XXad4sz+v+uzNsWlhaGjsnZqUOVBw1OmAMpG3uNV/Mr0UrCpRJvSK0qvfuqSQOjg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT OR Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=14.21.3"
+ }
+ },
+ "node_modules/@biomejs/cli-linux-arm64": {
+ "version": "2.6.2",
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.6.2.tgz",
+ "integrity": "sha512-Qs4CofCE8MdkjZFfEjswh2NAcbyQ7IVYtGSzQBvLqFlla/CSXroGbktUFndWG/kVSTywadauzAspYWtSfqAc2g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT OR Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=14.21.3"
+ }
+ },
+ "node_modules/@biomejs/cli-linux-arm64-musl": {
+ "version": "2.6.2",
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.6.2.tgz",
+ "integrity": "sha512-I+FYCLVSmR3cYfKcQ+KNeLOZ5w0wRd9fQFlw3JbSvy7bkegvTqdHaBRRcJzHQB/1ldldTLIcG21ka6X2cTWmJw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT OR Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=14.21.3"
+ }
+ },
+ "node_modules/@biomejs/cli-linux-x64": {
+ "version": "2.6.2",
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.6.2.tgz",
+ "integrity": "sha512-FJKsb95418139xDdbv9XnNJsIIwvsgR21Of0aE9B9U+nQlOtRq5gJl2ps0jZUmYBxTaOlP9BKo8N+Ff8+Rui1g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT OR Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=14.21.3"
+ }
+ },
+ "node_modules/@biomejs/cli-linux-x64-musl": {
+ "version": "2.6.2",
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.6.2.tgz",
+ "integrity": "sha512-wnjEXAK2pDd2j8r9gKI3L+44sIxjkYzS5aHmD1+K57RNJ3sFyT4n22NH6QFPqqeVdRBxN54H3kckUUCrFKCcJw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT OR Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=14.21.3"
+ }
+ },
+ "node_modules/@biomejs/cli-win32-arm64": {
+ "version": "2.6.2",
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.6.2.tgz",
+ "integrity": "sha512-/PEOo6evlgrQOfKhMNtC/4Gk8ai3hySXXEtjUzCqZe/4iAbMdqelpp7qvZEDyLxlZS+BjOMQFpq2gXI/BPaAqw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT OR Apache-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=14.21.3"
+ }
+ },
+ "node_modules/@biomejs/cli-win32-x64": {
+ "version": "2.6.2",
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.6.2.tgz",
+ "integrity": "sha512-FVlWD+mDDz8nN5LWKRWUhhb7wKIK0VE3oXQvQBX7Fwwj00CvqMVhMyiz1iz8O5aZGmnkhFlKSwJPrUvMh5prQw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT OR Apache-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=14.21.3"
+ }
+ },
+ "node_modules/@bramus/specificity": {
+ "version": "2.4.3",
+ "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.3.tgz",
+ "integrity": "sha512-0GEz8qJWokkUVYQYRukmkSHIFNoBH8QTreeHrAFbvz1XFYc3Y+puhMs8PQ5x8GNugiC2l5jvDBXKLbBIByeSBw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "css-tree": "^3.2.1"
+ },
+ "bin": {
+ "specificity": "bin/cli.js"
+ }
+ },
+ "node_modules/@csstools/color-helpers": {
+ "version": "6.1.2",
+ "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.2.tgz",
+ "integrity": "sha512-grhRy3OKmniaAEKXMjua5z/EODX0MSqBGjunw8+j/3HQjOnahs2AGhvEOIYVUWcU6ScApbhLhVrQTX8XqrMrow==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT-0",
+ "engines": {
+ "node": ">=20.19.0"
+ }
+ },
+ "node_modules/@csstools/css-calc": {
+ "version": "3.4.3",
+ "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.4.3.tgz",
+ "integrity": "sha512-iex20d8CHVkyvg6B7UKV7uHnI2Bqo9g+EFfT9E0y+GvTvhZ/DwONJ+9aKb1dlqm0ZiGsL5RXjp0fCoJYnkeDjA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^4.0.2",
+ "@csstools/css-tokenizer": "^4.0.2"
+ }
+ },
+ "node_modules/@csstools/css-color-parser": {
+ "version": "4.2.6",
+ "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.6.tgz",
+ "integrity": "sha512-iiPQ3iRWwnJkeEn6RIu6SJPr7hYrLz6XZ9s/QZl+2/LI5KQVjpl2fdmDSZKuD4xP6GMmMPgHFFXg6k1Wkz0Trg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@csstools/color-helpers": "^6.1.2",
+ "@csstools/css-calc": "^3.4.3"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^4.0.2",
+ "@csstools/css-tokenizer": "^4.0.2"
+ }
+ },
+ "node_modules/@csstools/css-parser-algorithms": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.2.tgz",
+ "integrity": "sha512-40cSKyMvK+tq4qz6Awrlye2WGuOKt3FwPgtGg6KTfbHOWNw+Rk1rzbAtZnZ6IBhsY491HLRnDXwoyBAijmmILA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "@csstools/css-tokenizer": "^4.0.2"
+ }
+ },
+ "node_modules/@csstools/css-syntax-patches-for-csstree": {
+ "version": "1.1.15",
+ "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.15.tgz",
+ "integrity": "sha512-J0u7HkVl2nzSlhsiTOp4AmwcUQ3D+mGEEKfBy/7To5/y7F2OHwyLrXfrhR0SMgr4p5Lo+eaMVSeai24zUcBIxA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT-0",
+ "peerDependencies": {
+ "css-tree": "^3.2.1"
+ },
+ "peerDependenciesMeta": {
+ "css-tree": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@csstools/css-tokenizer": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.2.tgz",
+ "integrity": "sha512-OoKoR0f76dCY666JlcbhmVTs2drYj1GUXZTYTcbUgJjh9Nv41aFfZ21bPQTERm5+L5cBDo466NltB2lplS5GBw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.19.0"
+ }
+ },
+ "node_modules/@dimforge/rapier3d-compat": {
+ "version": "0.12.0",
+ "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz",
+ "integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==",
+ "dev": true,
+ "license": "Apache-2.0"
+ },
+ "node_modules/@exodus/bytes": {
+ "version": "1.16.0",
+ "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.16.0.tgz",
+ "integrity": "sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ },
+ "peerDependencies": {
+ "@noble/hashes": "^1.8.0 || ^2.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@noble/hashes": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@floating-ui/core": {
+ "version": "1.8.0",
+ "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz",
+ "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@floating-ui/utils": "^0.2.12"
+ }
+ },
+ "node_modules/@floating-ui/dom": {
+ "version": "1.8.0",
+ "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz",
+ "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==",
+ "license": "MIT",
+ "dependencies": {
+ "@floating-ui/core": "^1.8.0",
+ "@floating-ui/utils": "^0.2.12"
+ }
+ },
+ "node_modules/@floating-ui/react-dom": {
+ "version": "2.1.9",
+ "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.9.tgz",
+ "integrity": "sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==",
+ "license": "MIT",
+ "dependencies": {
+ "@floating-ui/dom": "^1.8.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0",
+ "react-dom": ">=16.8.0"
+ }
+ },
+ "node_modules/@floating-ui/utils": {
+ "version": "0.2.12",
+ "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz",
+ "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==",
+ "license": "MIT"
+ },
+ "node_modules/@fontsource-variable/inter": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.3.0.tgz",
+ "integrity": "sha512-OupL48va4JNofb97w6NYeF9S7W/kHNKM0Er8Dem5nqi4jeOLrVJDoE8tZEpnMJmtkvNbB1EIPPwHcdkF6b1oUA==",
+ "license": "OFL-1.1",
+ "funding": {
+ "url": "https://github.com/sponsors/ayuhito"
+ }
+ },
+ "node_modules/@fontsource-variable/sora": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/@fontsource-variable/sora/-/sora-5.3.0.tgz",
+ "integrity": "sha512-h8kHta4Z8SkfUGeI82TUy1n3rbrKpBRGwuk4t4qCwto5oEHxlbOFD2yGP+Df5fbmTXwKGn+o667vYp0E4K8Whw==",
+ "license": "OFL-1.1",
+ "funding": {
+ "url": "https://github.com/sponsors/ayuhito"
+ }
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/remapping": {
+ "version": "2.3.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
+ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
+ "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@oxc-project/types": {
+ "version": "0.155.0",
+ "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.155.0.tgz",
+ "integrity": "sha512-SeE8JPwW1xs1tHeNycjUtUBdhorRa8o4jlvjwk49R7/Z291i+4DQa7JJIgOm/TaYQZx+QrPQbVJ/9c8dMIZN8Q==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/oxc-project"
+ }
+ },
+ "node_modules/@radix-ui/number": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.3.tgz",
+ "integrity": "sha512-Road2bidD0uu/1BGDOWNdPI06g0lIRy6IF9GZcIrDK2KGItfor8IQwQa+yM2ERgHM1MmHxaxpTzk0/Jp42lNfA==",
+ "license": "MIT"
+ },
+ "node_modules/@radix-ui/primitive": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.2.0.tgz",
+ "integrity": "sha512-KABY+VMIRPJL+Vyzv/335FEhXXD/xDv8BMb1kl48/aXJZMH0LtHCTx5ga5RnxaEP3nZHtmTLf1P9vanmFX46bw==",
+ "license": "MIT"
+ },
+ "node_modules/@radix-ui/react-arrow": {
+ "version": "1.1.17",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.17.tgz",
+ "integrity": "sha512-67jjcVvxjXCCStnTW3Z+M7jmaFMuMR7M2JgpDzH4w7JSRrikmzhjfYgP/09ECb8n7whnsgh1ifJ+Ie96qEj+pQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-primitive": "2.2.0"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-collection": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.2.0.tgz",
+ "integrity": "sha512-+yiQzqcc7Sy7/5s0jrMCjpPRFB3538VNrXPWdTLZhE/SCfgEws4aI6lHWN/vvcnyhHndROWBzqmqB4QBrITZ9Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.5",
+ "@radix-ui/react-context": "1.3.0",
+ "@radix-ui/react-primitive": "2.2.0",
+ "@radix-ui/react-slot": "1.5.0",
+ "@radix-ui/react-use-layout-effect": "1.1.4"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-compose-refs": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.5.tgz",
+ "integrity": "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-context": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.3.0.tgz",
+ "integrity": "sha512-v5AYEocMISkbJ+1fTXzZpiFv7NRr3D2WiYbLIKf+FkmJ8VkP6d503eV2n34MFussHXY1pcoq3yfJiQH77dc4Mg==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-dialog": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.3.1.tgz",
+ "integrity": "sha512-+0E4qvVTwjAWra9sEhJ+9BTsvHE00zkUuNw1VGh7S+Gc13dfYk+S1PVQdchWDj+XYHhAai7Z7IpzXVDCGsDplg==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.2.0",
+ "@radix-ui/react-compose-refs": "1.1.5",
+ "@radix-ui/react-context": "1.3.0",
+ "@radix-ui/react-dismissable-layer": "1.2.1",
+ "@radix-ui/react-focus-guards": "1.2.0",
+ "@radix-ui/react-focus-scope": "1.3.1",
+ "@radix-ui/react-id": "1.1.4",
+ "@radix-ui/react-portal": "1.2.0",
+ "@radix-ui/react-presence": "1.1.11",
+ "@radix-ui/react-primitive": "2.2.0",
+ "@radix-ui/react-slot": "1.5.0",
+ "@radix-ui/react-use-controllable-state": "1.3.0",
+ "@radix-ui/react-use-layout-effect": "1.1.4",
+ "aria-hidden": "^1.2.4",
+ "react-remove-scroll": "^2.7.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-direction": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.2.0.tgz",
+ "integrity": "sha512-FtRA52a/r+LKStFT+neLUm+8vSe7wbzkbQGGeU8ixFiwC8NU14Evoq9ATECqL3eGbcUzJR4fyfA4KkK/q6dfqg==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-dismissable-layer": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.2.1.tgz",
+ "integrity": "sha512-KIfDcAsx+oVylFZ4xryVKusJUrLLhTQmfnJW4kwk4Km5myvhHCrj04hnWWUC166+xD4DHdH8tQGylwYX1h6+Pg==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.2.0",
+ "@radix-ui/react-compose-refs": "1.1.5",
+ "@radix-ui/react-primitive": "2.2.0",
+ "@radix-ui/react-use-callback-ref": "1.1.5",
+ "@radix-ui/react-use-effect-event": "0.0.5"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-dropdown-menu": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.2.1.tgz",
+ "integrity": "sha512-XsHcmYG2AcqnvTqrOXXWcMrhquEIlqucizuDH1MV/qMRW+DndpBCMvNeTRXOxqmG1H5j89M/+I0/34Zdd+zDcQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.2.0",
+ "@radix-ui/react-compose-refs": "1.1.5",
+ "@radix-ui/react-context": "1.3.0",
+ "@radix-ui/react-id": "1.1.4",
+ "@radix-ui/react-menu": "2.2.1",
+ "@radix-ui/react-primitive": "2.2.0",
+ "@radix-ui/react-use-controllable-state": "1.3.0"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-focus-guards": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.2.0.tgz",
+ "integrity": "sha512-MPm5R0iRGUDrDnPNxmLBkv+X7pN4XKzt+MbWJEuJQsH3NFH4omrbpXpucyYCCKTSt+na/ZxGhEWQrWTZ0285cA==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-focus-scope": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.3.1.tgz",
+ "integrity": "sha512-ugeMhbXt4y5S2T1GnIqbWDKEN962PDwnX1fN0eKOOgf5f63Dh2mn8F1FUySlgW0iF6GiVsRF32DzuDIWTUyQJg==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.5",
+ "@radix-ui/react-primitive": "2.2.0",
+ "@radix-ui/react-use-callback-ref": "1.1.5"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-id": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.4.tgz",
+ "integrity": "sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-use-layout-effect": "1.1.4"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-menu": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.2.1.tgz",
+ "integrity": "sha512-G34BsU90SBj5foYpz8pQqq/Q0Dlfyyl6KitWEfRwXC0QvfwhWNAwS49DWP2+rRJ0iwv20Z2Qx7Sk+T03bGo5JA==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.2.0",
+ "@radix-ui/react-collection": "1.2.0",
+ "@radix-ui/react-compose-refs": "1.1.5",
+ "@radix-ui/react-context": "1.3.0",
+ "@radix-ui/react-direction": "1.2.0",
+ "@radix-ui/react-dismissable-layer": "1.2.1",
+ "@radix-ui/react-focus-guards": "1.2.0",
+ "@radix-ui/react-focus-scope": "1.3.1",
+ "@radix-ui/react-id": "1.1.4",
+ "@radix-ui/react-popper": "1.4.0",
+ "@radix-ui/react-portal": "1.2.0",
+ "@radix-ui/react-presence": "1.1.11",
+ "@radix-ui/react-primitive": "2.2.0",
+ "@radix-ui/react-roving-focus": "1.2.1",
+ "@radix-ui/react-slot": "1.5.0",
+ "@radix-ui/react-use-callback-ref": "1.1.5",
+ "aria-hidden": "^1.2.4",
+ "react-remove-scroll": "^2.7.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-popover": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.3.1.tgz",
+ "integrity": "sha512-ZNxvXRx5W2Wos4fXCXb5lpdqN0Z5chf+mStOUOTr4ik7d+62MRvoe9rzCA/eGNg36lxXqSrKEKmwuZEcUrIstw==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.2.0",
+ "@radix-ui/react-compose-refs": "1.1.5",
+ "@radix-ui/react-context": "1.3.0",
+ "@radix-ui/react-dismissable-layer": "1.2.1",
+ "@radix-ui/react-focus-guards": "1.2.0",
+ "@radix-ui/react-focus-scope": "1.3.1",
+ "@radix-ui/react-id": "1.1.4",
+ "@radix-ui/react-popper": "1.4.0",
+ "@radix-ui/react-portal": "1.2.0",
+ "@radix-ui/react-presence": "1.1.11",
+ "@radix-ui/react-primitive": "2.2.0",
+ "@radix-ui/react-slot": "1.5.0",
+ "@radix-ui/react-use-controllable-state": "1.3.0",
+ "@radix-ui/react-use-layout-effect": "1.1.4",
+ "aria-hidden": "^1.2.4",
+ "react-remove-scroll": "^2.7.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-popper": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.4.0.tgz",
+ "integrity": "sha512-yCWRvLeGv9Pz3+efwHqGPp70mZ56r3kfIgQVF56Oj2aAa4uP/Tb+IE59wNcc+758rctqbCKx4LbK8II/r7Bn0Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@floating-ui/react-dom": "^2.0.0",
+ "@radix-ui/react-arrow": "1.1.17",
+ "@radix-ui/react-compose-refs": "1.1.5",
+ "@radix-ui/react-context": "1.3.0",
+ "@radix-ui/react-primitive": "2.2.0",
+ "@radix-ui/react-use-callback-ref": "1.1.5",
+ "@radix-ui/react-use-layout-effect": "1.1.4",
+ "@radix-ui/react-use-rect": "1.1.4",
+ "@radix-ui/react-use-size": "1.1.5",
+ "@radix-ui/rect": "1.1.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-portal": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.2.0.tgz",
+ "integrity": "sha512-StX7xXnaRU41VvFtMm9zlhVGz8XF6GmWWTXftkQaY+tqVkbs/R9UmAp5mLzHHapk5hM9RMZQvO/3usyIP/g42g==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-primitive": "2.2.0",
+ "@radix-ui/react-use-layout-effect": "1.1.4"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-presence": {
+ "version": "1.1.11",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.11.tgz",
+ "integrity": "sha512-Z7/atlkab36Z8hm+V8zYc79r0W+tiOB1o0tun1By94JyadkekLQmtEAnBSP9XKXBNQsStoStS4+gX10JpS5akA==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-use-layout-effect": "1.1.4"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-primitive": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.2.0.tgz",
+ "integrity": "sha512-HCTSPMNqv55Qk0IX4geJbpyXazmqulcimwuyjxTkKi29v+QZbG2Zho9BnbBq4YS2PF+KlNb2CXoLz66uUmblkg==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-slot": "1.5.0"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-roving-focus": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.2.1.tgz",
+ "integrity": "sha512-UwraORzVUeEPMclhO1p0vz0qI+TbdDLO9i+FiOqhJyHKwdpCRCck9yVEscUnAMIzCiuNoQa8D4A9bIpOPXGt6Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.2.0",
+ "@radix-ui/react-collection": "1.2.0",
+ "@radix-ui/react-compose-refs": "1.1.5",
+ "@radix-ui/react-context": "1.3.0",
+ "@radix-ui/react-direction": "1.2.0",
+ "@radix-ui/react-id": "1.1.4",
+ "@radix-ui/react-primitive": "2.2.0",
+ "@radix-ui/react-use-callback-ref": "1.1.5",
+ "@radix-ui/react-use-controllable-state": "1.3.0",
+ "@radix-ui/react-use-is-hydrated": "0.1.3",
+ "@radix-ui/react-use-layout-effect": "1.1.4"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-select": {
+ "version": "2.4.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.4.1.tgz",
+ "integrity": "sha512-888ZwW0Md6GyptuqYABRX9nLKgoYfcooCyBBvVaX5Jp3e+8Jp2i4Zo/Uy50Hm+Ysn2JfloQXstW4U36w8teVtg==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/number": "1.1.3",
+ "@radix-ui/primitive": "1.2.0",
+ "@radix-ui/react-collection": "1.2.0",
+ "@radix-ui/react-compose-refs": "1.1.5",
+ "@radix-ui/react-context": "1.3.0",
+ "@radix-ui/react-direction": "1.2.0",
+ "@radix-ui/react-dismissable-layer": "1.2.1",
+ "@radix-ui/react-focus-guards": "1.2.0",
+ "@radix-ui/react-focus-scope": "1.3.1",
+ "@radix-ui/react-id": "1.1.4",
+ "@radix-ui/react-popper": "1.4.0",
+ "@radix-ui/react-portal": "1.2.0",
+ "@radix-ui/react-presence": "1.1.11",
+ "@radix-ui/react-primitive": "2.2.0",
+ "@radix-ui/react-slot": "1.5.0",
+ "@radix-ui/react-use-callback-ref": "1.1.5",
+ "@radix-ui/react-use-controllable-state": "1.3.0",
+ "@radix-ui/react-use-layout-effect": "1.1.4",
+ "@radix-ui/react-visually-hidden": "1.2.13",
+ "aria-hidden": "^1.2.4",
+ "react-remove-scroll": "^2.7.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-slot": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.5.0.tgz",
+ "integrity": "sha512-tyYd9ddPXAysAbdbD9eYxgwpCSv6f3PncqVSNKQFSR5lcJVLz3cb/pANDoYrLiHBpXv7skortyUgGUWsYuPPFQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.5"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-tabs": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.2.1.tgz",
+ "integrity": "sha512-hRoJlode+hg5ZNlR+3tkfkUliIybdTpD5lkAvHQ9yaXseGf7XVO9Ib2GPEIOuaJJbo7YWYbNpDDRfo8Dhf6/xA==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.2.0",
+ "@radix-ui/react-context": "1.3.0",
+ "@radix-ui/react-direction": "1.2.0",
+ "@radix-ui/react-id": "1.1.4",
+ "@radix-ui/react-presence": "1.1.11",
+ "@radix-ui/react-primitive": "2.2.0",
+ "@radix-ui/react-roving-focus": "1.2.1",
+ "@radix-ui/react-use-controllable-state": "1.3.0"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-tooltip": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.4.1.tgz",
+ "integrity": "sha512-NxLFGSFtZ78zVfcoOhTwR1xwqo5qx1Jh8Pw6FMZ9McCaqfRzz7/gIRahs2Oy5XlPgQ+8QkUcKbN5B0U44J/kCg==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.2.0",
+ "@radix-ui/react-compose-refs": "1.1.5",
+ "@radix-ui/react-context": "1.3.0",
+ "@radix-ui/react-dismissable-layer": "1.2.1",
+ "@radix-ui/react-focus-scope": "1.3.1",
+ "@radix-ui/react-id": "1.1.4",
+ "@radix-ui/react-popper": "1.4.0",
+ "@radix-ui/react-portal": "1.2.0",
+ "@radix-ui/react-presence": "1.1.11",
+ "@radix-ui/react-primitive": "2.2.0",
+ "@radix-ui/react-slot": "1.5.0",
+ "@radix-ui/react-use-controllable-state": "1.3.0",
+ "@radix-ui/react-use-layout-effect": "1.1.4",
+ "@radix-ui/react-visually-hidden": "1.2.13"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-use-callback-ref": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.5.tgz",
+ "integrity": "sha512-Y5GBf4NKeADoXI1geiMlMuMoURT3k9MizzzKhGb/m55H94aGigZ01PIpkvsO6lML13BA6XYAXSJ6gza7vTn5nw==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-use-layout-effect": "1.1.4"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-use-controllable-state": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.3.0.tgz",
+ "integrity": "sha512-TAcBI7bw2ljYCbv4+zuTSd6zICu8pRt6SDN2vJ/Z06K2MXh6I9WYFe/A9ovlFDtR5HVHGm5iDxDoABm/++dbKg==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.2.0",
+ "@radix-ui/react-use-effect-event": "0.0.5",
+ "@radix-ui/react-use-layout-effect": "1.1.4"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-use-effect-event": {
+ "version": "0.0.5",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.5.tgz",
+ "integrity": "sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-use-layout-effect": "1.1.4"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-use-is-hydrated": {
+ "version": "0.1.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.3.tgz",
+ "integrity": "sha512-umO/aJ+82CpOnhDZUTbILCQf7kU/g0iv+oGs/Q8jw7IkhWBzaEP4sA268PhFAJTFetbwp3ICc6ktpI4TqtxcIw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-use-layout-effect": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.4.tgz",
+ "integrity": "sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-use-rect": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.4.tgz",
+ "integrity": "sha512-cSOCh6JlkmfjLyNcLiu2nB4v+nm+dkZ+Q5KHWk/soo4U7ZLiEQFKHK9/YmtBHjfCEaU43IBKQOc4/uJmCaiCTQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/rect": "1.1.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-use-size": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.5.tgz",
+ "integrity": "sha512-V1McDQkMGzgivwqdu7u96APs7oi0Jvj9LTzgq3WWrDZqxWdAqU9yZnZQqk5OizGFR8badUQwZsrlao7MOlWYOA==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-use-layout-effect": "1.1.4"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-visually-hidden": {
+ "version": "1.2.13",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.13.tgz",
+ "integrity": "sha512-feijiN1oPk4rck4u9Dm9tsU4mT6Wp9oeEEhaz1WUEs1VuqLf8xzsFG+Qk+uWq6TnMH9O+WRECRg0NftyP9E4Fw==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-primitive": "2.2.0"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/rect": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.3.tgz",
+ "integrity": "sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==",
+ "license": "MIT"
+ },
+ "node_modules/@reduxjs/toolkit": {
+ "version": "2.13.0",
+ "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.13.0.tgz",
+ "integrity": "sha512-f6jwVRfwW30+tnllz4YcsHHpPYYOE7abcHkyUCovD+P7DyhdAI5prz2Fh/8TLhakjpN+7aIEion1s5L/tvlYlA==",
+ "license": "MIT",
+ "dependencies": {
+ "@standard-schema/spec": "^1.0.0",
+ "@standard-schema/utils": "^0.3.0",
+ "immer": "^11.0.0",
+ "redux": "^5.0.1",
+ "redux-thunk": "^3.1.0",
+ "reselect": "^5.1.0"
+ },
+ "peerDependencies": {
+ "react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
+ "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
+ },
+ "peerDependenciesMeta": {
+ "react": {
+ "optional": true
+ },
+ "react-redux": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@rolldown/binding-android-arm-eabi": {
+ "version": "1.2.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.15.tgz",
+ "integrity": "sha512-aLcgK9gIJ3YRvrrtfSBbRa27F5WrpiTaPulnFpsHPxpUHgFlSgnU4854MHwoRGEBhfRJDWCS2WQ7lIjskE9Olw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-android-arm64": {
+ "version": "1.2.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.15.tgz",
+ "integrity": "sha512-xD7DdZCrbThKuxNg7ooQmvp07ys4J/pnSEMTG3/OdOWl6daC67m/Q4fL78sRHMxTH/N4fGUbYhcPM0MF5WFHkg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-arm64": {
+ "version": "1.2.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.15.tgz",
+ "integrity": "sha512-m2WuQ6r0B4OTYxyPpNLtXaMRbRDUbG8YQV2Q3KTdSSK8Th6KHCnC8jVCJt8bvYeSB5WivkkNbc85mKadaSdOxA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-x64": {
+ "version": "1.2.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.15.tgz",
+ "integrity": "sha512-5RtnsHM36nodM9WbMy+zip0LGS/G+y5wro5RHrpOlr4ac24+1zSi9kiV2MP1myZeZ/ifEHfulQcWjnAXzc2C2A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-freebsd-x64": {
+ "version": "1.2.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.15.tgz",
+ "integrity": "sha512-g3l09pyFqhu+135JKkbkwiKGQmwU5hUMUPJ4VdBADvuqR8d33L6H578rh8m7rtxbdp+VmX/+nzI7nvhSZlzpUQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
+ "version": "1.2.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.15.tgz",
+ "integrity": "sha512-nIe3VugI97nGQtspPxDqA4sXMUpeiocKIP/CqpY/u/vgbT+alX+cVxu1C/oVBPKg4feNTmYovE91v4DgXvXNwQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-gnu": {
+ "version": "1.2.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.15.tgz",
+ "integrity": "sha512-iTBjXx+B5QSvgI89lJfmqh4wTh8NabR1YZIIN3hNx4zSG0EVfVqkOvLulNpG4251pbd6/IAmZ/LefVk1Y4rPhw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-musl": {
+ "version": "1.2.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.15.tgz",
+ "integrity": "sha512-uN26XOnVR7rqQupG3V9oAG2EGGatWjcJHOeDOHUFCh4TBWmP2tHTFoVN13aYbwfm0aHqJAEmcwhAu6sooIjfqQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-ppc64-gnu": {
+ "version": "1.2.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.15.tgz",
+ "integrity": "sha512-9nWkcdd+H+XhaA3fQIwpHnKIrfx/PQ6rO0pivr78Og07tXlUlctBBJoEYLPfhTmCR/QxEVdn6uPjHV5SymPhdw==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-s390x-gnu": {
+ "version": "1.2.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.15.tgz",
+ "integrity": "sha512-Ggh0ciFjLwIusLSLz7LZYEaGdxOAUJXI+Qu5JCbhPJJHEt8vP+IjRATTJheiJsdzzTYgUiHUiw3X1Ea3ZpMBdA==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-gnu": {
+ "version": "1.2.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.15.tgz",
+ "integrity": "sha512-FHg60rS6DBePAYZ9X9F67x+JObFdmgn6tupA1C/a3B+CufFmTD4mX+e8M75tCiAO3foR/6xwVBI0KQiI7sn/rw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-musl": {
+ "version": "1.2.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.15.tgz",
+ "integrity": "sha512-/cvV/7v5VGz/moiz0XKRj2dE8DzsKyhIu1KpOvJGPjRywg3yscON889UGvAe8zfiJLHup7VLjBduoGWi3HusWQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-openharmony-arm64": {
+ "version": "1.2.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.15.tgz",
+ "integrity": "sha512-MyzC5Hrv/sm+Z7TSGKP/j8001RO2xQm1zOpCNVL4j0cY/VhBZviludA2YFQ7i4lH6ZAXxQm0lcMdBzM+99mE7w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-arm64-msvc": {
+ "version": "1.2.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.15.tgz",
+ "integrity": "sha512-+PgYiS+AH5omy6L45ZLxybasauJaj3M8buoT83D+nzPmCayXlxUV9hydhciCKVyIg0B/v0BtXPDE1uGqNwCPpg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-x64-msvc": {
+ "version": "1.2.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.15.tgz",
+ "integrity": "sha512-HFZd2QNQYn1JbIi+Hpd7H+r/lkWxcfIu28nirZ6eCn2LafuvmExMlb/P96Sfx5tf7pPArw39IdhmCF8vmV+NhQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
+ "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@standard-schema/spec": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
+ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
+ "license": "MIT"
+ },
+ "node_modules/@standard-schema/utils": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
+ "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
+ "license": "MIT"
+ },
+ "node_modules/@tailwindcss/node": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz",
+ "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/remapping": "^2.3.5",
+ "enhanced-resolve": "^5.24.1",
+ "jiti": "^2.7.0",
+ "lightningcss": "1.32.0",
+ "magic-string": "^0.30.21",
+ "source-map-js": "^1.2.1",
+ "tailwindcss": "4.3.3"
+ }
+ },
+ "node_modules/@tailwindcss/oxide": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz",
+ "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 20"
+ },
+ "optionalDependencies": {
+ "@tailwindcss/oxide-android-arm64": "4.3.3",
+ "@tailwindcss/oxide-darwin-arm64": "4.3.3",
+ "@tailwindcss/oxide-darwin-x64": "4.3.3",
+ "@tailwindcss/oxide-freebsd-x64": "4.3.3",
+ "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3",
+ "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3",
+ "@tailwindcss/oxide-linux-arm64-musl": "4.3.3",
+ "@tailwindcss/oxide-linux-x64-gnu": "4.3.3",
+ "@tailwindcss/oxide-linux-x64-musl": "4.3.3",
+ "@tailwindcss/oxide-wasm32-wasi": "4.3.3",
+ "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3",
+ "@tailwindcss/oxide-win32-x64-msvc": "4.3.3"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-android-arm64": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz",
+ "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-darwin-arm64": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz",
+ "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-darwin-x64": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz",
+ "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-freebsd-x64": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz",
+ "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz",
+ "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz",
+ "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm64-musl": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz",
+ "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-x64-gnu": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz",
+ "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-x64-musl": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz",
+ "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-wasm32-wasi": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz",
+ "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==",
+ "bundleDependencies": [
+ "@napi-rs/wasm-runtime",
+ "@emnapi/core",
+ "@emnapi/runtime",
+ "@tybys/wasm-util",
+ "@emnapi/wasi-threads",
+ "tslib"
+ ],
+ "cpu": [
+ "wasm32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/core": "^1.11.1",
+ "@emnapi/runtime": "^1.11.1",
+ "@emnapi/wasi-threads": "^1.2.2",
+ "@napi-rs/wasm-runtime": "^1.1.4",
+ "@tybys/wasm-util": "^0.10.2",
+ "tslib": "^2.8.1"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz",
+ "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-win32-x64-msvc": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz",
+ "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/vite": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz",
+ "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@tailwindcss/node": "4.3.3",
+ "@tailwindcss/oxide": "4.3.3",
+ "tailwindcss": "4.3.3"
+ },
+ "peerDependencies": {
+ "vite": "^5.2.0 || ^6 || ^7 || ^8"
+ }
+ },
+ "node_modules/@tanstack/query-core": {
+ "version": "5.104.5",
+ "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.104.5.tgz",
+ "integrity": "sha512-Q94gI8Opd+CbdVNOdj8ZCyV8WqLYitrDq1t96Xc8WZT6lY0r5iRnJLXYDxMex7EEf2v+mXvn4FcjaBKSxXZQ9A==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ }
+ },
+ "node_modules/@tanstack/react-query": {
+ "version": "5.104.5",
+ "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.104.5.tgz",
+ "integrity": "sha512-RORgFe052U+93weaNuqg7qHq7vNVNzXN/VrXqD4bbAuQCWUqOl2lV5Ui9flWk5CVvMyL/Bh27QWhzwH0aaOx9g==",
+ "license": "MIT",
+ "dependencies": {
+ "@tanstack/query-core": "5.104.5"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ },
+ "peerDependencies": {
+ "@types/react": "^18 || ^19",
+ "react": "^18 || ^19"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@testing-library/dom": {
+ "version": "10.4.2",
+ "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.2.tgz",
+ "integrity": "sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@babel/code-frame": "^7.10.4",
+ "@babel/runtime": "^7.12.5",
+ "@types/aria-query": "^5.0.1",
+ "aria-query": "5.3.0",
+ "dom-accessibility-api": "^0.5.9",
+ "lz-string": "^1.5.0",
+ "picocolors": "1.1.1",
+ "pretty-format": "^27.0.2"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@testing-library/jest-dom": {
+ "version": "6.9.1",
+ "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz",
+ "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@adobe/css-tools": "^4.4.0",
+ "aria-query": "^5.0.0",
+ "css.escape": "^1.5.1",
+ "dom-accessibility-api": "^0.6.3",
+ "picocolors": "^1.1.1",
+ "redent": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=14",
+ "npm": ">=6",
+ "yarn": ">=1"
+ }
+ },
+ "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz",
+ "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@testing-library/react": {
+ "version": "16.3.3",
+ "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.3.tgz",
+ "integrity": "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.12.5"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@testing-library/dom": "^10.0.0",
+ "@types/react": "^18.0.0 || ^19.0.0",
+ "@types/react-dom": "^18.0.0 || ^19.0.0",
+ "react": "^18.0.0 || ^19.0.0",
+ "react-dom": "^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@testing-library/user-event": {
+ "version": "14.6.8",
+ "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.8.tgz",
+ "integrity": "sha512-vgSSqnANMnkcYR/vUNDbwS7aM3OErRLNUG8vCVVlj4oc6cKffUrccX7UAS5AmJQQNZTSibi9z5GvvRZ0aYgUCQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12",
+ "npm": ">=6"
+ },
+ "peerDependencies": {
+ "@testing-library/dom": ">=7.21.4"
+ }
+ },
+ "node_modules/@tweenjs/tween.js": {
+ "version": "23.1.3",
+ "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
+ "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/aria-query": {
+ "version": "5.0.4",
+ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
+ "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/@types/chai": {
+ "version": "5.2.3",
+ "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
+ "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/deep-eql": "*",
+ "assertion-error": "^2.0.1"
+ }
+ },
+ "node_modules/@types/d3-array": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
+ "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-color": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
+ "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-drag": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz",
+ "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-selection": "*"
+ }
+ },
+ "node_modules/@types/d3-ease": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
+ "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-interpolate": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
+ "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-color": "*"
+ }
+ },
+ "node_modules/@types/d3-path": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
+ "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-scale": {
+ "version": "4.0.9",
+ "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
+ "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-time": "*"
+ }
+ },
+ "node_modules/@types/d3-selection": {
+ "version": "3.0.12",
+ "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.12.tgz",
+ "integrity": "sha512-Qe/KWYhEiIIxGs7HrAAjMfShxKldx19SJtr5zu53f3afPsdZNz7HHtdTLXo/kqeiWNXVycI24kSnfzBYkTzpgw==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-shape": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.2.0.tgz",
+ "integrity": "sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-path": "*"
+ }
+ },
+ "node_modules/@types/d3-time": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
+ "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-timer": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
+ "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-transition": {
+ "version": "3.0.9",
+ "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
+ "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-selection": "*"
+ }
+ },
+ "node_modules/@types/d3-zoom": {
+ "version": "3.0.9",
+ "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.9.tgz",
+ "integrity": "sha512-0sE1406XBYJGiqD3AusTl9ZqC//2mIXix51tbom25gDCA8ri4xnSZg28CaSE8Srl6FClqABUKfsn0qghgdepMA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-interpolate": "*",
+ "@types/d3-selection": "*"
+ }
+ },
+ "node_modules/@types/deep-eql": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
+ "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/node": {
+ "version": "26.6.5",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.5.tgz",
+ "integrity": "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~8.9.0"
+ }
+ },
+ "node_modules/@types/react": {
+ "version": "19.3.0",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz",
+ "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==",
+ "devOptional": true,
+ "license": "MIT",
+ "dependencies": {
+ "csstype": "^3.2.2"
+ }
+ },
+ "node_modules/@types/react-dom": {
+ "version": "19.3.0",
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz",
+ "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==",
+ "devOptional": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "^19.3.0"
+ }
+ },
+ "node_modules/@types/stats.js": {
+ "version": "0.17.4",
+ "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz",
+ "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/three": {
+ "version": "0.186.0",
+ "resolved": "https://registry.npmjs.org/@types/three/-/three-0.186.0.tgz",
+ "integrity": "sha512-mxYSBpDC+D0pLfSP6sW4WZTcT+nrtmZcimMqnVmy36Hte3XpeYSrvgg4TRdaM1GemGog1AWzI5qL2VoIfMXbJQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@dimforge/rapier3d-compat": "~0.12.0",
+ "@tweenjs/tween.js": "~23.1.3",
+ "@types/stats.js": "*",
+ "@types/webxr": ">=0.5.17",
+ "fflate": "~0.8.3",
+ "meshoptimizer": "~1.1.1"
+ }
+ },
+ "node_modules/@types/use-sync-external-store": {
+ "version": "0.0.6",
+ "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
+ "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
+ "license": "MIT"
+ },
+ "node_modules/@types/webxr": {
+ "version": "0.5.24",
+ "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
+ "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@typescript/typescript-aix-ppc64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz",
+ "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-darwin-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz",
+ "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-darwin-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz",
+ "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-freebsd-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz",
+ "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-freebsd-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz",
+ "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-arm": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz",
+ "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz",
+ "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-loong64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz",
+ "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-mips64el": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz",
+ "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-ppc64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz",
+ "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-riscv64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz",
+ "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-s390x": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz",
+ "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz",
+ "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-netbsd-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz",
+ "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-netbsd-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz",
+ "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-openbsd-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz",
+ "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-openbsd-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz",
+ "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-sunos-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz",
+ "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-win32-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz",
+ "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-win32-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz",
+ "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@vitejs/plugin-react": {
+ "version": "6.1.2",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.2.tgz",
+ "integrity": "sha512-fhJNGUdscQa4HkI0xFxwonWyhnVReCPDbXI7FH7jmzpnVCrFQI7PRrhWecO263610vOrs9tr2Y4xcf8uAdFxbw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@rolldown/pluginutils": "^1.0.1"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "peerDependencies": {
+ "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0",
+ "babel-plugin-react-compiler": "^1.0.0",
+ "oxc-transform-react": "^0.152.0",
+ "vite": "^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@rolldown/plugin-babel": {
+ "optional": true
+ },
+ "babel-plugin-react-compiler": {
+ "optional": true
+ },
+ "oxc-transform-react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/mocker": {
+ "version": "5.0.5",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.5.tgz",
+ "integrity": "sha512-xLL3lkDegJZw+MGoGaCCsFayDZthGgpAtr3Vziyfc4p7xH89K7KRPcRBBh/kEYm0Aqkt5DtTmkYpBtRV/FrHZg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/trace-mapping": "0.3.31",
+ "@vitest/spy": "5.0.5",
+ "estree-walker": "^3.0.3",
+ "magic-string": "^1.4.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "msw": "^2.4.9 || ^3.0.0",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "msw": {
+ "optional": true
+ },
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/mocker/node_modules/magic-string": {
+ "version": "1.4.3",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.3.tgz",
+ "integrity": "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.6.0"
+ }
+ },
+ "node_modules/@vitest/spy": {
+ "version": "5.0.5",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.5.tgz",
+ "integrity": "sha512-lwJRExuH417257Q5wv9QYugN9IMJkoxcrKrYxGPQkBzl7wlNpTUxD/5yNX4ESKVUIShKPgkGchYPFMWOOk0wzw==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@xyflow/react": {
+ "version": "12.12.0",
+ "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.12.0.tgz",
+ "integrity": "sha512-74oXI0Rgm1Eu33cHQAd74J6oVgCH9mbFyTVQbsGgp2kmWwh0HdtBr2VP04z8mbPD+M/xO69nqG5dDEt7/EN2rQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@xyflow/system": "0.0.83",
+ "classcat": "^5.0.3",
+ "zustand": "^4.4.0"
+ },
+ "peerDependencies": {
+ "@types/react": ">=17",
+ "@types/react-dom": ">=17",
+ "react": ">=17",
+ "react-dom": ">=17"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@xyflow/react/node_modules/zustand": {
+ "version": "4.5.7",
+ "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
+ "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
+ "license": "MIT",
+ "dependencies": {
+ "use-sync-external-store": "^1.2.2"
+ },
+ "engines": {
+ "node": ">=12.7.0"
+ },
+ "peerDependencies": {
+ "@types/react": ">=16.8",
+ "immer": ">=9.0.6",
+ "react": ">=16.8"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "immer": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@xyflow/system": {
+ "version": "0.0.83",
+ "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.83.tgz",
+ "integrity": "sha512-a//eDjez3WZF47aaPTfVUDD0OINOq+WVwBZCRTqG+m0a5euX8EwlJ4jMY1Wxz0Ocj4xOeekJ70V22Pq0ll8RJw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-drag": "^3.0.7",
+ "@types/d3-interpolate": "^3.0.4",
+ "@types/d3-selection": "^3.0.10",
+ "@types/d3-transition": "^3.0.8",
+ "@types/d3-zoom": "^3.0.8",
+ "d3-drag": "^3.0.0",
+ "d3-interpolate": "^3.0.1",
+ "d3-selection": "^3.0.0",
+ "d3-zoom": "^3.0.0"
+ }
+ },
+ "node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/ansi-styles": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
+ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/aria-hidden": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz",
+ "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/aria-query": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
+ "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "dequal": "^2.0.3"
+ }
+ },
+ "node_modules/assertion-error": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
+ "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/bidi-js": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz",
+ "integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "require-from-string": "^2.0.2"
+ }
+ },
+ "node_modules/chai": {
+ "version": "6.3.0",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz",
+ "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/class-variance-authority": {
+ "version": "0.7.1",
+ "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz",
+ "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "clsx": "^2.1.1"
+ },
+ "funding": {
+ "url": "https://polar.sh/cva"
+ }
+ },
+ "node_modules/classcat": {
+ "version": "5.0.5",
+ "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz",
+ "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==",
+ "license": "MIT"
+ },
+ "node_modules/clsx": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
+ "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/cmdk": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz",
+ "integrity": "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "^1.1.1",
+ "@radix-ui/react-dialog": "^1.1.6",
+ "@radix-ui/react-id": "^1.1.0",
+ "@radix-ui/react-primitive": "^2.0.2"
+ },
+ "peerDependencies": {
+ "react": "^18 || ^19 || ^19.0.0-rc",
+ "react-dom": "^18 || ^19 || ^19.0.0-rc"
+ }
+ },
+ "node_modules/cookie": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
+ "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/css-tree": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
+ "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "mdn-data": "2.27.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
+ }
+ },
+ "node_modules/css.escape": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
+ "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "devOptional": true,
+ "license": "MIT"
+ },
+ "node_modules/d3-array": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
+ "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
+ "license": "ISC",
+ "dependencies": {
+ "internmap": "1 - 2"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-color": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
+ "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-dispatch": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
+ "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-drag": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
+ "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-dispatch": "1 - 3",
+ "d3-selection": "3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-ease": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
+ "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-format": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
+ "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-interpolate": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
+ "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-color": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-path": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
+ "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-scale": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
+ "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-array": "2.10.0 - 3",
+ "d3-format": "1 - 3",
+ "d3-interpolate": "1.2.0 - 3",
+ "d3-time": "2.1.1 - 3",
+ "d3-time-format": "2 - 4"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-selection": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
+ "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-shape": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
+ "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-path": "^3.1.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-time": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
+ "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-array": "2 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-time-format": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
+ "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-time": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-timer": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
+ "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-transition": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
+ "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-color": "1 - 3",
+ "d3-dispatch": "1 - 3",
+ "d3-ease": "1 - 3",
+ "d3-interpolate": "1 - 3",
+ "d3-timer": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "peerDependencies": {
+ "d3-selection": "2 - 3"
+ }
+ },
+ "node_modules/d3-zoom": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
+ "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-dispatch": "1 - 3",
+ "d3-drag": "2 - 3",
+ "d3-interpolate": "1 - 3",
+ "d3-selection": "2 - 3",
+ "d3-transition": "2 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/data-urls": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-8.0.0.tgz",
+ "integrity": "sha512-JQUCCK+/CAGbGGx618PlFZMXH81Jtj7t73CHv9dd/zVWvxgGT2zLFRjnKb+Ng46GIwcy+f7f88qd4duqllK+WQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "whatwg-mimetype": "^5.0.0",
+ "whatwg-url": "^17.1.2"
+ },
+ "engines": {
+ "node": "^22.14.0 || >=24.0.0"
+ }
+ },
+ "node_modules/decimal.js": {
+ "version": "10.6.0",
+ "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
+ "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/decimal.js-light": {
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
+ "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
+ "license": "MIT"
+ },
+ "node_modules/dequal": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
+ "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/detect-node-es": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz",
+ "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
+ "license": "MIT"
+ },
+ "node_modules/dom-accessibility-api": {
+ "version": "0.5.16",
+ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
+ "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/enhanced-resolve": {
+ "version": "5.26.0",
+ "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.26.0.tgz",
+ "integrity": "sha512-9vhedylFonb2YGogzUKX6+Ja72gOJbN1QHAqdrvqLwhdl/QWbKopzoUC9EbQNVsAns/bx/4uyqalrQAoy1IByw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "graceful-fs": "^4.2.4",
+ "tapable": "^2.3.3"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/entities": {
+ "version": "8.1.0",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz",
+ "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/es-module-lexer": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz",
+ "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/es-toolkit": {
+ "version": "1.52.0",
+ "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.52.0.tgz",
+ "integrity": "sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA==",
+ "license": "MIT",
+ "workspaces": [
+ "docs",
+ "benchmarks",
+ "tests/types",
+ "tests/browser-compat"
+ ]
+ },
+ "node_modules/estree-walker": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0"
+ }
+ },
+ "node_modules/eventemitter3": {
+ "version": "5.0.4",
+ "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
+ "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
+ "license": "MIT"
+ },
+ "node_modules/expect-type": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
+ "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "node_modules/fake-indexeddb": {
+ "version": "6.2.6",
+ "resolved": "https://registry.npmjs.org/fake-indexeddb/-/fake-indexeddb-6.2.6.tgz",
+ "integrity": "sha512-ihhmr0KoET51jT8R4YhbUYuLLvr1qqO6INISygtN16i8loR8kAxtAUr4+EKHTSMjL+lggaAn4OC7DRqzrNgnuQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fflate": {
+ "version": "0.8.3",
+ "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
+ "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/framer-motion": {
+ "version": "14.6.0",
+ "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-14.6.0.tgz",
+ "integrity": "sha512-MzBgZxGgoHPfSLEXmo4o5XlZvb+nVRFdHtGjVhm6LMIEJKTt/8hNO023euL5mYyzc8llV/hQTfjnN68tNRHalw==",
+ "license": "MIT",
+ "dependencies": {
+ "motion-dom": "14.6.0",
+ "motion-utils": "14.6.0",
+ "tslib": "^2.4.0"
+ },
+ "peerDependencies": {
+ "react": "^18.0.0 || ^19.0.0",
+ "react-dom": "^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "react": {
+ "optional": true
+ },
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/get-nonce": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz",
+ "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/graceful-fs": {
+ "version": "4.2.11",
+ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
+ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/gsap": {
+ "version": "3.15.0",
+ "resolved": "https://registry.npmjs.org/gsap/-/gsap-3.15.0.tgz",
+ "integrity": "sha512-dMW4CWBTUK1AEEDeZc1g4xpPGIrSf9fJF960qbTZmN/QwZIWY5wgliS6JWl9/25fpTGJrMRtSjGtOmPnfjZB+A==",
+ "license": "Standard 'no charge' license: https://gsap.com/standard-license."
+ },
+ "node_modules/html-encoding-sniffer": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-7.0.0.tgz",
+ "integrity": "sha512-UikN5yr7xsCDAq87Or5or0PAlD3HJJOKVzM05az588WnpDJ4Ux7a2A53Qi6gofGg2/EtvF/H4hCi/TXfCW4Y6w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@exodus/bytes": "^1.15.1"
+ },
+ "engines": {
+ "node": "^22.13.0 || >=24.0.0"
+ }
+ },
+ "node_modules/idb": {
+ "version": "8.0.4",
+ "resolved": "https://registry.npmjs.org/idb/-/idb-8.0.4.tgz",
+ "integrity": "sha512-jd0ITCrN+Zah4J7R/Z2jnR3W7A4BXDAjmo62MwWuSjxCiueUAf7X5rbodEdidWzeknwXHmLpdZkoiiCppNi30Q==",
+ "license": "ISC"
+ },
+ "node_modules/immer": {
+ "version": "11.2.9",
+ "resolved": "https://registry.npmjs.org/immer/-/immer-11.2.9.tgz",
+ "integrity": "sha512-IQ4UEtXjZjyefyWjtLEDXUSrF6T2hwjKPi6BIJl3bY/2XXW24d6cNGdXJSBOA/+KNnuiXkZY6DR0C3efolhitA==",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/immer"
+ }
+ },
+ "node_modules/indent-string": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
+ "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/internmap": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
+ "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/is-potential-custom-element-name": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
+ "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/jiti": {
+ "version": "2.7.0",
+ "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
+ "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jiti": "lib/jiti-cli.mjs"
+ }
+ },
+ "node_modules/js-tokens": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/jsdom": {
+ "version": "30.1.3",
+ "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.1.3.tgz",
+ "integrity": "sha512-hQMUPvRgN7KxsfIytDQji7bimuJeRQa6Qo5+iuiZQk5CafeRxLIA10/cG5YCBFNpD9RO3oqIiN4CO1Y44kxqXg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@asamuzakjp/css-color": "^7.1.3",
+ "@asamuzakjp/dom-selector": "^10.0.3",
+ "@bramus/specificity": "^2.4.2",
+ "@csstools/css-syntax-patches-for-csstree": "^1.1.15",
+ "@exodus/bytes": "^1.16.0",
+ "css-tree": "^3.2.1",
+ "data-urls": "^8.0.0",
+ "decimal.js": "^10.6.0",
+ "html-encoding-sniffer": "^7.0.0",
+ "is-potential-custom-element-name": "^1.0.1",
+ "lru-cache": "^11.5.3",
+ "parse5": "^8.0.1",
+ "saxes": "^6.0.0",
+ "tough-cookie": "^6.0.2",
+ "undici": "^8.12.1",
+ "w3c-xmlserializer": "^6.0.0",
+ "webidl-conversions": "^8.0.1",
+ "whatwg-mimetype": "^5.0.0",
+ "whatwg-url": "^17.2.0",
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": "^22.22.2 || ^24.15.0 || >=26.0.0"
+ },
+ "peerDependencies": {
+ "canvas": "^3.2.3"
+ },
+ "peerDependenciesMeta": {
+ "canvas": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/lenis": {
+ "version": "1.3.27",
+ "resolved": "https://registry.npmjs.org/lenis/-/lenis-1.3.27.tgz",
+ "integrity": "sha512-QIHNH4LIfWyLfgH/5K0aTGZgyFJJ7GfHNcaH/AIgyKjb8gstLh5siNGvrVqz1w5ClLXzOvllBVk7vby8JzUVPQ==",
+ "license": "MIT",
+ "workspaces": [
+ "packages/*",
+ "playground",
+ "playground/*"
+ ],
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/darkroomengineering"
+ },
+ "peerDependencies": {
+ "@nuxt/kit": ">=3.0.0",
+ "react": ">=17.0.0",
+ "vue": ">=3.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@nuxt/kit": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ },
+ "vue": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/lightningcss": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
+ "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
+ "dev": true,
+ "license": "MPL-2.0",
+ "dependencies": {
+ "detect-libc": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ },
+ "optionalDependencies": {
+ "lightningcss-android-arm64": "1.32.0",
+ "lightningcss-darwin-arm64": "1.32.0",
+ "lightningcss-darwin-x64": "1.32.0",
+ "lightningcss-freebsd-x64": "1.32.0",
+ "lightningcss-linux-arm-gnueabihf": "1.32.0",
+ "lightningcss-linux-arm64-gnu": "1.32.0",
+ "lightningcss-linux-arm64-musl": "1.32.0",
+ "lightningcss-linux-x64-gnu": "1.32.0",
+ "lightningcss-linux-x64-musl": "1.32.0",
+ "lightningcss-win32-arm64-msvc": "1.32.0",
+ "lightningcss-win32-x64-msvc": "1.32.0"
+ }
+ },
+ "node_modules/lightningcss-android-arm64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
+ "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-arm64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
+ "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-x64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
+ "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-freebsd-x64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
+ "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm-gnueabihf": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
+ "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-gnu": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
+ "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-musl": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
+ "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-gnu": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
+ "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-musl": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
+ "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-arm64-msvc": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
+ "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-x64-msvc": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
+ "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lru-cache": {
+ "version": "11.5.3",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz",
+ "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
+ "node_modules/lucide-react": {
+ "version": "1.61.0",
+ "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.61.0.tgz",
+ "integrity": "sha512-v2SQPPPNjnr+BlhL9Spuzmo5yR+hLkftfuE4E3jaxdNPgSH0hJKPcLJBP7yAta87jQNPsSERS59PJjCJ/CorBA==",
+ "license": "ISC",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/lz-string": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
+ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "bin": {
+ "lz-string": "bin/bin.js"
+ }
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/mdn-data": {
+ "version": "2.27.1",
+ "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
+ "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
+ "dev": true,
+ "license": "CC0-1.0"
+ },
+ "node_modules/meshoptimizer": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.1.1.tgz",
+ "integrity": "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/min-indent": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
+ "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/motion-dom": {
+ "version": "14.6.0",
+ "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-14.6.0.tgz",
+ "integrity": "sha512-lIojTl9aYSPIkeOOSzBliy1nNvpdO3JtVzW2WgTXQlOP4ZMuJhtWO3fP7OD4TVFwlRucdvxlDSMkL+fEQnQ52A==",
+ "license": "MIT",
+ "dependencies": {
+ "motion-utils": "14.6.0"
+ }
+ },
+ "node_modules/motion-utils": {
+ "version": "14.6.0",
+ "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-14.6.0.tgz",
+ "integrity": "sha512-bZJo2AmQ2aOFkaato6VXrTbkPG0XhMzivWUWSaI59pxyllj8QMQUFZ84MYRQnPkK+7dr4KldyJwF2+RM0sdq/w==",
+ "license": "MIT"
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.20",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz",
+ "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/obug": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/obug/-/obug-3.0.0.tgz",
+ "integrity": "sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==",
+ "dev": true,
+ "funding": [
+ "https://github.com/sponsors/sxzz",
+ "https://opencollective.com/debug"
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.20.0"
+ }
+ },
+ "node_modules/parse5": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
+ "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "entities": "^8.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/inikulin/parse5?sponsor=1"
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
+ "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.31",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.31.tgz",
+ "integrity": "sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.20",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.2"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/pretty-format": {
+ "version": "27.5.1",
+ "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
+ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "ansi-regex": "^5.0.1",
+ "ansi-styles": "^5.0.0",
+ "react-is": "^17.0.1"
+ },
+ "engines": {
+ "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
+ }
+ },
+ "node_modules/pretty-format/node_modules/react-is": {
+ "version": "17.0.2",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
+ "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/punycode": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
+ "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/react": {
+ "version": "19.3.1",
+ "resolved": "https://registry.npmjs.org/react/-/react-19.3.1.tgz",
+ "integrity": "sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "19.3.1",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.1.tgz",
+ "integrity": "sha512-Y0bARz53lhEC1NMCASs0/j+faB/zqt3R8hpvg1e8+61ZVJ4UL0UdC4cBbXfZMcIuhrfgeILTLe+ZeBCavQPtKQ==",
+ "license": "MIT",
+ "dependencies": {
+ "scheduler": "^0.28.0"
+ },
+ "peerDependencies": {
+ "react": "^19.3.1"
+ }
+ },
+ "node_modules/react-is": {
+ "version": "19.3.1",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.3.1.tgz",
+ "integrity": "sha512-RRieF6IyxQBkcK6pP8xOHprLTPW6QjRkbGdImtqNme+/hYVwCZkLQLdlZGOhUqXwkS9IEynaGvPJtv6XB/cz0Q==",
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/react-redux": {
+ "version": "9.3.0",
+ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz",
+ "integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/use-sync-external-store": "^0.0.6",
+ "use-sync-external-store": "^1.4.0"
+ },
+ "peerDependencies": {
+ "@types/react": "^18.2.25 || ^19",
+ "react": "^18.0 || ^19",
+ "redux": "^5.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "redux": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/react-remove-scroll": {
+ "version": "2.7.2",
+ "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
+ "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==",
+ "license": "MIT",
+ "dependencies": {
+ "react-remove-scroll-bar": "^2.3.7",
+ "react-style-singleton": "^2.2.3",
+ "tslib": "^2.1.0",
+ "use-callback-ref": "^1.3.3",
+ "use-sidecar": "^1.1.3"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/react-remove-scroll-bar": {
+ "version": "2.3.8",
+ "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz",
+ "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==",
+ "license": "MIT",
+ "dependencies": {
+ "react-style-singleton": "^2.2.2",
+ "tslib": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/react-router": {
+ "version": "7.18.4",
+ "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.4.tgz",
+ "integrity": "sha512-PUPQcMhMGRAslLcvtlPz/kmzBEWPhLdgLFrL7pLNepBL6dX0lWj4WD2cUYVgYCuT3jxvghYFg81cDTj44DhetQ==",
+ "license": "MIT",
+ "dependencies": {
+ "cookie": "^1.0.1",
+ "set-cookie-parser": "^2.6.0"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=18",
+ "react-dom": ">=18"
+ },
+ "peerDependenciesMeta": {
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/react-router-dom": {
+ "version": "7.18.4",
+ "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.4.tgz",
+ "integrity": "sha512-yrfmJHIpDG7taCpqKjT1G5B6q3O2K+RN8/fgNf0lTjCwiPbQ0ei6vXX9ZjQR+7ld8Tr7Z5xmyMnZ8YJrphWQUw==",
+ "license": "MIT",
+ "dependencies": {
+ "react-router": "7.18.4"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=18",
+ "react-dom": ">=18"
+ }
+ },
+ "node_modules/react-style-singleton": {
+ "version": "2.2.3",
+ "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
+ "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==",
+ "license": "MIT",
+ "dependencies": {
+ "get-nonce": "^1.0.0",
+ "tslib": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/recharts": {
+ "version": "3.10.1",
+ "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.10.1.tgz",
+ "integrity": "sha512-QXFrvt6IVcw7eeZCoyXTwkIJAX3Dv1nyVhMicXJ47GsGDDpcN8z6o644DibE9XjpBTThtsomLKnTV6lc+cVFUA==",
+ "license": "MIT",
+ "workspaces": [
+ "www"
+ ],
+ "dependencies": {
+ "@reduxjs/toolkit": "^1.9.0 || 2.x.x",
+ "clsx": "^2.1.1",
+ "decimal.js-light": "^2.5.1",
+ "es-toolkit": "^1.39.3",
+ "eventemitter3": "^5.0.1",
+ "immer": "^11.1.8",
+ "react-redux": "8.x.x || 9.x.x",
+ "reselect": "5.2.0",
+ "tiny-invariant": "^1.3.3",
+ "use-sync-external-store": "^1.2.2",
+ "victory-vendor": "^37.0.2"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/redent": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
+ "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "indent-string": "^4.0.0",
+ "strip-indent": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/redux": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
+ "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
+ "license": "MIT"
+ },
+ "node_modules/redux-thunk": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
+ "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "redux": "^5.0.0"
+ }
+ },
+ "node_modules/require-from-string": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
+ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/reselect": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz",
+ "integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==",
+ "license": "MIT"
+ },
+ "node_modules/rolldown": {
+ "version": "1.2.15",
+ "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.15.tgz",
+ "integrity": "sha512-6odND+fel5zVw3I4Yc2e69gvguDnX0CAAGiNDZSq4ik5WUcaLrkD2GLuog0XcODbor31hZ5ayEMgVDno+KPV5g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@oxc-project/types": "=0.155.0",
+ "@rolldown/pluginutils": "^1.0.0"
+ },
+ "bin": {
+ "rolldown": "bin/cli.mjs"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "optionalDependencies": {
+ "@rolldown/binding-android-arm-eabi": "1.2.15",
+ "@rolldown/binding-android-arm64": "1.2.15",
+ "@rolldown/binding-darwin-arm64": "1.2.15",
+ "@rolldown/binding-darwin-x64": "1.2.15",
+ "@rolldown/binding-freebsd-x64": "1.2.15",
+ "@rolldown/binding-linux-arm-gnueabihf": "1.2.15",
+ "@rolldown/binding-linux-arm64-gnu": "1.2.15",
+ "@rolldown/binding-linux-arm64-musl": "1.2.15",
+ "@rolldown/binding-linux-ppc64-gnu": "1.2.15",
+ "@rolldown/binding-linux-s390x-gnu": "1.2.15",
+ "@rolldown/binding-linux-x64-gnu": "1.2.15",
+ "@rolldown/binding-linux-x64-musl": "1.2.15",
+ "@rolldown/binding-openharmony-arm64": "1.2.15",
+ "@rolldown/binding-win32-arm64-msvc": "1.2.15",
+ "@rolldown/binding-win32-x64-msvc": "1.2.15"
+ }
+ },
+ "node_modules/saxes": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
+ "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "xmlchars": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=v12.22.7"
+ }
+ },
+ "node_modules/scheduler": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz",
+ "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==",
+ "license": "MIT"
+ },
+ "node_modules/set-cookie-parser": {
+ "version": "2.7.2",
+ "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
+ "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
+ "license": "MIT"
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
+ "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/std-env": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz",
+ "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/strip-indent": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
+ "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "min-indent": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/tailwind-merge": {
+ "version": "3.7.0",
+ "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.7.0.tgz",
+ "integrity": "sha512-XPPUyAc+cvspz3lHTcR/QgPfW2A0lv/xQNIjX3HGhLR+Nq2lHaLq5MtTesHn8GUr3W3DguT2KT5x3NVgRtYwmA==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/dcastil"
+ }
+ },
+ "node_modules/tailwindcss": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz",
+ "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tapable": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz",
+ "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ }
+ },
+ "node_modules/three": {
+ "version": "0.186.1",
+ "resolved": "https://registry.npmjs.org/three/-/three-0.186.1.tgz",
+ "integrity": "sha512-blFeqb49wRCSGUGj7gtpfnSGHy2lwDk94RhUmS1c/hTby70kvChbWpkJ4Pm1390LqzzvTmzgXKHPEafJwCb8jA==",
+ "license": "MIT"
+ },
+ "node_modules/tiny-invariant": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
+ "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
+ "license": "MIT"
+ },
+ "node_modules/tinybench": {
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.2.tgz",
+ "integrity": "sha512-nIZ2N2dFpjBsg2hgitKu7mA+FvsIJuGJOwysRngmVRRmWgR/UyPjwJj8c/fT/dhm8EkHnF5EOogRf+0Z2qrimA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/tinyexec": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz",
+ "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.17",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/tldts": {
+ "version": "7.4.19",
+ "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.19.tgz",
+ "integrity": "sha512-48dH6aSpQnpbzDUc4vIQZ1VjB8tkK+e8c1yx05/SZK+rkssgjEOlxDd/yU8/a4vGYQ9vRgJr9dwMq9TJ0b3X2Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tldts-core": "^7.4.19"
+ },
+ "bin": {
+ "tldts": "bin/cli.js"
+ }
+ },
+ "node_modules/tldts-core": {
+ "version": "7.4.19",
+ "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.19.tgz",
+ "integrity": "sha512-goNXV6Ho9TIOfpJZanTd6CKG5DyYgYAL+k+22T0SLh8l6+KHIfBUPFuePuSZv05UttIaaLmI9tb7TmGIdK5AkQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tough-cookie": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz",
+ "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "tldts": "^7.0.5"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/tr46": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/tr46/-/tr46-7.0.0.tgz",
+ "integrity": "sha512-BcDSDW+SIGWVOns1Zm6Yg91q9AEj7eaID0QDY9oIqJD1A8dscNwMcl9Sms2DeuWCNov1X0/oXanHGYQBSLwZsg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "punycode": "^2.3.1"
+ },
+ "engines": {
+ "node": "^22.14.0 || >=24.0.0"
+ }
+ },
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "license": "0BSD"
+ },
+ "node_modules/typescript": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
+ "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc"
+ },
+ "engines": {
+ "node": ">=16.20.0"
+ },
+ "optionalDependencies": {
+ "@typescript/typescript-aix-ppc64": "7.0.2",
+ "@typescript/typescript-darwin-arm64": "7.0.2",
+ "@typescript/typescript-darwin-x64": "7.0.2",
+ "@typescript/typescript-freebsd-arm64": "7.0.2",
+ "@typescript/typescript-freebsd-x64": "7.0.2",
+ "@typescript/typescript-linux-arm": "7.0.2",
+ "@typescript/typescript-linux-arm64": "7.0.2",
+ "@typescript/typescript-linux-loong64": "7.0.2",
+ "@typescript/typescript-linux-mips64el": "7.0.2",
+ "@typescript/typescript-linux-ppc64": "7.0.2",
+ "@typescript/typescript-linux-riscv64": "7.0.2",
+ "@typescript/typescript-linux-s390x": "7.0.2",
+ "@typescript/typescript-linux-x64": "7.0.2",
+ "@typescript/typescript-netbsd-arm64": "7.0.2",
+ "@typescript/typescript-netbsd-x64": "7.0.2",
+ "@typescript/typescript-openbsd-arm64": "7.0.2",
+ "@typescript/typescript-openbsd-x64": "7.0.2",
+ "@typescript/typescript-sunos-x64": "7.0.2",
+ "@typescript/typescript-win32-arm64": "7.0.2",
+ "@typescript/typescript-win32-x64": "7.0.2"
+ }
+ },
+ "node_modules/undici": {
+ "version": "8.12.3",
+ "resolved": "https://registry.npmjs.org/undici/-/undici-8.12.3.tgz",
+ "integrity": "sha512-+fdndUTwBP86cVjihTp21hZHo7d6oj1c/47XPA3lL8mM0XHs+C4EkcjmgRsFnFk2f/UONGDy9ItBItmXsLOQHw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=22.19.0"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "8.9.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz",
+ "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/use-callback-ref": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz",
+ "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/use-sidecar": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz",
+ "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==",
+ "license": "MIT",
+ "dependencies": {
+ "detect-node-es": "^1.1.0",
+ "tslib": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/use-sync-external-store": {
+ "version": "1.7.0",
+ "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.7.0.tgz",
+ "integrity": "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/victory-vendor": {
+ "version": "37.3.6",
+ "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
+ "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
+ "license": "MIT AND ISC",
+ "dependencies": {
+ "@types/d3-array": "^3.0.3",
+ "@types/d3-ease": "^3.0.0",
+ "@types/d3-interpolate": "^3.0.1",
+ "@types/d3-scale": "^4.0.2",
+ "@types/d3-shape": "^3.1.0",
+ "@types/d3-time": "^3.0.0",
+ "@types/d3-timer": "^3.0.0",
+ "d3-array": "^3.1.6",
+ "d3-ease": "^3.0.1",
+ "d3-interpolate": "^3.0.1",
+ "d3-scale": "^4.0.2",
+ "d3-shape": "^3.1.0",
+ "d3-time": "^3.0.0",
+ "d3-timer": "^3.0.1"
+ }
+ },
+ "node_modules/vite": {
+ "version": "8.3.5",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.5.tgz",
+ "integrity": "sha512-z1YpjieJ9g0ebTmQpLBv/oq9E3F9EIlOe+7LahW8SrE3LTiG3udBRUXQOlYXP9b+IC2q2phHeVxj9xGlw34OEQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "lightningcss": "^1.33.0",
+ "picomatch": "^4.0.7",
+ "postcss": "^8.5.30",
+ "rolldown": "~1.2.14",
+ "tinyglobby": "^0.2.17"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "@vitejs/devtools": "^0.7.1 || ^0.8.0",
+ "esbuild": "^0.27.0 || ^0.28.0",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "@vitejs/devtools": {
+ "optional": true
+ },
+ "esbuild": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vite/node_modules/lightningcss": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
+ "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==",
+ "dev": true,
+ "license": "MPL-2.0",
+ "dependencies": {
+ "detect-libc": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ },
+ "optionalDependencies": {
+ "lightningcss-android-arm64": "1.33.0",
+ "lightningcss-darwin-arm64": "1.33.0",
+ "lightningcss-darwin-x64": "1.33.0",
+ "lightningcss-freebsd-x64": "1.33.0",
+ "lightningcss-linux-arm-gnueabihf": "1.33.0",
+ "lightningcss-linux-arm64-gnu": "1.33.0",
+ "lightningcss-linux-arm64-musl": "1.33.0",
+ "lightningcss-linux-x64-gnu": "1.33.0",
+ "lightningcss-linux-x64-musl": "1.33.0",
+ "lightningcss-win32-arm64-msvc": "1.33.0",
+ "lightningcss-win32-x64-msvc": "1.33.0"
+ }
+ },
+ "node_modules/vite/node_modules/lightningcss-android-arm64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz",
+ "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/vite/node_modules/lightningcss-darwin-arm64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz",
+ "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/vite/node_modules/lightningcss-darwin-x64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz",
+ "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/vite/node_modules/lightningcss-freebsd-x64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz",
+ "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/vite/node_modules/lightningcss-linux-arm-gnueabihf": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz",
+ "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/vite/node_modules/lightningcss-linux-arm64-gnu": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz",
+ "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/vite/node_modules/lightningcss-linux-arm64-musl": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz",
+ "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/vite/node_modules/lightningcss-linux-x64-gnu": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz",
+ "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/vite/node_modules/lightningcss-linux-x64-musl": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz",
+ "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/vite/node_modules/lightningcss-win32-arm64-msvc": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz",
+ "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/vite/node_modules/lightningcss-win32-x64-msvc": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz",
+ "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/vitest": {
+ "version": "5.0.5",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.5.tgz",
+ "integrity": "sha512-cPODOMF8YnkiiYtDdHo6n4wWmBrOBBbOIYH2d33M4l3CdNn72h+qMmO6uXffUN6A5AZFXNND/d+krS9mgkXRfg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/chai": "^5.2.2",
+ "@vitest/mocker": "5.0.5",
+ "chai": "^6.3.0",
+ "es-module-lexer": "^2.3.2",
+ "expect-type": "^1.4.0",
+ "magic-string": "^1.4.3",
+ "obug": "^3.0.0",
+ "picomatch": "^4.0.7",
+ "std-env": "^4.3.0",
+ "tinybench": "^6.2.2",
+ "tinyexec": "^1.3.1",
+ "tinyglobby": "^0.2.17",
+ "why-is-node-running": "3.2.1"
+ },
+ "bin": {
+ "vitest": "vitest.mjs"
+ },
+ "engines": {
+ "node": "^22.12.0 || ^24.0.0 || >=26.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@edge-runtime/vm": "*",
+ "@opentelemetry/api": "^1.9.0",
+ "@types/node": "^22.0.0 || >=24.0.0",
+ "@vitest/browser-playwright": "5.0.5",
+ "@vitest/browser-preview": "5.0.5",
+ "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0",
+ "@vitest/coverage-istanbul": "5.0.5",
+ "@vitest/coverage-v8": "5.0.5",
+ "@vitest/ui": "5.0.5",
+ "happy-dom": "*",
+ "jsdom": "*",
+ "vite": "^6.4.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@edge-runtime/vm": {
+ "optional": true
+ },
+ "@opentelemetry/api": {
+ "optional": true
+ },
+ "@types/node": {
+ "optional": true
+ },
+ "@vitest/browser-playwright": {
+ "optional": true
+ },
+ "@vitest/browser-preview": {
+ "optional": true
+ },
+ "@vitest/browser-webdriverio": {
+ "optional": true
+ },
+ "@vitest/coverage-istanbul": {
+ "optional": true
+ },
+ "@vitest/coverage-v8": {
+ "optional": true
+ },
+ "@vitest/ui": {
+ "optional": true
+ },
+ "happy-dom": {
+ "optional": true
+ },
+ "jsdom": {
+ "optional": true
+ },
+ "vite": {
+ "optional": false
+ }
+ }
+ },
+ "node_modules/vitest/node_modules/magic-string": {
+ "version": "1.4.3",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.3.tgz",
+ "integrity": "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.6.0"
+ }
+ },
+ "node_modules/w3c-xmlserializer": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-6.0.0.tgz",
+ "integrity": "sha512-4Nsy8K5Tr6SPDH9jhKJOHf7ChDrc1zufZTVSF7x72hwuEXBqxqk9G6cK+K2NRUtB3iELRJqjXb4JPDMBjMTl2Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": "^22.22.2 || ^24.15.0 || >=26.0.0"
+ }
+ },
+ "node_modules/webidl-conversions": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
+ "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/whatwg-mimetype": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
+ "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/whatwg-url": {
+ "version": "17.3.0",
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.3.0.tgz",
+ "integrity": "sha512-KwOAJrpf+GTgBvcBQJcP38sMF0DsQKdwtG+TxM2pW44P6E5k0hDbWrDmyuJDIF0Uef0aQq0ZoJs+5oI+k8ddhg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@exodus/bytes": "^1.16.0",
+ "tr46": "^7.0.0",
+ "webidl-conversions": "^8.0.1"
+ },
+ "engines": {
+ "node": "^22.14.0 || >=24.0.0"
+ }
+ },
+ "node_modules/why-is-node-running": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.1.tgz",
+ "integrity": "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "why-is-node-running": "cli.js"
+ },
+ "engines": {
+ "node": ">=20.11"
+ }
+ },
+ "node_modules/xml-name-validator": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
+ "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/xmlchars": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
+ "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/zod": {
+ "version": "4.6.7",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz",
+ "integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ },
+ "node_modules/zustand": {
+ "version": "5.0.16",
+ "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.16.tgz",
+ "integrity": "sha512-Uv7e327ihlCfayfja3hDc0M64Upev1LWBKpRobdK4NyovDDPeeX4imWXc/wzC1yc6fkYPoAY/ncKVPAs2jrtQw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.20.0"
+ },
+ "peerDependencies": {
+ "@types/react": ">=18.0.0",
+ "immer": ">=9.0.6",
+ "react": ">=18.0.0",
+ "use-sync-external-store": ">=1.2.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "immer": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ },
+ "use-sync-external-store": {
+ "optional": true
+ }
+ }
+ }
+ }
+}
diff --git a/dashboard/package.json b/dashboard/package.json
new file mode 100644
index 0000000..53566e7
--- /dev/null
+++ b/dashboard/package.json
@@ -0,0 +1,62 @@
+{
+ "name": "@opencluster/dashboard",
+ "private": true,
+ "version": "0.1.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc -p tsconfig.json && vite build",
+ "preview": "vite preview",
+ "typecheck": "tsc -p tsconfig.json",
+ "lint": "biome check src tests",
+ "format": "biome format --write src tests",
+ "test": "vitest run",
+ "test:watch": "vitest"
+ },
+ "dependencies": {
+ "@fontsource-variable/inter": "^5.3.0",
+ "@fontsource-variable/sora": "^5.3.0",
+ "@radix-ui/react-dialog": "^1.1.15",
+ "@radix-ui/react-dropdown-menu": "^2.1.16",
+ "@radix-ui/react-popover": "^1.1.15",
+ "@radix-ui/react-select": "^2.2.6",
+ "@radix-ui/react-slot": "^1.2.4",
+ "@radix-ui/react-tabs": "^1.1.13",
+ "@radix-ui/react-tooltip": "^1.2.8",
+ "@tanstack/react-query": "^5.104.5",
+ "@xyflow/react": "^12.12.0",
+ "class-variance-authority": "^0.7.1",
+ "clsx": "^2.1.1",
+ "cmdk": "^1.1.1",
+ "framer-motion": "^14.6.0",
+ "gsap": "^3.15.0",
+ "idb": "^8.0.4",
+ "lenis": "^1.3.27",
+ "lucide-react": "^1.61.0",
+ "react": "^19.3.1",
+ "react-dom": "^19.3.1",
+ "react-router-dom": "^7.18.4",
+ "recharts": "^3.10.1",
+ "tailwind-merge": "^3.6.0",
+ "three": "^0.186.1",
+ "zod": "^4.6.7",
+ "zustand": "^5.0.16"
+ },
+ "devDependencies": {
+ "@biomejs/biome": "^2.4.5",
+ "@tailwindcss/vite": "^4.3.3",
+ "@testing-library/jest-dom": "^6.9.1",
+ "@testing-library/react": "^16.3.2",
+ "@testing-library/user-event": "^14.6.1",
+ "@types/node": "^26.6.5",
+ "@types/react": "^19.3.0",
+ "@types/react-dom": "^19.3.0",
+ "@types/three": "^0.186.0",
+ "@vitejs/plugin-react": "^6.1.2",
+ "fake-indexeddb": "^6.2.6",
+ "jsdom": "^30.1.3",
+ "typescript": "^7.0.2",
+ "vite": "^8.3.5",
+ "vitest": "^5.0.5"
+ }
+}
diff --git a/dashboard/src/components/AppErrorBoundary.tsx b/dashboard/src/components/AppErrorBoundary.tsx
new file mode 100644
index 0000000..dfdd779
--- /dev/null
+++ b/dashboard/src/components/AppErrorBoundary.tsx
@@ -0,0 +1,34 @@
+import { Component, type ErrorInfo, type ReactNode } from "react";
+
+type Props = { children: ReactNode };
+type State = { error: Error | null };
+
+export class AppErrorBoundary extends Component {
+ state: State = { error: null };
+
+ static getDerivedStateFromError(error: Error): State {
+ return { error };
+ }
+
+ componentDidCatch(error: Error, info: ErrorInfo) {
+ console.error("OpenCluster dashboard render failure", {
+ error,
+ componentStack: info.componentStack,
+ });
+ }
+
+ render() {
+ if (this.state.error) {
+ return (
+
+ OpenCluster could not render this view
+ {this.state.error.message}
+ window.location.reload()}>
+ Reload dashboard
+
+
+ );
+ }
+ return this.props.children;
+ }
+}
diff --git a/dashboard/src/components/AppShell.tsx b/dashboard/src/components/AppShell.tsx
new file mode 100644
index 0000000..069ec7a
--- /dev/null
+++ b/dashboard/src/components/AppShell.tsx
@@ -0,0 +1,432 @@
+import { motion, useReducedMotion } from "framer-motion";
+import Lenis from "lenis";
+import {
+ Activity,
+ Bell,
+ BrainCircuit,
+ ChevronDown,
+ CircleHelp,
+ Command,
+ Cpu,
+ Gauge,
+ Grid2X2,
+ Layers3,
+ Menu,
+ Search,
+ Settings,
+ Sparkles,
+ TerminalSquare,
+ Workflow,
+ X,
+} from "lucide-react";
+import type { KeyboardEvent as ReactKeyboardEvent } from "react";
+import { useEffect, useMemo, useRef, useState } from "react";
+import { NavLink, Outlet, useLocation, useNavigate } from "react-router-dom";
+import { useDashboardStore } from "../state/useDashboardStore";
+import { BranchedMenu } from "./BranchedMenu";
+import { Dock } from "./Dock";
+import { Button } from "./ui";
+
+const navItems = [
+ { label: "Home", to: "/", icon: Grid2X2 },
+ { label: "Mission Control", to: "/mission-control", icon: Sparkles },
+ { label: "Clusters", to: "/clusters", icon: Layers3 },
+ { label: "Nodes", to: "/nodes", icon: Cpu },
+ { label: "Agents", to: "/agents", icon: BrainCircuit },
+ { label: "Automation", to: "/automation", icon: Workflow },
+ { label: "AI Software", to: "/software", icon: TerminalSquare },
+ { label: "Tasks", to: "/tasks", icon: Activity },
+ { label: "Monitoring", to: "/monitoring", icon: Gauge },
+ { label: "Activity", to: "/activity", icon: Bell },
+];
+const routeNames: Record = {
+ "/": "Home",
+ "/mission-control": "Mission Control",
+ "/clusters": "Clusters",
+ "/nodes": "Nodes",
+ "/agents": "Agents",
+ "/automation": "Automation",
+ "/software": "AI Software",
+ "/tasks": "Tasks",
+ "/monitoring": "Monitoring",
+ "/activity": "Activity",
+ "/settings": "Settings",
+};
+const branchedItems = [
+ { label: "Overview", value: "/", icon: },
+ {
+ label: "Operations",
+ children: [
+ {
+ value: "/mission-control",
+ label: "Mission Control",
+ icon: ,
+ },
+ { value: "/clusters", label: "Clusters", icon: },
+ { value: "/nodes", label: "Nodes", icon: },
+ { value: "/monitoring", label: "Monitoring", icon: },
+ { value: "/activity", label: "Activity", icon: },
+ ],
+ },
+ {
+ label: "Orchestration",
+ children: [
+ { value: "/agents", label: "Agents", icon: },
+ {
+ value: "/automation",
+ label: "Automation",
+ icon: ,
+ },
+ {
+ value: "/software",
+ label: "AI Software",
+ icon: ,
+ },
+ { value: "/tasks", label: "Tasks", icon: },
+ ],
+ },
+];
+function keepFocusInDialog(event: ReactKeyboardEvent) {
+ if (event.key !== "Tab") return;
+ const focusable = Array.from(
+ event.currentTarget.querySelectorAll(
+ 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
+ ),
+ );
+ const first = focusable[0];
+ const last = focusable.at(-1);
+ if (event.shiftKey && document.activeElement === first) {
+ event.preventDefault();
+ last?.focus();
+ } else if (!event.shiftKey && document.activeElement === last) {
+ event.preventDefault();
+ first?.focus();
+ }
+}
+
+export function AppShell() {
+ const location = useLocation();
+ const navigate = useNavigate();
+ const clusters = useDashboardStore((s) => s.clusters);
+ const selectedClusterId = useDashboardStore((s) => s.selectedClusterId);
+ const selectCluster = useDashboardStore((s) => s.selectCluster);
+ const collapsed = useDashboardStore((s) => s.sidebarCollapsed);
+ const toggleSidebar = useDashboardStore((s) => s.toggleSidebar);
+ const theme = useDashboardStore((s) => s.theme);
+ const demoMotion = useDashboardStore((s) => s.demoMotion);
+ const compactDashboard = useDashboardStore((s) => s.compactDashboard);
+ const performanceMode = useDashboardStore((s) => s.performanceMode);
+ const cinematicMotion = useDashboardStore((s) => s.cinematicMotion);
+ const displayName = useDashboardStore((s) => s.displayName);
+ const persistenceError = useDashboardStore((s) => s.persistenceError);
+ const clearPersistenceError = useDashboardStore(
+ (s) => s.clearPersistenceError,
+ );
+ const prefersReducedMotion = useReducedMotion();
+ const [searchOpen, setSearchOpen] = useState(false);
+ const [query, setQuery] = useState("");
+ const searchInput = useRef(null);
+ useEffect(() => {
+ const media = window.matchMedia("(prefers-color-scheme: light)");
+ const updateTheme = () => {
+ document.documentElement.dataset.theme =
+ theme === "system" ? (media.matches ? "light" : "dark") : theme;
+ };
+ updateTheme();
+ media.addEventListener("change", updateTheme);
+ document.documentElement.dataset.motion = demoMotion ? "full" : "reduced";
+ document.documentElement.dataset.performance = performanceMode
+ ? "on"
+ : "off";
+ return () => media.removeEventListener("change", updateTheme);
+ }, [theme, demoMotion, performanceMode]);
+ useEffect(() => {
+ if (!demoMotion || performanceMode || prefersReducedMotion) return;
+ const lenis = new Lenis({
+ autoRaf: true,
+ anchors: true,
+ allowNestedScroll: true,
+ stopInertiaOnNavigate: true,
+ });
+ const handleVisibility = () => {
+ if (document.hidden) lenis.stop();
+ else lenis.start();
+ };
+ document.addEventListener("visibilitychange", handleVisibility);
+ return () => {
+ document.removeEventListener("visibilitychange", handleVisibility);
+ lenis.destroy();
+ };
+ }, [demoMotion, performanceMode, prefersReducedMotion]);
+ useEffect(() => {
+ const onKeyDown = (event: KeyboardEvent) => {
+ if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
+ event.preventDefault();
+ setSearchOpen((open) => !open);
+ }
+ };
+ window.addEventListener("keydown", onKeyDown);
+ return () => window.removeEventListener("keydown", onKeyDown);
+ }, []);
+ useEffect(() => {
+ if (searchOpen) searchInput.current?.focus();
+ }, [searchOpen]);
+ const selected =
+ clusters.find((cluster) => cluster.id === selectedClusterId) ?? clusters[0];
+ const commands = useMemo(
+ () =>
+ [
+ ...navItems.map((item) => ({ label: item.label, to: item.to })),
+ { label: "Settings", to: "/settings" },
+ ].filter((item) =>
+ item.label.toLowerCase().includes(query.toLowerCase()),
+ ),
+ [query],
+ );
+ const openSearch = () => setSearchOpen(true);
+ return (
+
+
+
+
+
+
+
+
+
+ opencluster
+
+ LOCAL WORKSPACE ACTIVE
+
+
+
+
+
+ ACTIVE CLUSTER
+
+
+
+ selectCluster(event.target.value)}
+ aria-label="Select active cluster"
+ >
+ {clusters.map((cluster) => (
+
+ {cluster.name}
+
+ ))}
+
+
+
+
+ ⌖ {selected?.region ?? "No cluster selected"}
+
+
+ WORKSPACE
+ navigate(value)}
+ />
+ {/* ORIGINAL (preserved, not deleted — disabled by comment):
+ {navItems.map(({ label, to, icon: Icon }) => (
+
+ `nav-link ${isActive ? "active" : ""}`
+ }
+ >
+
+ {label}
+ {label === "Activity" && 2 }
+
+ ))}
+ */}
+
+
+
+
+
+
+ Local workspace ready
+ Hardware metrics use sample records
+
+
+
+ `nav-link settings-link ${location.pathname === "/settings" || isActive ? "active" : ""}`
+ }
+ >
+
+ Settings
+
+
+
OC
+
+ {displayName}
+ Local profile
+
+
+
+
+
+
+
+
+ {persistenceError && (
+
+
+ Local changes could not be saved: {persistenceError}
+
+
+
+
+
+ )}
+
+
+
+
+
+
+
+ OpenCluster LOCAL MODE
+
+
+ Hardware telemetry uses seed data · Backend services are not
+ connected
+
+
+
+
+ {searchOpen && (
+
+ {/* ORIGINAL (preserved, not deleted — disabled by comment): onMouseDown={(e) => { if (e.target === e.currentTarget) setSearchOpen(false); }} */}
+
setSearchOpen(false)}
+ />
+
+
+
+ setQuery(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === "Escape") setSearchOpen(false);
+ if (e.key === "Enter" && commands[0]) {
+ navigate(commands[0].to);
+ setSearchOpen(false);
+ }
+ }}
+ />
+ ESC
+ setSearchOpen(false)}
+ >
+
+
+
+ PAGES
+ {commands.length ? (
+ commands.map((item) => (
+ {
+ navigate(item.to);
+ setSearchOpen(false);
+ }}
+ >
+ {item.label}
+ ↵
+
+ ))
+ ) : (
+ No pages match “{query}”
+ )}
+
+ ↑↓ navigate
+ ↵ open
+ ESC close
+
+
+
+ )}
+
+ );
+}
diff --git a/dashboard/src/components/BranchedMenu.css b/dashboard/src/components/BranchedMenu.css
new file mode 100644
index 0000000..6fc79b3
--- /dev/null
+++ b/dashboard/src/components/BranchedMenu.css
@@ -0,0 +1,131 @@
+.branched-menu {
+ --bm-line: #1c2a34;
+ --bm-accent: #62d3a5;
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+ padding-left: 13px;
+}
+.branched-menu::before,
+.branched-menu__marker {
+ position: absolute;
+ left: 2px;
+ width: 1px;
+ border-radius: 1px;
+ content: "";
+}
+.branched-menu::before {
+ top: 10px;
+ bottom: 0;
+ background: linear-gradient(var(--bm-line) 0 85%, transparent);
+}
+.branched-menu__marker {
+ top: 0;
+ z-index: 1;
+ height: 16px;
+ background: var(--bm-accent);
+ opacity: 0;
+ transition:
+ transform 220ms cubic-bezier(0.23, 1, 0.32, 1),
+ opacity 150ms ease;
+}
+.branched-menu__marker[data-on] {
+ opacity: 1;
+}
+.branched-menu__head,
+.branched-menu__item {
+ display: flex;
+ align-items: center;
+ width: 100%;
+ min-width: 0;
+ border: 0;
+ background: transparent;
+ color: #81909b;
+ text-align: left;
+ transition: color 160ms ease;
+}
+.branched-menu__head {
+ min-height: 35px;
+ padding: 8px 4px;
+ font-size: 11px;
+ font-weight: 600;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+.branched-menu__section[data-open] > .branched-menu__head,
+.branched-menu__head[aria-current="page"],
+.branched-menu__item[aria-current="page"] {
+ color: #a5e4ca;
+}
+.branched-menu__body {
+ display: grid;
+ grid-template-rows: 0fr;
+ transition: grid-template-rows 240ms cubic-bezier(0.23, 1, 0.32, 1);
+}
+.branched-menu__section[data-open] .branched-menu__body {
+ grid-template-rows: 1fr;
+}
+.branched-menu__fold {
+ min-height: 0;
+ overflow: hidden;
+}
+.branched-menu__tree {
+ position: relative;
+ padding: 6px 0;
+ box-sizing: border-box;
+}
+.branched-menu__lines {
+ position: absolute;
+ top: 0;
+ left: 0;
+ overflow: visible;
+ pointer-events: none;
+}
+.branched-menu__base,
+.branched-menu__reach {
+ fill: none;
+ stroke-width: 1px;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+}
+.branched-menu__base {
+ stroke: var(--bm-line);
+}
+.branched-menu__reach {
+ stroke: var(--bm-accent);
+ transition: stroke-dashoffset 400ms cubic-bezier(0.23, 1, 0.32, 1);
+}
+.branched-menu__item {
+ height: 35px;
+ gap: 10px;
+ padding: 0 4px 0 30px;
+ border-radius: 6px;
+ font-size: 12px;
+ cursor: pointer;
+}
+.branched-menu__item:hover,
+.branched-menu__head:hover {
+ color: #dce5eb;
+}
+.branched-menu__item[aria-current="page"] {
+ background: #152a28;
+ box-shadow: inset 2px 0 #5dd3a3;
+}
+.branched-menu__icon {
+ display: inline-flex;
+ flex: 0 0 16px;
+ color: #758592;
+}
+.branched-menu__item[aria-current="page"] .branched-menu__icon {
+ color: #6ed9ad;
+}
+@media (prefers-reduced-motion: reduce) {
+ .branched-menu__marker,
+ .branched-menu__body {
+ transition: none;
+ }
+ .branched-menu__reach {
+ transition: none;
+ }
+}
diff --git a/dashboard/src/components/BranchedMenu.tsx b/dashboard/src/components/BranchedMenu.tsx
new file mode 100644
index 0000000..588d06c
--- /dev/null
+++ b/dashboard/src/components/BranchedMenu.tsx
@@ -0,0 +1,168 @@
+import type { ReactNode } from "react";
+import { useLayoutEffect, useRef, useState } from "react";
+import "./BranchedMenu.css";
+
+export type BranchedMenuEntry = {
+ value: string;
+ label: string;
+ icon?: ReactNode;
+};
+export type BranchedMenuSection = {
+ label: string;
+ children?: BranchedMenuEntry[];
+ value?: string;
+};
+
+export function BranchedMenu({
+ items,
+ active,
+ onSelect,
+}: {
+ items: BranchedMenuSection[];
+ active: string;
+ onSelect: (value: string) => void;
+}) {
+ const [open, setOpen] = useState(
+ () => new Set(items.map((_, index) => index)),
+ );
+ const marker = useRef(null);
+ const heads = useRef>([]);
+ const sections = useRef(null);
+ const sectionIndex = items.findIndex((section) =>
+ section.children?.some((entry) => entry.value === active),
+ );
+ useLayoutEffect(() => {
+ const place = () => {
+ const selected = marker.current;
+ if (!selected) return;
+ const head = heads.current[sectionIndex];
+ const sectionOffset = head?.parentElement?.offsetTop ?? 0;
+ selected.style.transform = head
+ ? `translateY(${sectionOffset + head.offsetTop + (head.offsetHeight - 16) / 2}px)`
+ : "translateY(-20px)";
+ selected.toggleAttribute(
+ "data-on",
+ Boolean(head && open.has(sectionIndex)),
+ );
+ };
+ place();
+ if (!sections.current || typeof ResizeObserver === "undefined") return;
+ const observer = new ResizeObserver(place);
+ observer.observe(sections.current);
+ return () => observer.disconnect();
+ }, [sectionIndex, open]);
+ const select = (value: string) => onSelect(value);
+
+ return (
+
+
+ {items.map((section, index) => {
+ const children = section.children;
+ const expanded = open.has(index);
+ const selected = !children && section.value === active;
+ const rowHeight = 35;
+ const branchX = 13;
+ const endX = 32;
+ const radius = 8;
+ const rowY = (childIndex: number) =>
+ 6 + childIndex * rowHeight + rowHeight / 2;
+ const branchPath = (childIndex: number) => {
+ const y = rowY(childIndex);
+ return `M ${branchX} ${y - radius} A ${radius} ${radius} 0 0 0 ${branchX + radius} ${y} H ${endX}`;
+ };
+ const reachPath = (childIndex: number) => {
+ const y = rowY(childIndex);
+ return `M ${branchX} 0 V ${y - radius} A ${radius} ${radius} 0 0 0 ${branchX + radius} ${y} H ${endX}`;
+ };
+ return (
+
+ {
+ heads.current[index] = element;
+ }}
+ className="branched-menu__head"
+ type="button"
+ aria-expanded={children ? expanded : undefined}
+ aria-current={selected ? "page" : undefined}
+ onClick={() => {
+ if (!children && section.value) select(section.value);
+ else
+ setOpen((current) => {
+ const next = new Set(current);
+ if (next.has(index)) next.delete(index);
+ else next.add(index);
+ return next;
+ });
+ }}
+ >
+ {section.label}
+
+ {children && (
+
+
+
+
+
+ {children.map((entry, childIndex) => (
+
+ ))}
+ {children.map((entry, childIndex) => (
+
+ ))}
+
+ {children.map((entry) => (
+
select(entry.value)}
+ >
+
+ {entry.icon}
+
+ {entry.label}
+
+ ))}
+
+
+
+ )}
+
+ );
+ })}
+
+ );
+}
diff --git a/dashboard/src/components/Charts.tsx b/dashboard/src/components/Charts.tsx
new file mode 100644
index 0000000..b90f809
--- /dev/null
+++ b/dashboard/src/components/Charts.tsx
@@ -0,0 +1,137 @@
+import {
+ Area,
+ AreaChart,
+ CartesianGrid,
+ ResponsiveContainer,
+ Tooltip,
+ XAxis,
+ YAxis,
+} from "recharts";
+import type { TelemetryPoint } from "../data/models";
+import { useLocalCollection } from "../data/useDashboardData";
+
+export function ResourceChart({
+ metric = "cpu",
+ compact = false,
+ range = "24h",
+ compareMemory = false,
+}: {
+ metric?: "cpu" | "memory" | "network";
+ compact?: boolean;
+ range?: "15m" | "1h" | "24h" | "7d";
+ compareMemory?: boolean;
+}) {
+ const { data: telemetry = [] } =
+ useLocalCollection("telemetry");
+ const series =
+ metric === "network"
+ ? [
+ { key: "networkIn", name: "Inbound", color: "#6cb9ff" },
+ { key: "networkOut", name: "Outbound", color: "#a78bfa" },
+ ]
+ : [
+ {
+ key: metric,
+ name: metric === "cpu" ? "CPU" : "Memory",
+ color: metric === "cpu" ? "#62d3a5" : "#65b9ff",
+ },
+ ];
+ if (metric === "cpu" && compareMemory) {
+ series.push({ key: "memory", name: "Memory", color: "#65b9ff" });
+ }
+ const visiblePoints =
+ range === "15m"
+ ? telemetry.slice(-4)
+ : range === "1h"
+ ? telemetry.slice(-6)
+ : telemetry;
+ const points =
+ range === "7d"
+ ? Array.from(
+ { length: 14 },
+ (_, index) => telemetry[Math.floor((index * telemetry.length) / 14)],
+ )
+ : visiblePoints;
+ const data = points.map((point, index) => ({
+ ...point,
+ time:
+ range === "7d"
+ ? `Day ${Math.floor(index / 2) + 1}`
+ : range === "1h"
+ ? `${index * 10} min`
+ : range === "15m"
+ ? `${index * 5} min`
+ : point.time,
+ }));
+ if (!data.length)
+ return (
+
+ No telemetry samples are stored locally.
+
+ );
+ return (
+
+
+
+
+ {series.map((s) => (
+
+
+
+
+ ))}
+
+
+
+
+
+ {series.map((s) => (
+
+ ))}
+
+
+
+ );
+}
diff --git a/dashboard/src/components/Counter.css b/dashboard/src/components/Counter.css
new file mode 100644
index 0000000..453db7d
--- /dev/null
+++ b/dashboard/src/components/Counter.css
@@ -0,0 +1,9 @@
+.counter {
+ display: inline-flex;
+ align-items: baseline;
+ font-variant-numeric: tabular-nums;
+ white-space: nowrap;
+}
+.counter > span {
+ display: inline-block;
+}
diff --git a/dashboard/src/components/Counter.tsx b/dashboard/src/components/Counter.tsx
new file mode 100644
index 0000000..77241c0
--- /dev/null
+++ b/dashboard/src/components/Counter.tsx
@@ -0,0 +1,133 @@
+import type { CSSProperties, ReactNode } from "react";
+import { useEffect, useMemo, useRef } from "react";
+import "./Counter.css";
+
+export type CounterProps = {
+ value?: number;
+ locale?: string;
+ format?: Intl.NumberFormatOptions;
+ decimals?: number;
+ minDigits?: number;
+ prefix?: ReactNode;
+ suffix?: ReactNode;
+ duration?: number;
+ fontSize?: number | string;
+ fontWeight?: number | string;
+ textColor?: string;
+ className?: string;
+ style?: CSSProperties;
+};
+
+export function Counter({
+ value = 0,
+ locale = "en-US",
+ format,
+ decimals,
+ minDigits = 1,
+ prefix,
+ suffix,
+ duration = 700,
+ fontSize = "inherit",
+ fontWeight = "inherit",
+ textColor = "inherit",
+ className = "",
+ style,
+}: CounterProps) {
+ const number = Number.isFinite(value) ? value : 0;
+ const numberRef = useRef(null);
+ const previousValue = useRef(number);
+ const numberFormat = useMemo(() => {
+ const options = { ...format };
+ if (decimals !== undefined) {
+ options.minimumFractionDigits = Math.max(
+ 0,
+ Math.min(20, Math.round(decimals)),
+ );
+ options.maximumFractionDigits = options.minimumFractionDigits;
+ }
+ options.minimumIntegerDigits = Math.max(
+ 1,
+ Math.min(21, Math.round(minDigits)),
+ );
+ try {
+ return new Intl.NumberFormat(locale, options);
+ } catch {
+ return new Intl.NumberFormat("en-US", options);
+ }
+ }, [locale, format, decimals, minDigits]);
+ const formatted = numberFormat.format(number);
+ const motionDuration = Math.max(0, duration);
+
+ useEffect(() => {
+ const target = numberRef.current;
+ if (!target) return;
+ let frame = 0;
+ let observer: IntersectionObserver | undefined;
+ let started = false;
+ const finish = () => {
+ target.textContent = formatted;
+ previousValue.current = number;
+ };
+ const run = () => {
+ if (started) return;
+ started = true;
+ if (
+ window.matchMedia("(prefers-reduced-motion: reduce)").matches ||
+ document.hidden ||
+ motionDuration === 0
+ ) {
+ finish();
+ return;
+ }
+ const previous = previousValue.current;
+ previousValue.current = number;
+ const start = performance.now();
+ const tick = (now: number) => {
+ const progress = Math.min(1, (now - start) / motionDuration);
+ const eased = 1 - (1 - progress) ** 4;
+ target.textContent = numberFormat.format(
+ previous + (number - previous) * eased,
+ );
+ if (progress < 1) frame = requestAnimationFrame(tick);
+ };
+ frame = requestAnimationFrame(tick);
+ };
+ if (typeof IntersectionObserver === "undefined") run();
+ else {
+ observer = new IntersectionObserver(
+ (entries) => {
+ if (entries.some((entry) => entry.isIntersecting)) {
+ observer?.disconnect();
+ run();
+ }
+ },
+ { threshold: 0.1 },
+ );
+ observer.observe(target);
+ }
+ return () => {
+ cancelAnimationFrame(frame);
+ observer?.disconnect();
+ };
+ }, [formatted, motionDuration, number, numberFormat]);
+
+ return (
+
+ {prefix}
+
+ {formatted}
+
+ {suffix}
+
+ );
+}
diff --git a/dashboard/src/components/Dock.css b/dashboard/src/components/Dock.css
new file mode 100644
index 0000000..bac75da
--- /dev/null
+++ b/dashboard/src/components/Dock.css
@@ -0,0 +1,119 @@
+.quick-dock {
+ position: fixed;
+ z-index: 8;
+ right: max(24px, env(safe-area-inset-right));
+ bottom: max(48px, env(safe-area-inset-bottom));
+ left: calc(248px + (100vw - 248px) / 2);
+ display: flex;
+ width: max-content;
+ max-width: calc(100vw - 290px);
+ align-items: center;
+ justify-content: center;
+ gap: 5px;
+ padding: 7px;
+ transform: translateX(-50%);
+ border: 1px solid rgb(161 207 208 / 15%);
+ border-radius: 16px;
+ background: rgb(13 23 29 / 86%);
+ box-shadow:
+ 0 12px 40px rgb(0 0 0 / 38%),
+ inset 0 1px rgb(255 255 255 / 5%);
+ -webkit-backdrop-filter: blur(18px) saturate(1.35);
+ backdrop-filter: blur(18px) saturate(1.35);
+}
+.quick-dock__item {
+ position: relative;
+ display: grid;
+ width: 38px;
+ height: 38px;
+ flex: 0 0 38px;
+ place-items: center;
+ border: 1px solid transparent;
+ border-radius: 11px;
+ color: #8c9ca7;
+ transition:
+ transform 180ms cubic-bezier(0.2, 0.8, 0.2, 1),
+ color 160ms ease,
+ background 160ms ease;
+}
+.quick-dock__item:hover,
+.quick-dock__item:focus-visible {
+ z-index: 1;
+ transform: translateY(-5px) scale(1.12);
+ color: #dff8ee;
+ background: #17342f;
+}
+.quick-dock__item:hover + .quick-dock__item,
+.quick-dock__item:has(+ .quick-dock__item:hover) {
+ transform: translateY(-2px) scale(1.04);
+}
+.quick-dock__item.is-active {
+ color: #6ed9ad;
+ background: #142923;
+ border-color: rgb(98 211 165 / 20%);
+}
+.quick-dock__item.is-active::after {
+ position: absolute;
+ bottom: -4px;
+ width: 3px;
+ height: 3px;
+ border-radius: 50%;
+ background: #6ed9ad;
+ content: "";
+}
+.quick-dock__label {
+ position: absolute;
+ bottom: calc(100% + 9px);
+ left: 50%;
+ padding: 6px 8px;
+ border: 1px solid #293b45;
+ border-radius: 6px;
+ background: #111b22;
+ color: #dce5eb;
+ font-size: 10px;
+ white-space: nowrap;
+ opacity: 0;
+ pointer-events: none;
+ transform: translate(-50%, 3px);
+ transition:
+ opacity 140ms ease,
+ transform 140ms ease;
+}
+.quick-dock__item:hover .quick-dock__label,
+.quick-dock__item:focus-visible .quick-dock__label {
+ opacity: 1;
+ transform: translate(-50%, 0);
+}
+@media (max-width: 820px) {
+ .quick-dock {
+ right: 10px;
+ bottom: max(44px, env(safe-area-inset-bottom));
+ left: 50%;
+ max-width: calc(100vw - 76px);
+ gap: 2px;
+ padding: 5px;
+ }
+ .quick-dock__item {
+ width: 30px;
+ height: 32px;
+ flex-basis: 30px;
+ }
+ .quick-dock__item svg {
+ width: 15px;
+ height: 15px;
+ }
+ .quick-dock__label {
+ display: none;
+ }
+}
+@media (max-width: 600px) {
+ .quick-dock__item:nth-child(n + 9) {
+ display: none;
+ }
+}
+@media (prefers-reduced-motion: reduce) {
+ .quick-dock__item,
+ .quick-dock__label {
+ transition: none;
+ }
+}
diff --git a/dashboard/src/components/Dock.tsx b/dashboard/src/components/Dock.tsx
new file mode 100644
index 0000000..bccea4c
--- /dev/null
+++ b/dashboard/src/components/Dock.tsx
@@ -0,0 +1,52 @@
+import {
+ Activity,
+ Bot,
+ Cpu,
+ Gauge,
+ Grid2X2,
+ Layers3,
+ ListTodo,
+ ScanEye,
+ Settings,
+ TerminalSquare,
+ Workflow,
+} from "lucide-react";
+import { NavLink } from "react-router-dom";
+import "./Dock.css";
+
+const items = [
+ { to: "/", label: "Home", icon: Grid2X2 },
+ { to: "/mission-control", label: "Mission Control", icon: ScanEye },
+ { to: "/clusters", label: "Clusters", icon: Layers3 },
+ { to: "/nodes", label: "Nodes", icon: Cpu },
+ { to: "/agents", label: "Agents", icon: Bot },
+ { to: "/automation", label: "Automation", icon: Workflow },
+ { to: "/monitoring", label: "Monitoring", icon: Gauge },
+ { to: "/activity", label: "Activity", icon: Activity },
+ { to: "/software", label: "AI Software", icon: TerminalSquare },
+ { to: "/tasks", label: "Tasks", icon: ListTodo },
+ { to: "/settings", label: "Settings", icon: Settings },
+];
+
+export function Dock() {
+ return (
+
+ {items.map(({ to, label, icon: Icon }) => (
+
+ `quick-dock__item${isActive ? " is-active" : ""}`
+ }
+ aria-label={label}
+ >
+
+
+ {label}
+
+
+ ))}
+
+ );
+}
diff --git a/dashboard/src/components/GlobeScene.tsx b/dashboard/src/components/GlobeScene.tsx
new file mode 100644
index 0000000..9a42fd6
--- /dev/null
+++ b/dashboard/src/components/GlobeScene.tsx
@@ -0,0 +1,372 @@
+import { useReducedMotion } from "framer-motion";
+import { Minus, Plus, RotateCcw } from "lucide-react";
+import { useEffect, useRef, useState } from "react";
+import {
+ BufferAttribute,
+ BufferGeometry,
+ Color,
+ Group,
+ Line,
+ LineBasicMaterial,
+ Mesh,
+ MeshBasicMaterial,
+ PerspectiveCamera,
+ Points,
+ PointsMaterial,
+ Raycaster,
+ Scene,
+ SphereGeometry,
+ Vector2,
+ Vector3,
+ WebGLRenderer,
+} from "three";
+import type { Node } from "../data/models";
+import { useDashboardStore } from "../state/useDashboardStore";
+import { Button, HealthBadge } from "./ui";
+
+function nodePosition(index: number, count: number) {
+ const y = 1 - ((index + 0.5) / count) * 2;
+ const radius = Math.sqrt(1 - y * y);
+ const theta = Math.PI * (3 - Math.sqrt(5)) * index;
+ return new Vector3(
+ Math.cos(theta) * radius,
+ y,
+ Math.sin(theta) * radius,
+ ).multiplyScalar(1.12);
+}
+
+export function GlobeScene({ nodes }: { nodes: Node[] }) {
+ const canvasHost = useRef(null);
+ const cameraRef = useRef(null);
+ const zoomRef = useRef(4.2);
+ const [failed, setFailed] = useState(false);
+ const [zoom, setZoom] = useState(4.2);
+ const selectedNodeId = useDashboardStore((state) => state.selectedNodeId);
+ const selectNode = useDashboardStore((state) => state.selectNode);
+ const demoMotion = useDashboardStore((state) => state.demoMotion);
+ const performanceMode = useDashboardStore((state) => state.performanceMode);
+ const visualQuality = useDashboardStore((state) => state.visualQuality);
+ const reducedMotion = useReducedMotion();
+ const animate = demoMotion && !performanceMode && !reducedMotion;
+
+ useEffect(() => {
+ const host = canvasHost.current;
+ if (!host || nodes.length === 0) return;
+ let renderer: WebGLRenderer;
+ try {
+ renderer = new WebGLRenderer({
+ alpha: true,
+ antialias: false,
+ powerPreference: "low-power",
+ });
+ } catch {
+ setFailed(true);
+ return;
+ }
+ const scene = new Scene();
+ const camera = new PerspectiveCamera(42, 1, 0.1, 30);
+ camera.position.z = zoomRef.current;
+ cameraRef.current = camera;
+ const globe = new Group();
+ scene.add(globe);
+ const sphere = new Mesh(
+ new SphereGeometry(1, 32, 24),
+ new MeshBasicMaterial({
+ color: 0x0b2a3b,
+ wireframe: true,
+ transparent: true,
+ opacity: 0.18,
+ }),
+ );
+ globe.add(sphere);
+ const dotPositions: number[] = [];
+ const dotCount = 360;
+ for (let i = 0; i < dotCount; i += 1) {
+ const point = nodePosition(i, dotCount).multiplyScalar(0.995);
+ dotPositions.push(point.x, point.y, point.z);
+ }
+ const dotGeometry = new BufferGeometry();
+ dotGeometry.setAttribute(
+ "position",
+ new BufferAttribute(new Float32Array(dotPositions), 3),
+ );
+ globe.add(
+ new Points(
+ dotGeometry,
+ new PointsMaterial({
+ color: 0x6bbbd0,
+ size: 0.012,
+ transparent: true,
+ opacity: 0.62,
+ }),
+ ),
+ );
+ const nodeGeometry = new SphereGeometry(0.035, 10, 8);
+ const nodeMeshes: Mesh[] = [];
+ const raycaster = new Raycaster();
+ const pointer = new Vector2();
+ nodes.forEach((node, index) => {
+ const position = nodePosition(index, nodes.length);
+ const color =
+ node.health === "healthy"
+ ? 0x77e1d0
+ : node.health === "warning"
+ ? 0xf0bd72
+ : 0x77858d;
+ const line = new Line(
+ new BufferGeometry().setFromPoints([
+ new Vector3(0, 0, 0),
+ position.clone().multiplyScalar(0.86),
+ ]),
+ new LineBasicMaterial({
+ color: 0x51abc2,
+ transparent: true,
+ opacity: 0.24,
+ }),
+ );
+ globe.add(line);
+ const mesh = new Mesh(
+ nodeGeometry,
+ new MeshBasicMaterial({ color: new Color(color) }),
+ );
+ mesh.position.copy(position);
+ mesh.userData.nodeId = node.id;
+ globe.add(mesh);
+ nodeMeshes.push(mesh);
+ });
+ setFailed(false);
+ renderer.setPixelRatio(
+ Math.min(
+ window.devicePixelRatio || 1,
+ performanceMode ? 1 : visualQuality === "high" ? 2 : 1.5,
+ ),
+ );
+ renderer.setClearColor(0x000000, 0);
+ host.appendChild(renderer.domElement);
+ renderer.domElement.setAttribute("aria-hidden", "true");
+ let frame = 0;
+ let resizeFrame = 0;
+ let pointerFrame = 0;
+ let dragging = false;
+ let moved = false;
+ let lastX = 0;
+ let lastY = 0;
+ const resize = () => {
+ cancelAnimationFrame(resizeFrame);
+ resizeFrame = requestAnimationFrame(() => {
+ const { width, height } = host.getBoundingClientRect();
+ if (!width || !height) return;
+ renderer.setSize(width, height, false);
+ camera.aspect = width / height;
+ camera.updateProjectionMatrix();
+ renderer.render(scene, camera);
+ });
+ };
+ const render = () => {
+ if (document.hidden) return;
+ if (animate) globe.rotation.y += 0.0012;
+ renderer.render(scene, camera);
+ if (animate) frame = requestAnimationFrame(render);
+ };
+ const renderAfterPointer = () => {
+ if (animate || pointerFrame) return;
+ pointerFrame = requestAnimationFrame(() => {
+ pointerFrame = 0;
+ renderer.render(scene, camera);
+ });
+ };
+ const visibility = () => {
+ cancelAnimationFrame(frame);
+ if (!document.hidden) render();
+ };
+ const pointerDown = (event: PointerEvent) => {
+ dragging = true;
+ moved = false;
+ lastX = event.clientX;
+ lastY = event.clientY;
+ renderer.domElement.setPointerCapture(event.pointerId);
+ };
+ const pointerMove = (event: PointerEvent) => {
+ if (!dragging) return;
+ const dx = event.clientX - lastX;
+ const dy = event.clientY - lastY;
+ if (Math.abs(dx) + Math.abs(dy) > 2) moved = true;
+ globe.rotation.y += dx * 0.006;
+ globe.rotation.x = Math.max(
+ -0.7,
+ Math.min(0.7, globe.rotation.x + dy * 0.004),
+ );
+ lastX = event.clientX;
+ lastY = event.clientY;
+ renderAfterPointer();
+ };
+ const pointerUp = (event: PointerEvent) => {
+ dragging = false;
+ if (moved) return;
+ const rect = renderer.domElement.getBoundingClientRect();
+ pointer.set(
+ ((event.clientX - rect.left) / rect.width) * 2 - 1,
+ -((event.clientY - rect.top) / rect.height) * 2 + 1,
+ );
+ raycaster.setFromCamera(pointer, camera);
+ const hit = raycaster.intersectObjects(nodeMeshes)[0]?.object;
+ if (typeof hit?.userData.nodeId === "string")
+ selectNode(hit.userData.nodeId);
+ };
+ const wheel = (event: WheelEvent) => {
+ event.preventDefault();
+ camera.position.z = Math.max(
+ 2.8,
+ Math.min(7, camera.position.z + Math.sign(event.deltaY) * 0.25),
+ );
+ zoomRef.current = camera.position.z;
+ setZoom(camera.position.z);
+ renderAfterPointer();
+ };
+ const observer = new ResizeObserver(resize);
+ observer.observe(host);
+ renderer.domElement.addEventListener("pointerdown", pointerDown);
+ renderer.domElement.addEventListener("pointermove", pointerMove);
+ renderer.domElement.addEventListener("pointerup", pointerUp);
+ renderer.domElement.addEventListener("pointercancel", pointerUp);
+ renderer.domElement.addEventListener("wheel", wheel, { passive: false });
+ document.addEventListener("visibilitychange", visibility);
+ resize();
+ render();
+ return () => {
+ cancelAnimationFrame(frame);
+ cancelAnimationFrame(resizeFrame);
+ cancelAnimationFrame(pointerFrame);
+ observer.disconnect();
+ document.removeEventListener("visibilitychange", visibility);
+ renderer.domElement.removeEventListener("pointerdown", pointerDown);
+ renderer.domElement.removeEventListener("pointermove", pointerMove);
+ renderer.domElement.removeEventListener("pointerup", pointerUp);
+ renderer.domElement.removeEventListener("pointercancel", pointerUp);
+ renderer.domElement.removeEventListener("wheel", wheel);
+ nodeGeometry.dispose();
+ dotGeometry.dispose();
+ sphere.geometry.dispose();
+ (sphere.material as MeshBasicMaterial).dispose();
+ globe.children.forEach((child) => {
+ if (child instanceof Line) {
+ child.geometry.dispose();
+ child.material.dispose();
+ }
+ if (child instanceof Points) child.material.dispose();
+ if (child instanceof Mesh && child !== sphere)
+ (child.material as MeshBasicMaterial).dispose();
+ });
+ renderer.dispose();
+ renderer.domElement.remove();
+ cameraRef.current = null;
+ };
+ }, [nodes, animate, performanceMode, visualQuality, selectNode]);
+
+ const changeZoom = (delta: number) =>
+ setZoom((value) => {
+ const next = Math.max(2.8, Math.min(7, value + delta));
+ zoomRef.current = next;
+ if (cameraRef.current) cameraRef.current.position.z = next;
+ return next;
+ });
+ const resetZoom = () => {
+ zoomRef.current = 4.2;
+ setZoom(4.2);
+ if (cameraRef.current) cameraRef.current.position.z = 4.2;
+ };
+ const selected = nodes.find((node) => node.id === selectedNodeId);
+ return (
+
+
+ {failed ? (
+
+
+
+
+
+ 3D rendering is unavailable. Select a node from the list below.
+
+
+ ) : (
+
+ )}
+
+ HOLOGRAPHIC CLUSTER MODEL SIMULATION
+
+
+ Globe controls
+ changeZoom(-0.3)}
+ >
+
+
+ {Math.round((4.2 / zoom) * 100)}%
+ changeZoom(0.3)}
+ >
+
+
+
+
+
+
+
+ Select a node
+ {nodes.map((node) => (
+ selectNode(node.id)}
+ >
+
+ {node.name}
+
+ ))}
+
+
+
+ NODE INSPECTOR
+ {selected ? (
+ <>
+ {selected.name}
+
+
+ {selected.host} · {selected.os}
+
+
+ CPU
+ {selected.cpu}%
+
+
+ MEMORY
+ {selected.memory}%
+
+
+ ROLE
+ {selected.role}
+
+ >
+ ) : (
+
+ Choose a node in the globe or node list to inspect its demo status.
+
+ )}
+ All positions and values are sample data.
+
+
+ );
+}
diff --git a/dashboard/src/components/HologramVisual.tsx b/dashboard/src/components/HologramVisual.tsx
new file mode 100644
index 0000000..2a3e93a
--- /dev/null
+++ b/dashboard/src/components/HologramVisual.tsx
@@ -0,0 +1,150 @@
+import { useReducedMotion } from "framer-motion";
+import { gsap } from "gsap";
+import { useEffect, useRef } from "react";
+import { useDashboardStore } from "../state/useDashboardStore";
+
+const signals = [
+ [48, 16],
+ [66, 24],
+ [79, 42],
+ [74, 65],
+ [56, 80],
+ [33, 73],
+ [20, 53],
+ [28, 31],
+ [50, 28],
+ [66, 51],
+ [47, 67],
+ [35, 46],
+] as const;
+
+export function HologramVisual() {
+ const root = useRef(null);
+ const demoMotion = useDashboardStore((state) => state.demoMotion);
+ const performanceMode = useDashboardStore((state) => state.performanceMode);
+ const cinematicMotion = useDashboardStore((state) => state.cinematicMotion);
+ const reducedMotion = useReducedMotion();
+
+ useEffect(() => {
+ if (!demoMotion || performanceMode || reducedMotion || !root.current)
+ return;
+ let timeline: gsap.core.Timeline | undefined;
+ const context = gsap.context(() => {
+ timeline = gsap
+ .timeline({ repeat: -1, yoyo: true, defaults: { ease: "sine.inOut" } })
+ .to(
+ ".holo-orbit-outer",
+ {
+ rotation: cinematicMotion ? 22 : 13,
+ duration: cinematicMotion ? 6 : 8,
+ },
+ 0,
+ )
+ .to(
+ ".holo-orbit-inner",
+ {
+ rotation: cinematicMotion ? -28 : -18,
+ duration: cinematicMotion ? 5 : 7,
+ },
+ 0,
+ )
+ .to(
+ ".holo-core-glow",
+ {
+ opacity: cinematicMotion ? 0.88 : 0.72,
+ scale: cinematicMotion ? 1.2 : 1.12,
+ duration: cinematicMotion ? 2.4 : 3,
+ },
+ 0,
+ )
+ .to(
+ ".holo-signal",
+ {
+ opacity: 0.35,
+ stagger: cinematicMotion ? 0.11 : 0.16,
+ duration: 1.3,
+ },
+ 0,
+ );
+ }, root);
+ const visibility = () =>
+ document.hidden ? timeline?.pause() : timeline?.play();
+ document.addEventListener("visibilitychange", visibility);
+ return () => {
+ document.removeEventListener("visibilitychange", visibility);
+ context.revert();
+ };
+ }, [cinematicMotion, demoMotion, performanceMode, reducedMotion]);
+
+ return (
+
+
+ CLUSTER SIGNAL MAP DEMO
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {signals.map(([x, y], index) => (
+
+
+
+
+ ))}
+
+
+
+
+
+ 01 / COORDINATOR
+ 12 / SIGNAL PATHS
+
+
+ );
+}
diff --git a/dashboard/src/components/Topology.tsx b/dashboard/src/components/Topology.tsx
new file mode 100644
index 0000000..6d88516
--- /dev/null
+++ b/dashboard/src/components/Topology.tsx
@@ -0,0 +1,336 @@
+/* ORIGINAL (preserved, not deleted — disabled by comment):
+import { useMemo } from "react";
+import { Cpu, Server, X } from "lucide-react";
+import type { Node } from "../data/models";
+import { useDashboardStore } from "../state/useDashboardStore";
+import { Button, HealthBadge, Progress } from "./ui";
+
+export function Topology({ nodes, layout = "topology" }: { nodes: Node[]; layout?: "topology" | "orbit" }) {
+ const selectedNodeId = useDashboardStore((state) => state.selectedNodeId); const selectNode = useDashboardStore((state) => state.selectNode);
+ const coordinator = nodes.find((node) => node.role === "coordinator") ?? nodes[0]; const workers = nodes.filter((node) => node.id !== coordinator?.id);
+ const positions = useMemo(() => workers.map((node, index) => ({ node, x: 25 + index * (50 / Math.max(workers.length - 1, 1)), y: index % 2 === 0 ? 73 : 82 })), [workers]);
+ const selected = nodes.find((node) => node.id === selectedNodeId);
+ return
{coordinator && <>
{positions.map(({ node, x, y }) => )} selectNode(coordinator.id)} aria-label={`Select coordinator ${coordinator.name}`}>{coordinator.name} COORDINATOR {positions.map(({ node, x, y }) =>
selectNode(node.id)} aria-label={`Select worker ${node.name}`}>{node.name} {node.health === "offline" ? "OFFLINE" : "WORKER"} )}>}
+
DEMO TOPOLOGY · {nodes.length} SAMPLE NODES
{selected ?
NODE DETAIL
{selected.name} selectNode(null)}> {selected.host} · {selected.os}
{selected.role === "coordinator" ? "Coordinator" : "Worker node"}
Uptime {selected.uptime}
Last sample {selected.lastSeen}
Sample values only. No node is connected.
:
}
;
+}
+*/
+
+import {
+ Cpu,
+ Maximize2,
+ Minimize2,
+ Minus,
+ Plus,
+ RotateCcw,
+ Server,
+ X,
+} from "lucide-react";
+import type {
+ KeyboardEvent as ReactKeyboardEvent,
+ PointerEvent as ReactPointerEvent,
+} from "react";
+import { useEffect, useMemo, useRef, useState } from "react";
+import type { Node } from "../data/models";
+import { useDashboardStore } from "../state/useDashboardStore";
+import { Button, HealthBadge, Progress } from "./ui";
+
+type PanPosition = { x: number; y: number };
+
+export function Topology({
+ nodes,
+ layout = "topology",
+}: {
+ nodes: Node[];
+ layout?: "topology" | "orbit";
+}) {
+ const selectedNodeId = useDashboardStore((state) => state.selectedNodeId);
+ const selectNode = useDashboardStore((state) => state.selectNode);
+ const coordinator =
+ nodes.find((node) => node.role === "coordinator") ?? nodes[0];
+ const workers = nodes.filter((node) => node.id !== coordinator?.id);
+ const positions = useMemo(
+ () =>
+ workers.map((node, index) => {
+ const angle =
+ (index / Math.max(workers.length, 1)) * Math.PI * 2 - Math.PI / 2;
+ return layout === "orbit"
+ ? { node, x: 50 + Math.cos(angle) * 32, y: 50 + Math.sin(angle) * 32 }
+ : {
+ node,
+ x: 25 + index * (50 / Math.max(workers.length - 1, 1)),
+ y: index % 2 === 0 ? 73 : 82,
+ };
+ }),
+ [workers, layout],
+ );
+ const selected = nodes.find((node) => node.id === selectedNodeId);
+ const board = useRef(null);
+ const drag = useRef<{
+ pointerId: number;
+ origin: PanPosition;
+ start: PanPosition;
+ } | null>(null);
+ const [zoom, setZoom] = useState(1);
+ const [pan, setPan] = useState({ x: 0, y: 0 });
+ const [fullscreen, setFullscreen] = useState(false);
+ const [fullscreenError, setFullscreenError] = useState("");
+
+ useEffect(() => {
+ const update = () =>
+ setFullscreen(document.fullscreenElement === board.current);
+ document.addEventListener("fullscreenchange", update);
+ return () => document.removeEventListener("fullscreenchange", update);
+ }, []);
+
+ const changeZoom = (delta: number) =>
+ setZoom((value) =>
+ Math.max(0.65, Math.min(1.6, Number((value + delta).toFixed(2)))),
+ );
+ const startPan = (event: ReactPointerEvent) => {
+ if (event.target instanceof HTMLElement && event.target.closest("button"))
+ return;
+ if (event.button !== 0) return;
+ drag.current = {
+ pointerId: event.pointerId,
+ origin: pan,
+ start: { x: event.clientX, y: event.clientY },
+ };
+ event.currentTarget.setPointerCapture(event.pointerId);
+ };
+ const movePan = (event: ReactPointerEvent) => {
+ if (!drag.current || drag.current.pointerId !== event.pointerId) return;
+ setPan({
+ x: drag.current.origin.x + event.clientX - drag.current.start.x,
+ y: drag.current.origin.y + event.clientY - drag.current.start.y,
+ });
+ };
+ const stopPan = (event: ReactPointerEvent) => {
+ if (drag.current?.pointerId === event.pointerId) drag.current = null;
+ };
+ const toggleFullscreen = async () => {
+ if (!board.current) return;
+ try {
+ if (document.fullscreenElement === board.current)
+ await document.exitFullscreen();
+ else await board.current.requestFullscreen();
+ setFullscreenError("");
+ } catch {
+ setFullscreenError("Fullscreen is unavailable in this browser context.");
+ }
+ };
+ const handleBoardKeyDown = (
+ event: ReactKeyboardEvent,
+ ) => {
+ if (event.key === "ArrowLeft")
+ setPan((value) => ({ ...value, x: value.x + 24 }));
+ if (event.key === "ArrowRight")
+ setPan((value) => ({ ...value, x: value.x - 24 }));
+ if (event.key === "ArrowUp")
+ setPan((value) => ({ ...value, y: value.y + 24 }));
+ if (event.key === "ArrowDown")
+ setPan((value) => ({ ...value, y: value.y - 24 }));
+ if (event.key === "+" || event.key === "=") changeZoom(0.1);
+ if (event.key === "-") changeZoom(-0.1);
+ if (event.key === "0") {
+ setZoom(1);
+ setPan({ x: 0, y: 0 });
+ }
+ if (
+ [
+ "ArrowLeft",
+ "ArrowRight",
+ "ArrowUp",
+ "ArrowDown",
+ "+",
+ "=",
+ "-",
+ "0",
+ ].includes(event.key)
+ )
+ event.preventDefault();
+ };
+
+ return (
+
+
+ Interactive demo cluster topology
+
+ {layout === "orbit" && (
+
+
+
+
+ )}
+
+ {coordinator && (
+ <>
+
+ {positions.map(({ node, x, y }) => (
+
+ ))}
+
+ selectNode(coordinator.id)}
+ aria-label={`Select coordinator ${coordinator.name}`}
+ >
+
+
+
+
+ {coordinator.name}
+ COORDINATOR
+
+ {positions.map(({ node, x, y }) => (
+ selectNode(node.id)}
+ aria-label={`Select worker ${node.name}`}
+ >
+
+ {node.name}
+
+ {node.health === "offline" ? "OFFLINE" : "WORKER"}
+
+
+ ))}
+ >
+ )}
+
+
+ Topology view controls
+ {Math.round(zoom * 100)}%
+ changeZoom(-0.1)}
+ >
+
+
+ changeZoom(0.1)}
+ >
+
+
+ {
+ setZoom(1);
+ setPan({ x: 0, y: 0 });
+ }}
+ >
+
+
+ void toggleFullscreen()}
+ >
+ {fullscreen ? : }
+
+
+
+ DEMO TOPOLOGY · {" "}
+ {nodes.length} SAMPLE NODES · DRAG TO PAN
+
+
+ {fullscreenError && (
+
+ {fullscreenError}
+
+ )}
+
+ Drag to pan, use arrow keys to pan, plus and minus to zoom, and zero to
+ reset.
+
+ {selected ? (
+
+
+
+ NODE DETAIL
+
{selected.name}
+
+
selectNode(null)}
+ >
+
+
+
+
+
+ {selected.host} · {selected.os}
+
+
+
+
CPU
+
{selected.cpu}%
+
+
+
+
Memory
+
{selected.memory}%
+
+
+
+
+
+ {selected.role === "coordinator" ? "Coordinator" : "Worker node"}
+
+
+ Uptime {selected.uptime}
+
+
+ Last sample {selected.lastSeen}
+
+ Sample values only. No node is connected.
+
+ ) : (
+
+ )}
+
+ );
+}
diff --git a/dashboard/src/components/ui.tsx b/dashboard/src/components/ui.tsx
new file mode 100644
index 0000000..d0b38c9
--- /dev/null
+++ b/dashboard/src/components/ui.tsx
@@ -0,0 +1,166 @@
+import {
+ AlertTriangle,
+ Check,
+ CircleHelp,
+ CircleOff,
+ LoaderCircle,
+} from "lucide-react";
+import type { ButtonHTMLAttributes, HTMLAttributes, ReactNode } from "react";
+import type { Health } from "../data/models";
+import { Counter } from "./Counter";
+
+export function Button({
+ className = "",
+ variant = "secondary",
+ ...props
+}: ButtonHTMLAttributes & {
+ variant?: "primary" | "secondary" | "ghost" | "danger";
+}) {
+ return (
+
+ );
+}
+export function Card({
+ className = "",
+ ...props
+}: HTMLAttributes) {
+ return ;
+}
+export function PageTitle({
+ eyebrow,
+ title,
+ description,
+ actions,
+}: {
+ eyebrow: string;
+ title: string;
+ description?: string;
+ actions?: ReactNode;
+}) {
+ return (
+
+
+
{eyebrow}
+
{title}
+ {description &&
{description}
}
+
+ {actions && {actions}
}
+
+ );
+}
+export function Stat({
+ label,
+ value,
+ detail,
+ icon,
+ tone = "neutral",
+}: {
+ label: string;
+ value: ReactNode;
+ detail: string;
+ icon: ReactNode;
+ tone?: string;
+}) {
+ return (
+
+ {icon}
+ {label}
+
+ {typeof value === "number" ? (
+
+ ) : (
+ value
+ )}
+
+ {detail}
+
+ );
+}
+export function HealthBadge({ health }: { health: Health }) {
+ const config: Record = {
+ healthy: { icon: , label: "Healthy" },
+ warning: { icon: , label: "Attention" },
+ offline: { icon: , label: "Offline" },
+ unknown: { icon: , label: "Unknown" },
+ };
+ return (
+
+ {config[health].icon}
+ {config[health].label}
+
+ );
+}
+export function Progress({
+ value,
+ tone = "blue",
+}: {
+ value: number;
+ tone?: string;
+}) {
+ return (
+
+ );
+}
+export function EmptyState({
+ title,
+ detail,
+}: {
+ title: string;
+ detail: string;
+}) {
+ return (
+
+
+
+
+
{title}
+
{detail}
+
+ );
+}
+
+export function LoadingState({ label }: { label: string }) {
+ return (
+
+
+ {label}
+
+ );
+}
+
+export function ErrorState({
+ title,
+ detail,
+ onRetry,
+}: {
+ title: string;
+ detail: string;
+ onRetry: () => void;
+}) {
+ return (
+
+
+
{title}
+
{detail}
+
+ Reload view
+
+
+ );
+}
diff --git a/dashboard/src/data/agentLayout.ts b/dashboard/src/data/agentLayout.ts
new file mode 100644
index 0000000..a3bc39b
--- /dev/null
+++ b/dashboard/src/data/agentLayout.ts
@@ -0,0 +1,93 @@
+import { localProvider } from "./localProvider";
+
+export type AgentGraphMode = "network" | "radial";
+export type AgentGraphPoint = { x: number; y: number };
+export type AgentGraphCamera = { x: number; y: number; zoom: number };
+export type AgentGraphLayout = {
+ positions: Record>;
+ cameras: Record;
+};
+
+function restoreLayout(
+ value: unknown,
+ fallback: AgentGraphLayout,
+): AgentGraphLayout {
+ if (typeof value !== "object" || value === null) return fallback;
+ const data = value as Partial;
+ const positions = { ...fallback.positions };
+ const cameras = { ...fallback.cameras };
+ for (const mode of ["network", "radial"] as const) {
+ const candidate = data.positions?.[mode];
+ if (candidate && typeof candidate === "object") {
+ positions[mode] = {
+ ...fallback.positions[mode],
+ ...Object.fromEntries(
+ Object.entries(candidate).filter(
+ (entry): entry is [string, AgentGraphPoint] =>
+ typeof entry[1] === "object" &&
+ entry[1] !== null &&
+ Number.isFinite((entry[1] as AgentGraphPoint).x) &&
+ Number.isFinite((entry[1] as AgentGraphPoint).y),
+ ),
+ ),
+ };
+ }
+ const camera = data.cameras?.[mode];
+ if (
+ camera &&
+ Number.isFinite(camera.x) &&
+ Number.isFinite(camera.y) &&
+ Number.isFinite(camera.zoom)
+ ) {
+ cameras[mode] = {
+ x: camera.x,
+ y: camera.y,
+ zoom: Math.max(0.65, Math.min(1.8, camera.zoom)),
+ };
+ }
+ }
+ return { positions, cameras };
+}
+
+export async function readAgentGraphLayout(
+ fallback: AgentGraphLayout,
+): Promise {
+ try {
+ return restoreLayout(
+ await localProvider.get("agent-layouts", "default"),
+ fallback,
+ );
+ } catch {
+ return fallback;
+ }
+}
+
+export async function writeAgentGraphLayout(
+ layout: AgentGraphLayout,
+): Promise {
+ try {
+ await localProvider.put("agent-layouts", "default", layout);
+ } catch {
+ // Layout persistence is best effort; graph editing remains available if IndexedDB is unavailable.
+ }
+}
+
+/* ORIGINAL (preserved, not deleted — disabled by comment):
+const STORAGE_KEY = "opencluster.agents.layout.v1";
+export function readAgentGraphLayoutFromLocalStorage(fallback: AgentGraphLayout): AgentGraphLayout {
+ try {
+ const raw = localStorage.getItem(STORAGE_KEY);
+ if (!raw) return fallback;
+ return restoreLayout(JSON.parse(raw), fallback);
+ } catch {
+ return fallback;
+ }
+}
+export function writeAgentGraphLayoutToLocalStorage(layout: AgentGraphLayout): void {
+ try {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(layout));
+ } catch {
+ // Layout persistence is best effort; graph editing remains available if storage is blocked or full.
+ }
+}
+*/
diff --git a/dashboard/src/data/agents.ts b/dashboard/src/data/agents.ts
new file mode 100644
index 0000000..f064b91
--- /dev/null
+++ b/dashboard/src/data/agents.ts
@@ -0,0 +1,199 @@
+export type AgentStatus = "active" | "idle" | "attention" | "paused";
+export type AgentKind =
+ | "agent"
+ | "model"
+ | "tool"
+ | "memory"
+ | "task"
+ | "service";
+export type AgentEntity = {
+ id: string;
+ name: string;
+ vendor: string;
+ model: string;
+ role: string;
+ status: AgentStatus;
+ tasks: number;
+ latency: string;
+ color: string;
+ icon: "codex" | "claude" | "hermes" | "openclaw" | "custom";
+ kind: AgentKind;
+};
+export type AgentRelation = {
+ source: string;
+ target: string;
+ label: string;
+ strength: number;
+};
+
+export const agentSeedCatalog: AgentEntity[] = [
+ {
+ id: "codex",
+ name: "Codex",
+ vendor: "OpenAI",
+ model: "Codex · demo adapter",
+ role: "Workflow coordinator",
+ status: "active",
+ tasks: 4,
+ latency: "42 ms",
+ color: "mint",
+ icon: "codex",
+ kind: "agent",
+ },
+ {
+ id: "claude",
+ name: "Claude Code",
+ vendor: "Anthropic",
+ model: "Claude · demo adapter",
+ role: "Code analysis",
+ status: "active",
+ tasks: 2,
+ latency: "58 ms",
+ color: "peach",
+ icon: "claude",
+ kind: "agent",
+ },
+ {
+ id: "hermes",
+ name: "Hermes Agent",
+ vendor: "Open source",
+ model: "Hermes · demo adapter",
+ role: "Task automation",
+ status: "idle",
+ tasks: 0,
+ latency: "—",
+ color: "violet",
+ icon: "hermes",
+ kind: "agent",
+ },
+ {
+ id: "openclaw",
+ name: "OpenClaw",
+ vendor: "Community",
+ model: "OpenClaw · demo adapter",
+ role: "Tool routing",
+ status: "attention",
+ tasks: 1,
+ latency: "91 ms",
+ color: "blue",
+ icon: "openclaw",
+ kind: "agent",
+ },
+ {
+ id: "custom",
+ name: "Custom adapter",
+ vendor: "Local example",
+ model: "Compatible endpoint · demo",
+ role: "Custom integration",
+ status: "paused",
+ tasks: 0,
+ latency: "—",
+ color: "gold",
+ icon: "custom",
+ kind: "agent",
+ },
+ {
+ id: "model",
+ name: "Reasoning model",
+ vendor: "MODEL",
+ model: "Local demo model",
+ role: "Language model",
+ status: "active",
+ tasks: 3,
+ latency: "36 ms",
+ color: "violet",
+ icon: "custom",
+ kind: "model",
+ },
+ {
+ id: "tools",
+ name: "Tool registry",
+ vendor: "TOOLS",
+ model: "Local demo tools",
+ role: "Tool collection",
+ status: "idle",
+ tasks: 0,
+ latency: "—",
+ color: "blue",
+ icon: "openclaw",
+ kind: "tool",
+ },
+ {
+ id: "memory",
+ name: "Shared context",
+ vendor: "MEMORY",
+ model: "Session memory · demo",
+ role: "Context store",
+ status: "active",
+ tasks: 2,
+ latency: "12 ms",
+ color: "gold",
+ icon: "hermes",
+ kind: "memory",
+ },
+ {
+ id: "task",
+ name: "Build review",
+ vendor: "TASK",
+ model: "Sample job · demo",
+ role: "Queued task",
+ status: "idle",
+ tasks: 1,
+ latency: "—",
+ color: "peach",
+ icon: "claude",
+ kind: "task",
+ },
+ {
+ id: "service",
+ name: "Git provider",
+ vendor: "SERVICE",
+ model: "External service · illustrative",
+ role: "Integration endpoint",
+ status: "paused",
+ tasks: 0,
+ latency: "—",
+ color: "blue",
+ icon: "custom",
+ kind: "service",
+ },
+];
+
+export const agentRelationSeeds: AgentRelation[] = [
+ {
+ source: "codex",
+ target: "claude",
+ label: "delegates review",
+ strength: 0.82,
+ },
+ { source: "codex", target: "hermes", label: "routes tasks", strength: 0.62 },
+ {
+ source: "codex",
+ target: "openclaw",
+ label: "shares context",
+ strength: 0.46,
+ },
+ { source: "claude", target: "custom", label: "handoff", strength: 0.34 },
+ { source: "hermes", target: "custom", label: "tool access", strength: 0.3 },
+ {
+ source: "openclaw",
+ target: "hermes",
+ label: "event bridge",
+ strength: 0.56,
+ },
+ { source: "codex", target: "model", label: "uses model", strength: 0.66 },
+ { source: "claude", target: "tools", label: "uses tools", strength: 0.42 },
+ {
+ source: "hermes",
+ target: "memory",
+ label: "reads context",
+ strength: 0.52,
+ },
+ { source: "codex", target: "task", label: "runs task", strength: 0.72 },
+ {
+ source: "task",
+ target: "service",
+ label: "requests change",
+ strength: 0.38,
+ },
+];
diff --git a/dashboard/src/data/demoApi.ts b/dashboard/src/data/demoApi.ts
new file mode 100644
index 0000000..a152718
--- /dev/null
+++ b/dashboard/src/data/demoApi.ts
@@ -0,0 +1,87 @@
+import {
+ initialActivity,
+ initialClusters,
+ initialNodes,
+ initialTasks,
+} from "./mock";
+import type { ActivityEvent, Cluster, DemoTask, Node } from "./models";
+
+export interface DashboardData {
+ getClusters(): Promise;
+ getNodes(clusterId?: string): Promise;
+ getTasks(): Promise;
+ getActivity(): Promise;
+ createCluster(name: string, region: string, description: string): Cluster;
+ updateCluster(
+ cluster: Cluster,
+ changes: Partial>,
+ ): Cluster;
+ moveNode(node: Node, clusterId: string): Node;
+ designateCoordinator(nodes: Node[], nodeId: string): Node[];
+ createTask(nodeId: string, id: string): DemoTask;
+ assignTask(task: DemoTask, nodeId: string): DemoTask;
+}
+const pause = () =>
+ new Promise((resolve) => window.setTimeout(resolve, 120));
+export const demoApi: DashboardData = {
+ async getClusters() {
+ await pause();
+ return structuredClone(initialClusters);
+ },
+ async getNodes(clusterId) {
+ await pause();
+ return structuredClone(
+ clusterId
+ ? initialNodes.filter((node) => node.clusterId === clusterId)
+ : initialNodes,
+ );
+ },
+ async getTasks() {
+ await pause();
+ return structuredClone(initialTasks);
+ },
+ async getActivity() {
+ await pause();
+ return structuredClone(initialActivity);
+ },
+ createCluster(name, region, description) {
+ return {
+ id: `demo-${Date.now()}`,
+ name: name.trim(),
+ region,
+ description,
+ nodeIds: [],
+ createdAt: new Date().toISOString().slice(0, 10),
+ };
+ },
+ updateCluster(cluster, changes) {
+ return { ...cluster, ...changes };
+ },
+ moveNode(node, clusterId) {
+ return { ...node, clusterId };
+ },
+ designateCoordinator(nodes, nodeId) {
+ const target = nodes.find((node) => node.id === nodeId);
+ if (!target) return nodes;
+ return nodes.map((node) =>
+ node.clusterId === target.clusterId
+ ? { ...node, role: node.id === nodeId ? "coordinator" : "worker" }
+ : node,
+ );
+ },
+ createTask(nodeId, id) {
+ return {
+ id,
+ name: "Manual demo task",
+ nodeId,
+ status: "queued",
+ progress: 0,
+ started: "Waiting",
+ duration: "—",
+ type: "Manual",
+ };
+ },
+ assignTask(task, nodeId) {
+ return { ...task, nodeId };
+ },
+};
diff --git a/dashboard/src/data/localProvider.ts b/dashboard/src/data/localProvider.ts
new file mode 100644
index 0000000..db28d75
--- /dev/null
+++ b/dashboard/src/data/localProvider.ts
@@ -0,0 +1,481 @@
+import { type DBSchema, type IDBPDatabase, openDB } from "idb";
+import { z } from "zod";
+import {
+ type AgentEntity,
+ type AgentRelation,
+ agentRelationSeeds,
+ agentSeedCatalog,
+} from "./agents";
+import {
+ initialActivity,
+ initialClusters,
+ initialNodes,
+ initialTasks,
+ softwareCatalog,
+ telemetry,
+} from "./mock";
+import type {
+ ActivityEvent,
+ Cluster,
+ DemoTask,
+ Node,
+ SoftwareConfig,
+ TelemetryPoint,
+} from "./models";
+import type {
+ DashboardProvider,
+ QueryOptions,
+ Repository,
+} from "./providerContracts";
+import type { Workflow } from "./workflows";
+
+const clusterSchema = z.object({
+ id: z.string().min(1),
+ name: z.string().trim().min(1).max(80),
+ region: z.string().max(100),
+ description: z.string().max(500),
+ nodeIds: z.array(z.string()),
+ createdAt: z.string(),
+});
+const nodeSchema = z.object({
+ id: z.string().min(1),
+ name: z.string().min(1),
+ role: z.enum(["coordinator", "worker"]),
+ clusterId: z.string().min(1),
+ host: z.string(),
+ os: z.string(),
+ health: z.enum(["healthy", "warning", "offline", "unknown"]),
+ cpu: z.number().finite().min(0).max(100),
+ memory: z.number().finite().min(0).max(100),
+ disk: z.number().finite().min(0).max(100),
+ network: z.number().finite().min(0),
+ gpu: z.string().optional(),
+ uptime: z.string(),
+ lastSeen: z.string(),
+ labels: z.array(z.string()),
+});
+const workflowSchema = z.object({
+ version: z.literal(1),
+ name: z.string().trim().min(1).max(120),
+ nodes: z
+ .array(
+ z.object({
+ id: z.string().min(1).max(120),
+ kind: z.enum([
+ "trigger",
+ "schedule",
+ "agent",
+ "condition",
+ "delay",
+ "transform",
+ "notify",
+ "success",
+ "failure",
+ ]),
+ title: z.string().min(1).max(120),
+ detail: z.string().max(500),
+ x: z.number().finite(),
+ y: z.number().finite(),
+ config: z.record(
+ z.string(),
+ z.union([z.string(), z.number().finite(), z.boolean()]),
+ ),
+ }),
+ )
+ .max(200),
+ edges: z
+ .array(
+ z.object({
+ id: z.string().min(1).max(240),
+ source: z.string().min(1).max(120),
+ target: z.string().min(1).max(120),
+ label: z.string().max(120).optional(),
+ }),
+ )
+ .max(500),
+});
+const recordValueSchemas: Record = {
+ agents: z.object({
+ id: z.string(),
+ name: z.string(),
+ vendor: z.string(),
+ model: z.string(),
+ role: z.string(),
+ status: z.enum(["active", "idle", "attention", "paused"]),
+ tasks: z.number().int().nonnegative(),
+ latency: z.string(),
+ color: z.string(),
+ icon: z.enum(["codex", "claude", "hermes", "openclaw", "custom"]),
+ kind: z.enum(["agent", "model", "tool", "memory", "task", "service"]),
+ }),
+ "agent-relations": z.object({
+ source: z.string().min(1),
+ target: z.string().min(1),
+ label: z.string().max(120),
+ strength: z.number().finite().min(0).max(1),
+ }),
+ workflows: workflowSchema,
+ "agent-layouts": z.object({
+ positions: z.object({
+ network: z.record(
+ z.string(),
+ z.object({ x: z.number().finite(), y: z.number().finite() }),
+ ),
+ radial: z.record(
+ z.string(),
+ z.object({ x: z.number().finite(), y: z.number().finite() }),
+ ),
+ }),
+ cameras: z.object({
+ network: z.object({
+ x: z.number().finite(),
+ y: z.number().finite(),
+ zoom: z.number().finite().min(0.1).max(10),
+ }),
+ radial: z.object({
+ x: z.number().finite(),
+ y: z.number().finite(),
+ zoom: z.number().finite().min(0.1).max(10),
+ }),
+ }),
+ }),
+ "software-configs": z.object({
+ id: z.string().min(1),
+ softwareId: z.string().min(1),
+ nodeId: z.string().min(1),
+ profile: z.string().min(1).max(120),
+ }),
+ meta: z.object({ version: z.literal(1) }),
+ clusters: clusterSchema,
+ nodes: nodeSchema,
+ telemetry: z.object({
+ time: z.string(),
+ cpu: z.number().finite(),
+ memory: z.number().finite(),
+ networkIn: z.number().finite(),
+ networkOut: z.number().finite(),
+ }),
+ tasks: z.object({
+ id: z.string(),
+ name: z.string(),
+ nodeId: z.string(),
+ status: z.enum(["running", "queued", "completed", "failed"]),
+ progress: z.number().finite().min(0).max(100),
+ started: z.string(),
+ duration: z.string(),
+ type: z.string(),
+ }),
+ activity: z.object({
+ id: z.string(),
+ title: z.string(),
+ detail: z.string(),
+ time: z.string(),
+ severity: z.enum(["info", "success", "warning", "error"]),
+ source: z.string(),
+ }),
+ "software-catalog": z.object({
+ id: z.string(),
+ name: z.string(),
+ vendor: z.string(),
+ description: z.string(),
+ category: z.string(),
+ color: z.string(),
+ status: z.enum(["available", "planned"]),
+ }),
+};
+const recordSchema = z.object({
+ key: z.string(),
+ bucket: z.string(),
+ id: z.string(),
+ value: z.unknown(),
+});
+type Bucket =
+ | "clusters"
+ | "nodes"
+ | "workflows"
+ | "agent-layouts"
+ | "agents"
+ | "agent-relations"
+ | "software-catalog"
+ | "software-configs"
+ | "telemetry"
+ | "tasks"
+ | "activity"
+ | "meta";
+type RecordRow = z.infer;
+interface OpenClusterDB extends DBSchema {
+ records: { key: string; value: RecordRow; indexes: { bucket: string } };
+}
+
+let database: Promise> | undefined;
+const db = () =>
+ (database ??= openDB("opencluster-dashboard", 1, {
+ upgrade(database) {
+ const store = database.createObjectStore("records", { keyPath: "key" });
+ store.createIndex("bucket", "bucket");
+ },
+ }));
+const keyFor = (bucket: Bucket, id: string) => `${bucket}:${id}`;
+const recordFor = (bucket: Bucket, id: string, value: T) => {
+ recordValueSchemas[bucket].parse(value);
+ return recordSchema.parse({ key: keyFor(bucket, id), bucket, id, value });
+};
+const valueFor = (bucket: Bucket, value: unknown): T =>
+ recordValueSchemas[bucket].parse(value) as T;
+
+export const localProvider = {
+ async list(bucket: Bucket): Promise {
+ const rows = await (await db()).getAllFromIndex(
+ "records",
+ "bucket",
+ bucket,
+ );
+ return rows.map((row) =>
+ valueFor(bucket, recordSchema.parse(row).value),
+ );
+ },
+ async get(bucket: Bucket, id: string): Promise {
+ const row = await (await db()).get("records", keyFor(bucket, id));
+ return row ? valueFor(bucket, recordSchema.parse(row).value) : undefined;
+ },
+ async put(bucket: Bucket, id: string, value: T): Promise {
+ const row = recordFor(bucket, id, value);
+ await (await db()).put("records", row);
+ },
+ async putMany(
+ bucket: Bucket,
+ records: Array<{ id: string; value: T }>,
+ ): Promise {
+ const validated = records.map((record) =>
+ recordFor(bucket, record.id, record.value),
+ );
+ const database = await db();
+ const tx = database.transaction("records", "readwrite");
+ for (const record of validated) {
+ // ORIGINAL (preserved, not deleted — disabled by comment):
+ // tx.store.put(recordFor(bucket, record.id, record.value));
+ tx.store.put(record);
+ }
+ await tx.done;
+ },
+ async replace(
+ bucket: Bucket,
+ records: Array<{ id: string; value: T }>,
+ ): Promise {
+ await this.replaceBuckets({ [bucket]: records });
+ },
+ async replaceBuckets(
+ buckets: Partial>>,
+ ): Promise {
+ // Validate every bucket before starting a transaction that clears saved records.
+ for (const [bucket, records] of Object.entries(buckets)) {
+ for (const record of records ?? [])
+ recordFor(bucket as Bucket, record.id, record.value);
+ }
+ const database = await db();
+ const tx = database.transaction("records", "readwrite");
+ for (const [bucket, records] of Object.entries(buckets) as Array<
+ [Bucket, Array<{ id: string; value: unknown }> | undefined]
+ >) {
+ if (!records) continue;
+ const existing = await tx.store.index("bucket").getAllKeys(bucket);
+ await Promise.all(existing.map((key) => tx.store.delete(key)));
+ for (const record of records)
+ tx.store.put(recordFor(bucket, record.id, record.value));
+ }
+ await tx.done;
+ },
+ async writeClusterNodeState(
+ clusters: Cluster[],
+ nodes: Node[],
+ ): Promise {
+ await this.replaceBuckets({
+ clusters: clusters.map((value) => ({ id: value.id, value })),
+ nodes: nodes.map((value) => ({ id: value.id, value })),
+ });
+ },
+ async delete(bucket: Bucket, id: string): Promise {
+ await (await db()).delete("records", keyFor(bucket, id));
+ },
+ async initialize(
+ legacy?: unknown,
+ ): Promise<{ clusters: Cluster[]; nodes: Node[] }> {
+ const database = await db();
+ const marker = await database.get("records", keyFor("meta", "initialized"));
+ const firstRun =
+ !marker || !recordValueSchemas.meta.safeParse(marker.value).success;
+ const existingClusters = (
+ await database.getAllFromIndex("records", "bucket", "clusters")
+ )
+ .map((row) => clusterSchema.safeParse(row.value))
+ .filter((r) => r.success)
+ .map((r) => r.data);
+ const existingNodes = (
+ await database.getAllFromIndex("records", "bucket", "nodes")
+ )
+ .map((row) => nodeSchema.safeParse(row.value))
+ .filter((r) => r.success)
+ .map((r) => r.data);
+ const old = z
+ .object({
+ clusters: z.array(clusterSchema).optional(),
+ nodes: z.array(nodeSchema).optional(),
+ })
+ .safeParse(legacy);
+ const clusters = existingClusters.length
+ ? existingClusters
+ : firstRun && old.success && old.data.clusters?.length
+ ? old.data.clusters
+ : firstRun
+ ? structuredClone(initialClusters)
+ : [];
+ const nodes = existingNodes.length
+ ? existingNodes
+ : firstRun && old.success && old.data.nodes?.length
+ ? old.data.nodes
+ : firstRun
+ ? structuredClone(initialNodes)
+ : [];
+ const validClusterIds = new Set(clusters.map((cluster) => cluster.id));
+ const safeNodes = nodes.filter((node) =>
+ validClusterIds.has(node.clusterId),
+ );
+ const consistentClusters = clusters.map((cluster) => ({
+ ...cluster,
+ nodeIds: safeNodes
+ .filter((node) => node.clusterId === cluster.id)
+ .map((node) => node.id),
+ }));
+ if (!existingClusters.length && consistentClusters.length)
+ await this.putMany(
+ "clusters",
+ consistentClusters.map((value) => ({ id: value.id, value })),
+ );
+ if (!existingNodes.length && safeNodes.length)
+ await this.putMany(
+ "nodes",
+ safeNodes.map((value) => ({ id: value.id, value })),
+ );
+ if (
+ firstRun &&
+ !(await database.countFromIndex("records", "bucket", "telemetry"))
+ )
+ await this.putMany(
+ "telemetry",
+ telemetry.map((value, index) => ({ id: String(index), value })),
+ );
+ if (
+ firstRun &&
+ !(await database.countFromIndex("records", "bucket", "tasks"))
+ )
+ await this.putMany(
+ "tasks",
+ initialTasks.map((value: DemoTask) => ({ id: value.id, value })),
+ );
+ if (
+ firstRun &&
+ !(await database.countFromIndex("records", "bucket", "activity"))
+ )
+ await this.putMany(
+ "activity",
+ initialActivity.map((value: ActivityEvent) => ({
+ id: value.id,
+ value,
+ })),
+ );
+ if (
+ firstRun &&
+ !(await database.countFromIndex("records", "bucket", "software-catalog"))
+ )
+ await this.putMany(
+ "software-catalog",
+ softwareCatalog.map((value) => ({ id: value.id, value })),
+ );
+ if (
+ firstRun &&
+ !(await database.countFromIndex("records", "bucket", "agents"))
+ )
+ await this.putMany(
+ "agents",
+ agentSeedCatalog.map((value: AgentEntity) => ({ id: value.id, value })),
+ );
+ if (
+ firstRun &&
+ !(await database.countFromIndex("records", "bucket", "agent-relations"))
+ )
+ await this.putMany(
+ "agent-relations",
+ agentRelationSeeds.map((value) => ({
+ id: `${value.source}:${value.target}`,
+ value,
+ })),
+ );
+ if (firstRun) await this.put("meta", "initialized", { version: 1 });
+ return { clusters: consistentClusters, nodes: safeNodes };
+ },
+ async resetRecords(): Promise {
+ await this.replaceBuckets({
+ clusters: initialClusters.map((value) => ({ id: value.id, value })),
+ nodes: initialNodes.map((value) => ({ id: value.id, value })),
+ telemetry: telemetry.map((value, index) => ({
+ id: String(index),
+ value,
+ })),
+ tasks: initialTasks.map((value) => ({ id: value.id, value })),
+ activity: initialActivity.map((value) => ({ id: value.id, value })),
+ "software-catalog": softwareCatalog.map((value) => ({
+ id: value.id,
+ value,
+ })),
+ workflows: [],
+ "agent-layouts": [],
+ agents: agentSeedCatalog.map((value) => ({ id: value.id, value })),
+ meta: [{ id: "initialized", value: { version: 1 } }],
+ "agent-relations": agentRelationSeeds.map((value) => ({
+ id: `${value.source}:${value.target}`,
+ value,
+ })),
+ "software-configs": [],
+ });
+ },
+};
+
+export type { Bucket, DashboardProvider, TelemetryPoint };
+
+function repository(bucket: Bucket): Repository {
+ return {
+ async list(options: QueryOptions = {}) {
+ let values = await localProvider.list(bucket);
+ if (options.search) {
+ const query = options.search.trim().toLocaleLowerCase();
+ values = values.filter((value) =>
+ JSON.stringify(value).toLocaleLowerCase().includes(query),
+ );
+ }
+ if (options.filter) values = values.filter(options.filter);
+ if (options.sort) values.sort(options.sort);
+ return values;
+ },
+ get: (id) => localProvider.get(bucket, id),
+ async create(id, value) {
+ if ((await localProvider.get(bucket, id)) !== undefined)
+ throw new Error(`Record ${id} already exists.`);
+ await localProvider.put(bucket, id, value);
+ },
+ update: (id, value) => localProvider.put(bucket, id, value),
+ delete: (id) => localProvider.delete(bucket, id),
+ };
+}
+
+export const dashboardLocalProvider: DashboardProvider = {
+ clusters: repository("clusters"),
+ nodes: repository("nodes"),
+ agents: repository("agents"),
+ agentRelations: repository("agent-relations"),
+ workflows: repository("workflows"),
+ softwareCatalog: repository("software-catalog"),
+ softwareConfigurations: repository("software-configs"),
+ tasks: repository("tasks"),
+ activity: repository("activity"),
+ telemetry: repository("telemetry"),
+};
diff --git a/dashboard/src/data/mock.ts b/dashboard/src/data/mock.ts
new file mode 100644
index 0000000..7e69a20
--- /dev/null
+++ b/dashboard/src/data/mock.ts
@@ -0,0 +1,274 @@
+import type {
+ ActivityEvent,
+ Cluster,
+ DemoTask,
+ Node,
+ Software,
+ TelemetryPoint,
+} from "./models";
+
+export const initialClusters: Cluster[] = [
+ {
+ id: "cl-main",
+ name: "Homelab Core",
+ region: "Local network",
+ description: "Primary demo cluster for local workloads",
+ nodeIds: ["node-01", "node-02", "node-03", "node-04"],
+ createdAt: "2026-10-18",
+ },
+ {
+ id: "cl-edge",
+ name: "Edge Lab",
+ region: "Remote site · demo",
+ description: "A second sample environment",
+ nodeIds: ["node-05", "node-06"],
+ createdAt: "2026-10-20",
+ },
+];
+export const initialNodes: Node[] = [
+ {
+ id: "node-01",
+ name: "atlas-control",
+ role: "coordinator",
+ clusterId: "cl-main",
+ host: "10.0.0.10",
+ os: "Arch Linux · x86_64",
+ health: "healthy",
+ cpu: 32,
+ memory: 58,
+ disk: 41,
+ network: 26,
+ gpu: "NVIDIA RTX 4080 · 16 GB",
+ uptime: "18d 04h",
+ lastSeen: "Just now",
+ labels: ["control", "gpu"],
+ },
+ {
+ id: "node-02",
+ name: "atlas-worker-01",
+ role: "worker",
+ clusterId: "cl-main",
+ host: "10.0.0.21",
+ os: "Debian 13 · x86_64",
+ health: "healthy",
+ cpu: 67,
+ memory: 74,
+ disk: 62,
+ network: 48,
+ uptime: "12d 11h",
+ lastSeen: "Just now",
+ labels: ["compute"],
+ },
+ {
+ id: "node-03",
+ name: "atlas-worker-02",
+ role: "worker",
+ clusterId: "cl-main",
+ host: "10.0.0.22",
+ os: "Arch Linux · x86_64",
+ health: "warning",
+ cpu: 84,
+ memory: 81,
+ disk: 55,
+ network: 71,
+ uptime: "8d 02h",
+ lastSeen: "1 min ago",
+ labels: ["compute", "memory-heavy"],
+ },
+ {
+ id: "node-04",
+ name: "atlas-storage",
+ role: "worker",
+ clusterId: "cl-main",
+ host: "10.0.0.30",
+ os: "Debian 12 · x86_64",
+ health: "healthy",
+ cpu: 18,
+ memory: 44,
+ disk: 78,
+ network: 32,
+ uptime: "41d 16h",
+ lastSeen: "Just now",
+ labels: ["storage"],
+ },
+ {
+ id: "node-05",
+ name: "edge-gateway",
+ role: "coordinator",
+ clusterId: "cl-edge",
+ host: "192.168.4.2",
+ os: "Debian 13 · aarch64",
+ health: "healthy",
+ cpu: 23,
+ memory: 39,
+ disk: 27,
+ network: 19,
+ uptime: "6d 09h",
+ lastSeen: "Just now",
+ labels: ["edge"],
+ },
+ {
+ id: "node-06",
+ name: "edge-worker-01",
+ role: "worker",
+ clusterId: "cl-edge",
+ host: "192.168.4.8",
+ os: "Ubuntu 24.04 · aarch64",
+ health: "offline",
+ cpu: 0,
+ memory: 0,
+ disk: 33,
+ network: 0,
+ uptime: "—",
+ lastSeen: "22 min ago",
+ labels: ["edge", "compute"],
+ },
+];
+export const telemetry: TelemetryPoint[] = Array.from(
+ { length: 24 },
+ (_, i) => {
+ const hour = String((i + 1) % 24).padStart(2, "0");
+ return {
+ time: `${hour}:00`,
+ cpu: Math.round(35 + Math.sin(i * 0.7) * 15 + (i % 5) * 3),
+ memory: Math.round(52 + Math.sin(i * 0.38) * 8 + (i % 4) * 2),
+ networkIn: Math.round(36 + Math.sin(i * 0.9) * 24 + (i % 3) * 8),
+ networkOut: Math.round(28 + Math.cos(i * 0.65) * 18 + (i % 4) * 5),
+ };
+ },
+);
+export const softwareCatalog: Software[] = [
+ {
+ id: "codex",
+ name: "Codex",
+ vendor: "OpenAI",
+ description:
+ "Coding agent for repository tasks and software engineering workflows.",
+ category: "Coding agent",
+ color: "#8fdfb0",
+ status: "available",
+ },
+ {
+ id: "claude",
+ name: "Claude Code",
+ vendor: "Anthropic",
+ description:
+ "Agentic coding assistant designed to work in development environments.",
+ category: "Coding agent",
+ color: "#e7a76e",
+ status: "available",
+ },
+ {
+ id: "hermes",
+ name: "Hermes Agent",
+ vendor: "Nous Research",
+ description:
+ "Extensible assistant runtime for configurable agent workflows.",
+ category: "AI agent",
+ color: "#b7a4ff",
+ status: "available",
+ },
+ {
+ id: "openclaw",
+ name: "OpenClaw",
+ vendor: "Community",
+ description: "Open agent platform integration concept for future adapters.",
+ category: "Integration",
+ color: "#77b9ff",
+ status: "planned",
+ },
+];
+export const initialTasks: DemoTask[] = [
+ {
+ id: "job-1042",
+ name: "Build documentation preview",
+ nodeId: "node-02",
+ status: "running",
+ progress: 68,
+ started: "2 min ago",
+ duration: "00:02:14",
+ type: "Build",
+ },
+ {
+ id: "job-1041",
+ name: "Index model artifacts",
+ nodeId: "node-03",
+ status: "queued",
+ progress: 0,
+ started: "Waiting",
+ duration: "—",
+ type: "AI workload",
+ },
+ {
+ id: "job-1040",
+ name: "Nightly storage check",
+ nodeId: "node-04",
+ status: "completed",
+ progress: 100,
+ started: "18 min ago",
+ duration: "00:00:42",
+ type: "Maintenance",
+ },
+ {
+ id: "job-1039",
+ name: "Container image scan",
+ nodeId: "node-02",
+ status: "completed",
+ progress: 100,
+ started: "1 hr ago",
+ duration: "00:01:09",
+ type: "Security scan",
+ },
+ {
+ id: "job-1038",
+ name: "GPU benchmark",
+ nodeId: "node-03",
+ status: "failed",
+ progress: 46,
+ started: "3 hrs ago",
+ duration: "00:00:31",
+ type: "Benchmark",
+ },
+];
+export const initialActivity: ActivityEvent[] = [
+ {
+ id: "evt-1",
+ title: "Node atlas-worker-02 reached high CPU usage",
+ detail: "CPU crossed the 80% demo threshold · Homelab Core",
+ time: "2 min ago",
+ severity: "warning",
+ source: "Monitoring",
+ },
+ {
+ id: "evt-2",
+ title: "Build documentation preview started",
+ detail: "Assigned to atlas-worker-01 · job-1042",
+ time: "4 min ago",
+ severity: "info",
+ source: "Tasks",
+ },
+ {
+ id: "evt-3",
+ title: "Nightly storage check completed",
+ detail: "atlas-storage · 00:00:42",
+ time: "18 min ago",
+ severity: "success",
+ source: "Tasks",
+ },
+ {
+ id: "evt-4",
+ title: "edge-worker-01 went offline",
+ detail: "Last sample received 22 minutes ago · Edge Lab",
+ time: "22 min ago",
+ severity: "error",
+ source: "Nodes",
+ },
+ {
+ id: "evt-5",
+ title: "Demo cluster initialized",
+ detail: "Homelab Core · 4 sample nodes",
+ time: "Yesterday",
+ severity: "success",
+ source: "Clusters",
+ },
+];
diff --git a/dashboard/src/data/models.ts b/dashboard/src/data/models.ts
new file mode 100644
index 0000000..b3eab21
--- /dev/null
+++ b/dashboard/src/data/models.ts
@@ -0,0 +1,66 @@
+export type Health = "healthy" | "warning" | "offline" | "unknown";
+export type Cluster = {
+ id: string;
+ name: string;
+ region: string;
+ description: string;
+ nodeIds: string[];
+ createdAt: string;
+};
+export type Node = {
+ id: string;
+ name: string;
+ role: "coordinator" | "worker";
+ clusterId: string;
+ host: string;
+ os: string;
+ health: Health;
+ cpu: number;
+ memory: number;
+ disk: number;
+ network: number;
+ gpu?: string;
+ uptime: string;
+ lastSeen: string;
+ labels: string[];
+};
+export type TelemetryPoint = {
+ time: string;
+ cpu: number;
+ memory: number;
+ networkIn: number;
+ networkOut: number;
+};
+export type Software = {
+ id: string;
+ name: string;
+ vendor: string;
+ description: string;
+ category: string;
+ color: string;
+ status: "available" | "planned";
+};
+export type SoftwareConfig = {
+ id: string;
+ softwareId: string;
+ nodeId: string;
+ profile: string;
+};
+export type DemoTask = {
+ id: string;
+ name: string;
+ nodeId: string;
+ status: "running" | "queued" | "completed" | "failed";
+ progress: number;
+ started: string;
+ duration: string;
+ type: string;
+};
+export type ActivityEvent = {
+ id: string;
+ title: string;
+ detail: string;
+ time: string;
+ severity: "info" | "success" | "warning" | "error";
+ source: string;
+};
diff --git a/dashboard/src/data/provider.ts b/dashboard/src/data/provider.ts
new file mode 100644
index 0000000..c39653c
--- /dev/null
+++ b/dashboard/src/data/provider.ts
@@ -0,0 +1,4 @@
+import { dashboardLocalProvider } from "./localProvider";
+import type { DashboardProvider } from "./providerContracts";
+
+export const dashboardProvider: DashboardProvider = dashboardLocalProvider;
diff --git a/dashboard/src/data/providerContracts.ts b/dashboard/src/data/providerContracts.ts
new file mode 100644
index 0000000..4f6a938
--- /dev/null
+++ b/dashboard/src/data/providerContracts.ts
@@ -0,0 +1,49 @@
+import type { AgentEntity, AgentRelation } from "./agents";
+import type {
+ ActivityEvent,
+ Cluster,
+ DemoTask,
+ Node,
+ Software,
+ SoftwareConfig,
+ TelemetryPoint,
+} from "./models";
+import type { Workflow } from "./workflows";
+
+export type QueryOptions = {
+ search?: string;
+ sort?: (left: T, right: T) => number;
+ filter?: (value: T) => boolean;
+};
+
+export interface Repository {
+ list(options?: QueryOptions): Promise;
+ get(id: string): Promise;
+ create(id: string, value: T): Promise;
+ update(id: string, value: T): Promise;
+ delete(id: string): Promise;
+}
+
+export interface DashboardProvider {
+ clusters: Repository;
+ nodes: Repository;
+ agents: Repository;
+ agentRelations: Repository;
+ workflows: Repository;
+ softwareCatalog: Repository;
+ softwareConfigurations: Repository;
+ tasks: Repository;
+ activity: Repository;
+ telemetry: Repository;
+}
+
+export interface RemoteDashboardTransport {
+ request(input: {
+ method: "GET" | "POST" | "PATCH" | "DELETE";
+ path: string;
+ body?: unknown;
+ }): Promise;
+ close(): void;
+}
+
+export type DataProviderMode = "local" | "cloud";
diff --git a/dashboard/src/data/queryClient.ts b/dashboard/src/data/queryClient.ts
new file mode 100644
index 0000000..847a491
--- /dev/null
+++ b/dashboard/src/data/queryClient.ts
@@ -0,0 +1,5 @@
+import { QueryClient } from "@tanstack/react-query";
+
+export const dashboardQueryClient = new QueryClient({
+ defaultOptions: { queries: { staleTime: 30_000, retry: false } },
+});
diff --git a/dashboard/src/data/useDashboardData.ts b/dashboard/src/data/useDashboardData.ts
new file mode 100644
index 0000000..b703073
--- /dev/null
+++ b/dashboard/src/data/useDashboardData.ts
@@ -0,0 +1,67 @@
+import { useQuery } from "@tanstack/react-query";
+import { useDashboardStore } from "../state/useDashboardStore";
+import type { Bucket } from "./localProvider";
+import { dashboardProvider } from "./provider";
+
+type CollectionBucket = Exclude;
+
+function listCollection(bucket: CollectionBucket): Promise {
+ switch (bucket) {
+ case "clusters":
+ return dashboardProvider.clusters.list() as Promise;
+ case "nodes":
+ return dashboardProvider.nodes.list() as Promise;
+ case "agents":
+ return dashboardProvider.agents.list() as Promise;
+ case "agent-relations":
+ return dashboardProvider.agentRelations.list() as Promise;
+ case "workflows":
+ return dashboardProvider.workflows.list() as Promise;
+ case "software-catalog":
+ return dashboardProvider.softwareCatalog.list() as Promise;
+ case "software-configs":
+ return dashboardProvider.softwareConfigurations.list() as Promise;
+ case "tasks":
+ return dashboardProvider.tasks.list() as Promise;
+ case "activity":
+ return dashboardProvider.activity.list() as Promise;
+ case "telemetry":
+ return dashboardProvider.telemetry.list() as Promise;
+ default:
+ throw new Error(`Unsupported dashboard collection: ${bucket}`);
+ }
+}
+
+export function useDemoData() {
+ const clusterId = useDashboardStore((state) => state.selectedClusterId);
+ const clusters = useDashboardStore((state) => state.clusters);
+ const nodes = useDashboardStore((state) => state.nodes);
+ const nodeQuery = useQuery({
+ queryKey: ["nodes", clusterId],
+ queryFn: async () =>
+ (await dashboardProvider.nodes.list()).filter(
+ (node) => node.clusterId === clusterId,
+ ),
+ });
+ const clusterQuery = useQuery({
+ queryKey: ["clusters"],
+ queryFn: () => dashboardProvider.clusters.list(),
+ });
+ const filteredNodes =
+ nodeQuery.data ?? nodes.filter((node) => node.clusterId === clusterId);
+ const availableClusters = clusterQuery.data ?? clusters;
+ return {
+ cluster: availableClusters.find((cluster) => cluster.id === clusterId),
+ nodes: filteredNodes,
+ clusters: availableClusters,
+ isLoading: nodeQuery.isLoading || clusterQuery.isLoading,
+ isError: nodeQuery.isError || clusterQuery.isError,
+ };
+}
+
+export function useLocalCollection(bucket: CollectionBucket) {
+ return useQuery({
+ queryKey: ["local-records", bucket],
+ queryFn: () => listCollection(bucket),
+ });
+}
diff --git a/dashboard/src/data/workflows.ts b/dashboard/src/data/workflows.ts
new file mode 100644
index 0000000..58d6d64
--- /dev/null
+++ b/dashboard/src/data/workflows.ts
@@ -0,0 +1,322 @@
+import { dashboardProvider } from "./provider";
+
+export type WorkflowKind =
+ | "trigger"
+ | "schedule"
+ | "agent"
+ | "condition"
+ | "delay"
+ | "transform"
+ | "notify"
+ | "success"
+ | "failure";
+
+export type WorkflowNode = {
+ id: string;
+ kind: WorkflowKind;
+ title: string;
+ detail: string;
+ x: number;
+ y: number;
+ config: Record;
+};
+
+export type WorkflowEdge = {
+ id: string;
+ source: string;
+ target: string;
+ label?: string;
+};
+export type Workflow = {
+ version: 1;
+ name: string;
+ nodes: WorkflowNode[];
+ edges: WorkflowEdge[];
+};
+
+export type WorkflowHistory = { past: Workflow[]; future: Workflow[] };
+
+export function recordWorkflowChange(
+ history: WorkflowHistory,
+ current: Workflow,
+): WorkflowHistory {
+ return {
+ past: [...history.past.slice(-19), structuredClone(current)],
+ future: [],
+ };
+}
+
+export function undoWorkflow(
+ history: WorkflowHistory,
+ current: Workflow,
+): { history: WorkflowHistory; workflow: Workflow } | undefined {
+ const previous = history.past.at(-1);
+ if (!previous) return undefined;
+ return {
+ history: {
+ past: history.past.slice(0, -1),
+ future: [structuredClone(current), ...history.future],
+ },
+ workflow: structuredClone(previous),
+ };
+}
+
+export function redoWorkflow(
+ history: WorkflowHistory,
+ current: Workflow,
+): { history: WorkflowHistory; workflow: Workflow } | undefined {
+ const next = history.future[0];
+ if (!next) return undefined;
+ return {
+ history: {
+ past: [...history.past, structuredClone(current)].slice(-20),
+ future: history.future.slice(1),
+ },
+ workflow: structuredClone(next),
+ };
+}
+
+export function isConnectionValid(
+ flow: Workflow,
+ source: string,
+ target: string,
+): boolean {
+ if (!source || !target || source === target) return false;
+ if (
+ !flow.nodes.some((node) => node.id === source) ||
+ !flow.nodes.some((node) => node.id === target)
+ )
+ return false;
+ if (
+ flow.edges.some((edge) => edge.source === source && edge.target === target)
+ )
+ return false;
+ const reachable = new Set();
+ const pending = [target];
+ while (pending.length) {
+ const id = pending.pop();
+ if (!id || reachable.has(id)) continue;
+ if (id === source) return false;
+ reachable.add(id);
+ pending.push(
+ ...flow.edges
+ .filter((edge) => edge.source === id)
+ .map((edge) => edge.target),
+ );
+ }
+ return true;
+}
+
+const workflowRecordId = "active";
+const maxImportBytes = 100_000;
+const kinds: WorkflowKind[] = [
+ "trigger",
+ "schedule",
+ "agent",
+ "condition",
+ "delay",
+ "transform",
+ "notify",
+ "success",
+ "failure",
+];
+
+export const starterWorkflow: Workflow = {
+ version: 1,
+ name: "Cluster health check",
+ nodes: [
+ {
+ id: "trigger",
+ kind: "schedule",
+ title: "Schedule trigger",
+ detail: "Starts a sample health check on a fixed interval.",
+ x: 54,
+ y: 210,
+ config: { intervalMinutes: 15 },
+ },
+ {
+ id: "inspect",
+ kind: "transform",
+ title: "Inspect cluster",
+ detail: "Collects mock health values from the selected demo cluster.",
+ x: 300,
+ y: 210,
+ config: { scope: "active cluster" },
+ },
+ {
+ id: "threshold",
+ kind: "condition",
+ title: "Health below 80%?",
+ detail: "Routes the sample run based on a configurable threshold.",
+ x: 548,
+ y: 210,
+ config: { threshold: 80 },
+ },
+ {
+ id: "notify",
+ kind: "notify",
+ title: "Notify operator",
+ detail: "Adds a preview notification to this simulated workflow run.",
+ x: 806,
+ y: 102,
+ config: { channel: "dashboard" },
+ },
+ {
+ id: "agent",
+ kind: "agent",
+ title: "Summarize status",
+ detail: "Creates a local sample summary. No AI service is contacted.",
+ x: 806,
+ y: 326,
+ config: { agent: "OpenCluster assistant" },
+ },
+ ],
+ edges: [
+ { id: "e1", source: "trigger", target: "inspect", label: "on schedule" },
+ {
+ id: "e2",
+ source: "inspect",
+ target: "threshold",
+ label: "health sample",
+ },
+ {
+ id: "e3",
+ source: "threshold",
+ target: "notify",
+ label: "below threshold",
+ },
+ { id: "e4", source: "threshold", target: "agent", label: "healthy" },
+ ],
+};
+
+export function validateWorkflow(value: unknown): value is Workflow {
+ if (!value || typeof value !== "object") return false;
+ const flow = value as Partial;
+ if (
+ flow.version !== 1 ||
+ typeof flow.name !== "string" ||
+ !flow.name.trim() ||
+ flow.name.length > 120 ||
+ !Array.isArray(flow.nodes) ||
+ !Array.isArray(flow.edges) ||
+ flow.nodes.length > 200 ||
+ flow.edges.length > 500
+ )
+ return false;
+ const ids = new Set();
+ for (const node of flow.nodes) {
+ if (
+ !node ||
+ typeof node.id !== "string" ||
+ !node.id ||
+ ids.has(node.id) ||
+ !kinds.includes(node.kind) ||
+ typeof node.title !== "string" ||
+ !node.title ||
+ node.title.length > 120 ||
+ typeof node.detail !== "string" ||
+ node.detail.length > 500 ||
+ !Number.isFinite(node.x) ||
+ !Number.isFinite(node.y) ||
+ !node.config ||
+ typeof node.config !== "object" ||
+ Object.entries(node.config).length > 50 ||
+ Object.entries(node.config).some(
+ ([key, value]) =>
+ !key ||
+ key.length > 120 ||
+ !(
+ typeof value === "string" ||
+ typeof value === "boolean" ||
+ (typeof value === "number" && Number.isFinite(value))
+ ),
+ )
+ )
+ return false;
+ ids.add(node.id);
+ }
+ const edgeIds = new Set();
+ for (const edge of flow.edges) {
+ if (
+ !edge ||
+ typeof edge.id !== "string" ||
+ !edge.id ||
+ edgeIds.has(edge.id) ||
+ !ids.has(edge.source) ||
+ !ids.has(edge.target) ||
+ edge.source === edge.target
+ )
+ return false;
+ edgeIds.add(edge.id);
+ }
+ const outgoing = new Map([...ids].map((id) => [id, [] as string[]]));
+ for (const edge of flow.edges) outgoing.get(edge.source)?.push(edge.target);
+ const visiting = new Set();
+ const visited = new Set();
+ const hasCycle = (id: string): boolean => {
+ if (visiting.has(id)) return true;
+ if (visited.has(id)) return false;
+ visiting.add(id);
+ if (outgoing.get(id)?.some(hasCycle)) return true;
+ visiting.delete(id);
+ visited.add(id);
+ return false;
+ };
+ return ![...ids].some(hasCycle);
+}
+
+export function workflowIssues(flow: Workflow): string[] {
+ const issues: string[] = [];
+ if (
+ !flow.nodes.some(
+ (node) => node.kind === "trigger" || node.kind === "schedule",
+ )
+ )
+ issues.push("Add a trigger or schedule node.");
+ for (const node of flow.nodes)
+ if (
+ !flow.edges.some((edge) => edge.source === node.id) &&
+ !["success", "failure", "notify", "agent"].includes(node.kind)
+ )
+ issues.push(`${node.title} has no outgoing connection.`);
+ for (const edge of flow.edges)
+ if (
+ !flow.edges.some((next) => next.target === edge.source) &&
+ !flow.nodes.some(
+ (node) =>
+ node.id === edge.source &&
+ (node.kind === "trigger" || node.kind === "schedule"),
+ )
+ )
+ issues.push(`${edge.source} is disconnected from the trigger.`);
+ return [...new Set(issues)];
+}
+
+export async function loadWorkflow(): Promise {
+ try {
+ const parsed: unknown =
+ await dashboardProvider.workflows.get(workflowRecordId);
+ return validateWorkflow(parsed) ? parsed : structuredClone(starterWorkflow);
+ } catch {
+ return structuredClone(starterWorkflow);
+ }
+}
+
+export async function saveWorkflow(flow: Workflow): Promise {
+ if (!validateWorkflow(flow))
+ throw new Error("This workflow is not valid and could not be saved.");
+ await dashboardProvider.workflows.update(workflowRecordId, flow);
+}
+
+export function parseWorkflowImport(text: string): Workflow {
+ assertWorkflowImportSize(new Blob([text]).size);
+ const value: unknown = JSON.parse(text);
+ if (!validateWorkflow(value))
+ throw new Error("This file is not a valid OpenCluster workflow.");
+ return value;
+}
+
+export function assertWorkflowImportSize(size: number): void {
+ if (size > maxImportBytes)
+ throw new Error("Workflow file exceeds the 100 KB demo limit.");
+}
diff --git a/dashboard/src/main.tsx b/dashboard/src/main.tsx
new file mode 100644
index 0000000..cdd47e4
--- /dev/null
+++ b/dashboard/src/main.tsx
@@ -0,0 +1,65 @@
+import { QueryClientProvider } from "@tanstack/react-query";
+import React from "react";
+import ReactDOM from "react-dom/client";
+import { BrowserRouter } from "react-router-dom";
+import "@fontsource-variable/inter/wght.css";
+import "@fontsource-variable/sora/wght.css";
+import { AppErrorBoundary } from "./components/AppErrorBoundary";
+import { localProvider } from "./data/localProvider";
+import { dashboardQueryClient } from "./data/queryClient";
+import { DashboardApp } from "./pages";
+import { useDashboardStore } from "./state/useDashboardStore";
+import "./styles.css";
+
+const root = document.getElementById("root");
+if (!root) throw new Error("Missing dashboard root element.");
+
+const appRoot = ReactDOM.createRoot(root);
+appRoot.render(
+
+ Loading your local OpenCluster workspace…
+ ,
+);
+
+async function startDashboard() {
+ try {
+ const persisted = localStorage.getItem("opencluster-demo-preferences");
+ let legacy: unknown;
+ if (persisted) {
+ try {
+ legacy = (JSON.parse(persisted) as { state?: unknown }).state;
+ } catch {
+ legacy = undefined;
+ }
+ }
+ const data = await localProvider.initialize(legacy);
+ const selectedClusterId = useDashboardStore.getState().selectedClusterId;
+ useDashboardStore.setState({
+ ...data,
+ selectedClusterId: data.clusters.some(
+ (cluster) => cluster.id === selectedClusterId,
+ )
+ ? selectedClusterId
+ : (data.clusters[0]?.id ?? ""),
+ });
+ } catch (error) {
+ useDashboardStore.setState({
+ persistenceError:
+ error instanceof Error
+ ? error.message
+ : "Local browser storage is unavailable.",
+ });
+ }
+ appRoot.render(
+
+
+
+
+
+
+
+
+ ,
+ );
+}
+void startDashboard();
diff --git a/dashboard/src/pages/Activity.tsx b/dashboard/src/pages/Activity.tsx
new file mode 100644
index 0000000..cf9dd4c
--- /dev/null
+++ b/dashboard/src/pages/Activity.tsx
@@ -0,0 +1,116 @@
+import { useQueryClient } from "@tanstack/react-query";
+import * as Icons from "lucide-react";
+import { useState } from "react";
+import * as UI from "../components/ui";
+import type { ActivityEvent } from "../data/models";
+import { useLocalCollection } from "../data/useDashboardData";
+import { ActivityLine } from "./shared";
+
+export function ActivityPage() {
+ const {
+ data: initialActivity = [],
+ isPending,
+ isError,
+ } = useLocalCollection("activity");
+ const queryClient = useQueryClient();
+ const [search, setSearch] = useState("");
+ const [severity, setSeverity] = useState("all");
+ const [source, setSource] = useState("all");
+ const [readAll, setReadAll] = useState(false);
+ const events = initialActivity
+ .filter((event) =>
+ `${event.title} ${event.detail} ${event.source}`
+ .toLowerCase()
+ .includes(search.toLowerCase()),
+ )
+ .filter((event) => severity === "all" || event.severity === severity)
+ .filter((event) => source === "all" || event.source === source);
+ return (
+ <>
+ setReadAll(true)}>
+
+ {readAll ? "Sample events marked read" : "Mark all read"}
+
+ }
+ />
+
+
+
+ setSearch(e.target.value)}
+ />
+
+ setSeverity(e.target.value)}
+ >
+ All severities
+ {["info", "success", "warning", "error"].map((s) => (
+
+ {s}
+
+ ))}
+
+ setSource(e.target.value)}
+ >
+ All sources
+ {[...new Set(initialActivity.map((e) => e.source))].map((s) => (
+
+ {s}
+
+ ))}
+
+ {events.length} events
+
+
+
+ DEMO EVENTS FIXED SAMPLE HISTORY
+
+ {isPending ? (
+
+ ) : isError ? (
+
+ void queryClient.invalidateQueries({
+ queryKey: ["local-records", "activity"],
+ })
+ }
+ />
+ ) : events.length ? (
+ events.map((event) => )
+ ) : (
+
+
+ No matching events
+ Try another search or filter.
+
+ )}
+ {events.some((event) => event.time === "Yesterday") && (
+
+ PREVIOUS SAMPLE HISTORY
+
+ )}
+
+
+ This activity feed is preloaded sample
+ data, not an audit log.
+
+ >
+ );
+}
diff --git a/dashboard/src/pages/Agents.css b/dashboard/src/pages/Agents.css
new file mode 100644
index 0000000..3a52d4c
--- /dev/null
+++ b/dashboard/src/pages/Agents.css
@@ -0,0 +1,1150 @@
+.agents-page {
+ --agent-ink: #dce8e9;
+ --agent-muted: #7c8e98;
+ --agent-line: rgba(110, 142, 151, 0.16);
+ --agent-panel: rgba(13, 22, 29, 0.82);
+ color: var(--agent-ink);
+}
+
+.agents-config-form {
+ display: grid;
+ gap: 9px;
+ margin: 12px 0;
+}
+
+.agents-config-form label {
+ display: grid;
+ gap: 5px;
+ color: var(--agent-muted);
+ font-size: 10px;
+}
+
+.agents-config-form input {
+ min-width: 0;
+ padding: 8px 9px;
+ border: 1px solid var(--agent-line);
+ border-radius: 6px;
+ color: var(--agent-ink);
+ background: rgba(2, 8, 12, 0.34);
+}
+
+.agents-demo-tag,
+.agents-summary-indicator,
+.agents-status-chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ border: 1px solid rgba(98, 211, 165, 0.24);
+ border-radius: 999px;
+ padding: 7px 10px;
+ color: #a0d8bf;
+ background: rgba(24, 57, 47, 0.28);
+ font: 600 9px var(--mono);
+ letter-spacing: 0.55px;
+ text-transform: uppercase;
+}
+
+.agents-demo-tag > span,
+.agents-summary-indicator > i,
+.agents-status-chip > i,
+.agents-legend > span > i {
+ width: 7px;
+ height: 7px;
+ border-radius: 50%;
+ background: #67d2a5;
+ box-shadow: 0 0 10px rgba(103, 210, 165, 0.48);
+}
+
+.agents-summary {
+ min-height: 78px;
+ margin: 19px 0 17px;
+ padding: 13px 17px;
+ display: flex;
+ align-items: center;
+ gap: 22px;
+ border: 1px solid var(--agent-line);
+ border-radius: 10px;
+ background: linear-gradient(
+ 110deg,
+ rgba(23, 42, 45, 0.72),
+ var(--agent-panel) 54%,
+ rgba(15, 22, 29, 0.85)
+ );
+ box-shadow: 0 12px 36px rgba(0, 0, 0, 0.11);
+}
+
+.agents-summary-intro {
+ min-width: 230px;
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+
+.agents-summary-icon {
+ width: 38px;
+ height: 38px;
+ display: grid;
+ place-items: center;
+ border: 1px solid rgba(98, 211, 165, 0.22);
+ border-radius: 9px;
+ color: #83d9b5;
+ background: rgba(57, 131, 103, 0.12);
+}
+
+.agents-summary-intro strong,
+.agents-summary-intro small {
+ display: block;
+}
+
+.agents-summary-intro strong {
+ font-size: 12px;
+ font-weight: 600;
+}
+
+.agents-summary-intro small {
+ margin-top: 4px;
+ color: var(--agent-muted);
+ font-size: 10px;
+}
+
+.agents-summary-stats {
+ flex: 1;
+ display: flex;
+ gap: clamp(20px, 4vw, 54px);
+ padding-left: 24px;
+ border-left: 1px solid var(--agent-line);
+}
+
+.agents-summary-stats > div span,
+.agents-summary-stats > div strong {
+ display: block;
+}
+
+.agents-summary-stats > div span,
+.agents-inspector-kicker,
+.agents-inspector-count,
+.agents-section-heading,
+.agents-model-box > span,
+.agents-inspector-metrics > div span {
+ color: #778a94;
+ font: 500 8px var(--mono);
+ letter-spacing: 0.85px;
+}
+
+.agents-summary-stats > div strong {
+ margin-top: 5px;
+ color: #d8e6e5;
+ font: 600 17px var(--mono);
+}
+
+.agents-summary-indicator {
+ flex: none;
+ color: #91a79e;
+ border-color: rgba(122, 160, 146, 0.16);
+ background: rgba(16, 28, 27, 0.45);
+ font-size: 8px;
+}
+
+.agents-workspace {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) 276px;
+ align-items: stretch;
+ gap: 14px;
+}
+
+.agents-canvas-panel,
+.agents-inspector {
+ min-width: 0;
+ border: 1px solid var(--agent-line);
+ border-radius: 10px;
+ background: var(--agent-panel);
+ box-shadow: 0 14px 34px rgba(0, 0, 0, 0.13);
+}
+
+.agents-canvas-panel {
+ overflow: hidden;
+}
+
+.agents-canvas-toolbar {
+ min-height: 61px;
+ padding: 12px 15px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ border-bottom: 1px solid var(--agent-line);
+}
+
+.agents-toolbar-title,
+.agents-toolbar-subtitle {
+ display: block;
+}
+
+.agents-toolbar-title {
+ color: #d5e0e2;
+ font-size: 11px;
+ font-weight: 600;
+}
+
+.agents-toolbar-subtitle {
+ margin-top: 4px;
+ color: #72838d;
+ font-size: 9px;
+}
+
+.agents-view-switch {
+ display: inline-flex;
+ gap: 3px;
+ padding: 3px;
+ border: 1px solid rgba(128, 153, 163, 0.14);
+ border-radius: 7px;
+ background: rgba(2, 8, 12, 0.34);
+}
+
+.agents-view-switch button {
+ height: 27px;
+ padding: 0 9px;
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ border: 0;
+ border-radius: 5px;
+ color: #85959d;
+ background: transparent;
+ font-size: 9px;
+ transition:
+ color 140ms ease,
+ background 140ms ease;
+}
+
+.agents-view-switch button:hover {
+ color: #d4e1e2;
+}
+
+.agents-view-switch button[aria-pressed="true"] {
+ color: #d6e8e3;
+ background: #1b302e;
+ box-shadow: inset 0 0 0 1px rgba(106, 197, 161, 0.19);
+}
+
+.agents-graph-wrap {
+ position: relative;
+ min-height: 410px;
+ overflow: hidden;
+ background:
+ radial-gradient(ellipse at 46% 44%, rgba(34, 98, 88, 0.12), transparent 45%),
+ linear-gradient(145deg, rgba(8, 16, 21, 0.2), rgba(9, 17, 23, 0.78));
+}
+
+.agents-reactflow {
+ min-height: 510px;
+ height: clamp(510px, 54vw, 680px);
+ background:
+ radial-gradient(ellipse at 46% 44%, rgba(34, 98, 88, 0.12), transparent 45%),
+ linear-gradient(145deg, rgba(8, 16, 21, 0.2), rgba(9, 17, 23, 0.78));
+}
+
+.agents-reactflow .react-flow__background pattern circle {
+ fill: rgba(115, 153, 161, 0.2);
+}
+
+.agents-reactflow .react-flow__controls,
+.agents-reactflow .react-flow__minimap {
+ overflow: hidden;
+ border: 1px solid rgba(124, 155, 165, 0.18);
+ border-radius: 7px;
+ background: rgba(8, 15, 20, 0.92);
+}
+
+.agents-reactflow .react-flow__controls-button {
+ border-bottom: 1px solid rgba(124, 155, 165, 0.12);
+ background: transparent;
+ fill: #a9bcc2;
+}
+
+.agents-reactflow .react-flow__edge-textbg {
+ fill: #0d171c;
+}
+
+.agents-reactflow .react-flow__node-agent {
+ width: 172px;
+ border: 0;
+ background: transparent;
+}
+
+.agent-flow-card {
+ position: relative;
+ min-height: 92px;
+ padding: 14px 12px 10px 48px;
+ border: 1px solid rgba(128, 159, 166, 0.34);
+ border-radius: 11px;
+ background: linear-gradient(145deg, #16252c, #101a20);
+ box-shadow: 0 10px 24px rgba(0, 0, 0, 0.38);
+ color: #e2ecec;
+}
+
+.agent-flow-card.selected,
+.react-flow__node.selected .agent-flow-card {
+ border-color: rgba(124, 211, 180, 0.78);
+ box-shadow:
+ 0 0 0 2px rgba(124, 211, 180, 0.11),
+ 0 10px 24px rgba(0, 0, 0, 0.38);
+}
+
+.agent-flow-icon {
+ position: absolute;
+ top: 20px;
+ left: 11px;
+ width: 26px;
+ height: 26px;
+ display: grid;
+ place-items: center;
+ border: 1px solid rgba(103, 196, 159, 0.25);
+ border-radius: 7px;
+ color: #8edcba;
+ background: rgba(103, 196, 159, 0.12);
+}
+
+.agent-flow-card.peach .agent-flow-icon {
+ color: #efb68f;
+ background: rgba(241, 173, 125, 0.12);
+}
+.agent-flow-card.violet .agent-flow-icon {
+ color: #b6a0f3;
+ background: rgba(176, 151, 243, 0.12);
+}
+.agent-flow-card.blue .agent-flow-icon {
+ color: #7bbde9;
+ background: rgba(104, 179, 236, 0.12);
+}
+.agent-flow-card.gold .agent-flow-icon {
+ color: #dfbf7d;
+ background: rgba(229, 191, 121, 0.12);
+}
+.agent-flow-card small,
+.agent-flow-card strong,
+.agent-flow-card em {
+ display: block;
+}
+.agent-flow-card small {
+ color: #84969d;
+ font: 500 7px var(--mono);
+ letter-spacing: 0.8px;
+}
+.agent-flow-card strong {
+ margin-top: 6px;
+ font: 600 11px var(--display);
+}
+.agent-flow-card em {
+ margin-top: 5px;
+ color: #788e96;
+ font: normal 6.5px var(--mono);
+ letter-spacing: 0.55px;
+}
+.agent-flow-status {
+ position: absolute;
+ top: 11px;
+ right: 11px;
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background: #67d2a5;
+ box-shadow: 0 0 8px rgba(103, 210, 165, 0.48);
+}
+.agent-flow-status.idle {
+ background: #8d9ba4;
+ box-shadow: none;
+}
+.agent-flow-status.attention {
+ background: #e5b76b;
+}
+.agent-flow-status.paused {
+ background: #7b8490;
+ box-shadow: none;
+}
+.agents-toolbar-actions {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+.agents-fit-button {
+ min-height: 29px;
+ padding: 0 9px;
+ border: 1px solid rgba(124, 155, 165, 0.18);
+ border-radius: 5px;
+ color: #a9bcc2;
+ background: rgba(8, 15, 20, 0.65);
+ font-size: 9px;
+}
+.agents-fit-button:hover {
+ color: #dcf1e9;
+ border-color: rgba(121, 194, 171, 0.36);
+}
+
+.agents-graph {
+ width: 100%;
+ height: clamp(410px, 49vw, 570px);
+ display: block;
+ color: #65818b;
+ touch-action: none;
+ cursor: grab;
+ user-select: none;
+}
+
+.agents-graph:active {
+ cursor: grabbing;
+}
+
+.agents-graph:focus-visible {
+ outline: 2px solid #66b9eb;
+ outline-offset: -3px;
+}
+
+.agent-node {
+ cursor: grab;
+ outline: none;
+}
+
+.agent-node:active {
+ cursor: grabbing;
+}
+
+.agent-node:focus-visible .agent-node-card {
+ stroke: #d0e3de;
+ stroke-width: 2;
+}
+
+.agent-node-card {
+ fill: #111e25;
+ stroke: rgba(128, 159, 166, 0.29);
+ stroke-width: 1;
+ filter: drop-shadow(0 8px 13px rgba(0, 0, 0, 0.37));
+ transition:
+ fill 160ms ease,
+ stroke 160ms ease,
+ stroke-width 160ms ease;
+}
+
+.agent-node:hover .agent-node-card,
+.agent-node.selected .agent-node-card {
+ fill: #15282d;
+ stroke: rgba(124, 211, 180, 0.68);
+}
+
+.agent-node.selected .agent-node-card {
+ stroke-width: 1.5;
+}
+
+.agent-node-glow {
+ fill: rgba(85, 206, 163, 0.08);
+ opacity: 0;
+ filter: url(#agent-glow);
+ transition: opacity 180ms ease;
+}
+
+.agent-node.selected .agent-node-glow {
+ opacity: 0.7;
+}
+
+.agent-node-icon-bg {
+ fill: rgba(103, 196, 159, 0.13);
+ stroke: rgba(103, 196, 159, 0.25);
+}
+
+.agent-node-icon-inner {
+ width: 20px;
+ height: 20px;
+ display: grid;
+ place-items: center;
+ color: #8edcba;
+}
+
+.agent-node.peach .agent-node-icon-bg,
+.agent-profile-mark.peach {
+ fill: rgba(241, 173, 125, 0.13);
+ background: rgba(241, 173, 125, 0.12);
+ stroke: rgba(241, 173, 125, 0.28);
+ color: #efb68f;
+}
+
+.agent-node.violet .agent-node-icon-bg,
+.agent-profile-mark.violet {
+ fill: rgba(176, 151, 243, 0.13);
+ background: rgba(176, 151, 243, 0.12);
+ stroke: rgba(176, 151, 243, 0.3);
+ color: #b6a0f3;
+}
+
+.agent-node.blue .agent-node-icon-bg,
+.agent-profile-mark.blue {
+ fill: rgba(104, 179, 236, 0.13);
+ background: rgba(104, 179, 236, 0.12);
+ stroke: rgba(104, 179, 236, 0.3);
+ color: #7bbde9;
+}
+
+.agent-node.gold .agent-node-icon-bg,
+.agent-profile-mark.gold {
+ fill: rgba(229, 191, 121, 0.13);
+ background: rgba(229, 191, 121, 0.12);
+ stroke: rgba(229, 191, 121, 0.28);
+ color: #dfbf7d;
+}
+
+.agent-node-status {
+ stroke: #111e25;
+ stroke-width: 2;
+}
+
+.agent-node-status.active,
+.agents-status-chip.active > i,
+.agents-legend i.active {
+ fill: #67d2a5;
+ background: #67d2a5;
+}
+
+.agent-node-status.idle,
+.agents-status-chip.idle > i,
+.agents-legend i.idle {
+ fill: #8d9ba4;
+ background: #8d9ba4;
+ box-shadow: none;
+}
+
+.agent-node-status.attention,
+.agents-status-chip.attention > i,
+.agents-legend i.attention {
+ fill: #e5b76b;
+ background: #e5b76b;
+ box-shadow: 0 0 10px rgba(229, 183, 107, 0.38);
+}
+
+.agent-node-status.paused,
+.agents-status-chip.paused > i {
+ fill: #7b8490;
+ background: #7b8490;
+ box-shadow: none;
+}
+
+.agent-node-vendor,
+.agent-node-meta {
+ fill: #84969d;
+ font: 500 7px var(--mono);
+ letter-spacing: 0.8px;
+}
+
+.agent-node-name {
+ fill: #e2ecec;
+ font: 600 12px var(--display);
+ letter-spacing: -0.2px;
+}
+
+.agent-node-meta {
+ fill: #788e96;
+ font-size: 6.5px;
+ letter-spacing: 0.55px;
+}
+
+.agents-edge path {
+ fill: none;
+ stroke: rgba(103, 144, 150, 0.32);
+ stroke-dasharray: 4 7;
+ transition:
+ stroke 150ms ease,
+ opacity 150ms ease;
+}
+
+.agents-edge.highlighted path {
+ stroke: rgba(113, 208, 171, 0.61);
+}
+
+.agents-edge-pulse {
+ fill: #8de4bd;
+ filter: drop-shadow(0 0 4px #59cd9d);
+}
+
+.agents-page.motion-off .agents-edge-pulse {
+ display: none;
+}
+
+.agents-edge text {
+ fill: #8ab6a7;
+ stroke: #0d161b;
+ stroke-width: 5px;
+ paint-order: stroke;
+ font: 500 8px var(--mono);
+ text-anchor: middle;
+}
+
+.agents-canvas-hint {
+ fill: #526672;
+ font: 500 7px var(--mono);
+ letter-spacing: 0.8px;
+}
+
+.agents-canvas-controls {
+ position: absolute;
+ right: 12px;
+ bottom: 13px;
+ min-width: 0;
+ margin: 0;
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ padding: 4px;
+ border: 1px solid rgba(124, 155, 165, 0.18);
+ border-radius: 7px;
+ background: rgba(8, 15, 20, 0.88);
+ box-shadow: 0 6px 22px rgba(0, 0, 0, 0.23);
+}
+
+.agents-canvas-controls legend {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ overflow: hidden;
+ clip-path: inset(50%);
+ white-space: nowrap;
+}
+
+.agents-canvas-controls button {
+ width: 27px;
+ height: 27px;
+ display: grid;
+ place-items: center;
+ border: 0;
+ border-radius: 4px;
+ color: #9aabb2;
+ background: transparent;
+}
+
+.agents-canvas-controls button:hover {
+ color: #dcf1e9;
+ background: #1a2a2f;
+}
+
+.agents-canvas-controls span:not(.agents-control-divider) {
+ min-width: 37px;
+ color: #8b9ca4;
+ font: 500 9px var(--mono);
+ text-align: center;
+}
+
+.agents-control-divider {
+ height: 17px;
+ margin: 0 3px;
+ border-left: 1px solid var(--agent-line);
+}
+
+.agents-legend {
+ min-height: 39px;
+ padding: 0 15px;
+ display: flex;
+ align-items: center;
+ gap: 18px;
+ border-top: 1px solid var(--agent-line);
+ color: #84949c;
+ font-size: 9px;
+}
+
+.agents-legend > span {
+ display: inline-flex;
+ align-items: center;
+ gap: 7px;
+ white-space: nowrap;
+}
+
+.agents-legend > span > i {
+ width: 6px;
+ height: 6px;
+ box-shadow: none;
+}
+
+.agents-legend-note {
+ margin-left: auto;
+ color: #62737d;
+ font-size: 8px;
+}
+
+.agents-inspector {
+ padding: 15px;
+ display: flex;
+ flex-direction: column;
+}
+
+.agents-inspector-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+}
+
+.agents-inspector-head > div {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.agents-inspector-count {
+ color: #566a74;
+ font-size: 7px;
+}
+
+.agents-status-chip {
+ gap: 6px;
+ padding: 5px 7px;
+ color: #9ab0a5;
+ border-color: rgba(113, 157, 136, 0.17);
+ background: rgba(18, 34, 29, 0.35);
+ font-size: 7px;
+}
+
+.agents-status-chip > i {
+ width: 5px;
+ height: 5px;
+}
+
+.agents-profile-mark {
+ width: 45px;
+ height: 45px;
+ margin-top: 24px;
+ display: grid;
+ place-items: center;
+ border: 1px solid rgba(103, 196, 159, 0.25);
+ border-radius: 11px;
+ color: #8edcba;
+ background: rgba(103, 196, 159, 0.12);
+}
+
+.agents-profile-mark.mint {
+ color: #8edcba;
+}
+
+.agents-inspector h2 {
+ margin: 13px 0 5px;
+ color: #e2ebeb;
+ font-size: 18px;
+ font-weight: 600;
+ letter-spacing: -0.5px;
+}
+
+.agents-profile-vendor {
+ margin: 0;
+ color: #82939b;
+ font-size: 9px;
+}
+
+.agents-profile-vendor span {
+ color: #52646d;
+ padding: 0 3px;
+}
+
+.agents-model-box {
+ margin-top: 19px;
+ padding: 11px;
+ border: 1px solid rgba(119, 148, 158, 0.13);
+ border-radius: 7px;
+ background: rgba(7, 14, 19, 0.45);
+}
+
+.agents-model-box > span,
+.agents-model-box > strong,
+.agents-model-box > small {
+ display: block;
+}
+
+.agents-model-box > strong {
+ margin-top: 8px;
+ color: #c2d2d5;
+ font-size: 10px;
+ font-weight: 500;
+ line-height: 1.45;
+}
+
+.agents-model-box > small {
+ margin-top: 5px;
+ color: #61747d;
+ font-size: 8px;
+}
+
+.agents-inspector-metrics {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ margin: 14px 0;
+ padding: 0 0 13px;
+ border-bottom: 1px solid var(--agent-line);
+}
+
+.agents-inspector-metrics > div + div {
+ padding-left: 14px;
+ border-left: 1px solid var(--agent-line);
+}
+
+.agents-inspector-metrics > div span,
+.agents-inspector-metrics > div strong {
+ display: block;
+}
+
+.agents-inspector-metrics > div span {
+ font-size: 7px;
+}
+
+.agents-inspector-metrics > div strong {
+ margin-top: 7px;
+ color: #d4e0e0;
+ font: 600 13px var(--mono);
+}
+
+.agents-section-heading {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+
+.agents-section-heading > span:last-child {
+ color: #687c85;
+}
+
+.agents-connected-row {
+ width: 100%;
+ min-height: 45px;
+ margin-top: 5px;
+ padding: 6px 7px;
+ display: flex;
+ align-items: center;
+ gap: 9px;
+ border: 1px solid transparent;
+ border-radius: 6px;
+ color: #c4d1d3;
+ text-align: left;
+ background: transparent;
+}
+
+.agents-connected-row:hover {
+ border-color: rgba(114, 166, 153, 0.16);
+ background: rgba(26, 43, 43, 0.42);
+}
+
+.agents-connected-dot {
+ width: 7px;
+ height: 7px;
+ flex: none;
+ border-radius: 50%;
+ background: #67d2a5;
+}
+
+.agents-connected-dot.peach {
+ background: #eeb28c;
+}
+.agents-connected-dot.violet {
+ background: #b09aee;
+}
+.agents-connected-dot.blue {
+ background: #73b6e7;
+}
+.agents-connected-dot.gold {
+ background: #d8b978;
+}
+
+.agents-connected-row > span:nth-child(2) {
+ flex: 1;
+ min-width: 0;
+ font-size: 9px;
+}
+
+.agents-connected-row small {
+ display: block;
+ margin-top: 3px;
+ color: #687b84;
+ font-size: 8px;
+}
+
+.agents-connected-arrow {
+ color: #61747d;
+ transform: rotate(-90deg);
+}
+
+.agents-no-links {
+ color: #788a92;
+ font-size: 9px;
+ line-height: 1.5;
+}
+
+.agents-directory {
+ margin-top: 15px;
+ padding-top: 12px;
+ border-top: 1px solid var(--agent-line);
+}
+
+.agents-directory > button {
+ width: 100%;
+ min-height: 34px;
+ padding: 5px 6px;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ border: 1px solid transparent;
+ border-radius: 5px;
+ color: #c4d1d3;
+ text-align: left;
+ background: transparent;
+ font-size: 9px;
+}
+
+.agents-directory > button:hover,
+.agents-directory > button[aria-pressed="true"] {
+ border-color: rgba(114, 166, 153, 0.16);
+ background: rgba(26, 43, 43, 0.42);
+}
+
+.agents-directory > button > span:last-child {
+ flex: 1;
+}
+
+.agents-directory small {
+ display: block;
+ margin-top: 2px;
+ color: #687b84;
+ font-size: 8px;
+}
+
+.agents-demo-action {
+ min-height: 34px;
+ margin-top: auto;
+ padding: 0 10px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 7px;
+ border: 1px solid rgba(110, 150, 154, 0.18);
+ border-radius: 6px;
+ color: #b7c9ca;
+ background: rgba(24, 38, 42, 0.42);
+ font-size: 9px;
+}
+
+.agents-demo-action:hover {
+ border-color: rgba(121, 194, 171, 0.36);
+ color: #e0f0e9;
+}
+
+.agents-inspector-note {
+ margin: 9px 0 0;
+ color: #60727a;
+ font-size: 8px;
+ line-height: 1.5;
+}
+
+.agents-footer-note {
+ min-height: 41px;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ color: #778891;
+ font-size: 9px;
+}
+
+.agents-footer-note > svg {
+ color: #76a99a;
+}
+
+.agents-footer-note > span {
+ height: 12px;
+ margin: 0 2px;
+ border-left: 1px solid var(--agent-line);
+}
+
+.agents-footer-note button {
+ padding: 4px 7px;
+ border: 1px solid rgba(112, 145, 152, 0.17);
+ border-radius: 5px;
+ color: #9cb0b4;
+ background: rgba(14, 24, 29, 0.75);
+ font-size: 8px;
+}
+
+.agents-footer-note small {
+ color: #5f7078;
+ font-size: 8px;
+}
+
+@media (max-width: 1180px) {
+ .agents-workspace {
+ grid-template-columns: minmax(0, 1fr) 255px;
+ }
+
+ .agents-legend {
+ gap: 11px;
+ }
+}
+
+@media (max-width: 900px) {
+ .agents-workspace {
+ grid-template-columns: 1fr;
+ }
+
+ .agents-inspector {
+ display: grid;
+ grid-template-columns: minmax(140px, 0.8fr) minmax(210px, 1.2fr);
+ column-gap: 18px;
+ }
+
+ .agents-inspector-head,
+ .agents-inspector > .agents-profile-mark,
+ .agents-inspector > h2,
+ .agents-inspector > .agents-profile-vendor,
+ .agents-inspector > .agents-demo-action,
+ .agents-inspector > .agents-inspector-note {
+ grid-column: 1 / -1;
+ }
+
+ .agents-inspector > .agents-profile-mark {
+ margin-top: 18px;
+ }
+
+ .agents-inspector > h2 {
+ margin-top: -50px;
+ padding-left: 57px;
+ }
+
+ .agents-inspector > .agents-profile-vendor {
+ margin-top: -18px;
+ padding-left: 57px;
+ }
+
+ .agents-model-box {
+ margin-top: 17px;
+ }
+
+ .agents-inspector-metrics {
+ align-self: start;
+ margin-top: 17px;
+ border-bottom: 0;
+ }
+
+ .agents-links-section {
+ grid-column: 1 / -1;
+ margin-top: 7px;
+ padding-top: 12px;
+ border-top: 1px solid var(--agent-line);
+ }
+
+ .agents-demo-action {
+ grid-row: 5;
+ margin-top: 12px;
+ }
+
+ .agents-inspector-note {
+ grid-row: 6;
+ }
+}
+
+@media (max-width: 640px) {
+ .agents-summary {
+ flex-wrap: wrap;
+ gap: 14px;
+ padding: 12px;
+ }
+
+ .agents-summary-intro {
+ min-width: 100%;
+ }
+
+ .agents-summary-stats {
+ flex: 1;
+ gap: clamp(15px, 7vw, 36px);
+ padding-left: 0;
+ border-left: 0;
+ }
+
+ .agents-summary-indicator {
+ margin-left: auto;
+ }
+
+ .agents-canvas-toolbar {
+ align-items: flex-start;
+ flex-direction: column;
+ }
+
+ .agents-graph-wrap {
+ min-height: 360px;
+ }
+
+ .agents-graph {
+ height: 390px;
+ }
+
+ .agents-legend {
+ padding: 9px 12px;
+ flex-wrap: wrap;
+ gap: 9px 13px;
+ }
+
+ .agents-legend-note {
+ width: 100%;
+ margin-left: 0;
+ }
+
+ .agents-inspector {
+ display: flex;
+ }
+
+ .agents-inspector > h2 {
+ margin-top: 13px;
+ padding-left: 0;
+ }
+
+ .agents-inspector > .agents-profile-vendor {
+ margin-top: 0;
+ padding-left: 0;
+ }
+
+ .agents-model-box,
+ .agents-inspector-metrics {
+ margin-top: 14px;
+ }
+
+ .agents-inspector-metrics {
+ border-bottom: 1px solid var(--agent-line);
+ }
+
+ .agents-links-section {
+ margin-top: 0;
+ padding-top: 0;
+ border-top: 0;
+ }
+
+ .agents-demo-action {
+ margin-top: 13px;
+ }
+
+ .agents-footer-note {
+ align-items: flex-start;
+ flex-wrap: wrap;
+ padding: 10px 0;
+ }
+
+ .agents-footer-note small {
+ width: 100%;
+ padding-left: 22px;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .agents-edge-pulse {
+ display: none;
+ }
+
+ .agents-page *,
+ .agents-page *::before,
+ .agents-page *::after {
+ animation-duration: 0.01ms;
+ animation-iteration-count: 1;
+ scroll-behavior: auto;
+ transition-duration: 0.01ms;
+ }
+}
+
+html[data-motion="reduced"] .agents-edge-pulse {
+ display: none;
+}
diff --git a/dashboard/src/pages/Agents.tsx b/dashboard/src/pages/Agents.tsx
new file mode 100644
index 0000000..8b88dd1
--- /dev/null
+++ b/dashboard/src/pages/Agents.tsx
@@ -0,0 +1,1673 @@
+import {
+ // ORIGINAL (preserved, not deleted — disabled by comment): addEdge,
+ Background,
+ type Connection,
+ Controls,
+ type Edge,
+ type EdgeChange,
+ Handle,
+ MarkerType,
+ MiniMap,
+ type Node,
+ type NodeProps,
+ Position,
+ ReactFlow,
+ ReactFlowProvider,
+ useEdgesState,
+ useNodesState,
+ useReactFlow,
+} from "@xyflow/react";
+import "@xyflow/react/dist/style.css";
+import { useQueryClient } from "@tanstack/react-query";
+import {
+ Activity,
+ Bot,
+ BrainCircuit,
+ ChevronDown,
+ CircleHelp,
+ Command,
+ Cpu,
+ GitBranch,
+ RotateCcw,
+ Sparkles,
+ Waypoints,
+} from "lucide-react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { PageTitle } from "../components/ui";
+import {
+ type AgentGraphLayout,
+ type AgentGraphMode,
+ type AgentGraphPoint,
+ readAgentGraphLayout,
+ writeAgentGraphLayout,
+} from "../data/agentLayout";
+import type { AgentEntity, AgentRelation } from "../data/agents";
+import { dashboardProvider } from "../data/provider";
+import { useLocalCollection } from "../data/useDashboardData";
+import "./Agents.css";
+
+type AgentNodeData = { agent: AgentEntity };
+type FlowNode = Node;
+const VIEW = { width: 900, height: 570 };
+const NETWORK_POSITIONS: Record = {
+ codex: { x: 250, y: 280 },
+ claude: { x: 570, y: 145 },
+ hermes: { x: 675, y: 310 },
+ openclaw: { x: 515, y: 445 },
+ custom: { x: 225, y: 460 },
+ model: { x: 110, y: 155 },
+ tools: { x: 775, y: 165 },
+ memory: { x: 800, y: 480 },
+ task: { x: 345, y: 510 },
+ service: { x: 110, y: 390 },
+};
+function positionsFor(
+ mode: AgentGraphMode,
+ agents: AgentEntity[],
+): Record {
+ if (mode === "network") return NETWORK_POSITIONS;
+ const center = { x: VIEW.width / 2, y: VIEW.height / 2 };
+ const peers = agents.filter((agent) => agent.id !== "codex");
+ return Object.fromEntries(
+ agents.map((agent) => {
+ if (agent.id === "codex") return [agent.id, center];
+ const index = peers.findIndex((peer) => peer.id === agent.id);
+ const angle =
+ -Math.PI / 2 + (index * (Math.PI * 2)) / Math.max(1, peers.length);
+ return [
+ agent.id,
+ {
+ x: center.x + Math.cos(angle) * 255,
+ y: center.y + Math.sin(angle) * 190,
+ },
+ ];
+ }),
+ );
+}
+function initialLayout(agents: AgentEntity[]): AgentGraphLayout {
+ return {
+ positions: {
+ network: positionsFor("network", agents),
+ radial: positionsFor("radial", agents),
+ },
+ cameras: {
+ network: { x: 0, y: 0, zoom: 1 },
+ radial: { x: 0, y: 0, zoom: 1 },
+ },
+ };
+}
+function AgentGlyph({ icon }: { icon: AgentEntity["icon"] }) {
+ if (icon === "codex") return ;
+ if (icon === "claude") return ;
+ if (icon === "hermes") return ;
+ if (icon === "openclaw") return ;
+ return ;
+}
+function AgentFlowCard({ data }: NodeProps) {
+ const agent = data.agent;
+ return (
+ <>
+
+
+
+
+
+
+
{agent.kind.toUpperCase()}
+
{agent.name}
+
{agent.status.toUpperCase()}
+
+
+ >
+ );
+}
+const nodeTypes = { agent: AgentFlowCard };
+function asNodes(
+ agents: AgentEntity[],
+ positions: Record,
+): FlowNode[] {
+ return agents.map((agent) => ({
+ id: agent.id,
+ type: "agent",
+ position: positions[agent.id] ?? { x: VIEW.width / 2, y: VIEW.height / 2 },
+ data: { agent },
+ }));
+}
+function asEdges(relations: AgentRelation[], selectedId: string): Edge[] {
+ return relations.map((relation) => {
+ const highlighted =
+ relation.source === selectedId || relation.target === selectedId;
+ return {
+ id: relation.source + ":" + relation.target,
+ source: relation.source,
+ target: relation.target,
+ label: highlighted ? relation.label : undefined,
+ animated: highlighted,
+ markerEnd: {
+ type: MarkerType.ArrowClosed,
+ color: highlighted ? "#75d4ae" : "#617c84",
+ },
+ style: {
+ stroke: highlighted ? "#75d4ae" : "#617c84",
+ strokeWidth: highlighted ? 1.8 : 1.2,
+ },
+ labelStyle: { fill: "#a1c9b9", fontSize: 9 },
+ labelBgStyle: { fill: "#0d171c", fillOpacity: 0.92 },
+ };
+ });
+}
+function AgentsContent() {
+ const {
+ data: storedAgents = [],
+ isPending,
+ isError,
+ } = useLocalCollection("agents");
+ const { data: relations = [] } =
+ useLocalCollection("agent-relations");
+ const queryClient = useQueryClient();
+ const [graphError, setGraphError] = useState("");
+ const [agentDraft, setAgentDraft] = useState({
+ name: "",
+ role: "",
+ model: "",
+ });
+ const agents = useMemo(() => storedAgents, [storedAgents]);
+ const [layout, setLayout] = useState(() =>
+ initialLayout([]),
+ );
+ const layoutEdited = useRef(false);
+ const [layoutLoaded, setLayoutLoaded] = useState(false);
+ const [mode, setMode] = useState("network");
+ const [selectedId, setSelectedId] = useState("codex");
+ const [live, setLive] = useState(true);
+ const [nodes, setNodes, onNodesChange] = useNodesState([]);
+ const [edges, setEdges, onEdgesChange] = useEdgesState([]);
+ const flow = useReactFlow();
+ const selected = agents.find((agent) => agent.id === selectedId) ?? agents[0];
+ const selectedEdges = relations.filter(
+ (edge) => edge.source === selected?.id || edge.target === selected?.id,
+ );
+
+ useEffect(() => {
+ if (selected)
+ setAgentDraft({
+ name: selected.name,
+ role: selected.role,
+ model: selected.model,
+ });
+ }, [selected]);
+
+ useEffect(() => {
+ if (isPending) return;
+ let active = true;
+ void readAgentGraphLayout(initialLayout(storedAgents)).then((saved) => {
+ if (!active) return;
+ if (!layoutEdited.current) setLayout(saved);
+ setLayoutLoaded(true);
+ });
+ return () => {
+ active = false;
+ };
+ }, [isPending, storedAgents]);
+ useEffect(() => {
+ if (!layoutLoaded) return;
+ const timeout = window.setTimeout(
+ () => void writeAgentGraphLayout(layout),
+ 250,
+ );
+ return () => window.clearTimeout(timeout);
+ }, [layout, layoutLoaded]);
+ useEffect(() => {
+ if (!agents.length) return;
+ setNodes(asNodes(agents, layout.positions[mode]));
+ setEdges(asEdges(relations, selectedId));
+ }, [
+ agents,
+ relations,
+ layout.positions,
+ mode,
+ selectedId,
+ setEdges,
+ setNodes,
+ ]);
+ useEffect(() => {
+ if (layoutLoaded)
+ void flow.setViewport(layout.cameras[mode], { duration: 0 });
+ }, [flow, layout.cameras, layoutLoaded, mode]);
+
+ const updateCamera = useCallback(
+ (
+ _event: unknown,
+ viewport: AgentGraphLayout["cameras"][AgentGraphMode],
+ ) => {
+ setLayout((value) => ({
+ ...value,
+ cameras: { ...value.cameras, [mode]: viewport },
+ }));
+ },
+ [mode],
+ );
+ const connect = useCallback(
+ (connection: Connection) => {
+ if (!connection.source || !connection.target) return;
+ const relation: AgentRelation = {
+ source: connection.source,
+ target: connection.target,
+ label: "local relation",
+ strength: 0.5,
+ };
+ if (
+ relation.source === relation.target ||
+ relations.some(
+ (edge) =>
+ edge.source === relation.source && edge.target === relation.target,
+ )
+ )
+ return;
+ void dashboardProvider.agentRelations
+ .create(`${relation.source}:${relation.target}`, relation)
+ .then(() =>
+ queryClient.invalidateQueries({
+ queryKey: ["local-records", "agent-relations"],
+ }),
+ )
+ .catch(() => setGraphError("Relationship could not be saved locally."));
+ },
+ [queryClient, relations],
+ );
+ const changeEdges = useCallback(
+ (changes: EdgeChange[]) => {
+ for (const change of changes) {
+ if (change.type !== "remove") continue;
+ const [source, target] = change.id.split(":");
+ if (source && target)
+ void dashboardProvider.agentRelations
+ .delete(`${source}:${target}`)
+ .then(() =>
+ queryClient.invalidateQueries({
+ queryKey: ["local-records", "agent-relations"],
+ }),
+ )
+ .catch(() =>
+ setGraphError("Relationship could not be removed locally."),
+ );
+ }
+ onEdgesChange(changes);
+ },
+ [onEdgesChange, queryClient],
+ );
+ const toggleAgentStatus = () => {
+ if (selected)
+ void dashboardProvider.agents
+ .update(selected.id, {
+ ...selected,
+ status: selected.status === "paused" ? "idle" : "paused",
+ })
+ .then(() =>
+ queryClient.invalidateQueries({
+ queryKey: ["local-records", "agents"],
+ }),
+ )
+ .catch(() =>
+ setGraphError("Agent configuration could not be saved locally."),
+ );
+ };
+ const fitView = () => void flow.fitView({ padding: 0.18, duration: 250 });
+ const resetGraph = () => {
+ layoutEdited.current = true;
+ setLayout((value) => ({
+ ...value,
+ positions: { ...value.positions, [mode]: positionsFor(mode, agents) },
+ cameras: { ...value.cameras, [mode]: { x: 0, y: 0, zoom: 1 } },
+ }));
+ void flow.fitView({ padding: 0.18, duration: 0 });
+ };
+ const onNodeDragStop = (_event: MouseEvent | TouchEvent, node: FlowNode) => {
+ layoutEdited.current = true;
+ setLayout((value) => ({
+ ...value,
+ positions: {
+ ...value.positions,
+ [mode]: { ...value.positions[mode], [node.id]: node.position },
+ },
+ }));
+ };
+
+ if (isPending)
+ return (
+
+
+
+ );
+ if (isError || !selected)
+ return (
+
+
+
+ );
+
+ return (
+
+
+ DEMO GRAPH
+
+ }
+ />
+
+
+
+
+
+
+ Agent network
+ Local sample topology · no external connections
+
+
+
+
+ ENTITIES
+ {agents.length.toString().padStart(2, "0")}
+
+
+ DEMO LINKS
+ {relations.length.toString().padStart(2, "0")}
+
+
+ ACTIVE TASKS
+
+ {agents
+ .reduce((sum, agent) => sum + agent.tasks, 0)
+ .toString()
+ .padStart(2, "0")}
+
+
+
+
+ Local demo data
+
+
+
+
+
+
+ Connection map
+
+ Drag nodes or canvas to explore · select an entity for details
+
+
+
+
+ setMode("network")}
+ >
+ Network
+
+ setMode("radial")}
+ >
+ Constellation
+
+
+
+ Fit graph
+
+
+
+
+ setSelectedId(node.id)}
+ onNodeDragStop={onNodeDragStop}
+ onMoveEnd={updateCamera}
+ onPaneClick={() => setSelectedId("")}
+ fitView
+ fitViewOptions={{ padding: 0.18 }}
+ minZoom={0.55}
+ maxZoom={1.8}
+ nodesDraggable
+ nodesConnectable
+ elementsSelectable
+ aria-label="Agent relationship graph"
+ >
+
+
+
+ node.id === selectedId ? "#75d4ae" : "#607b85"
+ }
+ />
+
+
+
+
+ Active
+
+
+ Idle
+
+
+ Needs attention
+
+
+ Lines show illustrative directed relationships
+
+
+
+
+ {graphError && (
+
+ {graphError}
+
+ )}
+
+
+ ENTITY INSPECTOR
+
+ {String(
+ agents.findIndex((agent) => agent.id === selected.id) + 1,
+ ).padStart(2, "0")}{" "}
+ / {String(agents.length).padStart(2, "0")}
+
+
+
+
+ {selected.status}
+
+
+
+ {selected.name}
+
+ {selected.vendor} · {selected.kind.toUpperCase()} ·{" "}
+ {selected.role}
+
+
+ MODEL / ADAPTER
+ {selected.model}
+ Configured for demonstration only
+
+
+
+
+ DEMO TASKS
+ {String(selected.tasks).padStart(2, "0")}
+
+
+ LATENCY SAMPLE
+ {selected.latency}
+
+
+
+
+ CONNECTED TO
+ {String(selectedEdges.length).padStart(2, "0")}
+
+ {selectedEdges.length ? (
+ selectedEdges.map((edge) => {
+ const otherId =
+ edge.source === selected.id ? edge.target : edge.source;
+ const other = agents.find((agent) => agent.id === otherId);
+ if (!other) return null;
+ return (
+
setSelectedId(other.id)}
+ >
+
+
+ {other.name}
+ {edge.label}
+
+
+
+ );
+ })
+ ) : (
+
+ No demo connections are shown for this entity.
+
+ )}
+
+
+
+ GRAPH ENTITIES
+ {String(agents.length).padStart(2, "0")}
+
+ {agents.map((agent) => (
+ setSelectedId(agent.id)}
+ >
+
+
+ {agent.name}
+ {agent.kind}
+
+
+ ))}
+
+
+
+ {selected.status === "paused"
+ ? "Resume demo adapter"
+ : "Pause demo adapter"}
+
+
+ Status changes are temporary and remain in this page session only.
+ No software is installed or contacted.
+
+
+
+
+ Sample graph data only
+ setLive((value) => !value)}
+ aria-pressed={live}
+ >
+ Motion {live ? "on" : "off"}
+
+ Decorative connection motion can be switched off.
+
+ Reset layout
+
+
+
+ );
+}
+export function AgentsPage() {
+ return (
+
+
+
+ );
+}
+
+// ORIGINAL (preserved, not deleted — disabled by comment): import {
+// ORIGINAL (preserved, not deleted — disabled by comment): Activity,
+// ORIGINAL (preserved, not deleted — disabled by comment): ArrowDown,
+// ORIGINAL (preserved, not deleted — disabled by comment): ArrowLeft,
+// ORIGINAL (preserved, not deleted — disabled by comment): ArrowRight,
+// ORIGINAL (preserved, not deleted — disabled by comment): ArrowUp,
+// ORIGINAL (preserved, not deleted — disabled by comment): Bot,
+// ORIGINAL (preserved, not deleted — disabled by comment): BrainCircuit,
+// ORIGINAL (preserved, not deleted — disabled by comment): ChevronDown,
+// ORIGINAL (preserved, not deleted — disabled by comment): CircleHelp,
+// ORIGINAL (preserved, not deleted — disabled by comment): Command,
+// ORIGINAL (preserved, not deleted — disabled by comment): Cpu,
+// ORIGINAL (preserved, not deleted — disabled by comment): GitBranch,
+// ORIGINAL (preserved, not deleted — disabled by comment): Minus,
+// ORIGINAL (preserved, not deleted — disabled by comment): Plus,
+// ORIGINAL (preserved, not deleted — disabled by comment): RotateCcw,
+// ORIGINAL (preserved, not deleted — disabled by comment): Sparkles,
+// ORIGINAL (preserved, not deleted — disabled by comment): Waypoints,
+// ORIGINAL (preserved, not deleted — disabled by comment): } from "lucide-react";
+// ORIGINAL (preserved, not deleted — disabled by comment): import {
+// ORIGINAL (preserved, not deleted — disabled by comment): // ORIGINAL (preserved, not deleted — disabled by comment): type KeyboardEvent as ReactKeyboardEvent,
+// ORIGINAL (preserved, not deleted — disabled by comment): type PointerEvent as ReactPointerEvent,
+// ORIGINAL (preserved, not deleted — disabled by comment): type WheelEvent as ReactWheelEvent,
+// ORIGINAL (preserved, not deleted — disabled by comment): useCallback,
+// ORIGINAL (preserved, not deleted — disabled by comment): useEffect,
+// ORIGINAL (preserved, not deleted — disabled by comment): useRef,
+// ORIGINAL (preserved, not deleted — disabled by comment): useState,
+// ORIGINAL (preserved, not deleted — disabled by comment): } from "react";
+// ORIGINAL (preserved, not deleted — disabled by comment): import { PageTitle } from "../components/ui";
+// ORIGINAL (preserved, not deleted — disabled by comment): import {
+// ORIGINAL (preserved, not deleted — disabled by comment): type AgentGraphLayout,
+// ORIGINAL (preserved, not deleted — disabled by comment): readAgentGraphLayout,
+// ORIGINAL (preserved, not deleted — disabled by comment): writeAgentGraphLayout,
+// ORIGINAL (preserved, not deleted — disabled by comment): } from "../data/agentLayout";
+// ORIGINAL (preserved, not deleted — disabled by comment): import "./Agents.css";
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment): type AgentStatus = "active" | "idle" | "attention" | "paused";
+// ORIGINAL (preserved, not deleted — disabled by comment): type GraphMode = "network" | "radial";
+// ORIGINAL (preserved, not deleted — disabled by comment): type Point = { x: number; y: number };
+// ORIGINAL (preserved, not deleted — disabled by comment): type AgentNode = {
+// ORIGINAL (preserved, not deleted — disabled by comment): id: string;
+// ORIGINAL (preserved, not deleted — disabled by comment): name: string;
+// ORIGINAL (preserved, not deleted — disabled by comment): vendor: string;
+// ORIGINAL (preserved, not deleted — disabled by comment): model: string;
+// ORIGINAL (preserved, not deleted — disabled by comment): role: string;
+// ORIGINAL (preserved, not deleted — disabled by comment): status: AgentStatus;
+// ORIGINAL (preserved, not deleted — disabled by comment): tasks: number;
+// ORIGINAL (preserved, not deleted — disabled by comment): latency: string;
+// ORIGINAL (preserved, not deleted — disabled by comment): color: string;
+// ORIGINAL (preserved, not deleted — disabled by comment): icon: "codex" | "claude" | "hermes" | "openclaw" | "custom";
+// ORIGINAL (preserved, not deleted — disabled by comment): kind: "agent" | "model" | "tool" | "memory" | "task" | "service";
+// ORIGINAL (preserved, not deleted — disabled by comment): };
+// ORIGINAL (preserved, not deleted — disabled by comment): type AgentEdge = {
+// ORIGINAL (preserved, not deleted — disabled by comment): source: string;
+// ORIGINAL (preserved, not deleted — disabled by comment): target: string;
+// ORIGINAL (preserved, not deleted — disabled by comment): label: string;
+// ORIGINAL (preserved, not deleted — disabled by comment): strength: number;
+// ORIGINAL (preserved, not deleted — disabled by comment): };
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment): const VIEW = { width: 900, height: 570 };
+// ORIGINAL (preserved, not deleted — disabled by comment): const INITIAL_AGENTS: AgentNode[] = [
+// ORIGINAL (preserved, not deleted — disabled by comment): {
+// ORIGINAL (preserved, not deleted — disabled by comment): id: "codex",
+// ORIGINAL (preserved, not deleted — disabled by comment): name: "Codex",
+// ORIGINAL (preserved, not deleted — disabled by comment): vendor: "OpenAI",
+// ORIGINAL (preserved, not deleted — disabled by comment): model: "Codex · demo adapter",
+// ORIGINAL (preserved, not deleted — disabled by comment): role: "Workflow coordinator",
+// ORIGINAL (preserved, not deleted — disabled by comment): status: "active",
+// ORIGINAL (preserved, not deleted — disabled by comment): tasks: 4,
+// ORIGINAL (preserved, not deleted — disabled by comment): latency: "42 ms",
+// ORIGINAL (preserved, not deleted — disabled by comment): color: "mint",
+// ORIGINAL (preserved, not deleted — disabled by comment): icon: "codex",
+// ORIGINAL (preserved, not deleted — disabled by comment): kind: "agent",
+// ORIGINAL (preserved, not deleted — disabled by comment): },
+// ORIGINAL (preserved, not deleted — disabled by comment): {
+// ORIGINAL (preserved, not deleted — disabled by comment): id: "claude",
+// ORIGINAL (preserved, not deleted — disabled by comment): name: "Claude Code",
+// ORIGINAL (preserved, not deleted — disabled by comment): vendor: "Anthropic",
+// ORIGINAL (preserved, not deleted — disabled by comment): model: "Claude · demo adapter",
+// ORIGINAL (preserved, not deleted — disabled by comment): role: "Code analysis",
+// ORIGINAL (preserved, not deleted — disabled by comment): status: "active",
+// ORIGINAL (preserved, not deleted — disabled by comment): tasks: 2,
+// ORIGINAL (preserved, not deleted — disabled by comment): latency: "58 ms",
+// ORIGINAL (preserved, not deleted — disabled by comment): color: "peach",
+// ORIGINAL (preserved, not deleted — disabled by comment): icon: "claude",
+// ORIGINAL (preserved, not deleted — disabled by comment): kind: "agent",
+// ORIGINAL (preserved, not deleted — disabled by comment): },
+// ORIGINAL (preserved, not deleted — disabled by comment): {
+// ORIGINAL (preserved, not deleted — disabled by comment): id: "hermes",
+// ORIGINAL (preserved, not deleted — disabled by comment): name: "Hermes Agent",
+// ORIGINAL (preserved, not deleted — disabled by comment): vendor: "Open source",
+// ORIGINAL (preserved, not deleted — disabled by comment): model: "Hermes · demo adapter",
+// ORIGINAL (preserved, not deleted — disabled by comment): role: "Task automation",
+// ORIGINAL (preserved, not deleted — disabled by comment): status: "idle",
+// ORIGINAL (preserved, not deleted — disabled by comment): tasks: 0,
+// ORIGINAL (preserved, not deleted — disabled by comment): latency: "—",
+// ORIGINAL (preserved, not deleted — disabled by comment): color: "violet",
+// ORIGINAL (preserved, not deleted — disabled by comment): icon: "hermes",
+// ORIGINAL (preserved, not deleted — disabled by comment): kind: "agent",
+// ORIGINAL (preserved, not deleted — disabled by comment): },
+// ORIGINAL (preserved, not deleted — disabled by comment): {
+// ORIGINAL (preserved, not deleted — disabled by comment): id: "openclaw",
+// ORIGINAL (preserved, not deleted — disabled by comment): name: "OpenClaw",
+// ORIGINAL (preserved, not deleted — disabled by comment): vendor: "Community",
+// ORIGINAL (preserved, not deleted — disabled by comment): model: "OpenClaw · demo adapter",
+// ORIGINAL (preserved, not deleted — disabled by comment): role: "Tool routing",
+// ORIGINAL (preserved, not deleted — disabled by comment): status: "attention",
+// ORIGINAL (preserved, not deleted — disabled by comment): tasks: 1,
+// ORIGINAL (preserved, not deleted — disabled by comment): latency: "91 ms",
+// ORIGINAL (preserved, not deleted — disabled by comment): color: "blue",
+// ORIGINAL (preserved, not deleted — disabled by comment): icon: "openclaw",
+// ORIGINAL (preserved, not deleted — disabled by comment): kind: "agent",
+// ORIGINAL (preserved, not deleted — disabled by comment): },
+// ORIGINAL (preserved, not deleted — disabled by comment): {
+// ORIGINAL (preserved, not deleted — disabled by comment): id: "custom",
+// ORIGINAL (preserved, not deleted — disabled by comment): name: "Custom adapter",
+// ORIGINAL (preserved, not deleted — disabled by comment): vendor: "Local example",
+// ORIGINAL (preserved, not deleted — disabled by comment): model: "Compatible endpoint · demo",
+// ORIGINAL (preserved, not deleted — disabled by comment): role: "Custom integration",
+// ORIGINAL (preserved, not deleted — disabled by comment): status: "paused",
+// ORIGINAL (preserved, not deleted — disabled by comment): tasks: 0,
+// ORIGINAL (preserved, not deleted — disabled by comment): latency: "—",
+// ORIGINAL (preserved, not deleted — disabled by comment): color: "gold",
+// ORIGINAL (preserved, not deleted — disabled by comment): icon: "custom",
+// ORIGINAL (preserved, not deleted — disabled by comment): kind: "agent",
+// ORIGINAL (preserved, not deleted — disabled by comment): },
+// ORIGINAL (preserved, not deleted — disabled by comment): {
+// ORIGINAL (preserved, not deleted — disabled by comment): id: "model",
+// ORIGINAL (preserved, not deleted — disabled by comment): name: "Reasoning model",
+// ORIGINAL (preserved, not deleted — disabled by comment): vendor: "MODEL",
+// ORIGINAL (preserved, not deleted — disabled by comment): model: "Local demo model",
+// ORIGINAL (preserved, not deleted — disabled by comment): role: "Language model",
+// ORIGINAL (preserved, not deleted — disabled by comment): status: "active",
+// ORIGINAL (preserved, not deleted — disabled by comment): tasks: 3,
+// ORIGINAL (preserved, not deleted — disabled by comment): latency: "36 ms",
+// ORIGINAL (preserved, not deleted — disabled by comment): color: "violet",
+// ORIGINAL (preserved, not deleted — disabled by comment): icon: "custom",
+// ORIGINAL (preserved, not deleted — disabled by comment): kind: "model",
+// ORIGINAL (preserved, not deleted — disabled by comment): },
+// ORIGINAL (preserved, not deleted — disabled by comment): {
+// ORIGINAL (preserved, not deleted — disabled by comment): id: "tools",
+// ORIGINAL (preserved, not deleted — disabled by comment): name: "Tool registry",
+// ORIGINAL (preserved, not deleted — disabled by comment): vendor: "TOOLS",
+// ORIGINAL (preserved, not deleted — disabled by comment): model: "Local demo tools",
+// ORIGINAL (preserved, not deleted — disabled by comment): role: "Tool collection",
+// ORIGINAL (preserved, not deleted — disabled by comment): status: "idle",
+// ORIGINAL (preserved, not deleted — disabled by comment): tasks: 0,
+// ORIGINAL (preserved, not deleted — disabled by comment): latency: "—",
+// ORIGINAL (preserved, not deleted — disabled by comment): color: "blue",
+// ORIGINAL (preserved, not deleted — disabled by comment): icon: "openclaw",
+// ORIGINAL (preserved, not deleted — disabled by comment): kind: "tool",
+// ORIGINAL (preserved, not deleted — disabled by comment): },
+// ORIGINAL (preserved, not deleted — disabled by comment): {
+// ORIGINAL (preserved, not deleted — disabled by comment): id: "memory",
+// ORIGINAL (preserved, not deleted — disabled by comment): name: "Shared context",
+// ORIGINAL (preserved, not deleted — disabled by comment): vendor: "MEMORY",
+// ORIGINAL (preserved, not deleted — disabled by comment): model: "Session memory · demo",
+// ORIGINAL (preserved, not deleted — disabled by comment): role: "Context store",
+// ORIGINAL (preserved, not deleted — disabled by comment): status: "active",
+// ORIGINAL (preserved, not deleted — disabled by comment): tasks: 2,
+// ORIGINAL (preserved, not deleted — disabled by comment): latency: "12 ms",
+// ORIGINAL (preserved, not deleted — disabled by comment): color: "gold",
+// ORIGINAL (preserved, not deleted — disabled by comment): icon: "hermes",
+// ORIGINAL (preserved, not deleted — disabled by comment): kind: "memory",
+// ORIGINAL (preserved, not deleted — disabled by comment): },
+// ORIGINAL (preserved, not deleted — disabled by comment): {
+// ORIGINAL (preserved, not deleted — disabled by comment): id: "task",
+// ORIGINAL (preserved, not deleted — disabled by comment): name: "Build review",
+// ORIGINAL (preserved, not deleted — disabled by comment): vendor: "TASK",
+// ORIGINAL (preserved, not deleted — disabled by comment): model: "Sample job · demo",
+// ORIGINAL (preserved, not deleted — disabled by comment): role: "Queued task",
+// ORIGINAL (preserved, not deleted — disabled by comment): status: "idle",
+// ORIGINAL (preserved, not deleted — disabled by comment): tasks: 1,
+// ORIGINAL (preserved, not deleted — disabled by comment): latency: "—",
+// ORIGINAL (preserved, not deleted — disabled by comment): color: "peach",
+// ORIGINAL (preserved, not deleted — disabled by comment): icon: "claude",
+// ORIGINAL (preserved, not deleted — disabled by comment): kind: "task",
+// ORIGINAL (preserved, not deleted — disabled by comment): },
+// ORIGINAL (preserved, not deleted — disabled by comment): {
+// ORIGINAL (preserved, not deleted — disabled by comment): id: "service",
+// ORIGINAL (preserved, not deleted — disabled by comment): name: "Git provider",
+// ORIGINAL (preserved, not deleted — disabled by comment): vendor: "SERVICE",
+// ORIGINAL (preserved, not deleted — disabled by comment): model: "External service · illustrative",
+// ORIGINAL (preserved, not deleted — disabled by comment): role: "Integration endpoint",
+// ORIGINAL (preserved, not deleted — disabled by comment): status: "paused",
+// ORIGINAL (preserved, not deleted — disabled by comment): tasks: 0,
+// ORIGINAL (preserved, not deleted — disabled by comment): latency: "—",
+// ORIGINAL (preserved, not deleted — disabled by comment): color: "blue",
+// ORIGINAL (preserved, not deleted — disabled by comment): icon: "custom",
+// ORIGINAL (preserved, not deleted — disabled by comment): kind: "service",
+// ORIGINAL (preserved, not deleted — disabled by comment): },
+// ORIGINAL (preserved, not deleted — disabled by comment): ];
+// ORIGINAL (preserved, not deleted — disabled by comment): const INITIAL_EDGES: AgentEdge[] = [
+// ORIGINAL (preserved, not deleted — disabled by comment): {
+// ORIGINAL (preserved, not deleted — disabled by comment): source: "codex",
+// ORIGINAL (preserved, not deleted — disabled by comment): target: "claude",
+// ORIGINAL (preserved, not deleted — disabled by comment): label: "delegates review",
+// ORIGINAL (preserved, not deleted — disabled by comment): strength: 0.82,
+// ORIGINAL (preserved, not deleted — disabled by comment): },
+// ORIGINAL (preserved, not deleted — disabled by comment): { source: "codex", target: "hermes", label: "routes tasks", strength: 0.62 },
+// ORIGINAL (preserved, not deleted — disabled by comment): {
+// ORIGINAL (preserved, not deleted — disabled by comment): source: "codex",
+// ORIGINAL (preserved, not deleted — disabled by comment): target: "openclaw",
+// ORIGINAL (preserved, not deleted — disabled by comment): label: "shares context",
+// ORIGINAL (preserved, not deleted — disabled by comment): strength: 0.46,
+// ORIGINAL (preserved, not deleted — disabled by comment): },
+// ORIGINAL (preserved, not deleted — disabled by comment): { source: "claude", target: "custom", label: "handoff", strength: 0.34 },
+// ORIGINAL (preserved, not deleted — disabled by comment): { source: "hermes", target: "custom", label: "tool access", strength: 0.3 },
+// ORIGINAL (preserved, not deleted — disabled by comment): {
+// ORIGINAL (preserved, not deleted — disabled by comment): source: "openclaw",
+// ORIGINAL (preserved, not deleted — disabled by comment): target: "hermes",
+// ORIGINAL (preserved, not deleted — disabled by comment): label: "event bridge",
+// ORIGINAL (preserved, not deleted — disabled by comment): strength: 0.56,
+// ORIGINAL (preserved, not deleted — disabled by comment): },
+// ORIGINAL (preserved, not deleted — disabled by comment): { source: "codex", target: "model", label: "uses model", strength: 0.66 },
+// ORIGINAL (preserved, not deleted — disabled by comment): { source: "claude", target: "tools", label: "uses tools", strength: 0.42 },
+// ORIGINAL (preserved, not deleted — disabled by comment): {
+// ORIGINAL (preserved, not deleted — disabled by comment): source: "hermes",
+// ORIGINAL (preserved, not deleted — disabled by comment): target: "memory",
+// ORIGINAL (preserved, not deleted — disabled by comment): label: "reads context",
+// ORIGINAL (preserved, not deleted — disabled by comment): strength: 0.52,
+// ORIGINAL (preserved, not deleted — disabled by comment): },
+// ORIGINAL (preserved, not deleted — disabled by comment): { source: "codex", target: "task", label: "runs task", strength: 0.72 },
+// ORIGINAL (preserved, not deleted — disabled by comment): {
+// ORIGINAL (preserved, not deleted — disabled by comment): source: "task",
+// ORIGINAL (preserved, not deleted — disabled by comment): target: "service",
+// ORIGINAL (preserved, not deleted — disabled by comment): label: "requests change",
+// ORIGINAL (preserved, not deleted — disabled by comment): strength: 0.38,
+// ORIGINAL (preserved, not deleted — disabled by comment): },
+// ORIGINAL (preserved, not deleted — disabled by comment): ];
+// ORIGINAL (preserved, not deleted — disabled by comment): // ORIGINAL (preserved, not deleted — disabled by comment): const edges = INITIAL_EDGES;
+// ORIGINAL (preserved, not deleted — disabled by comment): const NETWORK_POSITIONS: Record = {
+// ORIGINAL (preserved, not deleted — disabled by comment): codex: { x: 250, y: 280 },
+// ORIGINAL (preserved, not deleted — disabled by comment): claude: { x: 570, y: 145 },
+// ORIGINAL (preserved, not deleted — disabled by comment): hermes: { x: 675, y: 310 },
+// ORIGINAL (preserved, not deleted — disabled by comment): openclaw: { x: 515, y: 445 },
+// ORIGINAL (preserved, not deleted — disabled by comment): custom: { x: 225, y: 460 },
+// ORIGINAL (preserved, not deleted — disabled by comment): model: { x: 110, y: 155 },
+// ORIGINAL (preserved, not deleted — disabled by comment): tools: { x: 775, y: 165 },
+// ORIGINAL (preserved, not deleted — disabled by comment): memory: { x: 800, y: 480 },
+// ORIGINAL (preserved, not deleted — disabled by comment): task: { x: 345, y: 510 },
+// ORIGINAL (preserved, not deleted — disabled by comment): service: { x: 110, y: 390 },
+// ORIGINAL (preserved, not deleted — disabled by comment): };
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment): function positionsFor(mode: GraphMode): Record {
+// ORIGINAL (preserved, not deleted — disabled by comment): if (mode === "network") return NETWORK_POSITIONS;
+// ORIGINAL (preserved, not deleted — disabled by comment): const center = { x: VIEW.width / 2, y: VIEW.height / 2 };
+// ORIGINAL (preserved, not deleted — disabled by comment): const radiusX = 255;
+// ORIGINAL (preserved, not deleted — disabled by comment): const radiusY = 190;
+// ORIGINAL (preserved, not deleted — disabled by comment): return Object.fromEntries(
+// ORIGINAL (preserved, not deleted — disabled by comment): INITIAL_AGENTS.map((agent) => {
+// ORIGINAL (preserved, not deleted — disabled by comment): if (agent.id === "codex") return [agent.id, center];
+// ORIGINAL (preserved, not deleted — disabled by comment): const peers = INITIAL_AGENTS.filter((item) => item.id !== "codex");
+// ORIGINAL (preserved, not deleted — disabled by comment): const spoke = peers.findIndex((item) => item.id === agent.id);
+// ORIGINAL (preserved, not deleted — disabled by comment): const angle = -Math.PI / 2 + (spoke * (Math.PI * 2)) / peers.length;
+// ORIGINAL (preserved, not deleted — disabled by comment): return [
+// ORIGINAL (preserved, not deleted — disabled by comment): agent.id,
+// ORIGINAL (preserved, not deleted — disabled by comment): {
+// ORIGINAL (preserved, not deleted — disabled by comment): x: center.x + Math.cos(angle) * radiusX,
+// ORIGINAL (preserved, not deleted — disabled by comment): y: center.y + Math.sin(angle) * radiusY,
+// ORIGINAL (preserved, not deleted — disabled by comment): },
+// ORIGINAL (preserved, not deleted — disabled by comment): ];
+// ORIGINAL (preserved, not deleted — disabled by comment): }),
+// ORIGINAL (preserved, not deleted — disabled by comment): );
+// ORIGINAL (preserved, not deleted — disabled by comment): }
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment): function initialLayout(): AgentGraphLayout {
+// ORIGINAL (preserved, not deleted — disabled by comment): return {
+// ORIGINAL (preserved, not deleted — disabled by comment): positions: { network: NETWORK_POSITIONS, radial: positionsFor("radial") },
+// ORIGINAL (preserved, not deleted — disabled by comment): cameras: {
+// ORIGINAL (preserved, not deleted — disabled by comment): network: { x: 0, y: 0, zoom: 1 },
+// ORIGINAL (preserved, not deleted — disabled by comment): radial: { x: 0, y: 0, zoom: 1 },
+// ORIGINAL (preserved, not deleted — disabled by comment): },
+// ORIGINAL (preserved, not deleted — disabled by comment): };
+// ORIGINAL (preserved, not deleted — disabled by comment): }
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment): function AgentGlyph({ icon }: { icon: AgentNode["icon"] }) {
+// ORIGINAL (preserved, not deleted — disabled by comment): if (icon === "codex") return ;
+// ORIGINAL (preserved, not deleted — disabled by comment): if (icon === "claude") return ;
+// ORIGINAL (preserved, not deleted — disabled by comment): if (icon === "hermes") return ;
+// ORIGINAL (preserved, not deleted — disabled by comment): if (icon === "openclaw") return ;
+// ORIGINAL (preserved, not deleted — disabled by comment): return ;
+// ORIGINAL (preserved, not deleted — disabled by comment): }
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment): function edgePath(a: Point, b: Point) {
+// ORIGINAL (preserved, not deleted — disabled by comment): const middleX = (a.x + b.x) / 2;
+// ORIGINAL (preserved, not deleted — disabled by comment): const middleY = (a.y + b.y) / 2;
+// ORIGINAL (preserved, not deleted — disabled by comment): const curve = (b.x - a.x) * 0.08;
+// ORIGINAL (preserved, not deleted — disabled by comment): return `M ${a.x} ${a.y} Q ${middleX - curve} ${middleY + curve} ${b.x} ${b.y}`;
+// ORIGINAL (preserved, not deleted — disabled by comment): }
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment): export function AgentsPage() {
+// ORIGINAL (preserved, not deleted — disabled by comment): const [agents, setAgents] = useState(INITIAL_AGENTS);
+// ORIGINAL (preserved, not deleted — disabled by comment): const [layout, setLayout] = useState(initialLayout);
+// ORIGINAL (preserved, not deleted — disabled by comment): const layoutEdited = useRef(false);
+// ORIGINAL (preserved, not deleted — disabled by comment): const [layoutLoaded, setLayoutLoaded] = useState(false);
+// ORIGINAL (preserved, not deleted — disabled by comment): const [mode, setMode] = useState("network");
+// ORIGINAL (preserved, not deleted — disabled by comment): const positions = layout.positions[mode];
+// ORIGINAL (preserved, not deleted — disabled by comment): const [selectedId, setSelectedId] = useState("codex");
+// ORIGINAL (preserved, not deleted — disabled by comment): const [zoom, setZoom] = useState(1);
+// ORIGINAL (preserved, not deleted — disabled by comment): const [pan, setPan] = useState({ x: 0, y: 0 });
+// ORIGINAL (preserved, not deleted — disabled by comment): const [live, setLive] = useState(true);
+// ORIGINAL (preserved, not deleted — disabled by comment): const svgRef = useRef(null);
+// ORIGINAL (preserved, not deleted — disabled by comment): const gesture = useRef<
+// ORIGINAL (preserved, not deleted — disabled by comment): | { type: "node"; id: string; pointer: Point }
+// ORIGINAL (preserved, not deleted — disabled by comment): | { type: "pan"; pointer: Point; pan: Point }
+// ORIGINAL (preserved, not deleted — disabled by comment): | null
+// ORIGINAL (preserved, not deleted — disabled by comment): >(null);
+// ORIGINAL (preserved, not deleted — disabled by comment): const selected = agents.find((agent) => agent.id === selectedId) ?? agents[0];
+// ORIGINAL (preserved, not deleted — disabled by comment): const selectedEdges = INITIAL_EDGES.filter(
+// ORIGINAL (preserved, not deleted — disabled by comment): (edge) => edge.source === selected.id || edge.target === selected.id,
+// ORIGINAL (preserved, not deleted — disabled by comment): );
+// ORIGINAL (preserved, not deleted — disabled by comment): useEffect(() => {
+// ORIGINAL (preserved, not deleted — disabled by comment): let active = true;
+// ORIGINAL (preserved, not deleted — disabled by comment): void readAgentGraphLayout(initialLayout()).then((saved) => {
+// ORIGINAL (preserved, not deleted — disabled by comment): if (!active) return;
+// ORIGINAL (preserved, not deleted — disabled by comment): if (!layoutEdited.current) setLayout(saved);
+// ORIGINAL (preserved, not deleted — disabled by comment): setLayoutLoaded(true);
+// ORIGINAL (preserved, not deleted — disabled by comment): });
+// ORIGINAL (preserved, not deleted — disabled by comment): return () => {
+// ORIGINAL (preserved, not deleted — disabled by comment): active = false;
+// ORIGINAL (preserved, not deleted — disabled by comment): };
+// ORIGINAL (preserved, not deleted — disabled by comment): }, []);
+// ORIGINAL (preserved, not deleted — disabled by comment): useEffect(() => {
+// ORIGINAL (preserved, not deleted — disabled by comment): if (!layoutLoaded) return;
+// ORIGINAL (preserved, not deleted — disabled by comment): const timeout = window.setTimeout(
+// ORIGINAL (preserved, not deleted — disabled by comment): () => void writeAgentGraphLayout(layout),
+// ORIGINAL (preserved, not deleted — disabled by comment): 250,
+// ORIGINAL (preserved, not deleted — disabled by comment): );
+// ORIGINAL (preserved, not deleted — disabled by comment): return () => window.clearTimeout(timeout);
+// ORIGINAL (preserved, not deleted — disabled by comment): }, [layout, layoutLoaded]);
+// ORIGINAL (preserved, not deleted — disabled by comment): useEffect(() => {
+// ORIGINAL (preserved, not deleted — disabled by comment): setZoom(layout.cameras[mode].zoom);
+// ORIGINAL (preserved, not deleted — disabled by comment): setPan({ x: layout.cameras[mode].x, y: layout.cameras[mode].y });
+// ORIGINAL (preserved, not deleted — disabled by comment): }, [layout.cameras, mode]);
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment): const updateCamera = (next: Partial & { zoom?: number }) => {
+// ORIGINAL (preserved, not deleted — disabled by comment): layoutEdited.current = true;
+// ORIGINAL (preserved, not deleted — disabled by comment): setLayout((value) => ({
+// ORIGINAL (preserved, not deleted — disabled by comment): ...value,
+// ORIGINAL (preserved, not deleted — disabled by comment): cameras: {
+// ORIGINAL (preserved, not deleted — disabled by comment): ...value.cameras,
+// ORIGINAL (preserved, not deleted — disabled by comment): [mode]: { ...value.cameras[mode], ...next },
+// ORIGINAL (preserved, not deleted — disabled by comment): },
+// ORIGINAL (preserved, not deleted — disabled by comment): }));
+// ORIGINAL (preserved, not deleted — disabled by comment): };
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment): const pointerPoint = useCallback(
+// ORIGINAL (preserved, not deleted — disabled by comment): (
+// ORIGINAL (preserved, not deleted — disabled by comment): event: ReactPointerEvent | ReactWheelEvent,
+// ORIGINAL (preserved, not deleted — disabled by comment): ) => {
+// ORIGINAL (preserved, not deleted — disabled by comment): const svg = svgRef.current;
+// ORIGINAL (preserved, not deleted — disabled by comment): if (!svg) return { x: 0, y: 0 };
+// ORIGINAL (preserved, not deleted — disabled by comment): const transform = svg.getScreenCTM()?.inverse();
+// ORIGINAL (preserved, not deleted — disabled by comment): if (!transform) return { x: 0, y: 0 };
+// ORIGINAL (preserved, not deleted — disabled by comment): const point = svg.createSVGPoint();
+// ORIGINAL (preserved, not deleted — disabled by comment): point.x = event.clientX;
+// ORIGINAL (preserved, not deleted — disabled by comment): point.y = event.clientY;
+// ORIGINAL (preserved, not deleted — disabled by comment): const local = point.matrixTransform(transform);
+// ORIGINAL (preserved, not deleted — disabled by comment): return { x: local.x, y: local.y };
+// ORIGINAL (preserved, not deleted — disabled by comment): },
+// ORIGINAL (preserved, not deleted — disabled by comment): [],
+// ORIGINAL (preserved, not deleted — disabled by comment): );
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment): const onPointerDown = (event: ReactPointerEvent) => {
+// ORIGINAL (preserved, not deleted — disabled by comment): if (event.button !== 0) return;
+// ORIGINAL (preserved, not deleted — disabled by comment): const target = (event.target as Element).closest(
+// ORIGINAL (preserved, not deleted — disabled by comment): "[data-agent-id]",
+// ORIGINAL (preserved, not deleted — disabled by comment): );
+// ORIGINAL (preserved, not deleted — disabled by comment): const pointer = pointerPoint(event);
+// ORIGINAL (preserved, not deleted — disabled by comment): if (target?.dataset.agentId) {
+// ORIGINAL (preserved, not deleted — disabled by comment): const id = target.dataset.agentId;
+// ORIGINAL (preserved, not deleted — disabled by comment): setSelectedId(id);
+// ORIGINAL (preserved, not deleted — disabled by comment): gesture.current = { type: "node", id, pointer };
+// ORIGINAL (preserved, not deleted — disabled by comment): } else {
+// ORIGINAL (preserved, not deleted — disabled by comment): gesture.current = { type: "pan", pointer, pan };
+// ORIGINAL (preserved, not deleted — disabled by comment): }
+// ORIGINAL (preserved, not deleted — disabled by comment): event.currentTarget.setPointerCapture(event.pointerId);
+// ORIGINAL (preserved, not deleted — disabled by comment): };
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment): const onPointerMove = (event: ReactPointerEvent) => {
+// ORIGINAL (preserved, not deleted — disabled by comment): const active = gesture.current;
+// ORIGINAL (preserved, not deleted — disabled by comment): if (!active) return;
+// ORIGINAL (preserved, not deleted — disabled by comment): const current = pointerPoint(event);
+// ORIGINAL (preserved, not deleted — disabled by comment): const delta = {
+// ORIGINAL (preserved, not deleted — disabled by comment): x: current.x - active.pointer.x,
+// ORIGINAL (preserved, not deleted — disabled by comment): y: current.y - active.pointer.y,
+// ORIGINAL (preserved, not deleted — disabled by comment): };
+// ORIGINAL (preserved, not deleted — disabled by comment): if (active.type === "node") {
+// ORIGINAL (preserved, not deleted — disabled by comment): layoutEdited.current = true;
+// ORIGINAL (preserved, not deleted — disabled by comment): setLayout((previous) => {
+// ORIGINAL (preserved, not deleted — disabled by comment): const original = previous.positions[mode][active.id];
+// ORIGINAL (preserved, not deleted — disabled by comment): return {
+// ORIGINAL (preserved, not deleted — disabled by comment): ...previous,
+// ORIGINAL (preserved, not deleted — disabled by comment): positions: {
+// ORIGINAL (preserved, not deleted — disabled by comment): ...previous.positions,
+// ORIGINAL (preserved, not deleted — disabled by comment): [mode]: {
+// ORIGINAL (preserved, not deleted — disabled by comment): ...previous.positions[mode],
+// ORIGINAL (preserved, not deleted — disabled by comment): [active.id]: {
+// ORIGINAL (preserved, not deleted — disabled by comment): x: original.x + delta.x / zoom,
+// ORIGINAL (preserved, not deleted — disabled by comment): y: original.y + delta.y / zoom,
+// ORIGINAL (preserved, not deleted — disabled by comment): },
+// ORIGINAL (preserved, not deleted — disabled by comment): },
+// ORIGINAL (preserved, not deleted — disabled by comment): },
+// ORIGINAL (preserved, not deleted — disabled by comment): };
+// ORIGINAL (preserved, not deleted — disabled by comment): });
+// ORIGINAL (preserved, not deleted — disabled by comment): active.pointer = current;
+// ORIGINAL (preserved, not deleted — disabled by comment): } else {
+// ORIGINAL (preserved, not deleted — disabled by comment): const next = { x: active.pan.x + delta.x, y: active.pan.y + delta.y };
+// ORIGINAL (preserved, not deleted — disabled by comment): setPan(next);
+// ORIGINAL (preserved, not deleted — disabled by comment): updateCamera(next);
+// ORIGINAL (preserved, not deleted — disabled by comment): }
+// ORIGINAL (preserved, not deleted — disabled by comment): };
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment): const onWheel = (event: ReactWheelEvent) => {
+// ORIGINAL (preserved, not deleted — disabled by comment): event.preventDefault();
+// ORIGINAL (preserved, not deleted — disabled by comment): setZoom((current) => {
+// ORIGINAL (preserved, not deleted — disabled by comment): const next = Math.max(
+// ORIGINAL (preserved, not deleted — disabled by comment): 0.65,
+// ORIGINAL (preserved, not deleted — disabled by comment): Math.min(1.8, current * (event.deltaY < 0 ? 1.08 : 0.92)),
+// ORIGINAL (preserved, not deleted — disabled by comment): );
+// ORIGINAL (preserved, not deleted — disabled by comment): updateCamera({ zoom: next });
+// ORIGINAL (preserved, not deleted — disabled by comment): return next;
+// ORIGINAL (preserved, not deleted — disabled by comment): });
+// ORIGINAL (preserved, not deleted — disabled by comment): };
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment): /* ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment): const onGraphKeyDown = (event: ReactKeyboardEvent) => {
+// ORIGINAL (preserved, not deleted — disabled by comment): const step = event.shiftKey ? 40 : 18;
+// ORIGINAL (preserved, not deleted — disabled by comment): if (event.key === "ArrowLeft")
+// ORIGINAL (preserved, not deleted — disabled by comment): setPan((value) => ({ ...value, x: value.x + step }));
+// ORIGINAL (preserved, not deleted — disabled by comment): else if (event.key === "ArrowRight")
+// ORIGINAL (preserved, not deleted — disabled by comment): setPan((value) => ({ ...value, x: value.x - step }));
+// ORIGINAL (preserved, not deleted — disabled by comment): else if (event.key === "ArrowUp")
+// ORIGINAL (preserved, not deleted — disabled by comment): setPan((value) => ({ ...value, y: value.y + step }));
+// ORIGINAL (preserved, not deleted — disabled by comment): else if (event.key === "ArrowDown")
+// ORIGINAL (preserved, not deleted — disabled by comment): setPan((value) => ({ ...value, y: value.y - step }));
+// ORIGINAL (preserved, not deleted — disabled by comment): else if (event.key === "+" || event.key === "=")
+// ORIGINAL (preserved, not deleted — disabled by comment): setZoom((value) => Math.min(1.8, value + 0.1));
+// ORIGINAL (preserved, not deleted — disabled by comment): else if (event.key === "-") setZoom((value) => Math.max(0.65, value - 0.1));
+// ORIGINAL (preserved, not deleted — disabled by comment): else return;
+// ORIGINAL (preserved, not deleted — disabled by comment): event.preventDefault();
+// ORIGINAL (preserved, not deleted — disabled by comment): };
+// ORIGINAL (preserved, not deleted — disabled by comment): */
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment): const setModeAndLayout = (nextMode: GraphMode) => {
+// ORIGINAL (preserved, not deleted — disabled by comment): setMode(nextMode);
+// ORIGINAL (preserved, not deleted — disabled by comment): setPan({ x: 0, y: 0 });
+// ORIGINAL (preserved, not deleted — disabled by comment): };
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment): const toggleAgentStatus = () => {
+// ORIGINAL (preserved, not deleted — disabled by comment): setAgents((current) =>
+// ORIGINAL (preserved, not deleted — disabled by comment): current.map((agent) =>
+// ORIGINAL (preserved, not deleted — disabled by comment): agent.id === selected.id
+// ORIGINAL (preserved, not deleted — disabled by comment): ? { ...agent, status: agent.status === "paused" ? "idle" : "paused" }
+// ORIGINAL (preserved, not deleted — disabled by comment): : agent,
+// ORIGINAL (preserved, not deleted — disabled by comment): ),
+// ORIGINAL (preserved, not deleted — disabled by comment): );
+// ORIGINAL (preserved, not deleted — disabled by comment): };
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment): return (
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment): DEMO GRAPH
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment): }
+// ORIGINAL (preserved, not deleted — disabled by comment): />
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment): Agent network
+// ORIGINAL (preserved, not deleted — disabled by comment): Local sample topology · no external connections
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment): ADAPTERS
+// ORIGINAL (preserved, not deleted — disabled by comment): {agents.length.toString().padStart(2, "0")}
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment): DEMO LINKS
+// ORIGINAL (preserved, not deleted — disabled by comment): {INITIAL_EDGES.length.toString().padStart(2, "0")}
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment): ACTIVE TASKS
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment): {agents
+// ORIGINAL (preserved, not deleted — disabled by comment): .reduce((sum, agent) => sum + agent.tasks, 0)
+// ORIGINAL (preserved, not deleted — disabled by comment): .toString()
+// ORIGINAL (preserved, not deleted — disabled by comment): .padStart(2, "0")}
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment): Local demo data
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment): Connection map
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment): Drag nodes or canvas to explore
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment): setModeAndLayout("network")}
+// ORIGINAL (preserved, not deleted — disabled by comment): >
+// ORIGINAL (preserved, not deleted — disabled by comment): Network
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment): setModeAndLayout("radial")}
+// ORIGINAL (preserved, not deleted — disabled by comment): >
+// ORIGINAL (preserved, not deleted — disabled by comment): Radial
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment): {
+// ORIGINAL (preserved, not deleted — disabled by comment): gesture.current = null;
+// ORIGINAL (preserved, not deleted — disabled by comment): }}
+// ORIGINAL (preserved, not deleted — disabled by comment): onPointerCancel={() => {
+// ORIGINAL (preserved, not deleted — disabled by comment): gesture.current = null;
+// ORIGINAL (preserved, not deleted — disabled by comment): }}
+// ORIGINAL (preserved, not deleted — disabled by comment): onWheel={onWheel}
+// ORIGINAL (preserved, not deleted — disabled by comment): >
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment): Demo AI agent relationship map
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment): {INITIAL_EDGES.map((edge) => {
+// ORIGINAL (preserved, not deleted — disabled by comment): const from = positions[edge.source];
+// ORIGINAL (preserved, not deleted — disabled by comment): const to = positions[edge.target];
+// ORIGINAL (preserved, not deleted — disabled by comment): const highlighted =
+// ORIGINAL (preserved, not deleted — disabled by comment): edge.source === selected.id ||
+// ORIGINAL (preserved, not deleted — disabled by comment): edge.target === selected.id;
+// ORIGINAL (preserved, not deleted — disabled by comment): const labelX = (from.x + to.x) / 2;
+// ORIGINAL (preserved, not deleted — disabled by comment): const labelY = (from.y + to.y) / 2;
+// ORIGINAL (preserved, not deleted — disabled by comment): return (
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment): {highlighted && (
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment): {edge.label}
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment): )}
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment): );
+// ORIGINAL (preserved, not deleted — disabled by comment): })}
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment): {agents.map((agent) => {
+// ORIGINAL (preserved, not deleted — disabled by comment): const point = positions[agent.id];
+// ORIGINAL (preserved, not deleted — disabled by comment): const isSelected = agent.id === selectedId;
+// ORIGINAL (preserved, not deleted — disabled by comment): return (
+// ORIGINAL (preserved, not deleted — disabled by comment): /* biome-ignore lint/a11y/useSemanticElements: SVG node geometry needs a keyboard-accessible button role. */
+// ORIGINAL (preserved, not deleted — disabled by comment): {
+// ORIGINAL (preserved, not deleted — disabled by comment): event.stopPropagation();
+// ORIGINAL (preserved, not deleted — disabled by comment): setSelectedId(agent.id);
+// ORIGINAL (preserved, not deleted — disabled by comment): }}
+// ORIGINAL (preserved, not deleted — disabled by comment): onKeyDown={(event) => {
+// ORIGINAL (preserved, not deleted — disabled by comment): if (event.key === "Enter" || event.key === " ") {
+// ORIGINAL (preserved, not deleted — disabled by comment): event.preventDefault();
+// ORIGINAL (preserved, not deleted — disabled by comment): setSelectedId(agent.id);
+// ORIGINAL (preserved, not deleted — disabled by comment): }
+// ORIGINAL (preserved, not deleted — disabled by comment): }}
+// ORIGINAL (preserved, not deleted — disabled by comment): >
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment): {agent.kind.toUpperCase()}
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment): {agent.name}
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment): {agent.tasks} TASK{agent.tasks === 1 ? "" : "S"} ·{" "}
+// ORIGINAL (preserved, not deleted — disabled by comment): {agent.status.toUpperCase()}
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment): );
+// ORIGINAL (preserved, not deleted — disabled by comment): })}
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment): DRAG TO REPOSITION · WHEEL TO ZOOM · SELECT A NODE FOR DETAILS
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment): Canvas controls
+// ORIGINAL (preserved, not deleted — disabled by comment): {
+// ORIGINAL (preserved, not deleted — disabled by comment): const next = { ...pan, x: pan.x + 24 };
+// ORIGINAL (preserved, not deleted — disabled by comment): setPan(next);
+// ORIGINAL (preserved, not deleted — disabled by comment): updateCamera(next);
+// ORIGINAL (preserved, not deleted — disabled by comment): }}
+// ORIGINAL (preserved, not deleted — disabled by comment): >
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment): {
+// ORIGINAL (preserved, not deleted — disabled by comment): const next = { ...pan, x: pan.x - 24 };
+// ORIGINAL (preserved, not deleted — disabled by comment): setPan(next);
+// ORIGINAL (preserved, not deleted — disabled by comment): updateCamera(next);
+// ORIGINAL (preserved, not deleted — disabled by comment): }}
+// ORIGINAL (preserved, not deleted — disabled by comment): >
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment): {
+// ORIGINAL (preserved, not deleted — disabled by comment): const next = { ...pan, y: pan.y + 24 };
+// ORIGINAL (preserved, not deleted — disabled by comment): setPan(next);
+// ORIGINAL (preserved, not deleted — disabled by comment): updateCamera(next);
+// ORIGINAL (preserved, not deleted — disabled by comment): }}
+// ORIGINAL (preserved, not deleted — disabled by comment): >
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment): {
+// ORIGINAL (preserved, not deleted — disabled by comment): const next = { ...pan, y: pan.y - 24 };
+// ORIGINAL (preserved, not deleted — disabled by comment): setPan(next);
+// ORIGINAL (preserved, not deleted — disabled by comment): updateCamera(next);
+// ORIGINAL (preserved, not deleted — disabled by comment): }}
+// ORIGINAL (preserved, not deleted — disabled by comment): >
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment): setZoom((value) => {
+// ORIGINAL (preserved, not deleted — disabled by comment): const next = Math.max(0.65, value - 0.1);
+// ORIGINAL (preserved, not deleted — disabled by comment): updateCamera({ zoom: next });
+// ORIGINAL (preserved, not deleted — disabled by comment): return next;
+// ORIGINAL (preserved, not deleted — disabled by comment): })
+// ORIGINAL (preserved, not deleted — disabled by comment): }
+// ORIGINAL (preserved, not deleted — disabled by comment): >
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment): {Math.round(zoom * 100)}%
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment): setZoom((value) => {
+// ORIGINAL (preserved, not deleted — disabled by comment): const next = Math.min(1.8, value + 0.1);
+// ORIGINAL (preserved, not deleted — disabled by comment): updateCamera({ zoom: next });
+// ORIGINAL (preserved, not deleted — disabled by comment): return next;
+// ORIGINAL (preserved, not deleted — disabled by comment): })
+// ORIGINAL (preserved, not deleted — disabled by comment): }
+// ORIGINAL (preserved, not deleted — disabled by comment): >
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment): {
+// ORIGINAL (preserved, not deleted — disabled by comment): setZoom(1);
+// ORIGINAL (preserved, not deleted — disabled by comment): setPan({ x: 0, y: 0 });
+// ORIGINAL (preserved, not deleted — disabled by comment): layoutEdited.current = true;
+// ORIGINAL (preserved, not deleted — disabled by comment): setLayout((value) => ({
+// ORIGINAL (preserved, not deleted — disabled by comment): ...value,
+// ORIGINAL (preserved, not deleted — disabled by comment): positions: {
+// ORIGINAL (preserved, not deleted — disabled by comment): ...value.positions,
+// ORIGINAL (preserved, not deleted — disabled by comment): [mode]: positionsFor(mode),
+// ORIGINAL (preserved, not deleted — disabled by comment): },
+// ORIGINAL (preserved, not deleted — disabled by comment): cameras: {
+// ORIGINAL (preserved, not deleted — disabled by comment): ...value.cameras,
+// ORIGINAL (preserved, not deleted — disabled by comment): [mode]: { x: 0, y: 0, zoom: 1 },
+// ORIGINAL (preserved, not deleted — disabled by comment): },
+// ORIGINAL (preserved, not deleted — disabled by comment): }));
+// ORIGINAL (preserved, not deleted — disabled by comment): }}
+// ORIGINAL (preserved, not deleted — disabled by comment): >
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment): Active
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment): Idle
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment): Needs attention
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment): Lines show illustrative adapter relationships
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment): ADAPTER INSPECTOR
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment): {String(
+// ORIGINAL (preserved, not deleted — disabled by comment): agents.findIndex((agent) => agent.id === selected.id) + 1,
+// ORIGINAL (preserved, not deleted — disabled by comment): ).padStart(2, "0")}{" "}
+// ORIGINAL (preserved, not deleted — disabled by comment): / {String(agents.length).padStart(2, "0")}
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment): {selected.status}
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment): {selected.name}
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment): {selected.vendor} · {selected.kind.toUpperCase()} ·{" "}
+// ORIGINAL (preserved, not deleted — disabled by comment): {selected.role}
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment): MODEL / ADAPTER
+// ORIGINAL (preserved, not deleted — disabled by comment): {selected.model}
+// ORIGINAL (preserved, not deleted — disabled by comment): Configured for demonstration only
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment): DEMO TASKS
+// ORIGINAL (preserved, not deleted — disabled by comment): {String(selected.tasks).padStart(2, "0")}
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment): LATENCY SAMPLE
+// ORIGINAL (preserved, not deleted — disabled by comment): {selected.latency}
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment): CONNECTED TO
+// ORIGINAL (preserved, not deleted — disabled by comment): {String(selectedEdges.length).padStart(2, "0")}
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment): {selectedEdges.length ? (
+// ORIGINAL (preserved, not deleted — disabled by comment): selectedEdges.map((edge) => {
+// ORIGINAL (preserved, not deleted — disabled by comment): const otherId =
+// ORIGINAL (preserved, not deleted — disabled by comment): edge.source === selected.id ? edge.target : edge.source;
+// ORIGINAL (preserved, not deleted — disabled by comment): const other = agents.find((agent) => agent.id === otherId);
+// ORIGINAL (preserved, not deleted — disabled by comment): if (!other) return null;
+// ORIGINAL (preserved, not deleted — disabled by comment): return (
+// ORIGINAL (preserved, not deleted — disabled by comment):
setSelectedId(other.id)}
+// ORIGINAL (preserved, not deleted — disabled by comment): >
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment): {other.name}
+// ORIGINAL (preserved, not deleted — disabled by comment): {edge.label}
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment): );
+// ORIGINAL (preserved, not deleted — disabled by comment): })
+// ORIGINAL (preserved, not deleted — disabled by comment): ) : (
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment): No demo connections are shown for this adapter.
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment): )}
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment): GRAPH ENTITIES
+// ORIGINAL (preserved, not deleted — disabled by comment): {String(agents.length).padStart(2, "0")}
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment): {agents.map((agent) => (
+// ORIGINAL (preserved, not deleted — disabled by comment): setSelectedId(agent.id)}
+// ORIGINAL (preserved, not deleted — disabled by comment): >
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment): {agent.name}
+// ORIGINAL (preserved, not deleted — disabled by comment): {agent.kind}
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment): ))}
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment): {selected.status === "paused"
+// ORIGINAL (preserved, not deleted — disabled by comment): ? "Resume demo adapter"
+// ORIGINAL (preserved, not deleted — disabled by comment): : "Pause demo adapter"}
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment): Status changes are temporary and remain in this page session only.
+// ORIGINAL (preserved, not deleted — disabled by comment): No software is installed or contacted.
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment): Sample graph data only {" "}
+// ORIGINAL (preserved, not deleted — disabled by comment): setLive((value) => !value)}
+// ORIGINAL (preserved, not deleted — disabled by comment): aria-pressed={live}
+// ORIGINAL (preserved, not deleted — disabled by comment): >
+// ORIGINAL (preserved, not deleted — disabled by comment): Motion {live ? "on" : "off"}
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment): Decorative connection motion can be switched off.
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment):
+// ORIGINAL (preserved, not deleted — disabled by comment): );
+// ORIGINAL (preserved, not deleted — disabled by comment): }
diff --git a/dashboard/src/pages/Automation.css b/dashboard/src/pages/Automation.css
new file mode 100644
index 0000000..e8d35fa
--- /dev/null
+++ b/dashboard/src/pages/Automation.css
@@ -0,0 +1,739 @@
+.automation-layout {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) 276px;
+ gap: 14px;
+ align-items: stretch;
+ margin-bottom: 16px;
+}
+.automation-workspace,
+.automation-inspector {
+ min-width: 0;
+ border: 1px solid var(--border);
+ border-radius: 10px;
+ background: linear-gradient(
+ 145deg,
+ rgba(15, 25, 32, 0.96),
+ rgba(10, 17, 23, 0.96)
+ );
+ overflow: hidden;
+}
+.automation-toolbar {
+ height: 65px;
+ padding: 0 17px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ border-bottom: 1px solid var(--border);
+}
+.automation-workflow-label,
+.automation-toolbar-actions,
+.automation-inspector-head,
+.automation-section-head,
+.automation-runbar,
+.automation-path-item,
+.preview-result,
+.inspector-type,
+.inspector-footer {
+ display: flex;
+ align-items: center;
+}
+.automation-workflow-label {
+ gap: 11px;
+}
+.automation-flow-icon {
+ width: 31px;
+ height: 31px;
+ display: grid;
+ place-items: center;
+ border: 1px solid #285343;
+ border-radius: 8px;
+ color: #78d9ae;
+ background: #12271f;
+}
+.automation-workflow-label span:last-child,
+.inspector-type > div,
+.automation-path-item > span:nth-child(2),
+.preview-result > div:last-child {
+ display: grid;
+ gap: 4px;
+}
+.automation-workflow-label strong,
+.automation-path-item strong,
+.preview-result strong {
+ font-size: 11px;
+ font-weight: 600;
+}
+.automation-name {
+ width: 190px;
+ border: 0;
+ padding: 0;
+ outline: 0;
+ background: transparent;
+ color: var(--text);
+ font: 600 11px inherit;
+}
+.automation-name:focus-visible {
+ outline: 1px solid #6bc8a1;
+ outline-offset: 3px;
+}
+.automation-palette {
+ display: flex;
+ gap: 6px;
+ overflow-x: auto;
+ padding: 8px 10px;
+ border-bottom: 1px solid var(--border);
+ scrollbar-width: thin;
+}
+.automation-palette button {
+ display: flex;
+ flex: 0 0 auto;
+ align-items: center;
+ gap: 5px;
+ border: 1px solid var(--border-light);
+ border-radius: 5px;
+ padding: 5px 7px;
+ background: #111b23;
+ color: #9aabb2;
+ font-size: 9px;
+ text-transform: capitalize;
+ cursor: pointer;
+}
+.automation-palette button:hover {
+ color: #bcebd7;
+ border-color: #3b6655;
+}
+.automation-reactflow {
+ height: 450px;
+ background:
+ radial-gradient(ellipse at 50% 42%, rgba(24, 65, 60, 0.14), transparent 61%),
+ #0a1117;
+}
+.automation-reactflow .react-flow__controls,
+.automation-reactflow .react-flow__minimap {
+ overflow: hidden;
+ border: 1px solid #2a3942;
+ border-radius: 7px;
+ background: #10191fdd;
+}
+.automation-reactflow .react-flow__controls-button {
+ background: #10191f;
+ fill: #91a1a8;
+ border-color: #2a3942;
+}
+.automation-reactflow .react-flow__node-default {
+ width: 188px;
+ border: 1px solid #35504e;
+ border-radius: 8px;
+ padding: 14px;
+ color: var(--text);
+ background: linear-gradient(150deg, #17232b, #101920);
+ box-shadow: 0 8px 24px #0005;
+}
+.automation-reactflow .react-flow__node-workflow {
+ width: 190px;
+ border: 1px solid #35504e;
+ border-radius: 8px;
+ color: var(--text);
+ background: linear-gradient(150deg, #17232b, #101920);
+ box-shadow: 0 8px 24px #0005;
+}
+.flow-card {
+ display: grid;
+ grid-template-columns: 18px 1fr;
+ gap: 5px 8px;
+ align-items: center;
+ padding: 12px;
+}
+.flow-card > span {
+ grid-row: span 2;
+ color: #7fd1ad;
+}
+.flow-card strong {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-size: 10px;
+}
+.flow-card small {
+ color: #82939a;
+ font-size: 8px;
+ text-transform: uppercase;
+}
+.automation-reactflow .react-flow__node-workflow.selected {
+ border-color: #58b38b;
+ box-shadow:
+ 0 0 0 1px #58b38b55,
+ 0 9px 26px #0008;
+}
+.automation-reactflow .react-flow__node-default.selected {
+ border-color: #58b38b;
+ box-shadow:
+ 0 0 0 1px #58b38b55,
+ 0 9px 26px #0008;
+}
+.automation-reactflow .react-flow__handle {
+ width: 8px;
+ height: 8px;
+ border: 1px solid #12231d;
+ background: #6dd7a8;
+}
+.automation-reactflow .react-flow__edge-path {
+ stroke: #68b896;
+ stroke-width: 1.8;
+}
+.automation-reactflow .react-flow__edge-text {
+ fill: #9bb0aa;
+ font-size: 10px;
+}
+.automation-issues {
+ color: #e4b56b;
+ font-size: 10px;
+ line-height: 1.5;
+}
+.automation-issues p {
+ margin: 8px 0;
+}
+.automation-workflow-label small,
+.automation-path-item small,
+.preview-result small {
+ font-size: 9px;
+ color: var(--muted);
+}
+.automation-toolbar-actions {
+ gap: 8px;
+}
+.demo-pill,
+.availability-tag {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 5px 7px;
+ border: 1px solid #2b473e;
+ border-radius: 5px;
+ color: #8ed7b6;
+ background: #12221d;
+ font: 500 8px var(--mono);
+ letter-spacing: 0.5px;
+}
+.demo-pill > span,
+.run-indicator > span {
+ width: 5px;
+ height: 5px;
+ border-radius: 50%;
+ background: #65d5a4;
+ box-shadow: 0 0 8px #65d5a488;
+}
+.automation-tool-button {
+ width: 30px;
+ height: 30px;
+ display: grid;
+ place-items: center;
+ border: 1px solid var(--border-light);
+ border-radius: 6px;
+ background: #111b23;
+ color: #8d9da8;
+}
+.automation-tool-button:hover,
+.automation-canvas-controls button:hover {
+ color: #bcebd7;
+ border-color: #3b6655;
+}
+.automation-stage {
+ position: relative;
+ height: 430px;
+ overflow: hidden;
+ background:
+ radial-gradient(ellipse at 50% 42%, rgba(24, 65, 60, 0.14), transparent 61%),
+ #0a1117;
+ touch-action: none;
+ outline: none;
+}
+.automation-stage:focus-visible {
+ box-shadow: inset 0 0 0 2px #66b9eb;
+}
+.automation-grid {
+ position: absolute;
+ inset: 0;
+ opacity: 0.54;
+ background-image: radial-gradient(#33424a 1px, transparent 1px);
+ background-size: 20px 20px;
+ pointer-events: none;
+}
+.automation-world {
+ position: absolute;
+ left: 0;
+ top: 0;
+ transform-origin: top left;
+ will-change: transform;
+}
+.automation-edges {
+ position: absolute;
+ inset: 0;
+ overflow: visible;
+ color: #568b78;
+ pointer-events: none;
+}
+.automation-edge path {
+ fill: none;
+ stroke: currentColor;
+ stroke-width: 1.5;
+ opacity: 0.72;
+}
+.automation-edge.is-branch path {
+ stroke: #b79963;
+ stroke-dasharray: 4 4;
+ color: #c59e5e;
+}
+.automation-edge text {
+ fill: #82949a;
+ font: 9px var(--mono);
+ paint-order: stroke;
+ stroke: #0a1117;
+ stroke-width: 5px;
+ stroke-linejoin: round;
+}
+.automation-step {
+ position: absolute;
+ z-index: 1;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 12px;
+ text-align: left;
+ border: 1px solid #2c3d46;
+ border-radius: 8px;
+ color: var(--text);
+ background: linear-gradient(150deg, #17232b, #101920);
+ box-shadow: 0 8px 24px #0005;
+ cursor: grab;
+ touch-action: none;
+}
+.automation-step:active {
+ cursor: grabbing;
+}
+.automation-step:hover {
+ border-color: #537566;
+ box-shadow:
+ 0 8px 28px #0007,
+ 0 0 0 1px #36594944;
+}
+.automation-step:focus-visible {
+ outline: 2px solid #6bc8a1;
+ outline-offset: 2px;
+}
+.automation-step.is-selected {
+ border-color: #58b38b;
+ box-shadow:
+ 0 0 0 1px #58b38b55,
+ 0 9px 26px #0008;
+}
+.automation-step-icon,
+.inspector-icon,
+.path-icon {
+ width: 32px;
+ height: 32px;
+ flex: 0 0 32px;
+ display: grid;
+ place-items: center;
+ border-radius: 7px;
+}
+.step-trigger.automation-step-icon,
+.step-trigger.inspector-icon,
+.step-trigger.path-icon {
+ background: #122f2b;
+ color: #66d8b0;
+}
+.step-action.automation-step-icon,
+.step-action.inspector-icon,
+.step-action.path-icon {
+ background: #1d2d3d;
+ color: #70b7eb;
+}
+.step-condition.automation-step-icon,
+.step-condition.inspector-icon,
+.step-condition.path-icon {
+ background: #322b1d;
+ color: #e4b56b;
+}
+.step-agent.automation-step-icon,
+.step-agent.inspector-icon,
+.step-agent.path-icon {
+ background: #29223d;
+ color: #baa3f1;
+}
+.automation-step-copy {
+ display: grid;
+ gap: 5px;
+ min-width: 0;
+}
+.automation-step-copy strong {
+ font-size: 10px;
+ font-weight: 600;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.automation-step-copy small {
+ font-size: 9px;
+ color: #80909a;
+ white-space: nowrap;
+}
+.automation-step-state {
+ margin-left: auto;
+ color: #61737b;
+}
+.status-running .automation-step-state {
+ color: #e5b56c;
+}
+.status-complete .automation-step-state {
+ color: #6dd7a8;
+}
+.automation-pulse {
+ display: block;
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background: #edbd6d;
+ box-shadow: 0 0 0 4px #edbd6d24;
+ animation: automation-pulse 1s ease-in-out infinite;
+}
+@keyframes automation-pulse {
+ 50% {
+ box-shadow: 0 0 0 7px #edbd6d08;
+ }
+}
+.automation-canvas-controls {
+ position: absolute;
+ right: 12px;
+ bottom: 12px;
+ z-index: 2;
+ display: flex;
+ align-items: center;
+ gap: 7px;
+ padding: 5px;
+ border: 1px solid #2a3942;
+ border-radius: 7px;
+ background: #10191fdd;
+ backdrop-filter: blur(10px);
+}
+.automation-canvas-controls button {
+ width: 25px;
+ height: 25px;
+ display: grid;
+ place-items: center;
+ border: 0;
+ border-radius: 4px;
+ background: transparent;
+ color: #91a1a8;
+}
+.automation-canvas-controls span {
+ min-width: 37px;
+ text-align: center;
+ color: #9caab0;
+ font: 9px var(--mono);
+}
+.automation-canvas-hint {
+ position: absolute;
+ left: 12px;
+ bottom: 17px;
+ display: flex;
+ align-items: center;
+ gap: 5px;
+ color: #70818a;
+ font-size: 9px;
+ pointer-events: none;
+}
+.automation-runbar {
+ height: 37px;
+ gap: 8px;
+ padding: 0 14px;
+ border-top: 1px solid var(--border);
+ font-size: 9px;
+ color: #8a999f;
+}
+.run-indicator {
+ display: grid;
+ place-items: center;
+ width: 9px;
+ height: 9px;
+ border-radius: 50%;
+ border: 1px solid #47705f;
+}
+.run-indicator > span {
+ width: 3px;
+ height: 3px;
+}
+.run-indicator.is-running > span {
+ animation: automation-pulse 1s infinite;
+}
+.automation-runbar small {
+ margin-left: auto;
+ color: #667780;
+ font: 8px var(--mono);
+ letter-spacing: 0.6px;
+}
+.automation-inspector {
+ padding: 17px 15px;
+ display: flex;
+ flex-direction: column;
+}
+.automation-inspector-head,
+.automation-section-head {
+ justify-content: space-between;
+}
+.automation-inspector-head h2,
+.automation-section-head h2 {
+ margin: 6px 0 0;
+ font-size: 14px;
+ letter-spacing: -0.25px;
+}
+.inspector-close {
+ width: 25px;
+ height: 25px;
+}
+.inspector-type {
+ gap: 9px;
+ padding: 17px 0 16px;
+ margin: 12px 0 14px;
+ border-bottom: 1px solid var(--border);
+}
+.inspector-type strong {
+ text-transform: capitalize;
+ font-size: 10px;
+}
+.inspector-type small {
+ font-size: 9px;
+ color: var(--muted);
+}
+.inspector-status {
+ margin-left: auto;
+ padding: 4px 6px;
+ border: 1px solid #293940;
+ border-radius: 4px;
+ color: #87949a;
+ font: 8px var(--mono);
+ text-transform: uppercase;
+}
+.inspector-status.status-complete {
+ color: #77d8ab;
+ border-color: #315d4c;
+}
+.inspector-status.status-running {
+ color: #e7c17e;
+ border-color: #66502c;
+}
+.inspector-field {
+ display: grid;
+ gap: 7px;
+ margin: 0 0 14px;
+ color: #82929b;
+ font-size: 9px;
+}
+.inspector-field input,
+.inspector-field textarea,
+.inspector-field select {
+ width: 100%;
+ padding: 9px;
+ border: 1px solid #293840;
+ border-radius: 6px;
+ background: #0b1319;
+ color: #dce5eb;
+ font-size: 10px;
+ resize: vertical;
+}
+.threshold-input {
+ position: relative;
+}
+.threshold-input span {
+ position: absolute;
+ right: 10px;
+ top: 9px;
+ color: #73828a;
+ font: 10px var(--mono);
+}
+.inspector-note {
+ display: flex;
+ gap: 8px;
+ padding: 10px;
+ border: 1px solid #2b3c35;
+ border-radius: 6px;
+ background: #11201b;
+ color: #7bb596;
+ font-size: 9px;
+ line-height: 1.5;
+}
+.inspector-note svg {
+ flex: 0 0 auto;
+}
+.inspector-footer {
+ justify-content: space-between;
+ gap: 8px;
+ margin-top: auto;
+ padding-top: 15px;
+ color: #65757e;
+ font-size: 8px;
+}
+.inspector-footer span {
+ display: flex;
+ gap: 4px;
+ align-items: center;
+}
+.text-button {
+ border: 0;
+ background: none;
+ color: #83c9a9;
+ font-size: 8px;
+}
+.automation-lower-grid {
+ display: grid;
+ grid-template-columns: minmax(0, 1.45fr) minmax(250px, 0.75fr);
+ gap: 14px;
+}
+.automation-steps-card,
+.automation-preview-card {
+ padding: 17px;
+}
+.automation-section-head {
+ margin-bottom: 14px;
+}
+.automation-section-head h2 {
+ font-size: 13px;
+}
+.automation-path-list {
+ display: grid;
+}
+.automation-path-item {
+ width: 100%;
+ gap: 10px;
+ padding: 9px 0;
+ border: 0;
+ border-top: 1px solid #1b2931;
+ background: transparent;
+ color: var(--text);
+ text-align: left;
+}
+.automation-path-item:hover strong {
+ color: #8ce0b6;
+}
+.path-icon {
+ width: 27px;
+ height: 27px;
+ flex-basis: 27px;
+}
+.automation-path-item > span:nth-child(2) {
+ min-width: 0;
+}
+.automation-path-item small {
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.path-status {
+ margin-left: auto;
+ color: #71828b;
+ font-size: 8px;
+}
+.path-status.status-complete {
+ color: #71d3a2;
+}
+.path-status.status-running {
+ color: #e4bb75;
+}
+.preview-symbol {
+ color: #7d949b;
+}
+.preview-result {
+ gap: 12px;
+ padding: 12px;
+ border: 1px solid #243b36;
+ border-radius: 7px;
+ background: linear-gradient(115deg, #142820, #101b1d);
+}
+.preview-score {
+ font: 500 25px var(--mono);
+ letter-spacing: -1px;
+ color: #78d7ab;
+}
+.preview-score span {
+ font-size: 12px;
+}
+.preview-meta {
+ display: flex;
+ justify-content: space-between;
+ margin-top: 13px;
+ color: #7f8f96;
+ font-size: 9px;
+}
+.preview-meta strong {
+ color: #bbc5c9;
+ font-size: 9px;
+ font-weight: 500;
+}
+.preview-disclaimer {
+ margin: 14px 0 0;
+ padding-top: 10px;
+ border-top: 1px solid #1c2b32;
+ color: #73838a;
+ font-size: 9px;
+ line-height: 1.5;
+}
+@media (max-width: 1100px) {
+ .automation-layout {
+ grid-template-columns: minmax(0, 1fr);
+ }
+ .automation-inspector {
+ min-height: 0;
+ }
+ .inspector-footer {
+ margin-top: 12px;
+ }
+ .automation-stage {
+ height: 400px;
+ }
+}
+@media (max-width: 680px) {
+ .automation-toolbar {
+ padding: 0 10px;
+ }
+ .automation-workflow-label {
+ gap: 7px;
+ }
+ .automation-workflow-label strong {
+ font-size: 10px;
+ }
+ .demo-pill {
+ font-size: 7px;
+ }
+ .automation-stage {
+ height: 360px;
+ }
+ .automation-canvas-hint {
+ max-width: 58%;
+ font-size: 8px;
+ }
+ .automation-lower-grid {
+ grid-template-columns: 1fr;
+ }
+ .automation-runbar {
+ font-size: 8px;
+ }
+ .automation-inspector {
+ padding: 13px;
+ }
+ .automation-path-item {
+ gap: 7px;
+ }
+ .path-status {
+ font-size: 7px;
+ }
+}
+@media (prefers-reduced-motion: reduce) {
+ .automation-pulse,
+ .run-indicator.is-running > span {
+ animation: none;
+ }
+ .automation-step,
+ .automation-tool-button {
+ transition: none;
+ }
+}
diff --git a/dashboard/src/pages/Automation.tsx b/dashboard/src/pages/Automation.tsx
new file mode 100644
index 0000000..e051dc3
--- /dev/null
+++ b/dashboard/src/pages/Automation.tsx
@@ -0,0 +1,1312 @@
+import {
+ addEdge,
+ Background,
+ type Connection,
+ Controls,
+ type Edge,
+ Handle,
+ MiniMap,
+ type Node,
+ type NodeProps,
+ Position,
+ ReactFlow,
+ ReactFlowProvider,
+ useEdgesState,
+ useNodesState,
+ useReactFlow,
+} from "@xyflow/react";
+import "@xyflow/react/dist/style.css";
+import {
+ Activity,
+ Bot,
+ CirclePlay,
+ Clock3,
+ GitBranch,
+ Plus,
+ RotateCcw,
+ Save,
+ Sparkles,
+ Webhook,
+} from "lucide-react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import * as UI from "../components/ui";
+import {
+ assertWorkflowImportSize,
+ isConnectionValid,
+ loadWorkflow,
+ parseWorkflowImport,
+ recordWorkflowChange,
+ redoWorkflow,
+ saveWorkflow,
+ starterWorkflow,
+ undoWorkflow,
+ type Workflow,
+ type WorkflowHistory,
+ type WorkflowKind,
+ type WorkflowNode,
+ workflowIssues,
+} from "../data/workflows";
+import "./Automation.css";
+
+type FlowData = {
+ title: string;
+ detail: string;
+ kind: WorkflowKind;
+ config: WorkflowNode["config"];
+};
+const icons: Record = {
+ trigger: Webhook,
+ schedule: Clock3,
+ agent: Bot,
+ condition: GitBranch,
+ delay: Clock3,
+ transform: Activity,
+ notify: Activity,
+ success: CirclePlay,
+ failure: Activity,
+};
+const palette: WorkflowKind[] = [
+ "trigger",
+ "schedule",
+ "agent",
+ "condition",
+ "delay",
+ "transform",
+ "notify",
+ "success",
+ "failure",
+];
+function WorkflowCard({ data }: NodeProps>) {
+ const Icon = icons[data.kind];
+ return (
+ <>
+
+
+
+
+
+ {data.title}
+ {data.kind}
+
+
+ >
+ );
+}
+const nodeTypes = { workflow: WorkflowCard };
+const asNodes = (flow: Workflow): Node[] =>
+ flow.nodes.map((n) => ({
+ id: n.id,
+ type: "workflow",
+ position: { x: n.x, y: n.y },
+ data: { title: n.title, detail: n.detail, kind: n.kind, config: n.config },
+ }));
+const asEdges = (flow: Workflow): Edge[] =>
+ flow.edges.map((e) => ({
+ id: e.id,
+ source: e.source,
+ target: e.target,
+ label: e.label,
+ type: "smoothstep",
+ }));
+
+function Editor() {
+ const initial = useMemo(loadWorkflow, []);
+ const [nodes, setNodes, onNodesChange] = useNodesState>(
+ asNodes(starterWorkflow),
+ );
+ const [edges, setEdges, onEdgesChange] = useEdgesState(
+ asEdges(starterWorkflow),
+ );
+ const [selectedId, setSelectedId] = useState(
+ starterWorkflow.nodes[1]?.id ?? "",
+ );
+ const [name, setName] = useState(starterWorkflow.name);
+ const [hydrated, setHydrated] = useState(false);
+ const [message, setMessage] = useState(
+ "Changes are stored locally in this browser.",
+ );
+ const [running, setRunning] = useState(false);
+ const [runs, setRuns] = useState(0);
+ const [history, setHistory] = useState({
+ past: [],
+ future: [],
+ });
+ const { screenToFlowPosition } = useReactFlow();
+ const fileRef = useRef(null);
+ const selected = nodes.find((n) => n.id === selectedId);
+ const model = useMemo(
+ () => ({
+ version: 1,
+ name,
+ nodes: nodes.map((n) => ({
+ id: n.id,
+ kind: n.data.kind,
+ title: n.data.title,
+ detail: n.data.detail,
+ x: n.position.x,
+ y: n.position.y,
+ config: n.data.config,
+ })),
+ edges: edges.map((e) => ({
+ id: e.id,
+ source: e.source,
+ target: e.target,
+ ...(typeof e.label === "string" ? { label: e.label } : {}),
+ })),
+ }),
+ [name, nodes, edges],
+ );
+ const latestModel = useRef(model);
+ const hydratedRef = useRef(hydrated);
+ const issues = useMemo(() => workflowIssues(model), [model]);
+ latestModel.current = model;
+ hydratedRef.current = hydrated;
+ useEffect(() => {
+ let active = true;
+ initial
+ .then((flow) => {
+ if (!active) return;
+ setName(flow.name);
+ setNodes(asNodes(flow));
+ setEdges(asEdges(flow));
+ setSelectedId(flow.nodes[1]?.id ?? flow.nodes[0]?.id ?? "");
+ setHydrated(true);
+ })
+ .catch(() => {
+ if (active)
+ setMessage(
+ "Saved workflows could not be loaded; demo remains available.",
+ );
+ });
+ return () => {
+ active = false;
+ };
+ }, [initial, setNodes, setEdges]);
+ useEffect(() => {
+ if (!hydrated) return;
+ const timer = window.setTimeout(() => {
+ void saveWorkflow(model).catch(() =>
+ setMessage("Could not save workflow to this browser."),
+ );
+ }, 350);
+ return () => window.clearTimeout(timer);
+ }, [hydrated, model]);
+ useEffect(
+ () => () => {
+ if (hydratedRef.current)
+ void saveWorkflow(latestModel.current).catch(() => undefined);
+ },
+ [],
+ );
+ const snapshot = () =>
+ setHistory((items) => recordWorkflowChange(items, model));
+ const connect = useCallback(
+ (connection: Connection) => {
+ setHistory((items) => recordWorkflowChange(items, model));
+ setEdges((current) =>
+ addEdge(
+ { ...connection, id: crypto.randomUUID(), type: "smoothstep" },
+ current,
+ ),
+ );
+ },
+ [model, setEdges],
+ );
+ const update = (patch: Partial) => {
+ snapshot();
+ setNodes((current) =>
+ current.map((n) =>
+ n.id === selectedId ? { ...n, data: { ...n.data, ...patch } } : n,
+ ),
+ );
+ };
+ const addNode = (kind: WorkflowKind, position?: { x: number; y: number }) => {
+ snapshot();
+ const id = kind + "-" + crypto.randomUUID().slice(0, 6);
+ const title = kind[0].toUpperCase() + kind.slice(1);
+ setNodes((current) => [
+ ...current,
+ {
+ id,
+ type: "workflow",
+ position: position ?? {
+ x: 160 + (current.length % 4) * 220,
+ y: 120 + Math.floor(current.length / 4) * 140,
+ },
+ data: { title, detail: title + " demo step.", kind, config: {} },
+ },
+ ]);
+ setSelectedId(id);
+ };
+ const restore = (flow: Workflow) => {
+ setName(flow.name);
+ setNodes(asNodes(flow));
+ setEdges(asEdges(flow));
+ setSelectedId(flow.nodes[0]?.id ?? "");
+ };
+ const undo = () => {
+ const result = undoWorkflow(history, model);
+ if (!result) return;
+ setHistory(result.history);
+ restore(result.workflow);
+ };
+ const redo = () => {
+ const result = redoWorkflow(history, model);
+ if (!result) return;
+ setHistory(result.history);
+ restore(result.workflow);
+ };
+ const exportFlow = () => {
+ const url = URL.createObjectURL(
+ new Blob([JSON.stringify(model, null, 2)], { type: "application/json" }),
+ );
+ const a = document.createElement("a");
+ a.href = url;
+ a.download =
+ (name.toLowerCase().replace(/[^a-z0-9]+/g, "-") || "workflow") + ".json";
+ a.click();
+ URL.revokeObjectURL(url);
+ };
+ const importFlow = async (file?: File) => {
+ if (!file) return;
+ try {
+ assertWorkflowImportSize(file.size);
+ const flow = parseWorkflowImport(await file.text());
+ snapshot();
+ setName(flow.name);
+ setNodes(asNodes(flow));
+ setEdges(asEdges(flow));
+ setSelectedId(flow.nodes[0]?.id ?? "");
+ setMessage("Workflow imported and validated.");
+ } catch (error) {
+ setMessage(
+ error instanceof Error ? error.message : "Unable to import workflow.",
+ );
+ } finally {
+ if (fileRef.current) fileRef.current.value = "";
+ }
+ };
+ const run = () => {
+ if (issues.length || running) return;
+ setRunning(true);
+ setRuns((n) => n + 1);
+ setMessage("Simulated preview · no jobs dispatched");
+ window.setTimeout(() => {
+ setRunning(false);
+ setMessage("Demo run completed · sample result only");
+ }, 1000);
+ };
+ const saveNow = () => {
+ void saveWorkflow(model)
+ .then(() => setMessage("Workflow saved in this browser."))
+ .catch(() => setMessage("Could not save workflow to this browser."));
+ };
+ const deleteSelected = () => {
+ if (!selected) return;
+ snapshot();
+ setNodes((current) => current.filter((n) => n.id !== selectedId));
+ setEdges((current) =>
+ current.filter((e) => e.source !== selectedId && e.target !== selectedId),
+ );
+ setSelectedId("");
+ };
+ const duplicateSelected = () => {
+ if (!selected) return;
+ snapshot();
+ const id = selected.id + "-copy-" + crypto.randomUUID().slice(0, 5);
+ setNodes((current) => [
+ ...current,
+ {
+ ...selected,
+ id,
+ selected: true,
+ position: { x: selected.position.x + 36, y: selected.position.y + 36 },
+ data: { ...selected.data, title: selected.data.title + " copy" },
+ },
+ ]);
+ setSelectedId(id);
+ };
+ const onDrop = (event: React.DragEvent) => {
+ event.preventDefault();
+ const kind = event.dataTransfer.getData(
+ "application/opencluster-workflow-kind",
+ ) as WorkflowKind;
+ if (palette.includes(kind))
+ addNode(
+ kind,
+ screenToFlowPosition({ x: event.clientX, y: event.clientY }),
+ );
+ };
+ const beforeDelete = async ({
+ nodes: removedNodes,
+ edges: removedEdges,
+ }: {
+ nodes: Node[];
+ edges: Edge[];
+ }) => {
+ if (removedNodes.length || removedEdges.length) snapshot();
+ if (removedNodes.some((node) => node.id === selectedId)) setSelectedId("");
+ return true;
+ };
+
+ return (
+ <>
+ 0}
+ >
+
+ {running ? "Running demo…" : "Run demo"}
+
+ }
+ />
+
+
+
+
+ {palette.map((kind) => {
+ const Icon = icons[kind];
+ return (
+
+ event.dataTransfer.setData(
+ "application/opencluster-workflow-kind",
+ kind,
+ )
+ }
+ onClick={() => addNode(kind)}
+ >
+
+
+ {kind}
+
+ );
+ })}
+
+ event.preventDefault()}
+ onDrop={onDrop}
+ >
+
+ isConnectionValid(model, connection.source, connection.target)
+ }
+ onBeforeDelete={beforeDelete}
+ onNodeClick={(_, node) => setSelectedId(node.id)}
+ onPaneClick={() => setSelectedId("")}
+ fitView
+ deleteKeyCode={["Backspace", "Delete"]}
+ defaultEdgeOptions={{ type: "smoothstep" }}
+ >
+
+
+
+
+
+
+
+
+
+ {message}
+
+ {runs ? "RUN #" + String(runs).padStart(2, "0") : "NO RUNS YET"}
+
+
+
+
+
+
+ WORKFLOW INSPECTOR
+
{selected ? "Configure node" : "Workflow checks"}
+
+
+ {selected ? (
+ <>
+
+ Node name
+ update({ title: e.target.value })}
+ />
+
+
+ Description
+
+ {selected.data.kind === "condition" && (
+
+ Threshold (%)
+
+ update({
+ config: {
+ ...selected.data.config,
+ threshold: Number(e.target.value),
+ },
+ })
+ }
+ />
+
+ )}
+ {selected.data.kind === "schedule" && (
+
+ Interval (minutes)
+
+ update({
+ config: {
+ ...selected.data.config,
+ intervalMinutes: Number(e.target.value),
+ },
+ })
+ }
+ />
+
+ )}
+
+
+
+ {selected.data.kind} configuration is demo-only. No external
+ service or command is called.
+
+
+
+ Delete selected node
+
+ >
+ ) : (
+
+ {issues.length ? (
+ issues.map((issue) =>
{issue}
)
+ ) : (
+
Workflow structure is valid for a demo run.
+ )}
+
+ )}
+
+
+ Saved in browser storage
+
+ {
+ setName(starterWorkflow.name);
+ setNodes(asNodes(starterWorkflow));
+ setEdges(asEdges(starterWorkflow));
+ setSelectedId("inspect");
+ }}
+ >
+ Restore sample
+
+
+
+
+
+
+
+ RUN PREVIEW
+
Workflow validation
+
+
SIMULATED
+
+ {issues.length ? (
+
+ {issues.map((issue) => (
+
{issue}
+ ))}
+
+ ) : (
+
+ Ready for local preview · {nodes.length} nodes · {edges.length}{" "}
+ connections · 0 actions dispatched.
+
+ )}
+
+ >
+ );
+}
+
+export function AutomationPage() {
+ return (
+
+
+
+ );
+}
+
+// ORIGINAL (preserved, not deleted — disabled by comment): prior custom AutomationPage implementation
+// import {
+// Activity,
+// Bot,
+// Check,
+// CirclePlay,
+// Clock3,
+// GitBranch,
+// Grip,
+// Minus,
+// MousePointer2,
+// Plus,
+// RotateCcw,
+// Search,
+// Settings2,
+// Sparkles,
+// Webhook,
+// X,
+// } from "lucide-react";
+// import type { PointerEvent as ReactPointerEvent, WheelEvent } from "react";
+// import { useState } from "react";
+// import * as UI from "../components/ui";
+// import "./Automation.css";
+//
+// type StepKind = "trigger" | "action" | "condition" | "agent";
+// type StepStatus = "idle" | "running" | "complete" | "skipped";
+// type WorkflowStep = {
+// id: string;
+// title: string;
+// subtitle: string;
+// kind: StepKind;
+// x: number;
+// y: number;
+// status: StepStatus;
+// detail: string;
+// };
+//
+// const starterSteps: WorkflowStep[] = [
+// {
+// id: "trigger",
+// title: "Schedule trigger",
+// subtitle: "Every 15 minutes",
+// kind: "trigger",
+// x: 54,
+// y: 210,
+// status: "idle",
+// detail: "Starts a sample health check on a fixed interval.",
+// },
+// {
+// id: "inspect",
+// title: "Inspect cluster",
+// subtitle: "Read node health",
+// kind: "action",
+// x: 300,
+// y: 210,
+// status: "idle",
+// detail: "Collects mock health values from the selected demo cluster.",
+// },
+// {
+// id: "threshold",
+// title: "Health below 80%?",
+// subtitle: "Condition · 80%",
+// kind: "condition",
+// x: 548,
+// y: 210,
+// status: "idle",
+// detail: "Routes the sample run based on a configurable threshold.",
+// },
+// {
+// id: "notify",
+// title: "Notify operator",
+// subtitle: "Create demo alert",
+// kind: "action",
+// x: 806,
+// y: 102,
+// status: "idle",
+// detail: "Adds a preview notification to this simulated workflow run.",
+// },
+// {
+// id: "agent",
+// title: "Summarize status",
+// subtitle: "OpenCluster assistant",
+// kind: "agent",
+// x: 806,
+// y: 326,
+// status: "idle",
+// detail: "Creates a local sample summary. No AI service is contacted.",
+// },
+// ];
+//
+// const kindIcon = {
+// trigger: Webhook,
+// action: Activity,
+// condition: GitBranch,
+// agent: Bot,
+// };
+//
+// const edges = [
+// { from: "trigger", to: "inspect", label: "on schedule" },
+// { from: "inspect", to: "threshold", label: "health sample" },
+// { from: "threshold", to: "notify", label: "below threshold", branch: true },
+// { from: "threshold", to: "agent", label: "healthy", branch: false },
+// ];
+//
+// const cardWidth = 188;
+// const cardHeight = 92;
+// const stageWidth = 1060;
+// const stageHeight = 620;
+//
+// function StepCard({
+// step,
+// selected,
+// onSelect,
+// onDragStart,
+// onMove,
+// }: {
+// step: WorkflowStep;
+// selected: boolean;
+// onSelect: () => void;
+// onDragStart: (event: ReactPointerEvent) => void;
+// onMove: (x: number, y: number) => void;
+// }) {
+// const Icon = kindIcon[step.kind];
+// return (
+// {
+// const delta = event.shiftKey ? 20 : 8;
+// const movement = {
+// ArrowUp: { x: 0, y: -delta },
+// ArrowDown: { x: 0, y: delta },
+// ArrowLeft: { x: -delta, y: 0 },
+// ArrowRight: { x: delta, y: 0 },
+// }[event.key as "ArrowUp" | "ArrowDown" | "ArrowLeft" | "ArrowRight"];
+// if (movement) {
+// event.preventDefault();
+// onMove(movement.x, movement.y);
+// }
+// }}
+// aria-pressed={selected}
+// aria-label={`${step.title}, ${step.kind} step, ${step.status}`}
+// >
+//
+//
+//
+//
+// {step.title}
+// {step.subtitle}
+//
+//
+// {step.status === "complete" ? (
+//
+// ) : step.status === "running" ? (
+//
+// ) : (
+//
+// )}
+//
+//
+// );
+// }
+//
+// export function AutomationPage() {
+// const [steps, setSteps] = useState(starterSteps);
+// const [selectedId, setSelectedId] = useState("inspect");
+// const [pan, setPan] = useState({ x: 22, y: 20 });
+// const [zoom, setZoom] = useState(0.92);
+// const [isRunning, setIsRunning] = useState(false);
+// const [runMessage, setRunMessage] = useState("Ready for a simulated run");
+// const [runCount, setRunCount] = useState(0);
+// const [dragState, setDragState] = useState<
+// | {
+// type: "pan";
+// pointerX: number;
+// pointerY: number;
+// originX: number;
+// originY: number;
+// }
+// | {
+// type: "step";
+// id: string;
+// pointerX: number;
+// pointerY: number;
+// originX: number;
+// originY: number;
+// }
+// | null
+// >(null);
+// const [inspectorOpen, setInspectorOpen] = useState(true);
+// const selected = steps.find((step) => step.id === selectedId) ?? steps[0];
+//
+// const resetWorkflow = () => {
+// setSteps(starterSteps);
+// setRunMessage("Workflow reset to its sample state");
+// setIsRunning(false);
+// };
+//
+// const runWorkflow = () => {
+// if (isRunning) return;
+// setIsRunning(true);
+// setRunCount((count) => count + 1);
+// const sequence = ["trigger", "inspect", "threshold", "notify", "agent"];
+// setSteps((current) => current.map((step) => ({ ...step, status: "idle" })));
+// setRunMessage("Simulated run started · no jobs are dispatched");
+// sequence.forEach((id, index) => {
+// window.setTimeout(() => {
+// setSteps((current) =>
+// current.map((step) => ({
+// ...step,
+// status:
+// step.id === id
+// ? "running"
+// : sequence.indexOf(step.id) < index
+// ? "complete"
+// : step.status,
+// })),
+// );
+// }, 380 * index);
+// });
+// window.setTimeout(() => {
+// setSteps((current) =>
+// current.map((step) => ({ ...step, status: "complete" })),
+// );
+// setIsRunning(false);
+// setRunMessage("Demo run completed · sample result only");
+// }, 380 * sequence.length);
+// };
+//
+// const startPan = (event: ReactPointerEvent) => {
+// if ((event.target as HTMLElement).closest(".automation-step")) return;
+// event.currentTarget.setPointerCapture(event.pointerId);
+// setDragState({
+// type: "pan",
+// pointerX: event.clientX,
+// pointerY: event.clientY,
+// originX: pan.x,
+// originY: pan.y,
+// });
+// };
+//
+// const startStepDrag = (
+// step: WorkflowStep,
+// event: ReactPointerEvent,
+// ) => {
+// event.stopPropagation();
+// event.currentTarget.setPointerCapture(event.pointerId);
+// setSelectedId(step.id);
+// setDragState({
+// type: "step",
+// id: step.id,
+// pointerX: event.clientX,
+// pointerY: event.clientY,
+// originX: step.x,
+// originY: step.y,
+// });
+// };
+//
+// const movePointer = (event: ReactPointerEvent) => {
+// if (!dragState) return;
+// const dx =
+// (event.clientX - dragState.pointerX) /
+// (dragState.type === "pan" ? 1 : zoom);
+// const dy =
+// (event.clientY - dragState.pointerY) /
+// (dragState.type === "pan" ? 1 : zoom);
+// if (dragState.type === "pan")
+// setPan({ x: dragState.originX + dx, y: dragState.originY + dy });
+// else
+// setSteps((current) =>
+// current.map((step) =>
+// step.id === dragState.id
+// ? {
+// ...step,
+// x: Math.max(0, dragState.originX + dx),
+// y: Math.max(0, dragState.originY + dy),
+// }
+// : step,
+// ),
+// );
+// };
+//
+// const onWheel = (event: WheelEvent) => {
+// if (!event.ctrlKey && !event.metaKey) return;
+// event.preventDefault();
+// setZoom((value) =>
+// Math.min(1.25, Math.max(0.55, value - event.deltaY * 0.001)),
+// );
+// };
+//
+// return (
+// <>
+//
+//
+// {isRunning ? "Running demo…" : "Run demo"}
+//
+// }
+// />
+//
+//
+//
+//
+//
+//
+//
+//
+// Cluster health check
+// Draft workflow · {steps.length} steps
+//
+//
+//
+//
+// DEMO ONLY
+//
+//
+//
+//
+// setInspectorOpen((open) => !open)}
+// aria-label={
+// inspectorOpen ? "Hide step inspector" : "Show step inspector"
+// }
+// >
+//
+//
+//
+//
+// setDragState(null)}
+// onPointerCancel={() => setDragState(null)}
+// onWheel={onWheel}
+// aria-label="Workflow canvas. Drag blocks to move them. Drag the background to pan. Use the zoom controls."
+// >
+//
+//
+//
+//
+//
+//
+//
+//
+// {edges.map((edge) => {
+// const from = steps.find((step) => step.id === edge.from);
+// const to = steps.find((step) => step.id === edge.to);
+// if (!from || !to) return null;
+// const x1 = from.x + cardWidth;
+// const y1 = from.y + cardHeight / 2;
+// const x2 = to.x;
+// const y2 = to.y + cardHeight / 2;
+// const mid = (x1 + x2) / 2;
+// const d = `M ${x1} ${y1} C ${mid} ${y1}, ${mid} ${y2}, ${x2 - 7} ${y2}`;
+// return (
+//
+//
+//
+// {edge.label}
+//
+//
+// );
+// })}
+//
+// {steps.map((step) => (
+//
setSelectedId(step.id)}
+// onDragStart={(event) => startStepDrag(step, event)}
+// onMove={(x, y) =>
+// setSteps((current) =>
+// current.map((item) =>
+// item.id === step.id
+// ? {
+// ...item,
+// x: Math.max(0, item.x + x),
+// y: Math.max(0, item.y + y),
+// }
+// : item,
+// ),
+// )
+// }
+// />
+// ))}
+//
+//
+//
setZoom((value) => Math.min(1.25, value + 0.1))}
+// aria-label="Zoom in"
+// >
+//
+//
+//
{Math.round(zoom * 100)}%
+//
setZoom((value) => Math.max(0.55, value - 0.1))}
+// aria-label="Zoom out"
+// >
+//
+//
+//
{
+// setZoom(0.92);
+// setPan({ x: 22, y: 20 });
+// }}
+// aria-label="Reset canvas view"
+// >
+//
+//
+//
+//
+// Drag blocks · drag empty canvas to pan
+// · Ctrl/⌘ + scroll to zoom
+//
+//
+//
+//
+//
+//
+// {runMessage}
+//
+// {runCount
+// ? `RUN #${String(runCount).padStart(2, "0")}`
+// : "NO RUNS YET"}
+//
+//
+//
+//
+// {inspectorOpen && selected && (
+//
+//
+//
+// STEP INSPECTOR
+//
Configure step
+//
+//
setInspectorOpen(false)}
+// aria-label="Close inspector"
+// >
+//
+//
+//
+//
+//
+// {(() => {
+// const Icon = kindIcon[selected.kind];
+// return ;
+// })()}
+//
+//
+// {selected.kind}
+// Local workflow step
+//
+//
+// {selected.status}
+//
+//
+//
+// Step name
+//
+// setSteps((current) =>
+// current.map((step) =>
+// step.id === selected.id
+// ? { ...step, title: event.target.value }
+// : step,
+// ),
+// )
+// }
+// />
+//
+//
+// Description
+//
+// {selected.kind === "condition" && (
+//
+// Health threshold
+//
+//
+// %
+//
+//
+// )}
+// {selected.kind === "trigger" && (
+//
+// Schedule
+//
+// Every 5 minutes
+// Every 15 minutes
+// Every hour
+//
+//
+// )}
+//
+//
+//
+// Settings are stored in this page only. Workflow changes are not
+// saved to a server.
+//
+//
+//
+//
+// Updated just now
+//
+// setSelectedId("inspect")}
+// >
+// Select main step
+//
+//
+//
+// )}
+//
+//
+//
+//
+//
+// FLOW OVERVIEW
+//
Execution path
+//
+//
SIMULATED
+//
+//
+// {steps.map((step, index) => {
+// const Icon = kindIcon[step.kind];
+// return (
+// setSelectedId(step.id)}
+// >
+//
+//
+//
+//
+// {step.title}
+//
+// {index === 2
+// ? "Routes to alert or summary"
+// : step.subtitle}
+//
+//
+//
+// {step.status === "complete"
+// ? "Complete"
+// : step.status === "running"
+// ? "Running"
+// : "Ready"}
+//
+//
+// );
+// })}
+//
+//
+//
+//
+//
+// RUN PREVIEW
+//
Latest sample
+//
+//
+//
+//
+//
+//
+//
+// 98%
+//
+//
+// Cluster health sample
+// 5 nodes checked · 0 actions dispatched
+//
+//
+//
+// Last run
+// {runCount ? "Just now" : "No execution yet"}
+//
+//
+// Duration
+// {runCount ? "1.9 sec · simulated" : "—"}
+//
+//
+// This result is illustrative mock data. It does not reflect live
+// cluster health.
+//
+//
+//
+// >
+// );
+// }
diff --git a/dashboard/src/pages/Clusters.tsx b/dashboard/src/pages/Clusters.tsx
new file mode 100644
index 0000000..30df074
--- /dev/null
+++ b/dashboard/src/pages/Clusters.tsx
@@ -0,0 +1,176 @@
+import * as Icons from "lucide-react";
+import { useState } from "react";
+import { Link } from "react-router-dom";
+import * as UI from "../components/ui";
+import { useDemoData } from "../data/useDashboardData";
+import { useDashboardStore } from "../state/useDashboardStore";
+import { ClusterModal } from "./shared";
+
+export function ClustersPage() {
+ const { clusters, cluster, nodes } = useDemoData();
+ const allNodes = useDashboardStore((state) => state.nodes);
+ const moveNode = useDashboardStore((state) => state.moveNode);
+ const selectedId = useDashboardStore((s) => s.selectedClusterId);
+ const select = useDashboardStore((s) => s.selectCluster);
+ const [modal, setModal] = useState(null);
+ return (
+ <>
+ setModal("new")}>
+ New cluster
+
+ }
+ />
+
+ {clusters.map((item) => {
+ const count = useDashboardStore
+ .getState()
+ .nodes.filter((n) => n.clusterId === item.id).length;
+ const isSelected = selectedId === item.id;
+ return (
+
+
+
+
+
+
setModal(item.id)}
+ >
+ •••
+
+
+
+
{item.name}
+ {isSelected && (
+
+ ACTIVE
+
+ )}
+
+ {item.description || "No description added."}
+
+ ⌖ {item.region}
+
+ {count} nodes
+
+
+
+
+
+ {
+ useDashboardStore
+ .getState()
+ .nodes.filter(
+ (n) =>
+ n.clusterId === item.id && n.health !== "offline",
+ ).length
+ }{" "}
+ online
+
+ {item.createdAt}
+
+
+ {!isSelected && (
+ select(item.id)}>
+ Set active cluster
+
+ )}
+ setModal(item.id)}>
+ Edit details
+
+
+
+ );
+ })}
+
setModal("new")}
+ >
+
+
+
+ Create a demo cluster
+ Add a sample environment to organize nodes
+
+
+ {cluster && (
+
+ ACTIVE CLUSTER DETAILS
+
+
+
{cluster.name}
+
{cluster.description}
+
+
+ LOCATION
+ {cluster.region}
+
+
+ MEMBERS
+ {nodes.length} nodes
+
+
+ CREATED
+ {cluster.createdAt}
+
+
+
+ Manage nodes
+
+
+ )}
+
+
+
+
Organize sample nodes
+ Move demo nodes between local clusters.
+
+
LOCAL DEMO
+
+
+ {allNodes.map((node) => (
+
+
+
+ {node.name}
+ {node.host}
+
+
+ Cluster for {node.name}
+ moveNode(node.id, event.target.value)}
+ aria-label={`Move ${node.name} to cluster`}
+ >
+ {clusters.map((item) => (
+
+ {item.name}
+
+ ))}
+
+
+
+ ))}
+
+
+ {modal && (
+ setModal(null)}
+ clusterId={modal === "new" ? undefined : modal}
+ />
+ )}
+ >
+ );
+}
diff --git a/dashboard/src/pages/Home.tsx b/dashboard/src/pages/Home.tsx
new file mode 100644
index 0000000..3bf6a74
--- /dev/null
+++ b/dashboard/src/pages/Home.tsx
@@ -0,0 +1,456 @@
+import { useQueryClient } from "@tanstack/react-query";
+import * as Icons from "lucide-react";
+import { useState } from "react";
+import { Link } from "react-router-dom";
+import { ResourceChart } from "../components/Charts";
+import { Counter } from "../components/Counter";
+import { HologramVisual } from "../components/HologramVisual";
+import * as UI from "../components/ui";
+import type { ActivityEvent, Software } from "../data/models";
+import { useDemoData, useLocalCollection } from "../data/useDashboardData";
+import { useDashboardStore } from "../state/useDashboardStore";
+import { ActivityLine } from "./shared";
+
+export function HomePage() {
+ const { cluster, clusters, nodes } = useDemoData();
+ const { data: initialActivity = [] } =
+ useLocalCollection("activity");
+ const { data: softwareCatalog = [] } =
+ useLocalCollection("software-catalog");
+ const { data: agentEntities = [] } =
+ useLocalCollection("agents");
+ const queryClient = useQueryClient();
+ const displayName = useDashboardStore((state) => state.displayName);
+ const online = nodes.filter(
+ (n) => n.health === "healthy" || n.health === "warning",
+ ).length;
+ const warning = nodes.filter(
+ (n) => n.health === "warning" || n.health === "offline",
+ ).length;
+ const avgCpu = nodes.length
+ ? Math.round(nodes.reduce((sum, n) => sum + n.cpu, 0) / nodes.length)
+ : 0;
+ const [toast, setToast] = useState("");
+ const notify = (message: string) => {
+ setToast(message);
+ window.setTimeout(() => setToast(""), 2600);
+ };
+ const greeting = new Intl.DateTimeFormat(undefined, {
+ weekday: "long",
+ month: "long",
+ day: "numeric",
+ }).format(new Date());
+ const hour = new Date().getHours();
+ const salutation =
+ hour < 12
+ ? "Good morning,"
+ : hour < 18
+ ? "Good afternoon,"
+ : "Good evening,";
+ return (
+ <>
+ {/* ORIGINAL (preserved, not deleted — disabled by comment): notify("Demo action: cluster overview refreshed")}> Refresh overview} /> */}
+
+
+
+ OPENCLUSTER / LOCAL WORKSPACE
+
+
+ {salutation}
+
+ {displayName}.
+
+
+ Your cluster operations, agent workflows, and system signals in one
+ place.
+
+
+
+ Enter Mission Control
+
+ {
+ void queryClient
+ .invalidateQueries()
+ .then(() => notify("Local workspace snapshot refreshed."));
+ }}
+ >
+ Refresh snapshot
+
+
+
+ SAMPLE ENVIRONMENT {" "}
+ {greeting.toUpperCase()}
+
+
+
+
+ OC / 01
+
+
+ {toast && (
+
+ {toast}
+
+ )}
+
+
+
+
+
+ Your local OpenCluster workspace is ready
+
+ Cluster records persist here. Hardware metrics remain sample data
+ until a backend is connected.
+
+
+
e.currentTarget.parentElement?.remove()}
+ >
+
+
+
+
+
+ }
+ detail="Local cluster records"
+ icon={ }
+ tone="green"
+ />
+
+ }
+ detail={`of ${nodes.length} · sample status`}
+ icon={ }
+ tone="blue"
+ />
+
+ }
+ detail="Sample health signals"
+ icon={ }
+ tone="purple"
+ />
+
+ }
+ detail={`${cluster?.name ?? "Active cluster"} · sample`}
+ icon={ }
+ tone="blue"
+ />
+ agent.kind === "agent").length
+ }
+ minDigits={2}
+ fontSize="inherit"
+ fontWeight={600}
+ />
+ }
+ detail="Persisted local agent records"
+ icon={ }
+ tone="amber"
+ />
+
+
+
+
+
+ CLUSTER FLEET
+
Active clusters
+
+
+ Manage
+
+
+
+ {clusters.slice(0, 3).map((item) => (
+
+
+
+ {item.name}
+ {item.region}
+
+
+ {nodes
+ .filter((node) => node.clusterId === item.id)
+ .length.toString()
+ .padStart(2, "0")}{" "}
+ NODES
+
+
+ ))}
+
+
+
+
+
+ AGENT ECOSYSTEM
+
Orchestration layer
+
+
+ Open graph
+
+
+ {agentEntities
+ .filter((agent) => agent.kind === "agent")
+ .slice(0, 3)
+ .map((agent) => (
+
+
+ {agent.icon === "codex" ? (
+
+ ) : (
+
+ )}
+
+
+ {agent.name}
+ {agent.role} · local record
+
+
+
+ ))}
+
+ Agent records are local
+ configuration; no agent is connected.
+
+
+
+
+
+ SOFTWARE CATALOG
+
Agent runtimes
+
+
+ Browse
+
+
+
+ {softwareCatalog.slice(0, 3).map((software) => (
+
+ {software.name.slice(0, 1)}
+ {software.name}
+
+ {software.status === "available" ? "PREVIEW" : "PLANNED"}
+
+
+ ))}
+
+
+
+
+
+
+
+ {warning
+ ? `${warning} sample node${warning === 1 ? "" : "s"} need attention`
+ : "No sample alerts"}
+
+
+ Illustrative health status only · no nodes are connected
+
+
+
+ Review health
+
+
+
+
+
+
+ RESOURCE OVERVIEW
+
Cluster utilization
+
+
+
+ CPU
+
+
+ Memory
+
+
+ Last 24 hours
+
+
+
+
+
+ AVG CPU
+ {avgCpu}%
+
+ 4.2% vs yesterday
+
+
+
+ AVG MEMORY
+
+ {Math.round(
+ nodes.reduce((sum, n) => sum + n.memory, 0) /
+ Math.max(nodes.length, 1),
+ )}
+ %
+
+ Across {nodes.length} sample nodes
+
+
+ NETWORK I/O
+
+ {nodes.reduce((sum, node) => sum + node.network, 0)}{" "}
+ Mb/s sample
+
+ Local sample nodes · no live network telemetry
+
+
+
+
+
+
+
+
+
+ SHORTCUTS
+
Quick actions
+
+
+
+
+
+
+
+
+
+ Create a cluster
+ Set up a new demo cluster
+
+
+
+
+
+
+
+
+ Explore nodes
+ Inspect resource details
+
+
+
+
+
+
+
+
+ Browse AI software
+ Preview catalog integrations
+
+
+
+
+
+
+
+
+ Design an automation
+ Preview a demo workflow
+
+
+
+
+
+ Actions in this demo do not affect
+ real systems.
+
+
+
+
+
+ INFRASTRUCTURE
+
Node status
+
+
+ View all nodes
+
+
+
+ {nodes.slice(0, 4).map((node) => (
+
+
+
+ {node.name}
+
+ {node.role === "coordinator" ? "Coordinator" : "Worker"}
+
+
+
+ CPU
+ {node.cpu}%
+ 75 ? "amber" : "blue"}
+ />
+
+
+
+ ))}
+
+
+
+
+
+ RECENT EVENTS
+
Activity
+
+
+ View activity
+
+
+
+ {initialActivity.slice(0, 4).map((event) => (
+
+ ))}
+
+
+
+ >
+ );
+}
diff --git a/dashboard/src/pages/MissionControl.tsx b/dashboard/src/pages/MissionControl.tsx
new file mode 100644
index 0000000..36d5e25
--- /dev/null
+++ b/dashboard/src/pages/MissionControl.tsx
@@ -0,0 +1,141 @@
+import {
+ ChevronDown,
+ CircleAlert,
+ Globe2,
+ Layers3,
+ Orbit,
+ Share2,
+} from "lucide-react";
+import { lazy, Suspense, useMemo, useState } from "react";
+import { Link } from "react-router-dom";
+import { Topology } from "../components/Topology";
+import { Button, LoadingState, PageTitle, Progress } from "../components/ui";
+import type { Health } from "../data/models";
+import { useDemoData } from "../data/useDashboardData";
+
+const GlobeScene = lazy(() =>
+ import("../components/GlobeScene").then((module) => ({
+ default: module.GlobeScene,
+ })),
+);
+
+export function MissionPage() {
+ const { cluster, nodes, clusters } = useDemoData();
+ const [layout, setLayout] = useState<"topology" | "orbit" | "globe">("globe");
+ const [healthFilter, setHealthFilter] = useState("all");
+ const visibleNodes = useMemo(
+ () =>
+ nodes.filter(
+ (node) => healthFilter === "all" || node.health === healthFilter,
+ ),
+ [nodes, healthFilter],
+ );
+ const averageCpu = nodes.length
+ ? Math.round(nodes.reduce((sum, node) => sum + node.cpu, 0) / nodes.length)
+ : 0;
+ const averageMemory = nodes.length
+ ? Math.round(
+ nodes.reduce((sum, node) => sum + node.memory, 0) / nodes.length,
+ )
+ : 0;
+ const alerts = nodes.filter(
+ (node) => node.health === "warning" || node.health === "offline",
+ ).length;
+ return (
+ <>
+
+ Switch cluster
+
+ }
+ />
+
+
+
+ {cluster?.name}
+ · {nodes.length} sample nodes
+
+ {cluster?.region}
+
+
DEMO TOPOLOGY
+
+
+
+ Visualization mode
+ setLayout("orbit")}
+ >
+ Orbit view
+
+ setLayout("topology")}
+ >
+ Topology
+
+ setLayout("globe")}
+ >
+ 3D globe
+
+
+
+ Filter nodes by health
+ {(["all", "healthy", "warning", "offline"] as const).map((health) => (
+ setHealthFilter(health)}
+ >
+ {health === "all" ? "All nodes" : health}
+ {health !== "all" && }
+
+ ))}
+
+
+ {layout === "globe" ? (
+ }>
+
+
+ ) : (
+
+ )}
+
+
+
AVERAGE CPU
+
{averageCpu}%
+
+
+
+
AVERAGE MEMORY
+
{averageMemory}%
+
+
+
+ HEALTH ALERTS
+
+ {alerts}
+
+
+ {alerts ? "Sample nodes need attention" : "No demo alerts"}
+
+
+
+
+ Node connections and status are illustrative.{" "}
+ {clusters.length} local demo clusters available.
+
+ >
+ );
+}
diff --git a/dashboard/src/pages/Monitoring.tsx b/dashboard/src/pages/Monitoring.tsx
new file mode 100644
index 0000000..70fcc05
--- /dev/null
+++ b/dashboard/src/pages/Monitoring.tsx
@@ -0,0 +1,173 @@
+import { ArrowRight, CircleAlert, HardDrive } from "lucide-react";
+import { useState } from "react";
+import { Link } from "react-router-dom";
+import { ResourceChart } from "../components/Charts";
+import { Card, HealthBadge, PageTitle, Progress } from "../components/ui";
+import { useDemoData } from "../data/useDashboardData";
+import { useDashboardStore } from "../state/useDashboardStore";
+
+export function MonitoringPage() {
+ const { nodes } = useDemoData();
+ const storageAverage = nodes.length
+ ? Math.round(nodes.reduce((sum, node) => sum + node.disk, 0) / nodes.length)
+ : 0;
+ const [range, setRange] = useState<"15m" | "1h" | "24h" | "7d">("24h");
+ const [metric, setMetric] = useState("cpu");
+ const [compare, setCompare] = useState(false);
+ const selectNode = useDashboardStore((state) => state.selectNode);
+ return (
+ <>
+ setRange(e.target.value as typeof range)}
+ aria-label="Select time range"
+ >
+ Last 15 minutes
+ Last hour
+ Last 24 hours
+ Last 7 days
+
+ }
+ />
+
+
+ SAMPLE HISTORY
+
+ ·
+ Fixed deterministic sample series · {range} window
+
+
+
+
+
+ AGGREGATE METRICS
+
+ {metric === "cpu"
+ ? "CPU utilization"
+ : metric === "memory"
+ ? "Memory utilization"
+ : "Network traffic"}
+
+
+
+ {["cpu", "memory", "network"].map((key) => (
+ setMetric(key)}
+ >
+ {key}
+
+ ))}
+ {metric === "cpu" && (
+ setCompare((value) => !value)}
+ >
+ Compare memory
+
+ )}
+
+
+
+
+
+
+ Deterministic illustrative samples · no live telemetry
+ Units: {metric === "network" ? "Mb/s" : "%"}
+
+
+
+
+
+ STORAGE
+
Disk utilization
+
+
+
+
+
+ {storageAverage}
+ %
+
+ average across {nodes.length} sample nodes
+
+
+
+
+
+ Used {storageAverage}%
+
+
+
+ Available {100 - storageAverage}%
+
+
+
+ {nodes.map((node) => (
+
+
{node.name}
+
{node.disk}%
+
70 ? "amber" : "blue"}
+ />
+
+ ))}
+
+
+
+
+
+ HEALTH
+
Node health summary
+
+
+ Inspect nodes
+
+
+
+ {nodes.map((node) => (
+
selectNode(node.id)}
+ >
+
+
+ {node.name}
+ {node.host}
+
+
+
+ ))}
+
+
+
+
+
+
Sample monitoring
+
+ Charts read persisted sample telemetry. No telemetry service is
+ connected and freshness does not update.
+
+
+
+
+ >
+ );
+}
diff --git a/dashboard/src/pages/Nodes.tsx b/dashboard/src/pages/Nodes.tsx
new file mode 100644
index 0000000..dd70047
--- /dev/null
+++ b/dashboard/src/pages/Nodes.tsx
@@ -0,0 +1,313 @@
+import * as Icons from "lucide-react";
+import { useState } from "react";
+import * as UI from "../components/ui";
+import { useDemoData } from "../data/useDashboardData";
+import { useDashboardStore } from "../state/useDashboardStore";
+import { handleDialogKeyDown, NodeRow } from "./shared";
+
+export function NodesPage() {
+ const { nodes, cluster } = useDemoData();
+ const selectedId = useDashboardStore((s) => s.selectedNodeId);
+ const allNodes = useDashboardStore((s) => s.nodes);
+ const clusters = useDashboardStore((s) => s.clusters);
+ const select = useDashboardStore((s) => s.selectNode);
+ const toggleCoordinator = useDashboardStore((s) => s.toggleCoordinator);
+ const [filter, setFilter] = useState("all");
+ const [search, setSearch] = useState("");
+ const [role, setRole] = useState("all");
+ const [os, setOs] = useState("all");
+ const [scope, setScope] = useState("active");
+ const [sort, setSort] = useState("name");
+ const visible = allNodes
+ .filter(
+ (node) =>
+ (scope === "all" ||
+ node.clusterId === (scope === "active" ? cluster?.id : scope)) &&
+ (filter === "all" || node.health === filter) &&
+ (role === "all" || node.role === role) &&
+ (os === "all" || node.os.toLowerCase().includes(os.toLowerCase())) &&
+ `${node.name} ${node.host} ${node.os}`
+ .toLowerCase()
+ .includes(search.toLowerCase()),
+ )
+ .sort((a, b) =>
+ sort === "cpu"
+ ? b.cpu - a.cpu
+ : sort === "memory"
+ ? b.memory - a.memory
+ : a.name.localeCompare(b.name),
+ );
+ const selected = nodes.find((node) => node.id === selectedId);
+ return (
+ <>
+
+ Enrollment unavailable
+
+ }
+ />
+
+
+
+
+ {nodes.length} total nodes
+
+
+
+
+ {nodes.filter((n) => n.health === "healthy").length} healthy
+
+
+
+ {nodes.filter((n) => n.health === "warning").length} attention
+
+
+
+ {nodes.filter((n) => n.health === "offline").length} offline
+
+
{cluster?.name}
+
+
+
+
+
Node inventory
+ {visible.length} nodes · sample status
+
+
+
+
+ setSearch(e.target.value)}
+ />
+
+ setFilter(e.target.value)}
+ >
+ All statuses
+ Healthy
+ Attention
+ Offline
+
+ setRole(e.target.value)}
+ >
+ All roles
+ Coordinator
+ Worker
+
+ setOs(e.target.value)}
+ >
+ All systems
+ {[
+ ...new Set(allNodes.map((node) => node.os.split(" · ")[0])),
+ ].map((system) => (
+
+ {system}
+
+ ))}
+
+ setScope(e.target.value)}
+ >
+ Active cluster
+ All clusters
+ {clusters
+ .filter((item) => item.id !== cluster?.id)
+ .map((item) => (
+
+ {item.name}
+
+ ))}
+
+ setSort(e.target.value)}
+ >
+ Sort by name
+ Highest CPU
+ Highest memory
+
+
+
+
+
+
+
+ NODE
+ STATUS
+ ROLE
+ CPU
+ MEMORY
+ LAST SEEN
+
+
+
+
+ {visible.map((node) => (
+ select(node.id)}
+ />
+ ))}
+
+
+
+
+ Telemetry shown is sample data generated for interface preview.
+
+
+ {selected && (
+ handleDialogKeyDown(event, () => select(null))}
+ >
+
+
+
+ NODE PROFILE
+
{selected.name}
+
+
select(null)}
+ autoFocus
+ >
+
+
+
+
+
+ {selected.host} · {selected.os}
+
+
+
+
+ CPU LOAD
+ {selected.cpu}%
+
+
+
+
+ MEMORY
+ {selected.memory}%
+
+
+
+
+ DISK USED
+ {selected.disk}%
+
+
+
+
+ NETWORK
+ {selected.network} Mb/s
+
+
+
+
+
System
+
+ Operating system {selected.os}
+
+
+ Uptime {selected.uptime}
+
+
+ Last sample {selected.lastSeen}
+
+
+ GPU {selected.gpu ?? "Not detected"}
+
+
+
+
Health history
+
Four illustrative samples, oldest to newest.
+
+
+ Demo health history ending {selected.health}
+
+ {(
+ [
+ ["sample-1", "healthy"],
+ ["sample-2", "healthy"],
+ [
+ "sample-3",
+ selected.health === "offline" ? "warning" : "healthy",
+ ],
+ ["sample-4", selected.health],
+ ] as const
+ ).map(([sample, health], index) => (
+
+ ))}
+ {!visible.length && (
+
+
+ No nodes match these filters.
+
+
+ )}
+
+
+
+
Node role
+
+ Role changes apply only to this browser-local cluster record.
+
+ {selected.role === "coordinator" ? (
+
+ Current cluster coordinator
+
+ ) : (
+
void toggleCoordinator(selected.id)}>
+ Designate as coordinator
+
+ )}
+ {/* ORIGINAL (preserved, not deleted — disabled by comment):
toggleCoordinator(selected.id)}>{selected.role === "coordinator" ? "Designate as worker" : "Designate as coordinator"} */}
+
+
+ Demo profile. No host telemetry is
+ connected.
+
+
+
+ )}
+ >
+ );
+}
diff --git a/dashboard/src/pages/NotFound.tsx b/dashboard/src/pages/NotFound.tsx
new file mode 100644
index 0000000..d2c97d1
--- /dev/null
+++ b/dashboard/src/pages/NotFound.tsx
@@ -0,0 +1,15 @@
+import * as Icons from "lucide-react";
+import { Link } from "react-router-dom";
+
+export function NotFoundPage() {
+ return (
+
+
404 · PAGE NOT FOUND
+
This page isn’t in the demo
+
Choose a section from the sidebar to continue.
+
+ Return home
+
+
+ );
+}
diff --git a/dashboard/src/pages/Settings.tsx b/dashboard/src/pages/Settings.tsx
new file mode 100644
index 0000000..2bb2c13
--- /dev/null
+++ b/dashboard/src/pages/Settings.tsx
@@ -0,0 +1,349 @@
+import { useQueryClient } from "@tanstack/react-query";
+import * as Icons from "lucide-react";
+import { useState } from "react";
+import * as UI from "../components/ui";
+import { useDashboardStore } from "../state/useDashboardStore";
+
+export function SettingsPage() {
+ const theme = useDashboardStore((s) => s.theme);
+ const setTheme = useDashboardStore((s) => s.setTheme);
+ const motion = useDashboardStore((s) => s.demoMotion);
+ const setMotionMode = useDashboardStore((s) => s.setMotionMode);
+ const motionMode = useDashboardStore((s) => s.motionMode);
+ const compact = useDashboardStore((s) => s.compactDashboard);
+ const setCompactDashboard = useDashboardStore((s) => s.setCompactDashboard);
+ const setVisualQuality = useDashboardStore((s) => s.setVisualQuality);
+ const visualQuality = useDashboardStore((s) => s.visualQuality);
+ const displayName = useDashboardStore((s) => s.displayName);
+ const setDisplayName = useDashboardStore((s) => s.setDisplayName);
+ const cinematicMotion = useDashboardStore((s) => s.cinematicMotion);
+ const demoDataset = useDashboardStore((s) => s.demoDataset);
+ const setDemoDataset = useDashboardStore((s) => s.setDemoDataset);
+ const resetDemoData = useDashboardStore((s) => s.resetDemoData);
+ const queryClient = useQueryClient();
+ const [saved, setSaved] = useState("");
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+
Appearance
+
Choose how OpenCluster looks in this browser.
+
+
+
+
+ Theme
+
+ Dark is the recommended command-center appearance.
+
+
+
+ {(["dark", "light", "system"] as const).map((value) => (
+ setTheme(value)}
+ >
+ {value[0].toUpperCase() + value.slice(1)}
+
+ ))}
+
+
+
+
+ Cinematic motion
+
+ Enable enhanced decorative motion when motion is allowed.
+
+
+
+ setMotionMode(cinematicMotion ? "system" : "cinematic")
+ }
+ >
+
+
+
+
+
+ Reduce motion
+ Suppress interface animations for this browser.
+
+
+ setMotionMode(motionMode === "reduced" ? "system" : "reduced")
+ }
+ >
+
+
+
+
+
+
+
+
+
+
+
Dashboard
+
Adjust information density for the home overview.
+
+
+
+
+ Compact dashboard cards
+ Use a denser layout for wide monitors.
+
+
setCompactDashboard(!compact)}
+ >
+
+
+
+
+
+ Performance mode
+ Reduce blur and decorative motion effects.
+
+
+ setVisualQuality(
+ event.target.value as "auto" | "performance" | "high",
+ )
+ }
+ >
+ Automatic
+ Performance
+ High
+
+
+
+
+
+
+
+
+
+
Accessibility
+
Interaction and display options.
+
+
+
+
+ Keyboard shortcuts
+ Use ⌘K or Ctrl+K to open page search.
+
+
⌘ K
+
+
+
+ Color and status
+
+ Status always includes a text label with its indicator.
+
+
+
+
+
+
+
+
+
+
+
+
Local data
+
+ Records are stored in IndexedDB; display preferences use
+ localStorage.
+
+
+
+
+
+
+ Seeded metrics are sample values. Cluster, node, workflow, and
+ graph records persist in this browser. No credentials or secrets
+ are stored.
+
+
+
+
+ Home greeting
+ Choose the local display name shown on Home.
+
+ setDisplayName(event.target.value)}
+ />
+
+
+
+ Seed dataset
+
+ Replace current cluster and node records with the selected
+ seed.
+
+
+ {
+ const next = event.target.value as
+ | "multi-cluster"
+ | "homelab-only";
+ if (
+ next !== demoDataset &&
+ window.confirm(
+ "Replace your current local clusters and nodes with this seed dataset?",
+ )
+ )
+ void setDemoDataset(next).catch(() =>
+ setSaved(
+ "The selected local dataset could not be saved.",
+ ),
+ );
+ }}
+ >
+ Multi-cluster demo
+ Homelab only
+
+
+
+
+ Reset demo data and preferences
+
+ Restore the original sample content and default settings.
+
+
+
{
+ if (
+ window.confirm(
+ "Reset local clusters, nodes, workflows, graph layouts, and preferences? This cannot be undone.",
+ )
+ ) {
+ try {
+ await resetDemoData();
+ await queryClient.invalidateQueries();
+ setSaved("Sample records and preferences restored.");
+ } catch {
+ setSaved(
+ "Local reset failed. Your records were not confirmed as reset.",
+ );
+ }
+ }
+ }}
+ >
+ Reset sample data
+
+
+
+
+
+
+
+
+
+
Integrations
+
Connection settings for future OpenCluster capabilities.
+
+
+
+
+
+ Browser-local provider
+
+ Connected · IndexedDB records, localStorage preferences
+
+
+
Active
+
+
+
+
+ Control plane API
+ Not connected · integration not implemented
+
+
Unavailable
+
+
+
+
+ GitHub
+ Not connected · integration not implemented
+
+
Unavailable
+
+
+
{
+ setSaved("Preferences are saved automatically in this browser.");
+ window.setTimeout(() => setSaved(""), 2500);
+ }}
+ >
+ Save preferences
+
+ {saved && (
+
+ {saved}
+
+ )}
+
+
+ >
+ );
+}
diff --git a/dashboard/src/pages/Software.tsx b/dashboard/src/pages/Software.tsx
new file mode 100644
index 0000000..5929492
--- /dev/null
+++ b/dashboard/src/pages/Software.tsx
@@ -0,0 +1,229 @@
+import { useQueryClient } from "@tanstack/react-query";
+import * as Icons from "lucide-react";
+import type { CSSProperties } from "react";
+import { useEffect, useState } from "react";
+import * as UI from "../components/ui";
+import type { Software, SoftwareConfig } from "../data/models";
+import { dashboardProvider } from "../data/provider";
+import { useDemoData, useLocalCollection } from "../data/useDashboardData";
+import { handleDialogKeyDown } from "./shared";
+
+export function SoftwarePage() {
+ const { nodes } = useDemoData();
+ const { data: softwareCatalog = [] } =
+ useLocalCollection("software-catalog");
+ const { data: savedConfigs = [] } =
+ useLocalCollection("software-configs");
+ const [softwareId, setSoftwareId] = useState(null);
+ const [selectedNode, setSelectedNode] = useState(nodes[0]?.id ?? "");
+ const [profile, setProfile] = useState("default-local");
+ const [saveError, setSaveError] = useState("");
+ const queryClient = useQueryClient();
+ const configured = savedConfigs.map((config) => config.softwareId);
+ useEffect(() => {
+ if (!nodes.some((node) => node.id === selectedNode)) {
+ setSelectedNode(nodes[0]?.id ?? "");
+ }
+ }, [nodes, selectedNode]);
+ const item = softwareCatalog.find((software) => software.id === softwareId);
+ return (
+ <>
+
+ Catalog preview
+
+ }
+ />
+
+
+
+
+
+
Agent ecosystem
+
+ OpenCluster may support integrations where upstream tools expose
+ stable interfaces. Nothing is installed or contacted in this
+ preview.
+
+
+
+
+ {softwareCatalog.map((software) => (
+
+
+
+ {software.name.slice(0, 1)}
+
+
+ {software.category}
+
{software.name}
+ {software.vendor}
+
+
+ {software.status === "available" ? "Preview" : "Planned"}
+
+
+ {software.description}
+
+
+ Adapter overview
+
+
+ Demo configuration
+
+
+ {
+ setSoftwareId(software.id);
+ setSaveError("");
+ }}
+ >
+ {configured.includes(software.id)
+ ? "View demo configuration"
+ : "Preview installation"}
+
+
+
+ ))}
+
+
+
+
+ Bring your own adapter
+
+ Future integrations can be added through documented,
+ permission-scoped adapters.
+
+ Coming later
+
+
+ {item && (
+
+ {/* ORIGINAL (preserved, not deleted — disabled by comment): onMouseDown={(e) => { if (e.target === e.currentTarget) setSoftwareId(null); }} */}
+
setSoftwareId(null)}
+ />
+
+ handleDialogKeyDown(event, () => setSoftwareId(null))
+ }
+ >
+
+
+ DEMONSTRATION ONLY
+
+ {configured.includes(item.id)
+ ? "Demo configuration"
+ : "Installation preview"}
+
+
+
setSoftwareId(null)}
+ >
+
+
+
+
+
+ {item.name.slice(0, 1)}
+
+
+ {item.name}
+
+ {item.vendor} · {item.category}
+
+
+
+
+ This preview does not download, install, or launch software. It
+ only stores a local demo selection.
+
+
+ Target node
+ setSelectedNode(e.target.value)}
+ >
+ {nodes.map((node) => (
+
+ {node.name}
+
+ ))}
+
+
+
+ Configuration profile
+ setProfile(event.target.value)}
+ >
+ Default local profile
+ Restricted preview
+ Developer sandbox
+
+
+
+ I understand this is a
+ local demonstration
+
+
+ setSoftwareId(null)}>Cancel
+ {
+ const config = {
+ id: item.id,
+ softwareId: item.id,
+ nodeId: selectedNode,
+ profile,
+ };
+ void dashboardProvider.softwareConfigurations
+ .update(item.id, config)
+ .then(() => {
+ void queryClient.invalidateQueries({
+ queryKey: ["local-records", "software-configs"],
+ });
+ setSoftwareId(null);
+ })
+ .catch(() =>
+ setSaveError(
+ "Configuration could not be saved in this browser.",
+ ),
+ );
+ }}
+ >
+ Save local configuration
+
+
+ {saveError && (
+
+ {saveError}
+
+ )}
+
+
+ )}
+ >
+ );
+}
diff --git a/dashboard/src/pages/Tasks.tsx b/dashboard/src/pages/Tasks.tsx
new file mode 100644
index 0000000..bd49e7a
--- /dev/null
+++ b/dashboard/src/pages/Tasks.tsx
@@ -0,0 +1,287 @@
+import { useQueryClient } from "@tanstack/react-query";
+import * as Icons from "lucide-react";
+import { useEffect, useState } from "react";
+import * as UI from "../components/ui";
+import { demoApi } from "../data/demoApi";
+import type { DemoTask } from "../data/models";
+import { dashboardProvider } from "../data/provider";
+import { useLocalCollection } from "../data/useDashboardData";
+import { useDashboardStore } from "../state/useDashboardStore";
+import { TaskBadge } from "./shared";
+
+export function TasksPage() {
+ const { data: savedTasks = [] } = useLocalCollection("tasks");
+ const [tasks, setTasks] = useState([]);
+ const [filter, setFilter] = useState("all");
+ const [sortBy, setSortBy] = useState("recent");
+ const [toast, setToast] = useState("");
+ const [assignment, setAssignment] = useState>({});
+ const nodes = useDashboardStore((state) => state.nodes);
+ const queryClient = useQueryClient();
+ useEffect(() => setTasks(savedTasks), [savedTasks]);
+ const filtered = tasks.filter(
+ (task) => filter === "all" || task.status === filter,
+ );
+ const sortedTasks = [...filtered].sort((left, right) => {
+ if (sortBy === "name") return left.name.localeCompare(right.name);
+ if (sortBy === "status") return left.status.localeCompare(right.status);
+ if (sortBy === "progress") return right.progress - left.progress;
+ return tasks.indexOf(left) - tasks.indexOf(right);
+ });
+ const runDemo = () => {
+ const id = `job-${Date.now()}`;
+ const task = demoApi.createTask(nodes[0]?.id ?? "Unassigned", id);
+ void dashboardProvider.tasks
+ .create(task.id, task)
+ .then(() => {
+ setTasks((previous) => [task, ...previous]);
+ void queryClient.invalidateQueries({
+ queryKey: ["local-records", "tasks"],
+ });
+ setToast("Task saved to the local queue. Execution is unavailable.");
+ })
+ .catch(() => setToast("Task could not be saved locally."));
+ window.setTimeout(() => setToast(""), 2800);
+ };
+ return (
+ <>
+
+ Queue demo task
+
+ }
+ />
+ {toast && (
+
+ {toast}
+
+ )}
+
+ t.status === "queued").length,
+ ).padStart(2, "0")}
+ detail="Waiting for assignment"
+ icon={ }
+ tone="amber"
+ />
+ t.status === "running").length,
+ ).padStart(2, "0")}
+ detail="On sample nodes"
+ icon={ }
+ tone="blue"
+ />
+ task.status === "completed").length,
+ ).padStart(2, "0")}
+ detail="Completed sample records"
+ icon={ }
+ tone="green"
+ />
+ t.status === "failed").length,
+ ).padStart(2, "0")}
+ detail="Review demo history"
+ icon={ }
+ tone="red"
+ />
+
+
+
+
+
Task queue
+ Sample execution history and assignments
+
+
+ setFilter(e.target.value)}
+ >
+ All statuses
+ Queued
+ Running
+ Completed
+ Failed
+
+ setSortBy(event.target.value)}
+ >
+ Recently added
+ Name
+ Status
+ Progress
+
+ {
+ const content = [
+ "id,name,status,assignedNode,progress",
+ ...sortedTasks.map((task) =>
+ [
+ task.id,
+ task.name,
+ task.status,
+ assignment[task.id] ?? task.nodeId,
+ task.progress,
+ ].join(","),
+ ),
+ ].join("\n");
+ const url = URL.createObjectURL(
+ new Blob([content], { type: "text/csv" }),
+ );
+ const link = document.createElement("a");
+ link.href = url;
+ link.download = "opencluster-demo-tasks.csv";
+ link.click();
+ URL.revokeObjectURL(url);
+ }}
+ >
+ Export demo log
+
+
+
+
+
+
+
+ TASK
+ STATUS
+ PROGRESS
+ ASSIGNED NODE
+ STARTED
+ DURATION
+
+
+
+
+ {sortedTasks.map((task) => (
+
+
+
+ {task.name}
+
+ {task.id} · {task.type}
+
+
+
+
+
+
+
+
+
+ {task.progress}%
+
+
+
+
+ (() => {
+ const assigned = demoApi.assignTask(
+ task,
+ e.target.value,
+ );
+ setAssignment((prev) => ({
+ ...prev,
+ [task.id]: assigned.nodeId,
+ }));
+ void dashboardProvider.tasks
+ .update(task.id, assigned)
+ .then(() => {
+ setTasks((previous) =>
+ previous.map((item) =>
+ item.id === task.id ? assigned : item,
+ ),
+ );
+ void queryClient.invalidateQueries({
+ queryKey: ["local-records", "tasks"],
+ });
+ })
+ .catch(() =>
+ setToast(
+ "Assignment could not be saved locally.",
+ ),
+ );
+ })()
+ }
+ >
+ {nodes.map((node) => (
+
+ {node.name}
+
+ ))}
+
+
+ {task.started}
+ {task.duration}
+
+
+ setToast("Task controls are simulated in this preview.")
+ }
+ >
+ •••
+
+
+
+ ))}
+
+
+
+
+ Task assignment is local UI state. No jobs are dispatched to nodes.
+
+
+
+
+
+ EXECUTION FLOW
+
Recent timeline
+
+
SIMULATED
+
+
+ {tasks.slice(0, 4).map((task, index) => (
+
+
+ {index + 1}
+
+
+ {task.name}
+
+ {task.started} · {task.duration}
+
+
+
+
+ ))}
+
+
+ >
+ );
+}
diff --git a/dashboard/src/pages/index.tsx b/dashboard/src/pages/index.tsx
new file mode 100644
index 0000000..3468828
--- /dev/null
+++ b/dashboard/src/pages/index.tsx
@@ -0,0 +1,92 @@
+import type { ReactNode } from "react";
+import { Component, lazy, Suspense } from "react";
+import { Route, Routes } from "react-router-dom";
+import { AppShell } from "../components/AppShell";
+import { ErrorState, LoadingState } from "../components/ui";
+
+const HomePage = lazy(() =>
+ import("./Home").then((module) => ({ default: module.HomePage })),
+);
+const MissionPage = lazy(() =>
+ import("./MissionControl").then((module) => ({
+ default: module.MissionPage,
+ })),
+);
+const ClustersPage = lazy(() =>
+ import("./Clusters").then((module) => ({ default: module.ClustersPage })),
+);
+const NodesPage = lazy(() =>
+ import("./Nodes").then((module) => ({ default: module.NodesPage })),
+);
+const AgentsPage = lazy(() =>
+ import("./Agents").then((module) => ({ default: module.AgentsPage })),
+);
+const AutomationPage = lazy(() =>
+ import("./Automation").then((module) => ({ default: module.AutomationPage })),
+);
+const SoftwarePage = lazy(() =>
+ import("./Software").then((module) => ({ default: module.SoftwarePage })),
+);
+const TasksPage = lazy(() =>
+ import("./Tasks").then((module) => ({ default: module.TasksPage })),
+);
+const MonitoringPage = lazy(() =>
+ import("./Monitoring").then((module) => ({ default: module.MonitoringPage })),
+);
+const ActivityPage = lazy(() =>
+ import("./Activity").then((module) => ({ default: module.ActivityPage })),
+);
+const SettingsPage = lazy(() =>
+ import("./Settings").then((module) => ({ default: module.SettingsPage })),
+);
+const NotFoundPage = lazy(() =>
+ import("./NotFound").then((module) => ({ default: module.NotFoundPage })),
+);
+
+class RouteErrorBoundary extends Component<
+ { children: ReactNode },
+ { failed: boolean }
+> {
+ state = { failed: false };
+
+ static getDerivedStateFromError() {
+ return { failed: true };
+ }
+
+ render() {
+ return this.state.failed ? (
+ window.location.reload()}
+ />
+ ) : (
+ this.props.children
+ );
+ }
+}
+
+export function DashboardApp() {
+ return (
+
+ }>
+
+ }>
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
+
+
+
+ );
+}
diff --git a/dashboard/src/pages/shared.tsx b/dashboard/src/pages/shared.tsx
new file mode 100644
index 0000000..80a1e37
--- /dev/null
+++ b/dashboard/src/pages/shared.tsx
@@ -0,0 +1,266 @@
+import * as Icons from "lucide-react";
+import type { FormEvent, KeyboardEvent as ReactKeyboardEvent } from "react";
+import { useState } from "react";
+import * as UI from "../components/ui";
+import type { ActivityEvent, DemoTask, Node } from "../data/models";
+import { useDashboardStore } from "../state/useDashboardStore";
+
+export function handleDialogKeyDown(
+ event: ReactKeyboardEvent,
+ close: () => void,
+) {
+ if (event.key === "Escape") {
+ event.stopPropagation();
+ close();
+ return;
+ }
+ if (event.key !== "Tab") return;
+ const focusable = Array.from(
+ event.currentTarget.querySelectorAll(
+ 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
+ ),
+ );
+ const first = focusable[0];
+ const last = focusable.at(-1);
+ if (event.shiftKey && document.activeElement === first) {
+ event.preventDefault();
+ last?.focus();
+ } else if (!event.shiftKey && document.activeElement === last) {
+ event.preventDefault();
+ first?.focus();
+ }
+}
+export function NodeRow({
+ node,
+ onSelect,
+}: {
+ node: Node;
+ onSelect?: () => void;
+}) {
+ return (
+
+
+
+
+
+ {node.name}
+ {node.host}
+
+
+
+
+
+
+
+ {node.role === "coordinator" ? (
+ Coordinator
+ ) : (
+ Worker
+ )}
+
+
+
+
+ {node.cpu}%
+
+
+
+
+
+ {node.memory}%
+
+
+ {node.lastSeen}
+
+ {
+ event.stopPropagation();
+ onSelect?.();
+ }}
+ >
+ •••
+
+
+
+ );
+}
+export function ActivityLine({
+ event,
+ compact = false,
+}: {
+ event: ActivityEvent;
+ compact?: boolean;
+}) {
+ return (
+
+
+
+
{event.title}
+
{event.detail}
+ {!compact &&
{event.source} }
+
+
{event.time}
+
+ );
+}
+export function ClusterModal({
+ close,
+ clusterId,
+}: {
+ close: () => void;
+ clusterId?: string;
+}) {
+ const clusters = useDashboardStore((s) => s.clusters);
+ const target = clusters.find((c) => c.id === clusterId);
+ const add = useDashboardStore((s) => s.addCluster);
+ const update = useDashboardStore((s) => s.updateCluster);
+ const remove = useDashboardStore((s) => s.removeCluster);
+ const [name, setName] = useState(target?.name ?? "");
+ const [region, setRegion] = useState(target?.region ?? "Local network");
+ const [description, setDescription] = useState(target?.description ?? "");
+ const [error, setError] = useState("");
+ const submit = async (e: FormEvent) => {
+ e.preventDefault();
+ if (!name.trim()) {
+ setError("Enter a cluster name.");
+ return;
+ }
+ if (
+ clusters.some(
+ (cluster) =>
+ cluster.id !== target?.id &&
+ cluster.name.trim().toLowerCase() === name.trim().toLowerCase(),
+ )
+ ) {
+ setError("A cluster with this name already exists.");
+ return;
+ }
+ try {
+ if (target)
+ await update(target.id, { name: name.trim(), region, description });
+ else await add(name, region, description);
+ close();
+ } catch {
+ setError("Cluster changes could not be saved in this browser.");
+ }
+ };
+ return (
+
+ {/* ORIGINAL (preserved, not deleted — disabled by comment): onMouseDown={(e) => { if (e.target === e.currentTarget) close(); }} */}
+
+
+
+ );
+}
+export function TaskBadge({ status }: { status: DemoTask["status"] }) {
+ return (
+
+
+ {status}
+
+ );
+}
diff --git a/dashboard/src/state/useDashboardStore.ts b/dashboard/src/state/useDashboardStore.ts
new file mode 100644
index 0000000..e5ebea0
--- /dev/null
+++ b/dashboard/src/state/useDashboardStore.ts
@@ -0,0 +1,515 @@
+import { create } from "zustand";
+import type { StateStorage } from "zustand/middleware";
+import { createJSONStorage, persist } from "zustand/middleware";
+import { demoApi } from "../data/demoApi";
+import { localProvider } from "../data/localProvider";
+import { initialClusters, initialNodes } from "../data/mock";
+import type { Cluster, Node } from "../data/models";
+import { dashboardQueryClient } from "../data/queryClient";
+
+let localMutationQueue: Promise = Promise.resolve();
+function serializeLocalMutation(mutation: () => Promise): Promise {
+ const pending = localMutationQueue.then(mutation, mutation);
+ localMutationQueue = pending.then(
+ () => undefined,
+ () => undefined,
+ );
+ return pending;
+}
+
+const isCluster = (value: unknown): value is Cluster => {
+ if (!value || typeof value !== "object") return false;
+ const item = value as Record;
+ return (
+ typeof item.id === "string" &&
+ typeof item.name === "string" &&
+ typeof item.region === "string" &&
+ typeof item.description === "string" &&
+ typeof item.createdAt === "string" &&
+ Array.isArray(item.nodeIds) &&
+ item.nodeIds.every((id) => typeof id === "string")
+ );
+};
+const isNode = (value: unknown): value is Node => {
+ if (!value || typeof value !== "object") return false;
+ const item = value as Record;
+ return (
+ typeof item.id === "string" &&
+ typeof item.name === "string" &&
+ (item.role === "coordinator" || item.role === "worker") &&
+ typeof item.clusterId === "string" &&
+ typeof item.host === "string" &&
+ typeof item.os === "string" &&
+ ["healthy", "warning", "offline", "unknown"].includes(
+ String(item.health),
+ ) &&
+ ["cpu", "memory", "disk", "network"].every(
+ (key) => typeof item[key] === "number" && Number.isFinite(item[key]),
+ ) &&
+ typeof item.uptime === "string" &&
+ typeof item.lastSeen === "string" &&
+ Array.isArray(item.labels) &&
+ item.labels.every((label) => typeof label === "string")
+ );
+};
+const guardedStorage: StateStorage = {
+ getItem(name) {
+ try {
+ const value = localStorage.getItem(name);
+ if (!value) return null;
+ const parsed: unknown = JSON.parse(value);
+ return parsed && typeof parsed === "object" && "state" in parsed
+ ? value
+ : null;
+ } catch {
+ return null;
+ }
+ },
+ setItem(name, value) {
+ try {
+ localStorage.setItem(name, value);
+ } catch {
+ /* Browser storage may be disabled or full. */
+ }
+ },
+ removeItem(name) {
+ try {
+ localStorage.removeItem(name);
+ } catch {
+ /* Browser storage may be disabled. */
+ }
+ },
+};
+
+type DashboardState = {
+ clusters: Cluster[];
+ nodes: Node[];
+ selectedClusterId: string;
+ selectedNodeId: string | null;
+ sidebarCollapsed: boolean;
+ compactDashboard: boolean;
+ demoMotion: boolean;
+ performanceMode: boolean;
+ displayName: string;
+ cinematicMotion: boolean;
+ motionMode: "system" | "reduced" | "cinematic";
+ visualQuality: "auto" | "performance" | "high";
+ demoDataset: "multi-cluster" | "homelab-only";
+ persistenceError: string | null;
+ theme: "dark" | "light" | "system";
+ addCluster: (
+ name: string,
+ region: string,
+ description: string,
+ ) => Promise;
+ updateCluster: (
+ id: string,
+ changes: Partial>,
+ ) => Promise;
+ removeCluster: (id: string) => Promise;
+ selectCluster: (id: string) => void;
+ selectNode: (id: string | null) => void;
+ toggleSidebar: () => void;
+ setCompactDashboard: (enabled: boolean) => void;
+ setMotion: (enabled: boolean) => void;
+ setPerformanceMode: (enabled: boolean) => void;
+ setDisplayName: (name: string) => void;
+ setCinematicMotion: (enabled: boolean) => void;
+ setMotionMode: (mode: "system" | "reduced" | "cinematic") => void;
+ setVisualQuality: (quality: "auto" | "performance" | "high") => void;
+ setDemoDataset: (dataset: "multi-cluster" | "homelab-only") => Promise;
+ resetDemoData: () => Promise;
+ clearPersistenceError: () => void;
+ setTheme: (theme: "dark" | "light" | "system") => void;
+ toggleCoordinator: (id: string) => Promise;
+ moveNode: (nodeId: string, clusterId: string) => Promise;
+};
+export const useDashboardStore = create()(
+ persist(
+ (set) => ({
+ clusters: initialClusters,
+ nodes: initialNodes,
+ selectedClusterId: initialClusters[0].id,
+ selectedNodeId: null,
+ sidebarCollapsed: false,
+ compactDashboard: false,
+ demoMotion: true,
+ performanceMode: false,
+ displayName: "operator",
+ cinematicMotion: false,
+ motionMode: "system",
+ visualQuality: "auto",
+ demoDataset: "multi-cluster",
+ persistenceError: null,
+ theme: "dark",
+ addCluster: (name, region, description) =>
+ serializeLocalMutation(async () => {
+ const state = useDashboardStore.getState();
+ const created = demoApi.createCluster(name, region, description);
+ const cluster = state.clusters.some((item) => item.id === created.id)
+ ? { ...created, id: `${created.id}-${crypto.randomUUID()}` }
+ : created;
+ try {
+ await localProvider.put("clusters", cluster.id, cluster);
+ set({
+ clusters: [...state.clusters, cluster],
+ selectedClusterId: cluster.id,
+ persistenceError: null,
+ });
+ void dashboardQueryClient.invalidateQueries();
+ } catch (error) {
+ set({
+ persistenceError:
+ error instanceof Error
+ ? error.message
+ : "Could not save cluster locally.",
+ });
+ throw error;
+ }
+ }),
+ updateCluster: (id, changes) =>
+ serializeLocalMutation(async () => {
+ const state = useDashboardStore.getState();
+ const clusters = state.clusters.map((cluster) =>
+ cluster.id === id
+ ? demoApi.updateCluster(cluster, changes)
+ : cluster,
+ );
+ const updated = clusters.find((cluster) => cluster.id === id);
+ if (!updated) return;
+ try {
+ await localProvider.put("clusters", id, updated);
+ set({ clusters, persistenceError: null });
+ void dashboardQueryClient.invalidateQueries();
+ } catch (error) {
+ set({
+ persistenceError:
+ error instanceof Error
+ ? error.message
+ : "Could not save cluster locally.",
+ });
+ throw error;
+ }
+ }),
+ removeCluster: (id) =>
+ serializeLocalMutation(async () => {
+ const state = useDashboardStore.getState();
+ if (state.clusters.length < 2) return false;
+ const fallback = state.clusters.find((cluster) => cluster.id !== id);
+ if (!fallback || !state.clusters.some((cluster) => cluster.id === id))
+ return false;
+ const movedNodes = state.nodes.map((node) =>
+ node.clusterId === id ? { ...node, clusterId: fallback.id } : node,
+ );
+ const targetNodes = movedNodes.filter(
+ (node) => node.clusterId === fallback.id,
+ );
+ const targetCoordinator =
+ state.nodes.find(
+ (node) =>
+ node.clusterId === fallback.id && node.role === "coordinator",
+ ) ?? targetNodes[0];
+ const nodes = movedNodes.map((node) =>
+ node.clusterId === fallback.id
+ ? {
+ ...node,
+ role:
+ node.id === targetCoordinator?.id
+ ? ("coordinator" as const)
+ : ("worker" as const),
+ }
+ : node,
+ );
+ const clusters = state.clusters
+ .filter((cluster) => cluster.id !== id)
+ .map((cluster) => ({
+ ...cluster,
+ nodeIds: nodes
+ .filter((node) => node.clusterId === cluster.id)
+ .map((node) => node.id),
+ }));
+ try {
+ await localProvider.writeClusterNodeState(clusters, nodes);
+ set({
+ clusters,
+ nodes,
+ selectedClusterId:
+ state.selectedClusterId === id
+ ? fallback.id
+ : state.selectedClusterId,
+ selectedNodeId: null,
+ persistenceError: null,
+ });
+ void dashboardQueryClient.invalidateQueries();
+ return true;
+ } catch (error) {
+ set({
+ persistenceError:
+ error instanceof Error
+ ? error.message
+ : "Could not remove cluster locally.",
+ });
+ return false;
+ }
+ }),
+ selectCluster: (id) =>
+ set({ selectedClusterId: id, selectedNodeId: null }),
+ selectNode: (id) => set({ selectedNodeId: id }),
+ toggleSidebar: () =>
+ set((state) => ({ sidebarCollapsed: !state.sidebarCollapsed })),
+ setCompactDashboard: (compactDashboard) => set({ compactDashboard }),
+ setMotion: (demoMotion) => set({ demoMotion }),
+ setPerformanceMode: (performanceMode) => set({ performanceMode }),
+ setDisplayName: (displayName) =>
+ set({ displayName: displayName.trim().slice(0, 40) || "operator" }),
+ setCinematicMotion: (cinematicMotion) => set({ cinematicMotion }),
+ setMotionMode: (motionMode) =>
+ set({
+ motionMode,
+ demoMotion: motionMode !== "reduced",
+ cinematicMotion: motionMode === "cinematic",
+ }),
+ setVisualQuality: (visualQuality) =>
+ set({
+ visualQuality,
+ performanceMode: visualQuality === "performance",
+ }),
+ setDemoDataset: (demoDataset) =>
+ serializeLocalMutation(async () => {
+ const clusters =
+ demoDataset === "homelab-only"
+ ? [initialClusters[0]]
+ : initialClusters;
+ const nodes = initialNodes.filter((node) =>
+ clusters.some((cluster) => cluster.id === node.clusterId),
+ );
+ const nextClusters = structuredClone(clusters).map((cluster) => ({
+ ...cluster,
+ nodeIds: nodes
+ .filter((node) => node.clusterId === cluster.id)
+ .map((node) => node.id),
+ }));
+ try {
+ await localProvider.writeClusterNodeState(nextClusters, nodes);
+ set({
+ demoDataset,
+ clusters: nextClusters,
+ nodes,
+ selectedClusterId: clusters[0].id,
+ selectedNodeId: null,
+ persistenceError: null,
+ });
+ void dashboardQueryClient.invalidateQueries();
+ } catch (error) {
+ set({
+ persistenceError:
+ error instanceof Error
+ ? error.message
+ : "Could not change the local dataset.",
+ });
+ throw error;
+ }
+ }),
+ resetDemoData: () =>
+ serializeLocalMutation(async () => {
+ await localProvider.resetRecords();
+ set({
+ clusters: initialClusters,
+ nodes: initialNodes,
+ selectedClusterId: initialClusters[0].id,
+ selectedNodeId: null,
+ displayName: "operator",
+ sidebarCollapsed: false,
+ compactDashboard: false,
+ demoMotion: true,
+ cinematicMotion: false,
+ motionMode: "system",
+ performanceMode: false,
+ visualQuality: "auto",
+ theme: "dark",
+ demoDataset: "multi-cluster",
+ });
+ void dashboardQueryClient.invalidateQueries();
+ }),
+ clearPersistenceError: () => set({ persistenceError: null }),
+ setTheme: (theme) => set({ theme }),
+ toggleCoordinator: (id) =>
+ serializeLocalMutation(async () => {
+ const state = useDashboardStore.getState();
+ const nodes = demoApi.designateCoordinator(state.nodes, id);
+ try {
+ await localProvider.putMany(
+ "nodes",
+ nodes.map((value) => ({ id: value.id, value })),
+ );
+ set({ nodes, persistenceError: null });
+ void dashboardQueryClient.invalidateQueries({
+ queryKey: ["nodes"],
+ });
+ } catch (error) {
+ set({
+ persistenceError:
+ error instanceof Error
+ ? error.message
+ : "Could not save coordinator selection.",
+ });
+ }
+ }),
+ moveNode: (nodeId, clusterId) =>
+ serializeLocalMutation(async () => {
+ const state = useDashboardStore.getState();
+ if (!state.clusters.some((cluster) => cluster.id === clusterId))
+ return;
+ const moving = state.nodes.find((node) => node.id === nodeId);
+ if (!moving || moving.clusterId === clusterId) return;
+ const sourceClusterId = moving.clusterId;
+ const targetHasCoordinator = state.nodes.some(
+ (node) =>
+ node.clusterId === clusterId && node.role === "coordinator",
+ );
+ const sourceReplacement = state.nodes.find(
+ (node) => node.clusterId === sourceClusterId && node.id !== nodeId,
+ );
+ const nodes = state.nodes.map((node) => {
+ if (node.id === nodeId) {
+ const moved = demoApi.moveNode(node, clusterId);
+ return {
+ ...moved,
+ role: targetHasCoordinator
+ ? ("worker" as const)
+ : ("coordinator" as const),
+ };
+ }
+ if (
+ moving.role === "coordinator" &&
+ node.id === sourceReplacement?.id
+ )
+ return { ...node, role: "coordinator" as const };
+ return node;
+ });
+ const clusters = state.clusters.map((cluster) => ({
+ ...cluster,
+ nodeIds: nodes
+ .filter((node) => node.clusterId === cluster.id)
+ .map((node) => node.id),
+ }));
+ try {
+ await localProvider.writeClusterNodeState(clusters, nodes);
+ set({ nodes, clusters, persistenceError: null });
+ void dashboardQueryClient.invalidateQueries();
+ } catch (error) {
+ set({
+ persistenceError:
+ error instanceof Error
+ ? error.message
+ : "Could not move node locally.",
+ });
+ }
+ }),
+ }),
+ {
+ name: "opencluster-demo-preferences",
+ storage: createJSONStorage(() => guardedStorage),
+ version: 4,
+ migrate: (persisted) => {
+ const old = persisted as Partial | undefined;
+ return {
+ ...old,
+ clusters: Array.isArray(old?.clusters)
+ ? old.clusters
+ : initialClusters,
+ nodes: Array.isArray(old?.nodes) ? old.nodes : initialNodes,
+ displayName:
+ typeof old?.displayName === "string" ? old.displayName : "operator",
+ cinematicMotion: old?.cinematicMotion === true,
+ motionMode:
+ old?.motionMode === "reduced" || old?.motionMode === "cinematic"
+ ? old.motionMode
+ : "system",
+ visualQuality:
+ old?.visualQuality === "performance" ||
+ old?.visualQuality === "high"
+ ? old.visualQuality
+ : "auto",
+ demoDataset:
+ old?.demoDataset === "homelab-only"
+ ? "homelab-only"
+ : "multi-cluster",
+ sidebarCollapsed: old?.sidebarCollapsed === true,
+ compactDashboard: old?.compactDashboard === true,
+ demoMotion: old?.demoMotion !== false,
+ performanceMode: old?.performanceMode === true,
+ theme:
+ old?.theme === "light" || old?.theme === "system"
+ ? old.theme
+ : "dark",
+ };
+ },
+ merge: (persisted, current) => {
+ const saved = persisted as Partial | undefined;
+ const clusters =
+ Array.isArray(saved?.clusters) && saved.clusters.every(isCluster)
+ ? saved.clusters
+ : initialClusters;
+ const nodes =
+ Array.isArray(saved?.nodes) && saved.nodes.every(isNode)
+ ? saved.nodes
+ : initialNodes;
+ const clusterIds = new Set(clusters.map((cluster) => cluster.id));
+ const safeNodes = nodes.filter((node) =>
+ clusterIds.has(node.clusterId),
+ );
+ return {
+ ...current,
+ ...saved,
+ selectedClusterId: clusters.some(
+ (cluster) => cluster.id === saved?.selectedClusterId,
+ )
+ ? (saved?.selectedClusterId as string)
+ : (clusters[0]?.id ?? ""),
+ clusters: clusters.map((cluster) => ({
+ ...cluster,
+ nodeIds: safeNodes
+ .filter((node) => node.clusterId === cluster.id)
+ .map((node) => node.id),
+ })),
+ nodes: safeNodes,
+ displayName:
+ typeof saved?.displayName === "string"
+ ? saved.displayName.slice(0, 40)
+ : "operator",
+ theme:
+ saved?.theme === "light" || saved?.theme === "system"
+ ? saved.theme
+ : "dark",
+ motionMode:
+ saved?.motionMode === "reduced" || saved?.motionMode === "cinematic"
+ ? saved.motionMode
+ : "system",
+ visualQuality:
+ saved?.visualQuality === "performance" ||
+ saved?.visualQuality === "high"
+ ? saved.visualQuality
+ : "auto",
+ demoDataset:
+ saved?.demoDataset === "homelab-only"
+ ? "homelab-only"
+ : "multi-cluster",
+ };
+ },
+ partialize: (state) => ({
+ sidebarCollapsed: state.sidebarCollapsed,
+ compactDashboard: state.compactDashboard,
+ demoMotion: state.demoMotion,
+ performanceMode: state.performanceMode,
+ theme: state.theme,
+ selectedClusterId: state.selectedClusterId,
+ displayName: state.displayName,
+ cinematicMotion: state.cinematicMotion,
+ motionMode: state.motionMode,
+ visualQuality: state.visualQuality,
+ demoDataset: state.demoDataset,
+ }),
+ },
+ ),
+);
diff --git a/dashboard/src/styles.css b/dashboard/src/styles.css
new file mode 100644
index 0000000..f76e003
--- /dev/null
+++ b/dashboard/src/styles.css
@@ -0,0 +1,4353 @@
+@import "tailwindcss";
+
+.app-bootstrap,
+.app-error {
+ min-height: 100vh;
+ display: grid;
+ place-content: center;
+ gap: 12px;
+ padding: 24px;
+ color: #dce5eb;
+ background: #080d12;
+ text-align: center;
+}
+.data-storage-error {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ margin: 0 0 14px;
+ padding: 10px 12px;
+ border: 1px solid #8d4549;
+ border-radius: 8px;
+ color: #ffd9d9;
+ background: #381d22;
+ font-size: 12px;
+}
+.data-storage-error button {
+ display: grid;
+ place-items: center;
+ border: 0;
+ background: transparent;
+ color: inherit;
+}
+
+:root {
+ font-family:
+ "Inter Variable", "Segoe UI", ui-sans-serif, system-ui, sans-serif;
+ color: #dce5eb;
+ background: #080d12;
+ font-synthesis: none;
+ text-rendering: optimizeLegibility;
+ font-optical-sizing: auto;
+ font-size: 14px;
+ font-weight: 400;
+ --bg: #080d12;
+ --surface: #0e151c;
+ --surface-2: #111a22;
+ --surface-3: #16212b;
+ --border: #1c2a34;
+ --border-light: #263641;
+ --text: #dce5eb;
+ --muted: #788895;
+ --faint: #536472;
+ --green: #62d3a5;
+ --blue: #65b9ff;
+ --purple: #b39aff;
+ --amber: #f2bf76;
+ --red: #f17879;
+ --mono: ui-monospace, "SFMono-Regular", Consolas, monospace;
+ --display:
+ "Sora Variable", "Inter Variable", "Segoe UI", ui-sans-serif, system-ui,
+ sans-serif;
+ color-scheme: dark;
+}
+* {
+ box-sizing: border-box;
+}
+body {
+ margin: 0;
+ min-width: 360px;
+ min-height: 100vh;
+ background:
+ radial-gradient(
+ ellipse at 73% -20%,
+ rgba(37, 90, 105, 0.12),
+ transparent 43%
+ ),
+ var(--bg);
+}
+button,
+input,
+select,
+textarea {
+ font: inherit;
+}
+button,
+select {
+ color: inherit;
+}
+button {
+ cursor: pointer;
+}
+button:focus-visible,
+a:focus-visible,
+input:focus-visible,
+select:focus-visible,
+textarea:focus-visible {
+ outline: 2px solid #66b9eb;
+ outline-offset: 2px;
+}
+a {
+ color: inherit;
+ text-decoration: none;
+}
+::selection {
+ background: #287a6a;
+ color: white;
+}
+.app-shell {
+ min-height: 100vh;
+}
+.sidebar {
+ position: fixed;
+ inset: 0 auto 0 0;
+ width: 248px;
+ padding: 25px 16px 14px;
+ background: #0a1016;
+ border-right: 1px solid #17232c;
+ display: flex;
+ flex-direction: column;
+ z-index: 5;
+ transition: width 0.2s ease;
+}
+.brand {
+ display: flex;
+ align-items: center;
+ gap: 11px;
+ padding: 0 9px 27px;
+}
+.brand-mark {
+ width: 27px;
+ height: 27px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 3px;
+ background: #153329;
+ border: 1px solid #245744;
+ border-radius: 8px;
+}
+.brand-mark span {
+ display: block;
+ width: 3px;
+ border-radius: 2px;
+ background: #6ce0b0;
+ transform: skew(-20deg);
+}
+.brand-mark span:nth-child(1) {
+ height: 8px;
+}
+.brand-mark span:nth-child(2) {
+ height: 15px;
+}
+.brand-mark span:nth-child(3) {
+ height: 11px;
+}
+.brand-word {
+ display: flex;
+ flex-direction: column;
+ gap: 3px;
+}
+.brand-word b {
+ font: 600 16px var(--display);
+ letter-spacing: -0.35px;
+ color: #edf5f3;
+}
+.brand-word small {
+ font: 500 8px var(--mono);
+ letter-spacing: 1px;
+ color: #748391;
+}
+.brand-word i {
+ margin-left: 4px;
+ font-style: normal;
+ color: #79d4ae;
+}
+.eyebrow {
+ font: 500 9px var(--mono);
+ letter-spacing: 1.1px;
+ color: #738493;
+}
+.cluster-picker-wrap {
+ margin: 0 2px 25px;
+ padding: 13px 12px 12px;
+ border: 1px solid #1d303a;
+ border-radius: 9px;
+ background: linear-gradient(
+ 140deg,
+ rgba(22, 45, 47, 0.48),
+ rgba(14, 21, 28, 0.55)
+ );
+}
+.cluster-picker-wrap label {
+ display: block;
+ margin-bottom: 10px;
+}
+.cluster-picker {
+ height: 28px;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+.cluster-picker select {
+ appearance: none;
+ flex: 1;
+ min-width: 0;
+ background: transparent;
+ border: 0;
+ outline: 0;
+ font-size: 12px;
+ font-weight: 600;
+ color: #e5ecef;
+ cursor: pointer;
+}
+.cluster-picker select option {
+ background: #101923;
+}
+.cluster-picker > svg {
+ color: #697986;
+ pointer-events: none;
+}
+.cluster-pip {
+ width: 7px;
+ height: 7px;
+ display: inline-block;
+ background: #61d8a4;
+ border-radius: 50%;
+ box-shadow: 0 0 8px #58d6a36b;
+}
+.cluster-location {
+ padding-left: 15px;
+ color: #74838e;
+ font-size: 10px;
+}
+.nav-heading {
+ padding: 0 11px 9px;
+ font: 500 9px var(--mono);
+ letter-spacing: 1.15px;
+ color: #5e6d78;
+}
+.main-nav {
+ display: grid;
+ gap: 3px;
+}
+.nav-link {
+ height: 38px;
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 0 11px;
+ border-radius: 7px;
+ color: #81909b;
+ font-size: 12px;
+ transition:
+ background 0.15s,
+ color 0.15s;
+}
+.nav-link svg {
+ color: #758592;
+}
+.nav-link:hover {
+ background: #111c24;
+ color: #c9d5dc;
+}
+.nav-link.active {
+ background: #152a28;
+ color: #a5e4ca;
+ box-shadow: inset 2px 0 #5dd3a3;
+}
+.nav-link.active svg {
+ color: #6ed9ad;
+}
+.nav-count {
+ margin-left: auto;
+ min-width: 19px;
+ padding: 2px 5px;
+ border-radius: 9px;
+ text-align: center;
+ font: 10px var(--mono);
+ color: #edbf7d;
+ background: #32281c;
+}
+.sidebar-spacer {
+ flex: 1;
+}
+.sidebar-health {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 12px 9px;
+ margin-bottom: 9px;
+ border: 1px solid #1c2a32;
+ border-radius: 8px;
+ background: #0d171b;
+}
+.health-orb {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background: #6bd5a6;
+ box-shadow: 0 0 9px #68d8a26b;
+}
+.sidebar-health div:last-child {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+.sidebar-health b {
+ font-size: 10px;
+ font-weight: 600;
+ color: #b7c8c7;
+}
+.sidebar-health small {
+ font-size: 9px;
+ color: #677782;
+}
+.settings-link {
+ margin-bottom: 8px;
+}
+.sidebar-footer {
+ display: flex;
+ align-items: center;
+ gap: 9px;
+ border-top: 1px solid #1b2730;
+ padding: 13px 8px 0;
+ color: #74828d;
+}
+.avatar,
+.top-avatar {
+ display: grid;
+ place-items: center;
+ border: 1px solid #334451;
+ background: #182630;
+ color: #bbd0d6;
+ font: 500 9px var(--mono);
+ border-radius: 50%;
+}
+.avatar {
+ width: 26px;
+ height: 26px;
+}
+.profile-copy {
+ display: grid;
+ gap: 3px;
+ flex: 1;
+}
+.profile-copy b {
+ font-size: 10px;
+ color: #c1ccd2;
+ font-weight: 500;
+}
+.profile-copy small {
+ font-size: 9px;
+ color: #6b7a86;
+}
+.sidebar-footer > svg {
+ color: #586873;
+}
+.main-area {
+ margin-left: 248px;
+ min-height: 100vh;
+ display: flex;
+ flex-direction: column;
+ transition: margin-left 0.2s ease;
+}
+.topbar {
+ height: 59px;
+ flex: none;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0 34px;
+ border-bottom: 1px solid #17232b;
+ background: rgba(9, 14, 19, 0.72);
+}
+.crumb {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ color: #768591;
+ font-size: 11px;
+}
+.crumb strong {
+ font-weight: 500;
+ color: #d7e1e7;
+}
+.crumb-slash {
+ color: #34424c;
+}
+.top-actions {
+ display: flex;
+ align-items: center;
+ gap: 9px;
+}
+.button {
+ height: 34px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ padding: 0 12px;
+ border: 1px solid #293944;
+ border-radius: 6px;
+ color: #b8c5cc;
+ background: #111a22;
+ font-size: 11px;
+ font-weight: 500;
+ white-space: nowrap;
+ transition:
+ background 0.15s,
+ border 0.15s,
+ color 0.15s;
+}
+.button:hover {
+ background: #182630;
+ border-color: #3a515f;
+ color: #e6eff2;
+}
+.button-primary {
+ background: #c4f1db;
+ color: #10251d;
+ border-color: #c4f1db;
+ font-weight: 700;
+}
+.button-primary:hover {
+ background: #a5e5c7;
+ border-color: #a5e5c7;
+ color: #10251d;
+}
+.button-ghost {
+ background: transparent;
+ border-color: transparent;
+ padding: 0 8px;
+ color: #8a99a4;
+}
+.button-danger {
+ color: #f29b9b;
+ border-color: #4c2b31;
+ background: #201416;
+}
+.button:disabled {
+ opacity: 0.45;
+ cursor: not-allowed;
+}
+.search-trigger {
+ height: 31px;
+ width: 232px;
+ justify-content: flex-start;
+ color: #70808d;
+ background: #0d141a;
+ border-color: #1c2931;
+ font-size: 10px;
+}
+.search-trigger kbd {
+ margin-left: auto;
+}
+.top-actions > button {
+ position: relative;
+ width: 32px;
+ padding: 0;
+}
+.notification-dot {
+ position: absolute;
+ right: 6px;
+ top: 7px;
+ width: 5px;
+ height: 5px;
+ border: 1px solid #0d1319;
+ border-radius: 50%;
+ background: #ed9b6c;
+}
+.top-divider {
+ height: 22px;
+ width: 1px;
+ margin: 0 4px;
+ background: #25313a;
+}
+.top-avatar {
+ width: 26px;
+ height: 26px;
+}
+.mobile-menu {
+ display: none;
+}
+.page-scroll {
+ width: min(100%, 1500px);
+ margin: 0 auto;
+ padding: 33px 34px 40px;
+ flex: 1;
+}
+.app-footer {
+ height: 36px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0 35px;
+ border-top: 1px solid #152029;
+ color: #657580;
+ font-size: 9px;
+}
+.app-footer b {
+ margin-left: 7px;
+ font: 500 8px var(--mono);
+ letter-spacing: 0.5px;
+ color: #76cdaa;
+}
+.app-footer i {
+ padding: 0 5px;
+ color: #354550;
+}
+.page-title {
+ display: flex;
+ align-items: flex-end;
+ justify-content: space-between;
+ gap: 24px;
+ margin-bottom: 24px;
+}
+.page-title h1,
+.not-found h1 {
+ margin: 7px 0 5px;
+ font: 600 28px / 1.15 var(--display);
+ letter-spacing: -0.8px;
+ color: #eaf0f2;
+}
+.page-title p {
+ margin: 0;
+ color: #82919b;
+ font-size: 12px;
+}
+.home-hero {
+ min-height: 322px;
+ position: relative;
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) minmax(280px, 0.88fr);
+ align-items: center;
+ gap: 18px;
+ overflow: hidden;
+ margin-bottom: 19px;
+ padding: 30px 34px 26px;
+ border: 1px solid #213540;
+ border-radius: 13px;
+ background: radial-gradient(
+ ellipse at 76% 45%,
+ #123245 0,
+ #0c1a23 31%,
+ #0a1117 72%
+ );
+ box-shadow:
+ inset 0 1px #e7faff0a,
+ 0 18px 55px #0003;
+}
+.home-hero::before {
+ position: absolute;
+ inset: 0;
+ content: "";
+ opacity: 0.3;
+ pointer-events: none;
+ background-image:
+ linear-gradient(#8ac7e20c 1px, transparent 1px),
+ linear-gradient(90deg, #8ac7e20c 1px, transparent 1px);
+ background-size: 36px 36px;
+ mask-image: linear-gradient(90deg, transparent, black 72%);
+}
+.home-hero-copy {
+ position: relative;
+ z-index: 1;
+ max-width: 590px;
+}
+.home-hero .eyebrow {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ color: #8faab6;
+}
+.hero-eyebrow-mark {
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background: #68e0d0;
+ box-shadow: 0 0 12px #68e0d0;
+}
+.home-hero h1 {
+ margin: 18px 0 12px;
+ color: #e8f4f7;
+ font: 500 clamp(34px, 4.2vw, 58px) / 1.07 var(--display);
+ letter-spacing: -2.6px;
+}
+.home-hero h1 em {
+ color: #83d3e7;
+ font-style: normal;
+ text-shadow: 0 0 34px #58c9e32e;
+}
+.home-hero-copy > p {
+ max-width: 420px;
+ margin: 0;
+ color: #90a1aa;
+ font-size: 13px;
+ line-height: 1.65;
+}
+.hero-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 9px;
+ margin-top: 21px;
+}
+.hero-footnote {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-top: 25px;
+ color: #748994;
+ font: 8px var(--mono);
+ letter-spacing: 0.55px;
+}
+.hero-footnote i {
+ width: 3px;
+ height: 3px;
+ border-radius: 50%;
+ background: #49606c;
+}
+.status-pulse {
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background: #67d9b0;
+ box-shadow: 0 0 8px #67d9b0;
+}
+.hero-watermark {
+ position: absolute;
+ right: 16px;
+ bottom: 12px;
+ color: #d6f6ff17;
+ font: 9px var(--mono);
+ letter-spacing: 1px;
+}
+.holo-visual {
+ position: relative;
+ width: min(100%, 390px);
+ aspect-ratio: 1;
+ justify-self: center;
+}
+.holo-visual svg {
+ width: 100%;
+ height: 100%;
+ overflow: visible;
+}
+.holo-visual svg g > ellipse,
+.holo-visual svg g > circle {
+ fill: none;
+ stroke: url(#holo-line-gradient);
+ stroke-width: 0.24;
+}
+.holo-visual svg .holo-signal line {
+ stroke: #62cfe6;
+ stroke-width: 0.18;
+ opacity: 0.36;
+}
+.holo-visual svg .holo-signal circle {
+ fill: #c3fbff;
+ filter: drop-shadow(0 0 2px #72dfff);
+}
+.holo-visual svg .holo-core-glow {
+ fill: url(#holo-core-gradient);
+ transform-origin: center;
+}
+.holo-visual svg .holo-core {
+ fill: #143e50;
+ stroke: #8beaff;
+ stroke-width: 0.7;
+ filter: drop-shadow(0 0 5px #68d8ff);
+}
+.holo-visual svg .holo-core-center {
+ fill: #e6ffff;
+ filter: drop-shadow(0 0 4px #8beaff);
+}
+.holo-visual-label,
+.holo-visual-footer {
+ position: absolute;
+ z-index: 1;
+ display: flex;
+ align-items: center;
+ gap: 7px;
+ color: #7898a6;
+ font: 8px var(--mono);
+ letter-spacing: 0.6px;
+}
+.holo-visual-label {
+ top: 10%;
+ left: 1%;
+}
+.holo-visual-label i {
+ width: 5px;
+ height: 5px;
+ border-radius: 50%;
+ background: #7cf1d7;
+}
+.holo-visual-label span {
+ color: #78a9bb;
+}
+.holo-visual-footer {
+ right: 0;
+ bottom: 7%;
+ justify-content: space-between;
+ width: 100%;
+}
+.home-pulse-grid {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 13px;
+ margin: 14px 0;
+}
+.home-pulse-card {
+ min-width: 0;
+ padding: 16px;
+}
+.home-cluster-list {
+ display: grid;
+}
+.home-cluster-row {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ min-height: 42px;
+ border-top: 1px solid #1c2931;
+}
+.home-cluster-row > span:nth-child(2) {
+ display: grid;
+ gap: 3px;
+ flex: 1;
+}
+.home-cluster-row b,
+.agent-summary-row b {
+ color: #c7d4d9;
+ font-size: 10px;
+ font-weight: 500;
+}
+.home-cluster-row small,
+.agent-summary-row small {
+ display: block;
+ color: #71828b;
+ font-size: 8px;
+}
+.home-cluster-row > strong {
+ color: #b2c7cf;
+ font: 10px var(--mono);
+}
+.home-cluster-row > strong small {
+ display: inline;
+ font-size: 7px;
+}
+.agent-summary-row {
+ display: flex;
+ align-items: center;
+ gap: 9px;
+ min-height: 42px;
+ border-top: 1px solid #1c2931;
+}
+.agent-summary-row > span:nth-child(2) {
+ display: grid;
+ gap: 3px;
+ flex: 1;
+}
+.agent-summary-icon {
+ display: grid;
+ place-items: center;
+ width: 27px;
+ height: 27px;
+ border: 1px solid #2d5860;
+ border-radius: 7px;
+ color: #82d7dd;
+ background: #112a31;
+}
+.agent-summary-icon.purple {
+ color: #c0a4ff;
+ border-color: #493b64;
+ background: #221a31;
+}
+.agent-state-dot {
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background: #70d7b0;
+ box-shadow: 0 0 7px #70d7b0;
+}
+.agent-summary-foot {
+ display: flex;
+ gap: 6px;
+ align-items: center;
+ margin-top: 8px;
+ color: #8799a2;
+ font-size: 8px;
+}
+.software-summary-list {
+ display: grid;
+}
+.software-summary-list > div {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ min-height: 35px;
+ border-top: 1px solid #1c2931;
+}
+.software-summary-list > div > span {
+ display: grid;
+ place-items: center;
+ width: 23px;
+ height: 23px;
+ border: 1px solid #314752;
+ border-radius: 6px;
+ background: #13252e;
+ color: #91d9e5;
+ font: 10px var(--display);
+}
+.software-summary-list b {
+ flex: 1;
+ color: #c0d0d5;
+ font-size: 9px;
+ font-weight: 500;
+}
+.software-summary-list small {
+ color: #778c95;
+ font: 7px var(--mono);
+}
+.home-alert-strip {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ margin: 13px 0;
+ padding: 11px 14px;
+ border: 1px solid #3d3628;
+ border-radius: 7px;
+ background: #1a1813;
+ color: #d4b174;
+}
+.home-alert-strip > span {
+ display: grid;
+ gap: 3px;
+ flex: 1;
+}
+.home-alert-strip b {
+ font-size: 9px;
+ font-weight: 500;
+}
+.home-alert-strip small {
+ color: #9b8b6d;
+ font-size: 8px;
+}
+.home-alert-strip a {
+ display: flex;
+ align-items: center;
+ gap: 5px;
+ color: #c2af88;
+ font-size: 8px;
+}
+.route-state {
+ min-height: 54vh;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 12px;
+ color: #9caeb5;
+ text-align: center;
+}
+.route-state-spinner {
+ color: #72d6d0;
+ animation: route-spin 1.4s linear infinite;
+}
+.route-state-error {
+ color: #d2a8a3;
+}
+.route-state-error > span {
+ color: #8e9da4;
+}
+.route-state-error .button {
+ margin-top: 3px;
+}
+@keyframes route-spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+.title-actions {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+.panel {
+ border: 1px solid var(--border);
+ border-radius: 9px;
+ background: linear-gradient(
+ 145deg,
+ rgba(16, 24, 32, 0.98),
+ rgba(13, 20, 27, 0.98)
+ );
+ box-shadow: 0 7px 24px #02060920;
+}
+.stats-grid {
+ display: grid;
+ grid-template-columns: repeat(5, minmax(0, 1fr));
+ gap: 13px;
+ margin-bottom: 17px;
+}
+.stat-card {
+ min-height: 124px;
+ padding: 15px 16px 13px;
+ position: relative;
+}
+.stat-icon {
+ position: absolute;
+ right: 15px;
+ top: 15px;
+ width: 29px;
+ height: 29px;
+ display: grid;
+ place-items: center;
+ border: 1px solid #214033;
+ border-radius: 7px;
+ color: var(--green);
+ background: #14251f;
+}
+.stat-icon.blue {
+ color: var(--blue);
+ border-color: #243b4b;
+ background: #14222c;
+}
+.stat-icon.purple {
+ color: var(--purple);
+ border-color: #3d3455;
+ background: #211c2e;
+}
+.stat-icon.amber {
+ color: var(--amber);
+ border-color: #493b27;
+ background: #292318;
+}
+.stat-icon.red {
+ color: var(--red);
+ border-color: #4d3032;
+ background: #2b1d20;
+}
+.stat-label {
+ color: #8996a0;
+ font-size: 10px;
+}
+.stat-value {
+ margin-top: 12px;
+ color: #e7eef0;
+ font: 600 24px var(--display);
+ letter-spacing: -0.6px;
+}
+.stat-detail {
+ margin-top: 3px;
+ color: #72818c;
+ font-size: 9px;
+}
+.dashboard-grid {
+ display: grid;
+ grid-template-columns: minmax(0, 1.72fr) minmax(270px, 0.9fr);
+ gap: 14px;
+}
+.overview-chart-card,
+.quick-actions,
+.node-overview,
+.activity-overview {
+ padding: 17px 18px;
+}
+.overview-chart-card {
+ min-height: 330px;
+}
+.panel-heading {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 14px;
+}
+.panel-heading h2,
+.table-toolbar h2 {
+ margin: 5px 0 0;
+ color: #dce5e9;
+ font: 600 15px var(--display);
+ letter-spacing: -0.2px;
+}
+.chart-legend {
+ display: flex;
+ align-items: center;
+ gap: 14px;
+ color: #82919b;
+ font-size: 9px;
+}
+.chart-legend > span {
+ display: flex;
+ align-items: center;
+ gap: 5px;
+}
+.legend-dot {
+ width: 6px;
+ height: 6px;
+ display: inline-block;
+ border-radius: 50%;
+}
+.cpu-dot {
+ background: #62d3a5;
+}
+.mem-dot {
+ background: #65b9ff;
+}
+.neutral-dot {
+ background: #33434d;
+}
+.select-pill {
+ height: 29px;
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ padding: 0 9px;
+ border: 1px solid #293842;
+ border-radius: 6px;
+ background: #101820;
+ color: #a4b0b7;
+ font-size: 10px;
+}
+.resource-summary {
+ display: flex;
+ gap: 36px;
+ margin: 21px 0 2px;
+}
+.resource-summary > div {
+ display: grid;
+ grid-template-columns: max-content auto;
+ column-gap: 9px;
+ align-items: baseline;
+}
+.resource-summary > div > span {
+ grid-column: 1 / -1;
+ margin-bottom: 2px;
+ color: #72828d;
+ font: 9px var(--mono);
+ letter-spacing: 0.45px;
+}
+.resource-summary b {
+ font: 600 18px var(--display);
+ color: #e2ebed;
+}
+.resource-summary i {
+ font: 10px var(--mono);
+ font-style: normal;
+ color: #91a2ab;
+}
+.resource-summary small {
+ display: flex;
+ align-items: center;
+ gap: 3px;
+ color: #71818a;
+ font-size: 8px;
+}
+.resource-summary .positive {
+ color: #64cd9e;
+}
+.chart-wrap {
+ height: 200px;
+ width: 100%;
+}
+.chart-compact {
+ height: 130px;
+}
+.home-chart {
+ height: 183px;
+ margin-top: 5px;
+}
+.quick-actions {
+ min-height: 330px;
+}
+.action-list {
+ display: grid;
+ margin-top: 17px;
+}
+.action-tile {
+ height: 61px;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ border-bottom: 1px solid #202b34;
+}
+.action-tile:last-child {
+ border-bottom: 0;
+}
+.action-icon {
+ width: 30px;
+ height: 30px;
+ display: grid;
+ place-items: center;
+ border-radius: 7px;
+ background: #14222c;
+ color: #70bafa;
+}
+.action-icon.green {
+ background: #14251f;
+ color: #68d6a5;
+}
+.action-icon.purple {
+ background: #211d2d;
+ color: #b49cf7;
+}
+.action-tile > span:nth-child(2) {
+ display: grid;
+ gap: 4px;
+ flex: 1;
+}
+.action-tile b {
+ font-size: 10px;
+ font-weight: 600;
+ color: #c6d1d6;
+}
+.action-tile small {
+ font-size: 9px;
+ color: #75848e;
+}
+.action-tile > svg {
+ color: #667783;
+}
+.quick-note {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ margin-top: 12px;
+ color: #74838d;
+ font-size: 8px;
+}
+.quick-note svg {
+ color: #e2b174;
+}
+.node-overview,
+.activity-overview {
+ min-height: 250px;
+}
+.text-link {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ color: #8bcbb2;
+ font-size: 9px;
+}
+.text-link:hover {
+ color: #c2f0dc;
+}
+.overview-node-list {
+ margin-top: 15px;
+}
+.overview-node {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ min-height: 47px;
+ border-bottom: 1px solid #1c2931;
+}
+.overview-node:last-child {
+ border-bottom: 0;
+}
+.node-indicator {
+ width: 7px;
+ height: 7px;
+ flex: none;
+ border-radius: 50%;
+ background: #61d5a2;
+ box-shadow: 0 0 8px #61d5a263;
+}
+.node-indicator.warning,
+.health-mini.warning {
+ background: #e6af65;
+ box-shadow: 0 0 7px #e6af6540;
+}
+.node-indicator.offline,
+.health-mini.offline {
+ background: #65717a;
+ box-shadow: none;
+}
+.overview-node-name {
+ width: 135px;
+ display: grid;
+ gap: 3px;
+}
+.overview-node-name b,
+.node-cell b {
+ font-size: 10px;
+ font-weight: 600;
+ color: #c8d4d9;
+}
+.overview-node-name small,
+.node-cell small {
+ color: #74838d;
+ font: 9px var(--mono);
+}
+.overview-cpu {
+ display: grid;
+ grid-template-columns: 1fr auto;
+ gap: 4px 7px;
+ align-items: center;
+ flex: 1;
+}
+.overview-cpu small {
+ color: #75848e;
+ font: 8px var(--mono);
+}
+.overview-cpu b {
+ font: 10px var(--mono);
+ color: #b8c9d0;
+}
+.overview-cpu .progress-track {
+ grid-column: 1 / -1;
+}
+.health {
+ height: 20px;
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ padding: 0 6px;
+ border: 1px solid #234336;
+ border-radius: 4px;
+ background: #13251f;
+ color: #7ed8ae;
+ font-size: 8px;
+ white-space: nowrap;
+}
+.health-warning {
+ border-color: #55432b;
+ background: #2a241b;
+ color: #eabc79;
+}
+.health-offline {
+ border-color: #39434a;
+ background: #1c2429;
+ color: #9aa5aa;
+}
+.health-unknown {
+ border-color: #33404b;
+ background: #19222a;
+ color: #9aaeb9;
+}
+.mini-timeline {
+ margin-top: 16px;
+}
+.activity-line {
+ position: relative;
+ display: flex;
+ align-items: flex-start;
+ gap: 11px;
+ padding: 12px 0;
+ border-bottom: 1px solid #1b2830;
+}
+.activity-line:last-child {
+ border-bottom: 0;
+}
+.activity-marker {
+ width: 7px;
+ height: 7px;
+ flex: none;
+ margin-top: 4px;
+ border-radius: 50%;
+ background: #62b9f1;
+ box-shadow: 0 0 7px #62b9f13d;
+}
+.activity-marker.success {
+ background: #62d3a5;
+}
+.activity-marker.warning {
+ background: #e8b268;
+}
+.activity-marker.error {
+ background: #e47c7c;
+}
+.activity-copy {
+ flex: 1;
+}
+.activity-copy b {
+ display: block;
+ color: #c7d1d6;
+ font-size: 10px;
+ font-weight: 500;
+}
+.activity-copy p {
+ margin: 4px 0 0;
+ color: #788792;
+ font-size: 9px;
+}
+.activity-line time {
+ color: #6f7e89;
+ font: 9px var(--mono);
+ white-space: nowrap;
+}
+.activity-overview .activity-line {
+ padding: 10px 0;
+}
+.demo-banner {
+ display: flex;
+ align-items: center;
+ gap: 11px;
+ margin-bottom: 16px;
+ padding: 11px 13px;
+ border: 1px solid #244338;
+ border-radius: 7px;
+ background: linear-gradient(90deg, #13231f, #121b1e);
+ color: #9fd5bc;
+}
+.demo-banner-icon {
+ width: 27px;
+ height: 27px;
+ display: grid;
+ place-items: center;
+ border-radius: 6px;
+ background: #1a352a;
+ color: #75d5ab;
+}
+.demo-banner > div {
+ display: grid;
+ gap: 3px;
+ flex: 1;
+}
+.demo-banner b {
+ font-size: 10px;
+ font-weight: 600;
+ color: #b9e2d0;
+}
+.demo-banner span {
+ font-size: 9px;
+ color: #80a894;
+}
+.demo-banner button {
+ display: grid;
+ place-items: center;
+ border: 0;
+ background: transparent;
+ color: #79968a;
+}
+.toast-demo {
+ position: fixed;
+ right: 30px;
+ top: 75px;
+ z-index: 20;
+ padding: 11px 15px;
+ border: 1px solid #31594a;
+ border-radius: 7px;
+ background: #14251f;
+ color: #b5e9cf;
+ font-size: 11px;
+ box-shadow: 0 10px 30px #0008;
+}
+.progress-track {
+ height: 4px;
+ overflow: hidden;
+ border-radius: 5px;
+ background: #24313a;
+}
+.progress-fill {
+ height: 100%;
+ border-radius: 5px;
+ background: #65b9ff;
+}
+.progress-fill.green {
+ background: #60d3a4;
+}
+.progress-fill.purple {
+ background: #a992eb;
+}
+.progress-fill.amber {
+ background: #e8b365;
+}
+.progress-fill.red {
+ background: #e27b7d;
+}
+.metric-line {
+ margin-top: 17px;
+}
+.metric-top {
+ display: flex;
+ justify-content: space-between;
+ margin-bottom: 7px;
+ color: #91a0a8;
+ font-size: 10px;
+}
+.metric-top b {
+ color: #d2dce0;
+ font: 11px var(--mono);
+}
+.metric-line small {
+ display: block;
+ margin-top: 5px;
+ color: #778690;
+ font-size: 9px;
+}
+.context-strip {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin: -4px 0 14px;
+ padding: 11px 14px;
+ border: 1px solid #1e3038;
+ border-radius: 7px;
+ background: #0d171b;
+ color: #84939b;
+ font-size: 10px;
+}
+.context-strip > div:first-child {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+.context-strip b {
+ color: #d1dfdf;
+}
+.context-separator {
+ height: 13px;
+ width: 1px;
+ margin: 0 4px;
+ background: #2c3940;
+}
+.context-demo,
+.availability-tag {
+ font: 9px var(--mono);
+ letter-spacing: 0.5px;
+ color: #78c9a3;
+}
+.topology-layout {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) 260px;
+ gap: 14px;
+}
+.topology-board {
+ height: min(560px, 60vh);
+ min-height: 390px;
+ position: relative;
+ overflow: hidden;
+ border: 1px solid #22343d;
+ border-radius: 9px;
+ background:
+ radial-gradient(
+ ellipse at 50% 40%,
+ rgba(34, 101, 84, 0.13),
+ transparent 44%
+ ),
+ linear-gradient(160deg, #0c171c, #0b1218);
+}
+.topology-grid {
+ position: absolute;
+ inset: 0;
+ background-image:
+ linear-gradient(rgba(73, 116, 121, 0.08) 1px, transparent 1px),
+ linear-gradient(90deg, rgba(73, 116, 121, 0.08) 1px, transparent 1px);
+ background-size: 38px 38px;
+ mask-image: linear-gradient(transparent, #000 12%, #000 90%, transparent);
+}
+.topology-links {
+ position: absolute;
+ inset: 0;
+ width: 100%;
+ height: 100%;
+ overflow: visible;
+}
+.link-line {
+ stroke: #326e5e;
+ stroke-width: 0.23;
+ stroke-dasharray: 1.6 1.3;
+ animation: flow 12s linear infinite;
+ opacity: 0.72;
+}
+.link-line.warning {
+ stroke: #b68d50;
+}
+.link-line.offline {
+ stroke: #47535b;
+ opacity: 0.35;
+}
+.topology-node {
+ position: absolute;
+ z-index: 2;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ transform: translate(-50%, -50%);
+ border: 0;
+ background: transparent;
+}
+.coordinator-node {
+ left: 50%;
+ top: 32%;
+ gap: 7px;
+}
+.node-avatar {
+ width: 54px;
+ height: 54px;
+ display: grid;
+ place-items: center;
+ border: 1px solid #428f73;
+ border-radius: 16px;
+ background: linear-gradient(145deg, #193d32, #10231f);
+ color: #82e0b6;
+ box-shadow:
+ 0 0 30px #46c08c2c,
+ inset 0 0 17px #50ba7b1c;
+}
+.coordinator-node.selected .node-avatar {
+ outline: 2px solid #9aebc4;
+ outline-offset: 3px;
+}
+.node-pulse {
+ position: absolute;
+ top: -5px;
+ right: -6px;
+ width: 8px;
+ height: 8px;
+ border: 2px solid #0d171b;
+ border-radius: 50%;
+ background: #67d5a1;
+ box-shadow: 0 0 10px #67d5a1;
+}
+.node-name {
+ font-size: 10px;
+ font-weight: 600;
+ color: #d2e1e0;
+ white-space: nowrap;
+}
+.node-role {
+ font: 8px var(--mono);
+ letter-spacing: 0.5px;
+ color: #80b89e;
+}
+.worker-node {
+ min-width: 110px;
+ padding: 9px 11px;
+ border: 1px solid #29423c;
+ border-radius: 8px;
+ background: #101d1b;
+ box-shadow: 0 6px 20px #050d0b80;
+}
+.worker-node:hover,
+.worker-node.selected {
+ border-color: #68c79f;
+ background: #14271f;
+ box-shadow: 0 0 18px #5bd7a22b;
+}
+.worker-node .node-name {
+ font-size: 9px;
+}
+.worker-node .node-role {
+ margin-top: 4px;
+ color: #829991;
+}
+.worker-dot {
+ position: absolute;
+ left: -4px;
+ top: -4px;
+ width: 7px;
+ height: 7px;
+ border: 1px solid #0c1418;
+ border-radius: 50%;
+ background: #65d5a3;
+}
+.worker-node.warning {
+ border-color: #50402b;
+}
+.worker-node.warning .worker-dot {
+ background: #e7b36e;
+}
+.worker-node.offline {
+ border-color: #303b41;
+ background: #141b1f;
+}
+.worker-node.offline .worker-dot {
+ background: #68737a;
+}
+.worker-node.offline .node-name {
+ color: #9aa5a8;
+}
+.topology-caption {
+ position: absolute;
+ bottom: 13px;
+ left: 14px;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ color: #768791;
+ font: 8px var(--mono);
+ letter-spacing: 0.45px;
+}
+.topology-caption .live-dot {
+ width: 5px;
+ height: 5px;
+}
+.topology-hint,
+.topology-detail {
+ padding: 17px;
+ border: 1px solid var(--border);
+ border-radius: 9px;
+ background: #0e161d;
+}
+.topology-hint {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ justify-content: center;
+ min-height: 180px;
+}
+.hint-icon {
+ width: 36px;
+ height: 36px;
+ display: grid;
+ place-items: center;
+ border: 1px solid #273b44;
+ border-radius: 9px;
+ color: #73bda0;
+ background: #13231f;
+}
+.topology-hint strong {
+ margin-top: 14px;
+ font-size: 12px;
+}
+.topology-hint p {
+ color: #7c8b95;
+ font-size: 10px;
+ line-height: 1.6;
+}
+.detail-head,
+.drawer-head {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+.detail-head h3 {
+ margin: 7px 0 0;
+ font: 600 15px var(--display);
+}
+.topology-detail > .health {
+ margin: 12px 0 4px;
+}
+.muted {
+ color: #7d8c95;
+}
+.topology-detail > .muted {
+ font-size: 9px;
+}
+.detail-metrics {
+ display: grid;
+ gap: 16px;
+ margin: 20px 0;
+}
+.detail-metrics > div {
+ display: grid;
+ grid-template-columns: 1fr auto;
+ gap: 7px;
+ color: #83919a;
+ font-size: 9px;
+}
+.detail-metrics b {
+ color: #c8d6da;
+ font: 10px var(--mono);
+}
+.detail-metrics .progress-track {
+ grid-column: 1 / -1;
+}
+.detail-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ min-height: 32px;
+ border-top: 1px solid #202b33;
+ color: #86949d;
+ font-size: 9px;
+}
+.detail-row b {
+ color: #c4d0d5;
+ font-weight: 500;
+ text-align: right;
+}
+.demo-note {
+ display: flex;
+ align-items: flex-start;
+ gap: 7px;
+ margin-top: 15px;
+ padding: 9px;
+ border: 1px solid #3a3327;
+ border-radius: 6px;
+ background: #1e1b15;
+ color: #c7a976;
+ font-size: 9px;
+ line-height: 1.5;
+}
+.demo-note svg {
+ flex: none;
+}
+.mission-footnote {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ margin-top: 12px;
+ color: #73828b;
+ font-size: 9px;
+}
+.mission-footnote svg {
+ color: #d5a969;
+}
+.cluster-cards {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 14px;
+}
+.cluster-card {
+ padding: 16px;
+}
+.cluster-card-top {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+}
+.cluster-symbol {
+ width: 35px;
+ height: 35px;
+ display: grid;
+ place-items: center;
+ border: 1px solid #29463c;
+ border-radius: 8px;
+ background: #14251f;
+ color: #73cda5;
+}
+.icon-button {
+ width: 28px;
+ height: 26px;
+ border: 0;
+ border-radius: 5px;
+ background: transparent;
+ color: #75848e;
+ letter-spacing: 1px;
+}
+.icon-button:hover {
+ background: #202b33;
+ color: #dbe4e8;
+}
+.cluster-card-name {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-top: 14px;
+}
+.cluster-card-name h2 {
+ margin: 0;
+ color: #dae5e8;
+ font: 600 15px var(--display);
+}
+.selected-tag {
+ display: flex;
+ align-items: center;
+ gap: 3px;
+ padding: 3px 5px;
+ border-radius: 4px;
+ background: #183126;
+ color: #81d5ac;
+ font: 8px var(--mono);
+}
+.cluster-card > p {
+ min-height: 29px;
+ margin: 8px 0 14px;
+ color: #7b8b94;
+ font-size: 10px;
+ line-height: 1.5;
+}
+.cluster-card-meta {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 11px 0;
+ border-top: 1px solid #1e2a32;
+ border-bottom: 1px solid #1e2a32;
+ color: #8997a0;
+ font-size: 9px;
+}
+.cluster-card-meta span {
+ display: flex;
+ align-items: center;
+ gap: 5px;
+}
+.cluster-card-health {
+ display: flex;
+ justify-content: space-between;
+ margin: 11px 0 13px;
+ color: #75858e;
+ font-size: 9px;
+}
+.cluster-card-health span:first-child {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ color: #8ec9ad;
+}
+.health-mini {
+ width: 6px;
+ height: 6px;
+ display: inline-block;
+ border-radius: 50%;
+ background: #64d5a3;
+}
+.cluster-card-actions {
+ display: flex;
+ gap: 7px;
+}
+.cluster-card-actions .button {
+ height: 30px;
+ font-size: 9px;
+}
+.cluster-card-actions .button-primary {
+ flex: 1;
+}
+.cluster-selected {
+ border-color: #315846;
+ box-shadow: 0 0 0 1px #214b3a;
+}
+.new-cluster-card {
+ min-height: 240px;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 10px;
+ border: 1px dashed #2c3b43;
+ border-radius: 9px;
+ background: #0c1319;
+ color: #9cabb3;
+}
+.new-cluster-card:hover {
+ border-color: #5db893;
+ background: #101b1a;
+}
+.new-cluster-card > span {
+ width: 39px;
+ height: 39px;
+ display: grid;
+ place-items: center;
+ border: 1px solid #33453f;
+ border-radius: 50%;
+ color: #7cd2ac;
+ background: #15251f;
+}
+.new-cluster-card b {
+ font-size: 11px;
+ font-weight: 500;
+ color: #c0ccd1;
+}
+.new-cluster-card small {
+ font-size: 9px;
+ color: #75848e;
+}
+.cluster-detail-card {
+ margin-top: 15px;
+ padding: 17px;
+}
+.cluster-detail-grid {
+ display: grid;
+ grid-template-columns: 2fr 1fr 1fr 1fr;
+ gap: 15px;
+ margin: 12px 0;
+}
+.cluster-detail-grid h2 {
+ margin: 0;
+ font: 600 16px var(--display);
+}
+.cluster-detail-grid p {
+ margin: 4px 0;
+ color: #77858f;
+ font-size: 9px;
+}
+.cluster-detail-grid > div:not(:first-child) {
+ display: grid;
+ gap: 5px;
+}
+.cluster-detail-grid small {
+ color: #74838d;
+ font: 8px var(--mono);
+ letter-spacing: 0.6px;
+}
+.cluster-detail-grid b {
+ font-size: 10px;
+ font-weight: 500;
+ color: #bdcbd1;
+}
+.node-summary-strip {
+ height: 43px;
+ display: flex;
+ align-items: center;
+ gap: 21px;
+ margin: -6px 0 14px;
+ padding: 0 14px;
+ border: 1px solid #1e2a33;
+ border-radius: 6px;
+ background: #0d151b;
+ color: #84929a;
+ font-size: 9px;
+}
+.node-summary-strip > div {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+}
+.node-summary-strip > div:first-child svg {
+ color: #77bea2;
+}
+.node-summary-strip b {
+ color: #c8d6da;
+ font: 11px var(--mono);
+}
+.node-summary-strip > .muted {
+ margin-left: auto;
+}
+.table-card {
+ overflow: hidden;
+}
+.table-toolbar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 15px;
+ padding: 15px 16px;
+ border-bottom: 1px solid #202d35;
+}
+.table-toolbar h2 {
+ margin: 0;
+ font-size: 13px;
+}
+.table-toolbar > div:first-child > span {
+ display: block;
+ margin-top: 5px;
+ color: #73838d;
+ font-size: 9px;
+}
+.toolbar-controls {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+.search-box {
+ height: 30px;
+ display: flex;
+ align-items: center;
+ gap: 7px;
+ padding: 0 9px;
+ border: 1px solid #293741;
+ border-radius: 6px;
+ background: #0c141a;
+ color: #70818c;
+}
+.search-box input {
+ width: 118px;
+ border: 0;
+ outline: 0;
+ background: transparent;
+ color: #cbd6dc;
+ font-size: 9px;
+}
+.search-box input::placeholder {
+ color: #70808a;
+}
+.table-scroll {
+ overflow: auto;
+}
+.data-table {
+ width: 100%;
+ border-collapse: collapse;
+ white-space: nowrap;
+ text-align: left;
+}
+.data-table th {
+ height: 34px;
+ padding: 0 13px;
+ background: #0e161c;
+ color: #72828d;
+ font: 8px var(--mono);
+ letter-spacing: 0.5px;
+}
+.data-table td {
+ height: 54px;
+ padding: 0 13px;
+ border-top: 1px solid #1d2931;
+ color: #a9b6bc;
+ font-size: 9px;
+}
+.data-table tbody tr {
+ transition: background 0.12s;
+}
+.data-table tbody tr:hover {
+ background: #141e25;
+}
+.data-table .clickable-row {
+ cursor: pointer;
+}
+.node-cell {
+ display: flex;
+ align-items: center;
+ gap: 9px;
+}
+.node-cell > div {
+ display: grid;
+ gap: 4px;
+}
+.role-pill {
+ padding: 4px 7px;
+ border: 1px solid #2b3941;
+ border-radius: 4px;
+ color: #84939c;
+ font-size: 8px;
+}
+.role-pill.coordinator {
+ border-color: #2a4b3d;
+ background: #14241f;
+ color: #89d2ad;
+}
+.table-meter,
+.task-progress {
+ min-width: 80px;
+ display: flex;
+ align-items: center;
+ gap: 7px;
+}
+.table-meter .progress-track {
+ width: 55px;
+}
+.table-meter span,
+.task-progress span {
+ color: #9cabb3;
+ font: 9px var(--mono);
+}
+.table-footnote {
+ padding: 10px 14px;
+ border-top: 1px solid #1e2a32;
+ color: #72818b;
+ font-size: 8px;
+}
+.node-detail-overlay {
+ position: fixed;
+ inset: 0;
+ z-index: 10;
+ display: flex;
+ justify-content: flex-end;
+ background: #02060996;
+ backdrop-filter: blur(2px);
+}
+.node-detail-drawer {
+ width: min(410px, 100%);
+ height: 100%;
+ overflow: auto;
+ padding: 24px;
+ border-left: 1px solid #263840;
+ background: #0d151b;
+ box-shadow: -20px 0 45px #0005;
+}
+.drawer-head h2 {
+ margin: 6px 0;
+ font: 600 21px var(--display);
+}
+.drawer-host {
+ margin: 10px 0 21px;
+ color: #82919a;
+ font: 10px var(--mono);
+}
+.drawer-host span {
+ padding: 0 5px;
+ color: #45545d;
+}
+.drawer-metric-grid {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 10px;
+}
+.drawer-metric-grid > div {
+ min-height: 92px;
+ display: grid;
+ grid-template-columns: auto 1fr;
+ gap: 6px 7px;
+ align-items: center;
+ padding: 11px;
+ border: 1px solid #202f37;
+ border-radius: 7px;
+ background: #101920;
+}
+.drawer-metric-grid svg {
+ color: #76bba0;
+}
+.drawer-metric-grid small {
+ color: #788892;
+ font: 8px var(--mono);
+}
+.drawer-metric-grid b {
+ grid-column: 1 / -1;
+ font: 600 18px var(--display);
+ color: #dce6e8;
+}
+.drawer-metric-grid .progress-track {
+ grid-column: 1 / -1;
+}
+.drawer-section {
+ margin-top: 24px;
+}
+.drawer-section h3 {
+ margin: 0 0 8px;
+ color: #cbd8dc;
+ font: 600 12px var(--display);
+}
+.drawer-section > p {
+ color: #798993;
+ font-size: 9px;
+ line-height: 1.5;
+}
+.demo-inline {
+ padding: 2px 4px;
+ border-radius: 3px;
+ background: #202c28;
+ color: #8bc7a9;
+ font: 8px var(--mono);
+}
+.software-intro {
+ display: flex;
+ align-items: center;
+ gap: 13px;
+ margin: -3px 0 16px;
+ padding: 14px;
+ border: 1px solid #293740;
+ border-radius: 8px;
+ background: linear-gradient(110deg, #141e28, #11191f);
+}
+.software-intro-symbol {
+ width: 38px;
+ height: 38px;
+ display: grid;
+ place-items: center;
+ border: 1px solid #34495a;
+ border-radius: 9px;
+ background: #182632;
+ color: #8dc8ef;
+}
+.software-intro b {
+ font-size: 11px;
+}
+.software-intro p {
+ margin: 4px 0 0;
+ color: #85949c;
+ font-size: 9px;
+}
+.software-grid {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 13px;
+}
+.software-card {
+ padding: 15px;
+ min-height: 224px;
+ display: flex;
+ flex-direction: column;
+}
+.software-card-head {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+.software-logo {
+ width: 36px;
+ height: 36px;
+ display: grid;
+ place-items: center;
+ border: 1px solid color-mix(in srgb, var(--software-color) 35%, transparent);
+ border-radius: 10px;
+ background: color-mix(in srgb, var(--software-color) 11%, #0c141a);
+ color: var(--software-color);
+ font: 600 17px var(--display);
+}
+.software-card-head > div:nth-child(2) {
+ flex: 1;
+}
+.software-category {
+ color: #81909a;
+ font: 8px var(--mono);
+ text-transform: uppercase;
+ letter-spacing: 0.6px;
+}
+.software-card h2 {
+ margin: 2px 0 0;
+ color: #d9e3e6;
+ font: 600 13px var(--display);
+}
+.software-card-head small,
+.software-modal-product small {
+ color: #75858f;
+ font-size: 9px;
+}
+.availability-pill {
+ padding: 4px 6px;
+ border: 1px solid #345242;
+ border-radius: 4px;
+ background: #17261f;
+ color: #8ed1ad;
+ font: 8px var(--mono);
+}
+.availability-pill.planned {
+ border-color: #39444c;
+ background: #1a2228;
+ color: #9aa8af;
+}
+.software-card > p {
+ flex: 1;
+ margin: 14px 0;
+ color: #84929a;
+ font-size: 9px;
+ line-height: 1.6;
+}
+.software-features {
+ display: flex;
+ gap: 12px;
+ margin-bottom: 13px;
+ color: #85968f;
+ font-size: 8px;
+}
+.software-features span {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+}
+.software-features svg {
+ color: #76c7a1;
+}
+.software-action {
+ justify-content: space-between;
+ height: 31px;
+}
+.software-future {
+ min-height: 224px;
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ justify-content: center;
+ padding: 19px;
+ border-style: dashed;
+}
+.future-icon {
+ width: 35px;
+ height: 35px;
+ display: grid;
+ place-items: center;
+ border: 1px solid #2c3941;
+ border-radius: 9px;
+ color: #83939a;
+ background: #131d23;
+}
+.software-future h2 {
+ margin: 15px 0 0;
+ font: 600 13px var(--display);
+}
+.software-future p {
+ margin: 7px 0 12px;
+ color: #7e8d96;
+ font-size: 9px;
+ line-height: 1.6;
+}
+.modal-backdrop,
+.command-backdrop {
+ position: fixed;
+ inset: 0;
+ z-index: 30;
+ display: grid;
+ place-items: center;
+ padding: 20px;
+ background: #020609b8;
+ backdrop-filter: blur(5px);
+}
+.modal-card {
+ position: relative;
+ z-index: 1;
+ width: min(440px, 100%);
+ max-height: calc(100vh - 40px);
+ overflow: auto;
+ padding: 19px;
+ border: 1px solid #34454f;
+ border-radius: 10px;
+ background: #101920;
+ box-shadow: 0 24px 80px #0009;
+}
+.overlay-dismiss {
+ position: absolute;
+ inset: 0;
+ width: 100%;
+ height: 100%;
+ border: 0;
+ background: transparent;
+ cursor: default;
+}
+.command-dialog {
+ position: relative;
+ z-index: 1;
+}
+.modal-head {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+}
+.modal-head h2 {
+ margin: 5px 0 0;
+ color: #e3ecee;
+ font: 600 18px var(--display);
+}
+.modal-card > p {
+ margin: 12px 0 16px;
+ color: #83929b;
+ font-size: 10px;
+ line-height: 1.5;
+}
+.modal-card > label {
+ display: grid;
+ gap: 6px;
+ margin-top: 13px;
+ color: #a8b6bc;
+ font-size: 9px;
+}
+.modal-card input:not([type="checkbox"]),
+.modal-card select,
+.modal-card textarea {
+ width: 100%;
+ padding: 9px 10px;
+ border: 1px solid #2b3b44;
+ border-radius: 6px;
+ outline: 0;
+ background: #0b1319;
+ color: #dbe5e8;
+ font-size: 10px;
+}
+.modal-card textarea {
+ resize: vertical;
+}
+.modal-actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 8px;
+ margin-top: 20px;
+}
+.modal-disclaimer {
+ padding: 10px;
+ border: 1px solid #55422a;
+ border-radius: 6px;
+ background: #241f16;
+ color: #d1b276 !important;
+}
+.software-modal-product {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 12px;
+ border: 1px solid #23343d;
+ border-radius: 7px;
+ background: #0d161c;
+}
+.software-modal-product > div {
+ display: grid;
+ gap: 4px;
+}
+.software-modal-product b {
+ font-size: 11px;
+}
+.check-label {
+ display: flex !important;
+ align-items: center;
+ grid-template-columns: auto 1fr !important;
+}
+.check-label input {
+ accent-color: #80d8ad;
+}
+.task-stats {
+ margin-top: 4px;
+}
+.queue-card {
+ margin-bottom: 14px;
+}
+.queue-card .data-table td {
+ height: 58px;
+}
+.task-name {
+ display: grid;
+ gap: 4px;
+}
+.task-name b {
+ font-size: 9px;
+ color: #c7d3d8;
+ font-weight: 500;
+}
+.task-name small {
+ font: 8px var(--mono);
+ color: #72828b;
+}
+.task-status {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ padding: 4px 6px;
+ border-radius: 4px;
+ background: #17252b;
+ color: #92adba;
+ font-size: 8px;
+ text-transform: capitalize;
+}
+.task-status i {
+ width: 5px;
+ height: 5px;
+ border-radius: 50%;
+ background: #76bdde;
+}
+.task-status.running {
+ background: #17252d;
+ color: #78bdeb;
+}
+.task-status.running i {
+ background: #6cbcf2;
+ box-shadow: 0 0 6px #6cbcf2;
+}
+.task-status.queued {
+ background: #2a2419;
+ color: #d7b26e;
+}
+.task-status.queued i {
+ background: #d7b26e;
+}
+.task-status.completed {
+ background: #15251e;
+ color: #83cda5;
+}
+.task-status.completed i {
+ background: #67c994;
+}
+.task-status.failed {
+ background: #2b1d20;
+ color: #e19090;
+}
+.task-status.failed i {
+ background: #e27575;
+}
+.task-progress {
+ width: 116px;
+}
+.task-progress .progress-track {
+ flex: 1;
+}
+.assignment-select {
+ max-width: 148px;
+ padding: 5px 7px;
+ border: 1px solid #2b3941;
+ border-radius: 5px;
+ background: #111a20;
+ color: #aebbc0;
+ font-size: 8px;
+}
+.queue-card .toolbar-controls .button {
+ height: 29px;
+ font-size: 9px;
+}
+.execution-card {
+ padding: 16px;
+}
+.execution-timeline {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ gap: 10px;
+ margin-top: 20px;
+}
+.execution-step {
+ position: relative;
+ display: grid;
+ grid-template-columns: 22px 1fr;
+ gap: 9px;
+ align-items: start;
+ padding-top: 13px;
+ border-top: 1px solid #2a3b43;
+}
+.execution-step::before {
+ content: "";
+ position: absolute;
+ top: -4px;
+ left: 0;
+ width: 7px;
+ height: 7px;
+ border-radius: 50%;
+ background: #67d0a1;
+ box-shadow: 0 0 7px #67d0a166;
+}
+.execution-node {
+ width: 21px;
+ height: 21px;
+ display: grid;
+ place-items: center;
+ border: 1px solid #2c4538;
+ border-radius: 50%;
+ background: #17251f;
+ color: #88d3aa;
+ font: 9px var(--mono);
+}
+.execution-step > div {
+ display: grid;
+ gap: 5px;
+}
+.execution-step > div b {
+ color: #bdcbd0;
+ font-size: 9px;
+ font-weight: 500;
+}
+.execution-step > div small {
+ color: #73828c;
+ font-size: 8px;
+}
+.execution-step > .task-status {
+ grid-column: 2;
+ justify-self: start;
+}
+.monitoring-context {
+ display: flex;
+ align-items: center;
+ gap: 9px;
+ margin: -5px 0 15px;
+ color: #778690;
+ font-size: 9px;
+}
+.monitoring-context > span:first-child {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font: 8px var(--mono);
+ color: #80bea2;
+}
+.live-dot {
+ width: 6px;
+ height: 6px;
+ display: inline-block;
+ border-radius: 50%;
+ background: #61d1a0;
+ box-shadow: 0 0 7px #61d1a078;
+}
+.monitoring-grid {
+ display: grid;
+ grid-template-columns: minmax(0, 1.55fr) minmax(270px, 0.85fr);
+ gap: 14px;
+}
+.monitoring-main-chart,
+.storage-card,
+.monitor-health-card,
+.monitoring-note {
+ padding: 16px;
+}
+.monitoring-main-chart {
+ min-height: 355px;
+}
+.metric-tabs {
+ display: flex;
+ gap: 3px;
+ padding: 3px;
+ border: 1px solid #26343c;
+ border-radius: 6px;
+ background: #0b1319;
+}
+.metric-tabs button {
+ padding: 5px 8px;
+ border: 0;
+ border-radius: 4px;
+ background: transparent;
+ color: #7d8c95;
+ font-size: 9px;
+ text-transform: capitalize;
+}
+.metric-tabs button.active {
+ background: #203129;
+ color: #a7dfc2;
+}
+.monitoring-chart {
+ height: 245px;
+ margin-top: 15px;
+}
+.chart-foot {
+ display: flex;
+ justify-content: space-between;
+ padding-top: 8px;
+ border-top: 1px solid #1e2a32;
+ color: #74838d;
+ font-size: 8px;
+}
+.storage-total {
+ display: flex;
+ align-items: baseline;
+ gap: 8px;
+ margin: 21px 0 13px;
+}
+.storage-total > b {
+ font: 600 23px var(--display);
+ color: #e0e9eb;
+}
+.storage-total small {
+ font-size: 12px;
+ color: #96a7ac;
+}
+.storage-total > span {
+ color: #7d8a93;
+ font-size: 9px;
+}
+.storage-legend {
+ display: flex;
+ justify-content: space-between;
+ margin: 12px 0 18px;
+ color: #7c8c94;
+ font-size: 8px;
+}
+.storage-legend span {
+ display: flex;
+ align-items: center;
+ gap: 5px;
+}
+.storage-legend b {
+ margin-left: 3px;
+ color: #aebbc0;
+ font: 9px var(--mono);
+}
+.storage-row-list {
+ display: grid;
+ gap: 12px;
+}
+.storage-row {
+ display: grid;
+ grid-template-columns: 1fr auto;
+ gap: 6px;
+ color: #97a4aa;
+ font-size: 8px;
+}
+.storage-row b {
+ font: 9px var(--mono);
+ color: #b8c7cb;
+}
+.storage-row .progress-track {
+ grid-column: 1 / -1;
+}
+.monitor-health-card {
+ grid-column: 1 / -1;
+}
+.health-summary-list {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ gap: 0 20px;
+ margin-top: 11px;
+}
+.health-summary-row {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ min-height: 45px;
+ border-bottom: 1px solid #1d2930;
+}
+.health-summary-row > div {
+ display: grid;
+ gap: 3px;
+ flex: 1;
+}
+.health-summary-row b {
+ font-size: 9px;
+ font-weight: 500;
+}
+.health-summary-row small {
+ color: #75848d;
+ font: 8px var(--mono);
+}
+.monitoring-note {
+ grid-column: 1 / -1;
+ display: flex;
+ align-items: flex-start;
+ gap: 10px;
+ border-color: #3b3529;
+ background: #1a1915;
+ color: #d2ad70;
+}
+.monitoring-note div b {
+ font-size: 10px;
+}
+.monitoring-note p {
+ margin: 4px 0 0;
+ color: #a79573;
+ font-size: 9px;
+ line-height: 1.5;
+}
+.activity-filterbar {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-bottom: 13px;
+}
+.activity-filterbar .search-box {
+ width: 250px;
+}
+.activity-filterbar .search-box input {
+ width: 100%;
+}
+.activity-total {
+ margin-left: auto;
+ color: #788791;
+ font: 9px var(--mono);
+}
+.activity-card {
+ padding: 16px 18px;
+}
+.activity-date-label {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 5px 0 4px;
+ color: #799181;
+ font: 8px var(--mono);
+ letter-spacing: 0.7px;
+}
+.activity-date-label span {
+ height: 1px;
+ flex: 1;
+ background: #26342f;
+}
+.activity-date-label.older {
+ margin-top: 15px;
+ color: #75818a;
+}
+.activity-line .activity-source {
+ display: inline-block;
+ margin-top: 6px;
+ color: #78958a;
+ font: 8px var(--mono);
+}
+.activity-card .activity-line {
+ padding: 14px 0;
+}
+.activity-card .activity-copy b {
+ font-size: 10px;
+}
+.activity-card .activity-copy p {
+ font-size: 9px;
+}
+.no-results {
+ display: grid;
+ justify-items: center;
+ gap: 8px;
+ padding: 35px;
+ color: #77858e;
+}
+.no-results b {
+ font-size: 11px;
+ color: #b8c5ca;
+}
+.no-results span {
+ font-size: 9px;
+}
+.activity-warning {
+ max-width: fit-content;
+}
+.settings-layout {
+ display: grid;
+ grid-template-columns: 175px minmax(0, 1fr);
+ gap: 20px;
+ align-items: start;
+}
+.settings-nav {
+ position: sticky;
+ top: 20px;
+ display: grid;
+ gap: 4px;
+}
+.settings-nav a {
+ display: flex;
+ align-items: center;
+ gap: 9px;
+ height: 34px;
+ padding: 0 9px;
+ border-radius: 5px;
+ color: #7e8d96;
+ font-size: 10px;
+}
+.settings-nav a:hover,
+.settings-nav a.active {
+ background: #14211e;
+ color: #9ed6b9;
+}
+.settings-content {
+ display: grid;
+ gap: 13px;
+}
+.settings-card {
+ padding: 17px;
+}
+.settings-card-heading {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding-bottom: 14px;
+ border-bottom: 1px solid #202c34;
+}
+.settings-icon {
+ width: 31px;
+ height: 31px;
+ display: grid;
+ place-items: center;
+ border: 1px solid #293c3e;
+ border-radius: 7px;
+ background: #15211f;
+ color: #7fc5a3;
+}
+.settings-card-heading h2 {
+ margin: 0;
+ color: #d6e0e3;
+ font: 600 12px var(--display);
+}
+.settings-card-heading p {
+ margin: 4px 0 0;
+ color: #788791;
+ font-size: 9px;
+}
+.setting-row {
+ min-height: 54px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ border-bottom: 1px solid #1c2830;
+}
+.setting-row:last-child {
+ border-bottom: 0;
+}
+.setting-row > div:first-child {
+ display: grid;
+ gap: 4px;
+}
+.setting-row b {
+ color: #bfcbd0;
+ font-size: 10px;
+ font-weight: 500;
+}
+.setting-row small {
+ color: #75848d;
+ font-size: 9px;
+}
+.theme-options {
+ display: flex;
+ gap: 4px;
+}
+.theme-options button {
+ height: 26px;
+ padding: 0 9px;
+ border: 1px solid #28373e;
+ border-radius: 5px;
+ background: #10181e;
+ color: #7c8b94;
+ font-size: 9px;
+}
+.theme-options button.selected {
+ border-color: #416d57;
+ background: #192920;
+ color: #a5dabd;
+}
+.toggle {
+ width: 33px;
+ height: 18px;
+ padding: 2px;
+ border: 1px solid #34434a;
+ border-radius: 10px;
+ background: #1c272d;
+}
+.toggle span {
+ width: 12px;
+ height: 12px;
+ display: block;
+ border-radius: 50%;
+ background: #7f8e95;
+ transition: transform 0.15s;
+}
+.toggle.on {
+ border-color: #3a775b;
+ background: #24553d;
+}
+.toggle.on span {
+ transform: translateX(14px);
+ background: #a6e9c4;
+}
+.setting-row kbd {
+ padding: 4px 6px;
+ border: 1px solid #34414a;
+ border-radius: 4px;
+ color: #a7b5bc;
+ font: 9px var(--mono);
+}
+.accent-icon {
+ color: #73c69e;
+}
+.demo-setting-note {
+ display: flex;
+ gap: 8px;
+ margin-top: 12px;
+ padding: 9px;
+ border: 1px solid #443a29;
+ border-radius: 6px;
+ background: #211e17;
+ color: #bda575;
+ font-size: 9px;
+ line-height: 1.5;
+}
+.demo-setting-note svg {
+ flex: none;
+}
+.integration-placeholder {
+ display: flex;
+ align-items: center;
+ gap: 9px;
+ min-height: 49px;
+ border-bottom: 1px solid #1c2830;
+}
+.integration-placeholder:last-child {
+ border-bottom: 0;
+}
+.integration-placeholder > div {
+ display: grid;
+ gap: 4px;
+ flex: 1;
+}
+.integration-placeholder b {
+ color: #b8c6cb;
+ font-size: 9px;
+ font-weight: 500;
+}
+.integration-placeholder small {
+ color: #72818b;
+ font-size: 8px;
+}
+.integration-pip {
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background: #6b7880;
+}
+.save-settings {
+ justify-self: start;
+}
+.save-message {
+ margin-top: -7px;
+ color: #8fd0ac;
+ font-size: 9px;
+}
+.not-found {
+ max-width: 500px;
+ margin: 12vh auto;
+ text-align: center;
+}
+.not-found p {
+ color: #81919a;
+ font-size: 12px;
+}
+.not-found .button {
+ margin-top: 14px;
+}
+.command-dialog {
+ width: min(530px, 100%);
+ overflow: hidden;
+ border: 1px solid #34434c;
+ border-radius: 10px;
+ background: #10191f;
+ box-shadow: 0 24px 70px #000a;
+}
+.command-input-wrap {
+ height: 54px;
+ display: flex;
+ align-items: center;
+ gap: 11px;
+ padding: 0 14px;
+ border-bottom: 1px solid #26343d;
+ color: #7e8f98;
+}
+.command-input-wrap input {
+ flex: 1;
+ border: 0;
+ outline: 0;
+ background: transparent;
+ color: #dbe4e8;
+ font-size: 12px;
+}
+.command-input-wrap kbd {
+ padding: 3px 5px;
+ border: 1px solid #34424a;
+ border-radius: 4px;
+ color: #829099;
+ font: 8px var(--mono);
+}
+.command-label {
+ padding: 12px 15px 5px;
+ color: #74838d;
+ font: 8px var(--mono);
+ letter-spacing: 0.7px;
+}
+.command-item {
+ width: calc(100% - 16px);
+ height: 35px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin: 2px 8px;
+ padding: 0 9px;
+ border: 0;
+ border-radius: 5px;
+ background: transparent;
+ color: #b8c6cb;
+ text-align: left;
+ font-size: 10px;
+}
+.command-item:hover {
+ background: #1b292e;
+ color: #a5e5c6;
+}
+.command-item > span:last-child {
+ color: #70818a;
+}
+.command-empty {
+ padding: 7px 15px;
+ color: #7c8b94;
+ font-size: 10px;
+}
+.command-foot {
+ display: flex;
+ gap: 14px;
+ padding: 10px 15px;
+ border-top: 1px solid #26343d;
+ color: #71808a;
+ font: 8px var(--mono);
+}
+@keyframes flow {
+ to {
+ stroke-dashoffset: -48;
+ }
+}
+@media (prefers-reduced-motion: reduce) {
+ *,
+ *::before,
+ *::after {
+ scroll-behavior: auto !important;
+ animation-duration: 0.01ms !important;
+ animation-iteration-count: 1 !important;
+ transition-duration: 0.01ms !important;
+ }
+}
+@media (max-width: 1150px) {
+ .sidebar {
+ width: 215px;
+ }
+ .main-area {
+ margin-left: 215px;
+ }
+ .dashboard-grid {
+ grid-template-columns: minmax(0, 1.4fr) minmax(250px, 0.9fr);
+ }
+ .overview-cpu {
+ display: none;
+ }
+ .overview-node-name {
+ flex: 1;
+ }
+ .software-grid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+ .cluster-cards {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+ .topology-layout {
+ grid-template-columns: minmax(0, 1fr) 230px;
+ }
+}
+@media (max-width: 820px) {
+ .sidebar {
+ width: 62px;
+ padding: 20px 8px 11px;
+ }
+ .brand {
+ justify-content: center;
+ padding: 0 0 24px;
+ }
+ .brand-word,
+ .cluster-picker-wrap,
+ .nav-heading,
+ .nav-link span,
+ .sidebar-health,
+ .sidebar-footer,
+ .settings-link span {
+ display: none;
+ }
+ .main-nav {
+ gap: 6px;
+ }
+ .nav-link {
+ justify-content: center;
+ padding: 0;
+ }
+ .nav-link svg {
+ width: 18px;
+ height: 18px;
+ }
+ .nav-link.active {
+ box-shadow:
+ inset 0 -2px #5dd3a3,
+ inset 2px 0 #5dd3a3;
+ }
+ .settings-link {
+ display: flex;
+ margin-top: 8px;
+ }
+ .main-area {
+ margin-left: 62px;
+ }
+ .topbar {
+ padding: 0 20px;
+ }
+ .page-scroll {
+ padding: 25px 20px 32px;
+ }
+ .app-footer {
+ padding: 0 20px;
+ }
+ .app-footer span:last-child {
+ max-width: 50%;
+ text-align: right;
+ }
+ .dashboard-grid {
+ grid-template-columns: 1fr 1fr;
+ }
+ .overview-chart-card {
+ grid-column: 1 / -1;
+ }
+ .quick-actions {
+ min-height: 270px;
+ }
+ .node-overview,
+ .activity-overview {
+ min-height: 230px;
+ }
+ .topology-layout {
+ grid-template-columns: 1fr;
+ }
+ .topology-board {
+ height: 480px;
+ }
+ .topology-hint,
+ .topology-detail {
+ min-height: 0;
+ }
+ .monitoring-grid {
+ grid-template-columns: 1fr 1fr;
+ }
+ .monitoring-main-chart {
+ grid-column: 1 / -1;
+ }
+ .storage-card {
+ grid-column: 1 / -1;
+ }
+ .settings-layout {
+ grid-template-columns: 1fr;
+ }
+ .settings-nav {
+ position: static;
+ display: flex;
+ overflow: auto;
+ }
+ .settings-nav a {
+ flex: none;
+ padding: 0 10px;
+ }
+ .cluster-detail-grid {
+ grid-template-columns: 1.5fr 1fr 1fr;
+ }
+ .cluster-detail-grid > div:last-child {
+ display: none;
+ }
+}
+@media (max-width: 600px) {
+ .topbar {
+ height: 52px;
+ padding: 0 12px;
+ }
+ .crumb-parent,
+ .crumb-slash {
+ display: none;
+ }
+ .crumb {
+ gap: 7px;
+ }
+ .mobile-menu {
+ display: inline-flex;
+ width: 29px;
+ height: 29px;
+ padding: 0;
+ justify-content: center;
+ }
+ .search-trigger {
+ width: 34px;
+ padding: 0;
+ justify-content: center;
+ }
+ .search-trigger > span,
+ .search-trigger kbd {
+ display: none;
+ }
+ .top-actions {
+ gap: 4px;
+ }
+ .top-actions > button {
+ height: 30px;
+ }
+ .top-divider {
+ margin: 0 2px;
+ }
+ .page-scroll {
+ padding: 22px 13px 28px;
+ }
+ .page-title {
+ align-items: flex-start;
+ margin-bottom: 17px;
+ }
+ .page-title h1 {
+ font-size: 24px;
+ }
+ .page-title p {
+ max-width: 300px;
+ font-size: 10px;
+ line-height: 1.5;
+ }
+ .title-actions {
+ align-self: flex-end;
+ }
+ .page-title > .title-actions .button {
+ height: 30px;
+ padding: 0 8px;
+ font-size: 9px;
+ }
+ .stats-grid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 8px;
+ margin-bottom: 10px;
+ }
+ .stat-card {
+ min-height: 107px;
+ padding: 11px;
+ }
+ .stat-icon {
+ width: 25px;
+ height: 25px;
+ right: 10px;
+ top: 10px;
+ }
+ .stat-icon svg {
+ width: 14px;
+ }
+ .stat-value {
+ font-size: 21px;
+ margin-top: 10px;
+ }
+ .stat-detail {
+ font-size: 8px;
+ }
+ .dashboard-grid {
+ grid-template-columns: 1fr;
+ gap: 10px;
+ }
+ .overview-chart-card {
+ grid-column: auto;
+ min-height: 290px;
+ padding: 13px;
+ }
+ .quick-actions,
+ .node-overview,
+ .activity-overview {
+ padding: 13px;
+ min-height: 0;
+ }
+ .panel-heading h2 {
+ font-size: 14px;
+ }
+ .chart-legend {
+ gap: 7px;
+ }
+ .chart-legend > span {
+ font-size: 8px;
+ }
+ .chart-legend .select-pill {
+ font-size: 8px;
+ padding: 0 6px;
+ }
+ .resource-summary {
+ gap: 19px;
+ margin-top: 17px;
+ }
+ .resource-summary b {
+ font-size: 16px;
+ }
+ .home-chart {
+ height: 150px;
+ }
+ .action-list {
+ margin-top: 10px;
+ }
+ .action-tile {
+ height: 54px;
+ }
+ .quick-note {
+ font-size: 8px;
+ }
+ .overview-node {
+ gap: 7px;
+ }
+ .overview-node-name {
+ width: auto;
+ flex: 1;
+ }
+ .overview-node .health {
+ font-size: 7px;
+ }
+ .overview-node .progress-track {
+ display: none;
+ }
+ .activity-overview .activity-line {
+ gap: 8px;
+ }
+ .activity-line time {
+ font-size: 8px;
+ }
+ .demo-banner {
+ align-items: flex-start;
+ padding: 9px;
+ }
+ .demo-banner-icon {
+ flex: none;
+ }
+ .demo-banner b {
+ line-height: 1.4;
+ }
+ .demo-banner span {
+ line-height: 1.45;
+ }
+ .app-footer {
+ height: auto;
+ min-height: 38px;
+ gap: 8px;
+ padding: 8px 13px;
+ font-size: 8px;
+ }
+ .app-footer span:last-child {
+ max-width: 59%;
+ line-height: 1.4;
+ }
+ .context-strip {
+ align-items: flex-start;
+ gap: 8px;
+ padding: 10px;
+ font-size: 8px;
+ }
+ .context-strip > div:first-child {
+ flex-wrap: wrap;
+ gap: 6px;
+ }
+ .context-demo {
+ font-size: 7px;
+ white-space: nowrap;
+ }
+ .context-separator {
+ display: none;
+ }
+ .topology-board {
+ height: 400px;
+ min-height: 400px;
+ }
+ .worker-node {
+ min-width: 84px;
+ padding: 7px 6px;
+ }
+ .worker-node .node-name {
+ font-size: 8px;
+ }
+ .node-role {
+ font-size: 7px;
+ }
+ .coordinator-node {
+ top: 31%;
+ }
+ .node-avatar {
+ width: 46px;
+ height: 46px;
+ }
+ .topology-detail {
+ padding: 13px;
+ }
+ .cluster-cards {
+ grid-template-columns: 1fr;
+ gap: 9px;
+ }
+ .cluster-card {
+ padding: 13px;
+ }
+ .new-cluster-card {
+ min-height: 130px;
+ }
+ .cluster-detail-grid {
+ grid-template-columns: 1fr 1fr;
+ }
+ .cluster-detail-grid > div:first-child {
+ grid-column: 1 / -1;
+ }
+ .cluster-detail-grid > div:last-child {
+ display: grid;
+ }
+ .node-summary-strip {
+ height: auto;
+ min-height: 39px;
+ flex-wrap: wrap;
+ gap: 8px 14px;
+ padding: 8px;
+ font-size: 8px;
+ }
+ .node-summary-strip > .muted {
+ display: none;
+ }
+ .table-toolbar {
+ align-items: flex-start;
+ flex-direction: column;
+ padding: 12px;
+ }
+ .toolbar-controls {
+ width: 100%;
+ justify-content: space-between;
+ }
+ .search-box {
+ flex: 1;
+ }
+ .search-box input {
+ width: 100%;
+ }
+ .data-table th,
+ .data-table td {
+ padding: 0 9px;
+ }
+ .data-table {
+ min-width: 720px;
+ }
+ .table-footnote {
+ font-size: 8px;
+ }
+ .software-grid {
+ grid-template-columns: 1fr;
+ }
+ .software-intro {
+ align-items: flex-start;
+ padding: 11px;
+ }
+ .software-intro p {
+ line-height: 1.5;
+ }
+ .software-card {
+ min-height: 210px;
+ }
+ .task-stats {
+ margin-bottom: 10px;
+ }
+ .queue-card .table-toolbar {
+ gap: 9px;
+ }
+ .queue-card .toolbar-controls {
+ justify-content: flex-start;
+ flex-wrap: wrap;
+ }
+ .execution-timeline {
+ grid-template-columns: 1fr 1fr;
+ row-gap: 17px;
+ }
+ .monitoring-grid {
+ grid-template-columns: 1fr;
+ }
+ .monitoring-main-chart,
+ .storage-card,
+ .monitor-health-card,
+ .monitoring-note {
+ grid-column: auto;
+ }
+ .monitoring-main-chart {
+ min-height: 310px;
+ padding: 12px;
+ }
+ .monitoring-chart {
+ height: 205px;
+ }
+ .metric-tabs button {
+ padding: 5px 6px;
+ font-size: 8px;
+ }
+ .storage-card,
+ .monitor-health-card {
+ padding: 12px;
+ }
+ .health-summary-list {
+ grid-template-columns: 1fr;
+ }
+ .activity-filterbar {
+ flex-wrap: wrap;
+ }
+ .activity-filterbar .search-box {
+ width: 100%;
+ flex: none;
+ }
+ .activity-filterbar .select-pill {
+ flex: 1;
+ }
+ .activity-total {
+ margin-left: auto;
+ }
+ .activity-card {
+ padding: 12px;
+ }
+ .activity-card .activity-line {
+ gap: 8px;
+ }
+ .activity-copy b {
+ line-height: 1.45;
+ }
+ .activity-copy p {
+ line-height: 1.45;
+ }
+ .activity-card time {
+ font-size: 7px;
+ }
+ .settings-nav {
+ gap: 3px;
+ }
+ .settings-nav a {
+ height: 31px;
+ gap: 5px;
+ padding: 0 7px;
+ font-size: 8px;
+ }
+ .settings-nav a svg {
+ width: 13px;
+ }
+ .settings-card {
+ padding: 12px;
+ }
+ .settings-card-heading {
+ padding-bottom: 11px;
+ }
+ .setting-row {
+ min-height: 58px;
+ }
+ .setting-row small {
+ max-width: 225px;
+ line-height: 1.4;
+ }
+ .theme-options {
+ gap: 2px;
+ }
+ .theme-options button {
+ height: 25px;
+ padding: 0 6px;
+ font-size: 8px;
+ }
+ .setting-row .button {
+ height: 29px;
+ padding: 0 7px;
+ font-size: 8px;
+ }
+ .node-detail-drawer {
+ padding: 19px;
+ }
+ .modal-card {
+ padding: 15px;
+ }
+ .modal-actions .button {
+ padding: 0 9px;
+ font-size: 9px;
+ }
+}
+
+@media (max-width: 820px) {
+ .sidebar {
+ overflow-y: auto;
+ }
+ .branched-menu {
+ display: none;
+ }
+ .sidebar .settings-link {
+ display: none;
+ }
+ .page-scroll {
+ padding-bottom: 96px;
+ }
+}
+
+.topology-stage {
+ position: absolute;
+ inset: 0;
+ transform-origin: center;
+ transition: transform 0.12s ease-out;
+}
+.topology-controls {
+ position: absolute;
+ z-index: 4;
+ top: 12px;
+ right: 12px;
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ padding: 4px;
+ border: 1px solid #2a3a42;
+ border-radius: 7px;
+ background: #0b1419e8;
+ backdrop-filter: blur(8px);
+}
+.topology-controls .button {
+ width: 27px;
+ height: 26px;
+ padding: 0;
+ border-color: transparent;
+ background: transparent;
+ color: #99a9b0;
+}
+.topology-controls .button:hover {
+ background: #1a292d;
+ color: #c5e9d6;
+}
+.zoom-label {
+ min-width: 33px;
+ text-align: center;
+ color: #95a6ac;
+ font: 8px var(--mono);
+}
+.topology-board:fullscreen {
+ width: 100vw;
+ height: 100vh;
+ min-height: 100vh;
+ border: 0;
+ border-radius: 0;
+}
+.topology-board:fullscreen .topology-stage {
+ transform-origin: center;
+}
+.topology-board {
+ touch-action: none;
+ cursor: grab;
+}
+.topology-board:active {
+ cursor: grabbing;
+}
+.topology-node {
+ cursor: pointer;
+}
+.compact-dashboard .dashboard-grid {
+ gap: 9px;
+}
+.compact-dashboard .overview-chart-card,
+.compact-dashboard .quick-actions,
+.compact-dashboard .node-overview,
+.compact-dashboard .activity-overview {
+ padding: 12px;
+}
+.compact-dashboard .overview-node {
+ min-height: 39px;
+}
+.compact-dashboard .action-tile {
+ height: 51px;
+}
+.compact-dashboard .stat-card {
+ min-height: 106px;
+}
+html[data-motion="reduced"] *,
+html[data-motion="reduced"] *::before,
+html[data-motion="reduced"] *::after {
+ animation: none !important;
+ transition: none !important;
+ scroll-behavior: auto !important;
+}
+@media (prefers-reduced-motion: reduce) {
+ html[data-motion="full"] *,
+ html[data-motion="full"] *::before,
+ html[data-motion="full"] *::after {
+ scroll-behavior: auto !important;
+ animation-duration: 0.01ms !important;
+ animation-iteration-count: 1 !important;
+ transition-duration: 0.01ms !important;
+ }
+}
+html[data-theme="light"] {
+ color-scheme: light;
+ --bg: #f2f5f5;
+ --surface: #fff;
+ --surface-2: #f8faf9;
+ --surface-3: #eef2f0;
+ --border: #dce5e2;
+ --border-light: #cedbd6;
+ --text: #172624;
+ --muted: #5d6d69;
+ --faint: #788782;
+ background: #f2f5f5;
+ color: #172624;
+}
+html[data-theme="light"] body {
+ background: #f2f5f5;
+ color: #172624;
+}
+html[data-theme="light"] .sidebar,
+html[data-theme="light"] .topbar,
+html[data-theme="light"] .panel,
+html[data-theme="light"] .modal-card,
+html[data-theme="light"] .node-detail-drawer,
+html[data-theme="light"] .command-dialog {
+ background: #fff;
+ color: #172624;
+ border-color: #dce5e2;
+}
+html[data-theme="light"] .sidebar {
+ background: #f9fbfa;
+}
+html[data-theme="light"] .panel {
+ background: #fff;
+ box-shadow: 0 7px 24px #172e2210;
+}
+html[data-theme="light"] .page-title h1,
+html[data-theme="light"] .panel-heading h2,
+html[data-theme="light"] .cluster-card-name h2,
+html[data-theme="light"] .stat-value,
+html[data-theme="light"] .modal-head h2,
+html[data-theme="light"] .settings-card-heading h2 {
+ color: #182724;
+}
+html[data-theme="light"] .nav-link {
+ color: #52645f;
+}
+html[data-theme="light"] .nav-link.active {
+ color: #23684d;
+ background: #e8f4ed;
+}
+html[data-theme="light"] .topbar,
+html[data-theme="light"] .table-toolbar,
+html[data-theme="light"] .data-table th,
+html[data-theme="light"] .app-footer {
+ background: #f9fbfa;
+ border-color: #dce5e2;
+}
+html[data-theme="light"] .data-table td,
+html[data-theme="light"] .activity-line,
+html[data-theme="light"] .detail-row,
+html[data-theme="light"] .setting-row {
+ border-color: #e3eae7;
+ color: #3f514c;
+}
+html[data-theme="light"] .data-table tbody tr:hover {
+ background: #f5f8f6;
+}
+html[data-theme="light"] .button:not(.button-primary),
+html[data-theme="light"] .select-pill,
+html[data-theme="light"] .search-box,
+html[data-theme="light"] .metric-tabs,
+html[data-theme="light"] .settings-nav a,
+html[data-theme="light"] .theme-options button {
+ background: #f5f8f6;
+ color: #43564f;
+ border-color: #d7e0dc;
+}
+html[data-theme="light"] .demo-banner {
+ background: #edf7f1;
+ border-color: #cfe5d8;
+}
+html[data-theme="light"] .topology-board {
+ background:
+ radial-gradient(ellipse at 50% 40%, #d8eee2, transparent 50%), #eef5f2;
+ border-color: #d6e3dd;
+}
+html[data-theme="light"] .topology-grid {
+ background-image:
+ linear-gradient(#2248340d 1px, transparent 1px),
+ linear-gradient(90deg, #2248340d 1px, transparent 1px);
+}
+html[data-theme="light"] .node-name,
+html[data-theme="light"] .coordinator-node .node-name,
+html[data-theme="light"] .worker-node .node-name {
+ color: #223c33;
+}
+html[data-theme="light"] .worker-node,
+html[data-theme="light"] .node-avatar {
+ background: #e7f3ec;
+ border-color: #b9d9c5;
+}
+html[data-theme="light"] .topology-controls {
+ background: #f9fcfa;
+ border-color: #d3e1da;
+}
+html[data-theme="light"] .topology-controls .button {
+ background: transparent;
+}
+html[data-theme="light"] .topology-controls .button:hover {
+ background: #e5f1e9;
+}
+html[data-theme="light"] input,
+html[data-theme="light"] textarea,
+html[data-theme="light"] select {
+ color: #20312c;
+}
+html[data-theme="light"] .command-input-wrap {
+ border-color: #dce5e2;
+}
+html[data-theme="light"] .command-item {
+ color: #334842;
+}
+html[data-theme="light"] .command-item:hover {
+ background: #e7f2ec;
+ color: #23684d;
+}
+.cluster-organizer {
+ margin-top: 14px;
+}
+.cluster-node-list {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ padding: 0 16px;
+}
+.cluster-node-row {
+ min-width: 0;
+ min-height: 51px;
+ display: flex;
+ align-items: center;
+ gap: 9px;
+ padding: 0 8px;
+ border-bottom: 1px solid #1d2931;
+}
+.cluster-node-row > div {
+ display: grid;
+ gap: 3px;
+ flex: 1;
+ min-width: 0;
+}
+.cluster-node-row b {
+ overflow: hidden;
+ color: #c7d3d8;
+ font-size: 9px;
+ font-weight: 500;
+ text-overflow: ellipsis;
+}
+.cluster-node-row small {
+ color: #75858e;
+ font: 8px var(--mono);
+}
+.cluster-node-row select {
+ max-width: 155px;
+ padding: 6px;
+ border: 1px solid #2b3941;
+ border-radius: 5px;
+ background: #111a20;
+ color: #aebbc0;
+ font-size: 8px;
+}
+.health-history {
+ display: flex;
+ gap: 6px;
+ margin-top: 10px;
+}
+.health-history-sample {
+ height: 5px;
+ flex: 1;
+ border-radius: 5px;
+ background: #63d4a3;
+}
+.health-history-sample.warning {
+ background: #e5b06b;
+}
+.health-history-sample.offline {
+ background: #77838a;
+}
+.topology-error {
+ position: absolute;
+ right: 13px;
+ bottom: 32px;
+ z-index: 5;
+ margin: 0;
+ padding: 6px 9px;
+ border: 1px solid #55432b;
+ border-radius: 5px;
+ background: #211d17;
+ color: #d2b47f;
+ font-size: 8px;
+}
+.topology-board:focus-visible {
+ outline-offset: 3px;
+}
+.topology-board.orbit-layout {
+ background: radial-gradient(
+ ellipse at 50% 50%,
+ #103244 0,
+ #0a1820 31%,
+ #0a1117 70%
+ );
+}
+.orbit-rings {
+ position: absolute;
+ inset: 12%;
+ display: grid;
+ place-items: center;
+ pointer-events: none;
+}
+.orbit-rings span {
+ position: absolute;
+ width: 100%;
+ aspect-ratio: 1;
+ max-height: 100%;
+ border: 1px solid #5bb9d128;
+ border-radius: 50%;
+ box-shadow:
+ 0 0 34px #57d3ff0b,
+ inset 0 0 36px #56c8ff08;
+}
+.orbit-rings span + span {
+ width: 73%;
+ border-color: #68e1cb22;
+}
+.orbit-layout .coordinator-node {
+ top: 50%;
+ transform: translate(-50%, -50%) scale(1.12);
+}
+.orbit-layout .node-avatar {
+ border-color: #5299b2;
+ background: linear-gradient(145deg, #1b4856, #10252f);
+ color: #9ce7ee;
+ box-shadow:
+ 0 0 35px #48b7d52b,
+ inset 0 0 17px #50ba7b1c;
+}
+.orbit-layout .worker-node {
+ border-color: #2a5260;
+ background: #0f2029ed;
+}
+.mission-toolbar {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 12px;
+ margin: 14px 0 9px;
+}
+.mission-view-switch,
+.mission-filters {
+ min-width: 0;
+ margin: 0;
+ padding: 0;
+ border: 0;
+ display: flex;
+ align-items: center;
+ gap: 4px;
+}
+.mission-view-switch .button {
+ height: 30px;
+ padding: 0 10px;
+ color: #81939c;
+ font-size: 9px;
+}
+.mission-view-switch .button.selected {
+ border-color: #3d777d;
+ background: #11292d;
+ color: #a7e8df;
+}
+.mission-filters button {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ min-height: 27px;
+ padding: 0 8px;
+ border: 1px solid transparent;
+ border-radius: 5px;
+ background: transparent;
+ color: #788a93;
+ font-size: 8px;
+ text-transform: capitalize;
+}
+.mission-filters button.selected {
+ border-color: #2d454e;
+ background: #101c22;
+ color: #c4d3d8;
+}
+.mission-filters .health-mini {
+ width: 5px;
+ height: 5px;
+}
+.mission-analytics {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 12px;
+ margin-top: 13px;
+}
+.mission-metric {
+ display: grid;
+ grid-template-columns: 1fr auto;
+ gap: 8px;
+ padding: 13px 15px;
+ border: 1px solid #1d2b33;
+ border-radius: 7px;
+ background: #0e171d;
+}
+.mission-metric > span {
+ color: #788994;
+ font: 8px var(--mono);
+ letter-spacing: 0.5px;
+}
+.mission-metric > b {
+ grid-row: span 2;
+ color: #d6e6e8;
+ font: 500 19px var(--display);
+}
+.mission-metric .progress-track {
+ grid-column: 1 / -1;
+}
+.mission-alerts > b {
+ display: flex;
+ align-items: center;
+ gap: 5px;
+ color: #e3b476;
+}
+.mission-alerts > small {
+ color: #7c8c94;
+ font-size: 8px;
+}
+.globe-layout {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) 260px;
+ gap: 14px;
+}
+.globe-board {
+ position: relative;
+ min-height: 460px;
+ overflow: hidden;
+ border: 1px solid #223945;
+ border-radius: 9px;
+ background: radial-gradient(
+ ellipse at 50% 49%,
+ #123746 0,
+ #0a1720 40%,
+ #081117 80%
+ );
+}
+.globe-board::before {
+ position: absolute;
+ inset: 0;
+ z-index: 0;
+ content: "";
+ pointer-events: none;
+ background-image: radial-gradient(#90d7e820 0.6px, transparent 0.8px);
+ background-size: 7px 7px;
+ mask-image: radial-gradient(ellipse at center, black, transparent 76%);
+}
+.globe-canvas {
+ position: absolute;
+ inset: 0;
+ touch-action: none;
+}
+.globe-canvas canvas {
+ display: block;
+ width: 100%;
+ height: 100%;
+ cursor: grab;
+}
+.globe-canvas canvas:active {
+ cursor: grabbing;
+}
+.globe-overline {
+ position: absolute;
+ top: 16px;
+ left: 17px;
+ display: flex;
+ gap: 7px;
+ align-items: center;
+ z-index: 1;
+ color: #87a6b1;
+ font: 8px var(--mono);
+ letter-spacing: 0.6px;
+}
+.globe-overline i {
+ width: 5px;
+ height: 5px;
+ border-radius: 50%;
+ background: #68e2c7;
+ box-shadow: 0 0 9px #68e2c7;
+}
+.globe-overline span {
+ color: #6c8b98;
+}
+.globe-controls {
+ position: absolute;
+ right: 12px;
+ top: 11px;
+ z-index: 2;
+ display: flex;
+ align-items: center;
+ gap: 3px;
+ padding: 4px;
+ border: 1px solid #27414b;
+ border-radius: 6px;
+ background: #0a171edb;
+ min-width: 0;
+ margin: 0;
+}
+.globe-controls .button {
+ width: 27px;
+ height: 26px;
+ padding: 0;
+ border-color: transparent;
+ background: transparent;
+ color: #a3c2ca;
+}
+.globe-controls > span {
+ min-width: 34px;
+ color: #8daab2;
+ font: 8px var(--mono);
+ text-align: center;
+}
+.globe-node-list {
+ position: absolute;
+ z-index: 2;
+ right: 10px;
+ bottom: 12px;
+ left: 10px;
+ display: flex;
+ justify-content: center;
+ flex-wrap: wrap;
+ gap: 5px;
+ min-width: 0;
+ margin: 0;
+ padding: 0;
+ border: 0;
+}
+.globe-node-list button {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ min-height: 25px;
+ padding: 0 7px;
+ border: 1px solid #29434d;
+ border-radius: 20px;
+ background: #0b1920d9;
+ color: #91a8af;
+ font-size: 8px;
+}
+.globe-node-list button.selected {
+ border-color: #65beb9;
+ color: #c6eeeb;
+}
+.globe-inspector {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 11px;
+ min-height: 230px;
+ padding: 17px;
+ border: 1px solid #1d2d36;
+ border-radius: 8px;
+ background: #0e171d;
+}
+.globe-inspector h2 {
+ margin: 0;
+ color: #dce8eb;
+ font: 500 18px var(--display);
+}
+.globe-inspector p,
+.globe-inspector > small {
+ margin: 0;
+ color: #81949d;
+ font-size: 9px;
+ line-height: 1.5;
+}
+.globe-inspector > small {
+ margin-top: auto;
+ color: #6e818a;
+}
+.globe-inspector-metric {
+ display: flex;
+ justify-content: space-between;
+ width: 100%;
+ padding-top: 8px;
+ border-top: 1px solid #20313a;
+ color: #81939b;
+ font: 8px var(--mono);
+ text-transform: uppercase;
+}
+.globe-inspector-metric b {
+ color: #c6d9df;
+ font: 10px var(--mono);
+}
+.globe-fallback {
+ position: absolute;
+ inset: 0;
+ display: grid;
+ align-content: center;
+ justify-items: center;
+ gap: 18px;
+ color: #8299a2;
+ font-size: 10px;
+ text-align: center;
+}
+.globe-fallback-orb {
+ display: grid;
+ place-items: center;
+ width: 185px;
+ aspect-ratio: 1;
+ border: 1px solid #458a9b66;
+ border-radius: 50%;
+ background: radial-gradient(
+ circle,
+ #4cc0d11a 0 25%,
+ #2e849f15 50%,
+ transparent 71%
+ );
+ box-shadow:
+ inset 0 0 35px #4bc3d51a,
+ 0 0 65px #4bc3d51a;
+}
+.globe-fallback-orb span {
+ width: 62px;
+ aspect-ratio: 1;
+ border: 1px solid #a5e8ed;
+ border-radius: 50%;
+ background: #74dbe52b;
+ box-shadow: 0 0 30px #73d9e7;
+}
+.globe-fallback p {
+ max-width: 260px;
+ padding: 0 12px;
+}
+html[data-performance="on"] .orbit-rings span {
+ box-shadow: none;
+}
+@media (max-width: 820px) {
+ .mission-toolbar {
+ align-items: flex-start;
+ flex-direction: column;
+ }
+ .mission-filters {
+ max-width: 100%;
+ flex-wrap: wrap;
+ }
+ .mission-analytics {
+ grid-template-columns: 1fr 1fr;
+ }
+ .mission-alerts {
+ grid-column: 1 / -1;
+ }
+ .globe-layout {
+ grid-template-columns: 1fr;
+ }
+ .globe-inspector {
+ min-height: auto;
+ }
+}
+@media (max-width: 600px) {
+ .mission-analytics {
+ grid-template-columns: 1fr;
+ }
+ .mission-alerts {
+ grid-column: auto;
+ }
+ .globe-board {
+ min-height: 390px;
+ }
+ .globe-controls {
+ right: 8px;
+ top: 40px;
+ }
+ .cluster-node-list {
+ grid-template-columns: 1fr;
+ padding: 0 9px;
+ }
+ .cluster-node-row {
+ padding: 0 4px;
+ }
+ .cluster-node-row select {
+ max-width: 132px;
+ }
+ .topology-error {
+ left: 10px;
+ right: 10px;
+ bottom: 35px;
+ text-align: center;
+ }
+}
+html[data-theme="light"] .cluster-node-row {
+ border-color: #e3eae7;
+}
+html[data-theme="light"] .cluster-node-row b {
+ color: #334942;
+}
+html[data-theme="light"] .cluster-node-row select {
+ background: #f5f8f6;
+ color: #43564f;
+ border-color: #d7e0dc;
+}
+html[data-motion="reduced"] *,
+html[data-motion="reduced"] *::before,
+html[data-motion="reduced"] *::after {
+ scroll-behavior: auto !important;
+ animation-duration: 0.01ms !important;
+ animation-iteration-count: 1 !important;
+ transition-duration: 0.01ms !important;
+}
+html[data-performance="on"] .home-hero,
+html[data-performance="on"] .panel,
+html[data-performance="on"] .topbar {
+ backdrop-filter: none;
+ box-shadow: none;
+}
+html[data-performance="on"] .holo-visual svg {
+ filter: none;
+}
+@media (max-width: 820px) {
+ .home-hero {
+ grid-template-columns: 1fr;
+ padding: 26px 24px 20px;
+ }
+ .home-hero-copy {
+ max-width: none;
+ }
+ .holo-visual {
+ position: absolute;
+ right: -8%;
+ top: 5%;
+ width: 54%;
+ opacity: 0.42;
+ pointer-events: none;
+ }
+ .home-pulse-grid {
+ grid-template-columns: 1fr;
+ }
+}
+@media (max-width: 600px) {
+ .home-hero {
+ min-height: 320px;
+ padding: 23px 17px;
+ }
+ .home-hero h1 {
+ font-size: 40px;
+ letter-spacing: -1.7px;
+ }
+ .home-hero-copy > p {
+ max-width: 290px;
+ }
+ .holo-visual {
+ right: -18%;
+ top: 14%;
+ width: 75%;
+ opacity: 0.25;
+ }
+ .hero-footnote {
+ flex-wrap: wrap;
+ font-size: 7px;
+ }
+}
diff --git a/dashboard/tests/agentLayout.test.ts b/dashboard/tests/agentLayout.test.ts
new file mode 100644
index 0000000..96aadfa
--- /dev/null
+++ b/dashboard/tests/agentLayout.test.ts
@@ -0,0 +1,68 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import {
+ type AgentGraphLayout,
+ readAgentGraphLayout,
+ writeAgentGraphLayout,
+} from "../src/data/agentLayout";
+import { localProvider } from "../src/data/localProvider";
+
+vi.mock("../src/data/localProvider", () => ({
+ localProvider: { get: vi.fn(), put: vi.fn() },
+}));
+
+const fallback: AgentGraphLayout = {
+ positions: { network: { a: { x: 1, y: 2 } }, radial: { a: { x: 3, y: 4 } } },
+ cameras: {
+ network: { x: 0, y: 0, zoom: 1 },
+ radial: { x: 0, y: 0, zoom: 1 },
+ },
+};
+
+beforeEach(() => vi.resetAllMocks());
+
+describe("agent graph layout persistence", () => {
+ it("round trips the layout through the agent-layouts bucket", async () => {
+ const changed: AgentGraphLayout = {
+ ...fallback,
+ positions: { ...fallback.positions, network: { a: { x: 42, y: 19 } } },
+ cameras: { ...fallback.cameras, network: { x: 8, y: -2, zoom: 1.4 } },
+ };
+ vi.mocked(localProvider.get).mockResolvedValue(changed);
+ await writeAgentGraphLayout(changed);
+ expect(localProvider.put).toHaveBeenCalledWith(
+ "agent-layouts",
+ "default",
+ changed,
+ );
+ expect(await readAgentGraphLayout(fallback)).toEqual(changed);
+ });
+
+ it("falls back for invalid records and clamps zoom", async () => {
+ vi.mocked(localProvider.get).mockResolvedValue({
+ positions: { network: { bad: { x: "x", y: 1 } } },
+ cameras: { network: { x: 0, y: 0, zoom: 9 } },
+ });
+ const restored = await readAgentGraphLayout(fallback);
+ expect(restored.positions.network).toEqual(fallback.positions.network);
+ expect(restored.cameras.network.zoom).toBe(1.8);
+ });
+
+ it("keeps graph usable when IndexedDB reads or writes fail", async () => {
+ vi.mocked(localProvider.get).mockRejectedValue(new Error("blocked"));
+ vi.mocked(localProvider.put).mockRejectedValue(new Error("quota"));
+ expect(await readAgentGraphLayout(fallback)).toEqual(fallback);
+ await expect(writeAgentGraphLayout(fallback)).resolves.toBeUndefined();
+ });
+});
+
+/* ORIGINAL (preserved, not deleted — disabled by comment):
+afterEach(() => localStorage.clear());
+it("round trips both layouts and camera states", () => {
+ writeAgentGraphLayout(changed);
+ expect(readAgentGraphLayout(fallback)).toEqual(changed);
+});
+it("falls back safely for malformed storage and clamps camera zoom", () => {
+ localStorage.setItem("opencluster.agents.layout.v1", "{");
+ expect(readAgentGraphLayout(fallback)).toEqual(fallback);
+});
+*/
diff --git a/dashboard/tests/demoApi.test.ts b/dashboard/tests/demoApi.test.ts
new file mode 100644
index 0000000..b6b4754
--- /dev/null
+++ b/dashboard/tests/demoApi.test.ts
@@ -0,0 +1,60 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { demoApi } from "../src/data/demoApi";
+import {
+ initialActivity,
+ initialClusters,
+ initialNodes,
+ initialTasks,
+} from "../src/data/mock";
+
+afterEach(() => {
+ vi.useRealTimers();
+});
+
+describe("demoApi", () => {
+ it("returns isolated copies of each demo collection", async () => {
+ vi.useFakeTimers();
+ const clustersRequest = demoApi.getClusters();
+ const nodesRequest = demoApi.getNodes();
+ const tasksRequest = demoApi.getTasks();
+ const activityRequest = demoApi.getActivity();
+
+ await vi.advanceTimersByTimeAsync(120);
+ const [clusters, nodes, tasks, activity] = await Promise.all([
+ clustersRequest,
+ nodesRequest,
+ tasksRequest,
+ activityRequest,
+ ]);
+
+ expect(clusters).toEqual(initialClusters);
+ expect(nodes).toEqual(initialNodes);
+ expect(tasks).toEqual(initialTasks);
+ expect(activity).toEqual(initialActivity);
+
+ clusters[0].name = "Changed in caller";
+ nodes[0].labels.push("caller-only");
+ tasks[0].name = "Changed in caller";
+ activity[0].title = "Changed in caller";
+ expect(initialClusters[0].name).not.toBe("Changed in caller");
+ expect(initialNodes[0].labels).not.toContain("caller-only");
+ expect(initialTasks[0].name).not.toBe("Changed in caller");
+ expect(initialActivity[0].title).not.toBe("Changed in caller");
+ });
+
+ it("filters nodes to the requested cluster and returns no nodes for an unknown cluster", async () => {
+ vi.useFakeTimers();
+ const edgeRequest = demoApi.getNodes("cl-edge");
+ const unknownRequest = demoApi.getNodes("missing-cluster");
+
+ await vi.advanceTimersByTimeAsync(120);
+ const [edgeNodes, missingNodes] = await Promise.all([
+ edgeRequest,
+ unknownRequest,
+ ]);
+
+ expect(edgeNodes.map((node) => node.id)).toEqual(["node-05", "node-06"]);
+ expect(edgeNodes.every((node) => node.clusterId === "cl-edge")).toBe(true);
+ expect(missingNodes).toEqual([]);
+ });
+});
diff --git a/dashboard/tests/localProvider.test.ts b/dashboard/tests/localProvider.test.ts
new file mode 100644
index 0000000..c4f3ebc
--- /dev/null
+++ b/dashboard/tests/localProvider.test.ts
@@ -0,0 +1,144 @@
+import { deleteDB } from "idb";
+import { beforeAll, describe, expect, it } from "vitest";
+import { type AgentEntity, agentSeedCatalog } from "../src/data/agents";
+import {
+ dashboardLocalProvider,
+ localProvider,
+} from "../src/data/localProvider";
+import { initialClusters, initialNodes } from "../src/data/mock";
+
+beforeAll(async () => {
+ await deleteDB("opencluster-dashboard");
+});
+
+describe("local IndexedDB provider", () => {
+ it("migrates a valid previous local cluster and keeps later records across initialization", async () => {
+ const legacy = {
+ clusters: [
+ {
+ id: "personal",
+ name: "Personal",
+ region: "Local",
+ description: "Saved",
+ nodeIds: [],
+ createdAt: "2026-10-01",
+ },
+ ],
+ nodes: [],
+ };
+ const initialized = await localProvider.initialize(legacy);
+ expect(initialized.clusters.map((cluster) => cluster.id)).toEqual([
+ "personal",
+ ]);
+ await dashboardLocalProvider.clusters.create("second", {
+ ...initialized.clusters[0],
+ id: "second",
+ name: "Second",
+ });
+ const reopened = await localProvider.initialize({
+ clusters: [],
+ nodes: [],
+ });
+ expect(reopened.clusters.map((cluster) => cluster.id)).toEqual([
+ "personal",
+ "second",
+ ]);
+ const seededAgents = await localProvider.list("agents");
+ expect(seededAgents.map((agent) => agent.id).sort()).toEqual(
+ agentSeedCatalog.map((agent) => agent.id).sort(),
+ );
+ await localProvider.put("agents", "codex", {
+ ...seededAgents[0],
+ name: "Locally customized Codex",
+ });
+ await localProvider.initialize();
+ expect(
+ (await localProvider.get("agents", "codex"))?.name,
+ ).toBe("Locally customized Codex");
+ });
+
+ it("rejects malformed cluster records at the provider boundary", async () => {
+ await expect(
+ localProvider.put("clusters", "bad", { id: "bad" }),
+ ).rejects.toThrow();
+ });
+
+ it("supports validated CRUD, search, filtering, and sorting", async () => {
+ const cluster = {
+ id: "query-test",
+ name: "Beta Lab",
+ region: "Local",
+ description: "Queryable record",
+ nodeIds: [],
+ createdAt: "2026-10-22",
+ };
+ await dashboardLocalProvider.clusters.create(cluster.id, cluster);
+ expect(await dashboardLocalProvider.clusters.get(cluster.id)).toEqual(
+ cluster,
+ );
+ expect(
+ await dashboardLocalProvider.clusters.list({
+ search: "BETA",
+ filter: (item) => item.region === "Local",
+ sort: (left, right) => left.name.localeCompare(right.name),
+ }),
+ ).toContainEqual(cluster);
+ await dashboardLocalProvider.clusters.update(cluster.id, {
+ ...cluster,
+ name: "Gamma Lab",
+ });
+ expect((await dashboardLocalProvider.clusters.get(cluster.id))?.name).toBe(
+ "Gamma Lab",
+ );
+ await dashboardLocalProvider.clusters.delete(cluster.id);
+ expect(
+ await dashboardLocalProvider.clusters.get(cluster.id),
+ ).toBeUndefined();
+ });
+
+ it("resets user records to the seeded workspace", async () => {
+ await localProvider.put("workflows", "user-flow", {
+ version: 1,
+ name: "Local workflow",
+ nodes: [
+ {
+ id: "trigger",
+ kind: "trigger",
+ title: "Trigger",
+ detail: "",
+ x: 0,
+ y: 0,
+ config: {},
+ },
+ ],
+ edges: [],
+ });
+ await localProvider.resetRecords();
+ expect(await localProvider.get("workflows", "user-flow")).toBeUndefined();
+ expect(
+ (await localProvider.list<{ id: string }>("clusters")).length,
+ ).toBeGreaterThan(0);
+ });
+
+ it("replaces related cluster and node records together", async () => {
+ const cluster = { ...initialClusters[0], nodeIds: [initialNodes[0].id] };
+ await localProvider.writeClusterNodeState([cluster], [initialNodes[0]]);
+ expect(await localProvider.list("clusters")).toEqual([cluster]);
+ expect(await localProvider.list("nodes")).toEqual([initialNodes[0]]);
+ });
+
+ it("preserves saved records when a replacement contains invalid data", async () => {
+ const before = await localProvider.list("clusters");
+ await expect(
+ localProvider.replace("clusters", [{ id: "bad", value: {} }]),
+ ).rejects.toThrow();
+ expect(await localProvider.list("clusters")).toEqual(before);
+ });
+
+ it("does not reseed collections a user intentionally emptied", async () => {
+ await localProvider.initialize();
+ await localProvider.replace("agent-relations", []);
+ await localProvider.initialize();
+ expect(await localProvider.list("agent-relations")).toEqual([]);
+ });
+});
diff --git a/dashboard/tests/navigationComponents.test.tsx b/dashboard/tests/navigationComponents.test.tsx
new file mode 100644
index 0000000..397a215
--- /dev/null
+++ b/dashboard/tests/navigationComponents.test.tsx
@@ -0,0 +1,36 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { BranchedMenu } from "../src/components/BranchedMenu";
+import { Counter } from "../src/components/Counter";
+
+afterEach(() => vi.unstubAllGlobals());
+
+describe("dashboard navigation components", () => {
+ it("selects a route from a branched menu section", () => {
+ const onSelect = vi.fn();
+ render(
+ ,
+ );
+
+ fireEvent.click(screen.getByRole("button", { name: "Clusters" }));
+ expect(onSelect).toHaveBeenCalledWith("/clusters");
+ });
+
+ it("formats a KPI accessibly and skips motion when reduced motion is preferred", () => {
+ vi.stubGlobal("matchMedia", () => ({ matches: true }));
+ render( );
+ expect(screen.getByRole("img", { name: "1,284" })).toBeInTheDocument();
+ });
+});
diff --git a/dashboard/tests/setup.ts b/dashboard/tests/setup.ts
new file mode 100644
index 0000000..2c29eea
--- /dev/null
+++ b/dashboard/tests/setup.ts
@@ -0,0 +1,2 @@
+import "@testing-library/jest-dom/vitest";
+import "fake-indexeddb/auto";
diff --git a/dashboard/tests/useDashboardStore.test.ts b/dashboard/tests/useDashboardStore.test.ts
new file mode 100644
index 0000000..06876ee
--- /dev/null
+++ b/dashboard/tests/useDashboardStore.test.ts
@@ -0,0 +1,150 @@
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import { initialClusters, initialNodes } from "../src/data/mock";
+import { useDashboardStore } from "../src/state/useDashboardStore";
+
+function resetStore() {
+ useDashboardStore.setState({
+ clusters: initialClusters,
+ nodes: initialNodes,
+ selectedClusterId: initialClusters[0].id,
+ selectedNodeId: null,
+ sidebarCollapsed: false,
+ demoMotion: true,
+ performanceMode: false,
+ theme: "dark",
+ });
+}
+
+beforeEach(() => {
+ localStorage.clear();
+ resetStore();
+});
+
+afterEach(() => {
+ localStorage.clear();
+});
+
+describe("useDashboardStore", () => {
+ it("adds a trimmed demo cluster and selects it", async () => {
+ await useDashboardStore
+ .getState()
+ .addCluster(" Research Lab ", "Local network", "Demo only");
+
+ const state = useDashboardStore.getState();
+ const added = state.clusters.at(-1);
+ expect(added).toMatchObject({
+ name: "Research Lab",
+ region: "Local network",
+ description: "Demo only",
+ nodeIds: [],
+ });
+ expect(added?.id).toMatch(/^demo-\d+$/);
+ expect(state.selectedClusterId).toBe(added?.id);
+ });
+
+ it("serializes concurrent local cluster additions without losing records", async () => {
+ const store = useDashboardStore.getState();
+ await Promise.all([
+ store.addCluster("First concurrent", "Local", ""),
+ store.addCluster("Second concurrent", "Local", ""),
+ ]);
+ expect(
+ useDashboardStore.getState().clusters.map((item) => item.name),
+ ).toEqual(
+ expect.arrayContaining(["First concurrent", "Second concurrent"]),
+ );
+ });
+
+ it("updates cluster metadata without changing other clusters", async () => {
+ await useDashboardStore.getState().updateCluster("cl-main", {
+ name: "Updated Core",
+ description: "Updated",
+ });
+
+ const { clusters } = useDashboardStore.getState();
+ expect(clusters[0]).toMatchObject({
+ name: "Updated Core",
+ description: "Updated",
+ });
+ expect(clusters[1]).toEqual(initialClusters[1]);
+ });
+
+ it("clears the selected node when switching clusters", () => {
+ useDashboardStore.getState().selectNode("node-01");
+ useDashboardStore.getState().selectCluster("cl-edge");
+
+ expect(useDashboardStore.getState()).toMatchObject({
+ selectedClusterId: "cl-edge",
+ selectedNodeId: null,
+ });
+ });
+
+ it("makes only the selected node's cluster coordinator", async () => {
+ await useDashboardStore.getState().toggleCoordinator("node-02");
+
+ const nodes = useDashboardStore.getState().nodes;
+ expect(nodes.find((node) => node.id === "node-02")?.role).toBe(
+ "coordinator",
+ );
+ expect(nodes.find((node) => node.id === "node-01")?.role).toBe("worker");
+ expect(nodes.find((node) => node.id === "node-05")?.role).toBe(
+ "coordinator",
+ );
+ });
+
+ it("moves a coordinator atomically and preserves one coordinator per nonempty cluster", async () => {
+ await useDashboardStore.getState().moveNode("node-01", "cl-edge");
+ const { clusters, nodes } = useDashboardStore.getState();
+ expect(nodes.find((node) => node.id === "node-01")).toMatchObject({
+ clusterId: "cl-edge",
+ role: "worker",
+ });
+ expect(nodes.find((node) => node.id === "node-02")?.role).toBe(
+ "coordinator",
+ );
+ expect(
+ nodes.filter(
+ (node) => node.clusterId === "cl-edge" && node.role === "coordinator",
+ ),
+ ).toHaveLength(1);
+ expect(
+ clusters.find((cluster) => cluster.id === "cl-main")?.nodeIds,
+ ).not.toContain("node-01");
+ });
+
+ it("removes a cluster atomically and preserves the fallback coordinator", async () => {
+ await expect(
+ useDashboardStore.getState().removeCluster("cl-main"),
+ ).resolves.toBe(true);
+ const { clusters, nodes } = useDashboardStore.getState();
+ expect(clusters.some((cluster) => cluster.id === "cl-main")).toBe(false);
+ expect(
+ nodes.filter(
+ (node) => node.clusterId === "cl-edge" && node.role === "coordinator",
+ ),
+ ).toHaveLength(1);
+ expect(nodes.find((node) => node.id === "node-01")).toMatchObject({
+ clusterId: "cl-edge",
+ role: "worker",
+ });
+ });
+
+ it("keeps selected node state out of persisted browser preferences", () => {
+ useDashboardStore.getState().selectNode("node-01");
+ useDashboardStore.getState().setTheme("light");
+
+ const persisted = JSON.parse(
+ localStorage.getItem("opencluster-demo-preferences") ?? "{}",
+ );
+ expect(persisted.state.theme).toBe("light");
+ expect(persisted.state.selectedNodeId).toBeUndefined();
+ });
+
+ it("persists the non-sensitive performance preference", () => {
+ useDashboardStore.getState().setPerformanceMode(true);
+ const persisted = JSON.parse(
+ localStorage.getItem("opencluster-demo-preferences") ?? "{}",
+ );
+ expect(persisted.state.performanceMode).toBe(true);
+ });
+});
diff --git a/dashboard/tests/workflows.test.ts b/dashboard/tests/workflows.test.ts
new file mode 100644
index 0000000..72db988
--- /dev/null
+++ b/dashboard/tests/workflows.test.ts
@@ -0,0 +1,125 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { dashboardProvider } from "../src/data/provider";
+import {
+ assertWorkflowImportSize,
+ isConnectionValid,
+ loadWorkflow,
+ parseWorkflowImport,
+ recordWorkflowChange,
+ redoWorkflow,
+ saveWorkflow,
+ starterWorkflow,
+ undoWorkflow,
+ validateWorkflow,
+ workflowIssues,
+} from "../src/data/workflows";
+
+const records = new Map();
+vi.mock("../src/data/provider", () => ({
+ dashboardProvider: {
+ workflows: {
+ get: vi.fn(async (id: string) => records.get(id)),
+ update: vi.fn(async (id: string, value: unknown) => {
+ records.set(id, structuredClone(value));
+ }),
+ },
+ },
+}));
+
+beforeEach(() => {
+ records.clear();
+ vi.clearAllMocks();
+});
+
+describe("demo workflows", () => {
+ it("rejects oversized files before reading or parsing their contents", () => {
+ expect(() => assertWorkflowImportSize(100_000)).not.toThrow();
+ expect(() => assertWorkflowImportSize(100_001)).toThrow(
+ "Workflow file exceeds the 100 KB demo limit.",
+ );
+ });
+
+ it("round trips a workflow through JSON import validation", () => {
+ expect(parseWorkflowImport(JSON.stringify(starterWorkflow))).toEqual(
+ starterWorkflow,
+ );
+ });
+
+ it("rejects dangling connections and duplicate node identifiers", () => {
+ const invalid = {
+ ...starterWorkflow,
+ edges: [{ id: "broken", source: "missing", target: "inspect" }],
+ };
+ expect(validateWorkflow(invalid)).toBe(false);
+ const duplicate = {
+ ...starterWorkflow,
+ nodes: [...starterWorkflow.nodes, starterWorkflow.nodes[0]],
+ };
+ expect(validateWorkflow(duplicate)).toBe(false);
+ });
+
+ it("rejects imported cycles and invalid saves", async () => {
+ const cyclic = {
+ ...starterWorkflow,
+ edges: [
+ { id: "cycle-a", source: "trigger", target: "inspect" },
+ { id: "cycle-b", source: "inspect", target: "trigger" },
+ ],
+ };
+ expect(validateWorkflow(cyclic)).toBe(false);
+ await expect(saveWorkflow(cyclic)).rejects.toThrow("could not be saved");
+ });
+
+ it("rejects nested workflow config that the provider cannot persist", () => {
+ const invalid = structuredClone(starterWorkflow);
+ invalid.nodes[0].config = { nested: { value: "unsupported" } } as never;
+ expect(validateWorkflow(invalid)).toBe(false);
+ });
+
+ it("requires a connected trigger path for runnable flows", () => {
+ expect(workflowIssues(starterWorkflow)).toEqual([]);
+ expect(workflowIssues({ ...starterWorkflow, edges: [] })).not.toHaveLength(
+ 0,
+ );
+ });
+
+ it("supports undo and redo while new edits clear redo history", () => {
+ const changed = { ...starterWorkflow, name: "Renamed flow" };
+ const recorded = recordWorkflowChange(
+ { past: [], future: [] },
+ starterWorkflow,
+ );
+ const undone = undoWorkflow(recorded, changed);
+ expect(undone?.workflow.name).toBe(starterWorkflow.name);
+ if (!undone) throw new Error("Expected the changed workflow to undo.");
+ const redone = redoWorkflow(undone.history, undone.workflow);
+ expect(redone?.workflow.name).toBe(changed.name);
+ if (!redone) throw new Error("Expected the undone workflow to redo.");
+ expect(recordWorkflowChange(redone.history, changed).future).toEqual([]);
+ });
+
+ it("rejects self, duplicate, and cyclic connections", () => {
+ expect(isConnectionValid(starterWorkflow, "trigger", "trigger")).toBe(
+ false,
+ );
+ expect(isConnectionValid(starterWorkflow, "trigger", "inspect")).toBe(
+ false,
+ );
+ expect(isConnectionValid(starterWorkflow, "threshold", "trigger")).toBe(
+ false,
+ );
+ expect(isConnectionValid(starterWorkflow, "trigger", "missing")).toBe(
+ false,
+ );
+ });
+
+ it("round trips the active flow through the IndexedDB provider contract", async () => {
+ await saveWorkflow(starterWorkflow);
+ expect(dashboardProvider.workflows.update).toHaveBeenCalledWith(
+ "active",
+ starterWorkflow,
+ );
+ await expect(loadWorkflow()).resolves.toEqual(starterWorkflow);
+ expect(dashboardProvider.workflows.get).toHaveBeenCalledWith("active");
+ });
+});
diff --git a/dashboard/tsconfig.json b/dashboard/tsconfig.json
new file mode 100644
index 0000000..a05570e
--- /dev/null
+++ b/dashboard/tsconfig.json
@@ -0,0 +1,22 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "useDefineForClassFields": true,
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react-jsx",
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true,
+ "paths": { "@/*": ["./src/*"] },
+ "types": ["vite/client", "vitest/globals"]
+ },
+ "include": ["src", "tests", "vite.config.ts"]
+}
diff --git a/dashboard/vite.config.ts b/dashboard/vite.config.ts
new file mode 100644
index 0000000..08018d3
--- /dev/null
+++ b/dashboard/vite.config.ts
@@ -0,0 +1,16 @@
+import tailwindcss from "@tailwindcss/vite";
+import react from "@vitejs/plugin-react";
+import { defineConfig } from "vitest/config";
+import { fileURLToPath } from "node:url";
+
+export default defineConfig({
+ plugins: [react(), tailwindcss()],
+ resolve: {
+ alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) },
+ },
+ test: {
+ environment: "jsdom",
+ setupFiles: ["./tests/setup.ts"],
+ restoreMocks: true,
+ },
+});
diff --git a/docs/DASHBOARD_ARCHITECTURE_AND_COMPONENT_MAP.md b/docs/DASHBOARD_ARCHITECTURE_AND_COMPONENT_MAP.md
new file mode 100644
index 0000000..b7610e9
--- /dev/null
+++ b/docs/DASHBOARD_ARCHITECTURE_AND_COMPONENT_MAP.md
@@ -0,0 +1,574 @@
+# OpenCluster — Dashboard Architecture & TSX Component Map
+
+**Document ID:** OC-DASH-ARCH-001
+**Version:** 1.0
+**Status:** Implementation blueprint
+**Repository:** `https://github.com/zyrixdevs/opencluster`
+**Scope:** create **only** the top-level `dashboard/` application and compatible documentation
+**Last updated:** 2026-10-22
+
+> **Implementation status:** This document began as the dashboard architecture specification. Phase 1 now uses the implemented structure under `dashboard/src/`: `data/localProvider.ts` (IndexedDB and validation), `data/providerContracts.ts` (provider contracts), TanStack Query collection hooks, Zustand preferences, and feature pages. Proposals below describe intent; the shipped structure and provider behavior take precedence where the original component map differs.
+
+## 1. Architectural decision record — summary
+
+OpenCluster Phase 1 is the permanent client application using **Vite + strict TypeScript + React Router**. Features access records through typed repository contracts and a persistent IndexedDB provider; localStorage is reserved for lightweight preferences. Deterministic seed records initialize an empty workspace only. Future local and cloud providers implement these contracts without requiring page components to change. There is **no backend, authentication server, privileged node agent, or real-time network service in Phase 1**.
+
+**Technology roles:**
+
+| Area | Recommended choice | Responsibility |
+|---|---|---|
+| Application | React, TypeScript, Vite | UI runtime and tooling |
+| Navigation | React Router | Routes, deep links, nested layouts, URL state |
+| Styles | Tailwind CSS + shadcn/ui | TSX-first component styling, accessible primitives |
+| Icons/fonts | Lucide React, Sora, Inter, optional JetBrains Mono | Visual system |
+| Async data contract | TanStack Query | Data caching hooks; IndexedDB provider now, remote provider later |
+| Local UI state | Zustand | Selected entities, inspector visibility, preferences |
+| Spatial graph editors | `@xyflow/react` | Agents graph and Automation canvas interaction |
+| 3D scenes | Three.js + React Three Fiber (R3F), Drei if needed | Home hologram and Mission Control globe |
+| Plots | Recharts | Monitoring and lightweight metrics |
+| UI motion | Motion for React (`motion/react`) | Cards, panels, route/page transitions |
+| Sequenced motion | GSAP | Rare hero choreography |
+| Smooth scroll | Lenis | Selected scrolling surfaces, **not flow canvas** |
+| Specialty micro-effects | anime.js | Limited SVG effects when justified |
+| Component inspiration | React Bits | Curated, vendored/adapted with accessible fallbacks |
+| Icons/exports | Static SVG assets | Non-WebGL visual fallbacks |
+| Tests | Vitest + Testing Library + Playwright + axe | Logic, accessibility and end-to-end flows |
+| Quality | ESLint, Prettier, TypeScript strict | Reliable contributions |
+
+**Do not install/initialize every heavy graphics library globally.** Feature routes own lazy imports; packages are introduced when their features are implemented. Audit compatibility and bundle output before lockfile changes.
+
+## 2. Monorepo boundary and exact folders
+
+Do not rename `dashboard` to `frontend`, `dsh` or `dash`; the confirmed top-level folder is **`dashboard/`**. Keep any existing root `AGENTS.md`, `.codex/`, `.github/` and repository docs intact. Add documentation or adjust references only after inspecting the repo.
+
+```text
+opencluster/
+├── dashboard/
+│ ├── public/
+│ │ ├── icons/
+│ │ ├── images/ # Original/licensed static visual assets
+│ │ └── scene-fallbacks/ # SVGs/posters for weak/no WebGL
+│ ├── src/
+│ │ ├── app/
+│ │ │ ├── App.tsx
+│ │ │ ├── AppProviders.tsx
+│ │ │ ├── router.tsx
+│ │ │ ├── routes.ts
+│ │ │ ├── error-boundary.tsx
+│ │ │ └── AppShell.tsx
+│ │ ├── components/
+│ │ │ ├── ui/ # shadcn/ui and app-level primitives
+│ │ │ ├── layout/ # Sidebar, Topbar, PageHeader, Drawer
+│ │ │ ├── feedback/ # DemoBadge, ErrorState, EmptyState, Toast
+│ │ │ ├── charts/ # ChartFrame, metric tooltip, chart legend
+│ │ │ ├── data-display/ # StatusBadge, KPICard, NodePill, Table
+│ │ │ ├── navigation/ # CommandPalette, ClusterSwitcher
+│ │ │ └── canvas/ # Shared graph controls, inspector shell
+│ │ ├── features/
+│ │ │ ├── home/
+│ │ │ │ ├── pages/HomePage.tsx
+│ │ │ │ ├── components/WelcomeHero.tsx
+│ │ │ │ ├── components/HologramStage.tsx
+│ │ │ │ ├── components/ClusterSummaryGrid.tsx
+│ │ │ │ └── scenes/SignalOrbScene.tsx
+│ │ │ ├── mission-control/
+│ │ │ │ ├── pages/MissionControlPage.tsx
+│ │ │ │ ├── components/MissionToolbar.tsx
+│ │ │ │ ├── components/NodeInspector.tsx
+│ │ │ │ ├── topology/TopologyCanvas.tsx
+│ │ │ │ ├── globe/ClusterGlobeScene.tsx
+│ │ │ │ └── globe/GlobeFallback.tsx
+│ │ │ ├── clusters/
+│ │ │ │ ├── pages/ClustersPage.tsx
+│ │ │ │ ├── pages/ClusterDetailsPage.tsx
+│ │ │ │ ├── components/ClusterCard.tsx
+│ │ │ │ └── components/ClusterFormDialog.tsx
+│ │ │ ├── nodes/
+│ │ │ │ ├── pages/NodesPage.tsx
+│ │ │ │ ├── pages/NodeDetailsPage.tsx
+│ │ │ │ ├── components/NodeInventory.tsx
+│ │ │ │ └── components/NodeMetricsPanel.tsx
+│ │ │ ├── agents/
+│ │ │ │ ├── pages/AgentsPage.tsx
+│ │ │ │ ├── components/AgentInspector.tsx
+│ │ │ │ ├── graph/AgentGraphCanvas.tsx
+│ │ │ │ ├── graph/AgentNode.tsx
+│ │ │ │ ├── radial/AgentConstellation.tsx
+│ │ │ │ └── lib/radial-layout.ts
+│ │ │ ├── automation/
+│ │ │ │ ├── pages/AutomationListPage.tsx
+│ │ │ │ ├── pages/WorkflowEditorPage.tsx
+│ │ │ │ ├── canvas/WorkflowCanvas.tsx
+│ │ │ │ ├── canvas/nodes/TriggerNode.tsx
+│ │ │ │ ├── canvas/nodes/AgentTaskNode.tsx
+│ │ │ │ ├── canvas/nodes/ConditionNode.tsx
+│ │ │ │ ├── components/NodePalette.tsx
+│ │ │ │ ├── components/WorkflowInspector.tsx
+│ │ │ │ ├── components/WorkflowToolbar.tsx
+│ │ │ │ ├── lib/validate-workflow.ts
+│ │ │ │ ├── lib/simulate-workflow.ts
+│ │ │ │ └── lib/serialize-workflow.ts
+│ │ │ ├── software/
+│ │ │ │ ├── pages/SoftwarePage.tsx
+│ │ │ │ ├── components/SoftwareCatalog.tsx
+│ │ │ │ └── components/InstallPreviewWizard.tsx
+│ │ │ ├── monitoring/
+│ │ │ │ ├── pages/MonitoringPage.tsx
+│ │ │ │ ├── components/MetricCharts.tsx
+│ │ │ │ └── components/NodeComparison.tsx
+│ │ │ ├── activity/
+│ │ │ │ ├── pages/ActivityPage.tsx
+│ │ │ │ └── components/EventTimeline.tsx
+│ │ │ └── settings/
+│ │ │ ├── pages/SettingsPage.tsx
+│ │ │ └── components/VisualQualitySettings.tsx
+│ │ ├── data/
+│ │ │ ├── contracts/ # Typed interfaces to swap mock for API
+│ │ │ │ ├── ClusterGateway.ts
+│ │ │ │ ├── NodeGateway.ts
+│ │ │ │ ├── AgentGateway.ts
+│ │ │ │ ├── WorkflowGateway.ts
+│ │ │ │ └── MonitoringGateway.ts
+│ │ │ ├── mock/ # Deterministic client-side implementations
+│ │ │ │ ├── seeds/
+│ │ │ │ ├── MockGateway.ts
+│ │ │ │ ├── mock-telemetry.ts
+│ │ │ │ └── persistence.ts
+│ │ │ ├── data-provider.tsx
+│ │ │ └── query-keys.ts
+│ │ ├── domain/
+│ │ │ ├── cluster.ts
+│ │ │ ├── node.ts
+│ │ │ ├── metrics.ts
+│ │ │ ├── agent.ts
+│ │ │ ├── workflow.ts
+│ │ │ ├── software.ts
+│ │ │ └── event.ts
+│ │ ├── state/
+│ │ │ ├── use-ui-store.ts
+│ │ │ ├── use-settings-store.ts
+│ │ │ └── use-graph-ui-store.ts
+│ │ ├── hooks/
+│ │ │ ├── use-clusters.ts
+│ │ │ ├── use-nodes.ts
+│ │ │ ├── use-telemetry.ts
+│ │ │ ├── use-motion-preferences.ts
+│ │ │ └── use-visibility.ts
+│ │ ├── motion/
+│ │ │ ├── motion-tokens.ts
+│ │ │ ├── use-hero-timeline.ts
+│ │ │ ├── effects-registry.ts
+│ │ │ └── reduced-motion.ts
+│ │ ├── styles/
+│ │ │ ├── index.css # Tailwind imports/base
+│ │ │ ├── tokens.css # Color/font/radius semantic tokens
+│ │ │ └── fonts.css
+│ │ ├── lib/
+│ │ │ ├── storage.ts
+│ │ │ ├── format.ts
+│ │ │ ├── id.ts
+│ │ │ └── errors.ts
+│ │ ├── tests/
+│ │ │ ├── fixtures/
+│ │ │ ├── unit/
+│ │ │ └── integration/
+│ │ ├── main.tsx
+│ │ └── vite-env.d.ts
+│ ├── e2e/
+│ ├── .env.example # No secrets required for Phase 1
+│ ├── components.json # shadcn/ui configuration
+│ ├── eslint.config.js
+│ ├── index.html
+│ ├── package.json
+│ ├── tsconfig.json
+│ ├── vite.config.ts
+│ ├── vitest.config.ts
+│ └── README.md
+├── docs/ # Existing docs; extend rather than overwrite
+│ ├── DASHBOARD_PRD.md
+│ ├── DASHBOARD_UI_UX_SPEC.md
+│ └── DASHBOARD_ARCHITECTURE.md
+├── AGENTS.md # Preserve and align existing instructions
+└── .codex/ # Existing Codex configuration
+```
+
+The structure above is a target, not a mandate to create empty files. Produce meaningful modules only when used. Co-locate feature tests beside components when more maintainable. No separate `frontend/` or `backend/` directories.
+
+## 3. Runtime composition and component tree
+
+```text
+main.tsx
+└─ AppProviders
+ ├─ ErrorBoundary
+ ├─ QueryClientProvider
+ ├─ DataGatewayProvider (MockGateway instance)
+ ├─ MotionConfig (respect reduced motion)
+ └─ RouterProvider
+ └─ AppShell
+ ├─ AppSidebar
+ ├─ AppTopbar
+ │ ├─ ClusterSwitcher
+ │ ├─ CommandPaletteTrigger
+ │ ├─ DemoBadge
+ │ └─ NotificationMenu
+ ├─ RouteOutlet
+ │ ├─ HomePage → WelcomeHero/HologramStage → SignalOrbScene (lazy)
+ │ ├─ MissionControlPage → TopologyCanvas OR ClusterGlobeScene (lazy)
+ │ ├─ ClustersPage / ClusterDetailsPage
+ │ ├─ NodesPage / NodeDetailsPage
+ │ ├─ AgentsPage → AgentGraphCanvas OR AgentConstellation
+ │ ├─ AutomationListPage / WorkflowEditorPage → WorkflowCanvas
+ │ ├─ SoftwarePage → InstallPreviewWizard
+ │ ├─ MonitoringPage → MetricCharts
+ │ ├─ ActivityPage → EventTimeline
+ │ └─ SettingsPage → VisualQualitySettings
+ └─ GlobalOverlays → Toasts / Dialogs / Confirmation / InspectorPortal
+```
+
+Lazy-load **all page route modules**, plus separate chunks for R3F, globe, agent graph and workflow editor. Shared shell never depends on Three.js imports. Different canvases should mount only when active.
+
+## 4. Data flow and isolation
+
+```text
+USER ACTION → UI component → typed hook / command
+ ↓
+ TanStack Query (reads)
+ Zustand (ephemeral UI state)
+ ↓
+ Domain gateway interface
+ ↓
+ IndexedDB local provider (Phase 1)
+ ↓
+ deterministic seed + versioned local persistence
+
+FUTURE: MockGateway can be replaced by LocalApiGateway or CloudApiGateway
+without rewriting pages or graph components.
+```
+
+**Separation of responsibilities:**
+
+- **React components** render, collect input and surface statuses; they do not fetch network data directly.
+- **Domain models** hold stable serializable types and transformations.
+- **Gateways** expose a typed domain API and are replaceable.
+- **TanStack Query** caches async-like reads, updates/query invalidation.
+- **Zustand** stores selected cluster, inspector state, theme/performance preferences and per-page viewport metadata—not giant mutable node arrays updated at every drag.
+- **React Flow instance/state** owns high-frequency graph editing; persistence occurs on stable events or debounced snapshots, not each pointer-move frame.
+- **IndexedDB** holds structured clusters, nodes, telemetry, agents, tasks, activity, software catalog/configuration, workflows and graph layouts. Writes and schema-bound records are validated; initialize migrates valid existing cluster/node records and seeds only empty collections. `localStorage` is reserved for non-sensitive preferences. Browser storage is user-controlled and is not a secret store.
+
+Do not put every metric series into Zustand. Do not treat localStorage as secure. No secrets/tokens are stored in Phase 1.
+
+## 5. Type contracts (example, refine to actual code)
+
+```ts
+export type NodeStatus =
+ | 'online' | 'offline' | 'degraded' | 'maintenance' | 'unknown';
+
+export type NodeRole = 'coordinator' | 'worker' | 'standalone';
+
+export interface Cluster {
+ id: string;
+ name: string;
+ description?: string;
+ nodeIds: string[];
+ preferredCoordinatorId?: string | null;
+ tags: string[];
+ createdAt: string;
+ updatedAt: string;
+}
+
+export interface ComputeNode {
+ id: string;
+ clusterId: string;
+ hostname: string;
+ displayName: string;
+ role: NodeRole;
+ status: NodeStatus;
+ os: { name: string; version?: string; architecture: string };
+ hardware: {
+ cpuModel: string;
+ cpuCores: number;
+ ramBytes: number;
+ gpu?: { model: string; memoryBytes?: number };
+ diskBytes?: number;
+ };
+ lastSeenAt?: string;
+}
+
+export interface MetricPoint { at: string; value: number | null }
+export interface MetricSeries {
+ name: 'cpu' | 'memory' | 'disk' | 'network-in' | 'network-out';
+ unit: 'percent' | 'bytes' | 'bytes-per-second';
+ points: MetricPoint[];
+ source: 'demo';
+}
+
+export type AgentKind = 'agent' | 'model' | 'tool' | 'memory' | 'task' | 'service';
+export interface AgentEntity {
+ id: string;
+ name: string;
+ kind: AgentKind;
+ status: 'idle' | 'running' | 'paused' | 'failed' | 'offline' | 'unknown';
+ nodeId?: string;
+}
+export interface AgentRelation {
+ id: string;
+ sourceId: string;
+ targetId: string;
+ kind: 'depends-on' | 'uses' | 'delegates-to' | 'runs-on' | 'produces';
+}
+
+export interface WorkflowDocument {
+ schemaVersion: 1;
+ id: string;
+ name: string;
+ nodes: WorkflowNode[];
+ edges: WorkflowEdge[];
+ updatedAt: string;
+ mode: 'demo';
+}
+
+export interface DemoRunResult {
+ runId: string;
+ simulated: true;
+ steps: Array<{ nodeId: string; status: 'passed' | 'failed' | 'skipped'; log: string }>;
+}
+```
+
+Make workflow node types discriminated unions keyed by `type` (e.g., `manualTrigger`, `agentTask`, `condition`, `notification`) with typed config schemas and explicit handles. Use schema validation for user JSON imports. `WorkflowNode` and `WorkflowEdge` are domain types mapped to React Flow display types so future API contract isn't coupled to one visualization library.
+
+## 6. Typed gateway interfaces
+
+```ts
+export interface ClusterGateway {
+ listClusters(): Promise;
+ getCluster(id: string): Promise;
+ createCluster(input: { name: string; description?: string }): Promise;
+ updateCluster(id: string, patch: Partial>): Promise;
+ deleteCluster(id: string): Promise;
+}
+
+export interface NodeGateway {
+ listNodes(filter?: { clusterId?: string; status?: NodeStatus }): Promise;
+ getNode(id: string): Promise;
+}
+
+export interface WorkflowGateway {
+ listWorkflows(): Promise;
+ getWorkflow(id: string): Promise;
+ saveWorkflow(document: WorkflowDocument): Promise;
+ deleteWorkflow(id: string): Promise;
+ simulateWorkflow(id: string): Promise;
+}
+
+export interface DashboardGateway {
+ clusters: ClusterGateway;
+ nodes: NodeGateway;
+ workflows: WorkflowGateway;
+ // Later: agents, monitoring, software, events, remote control.
+}
+```
+
+Call methods through hooks, e.g. `useClusters()` based on query key `['clusters']`, `useNode(id)` based on `['node', id]`; mutations invalidate precisely affected keys. Query hooks should not expose server URLs or assume cookies/token storage. Use mock fixtures rather than random runtime network behavior.
+
+## 7. Canvas architecture — Agents and Automation
+
+### 7.1 Shared primitives, not a monolithic graph
+
+Create a shared **CanvasFrame**, **CanvasToolbar**, **GraphInspectorLayout**, **StatusLegend**, **FitViewButton**, **ZoomControls**, **CanvasEmptyState** and **KeyboardNodeList**. Share these primitives while **keeping each feature's graph model, state and behaviors distinct**. The AI Agents relationship map is not the same domain as an executable workflow graph.
+
+### 7.2 Agents graph
+
+- `AgentGraphCanvas` uses React Flow custom nodes/edges representing agents, models, tools, memory and tasks.
+- `AgentConstellation` consumes same entities and relations, runs a deterministic radial layout function by breadth-first graph depth or grouped categories; visualize with React Flow positions or efficient SVG, whichever performs better and preserves inspector.
+- Do **not** model agent cognitive internals or pretend to show hidden model reasoning.
+- `useAgentGraphState` maintains layout and selection. Layout versions persist per mode.
+- Edges have semantic kind and are not blindly editable unless the demo explicitly supports relationship editing.
+
+### 7.3 Automation editor
+
+- `WorkflowCanvas` uses React Flow with typed custom node components registered outside render.
+- `NodePalette` owns available node metadata/config schemas; drag/drop and click-to-add must both work.
+- `WorkflowInspector` edits typed config with schema validation.
+- `validateWorkflow(doc)` is pure, synchronous and testable. It checks IDs, ports, required fields, supported connections, unreachable graph segments, cycle policy, and at least one trigger.
+- `simulateWorkflow(doc, seed)` never executes arbitrary expressions or network requests. Deterministic branch choices are supplied as fixtures/controlled demo inputs.
+- `serializeWorkflow` and `parseWorkflow` implement schema version validation and safe data import.
+- Undo/redo tracks discrete document operations, not every intermediate pixel while dragging.
+- Edge styles convey status and semantics; selection states reflected in a synchronized accessible list.
+
+**Graph performance rules** (based on React Flow docs): memoize custom nodes/edges; memoize callbacks passed to canvas; avoid subscribing the whole page to `useNodes()` or full graph state if only a count or selected ID is needed; render inspector off discrete selection; virtualize palettes/lists if extensive.
+
+### 7.4 Interaction event contract
+
+| Event | Meaning | Persistence |
+|---|---|---|
+| `node:selected` | Open inspector and highlight relations | URL or ephemeral state |
+| `node:drag-start` | Interaction begins; suspend costly ambient animations | No |
+| `node:drag-end` | Commit coordinates and history step | Debounced save |
+| `edge:connected` | Validate types; add edge if valid | Commit + save |
+| `inspector:changed` | Change node configuration | Commit + save |
+| `canvas:mode-change` | Graph vs radial | Save both per-mode layouts |
+| `workflow:simulate` | Generate deterministic local run result | History optional |
+| `workflow:import` | Parse, validate and preview before replacing | Save only after consent |
+
+## 8. 3D hologram architecture and failure isolation
+
+- Home route lazy imports `HologramStage` → `SignalOrbScene`; Mission Control lazy imports `ClusterGlobeScene` only when Globe mode selected.
+- Wrap scenes with React `Suspense` and error boundary; static SVG/poster fallback handles WebGL disabled/context-loss and loading.
+- Use small, reused buffers/instanced particles; avoid allocating geometry/materials in render loops.
+- Use quality-dependent `[min, max]` DPR (e.g. `[1, 1.5]` on desktop, lower on weaker devices) and capped geometry/particle budgets.
+- Prefer R3F `frameloop="demand"` for static or mostly interaction-driven scenes; animated scenes request frames only while active. Inactive pages unmount scene.
+- Recompute only when scene props change; separate time updates from React state updates.
+- Use WebGL context cleanup and detection; scene never steals pointer actions from adjacent cards.
+- `prefers-reduced-motion`, performance mode, and tab visibility can all disable continuous motion.
+
+**Scene design:** original teal/blue orbital signal mesh with rings, arcs and sparse particles; glowing globe with optional demo markers. No copy of proprietary graphic assets, no copyrighted JARVIS marks.
+
+## 9. Animation policy and lifecycle
+
+1. Use Motion's `MotionConfig reducedMotion="user"`, but expose app preference that can further reduce motion; do not override the OS need for accessibility.
+2. Use GSAP for a limited number of sophisticated entrance timelines. Every timeline must be scoped, revertable and killed on unmount.
+3. Lenis only in scroll-driven informational surfaces. Do not initialize a second global RAF alongside GSAP ticker. If integrating, use **one** ticking strategy and unsubscribe on unmount. React Flow retains native wheel/scroll behaviors.
+4. anime.js allowed for a specific SVG path/micro-effect when justified and documented; do not duplicate Motion/GSAP capabilities unnecessarily.
+5. React Bits should be evaluated component-by-component: copy/adapt only components that fit accessibility/performance, verify original attribution/license, and use fallback variants.
+6. Use CSS transitions for simple hover/focus where enough; TSX/Tailwind-first does not eliminate CSS.
+
+## 10. Performance budget and engineering checks
+
+Set **provisional** budgets, then profile and revise on target machines. Never present target numbers as measured achievements.
+
+- Initial app shell should remain small, with WebGL libraries isolated in lazy route chunks.
+- Desktop target: fluid 60 FPS for typical interactions; 30 FPS fallback acceptable on constrained devices if usable, with reduced effects.
+- Graph target: smooth editing with 50 nodes/100 edges on ordinary laptop; stress-test 100+ with representative UI.
+- Avoid repeated chart animation on every update; use seeded stable metrics.
+- No `setState` per particle or per frame; don't rerender root app for animation frame ticks.
+- Suspend ambient scenes when hidden; disable visual effects while dragging graph when they compete for GPU.
+- Use source maps, bundle analyzer and performance profiler in development; examine chunk waterfalls and context-loss handling.
+- List/table virtualization where 200+ events are displayed.
+- Use React Profiler to identify state subscription hotspots; memoization must be measurement-driven rather than blanket.
+
+## 11. Security and trust model for this client-only phase
+
+- No access tokens, SSH keys, or user credentials in demo storage.
+- No embedded remote execution, command injection or unreviewed installs.
+- No `eval` or dynamic execution of imported workflow content.
+- Sanitize/escape untrusted display strings; React output is escaped by default, avoid unsafe HTML.
+- Parse imported JSON with size limit, schema validation, and safe errors; file content is **data**, not executable logic.
+- Enforce no real fetches to `localhost`, private network hosts or cloud control endpoints in the demo.
+- Label any screenshots/docs of the dashboard as sample/demo to avoid misleading users.
+- No assumption that a browser can directly control nodes or install software.
+
+## 12. Accessibility architecture
+
+- Use semantic primitives from shadcn/ui / Radix where possible.
+- Global focus indicators, skip-to-content, labeled inputs, useful error announcements and focus restoration after dialogs.
+- Keyboard interaction for graph selection, canvas zoom/fit, node palette, flow connecting/editing through menu alternatives.
+- Text/table equivalents for graphics; describe node connectivity in side inspector.
+- Honor `prefers-reduced-motion`; avoid persistent flashing and rapid light effects.
+- Charts have accessible summaries and tabular fallback. 3D is decorative or a supplemental selection mode, never sole source of facts.
+
+## 13. Testing architecture
+
+### Unit (Vitest)
+
+- Formatter precision/unit conversion.
+- Cluster validation and persistence migration.
+- Workflow schema parse/serialize and invalid import handling.
+- Graph connectivity, handle type checking, cycle detection and conditional validation.
+- Simulated workflow traversal determinism and failure cases.
+- Seeded metric series repeatability and null metric handling.
+
+### Integration (Testing Library)
+
+- Home cluster context switching.
+- Create cluster flow and state update.
+- Nodes filter and detail drawer.
+- Agent selection synchronized with inspector.
+- Workflow settings form reflects selected block and validation.
+- Settings change triggers low-motion/static fallback.
+
+### E2E (Playwright)
+
+- Navigate all ten areas and deep-link refresh.
+- Create/edit/delete demo cluster with persistence.
+- Select topology node and switch modes without selection loss.
+- Build workflow, branch, save, reload, export/import and simulate.
+- Mobile smoke and keyboard navigation.
+- WebGL unavailability fallback, reduced-motion setting, corrupt saved data recovery.
+
+### Quality gates
+
+- `npm run lint`
+- `npm run typecheck`
+- `npm run test`
+- `npm run build`
+- `npm run test:e2e` (in CI with browser dependencies available)
+
+Adjust package-manager command names after checking repository conventions. Include test fixtures; never assert pass without running.
+
+## 14. Codex task decomposition and coordination
+
+Before acting, Codex must inspect root `AGENTS.md`, `.codex/`, `docs/`, branch status and working tree. Avoid changing files outside scope except agreed documentation references. Development branches and PRs must follow repository permissions, without fake reviews.
+
+| Stage | Workstream | Key files | Exit condition |
+|---|---|---|---|
+| 0 | Audit/architecture | Existing docs + planned dashboard | No conflicts with root project rules |
+| 1 | Foundation | `dashboard/package.json`, app, styles, providers, router | App shell and blank routes compile |
+| 2 | Shared UI/data | `components/*`, `data/*`, `domain/*`, `state/*` | Typed demos and design tokens working |
+| 3 | Home/hero | `features/home/*` | First-class readable home and fallback |
+| 4 | Clusters/nodes/monitoring | Three related features | Filters, details, charts and persistence |
+| 5 | Mission Control | Topology and globe | Selection, zoom and inspector with fallback |
+| 6 | Agents canvas | `features/agents/*` | Graph/radial modes + saved layout |
+| 7 | Automation canvas | `features/automation/*` | Complete deterministic demo editor |
+| 8 | Software/activity/settings | Respective features | Functional previews, logs, preferences |
+| 9 | QA/hardening | Tests, docs, bundles | Accessible, responsive, lint/type/test/build pass |
+
+**Parallel safe boundaries:** a UI component workstream can implement independent shared primitives while data/domain types are established; mission scene and monitoring can be parallel after contracts stabilize. Avoid concurrent modifications to `router.tsx`, shared theme tokens, `package.json` or the same graph model. Merge small, tested increments.
+
+## 15. `dashboard/package.json` script contract
+
+The application should expose at minimum:
+
+```json
+{
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc -b && vite build",
+ "preview": "vite preview",
+ "lint": "eslint .",
+ "typecheck": "tsc --noEmit",
+ "test": "vitest run",
+ "test:watch": "vitest",
+ "test:e2e": "playwright test"
+ }
+}
+```
+
+This is a **proposed script interface**, not a complete package manifest. Align TS project-reference setup with `tsc -b` as appropriate; avoid an inconsistent or broken `typecheck` command. Pin to compatible current stable package releases rather than guessing exact version strings.
+
+## 16. Architecture validation / definition of done
+
+- [ ] One runnable app exists in `dashboard/`, no backend installed.
+- [ ] All ten functional areas navigable and route errors handled.
+- [ ] Service contracts isolate persisted local records and sample telemetry from presentational UI.
+- [ ] Graph inspector and canvases use stable, typed state.
+- [ ] Workflow builder supports real UI editing with **simulated** execution only.
+- [ ] Node view and globe share one source of truth; scene fallback works.
+- [ ] Animations adhere to clear ownership; no repeated RAF loops.
+- [ ] Fonts, colors and accessibility modes are consistent.
+- [ ] WebGL route loading is lazy; initial shell has no unneeded Three.js chunk.
+- [ ] Import/export validation, persistence migration and recovery tested.
+- [ ] Open-source build/run instructions and licensing verified.
+- [ ] All attempted lint/test/typecheck/build steps reported truthfully.
+
+## 17. Sources for API verification
+
+- React Flow home: https://reactflow.dev/
+- React Flow performance: https://reactflow.dev/learn/advanced-use/performance
+- React Flow save/restore: https://reactflow.dev/examples/interaction/save-and-restore
+- Motion: https://motion.dev/docs/react
+- Motion accessibility: https://motion.dev/docs/react-use-reduced-motion
+- React Three Fiber: https://r3f.docs.pmnd.rs/
+- R3F performance: https://r3f.docs.pmnd.rs/advanced/scaling-performance
+- Lenis React integration: https://github.com/darkroomengineering/lenis/blob/main/packages/react/README.md
+
+Use these as implementation references, and verify package API/version compatibility at coding time.
diff --git a/docs/DASHBOARD_PERFORMANCE.md b/docs/DASHBOARD_PERFORMANCE.md
new file mode 100644
index 0000000..a2ac1b8
--- /dev/null
+++ b/docs/DASHBOARD_PERFORMANCE.md
@@ -0,0 +1,29 @@
+# Dashboard Motion and Performance
+
+## Rendering boundaries
+
+- React Router lazy-loads each page so the initial shell does not include monitoring charts, the AI graph, automation canvas, or 3D dependencies.
+- Mission Control loads the Three.js globe only after the operator selects the 3D globe mode. Network and orbit modes use SVG.
+- Agents and Automation use route-loaded React Flow canvases with local graph/workflow persistence. They never invoke external services. Current seeded graphs are small enough that graph virtualization is not needed.
+- Font files are bundled with the dashboard. The app makes no external font request.
+
+## Motion responsibilities
+
+- Framer Motion handles small route entrance transitions.
+- GSAP sequences the Home holographic signal visual only.
+- Lenis provides document scrolling while full motion is enabled. It is not active in performance mode or when the operating system requests reduced motion.
+- Three.js is limited to the optional Mission Control globe. No animation library animates telemetry values.
+
+The decorative GSAP and Three.js loops stop when the browser tab is hidden. The controls still work without automatic motion. OS reduced-motion and the saved motion preference disable decorative animation. Performance mode avoids Lenis, reduces WebGL pixel ratio, and removes costly blur/glow treatments.
+
+## Performance expectations
+
+- Keep canvas and SVG animations scoped to the active page and use transforms/opacity for motion.
+- Coalesce resize updates with `requestAnimationFrame`; dispose WebGL geometry, materials, observers, and event listeners when leaving globe mode.
+- Use a static, selectable-node fallback when WebGL is unavailable. Lower the renderer pixel ratio for performance mode and weak displays.
+- Inspect production chunk output when adding dependencies. Three.js must remain in the lazy globe chunk; do not increase the initial shell chunk to support optional visualizations.
+- Large event histories are not present in the seeded local dataset. Add virtualization when stored user data sizes show a measurable need.
+
+## Scope limits
+
+Performance has been checked through typechecking and the production build, not through a device lab or benchmark suite. Hardware telemetry remains illustrative; no real-time refresh rate, node-count ceiling, or hardware performance target is claimed.
diff --git a/docs/DASHBOARD_PRD.md b/docs/DASHBOARD_PRD.md
new file mode 100644
index 0000000..5ca660d
--- /dev/null
+++ b/docs/DASHBOARD_PRD.md
@@ -0,0 +1,316 @@
+# OpenCluster — Dashboard Product Requirements Document
+
+**Document ID:** OC-DASH-PRD-001
+**Version:** 1.0 — Phase 1 specification
+**Status:** Phase 1 implementation in progress
+**Owner:** ZYRIX DEVS
+**Project:** `https://github.com/zyrixdevs/opencluster`
+**Implementation scope:** `dashboard/` only
+**Last updated:** 2026-10-22
+
+> **Delivery clarification:** Phase 1 is the permanent OpenCluster frontend, not a disposable demo. It currently runs against a validated, persistent browser-local provider. Seed records and hardware telemetry are labeled as sample data. Until backend services exist, the frontend must not connect to real nodes, perform software installation, remote execution, cloud account management or AI inference.
+
+## 1. Executive summary
+
+OpenCluster is an open-source interface for managing computer clusters, individual Linux/other compute nodes, AI agents, automation flows and infrastructure health. Its product model supports **local/self-hosted** and **cloud-connected** installations using the same dashboard UI. Phase 1 builds the permanent standalone dashboard with a local data provider first; its provider contracts are the integration boundary for future local and cloud services.
+
+**Product promise:** A powerful, approachable *mission control* for systems that would otherwise require many terminals, tabs, scripts and monitoring tools. The experience must communicate truthfully which actions are real and which are simulated.
+
+**One-line vision:** *One dashboard. Every node. Total control.* “Control” is a future product aspiration, not a Phase 1 claim.
+
+## 2. Problem, audience and jobs to be done
+
+### Problems
+
+- Home lab operators have limited at-a-glance visibility across multiple computers.
+- AI tool and agent operators switch among disjoint consoles with no unified visual overview.
+- Existing infrastructure interfaces can be overly complex for small, mixed-OS clusters.
+- Workflow builders, agent relationship maps and hardware monitoring are typically separate products.
+- Attractive monitoring dashboards often lack usable deep interactions; technical tools often lack readable hierarchy.
+
+### Primary user personas
+
+1. **Solo developer / home lab owner:** 2–10 computers, mixed Arch Linux/Debian, wants health, tasks and software overview in one place.
+2. **AI agent builder:** wants a visual relationship map connecting agents, models, tools, memory and jobs; wants an editable workflow canvas.
+3. **Small-team operator:** tracks multiple clusters, compares hardware capacity, and inspects warnings.
+4. **Open-source contributor:** needs clear code separation, typed mock APIs, docs, tests and manageable components.
+
+### Top user jobs
+
+- Understand cluster health within five seconds of arriving.
+- Identify *which* node or agent requires attention and why.
+- Explore topology visually without sacrificing precise tabular detail.
+- Create a demo automation with conditional branches, save it and restore it.
+- Explore relationships among AI agents in graph and radial views.
+- Preview an installation or task without mistaking it for a real remote action.
+
+## 3. Scope and non-goals
+
+### In scope: Phase 1
+
+- `dashboard/` Vite + React + TypeScript app, independent of any server.
+- Complete application shell and ten navigable pages.
+- Design system, polished motion, responsive layouts and accessibility.
+- Demo cluster and node models, deterministic telemetry, mock activity streams.
+- Editable demo clusters, node groups, and preferences with persistence.
+- **Mission Control** interactive topology with node selection, inspector and optional globe presentation.
+- **Agents** draggable graph with dual **network** and **radial/constellation** visualization modes; inspect agent relations.
+- **Automation** n8n-inspired visual editor with working drag/connect/configure/save/load/undo/redo and a **simulated** run preview.
+- AI Software catalog with reviewable **preview-only** deployment wizard.
+- Search/command palette, keyboard navigation, coherent loading/empty/error states.
+- Local docs, examples, tests, production build, extensible data service layer.
+
+### Explicitly out of scope: Phase 1
+
+- Backend servers, PostgreSQL, real-time network ingestion, authentication and real user accounts.
+- Node enrollment agents, remote shell, system configuration, OS package installs, container deployment, real task execution.
+- Actual Codex/Claude Code/Hermes/OpenClaw adapters and integrations.
+- Actual n8n execution engine or n8n-compatible workflow format.
+- Real geographic geolocation/IP tracking, cloud resource billing, production security enforcement.
+- Billing, organization management, server-side collaboration and multi-user sync.
+
+### Future operating modes (architect for, do not build)
+
+- **Standalone local:** user hosts dashboard plus eventual control plane locally.
+- **Managed cloud:** user accesses cloud dashboard; securely enrolled agents initiate outbound connections to backend.
+- Shared application features, different data adapters and authentication contexts. Do not tightly couple components to either mode.
+
+## 4. Product principles
+
+1. **Truthful states:** seeded sample data and simulated execution must be unmistakably labeled; local record management is real and persistent.
+2. **Command center, not wallpaper:** visuals lead to information and action.
+3. **Spatial + tabular:** every important visual graph has a textual/list alternative.
+4. **Progressive disclosure:** summary first, details on inspection.
+5. **Performance-aware spectacle:** cinematic effects only where meaningful; degrade gracefully.
+6. **Accessible by default:** keyboard, contrast, screen readers, reduced motion and touch support.
+7. **Local-first Phase 1:** local records work without login or network; backend-dependent services stay disconnected.
+8. **Open-source maintainability:** clear components, tests, explainable choices, no proprietary dependencies required to run demo.
+
+## 5. Navigation and application routes
+
+| Area | Route | Main purpose |
+|---|---|---|
+| Home | `/` | Greeting, holographic hero, at-a-glance cluster intelligence |
+| Mission Control | `/mission-control` | Interactive topology, globe/network modes, health inspector |
+| Clusters | `/clusters`, `/clusters/:clusterId` | Create demo clusters and inspect hierarchy |
+| Nodes | `/nodes`, `/nodes/:nodeId` | Inventory, health and hardware details |
+| Agents | `/agents` | Agent relationship graph + radial constellation |
+| Automation | `/automation`, `/automation/:workflowId` | Visual workflow editor and saved demo flows |
+| AI Software | `/software` | Supported software catalog and install previews |
+| Monitoring | `/monitoring` | Comparative telemetry, charts and thresholds |
+| Activity | `/activity` | Logs, event timeline and simulated notifications |
+| Settings | `/settings` | Theme, visual quality, motion, mock data and preferences |
+
+Routes should preserve meaningful UI state with URL search params where practical (e.g. `cluster`, `node`, `view`, `timeRange`), so refresh and browser back work.
+
+## 6. Functional requirements (P0/P1/P2)
+
+**Priority:** P0 must ship in Phase 1; P1 desirable during polish; P2 is explicitly deferred.
+
+### FR-001 Application shell (P0)
+
+- Persistent collapsible sidebar or navigation rail; top bar with cluster selector, search, notifications, visual quality control.
+- Deep links, active-route indicator, breadcrumbs on detail pages, responsive mobile navigation.
+- Accessible command palette (`Ctrl/⌘ + K`) that navigates to pages and filters demo entities.
+- Demo-mode indicator visible globally.
+- Every visible button either performs a usable action or shows a clear, nondeceptive explanation.
+
+**Acceptance:** Keyboard-only user can reach every page and use the main menus; refresh on a deep link succeeds.
+
+### FR-002 Home (P0)
+
+- Personalized-looking but local-only greeting (`Operator` default; editable display name).
+- Hero includes 3D/simulated JARVIS signal-orb from reference 7, with graphic fallback.
+- Summary KPIs for clusters, online nodes, alerts, average CPU and agents.
+- Cluster cards; quick route actions; recent event list; AI automation overview.
+- Scope selector for all clusters vs one selected cluster.
+
+**Acceptance:** Selecting a cluster updates appropriate cards; pausing animation does not hide content; hero loads without blocking text.
+
+### FR-003 Mission Control (P0)
+
+- Interactive node topology graph with visible coordinator/worker roles (not a mandatory master model).
+- Select, focus, pan, zoom, fit-to-view; inspector panel showing OS, telemetry, health, connections and jobs.
+- Toggle between **Topology** and **Globe** view. In Phase 1 globe uses fabricated/demo placement and explicitly says so; never imply geolocation detection.
+- Status legend, filters, offline state and connection-failure indication.
+
+**Acceptance:** Selecting node A updates details for node A; topology remains usable with 50 demo nodes; the 3D globe has a non-WebGL fallback.
+
+### FR-004 Clusters (P0)
+
+- Create, rename, archive/delete **demo** cluster, inspect members, set optional preferred coordinator for demonstration.
+- Create form validates title, prevents duplicate identifiers, explains deletion.
+- Clusters persisted locally; new cluster visible in selector and summary.
+
+**Acceptance:** Create → navigate → rename → reload → change retained; undo or confirmation before destructive local deletion.
+
+### FR-005 Nodes (P0)
+
+- Inventory grid and table; sorting and filters (cluster, OS, online status, node role).
+- Node detail displays CPU, RAM, GPU when available, storage, network, uptime and heartbeat timestamp.
+- Show `N/A` when GPU/metric unavailable instead of `0`.
+- Metrics are simulated/deterministic and visibly labelled.
+
+**Acceptance:** Node filtering works without visual graph; units, times and percentages display correctly.
+
+### FR-006 AI Agent Canvas (P0)
+
+- Graph mode: draggable agents, tools, LLMs, memory and jobs with labeled edges, connection directions and inspector.
+- Radial mode: concentric structure or orbit layout inspired by reference 5, preserving selection/context across mode switch.
+- Zoom/pan/fit, selection, filters, grouped nodes, minimap as useful.
+- Graph layouts persist locally per example workspace.
+- Graph represents **relationships**, not live agent reasoning or execution.
+
+**Acceptance:** Dragging maintains edge attachment; changing mode retains node selection and selected inspector; keyboard-accessible entity list is supplied.
+
+### FR-007 Automation Canvas (P0)
+
+- React Flow-based editor with node palette; add via click or drag/drop; connect handles.
+- Node types: Manual Trigger, Schedule Trigger (demo), Agent Task, Condition, Delay (demo), Data Transform, Notify (demo), Success, Failure.
+- Inspector edits labels and typed config; validation catches disconnected required inputs, missing required settings and incompatible connection types.
+- Auto-save or explicit save (choose and communicate clearly), rename, duplicate, import/export **OpenCluster demo JSON**, undo/redo, delete, pan/zoom, mini-map.
+- Simulated run highlights nodes and shows synthetic logs without invoking real services.
+
+**Acceptance:** A branching example can be built, saved, reloaded and exported/imported; graph syntax validated; simulator clearly identifies itself as simulated.
+
+### FR-008 AI Software (P0)
+
+- Catalog for Codex, Claude Code, Hermes Agent, OpenClaw, plus generic placeholders, with categories and compatibility notes where verified.
+- Installation preview wizard: select tool → select demo nodes → review requested configuration → show *Simulation only / Nothing installed* result.
+- No user-provided API keys, credentials or arbitrary shell commands in Phase 1 forms.
+
+**Acceptance:** Preview never performs a network or OS action; result is explicitly labeled preview.
+
+### FR-009 Monitoring (P0)
+
+- Aggregated cluster/node telemetry charts and cards using Recharts.
+- Time range presets (15m, 1h, 24h, 7d) driven by seeded sample series rather than random spikes each render.
+- Clear metric units, axes, hover tooltips, thresholds and zero-data states.
+- Node comparison and cluster filters.
+
+**Acceptance:** Chart values stable under re-render; accessibility summary available in text/table; no unsupported data is fabricated as real.
+
+### FR-010 Activity (P0)
+
+- Event timeline and log-style table; search, severity/source/status filters; sort and drill-down.
+- Messages distinguish demo actions from seeded events. Optional “generate demo event” action is local only.
+- Long lists are virtualized where necessary.
+
+**Acceptance:** Filters and details work; dates and local timezone consistently formatted.
+
+### FR-011 Settings (P0)
+
+- Theme: dark default, optional light/high-contrast if feasible.
+- Motion: system / reduced / cinematic.
+- Visual quality: automatic / performance / high.
+- Demo reset (with confirmation), sample dataset selector, display name and UI density.
+- Persist non-sensitive settings in browser storage with schema version/migration.
+
+**Acceptance:** Reduced motion disables continuous 3D movement; reset returns data/settings to defaults, explicitly warned.
+
+### FR-012 Cross-cutting (P0)
+
+- Responsive desktop/tablet/mobile designs; empty/error/skeleton states; toasts and confirmation dialogs.
+- No magic numbers in component rendering; standardized data types, theme tokens and sizing.
+- Tests, accessibility checks, lint, TypeScript strict validation and production build.
+
+### P1 extras
+
+- Guided onboarding, “demo tour,” advanced graph search, saved graph viewpoints, URL-shareable visual filters, dashboard card arrangement, contextual keyboard shortcuts, chart screenshot/export, tutorials and UI preference presets.
+
+### P2/future integrations (NOT Phase 1)
+
+- Real account auth, cloud control plane, node agent installation and pairing, streaming metrics, remote commands, real scheduling and task execution, agent adapters, marketplace, organizational roles and multi-user collaboration.
+
+## 7. Seeded demo scenario
+
+Default dataset: **Helena Lab** or **OpenCluster Demo Lab** (avoid personal identifiers): five computers, one optional coordinator and four workers. Example OS distribution: one Arch Linux, four Debian. Include another small demo cluster to demonstrate cross-cluster navigation. Sample agents, workflows, jobs, events and software availability must be internally consistent. CPU/RAM/time series should derive from seeded input and timestamps, not `Math.random()` on every render.
+
+Statuses: `online`, `offline`, `degraded`, `maintenance`, `unknown`. Agent statuses: `idle`, `running`, `paused`, `failed`, `offline`, `unknown`. Clearly mark seeded data “DEMO”.
+
+## 8. Product data entities
+
+- **Cluster:** ID, name, description, tags, optional coordinator node ID, member node IDs, created/updated times.
+- **Node:** ID, cluster ID, hostname, display name, role, OS/arch, capabilities, status, lastSeen, hardware, metrics.
+- **MetricSeries:** metric name, unit, time range, ordered timestamp/value points, sample provenance (`demo`).
+- **Agent:** ID, name, kind, model/tool/memory links, status, optional node binding, metadata.
+- **Relation:** source ID, target ID, type, direction, visual metadata.
+- **Workflow:** ID, name, version, graph nodes/edges, config, created/updated.
+- **Run:** run ID, workflow ID, `simulated` flag, steps with timestamps/status/log messages.
+- **Software:** ID, supported platforms (documented, not guessed), icon, category, descriptions, preview steps.
+- **ActivityEvent:** ID, source, cluster/node/agent ID, severity, text, timestamp, `demo` flag.
+- **Settings:** display name, theme, motion preference, render quality, density, schema version.
+
+## 9. UX constraints and reference treatment
+
+**Ref 1** is the principal UI composition: editorial typography, measured glass, rounded modules and dense KPIs. **Refs 2–3** guide global/network holograms. **Refs 4–5** guide relationship map and radial constellation. **Ref 6** specifies workflow canvas mechanics. **Ref 7** guides a JARVIS-like abstract energy structure (no copyrighted JARVIS identity, audio or exact film recreation). All images are *inspiration*, not assets to embed or designs to copy verbatim.
+
+Visual character: near-black graphite/navy, restrained mint/cyan for healthy state, amber caution and red error, subtle light/glass where appropriate. Typography: **Sora** for hero/headings, **Inter** for UI/body, optional **JetBrains Mono** for hostnames/metrics. Accessibility takes precedence over glow and translucent overlays.
+
+## 10. Non-functional requirements and quality targets
+
+The following are **engineering targets to test**, not promised results:
+
+| Area | Target / requirement | Verification |
+|---|---|---|
+| Browser | Current stable Chromium, Firefox, Safari | Manual smoke / Playwright |
+| Responsiveness | 360px mobile through 1920px+ desktop | Visual regression at key breakpoints |
+| Interactions | 60 FPS target on capable desktops, graceful lower-power fallback | Performance profiling on reference devices |
+| Startup | Fast initial text/UI shell; heavy routes split into chunks | Bundle report / Web Vitals |
+| Graph | Smooth dragging and zooming on representative 30–100 node dataset | Browser profiler |
+| 3D | Dynamically scaled DPR, visibility pause, reduced-motion fallback | GPU / CPU trace |
+| Accessibility | WCAG 2.2 AA-oriented semantic UI, focus and contrast | axe + keyboard audit |
+| Offline | Dashboard and seeded demo work after initial load with cached assets where configured | Offline smoke test |
+| Persistence | Browser-storage migration and corrupt-state recovery | Unit tests |
+| Reliability | Graph state not lost on route changes/reloads | E2E test |
+| Security | No arbitrary code execution, no tokens, no hidden privileged actions | Code review |
+
+Use `prefers-reduced-motion` and quality settings to adjust visual intensity. Decorative scenes must not capture keyboard focus or obscure meaningful controls. Avoid a permanent WebGL canvas rendering in the global shell.
+
+## 11. Testable acceptance journeys
+
+1. **First open:** enter `/`, see readable cards, demo label, working navigation and optional 3D hero fallback.
+2. **Cluster management:** create cluster “Lab B”; assign node; switch cluster; refresh; state retained.
+3. **Topology inspection:** open Mission Control; select node; inspector matches; switch view; selection stays; `Fit view` works.
+4. **Agent mapping:** drag tool node; see attached edge update; radial mode; inspect a linked model; refresh restores graph.
+5. **Workflow authoring:** add manual trigger, agent step and condition; connect success/failure branches; edit config; validation; save; refresh; mock run highlights steps; export/import produces same graph.
+6. **Software preview:** choose Hermes; select demo target; see “Simulation only”; finish without actual installation.
+7. **Accessibility:** complete primary workflows by keyboard; reduced-motion mode presents stable visuals.
+8. **Failure:** disable WebGL; fallback and all information remain available; corrupt browser demo state triggers safe recovery.
+
+## 12. Milestones and release gates
+
+- **M0 — Foundation:** app scaffold, strict TS, routes, tokens, shared UI, data gateway, docs.
+- **M1 — UI shell + Home:** fully navigable product, cards, seeded data, Jarvis-style visual with fallback.
+- **M2 — Clusters + Nodes + Monitoring:** state flows, detail panels, stable charts.
+- **M3 — Mission Control:** graph topology, inspector, optional globe, responsive fallback.
+- **M4 — Agents:** graph canvas + radial view, selection/persistence.
+- **M5 — Automation:** complete demo workflow editor, validation, simulation and persistence.
+- **M6 — Software + Activity + Settings:** end-to-end previews and system preferences.
+- **M7 — Hardening:** accessibility, profiling, tests, docs and release polish.
+
+**Exit gate:** no misleading “live” labels; all pages usable; no critical lint/type/build/test issues; responsive; accessible; docs match actual functionality.
+
+## 13. Risks, mitigations and unresolved decisions
+
+| Risk | Mitigation |
+|---|---|
+| Heavy visual effects overshadow usability | Progressive enhancement; preserve readable cards and inspector first |
+| Library overlap causes animation conflicts | Assign one animation ownership per component; central motion policy |
+| React Flow rerender performance | Memoized node types and stable callbacks; don't subscribe globally to every node change |
+| 3D fails on low-end browsers | SVG/static scene, on-demand render, WebGL detection, error boundary |
+| Demo actions resemble real actions | Persistent “DEMO” status and inline preview confirmation |
+| Graph editor design too complex | Deliver graph baseline and save/load before radial polish |
+| n8n confusion | Specify n8n-inspired interactions; no compatibility claim |
+| Local vs cloud scope leaks into Phase 1 | Typed transport interfaces; mock implementation only |
+
+**Decisions for later:** backend technology, local agent language, auth vendor/model, plugin integration execution policy, real metrics protocol, distributed execution engine, final domain, brand asset license. None blocks dashboard implementation.
+
+## 14. References for implementers
+
+- React Flow: https://reactflow.dev/learn/advanced-use/performance and https://reactflow.dev/examples/interaction/save-and-restore
+- Motion for React: https://motion.dev/docs/react and https://motion.dev/docs/react-motion-config
+- React Three Fiber scaling: https://r3f.docs.pmnd.rs/advanced/scaling-performance
+- Lenis: https://github.com/darkroomengineering/lenis
+
+External APIs/libraries should be checked against installed package versions before coding.
diff --git a/docs/DASHBOARD_UI_UX_SPEC.md b/docs/DASHBOARD_UI_UX_SPEC.md
new file mode 100644
index 0000000..697790d
--- /dev/null
+++ b/docs/DASHBOARD_UI_UX_SPEC.md
@@ -0,0 +1,349 @@
+# OpenCluster — Dashboard UI/UX Specification
+
+**Document ID:** OC-DASH-UX-001
+**Version:** 1.0
+**Status:** Design/implementation specification
+**Scope:** `dashboard/` UI and local record workflows; sample telemetry and backend-dependent actions remain simulated
+**Last updated:** 2026-10-22
+
+## 1. Experience statement
+
+OpenCluster should look and behave like an elegant, futuristic **cluster operations workspace**, with the polish of an editorial glassmorphism dashboard and the interactive power of a graph-based development tool. The primary screen must still answer real questions: *Is the system healthy? Which computer is doing what? What needs attention?*
+
+**Art direction:** dark graphite and navy foundations; large, beautiful headings; intentionally sparse glow; transparent/translucent glass panels in a few places; fine grid lines; mint/cyan signals; animated infrastructure graphics; lots of hierarchy. It must never resemble a neon-themed gaming HUD at the expense of readability.
+
+## 2. The seven references: design mapping
+
+| Image | Observed motifs | OpenCluster usage | Translation rule |
+|---|---|---|---|
+| **1** | Large editorial heading, frosted floating modular widgets, smooth rounded corners | Home layout, KPI cards, dashboard panels, navigation | Adapt modular composition to dark canvas; maintain legible text and logical hierarchy |
+| **2** | Huge Earth visualization, side telemetry bars, geospatial labels | Mission Control globe mode | Use demo node anchors; avoid pretending we know actual node location |
+| **3** | Blue holographic planetary mesh, glowing links, data atmosphere | Mission Control ambient scene and home visual cues | Keep effects restrained; use Three.js selectively |
+| **4** | Horizontal branching relation map, detail inspector, relationship paths | Agents graph mode and inspector | Preserve functional search, labels, selectable relations, typed info |
+| **5** | Concentric/radial constellation, rings and node groupings | Agents radial mode; optional Mission Control constellation preset | Provide zoom/selection/legend and text alternative; avoid tiny unreadable labels |
+| **6** | n8n-style node workflow, ports, edges, groups and editor tooling | Automation canvas | Custom React Flow node/edge types, keyboard editing, validation, simulation |
+| **7** | Six examples of abstract orbital/ring/wire energy geometry | Home JARVIS-inspired 3D hero | Original abstract particle/orbit graphic; not a reproduction of existing franchise assets |
+
+References are **visual inspiration**, not bundled application media. Do not paste reference screenshots into the product. All graphic assets must be original or properly licensed.
+
+## 3. Foundation: type, layout, color and motion
+
+### Typography
+
+- **Sora**: 500–700 for major headlines and section titles. Desktop hero target **clamp(2.75rem, 4vw, 5.25rem)**, with responsive line height ~1.05–1.12.
+- **Inter**: 400–600 for navigation, labels, cards, tables, forms and dense technical UI. Standard body 14–16px with generous line heights.
+- **JetBrains Mono** (optional): clocks, percentages, hostnames, IDs, code and terminal-style data only. Use tabular numbers for changing values.
+- **Rule:** never let fonts carry status meaning by themselves; icons, text and shape should reinforce colors.
+
+### Suggested tokens (finalize after visual comparison)
+
+```css
+/* CSS custom properties in dashboard/src/styles/tokens.css;
+ Tailwind references them; avoid custom per-component styles. */
+--oc-bg: #080C12;
+--oc-surface: #111922;
+--oc-surface-raised: #17212D;
+--oc-border: #273441;
+--oc-text: #F2F5F8;
+--oc-text-muted: #A3B0BE;
+--oc-accent: #A9F276;
+--oc-cyan: #68D8D1;
+--oc-warning: #F0BB68;
+--oc-danger: #F17B83;
+--oc-radius-card: 20px;
+--oc-radius-control: 12px;
+```
+
+- Additional theme tokens: focus ring, semantic foreground on every status, grid color, shadow, glass opacity, chart series.
+- Prefer dark default; future light theme can be optional but semantic tokens must be theme-ready.
+- Layout: 8px spacing base, max text measure for paragraphs, 12-column desktop dashboard grid; navigation rail/sidebar ~80/248px.
+- Cards: two densities, regular and compact. Glass only for hero/topbar, not every content panel.
+- On desktop use consistent content gutters 24–40px; tablet 20–24px; mobile 16px.
+
+### Motion vocabulary and ownership
+
+| Motion category | Primary tool | Typical use |
+|---|---|---|
+| Route/panel/dialog transitions | Motion for React (`motion/react`) | Opacity, 8–20px translate, presence |
+| Complex sequenced hero reveals | GSAP | Main greeting/hero choreography |
+| Long scroll storytelling sections | Lenis + GSAP only if justified | Home detail sections; not graph editor |
+| 3D holographic mesh | Three.js / React Three Fiber | Hero and globe only |
+| Fine path animations | anime.js **only if needed** | Short SVG path highlights |
+| Reusable motion widgets | React Bits | Curated background, text, hover effects |
+| Draggable data graphs | React Flow | Agent and automation canvas interactions |
+
+**Rules:** never animate one property with two engines simultaneously; all loops clean up on unmount; pause nonessential effects on hidden tab; reduced motion uses static variants; don't run Lenis on editable flow canvases; ensure no horizontal page scrolling from 3D scenes.
+
+### Standard easing/timing
+
+- Micro: 120–200ms; regular interface: 200–350ms; transitions: 350–550ms.
+- Heavy hero arrival: 1–1.8 seconds in cinematic mode only; ordinary navigation is quicker.
+- Avoid forced blocking intro sequences, perpetual camera motion when the user is manipulating a graph, and infinite pulsing of every metric card.
+
+## 4. Global application shell
+
+### Desktop (1280px+)
+
+```text
+┌────────────┬────────────────────────────────────────────────────────────┐
+│ OPENCLUSTER│ Cluster: Demo Lab ▾ [ Search ⌘K ] DEMO Alerts Profile│
+│ ├────────────────────────────────────────────────────────────┤
+│ Home │ Page breadcrumb / title Contextual actions│
+│ Mission │ │
+│ Clusters │ PAGE CONTENT │
+│ Nodes │ │
+│ Agents │ │
+│ Automation │ │
+│ Software │ │
+│ Monitoring │ │
+│ Activity │ │
+│ Settings │ │
+└────────────┴────────────────────────────────────────────────────────────┘
+```
+
+Top bar remains readable even with 3D scene below. Left rail collapses; menu groups: **Overview**, **Operate**, **Build**, **Insights**, **System**. Avoid overly nested navigation.
+
+### Tablet (768–1279px)
+
+- Compact rail + utility top bar. Cards flow 2 columns; inspectors become overlays/drawers when needed.
+- Graph canvases use full main area, accessible tools float above graph and never obscure focus/selection.
+
+### Mobile (360–767px)
+
+- Compact topbar, menu drawer, stacked cards; lower-density labels and charts.
+- Graphs support touch pan/zoom plus an **accessible list inspector**; do not require precise touch node linking for essential inspection.
+- Complex workflow editing may be read-only or simplified if fully capable touch editing cannot be delivered, and this limitation must be explicitly indicated.
+
+### Global layers and feedback
+
+- Global command palette, context menu, toast stack, confirm dialogs, side drawers, busy indicators.
+- Status labels: `ONLINE`, `DEGRADED`, `OFFLINE`, `SIMULATED`, `UNKNOWN` with text + color + icon.
+- A **Sample Data** badge or banner is always discoverable where seeded hardware values are shown; preview flows repeat their unavailable status at the action point.
+- Every page has helpful blank/loading/failure states, not placeholder lorem ipsum.
+- Search results include category, readable entity name, cluster and destination.
+- Respect system reduced motion even when visual quality is set to high.
+
+## 5. Page-by-page UI specification
+
+### 5.1 Home — `/`
+
+**Purpose:** Immediate confidence and atmosphere. Reference images **1 and 7**, with limited motifs from **3**.
+
+**Desktop composition:**
+
+```text
+┌──────────────────────────────┬─────────────────────────────────┐
+│ GOOD EVENING, OPERATOR. │ JARVIS-LIKE ORIGINAL HOLOGRAM │
+│ Your infrastructure at a │ Particle rings / orbit visual │
+│ glance. │ Pause / Reduce detail control │
+│ [Explore Cluster] [New Flow] │ │
+├───────────┬──────────┬───────┴──────┬──────────────────────────┤
+│ Clusters │ Online │ Alerts │ Active Agents │
+├──────────────────────────────┬───────┴──────────────────────────┤
+│ Cluster summary cards │ Utilization / alert trend │
+├──────────────────────────────┼──────────────────────────────────┤
+│ Recent activity │ Software / workflow snapshots │
+└──────────────────────────────┴──────────────────────────────────┘
+```
+
+**Interactions:** clicking KPI navigates to filtered area; cluster selector adjusts summaries; control toggles hologram animated/static; quick actions open demo create cluster / workflow; display name from settings; keyboard focus never enters purely decorative canvas.
+
+**Visual constraints:** hero height bounded; Sora statement strong and readable; orb never overlays text. Mobile hides/degrades 3D before metrics; static SVG is valid fallback. Data status “Demo”.
+
+**Acceptance:** information is complete without WebGL; greeting and KPIs readable at 360px; intro skips with reduced motion.
+
+### 5.2 Mission Control — `/mission-control`
+
+**Purpose:** Spatial overview and precise node inspection. References **2 and 3**.
+
+**Desktop composition:** top section with cluster selection and `Topology | Globe` segmented control; central large canvas; floating legend/filter/zoom controls; right inspector (320–380px); bottom or overlay node list and alerts strip.
+
+**Topology view:** stable force/radial layout around optional coordinator; connection lines and status rings; hover tooltip shows hostname, role, CPU and status; click selects; double click focuses; drag pans empty area; pinch/wheel zoom, explicit +/- buttons and fit-to-view.
+
+**Globe view:** floating holographic sphere representing **illustrative**, not geolocated, clusters; clickable glowing anchors correspond to seeded nodes; right inspector is identical to topology inspector. Position locations should be clearly labeled “Illustrative placement”. A 2D map/topology fallback is essential.
+
+**Empty/error:** zero nodes prompts adding a demo node; unsupported WebGL falls back to topology with notice. `Globe` is not prerequisite for functional node management.
+
+**Acceptance:** inspector updates immediately; selection preserved between modes; keyboard-only user can select through synchronized node list.
+
+### 5.3 Clusters — `/clusters` and `/clusters/:clusterId`
+
+**Purpose:** Organize clusters and understand their composition. Reference **1**.
+
+**List:** large title, `Create demo cluster`, search and status filters; cluster cards display name, OS mix, online count, total nodes, optional coordinator, last event and a compact status mini-chart.
+
+**Details:** breadcrumbs; hero overview; nodes grouped by role/status; recent events; small topology preview; editable metadata drawer.
+
+**Dialogs:** create/rename/archive/delete; validation, accessible errors, confirm destructive actions and explain local persistence.
+
+**Acceptance:** create, rename, switch and refresh survive; delete updates all related demo views safely.
+
+### 5.4 Nodes — `/nodes` and `/nodes/:nodeId`
+
+**Purpose:** Precise hardware and status inspection. Reference **1** for cards, **4** for detail layout.
+
+**Inventory:** table/grid toggle; sticky search/filter header; columns Hostname, Cluster, Role, Status, OS, CPU, RAM, GPU, Last seen; sortable headers; density toggle. Mobile displays condensed cards.
+
+**Details:** node identity hero, “Coordinator” badge when selected, hardware specifications, telemetry small multiples, processes/jobs **demo** placeholder data, latest events. No fake `SSH` or `Install` button pretending to work.
+
+**Interaction:** select node from list, navigate deep link; “View in Mission Control” focuses matching node. Missing GPU is `Not reported`, not 0%. Offline state visibly modifies metrics.
+
+**Acceptance:** sorting/filtering correct, units meaningful, no contradictory status labels.
+
+### 5.5 Agents — `/agents`
+
+**Purpose:** Relationship intelligence and agent orchestration visualization. References **4 and 5**.
+
+**Canvas priority:** Full-height central workspace with a left catalog/search, floating graph tools, inspector on right. Modes: `Network` (branching graph) and `Constellation` (radial hierarchy). Both access the same typed agent-and-relation data.
+
+**Graph node types:** Agent, Model, Tool, Memory, Task, External Service (visual category only). Each has shape/icon/status/connection port; colors are not the sole differentiator.
+
+**Behaviors:** drag node; pan; zoom; selection; filter by category; group/ungroup where viable; fit; reset graph; open inspector; highlight incoming/outgoing relations; save layout. Radial mode arranges the same entities by relationship depth, not by random particle distribution. On switching modes, preserve selection and inspector, and remember last camera position per mode.
+
+**Inspector:** title, kind, status, node host if linked, dependencies, inputs/outputs, associated mock tasks, graph actions. Text search/list alternative gives complete access to every entity.
+
+**Safety and clarity:** “Relationship visualization — demo graph; no real agent execution.” Avoid implying visualization shows private model reasoning.
+
+**Acceptance:** no edge detach while moving; graph not reinitialized during inspector updates; radial view still supports inspect/zoom; both modes work without WebGL.
+
+### 5.6 Automation — `/automation` and `/automation/:workflowId`
+
+**Purpose:** Functional **demo** agent workflow builder similar in interaction to n8n, not n8n source reuse. Reference **6**.
+
+**Desktop composition:**
+
+```text
+┌── Palette ─┬──────────────────── Workflow canvas ───────────────────┬ Inspector ─┐
+│ Trigger │ [Manual] ─> [Agent Task] ─> [Condition] ─┐ │ Node name │
+│ Agent │ ├─ true -> [Notify] │ Parameters│
+│ Logic │ └─ false -> [Failure] │ Validation│
+│ Tools │ Zoom | Fit | Mini-map | Undo | Redo [DEMO] │ Help │
+└────────────┴────────────────────────────────────────────────────────┴───────────┘
+Top: Back | Flow name [editable] | Saved locally | Validate | Simulate | Export
+```
+
+**Core interaction contract:**
+
+1. Empty state offers templates or new blank flow.
+2. Drag block from palette onto canvas (keyboard/click alternate required).
+3. Connection handles enforce typed edges; mark invalid target without changing graph.
+4. Select node; inspector edits schema-driven fields; validation errors indicate exactly which config is missing.
+5. Toolbar: undo/redo, fit-to-view, zoom, minimap, save, duplicate, import/export JSON.
+6. `Simulate` runs a deterministic client-side traversal and highlights nodes; logs show “SIMULATED / No external services were contacted”.
+7. Branching condition demonstrates true/false ports; errors show failure path. No arbitrary JS/script execution by workflow users.
+
+**Visual style:** neutral card nodes, bright semantic ports/edges, lightly dotted grid, dark canvas; avoid tiny text. Port labels readable. Per-step status glow is subtle.
+
+**Persistence:** saved flows and viewport restore after reload. Schematized JSON with version and validation; invalid imports report errors safely. No automatic execution of imported scripts.
+
+**Acceptance:** save/export/import yields same nodes/edges; undo/redo works after drag, connect and edit; no malformed dangling edges; keyboard alternative exists for connecting nodes.
+
+### 5.7 AI Software — `/software`
+
+**Purpose:** Discover integrations and preview installation journeys. References **1** for visual catalog and **4** for inspector.
+
+**Catalog:** searchable cards for Codex, Claude Code, Hermes Agent and OpenClaw, with clear software name, provider, verified platform notes, intended purpose, demo status. Generic extensible catalog template.
+
+**Preview wizard:** `Choose software → Choose sample target nodes → Review configuration → Preview plan → Simulation complete`. Do not ask for real API credentials; never show copyable installation commands under the guise of having verified them unless separately documented.
+
+**Acceptance:** users cannot confuse preview success with installed software; dashboard data updates only as explicitly simulated.
+
+### 5.8 Monitoring — `/monitoring`
+
+**Purpose:** High-density but legible telemetry. References **1, 2**.
+
+**Layout:** summary strip, trend graphs by CPU/RAM/storage/network; cluster comparison; selected node detail; offline-aware dataset labels. Range controls 15m/1h/24h/7d, hover details, series toggle and accessible numerical summary/table.
+
+**Rules:** stable demo time series; no false precision; healthy/warning thresholds explained; no endless animating charts; chart labels and units visible when hovered or focused.
+
+**Acceptance:** chart and source table agree, missing metrics not charted as zero.
+
+### 5.9 Activity — `/activity`
+
+**Purpose:** Operator history across clusters, nodes, workflows, agents and software previews.
+
+**Layout:** timeline + table views; tabs for All/Alerts/Automation/Software; search, source/severity/time filters; drawer with event payload and cross-link to entity. Demo markers on simulated history.
+
+**Acceptance:** all events trace to valid seeded entities; ordering/filtering deterministic; no fabricated security audit implications.
+
+### 5.10 Settings — `/settings`
+
+**Purpose:** User-controlled quality, accessibility and demo environment.
+
+**Sections:** Appearance (theme + density), Motion (system/reduced/cinematic), Rendering (auto/performance/high + 3D toggle), Demo environment (seed set/reset), Accessibility (contrast hints), About/Open-source (license/docs links). Future remote connections section may show explanatory **Not available in Phase 1** copy, not fake form controls.
+
+**Acceptance:** toggles actually affect all relevant views; preferences survive reload; reduced motion overrides cinematic preference; reset requires confirmation.
+
+## 6. Canvas interaction details (shared specification)
+
+| Interaction | Agents | Automation |
+|---|---|---|
+| Drag empty space | Pan | Pan |
+| Scroll/pinch | Zoom with bounds | Zoom with bounds |
+| Click graph item | Select + inspector | Select + inspector |
+| Multi-select | Optional P1 | Required for multi-edit P1; basic selection P0 |
+| Keyboard accessible list | Required | Required node palette + connect/edit alternatives |
+| Mini-map | Optional | Required/strongly preferred |
+| Save positions | Required | Required |
+| Snap/grid | Optional | Snap grid preferred |
+| Undo/redo | P1 | P0 |
+| Fit view | Required | Required |
+| Compact/touch mode | Select+inspect first | Readability/inspect first; communicate editing limits |
+
+During drag, **don't show heavyweight new animations** in adjacent panels. Smoothness and pointer accuracy take priority. A right-click context menu may supplement but never replace accessible controls.
+
+## 7. Motion/graphics quality profiles
+
+- **Reduced:** static orb SVG; no automated camera motion, parallax or background particles; transitions limited to short fades.
+- **Performance:** low DPR and geometry complexity; default 2D topology; fewer glow passes; lower particle count; no ambient scenes offscreen.
+- **Auto:** device- and preference-aware, avoids mandatory hardware fingerprinting; sensible browser defaults.
+- **Cinematic:** richer orb/3D details and entrance sequences only on capable devices with explicit user opt-in; never hides controls.
+
+GL error or unavailable WebGL immediately falls back to static art/2D graph. Use `IntersectionObserver` or visibility semantics to pause hidden scenes; high frame counts in inactive tabs are unacceptable.
+
+## 8. Responsive interaction matrix
+
+| Element | Desktop | Tablet | Mobile |
+|---|---|---|---|
+| Navigation | Full sidebar | Compact rail | Drawer/menu |
+| Home hero | Text + 3D side-by-side | Reduced-height split | Text first; static/light orb |
+| KPI cards | 4–5 columns | 2 columns | 1–2 columns |
+| Topology | Main stage + persistent inspector | Main stage + drawer | 2D compact + list |
+| Agents | Graph + inspector | Graph + overlay inspector | List-first, inspect graph optionally |
+| Automation | Three-panel editor | Collapsible palette/inspector | Simplified editor or transparent read-only |
+| Data tables | Full sortable | Horizontally scrollable | Responsive cards / essential columns |
+| Monitoring | Multi-series grid | Two-column charts | Single-column charts + summaries |
+
+## 9. Content and microcopy guidelines
+
+- Greeting example: **“Good evening, Operator.”** Subtitle: **“Your infrastructure, at a glance.”**
+- Demo banner: **“Demo environment — no real nodes connected.”**
+- Preview confirmation: **“Simulation complete. No software was installed.”**
+- Empty graph: **“No agents in this view. Choose a demo workspace or add a sample agent.”**
+- Disconnected state: **“No live data source configured in this release.”**
+- Avoid “Installed”, “Connected”, “Running live”, or “Secured” unless the display context explicitly says simulated.
+
+## 10. Visual QA and accessibility checklist
+
+- [ ] Seven references interpreted correctly and originally; not directly copied.
+- [ ] Every page has a unique purpose, strong title and scannable hierarchy.
+- [ ] Typography usable at 200% zoom; no critical content clipped.
+- [ ] Focus ring and hover do not depend on glow alone.
+- [ ] Forms have labels, descriptions and inline validation.
+- [ ] Side panels and modals trap/restore focus appropriately.
+- [ ] Charts have text/table equivalents.
+- [ ] Canvas selections have searchable/list alternatives.
+- [ ] Motion/quality preferences apply consistently.
+- [ ] 3D scene never blocks input; GPU disabled fallback verified.
+- [ ] Light/glass elements maintain contrast over every background.
+- [ ] Responsive widths 360, 390, 768, 1024, 1440, 1920 tested.
+- [ ] Demo and preview labeling correct in every module.
+
+## 11. Implementation references
+
+- React Flow examples: https://reactflow.dev/examples/interaction/save-and-restore
+- React Flow performance: https://reactflow.dev/learn/advanced-use/performance
+- Motion accessible animations: https://motion.dev/docs/react-use-reduced-motion
+- React Three Fiber Canvas: https://r3f.docs.pmnd.rs/api/canvas
+
+Review the bundled seven user references in order before implementation. If image assets are not available in a Codex environment, follow this mapping document rather than inventing unseen details.
diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md
index 4cf5d93..f9e61fd 100644
--- a/docs/DECISIONS.md
+++ b/docs/DECISIONS.md
@@ -4,6 +4,34 @@ This is a lightweight decision log. Proposals below are not implementation commi
## Proposed starting decisions
+### D-007 — Permanent dashboard with browser-local provider
+
+- **Status:** Accepted, 2026-10-22; supersedes the prototype wording in D-005.
+- **Decision:** Phase 1 is the permanent frontend application. Structured local records use IndexedDB with validation and seed only an empty workspace; localStorage is limited to non-sensitive preferences. Sample hardware telemetry is explicitly labeled.
+- **Reason:** Cluster, node, graph, workflow, and configuration editing should work before backend services exist and survive reloads.
+- **Limits:** No backend, authentication, node agent, live telemetry, remote command, installation, or workflow execution is implemented.
+
+### D-008 — React Flow for editable canvases
+
+- **Status:** Accepted, 2026-10-22; supersedes the SVG/HTML canvas preference in D-006 for Agents and Automation.
+- **Decision:** Use `@xyflow/react` for both editors, with separate domain schemas and persistence. Keep the optional Three.js globe route-lazy.
+- **Reason:** Both canvases require genuine graph editing, keyboard access, validation, and shared pan/zoom behavior.
+
+### D-005 — Dashboard-first Phase 1
+
+- **Status:** Superseded by D-007.
+- **Decision:** Build a standalone React/TypeScript dashboard in `dashboard/`, backed by a typed local mock-data interface. The dashboard demonstrates navigation and workflows only; its install, task, and cluster actions do not control real systems.
+- **Reason:** Establish the operator experience and data contracts without adding backend or infrastructure scope.
+- **Boundaries:** No backend, authentication service, node agent, remote execution, live installation, or cloud service. Persist only non-sensitive demo preferences in browser storage.
+- **Revisit when:** a maintainer approves the next backend/node-agent milestone and its security design.
+
+### D-006 — Page-split visualizations for Phase 1
+
+- **Status:** Superseded by D-008 for editable canvases; retained for lazy page/scene loading and the globe boundary.
+- **Decision:** Use lazy-loaded pages, SVG/HTML for agent and workflow canvases, and Three.js only for the optional Mission Control globe. Keep Framer Motion, GSAP, and Lenis responsibilities separate and honor reduced-motion, performance mode, and hidden tabs.
+- **Reason:** Deliver the requested visual experiences while keeping the initial application route lighter and preserving static/accessibility fallbacks.
+- **Revisit when:** production profiles show a need for graph virtualization, a different rendering engine, or tighter resource limits.
+
### D-001 — Foundation-only scope
- **Status:** Accepted for Phase 0.
diff --git a/docs/DESIGN_SYSTEM.md b/docs/DESIGN_SYSTEM.md
index f4d4ec1..0386b4d 100644
--- a/docs/DESIGN_SYSTEM.md
+++ b/docs/DESIGN_SYSTEM.md
@@ -1,14 +1,16 @@
# Design System Direction
-**Status:** Visual and interaction guidance only; no interface is implemented.
+**Status:** Implemented dashboard direction for Phase 1; this remains a demo interface and does not indicate an operational control plane.
## Direction
OpenCluster should read as a professional infrastructure command center: dark-first, restrained, information-dense, and calm under operational pressure. Favor clear hierarchy and consistent alignment over decorative effects. Support beginner homelab users with plain labels and useful empty states while retaining fast scanning and filters for experienced administrators.
+The dashboard uses a dark graphite foundation, blue/cyan holographic accents, and subtle glass-like surfaces. Sora is used for display headings and Inter for dense UI text and labels. Font files are bundled locally; page rendering does not depend on a font CDN.
+
## Layout and navigation
-Use a persistent primary navigation for Overview, Nodes, Workloads/Agents (when implemented), Alerts, and Administration. Keep global cluster context and connection state visible. Desktop layouts prioritize multi-column comparison and resizable information density; tablet collapses secondary navigation; mobile prioritizes status, search, and focused detail views. Multi-monitor use should preserve context across wide displays without relying on extreme-width layouts.
+Use a persistent primary navigation for Home, Mission Control, Clusters, Nodes, Agents, Automation, AI Software, Tasks, Monitoring, Activity, and Settings. Keep global cluster context and demo status visible. Desktop layouts prioritize multi-column comparison and information density; tablet collapses secondary navigation; mobile prioritizes status, search, and focused detail views.
## Component categories
diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md
index 22a8021..63a8e75 100644
--- a/docs/DEVELOPMENT.md
+++ b/docs/DEVELOPMENT.md
@@ -2,20 +2,31 @@
## Current status
-OpenCluster is in Phase 0. No application package, backend, database schema, node agent, or runnable product exists. Repository checks below validate only Markdown links and project configuration.
+OpenCluster Phase 1 is the permanent standalone dashboard in `dashboard/`. It uses a validated IndexedDB provider for local records and localStorage for non-sensitive preferences. Seeded hardware telemetry is sample data. No backend, authentication service, node agent, remote execution, or cloud service is part of this phase.
## Prerequisites
- Git
-- Python 3.12 or later for the current documentation check (standard library only)
+- Node.js 20.19+ (or 22.12+) and npm for the dashboard (Vite requirement)
+- Python 3.12 or later for documentation checks (standard library only)
- Codex CLI only if you intend to use the project-local custom agents
-Node.js, PostgreSQL, Redis, Docker, and Kubernetes are not required for Phase 0. They will be needed only when the corresponding application or infrastructure work is approved.
+PostgreSQL, Redis, Docker, Kubernetes, credentials, and cloud services are not required to run the dashboard. The browser uses IndexedDB for structured local dashboard data.
## Local workflow
```sh
-git switch -c docs/your-change
+npm --prefix dashboard install
+npm --prefix dashboard run dev
+```
+
+The dev server prints its local URL. For checks, run:
+
+```sh
+npm --prefix dashboard run typecheck
+npm --prefix dashboard run lint
+npm --prefix dashboard test
+npm --prefix dashboard run build
python -m unittest discover -s tests -v
python scripts/check_docs.py
```
@@ -24,10 +35,19 @@ Before a pull request, inspect `git status`, review the diff, and verify that cl
## Style and dependencies
-TypeScript will use strict mode and TSX for React components. Python will use type annotations at public interfaces. Formatting and linting tools will be selected with the first application milestone; no application dependency manifests exist yet. Avoid adding dependencies to solve checks already covered by the standard library.
+TypeScript uses strict mode and TSX for React components. Biome handles dashboard formatting and linting; Vite builds the app, and Vitest runs focused dashboard tests. Python will use type annotations at public interfaces. Avoid adding dependencies to solve checks already covered by the standard library.
Use Conventional Commits (for example `docs: clarify node enrollment proposal`). Releases will follow semantic versioning once a first release is planned; no release version has been declared.
+## Dashboard architecture and motion
+
+Each page is loaded on demand. Pages read structured records through the data access layer; IndexedDB stores local clusters, nodes, agent entities, workflows, task records, and sample telemetry. Browser storage holds only preferences. Agents and Automation are editable React Flow canvases and never invoke external services. Mission Control's optional 3D globe is lazy-loaded and has a selectable-node fallback when WebGL is unavailable. See [Dashboard Motion and Performance](DASHBOARD_PERFORMANCE.md) for animation responsibilities, tab-visibility pause, reduced-motion handling, and current limits.
+
## Codex agents
Root instructions are in `AGENTS.md`; project-local agent roles are in `.codex/agents/`. The role files use Codex's supported standalone TOML format. No model, credential, or machine-specific path is pinned. See [agent orchestration](AGENT_ORCHESTRATION.md).
+## Dashboard navigation and motion
+
+The dashboard shell uses the grouped BranchedMenu for its workspace navigation and a compact Dock for quick links. The Home KPI cards use an accessible Counter animation; reduced-motion preferences show the final value immediately. These are local UI controls and do not imply live cluster connectivity.
+
+The automation editor debounces local persistence while nodes are being dragged. Pointer movement updates the canvas immediately; only the settled workflow snapshot is written to IndexedDB. Route-level visualization and chart chunks remain lazy loaded to keep the initial shell smaller.
diff --git a/docs/PRD.md b/docs/PRD.md
index 69d652a..8f1d12e 100644
--- a/docs/PRD.md
+++ b/docs/PRD.md
@@ -1,110 +1,83 @@
# OpenCluster Product Requirements
-**Status:** Phase 0 proposal. OpenCluster has no implemented application features yet.
+**Status:** Phase 1 dashboard scope. The dashboard is specified here; implementation is in progress and no dashboard capability should be treated as available until it is present in the repository and verified.
-## Vision and problem
+## Product vision
-OpenCluster aims to give self-hosters and small infrastructure teams one place to understand and operate heterogeneous compute clusters. Today, node health, workload state, logs, automation, and AI agents are commonly split across separate tools. OpenCluster proposes a self-hosted control plane with explicit security boundaries and replaceable integrations.
+OpenCluster aims to give self-hosters and small infrastructure teams one place to understand and eventually operate heterogeneous compute clusters. The long-term product is a self-hosted control plane. Phase 1 delivers the permanent standalone browser dashboard, currently backed by a validated persistent local provider. Seeded hardware telemetry remains sample data; the UI is not a disposable prototype.
## Users and use cases
-- **Homelab operator:** see which machines are reachable, compare resource use, and understand alerts without logging into each machine.
-- **Small platform team:** group nodes by environment or role, review workload state, and audit privileged operations.
-- **AI/automation operator:** observe compatible agents and jobs, then control approved workflows.
-- **Contributor/integrator:** add an adapter or node capability without coupling it to the dashboard.
+- **Homelab operator:** explore cluster and node health, resource use, alerts, and recent activity in one interface.
+- **Small platform team:** organize nodes and inspect task and agent views as a demonstrable interaction model.
+- **Contributor/integrator:** develop dashboard components against typed local data contracts before a backend is selected.
-## Supported environments (planned)
+These use cases describe the dashboard experience. They do not imply connectivity to real infrastructure.
-The first node-agent target is Linux on Arch Linux and Debian, with architecture intended to remain distribution- and hardware-neutral. The control plane is planned for Linux-hosted containers. Other Linux distributions, Windows, macOS, Kubernetes, and managed cloud environments are future evaluation targets, not current support claims. Hardware metrics depend on operating-system interfaces and optional vendor drivers; GPU support must be capability-detected.
+## Phase 1 scope: standalone dashboard
-The proposed MVP cluster type is an operator-owned homelab or small self-managed on-prem environment: one OpenCluster control-plane deployment managing one cluster of Linux nodes. Nodes may use different hardware; capabilities must be reported rather than assumed. Exact node-count limits and control-plane high availability are not set. Multi-cluster federation, managed cloud fleets, HPC scheduling, and Kubernetes clusters as a managed target are outside the initial supported scope.
+The dashboard is a local web application built with React, TypeScript/TSX, Vite, Tailwind CSS, shadcn/ui patterns, Lucide React, TanStack Query, Zustand, Recharts, React Router, self-hosted Sora and Inter fonts, Framer Motion, GSAP, Lenis, and Three.js for a lazy-loaded Mission Control globe. React Flow powers the editable Agents and Automation canvases.
-## Product principles
+The application runs without a backend, account, cloud service, node agent, or external service. Typed repositories use IndexedDB for structured local records and localStorage for non-sensitive preferences. Seed fixtures initialize an empty workspace only; data-access contracts are the boundary for future local/cloud APIs. Hardware telemetry and connectivity health stay clearly marked as sample data until services exist.
-1. Self-hosted and operator-controlled; telemetry stays within the operator's deployment unless they configure an integration.
-2. Least privilege and explicit consent for every administrative action.
-3. Read-only visibility before remote mutation.
-4. Heterogeneous nodes and integrations through documented interfaces.
-5. Clear health, freshness, provenance, and failure states; never imply stale data is live.
-6. Useful defaults, documented trade-offs, and deployable components that can be operated independently.
+### Required dashboard areas
-## Planned functional requirements
+- **Home:** greeting, selected-cluster summary, node status, resource utilization, recent activity, alerts, AI-agent summary, and meaningful quick actions.
+- **Mission Control:** selectable coordinator and worker nodes, animated connections, node details, cluster switching, pan/zoom, and fullscreen visualization. All values and statuses are simulated.
+- **Clusters:** create, rename, select, and edit demo cluster metadata; organize demo nodes and inspect cluster details.
+- **Nodes:** list and detail views with CPU, memory, GPU, disk, network, operating system, uptime, and sample health history; coordinator designation updates local records.
+- **Agents:** interactive local relationship canvas with draggable agent nodes, pan/zoom, network and radial modes, a selectable inspector, and persisted agent configuration.
+- **Automation:** interactive demo workflow canvas with draggable steps, branches, configuration preview, and simulated run status. No automation is executed.
+- **AI Software:** catalog Codex, Claude Code, Hermes Agent, OpenClaw, and future custom integrations. Provide target selection and configuration/install previews; every install action must be clearly labeled as a demonstration and must not install software.
+- **Tasks:** simulated task overview, queue, assignment controls, job states, timeline, history, filters, and sorting. No job is executed on a real node.
+- **Monitoring:** historical demo CPU, memory, network, storage, and node health views with selectable time ranges. Do not imply live telemetry.
+- **Activity:** searchable and filterable demo event timeline and log table with severity categories and notifications.
+- **Settings:** appearance, theme, motion, dashboard customization, accessibility, mock-data/demo options, and clearly identified future integration placeholders.
-All requirements below describe intended behavior and are **not implemented**.
+### Interaction and presentation requirements
-### Cluster overview
+- Navigation between required areas works, with responsive desktop, tablet, and mobile layouts.
+- Visible controls either perform a meaningful local demo action or clearly state that the capability is unavailable.
+- Provide keyboard access, visible focus, semantic controls, readable contrast, reduced-motion support, and status cues that do not rely on color alone.
+- Use a dark-first, professional infrastructure command-center visual language with restrained motion and legible information density.
+- Include reusable navigation, search/command palette, dialogs, tables, charts, node and cluster cards, status indicators, notifications, and loading, empty, and error states where appropriate.
-- Summarize registered nodes and distinguish online, offline, unknown, and stale telemetry.
-- Show CPU, memory, storage, network, and capability-detected GPU metrics with timestamps and units.
-- Provide health indicators, alerts, recent activity, and historical metrics when retention is configured.
+## Explicit exclusions and demo boundaries
-### Node management
+Phase 1 does **not** include a backend, authentication service, database, node agent, real cluster connection, remote command execution, live AI software installation, real task execution, cloud dependency, or production telemetry. Demo state and browser preferences are not authoritative operational records. No security, availability, real-time, or infrastructure-management guarantee should be inferred from the interface.
-- Enroll, inspect, label, group, disable, and revoke nodes.
-- Display node identity, operating-system information, capabilities, connectivity, and resource use.
-- Keep monitoring permissions distinct from permissions to invoke node actions.
-- Expose a controlled service-operation interface only after authorization and audit requirements are designed and implemented.
+## Later product direction
-### Agent management and orchestration
+The eventual platform may add a self-hosted control plane, secure node enrollment, authenticated and authorized telemetry, audit records, integrations, and carefully scoped operations. Those capabilities require separate design decisions and implementation phases; this Phase 1 dashboard does not establish their security or architecture as complete.
-- Represent Codex, Hermes Agent, and custom integrations through adapters where those products expose supported interfaces.
-- Show agent health, execution history, and configured workflows.
-- Queue and schedule distributed jobs with explicit state, dependencies, retries, cancellation, and recovery semantics.
-- No arbitrary shell execution or agent autonomy is implied by this requirement.
+Potential future capabilities include:
-### Monitoring and developer operations
+- Linux node inventory and telemetry collection, initially evaluating Arch Linux and Debian without assuming specific hardware.
+- User authentication, role-based authorization, secure enrollment and revocation, and audit logging.
+- Historical monitoring, health checks, alerting, log search, and operator-managed retention.
+- Scoped GitHub and AI-agent adapters, followed by explicitly authorized workload orchestration.
+- Docker Compose local deployment; Kubernetes and cloud integrations only after separate evaluation.
-- Receive live telemetry and health checks; provide searchable logs, charts, and configurable alerts.
-- Integrate GitHub repositories and deployment/build status through scoped, revocable credentials.
-- Make remote terminal access an explicitly gated later capability; it is not an MVP requirement.
+Unrestricted remote shells, arbitrary cross-node command execution, autonomous remediation, GPU scheduling, and cross-cluster federation remain out of scope unless separately reviewed and approved.
-### Security and administration
+## Quality and acceptance criteria
-- Authenticate users and authorize actions with role-based permissions.
-- Securely enroll and revoke nodes; protect secrets and transport.
-- Record attributable audit events for privileged operations.
-- Require a clear confirmation step for dangerous actions and report their target and expected effect.
+For the Phase 1 dashboard, completion requires:
-## Non-functional requirements and targets
+1. The application runs locally using commands documented against the checked-in package manifest.
+2. All required dashboard areas are reachable through working navigation, including Agents and Automation.
+3. Topology selection and controls, local cluster management, catalog previews, and task records work against the persistent local provider. Hardware telemetry remains seeded sample data until a backend is available.
+4. Monitoring charts render typed historical demo data with clear time ranges and no false live-connectivity claims.
+5. Responsive behavior, keyboard access, visible focus, reduced-motion preferences, and performance mode are implemented.
+6. Type checking, linting, tests, and a production build pass using checked-in tooling.
+7. README and development documentation accurately explain setup, local record persistence, and backend integration limits.
-Targets below are requirements to validate during implementation, not measured performance claims.
+For future control-plane phases, acceptance criteria must additionally define threat model, authorization, enrollment/revocation, data handling, recovery, performance baselines, and operational support before production claims are made.
-- **Security:** deny by default; encrypted network paths; no embedded credentials; auditable privileged access; documented threat model and recovery process.
-- **Reliability:** show data age and collection failures; tolerate temporary node/control-plane disconnection without representing old samples as current.
-- **Performance:** design for incremental telemetry and bounded queries; establish measurable latency/resource budgets after MVP workload and hardware baselines are defined.
-- **Privacy:** document collected fields, retention, and outbound integrations; provide operator-controlled retention and deletion.
-- **Accessibility:** keyboard operation, semantic controls, readable contrast, and status that is not conveyed by color alone.
-- **Portability:** avoid hardware-vendor assumptions; containerized local deployment is the initial deployment proposal.
-- **Maintainability:** typed API contracts, migrations, supported dependency policy, automated checks, and documented interfaces.
+## Risks and assumptions
-## MVP boundary
-
-The proposed MVP is a secure, self-hosted, read-mostly control plane: user authentication, node enrollment/revocation, node inventory and labels, basic host telemetry, current node status, simple history/health views, and audit events for administration. Baseline secret protection—including no embedded credentials, protected storage/configuration, and log redaction—is required; a general-purpose secrets manager or broad secret distribution service is outside MVP scope. Exact identity provider, retention defaults, and authorization policy remain decision points. Work queues, remote commands, broad agent execution, Kubernetes deployment, and GitHub automation are outside initial scope.
-
-## Later and experimental capabilities
-
-- Later: job scheduling, richer alerting and log search, agent adapters, GitHub status integration, service controls, multi-user administration, Kubernetes deployment, and terminal access after security review.
-- Experimental: cross-cluster federation, automated remediation, AI-assisted operations, GPU scheduling, and policy-driven workflows. These require separate threat, reliability, and operator-control review.
-
-## Assumptions, limitations, dependencies, and risks
-
-- Operators control the control-plane host, network, database, and backups.
-- Nodes can initiate or receive an allowed encrypted connection according to the eventual deployment topology; NAT traversal is not assumed.
-- Metrics availability varies by kernel, permissions, and hardware. Unsupported values must be reported as unavailable.
-- PostgreSQL is the proposed durable store. Redis is optional and must be justified by measured queue, fanout, or cache needs.
-- External agent APIs, GitHub access, TLS certificates, and GPU drivers may require separate setup and may change independently.
-- A compromised control plane could affect enrolled nodes; isolation, narrow credentials, revocation, and recovery are critical risks.
-- Remote execution, advanced secrets-management and secret-distribution features, and autonomous remediation substantially expand the threat surface and are excluded from the MVP proposal. Baseline protection of credentials used by MVP services remains required.
-
-## Success measures
-
-Before release, define and measure: successful enrollment and revocation flows; telemetry freshness and completeness under representative test conditions; task completion and recovery behavior when later implemented; operator ability to identify stale/offline nodes; security findings resolved; and onboarding completion using only published documentation. No numeric target is asserted until a repeatable baseline exists.
-
-## Acceptance criteria for MVP planning
-
-- Architecture and interfaces distinguish control plane, node agent, and optional integrations.
-- Enrollment, revocation, authorization, audit, data retention, and failure states have reviewed designs before implementation.
-- Supported operating systems and metrics are explicitly capability-based.
-- MVP excludes unrestricted shell execution and unreviewed automated changes.
-- Documentation and CI validate only files and tools that exist.
-- Each delivered feature has tests, operational guidance, and evidence-based support claims.
+- Mock data can be mistaken for real telemetry; persistent labels and clear demo-only language are required.
+- UI contracts may evolve when the eventual API is designed; the local-provider boundary should remain explicit and typed.
+- Browser storage is suitable only for non-sensitive demo preferences, not secrets or operational state.
+- External products and APIs may change; catalog entries are informational placeholders until an integration is implemented and reviewed.
+- No performance or reliability targets are asserted for the eventual infrastructure product until measurable workloads and hardware baselines exist.
diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md
index e3592df..51065ab 100644
--- a/docs/ROADMAP.md
+++ b/docs/ROADMAP.md
@@ -1,28 +1,35 @@
# Roadmap
-**Status:** Planning roadmap; no milestones are complete and no dates are committed.
+**Status:** Phase 0 foundation is complete. Phase 1 dashboard development is the current milestone. No dates or release versions are committed.
-## Phase 0 — Foundation (current)
+## Phase 0 — Repository foundation (complete)
-- Establish product scope, architecture proposals, security boundaries, contribution workflow, and project-local agent guidance.
-- Validate documentation and repository conventions.
+- Establish the repository structure, community policies, project-local Codex guidance, and product/architecture proposals.
+- Add documentation validation and contribution workflows.
-## Phase 1 — MVP design and secure vertical slice
+## Phase 1 — Standalone dashboard (current)
-- Resolve authentication, authorization, enrollment, revocation, audit, retention, and deployment decisions.
-- Implement the smallest end-to-end path: authenticated control-plane access, node enrollment, node inventory, and read-only basic telemetry.
-- Add contract tests, migrations, packaging, threat review, and operational recovery documentation.
+Build the permanent responsive React, TypeScript, and Vite dashboard in `dashboard/`, backed by typed local repositories and IndexedDB records. Seeded hardware telemetry remains sample data until a control plane is available.
-## Phase 2 — Operator visibility
+- Deliver Home, Mission Control, Clusters, Nodes, Agents, Automation, AI Software, Tasks, Monitoring, Activity, and Settings views.
+- Support persistent local cluster/node management, node selection, topology navigation, agent graph and workflow canvases, catalog previews, local task records, filters, and preferences.
+- Keep heavy scenes route-split and lazy-loaded; respect reduced-motion, performance, and tab visibility preferences.
+- Clearly label simulated data, task execution, and software installation previews.
+- Validate with the checked-in type checker, linter, tests, and production build; document local setup using actual package scripts.
-- Add historical views, health checks, alert rules, and searchable operational events based on measured retention requirements.
-- Improve deployment, upgrade, backup, and restore workflows.
+**Phase 1 exclusions:** backend, authentication services, server database, node agent, live telemetry, remote commands, real software installation, real workload execution, and required cloud services. Local record management is functional; backend-dependent infrastructure operations remain unavailable.
-## Phase 3 — Controlled operations and integrations
+## Phase 2 — Control-plane design and foundation (future)
-- Evaluate narrow service operations, GitHub status integration, and agent adapters with scoped credentials and reviewable audit trails.
-- Define job queue and retry semantics before distributing workloads.
+- Revisit API, database, identity, authorization, enrollment, revocation, audit, telemetry freshness, retention, deployment, and recovery decisions using the dashboard contracts as input.
+- Implement a self-hosted control plane only after the security and operational boundaries are reviewed.
+
+## Phase 3 — Linux node agent and read-only telemetry (future)
+
+- Evaluate and implement secure node enrollment and a lightweight Linux service for capability-based host metrics.
+- Add authenticated, bounded telemetry transport and operator-visible freshness/failure states.
+- Validate on explicitly supported environments before making support claims.
## Later evaluation
-Kubernetes deployment, multi-cluster federation, GPU scheduling, broad terminal access, automated remediation, and AI-driven workflows require separate evidence and security review. No feature above is promised until maintainers accept its design and implementation.
+Historical monitoring, alerts, log search, scoped GitHub and AI-agent integrations, controlled service operations, job scheduling, Docker Compose packaging, Kubernetes, multi-cluster federation, GPU scheduling, terminal access, automated remediation, and AI-driven workflows require separate design, implementation, and security review. None is promised by the current dashboard milestone.
diff --git a/docs/TECH_STACK.md b/docs/TECH_STACK.md
index f15db5e..804ac9d 100644
--- a/docs/TECH_STACK.md
+++ b/docs/TECH_STACK.md
@@ -1,13 +1,14 @@
# Proposed Technical Stack
-**Status:** Proposal; dependencies have not been selected or installed for an application.
+**Status:** Dashboard choices are selected for Phase 1; backend, database, node-agent, and deployment choices remain proposals.
| Area | Starting choice | Rationale and trade-off |
|---|---|---|
-| Web | React, TypeScript, Vite | Familiar component ecosystem and fast local development; adds a separate frontend build. |
-| UI | Tailwind CSS, shadcn/ui, Lucide React; React Bits selectively | Utility-first styling and composable components; third-party effects must not compromise clarity or accessibility. |
-| Client data | TanStack Query; Zustand only for local cross-view state | Query cache handles server state; Zustand should not duplicate server data. |
-| Charts | Recharts | React-oriented charts; evaluate density and accessibility with actual telemetry needs. |
+| Web | React, TypeScript, Vite in `dashboard/` | Standalone local app; no backend required for Phase 1. |
+| UI | Tailwind CSS, shadcn/ui component patterns, Lucide React; Sora + Inter self-hosted fonts | Utility-first styling and composable components; bundled fonts avoid runtime font CDN requests. |
+| Client data | TanStack Query for the typed repository boundary and IndexedDB local provider; Zustand for lightweight cross-view state/preferences | Persistent local records now; future providers must not imply a live connection unless connected. |
+| Charts | Recharts | Charts display persisted sample telemetry until a monitoring provider exists. |
+| Motion and visualization | Framer Motion for route transitions, GSAP for the Home visual, Lenis for document scroll, Three.js for an optional lazy Mission Control globe; React Flow for editable agent/workflow canvases | Each tool has a narrow role; respect reduced motion, pause scenes when hidden, and keep heavy visualizations route-lazy. See [dashboard performance](DASHBOARD_PERFORMANCE.md). |
| API | Python and FastAPI | Typed schemas and async support; concurrency and blocking work must be managed explicitly. |
| Durable data | PostgreSQL | Transactions and relational integrity; time-series retention strategy remains open. |
| Live updates | WebSockets | Interactive updates; requires authorization, reconnect, and backpressure design. |
@@ -18,7 +19,7 @@
## Engineering conventions proposed
-Enable TypeScript strict mode. Use Python type annotations and a project-selected formatter/linter once application code begins. Pin and review dependencies through lockfiles and automated dependency updates. Prefer generated or shared API schemas over duplicated assumptions. Choose semantic versioning after the project publishes releases; use Conventional Commits as a contribution convention. Exact tooling versions are intentionally not specified in this foundation document.
+The dashboard enables TypeScript strict mode and uses a committed npm lockfile, Biome, and Vitest. Use Python type annotations when backend code begins. Prefer generated or shared API schemas over duplicated assumptions. Choose semantic versioning after the project publishes releases; use Conventional Commits as a contribution convention.
## Alternatives to evaluate
diff --git a/docs/TESTING_STRATEGY.md b/docs/TESTING_STRATEGY.md
index b036193..2600edb 100644
--- a/docs/TESTING_STRATEGY.md
+++ b/docs/TESTING_STRATEGY.md
@@ -1,9 +1,10 @@
# Testing Strategy
-**Status:** Strategy for future implementation. The repository currently has no application code, so application test suites are not claimed.
+**Status:** Phase 1 dashboard checks are implemented; backend, agent, and deployment test layers remain future strategy.
## Test layers
+- **Dashboard checks:** strict TypeScript typecheck, Biome lint, Vitest tests for IndexedDB validation/migration, graph and workflow behavior, and a Vite production build.
- **Documentation/repository checks:** verify required documents, Markdown structure/links where tooling exists, and safe workflow configuration.
- **Unit tests:** metric parsing, validation, authorization decisions, state transitions, and serialization.
- **Contract tests:** frontend/API schemas and control-server/agent protocol compatibility.
@@ -14,7 +15,7 @@
## Quality gates
-Require formatting, linting, type checks, tests relevant to changed areas, migration validation, and security-sensitive review before merge. CI should run only tools and artifacts present in the repository. Do not add a frontend/backend build job until those applications and their package configuration exist. Publish reproducible commands in `DEVELOPMENT.md` when tooling is selected.
+Require formatting, linting, type checks, tests relevant to changed areas, migration validation when applicable, and security-sensitive review before merge. CI runs the dashboard's checked-in scripts and documentation checks. Backend migrations and integration jobs wait until backend services exist. Reproducible commands are in `DEVELOPMENT.md`.
## Test data and environments
diff --git a/scripts/check_docs.py b/scripts/check_docs.py
index 0d51240..d0e427a 100644
--- a/scripts/check_docs.py
+++ b/scripts/check_docs.py
@@ -16,7 +16,7 @@ def broken_links(root: Path = ROOT) -> list[str]:
# ORIGINAL (preserved, not deleted — disabled by comment):
# if any(part.startswith(".") for part in markdown.relative_to(root).parts):
# continue
- if ".git" in markdown.relative_to(root).parts:
+ if any(part in {".git", "node_modules", "dist"} for part in markdown.relative_to(root).parts):
continue
for target in LINK.findall(markdown.read_text(encoding="utf-8")):
target = target.strip().split(maxsplit=1)[0].strip("<>")
diff --git a/tests/test_docs_links.py b/tests/test_docs_links.py
index 2b62ba1..de9a50d 100644
--- a/tests/test_docs_links.py
+++ b/tests/test_docs_links.py
@@ -23,6 +23,14 @@ def test_accepts_existing_relative_target_and_external_links(self):
)
self.assertEqual(broken_links(root), [])
+ def test_ignores_installed_dependency_documentation(self):
+ with tempfile.TemporaryDirectory() as directory:
+ root = Path(directory)
+ dependency = root / "dashboard" / "node_modules" / "package"
+ dependency.mkdir(parents=True)
+ (dependency / "README.md").write_text("[upstream](missing.md)", encoding="utf-8")
+ self.assertEqual(broken_links(root), [])
+
if __name__ == "__main__":
unittest.main()