Compare commits
15 Commits
bc4c1f9c70
...
v0.0.15
| Author | SHA1 | Date | |
|---|---|---|---|
| d218902cb0 | |||
| b43e860c40 | |||
| 716f230f72 | |||
| a5ed260fbd | |||
| 9b5c15ca56 | |||
| 74c8c376b7 | |||
| 9901fba61e | |||
| 17144b1c42 | |||
| a6f75cd587 | |||
| 06e54328d5 | |||
| 7480deff10 | |||
| 1b66417be5 | |||
| 23d610ba5b | |||
| 25ae14aba1 | |||
| 1425893318 |
@@ -215,11 +215,9 @@ NODE_ENV=development
|
||||
# Used by docker-compose.yml (pulls images) and docker-swarm.yml
|
||||
# For local builds, use docker-compose.build.yml instead
|
||||
# Options:
|
||||
# - dev: Pull development images from registry (default, built from develop branch)
|
||||
# - latest: Pull latest stable images from registry (built from main branch)
|
||||
# - <commit-sha>: Use specific commit SHA tag (e.g., 658ec077)
|
||||
# - latest: Pull latest images from registry (default, built from main branch)
|
||||
# - <version>: Use specific version tag (e.g., v1.0.0)
|
||||
IMAGE_TAG=dev
|
||||
IMAGE_TAG=latest
|
||||
|
||||
# ======================
|
||||
# Docker Compose Profiles
|
||||
|
||||
@@ -85,12 +85,11 @@ install -> [ruff-check, mypy, security-bandit, security-pip-audit, test]
|
||||
|
||||
## Image Tagging
|
||||
|
||||
| Condition | Tag | Purpose |
|
||||
| ---------------- | -------------------------- | -------------------------- |
|
||||
| Always | `${CI_COMMIT_SHA:0:8}` | Immutable commit reference |
|
||||
| `main` branch | `latest` | Current production release |
|
||||
| `develop` branch | `dev` | Current development build |
|
||||
| Git tag | tag value (e.g., `v1.0.0`) | Semantic version release |
|
||||
| Condition | Tag | Purpose |
|
||||
| ------------- | -------------------------- | -------------------------- |
|
||||
| Always | `${CI_COMMIT_SHA:0:8}` | Immutable commit reference |
|
||||
| `main` branch | `latest` | Current latest build |
|
||||
| Git tag | tag value (e.g., `v1.0.0`) | Semantic version release |
|
||||
|
||||
## Required Secrets
|
||||
|
||||
@@ -138,5 +137,5 @@ Fails on blockers or critical/high severity security findings.
|
||||
|
||||
### Pipeline runs Docker builds on pull requests
|
||||
|
||||
- Docker build steps have `when: branch: [main, develop]` guards
|
||||
- Docker build steps have `when: branch: [main]` guards
|
||||
- PRs only run quality gates, not Docker builds
|
||||
|
||||
@@ -152,12 +152,10 @@ steps:
|
||||
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-api:$CI_COMMIT_TAG"
|
||||
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
||||
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-api:latest"
|
||||
elif [ "$CI_COMMIT_BRANCH" = "develop" ]; then
|
||||
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-api:dev"
|
||||
fi
|
||||
/kaniko/executor --context . --dockerfile apps/api/Dockerfile --snapshot-mode=redo $DESTINATIONS
|
||||
when:
|
||||
- branch: [main, develop]
|
||||
- branch: [main]
|
||||
event: [push, manual, tag]
|
||||
depends_on:
|
||||
- build
|
||||
@@ -180,7 +178,7 @@ steps:
|
||||
elif [ "$$CI_COMMIT_BRANCH" = "main" ]; then
|
||||
SCAN_TAG="latest"
|
||||
else
|
||||
SCAN_TAG="dev"
|
||||
SCAN_TAG="latest"
|
||||
fi
|
||||
mkdir -p ~/.docker
|
||||
echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$$GITEA_USER\",\"password\":\"$$GITEA_TOKEN\"}}}" > ~/.docker/config.json
|
||||
@@ -188,7 +186,7 @@ steps:
|
||||
--ignorefile .trivyignore \
|
||||
git.mosaicstack.dev/mosaic/stack-api:$$SCAN_TAG
|
||||
when:
|
||||
- branch: [main, develop]
|
||||
- branch: [main]
|
||||
event: [push, manual, tag]
|
||||
depends_on:
|
||||
- docker-build-api
|
||||
@@ -230,7 +228,7 @@ steps:
|
||||
}
|
||||
link_package "stack-api"
|
||||
when:
|
||||
- branch: [main, develop]
|
||||
- branch: [main]
|
||||
event: [push, manual, tag]
|
||||
depends_on:
|
||||
- security-trivy-api
|
||||
|
||||
@@ -92,12 +92,10 @@ steps:
|
||||
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-coordinator:$CI_COMMIT_TAG"
|
||||
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
||||
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-coordinator:latest"
|
||||
elif [ "$CI_COMMIT_BRANCH" = "develop" ]; then
|
||||
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-coordinator:dev"
|
||||
fi
|
||||
/kaniko/executor --context apps/coordinator --dockerfile apps/coordinator/Dockerfile --snapshot-mode=redo $DESTINATIONS
|
||||
when:
|
||||
- branch: [main, develop]
|
||||
- branch: [main]
|
||||
event: [push, manual, tag]
|
||||
depends_on:
|
||||
- ruff-check
|
||||
@@ -124,7 +122,7 @@ steps:
|
||||
elif [ "$$CI_COMMIT_BRANCH" = "main" ]; then
|
||||
SCAN_TAG="latest"
|
||||
else
|
||||
SCAN_TAG="dev"
|
||||
SCAN_TAG="latest"
|
||||
fi
|
||||
mkdir -p ~/.docker
|
||||
echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$$GITEA_USER\",\"password\":\"$$GITEA_TOKEN\"}}}" > ~/.docker/config.json
|
||||
@@ -132,7 +130,7 @@ steps:
|
||||
--ignorefile .trivyignore \
|
||||
git.mosaicstack.dev/mosaic/stack-coordinator:$$SCAN_TAG
|
||||
when:
|
||||
- branch: [main, develop]
|
||||
- branch: [main]
|
||||
event: [push, manual, tag]
|
||||
depends_on:
|
||||
- docker-build-coordinator
|
||||
@@ -174,7 +172,7 @@ steps:
|
||||
}
|
||||
link_package "stack-coordinator"
|
||||
when:
|
||||
- branch: [main, develop]
|
||||
- branch: [main]
|
||||
event: [push, manual, tag]
|
||||
depends_on:
|
||||
- security-trivy-coordinator
|
||||
|
||||
@@ -36,12 +36,10 @@ steps:
|
||||
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-postgres:$CI_COMMIT_TAG"
|
||||
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
||||
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-postgres:latest"
|
||||
elif [ "$CI_COMMIT_BRANCH" = "develop" ]; then
|
||||
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-postgres:dev"
|
||||
fi
|
||||
/kaniko/executor --context docker/postgres --dockerfile docker/postgres/Dockerfile --snapshot-mode=redo $DESTINATIONS
|
||||
when:
|
||||
- branch: [main, develop]
|
||||
- branch: [main]
|
||||
event: [push, manual, tag]
|
||||
|
||||
docker-build-openbao:
|
||||
@@ -61,12 +59,10 @@ steps:
|
||||
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-openbao:$CI_COMMIT_TAG"
|
||||
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
||||
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-openbao:latest"
|
||||
elif [ "$CI_COMMIT_BRANCH" = "develop" ]; then
|
||||
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-openbao:dev"
|
||||
fi
|
||||
/kaniko/executor --context docker/openbao --dockerfile docker/openbao/Dockerfile --snapshot-mode=redo $DESTINATIONS
|
||||
when:
|
||||
- branch: [main, develop]
|
||||
- branch: [main]
|
||||
event: [push, manual, tag]
|
||||
|
||||
# === Container Security Scans ===
|
||||
@@ -87,7 +83,7 @@ steps:
|
||||
elif [ "$$CI_COMMIT_BRANCH" = "main" ]; then
|
||||
SCAN_TAG="latest"
|
||||
else
|
||||
SCAN_TAG="dev"
|
||||
SCAN_TAG="latest"
|
||||
fi
|
||||
mkdir -p ~/.docker
|
||||
echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$$GITEA_USER\",\"password\":\"$$GITEA_TOKEN\"}}}" > ~/.docker/config.json
|
||||
@@ -95,7 +91,7 @@ steps:
|
||||
--ignorefile .trivyignore \
|
||||
git.mosaicstack.dev/mosaic/stack-postgres:$$SCAN_TAG
|
||||
when:
|
||||
- branch: [main, develop]
|
||||
- branch: [main]
|
||||
event: [push, manual, tag]
|
||||
depends_on:
|
||||
- docker-build-postgres
|
||||
@@ -116,7 +112,7 @@ steps:
|
||||
elif [ "$$CI_COMMIT_BRANCH" = "main" ]; then
|
||||
SCAN_TAG="latest"
|
||||
else
|
||||
SCAN_TAG="dev"
|
||||
SCAN_TAG="latest"
|
||||
fi
|
||||
mkdir -p ~/.docker
|
||||
echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$$GITEA_USER\",\"password\":\"$$GITEA_TOKEN\"}}}" > ~/.docker/config.json
|
||||
@@ -124,7 +120,7 @@ steps:
|
||||
--ignorefile .trivyignore \
|
||||
git.mosaicstack.dev/mosaic/stack-openbao:$$SCAN_TAG
|
||||
when:
|
||||
- branch: [main, develop]
|
||||
- branch: [main]
|
||||
event: [push, manual, tag]
|
||||
depends_on:
|
||||
- docker-build-openbao
|
||||
@@ -167,7 +163,7 @@ steps:
|
||||
link_package "stack-postgres"
|
||||
link_package "stack-openbao"
|
||||
when:
|
||||
- branch: [main, develop]
|
||||
- branch: [main]
|
||||
event: [push, manual, tag]
|
||||
depends_on:
|
||||
- security-trivy-postgres
|
||||
|
||||
@@ -109,12 +109,10 @@ steps:
|
||||
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-orchestrator:$CI_COMMIT_TAG"
|
||||
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
||||
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-orchestrator:latest"
|
||||
elif [ "$CI_COMMIT_BRANCH" = "develop" ]; then
|
||||
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-orchestrator:dev"
|
||||
fi
|
||||
/kaniko/executor --context . --dockerfile apps/orchestrator/Dockerfile --snapshot-mode=redo $DESTINATIONS
|
||||
when:
|
||||
- branch: [main, develop]
|
||||
- branch: [main]
|
||||
event: [push, manual, tag]
|
||||
depends_on:
|
||||
- build
|
||||
@@ -137,7 +135,7 @@ steps:
|
||||
elif [ "$$CI_COMMIT_BRANCH" = "main" ]; then
|
||||
SCAN_TAG="latest"
|
||||
else
|
||||
SCAN_TAG="dev"
|
||||
SCAN_TAG="latest"
|
||||
fi
|
||||
mkdir -p ~/.docker
|
||||
echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$$GITEA_USER\",\"password\":\"$$GITEA_TOKEN\"}}}" > ~/.docker/config.json
|
||||
@@ -145,7 +143,7 @@ steps:
|
||||
--ignorefile .trivyignore \
|
||||
git.mosaicstack.dev/mosaic/stack-orchestrator:$$SCAN_TAG
|
||||
when:
|
||||
- branch: [main, develop]
|
||||
- branch: [main]
|
||||
event: [push, manual, tag]
|
||||
depends_on:
|
||||
- docker-build-orchestrator
|
||||
@@ -187,7 +185,7 @@ steps:
|
||||
}
|
||||
link_package "stack-orchestrator"
|
||||
when:
|
||||
- branch: [main, develop]
|
||||
- branch: [main]
|
||||
event: [push, manual, tag]
|
||||
depends_on:
|
||||
- security-trivy-orchestrator
|
||||
|
||||
@@ -120,12 +120,10 @@ steps:
|
||||
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-web:$CI_COMMIT_TAG"
|
||||
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
||||
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-web:latest"
|
||||
elif [ "$CI_COMMIT_BRANCH" = "develop" ]; then
|
||||
DESTINATIONS="--destination git.mosaicstack.dev/mosaic/stack-web:dev"
|
||||
fi
|
||||
/kaniko/executor --context . --dockerfile apps/web/Dockerfile --snapshot-mode=redo --build-arg NEXT_PUBLIC_API_URL=https://api.mosaicstack.dev $DESTINATIONS
|
||||
when:
|
||||
- branch: [main, develop]
|
||||
- branch: [main]
|
||||
event: [push, manual, tag]
|
||||
depends_on:
|
||||
- build
|
||||
@@ -148,7 +146,7 @@ steps:
|
||||
elif [ "$$CI_COMMIT_BRANCH" = "main" ]; then
|
||||
SCAN_TAG="latest"
|
||||
else
|
||||
SCAN_TAG="dev"
|
||||
SCAN_TAG="latest"
|
||||
fi
|
||||
mkdir -p ~/.docker
|
||||
echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$$GITEA_USER\",\"password\":\"$$GITEA_TOKEN\"}}}" > ~/.docker/config.json
|
||||
@@ -156,7 +154,7 @@ steps:
|
||||
--ignorefile .trivyignore \
|
||||
git.mosaicstack.dev/mosaic/stack-web:$$SCAN_TAG
|
||||
when:
|
||||
- branch: [main, develop]
|
||||
- branch: [main]
|
||||
event: [push, manual, tag]
|
||||
depends_on:
|
||||
- docker-build-web
|
||||
@@ -198,7 +196,7 @@ steps:
|
||||
}
|
||||
link_package "stack-web"
|
||||
when:
|
||||
- branch: [main, develop]
|
||||
- branch: [main]
|
||||
event: [push, manual, tag]
|
||||
depends_on:
|
||||
- security-trivy-web
|
||||
|
||||
11
README.md
11
README.md
@@ -232,7 +232,7 @@ docker compose -f docker-compose.openbao.yml up -d
|
||||
sleep 30 # Wait for auto-initialization
|
||||
|
||||
# 5. Deploy swarm stack
|
||||
IMAGE_TAG=dev ./scripts/deploy-swarm.sh mosaic
|
||||
IMAGE_TAG=latest ./scripts/deploy-swarm.sh mosaic
|
||||
|
||||
# 6. Check deployment status
|
||||
docker stack services mosaic
|
||||
@@ -526,10 +526,9 @@ KNOWLEDGE_CACHE_TTL=300 # 5 minutes
|
||||
|
||||
### Branch Strategy
|
||||
|
||||
- `main` — Stable releases only
|
||||
- `develop` — Active development (default working branch)
|
||||
- `feature/*` — Feature branches from develop
|
||||
- `fix/*` — Bug fix branches
|
||||
- `main` — Trunk branch (all development merges here)
|
||||
- `feature/*` — Feature branches from main
|
||||
- `fix/*` — Bug fix branches from main
|
||||
|
||||
### Running Locally
|
||||
|
||||
@@ -739,7 +738,7 @@ See [Type Sharing Strategy](docs/2-development/3-type-sharing/1-strategy.md) for
|
||||
4. Run tests: `pnpm test`
|
||||
5. Build: `pnpm build`
|
||||
6. Commit with conventional format: `feat(#issue): Description`
|
||||
7. Push and create a pull request to `develop`
|
||||
7. Push and create a pull request to `main`
|
||||
|
||||
### Commit Format
|
||||
|
||||
|
||||
@@ -47,7 +47,10 @@
|
||||
"@types/react-grid-layout": "^2.1.0",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"@vitest/coverage-v8": "^3.2.4",
|
||||
"autoprefixer": "^10.4.24",
|
||||
"jsdom": "^26.0.0",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"typescript": "^5.8.2",
|
||||
"vitest": "^3.0.8"
|
||||
}
|
||||
|
||||
8
apps/web/postcss.config.mjs
Normal file
8
apps/web/postcss.config.mjs
Normal file
@@ -0,0 +1,8 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -127,8 +127,8 @@ describe("LoginPage", (): void => {
|
||||
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent("Welcome to Mosaic Stack");
|
||||
expect(screen.getByText(/Your personal assistant platform/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent("Command Center");
|
||||
expect(screen.getByText(/Sign in to your orchestration platform/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("has proper layout styling", async (): Promise<void> => {
|
||||
@@ -186,7 +186,7 @@ describe("LoginPage", (): void => {
|
||||
expect(screen.getByRole("button", { name: /continue with authentik/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText(/or continue with email/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/or continue with/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
|
||||
});
|
||||
@@ -200,7 +200,11 @@ describe("LoginPage", (): void => {
|
||||
expect(screen.getByRole("button", { name: /continue with authentik/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.queryByText(/or continue with email/i)).not.toBeInTheDocument();
|
||||
// The divider element should not appear (no credentials provider)
|
||||
const dividerTexts = screen.queryAllByText(/or continue with/i);
|
||||
// OAuthButton text contains "Continue with" so filter for the divider specifically
|
||||
const dividerOnly = dividerTexts.filter((el) => el.textContent === "or continue with");
|
||||
expect(dividerOnly).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("shows error state with retry button on fetch failure instead of silent fallback", async (): Promise<void> => {
|
||||
@@ -215,7 +219,6 @@ describe("LoginPage", (): void => {
|
||||
// Should NOT silently fall back to email form
|
||||
expect(screen.queryByLabelText(/email/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText(/password/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /continue with/i })).not.toBeInTheDocument();
|
||||
|
||||
// Should show the error banner with helpful message
|
||||
expect(
|
||||
@@ -453,7 +456,7 @@ describe("LoginPage", (): void => {
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
describe("responsive layout", (): void => {
|
||||
it("applies mobile-first padding to main element", async (): Promise<void> => {
|
||||
it("applies AuthShell layout classes to main element", async (): Promise<void> => {
|
||||
mockFetchConfig(EMAIL_ONLY_CONFIG);
|
||||
|
||||
const { container } = render(<LoginPage />);
|
||||
@@ -463,8 +466,7 @@ describe("LoginPage", (): void => {
|
||||
});
|
||||
|
||||
const main = container.querySelector("main");
|
||||
|
||||
expect(main).toHaveClass("p-4", "sm:p-8");
|
||||
expect(main).toHaveClass("min-h-screen", "items-center", "justify-center");
|
||||
});
|
||||
|
||||
it("applies responsive text size to heading", async (): Promise<void> => {
|
||||
@@ -477,10 +479,10 @@ describe("LoginPage", (): void => {
|
||||
});
|
||||
|
||||
const heading = screen.getByRole("heading", { level: 1 });
|
||||
expect(heading).toHaveClass("text-2xl", "sm:text-4xl");
|
||||
expect(heading).toHaveClass("text-xl", "sm:text-2xl");
|
||||
});
|
||||
|
||||
it("applies responsive padding to card container", async (): Promise<void> => {
|
||||
it("AuthCard applies card styling with padding", async (): Promise<void> => {
|
||||
mockFetchConfig(EMAIL_ONLY_CONFIG);
|
||||
|
||||
const { container } = render(<LoginPage />);
|
||||
@@ -489,12 +491,12 @@ describe("LoginPage", (): void => {
|
||||
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const card = container.querySelector(".bg-white");
|
||||
|
||||
expect(card).toHaveClass("p-4", "sm:p-8");
|
||||
// AuthCard uses rounded-b-2xl and p-6 sm:p-10
|
||||
const card = container.querySelector(".rounded-b-2xl");
|
||||
expect(card).toHaveClass("p-6", "sm:p-10");
|
||||
});
|
||||
|
||||
it("card container has full width with max-width constraint", async (): Promise<void> => {
|
||||
it("AuthShell constrains card width", async (): Promise<void> => {
|
||||
mockFetchConfig(EMAIL_ONLY_CONFIG);
|
||||
|
||||
const { container } = render(<LoginPage />);
|
||||
@@ -503,9 +505,9 @@ describe("LoginPage", (): void => {
|
||||
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const wrapper = container.querySelector(".max-w-md");
|
||||
|
||||
expect(wrapper).toHaveClass("w-full", "max-w-md");
|
||||
// AuthShell wraps children in max-w-[27rem]
|
||||
const wrapper = container.querySelector(".max-w-\\[27rem\\]");
|
||||
expect(wrapper).toHaveClass("w-full");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { ReactElement } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import type { AuthConfigResponse, AuthProviderConfig } from "@mosaic/shared";
|
||||
import { AuthShell, AuthCard, AuthBrand, AuthStatusPill } from "@mosaic/ui";
|
||||
import { API_BASE_URL, IS_MOCK_AUTH_MODE } from "@/lib/config";
|
||||
import { signIn } from "@/lib/auth-client";
|
||||
import { fetchWithRetry } from "@/lib/auth/fetch-with-retry";
|
||||
@@ -19,23 +20,21 @@ export default function LoginPage(): ReactElement {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<main className="flex min-h-screen flex-col items-center justify-center p-4 sm:p-8 bg-gray-50">
|
||||
<div className="w-full max-w-md space-y-8">
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl sm:text-4xl font-bold mb-4">Welcome to Mosaic Stack</h1>
|
||||
</div>
|
||||
<div className="bg-white p-4 sm:p-8 rounded-lg shadow-md">
|
||||
<AuthShell>
|
||||
<AuthCard>
|
||||
<div className="flex flex-col items-center gap-6">
|
||||
<AuthBrand />
|
||||
<div
|
||||
className="flex items-center justify-center py-8"
|
||||
role="status"
|
||||
aria-label="Loading authentication options"
|
||||
>
|
||||
<Loader2 className="h-8 w-8 animate-spin text-blue-500" aria-hidden="true" />
|
||||
<Loader2 className="h-8 w-8 animate-spin text-[#56a0ff]" aria-hidden="true" />
|
||||
<span className="sr-only">Loading authentication options</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</AuthCard>
|
||||
</AuthShell>
|
||||
}
|
||||
>
|
||||
<LoginPageContent />
|
||||
@@ -185,47 +184,51 @@ function LoginPageContent(): ReactElement {
|
||||
|
||||
if (IS_MOCK_AUTH_MODE) {
|
||||
return (
|
||||
<main className="flex min-h-screen flex-col items-center justify-center p-4 sm:p-8 bg-gray-50">
|
||||
<div className="w-full max-w-md space-y-8">
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl sm:text-4xl font-bold mb-4">Welcome to Mosaic Stack</h1>
|
||||
<p className="text-base sm:text-lg text-gray-600">
|
||||
Local mock auth mode is active. Real sign-in is bypassed for frontend development.
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white p-4 sm:p-8 rounded-lg shadow-md space-y-4">
|
||||
<div className="rounded-md border border-amber-300 bg-amber-50 p-3 text-sm text-amber-900">
|
||||
Mock auth mode is local-only and blocked outside development.
|
||||
<AuthShell>
|
||||
<AuthCard>
|
||||
<div className="flex flex-col items-center gap-6">
|
||||
<AuthBrand />
|
||||
<div className="text-center">
|
||||
<h1 className="text-xl font-bold tracking-tight sm:text-2xl">Command Center</h1>
|
||||
<p className="mt-1 text-sm text-[#5a6a87] dark:text-[#8f9db7]">
|
||||
Local mock auth mode is active
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 space-y-4">
|
||||
<AuthStatusPill label="Mock mode" tone="warning" className="w-full justify-center" />
|
||||
{error && <AuthErrorBanner message={error} />}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void handleMockLogin();
|
||||
}}
|
||||
className="w-full rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 transition-colors"
|
||||
className="w-full inline-flex items-center justify-center gap-2 rounded-lg px-4 py-3 text-sm font-semibold text-white bg-[linear-gradient(135deg,#2f80ff,#8b5cf6)] transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-[#56a0ff]/60 hover:-translate-y-0.5 hover:shadow-[0_10px_30px_rgba(47,128,255,0.38)]"
|
||||
data-testid="mock-auth-login"
|
||||
>
|
||||
Continue with Mock Session
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</AuthCard>
|
||||
</AuthShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="flex min-h-screen flex-col items-center justify-center p-4 sm:p-8 bg-gray-50">
|
||||
<div className="w-full max-w-md space-y-8">
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl sm:text-4xl font-bold mb-4">Welcome to Mosaic Stack</h1>
|
||||
<p className="text-base sm:text-lg text-gray-600">
|
||||
Your personal assistant platform. Organize tasks, events, and projects with a
|
||||
PDA-friendly approach.
|
||||
</p>
|
||||
<AuthShell>
|
||||
<AuthCard>
|
||||
<div className="flex flex-col items-center gap-6">
|
||||
<AuthBrand />
|
||||
<div className="text-center">
|
||||
<h1 className="text-xl font-bold tracking-tight sm:text-2xl">Command Center</h1>
|
||||
<p className="mt-1 text-sm text-[#5a6a87] dark:text-[#8f9db7]">
|
||||
Sign in to your orchestration platform
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-4 sm:p-8 rounded-lg shadow-md">
|
||||
<div className="mt-6">
|
||||
{loadingConfig ? (
|
||||
<div
|
||||
className="flex items-center justify-center py-8"
|
||||
@@ -233,7 +236,7 @@ function LoginPageContent(): ReactElement {
|
||||
role="status"
|
||||
aria-label="Loading authentication options"
|
||||
>
|
||||
<Loader2 className="h-8 w-8 animate-spin text-blue-500" aria-hidden="true" />
|
||||
<Loader2 className="h-8 w-8 animate-spin text-[#56a0ff]" aria-hidden="true" />
|
||||
<span className="sr-only">Loading authentication options</span>
|
||||
</div>
|
||||
) : config === null ? (
|
||||
@@ -243,47 +246,35 @@ function LoginPageContent(): ReactElement {
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRetry}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
||||
className="inline-flex items-center justify-center gap-2 rounded-lg px-4 py-2.5 text-sm font-semibold text-white bg-[linear-gradient(135deg,#2f80ff,#8b5cf6)] transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-[#56a0ff]/60 hover:-translate-y-0.5 hover:shadow-[0_10px_30px_rgba(47,128,255,0.38)]"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-0">
|
||||
{urlError && (
|
||||
<AuthErrorBanner
|
||||
message={urlError}
|
||||
onDismiss={(): void => {
|
||||
setUrlError(null);
|
||||
}}
|
||||
/>
|
||||
<div className="mb-4">
|
||||
<AuthErrorBanner
|
||||
message={urlError}
|
||||
onDismiss={(): void => {
|
||||
setUrlError(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && !hasCredentials && (
|
||||
<AuthErrorBanner
|
||||
message={error}
|
||||
onDismiss={(): void => {
|
||||
setError(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{hasOAuth &&
|
||||
oauthProviders.map((provider) => (
|
||||
<OAuthButton
|
||||
key={provider.id}
|
||||
providerName={provider.name}
|
||||
providerId={provider.id}
|
||||
onClick={(): void => {
|
||||
handleOAuthLogin(provider.id);
|
||||
<div className="mb-4">
|
||||
<AuthErrorBanner
|
||||
message={error}
|
||||
onDismiss={(): void => {
|
||||
setError(null);
|
||||
}}
|
||||
isLoading={oauthLoading === provider.id}
|
||||
disabled={oauthLoading !== null && oauthLoading !== provider.id}
|
||||
/>
|
||||
))}
|
||||
|
||||
{hasOAuth && hasCredentials && <AuthDivider />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasCredentials && (
|
||||
<LoginForm
|
||||
@@ -292,10 +283,33 @@ function LoginPageContent(): ReactElement {
|
||||
error={error}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
{hasOAuth && hasCredentials && <AuthDivider />}
|
||||
|
||||
{hasOAuth && (
|
||||
<div className="space-y-2">
|
||||
{oauthProviders.map((provider) => (
|
||||
<OAuthButton
|
||||
key={provider.id}
|
||||
providerName={provider.name}
|
||||
providerId={provider.id}
|
||||
onClick={(): void => {
|
||||
handleOAuthLogin(provider.id);
|
||||
}}
|
||||
isLoading={oauthLoading === provider.id}
|
||||
disabled={oauthLoading !== null && oauthLoading !== provider.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<div className="mt-6 flex justify-center">
|
||||
<AuthStatusPill label="Mosaic v0.1" tone="neutral" />
|
||||
</div>
|
||||
</AuthCard>
|
||||
</AuthShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,10 +4,79 @@ import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useAuth } from "@/lib/auth/auth-context";
|
||||
import { IS_MOCK_AUTH_MODE } from "@/lib/config";
|
||||
import { Navigation } from "@/components/layout/Navigation";
|
||||
import { AppHeader } from "@/components/layout/AppHeader";
|
||||
import { AppSidebar } from "@/components/layout/AppSidebar";
|
||||
import { SidebarProvider, useSidebar } from "@/components/layout/SidebarContext";
|
||||
import { ChatOverlay } from "@/components/chat";
|
||||
import { MosaicSpinner } from "@/components/ui/MosaicSpinner";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SIDEBAR_EXPANDED_WIDTH = "240px";
|
||||
const SIDEBAR_COLLAPSED_WIDTH = "60px";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inner shell — must be a child of SidebarProvider to use useSidebar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface AppShellProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function AppShell({ children }: AppShellProps): React.JSX.Element {
|
||||
const { collapsed, isMobile } = useSidebar();
|
||||
|
||||
// On tablet (md–lg), hide sidebar from the grid when the sidebar is collapsed.
|
||||
// On mobile, the sidebar is fixed-position so the grid is always single-column.
|
||||
const sidebarHidden = !isMobile && collapsed;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="app-shell"
|
||||
data-sidebar-hidden={sidebarHidden ? "true" : undefined}
|
||||
style={
|
||||
{
|
||||
"--sidebar-w": collapsed ? SIDEBAR_COLLAPSED_WIDTH : SIDEBAR_EXPANDED_WIDTH,
|
||||
transition: "grid-template-columns 0.2s var(--ease, ease)",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
{/* Full-width header — grid-column: 1 / -1 via .app-header CSS class */}
|
||||
<AppHeader />
|
||||
|
||||
{/* Sidebar — left column, row 2, via .app-sidebar CSS class */}
|
||||
<AppSidebar />
|
||||
|
||||
{/* Main content — right column, row 2, via .app-main CSS class */}
|
||||
<main className="app-main" id="main-content">
|
||||
{IS_MOCK_AUTH_MODE && (
|
||||
<div
|
||||
className="border-b px-4 py-2 text-xs font-medium flex-shrink-0"
|
||||
style={{
|
||||
borderColor: "var(--ms-amber-500)",
|
||||
background: "rgba(245, 158, 11, 0.08)",
|
||||
color: "var(--ms-amber-400)",
|
||||
}}
|
||||
data-testid="mock-auth-banner"
|
||||
>
|
||||
Mock Auth Mode (Local Only): Real authentication is bypassed for frontend development.
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 overflow-y-auto p-5">{children}</div>
|
||||
</main>
|
||||
|
||||
{!IS_MOCK_AUTH_MODE && <ChatOverlay />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Authenticated layout — handles auth guard + provides sidebar context
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function AuthenticatedLayout({
|
||||
children,
|
||||
}: {
|
||||
@@ -23,11 +92,7 @@ export default function AuthenticatedLayout({
|
||||
}, [isAuthenticated, isLoading, router]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-gray-900"></div>
|
||||
</div>
|
||||
);
|
||||
return <MosaicSpinner size={48} fullPage />;
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
@@ -35,20 +100,8 @@ export default function AuthenticatedLayout({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<Navigation />
|
||||
<div className="pt-16">
|
||||
{IS_MOCK_AUTH_MODE && (
|
||||
<div
|
||||
className="border-b border-amber-300 bg-amber-50 px-4 py-2 text-sm text-amber-900"
|
||||
data-testid="mock-auth-banner"
|
||||
>
|
||||
Mock Auth Mode (Local Only): Real authentication is bypassed for frontend development.
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
{!IS_MOCK_AUTH_MODE && <ChatOverlay />}
|
||||
</div>
|
||||
<SidebarProvider>
|
||||
<AppShell>{children}</AppShell>
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,78 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import type { ReactElement } from "react";
|
||||
import { RecentTasksWidget } from "@/components/dashboard/RecentTasksWidget";
|
||||
import { UpcomingEventsWidget } from "@/components/dashboard/UpcomingEventsWidget";
|
||||
import { QuickCaptureWidget } from "@/components/dashboard/QuickCaptureWidget";
|
||||
import { DomainOverviewWidget } from "@/components/dashboard/DomainOverviewWidget";
|
||||
import { mockTasks } from "@/lib/api/tasks";
|
||||
import { mockEvents } from "@/lib/api/events";
|
||||
import type { Task, Event } from "@mosaic/shared";
|
||||
import { DashboardMetrics } from "@/components/dashboard/DashboardMetrics";
|
||||
import { OrchestratorSessions } from "@/components/dashboard/OrchestratorSessions";
|
||||
import { QuickActions } from "@/components/dashboard/QuickActions";
|
||||
import { ActivityFeed } from "@/components/dashboard/ActivityFeed";
|
||||
import { TokenBudget } from "@/components/dashboard/TokenBudget";
|
||||
|
||||
export default function DashboardPage(): ReactElement {
|
||||
const [tasks, setTasks] = useState<Task[]>([]);
|
||||
const [events, setEvents] = useState<Event[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
void loadDashboardData();
|
||||
}, []);
|
||||
|
||||
async function loadDashboardData(): Promise<void> {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// TODO: Replace with real API calls when backend is ready
|
||||
// const [tasksData, eventsData] = await Promise.all([fetchTasks(), fetchEvents()]);
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
setTasks(mockTasks);
|
||||
setEvents(mockEvents);
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "We had trouble loading your dashboard. Please try again when you're ready."
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="container mx-auto px-4 py-8">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900">Dashboard</h1>
|
||||
<p className="text-gray-600 mt-2">Welcome back! Here's your overview</p>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
||||
<DashboardMetrics />
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr 320px",
|
||||
gap: 16,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 16, minWidth: 0 }}>
|
||||
<OrchestratorSessions />
|
||||
<QuickActions />
|
||||
</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
||||
<ActivityFeed />
|
||||
<TokenBudget />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error !== null ? (
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50 p-6 text-center">
|
||||
<p className="text-amber-800">{error}</p>
|
||||
<button
|
||||
onClick={() => void loadDashboardData()}
|
||||
className="mt-4 rounded-md bg-amber-600 px-4 py-2 text-sm font-medium text-white hover:bg-amber-700 transition-colors"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Top row: Domain Overview and Quick Capture */}
|
||||
<div className="lg:col-span-2">
|
||||
<DomainOverviewWidget tasks={tasks} isLoading={isLoading} />
|
||||
</div>
|
||||
|
||||
<RecentTasksWidget tasks={tasks} isLoading={isLoading} />
|
||||
<UpcomingEventsWidget events={events} isLoading={isLoading} />
|
||||
|
||||
<div className="lg:col-span-2">
|
||||
<QuickCaptureWidget />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,147 +3,303 @@
|
||||
@tailwind utilities;
|
||||
|
||||
/* =============================================================================
|
||||
DESIGN C: PROFESSIONAL/ENTERPRISE DESIGN SYSTEM
|
||||
Philosophy: "Good design is as little design as possible." - Dieter Rams
|
||||
MOSAIC DESIGN SYSTEM — Reference token system from dashboard design
|
||||
============================================================================= */
|
||||
|
||||
/* -----------------------------------------------------------------------------
|
||||
CSS Custom Properties - Light Theme (Default)
|
||||
Primitive Tokens (Dark-first — dark is the default theme)
|
||||
----------------------------------------------------------------------------- */
|
||||
:root {
|
||||
/* Base colors - increased contrast from surfaces */
|
||||
--color-background: 245 247 250;
|
||||
--color-foreground: 15 23 42;
|
||||
/* Mosaic design tokens — dark palette (default) */
|
||||
--ms-bg-950: #080b12;
|
||||
--ms-bg-900: #0f141d;
|
||||
--ms-bg-850: #151b26;
|
||||
--ms-surface-800: #1b2331;
|
||||
--ms-surface-750: #232d3f;
|
||||
--ms-border-700: #2f3b52;
|
||||
--ms-text-100: #eef3ff;
|
||||
--ms-text-300: #c5d0e6;
|
||||
--ms-text-500: #8f9db7;
|
||||
--ms-blue-500: #2f80ff;
|
||||
--ms-blue-400: #56a0ff;
|
||||
--ms-red-500: #e5484d;
|
||||
--ms-red-400: #f06a6f;
|
||||
--ms-purple-500: #8b5cf6;
|
||||
--ms-purple-400: #a78bfa;
|
||||
--ms-teal-500: #14b8a6;
|
||||
--ms-teal-400: #2dd4bf;
|
||||
--ms-amber-500: #f59e0b;
|
||||
--ms-amber-400: #fbbf24;
|
||||
--ms-pink-500: #ec4899;
|
||||
--ms-emerald-500: #10b981;
|
||||
--ms-orange-500: #f97316;
|
||||
--ms-cyan-500: #06b6d4;
|
||||
--ms-indigo-500: #6366f1;
|
||||
|
||||
/* Surface hierarchy (elevation levels) - improved contrast */
|
||||
--surface-0: 255 255 255;
|
||||
--surface-1: 250 251 252;
|
||||
--surface-2: 241 245 249;
|
||||
--surface-3: 226 232 240;
|
||||
/* Semantic aliases — dark theme is default */
|
||||
--bg: var(--ms-bg-900);
|
||||
--bg-deep: var(--ms-bg-950);
|
||||
--bg-mid: var(--ms-bg-850);
|
||||
--surface: var(--ms-surface-800);
|
||||
--surface-2: var(--ms-surface-750);
|
||||
--border: var(--ms-border-700);
|
||||
--text: var(--ms-text-100);
|
||||
--text-2: var(--ms-text-300);
|
||||
--muted: var(--ms-text-500);
|
||||
--primary: var(--ms-blue-500);
|
||||
--primary-l: var(--ms-blue-400);
|
||||
--danger: var(--ms-red-500);
|
||||
--success: var(--ms-teal-500);
|
||||
--warn: var(--ms-amber-500);
|
||||
--purple: var(--ms-purple-500);
|
||||
|
||||
/* Text hierarchy */
|
||||
--text-primary: 15 23 42;
|
||||
--text-secondary: 51 65 85;
|
||||
--text-tertiary: 71 85 105;
|
||||
--text-muted: 100 116 139;
|
||||
/* Typography */
|
||||
--font: var(--font-outfit, 'Outfit'), system-ui, sans-serif;
|
||||
--mono: var(--font-fira-code, 'Fira Code'), 'Cascadia Code', monospace;
|
||||
|
||||
/* Border colors - stronger borders for light mode */
|
||||
--border-default: 203 213 225;
|
||||
--border-subtle: 226 232 240;
|
||||
--border-strong: 148 163 184;
|
||||
/* Radius scale */
|
||||
--r: 8px;
|
||||
--r-sm: 5px;
|
||||
--r-lg: 12px;
|
||||
--r-xl: 16px;
|
||||
|
||||
/* Brand accent - Indigo (professional, trustworthy) */
|
||||
--accent-primary: 79 70 229;
|
||||
--accent-primary-hover: 67 56 202;
|
||||
--accent-primary-light: 238 242 255;
|
||||
--accent-primary-muted: 199 210 254;
|
||||
/* Layout dimensions */
|
||||
--sidebar-w: 260px;
|
||||
--topbar-h: 56px;
|
||||
--terminal-h: 220px;
|
||||
|
||||
/* Semantic colors - Success (Emerald) */
|
||||
--semantic-success: 16 185 129;
|
||||
--semantic-success-light: 209 250 229;
|
||||
--semantic-success-dark: 6 95 70;
|
||||
/* Easing */
|
||||
--ease: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
|
||||
/* Semantic colors - Warning (Amber) */
|
||||
--semantic-warning: 245 158 11;
|
||||
--semantic-warning-light: 254 243 199;
|
||||
--semantic-warning-dark: 146 64 14;
|
||||
|
||||
/* Semantic colors - Error (Rose) */
|
||||
--semantic-error: 244 63 94;
|
||||
--semantic-error-light: 255 228 230;
|
||||
--semantic-error-dark: 159 18 57;
|
||||
|
||||
/* Semantic colors - Info (Sky) */
|
||||
--semantic-info: 14 165 233;
|
||||
--semantic-info-light: 224 242 254;
|
||||
--semantic-info-dark: 3 105 161;
|
||||
|
||||
/* Focus ring */
|
||||
--focus-ring: 99 102 241;
|
||||
--focus-ring-offset: 255 255 255;
|
||||
|
||||
/* Shadows - visible but subtle */
|
||||
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05), 0 1px 3px 0 rgb(0 0 0 / 0.05);
|
||||
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.08), 0 2px 4px -2px rgb(0 0 0 / 0.06);
|
||||
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.08);
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------------
|
||||
CSS Custom Properties - Dark Theme
|
||||
----------------------------------------------------------------------------- */
|
||||
.dark {
|
||||
--color-background: 3 7 18;
|
||||
--color-foreground: 248 250 252;
|
||||
|
||||
/* Surface hierarchy (elevation levels) */
|
||||
--surface-0: 15 23 42;
|
||||
--surface-1: 30 41 59;
|
||||
--surface-2: 51 65 85;
|
||||
--surface-3: 71 85 105;
|
||||
|
||||
/* Text hierarchy */
|
||||
--text-primary: 248 250 252;
|
||||
--text-secondary: 203 213 225;
|
||||
--text-tertiary: 148 163 184;
|
||||
--text-muted: 100 116 139;
|
||||
|
||||
/* Border colors */
|
||||
--border-default: 51 65 85;
|
||||
--border-subtle: 30 41 59;
|
||||
--border-strong: 71 85 105;
|
||||
|
||||
/* Brand accent adjustments for dark mode */
|
||||
--accent-primary: 129 140 248;
|
||||
--accent-primary-hover: 165 180 252;
|
||||
--accent-primary-light: 30 27 75;
|
||||
--accent-primary-muted: 55 48 163;
|
||||
|
||||
/* Semantic colors adjustments */
|
||||
--semantic-success: 52 211 153;
|
||||
--semantic-success-light: 6 78 59;
|
||||
--semantic-success-dark: 167 243 208;
|
||||
|
||||
--semantic-warning: 251 191 36;
|
||||
--semantic-warning-light: 120 53 15;
|
||||
--semantic-warning-dark: 253 230 138;
|
||||
|
||||
--semantic-error: 251 113 133;
|
||||
--semantic-error-light: 136 19 55;
|
||||
--semantic-error-dark: 253 164 175;
|
||||
|
||||
--semantic-info: 56 189 248;
|
||||
--semantic-info-light: 12 74 110;
|
||||
--semantic-info-dark: 186 230 253;
|
||||
|
||||
/* Focus ring */
|
||||
--focus-ring: 129 140 248;
|
||||
--focus-ring-offset: 15 23 42;
|
||||
|
||||
/* Shadows - subtle glow in dark mode */
|
||||
/* Legacy shadow tokens (retained for component compat) */
|
||||
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.3);
|
||||
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.4), 0 2px 4px -2px rgb(0 0 0 / 0.3);
|
||||
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.5), 0 4px 6px -4px rgb(0 0 0 / 0.4);
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------------
|
||||
Light Theme Override — applied via data-theme attribute on <html>
|
||||
----------------------------------------------------------------------------- */
|
||||
[data-theme="light"] {
|
||||
--ms-bg-950: #f8faff;
|
||||
--ms-bg-900: #f0f4fc;
|
||||
--ms-bg-850: #e8edf8;
|
||||
--ms-surface-800: #dde4f2;
|
||||
--ms-surface-750: #d0d9ec;
|
||||
--ms-border-700: #b8c4de;
|
||||
--ms-text-100: #0f141d;
|
||||
--ms-text-300: #2f3b52;
|
||||
--ms-text-500: #5a6a87;
|
||||
|
||||
/* Re-alias semantics for light — identical structure, primitive tokens differ */
|
||||
--bg: var(--ms-bg-900);
|
||||
--bg-deep: var(--ms-bg-950);
|
||||
--bg-mid: var(--ms-bg-850);
|
||||
--surface: var(--ms-surface-800);
|
||||
--surface-2: var(--ms-surface-750);
|
||||
--border: var(--ms-border-700);
|
||||
--text: var(--ms-text-100);
|
||||
--text-2: var(--ms-text-300);
|
||||
--muted: var(--ms-text-500);
|
||||
|
||||
/* Lighter shadows for light mode */
|
||||
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05), 0 1px 3px 0 rgb(0 0 0 / 0.05);
|
||||
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.08), 0 2px 4px -2px rgb(0 0 0 / 0.06);
|
||||
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.08);
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------------
|
||||
Base Styles
|
||||
----------------------------------------------------------------------------- */
|
||||
* {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
font-size: 15px;
|
||||
font-feature-settings: "cv02", "cv03", "cv04", "cv11";
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
body {
|
||||
color: rgb(var(--text-primary));
|
||||
background: rgb(var(--color-background));
|
||||
font-size: 14px;
|
||||
font-family: var(--font);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
line-height: 1.5;
|
||||
transition: background-color 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
button {
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
/* Subtle grain/noise overlay for texture */
|
||||
body::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 9999;
|
||||
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='1'/%3E%3C/svg%3E");
|
||||
opacity: 0.025;
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------------
|
||||
Focus States - Accessible & Visible
|
||||
----------------------------------------------------------------------------- */
|
||||
@layer base {
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--ms-blue-400);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
:focus:not(:focus-visible) {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------------
|
||||
Scrollbar Styling - Minimal & Professional
|
||||
----------------------------------------------------------------------------- */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--border);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--muted);
|
||||
}
|
||||
|
||||
/* Firefox */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border) transparent;
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------------
|
||||
App Shell Grid Layout
|
||||
----------------------------------------------------------------------------- */
|
||||
.app-shell {
|
||||
display: grid;
|
||||
grid-template-columns: var(--sidebar-w) 1fr;
|
||||
grid-template-rows: var(--topbar-h) 1fr;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
grid-column: 1 / -1;
|
||||
grid-row: 1;
|
||||
background: var(--bg-deep);
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 20px;
|
||||
gap: 12px;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.app-sidebar {
|
||||
grid-column: 1;
|
||||
grid-row: 2;
|
||||
background: var(--bg-deep);
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app-main {
|
||||
grid-column: 2;
|
||||
grid-row: 2;
|
||||
background: var(--bg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------------
|
||||
Responsive App Shell — Mobile (< 768px): single-column, sidebar as overlay
|
||||
----------------------------------------------------------------------------- */
|
||||
@media (max-width: 767px) {
|
||||
.app-shell {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.app-sidebar {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: var(--topbar-h);
|
||||
bottom: 0;
|
||||
width: 240px;
|
||||
z-index: 150;
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.app-sidebar[data-mobile-open="true"] {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.app-main {
|
||||
grid-column: 1;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
grid-column: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------------
|
||||
Responsive App Shell — Tablet (768px–1023px): sidebar toggleable, pushes content
|
||||
----------------------------------------------------------------------------- */
|
||||
@media (min-width: 768px) and (max-width: 1023px) {
|
||||
.app-shell[data-sidebar-hidden="true"] {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.app-shell[data-sidebar-hidden="true"] .app-sidebar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.app-shell[data-sidebar-hidden="true"] .app-main {
|
||||
grid-column: 1;
|
||||
}
|
||||
|
||||
.app-shell[data-sidebar-hidden="true"] .app-header {
|
||||
grid-column: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------------
|
||||
@@ -182,102 +338,10 @@ body {
|
||||
}
|
||||
|
||||
.text-mono {
|
||||
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.25rem;
|
||||
}
|
||||
|
||||
/* Text color utilities */
|
||||
.text-primary {
|
||||
color: rgb(var(--text-primary));
|
||||
}
|
||||
|
||||
.text-secondary {
|
||||
color: rgb(var(--text-secondary));
|
||||
}
|
||||
|
||||
.text-tertiary {
|
||||
color: rgb(var(--text-tertiary));
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: rgb(var(--text-muted));
|
||||
}
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------------
|
||||
Surface & Card Utilities
|
||||
----------------------------------------------------------------------------- */
|
||||
@layer utilities {
|
||||
.surface-0 {
|
||||
background-color: rgb(var(--surface-0));
|
||||
}
|
||||
|
||||
.surface-1 {
|
||||
background-color: rgb(var(--surface-1));
|
||||
}
|
||||
|
||||
.surface-2 {
|
||||
background-color: rgb(var(--surface-2));
|
||||
}
|
||||
|
||||
.surface-3 {
|
||||
background-color: rgb(var(--surface-3));
|
||||
}
|
||||
|
||||
.border-default {
|
||||
border-color: rgb(var(--border-default));
|
||||
}
|
||||
|
||||
.border-subtle {
|
||||
border-color: rgb(var(--border-subtle));
|
||||
}
|
||||
|
||||
.border-strong {
|
||||
border-color: rgb(var(--border-strong));
|
||||
}
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------------
|
||||
Focus States - Accessible & Visible
|
||||
----------------------------------------------------------------------------- */
|
||||
@layer base {
|
||||
:focus-visible {
|
||||
outline: 2px solid rgb(var(--focus-ring));
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Remove default focus for mouse users */
|
||||
:focus:not(:focus-visible) {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------------
|
||||
Scrollbar Styling - Minimal & Professional
|
||||
----------------------------------------------------------------------------- */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgb(var(--text-muted) / 0.4);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgb(var(--text-muted) / 0.6);
|
||||
}
|
||||
|
||||
/* Firefox */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgb(var(--text-muted) / 0.4) transparent;
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------------
|
||||
@@ -292,40 +356,46 @@ body {
|
||||
|
||||
.btn-primary {
|
||||
@apply btn px-4 py-2;
|
||||
background-color: rgb(var(--accent-primary));
|
||||
background: linear-gradient(135deg, var(--ms-blue-500), var(--ms-purple-500));
|
||||
color: white;
|
||||
border-radius: var(--r);
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background-color: rgb(var(--accent-primary-hover));
|
||||
box-shadow: 0 8px 28px rgba(47, 128, 255, 0.38);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply btn px-4 py-2;
|
||||
background-color: rgb(var(--surface-2));
|
||||
color: rgb(var(--text-primary));
|
||||
border: 1px solid rgb(var(--border-default));
|
||||
background-color: var(--surface);
|
||||
color: var(--text-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r);
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background-color: rgb(var(--surface-3));
|
||||
background-color: var(--surface-2);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
@apply btn px-3 py-2;
|
||||
background-color: transparent;
|
||||
color: rgb(var(--text-secondary));
|
||||
color: var(--muted);
|
||||
border-radius: var(--r);
|
||||
}
|
||||
|
||||
.btn-ghost:hover:not(:disabled) {
|
||||
background-color: rgb(var(--surface-2));
|
||||
color: rgb(var(--text-primary));
|
||||
background-color: var(--surface);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
@apply btn px-4 py-2;
|
||||
background-color: rgb(var(--semantic-error));
|
||||
background-color: var(--danger);
|
||||
color: white;
|
||||
border-radius: var(--r);
|
||||
}
|
||||
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
@@ -346,34 +416,36 @@ body {
|
||||
----------------------------------------------------------------------------- */
|
||||
@layer components {
|
||||
.input {
|
||||
@apply w-full rounded-md px-3 py-2 text-sm transition-all duration-150;
|
||||
@apply focus:outline-none focus:ring-2 focus:ring-offset-0;
|
||||
background-color: rgb(var(--surface-0));
|
||||
border: 1px solid rgb(var(--border-default));
|
||||
color: rgb(var(--text-primary));
|
||||
@apply w-full text-sm transition-all duration-150;
|
||||
@apply focus:outline-none;
|
||||
background-color: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r);
|
||||
color: var(--text);
|
||||
padding: 11px 14px;
|
||||
}
|
||||
|
||||
.input::placeholder {
|
||||
color: rgb(var(--text-muted));
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
border-color: rgb(var(--accent-primary));
|
||||
box-shadow: 0 0 0 3px rgb(var(--accent-primary) / 0.1);
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px rgba(47, 128, 255, 0.12);
|
||||
}
|
||||
|
||||
.input:disabled {
|
||||
@apply opacity-50 cursor-not-allowed;
|
||||
background-color: rgb(var(--surface-1));
|
||||
background-color: var(--surface);
|
||||
}
|
||||
|
||||
.input-error {
|
||||
border-color: rgb(var(--semantic-error));
|
||||
border-color: var(--danger);
|
||||
}
|
||||
|
||||
.input-error:focus {
|
||||
border-color: rgb(var(--semantic-error));
|
||||
box-shadow: 0 0 0 3px rgb(var(--semantic-error) / 0.1);
|
||||
border-color: var(--danger);
|
||||
box-shadow: 0 0 0 3px rgba(229, 72, 77, 0.12);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -383,8 +455,8 @@ body {
|
||||
@layer components {
|
||||
.card {
|
||||
@apply rounded-lg p-4;
|
||||
background-color: rgb(var(--surface-0));
|
||||
border: 1px solid rgb(var(--border-default));
|
||||
background-color: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
@@ -398,7 +470,7 @@ body {
|
||||
}
|
||||
|
||||
.card-interactive:hover {
|
||||
border-color: rgb(var(--border-strong));
|
||||
border-color: var(--muted);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
}
|
||||
@@ -412,33 +484,33 @@ body {
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
background-color: rgb(var(--semantic-success-light));
|
||||
color: rgb(var(--semantic-success-dark));
|
||||
background-color: rgba(20, 184, 166, 0.15);
|
||||
color: var(--ms-teal-400);
|
||||
}
|
||||
|
||||
.badge-warning {
|
||||
background-color: rgb(var(--semantic-warning-light));
|
||||
color: rgb(var(--semantic-warning-dark));
|
||||
background-color: rgba(245, 158, 11, 0.15);
|
||||
color: var(--ms-amber-400);
|
||||
}
|
||||
|
||||
.badge-error {
|
||||
background-color: rgb(var(--semantic-error-light));
|
||||
color: rgb(var(--semantic-error-dark));
|
||||
background-color: rgba(229, 72, 77, 0.15);
|
||||
color: var(--ms-red-400);
|
||||
}
|
||||
|
||||
.badge-info {
|
||||
background-color: rgb(var(--semantic-info-light));
|
||||
color: rgb(var(--semantic-info-dark));
|
||||
background-color: rgba(47, 128, 255, 0.15);
|
||||
color: var(--ms-blue-400);
|
||||
}
|
||||
|
||||
.badge-neutral {
|
||||
background-color: rgb(var(--surface-2));
|
||||
color: rgb(var(--text-secondary));
|
||||
background-color: var(--surface-2);
|
||||
color: var(--text-2);
|
||||
}
|
||||
|
||||
.badge-primary {
|
||||
background-color: rgb(var(--accent-primary-light));
|
||||
color: rgb(var(--accent-primary));
|
||||
background-color: rgba(47, 128, 255, 0.15);
|
||||
color: var(--primary-l);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -451,26 +523,29 @@ body {
|
||||
}
|
||||
|
||||
.status-dot-success {
|
||||
background-color: rgb(var(--semantic-success));
|
||||
background-color: var(--success);
|
||||
box-shadow: 0 0 5px var(--success);
|
||||
}
|
||||
|
||||
.status-dot-warning {
|
||||
background-color: rgb(var(--semantic-warning));
|
||||
background-color: var(--warn);
|
||||
box-shadow: 0 0 5px var(--warn);
|
||||
}
|
||||
|
||||
.status-dot-error {
|
||||
background-color: rgb(var(--semantic-error));
|
||||
background-color: var(--danger);
|
||||
box-shadow: 0 0 5px var(--danger);
|
||||
}
|
||||
|
||||
.status-dot-info {
|
||||
background-color: rgb(var(--semantic-info));
|
||||
background-color: var(--primary);
|
||||
box-shadow: 0 0 5px var(--primary);
|
||||
}
|
||||
|
||||
.status-dot-neutral {
|
||||
background-color: rgb(var(--text-muted));
|
||||
background-color: var(--muted);
|
||||
}
|
||||
|
||||
/* Pulsing indicator for live/active status */
|
||||
.status-dot-pulse {
|
||||
@apply relative;
|
||||
}
|
||||
@@ -489,12 +564,12 @@ body {
|
||||
@layer components {
|
||||
.kbd {
|
||||
@apply inline-flex items-center justify-center rounded px-1.5 py-0.5 text-xs font-medium;
|
||||
background-color: rgb(var(--surface-2));
|
||||
border: 1px solid rgb(var(--border-default));
|
||||
color: rgb(var(--text-tertiary));
|
||||
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
|
||||
background-color: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--muted);
|
||||
font-family: var(--mono);
|
||||
min-width: 1.5rem;
|
||||
box-shadow: 0 1px 0 rgb(var(--border-strong));
|
||||
box-shadow: 0 1px 0 var(--border);
|
||||
}
|
||||
|
||||
.kbd-group {
|
||||
@@ -512,13 +587,13 @@ body {
|
||||
|
||||
.table-pro thead {
|
||||
@apply sticky top-0;
|
||||
background-color: rgb(var(--surface-1));
|
||||
border-bottom: 1px solid rgb(var(--border-default));
|
||||
background-color: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.table-pro th {
|
||||
@apply px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider;
|
||||
color: rgb(var(--text-tertiary));
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.table-pro th.sortable {
|
||||
@@ -526,16 +601,16 @@ body {
|
||||
}
|
||||
|
||||
.table-pro th.sortable:hover {
|
||||
color: rgb(var(--text-primary));
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.table-pro tbody tr {
|
||||
border-bottom: 1px solid rgb(var(--border-subtle));
|
||||
border-bottom: 1px solid var(--border);
|
||||
transition: background-color 0.1s ease;
|
||||
}
|
||||
|
||||
.table-pro tbody tr:hover {
|
||||
background-color: rgb(var(--surface-1));
|
||||
background-color: var(--surface);
|
||||
}
|
||||
|
||||
.table-pro td {
|
||||
@@ -555,9 +630,9 @@ body {
|
||||
@apply animate-pulse rounded;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgb(var(--surface-2)) 0%,
|
||||
rgb(var(--surface-1)) 50%,
|
||||
rgb(var(--surface-2)) 100%
|
||||
var(--surface) 0%,
|
||||
var(--surface-2) 50%,
|
||||
var(--surface) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
}
|
||||
@@ -590,15 +665,16 @@ body {
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
@apply relative max-h-[90vh] w-full max-w-lg overflow-y-auto rounded-lg;
|
||||
background-color: rgb(var(--surface-0));
|
||||
border: 1px solid rgb(var(--border-default));
|
||||
@apply relative max-h-[90vh] w-full max-w-lg overflow-y-auto;
|
||||
background-color: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-lg);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
@apply flex items-center justify-between p-4 border-b;
|
||||
border-color: rgb(var(--border-default));
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
@@ -607,7 +683,7 @@ body {
|
||||
|
||||
.modal-footer {
|
||||
@apply flex items-center justify-end gap-3 p-4 border-t;
|
||||
border-color: rgb(var(--border-default));
|
||||
border-color: var(--border);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -617,9 +693,10 @@ body {
|
||||
@layer components {
|
||||
.tooltip {
|
||||
@apply absolute z-50 rounded px-2 py-1 text-xs font-medium;
|
||||
background-color: rgb(var(--text-primary));
|
||||
color: rgb(var(--color-background));
|
||||
background-color: var(--text);
|
||||
color: var(--bg);
|
||||
box-shadow: var(--shadow-md);
|
||||
border-radius: var(--r-sm);
|
||||
}
|
||||
|
||||
.tooltip::before {
|
||||
@@ -630,7 +707,7 @@ body {
|
||||
|
||||
.tooltip-top::before {
|
||||
@apply left-1/2 top-full -translate-x-1/2;
|
||||
border-top-color: rgb(var(--text-primary));
|
||||
border-top-color: var(--text);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -680,12 +757,10 @@ body {
|
||||
animation: scaleIn 0.15s ease-out;
|
||||
}
|
||||
|
||||
/* Message animation - subtle for chat */
|
||||
.message-animate {
|
||||
animation: slideIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
/* Menu dropdown animation */
|
||||
.animate-menu-enter {
|
||||
animation: scaleIn 0.1s ease-out;
|
||||
}
|
||||
@@ -710,13 +785,8 @@ body {
|
||||
----------------------------------------------------------------------------- */
|
||||
@media (prefers-contrast: high) {
|
||||
:root {
|
||||
--border-default: 100 116 139;
|
||||
--border-strong: 71 85 105;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--border-default: 148 163 184;
|
||||
--border-strong: 203 213 225;
|
||||
--border: #4a5a78;
|
||||
--muted: #a0b0cc;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,18 +1,56 @@
|
||||
import type { Metadata } from "next";
|
||||
import type { ReactNode } from "react";
|
||||
import { Outfit, Fira_Code } from "next/font/google";
|
||||
import { AuthProvider } from "@/lib/auth/auth-context";
|
||||
import { ErrorBoundary } from "@/components/error-boundary";
|
||||
import { ThemeProvider } from "@/providers/ThemeProvider";
|
||||
import "./globals.css";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Mosaic Stack",
|
||||
description: "Mosaic Stack Web Application",
|
||||
};
|
||||
|
||||
const outfit = Outfit({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-outfit",
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
const firaCode = Fira_Code({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-fira-code",
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
/**
|
||||
* Runtime env vars injected as a synchronous script so client-side modules
|
||||
* can read them before React hydration. This allows Docker env vars to
|
||||
* override the build-time baked NEXT_PUBLIC_* values.
|
||||
*/
|
||||
function runtimeEnvScript(): string {
|
||||
const env: Record<string, string> = {};
|
||||
for (const key of [
|
||||
"NEXT_PUBLIC_API_URL",
|
||||
"NEXT_PUBLIC_ORCHESTRATOR_URL",
|
||||
"NEXT_PUBLIC_AUTH_MODE",
|
||||
]) {
|
||||
const value = process.env[key];
|
||||
if (value) {
|
||||
env[key] = value;
|
||||
}
|
||||
}
|
||||
return `window.__MOSAIC_ENV__=${JSON.stringify(env)};`;
|
||||
}
|
||||
|
||||
export default function RootLayout({ children }: { children: ReactNode }): React.JSX.Element {
|
||||
return (
|
||||
<html lang="en">
|
||||
<html lang="en" className={`${outfit.variable} ${firaCode.variable}`}>
|
||||
<head>
|
||||
<script dangerouslySetInnerHTML={{ __html: runtimeEnvScript() }} />
|
||||
</head>
|
||||
<body>
|
||||
<ThemeProvider>
|
||||
<ErrorBoundary>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { AuthDivider } from "./AuthDivider";
|
||||
describe("AuthDivider", (): void => {
|
||||
it("should render with default text", (): void => {
|
||||
render(<AuthDivider />);
|
||||
expect(screen.getByText("or continue with email")).toBeInTheDocument();
|
||||
expect(screen.getByText("or continue with")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render with custom text", (): void => {
|
||||
@@ -13,10 +13,10 @@ describe("AuthDivider", (): void => {
|
||||
expect(screen.getByText("or sign up")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render a horizontal divider line", (): void => {
|
||||
it("should render horizontal divider lines", (): void => {
|
||||
const { container } = render(<AuthDivider />);
|
||||
const line = container.querySelector("span.border-t");
|
||||
expect(line).toBeInTheDocument();
|
||||
const lines = container.querySelectorAll("[aria-hidden='true'].h-px");
|
||||
expect(lines.length).toBe(2);
|
||||
});
|
||||
|
||||
it("should apply uppercase styling to text", (): void => {
|
||||
|
||||
@@ -1,18 +1,2 @@
|
||||
interface AuthDividerProps {
|
||||
text?: string;
|
||||
}
|
||||
|
||||
export function AuthDivider({
|
||||
text = "or continue with email",
|
||||
}: AuthDividerProps): React.ReactElement {
|
||||
return (
|
||||
<div className="relative my-6">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<span className="w-full border-t border-slate-200" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-white px-2 text-slate-500">{text}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export { AuthDivider } from "@mosaic/ui";
|
||||
export type { AuthDividerProps } from "@mosaic/ui";
|
||||
|
||||
@@ -18,17 +18,10 @@ describe("AuthErrorBanner", (): void => {
|
||||
expect(alert).toHaveAttribute("aria-live", "polite");
|
||||
});
|
||||
|
||||
it("should render the info icon, not a warning icon", (): void => {
|
||||
it("should render an icon", (): void => {
|
||||
const { container } = render(<AuthErrorBanner message="Test message" />);
|
||||
// Info icon from lucide-react renders as an SVG
|
||||
const svgs = container.querySelectorAll("svg");
|
||||
expect(svgs.length).toBeGreaterThanOrEqual(1);
|
||||
// The container should use blue styling, not red/yellow
|
||||
const alert = screen.getByRole("alert");
|
||||
expect(alert.className).toContain("bg-blue-50");
|
||||
expect(alert.className).toContain("text-blue-700");
|
||||
expect(alert.className).not.toContain("red");
|
||||
expect(alert.className).not.toContain("yellow");
|
||||
});
|
||||
|
||||
it("should render dismiss button when onDismiss is provided", (): void => {
|
||||
@@ -54,14 +47,6 @@ describe("AuthErrorBanner", (): void => {
|
||||
expect(onDismiss).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should use blue info styling, not red or alarming colors", (): void => {
|
||||
render(<AuthErrorBanner message="Test" />);
|
||||
const alert = screen.getByRole("alert");
|
||||
expect(alert.className).toContain("bg-blue-50");
|
||||
expect(alert.className).toContain("border-blue-200");
|
||||
expect(alert.className).toContain("text-blue-700");
|
||||
});
|
||||
|
||||
it("should render all PDA-friendly error messages", (): void => {
|
||||
const messages = [
|
||||
"Authentication paused. Please try again when ready.",
|
||||
|
||||
@@ -13,7 +13,7 @@ export function AuthErrorBanner({ message, onDismiss }: AuthErrorBannerProps): R
|
||||
<div
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
className="bg-blue-50 border border-blue-200 text-blue-700 rounded-lg p-4 flex items-start gap-3"
|
||||
className="flex items-start gap-3 rounded-lg border border-[#f06a6f]/55 bg-[#fff1f2] p-4 text-[#9f1239] dark:border-[#e5484d]/55 dark:bg-[#3a111b]/70 dark:text-[#fecdd3]"
|
||||
>
|
||||
<Info className="h-5 w-5 flex-shrink-0 mt-0.5" aria-hidden="true" />
|
||||
<span className="flex-1 text-sm">{message}</span>
|
||||
@@ -21,7 +21,7 @@ export function AuthErrorBanner({ message, onDismiss }: AuthErrorBannerProps): R
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDismiss}
|
||||
className="flex-shrink-0 text-blue-500 hover:text-blue-700 transition-colors"
|
||||
className="flex-shrink-0 text-[#be123c] transition-colors hover:text-[#881337] dark:text-[#fda4af] dark:hover:text-[#ffe4e6]"
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
<X className="h-4 w-4" aria-hidden="true" />
|
||||
|
||||
@@ -9,12 +9,14 @@ export interface LoginFormProps {
|
||||
onSubmit: (email: string, password: string) => void | Promise<void>;
|
||||
isLoading?: boolean;
|
||||
error?: string | null;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function LoginForm({
|
||||
onSubmit,
|
||||
isLoading = false,
|
||||
error = null,
|
||||
disabled = false,
|
||||
}: LoginFormProps): ReactElement {
|
||||
const emailRef = useRef<HTMLInputElement>(null);
|
||||
const [email, setEmail] = useState("");
|
||||
@@ -77,7 +79,10 @@ export function LoginForm({
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label htmlFor="login-email" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
<label
|
||||
htmlFor="login-email"
|
||||
className="mb-2 block text-[0.72rem] font-semibold uppercase tracking-[0.08em] text-[#2f3b52] dark:text-[#c5d0e6]"
|
||||
>
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
@@ -91,13 +96,17 @@ export function LoginForm({
|
||||
validateEmail(e.target.value);
|
||||
}
|
||||
}}
|
||||
disabled={isLoading}
|
||||
disabled={isLoading || disabled}
|
||||
autoComplete="email"
|
||||
className={[
|
||||
"w-full px-3 py-2 border rounded-md",
|
||||
"focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors",
|
||||
emailError ? "border-blue-400" : "border-gray-300",
|
||||
isLoading ? "opacity-50" : "",
|
||||
"w-full rounded-lg border px-3.5 py-2.5 text-sm",
|
||||
"bg-[#f8faff]/90 text-[#0f141d] placeholder:text-[#5a6a87]",
|
||||
"transition-colors focus:outline-none focus:ring-2 focus:ring-[#56a0ff]/25",
|
||||
"dark:bg-[#0f141d]/80 dark:text-[#eef3ff] dark:placeholder:text-[#8f9db7]",
|
||||
emailError
|
||||
? "border-[#f06a6f] focus:border-[#e5484d]"
|
||||
: "border-[#b8c4de] focus:border-[#2f80ff] dark:border-[#2f3b52] dark:focus:border-[#56a0ff]",
|
||||
isLoading || disabled ? "opacity-50" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
@@ -105,14 +114,21 @@ export function LoginForm({
|
||||
aria-describedby={emailError ? "login-email-error" : undefined}
|
||||
/>
|
||||
{emailError && (
|
||||
<p id="login-email-error" className="mt-1 text-sm text-blue-600" role="alert">
|
||||
<p
|
||||
id="login-email-error"
|
||||
className="mt-1 text-sm text-[#b91c1c] dark:text-[#fda4af]"
|
||||
role="alert"
|
||||
>
|
||||
{emailError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="login-password" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
<label
|
||||
htmlFor="login-password"
|
||||
className="mb-2 block text-[0.72rem] font-semibold uppercase tracking-[0.08em] text-[#2f3b52] dark:text-[#c5d0e6]"
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
@@ -125,13 +141,17 @@ export function LoginForm({
|
||||
validatePassword(e.target.value);
|
||||
}
|
||||
}}
|
||||
disabled={isLoading}
|
||||
disabled={isLoading || disabled}
|
||||
autoComplete="current-password"
|
||||
className={[
|
||||
"w-full px-3 py-2 border rounded-md",
|
||||
"focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors",
|
||||
passwordError ? "border-blue-400" : "border-gray-300",
|
||||
isLoading ? "opacity-50" : "",
|
||||
"w-full rounded-lg border px-3.5 py-2.5 text-sm",
|
||||
"bg-[#f8faff]/90 text-[#0f141d] placeholder:text-[#5a6a87]",
|
||||
"transition-colors focus:outline-none focus:ring-2 focus:ring-[#56a0ff]/25",
|
||||
"dark:bg-[#0f141d]/80 dark:text-[#eef3ff] dark:placeholder:text-[#8f9db7]",
|
||||
passwordError
|
||||
? "border-[#f06a6f] focus:border-[#e5484d]"
|
||||
: "border-[#b8c4de] focus:border-[#2f80ff] dark:border-[#2f3b52] dark:focus:border-[#56a0ff]",
|
||||
isLoading || disabled ? "opacity-50" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
@@ -139,7 +159,11 @@ export function LoginForm({
|
||||
aria-describedby={passwordError ? "login-password-error" : undefined}
|
||||
/>
|
||||
{passwordError && (
|
||||
<p id="login-password-error" className="mt-1 text-sm text-blue-600" role="alert">
|
||||
<p
|
||||
id="login-password-error"
|
||||
className="mt-1 text-sm text-[#b91c1c] dark:text-[#fda4af]"
|
||||
role="alert"
|
||||
>
|
||||
{passwordError}
|
||||
</p>
|
||||
)}
|
||||
@@ -147,13 +171,13 @@ export function LoginForm({
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
disabled={isLoading || disabled}
|
||||
className={[
|
||||
"w-full inline-flex items-center justify-center gap-2",
|
||||
"rounded-md px-4 py-2 text-base font-medium",
|
||||
"bg-blue-600 text-white hover:bg-blue-700",
|
||||
"transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500",
|
||||
isLoading ? "opacity-50 pointer-events-none" : "",
|
||||
"w-full inline-flex items-center justify-center gap-2 rounded-lg px-4 py-3 text-sm font-semibold text-white",
|
||||
"bg-[linear-gradient(135deg,#2f80ff,#8b5cf6)]",
|
||||
"transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-[#56a0ff]/60",
|
||||
"hover:-translate-y-0.5 hover:shadow-[0_10px_30px_rgba(47,128,255,0.38)]",
|
||||
isLoading || disabled ? "opacity-50 pointer-events-none" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
|
||||
@@ -13,10 +13,12 @@ export interface OAuthButtonProps {
|
||||
|
||||
export function OAuthButton({
|
||||
providerName,
|
||||
providerId,
|
||||
onClick,
|
||||
isLoading = false,
|
||||
disabled = false,
|
||||
}: OAuthButtonProps): ReactElement {
|
||||
const accentColor = resolveProviderAccent(providerId);
|
||||
const isDisabled = disabled || isLoading;
|
||||
|
||||
return (
|
||||
@@ -27,10 +29,12 @@ export function OAuthButton({
|
||||
disabled={isDisabled}
|
||||
aria-label={isLoading ? "Connecting" : `Continue with ${providerName}`}
|
||||
className={[
|
||||
"w-full inline-flex items-center justify-center gap-2",
|
||||
"rounded-md px-4 py-2 text-base font-medium",
|
||||
"bg-blue-600 text-white hover:bg-blue-700",
|
||||
"transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500",
|
||||
"w-full inline-flex items-center justify-center gap-2 rounded-lg",
|
||||
"border border-[#b8c4de] bg-[#f8faff]/90 px-4 py-3 text-sm font-semibold text-[#2f3b52]",
|
||||
"transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-[#56a0ff]/60",
|
||||
"hover:border-[#2f80ff] hover:bg-[#dde4f2] hover:text-[#0f141d]",
|
||||
"dark:border-[#2f3b52] dark:bg-[#0f141d]/75 dark:text-[#c5d0e6]",
|
||||
"dark:hover:border-[#2f80ff] dark:hover:bg-[#232d3f] dark:hover:text-[#eef3ff]",
|
||||
isDisabled ? "opacity-50 pointer-events-none" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
@@ -42,8 +46,33 @@ export function OAuthButton({
|
||||
<span>Connecting...</span>
|
||||
</>
|
||||
) : (
|
||||
<span>Continue with {providerName}</span>
|
||||
<>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="h-2 w-2 rounded-full"
|
||||
style={{ backgroundColor: accentColor }}
|
||||
/>
|
||||
<span>Continue with {providerName}</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function resolveProviderAccent(providerId: string): string {
|
||||
const normalized = providerId.toLowerCase();
|
||||
|
||||
if (normalized.includes("github")) {
|
||||
return "#8b5cf6";
|
||||
}
|
||||
|
||||
if (normalized.includes("google")) {
|
||||
return "#e5484d";
|
||||
}
|
||||
|
||||
if (normalized.includes("ldap")) {
|
||||
return "#14b8a6";
|
||||
}
|
||||
|
||||
return "#2f80ff";
|
||||
}
|
||||
|
||||
169
apps/web/src/components/dashboard/ActivityFeed.tsx
Normal file
169
apps/web/src/components/dashboard/ActivityFeed.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
import type { ReactElement } from "react";
|
||||
import { Card, SectionHeader, Badge } from "@mosaic/ui";
|
||||
|
||||
type BadgeVariantType =
|
||||
| "badge-amber"
|
||||
| "badge-red"
|
||||
| "badge-teal"
|
||||
| "badge-blue"
|
||||
| "badge-muted"
|
||||
| "badge-purple"
|
||||
| "badge-pulse";
|
||||
|
||||
interface ActivityItem {
|
||||
id: string;
|
||||
icon: string;
|
||||
iconBg: string;
|
||||
title: string;
|
||||
highlight: string;
|
||||
rest: string;
|
||||
timestamp: string;
|
||||
badge?: {
|
||||
text: string;
|
||||
variant: BadgeVariantType;
|
||||
};
|
||||
}
|
||||
|
||||
const activityItems: ActivityItem[] = [
|
||||
{
|
||||
id: "act-1",
|
||||
icon: "✓",
|
||||
iconBg: "rgba(20,184,166,0.15)",
|
||||
title: "",
|
||||
highlight: "planner-agent",
|
||||
rest: " completed task analysis for infra-refactor",
|
||||
timestamp: "2m ago",
|
||||
},
|
||||
{
|
||||
id: "act-2",
|
||||
icon: "⚠",
|
||||
iconBg: "rgba(245,158,11,0.15)",
|
||||
title: "",
|
||||
highlight: "executor-agent",
|
||||
rest: " hit rate limit on Terraform API",
|
||||
timestamp: "5m ago",
|
||||
badge: { text: "warn", variant: "badge-amber" },
|
||||
},
|
||||
{
|
||||
id: "act-3",
|
||||
icon: "↑",
|
||||
iconBg: "rgba(47,128,255,0.15)",
|
||||
title: "",
|
||||
highlight: "ORCH-002",
|
||||
rest: " session started for api-v3-migration",
|
||||
timestamp: "12m ago",
|
||||
},
|
||||
{
|
||||
id: "act-4",
|
||||
icon: "✗",
|
||||
iconBg: "rgba(229,72,77,0.15)",
|
||||
title: "",
|
||||
highlight: "migrator-agent",
|
||||
rest: " failed to connect to staging database",
|
||||
timestamp: "18m ago",
|
||||
badge: { text: "error", variant: "badge-red" },
|
||||
},
|
||||
{
|
||||
id: "act-5",
|
||||
icon: "✓",
|
||||
iconBg: "rgba(20,184,166,0.15)",
|
||||
title: "",
|
||||
highlight: "reviewer-agent",
|
||||
rest: " approved PR #214 in infra-refactor",
|
||||
timestamp: "34m ago",
|
||||
},
|
||||
{
|
||||
id: "act-6",
|
||||
icon: "⟳",
|
||||
iconBg: "rgba(139,92,246,0.15)",
|
||||
title: "Token budget reset for ",
|
||||
highlight: "gpt-4o",
|
||||
rest: " model",
|
||||
timestamp: "1h ago",
|
||||
},
|
||||
{
|
||||
id: "act-7",
|
||||
icon: "★",
|
||||
iconBg: "rgba(20,184,166,0.15)",
|
||||
title: "Project ",
|
||||
highlight: "data-pipeline",
|
||||
rest: " marked as completed",
|
||||
timestamp: "2h ago",
|
||||
},
|
||||
];
|
||||
|
||||
interface ActivityItemRowProps {
|
||||
item: ActivityItem;
|
||||
}
|
||||
|
||||
function ActivityItemRow({ item }: ActivityItemRowProps): ReactElement {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "flex-start",
|
||||
gap: 10,
|
||||
padding: "8px 0",
|
||||
borderBottom: "1px solid var(--border)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 6,
|
||||
flexShrink: 0,
|
||||
background: item.iconBg,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: "0.8rem",
|
||||
color: "var(--text)",
|
||||
}}
|
||||
>
|
||||
{item.icon}
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "0.8rem",
|
||||
color: "var(--text-2)",
|
||||
lineHeight: 1.4,
|
||||
}}
|
||||
>
|
||||
{item.title}
|
||||
<strong style={{ color: "var(--text)" }}>{item.highlight}</strong>
|
||||
{item.rest}
|
||||
{item.badge !== undefined && (
|
||||
<Badge variant={item.badge.variant} style={{ marginLeft: 6 }}>
|
||||
{item.badge.text}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "0.7rem",
|
||||
fontFamily: "var(--mono)",
|
||||
color: "var(--muted)",
|
||||
marginTop: 2,
|
||||
}}
|
||||
>
|
||||
{item.timestamp}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ActivityFeed(): ReactElement {
|
||||
return (
|
||||
<Card>
|
||||
<SectionHeader title="Activity Feed" subtitle="Recent agent events" />
|
||||
<div>
|
||||
{activityItems.map((item) => (
|
||||
<ActivityItemRow key={item.id} item={item} />
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
45
apps/web/src/components/dashboard/DashboardMetrics.tsx
Normal file
45
apps/web/src/components/dashboard/DashboardMetrics.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { ReactElement } from "react";
|
||||
import { MetricsStrip, type MetricCell } from "@mosaic/ui";
|
||||
|
||||
const cells: MetricCell[] = [
|
||||
{
|
||||
label: "Active Agents",
|
||||
value: "47",
|
||||
color: "var(--ms-blue-400)",
|
||||
trend: { direction: "up", text: "↑ +3 from yesterday" },
|
||||
},
|
||||
{
|
||||
label: "Tasks Completed",
|
||||
value: "1,284",
|
||||
color: "var(--ms-teal-400)",
|
||||
trend: { direction: "up", text: "↑ +128 today" },
|
||||
},
|
||||
{
|
||||
label: "Avg Response Time",
|
||||
value: "2.4s",
|
||||
color: "var(--ms-purple-400)",
|
||||
trend: { direction: "down", text: "↓ -0.3s improved" },
|
||||
},
|
||||
{
|
||||
label: "Token Usage",
|
||||
value: "3.2M",
|
||||
color: "var(--ms-amber-400)",
|
||||
trend: { direction: "neutral", text: "78% of budget" },
|
||||
},
|
||||
{
|
||||
label: "Error Rate",
|
||||
value: "0.4%",
|
||||
color: "var(--ms-red-400)",
|
||||
trend: { direction: "down", text: "↓ -0.1% improved" },
|
||||
},
|
||||
{
|
||||
label: "Active Projects",
|
||||
value: "8",
|
||||
color: "var(--ms-cyan-500)",
|
||||
trend: { direction: "neutral", text: "2 deploying" },
|
||||
},
|
||||
];
|
||||
|
||||
export function DashboardMetrics(): ReactElement {
|
||||
return <MetricsStrip cells={cells} />;
|
||||
}
|
||||
241
apps/web/src/components/dashboard/OrchestratorSessions.tsx
Normal file
241
apps/web/src/components/dashboard/OrchestratorSessions.tsx
Normal file
@@ -0,0 +1,241 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import type { ReactElement } from "react";
|
||||
import { Card, SectionHeader, Badge, Dot } from "@mosaic/ui";
|
||||
|
||||
interface AgentNode {
|
||||
id: string;
|
||||
initials: string;
|
||||
avatarColor: string;
|
||||
name: string;
|
||||
task: string;
|
||||
status: "teal" | "blue" | "amber" | "red" | "muted";
|
||||
}
|
||||
|
||||
interface OrchestratorSession {
|
||||
id: string;
|
||||
orchId: string;
|
||||
name: string;
|
||||
badge: string;
|
||||
badgeVariant:
|
||||
| "badge-teal"
|
||||
| "badge-amber"
|
||||
| "badge-red"
|
||||
| "badge-blue"
|
||||
| "badge-muted"
|
||||
| "badge-purple"
|
||||
| "badge-pulse";
|
||||
duration: string;
|
||||
agents: AgentNode[];
|
||||
}
|
||||
|
||||
const sessions: OrchestratorSession[] = [
|
||||
{
|
||||
id: "s1",
|
||||
orchId: "ORCH-001",
|
||||
name: "infra-refactor",
|
||||
badge: "running",
|
||||
badgeVariant: "badge-teal",
|
||||
duration: "2h 14m",
|
||||
agents: [
|
||||
{
|
||||
id: "a1",
|
||||
initials: "PL",
|
||||
avatarColor: "rgba(47,128,255,0.15)",
|
||||
name: "planner-agent",
|
||||
task: "Analyzing network topology",
|
||||
status: "blue",
|
||||
},
|
||||
{
|
||||
id: "a2",
|
||||
initials: "EX",
|
||||
avatarColor: "rgba(20,184,166,0.15)",
|
||||
name: "executor-agent",
|
||||
task: "Applying Terraform modules",
|
||||
status: "teal",
|
||||
},
|
||||
{
|
||||
id: "a3",
|
||||
initials: "QA",
|
||||
avatarColor: "rgba(245,158,11,0.15)",
|
||||
name: "reviewer-agent",
|
||||
task: "Waiting for executor output",
|
||||
status: "amber",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "s2",
|
||||
orchId: "ORCH-002",
|
||||
name: "api-v3-migration",
|
||||
badge: "running",
|
||||
badgeVariant: "badge-teal",
|
||||
duration: "45m",
|
||||
agents: [
|
||||
{
|
||||
id: "a4",
|
||||
initials: "MG",
|
||||
avatarColor: "rgba(139,92,246,0.15)",
|
||||
name: "migrator-agent",
|
||||
task: "Rewriting endpoint handlers",
|
||||
status: "blue",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
interface AgentNodeItemProps {
|
||||
agent: AgentNode;
|
||||
}
|
||||
|
||||
function AgentNodeItem({ agent }: AgentNodeItemProps): ReactElement {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
|
||||
return (
|
||||
<div
|
||||
onMouseEnter={(): void => {
|
||||
setHovered(true);
|
||||
}}
|
||||
onMouseLeave={(): void => {
|
||||
setHovered(false);
|
||||
}}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
padding: "7px 10px",
|
||||
borderRadius: "var(--r-sm)",
|
||||
border: `1px solid ${hovered ? "var(--ms-border-700)" : "var(--border)"}`,
|
||||
background: hovered ? "var(--surface)" : "var(--bg-mid)",
|
||||
transition: "border-color 0.15s, background 0.15s",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 30,
|
||||
height: 30,
|
||||
borderRadius: 6,
|
||||
flexShrink: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontWeight: 700,
|
||||
fontFamily: "var(--mono)",
|
||||
fontSize: "0.7rem",
|
||||
background: agent.avatarColor,
|
||||
color: "var(--text)",
|
||||
}}
|
||||
>
|
||||
{agent.initials}
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "0.8rem",
|
||||
fontWeight: 600,
|
||||
color: "var(--text)",
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
>
|
||||
{agent.name}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "0.72rem",
|
||||
color: "var(--muted)",
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
>
|
||||
{agent.task}
|
||||
</div>
|
||||
</div>
|
||||
<Dot variant={agent.status} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface OrchCardProps {
|
||||
session: OrchestratorSession;
|
||||
}
|
||||
|
||||
function OrchCard({ session }: OrchCardProps): ReactElement {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
background: "var(--bg-mid)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "var(--r-md)",
|
||||
padding: "12px 14px",
|
||||
marginBottom: 10,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
marginBottom: 10,
|
||||
}}
|
||||
>
|
||||
<Dot variant="teal" />
|
||||
<span
|
||||
style={{
|
||||
fontFamily: "var(--mono)",
|
||||
fontSize: "0.75rem",
|
||||
color: "var(--ms-purple-400)",
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
{session.orchId}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: "0.8rem",
|
||||
fontWeight: 600,
|
||||
color: "var(--text)",
|
||||
}}
|
||||
>
|
||||
{session.name}
|
||||
</span>
|
||||
<Badge variant={session.badgeVariant}>{session.badge}</Badge>
|
||||
<span
|
||||
style={{
|
||||
marginLeft: "auto",
|
||||
fontSize: "0.72rem",
|
||||
fontFamily: "var(--mono)",
|
||||
color: "var(--muted)",
|
||||
}}
|
||||
>
|
||||
{session.duration}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
{session.agents.map((agent) => (
|
||||
<AgentNodeItem key={agent.id} agent={agent} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function OrchestratorSessions(): ReactElement {
|
||||
return (
|
||||
<Card>
|
||||
<SectionHeader
|
||||
title="Active Orchestrator Sessions"
|
||||
subtitle="3 of 8 projects running"
|
||||
actions={<Badge variant="badge-teal">3 active</Badge>}
|
||||
/>
|
||||
<div>
|
||||
{sessions.map((session) => (
|
||||
<OrchCard key={session.id} session={session} />
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
96
apps/web/src/components/dashboard/QuickActions.tsx
Normal file
96
apps/web/src/components/dashboard/QuickActions.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import type { ReactElement } from "react";
|
||||
import { Card, SectionHeader } from "@mosaic/ui";
|
||||
|
||||
interface QuickAction {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
iconBg: string;
|
||||
}
|
||||
|
||||
const actions: QuickAction[] = [
|
||||
{ id: "new-project", label: "New Project", icon: "🚀", iconBg: "rgba(47,128,255,0.15)" },
|
||||
{ id: "spawn-agent", label: "Spawn Agent", icon: "🤖", iconBg: "rgba(139,92,246,0.15)" },
|
||||
{ id: "view-telemetry", label: "View Telemetry", icon: "📊", iconBg: "rgba(20,184,166,0.15)" },
|
||||
{ id: "review-tasks", label: "Review Tasks", icon: "📋", iconBg: "rgba(245,158,11,0.15)" },
|
||||
];
|
||||
|
||||
interface ActionButtonProps {
|
||||
action: QuickAction;
|
||||
}
|
||||
|
||||
function ActionButton({ action }: ActionButtonProps): ReactElement {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onMouseEnter={(): void => {
|
||||
setHovered(true);
|
||||
}}
|
||||
onMouseLeave={(): void => {
|
||||
setHovered(false);
|
||||
}}
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 8,
|
||||
padding: "16px 12px",
|
||||
borderRadius: "var(--r-md)",
|
||||
border: `1px solid ${hovered ? "var(--ms-border-700)" : "var(--border)"}`,
|
||||
background: hovered ? "var(--surface)" : "var(--bg-mid)",
|
||||
cursor: "pointer",
|
||||
transition: "border-color 0.15s, background 0.15s",
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: 6,
|
||||
background: action.iconBg,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: "0.875rem",
|
||||
}}
|
||||
>
|
||||
{action.icon}
|
||||
</div>
|
||||
<span
|
||||
style={{
|
||||
fontSize: "0.8rem",
|
||||
fontWeight: 600,
|
||||
color: "var(--text)",
|
||||
}}
|
||||
>
|
||||
{action.label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function QuickActions(): ReactElement {
|
||||
return (
|
||||
<Card>
|
||||
<SectionHeader title="Quick Actions" />
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr 1fr",
|
||||
gap: 10,
|
||||
}}
|
||||
>
|
||||
{actions.map((action) => (
|
||||
<ActionButton key={action.id} action={action} />
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
98
apps/web/src/components/dashboard/TokenBudget.tsx
Normal file
98
apps/web/src/components/dashboard/TokenBudget.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import type { ReactElement } from "react";
|
||||
import { Card, SectionHeader, ProgressBar, type ProgressBarVariant } from "@mosaic/ui";
|
||||
|
||||
interface ModelBudget {
|
||||
id: string;
|
||||
label: string;
|
||||
usage: string;
|
||||
value: number;
|
||||
variant: ProgressBarVariant;
|
||||
}
|
||||
|
||||
const models: ModelBudget[] = [
|
||||
{
|
||||
id: "sonnet",
|
||||
label: "claude-3-5-sonnet",
|
||||
usage: "2.1M / 3M",
|
||||
value: 70,
|
||||
variant: "blue",
|
||||
},
|
||||
{
|
||||
id: "haiku",
|
||||
label: "claude-3-haiku",
|
||||
usage: "890K / 5M",
|
||||
value: 18,
|
||||
variant: "teal",
|
||||
},
|
||||
{
|
||||
id: "gpt4o",
|
||||
label: "gpt-4o",
|
||||
usage: "320K / 1M",
|
||||
value: 32,
|
||||
variant: "purple",
|
||||
},
|
||||
{
|
||||
id: "llama",
|
||||
label: "local/llama-3.3",
|
||||
usage: "unlimited",
|
||||
value: 55,
|
||||
variant: "amber",
|
||||
},
|
||||
];
|
||||
|
||||
interface ModelRowProps {
|
||||
model: ModelBudget;
|
||||
}
|
||||
|
||||
function ModelRow({ model }: ModelRowProps): ReactElement {
|
||||
return (
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
marginBottom: 5,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: "0.8rem",
|
||||
fontWeight: 600,
|
||||
color: "var(--text-2)",
|
||||
fontFamily: "var(--mono)",
|
||||
}}
|
||||
>
|
||||
{model.label}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: "0.72rem",
|
||||
color: "var(--muted)",
|
||||
fontFamily: "var(--mono)",
|
||||
}}
|
||||
>
|
||||
{model.usage}
|
||||
</span>
|
||||
</div>
|
||||
<ProgressBar
|
||||
value={model.value}
|
||||
variant={model.variant}
|
||||
label={`${model.label} token usage`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TokenBudget(): ReactElement {
|
||||
return (
|
||||
<Card>
|
||||
<SectionHeader title="Token Budget" subtitle="Usage by model" />
|
||||
<div>
|
||||
{models.map((model) => (
|
||||
<ModelRow key={model.id} model={model} />
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -46,8 +46,8 @@ describe("FilterBar", (): void => {
|
||||
it("should debounce search input", async (): Promise<void> => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
// Use a very short debounce to test the behavior without flaky timing
|
||||
render(<FilterBar onFilterChange={mockOnFilterChange} debounceMs={100} />);
|
||||
// Use a debounce long enough that CI environments don't fire it between keystrokes
|
||||
render(<FilterBar onFilterChange={mockOnFilterChange} debounceMs={500} />);
|
||||
|
||||
const searchInput = screen.getByPlaceholderText(/search/i);
|
||||
mockOnFilterChange.mockClear();
|
||||
@@ -71,7 +71,7 @@ describe("FilterBar", (): void => {
|
||||
expect.objectContaining({ search: "test" })
|
||||
);
|
||||
},
|
||||
{ timeout: 200 }
|
||||
{ timeout: 1000 }
|
||||
);
|
||||
|
||||
// Verify it was only called once (debounced)
|
||||
|
||||
647
apps/web/src/components/layout/AppHeader.tsx
Normal file
647
apps/web/src/components/layout/AppHeader.tsx
Normal file
@@ -0,0 +1,647 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useAuth } from "@/lib/auth/auth-context";
|
||||
import { ThemeToggle } from "./ThemeToggle";
|
||||
import { useSidebar } from "./SidebarContext";
|
||||
|
||||
/**
|
||||
* Full-width application header (topbar).
|
||||
* Logo/brand MUST live here — not in the sidebar — per MS15 design spec.
|
||||
* Spans grid-column 1 / -1 in the app shell grid layout.
|
||||
*
|
||||
* Layout (left → right):
|
||||
* [Logo/Brand] [Breadcrumb] [Search] [spacer]
|
||||
* [System Status] [Terminal Toggle] [Notifications] [Theme Toggle] [User Avatar+Dropdown]
|
||||
*/
|
||||
export function AppHeader(): React.JSX.Element {
|
||||
const { user, signOut } = useAuth();
|
||||
const { isMobile, mobileOpen, setMobileOpen, toggleCollapsed } = useSidebar();
|
||||
const pathname = usePathname();
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
const [searchFocused, setSearchFocused] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Close dropdown on outside click
|
||||
const handleOutsideClick = useCallback((event: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setDropdownOpen(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (dropdownOpen) {
|
||||
document.addEventListener("mousedown", handleOutsideClick);
|
||||
} else {
|
||||
document.removeEventListener("mousedown", handleOutsideClick);
|
||||
}
|
||||
return (): void => {
|
||||
document.removeEventListener("mousedown", handleOutsideClick);
|
||||
};
|
||||
}, [dropdownOpen, handleOutsideClick]);
|
||||
|
||||
// Derive breadcrumb segments from pathname
|
||||
const breadcrumbSegments = pathname
|
||||
.split("/")
|
||||
.filter(Boolean)
|
||||
.map((seg) => seg.charAt(0).toUpperCase() + seg.slice(1).replace(/-/g, " "));
|
||||
|
||||
// User initials for avatar fallback
|
||||
const initials = user?.name
|
||||
? user.name
|
||||
.split(" ")
|
||||
.slice(0, 2)
|
||||
.map((part) => part[0])
|
||||
.join("")
|
||||
.toUpperCase()
|
||||
: user?.email
|
||||
? (user.email[0] ?? "?").toUpperCase()
|
||||
: "?";
|
||||
|
||||
const handleHamburgerClick = useCallback((): void => {
|
||||
if (isMobile) {
|
||||
setMobileOpen(!mobileOpen);
|
||||
} else {
|
||||
toggleCollapsed();
|
||||
}
|
||||
}, [isMobile, mobileOpen, setMobileOpen, toggleCollapsed]);
|
||||
|
||||
return (
|
||||
<header className="app-header">
|
||||
{/* ── Hamburger — visible below lg ── */}
|
||||
<button
|
||||
type="button"
|
||||
className="lg:hidden"
|
||||
onClick={handleHamburgerClick}
|
||||
aria-label={mobileOpen ? "Close navigation menu" : "Open navigation menu"}
|
||||
aria-expanded={mobileOpen}
|
||||
aria-controls="app-sidebar"
|
||||
style={{
|
||||
width: 34,
|
||||
height: 34,
|
||||
borderRadius: 6,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: "var(--muted)",
|
||||
background: "none",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
(e.currentTarget as HTMLButtonElement).style.background = "var(--surface)";
|
||||
(e.currentTarget as HTMLButtonElement).style.color = "var(--text)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
(e.currentTarget as HTMLButtonElement).style.background = "none";
|
||||
(e.currentTarget as HTMLButtonElement).style.color = "var(--muted)";
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M2 4h12M2 8h12M2 12h12" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* ── Brand / Logo ── */}
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center gap-2 flex-shrink-0"
|
||||
aria-label="Mosaic Stack home"
|
||||
>
|
||||
{/* Mosaic logo mark: four colored squares + center dot */}
|
||||
<div
|
||||
className="relative flex-shrink-0"
|
||||
style={{ width: 28, height: 28 }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span
|
||||
className="absolute rounded-sm"
|
||||
style={{ top: 0, left: 0, width: 11, height: 11, background: "var(--ms-blue-500)" }}
|
||||
/>
|
||||
<span
|
||||
className="absolute rounded-sm"
|
||||
style={{ top: 0, right: 0, width: 11, height: 11, background: "var(--ms-purple-500)" }}
|
||||
/>
|
||||
<span
|
||||
className="absolute rounded-sm"
|
||||
style={{
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
width: 11,
|
||||
height: 11,
|
||||
background: "var(--ms-teal-500)",
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
className="absolute rounded-sm"
|
||||
style={{
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
width: 11,
|
||||
height: 11,
|
||||
background: "var(--ms-amber-500)",
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
className="absolute rounded-full"
|
||||
style={{
|
||||
top: "50%",
|
||||
left: "50%",
|
||||
transform: "translate(-50%, -50%)",
|
||||
width: 8,
|
||||
height: 8,
|
||||
background: "var(--ms-pink-500)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<span
|
||||
className="text-sm font-bold"
|
||||
style={{
|
||||
background: "linear-gradient(135deg, var(--ms-blue-400), var(--ms-purple-500))",
|
||||
backgroundClip: "text",
|
||||
WebkitBackgroundClip: "text",
|
||||
WebkitTextFillColor: "transparent",
|
||||
letterSpacing: "-0.02em",
|
||||
}}
|
||||
>
|
||||
Mosaic Stack
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
{/* ── Breadcrumb ── */}
|
||||
<nav
|
||||
aria-label="Breadcrumb"
|
||||
className="hidden sm:flex items-center"
|
||||
style={{ fontSize: "0.8rem", color: "var(--text-2)", marginLeft: 4 }}
|
||||
>
|
||||
{breadcrumbSegments.length === 0 ? (
|
||||
<span>Dashboard</span>
|
||||
) : (
|
||||
breadcrumbSegments.map((seg, idx) => (
|
||||
<span key={idx} className="flex items-center gap-1">
|
||||
{idx > 0 && <span style={{ color: "var(--muted)", margin: "0 2px" }}>/</span>}
|
||||
<span
|
||||
style={{
|
||||
color: idx === breadcrumbSegments.length - 1 ? "var(--text-2)" : "var(--muted)",
|
||||
}}
|
||||
>
|
||||
{seg}
|
||||
</span>
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
</nav>
|
||||
|
||||
{/* ── Search Bar ── */}
|
||||
<div
|
||||
className="hidden md:flex items-center"
|
||||
style={{
|
||||
flex: 1,
|
||||
maxWidth: 340,
|
||||
marginLeft: 16,
|
||||
gap: 8,
|
||||
background: "var(--surface)",
|
||||
border: `1px solid ${searchFocused ? "var(--primary)" : "var(--border)"}`,
|
||||
borderRadius: 6,
|
||||
padding: "7px 12px",
|
||||
transition: "border-color 0.15s",
|
||||
}}
|
||||
>
|
||||
{/* Search icon */}
|
||||
<svg
|
||||
width="13"
|
||||
height="13"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
style={{ color: "var(--muted)", flexShrink: 0 }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="7" cy="7" r="5" />
|
||||
<path d="M11 11l3 3" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search projects, agents, tasks… (⌘K)"
|
||||
onFocus={() => {
|
||||
setSearchFocused(true);
|
||||
}}
|
||||
onBlur={() => {
|
||||
setSearchFocused(false);
|
||||
}}
|
||||
style={{
|
||||
flex: 1,
|
||||
background: "none",
|
||||
border: "none",
|
||||
outline: "none",
|
||||
color: "var(--text)",
|
||||
fontSize: "0.83rem",
|
||||
fontFamily: "inherit",
|
||||
}}
|
||||
aria-label="Search projects, agents, tasks"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── Spacer ── */}
|
||||
<div style={{ flex: 1 }} />
|
||||
|
||||
{/* ── Right side controls ── */}
|
||||
<div className="flex items-center" style={{ gap: 8 }}>
|
||||
{/* System Status */}
|
||||
<div
|
||||
className="hidden lg:flex items-center"
|
||||
style={{
|
||||
gap: 7,
|
||||
padding: "5px 10px",
|
||||
borderRadius: 6,
|
||||
background: "var(--surface)",
|
||||
border: "1px solid var(--border)",
|
||||
fontSize: "0.75rem",
|
||||
fontFamily: "var(--mono)",
|
||||
}}
|
||||
aria-label="System status: All Systems Operational"
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: "50%",
|
||||
background: "var(--success)",
|
||||
boxShadow: "0 0 5px var(--success)",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span style={{ color: "var(--muted)" }}>All Systems</span>
|
||||
<span style={{ color: "var(--success)" }}>Operational</span>
|
||||
</div>
|
||||
|
||||
{/* Terminal Toggle */}
|
||||
<TerminalToggleButton />
|
||||
|
||||
{/* Notifications */}
|
||||
<button
|
||||
title="Notifications"
|
||||
aria-label="Notifications (1 unread)"
|
||||
style={{
|
||||
width: 34,
|
||||
height: 34,
|
||||
borderRadius: 6,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: "var(--muted)",
|
||||
position: "relative",
|
||||
background: "none",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
(e.currentTarget as HTMLButtonElement).style.background = "var(--surface)";
|
||||
(e.currentTarget as HTMLButtonElement).style.color = "var(--text)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
(e.currentTarget as HTMLButtonElement).style.background = "none";
|
||||
(e.currentTarget as HTMLButtonElement).style.color = "var(--muted)";
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M8 1a5 5 0 0 1 5 5v2l1 2H2l1-2V6a5 5 0 0 1 5-5z" />
|
||||
<path d="M6 13a2 2 0 0 0 4 0" />
|
||||
</svg>
|
||||
{/* Notification badge */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 4,
|
||||
right: 4,
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: "50%",
|
||||
background: "var(--danger)",
|
||||
border: "2px solid var(--bg-deep)",
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{/* Theme Toggle */}
|
||||
<ThemeToggle />
|
||||
|
||||
{/* User Avatar + Dropdown */}
|
||||
<div ref={dropdownRef} style={{ position: "relative", flexShrink: 0 }}>
|
||||
<button
|
||||
onClick={() => {
|
||||
setDropdownOpen((prev) => !prev);
|
||||
}}
|
||||
aria-label="Open user menu"
|
||||
aria-expanded={dropdownOpen}
|
||||
aria-haspopup="menu"
|
||||
style={{
|
||||
width: 30,
|
||||
height: 30,
|
||||
borderRadius: "50%",
|
||||
background: user?.image
|
||||
? "none"
|
||||
: "linear-gradient(135deg, var(--ms-blue-500), var(--ms-purple-500))",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: 0,
|
||||
flexShrink: 0,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{user?.image ? (
|
||||
<img
|
||||
src={user.image}
|
||||
alt={user.name || user.email || "User avatar"}
|
||||
style={{ width: "100%", height: "100%", objectFit: "cover" }}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
style={{
|
||||
fontSize: "0.65rem",
|
||||
fontWeight: 700,
|
||||
color: "#fff",
|
||||
letterSpacing: "0.02em",
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
{initials}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Dropdown Menu */}
|
||||
{dropdownOpen && (
|
||||
<div
|
||||
role="menu"
|
||||
aria-label="User menu"
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "calc(100% + 8px)",
|
||||
right: 0,
|
||||
background: "var(--surface)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 8,
|
||||
padding: 6,
|
||||
minWidth: 200,
|
||||
boxShadow: "0 8px 32px rgba(0,0,0,0.3)",
|
||||
zIndex: 200,
|
||||
}}
|
||||
>
|
||||
{/* User info header */}
|
||||
<div
|
||||
style={{
|
||||
padding: "8px 12px",
|
||||
borderRadius: 6,
|
||||
marginBottom: 2,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "0.83rem",
|
||||
fontWeight: 600,
|
||||
color: "var(--text)",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{user?.name ?? "User"}
|
||||
</div>
|
||||
{user?.email && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: "0.75rem",
|
||||
color: "var(--muted)",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
marginTop: 2,
|
||||
}}
|
||||
>
|
||||
{user.email}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{ height: 1, background: "var(--border)", margin: "4px 0" }}
|
||||
/>
|
||||
|
||||
{/* Profile link */}
|
||||
<DropdownItem
|
||||
href="/profile"
|
||||
onClick={() => {
|
||||
setDropdownOpen(false);
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="8" cy="5" r="3" />
|
||||
<path d="M2 14c0-3.3 2.7-6 6-6s6 2.7 6 6" />
|
||||
</svg>
|
||||
Profile
|
||||
</DropdownItem>
|
||||
|
||||
{/* Account Settings link */}
|
||||
<DropdownItem
|
||||
href="/settings"
|
||||
onClick={() => {
|
||||
setDropdownOpen(false);
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="8" cy="8" r="2.5" />
|
||||
<path d="M8 1v1.5M8 13.5V15M1 8h1.5M13.5 8H15M3.05 3.05l1.06 1.06M11.89 11.89l1.06 1.06M3.05 12.95l1.06-1.06M11.89 4.11l1.06-1.06" />
|
||||
</svg>
|
||||
Account Settings
|
||||
</DropdownItem>
|
||||
|
||||
{/* Divider */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{ height: 1, background: "var(--border)", margin: "4px 0" }}
|
||||
/>
|
||||
|
||||
{/* Sign Out */}
|
||||
<button
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
setDropdownOpen(false);
|
||||
void signOut();
|
||||
}}
|
||||
style={{
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
padding: "8px 12px",
|
||||
borderRadius: 6,
|
||||
fontSize: "0.83rem",
|
||||
cursor: "pointer",
|
||||
background: "none",
|
||||
border: "none",
|
||||
color: "var(--danger)",
|
||||
textAlign: "left",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
(e.currentTarget as HTMLButtonElement).style.background = "var(--surface-2)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
(e.currentTarget as HTMLButtonElement).style.background = "none";
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M6 2H3a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h3M10 11l4-4-4-4M14 8H6" />
|
||||
</svg>
|
||||
Sign Out
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sub-components
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Terminal toggle button — visual only; no panel wired yet. */
|
||||
function TerminalToggleButton(): React.JSX.Element {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
|
||||
return (
|
||||
<button
|
||||
title="Toggle terminal"
|
||||
aria-label="Toggle terminal panel"
|
||||
className="hidden lg:flex items-center"
|
||||
style={{
|
||||
gap: 6,
|
||||
padding: "5px 10px",
|
||||
borderRadius: 6,
|
||||
background: "var(--surface)",
|
||||
border: `1px solid ${hovered ? "var(--success)" : "var(--border)"}`,
|
||||
fontSize: "0.75rem",
|
||||
fontFamily: "var(--mono)",
|
||||
color: hovered ? "var(--success)" : "var(--text-2)",
|
||||
cursor: "pointer",
|
||||
transition: "border-color 0.15s, color 0.15s",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
setHovered(true);
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
setHovered(false);
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect x="1" y="2" width="14" height="12" rx="1.5" />
|
||||
<path d="M4 6l3 3-3 3M9 12h3" />
|
||||
</svg>
|
||||
Terminal
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
interface DropdownItemProps {
|
||||
href: string;
|
||||
onClick: () => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/** A navigation link styled as a dropdown menu item. */
|
||||
function DropdownItem({ href, onClick, children }: DropdownItemProps): React.JSX.Element {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
role="menuitem"
|
||||
onClick={onClick}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
padding: "8px 12px",
|
||||
borderRadius: 6,
|
||||
fontSize: "0.83rem",
|
||||
cursor: "pointer",
|
||||
color: "var(--text-2)",
|
||||
textDecoration: "none",
|
||||
background: hovered ? "var(--surface-2)" : "none",
|
||||
transition: "background 0.1s",
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
setHovered(true);
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
setHovered(false);
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
731
apps/web/src/components/layout/AppSidebar.tsx
Normal file
731
apps/web/src/components/layout/AppSidebar.tsx
Normal file
@@ -0,0 +1,731 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import Image from "next/image";
|
||||
import { useAuth } from "@/lib/auth/auth-context";
|
||||
import { useSidebar } from "./SidebarContext";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface NavBadge {
|
||||
label: string;
|
||||
pulse?: boolean;
|
||||
}
|
||||
|
||||
interface NavItemConfig {
|
||||
href: string;
|
||||
label: string;
|
||||
icon: React.JSX.Element;
|
||||
badge?: NavBadge;
|
||||
}
|
||||
|
||||
interface NavGroup {
|
||||
label: string;
|
||||
items: NavItemConfig[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SVG Icons (16x16 viewBox, stroke="currentColor", strokeWidth="1.5")
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function IconDashboard(): React.JSX.Element {
|
||||
return (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect x="1" y="1" width="6" height="6" rx="1" />
|
||||
<rect x="9" y="1" width="6" height="6" rx="1" />
|
||||
<rect x="1" y="9" width="6" height="6" rx="1" />
|
||||
<rect x="9" y="9" width="6" height="6" rx="1" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconProjects(): React.JSX.Element {
|
||||
return (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect x="1" y="4" width="14" height="10" rx="1.5" />
|
||||
<path d="M1 7h14" />
|
||||
<path d="M5 4V2.5A.5.5 0 0 1 5.5 2h5a.5.5 0 0 1 .5.5V4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconProjectWorkspace(): React.JSX.Element {
|
||||
return (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="8" cy="4" r="2" />
|
||||
<circle cx="3" cy="12" r="2" />
|
||||
<circle cx="13" cy="12" r="2" />
|
||||
<path d="M8 6v2M5 12h6M6 8l-2 2M10 8l2 2" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconKanban(): React.JSX.Element {
|
||||
return (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect x="1" y="2" width="4" height="12" rx="1" />
|
||||
<rect x="6" y="2" width="4" height="12" rx="1" />
|
||||
<rect x="11" y="2" width="4" height="12" rx="1" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconFileManager(): React.JSX.Element {
|
||||
return (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M2 3.5A1.5 1.5 0 0 1 3.5 2h4l2 2h3A1.5 1.5 0 0 1 14 5.5v7A1.5 1.5 0 0 1 12.5 14h-9A1.5 1.5 0 0 1 2 12.5v-9z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconLogs(): React.JSX.Element {
|
||||
return (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M2 4h12M2 8h8M2 12h10" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconTerminal(): React.JSX.Element {
|
||||
return (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect x="1" y="2" width="14" height="12" rx="1.5" />
|
||||
<path d="M4 6l3 3-3 3M9 12h3" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconSettings(): React.JSX.Element {
|
||||
return (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="8" cy="8" r="2.5" />
|
||||
<path d="M8 1v2M8 13v2M1 8h2M13 8h2M3.05 3.05l1.41 1.41M11.54 11.54l1.41 1.41M3.05 12.95l1.41-1.41M11.54 4.46l1.41-1.41" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconChevronLeft(): React.JSX.Element {
|
||||
return (
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.75"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M10 3L5 8l5 5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconChevronRight(): React.JSX.Element {
|
||||
return (
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.75"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M6 3l5 5-5 5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Nav groups definition
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const NAV_GROUPS: NavGroup[] = [
|
||||
{
|
||||
label: "Overview",
|
||||
items: [
|
||||
{
|
||||
href: "/",
|
||||
label: "Dashboard",
|
||||
icon: <IconDashboard />,
|
||||
badge: { label: "live", pulse: true },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Workspace",
|
||||
items: [
|
||||
{
|
||||
href: "/projects",
|
||||
label: "Projects",
|
||||
icon: <IconProjects />,
|
||||
},
|
||||
{
|
||||
href: "/workspace",
|
||||
label: "Project Workspace",
|
||||
icon: <IconProjectWorkspace />,
|
||||
},
|
||||
{
|
||||
href: "/kanban",
|
||||
label: "Kanban",
|
||||
icon: <IconKanban />,
|
||||
},
|
||||
{
|
||||
href: "/files",
|
||||
label: "File Manager",
|
||||
icon: <IconFileManager />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Operations",
|
||||
items: [
|
||||
{
|
||||
href: "/logs",
|
||||
label: "Logs & Telemetry",
|
||||
icon: <IconLogs />,
|
||||
badge: { label: "live", pulse: true },
|
||||
},
|
||||
{
|
||||
href: "#terminal",
|
||||
label: "Terminal",
|
||||
icon: <IconTerminal />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "System",
|
||||
items: [
|
||||
{
|
||||
href: "/settings",
|
||||
label: "Settings",
|
||||
icon: <IconSettings />,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: derive initials from display name
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function getInitials(name: string): string {
|
||||
const parts = name.trim().split(/\s+/);
|
||||
const first = parts[0] ?? "";
|
||||
if (parts.length === 1) {
|
||||
return first.slice(0, 2).toUpperCase();
|
||||
}
|
||||
const last = parts[parts.length - 1] ?? "";
|
||||
return ((first[0] ?? "") + (last[0] ?? "")).toUpperCase();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// NavBadge component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface NavBadgeProps {
|
||||
badge: NavBadge;
|
||||
}
|
||||
|
||||
function NavBadgeChip({ badge }: NavBadgeProps): React.JSX.Element {
|
||||
const pulseStyle: React.CSSProperties = badge.pulse
|
||||
? {
|
||||
background: "rgba(47,128,255,0.15)",
|
||||
color: "var(--primary-l)",
|
||||
}
|
||||
: {
|
||||
background: "var(--surface-2)",
|
||||
color: "var(--muted)",
|
||||
};
|
||||
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
marginLeft: "auto",
|
||||
fontSize: "0.68rem",
|
||||
fontFamily: "var(--mono)",
|
||||
padding: "1px 6px",
|
||||
borderRadius: "10px",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "4px",
|
||||
flexShrink: 0,
|
||||
...pulseStyle,
|
||||
}}
|
||||
aria-label={badge.pulse ? `${badge.label} indicator` : badge.label}
|
||||
>
|
||||
{badge.pulse && (
|
||||
<span
|
||||
style={{
|
||||
display: "inline-block",
|
||||
width: "5px",
|
||||
height: "5px",
|
||||
borderRadius: "50%",
|
||||
background: "var(--primary-l)",
|
||||
boxShadow: "0 0 4px var(--primary)",
|
||||
animation: "pulse 2s cubic-bezier(0.4,0,0.6,1) infinite",
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
{badge.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// NavItem component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface NavItemProps {
|
||||
item: NavItemConfig;
|
||||
isActive: boolean;
|
||||
collapsed: boolean;
|
||||
}
|
||||
|
||||
function NavItem({ item, isActive, collapsed }: NavItemProps): React.JSX.Element {
|
||||
const baseStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "11px",
|
||||
padding: "9px 10px",
|
||||
borderRadius: "6px",
|
||||
fontSize: "0.875rem",
|
||||
fontWeight: 500,
|
||||
color: isActive ? "var(--text)" : "var(--muted)",
|
||||
background: isActive ? "var(--surface)" : "transparent",
|
||||
position: "relative",
|
||||
transition: "background 0.12s ease, color 0.12s ease",
|
||||
textDecoration: "none",
|
||||
justifyContent: collapsed ? "center" : undefined,
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
};
|
||||
|
||||
const iconStyle: React.CSSProperties = {
|
||||
width: "16px",
|
||||
height: "16px",
|
||||
flexShrink: 0,
|
||||
opacity: isActive ? 1 : 0.7,
|
||||
transition: "opacity 0.12s ease",
|
||||
};
|
||||
|
||||
const content = (
|
||||
<>
|
||||
{/* Active left accent bar */}
|
||||
{isActive && (
|
||||
<span
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
top: "6px",
|
||||
bottom: "6px",
|
||||
width: "3px",
|
||||
background: "var(--primary)",
|
||||
borderRadius: "0 2px 2px 0",
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Icon */}
|
||||
<span style={iconStyle}>{item.icon}</span>
|
||||
|
||||
{/* Label and badge — hidden when collapsed */}
|
||||
{!collapsed && (
|
||||
<>
|
||||
<span style={{ flex: 1, overflow: "hidden", textOverflow: "ellipsis" }}>
|
||||
{item.label}
|
||||
</span>
|
||||
{item.badge !== undefined && <NavBadgeChip badge={item.badge} />}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
const sharedProps = {
|
||||
style: baseStyle,
|
||||
"aria-current": isActive ? ("page" as const) : undefined,
|
||||
title: collapsed ? item.label : undefined,
|
||||
onMouseEnter: (e: React.MouseEvent<HTMLElement>): void => {
|
||||
if (!isActive) {
|
||||
(e.currentTarget as HTMLElement).style.background = "var(--surface)";
|
||||
(e.currentTarget as HTMLElement).style.color = "var(--text-2)";
|
||||
const iconEl = (e.currentTarget as HTMLElement).querySelector<HTMLElement>(
|
||||
"[data-nav-icon]"
|
||||
);
|
||||
if (iconEl) iconEl.style.opacity = "1";
|
||||
}
|
||||
},
|
||||
onMouseLeave: (e: React.MouseEvent<HTMLElement>): void => {
|
||||
if (!isActive) {
|
||||
(e.currentTarget as HTMLElement).style.background = "transparent";
|
||||
(e.currentTarget as HTMLElement).style.color = "var(--muted)";
|
||||
const iconEl = (e.currentTarget as HTMLElement).querySelector<HTMLElement>(
|
||||
"[data-nav-icon]"
|
||||
);
|
||||
if (iconEl) iconEl.style.opacity = "0.7";
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
if (item.href.startsWith("#")) {
|
||||
return (
|
||||
<a href={item.href} {...sharedProps}>
|
||||
{content}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link href={item.href} {...sharedProps}>
|
||||
{content}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// UserCard component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface UserCardProps {
|
||||
collapsed: boolean;
|
||||
}
|
||||
|
||||
function UserCard({ collapsed }: UserCardProps): React.JSX.Element {
|
||||
const { user } = useAuth();
|
||||
|
||||
const displayName = user?.name ?? "User";
|
||||
const initials = getInitials(displayName);
|
||||
const role = user?.workspaceRole ?? "Member";
|
||||
|
||||
return (
|
||||
<footer
|
||||
style={{
|
||||
padding: "12px 10px",
|
||||
borderTop: "1px solid var(--border)",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "10px",
|
||||
padding: "8px 10px",
|
||||
borderRadius: "6px",
|
||||
cursor: "pointer",
|
||||
transition: "background 0.12s ease",
|
||||
justifyContent: collapsed ? "center" : undefined,
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
title={collapsed ? `${displayName} — ${role}` : undefined}
|
||||
onMouseEnter={(e): void => {
|
||||
(e.currentTarget as HTMLElement).style.background = "var(--surface)";
|
||||
}}
|
||||
onMouseLeave={(e): void => {
|
||||
(e.currentTarget as HTMLElement).style.background = "transparent";
|
||||
}}
|
||||
onKeyDown={(e): void => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
aria-label={`User: ${displayName}, Role: ${role}`}
|
||||
>
|
||||
{/* Avatar */}
|
||||
<div
|
||||
style={{
|
||||
width: "30px",
|
||||
height: "30px",
|
||||
borderRadius: "50%",
|
||||
background: "linear-gradient(135deg, var(--ms-blue-500), var(--ms-purple-500))",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: "0.75rem",
|
||||
fontWeight: 700,
|
||||
color: "#fff",
|
||||
flexShrink: 0,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{user?.image ? (
|
||||
<Image
|
||||
src={user.image}
|
||||
alt={`${displayName} avatar`}
|
||||
width={30}
|
||||
height={30}
|
||||
style={{ width: "100%", height: "100%", objectFit: "cover", borderRadius: "50%" }}
|
||||
/>
|
||||
) : (
|
||||
<span aria-hidden="true">{initials}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Name and role — hidden when collapsed */}
|
||||
{!collapsed && (
|
||||
<>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "0.83rem",
|
||||
fontWeight: 600,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
color: "var(--text)",
|
||||
}}
|
||||
>
|
||||
{displayName}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "0.72rem",
|
||||
color: "var(--muted)",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{role}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Online status dot */}
|
||||
<div
|
||||
style={{
|
||||
marginLeft: "auto",
|
||||
width: "7px",
|
||||
height: "7px",
|
||||
borderRadius: "50%",
|
||||
background: "var(--success)",
|
||||
boxShadow: "0 0 6px var(--success)",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
aria-label="Online"
|
||||
role="img"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CollapseToggle component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface CollapseToggleProps {
|
||||
collapsed: boolean;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
function CollapseToggle({ collapsed, onToggle }: CollapseToggleProps): React.JSX.Element {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: "4px 10px 8px",
|
||||
display: "flex",
|
||||
justifyContent: collapsed ? "center" : "flex-end",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
aria-label={collapsed ? "Expand sidebar" : "Collapse sidebar"}
|
||||
title={collapsed ? "Expand sidebar" : "Collapse sidebar"}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: "26px",
|
||||
height: "26px",
|
||||
borderRadius: "6px",
|
||||
color: "var(--muted)",
|
||||
transition: "background 0.12s ease, color 0.12s ease",
|
||||
cursor: "pointer",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
onMouseEnter={(e): void => {
|
||||
(e.currentTarget as HTMLElement).style.background = "var(--surface)";
|
||||
(e.currentTarget as HTMLElement).style.color = "var(--text-2)";
|
||||
}}
|
||||
onMouseLeave={(e): void => {
|
||||
(e.currentTarget as HTMLElement).style.background = "transparent";
|
||||
(e.currentTarget as HTMLElement).style.color = "var(--muted)";
|
||||
}}
|
||||
>
|
||||
{collapsed ? <IconChevronRight /> : <IconChevronLeft />}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main AppSidebar component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Application sidebar — navigation groups, collapse toggle, and user card.
|
||||
* Logo lives in AppHeader per MS15 design spec.
|
||||
*/
|
||||
export function AppSidebar(): React.JSX.Element {
|
||||
const pathname = usePathname();
|
||||
const { collapsed, toggleCollapsed, mobileOpen, setMobileOpen, isMobile } = useSidebar();
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile backdrop — rendered behind the sidebar when open on mobile */}
|
||||
{isMobile && mobileOpen && (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
onClick={() => {
|
||||
setMobileOpen(false);
|
||||
}}
|
||||
style={{
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
top: "var(--topbar-h)",
|
||||
background: "rgba(0,0,0,0.5)",
|
||||
zIndex: 140,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<aside
|
||||
id="app-sidebar"
|
||||
className="app-sidebar"
|
||||
data-collapsed={collapsed ? "true" : undefined}
|
||||
data-mobile-open={mobileOpen ? "true" : undefined}
|
||||
aria-label="Application navigation"
|
||||
>
|
||||
{/* Sidebar body — scrollable nav area */}
|
||||
<nav
|
||||
style={{
|
||||
flex: 1,
|
||||
overflowY: "auto",
|
||||
overflowX: "hidden",
|
||||
padding: "10px 10px",
|
||||
}}
|
||||
aria-label="Main navigation"
|
||||
>
|
||||
{NAV_GROUPS.map((group) => (
|
||||
<div key={group.label} style={{ marginBottom: "18px" }}>
|
||||
{/* Group label — hidden when collapsed */}
|
||||
{!collapsed && (
|
||||
<p
|
||||
style={{
|
||||
fontSize: "0.67rem",
|
||||
fontWeight: 600,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.09em",
|
||||
color: "var(--muted)",
|
||||
padding: "0 10px",
|
||||
marginBottom: "4px",
|
||||
userSelect: "none",
|
||||
}}
|
||||
>
|
||||
{group.label}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Nav items */}
|
||||
<ul style={{ listStyle: "none", margin: 0, padding: 0 }}>
|
||||
{group.items.map((item) => {
|
||||
const isActive =
|
||||
item.href === "/"
|
||||
? pathname === "/"
|
||||
: item.href.startsWith("#")
|
||||
? false
|
||||
: pathname === item.href || pathname.startsWith(item.href + "/");
|
||||
|
||||
return (
|
||||
<li key={item.href}>
|
||||
<NavItem item={item} isActive={isActive} collapsed={collapsed} />
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Collapse toggle — anchored at bottom of nav */}
|
||||
<CollapseToggle collapsed={collapsed} onToggle={toggleCollapsed} />
|
||||
</nav>
|
||||
|
||||
{/* User card footer */}
|
||||
<UserCard collapsed={collapsed} />
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
}
|
||||
65
apps/web/src/components/layout/SidebarContext.tsx
Normal file
65
apps/web/src/components/layout/SidebarContext.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useState, useCallback, useEffect, type ReactNode } from "react";
|
||||
|
||||
interface SidebarContextValue {
|
||||
collapsed: boolean;
|
||||
toggleCollapsed: () => void;
|
||||
mobileOpen: boolean;
|
||||
setMobileOpen: (open: boolean) => void;
|
||||
isMobile: boolean;
|
||||
}
|
||||
|
||||
const SidebarContext = createContext<SidebarContextValue | undefined>(undefined);
|
||||
|
||||
/** Breakpoint below which we treat the viewport as "mobile" (matches CSS max-width: 767px). */
|
||||
const MOBILE_MAX_WIDTH = 767;
|
||||
|
||||
export function SidebarProvider({ children }: { children: ReactNode }): React.JSX.Element {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
|
||||
// Initialise and track mobile breakpoint using matchMedia
|
||||
useEffect((): (() => void) => {
|
||||
const mql = window.matchMedia(`(max-width: ${String(MOBILE_MAX_WIDTH)}px)`);
|
||||
|
||||
const handleChange = (e: MediaQueryListEvent): void => {
|
||||
setIsMobile(e.matches);
|
||||
// Close mobile sidebar when viewport grows out of mobile range
|
||||
if (!e.matches) {
|
||||
setMobileOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Set initial value synchronously
|
||||
setIsMobile(mql.matches);
|
||||
|
||||
mql.addEventListener("change", handleChange);
|
||||
return (): void => {
|
||||
mql.removeEventListener("change", handleChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const toggleCollapsed = useCallback((): void => {
|
||||
setCollapsed((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
const value: SidebarContextValue = {
|
||||
collapsed,
|
||||
toggleCollapsed,
|
||||
mobileOpen,
|
||||
setMobileOpen,
|
||||
isMobile,
|
||||
};
|
||||
|
||||
return <SidebarContext.Provider value={value}>{children}</SidebarContext.Provider>;
|
||||
}
|
||||
|
||||
export function useSidebar(): SidebarContextValue {
|
||||
const context = useContext(SidebarContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("useSidebar must be used within SidebarProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -20,7 +20,7 @@ export function ThemeToggle({ className = "" }: ThemeToggleProps): React.JSX.Ele
|
||||
// Sun icon for dark mode (click to switch to light)
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
style={{ color: "rgb(var(--semantic-warning))" }}
|
||||
style={{ color: "var(--warn)" }}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
@@ -33,7 +33,7 @@ export function ThemeToggle({ className = "" }: ThemeToggleProps): React.JSX.Ele
|
||||
// Moon icon for light mode (click to switch to dark)
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
style={{ color: "rgb(var(--text-secondary))" }}
|
||||
style={{ color: "var(--text-2)" }}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
|
||||
257
apps/web/src/components/terminal/TerminalPanel.tsx
Normal file
257
apps/web/src/components/terminal/TerminalPanel.tsx
Normal file
@@ -0,0 +1,257 @@
|
||||
import type { ReactElement, CSSProperties } from "react";
|
||||
|
||||
export interface TerminalLine {
|
||||
type: "prompt" | "command" | "output" | "error" | "warning" | "success";
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface TerminalTab {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface TerminalPanelProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
tabs?: TerminalTab[];
|
||||
activeTab?: string;
|
||||
onTabChange?: (id: string) => void;
|
||||
lines?: TerminalLine[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const defaultTabs: TerminalTab[] = [
|
||||
{ id: "main", label: "main" },
|
||||
{ id: "build", label: "build" },
|
||||
{ id: "logs", label: "logs" },
|
||||
];
|
||||
|
||||
const blinkKeyframes = `
|
||||
@keyframes ms-terminal-blink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
`;
|
||||
|
||||
let blinkStyleInjected = false;
|
||||
|
||||
function ensureBlinkStyle(): void {
|
||||
if (blinkStyleInjected || typeof document === "undefined") return;
|
||||
const styleEl = document.createElement("style");
|
||||
styleEl.textContent = blinkKeyframes;
|
||||
document.head.appendChild(styleEl);
|
||||
blinkStyleInjected = true;
|
||||
}
|
||||
|
||||
function getLineColor(type: TerminalLine["type"]): string {
|
||||
switch (type) {
|
||||
case "prompt":
|
||||
return "var(--success)";
|
||||
case "command":
|
||||
return "var(--text-2)";
|
||||
case "output":
|
||||
return "var(--muted)";
|
||||
case "error":
|
||||
return "var(--danger)";
|
||||
case "warning":
|
||||
return "var(--warn)";
|
||||
case "success":
|
||||
return "var(--success)";
|
||||
default:
|
||||
return "var(--muted)";
|
||||
}
|
||||
}
|
||||
|
||||
export function TerminalPanel({
|
||||
open,
|
||||
onClose,
|
||||
tabs,
|
||||
activeTab,
|
||||
onTabChange,
|
||||
lines = [],
|
||||
className = "",
|
||||
}: TerminalPanelProps): ReactElement {
|
||||
ensureBlinkStyle();
|
||||
|
||||
const resolvedTabs = tabs ?? defaultTabs;
|
||||
const resolvedActiveTab = activeTab ?? resolvedTabs[0]?.id ?? "";
|
||||
|
||||
const panelStyle: CSSProperties = {
|
||||
background: "var(--bg-deep)",
|
||||
borderTop: "1px solid var(--border)",
|
||||
overflow: "hidden",
|
||||
flexShrink: 0,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
height: open ? 280 : 0,
|
||||
transition: "height 0.3s ease",
|
||||
};
|
||||
|
||||
const headerStyle: CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
padding: "6px 16px",
|
||||
borderBottom: "1px solid var(--border)",
|
||||
flexShrink: 0,
|
||||
};
|
||||
|
||||
const tabBarStyle: CSSProperties = {
|
||||
display: "flex",
|
||||
gap: 2,
|
||||
};
|
||||
|
||||
const actionsStyle: CSSProperties = {
|
||||
marginLeft: "auto",
|
||||
display: "flex",
|
||||
gap: 4,
|
||||
};
|
||||
|
||||
const bodyStyle: CSSProperties = {
|
||||
flex: 1,
|
||||
overflowY: "auto",
|
||||
padding: "10px 16px",
|
||||
fontFamily: "var(--mono)",
|
||||
fontSize: "0.78rem",
|
||||
lineHeight: 1.6,
|
||||
};
|
||||
|
||||
const cursorStyle: CSSProperties = {
|
||||
display: "inline-block",
|
||||
width: 7,
|
||||
height: 14,
|
||||
background: "var(--success)",
|
||||
marginLeft: 2,
|
||||
animation: "ms-terminal-blink 1s step-end infinite",
|
||||
verticalAlign: "text-bottom",
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
style={panelStyle}
|
||||
role="region"
|
||||
aria-label="Terminal panel"
|
||||
aria-hidden={!open}
|
||||
>
|
||||
{/* Header */}
|
||||
<div style={headerStyle}>
|
||||
{/* Tab bar */}
|
||||
<div style={tabBarStyle} role="tablist" aria-label="Terminal tabs">
|
||||
{resolvedTabs.map((tab) => {
|
||||
const isActive = tab.id === resolvedActiveTab;
|
||||
const tabStyle: CSSProperties = {
|
||||
padding: "3px 10px",
|
||||
borderRadius: 4,
|
||||
fontSize: "0.75rem",
|
||||
fontFamily: "var(--mono)",
|
||||
color: isActive ? "var(--success)" : "var(--muted)",
|
||||
cursor: "pointer",
|
||||
background: isActive ? "var(--surface)" : "transparent",
|
||||
border: "none",
|
||||
outline: "none",
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
style={tabStyle}
|
||||
onClick={(): void => {
|
||||
onTabChange?.(tab.id);
|
||||
}}
|
||||
onMouseEnter={(e): void => {
|
||||
if (!isActive) {
|
||||
(e.currentTarget as HTMLButtonElement).style.background = "var(--surface)";
|
||||
(e.currentTarget as HTMLButtonElement).style.color = "var(--text-2)";
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e): void => {
|
||||
if (!isActive) {
|
||||
(e.currentTarget as HTMLButtonElement).style.background = "transparent";
|
||||
(e.currentTarget as HTMLButtonElement).style.color = "var(--muted)";
|
||||
}
|
||||
}}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div style={actionsStyle}>
|
||||
<button
|
||||
aria-label="Close terminal"
|
||||
style={{
|
||||
width: 22,
|
||||
height: 22,
|
||||
borderRadius: 4,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: "var(--muted)",
|
||||
cursor: "pointer",
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
outline: "none",
|
||||
padding: 0,
|
||||
}}
|
||||
onClick={onClose}
|
||||
onMouseEnter={(e): void => {
|
||||
(e.currentTarget as HTMLButtonElement).style.background = "var(--surface)";
|
||||
(e.currentTarget as HTMLButtonElement).style.color = "var(--text)";
|
||||
}}
|
||||
onMouseLeave={(e): void => {
|
||||
(e.currentTarget as HTMLButtonElement).style.background = "transparent";
|
||||
(e.currentTarget as HTMLButtonElement).style.color = "var(--muted)";
|
||||
}}
|
||||
>
|
||||
{/* Close icon — simple X using SVG */}
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M1 1L11 11M11 1L1 11"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div style={bodyStyle} role="log" aria-live="polite" aria-label="Terminal output">
|
||||
{lines.map((line, index) => {
|
||||
const isLast = index === lines.length - 1;
|
||||
const lineStyle: CSSProperties = {
|
||||
display: "flex",
|
||||
gap: 8,
|
||||
};
|
||||
const contentStyle: CSSProperties = {
|
||||
color: getLineColor(line.type),
|
||||
};
|
||||
|
||||
return (
|
||||
<div key={index} style={lineStyle}>
|
||||
<span style={contentStyle}>
|
||||
{line.content}
|
||||
{isLast && <span aria-hidden="true" style={cursorStyle} />}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Show cursor even when no lines */}
|
||||
{lines.length === 0 && (
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<span style={{ color: "var(--success)" }}>
|
||||
<span aria-hidden="true" style={cursorStyle} />
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
2
apps/web/src/components/terminal/index.ts
Normal file
2
apps/web/src/components/terminal/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export type { TerminalLine, TerminalTab, TerminalPanelProps } from "./TerminalPanel";
|
||||
export { TerminalPanel } from "./TerminalPanel";
|
||||
100
apps/web/src/components/ui/MosaicLogo.tsx
Normal file
100
apps/web/src/components/ui/MosaicLogo.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
"use client";
|
||||
|
||||
import type { CSSProperties } from "react";
|
||||
|
||||
export interface MosaicLogoProps {
|
||||
/** Width and height in pixels (default: 36) */
|
||||
size?: number;
|
||||
/** Whether to animate rotation (default: false) */
|
||||
spinning?: boolean;
|
||||
/** Seconds for one full rotation (default: 20) */
|
||||
spinDuration?: number;
|
||||
/** Additional CSS classes for the root element */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* MosaicLogo renders the 5-element Mosaic logo icon:
|
||||
* - 4 corner squares (blue, purple, teal, amber)
|
||||
* - 1 center circle (pink)
|
||||
*
|
||||
* Colors use CSS custom properties so they respond to theme changes.
|
||||
* When `spinning` is true the logo rotates continuously, making it
|
||||
* suitable for use as a loading indicator.
|
||||
*/
|
||||
export function MosaicLogo({
|
||||
size = 36,
|
||||
spinning = false,
|
||||
spinDuration = 20,
|
||||
className = "",
|
||||
}: MosaicLogoProps): React.JSX.Element {
|
||||
// Scale factor relative to the 36px reference design
|
||||
const scale = size / 36;
|
||||
|
||||
// Derived dimensions
|
||||
const squareSize = Math.round(14 * scale);
|
||||
const circleSize = Math.round(11 * scale);
|
||||
const borderRadius = Math.round(3 * scale);
|
||||
|
||||
const animationValue = spinning
|
||||
? `mosaicLogoSpin ${String(spinDuration)}s linear infinite`
|
||||
: undefined;
|
||||
|
||||
const containerStyle: CSSProperties = {
|
||||
width: size,
|
||||
height: size,
|
||||
position: "relative",
|
||||
flexShrink: 0,
|
||||
animation: animationValue,
|
||||
transformOrigin: "center",
|
||||
};
|
||||
|
||||
const baseSquareStyle: CSSProperties = {
|
||||
position: "absolute",
|
||||
width: squareSize,
|
||||
height: squareSize,
|
||||
borderRadius,
|
||||
};
|
||||
|
||||
const circleStyle: CSSProperties = {
|
||||
position: "absolute",
|
||||
top: "50%",
|
||||
left: "50%",
|
||||
transform: "translate(-50%, -50%)",
|
||||
width: circleSize,
|
||||
height: circleSize,
|
||||
borderRadius: "50%",
|
||||
background: "var(--ms-pink-500)",
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{spinning && (
|
||||
<style>{`
|
||||
@keyframes mosaicLogoSpin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
`}</style>
|
||||
)}
|
||||
<div style={containerStyle} className={className} role="img" aria-label="Mosaic logo">
|
||||
{/* Top-left: blue */}
|
||||
<div style={{ ...baseSquareStyle, top: 0, left: 0, background: "var(--ms-blue-500)" }} />
|
||||
{/* Top-right: purple */}
|
||||
<div style={{ ...baseSquareStyle, top: 0, right: 0, background: "var(--ms-purple-500)" }} />
|
||||
{/* Bottom-right: teal */}
|
||||
<div
|
||||
style={{ ...baseSquareStyle, bottom: 0, right: 0, background: "var(--ms-teal-500)" }}
|
||||
/>
|
||||
{/* Bottom-left: amber */}
|
||||
<div
|
||||
style={{ ...baseSquareStyle, bottom: 0, left: 0, background: "var(--ms-amber-500)" }}
|
||||
/>
|
||||
{/* Center: pink circle */}
|
||||
<div style={circleStyle} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default MosaicLogo;
|
||||
49
apps/web/src/components/ui/MosaicSpinner.tsx
Normal file
49
apps/web/src/components/ui/MosaicSpinner.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import { MosaicLogo } from "./MosaicLogo";
|
||||
import type { ReactElement } from "react";
|
||||
|
||||
export interface MosaicSpinnerProps {
|
||||
/** Width and height of the logo in pixels (default: 36) */
|
||||
size?: number;
|
||||
/** Seconds for one full rotation (default: 20) */
|
||||
spinDuration?: number;
|
||||
/** Optional text label displayed below the spinner */
|
||||
label?: string;
|
||||
/**
|
||||
* When true, wraps the spinner in a full-page centered overlay.
|
||||
* When false (default), renders inline.
|
||||
*/
|
||||
fullPage?: boolean;
|
||||
/** Additional CSS classes for the wrapper element */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* MosaicSpinner wraps MosaicLogo with spinning enabled.
|
||||
* It can be used as a full-page loading overlay or as an inline indicator.
|
||||
*/
|
||||
export function MosaicSpinner({
|
||||
size = 36,
|
||||
spinDuration = 20,
|
||||
label,
|
||||
fullPage = false,
|
||||
className = "",
|
||||
}: MosaicSpinnerProps): ReactElement {
|
||||
const inner = (
|
||||
<div className={`flex flex-col items-center gap-3 ${className}`}>
|
||||
<MosaicLogo size={size} spinning spinDuration={spinDuration} />
|
||||
{label !== undefined && label !== "" && (
|
||||
<span className="text-sm text-gray-500">{label}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (fullPage) {
|
||||
return <div className="flex min-h-screen items-center justify-center">{inner}</div>;
|
||||
}
|
||||
|
||||
return inner;
|
||||
}
|
||||
|
||||
export default MosaicSpinner;
|
||||
@@ -4,6 +4,11 @@
|
||||
* This module provides a single source of truth for all API endpoints and URLs.
|
||||
* All components should import from here instead of reading environment variables directly.
|
||||
*
|
||||
* Runtime config injection:
|
||||
* - In production containers, NEXT_PUBLIC_* vars are baked at build time and cannot
|
||||
* be overridden via Docker env vars. The root layout injects runtime values into
|
||||
* `window.__MOSAIC_ENV__` via a synchronous <script>, which this module reads first.
|
||||
*
|
||||
* Environment Variables:
|
||||
* - NEXT_PUBLIC_API_URL: The main API server URL (default: http://localhost:3001)
|
||||
* - NEXT_PUBLIC_ORCHESTRATOR_URL: The orchestrator service URL (default: same as API URL)
|
||||
@@ -11,6 +16,24 @@
|
||||
* - If unset: development defaults to `mock`, production defaults to `real`
|
||||
*/
|
||||
|
||||
/**
|
||||
* Read an env variable, preferring runtime-injected values on the client.
|
||||
*
|
||||
* Execution order guarantees this works:
|
||||
* 1. Root layout emits `<script>window.__MOSAIC_ENV__={…}</script>` synchronously.
|
||||
* 2. Next.js hydrates, loading client modules that call this helper.
|
||||
*/
|
||||
function getEnv(name: string): string | undefined {
|
||||
if (typeof window !== "undefined") {
|
||||
const w = window as Window & { __MOSAIC_ENV__?: Record<string, string> };
|
||||
if (w.__MOSAIC_ENV__?.[name]) {
|
||||
return w.__MOSAIC_ENV__[name];
|
||||
}
|
||||
}
|
||||
// Server-side or build-time fallback
|
||||
return process.env[name];
|
||||
}
|
||||
|
||||
/**
|
||||
* Default API server URL for local development
|
||||
*/
|
||||
@@ -25,10 +48,10 @@ export type AuthMode = (typeof VALID_AUTH_MODES)[number];
|
||||
* Main API server URL
|
||||
* Used for authentication, tasks, events, knowledge, and all core API calls
|
||||
*/
|
||||
export const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL ?? DEFAULT_API_URL;
|
||||
export const API_BASE_URL = getEnv("NEXT_PUBLIC_API_URL") ?? DEFAULT_API_URL;
|
||||
|
||||
function resolveAuthMode(): AuthMode {
|
||||
const rawMode = (process.env.NEXT_PUBLIC_AUTH_MODE ?? DEFAULT_AUTH_MODE).toLowerCase();
|
||||
const rawMode = (getEnv("NEXT_PUBLIC_AUTH_MODE") ?? DEFAULT_AUTH_MODE).toLowerCase();
|
||||
|
||||
if (!VALID_AUTH_MODES.includes(rawMode as AuthMode)) {
|
||||
throw new Error(
|
||||
@@ -60,7 +83,7 @@ export const IS_MOCK_AUTH_MODE = AUTH_MODE === "mock";
|
||||
* Used for agent management, task progress, and orchestration features
|
||||
* Falls back to main API URL if not specified (they may run on the same server)
|
||||
*/
|
||||
export const ORCHESTRATOR_URL = process.env.NEXT_PUBLIC_ORCHESTRATOR_URL ?? API_BASE_URL;
|
||||
export const ORCHESTRATOR_URL = getEnv("NEXT_PUBLIC_ORCHESTRATOR_URL") ?? API_BASE_URL;
|
||||
|
||||
/**
|
||||
* Build a full API endpoint URL
|
||||
|
||||
@@ -29,6 +29,21 @@ function getStoredTheme(): Theme {
|
||||
return "system";
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the resolved theme to the <html> element via data-theme attribute.
|
||||
* The default (no attribute or data-theme="dark") renders dark — dark is default.
|
||||
* Light theme requires data-theme="light".
|
||||
*/
|
||||
function applyThemeAttribute(resolved: "light" | "dark"): void {
|
||||
const root = document.documentElement;
|
||||
if (resolved === "light") {
|
||||
root.setAttribute("data-theme", "light");
|
||||
} else {
|
||||
// Remove the attribute so the default (dark) CSS variables apply.
|
||||
root.removeAttribute("data-theme");
|
||||
}
|
||||
}
|
||||
|
||||
interface ThemeProviderProps {
|
||||
children: ReactNode;
|
||||
defaultTheme?: Theme;
|
||||
@@ -46,19 +61,18 @@ export function ThemeProvider({
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
const storedTheme = getStoredTheme();
|
||||
const resolved = storedTheme === "system" ? getSystemTheme() : storedTheme;
|
||||
setThemeState(storedTheme);
|
||||
setResolvedTheme(storedTheme === "system" ? getSystemTheme() : storedTheme);
|
||||
setResolvedTheme(resolved);
|
||||
applyThemeAttribute(resolved);
|
||||
}, []);
|
||||
|
||||
// Apply theme class to html element
|
||||
// Apply theme via data-theme attribute on html element
|
||||
useEffect(() => {
|
||||
if (!mounted) return;
|
||||
|
||||
const root = document.documentElement;
|
||||
const resolved = theme === "system" ? getSystemTheme() : theme;
|
||||
|
||||
root.classList.remove("light", "dark");
|
||||
root.classList.add(resolved);
|
||||
applyThemeAttribute(resolved);
|
||||
setResolvedTheme(resolved);
|
||||
}, [theme, mounted]);
|
||||
|
||||
@@ -68,9 +82,9 @@ export function ThemeProvider({
|
||||
|
||||
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const handleChange = (e: MediaQueryListEvent): void => {
|
||||
setResolvedTheme(e.matches ? "dark" : "light");
|
||||
document.documentElement.classList.remove("light", "dark");
|
||||
document.documentElement.classList.add(e.matches ? "dark" : "light");
|
||||
const resolved = e.matches ? "dark" : "light";
|
||||
setResolvedTheme(resolved);
|
||||
applyThemeAttribute(resolved);
|
||||
};
|
||||
|
||||
mediaQuery.addEventListener("change", handleChange);
|
||||
|
||||
36
apps/web/tailwind.config.ts
Normal file
36
apps/web/tailwind.config.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { Config } from "tailwindcss";
|
||||
|
||||
const config: Config = {
|
||||
// Use data-theme attribute selector for dark mode instead of .dark class
|
||||
darkMode: ["selector", '[data-theme="dark"]'],
|
||||
content: ["./src/**/*.{js,ts,jsx,tsx,mdx}", "../../packages/ui/src/**/*.{js,ts,jsx,tsx,mdx}"],
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ["var(--font-outfit)", "system-ui", "sans-serif"],
|
||||
mono: ["var(--font-fira-code)", "Cascadia Code", "monospace"],
|
||||
},
|
||||
colors: {
|
||||
// Expose Mosaic semantic tokens as Tailwind colors
|
||||
bg: "var(--bg)",
|
||||
"bg-deep": "var(--bg-deep)",
|
||||
"bg-mid": "var(--bg-mid)",
|
||||
surface: "var(--surface)",
|
||||
"surface-2": "var(--surface-2)",
|
||||
border: "var(--border)",
|
||||
text: "var(--text)",
|
||||
"text-2": "var(--text-2)",
|
||||
muted: "var(--muted)",
|
||||
primary: "var(--primary)",
|
||||
"primary-l": "var(--primary-l)",
|
||||
danger: "var(--danger)",
|
||||
success: "var(--success)",
|
||||
warn: "var(--warn)",
|
||||
purple: "var(--purple)",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
|
||||
export default config;
|
||||
280
docker-compose.coolify.yml
Normal file
280
docker-compose.coolify.yml
Normal file
@@ -0,0 +1,280 @@
|
||||
# ==============================================
|
||||
# Mosaic Stack — Coolify Core Deployment
|
||||
# ==============================================
|
||||
#
|
||||
# Core services only. For Matrix, speech, and other optional
|
||||
# services, deploy them as separate Coolify services or extend
|
||||
# this file.
|
||||
#
|
||||
# Usage (Coolify):
|
||||
# 1. New Resource -> Docker Compose
|
||||
# 2. Paste this file
|
||||
# 3. Set environment variables in Coolify UI
|
||||
# 4. Configure domains for web + api in Coolify UI
|
||||
# 5. Deploy
|
||||
#
|
||||
# NOTE: Traefik labels are NOT included here. Coolify manages
|
||||
# routing and TLS via its own proxy integration. Configure
|
||||
# domains in the Coolify service settings.
|
||||
#
|
||||
# ==============================================
|
||||
|
||||
services:
|
||||
# ======================
|
||||
# PostgreSQL Database
|
||||
# ======================
|
||||
postgres:
|
||||
image: git.mosaicstack.dev/mosaic/stack-postgres:${IMAGE_TAG:-latest}
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- POSTGRES_USER=${POSTGRES_USER}
|
||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
|
||||
- POSTGRES_DB=${POSTGRES_DB}
|
||||
- POSTGRES_SHARED_BUFFERS=${POSTGRES_SHARED_BUFFERS:-256MB}
|
||||
- POSTGRES_EFFECTIVE_CACHE_SIZE=${POSTGRES_EFFECTIVE_CACHE_SIZE:-1GB}
|
||||
- POSTGRES_MAX_CONNECTIONS=${POSTGRES_MAX_CONNECTIONS:-100}
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
networks:
|
||||
- internal
|
||||
|
||||
# ======================
|
||||
# Valkey Cache
|
||||
# ======================
|
||||
valkey:
|
||||
image: valkey/valkey:8-alpine
|
||||
restart: unless-stopped
|
||||
command:
|
||||
- valkey-server
|
||||
- --maxmemory ${VALKEY_MAXMEMORY:-256mb}
|
||||
- --maxmemory-policy noeviction
|
||||
- --appendonly yes
|
||||
volumes:
|
||||
- valkey_data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "valkey-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
networks:
|
||||
- internal
|
||||
|
||||
# ======================
|
||||
# Mosaic API
|
||||
# ======================
|
||||
api:
|
||||
image: git.mosaicstack.dev/mosaic/stack-api:${IMAGE_TAG:-latest}
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
# Coolify domain assignment (magic variable — tells Coolify this service gets a domain on port 3001)
|
||||
- SERVICE_FQDN_API_3001
|
||||
- NODE_ENV=production
|
||||
- PORT=${API_PORT:-3001}
|
||||
- API_HOST=${API_HOST:-0.0.0.0}
|
||||
# Database
|
||||
- DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}
|
||||
# Cache
|
||||
- VALKEY_URL=redis://valkey:6379
|
||||
# Auth (external Authentik — optional)
|
||||
- OIDC_ENABLED=${OIDC_ENABLED:-false}
|
||||
- OIDC_ISSUER=${OIDC_ISSUER:-}
|
||||
- OIDC_CLIENT_ID=${OIDC_CLIENT_ID:-}
|
||||
- OIDC_CLIENT_SECRET=${OIDC_CLIENT_SECRET:-}
|
||||
- OIDC_REDIRECT_URI=${OIDC_REDIRECT_URI:-}
|
||||
# JWT
|
||||
- JWT_SECRET=${JWT_SECRET}
|
||||
- JWT_EXPIRATION=${JWT_EXPIRATION:-24h}
|
||||
# Better Auth
|
||||
- BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET}
|
||||
- BETTER_AUTH_URL=${BETTER_AUTH_URL:-}
|
||||
- CSRF_SECRET=${CSRF_SECRET}
|
||||
- COOKIE_DOMAIN=${COOKIE_DOMAIN:-}
|
||||
# Encryption
|
||||
- ENCRYPTION_KEY=${ENCRYPTION_KEY}
|
||||
# External services (optional — leave empty to disable)
|
||||
- OLLAMA_ENDPOINT=${OLLAMA_ENDPOINT:-}
|
||||
- OLLAMA_MODEL=${OLLAMA_MODEL:-llama3.2}
|
||||
- OPENBAO_ADDR=${OPENBAO_ADDR:-}
|
||||
# Knowledge module
|
||||
- KNOWLEDGE_CACHE_ENABLED=${KNOWLEDGE_CACHE_ENABLED:-true}
|
||||
- KNOWLEDGE_CACHE_TTL=${KNOWLEDGE_CACHE_TTL:-300}
|
||||
- SEMANTIC_SEARCH_SIMILARITY_THRESHOLD=${SEMANTIC_SEARCH_SIMILARITY_THRESHOLD:-0.5}
|
||||
# Rate limiting
|
||||
- RATE_LIMIT_TTL=${RATE_LIMIT_TTL:-60}
|
||||
- RATE_LIMIT_GLOBAL_LIMIT=${RATE_LIMIT_GLOBAL_LIMIT:-100}
|
||||
- RATE_LIMIT_STORAGE=${RATE_LIMIT_STORAGE:-redis}
|
||||
# Speech services (disabled — not in core stack)
|
||||
- STT_ENABLED=${STT_ENABLED:-false}
|
||||
- TTS_ENABLED=${TTS_ENABLED:-false}
|
||||
# Matrix bridge (disabled — not in core stack)
|
||||
- MATRIX_ACCESS_TOKEN=${MATRIX_ACCESS_TOKEN:-}
|
||||
# Telemetry (disabled by default)
|
||||
- MOSAIC_TELEMETRY_ENABLED=${MOSAIC_TELEMETRY_ENABLED:-false}
|
||||
- MOSAIC_TELEMETRY_SERVER_URL=${MOSAIC_TELEMETRY_SERVER_URL:-}
|
||||
- MOSAIC_TELEMETRY_API_KEY=${MOSAIC_TELEMETRY_API_KEY:-}
|
||||
- MOSAIC_TELEMETRY_INSTANCE_ID=${MOSAIC_TELEMETRY_INSTANCE_ID:-}
|
||||
- MOSAIC_TELEMETRY_DRY_RUN=${MOSAIC_TELEMETRY_DRY_RUN:-false}
|
||||
# Frontend URLs (for CORS and auth redirects)
|
||||
- NEXT_PUBLIC_APP_URL=${NEXT_PUBLIC_APP_URL}
|
||||
- NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}
|
||||
- TRUSTED_ORIGINS=${TRUSTED_ORIGINS:-}
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
valkey:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD-SHELL",
|
||||
'node -e "require(''http'').get(''http://localhost:${API_PORT:-3001}/health'', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"',
|
||||
]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
networks:
|
||||
- internal
|
||||
|
||||
# ======================
|
||||
# Mosaic Web
|
||||
# ======================
|
||||
web:
|
||||
image: git.mosaicstack.dev/mosaic/stack-web:${IMAGE_TAG:-latest}
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
# Coolify domain assignment (magic variable — tells Coolify this service gets a domain on port 3000)
|
||||
- SERVICE_FQDN_WEB_3000
|
||||
- NODE_ENV=production
|
||||
- PORT=${WEB_PORT:-3000}
|
||||
- NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}
|
||||
- NEXT_PUBLIC_APP_URL=${NEXT_PUBLIC_APP_URL}
|
||||
- NEXT_PUBLIC_ORCHESTRATOR_URL=${NEXT_PUBLIC_ORCHESTRATOR_URL:-}
|
||||
- NEXT_PUBLIC_AUTH_MODE=${NEXT_PUBLIC_AUTH_MODE:-real}
|
||||
- ORCHESTRATOR_API_KEY=${ORCHESTRATOR_API_KEY:-}
|
||||
depends_on:
|
||||
api:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD-SHELL",
|
||||
'node -e "require(''http'').get(''http://localhost:${WEB_PORT:-3000}'', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"',
|
||||
]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
networks:
|
||||
- internal
|
||||
|
||||
# ======================
|
||||
# Mosaic Coordinator
|
||||
# ======================
|
||||
coordinator:
|
||||
image: git.mosaicstack.dev/mosaic/stack-coordinator:${IMAGE_TAG:-latest}
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- GITEA_WEBHOOK_SECRET=${GITEA_WEBHOOK_SECRET:-}
|
||||
- GITEA_URL=${GITEA_URL:-}
|
||||
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
|
||||
- LOG_LEVEL=${LOG_LEVEL:-info}
|
||||
- HOST=0.0.0.0
|
||||
- PORT=8000
|
||||
- COORDINATOR_POLL_INTERVAL=${COORDINATOR_POLL_INTERVAL:-5.0}
|
||||
- COORDINATOR_MAX_CONCURRENT_AGENTS=${COORDINATOR_MAX_CONCURRENT_AGENTS:-10}
|
||||
- COORDINATOR_ENABLED=${COORDINATOR_ENABLED:-true}
|
||||
# Telemetry
|
||||
- MOSAIC_TELEMETRY_ENABLED=${MOSAIC_TELEMETRY_ENABLED:-false}
|
||||
- MOSAIC_TELEMETRY_SERVER_URL=${MOSAIC_TELEMETRY_SERVER_URL:-}
|
||||
- MOSAIC_TELEMETRY_API_KEY=${MOSAIC_TELEMETRY_API_KEY:-}
|
||||
- MOSAIC_TELEMETRY_INSTANCE_ID=${MOSAIC_TELEMETRY_INSTANCE_ID:-}
|
||||
- MOSAIC_TELEMETRY_DRY_RUN=${MOSAIC_TELEMETRY_DRY_RUN:-false}
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"python",
|
||||
"-c",
|
||||
"import urllib.request; urllib.request.urlopen('http://localhost:8000/health')",
|
||||
]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 5s
|
||||
networks:
|
||||
- internal
|
||||
|
||||
# ======================
|
||||
# Mosaic Orchestrator
|
||||
# ======================
|
||||
orchestrator:
|
||||
image: git.mosaicstack.dev/mosaic/stack-orchestrator:${IMAGE_TAG:-latest}
|
||||
restart: unless-stopped
|
||||
user: "1000:1000"
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- ORCHESTRATOR_PORT=3001
|
||||
- AI_PROVIDER=${AI_PROVIDER:-ollama}
|
||||
- OLLAMA_ENDPOINT=${OLLAMA_ENDPOINT:-}
|
||||
- OLLAMA_MODEL=${OLLAMA_MODEL:-llama3.2}
|
||||
- VALKEY_URL=redis://valkey:6379
|
||||
- VALKEY_HOST=valkey
|
||||
- VALKEY_PORT=6379
|
||||
- CLAUDE_API_KEY=${CLAUDE_API_KEY:-}
|
||||
- ORCHESTRATOR_API_KEY=${ORCHESTRATOR_API_KEY:-}
|
||||
- DOCKER_SOCKET=/var/run/docker.sock
|
||||
- GIT_USER_NAME=Mosaic Orchestrator
|
||||
- GIT_USER_EMAIL=orchestrator@mosaicstack.dev
|
||||
- KILLSWITCH_ENABLED=true
|
||||
- SANDBOX_ENABLED=true
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
- orchestrator_workspace:/workspace
|
||||
depends_on:
|
||||
valkey:
|
||||
condition: service_healthy
|
||||
api:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD-SHELL",
|
||||
'node -e "require(''http'').get(''http://localhost:3001/health'', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"',
|
||||
]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
networks:
|
||||
- internal
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
cap_drop:
|
||||
- ALL
|
||||
cap_add:
|
||||
- NET_BIND_SERVICE
|
||||
tmpfs:
|
||||
- /tmp:noexec,nosuid,size=100m
|
||||
|
||||
# ======================
|
||||
# Volumes
|
||||
# ======================
|
||||
volumes:
|
||||
postgres_data:
|
||||
valkey_data:
|
||||
orchestrator_workspace:
|
||||
|
||||
# ======================
|
||||
# Networks
|
||||
# ======================
|
||||
networks:
|
||||
internal:
|
||||
driver: bridge
|
||||
@@ -14,7 +14,7 @@ services:
|
||||
# OpenBao Secrets Vault
|
||||
# ======================
|
||||
openbao:
|
||||
image: git.mosaicstack.dev/mosaic/stack-openbao:${IMAGE_TAG:-dev}
|
||||
image: git.mosaicstack.dev/mosaic/stack-openbao:${IMAGE_TAG:-latest}
|
||||
entrypoint: ["dumb-init", "--"]
|
||||
command: ["bao", "server", "-config=/openbao/config/config.hcl"]
|
||||
environment:
|
||||
@@ -48,7 +48,7 @@ services:
|
||||
# Has built-in retry logic (polls OpenBao API for 60 seconds).
|
||||
# After init, runs an unseal watch loop to handle container restarts.
|
||||
openbao-init:
|
||||
image: git.mosaicstack.dev/mosaic/stack-openbao:${IMAGE_TAG:-dev}
|
||||
image: git.mosaicstack.dev/mosaic/stack-openbao:${IMAGE_TAG:-latest}
|
||||
command: /openbao/init.sh
|
||||
environment:
|
||||
VAULT_ADDR: http://openbao:8200
|
||||
|
||||
@@ -3,7 +3,7 @@ services:
|
||||
# PostgreSQL Database
|
||||
# ======================
|
||||
postgres:
|
||||
image: git.mosaicstack.dev/mosaic/stack-postgres:${IMAGE_TAG:-dev}
|
||||
image: git.mosaicstack.dev/mosaic/stack-postgres:${IMAGE_TAG:-latest}
|
||||
container_name: mosaic-postgres
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
@@ -251,7 +251,7 @@ services:
|
||||
# OpenBao Secrets Management (Optional)
|
||||
# ======================
|
||||
openbao:
|
||||
image: git.mosaicstack.dev/mosaic/stack-openbao:${IMAGE_TAG:-dev}
|
||||
image: git.mosaicstack.dev/mosaic/stack-openbao:${IMAGE_TAG:-latest}
|
||||
container_name: mosaic-openbao
|
||||
restart: unless-stopped
|
||||
user: root
|
||||
@@ -283,7 +283,7 @@ services:
|
||||
- "com.mosaic.description=OpenBao secrets management"
|
||||
|
||||
openbao-init:
|
||||
image: git.mosaicstack.dev/mosaic/stack-openbao:${IMAGE_TAG:-dev}
|
||||
image: git.mosaicstack.dev/mosaic/stack-openbao:${IMAGE_TAG:-latest}
|
||||
container_name: mosaic-openbao-init
|
||||
restart: unless-stopped
|
||||
user: root
|
||||
@@ -345,7 +345,7 @@ services:
|
||||
# Mosaic API
|
||||
# ======================
|
||||
api:
|
||||
image: git.mosaicstack.dev/mosaic/stack-api:${IMAGE_TAG:-dev}
|
||||
image: git.mosaicstack.dev/mosaic/stack-api:${IMAGE_TAG:-latest}
|
||||
container_name: mosaic-api
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
@@ -424,7 +424,7 @@ services:
|
||||
# Mosaic Orchestrator
|
||||
# ======================
|
||||
orchestrator:
|
||||
image: git.mosaicstack.dev/mosaic/stack-orchestrator:${IMAGE_TAG:-dev}
|
||||
image: git.mosaicstack.dev/mosaic/stack-orchestrator:${IMAGE_TAG:-latest}
|
||||
container_name: mosaic-orchestrator
|
||||
restart: unless-stopped
|
||||
# Run as non-root user (node:node, UID 1000)
|
||||
@@ -491,7 +491,7 @@ services:
|
||||
# Mosaic Web
|
||||
# ======================
|
||||
web:
|
||||
image: git.mosaicstack.dev/mosaic/stack-web:${IMAGE_TAG:-dev}
|
||||
image: git.mosaicstack.dev/mosaic/stack-web:${IMAGE_TAG:-latest}
|
||||
container_name: mosaic-web
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
|
||||
@@ -12,10 +12,10 @@ Pull and run the latest images from the Gitea container registry:
|
||||
# Copy environment template
|
||||
cp .env.example .env
|
||||
|
||||
# Edit .env and set IMAGE_TAG (optional, defaults to 'dev')
|
||||
# IMAGE_TAG=dev # Development images (develop branch)
|
||||
# IMAGE_TAG=latest # Production images (main branch)
|
||||
# Edit .env and set IMAGE_TAG (optional, defaults to 'latest')
|
||||
# IMAGE_TAG=latest # Latest images from main branch (default)
|
||||
# IMAGE_TAG=658ec077 # Specific commit SHA
|
||||
# IMAGE_TAG=v1.0.0 # Specific version tag
|
||||
|
||||
# Pull and start services
|
||||
docker compose pull
|
||||
@@ -49,8 +49,7 @@ docker compose -f docker-compose.build.yml up -d --build
|
||||
|
||||
The `IMAGE_TAG` environment variable controls which image version to pull:
|
||||
|
||||
- `dev` - Latest development build from `develop` branch (default)
|
||||
- `latest` - Latest stable build from `main` branch
|
||||
- `latest` - Latest build from `main` branch (default)
|
||||
- `658ec077` - Specific commit SHA (first 8 characters)
|
||||
- `v1.0.0` - Specific version tag
|
||||
|
||||
@@ -210,7 +209,7 @@ The repository includes three example compose files for common deployment scenar
|
||||
```bash
|
||||
# Set in .env
|
||||
COMPOSE_PROFILES=full
|
||||
IMAGE_TAG=dev
|
||||
IMAGE_TAG=latest
|
||||
|
||||
# Start all services
|
||||
docker compose up -d
|
||||
|
||||
@@ -29,12 +29,12 @@ Context = tokens = cost. Be smart.
|
||||
2. Code → TDD: write test (RED), implement (GREEN), refactor
|
||||
3. Test → pnpm test (must pass)
|
||||
4. Push → git push origin feature/XX-description
|
||||
5. PR → Create PR to develop (not main)
|
||||
5. PR → Create PR to main
|
||||
6. Review → Wait for approval or self-merge if authorized
|
||||
7. Close → Close related issues via API
|
||||
```
|
||||
|
||||
**Never merge directly to develop without a PR.**
|
||||
**Never merge directly to main without a PR.**
|
||||
|
||||
### Issue Management
|
||||
|
||||
@@ -53,7 +53,7 @@ curl -s -X PATCH -H "Authorization: token $TOKEN" -H "Content-Type: application/
|
||||
-d '{"state":"closed"}'
|
||||
|
||||
# Create PR (tea CLI works for this)
|
||||
tea pulls create --repo mosaic/stack --base develop --head feature/XX-name \
|
||||
tea pulls create --repo mosaic/stack --base main --head feature/XX-name \
|
||||
--title "feat(#XX): Title" --description "Description"
|
||||
```
|
||||
|
||||
|
||||
@@ -159,13 +159,12 @@ We follow a Git-based workflow with the following branch types:
|
||||
|
||||
### Workflow
|
||||
|
||||
1. Always branch from `develop`
|
||||
2. Merge back to `develop` via pull request
|
||||
3. `main` is for stable releases only
|
||||
1. Always branch from `main`
|
||||
2. Merge back to `main` via pull request
|
||||
|
||||
```bash
|
||||
# Start a new feature
|
||||
git checkout develop
|
||||
git checkout main
|
||||
git pull --rebase
|
||||
git checkout -b feature/my-feature-name
|
||||
|
||||
@@ -269,7 +268,7 @@ Clarified pagination and filtering parameters.
|
||||
2. Create a PR via GitLab at:
|
||||
https://git.mosaicstack.dev/mosaic/stack/-/merge_requests
|
||||
|
||||
3. Target branch: `develop`
|
||||
3. Target branch: `main`
|
||||
|
||||
4. Fill in the PR template:
|
||||
- **Title:** `feat(#issue): Brief description` (follows commit format)
|
||||
|
||||
171
docs/COOLIFY-DEPLOYMENT.md
Normal file
171
docs/COOLIFY-DEPLOYMENT.md
Normal file
@@ -0,0 +1,171 @@
|
||||
# Mosaic Stack — Coolify Deployment
|
||||
|
||||
## Overview
|
||||
|
||||
Coolify deployment on VM `10.1.1.44` (Proxmox). Replaces the Docker Swarm deployment on w-docker0 (`10.1.1.45`).
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Internet → Cloudflare → Public IP (174.137.97.162)
|
||||
→ Main Traefik (10.1.1.43) — TCP TLS passthrough for *.woltje.com
|
||||
→ Coolify Traefik (10.1.1.44) — terminates TLS via Cloudflare DNS-01 wildcard certs
|
||||
→ Service containers
|
||||
```
|
||||
|
||||
## Services (Core Stack)
|
||||
|
||||
| Service | Image | Internal Port | External Domain |
|
||||
| ------------ | ----------------------------------------------- | --------------- | ----------------------- |
|
||||
| postgres | `git.mosaicstack.dev/mosaic/stack-postgres` | 5432 | — |
|
||||
| valkey | `valkey/valkey:8-alpine` | 6379 | — |
|
||||
| api | `git.mosaicstack.dev/mosaic/stack-api` | 3001 | `api.mosaic.woltje.com` |
|
||||
| web | `git.mosaicstack.dev/mosaic/stack-web` | 3000 | `mosaic.woltje.com` |
|
||||
| coordinator | `git.mosaicstack.dev/mosaic/stack-coordinator` | 8000 | — |
|
||||
| orchestrator | `git.mosaicstack.dev/mosaic/stack-orchestrator` | 3001 (internal) | — |
|
||||
|
||||
Matrix (synapse, element-web) and speech services (speaches, kokoro-tts) are NOT included in the core stack. Deploy separately if needed.
|
||||
|
||||
## Compose File
|
||||
|
||||
`docker-compose.coolify.yml` in the repo root. This is the Coolify-compatible version of the deployment compose.
|
||||
|
||||
Key differences from the Swarm compose (`docker-compose.swarm.portainer.yml`):
|
||||
|
||||
- No `deploy:` blocks (Swarm-only)
|
||||
- No Traefik labels (Coolify manages routing)
|
||||
- Bridge network instead of overlay
|
||||
- `restart: unless-stopped` instead of Swarm restart policies
|
||||
- `SERVICE_FQDN_*` magic environment variables for Coolify domain assignment
|
||||
- List-style environment syntax (required for Coolify magic vars)
|
||||
|
||||
## Coolify IDs
|
||||
|
||||
| Resource | UUID |
|
||||
| ----------- | -------------------------- |
|
||||
| Project | `rs04g008kgkkw4s0wgsk40w4` |
|
||||
| Environment | `gko8csc804g8og0oosc8ccs8` |
|
||||
| Service | `ug0ssok4g44wocok8kws8gg8` |
|
||||
| Server | `as8kcogk08skskkcsok888g4` |
|
||||
|
||||
### Application UUIDs
|
||||
|
||||
| App | UUID |
|
||||
| ------------ | --------------------------- |
|
||||
| postgres | `jcw0ogskkw040os48ggkgkc8` |
|
||||
| valkey | `skssgwcggc0c8owoogcso8og` |
|
||||
| api | `mc40cgwwo8okwwoko84408k4k` |
|
||||
| web | `c48gcwgc40ok44scscowc8cc` |
|
||||
| coordinator | `s8gwog4c44w08c8sgkcg04k8` |
|
||||
| orchestrator | `uo4wkg88co0ckc4c4k44sowc` |
|
||||
|
||||
## Coolify API
|
||||
|
||||
Base URL: `http://10.1.1.44:8000/api/v1`
|
||||
Auth: Bearer token from `credentials.json` → `coolify.app_token`
|
||||
|
||||
### Patterns & Gotchas
|
||||
|
||||
- **Compose must be base64-encoded** when sending via `docker_compose_raw` field
|
||||
- **`SERVICE_FQDN_*` magic vars**: Coolify reads these from the compose to auto-assign domains. Format: `SERVICE_FQDN_{NAME}_{PORT}` (e.g., `SERVICE_FQDN_API_3001`). Must use list-style env syntax (`- SERVICE_FQDN_API_3001`), NOT dict-style.
|
||||
- **FQDN updates on sub-applications**: Coolify API doesn't support updating FQDNs on compose service sub-apps via REST. Workaround: update directly in Coolify's PostgreSQL DB (`coolify-db` container, `service_applications` table).
|
||||
- **Environment variable management**: Use `PATCH /api/v1/services/{uuid}/envs` with `{ "key": "VAR_NAME", "value": "val", "is_preview": false }`
|
||||
- **Service start**: `POST /api/v1/services/{uuid}/start`
|
||||
- **Coolify uses PostgreSQL** (not SQLite) for its internal database — container `coolify-db`
|
||||
|
||||
### DB Access (for workarounds)
|
||||
|
||||
```bash
|
||||
ssh localadmin@10.1.1.44
|
||||
docker exec -it coolify-db psql -U coolify -d coolify
|
||||
|
||||
-- Check service app FQDNs
|
||||
SELECT name, fqdn FROM service_applications WHERE service_id = (
|
||||
SELECT id FROM services WHERE uuid = 'ug0ssok4g44wocok8kws8gg8'
|
||||
);
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
All env vars are set via Coolify API and stored in `/data/coolify/services/{uuid}/.env` on the node.
|
||||
|
||||
Critical vars that were missing initially:
|
||||
|
||||
- `BETTER_AUTH_URL` — **Required** in production. API won't start without it. Set to `https://api.mosaic.woltje.com`.
|
||||
|
||||
## Operations
|
||||
|
||||
### Restart Procedure (IMPORTANT)
|
||||
|
||||
Coolify's `CleanupDocker` action periodically prunes unused images. During a restart (stop → start), images become "unused" when containers stop and may be pruned before the start phase runs. This causes "No such image" failures.
|
||||
|
||||
**Always pre-pull images before any Coolify restart/start:**
|
||||
|
||||
```bash
|
||||
ssh localadmin@10.1.1.44
|
||||
|
||||
# 1. Pre-pull all images (run in parallel)
|
||||
docker pull git.mosaicstack.dev/mosaic/stack-postgres:latest &
|
||||
docker pull valkey/valkey:8-alpine &
|
||||
docker pull git.mosaicstack.dev/mosaic/stack-api:latest &
|
||||
docker pull git.mosaicstack.dev/mosaic/stack-web:latest &
|
||||
docker pull git.mosaicstack.dev/mosaic/stack-coordinator:latest &
|
||||
docker pull git.mosaicstack.dev/mosaic/stack-orchestrator:latest &
|
||||
wait
|
||||
|
||||
# 2. Remove stale internal network (prevents "already exists" errors)
|
||||
docker network rm ug0ssok4g44wocok8kws8gg8_internal 2>/dev/null || true
|
||||
|
||||
# 3. Start via Coolify API
|
||||
TOKEN="<from credentials.json>"
|
||||
curl -X POST "http://10.1.1.44:8000/api/v1/services/ug0ssok4g44wocok8kws8gg8/start" \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
|
||||
# 4. Verify (wait ~30s for health checks)
|
||||
docker ps --filter 'name=ug0ssok4g44wocok8kws8gg8' --format 'table {{.Names}}\t{{.Status}}'
|
||||
```
|
||||
|
||||
### OTEL Configuration
|
||||
|
||||
The coordinator's Python OTLP exporter initializes at import time, before checking `MOSAIC_TELEMETRY_ENABLED`. To suppress OTLP connection noise, set the standard OpenTelemetry env var in the service `.env`:
|
||||
|
||||
```
|
||||
OTEL_SDK_DISABLED=true
|
||||
```
|
||||
|
||||
## Current State (2026-02-22)
|
||||
|
||||
### Verified Working
|
||||
|
||||
- All 6 containers running and healthy
|
||||
- Web UI at `https://mosaic.woltje.com/login` — 200 OK
|
||||
- API health at `https://api.mosaic.woltje.com/health` — healthy, PostgreSQL connected
|
||||
- CORS: `access-control-allow-origin: https://mosaic.woltje.com`
|
||||
- Runtime env injection: `NEXT_PUBLIC_API_URL=https://api.mosaic.woltje.com`, `AUTH_MODE=real`
|
||||
- Valkey: PONG
|
||||
- Coordinator: healthy, no OTLP noise (`OTEL_SDK_DISABLED=true`)
|
||||
- Orchestrator: healthy
|
||||
- TLS: Let's Encrypt certs (web + api), valid until May 23 2026
|
||||
- Auth endpoint: `/auth/get-session` responds correctly
|
||||
|
||||
### Resolved Issues
|
||||
|
||||
- **#441**: Coordinator OTLP noise — fixed via `OTEL_SDK_DISABLED=true`
|
||||
- **#442**: Coolify managed lifecycle — root cause was image pruning during restart + CoolifyTask timeout on large pulls. Fix: pre-pull images before start.
|
||||
- **#443**: Full stack connectivity — all checks pass
|
||||
|
||||
### Known Limitations
|
||||
|
||||
- Coolify restart is NOT safe without pre-pulling images first (CleanupDocker prunes between stop/start)
|
||||
- CoolifyTask has ~40s timeout — large image pulls will fail if not cached
|
||||
|
||||
## SSH Access
|
||||
|
||||
```bash
|
||||
ssh localadmin@10.1.1.44
|
||||
# Note: localadmin cannot sudo without TTY/password
|
||||
# Use docker to access files:
|
||||
docker run --rm -v /data/coolify/services:/srv alpine cat /srv/{uuid}/docker-compose.yml
|
||||
# Use docker exec for Coolify DB:
|
||||
docker exec -it coolify-db psql -U coolify -d coolify
|
||||
```
|
||||
@@ -144,7 +144,7 @@ sleep 30
|
||||
docker logs mosaic-openbao-init
|
||||
|
||||
# 3. Deploy swarm stack
|
||||
IMAGE_TAG=dev ./scripts/deploy-swarm.sh mosaic
|
||||
IMAGE_TAG=latest ./scripts/deploy-swarm.sh mosaic
|
||||
|
||||
# 4. Verify API connects to OpenBao
|
||||
docker service logs mosaic_api | grep -i openbao
|
||||
@@ -172,7 +172,7 @@ docker logs mosaic-openbao-init
|
||||
# OPENBAO_SECRET_ID=...
|
||||
|
||||
# 2. Deploy stack (no OpenBao)
|
||||
IMAGE_TAG=dev ./scripts/deploy-swarm.sh mosaic
|
||||
IMAGE_TAG=latest ./scripts/deploy-swarm.sh mosaic
|
||||
|
||||
# 3. Verify API connects to external Vault
|
||||
docker service logs mosaic_api | grep -i vault
|
||||
|
||||
@@ -62,7 +62,7 @@ If using private registry images from `git.mosaicstack.dev`:
|
||||
4. **Web editor:** Copy and paste contents of `docker-compose.portainer.yml`
|
||||
5. **Environment variables:**
|
||||
```
|
||||
IMAGE_TAG=dev
|
||||
IMAGE_TAG=latest
|
||||
OPENBAO_PORT=8200
|
||||
```
|
||||
6. Click **Deploy the stack**
|
||||
@@ -90,7 +90,7 @@ If using private registry images from `git.mosaicstack.dev`:
|
||||
**Option A: Git Repository (Recommended)**
|
||||
|
||||
- Repository URL: `https://git.mosaicstack.dev/mosaic/stack`
|
||||
- Repository reference: `refs/heads/develop`
|
||||
- Repository reference: `refs/heads/main`
|
||||
- Compose path: `docker-compose.swarm.yml`
|
||||
- Authentication: Enable if repository is private
|
||||
- Enable **Automatic updates** (optional)
|
||||
@@ -103,7 +103,7 @@ If using private registry images from `git.mosaicstack.dev`:
|
||||
4. **Environment variables:**
|
||||
|
||||
```
|
||||
IMAGE_TAG=dev
|
||||
IMAGE_TAG=latest
|
||||
POSTGRES_PASSWORD=<your-secure-password>
|
||||
JWT_SECRET=<your-jwt-secret>
|
||||
BETTER_AUTH_SECRET=<your-auth-secret>
|
||||
@@ -148,7 +148,7 @@ If using private registry images from `git.mosaicstack.dev`:
|
||||
|
||||
```bash
|
||||
# Image Configuration
|
||||
IMAGE_TAG=dev # or 'latest' or specific commit SHA
|
||||
IMAGE_TAG=latest # or 'latest' or specific commit SHA
|
||||
|
||||
# Database
|
||||
POSTGRES_PASSWORD=<secure-password>
|
||||
|
||||
233
docs/PRD.md
Normal file
233
docs/PRD.md
Normal file
@@ -0,0 +1,233 @@
|
||||
# PRD: Mosaic Stack Dashboard & Platform Implementation
|
||||
|
||||
## Metadata
|
||||
|
||||
- Owner: Jason Woltje
|
||||
- Date: 2026-02-22
|
||||
- Status: in-progress
|
||||
- Best-Guess Mode: true
|
||||
|
||||
## Problem Statement
|
||||
|
||||
The Mosaic Stack web UI has a basic navigation and simple widget-based dashboard that doesn't match the production-ready design vision. The reference design (dashboard.html) defines a comprehensive command center UI with sidebar navigation, topbar, terminal panel, and multiple page layouts. The current implementation uses mismatched design tokens (raw Tailwind colors vs CSS variables), has no collapsible sidebar, no global terminal, and lacks the polished design system from the reference.
|
||||
|
||||
## Objectives
|
||||
|
||||
1. Implement the dashboard.html reference design as the production UI foundation
|
||||
2. Establish a consistent CSS design token system that supports multiple themes
|
||||
3. Build a responsive, accessible app shell with collapsible sidebar and full-width header
|
||||
4. Create a theme system supporting installable theme packages
|
||||
5. Build all dashboard pages (Dashboard, Projects, Workspace, Kanban, Files, Logs, Settings, Profile)
|
||||
6. Implement real backend integration (no mock data)
|
||||
7. Support multi-tenant configuration with RBAC
|
||||
8. Implement federation (master-master and master-slave)
|
||||
9. Build global terminal, project chat, and master chat session
|
||||
10. Configure telemetry with opt-out support
|
||||
|
||||
## Scope
|
||||
|
||||
### In Scope (Milestone 0.0.15 — Dashboard Shell & Design System)
|
||||
|
||||
1. CSS design token system overhaul (colors, fonts, spacing, radii from dashboard.html)
|
||||
2. App shell layout: sidebar + full-width header + main content area
|
||||
3. Full-width header with logo, search, system status, terminal toggle, notifications, theme toggle, user avatar dropdown
|
||||
4. Collapsible sidebar with nav groups, icons, badges, active states, collapse/expand button
|
||||
5. Responsive layout with hamburger button at small breakpoints, sidebar hidden by default at mobile
|
||||
6. Light/dark theme matching the reference design
|
||||
7. Mosaic logo icon as global loading spinner
|
||||
8. Shared component updates in packages/ui (Card, Badge, Button, Dot, MetricsStrip, ProgressBar, FilterTabs, SectionHeader, Table, LogLine, Terminal panel)
|
||||
9. Dashboard page: metrics strip, active orchestrator sessions, quick actions, activity feed, token budget
|
||||
10. Grain overlay texture from reference design
|
||||
|
||||
### In Scope (Future Milestones — Documented for Planning)
|
||||
|
||||
11. Additional pages: Projects, Workspace, Kanban, File Manager, Logs & Telemetry, Settings, Profile
|
||||
12. Theme system with installable theme packages
|
||||
13. Widget system with installable widget packages, customizable sizes
|
||||
14. Global terminal (project/orchestrator level, smart)
|
||||
15. Project-level orchestrator chat
|
||||
16. Master chat session (collapsible sidebar/slideout, always available)
|
||||
17. Settings page for ALL environment variables, dynamically configurable via webUI
|
||||
18. Multi-tenant configuration with admin user management
|
||||
19. Team management with shared data spaces and chat rooms
|
||||
20. RBAC for file access, resources, models
|
||||
21. Federation: master-master and master-slave with key exchange
|
||||
22. Federation testing: 3 instances on Coolify (woltje.com domain)
|
||||
23. Agent task mapping configuration (system-level defaults, user-level overrides)
|
||||
24. Telemetry: opt-out, customizable endpoint, sanitized data
|
||||
25. File manager with WYSIWYG editing (system/user/project levels)
|
||||
26. User-level and project-level Kanban with filtering
|
||||
27. Break-glass authentication user
|
||||
28. Playwright E2E tests for all pages
|
||||
29. API documentation via Swagger
|
||||
30. Backend endpoints for all dashboard data
|
||||
|
||||
### Out of Scope
|
||||
|
||||
1. Mobile native app
|
||||
2. Third-party marketplace for themes/widgets (initial implementation is local package management only)
|
||||
3. Production deployment to non-Coolify targets
|
||||
4. Calendar system redesign (existing calendar implementation is retained)
|
||||
|
||||
## User/Stakeholder Requirements
|
||||
|
||||
1. The `jarvis` user must be able to log into mosaic.woltje.com via Authentik as administrator with access to all pages
|
||||
2. A standard `jarvis-user` must operate at a lower permission level
|
||||
3. A break-glass user must have access without Authentik authentication
|
||||
4. All pages must be navigable without errors
|
||||
5. Light and dark themes must work across all pages and components
|
||||
6. Sidebar must be collapsible with open/close button; hidden by default at small breakpoints
|
||||
7. Hamburger button visible at lower breakpoints for sidebar control
|
||||
8. The Mosaic Stack logo icon must be the site-wide loading spinner
|
||||
9. No mock data — all data pulled from backend APIs
|
||||
|
||||
## Functional Requirements
|
||||
|
||||
### FR-001: Design Token System
|
||||
|
||||
- CSS custom properties for all colors, spacing, typography, radii
|
||||
- Dark theme as default (`:root`), light theme via `[data-theme="light"]`
|
||||
- Fonts: Outfit (body), Fira Code (monospace)
|
||||
- All components must use design tokens, never hardcoded colors
|
||||
|
||||
### FR-002: App Shell Layout
|
||||
|
||||
- CSS Grid: sidebar column + header row + main content
|
||||
- Full-width header spanning above sidebar and content
|
||||
- ASSUMPTION: Header spans full width including above sidebar area. The logo is in the header, not the sidebar. Rationale: User explicitly stated "The logo will NOT be part of the sidebar."
|
||||
|
||||
### FR-003: Sidebar Navigation
|
||||
|
||||
- Nav groups: Overview (Dashboard), Workspace (Projects, Project Workspace, Kanban, File Manager), Operations (Logs & Telemetry, Terminal), System (Settings)
|
||||
- Collapsible: icon-only mode when collapsed
|
||||
- Active state indicator (left border accent)
|
||||
- User card in footer with avatar, name, role, online status
|
||||
- ASSUMPTION: Sidebar footer user card navigates to Profile page. Rationale: Matches reference design behavior.
|
||||
|
||||
### FR-004: Header/Topbar
|
||||
|
||||
- Logo + brand wordmark (left)
|
||||
- Search bar with keyboard shortcut hint
|
||||
- System status indicator
|
||||
- Terminal toggle button
|
||||
- Notification bell with badge
|
||||
- Theme toggle (sun/moon icon)
|
||||
- User avatar button with dropdown (Profile, Account Settings, Sign Out)
|
||||
|
||||
### FR-005: Responsive Design
|
||||
|
||||
- Breakpoints: sm (640px), md (768px), lg (1024px), xl (1280px)
|
||||
- Below md: sidebar hidden, hamburger button in header
|
||||
- md-lg: sidebar can be toggled
|
||||
- lg+: sidebar visible by default
|
||||
|
||||
### FR-006: Dashboard Page
|
||||
|
||||
- 6-cell metrics strip with colored top borders and trend indicators
|
||||
- Active Orchestrator Sessions card with agent nodes
|
||||
- Quick Actions 2x2 grid
|
||||
- Activity Feed sidebar card
|
||||
- Token Budget sidebar card with progress bars
|
||||
|
||||
### FR-007: Loading Spinner
|
||||
|
||||
- Mosaic logo icon (4 corner squares + center circle) with CSS rotation animation
|
||||
- Used as global loading indicator across all pages
|
||||
- Available as a shared component
|
||||
|
||||
### FR-008: Theme System (Future Milestone)
|
||||
|
||||
- Support multiple themes beyond default dark/light
|
||||
- Themes are installable packages from Mosaic Stack repo
|
||||
- Theme installation and selection from Settings page
|
||||
- ASSUMPTION: Initial implementation supports dark/light from reference design. Multi-theme package system is a future milestone. Rationale: Foundation must be solid before extensibility.
|
||||
|
||||
### FR-009: Terminal Panel (Future Milestone)
|
||||
|
||||
- Bottom drawer panel, toggleable from header and sidebar
|
||||
- Multiple tabs (Orchestrator, Shell, Build)
|
||||
- Smart terminal operating at project/orchestrator level
|
||||
- Global terminal for system interaction
|
||||
|
||||
### FR-010: Settings Page (Future Milestone)
|
||||
|
||||
- All environment variables configurable via UI
|
||||
- Minimal launch env vars, rest configurable dynamically
|
||||
- Settings stored in DB with RLS
|
||||
- Theme selection, widget management, federation config, telemetry config
|
||||
|
||||
## Non-Functional Requirements
|
||||
|
||||
1. Security: All API endpoints require authentication. RBAC enforced. No PII in telemetry. Secrets never hardcoded.
|
||||
2. Performance: Dashboard loads in <2s. No layout shift during theme toggle. Sidebar toggle is instant (<100ms animation).
|
||||
3. Reliability: Break-glass auth ensures access when Authentik is down.
|
||||
4. Observability: Telemetry with opt-out support. Wide-event logging. Customizable telemetry endpoint.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
### Milestone 0.0.15
|
||||
|
||||
1. Design tokens from dashboard.html are implemented in globals.css
|
||||
2. App shell shows full-width header with logo, collapsible sidebar, main content area
|
||||
3. Sidebar has all nav groups with icons, collapses to icon-only mode
|
||||
4. Hamburger button appears at mobile breakpoints, sidebar hidden by default
|
||||
5. Light/dark theme toggle works across all components
|
||||
6. Mosaic logo spinner is used as site-wide loading indicator
|
||||
7. Dashboard page shows metrics strip, orchestrator sessions, quick actions, activity feed, token budget
|
||||
8. All shared components in packages/ui use design tokens (no hardcoded colors)
|
||||
9. Lint, typecheck, and existing tests pass
|
||||
10. Grain overlay texture from reference is applied
|
||||
|
||||
### Full Project (All Milestones)
|
||||
|
||||
11. jarvis user logs in via Authentik, has admin access to all pages
|
||||
12. jarvis-user has standard access at lower permission level
|
||||
13. Break-glass user has access without Authentik
|
||||
14. Three Mosaic Stack instances on Coolify with federation testing
|
||||
15. Playwright tests confirm all pages, functions, theming work
|
||||
16. No errors during site navigation
|
||||
17. API documented via Swagger with proper auth gating
|
||||
18. Telemetry working locally with wide-event logging
|
||||
19. Mosaic Telemetry properly reporting to telemetry endpoint
|
||||
|
||||
## Constraints and Dependencies
|
||||
|
||||
1. Next.js 16 with App Router — all pages use server/client component patterns
|
||||
2. Tailwind CSS 3.4 — design tokens must integrate with Tailwind's utility class system
|
||||
3. BetterAuth for authentication — must maintain existing auth flow
|
||||
4. Authentik as IdP at auth.diversecanvas.com — must remain operational
|
||||
5. PostgreSQL 17 with Prisma — all settings stored in DB
|
||||
6. Coolify for deployment — 3 instances needed for federation testing
|
||||
7. packages/ui is shared across apps — changes affect all consumers
|
||||
|
||||
## Risks and Open Questions
|
||||
|
||||
1. **Risk**: Changing globals.css design tokens may break existing pages (login, knowledge, calendar). Mitigation: Thorough regression testing.
|
||||
2. **Risk**: packages/ui uses hardcoded Tailwind colors — migration to CSS variables needs care. Mitigation: Phase the migration, test each component.
|
||||
3. **Open**: Exact federation protocol details for master-master vs master-slave data sync.
|
||||
4. **Open**: Specific telemetry data points to collect.
|
||||
5. **Open**: Agent task mapping configuration schema (informed by OpenClaw research).
|
||||
|
||||
## Testing and Verification
|
||||
|
||||
1. Baseline: `pnpm lint && pnpm build` must pass
|
||||
2. Situational: Visual verification at sm/md/lg/xl breakpoints
|
||||
3. Situational: Theme toggle across all pages
|
||||
4. Situational: Sidebar collapse/expand at all breakpoints
|
||||
5. E2E: Playwright tests for all page navigation
|
||||
6. E2E: Auth flow with Authentik
|
||||
7. Federation: Master-master and master-slave data access tests
|
||||
|
||||
## Delivery/Milestone Intent
|
||||
|
||||
| Milestone | Version | Focus |
|
||||
| ----------------------- | ------- | ----------------------------------------------------------------- |
|
||||
| MS15-DashboardShell | 0.0.15 | Design system + app shell + dashboard page |
|
||||
| MS16-Pages | 0.0.16 | Projects, Workspace, Kanban, Settings, Profile, Files, Logs pages |
|
||||
| MS17-BackendIntegration | 0.0.17 | API endpoints, real data, Swagger docs |
|
||||
| MS18-ThemeWidgets | 0.0.18 | Theme package system, widget registry, dashboard customization |
|
||||
| MS19-ChatTerminal | 0.0.19 | Global terminal, project chat, master chat session |
|
||||
| MS20-MultiTenant | 0.0.20 | Multi-tenant, teams, RBAC, RLS enforcement, break-glass auth |
|
||||
| MS21-Federation | 0.0.21 | Federation (M-M, M-S), 3 instances, key exchange, data separation |
|
||||
| MS22-AgentTelemetry | 0.0.22 | Agent task mapping, telemetry, wide-event logging |
|
||||
| MS23-Testing | 0.0.23 | Playwright E2E, federation tests, documentation finalization |
|
||||
@@ -49,7 +49,7 @@ nano .env
|
||||
- `OIDC_CLIENT_ID` - From your Authentik/OIDC provider
|
||||
- `OIDC_CLIENT_SECRET` - From your Authentik/OIDC provider
|
||||
- `OIDC_ISSUER` - Your OIDC provider URL (must end with `/`)
|
||||
- `IMAGE_TAG` - `dev` or `latest` or specific commit SHA
|
||||
- `IMAGE_TAG` - `latest` (default) or specific version/commit SHA
|
||||
|
||||
### 2. Configure for External Services (Optional)
|
||||
|
||||
@@ -131,10 +131,10 @@ See [OpenBao Deployment Guide](OPENBAO-DEPLOYMENT.md) for detailed options.
|
||||
cd /opt/mosaic/stack
|
||||
|
||||
# Using the deploy script (recommended)
|
||||
IMAGE_TAG=dev ./scripts/deploy-swarm.sh mosaic
|
||||
IMAGE_TAG=latest ./scripts/deploy-swarm.sh mosaic
|
||||
|
||||
# Or manually
|
||||
IMAGE_TAG=dev docker stack deploy \
|
||||
IMAGE_TAG=latest docker stack deploy \
|
||||
-c docker-compose.swarm.yml \
|
||||
--with-registry-auth mosaic
|
||||
```
|
||||
|
||||
141
docs/design/design-system.md
Normal file
141
docs/design/design-system.md
Normal file
@@ -0,0 +1,141 @@
|
||||
# Mosaic Stack Design System
|
||||
|
||||
## Overview
|
||||
|
||||
The Mosaic Stack design system provides a unified visual language across the dashboard application. It uses CSS custom properties for theming, inline styles for `packages/ui` components, and a dark-first approach with light theme support.
|
||||
|
||||
## Design Tokens
|
||||
|
||||
All tokens are defined in `apps/web/src/app/globals.css` as CSS custom properties on `:root` (dark default) and `[data-theme="light"]`.
|
||||
|
||||
### Color Palette
|
||||
|
||||
| Token | Dark Value | Purpose |
|
||||
| ----------------- | ---------- | ------------------------------ |
|
||||
| `--ms-blue-400` | `#4d94ff` | Primary accent, active states |
|
||||
| `--ms-blue-500` | `#2f80ff` | Primary brand color |
|
||||
| `--ms-teal-400` | `#2dd4bf` | Success, positive metrics |
|
||||
| `--ms-teal-500` | `#14b8a6` | Success emphasis |
|
||||
| `--ms-purple-400` | `#a78bfa` | Orchestrator, secondary accent |
|
||||
| `--ms-purple-500` | `#8b5cf6` | Purple emphasis |
|
||||
| `--ms-amber-400` | `#fbbf24` | Warnings, caution |
|
||||
| `--ms-red-400` | `#f87171` | Errors, danger |
|
||||
| `--ms-cyan-500` | `#06b6d4` | Info, neutral accent |
|
||||
|
||||
### Semantic Tokens
|
||||
|
||||
| Token | Dark Value | Purpose |
|
||||
| ------------- | --------------------- | ---------------------- |
|
||||
| `--bg` | `#0a0e17` | Page background |
|
||||
| `--bg-mid` | `#111827` | Card/panel background |
|
||||
| `--bg-deep` | `#060a12` | Terminal/deep surfaces |
|
||||
| `--surface` | `#151c2c` | Card surfaces |
|
||||
| `--surface-2` | `#1e2940` | Hover/active surfaces |
|
||||
| `--border` | `#1e2940` | Default borders |
|
||||
| `--text` | `#e8ecf4` | Primary text |
|
||||
| `--text-2` | `#94a3b8` | Secondary text |
|
||||
| `--muted` | `#64748b` | Muted/label text |
|
||||
| `--primary` | `var(--ms-blue-500)` | Primary action |
|
||||
| `--success` | `var(--ms-teal-500)` | Success state |
|
||||
| `--danger` | `var(--ms-red-400)` | Error state |
|
||||
| `--warn` | `var(--ms-amber-400)` | Warning state |
|
||||
|
||||
### Typography
|
||||
|
||||
| Token | Value | Purpose |
|
||||
| -------- | ------------------------------ | ------------------------- |
|
||||
| `--font` | `Inter, system-ui, sans-serif` | Body text |
|
||||
| `--mono` | `JetBrains Mono, monospace` | Code, metrics, timestamps |
|
||||
|
||||
### Spacing & Radii
|
||||
|
||||
| Token | Value | Purpose |
|
||||
| -------- | ------ | ------------------------------- |
|
||||
| `--r-sm` | `4px` | Small radius (buttons, inputs) |
|
||||
| `--r` | `8px` | Default radius (cards, panels) |
|
||||
| `--r-lg` | `12px` | Large radius (sections, modals) |
|
||||
|
||||
## App Shell Layout
|
||||
|
||||
The dashboard uses a CSS Grid layout:
|
||||
|
||||
```
|
||||
grid-template-columns: var(--sidebar-w) 1fr;
|
||||
grid-template-rows: var(--topbar-h) 1fr;
|
||||
```
|
||||
|
||||
- **Header** spans full width (`grid-column: 1 / -1`)
|
||||
- **Sidebar** width: `240px` (expanded) / `60px` (collapsed)
|
||||
- **Topbar** height: `52px`
|
||||
|
||||
### Responsive Breakpoints
|
||||
|
||||
| Breakpoint | Behavior |
|
||||
| --------------------- | ----------------------------------------------- |
|
||||
| `< 768px` (Mobile) | Single column, sidebar as overlay with backdrop |
|
||||
| `768-1023px` (Tablet) | Sidebar toggleable, hidden by default |
|
||||
| `>= 1024px` (Desktop) | Sidebar always visible |
|
||||
|
||||
## Shared Components (packages/ui)
|
||||
|
||||
All `packages/ui` components use inline styles with CSS custom properties since the package is built with `tsc` only (no CSS processing).
|
||||
|
||||
### Button
|
||||
|
||||
Variants: `primary` (blue), `secondary` (transparent), `ghost`, `danger` (red), `success` (teal)
|
||||
|
||||
### Badge
|
||||
|
||||
Variants: `badge-teal`, `badge-amber`, `badge-red`, `badge-blue`, `badge-muted`, `badge-purple`, `badge-pulse`
|
||||
|
||||
Pill shape with monospace font. `badge-pulse` includes an animated dot.
|
||||
|
||||
### Card
|
||||
|
||||
Flat design with `var(--surface)` background, `var(--border)` border, `var(--r-lg)` border-radius. Header and footer slots available.
|
||||
|
||||
### Dot
|
||||
|
||||
Status indicator (7x7px circle with glow). Variants: `teal`, `blue`, `amber`, `red`, `muted`.
|
||||
|
||||
### MetricsStrip
|
||||
|
||||
Grid of metric cells with colored top borders, hover state, and optional trend indicators. Each cell: value (large mono), label (small muted), trend (colored directional text).
|
||||
|
||||
### ProgressBar
|
||||
|
||||
4px track with fill animation. Variants: `blue`, `teal`, `purple`, `amber`. Full ARIA progressbar semantics.
|
||||
|
||||
### FilterTabs
|
||||
|
||||
Segmented control with active/hover states.
|
||||
|
||||
### SectionHeader
|
||||
|
||||
Flex row with title/subtitle on left, action slot on right.
|
||||
|
||||
### DataTable
|
||||
|
||||
Generic typed table with column render functions, row hover, and row click support.
|
||||
|
||||
### LogLine
|
||||
|
||||
3-column grid (timestamp, level, message) with color-coded log levels.
|
||||
|
||||
## Dashboard Page Layout
|
||||
|
||||
The dashboard page uses:
|
||||
|
||||
1. **MetricsStrip** (full width, 6 cells)
|
||||
2. **Two-column grid** (`1fr 320px`):
|
||||
- Main: OrchestratorSessions + QuickActions
|
||||
- Sidebar: ActivityFeed + TokenBudget
|
||||
|
||||
## Terminal Panel
|
||||
|
||||
Bottom drawer component (`apps/web/src/components/terminal/TerminalPanel.tsx`) with:
|
||||
|
||||
- Height animation (0 → 280px)
|
||||
- Tab bar (main, build, logs)
|
||||
- Color-coded output lines (prompt, command, output, error, warning, success)
|
||||
- Blinking cursor animation
|
||||
@@ -9,17 +9,15 @@ Images are tagged based on branch and event type:
|
||||
| Trigger | Tags Applied | Example |
|
||||
| ----------------- | ----------------- | -------------------- |
|
||||
| Push to `main` | `{sha}`, `latest` | `658ec077`, `latest` |
|
||||
| Push to `develop` | `{sha}`, `dev` | `a1b2c3d4`, `dev` |
|
||||
| Git tag (release) | `{sha}`, `{tag}` | `658ec077`, `v1.0.0` |
|
||||
|
||||
### Tag Meanings
|
||||
|
||||
| Tag | Purpose | Stability |
|
||||
| -------------------------- | ------------------------------------------ | --------- |
|
||||
| `latest` | Current production-ready build from `main` | Stable |
|
||||
| `dev` | Current development build from `develop` | Unstable |
|
||||
| `v*` (e.g., `v1.0.0`) | Versioned release | Immutable |
|
||||
| `{sha}` (e.g., `658ec077`) | Specific commit for traceability | Immutable |
|
||||
| Tag | Purpose | Stability |
|
||||
| -------------------------- | ---------------------------------- | --------- |
|
||||
| `latest` | Current build from `main` | Latest |
|
||||
| `v*` (e.g., `v1.0.0`) | Versioned release | Immutable |
|
||||
| `{sha}` (e.g., `658ec077`) | Specific commit for traceability | Immutable |
|
||||
|
||||
## Retention Policy Configuration
|
||||
|
||||
|
||||
63
docs/scratchpads/ms15-dashboard-shell.md
Normal file
63
docs/scratchpads/ms15-dashboard-shell.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# MS15 — Dashboard Shell & Design System
|
||||
|
||||
## Objective
|
||||
|
||||
Implement the dashboard.html reference design across the Mosaic Stack web app. Establish the design token system, app shell layout, shared components, and dashboard page.
|
||||
|
||||
## Design Reference
|
||||
|
||||
`/home/jwoltje/src/mosaic-stack-website/docs/designs/round-5/claude/01/dashboard.html`
|
||||
|
||||
## Key Architectural Decisions
|
||||
|
||||
1. **Theme approach**: CSS custom properties via `:root` + `[data-theme="light"]`. Tailwind configured to use these variables. ThemeProvider updated to set `data-theme` attribute on `<html>`.
|
||||
2. **Logo placement**: Logo in full-width header (topbar), NOT in sidebar. User spec overrides reference design.
|
||||
3. **Sidebar collapse**: Collapsible with icon-only mode. Hidden by default at mobile breakpoints. Hamburger button for small screens.
|
||||
4. **Fonts**: Outfit (body) + Fira Code (mono). Loaded via `next/font/google`.
|
||||
5. **Loading spinner**: Mosaic logo icon component with CSS rotation animation.
|
||||
6. **Grain overlay**: Subtle noise texture via CSS pseudo-element, same as reference.
|
||||
7. **Per-phase branches**: `feat/ms15-design-system`, `feat/ms15-shared-components`, `feat/ms15-dashboard-page`.
|
||||
|
||||
## Phases
|
||||
|
||||
### Phase 1: Foundation (Design System & App Shell)
|
||||
|
||||
- MS15-FE-001: Design token system overhaul
|
||||
- MS15-FE-002: App shell grid layout
|
||||
- MS15-FE-003: Sidebar component
|
||||
- MS15-FE-004: Topbar/Header component
|
||||
- MS15-FE-005: Responsive breakpoints
|
||||
- MS15-FE-006: Loading spinner (Mosaic logo)
|
||||
|
||||
### Phase 2: Shared Components
|
||||
|
||||
- MS15-UI-001: packages/ui token alignment
|
||||
- MS15-UI-002: Card, Badge, Button, Dot updates
|
||||
- MS15-UI-003: MetricsStrip, ProgressBar, FilterTabs
|
||||
- MS15-UI-004: SectionHeader, Table, LogLine
|
||||
- MS15-UI-005: Terminal panel component
|
||||
|
||||
### Phase 3: Dashboard Page
|
||||
|
||||
- MS15-DASH-001: Metrics strip
|
||||
- MS15-DASH-002: Active Orchestrator Sessions
|
||||
- MS15-DASH-003: Quick Actions
|
||||
- MS15-DASH-004: Activity Feed
|
||||
- MS15-DASH-005: Token Budget
|
||||
|
||||
### Phase 4: Quality
|
||||
|
||||
- MS15-QA-001: Baseline tests
|
||||
- MS15-QA-002: Situational tests
|
||||
- MS15-DOC-001: Documentation
|
||||
|
||||
## Progress Log
|
||||
|
||||
- Session started: 2026-02-22
|
||||
- Status: Bootstrap phase
|
||||
|
||||
## Risks
|
||||
|
||||
- Large surface area: globals.css change affects all existing pages
|
||||
- packages/ui color system mismatch requires careful migration
|
||||
- Existing pages (login, auth) may need adjustment after token changes
|
||||
45
docs/scratchpads/ms15-fe-006-mosaic-logo-spinner.md
Normal file
45
docs/scratchpads/ms15-fe-006-mosaic-logo-spinner.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# MS15-FE-006: MosaicLogo and MosaicSpinner Components
|
||||
|
||||
## Task
|
||||
|
||||
Create Mosaic logo icon component and spinner wrapper for use as the site-wide loading indicator.
|
||||
|
||||
## Files to Create
|
||||
|
||||
1. `apps/web/src/components/ui/MosaicLogo.tsx` — 5-element logo icon
|
||||
2. `apps/web/src/components/ui/MosaicSpinner.tsx` — spinner wrapper
|
||||
|
||||
## Files to Modify
|
||||
|
||||
1. `apps/web/src/app/(authenticated)/layout.tsx` — replace loading spinner (isLoading block only)
|
||||
|
||||
## Design
|
||||
|
||||
- 4 corner squares: blue (TL), purple (TR), teal (BR), amber (BL)
|
||||
- 1 center circle: pink
|
||||
- CSS vars: --ms-blue-500, --ms-purple-500, --ms-teal-500, --ms-amber-500, --ms-pink-500
|
||||
- Animation: linear 360deg rotation
|
||||
|
||||
## Props
|
||||
|
||||
### MosaicLogo
|
||||
|
||||
- size?: number (default 36)
|
||||
- spinning?: boolean (default false)
|
||||
- spinDuration?: number (default 20) seconds
|
||||
- className?: string
|
||||
|
||||
### MosaicSpinner
|
||||
|
||||
- Wraps MosaicLogo with spinning=true
|
||||
- label?: string — optional text label below
|
||||
- fullPage?: boolean — center on screen
|
||||
|
||||
## Status
|
||||
|
||||
- [x] Scratchpad created
|
||||
- [ ] MosaicLogo.tsx created
|
||||
- [ ] MosaicSpinner.tsx created
|
||||
- [ ] layout.tsx updated
|
||||
- [ ] Lint clean
|
||||
- [ ] Committed and pushed
|
||||
@@ -1,5 +1,36 @@
|
||||
# Tasks
|
||||
|
||||
## MS15-DashboardShell (0.0.15) — Dashboard Shell & Design System
|
||||
|
||||
**Orchestrator:** Claude Code (Opus 4.6)
|
||||
**Started:** 2026-02-22
|
||||
**Branch:** feat/ms15-design-system (Phase 1), feat/ms15-shared-components (Phase 2), feat/ms15-dashboard-page (Phase 3)
|
||||
**Milestone:** MS15-DashboardShell (0.0.15)
|
||||
**PRD:** docs/PRD.md
|
||||
|
||||
| id | status | description | issue | repo | branch | depends_on | blocks | agent | started_at | completed_at | estimate | used | notes |
|
||||
| ------------- | ------ | -------------------------------------------------------------------------------------------- | ----- | ---- | --------------------------- | ----------------------- | ----------------------------------------------- | ----- | ----------------- | ----------------- | -------- | ---- | --------------------------------------------------------------- |
|
||||
| MS15-FE-001 | done | Design token system overhaul (globals.css → dashboard.html tokens, dark/light, fonts) | #448 | web | feat/ms15-design-system | | MS15-FE-002,MS15-FE-003,MS15-FE-004,MS15-UI-001 | w-1 | 2026-02-22T14:30Z | 2026-02-22T15:00Z | 25K | 18K | Combined with FE-002. Commit e615fa8. Build passes. |
|
||||
| MS15-FE-002 | done | App shell grid layout (sidebar + full-width header + main content) | #448 | web | feat/ms15-design-system | MS15-FE-001 | MS15-FE-003,MS15-FE-004,MS15-FE-005 | w-1 | 2026-02-22T14:30Z | 2026-02-22T15:00Z | 20K | 0K | Combined with FE-001 (same commit). |
|
||||
| MS15-FE-003 | done | Sidebar component (collapsible, nav groups, icons, badges, user card footer) | #448 | web | feat/ms15-design-system | MS15-FE-002 | MS15-FE-005 | w-3 | 2026-02-22T15:30Z | 2026-02-22T16:00Z | 25K | 67K | 4 nav groups, SidebarContext, collapse toggle. Commit 04f9918. |
|
||||
| MS15-FE-004 | done | Topbar/Header component (logo, search, status, notifications, theme toggle, avatar dropdown) | #448 | web | feat/ms15-design-system | MS15-FE-002 | MS15-FE-005 | w-4 | 2026-02-22T15:30Z | 2026-02-22T16:00Z | 25K | 44K | Search, status, notifications, avatar dropdown. Commit 04f9918. |
|
||||
| MS15-FE-005 | done | Responsive layout (breakpoints, hamburger, sidebar auto-hide at mobile) | #448 | web | feat/ms15-design-system | MS15-FE-003,MS15-FE-004 | MS15-QA-001 | w-5 | 2026-02-22T16:00Z | 2026-02-22T16:30Z | 20K | 57K | Mobile overlay, hamburger, matchMedia. Commit 28620b2. |
|
||||
| MS15-FE-006 | done | Loading spinner (Mosaic logo icon with rotation animation, site-wide) | #448 | web | feat/ms15-design-system | MS15-FE-001 | | w-2 | 2026-02-22T14:30Z | 2026-02-22T15:00Z | 10K | 8K | MosaicLogo + MosaicSpinner components. Same commit e615fa8. |
|
||||
| MS15-UI-001 | done | Align packages/ui tokens with new CSS variable design system | #449 | ui | feat/ms15-shared-components | MS15-FE-001 | MS15-UI-002,MS15-UI-003,MS15-UI-004 | w-6 | 2026-02-22T17:00Z | 2026-02-22T17:30Z | 20K | 35K | Combined with UI-002. Commit 44011f4. Build passes. |
|
||||
| MS15-UI-002 | done | Update Card, Badge, Button, Dot component variants to match reference | #449 | ui | feat/ms15-shared-components | MS15-UI-001 | MS15-DASH-001 | w-6 | 2026-02-22T17:00Z | 2026-02-22T17:30Z | 25K | 0K | Combined with UI-001 (same commit 44011f4). |
|
||||
| MS15-UI-003 | done | Create MetricsStrip, ProgressBar, FilterTabs shared components | #449 | ui | feat/ms15-shared-components | MS15-UI-001 | MS15-DASH-001 | w-7 | 2026-02-22T17:30Z | 2026-02-22T18:00Z | 20K | 30K | Commit 9b0445c. Build passes. |
|
||||
| MS15-UI-004 | done | Create SectionHeader, Table, LogLine shared components | #449 | ui | feat/ms15-shared-components | MS15-UI-001 | MS15-DASH-002 | w-8 | 2026-02-22T17:30Z | 2026-02-22T18:00Z | 15K | 25K | Commit 9b0445c. Build passes. |
|
||||
| MS15-UI-005 | done | Create Terminal panel component (bottom drawer, tabs, output) | #449 | web | feat/ms15-shared-components | MS15-UI-001 | | w-9 | 2026-02-22T17:30Z | 2026-02-22T18:00Z | 20K | 30K | Commit 9b0445c. Build passes. |
|
||||
| MS15-DASH-001 | done | Dashboard metrics strip (6 cells, colored borders, values, trends) | #450 | web | feat/ms15-dashboard-page | MS15-UI-002,MS15-UI-003 | | w-10 | 2026-02-22T19:00Z | 2026-02-22T19:30Z | 15K | 12K | DashboardMetrics.tsx. Commit 4500f80. Build passes. |
|
||||
| MS15-DASH-002 | done | Active Orchestrator Sessions card with agent nodes | #450 | web | feat/ms15-dashboard-page | MS15-UI-004 | | w-10 | 2026-02-22T19:00Z | 2026-02-22T19:30Z | 20K | 15K | OrchestratorSessions.tsx. Commit 4500f80. |
|
||||
| MS15-DASH-003 | done | Quick Actions 2x2 grid | #450 | web | feat/ms15-dashboard-page | MS15-UI-002 | | w-10 | 2026-02-22T19:00Z | 2026-02-22T19:30Z | 10K | 8K | QuickActions.tsx. Commit 4500f80. |
|
||||
| MS15-DASH-004 | done | Activity Feed sidebar card | #450 | web | feat/ms15-dashboard-page | MS15-UI-002 | | w-10 | 2026-02-22T19:00Z | 2026-02-22T19:30Z | 15K | 10K | ActivityFeed.tsx. Commit 4500f80. |
|
||||
| MS15-DASH-005 | done | Token Budget sidebar card with progress bars | #450 | web | feat/ms15-dashboard-page | MS15-UI-003 | | w-10 | 2026-02-22T19:00Z | 2026-02-22T19:30Z | 10K | 8K | TokenBudget.tsx. Commit 4500f80. |
|
||||
| MS15-QA-001 | done | Baseline tests (lint, typecheck, build) and situational tests (responsive, themes) | #448 | web | feat/ms15-design-system | MS15-FE-005 | | orch | 2026-02-22T16:30Z | 2026-02-22T16:35Z | 15K | 2K | lint 0 errors, typecheck clean, build passes. All caches hit. |
|
||||
| MS15-DOC-001 | done | Documentation: design system reference, component docs | #448 | docs | feat/ms15-docs | MS15-QA-001 | | orch | 2026-02-22T19:40Z | 2026-02-22T19:45Z | 10K | 3K | docs/design/design-system.md. Commit a7a2708. |
|
||||
|
||||
---
|
||||
|
||||
## M10-Telemetry (0.0.10) — Telemetry Integration
|
||||
|
||||
**Orchestrator:** Claude Code
|
||||
|
||||
165
packages/ui/src/components/AuthSurface.tsx
Normal file
165
packages/ui/src/components/AuthSurface.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
import type { HTMLAttributes, ReactElement, ReactNode } from "react";
|
||||
|
||||
function joinClassNames(...classNames: (string | undefined)[]): string {
|
||||
return classNames.filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
export interface AuthShellProps extends HTMLAttributes<HTMLElement> {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function AuthShell({ children, className, ...props }: AuthShellProps): ReactElement {
|
||||
return (
|
||||
<main
|
||||
className={joinClassNames(
|
||||
"relative isolate flex min-h-screen items-center justify-center overflow-hidden bg-[#f0f4fc] px-4 py-8 text-[#0f141d] sm:px-8 dark:bg-[#080b12] dark:text-[#eef3ff]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div aria-hidden="true" className="pointer-events-none absolute inset-0 overflow-hidden">
|
||||
<div
|
||||
className="absolute left-1/2 top-1/2 h-[52rem] w-[52rem] -translate-x-1/2 -translate-y-1/2 rounded-full opacity-20 blur-[2px] animate-spin"
|
||||
style={{
|
||||
background:
|
||||
"conic-gradient(from 0deg, #2f80ff, #8b5cf6, #ec4899, #e5484d, #f59e0b, #14b8a6, #2f80ff)",
|
||||
animationDuration: "30s",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="absolute left-1/2 top-1/2 h-[36rem] w-[36rem] -translate-x-1/2 -translate-y-1/2 rounded-full opacity-20 blur-[1px] animate-spin"
|
||||
style={{
|
||||
background:
|
||||
"conic-gradient(from 120deg, #14b8a6, #06b6d4, #2f80ff, #6366f1, #8b5cf6, #14b8a6)",
|
||||
animationDuration: "20s",
|
||||
animationDirection: "reverse",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="absolute left-1/2 top-1/2 h-[22rem] w-[22rem] -translate-x-1/2 -translate-y-1/2 rounded-full opacity-25 animate-spin"
|
||||
style={{
|
||||
background:
|
||||
"conic-gradient(from 240deg, #f59e0b, #f97316, #e5484d, #ec4899, #8b5cf6, #f59e0b)",
|
||||
animationDuration: "14s",
|
||||
}}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_center,rgba(248,250,255,0.58)_0%,rgba(15,20,29,0.72)_62%,rgba(15,20,29,0.9)_100%)] dark:bg-[radial-gradient(ellipse_at_center,rgba(8,11,18,0.32)_0%,rgba(8,11,18,0.78)_62%,rgba(8,11,18,0.96)_100%)]" />
|
||||
</div>
|
||||
<div className="relative z-10 w-full max-w-[27rem]">{children}</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export interface AuthCardProps extends HTMLAttributes<HTMLDivElement> {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function AuthCard({ children, className, ...props }: AuthCardProps): ReactElement {
|
||||
return (
|
||||
<div
|
||||
className={joinClassNames(
|
||||
"relative w-full overflow-hidden rounded-b-2xl border border-[#b8c4de] bg-[#dde4f2]/90 p-6 shadow-[0_30px_70px_rgba(15,20,29,0.24)] backdrop-blur-sm sm:p-10 dark:border-[#2f3b52] dark:bg-[#1b2331]/[0.92] dark:shadow-[0_32px_80px_rgba(0,0,0,0.52)]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-x-0 top-0 h-0.5 bg-[linear-gradient(90deg,#2f80ff,#8b5cf6,#14b8a6,#f59e0b)]"
|
||||
/>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface AuthBrandProps {
|
||||
title?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function AuthBrand({ title = "Mosaic Stack", className }: AuthBrandProps): ReactElement {
|
||||
return (
|
||||
<div className={joinClassNames("flex items-center justify-center gap-3", className)}>
|
||||
<div className="relative h-9 w-9 animate-spin" style={{ animationDuration: "20s" }}>
|
||||
<span className="absolute left-0 top-0 h-[0.88rem] w-[0.88rem] rounded-[3px] bg-[#2f80ff]" />
|
||||
<span className="absolute right-0 top-0 h-[0.88rem] w-[0.88rem] rounded-[3px] bg-[#8b5cf6]" />
|
||||
<span className="absolute bottom-0 right-0 h-[0.88rem] w-[0.88rem] rounded-[3px] bg-[#14b8a6]" />
|
||||
<span className="absolute bottom-0 left-0 h-[0.88rem] w-[0.88rem] rounded-[3px] bg-[#f59e0b]" />
|
||||
<span className="absolute left-1/2 top-1/2 h-3 w-3 -translate-x-1/2 -translate-y-1/2 rounded-full bg-[#ec4899]" />
|
||||
</div>
|
||||
<span className="bg-[linear-gradient(135deg,#56a0ff,#8b5cf6,#14b8a6)] bg-clip-text text-xl font-extrabold tracking-tight text-transparent">
|
||||
{title}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export type AuthStatusTone = "neutral" | "info" | "success" | "warning" | "danger";
|
||||
|
||||
export interface AuthStatusPillProps {
|
||||
label: string;
|
||||
tone?: AuthStatusTone;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function AuthStatusPill({
|
||||
label,
|
||||
tone = "neutral",
|
||||
className,
|
||||
}: AuthStatusPillProps): ReactElement {
|
||||
const toneStyles: Record<AuthStatusTone, string> = {
|
||||
neutral:
|
||||
"border-[#b8c4de] bg-[#f8faff] text-[#2f3b52] dark:border-[#2f3b52] dark:bg-[#0f141d]/70 dark:text-[#c5d0e6]",
|
||||
info: "border-sky-400/50 bg-sky-500/15 text-sky-900 dark:text-sky-200",
|
||||
success: "border-emerald-400/55 bg-emerald-500/15 text-emerald-900 dark:text-emerald-200",
|
||||
warning: "border-amber-400/60 bg-amber-500/15 text-amber-900 dark:text-amber-200",
|
||||
danger: "border-rose-400/55 bg-rose-500/15 text-rose-900 dark:text-rose-200",
|
||||
};
|
||||
|
||||
const dotStyles: Record<AuthStatusTone, string> = {
|
||||
neutral: "bg-[#5a6a87] dark:bg-[#8f9db7]",
|
||||
info: "bg-sky-500",
|
||||
success: "bg-emerald-500",
|
||||
warning: "bg-amber-500",
|
||||
danger: "bg-rose-500",
|
||||
};
|
||||
|
||||
return (
|
||||
<span
|
||||
className={joinClassNames(
|
||||
"inline-flex items-center gap-2 rounded-full border px-2.5 py-1 text-[0.67rem] font-semibold uppercase tracking-[0.08em]",
|
||||
toneStyles[tone],
|
||||
className
|
||||
)}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={joinClassNames("h-1.5 w-1.5 rounded-full", dotStyles[tone])}
|
||||
/>
|
||||
<span>{label}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export interface AuthDividerProps {
|
||||
text?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function AuthDivider({
|
||||
text = "or continue with",
|
||||
className,
|
||||
}: AuthDividerProps): ReactElement {
|
||||
return (
|
||||
<div
|
||||
className={joinClassNames(
|
||||
"py-8 flex items-center gap-3 text-[0.67rem] font-semibold uppercase tracking-[0.08em] text-[#5a6a87] dark:text-[#8f9db7]",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<span aria-hidden="true" className="h-px flex-1 bg-[#b8c4de] dark:bg-[#2f3b52]" />
|
||||
<span>{text}</span>
|
||||
<span aria-hidden="true" className="h-px flex-1 bg-[#b8c4de] dark:bg-[#2f3b52]" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -15,41 +15,51 @@ export function Avatar({
|
||||
fallback,
|
||||
initials,
|
||||
className = "",
|
||||
style,
|
||||
...props
|
||||
}: AvatarProps): ReactElement {
|
||||
const sizeStyles = {
|
||||
type AvatarSize = "sm" | "md" | "lg" | "xl";
|
||||
const sizeStyles: Record<AvatarSize, string> = {
|
||||
sm: "w-6 h-6 text-xs",
|
||||
md: "w-8 h-8 text-sm",
|
||||
lg: "w-12 h-12 text-base",
|
||||
xl: "w-16 h-16 text-xl",
|
||||
};
|
||||
|
||||
const baseStyles =
|
||||
"rounded-full overflow-hidden flex items-center justify-center bg-gray-200 font-medium text-gray-600";
|
||||
const baseClass = `rounded-full overflow-hidden flex items-center justify-center font-medium ${sizeStyles[size]} ${className}`;
|
||||
|
||||
const combinedClassName = [baseStyles, sizeStyles[size], className].filter(Boolean).join(" ");
|
||||
const gradientStyle: React.CSSProperties = {
|
||||
background: "linear-gradient(135deg, var(--ms-blue-500), var(--ms-purple-500))",
|
||||
color: "#fff",
|
||||
...style,
|
||||
};
|
||||
|
||||
if (src) {
|
||||
return <img src={src} alt={alt} className={`${combinedClassName} object-cover`} {...props} />;
|
||||
return (
|
||||
<img src={src} alt={alt} className={`${baseClass} object-cover`} style={style} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
if (fallback) {
|
||||
return <div className={combinedClassName}>{fallback}</div>;
|
||||
return (
|
||||
<div className={baseClass} style={gradientStyle}>
|
||||
{fallback}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (initials) {
|
||||
return <div className={combinedClassName}>{initials}</div>;
|
||||
return (
|
||||
<div className={baseClass} style={gradientStyle}>
|
||||
{initials}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Default fallback with user icon
|
||||
return (
|
||||
<div className={combinedClassName}>
|
||||
<svg
|
||||
className="w-1/2 h-1/2 text-gray-400"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div className={baseClass} style={gradientStyle}>
|
||||
<svg className="w-1/2 h-1/2" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z"
|
||||
|
||||
@@ -8,38 +8,199 @@ export type BadgeVariant =
|
||||
| "status-warning"
|
||||
| "status-error"
|
||||
| "status-info"
|
||||
| "status-neutral";
|
||||
| "status-neutral"
|
||||
| "badge-teal"
|
||||
| "badge-amber"
|
||||
| "badge-red"
|
||||
| "badge-blue"
|
||||
| "badge-muted"
|
||||
| "badge-purple"
|
||||
| "badge-pulse";
|
||||
|
||||
export interface BadgeProps extends HTMLAttributes<HTMLSpanElement> {
|
||||
variant?: BadgeVariant;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const variantStyles: Record<BadgeVariant, string> = {
|
||||
"priority-high": "bg-red-100 text-red-800 border-red-200",
|
||||
"priority-medium": "bg-yellow-100 text-yellow-800 border-yellow-200",
|
||||
"priority-low": "bg-green-100 text-green-800 border-green-200",
|
||||
"status-success": "bg-green-100 text-green-800 border-green-200",
|
||||
"status-warning": "bg-yellow-100 text-yellow-800 border-yellow-200",
|
||||
"status-error": "bg-red-100 text-red-800 border-red-200",
|
||||
"status-info": "bg-blue-100 text-blue-800 border-blue-200",
|
||||
"status-neutral": "bg-gray-100 text-gray-800 border-gray-200",
|
||||
interface BadgeStyleDef {
|
||||
style: React.CSSProperties;
|
||||
pulse?: boolean;
|
||||
}
|
||||
|
||||
const variantDefs: Record<BadgeVariant, BadgeStyleDef> = {
|
||||
"priority-high": {
|
||||
style: {
|
||||
background: "rgba(229,72,77,0.12)",
|
||||
color: "var(--ms-red-400)",
|
||||
border: "1px solid rgba(229,72,77,0.2)",
|
||||
},
|
||||
},
|
||||
"priority-medium": {
|
||||
style: {
|
||||
background: "rgba(245,158,11,0.12)",
|
||||
color: "var(--ms-amber-400)",
|
||||
border: "1px solid rgba(245,158,11,0.2)",
|
||||
},
|
||||
},
|
||||
"priority-low": {
|
||||
style: {
|
||||
background: "rgba(20,184,166,0.12)",
|
||||
color: "var(--ms-teal-400)",
|
||||
border: "1px solid rgba(20,184,166,0.2)",
|
||||
},
|
||||
},
|
||||
"status-success": {
|
||||
style: {
|
||||
background: "rgba(20,184,166,0.12)",
|
||||
color: "var(--ms-teal-400)",
|
||||
border: "1px solid rgba(20,184,166,0.2)",
|
||||
},
|
||||
},
|
||||
"status-warning": {
|
||||
style: {
|
||||
background: "rgba(245,158,11,0.12)",
|
||||
color: "var(--ms-amber-400)",
|
||||
border: "1px solid rgba(245,158,11,0.2)",
|
||||
},
|
||||
},
|
||||
"status-error": {
|
||||
style: {
|
||||
background: "rgba(229,72,77,0.12)",
|
||||
color: "var(--ms-red-400)",
|
||||
border: "1px solid rgba(229,72,77,0.2)",
|
||||
},
|
||||
},
|
||||
"status-info": {
|
||||
style: {
|
||||
background: "rgba(47,128,255,0.12)",
|
||||
color: "var(--ms-blue-400)",
|
||||
border: "1px solid rgba(47,128,255,0.2)",
|
||||
},
|
||||
},
|
||||
"status-neutral": {
|
||||
style: {
|
||||
background: "var(--surface)",
|
||||
color: "var(--muted)",
|
||||
border: "1px solid var(--border)",
|
||||
},
|
||||
},
|
||||
"badge-teal": {
|
||||
style: {
|
||||
background: "rgba(20,184,166,0.12)",
|
||||
color: "var(--ms-teal-400)",
|
||||
border: "1px solid rgba(20,184,166,0.2)",
|
||||
},
|
||||
},
|
||||
"badge-amber": {
|
||||
style: {
|
||||
background: "rgba(245,158,11,0.12)",
|
||||
color: "var(--ms-amber-400)",
|
||||
border: "1px solid rgba(245,158,11,0.2)",
|
||||
},
|
||||
},
|
||||
"badge-red": {
|
||||
style: {
|
||||
background: "rgba(229,72,77,0.12)",
|
||||
color: "var(--ms-red-400)",
|
||||
border: "1px solid rgba(229,72,77,0.2)",
|
||||
},
|
||||
},
|
||||
"badge-blue": {
|
||||
style: {
|
||||
background: "rgba(47,128,255,0.12)",
|
||||
color: "var(--ms-blue-400)",
|
||||
border: "1px solid rgba(47,128,255,0.2)",
|
||||
},
|
||||
},
|
||||
"badge-muted": {
|
||||
style: {
|
||||
background: "var(--surface)",
|
||||
color: "var(--muted)",
|
||||
border: "1px solid var(--border)",
|
||||
},
|
||||
},
|
||||
"badge-purple": {
|
||||
style: {
|
||||
background: "rgba(139,92,246,0.12)",
|
||||
color: "var(--ms-purple-400)",
|
||||
border: "1px solid rgba(139,92,246,0.2)",
|
||||
},
|
||||
},
|
||||
"badge-pulse": {
|
||||
style: {
|
||||
background: "rgba(47,128,255,0.12)",
|
||||
color: "var(--ms-blue-400)",
|
||||
border: "1px solid rgba(47,128,255,0.2)",
|
||||
},
|
||||
pulse: true,
|
||||
},
|
||||
};
|
||||
|
||||
const pulseKeyframes = `
|
||||
@keyframes ms-badge-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.3; }
|
||||
}
|
||||
`;
|
||||
|
||||
let pulseStyleInjected = false;
|
||||
|
||||
function ensurePulseStyle(): void {
|
||||
if (pulseStyleInjected || typeof document === "undefined") return;
|
||||
const styleEl = document.createElement("style");
|
||||
styleEl.textContent = pulseKeyframes;
|
||||
document.head.appendChild(styleEl);
|
||||
pulseStyleInjected = true;
|
||||
}
|
||||
|
||||
export function Badge({
|
||||
variant = "status-neutral",
|
||||
children,
|
||||
className = "",
|
||||
style,
|
||||
...props
|
||||
}: BadgeProps): ReactElement {
|
||||
const baseStyles =
|
||||
"inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium border";
|
||||
const combinedClassName = [baseStyles, variantStyles[variant], className]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
const def = variantDefs[variant];
|
||||
|
||||
if (def.pulse) {
|
||||
ensurePulseStyle();
|
||||
}
|
||||
|
||||
const baseStyle: React.CSSProperties = {
|
||||
fontSize: "0.7rem",
|
||||
fontWeight: 600,
|
||||
fontFamily: "var(--mono)",
|
||||
padding: "2px 8px",
|
||||
borderRadius: "20px",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "5px",
|
||||
...def.style,
|
||||
...style,
|
||||
};
|
||||
|
||||
return (
|
||||
<span className={combinedClassName} role="status" aria-label={children as string} {...props}>
|
||||
<span
|
||||
className={className}
|
||||
style={baseStyle}
|
||||
role="status"
|
||||
aria-label={children as string}
|
||||
{...props}
|
||||
>
|
||||
{def.pulse && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
display: "inline-block",
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: "50%",
|
||||
background: "var(--ms-blue-400)",
|
||||
flexShrink: 0,
|
||||
animation: "ms-badge-pulse 1.4s ease-in-out infinite",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -1,39 +1,120 @@
|
||||
import type { ButtonHTMLAttributes, ReactNode, ReactElement } from "react";
|
||||
import { useState, type ButtonHTMLAttributes, type ReactNode, type ReactElement } from "react";
|
||||
|
||||
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: "primary" | "secondary" | "danger" | "ghost";
|
||||
variant?: "primary" | "secondary" | "ghost" | "danger" | "success";
|
||||
size?: "sm" | "md" | "lg";
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
interface VariantStyle {
|
||||
base: React.CSSProperties;
|
||||
hover: React.CSSProperties;
|
||||
}
|
||||
|
||||
type ButtonVariant = "primary" | "secondary" | "ghost" | "danger" | "success";
|
||||
|
||||
const variantStyles: Record<ButtonVariant, VariantStyle> = {
|
||||
primary: {
|
||||
base: {
|
||||
background: "var(--ms-blue-500)",
|
||||
color: "#fff",
|
||||
border: "none",
|
||||
},
|
||||
hover: {
|
||||
background: "var(--ms-blue-400)",
|
||||
boxShadow: "0 4px 16px rgba(47,128,255,0.3)",
|
||||
},
|
||||
},
|
||||
secondary: {
|
||||
base: {
|
||||
background: "transparent",
|
||||
border: "1px solid var(--border)",
|
||||
color: "var(--text-2)",
|
||||
},
|
||||
hover: {
|
||||
background: "var(--surface)",
|
||||
color: "var(--text)",
|
||||
},
|
||||
},
|
||||
ghost: {
|
||||
base: {
|
||||
background: "transparent",
|
||||
border: "1px solid var(--border)",
|
||||
color: "var(--text-2)",
|
||||
},
|
||||
hover: {
|
||||
background: "var(--surface)",
|
||||
color: "var(--text)",
|
||||
},
|
||||
},
|
||||
danger: {
|
||||
base: {
|
||||
background: "rgba(229,72,77,0.12)",
|
||||
border: "1px solid rgba(229,72,77,0.3)",
|
||||
color: "var(--danger)",
|
||||
},
|
||||
hover: {
|
||||
background: "rgba(229,72,77,0.2)",
|
||||
},
|
||||
},
|
||||
success: {
|
||||
base: {
|
||||
background: "rgba(20,184,166,0.12)",
|
||||
border: "1px solid rgba(20,184,166,0.3)",
|
||||
color: "var(--success)",
|
||||
},
|
||||
hover: {
|
||||
background: "rgba(20,184,166,0.2)",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
type ButtonSize = "sm" | "md" | "lg";
|
||||
|
||||
const sizeStyles: Record<ButtonSize, string> = {
|
||||
sm: "px-3 py-1.5 text-sm",
|
||||
md: "px-4 py-2 text-base",
|
||||
lg: "px-6 py-3 text-lg",
|
||||
};
|
||||
|
||||
export function Button({
|
||||
variant = "primary",
|
||||
size = "md",
|
||||
children,
|
||||
className = "",
|
||||
style,
|
||||
onMouseEnter,
|
||||
onMouseLeave,
|
||||
disabled,
|
||||
...props
|
||||
}: ButtonProps): ReactElement {
|
||||
const baseStyles = "inline-flex items-center justify-center font-medium rounded-md";
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
const variantStyles = {
|
||||
primary: "bg-blue-600 text-white hover:bg-blue-700",
|
||||
secondary: "bg-gray-200 text-gray-900 hover:bg-gray-300",
|
||||
danger: "bg-red-600 text-white hover:bg-red-700",
|
||||
ghost: "bg-transparent text-gray-700 hover:bg-gray-100 border border-gray-300",
|
||||
const vStyles = variantStyles[variant];
|
||||
const baseClass = `inline-flex items-center justify-center font-medium rounded-md transition-colors ${sizeStyles[size]} ${className}`;
|
||||
|
||||
const computedStyle: React.CSSProperties = {
|
||||
...vStyles.base,
|
||||
...(isHovered && !disabled ? vStyles.hover : {}),
|
||||
...(disabled ? { opacity: 0.5, cursor: "not-allowed" } : { cursor: "pointer" }),
|
||||
...style,
|
||||
};
|
||||
|
||||
const sizeStyles = {
|
||||
sm: "px-3 py-1.5 text-sm",
|
||||
md: "px-4 py-2 text-base",
|
||||
lg: "px-6 py-3 text-lg",
|
||||
};
|
||||
|
||||
const combinedClassName = [baseStyles, variantStyles[variant], sizeStyles[size], className]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
return (
|
||||
<button className={combinedClassName} {...props}>
|
||||
<button
|
||||
className={baseClass}
|
||||
style={computedStyle}
|
||||
disabled={disabled}
|
||||
onMouseEnter={(e) => {
|
||||
setIsHovered(true);
|
||||
onMouseEnter?.(e);
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
setIsHovered(false);
|
||||
onMouseLeave?.(e);
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { ReactNode, ReactElement } from "react";
|
||||
export interface CardProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
id?: string;
|
||||
onMouseEnter?: () => void;
|
||||
onMouseLeave?: () => void;
|
||||
@@ -11,21 +12,25 @@ export interface CardProps {
|
||||
export interface CardHeaderProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
export interface CardContentProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
export interface CardFooterProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
export function Card({
|
||||
children,
|
||||
className = "",
|
||||
style,
|
||||
id,
|
||||
onMouseEnter,
|
||||
onMouseLeave,
|
||||
@@ -35,24 +40,52 @@ export function Card({
|
||||
id={id}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
className={`bg-white rounded-lg shadow-md border border-gray-200 ${className}`}
|
||||
className={className}
|
||||
style={{
|
||||
background: "var(--surface)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "var(--r-lg)",
|
||||
padding: "16px",
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CardHeader({ children, className = "" }: CardHeaderProps): ReactElement {
|
||||
return <div className={`px-6 py-4 border-b border-gray-200 ${className}`}>{children}</div>;
|
||||
}
|
||||
|
||||
export function CardContent({ children, className = "" }: CardContentProps): ReactElement {
|
||||
return <div className={`px-6 py-4 ${className}`}>{children}</div>;
|
||||
}
|
||||
|
||||
export function CardFooter({ children, className = "" }: CardFooterProps): ReactElement {
|
||||
export function CardHeader({ children, className = "", style }: CardHeaderProps): ReactElement {
|
||||
return (
|
||||
<div className={`px-6 py-4 border-t border-gray-200 bg-gray-50 rounded-b-lg ${className}`}>
|
||||
<div
|
||||
className={`px-6 py-4 ${className}`}
|
||||
style={{
|
||||
borderBottom: "1px solid var(--border)",
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CardContent({ children, className = "", style }: CardContentProps): ReactElement {
|
||||
return (
|
||||
<div className={`px-6 py-4 ${className}`} style={style}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CardFooter({ children, className = "", style }: CardFooterProps): ReactElement {
|
||||
return (
|
||||
<div
|
||||
className={`px-6 py-4 rounded-b-lg ${className}`}
|
||||
style={{
|
||||
borderTop: "1px solid var(--border)",
|
||||
background: "var(--bg-mid)",
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
102
packages/ui/src/components/DataTable.tsx
Normal file
102
packages/ui/src/components/DataTable.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import { useState, type ReactElement, type ReactNode } from "react";
|
||||
|
||||
export interface DataTableColumn<T> {
|
||||
key: string;
|
||||
header: string;
|
||||
render?: (row: T) => ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface DataTableProps<T extends Record<string, unknown>> {
|
||||
columns: DataTableColumn<T>[];
|
||||
data: T[];
|
||||
className?: string;
|
||||
onRowClick?: (row: T) => void;
|
||||
}
|
||||
|
||||
export function DataTable<T extends Record<string, unknown>>({
|
||||
columns,
|
||||
data,
|
||||
className = "",
|
||||
onRowClick,
|
||||
}: DataTableProps<T>): ReactElement {
|
||||
const [hoveredRow, setHoveredRow] = useState<number | null>(null);
|
||||
|
||||
return (
|
||||
<table
|
||||
className={className}
|
||||
style={{
|
||||
width: "100%",
|
||||
borderCollapse: "collapse",
|
||||
fontSize: "0.83rem",
|
||||
}}
|
||||
>
|
||||
<thead>
|
||||
<tr>
|
||||
{columns.map((col) => (
|
||||
<th
|
||||
key={col.key}
|
||||
className={col.className}
|
||||
style={{
|
||||
textAlign: "left",
|
||||
padding: "8px 12px",
|
||||
fontSize: "0.7rem",
|
||||
fontWeight: 600,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.07em",
|
||||
color: "var(--muted)",
|
||||
borderBottom: "1px solid var(--border)",
|
||||
background: "var(--surface)",
|
||||
}}
|
||||
>
|
||||
{col.header}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.map((row, rowIndex) => {
|
||||
const isLast = rowIndex === data.length - 1;
|
||||
const isHovered = hoveredRow === rowIndex;
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={rowIndex}
|
||||
style={{
|
||||
background: isHovered ? "var(--surface)" : undefined,
|
||||
cursor: onRowClick !== undefined ? "pointer" : undefined,
|
||||
}}
|
||||
onClick={
|
||||
onRowClick !== undefined
|
||||
? (): void => {
|
||||
onRowClick(row);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onMouseEnter={(): void => {
|
||||
setHoveredRow(rowIndex);
|
||||
}}
|
||||
onMouseLeave={(): void => {
|
||||
setHoveredRow(null);
|
||||
}}
|
||||
>
|
||||
{columns.map((col) => (
|
||||
<td
|
||||
key={col.key}
|
||||
className={col.className}
|
||||
style={{
|
||||
padding: "10px 12px",
|
||||
borderBottom: isLast ? undefined : "1px solid var(--border)",
|
||||
color: isHovered ? "var(--text)" : "var(--text-2)",
|
||||
}}
|
||||
>
|
||||
{col.render !== undefined ? col.render(row) : (row[col.key] as ReactNode)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
39
packages/ui/src/components/Dot.tsx
Normal file
39
packages/ui/src/components/Dot.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { ReactElement } from "react";
|
||||
|
||||
export type DotVariant = "teal" | "blue" | "amber" | "red" | "muted";
|
||||
|
||||
export interface DotProps {
|
||||
variant?: DotVariant;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface DotColorDef {
|
||||
bg: string;
|
||||
shadow: string;
|
||||
}
|
||||
|
||||
export function Dot({ variant = "muted", className = "" }: DotProps): ReactElement {
|
||||
const colors: Record<DotVariant, DotColorDef> = {
|
||||
teal: { bg: "var(--success)", shadow: "0 0 5px var(--success)" },
|
||||
blue: { bg: "var(--primary)", shadow: "0 0 5px var(--primary)" },
|
||||
amber: { bg: "var(--warn)", shadow: "0 0 5px var(--warn)" },
|
||||
red: { bg: "var(--danger)", shadow: "0 0 5px var(--danger)" },
|
||||
muted: { bg: "var(--muted)", shadow: "none" },
|
||||
};
|
||||
|
||||
const { bg, shadow } = colors[variant];
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`inline-block ${className}`}
|
||||
style={{
|
||||
width: 7,
|
||||
height: 7,
|
||||
borderRadius: "50%",
|
||||
background: bg,
|
||||
boxShadow: shadow,
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
}
|
||||
85
packages/ui/src/components/FilterTabs.tsx
Normal file
85
packages/ui/src/components/FilterTabs.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import { useState } from "react";
|
||||
import type { ReactElement } from "react";
|
||||
|
||||
export interface FilterTab {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface FilterTabsProps {
|
||||
tabs: FilterTab[];
|
||||
activeTab: string;
|
||||
onTabChange: (id: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function FilterTabItem({
|
||||
tab,
|
||||
isActive,
|
||||
onClick,
|
||||
}: {
|
||||
tab: FilterTab;
|
||||
isActive: boolean;
|
||||
onClick: () => void;
|
||||
}): ReactElement {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
onMouseEnter={(): void => {
|
||||
setHovered(true);
|
||||
}}
|
||||
onMouseLeave={(): void => {
|
||||
setHovered(false);
|
||||
}}
|
||||
style={{
|
||||
padding: "5px 14px",
|
||||
borderRadius: 6,
|
||||
fontSize: "0.8rem",
|
||||
fontWeight: 600,
|
||||
color: isActive ? "var(--text)" : hovered ? "var(--text-2)" : "var(--muted)",
|
||||
background: isActive ? "var(--surface-2)" : "transparent",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
transition: "color 0.15s ease, background 0.15s ease",
|
||||
outline: "none",
|
||||
}}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function FilterTabs({
|
||||
tabs,
|
||||
activeTab,
|
||||
onTabChange,
|
||||
className = "",
|
||||
}: FilterTabsProps): ReactElement {
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
gap: 2,
|
||||
padding: 3,
|
||||
background: "var(--surface)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "var(--r)",
|
||||
}}
|
||||
>
|
||||
{tabs.map((tab) => (
|
||||
<FilterTabItem
|
||||
key={tab.id}
|
||||
tab={tab}
|
||||
isActive={tab.id === activeTab}
|
||||
onClick={(): void => {
|
||||
onTabChange(tab.id);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { forwardRef } from "react";
|
||||
import { useState, forwardRef } from "react";
|
||||
import type { InputHTMLAttributes, ReactElement } from "react";
|
||||
|
||||
export interface InputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "size"> {
|
||||
@@ -9,44 +9,75 @@ export interface InputProps extends Omit<InputHTMLAttributes<HTMLInputElement>,
|
||||
}
|
||||
|
||||
export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
|
||||
{ label, error, helperText, fullWidth = false, className = "", id, ...props },
|
||||
{
|
||||
label,
|
||||
error,
|
||||
helperText,
|
||||
fullWidth = false,
|
||||
className = "",
|
||||
id,
|
||||
style,
|
||||
onFocus,
|
||||
onBlur,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
): ReactElement {
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const inputId = id ?? `input-${Math.random().toString(36).substring(2, 11)}`;
|
||||
const errorId = error ? `${inputId}-error` : undefined;
|
||||
const helperId = helperText ? `${inputId}-helper` : undefined;
|
||||
|
||||
const baseStyles =
|
||||
"px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors";
|
||||
const widthStyles = fullWidth ? "w-full" : "";
|
||||
const errorStyles = error ? "border-red-500 focus:ring-red-500" : "border-gray-300";
|
||||
const inputStyle: React.CSSProperties = {
|
||||
background: "var(--bg-mid)",
|
||||
border: error
|
||||
? `1px solid var(--danger)`
|
||||
: isFocused
|
||||
? `1px solid var(--primary)`
|
||||
: `1px solid var(--border)`,
|
||||
color: "var(--text)",
|
||||
outline: "none",
|
||||
boxShadow: isFocused ? `0 0 0 2px rgba(47,128,255,0.2)` : "none",
|
||||
...style,
|
||||
};
|
||||
|
||||
const combinedClassName = [baseStyles, widthStyles, errorStyles, className]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
const widthClass = fullWidth ? "w-full" : "";
|
||||
|
||||
return (
|
||||
<div className={fullWidth ? "w-full" : ""}>
|
||||
{label && (
|
||||
<label htmlFor={inputId} className="block text-sm font-medium text-gray-700 mb-1">
|
||||
<label
|
||||
htmlFor={inputId}
|
||||
className="block text-sm font-medium mb-1"
|
||||
style={{ color: "var(--text-2)" }}
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<input
|
||||
ref={ref}
|
||||
id={inputId}
|
||||
className={combinedClassName}
|
||||
className={`px-3 py-2 rounded-md transition-colors ${widthClass} ${className}`}
|
||||
style={inputStyle}
|
||||
aria-invalid={error ? "true" : "false"}
|
||||
aria-describedby={[errorId, helperId].filter(Boolean).join(" ") || undefined}
|
||||
onFocus={(e) => {
|
||||
setIsFocused(true);
|
||||
onFocus?.(e);
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
setIsFocused(false);
|
||||
onBlur?.(e);
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
{error && (
|
||||
<p id={errorId} className="mt-1 text-sm text-red-600" role="alert">
|
||||
<p id={errorId} className="mt-1 text-sm" style={{ color: "var(--danger)" }} role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{helperText && !error && (
|
||||
<p id={helperId} className="mt-1 text-sm text-gray-500">
|
||||
<p id={helperId} className="mt-1 text-sm" style={{ color: "var(--muted)" }}>
|
||||
{helperText}
|
||||
</p>
|
||||
)}
|
||||
|
||||
40
packages/ui/src/components/LogLine.tsx
Normal file
40
packages/ui/src/components/LogLine.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { ReactElement } from "react";
|
||||
|
||||
export type LogLevel = "info" | "warn" | "error" | "debug" | "success";
|
||||
|
||||
export interface LogLineProps {
|
||||
timestamp: string;
|
||||
level: LogLevel;
|
||||
message: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const levelColors: Record<LogLevel, string> = {
|
||||
info: "var(--primary-l)",
|
||||
warn: "var(--warn)",
|
||||
error: "var(--danger)",
|
||||
debug: "var(--muted)",
|
||||
success: "var(--success)",
|
||||
};
|
||||
|
||||
export function LogLine({ timestamp, level, message, className = "" }: LogLineProps): ReactElement {
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "100px 50px 1fr",
|
||||
gap: 10,
|
||||
padding: "4px 0",
|
||||
fontFamily: "var(--mono)",
|
||||
fontSize: "0.76rem",
|
||||
borderBottom: "1px solid rgba(47,59,82,0.3)",
|
||||
alignItems: "start",
|
||||
}}
|
||||
>
|
||||
<span style={{ color: "var(--muted)" }}>{timestamp}</span>
|
||||
<span style={{ fontWeight: 600, color: levelColors[level] }}>{level}</span>
|
||||
<span style={{ color: "var(--text-2)", wordBreak: "break-all" }}>{message}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
99
packages/ui/src/components/MetricsStrip.tsx
Normal file
99
packages/ui/src/components/MetricsStrip.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import { useState } from "react";
|
||||
import type { ReactElement } from "react";
|
||||
|
||||
export interface MetricCell {
|
||||
label: string;
|
||||
value: string;
|
||||
color: string; // CSS color, e.g., "var(--ms-blue-400)"
|
||||
trend?: {
|
||||
direction: "up" | "down" | "neutral";
|
||||
text: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface MetricsStripProps {
|
||||
cells: MetricCell[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function MetricCellItem({ cell, isFirst }: { cell: MetricCell; isFirst: boolean }): ReactElement {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
|
||||
const trendColor =
|
||||
cell.trend?.direction === "up"
|
||||
? "var(--success)"
|
||||
: cell.trend?.direction === "down"
|
||||
? "var(--danger)"
|
||||
: "var(--muted)";
|
||||
|
||||
return (
|
||||
<div
|
||||
onMouseEnter={(): void => {
|
||||
setHovered(true);
|
||||
}}
|
||||
onMouseLeave={(): void => {
|
||||
setHovered(false);
|
||||
}}
|
||||
style={{
|
||||
padding: "14px 16px",
|
||||
background: hovered ? "var(--surface-2)" : "var(--surface)",
|
||||
borderLeft: isFirst ? "none" : "1px solid var(--border)",
|
||||
borderTop: `2px solid ${cell.color}`,
|
||||
transition: "background 0.15s ease",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "1.4rem",
|
||||
fontWeight: 800,
|
||||
fontFamily: "var(--mono)",
|
||||
lineHeight: 1.1,
|
||||
color: cell.color,
|
||||
}}
|
||||
>
|
||||
{cell.value}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "0.72rem",
|
||||
color: "var(--muted)",
|
||||
marginTop: 3,
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{cell.label}
|
||||
</div>
|
||||
{cell.trend && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: "0.68rem",
|
||||
fontFamily: "var(--mono)",
|
||||
marginTop: 4,
|
||||
color: trendColor,
|
||||
}}
|
||||
>
|
||||
{cell.trend.text}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MetricsStrip({ cells, className = "" }: MetricsStripProps): ReactElement {
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: `repeat(${String(cells.length)}, 1fr)`,
|
||||
borderRadius: "var(--r-lg)",
|
||||
overflow: "hidden",
|
||||
border: "1px solid var(--border)",
|
||||
}}
|
||||
>
|
||||
{cells.map((cell, index) => (
|
||||
<MetricCellItem key={cell.label} cell={cell} isFirst={index === 0} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -25,7 +25,8 @@ export function Modal({
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
const modalId = useRef(`modal-${Math.random().toString(36).substring(2, 11)}`);
|
||||
|
||||
const sizeStyles = {
|
||||
type ModalSize = "sm" | "md" | "lg" | "xl" | "full";
|
||||
const sizeStyles: Record<ModalSize, string> = {
|
||||
sm: "max-w-md",
|
||||
md: "max-w-lg",
|
||||
lg: "max-w-2xl",
|
||||
@@ -65,7 +66,8 @@ export function Modal({
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50 p-4"
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
style={{ background: "rgba(0,0,0,0.5)" }}
|
||||
onClick={handleOverlayClick}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
@@ -75,18 +77,30 @@ export function Modal({
|
||||
<div
|
||||
ref={dialogRef}
|
||||
tabIndex={-1}
|
||||
className={`bg-white rounded-lg shadow-xl w-full ${sizeStyles[size]} ${className}`}
|
||||
className={`rounded-lg w-full ${sizeStyles[size]} ${className}`}
|
||||
style={{
|
||||
background: "var(--surface)",
|
||||
border: "1px solid var(--border)",
|
||||
}}
|
||||
role="document"
|
||||
>
|
||||
{title && (
|
||||
<div className="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
|
||||
<h2 id={`${modalId.current}-title`} className="text-lg font-semibold text-gray-900">
|
||||
<div
|
||||
className="px-6 py-4 flex items-center justify-between"
|
||||
style={{ borderBottom: "1px solid var(--border)" }}
|
||||
>
|
||||
<h2
|
||||
id={`${modalId.current}-title`}
|
||||
className="text-lg font-semibold"
|
||||
style={{ color: "var(--text)" }}
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 transition-colors p-1 rounded hover:bg-gray-100"
|
||||
className="transition-colors p-1 rounded"
|
||||
style={{ color: "var(--muted)" }}
|
||||
aria-label="Close modal"
|
||||
>
|
||||
<svg
|
||||
@@ -108,7 +122,13 @@ export function Modal({
|
||||
)}
|
||||
<div className="px-6 py-4 max-h-[70vh] overflow-y-auto">{children}</div>
|
||||
{footer && (
|
||||
<div className="px-6 py-4 border-t border-gray-200 bg-gray-50 rounded-b-lg flex justify-end gap-2">
|
||||
<div
|
||||
className="px-6 py-4 rounded-b-lg flex justify-end gap-2"
|
||||
style={{
|
||||
borderTop: "1px solid var(--border)",
|
||||
background: "var(--bg-mid)",
|
||||
}}
|
||||
>
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
|
||||
53
packages/ui/src/components/ProgressBar.tsx
Normal file
53
packages/ui/src/components/ProgressBar.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import type { ReactElement } from "react";
|
||||
|
||||
export type ProgressBarVariant = "blue" | "teal" | "purple" | "amber";
|
||||
|
||||
export interface ProgressBarProps {
|
||||
value: number; // 0-100
|
||||
variant?: ProgressBarVariant;
|
||||
className?: string;
|
||||
label?: string; // screen reader label
|
||||
}
|
||||
|
||||
const variantColors: Record<ProgressBarVariant, string> = {
|
||||
blue: "var(--primary)",
|
||||
teal: "var(--success)",
|
||||
purple: "var(--purple)",
|
||||
amber: "var(--warn)",
|
||||
};
|
||||
|
||||
export function ProgressBar({
|
||||
value,
|
||||
variant = "blue",
|
||||
className = "",
|
||||
label,
|
||||
}: ProgressBarProps): ReactElement {
|
||||
const clampedValue = Math.min(100, Math.max(0, value));
|
||||
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
role="progressbar"
|
||||
aria-valuenow={clampedValue}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-label={label}
|
||||
style={{
|
||||
height: 4,
|
||||
borderRadius: 4,
|
||||
background: "var(--surface-2)",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: "100%",
|
||||
borderRadius: 4,
|
||||
width: `${String(clampedValue)}%`,
|
||||
background: variantColors[variant],
|
||||
transition: "width 0.4s ease",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
62
packages/ui/src/components/SectionHeader.tsx
Normal file
62
packages/ui/src/components/SectionHeader.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import type { ReactElement, ReactNode } from "react";
|
||||
|
||||
export interface SectionHeaderProps {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
actions?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SectionHeader({
|
||||
title,
|
||||
subtitle,
|
||||
actions,
|
||||
className = "",
|
||||
}: SectionHeaderProps): ReactElement {
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "0.95rem",
|
||||
fontWeight: 700,
|
||||
color: "var(--text)",
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
{subtitle !== undefined && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: "0.78rem",
|
||||
color: "var(--muted)",
|
||||
marginTop: 2,
|
||||
}}
|
||||
>
|
||||
{subtitle}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{actions !== undefined && (
|
||||
<div
|
||||
style={{
|
||||
marginLeft: "auto",
|
||||
display: "flex",
|
||||
gap: 8,
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
{actions}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import type { SelectHTMLAttributes, ReactElement } from "react";
|
||||
|
||||
export interface SelectOption {
|
||||
@@ -24,33 +25,56 @@ export function Select({
|
||||
placeholder = "Select an option...",
|
||||
className = "",
|
||||
id,
|
||||
style,
|
||||
onFocus,
|
||||
onBlur,
|
||||
...props
|
||||
}: SelectProps): ReactElement {
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const selectId = id ?? `select-${Math.random().toString(36).substring(2, 11)}`;
|
||||
const errorId = error ? `${selectId}-error` : undefined;
|
||||
const helperId = helperText ? `${selectId}-helper` : undefined;
|
||||
|
||||
const baseStyles =
|
||||
"px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors bg-white";
|
||||
const widthStyles = fullWidth ? "w-full" : "";
|
||||
const errorStyles = error ? "border-red-500 focus:ring-red-500" : "border-gray-300";
|
||||
const selectStyle: React.CSSProperties = {
|
||||
background: "var(--bg-mid)",
|
||||
border: error
|
||||
? `1px solid var(--danger)`
|
||||
: isFocused
|
||||
? `1px solid var(--primary)`
|
||||
: `1px solid var(--border)`,
|
||||
color: "var(--text)",
|
||||
outline: "none",
|
||||
boxShadow: isFocused ? `0 0 0 2px rgba(47,128,255,0.2)` : "none",
|
||||
...style,
|
||||
};
|
||||
|
||||
const combinedClassName = [baseStyles, widthStyles, errorStyles, className]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
const widthClass = fullWidth ? "w-full" : "";
|
||||
|
||||
return (
|
||||
<div className={fullWidth ? "w-full" : ""}>
|
||||
{label && (
|
||||
<label htmlFor={selectId} className="block text-sm font-medium text-gray-700 mb-1">
|
||||
<label
|
||||
htmlFor={selectId}
|
||||
className="block text-sm font-medium mb-1"
|
||||
style={{ color: "var(--text-2)" }}
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<select
|
||||
id={selectId}
|
||||
className={combinedClassName}
|
||||
className={`px-3 py-2 rounded-md transition-colors ${widthClass} ${className}`}
|
||||
style={selectStyle}
|
||||
aria-invalid={error ? "true" : "false"}
|
||||
aria-describedby={[errorId, helperId].filter(Boolean).join(" ") || undefined}
|
||||
onFocus={(e) => {
|
||||
setIsFocused(true);
|
||||
onFocus?.(e);
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
setIsFocused(false);
|
||||
onBlur?.(e);
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<option value="" disabled>
|
||||
@@ -63,12 +87,12 @@ export function Select({
|
||||
))}
|
||||
</select>
|
||||
{error && (
|
||||
<p id={errorId} className="mt-1 text-sm text-red-600" role="alert">
|
||||
<p id={errorId} className="mt-1 text-sm" style={{ color: "var(--danger)" }} role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{helperText && !error && (
|
||||
<p id={helperId} className="mt-1 text-sm text-gray-500">
|
||||
<p id={helperId} className="mt-1 text-sm" style={{ color: "var(--muted)" }}>
|
||||
{helperText}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import type { TextareaHTMLAttributes, ReactElement } from "react";
|
||||
|
||||
export interface TextareaProps extends Omit<TextareaHTMLAttributes<HTMLTextAreaElement>, "size"> {
|
||||
@@ -16,48 +17,72 @@ export function Textarea({
|
||||
resize = "vertical",
|
||||
className = "",
|
||||
id,
|
||||
style,
|
||||
onFocus,
|
||||
onBlur,
|
||||
...props
|
||||
}: TextareaProps): ReactElement {
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const textareaId = id ?? `textarea-${Math.random().toString(36).substring(2, 11)}`;
|
||||
const errorId = error ? `${textareaId}-error` : undefined;
|
||||
const helperId = helperText ? `${textareaId}-helper` : undefined;
|
||||
|
||||
const baseStyles =
|
||||
"px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors";
|
||||
const widthStyles = fullWidth ? "w-full" : "";
|
||||
const resizeStyles = {
|
||||
const resizeStyles: Record<string, string> = {
|
||||
none: "resize-none",
|
||||
both: "resize",
|
||||
horizontal: "resize-x",
|
||||
vertical: "resize-y",
|
||||
};
|
||||
const errorStyles = error ? "border-red-500 focus:ring-red-500" : "border-gray-300";
|
||||
|
||||
const combinedClassName = [baseStyles, widthStyles, resizeStyles[resize], errorStyles, className]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
const textareaStyle: React.CSSProperties = {
|
||||
background: "var(--bg-mid)",
|
||||
border: error
|
||||
? `1px solid var(--danger)`
|
||||
: isFocused
|
||||
? `1px solid var(--primary)`
|
||||
: `1px solid var(--border)`,
|
||||
color: "var(--text)",
|
||||
outline: "none",
|
||||
boxShadow: isFocused ? `0 0 0 2px rgba(47,128,255,0.2)` : "none",
|
||||
...style,
|
||||
};
|
||||
|
||||
const widthClass = fullWidth ? "w-full" : "";
|
||||
|
||||
return (
|
||||
<div className={fullWidth ? "w-full" : ""}>
|
||||
{label && (
|
||||
<label htmlFor={textareaId} className="block text-sm font-medium text-gray-700 mb-1">
|
||||
<label
|
||||
htmlFor={textareaId}
|
||||
className="block text-sm font-medium mb-1"
|
||||
style={{ color: "var(--text-2)" }}
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<textarea
|
||||
id={textareaId}
|
||||
className={combinedClassName}
|
||||
className={`px-3 py-2 rounded-md transition-colors ${widthClass} ${resizeStyles[resize] ?? "resize-y"} ${className}`}
|
||||
style={textareaStyle}
|
||||
aria-invalid={error ? "true" : "false"}
|
||||
aria-describedby={[errorId, helperId].filter(Boolean).join(" ") || undefined}
|
||||
onFocus={(e) => {
|
||||
setIsFocused(true);
|
||||
onFocus?.(e);
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
setIsFocused(false);
|
||||
onBlur?.(e);
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
{error && (
|
||||
<p id={errorId} className="mt-1 text-sm text-red-600" role="alert">
|
||||
<p id={errorId} className="mt-1 text-sm" style={{ color: "var(--danger)" }} role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{helperText && !error && (
|
||||
<p id={helperId} className="mt-1 text-sm text-gray-500">
|
||||
<p id={helperId} className="mt-1 text-sm" style={{ color: "var(--muted)" }}>
|
||||
{helperText}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -97,13 +97,37 @@ interface ToastItemProps {
|
||||
onRemove: (id: string) => void;
|
||||
}
|
||||
|
||||
interface ToastVariantStyle {
|
||||
background: string;
|
||||
border: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
const variantStyles: Record<ToastVariant, ToastVariantStyle> = {
|
||||
success: {
|
||||
background: "rgba(20,184,166,0.15)",
|
||||
border: "1px solid rgba(20,184,166,0.35)",
|
||||
color: "var(--success)",
|
||||
},
|
||||
error: {
|
||||
background: "rgba(229,72,77,0.15)",
|
||||
border: "1px solid rgba(229,72,77,0.35)",
|
||||
color: "var(--danger)",
|
||||
},
|
||||
warning: {
|
||||
background: "rgba(245,158,11,0.15)",
|
||||
border: "1px solid rgba(245,158,11,0.35)",
|
||||
color: "var(--warn)",
|
||||
},
|
||||
info: {
|
||||
background: "rgba(47,128,255,0.15)",
|
||||
border: "1px solid rgba(47,128,255,0.35)",
|
||||
color: "var(--info)",
|
||||
},
|
||||
};
|
||||
|
||||
function ToastItem({ toast, onRemove }: ToastItemProps): ReactElement {
|
||||
const variantStyles: Record<ToastVariant, string> = {
|
||||
success: "bg-green-500 text-white border-green-600",
|
||||
error: "bg-red-500 text-white border-red-600",
|
||||
warning: "bg-yellow-500 text-white border-yellow-600",
|
||||
info: "bg-blue-500 text-white border-blue-600",
|
||||
};
|
||||
const vStyle = variantStyles[toast.variant ?? "info"];
|
||||
|
||||
const icon: Record<ToastVariant, ReactNode> = {
|
||||
success: (
|
||||
@@ -146,7 +170,12 @@ function ToastItem({ toast, onRemove }: ToastItemProps): ReactElement {
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${variantStyles[toast.variant ?? "info"]} border rounded-md shadow-lg px-4 py-3 flex items-center gap-3 min-w-[300px] max-w-md`}
|
||||
className="rounded-md px-4 py-3 flex items-center gap-3 min-w-[300px] max-w-md"
|
||||
style={{
|
||||
background: vStyle.background,
|
||||
border: vStyle.border,
|
||||
color: vStyle.color,
|
||||
}}
|
||||
role="alert"
|
||||
>
|
||||
<span className="flex-shrink-0">{icon[toast.variant ?? "info"]}</span>
|
||||
@@ -155,7 +184,7 @@ function ToastItem({ toast, onRemove }: ToastItemProps): ReactElement {
|
||||
onClick={() => {
|
||||
onRemove(toast.id);
|
||||
}}
|
||||
className="flex-shrink-0 opacity-70 hover:opacity-100 transition-opacity p-0.5 rounded hover:bg-white/20"
|
||||
className="flex-shrink-0 opacity-70 hover:opacity-100 transition-opacity p-0.5 rounded"
|
||||
aria-label="Close notification"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
|
||||
|
||||
@@ -43,3 +43,48 @@ export type {
|
||||
ToastContextValue,
|
||||
ToastProviderProps,
|
||||
} from "./components/Toast.js";
|
||||
|
||||
// Auth Surface
|
||||
export {
|
||||
AuthShell,
|
||||
AuthCard,
|
||||
AuthBrand,
|
||||
AuthStatusPill,
|
||||
AuthDivider,
|
||||
} from "./components/AuthSurface.js";
|
||||
export type {
|
||||
AuthShellProps,
|
||||
AuthCardProps,
|
||||
AuthBrandProps,
|
||||
AuthStatusTone,
|
||||
AuthStatusPillProps,
|
||||
AuthDividerProps,
|
||||
} from "./components/AuthSurface.js";
|
||||
|
||||
// Dot
|
||||
export { Dot } from "./components/Dot.js";
|
||||
export type { DotProps, DotVariant } from "./components/Dot.js";
|
||||
|
||||
// MetricsStrip
|
||||
export { MetricsStrip } from "./components/MetricsStrip.js";
|
||||
export type { MetricsStripProps, MetricCell } from "./components/MetricsStrip.js";
|
||||
|
||||
// ProgressBar
|
||||
export { ProgressBar } from "./components/ProgressBar.js";
|
||||
export type { ProgressBarProps, ProgressBarVariant } from "./components/ProgressBar.js";
|
||||
|
||||
// FilterTabs
|
||||
export { FilterTabs } from "./components/FilterTabs.js";
|
||||
export type { FilterTabsProps, FilterTab } from "./components/FilterTabs.js";
|
||||
|
||||
// SectionHeader
|
||||
export { SectionHeader } from "./components/SectionHeader.js";
|
||||
export type { SectionHeaderProps } from "./components/SectionHeader.js";
|
||||
|
||||
// DataTable
|
||||
export { DataTable } from "./components/DataTable.js";
|
||||
export type { DataTableColumn, DataTableProps } from "./components/DataTable.js";
|
||||
|
||||
// LogLine
|
||||
export { LogLine } from "./components/LogLine.js";
|
||||
export type { LogLineProps, LogLevel } from "./components/LogLine.js";
|
||||
|
||||
482
pnpm-lock.yaml
generated
482
pnpm-lock.yaml
generated
@@ -403,7 +403,7 @@ importers:
|
||||
version: 12.10.0(@types/react@19.2.10)(immer@11.1.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
better-auth:
|
||||
specifier: ^1.4.17
|
||||
version: 1.4.17(@prisma/client@6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.6.2)(drizzle-orm@0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(postgres@3.4.8)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)))(next@16.1.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(pg@8.17.2)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@3.2.4(@types/node@22.19.7)(jiti@2.6.1)(jsdom@26.1.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
|
||||
version: 1.4.17(@prisma/client@6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.6.2)(drizzle-orm@0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(postgres@3.4.8)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)))(next@16.1.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(pg@8.17.2)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@3.2.4(@types/node@22.19.7)(jiti@1.21.7)(jsdom@26.1.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
|
||||
date-fns:
|
||||
specifier: ^4.1.0
|
||||
version: 4.1.0
|
||||
@@ -464,19 +464,28 @@ importers:
|
||||
version: 2.1.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@vitejs/plugin-react':
|
||||
specifier: ^4.3.4
|
||||
version: 4.7.0(vite@7.3.1(@types/node@22.19.7)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
|
||||
version: 4.7.0(vite@7.3.1(@types/node@22.19.7)(jiti@1.21.7)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
|
||||
'@vitest/coverage-v8':
|
||||
specifier: ^3.2.4
|
||||
version: 3.2.4(vitest@3.2.4(@types/node@22.19.7)(jiti@2.6.1)(jsdom@26.1.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
|
||||
version: 3.2.4(vitest@3.2.4(@types/node@22.19.7)(jiti@1.21.7)(jsdom@26.1.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
|
||||
autoprefixer:
|
||||
specifier: ^10.4.24
|
||||
version: 10.4.24(postcss@8.5.6)
|
||||
jsdom:
|
||||
specifier: ^26.0.0
|
||||
version: 26.1.0
|
||||
postcss:
|
||||
specifier: ^8.5.6
|
||||
version: 8.5.6
|
||||
tailwindcss:
|
||||
specifier: ^3.4.19
|
||||
version: 3.4.19(tsx@4.21.0)(yaml@2.8.2)
|
||||
typescript:
|
||||
specifier: ^5.8.2
|
||||
version: 5.9.3
|
||||
vitest:
|
||||
specifier: ^3.0.8
|
||||
version: 3.2.4(@types/node@22.19.7)(jiti@2.6.1)(jsdom@26.1.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
|
||||
version: 3.2.4(@types/node@22.19.7)(jiti@1.21.7)(jsdom@26.1.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
|
||||
|
||||
packages/cli-tools: {}
|
||||
|
||||
@@ -558,6 +567,10 @@ packages:
|
||||
'@adobe/css-tools@4.4.4':
|
||||
resolution: {integrity: sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==}
|
||||
|
||||
'@alloc/quick-lru@5.2.0':
|
||||
resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
'@ampproject/remapping@2.3.0':
|
||||
resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
@@ -1748,6 +1761,18 @@ packages:
|
||||
resolution: {integrity: sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==}
|
||||
engines: {node: '>= 20.19.0'}
|
||||
|
||||
'@nodelib/fs.scandir@2.1.5':
|
||||
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
|
||||
engines: {node: '>= 8'}
|
||||
|
||||
'@nodelib/fs.stat@2.0.5':
|
||||
resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==}
|
||||
engines: {node: '>= 8'}
|
||||
|
||||
'@nodelib/fs.walk@1.2.8':
|
||||
resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
|
||||
engines: {node: '>= 8'}
|
||||
|
||||
'@nuxt/opencollective@0.4.1':
|
||||
resolution: {integrity: sha512-GXD3wy50qYbxCJ652bDrDzgMr3NFEkIS374+IgFQKkCvk9yiYcLvX2XDYr7UyQxf4wK0e+yqDYRubZ0DtOxnmQ==}
|
||||
engines: {node: ^14.18.0 || >=16.10.0, npm: '>=5.10.0'}
|
||||
@@ -3353,6 +3378,13 @@ packages:
|
||||
resolution: {integrity: sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
any-promise@1.3.0:
|
||||
resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==}
|
||||
|
||||
anymatch@3.1.3:
|
||||
resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==}
|
||||
engines: {node: '>= 8'}
|
||||
|
||||
append-field@1.0.0:
|
||||
resolution: {integrity: sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==}
|
||||
|
||||
@@ -3367,6 +3399,9 @@ packages:
|
||||
arg@4.1.3:
|
||||
resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==}
|
||||
|
||||
arg@5.0.2:
|
||||
resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==}
|
||||
|
||||
argparse@1.0.10:
|
||||
resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==}
|
||||
|
||||
@@ -3412,6 +3447,13 @@ packages:
|
||||
asynckit@0.4.0:
|
||||
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
|
||||
|
||||
autoprefixer@10.4.24:
|
||||
resolution: {integrity: sha512-uHZg7N9ULTVbutaIsDRoUkoS8/h3bdsmVJYZ5l3wv8Cp/6UIIoRDm90hZ+BwxUj/hGBEzLxdHNSKuFpn8WOyZw==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
postcss: ^8.1.0
|
||||
|
||||
aws-sign2@0.7.0:
|
||||
resolution: {integrity: sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA==}
|
||||
|
||||
@@ -3536,6 +3578,10 @@ packages:
|
||||
bignumber.js@9.3.1:
|
||||
resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==}
|
||||
|
||||
binary-extensions@2.3.0:
|
||||
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
bindings@1.5.0:
|
||||
resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==}
|
||||
|
||||
@@ -3630,6 +3676,10 @@ packages:
|
||||
resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
camelcase-css@2.0.1:
|
||||
resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
caniuse-lite@1.0.30001766:
|
||||
resolution: {integrity: sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA==}
|
||||
|
||||
@@ -3670,6 +3720,10 @@ packages:
|
||||
chevrotain@11.0.3:
|
||||
resolution: {integrity: sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw==}
|
||||
|
||||
chokidar@3.6.0:
|
||||
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
|
||||
engines: {node: '>= 8.10.0'}
|
||||
|
||||
chokidar@4.0.3:
|
||||
resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
|
||||
engines: {node: '>= 14.16.0'}
|
||||
@@ -3893,6 +3947,11 @@ packages:
|
||||
css.escape@1.5.1:
|
||||
resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==}
|
||||
|
||||
cssesc@3.0.0:
|
||||
resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
|
||||
engines: {node: '>=4'}
|
||||
hasBin: true
|
||||
|
||||
cssstyle@4.6.0:
|
||||
resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -4167,6 +4226,9 @@ packages:
|
||||
dezalgo@1.0.4:
|
||||
resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==}
|
||||
|
||||
didyoumean@1.2.2:
|
||||
resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==}
|
||||
|
||||
diff@4.0.4:
|
||||
resolution: {integrity: sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==}
|
||||
engines: {node: '>=0.3.1'}
|
||||
@@ -4178,6 +4240,9 @@ packages:
|
||||
resolution: {integrity: sha512-2l0gsPOLPs5t6GFZfQZKnL1OJNYFcuC/ETWsW4VtKVD/tg4ICa9x+jb9bkPffkMdRpRpuUaO/fKkHCBeiCKh8g==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
dlv@1.1.3:
|
||||
resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==}
|
||||
|
||||
docker-modem@5.0.6:
|
||||
resolution: {integrity: sha512-ens7BiayssQz/uAxGzH8zGXCtiV24rRWXdjNha5V4zSOcxmAZsfGVm/PPFbwQdqEkDnhG+SyR9E3zSHUbOKXBQ==}
|
||||
engines: {node: '>= 8.0'}
|
||||
@@ -4575,6 +4640,10 @@ packages:
|
||||
fast-fifo@1.3.2:
|
||||
resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==}
|
||||
|
||||
fast-glob@3.3.3:
|
||||
resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==}
|
||||
engines: {node: '>=8.6.0'}
|
||||
|
||||
fast-json-stable-stringify@2.1.0:
|
||||
resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==}
|
||||
|
||||
@@ -4587,6 +4656,9 @@ packages:
|
||||
fast-uri@3.1.0:
|
||||
resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==}
|
||||
|
||||
fastq@1.20.1:
|
||||
resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==}
|
||||
|
||||
fdir@6.5.0:
|
||||
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
@@ -4668,6 +4740,9 @@ packages:
|
||||
resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
fraction.js@5.3.4:
|
||||
resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==}
|
||||
|
||||
fresh@0.5.2:
|
||||
resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==}
|
||||
engines: {node: '>= 0.6'}
|
||||
@@ -4738,6 +4813,10 @@ packages:
|
||||
github-slugger@2.0.0:
|
||||
resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==}
|
||||
|
||||
glob-parent@5.1.2:
|
||||
resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
glob-parent@6.0.2:
|
||||
resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
@@ -4904,6 +4983,10 @@ packages:
|
||||
is-arrayish@0.2.1:
|
||||
resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==}
|
||||
|
||||
is-binary-path@2.1.0:
|
||||
resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
is-core-module@2.16.1:
|
||||
resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -5010,6 +5093,10 @@ packages:
|
||||
resolution: {integrity: sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==}
|
||||
engines: {node: '>= 10.13.0'}
|
||||
|
||||
jiti@1.21.7:
|
||||
resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==}
|
||||
hasBin: true
|
||||
|
||||
jiti@2.6.1:
|
||||
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
|
||||
hasBin: true
|
||||
@@ -5141,6 +5228,10 @@ packages:
|
||||
resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
lilconfig@3.1.3:
|
||||
resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
lines-and-columns@1.2.4:
|
||||
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
|
||||
|
||||
@@ -5307,6 +5398,10 @@ packages:
|
||||
merge-stream@2.0.0:
|
||||
resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==}
|
||||
|
||||
merge2@1.4.1:
|
||||
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
|
||||
engines: {node: '>= 8'}
|
||||
|
||||
mermaid@11.12.2:
|
||||
resolution: {integrity: sha512-n34QPDPEKmaeCG4WDMGy0OT6PSyxKCfy2pJgShP+Qow2KLrvWjclwbc3yXfSIf4BanqWEhQEpngWwNp/XhZt6w==}
|
||||
|
||||
@@ -5417,6 +5512,9 @@ packages:
|
||||
resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==}
|
||||
engines: {node: ^18.17.0 || >=20.5.0}
|
||||
|
||||
mz@2.7.0:
|
||||
resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==}
|
||||
|
||||
nan@2.25.0:
|
||||
resolution: {integrity: sha512-0M90Ag7Xn5KMLLZ7zliPWP3rT90P6PN+IzVFS0VqmnPktBk3700xUVv8Ikm9EUaUE5SDWdp/BIxdENzVznpm1g==}
|
||||
|
||||
@@ -5725,10 +5823,18 @@ packages:
|
||||
engines: {node: '>=0.10'}
|
||||
hasBin: true
|
||||
|
||||
pify@2.3.0:
|
||||
resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
pify@3.0.0:
|
||||
resolution: {integrity: sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
pirates@4.0.7:
|
||||
resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
pkg-types@1.3.1:
|
||||
resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==}
|
||||
|
||||
@@ -5745,6 +5851,49 @@ packages:
|
||||
points-on-path@0.2.1:
|
||||
resolution: {integrity: sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==}
|
||||
|
||||
postcss-import@15.1.0:
|
||||
resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
peerDependencies:
|
||||
postcss: ^8.0.0
|
||||
|
||||
postcss-js@4.1.0:
|
||||
resolution: {integrity: sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==}
|
||||
engines: {node: ^12 || ^14 || >= 16}
|
||||
peerDependencies:
|
||||
postcss: ^8.4.21
|
||||
|
||||
postcss-load-config@6.0.1:
|
||||
resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==}
|
||||
engines: {node: '>= 18'}
|
||||
peerDependencies:
|
||||
jiti: '>=1.21.0'
|
||||
postcss: '>=8.0.9'
|
||||
tsx: ^4.8.1
|
||||
yaml: ^2.4.2
|
||||
peerDependenciesMeta:
|
||||
jiti:
|
||||
optional: true
|
||||
postcss:
|
||||
optional: true
|
||||
tsx:
|
||||
optional: true
|
||||
yaml:
|
||||
optional: true
|
||||
|
||||
postcss-nested@6.2.0:
|
||||
resolution: {integrity: sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==}
|
||||
engines: {node: '>=12.0'}
|
||||
peerDependencies:
|
||||
postcss: ^8.2.14
|
||||
|
||||
postcss-selector-parser@6.1.2:
|
||||
resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
postcss-value-parser@4.2.0:
|
||||
resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==}
|
||||
|
||||
postcss@8.4.31:
|
||||
resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
@@ -5844,6 +5993,9 @@ packages:
|
||||
resolution: {integrity: sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==}
|
||||
engines: {node: '>=0.6'}
|
||||
|
||||
queue-microtask@1.2.3:
|
||||
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
|
||||
|
||||
randombytes@2.1.0:
|
||||
resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==}
|
||||
|
||||
@@ -5915,6 +6067,9 @@ packages:
|
||||
resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
read-cache@1.0.0:
|
||||
resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==}
|
||||
|
||||
readable-stream@2.3.8:
|
||||
resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==}
|
||||
|
||||
@@ -5929,6 +6084,10 @@ packages:
|
||||
readdir-glob@1.1.3:
|
||||
resolution: {integrity: sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==}
|
||||
|
||||
readdirp@3.6.0:
|
||||
resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
|
||||
engines: {node: '>=8.10.0'}
|
||||
|
||||
readdirp@4.1.2:
|
||||
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
|
||||
engines: {node: '>= 14.18.0'}
|
||||
@@ -6026,6 +6185,10 @@ packages:
|
||||
resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
reusify@1.1.0:
|
||||
resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
|
||||
engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
|
||||
|
||||
rfdc@1.4.1:
|
||||
resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==}
|
||||
|
||||
@@ -6054,6 +6217,9 @@ packages:
|
||||
resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
run-parallel@1.2.0:
|
||||
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
|
||||
|
||||
rw@1.3.3:
|
||||
resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==}
|
||||
|
||||
@@ -6348,6 +6514,11 @@ packages:
|
||||
stylis@4.3.6:
|
||||
resolution: {integrity: sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==}
|
||||
|
||||
sucrase@3.35.1:
|
||||
resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==}
|
||||
engines: {node: '>=16 || 14 >=14.17'}
|
||||
hasBin: true
|
||||
|
||||
superagent@10.3.0:
|
||||
resolution: {integrity: sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==}
|
||||
engines: {node: '>=14.18.0'}
|
||||
@@ -6379,6 +6550,11 @@ packages:
|
||||
resolution: {integrity: sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==}
|
||||
engines: {node: ^14.18.0 || >=16.0.0}
|
||||
|
||||
tailwindcss@3.4.19:
|
||||
resolution: {integrity: sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
hasBin: true
|
||||
|
||||
tapable@2.3.0:
|
||||
resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -6421,6 +6597,13 @@ packages:
|
||||
text-decoder@1.2.3:
|
||||
resolution: {integrity: sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==}
|
||||
|
||||
thenify-all@1.6.0:
|
||||
resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==}
|
||||
engines: {node: '>=0.8'}
|
||||
|
||||
thenify@3.3.1:
|
||||
resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==}
|
||||
|
||||
tiny-invariant@1.3.3:
|
||||
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
|
||||
|
||||
@@ -6497,6 +6680,9 @@ packages:
|
||||
resolution: {integrity: sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==}
|
||||
engines: {node: '>=6.10'}
|
||||
|
||||
ts-interface-checker@0.1.13:
|
||||
resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==}
|
||||
|
||||
ts-mixer@6.0.4:
|
||||
resolution: {integrity: sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==}
|
||||
|
||||
@@ -7020,6 +7206,8 @@ snapshots:
|
||||
|
||||
'@adobe/css-tools@4.4.4': {}
|
||||
|
||||
'@alloc/quick-lru@5.2.0': {}
|
||||
|
||||
'@ampproject/remapping@2.3.0':
|
||||
dependencies:
|
||||
'@jridgewell/gen-mapping': 0.3.13
|
||||
@@ -8296,6 +8484,18 @@ snapshots:
|
||||
|
||||
'@noble/hashes@2.0.1': {}
|
||||
|
||||
'@nodelib/fs.scandir@2.1.5':
|
||||
dependencies:
|
||||
'@nodelib/fs.stat': 2.0.5
|
||||
run-parallel: 1.2.0
|
||||
|
||||
'@nodelib/fs.stat@2.0.5': {}
|
||||
|
||||
'@nodelib/fs.walk@1.2.8':
|
||||
dependencies:
|
||||
'@nodelib/fs.scandir': 2.1.5
|
||||
fastq: 1.20.1
|
||||
|
||||
'@nuxt/opencollective@0.4.1':
|
||||
dependencies:
|
||||
consola: 3.4.2
|
||||
@@ -9940,7 +10140,7 @@ snapshots:
|
||||
'@typescript-eslint/types': 8.54.0
|
||||
eslint-visitor-keys: 4.2.1
|
||||
|
||||
'@vitejs/plugin-react@4.7.0(vite@7.3.1(@types/node@22.19.7)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))':
|
||||
'@vitejs/plugin-react@4.7.0(vite@7.3.1(@types/node@22.19.7)(jiti@1.21.7)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))':
|
||||
dependencies:
|
||||
'@babel/core': 7.28.6
|
||||
'@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.6)
|
||||
@@ -9948,11 +10148,11 @@ snapshots:
|
||||
'@rolldown/pluginutils': 1.0.0-beta.27
|
||||
'@types/babel__core': 7.20.5
|
||||
react-refresh: 0.17.0
|
||||
vite: 7.3.1(@types/node@22.19.7)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
|
||||
vite: 7.3.1(@types/node@22.19.7)(jiti@1.21.7)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/node@22.19.7)(jiti@2.6.1)(jsdom@26.1.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))':
|
||||
'@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/node@22.19.7)(jiti@1.21.7)(jsdom@26.1.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))':
|
||||
dependencies:
|
||||
'@ampproject/remapping': 2.3.0
|
||||
'@bcoe/v8-coverage': 1.0.2
|
||||
@@ -9967,7 +10167,7 @@ snapshots:
|
||||
std-env: 3.10.0
|
||||
test-exclude: 7.0.1
|
||||
tinyrainbow: 2.0.0
|
||||
vitest: 3.2.4(@types/node@22.19.7)(jiti@2.6.1)(jsdom@26.1.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
|
||||
vitest: 3.2.4(@types/node@22.19.7)(jiti@1.21.7)(jsdom@26.1.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -10016,6 +10216,14 @@ snapshots:
|
||||
chai: 6.2.2
|
||||
tinyrainbow: 3.0.3
|
||||
|
||||
'@vitest/mocker@3.2.4(vite@7.3.1(@types/node@22.19.7)(jiti@1.21.7)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))':
|
||||
dependencies:
|
||||
'@vitest/spy': 3.2.4
|
||||
estree-walker: 3.0.3
|
||||
magic-string: 0.30.21
|
||||
optionalDependencies:
|
||||
vite: 7.3.1(@types/node@22.19.7)(jiti@1.21.7)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
|
||||
|
||||
'@vitest/mocker@3.2.4(vite@7.3.1(@types/node@22.19.7)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))':
|
||||
dependencies:
|
||||
'@vitest/spy': 3.2.4
|
||||
@@ -10281,6 +10489,13 @@ snapshots:
|
||||
|
||||
ansis@4.2.0: {}
|
||||
|
||||
any-promise@1.3.0: {}
|
||||
|
||||
anymatch@3.1.3:
|
||||
dependencies:
|
||||
normalize-path: 3.0.0
|
||||
picomatch: 2.3.1
|
||||
|
||||
append-field@1.0.0: {}
|
||||
|
||||
archiver-utils@5.0.2:
|
||||
@@ -10308,6 +10523,8 @@ snapshots:
|
||||
|
||||
arg@4.1.3: {}
|
||||
|
||||
arg@5.0.2: {}
|
||||
|
||||
argparse@1.0.10:
|
||||
dependencies:
|
||||
sprintf-js: 1.0.3
|
||||
@@ -10346,6 +10563,15 @@ snapshots:
|
||||
|
||||
asynckit@0.4.0: {}
|
||||
|
||||
autoprefixer@10.4.24(postcss@8.5.6):
|
||||
dependencies:
|
||||
browserslist: 4.28.1
|
||||
caniuse-lite: 1.0.30001766
|
||||
fraction.js: 5.3.4
|
||||
picocolors: 1.1.1
|
||||
postcss: 8.5.6
|
||||
postcss-value-parser: 4.2.0
|
||||
|
||||
aws-sign2@0.7.0: {}
|
||||
|
||||
aws4@1.13.2: {}
|
||||
@@ -10428,7 +10654,7 @@ snapshots:
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@22.19.7)(jiti@2.6.1)(jsdom@26.1.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
|
||||
|
||||
better-auth@1.4.17(@prisma/client@6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.6.2)(drizzle-orm@0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(postgres@3.4.8)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)))(next@16.1.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(pg@8.17.2)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@3.2.4(@types/node@22.19.7)(jiti@2.6.1)(jsdom@26.1.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)):
|
||||
better-auth@1.4.17(@prisma/client@6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.6.2)(drizzle-orm@0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(postgres@3.4.8)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)))(next@16.1.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(pg@8.17.2)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@3.2.4(@types/node@22.19.7)(jiti@1.21.7)(jsdom@26.1.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)):
|
||||
dependencies:
|
||||
'@better-auth/core': 1.4.17(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.10)(nanostores@1.1.0)
|
||||
'@better-auth/telemetry': 1.4.17(@better-auth/core@1.4.17(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.10)(nanostores@1.1.0))
|
||||
@@ -10451,7 +10677,7 @@ snapshots:
|
||||
prisma: 6.19.2(magicast@0.3.5)(typescript@5.9.3)
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
vitest: 3.2.4(@types/node@22.19.7)(jiti@2.6.1)(jsdom@26.1.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
|
||||
vitest: 3.2.4(@types/node@22.19.7)(jiti@1.21.7)(jsdom@26.1.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
|
||||
|
||||
better-call@1.1.8(zod@4.3.6):
|
||||
dependencies:
|
||||
@@ -10469,6 +10695,8 @@ snapshots:
|
||||
|
||||
bignumber.js@9.3.1: {}
|
||||
|
||||
binary-extensions@2.3.0: {}
|
||||
|
||||
bindings@1.5.0:
|
||||
dependencies:
|
||||
file-uri-to-path: 1.0.0
|
||||
@@ -10615,6 +10843,8 @@ snapshots:
|
||||
|
||||
callsites@3.1.0: {}
|
||||
|
||||
camelcase-css@2.0.1: {}
|
||||
|
||||
caniuse-lite@1.0.30001766: {}
|
||||
|
||||
caseless@0.12.0: {}
|
||||
@@ -10663,6 +10893,18 @@ snapshots:
|
||||
'@chevrotain/utils': 11.0.3
|
||||
lodash-es: 4.17.23
|
||||
|
||||
chokidar@3.6.0:
|
||||
dependencies:
|
||||
anymatch: 3.1.3
|
||||
braces: 3.0.3
|
||||
glob-parent: 5.1.2
|
||||
is-binary-path: 2.1.0
|
||||
is-glob: 4.0.3
|
||||
normalize-path: 3.0.0
|
||||
readdirp: 3.6.0
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.3
|
||||
|
||||
chokidar@4.0.3:
|
||||
dependencies:
|
||||
readdirp: 4.1.2
|
||||
@@ -10859,6 +11101,8 @@ snapshots:
|
||||
|
||||
css.escape@1.5.1: {}
|
||||
|
||||
cssesc@3.0.0: {}
|
||||
|
||||
cssstyle@4.6.0:
|
||||
dependencies:
|
||||
'@asamuzakjp/css-color': 3.2.0
|
||||
@@ -11127,6 +11371,8 @@ snapshots:
|
||||
asap: 2.0.6
|
||||
wrappy: 1.0.2
|
||||
|
||||
didyoumean@1.2.2: {}
|
||||
|
||||
diff@4.0.4: {}
|
||||
|
||||
discord-api-types@0.38.38: {}
|
||||
@@ -11150,6 +11396,8 @@ snapshots:
|
||||
- bufferutil
|
||||
- utf-8-validate
|
||||
|
||||
dlv@1.1.3: {}
|
||||
|
||||
docker-modem@5.0.6:
|
||||
dependencies:
|
||||
debug: 4.4.3
|
||||
@@ -11567,6 +11815,14 @@ snapshots:
|
||||
|
||||
fast-fifo@1.3.2: {}
|
||||
|
||||
fast-glob@3.3.3:
|
||||
dependencies:
|
||||
'@nodelib/fs.stat': 2.0.5
|
||||
'@nodelib/fs.walk': 1.2.8
|
||||
glob-parent: 5.1.2
|
||||
merge2: 1.4.1
|
||||
micromatch: 4.0.8
|
||||
|
||||
fast-json-stable-stringify@2.1.0: {}
|
||||
|
||||
fast-levenshtein@2.0.6: {}
|
||||
@@ -11575,6 +11831,10 @@ snapshots:
|
||||
|
||||
fast-uri@3.1.0: {}
|
||||
|
||||
fastq@1.20.1:
|
||||
dependencies:
|
||||
reusify: 1.1.0
|
||||
|
||||
fdir@6.5.0(picomatch@4.0.3):
|
||||
optionalDependencies:
|
||||
picomatch: 4.0.3
|
||||
@@ -11677,6 +11937,8 @@ snapshots:
|
||||
|
||||
forwarded@0.2.0: {}
|
||||
|
||||
fraction.js@5.3.4: {}
|
||||
|
||||
fresh@0.5.2: {}
|
||||
|
||||
fresh@2.0.0: {}
|
||||
@@ -11761,6 +12023,10 @@ snapshots:
|
||||
|
||||
github-slugger@2.0.0: {}
|
||||
|
||||
glob-parent@5.1.2:
|
||||
dependencies:
|
||||
is-glob: 4.0.3
|
||||
|
||||
glob-parent@6.0.2:
|
||||
dependencies:
|
||||
is-glob: 4.0.3
|
||||
@@ -11935,6 +12201,10 @@ snapshots:
|
||||
|
||||
is-arrayish@0.2.1: {}
|
||||
|
||||
is-binary-path@2.1.0:
|
||||
dependencies:
|
||||
binary-extensions: 2.3.0
|
||||
|
||||
is-core-module@2.16.1:
|
||||
dependencies:
|
||||
hasown: 2.0.2
|
||||
@@ -12022,6 +12292,8 @@ snapshots:
|
||||
merge-stream: 2.0.0
|
||||
supports-color: 8.1.1
|
||||
|
||||
jiti@1.21.7: {}
|
||||
|
||||
jiti@2.6.1: {}
|
||||
|
||||
jose@6.1.3: {}
|
||||
@@ -12153,6 +12425,8 @@ snapshots:
|
||||
|
||||
lilconfig@2.1.0: {}
|
||||
|
||||
lilconfig@3.1.3: {}
|
||||
|
||||
lines-and-columns@1.2.4: {}
|
||||
|
||||
lint-staged@16.2.7:
|
||||
@@ -12323,6 +12597,8 @@ snapshots:
|
||||
|
||||
merge-stream@2.0.0: {}
|
||||
|
||||
merge2@1.4.1: {}
|
||||
|
||||
mermaid@11.12.2:
|
||||
dependencies:
|
||||
'@braintree/sanitize-url': 7.1.2
|
||||
@@ -12446,6 +12722,12 @@ snapshots:
|
||||
|
||||
mute-stream@2.0.0: {}
|
||||
|
||||
mz@2.7.0:
|
||||
dependencies:
|
||||
any-promise: 1.3.0
|
||||
object-assign: 4.1.1
|
||||
thenify-all: 1.6.0
|
||||
|
||||
nan@2.25.0:
|
||||
optional: true
|
||||
|
||||
@@ -12711,8 +12993,12 @@ snapshots:
|
||||
|
||||
pidtree@0.6.0: {}
|
||||
|
||||
pify@2.3.0: {}
|
||||
|
||||
pify@3.0.0: {}
|
||||
|
||||
pirates@4.0.7: {}
|
||||
|
||||
pkg-types@1.3.1:
|
||||
dependencies:
|
||||
confbox: 0.1.8
|
||||
@@ -12734,6 +13020,39 @@ snapshots:
|
||||
path-data-parser: 0.1.0
|
||||
points-on-curve: 0.2.0
|
||||
|
||||
postcss-import@15.1.0(postcss@8.5.6):
|
||||
dependencies:
|
||||
postcss: 8.5.6
|
||||
postcss-value-parser: 4.2.0
|
||||
read-cache: 1.0.0
|
||||
resolve: 1.22.11
|
||||
|
||||
postcss-js@4.1.0(postcss@8.5.6):
|
||||
dependencies:
|
||||
camelcase-css: 2.0.1
|
||||
postcss: 8.5.6
|
||||
|
||||
postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.6)(tsx@4.21.0)(yaml@2.8.2):
|
||||
dependencies:
|
||||
lilconfig: 3.1.3
|
||||
optionalDependencies:
|
||||
jiti: 1.21.7
|
||||
postcss: 8.5.6
|
||||
tsx: 4.21.0
|
||||
yaml: 2.8.2
|
||||
|
||||
postcss-nested@6.2.0(postcss@8.5.6):
|
||||
dependencies:
|
||||
postcss: 8.5.6
|
||||
postcss-selector-parser: 6.1.2
|
||||
|
||||
postcss-selector-parser@6.1.2:
|
||||
dependencies:
|
||||
cssesc: 3.0.0
|
||||
util-deprecate: 1.0.2
|
||||
|
||||
postcss-value-parser@4.2.0: {}
|
||||
|
||||
postcss@8.4.31:
|
||||
dependencies:
|
||||
nanoid: 3.3.11
|
||||
@@ -12846,6 +13165,8 @@ snapshots:
|
||||
dependencies:
|
||||
side-channel: 1.1.0
|
||||
|
||||
queue-microtask@1.2.3: {}
|
||||
|
||||
randombytes@2.1.0:
|
||||
dependencies:
|
||||
safe-buffer: 5.2.1
|
||||
@@ -12925,6 +13246,10 @@ snapshots:
|
||||
|
||||
react@19.2.4: {}
|
||||
|
||||
read-cache@1.0.0:
|
||||
dependencies:
|
||||
pify: 2.3.0
|
||||
|
||||
readable-stream@2.3.8:
|
||||
dependencies:
|
||||
core-util-is: 1.0.3
|
||||
@@ -12953,6 +13278,10 @@ snapshots:
|
||||
dependencies:
|
||||
minimatch: 10.2.1
|
||||
|
||||
readdirp@3.6.0:
|
||||
dependencies:
|
||||
picomatch: 2.3.1
|
||||
|
||||
readdirp@4.1.2: {}
|
||||
|
||||
readdirp@5.0.0: {}
|
||||
@@ -13049,6 +13378,8 @@ snapshots:
|
||||
onetime: 7.0.0
|
||||
signal-exit: 4.1.0
|
||||
|
||||
reusify@1.1.0: {}
|
||||
|
||||
rfdc@1.4.1: {}
|
||||
|
||||
robust-predicates@3.0.2: {}
|
||||
@@ -13107,6 +13438,10 @@ snapshots:
|
||||
|
||||
run-applescript@7.1.0: {}
|
||||
|
||||
run-parallel@1.2.0:
|
||||
dependencies:
|
||||
queue-microtask: 1.2.3
|
||||
|
||||
rw@1.3.3: {}
|
||||
|
||||
rxjs@7.8.1:
|
||||
@@ -13500,6 +13835,16 @@ snapshots:
|
||||
|
||||
stylis@4.3.6: {}
|
||||
|
||||
sucrase@3.35.1:
|
||||
dependencies:
|
||||
'@jridgewell/gen-mapping': 0.3.13
|
||||
commander: 4.1.1
|
||||
lines-and-columns: 1.2.4
|
||||
mz: 2.7.0
|
||||
pirates: 4.0.7
|
||||
tinyglobby: 0.2.15
|
||||
ts-interface-checker: 0.1.13
|
||||
|
||||
superagent@10.3.0:
|
||||
dependencies:
|
||||
component-emitter: 1.3.1
|
||||
@@ -13540,6 +13885,34 @@ snapshots:
|
||||
dependencies:
|
||||
'@pkgr/core': 0.2.9
|
||||
|
||||
tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.2):
|
||||
dependencies:
|
||||
'@alloc/quick-lru': 5.2.0
|
||||
arg: 5.0.2
|
||||
chokidar: 3.6.0
|
||||
didyoumean: 1.2.2
|
||||
dlv: 1.1.3
|
||||
fast-glob: 3.3.3
|
||||
glob-parent: 6.0.2
|
||||
is-glob: 4.0.3
|
||||
jiti: 1.21.7
|
||||
lilconfig: 3.1.3
|
||||
micromatch: 4.0.8
|
||||
normalize-path: 3.0.0
|
||||
object-hash: 3.0.0
|
||||
picocolors: 1.1.1
|
||||
postcss: 8.5.6
|
||||
postcss-import: 15.1.0(postcss@8.5.6)
|
||||
postcss-js: 4.1.0(postcss@8.5.6)
|
||||
postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.6)(tsx@4.21.0)(yaml@2.8.2)
|
||||
postcss-nested: 6.2.0(postcss@8.5.6)
|
||||
postcss-selector-parser: 6.1.2
|
||||
resolve: 1.22.11
|
||||
sucrase: 3.35.1
|
||||
transitivePeerDependencies:
|
||||
- tsx
|
||||
- yaml
|
||||
|
||||
tapable@2.3.0: {}
|
||||
|
||||
tar-fs@2.1.4:
|
||||
@@ -13596,6 +13969,14 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- react-native-b4a
|
||||
|
||||
thenify-all@1.6.0:
|
||||
dependencies:
|
||||
thenify: 3.3.1
|
||||
|
||||
thenify@3.3.1:
|
||||
dependencies:
|
||||
any-promise: 1.3.0
|
||||
|
||||
tiny-invariant@1.3.3: {}
|
||||
|
||||
tinybench@2.9.0: {}
|
||||
@@ -13653,6 +14034,8 @@ snapshots:
|
||||
|
||||
ts-dedent@2.2.0: {}
|
||||
|
||||
ts-interface-checker@0.1.13: {}
|
||||
|
||||
ts-mixer@6.0.4: {}
|
||||
|
||||
ts-node@10.9.2(@swc/core@1.15.11)(@types/node@22.19.7)(typescript@5.9.3):
|
||||
@@ -13849,6 +14232,27 @@ snapshots:
|
||||
d3-time: 3.1.0
|
||||
d3-timer: 3.0.1
|
||||
|
||||
vite-node@3.2.4(@types/node@22.19.7)(jiti@1.21.7)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2):
|
||||
dependencies:
|
||||
cac: 6.7.14
|
||||
debug: 4.4.3
|
||||
es-module-lexer: 1.7.0
|
||||
pathe: 2.0.3
|
||||
vite: 7.3.1(@types/node@22.19.7)(jiti@1.21.7)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
|
||||
transitivePeerDependencies:
|
||||
- '@types/node'
|
||||
- jiti
|
||||
- less
|
||||
- lightningcss
|
||||
- sass
|
||||
- sass-embedded
|
||||
- stylus
|
||||
- sugarss
|
||||
- supports-color
|
||||
- terser
|
||||
- tsx
|
||||
- yaml
|
||||
|
||||
vite-node@3.2.4(@types/node@22.19.7)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2):
|
||||
dependencies:
|
||||
cac: 6.7.14
|
||||
@@ -13870,6 +14274,22 @@ snapshots:
|
||||
- tsx
|
||||
- yaml
|
||||
|
||||
vite@7.3.1(@types/node@22.19.7)(jiti@1.21.7)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2):
|
||||
dependencies:
|
||||
esbuild: 0.27.2
|
||||
fdir: 6.5.0(picomatch@4.0.3)
|
||||
picomatch: 4.0.3
|
||||
postcss: 8.5.6
|
||||
rollup: 4.57.0
|
||||
tinyglobby: 0.2.15
|
||||
optionalDependencies:
|
||||
'@types/node': 22.19.7
|
||||
fsevents: 2.3.3
|
||||
jiti: 1.21.7
|
||||
terser: 5.46.0
|
||||
tsx: 4.21.0
|
||||
yaml: 2.8.2
|
||||
|
||||
vite@7.3.1(@types/node@22.19.7)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2):
|
||||
dependencies:
|
||||
esbuild: 0.27.2
|
||||
@@ -13886,6 +14306,48 @@ snapshots:
|
||||
tsx: 4.21.0
|
||||
yaml: 2.8.2
|
||||
|
||||
vitest@3.2.4(@types/node@22.19.7)(jiti@1.21.7)(jsdom@26.1.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2):
|
||||
dependencies:
|
||||
'@types/chai': 5.2.3
|
||||
'@vitest/expect': 3.2.4
|
||||
'@vitest/mocker': 3.2.4(vite@7.3.1(@types/node@22.19.7)(jiti@1.21.7)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
|
||||
'@vitest/pretty-format': 3.2.4
|
||||
'@vitest/runner': 3.2.4
|
||||
'@vitest/snapshot': 3.2.4
|
||||
'@vitest/spy': 3.2.4
|
||||
'@vitest/utils': 3.2.4
|
||||
chai: 5.3.3
|
||||
debug: 4.4.3
|
||||
expect-type: 1.3.0
|
||||
magic-string: 0.30.21
|
||||
pathe: 2.0.3
|
||||
picomatch: 4.0.3
|
||||
std-env: 3.10.0
|
||||
tinybench: 2.9.0
|
||||
tinyexec: 0.3.2
|
||||
tinyglobby: 0.2.15
|
||||
tinypool: 1.1.1
|
||||
tinyrainbow: 2.0.0
|
||||
vite: 7.3.1(@types/node@22.19.7)(jiti@1.21.7)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
|
||||
vite-node: 3.2.4(@types/node@22.19.7)(jiti@1.21.7)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
|
||||
why-is-node-running: 2.3.0
|
||||
optionalDependencies:
|
||||
'@types/node': 22.19.7
|
||||
jsdom: 26.1.0
|
||||
transitivePeerDependencies:
|
||||
- jiti
|
||||
- less
|
||||
- lightningcss
|
||||
- msw
|
||||
- sass
|
||||
- sass-embedded
|
||||
- stylus
|
||||
- sugarss
|
||||
- supports-color
|
||||
- terser
|
||||
- tsx
|
||||
- yaml
|
||||
|
||||
vitest@3.2.4(@types/node@22.19.7)(jiti@2.6.1)(jsdom@26.1.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2):
|
||||
dependencies:
|
||||
'@types/chai': 5.2.3
|
||||
|
||||
Reference in New Issue
Block a user