Compare commits
71 Commits
55fd2cd0d2
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| f3ce08497a | |||
| fd60b0bb57 | |||
| b7b05bb4e3 | |||
| d370d5ec23 | |||
| 1b82e1d3a6 | |||
| 93bf434a47 | |||
| 010408cc45 | |||
| 23f9d4a8fb | |||
| 3d5bf16d37 | |||
| d9a1640b10 | |||
| 5347944c4c | |||
| 48a8598d3b | |||
| a0eb393c6c | |||
| d294818581 | |||
| 9e0366e780 | |||
| 20404b30bb | |||
| b7a54c8461 | |||
| b6e44cb4f8 | |||
| 49b959aee5 | |||
| ae37d79aa8 | |||
| 8fb4183abe | |||
| 6fd2d9bec4 | |||
| ee6ad10db9 | |||
| 5f42a3be18 | |||
| 0e452941dd | |||
| 87cb517623 | |||
| 439741e55f | |||
| 3c26b8deba | |||
| 4569fef11d | |||
| 7a93d43b7e | |||
| d28d6e8dac | |||
| efcedde649 | |||
| 0ac4898027 | |||
| e131798f3b | |||
| 9062f8fa8d | |||
| 60ba3e5b4f | |||
| 70d39b87d1 | |||
| 519e872027 | |||
| 2b4b9b3e96 | |||
| 9a802b4212 | |||
| 1a50306f7d | |||
| e8ced74429 | |||
| 8b8cb8210c | |||
| 4a2e660a4a | |||
| 07d40d729f | |||
| 8da593c450 | |||
| 437a519c36 | |||
| c906cd46ad | |||
| cce3e061a7 | |||
| ab19a7ccde | |||
| 745994182f | |||
| 1775c25b61 | |||
| 999f6614ce | |||
| 048101e85c | |||
| dcfa4dc2a2 | |||
| 679d65ccea | |||
| e84a479e33 | |||
| 7223a2745f | |||
| 8331468b44 | |||
| e5d9b7ea07 | |||
| 0108d8aca0 | |||
| 27d877db6c | |||
| 29dd9321f8 | |||
| 64adffa0b4 | |||
| 3a0efaa5a4 | |||
| 2a99ace9f8 | |||
| 0ddffaf266 | |||
| e2582569b0 | |||
| ed1ee886db | |||
| bcaa526a69 | |||
| b4e110f4c3 |
@@ -0,0 +1,50 @@
|
|||||||
|
# Control Center - Environment Variables
|
||||||
|
# ======================================
|
||||||
|
|
||||||
|
# ── Backend Variables ───────────────────────────────────────────────────
|
||||||
|
# Server configuration
|
||||||
|
PORT=8080
|
||||||
|
CORS_ORIGIN=http://localhost:3000
|
||||||
|
LOG_LEVEL=info
|
||||||
|
ENVIRONMENT=development
|
||||||
|
|
||||||
|
# Database connection (PostgreSQL DSN)
|
||||||
|
# Format: postgresql://user:password@host:port/database?sslmode=disable
|
||||||
|
DATABASE_URL=postgresql://controlcenter:controlcenter@localhost:5432/controlcenter?sslmode=disable
|
||||||
|
|
||||||
|
# Gateway (OpenClaw) connection
|
||||||
|
# WebSocket gateway config (primary path)
|
||||||
|
WS_GATEWAY_URL=ws://host.docker.internal:18789/
|
||||||
|
# Gateway auth token — same as OPENCLAW_GATEWAY_TOKEN (set in environment)
|
||||||
|
GATEWAY_TOKEN=
|
||||||
|
|
||||||
|
# REST poller config (fallback, only used if WS fails to connect)
|
||||||
|
GATEWAY_URL=http://host.docker.internal:18789/api/agents
|
||||||
|
# Polling interval for agent state updates (fallback only)
|
||||||
|
GATEWAY_POLL_INTERVAL=5s
|
||||||
|
|
||||||
|
# ── Frontend Variables (via Vite) ───────────────────────────────────────
|
||||||
|
# The Vite config exposes these as import.meta.env.VITE_*
|
||||||
|
# Set via environment variable when building: VITE_API_URL
|
||||||
|
# VITE_API_URL=http://localhost:8080
|
||||||
|
|
||||||
|
# ── Docker Compose Specific ─────────────────────────────────────────────
|
||||||
|
# When using docker-compose, these are set in the services section
|
||||||
|
# See docker-compose.yml for service-specific environment variables
|
||||||
|
|
||||||
|
# ── Database Configuration ───────────────────────────────────────────────
|
||||||
|
# Set in the db service environment section of docker-compose.yml
|
||||||
|
# POSTGRES_USER=controlcenter
|
||||||
|
# POSTGRES_PASSWORD=controlcenter
|
||||||
|
# POSTGRES_DB=controlcenter
|
||||||
|
|
||||||
|
# ── Development Notes ───────────────────────────────────────────────────
|
||||||
|
# For local development without Docker:
|
||||||
|
# 1. Start PostgreSQL locally
|
||||||
|
# 2. Run: go run ./cmd/server/main.go
|
||||||
|
# 3. Run: npm run dev in frontend/
|
||||||
|
#
|
||||||
|
# For Docker deployment:
|
||||||
|
# 1. Copy .env.example to .env (backend only)
|
||||||
|
# 2. Run: docker compose up -d
|
||||||
|
# 3. Access frontend at http://localhost:3000
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
name: Deploy (Dev)
|
||||||
|
|
||||||
|
on:
|
||||||
|
repository_dispatch:
|
||||||
|
types:
|
||||||
|
- dev-build-success
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
env:
|
||||||
|
BINARY_NAME: server
|
||||||
|
DEV_HOST: ${{ secrets.DEV_HOST }}
|
||||||
|
DEV_USER: ${{ secrets.DEV_USER }}
|
||||||
|
DEPLOY_BINARY_PATH: /opt/control-center/server
|
||||||
|
DEPLOY_FRONTEND_PATH: /usr/share/nginx/html
|
||||||
|
SERVICE_NAME: control-center-server
|
||||||
|
FRONTEND_SERVICE: nginx
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Download Go binary
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: go-backend-binary
|
||||||
|
|
||||||
|
- name: Download frontend dist
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: frontend-dist
|
||||||
|
path: dist
|
||||||
|
|
||||||
|
- name: Make binary executable
|
||||||
|
run: chmod +x ${{ env.BINARY_NAME }}
|
||||||
|
|
||||||
|
- name: Generate deploy script
|
||||||
|
run: |
|
||||||
|
cat > deploy.sh <<'SCRIPT'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
BINARY="${1}"
|
||||||
|
FRONTEND_DIST="${2:-dist}"
|
||||||
|
BINARY_PATH="${3:-/opt/control-center/server}"
|
||||||
|
FRONTEND_PATH="${4:-/usr/share/nginx/html}"
|
||||||
|
BINARY_SERVICE="${5:-control-center-server}"
|
||||||
|
FRONTEND_SERVICE="${6:-nginx}"
|
||||||
|
|
||||||
|
TIMESTAMP=$(date +%Y%m%d%H%M%S)
|
||||||
|
BACKUP="${BINARY_PATH}.${TIMESTAMP}.bak"
|
||||||
|
|
||||||
|
echo "=== deploy backend ==="
|
||||||
|
|
||||||
|
if [ -f "$BINARY_PATH" ]; then
|
||||||
|
echo "backing up current binary"
|
||||||
|
cp "$BINARY_PATH" "$BACKUP"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "installing new binary"
|
||||||
|
cp "$BINARY" "$BINARY_PATH"
|
||||||
|
chmod +x "$BINARY_PATH"
|
||||||
|
|
||||||
|
echo "restarting service"
|
||||||
|
systemctl reload-or-restart "$BINARY_SERVICE" || systemctl restart "$BINARY_SERVICE"
|
||||||
|
|
||||||
|
sleep 3
|
||||||
|
|
||||||
|
if ! systemctl is-active --quiet "$BINARY_SERVICE"; then
|
||||||
|
echo "FAILED: $BINARY_SERVICE did not start — rolling back"
|
||||||
|
if [ -f "$BACKUP" ]; then
|
||||||
|
cp "$BACKUP" "$BINARY_PATH"
|
||||||
|
systemctl restart "$BINARY_SERVICE"
|
||||||
|
fi
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "backend deploy ok — keeping last 3 backups"
|
||||||
|
ls -t "${BINARY_PATH}."*.bak 2>/dev/null | tail -n +4 | xargs -r rm -f
|
||||||
|
|
||||||
|
echo "=== deploy frontend ==="
|
||||||
|
if [ -d "$FRONTEND_DIST" ] && [ -n "$(ls -A "$FRONTEND_DIST" 2>/dev/null)" ]; then
|
||||||
|
rsync -a --delete "$FRONTEND_DIST/" "$FRONTEND_PATH/"
|
||||||
|
systemctl reload "$FRONTEND_SERVICE" 2>/dev/null ||:
|
||||||
|
echo "frontend deploy ok"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "=== deploy complete ==="
|
||||||
|
SCRIPT
|
||||||
|
chmod +x deploy.sh
|
||||||
|
|
||||||
|
- name: Copy artifacts to dev server
|
||||||
|
uses: appleboy/scp-action@v0.1.7
|
||||||
|
with:
|
||||||
|
host: ${{ env.DEV_HOST }}
|
||||||
|
username: ${{ env.DEV_USER }}
|
||||||
|
key: ${{ secrets.DEV_SSH_KEY }}
|
||||||
|
source: "${{ env.BINARY_NAME }},deploy.sh,dist"
|
||||||
|
target: "/tmp/control-center-deploy"
|
||||||
|
|
||||||
|
- name: Execute deploy on dev server
|
||||||
|
uses: appleboy/ssh-action@v1
|
||||||
|
with:
|
||||||
|
host: ${{ env.DEV_HOST }}
|
||||||
|
username: ${{ env.DEV_USER }}
|
||||||
|
key: ${{ secrets.DEV_SSH_KEY }}
|
||||||
|
script: |
|
||||||
|
set -euo pipefail
|
||||||
|
cd /tmp/control-center-deploy
|
||||||
|
sudo ./deploy.sh \
|
||||||
|
"${{ env.BINARY_NAME }}" \
|
||||||
|
"dist" \
|
||||||
|
"${{ env.DEPLOY_BINARY_PATH }}" \
|
||||||
|
"${{ env.DEPLOY_FRONTEND_PATH }}" \
|
||||||
|
"${{ env.SERVICE_NAME }}" \
|
||||||
|
"${{ env.FRONTEND_SERVICE }}"
|
||||||
|
rm -rf /tmp/control-center-deploy
|
||||||
|
|
||||||
|
- name: Notify on failure
|
||||||
|
if: failure()
|
||||||
|
uses: appleboy/ssh-action@v1
|
||||||
|
with:
|
||||||
|
host: ${{ env.DEV_HOST }}
|
||||||
|
username: ${{ env.DEV_USER }}
|
||||||
|
key: ${{ secrets.DEV_SSH_KEY }}
|
||||||
|
script: |
|
||||||
|
echo "deploy failed — commit ${{ github.sha }}" > /tmp/control-center-deploy-failure.log
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
name: Dev Build & Deploy
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches: [dev]
|
||||||
|
push:
|
||||||
|
branches: [dev]
|
||||||
|
|
||||||
|
env:
|
||||||
|
GO_VERSION: "1.23"
|
||||||
|
NODE_VERSION: "22"
|
||||||
|
REGISTRY: code.cubecraftcreations.com
|
||||||
|
BACKEND_IMAGE: ${{ gitea.repository }}/backend
|
||||||
|
FRONTEND_IMAGE: ${{ gitea.repository }}/frontend
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test-and-build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install Go
|
||||||
|
run: |
|
||||||
|
curl -fsSL "https://go.dev/dl/go${GO_VERSION}.linux-amd64.tar.gz" | sudo tar -C /usr/local -xz
|
||||||
|
echo "/usr/local/go/bin" >> $GITHUB_PATH
|
||||||
|
|
||||||
|
- name: Install Node.js
|
||||||
|
run: |
|
||||||
|
curl -fsSL "https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-x64.tar.xz" | sudo tar -C /usr/local --strip-components=1 -xJ
|
||||||
|
echo "/usr/local/bin" >> $GITHUB_PATH
|
||||||
|
|
||||||
|
- name: Run backend tests
|
||||||
|
run: go test ./...
|
||||||
|
working-directory: ./go-backend
|
||||||
|
|
||||||
|
- name: Build backend
|
||||||
|
run: go build -ldflags="-w -s" -o /tmp/server ./cmd/server
|
||||||
|
working-directory: ./go-backend
|
||||||
|
|
||||||
|
- name: Install frontend deps
|
||||||
|
run: npm ci
|
||||||
|
working-directory: ./frontend
|
||||||
|
|
||||||
|
- name: Lint frontend
|
||||||
|
run: npm run lint
|
||||||
|
working-directory: ./frontend
|
||||||
|
|
||||||
|
- name: Build frontend
|
||||||
|
run: npm run build
|
||||||
|
working-directory: ./frontend
|
||||||
|
|
||||||
|
docker-build-push:
|
||||||
|
needs: test-and-build
|
||||||
|
if: gitea.event_name == 'push'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Login to Gitea Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ gitea.actor }}
|
||||||
|
password: ${{ secrets.ACCESS_TOKEN }}
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Build & push backend image
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: ./go-backend
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE }}:dev
|
||||||
|
${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE }}:${{ gitea.sha }}
|
||||||
|
|
||||||
|
- name: Build & push frontend image
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: ./frontend
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE }}:dev
|
||||||
|
${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE }}:${{ gitea.sha }}
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
# Control Center Deployment Guide
|
||||||
|
|
||||||
|
This document covers the Docker Compose deployment and kiosk configuration for the Control Center Go + React application.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start all services (backend, frontend, database)
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
# View logs
|
||||||
|
docker compose logs -f
|
||||||
|
|
||||||
|
# Stop all services
|
||||||
|
docker compose down
|
||||||
|
|
||||||
|
# Stop and remove volumes (database data)
|
||||||
|
docker compose down -v
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────┐
|
||||||
|
│ Frontend │ Port 3000 (host) → 80 (container)
|
||||||
|
│ React + nginx │ Serves SPA, proxies /api/ to backend
|
||||||
|
└────────┬────────┘
|
||||||
|
│
|
||||||
|
│ HTTP
|
||||||
|
│
|
||||||
|
┌────────▼────────┐
|
||||||
|
│ Backend │ Port 8080 (host) → 8080 (container)
|
||||||
|
│ Go HTTP API │ PostgreSQL-backed REST API
|
||||||
|
└────────┬────────┘
|
||||||
|
│
|
||||||
|
│ PostgreSQL
|
||||||
|
│
|
||||||
|
┌────────▼────────┐
|
||||||
|
│ PostgreSQL │ Port 5432 (internal only)
|
||||||
|
│ Database │ Persistent volume at /var/lib/postgresql/data
|
||||||
|
└─────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Services
|
||||||
|
|
||||||
|
### Backend (`go-backend`)
|
||||||
|
|
||||||
|
- **Image**: Custom `alpine:latest` with Go binary
|
||||||
|
- **Port**: 8080
|
||||||
|
- **Build**: Multi-stage from `go-backend/Dockerfile`
|
||||||
|
- **Environment Variables**:
|
||||||
|
- `PORT` (default: 8080)
|
||||||
|
- `DATABASE_URL` (PostgreSQL DSN)
|
||||||
|
- `CORS_ORIGIN` (default: `*`)
|
||||||
|
- `LOG_LEVEL` (default: `info`)
|
||||||
|
- `ENVIRONMENT` (default: `development`)
|
||||||
|
- `GATEWAY_URL` (OpenClaw gateway endpoint)
|
||||||
|
|
||||||
|
### Frontend (`frontend`)
|
||||||
|
|
||||||
|
- **Image**: `nginx:1.27-alpine`
|
||||||
|
- **Port**: 80 (internal) → 3000 (host)
|
||||||
|
- **Build**: Multi-stage from `frontend/Dockerfile`
|
||||||
|
- Node 22 for build
|
||||||
|
- Nginx 1.27 for serving
|
||||||
|
- **Config**: Custom nginx config in `frontend/nginx.conf`
|
||||||
|
- **Environment Variables**:
|
||||||
|
- `VITE_API_URL` (passed at build time via Vite config)
|
||||||
|
|
||||||
|
### Database (`db`)
|
||||||
|
|
||||||
|
- **Image**: `postgres:16-alpine`
|
||||||
|
- **Port**: 5432 (internal only)
|
||||||
|
- **Volume**: `postgres-data:/var/lib/postgresql/data`
|
||||||
|
- **Environment Variables**:
|
||||||
|
- `POSTGRES_USER` (default: `controlcenter`)
|
||||||
|
- `POSTGRES_PASSWORD` (default: `controlcenter`)
|
||||||
|
- `POSTGRES_DB` (default: `controlcenter`)
|
||||||
|
|
||||||
|
## Kiosk Mode
|
||||||
|
|
||||||
|
For dedicated display installations (e.g., control center dashboard), Chromium can run in kiosk mode.
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
|
||||||
|
1. **Install the systemd service** (on Debian/Ubuntu with systemd):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo cp kiosk/control-center-kiosk.service /etc/systemd/system/
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Enable auto-start**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl enable control-center-kiosk
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Start the service**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl start control-center-kiosk
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Check status and logs**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl status control-center-kiosk
|
||||||
|
sudo journalctl -u control-center-kiosk -f
|
||||||
|
```
|
||||||
|
|
||||||
|
### Manual Launch
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# From project root
|
||||||
|
./kiosk/start-kiosk.sh http://localhost:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
### Uninstall
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Stop and disable service
|
||||||
|
sudo systemctl stop control-center-kiosk
|
||||||
|
sudo systemctl disable control-center-kiosk
|
||||||
|
sudo rm /etc/systemd/system/control-center-kiosk.service
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
```
|
||||||
|
|
||||||
|
### Kiosk Requirements
|
||||||
|
|
||||||
|
- **Browser**: `chromium-browser` (install via `apt-get install chromium`)
|
||||||
|
- **Display**: X11 session with `DISPLAY=:0`
|
||||||
|
- **User**: Must run as a user with X11 access (typically `overseer`)
|
||||||
|
- **Permissions**: Read access to the project directory
|
||||||
|
|
||||||
|
## Environment Variables Reference
|
||||||
|
|
||||||
|
### Backend (`go-backend/.env`)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PORT=8080
|
||||||
|
DATABASE_URL=postgresql://controlcenter:controlcenter@localhost:5432/controlcenter?sslmode=disable
|
||||||
|
CORS_ORIGIN=*
|
||||||
|
LOG_LEVEL=info
|
||||||
|
ENVIRONMENT=development
|
||||||
|
GATEWAY_URL=http://localhost:18789/api/agents
|
||||||
|
GATEWAY_POLL_INTERVAL=5s
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend (build-time)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
VITE_API_URL=http://localhost:8080
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker Compose
|
||||||
|
|
||||||
|
Set via `services.<name>.environment` in `docker-compose.yml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
backend:
|
||||||
|
environment:
|
||||||
|
- DATABASE_URL=...
|
||||||
|
frontend:
|
||||||
|
environment:
|
||||||
|
- VITE_API_URL=...
|
||||||
|
db:
|
||||||
|
environment:
|
||||||
|
- POSTGRES_USER=...
|
||||||
|
- POSTGRES_PASSWORD=...
|
||||||
|
- POSTGRES_DB=...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### Local Development (non-Docker)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Backend
|
||||||
|
cd go-backend
|
||||||
|
go run ./cmd/server/main.go
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database Migrations
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# If using pgx/migrate or similar
|
||||||
|
# The database is created automatically on first connection if it doesn't exist
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Backend won't connect to database
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check database container status
|
||||||
|
docker compose ps
|
||||||
|
|
||||||
|
# View database logs
|
||||||
|
docker compose logs db
|
||||||
|
|
||||||
|
# Test database connectivity from backend
|
||||||
|
docker compose exec backend ping db
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend can't reach backend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check network connectivity
|
||||||
|
docker compose exec frontend ping backend
|
||||||
|
|
||||||
|
# Verify backend is running
|
||||||
|
docker compose logs backend
|
||||||
|
```
|
||||||
|
|
||||||
|
### Kiosk browser won't start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check Chromium installation
|
||||||
|
which chromium-browser
|
||||||
|
|
||||||
|
# Check X11 forwarding
|
||||||
|
echo $DISPLAY
|
||||||
|
|
||||||
|
# Manual launch for debugging
|
||||||
|
./kiosk/start-kiosk.sh http://localhost:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
### Port conflicts
|
||||||
|
|
||||||
|
If ports 8080, 3000, or 5432 are already in use, modify `docker-compose.yml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
backend:
|
||||||
|
ports:
|
||||||
|
- "8081:8080" # Change host port
|
||||||
|
frontend:
|
||||||
|
ports:
|
||||||
|
- "3001:80" # Change host port
|
||||||
|
```
|
||||||
|
|
||||||
|
## Production Considerations
|
||||||
|
|
||||||
|
1. **HTTPS**: Add a reverse proxy (nginx/Traefik) for SSL termination
|
||||||
|
2. **Database security**: Use strong passwords, enable SSL
|
||||||
|
3. **CORS**: Restrict `CORS_ORIGIN` to production domain
|
||||||
|
4. **Logs**: Configure log aggregation (e.g., ELK, Loki)
|
||||||
|
5. **Backups**: Regular PostgreSQL volume backups
|
||||||
|
6. **Monitoring**: Add health checks and alerting
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
| File/Directory | Purpose |
|
||||||
|
|----------------|---------|
|
||||||
|
| `docker-compose.yml` | Service definitions and configuration |
|
||||||
|
| `.env.example` | Environment variable template |
|
||||||
|
| `go-backend/Dockerfile` | Backend build definition |
|
||||||
|
| `frontend/Dockerfile` | Frontend build definition |
|
||||||
|
| `frontend/nginx.conf` | Nginx config for SPA routing |
|
||||||
|
| `kiosk/start-kiosk.sh` | Kiosk browser startup script |
|
||||||
|
| `kiosk/control-center-kiosk.service` | Systemd unit for auto-start |
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
# Control Center
|
||||||
|
|
||||||
|
> Real-time agent fleet dashboard for the OpenClaw AI development team.
|
||||||
|
|
||||||
|
Control Center monitors and controls the full CubeCraft Creations AI agent fleet — Otto, Rex, Dex, Hex, Pip, Nano, Sketch, Bob, Stuart, Norbert, and Flip. It provides live agent status, task progress, session logs, and a command interface, backed by SignalR for push-based updates directly from the OpenClaw gateway.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
| Layer | Technology |
|
||||||
|
|---|---|
|
||||||
|
| Backend | ASP.NET Core Web API (.NET 8) |
|
||||||
|
| Database | PostgreSQL (snake_case via EF Core) |
|
||||||
|
| ORM | Entity Framework Core |
|
||||||
|
| Real-time | SignalR (`AgentStatusHub`) |
|
||||||
|
| Frontend | Angular (latest), Angular Material, Angular Signals |
|
||||||
|
| API Client | TypeScript package (`api-client/`) |
|
||||||
|
| Deployment | Docker |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
Control-Center/
|
||||||
|
├── backend/
|
||||||
|
│ ├── ControlCenter/
|
||||||
|
│ │ ├── Controllers/ # AgentsController, CommandController, LogsController
|
||||||
|
│ │ ├── Hubs/ # AgentStatusHub, IAgentStatusClient, AgentStatusModels
|
||||||
|
│ │ ├── Models/ # AgentMinionMapping
|
||||||
|
│ │ ├── Services/ # AgentMinionMapperService, GatewayEventBridgeService
|
||||||
|
│ │ ├── Program.cs
|
||||||
|
│ │ └── appsettings.json
|
||||||
|
│ ├── Configurations/ # AgentConfiguration
|
||||||
|
│ ├── Data/ # AppDbContext, AppDbContextFactory
|
||||||
|
│ ├── Dtos/ # AgentStatusUpdateDto
|
||||||
|
│ ├── Entities/ # Agent, AgentStatus
|
||||||
|
│ ├── Migrations/ # EF migrations
|
||||||
|
│ ├── Models/ # AgentState
|
||||||
|
│ └── Repositories/ # AgentStateRepository, IAgentStateRepository
|
||||||
|
├── frontend/
|
||||||
|
│ └── src/app/
|
||||||
|
│ ├── layout/ # LayoutShell, HeaderBar, NavRail, BottomNav
|
||||||
|
│ ├── pages/ # hub, logs, projects, sessions, settings
|
||||||
|
│ ├── components/ # AgentCard, AgentStatusBadge, TaskProgressBar,
|
||||||
|
│ │ # QuickJumpButton, QuickJumpDrawer,
|
||||||
|
│ │ # GlobalActionModal, AdaptiveNavigation
|
||||||
|
│ ├── command-hub/ # AgentCard command hub view
|
||||||
|
│ ├── models/ # Agent model, AgentStatus, AgentCardData, Nav
|
||||||
|
│ └── services/ # AgentStatusService (Angular Signals)
|
||||||
|
├── api-client/ # Shared TS client (models, SignalR WS, HTTP utils)
|
||||||
|
├── design/
|
||||||
|
│ ├── command-hub-spec.md
|
||||||
|
│ └── mockups/ # Desktop kiosk, mobile, quick-jump drawer
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Domain Model
|
||||||
|
|
||||||
|
### Agent
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `Id` | `Guid` | Primary key |
|
||||||
|
| `Status` | `AgentStatus` | Current agent state |
|
||||||
|
| `Task` | `string?` | Active task description |
|
||||||
|
| `Progress` | `int?` | Task completion % (0–100) |
|
||||||
|
| `SessionKey` | `string` | OpenClaw session identifier |
|
||||||
|
| `Channel` | `string` | Source channel (telegram, slack, etc.) |
|
||||||
|
| `LastActivity` | `DateTime` | Last event timestamp |
|
||||||
|
| `CreatedAt` / `UpdatedAt` | `DateTime` | Audit fields |
|
||||||
|
|
||||||
|
### AgentStatus enum
|
||||||
|
|
||||||
|
| Value | Integer | Meaning |
|
||||||
|
|---|---|---|
|
||||||
|
| `Active` | 0 | Agent is executing a task |
|
||||||
|
| `Idle` | 1 | Agent is waiting for work |
|
||||||
|
| `Thinking` | 2 | Agent is reasoning/planning |
|
||||||
|
| `Error` | 3 | Agent hit an unrecoverable state |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Design Decisions
|
||||||
|
|
||||||
|
1. **Angular Signals** — Reactive state management. No NgRx — kept intentionally simple.
|
||||||
|
2. **Adaptive layout** — Sidebar nav (NavRail) on desktop/kiosk (≥768px); bottom nav on mobile.
|
||||||
|
3. **Tactical Dark Mode** — Theme via CSS custom properties in `styles.scss`.
|
||||||
|
4. **SignalR fleet group** — Clients call `JoinFleet()` to subscribe to all agent updates broadcast by the hub.
|
||||||
|
5. **Backend push model** — Hub uses `IHubContext<AgentStatusHub, IAgentStatusClient>` extension methods to push to clients; no polling.
|
||||||
|
6. **Gateway bridge** — `GatewayEventBridgeService` connects the OpenClaw gateway WebSocket to the SignalR hub.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- .NET 8 SDK
|
||||||
|
- Node.js 20+
|
||||||
|
- Docker
|
||||||
|
- PostgreSQL
|
||||||
|
- Running OpenClaw gateway (for live agent events)
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
|
||||||
|
# Restore and build
|
||||||
|
dotnet restore
|
||||||
|
dotnet build
|
||||||
|
|
||||||
|
# Apply migrations
|
||||||
|
dotnet ef database update --project ControlCenter
|
||||||
|
|
||||||
|
# Run API
|
||||||
|
dotnet run --project ControlCenter
|
||||||
|
```
|
||||||
|
|
||||||
|
API runs at `http://localhost:5000` · Swagger at `http://localhost:5000/swagger`
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
ng serve
|
||||||
|
```
|
||||||
|
|
||||||
|
Frontend runs at `http://localhost:4200`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
`backend/ControlCenter/appsettings.json` — override in `appsettings.Development.json` or environment variables:
|
||||||
|
|
||||||
|
| Key | Default | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `ConnectionStrings:DefaultConnection` | *(set in dev config)* | PostgreSQL connection string |
|
||||||
|
| `Gateway:WebSocketUrl` | `ws://localhost:3271/ws` | OpenClaw gateway WebSocket URL |
|
||||||
|
| `Gateway:AuthToken` | `""` | Gateway auth token |
|
||||||
|
| `Cors:AllowedOrigins` | `localhost:4200`, `localhost:5000` | Frontend origins for CORS |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Real-Time Events
|
||||||
|
|
||||||
|
SignalR hub endpoint: `/hubs/agent-status`
|
||||||
|
|
||||||
|
### Hub Methods (server → client)
|
||||||
|
|
||||||
|
| Method | Payload | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `ReceiveStatusUpdate` | `AgentStatusUpdateDto` | Agent status changed |
|
||||||
|
| `ReceiveAgentList` | `Agent[]` | Full fleet snapshot on join |
|
||||||
|
|
||||||
|
### Client → Server
|
||||||
|
|
||||||
|
| Method | Description |
|
||||||
|
|---|---|
|
||||||
|
| `JoinFleet()` | Subscribe to all agent updates |
|
||||||
|
| `LeaveFleet()` | Unsubscribe |
|
||||||
|
| `SendStatusUpdate(dto)` | Push a status update (internal use) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pages
|
||||||
|
|
||||||
|
| Route | Page | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `/hub` | Command Hub | Live agent grid — status, task, progress |
|
||||||
|
| `/logs` | Logs | Agent session log viewer |
|
||||||
|
| `/projects` | Projects | Linear project tracking view |
|
||||||
|
| `/sessions` | Sessions | OpenClaw session browser |
|
||||||
|
| `/settings` | Settings | App configuration |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Agent Fleet
|
||||||
|
|
||||||
|
| Agent | Role |
|
||||||
|
|---|---|
|
||||||
|
| Otto | Orchestrator — owns the dev lifecycle |
|
||||||
|
| Rex | Frontend (Angular) |
|
||||||
|
| Dex | Backend (ASP.NET Core) |
|
||||||
|
| Hex | Database (schema, migrations) |
|
||||||
|
| Pip | Raspberry Pi / Python |
|
||||||
|
| Nano | Arduino / ESP32 / ESPHome |
|
||||||
|
| Sketch | UX/UI design |
|
||||||
|
| Flip | Mobile development |
|
||||||
|
| Bob | Content & marketing copy |
|
||||||
|
| Stuart | Concept visualization (images) |
|
||||||
|
| Norbert | 3D design (OpenSCAD) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Branch & PR Rules
|
||||||
|
|
||||||
|
- All feature branches target `dev` — **never `main`**
|
||||||
|
- Branch naming: `agent/<agent>/CUB-N-short-description`
|
||||||
|
- PR titles: `CUB-N: short description`
|
||||||
|
- PRs require Otto review before Joshua merges
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Overview
|
||||||
|
|
||||||
|
| Route prefix | Resource |
|
||||||
|
|---|---|
|
||||||
|
| `/api/agents` | Agent registry and status |
|
||||||
|
| `/api/command` | Issue commands to agents |
|
||||||
|
| `/api/logs` | Session log retrieval |
|
||||||
|
|
||||||
|
Full schema at `/swagger` when running in dev.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Built by CubeCraft Creations · Orchestrated by Otto*
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
// Package models defines the database entities for the Control Center Go backend.
|
||||||
|
// Structs map 1:1 to the PostgreSQL schema defined in backend/migrations/.
|
||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AgentStatus represents the possible lifecycle states of an agent.
|
||||||
|
type AgentStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
AgentStatusActive AgentStatus = "active"
|
||||||
|
AgentStatusIdle AgentStatus = "idle"
|
||||||
|
AgentStatusThinking AgentStatus = "thinking"
|
||||||
|
AgentStatusError AgentStatus = "error"
|
||||||
|
AgentStatusOffline AgentStatus = "offline"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Agent represents a registered agent and its current state.
|
||||||
|
type Agent struct {
|
||||||
|
ID pgtype.UUID `db:"id" json:"id"`
|
||||||
|
Name string `db:"name" json:"name"`
|
||||||
|
Status AgentStatus `db:"status" json:"status"`
|
||||||
|
Task *string `db:"task" json:"task,omitempty"`
|
||||||
|
Progress int32 `db:"progress" json:"progress"`
|
||||||
|
SessionKey *string `db:"session_key" json:"session_key,omitempty"`
|
||||||
|
Channel *string `db:"channel" json:"channel,omitempty"`
|
||||||
|
LastActivity time.Time `db:"last_activity" json:"last_activity"`
|
||||||
|
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||||
|
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SessionStatus represents the possible states of an agent session.
|
||||||
|
type SessionStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
SessionStatusRunning SessionStatus = "running"
|
||||||
|
SessionStatusCompleted SessionStatus = "completed"
|
||||||
|
SessionStatusCrashed SessionStatus = "crashed"
|
||||||
|
SessionStatusTerminated SessionStatus = "terminated"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Session tracks an agent session over time.
|
||||||
|
type Session struct {
|
||||||
|
ID pgtype.UUID `db:"id" json:"id"`
|
||||||
|
AgentID pgtype.UUID `db:"agent_id" json:"agent_id"`
|
||||||
|
StartedAt time.Time `db:"started_at" json:"started_at"`
|
||||||
|
EndedAt *time.Time `db:"ended_at" json:"ended_at,omitempty"`
|
||||||
|
Status SessionStatus `db:"status" json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TaskLogStatus represents the possible states of a task log entry.
|
||||||
|
type TaskLogStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
TaskLogStatusPending TaskLogStatus = "pending"
|
||||||
|
TaskLogStatusRunning TaskLogStatus = "running"
|
||||||
|
TaskLogStatusCompleted TaskLogStatus = "completed"
|
||||||
|
TaskLogStatusFailed TaskLogStatus = "failed"
|
||||||
|
TaskLogStatusCancelled TaskLogStatus = "cancelled"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TaskLog records a historical task assigned to an agent.
|
||||||
|
type TaskLog struct {
|
||||||
|
ID pgtype.UUID `db:"id" json:"id"`
|
||||||
|
AgentID pgtype.UUID `db:"agent_id" json:"agent_id"`
|
||||||
|
Task string `db:"task" json:"task"`
|
||||||
|
Status TaskLogStatus `db:"status" json:"status"`
|
||||||
|
StartedAt time.Time `db:"started_at" json:"started_at"`
|
||||||
|
CompletedAt *time.Time `db:"completed_at" json:"completed_at,omitempty"`
|
||||||
|
ErrorMessage *string `db:"error_message" json:"error_message,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProjectStatus represents the possible states of a project.
|
||||||
|
type ProjectStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ProjectStatusPlanned ProjectStatus = "planned"
|
||||||
|
ProjectStatusInProgress ProjectStatus = "in_progress"
|
||||||
|
ProjectStatusCompleted ProjectStatus = "completed"
|
||||||
|
ProjectStatusPaused ProjectStatus = "paused"
|
||||||
|
ProjectStatusCancelled ProjectStatus = "cancelled"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Project represents a project managed by the Control Center.
|
||||||
|
type Project struct {
|
||||||
|
ID pgtype.UUID `db:"id" json:"id"`
|
||||||
|
Name string `db:"name" json:"name"`
|
||||||
|
Description *string `db:"description" json:"description,omitempty"`
|
||||||
|
Status ProjectStatus `db:"status" json:"status"`
|
||||||
|
AgentID *pgtype.UUID `db:"agent_id" json:"agent_id,omitempty"`
|
||||||
|
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||||
|
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AgentEvent represents an event in the agent lifecycle or telemetry stream.
|
||||||
|
type AgentEvent struct {
|
||||||
|
ID pgtype.UUID `db:"id" json:"id"`
|
||||||
|
AgentID pgtype.UUID `db:"agent_id" json:"agent_id"`
|
||||||
|
EventType string `db:"event_type" json:"event_type"`
|
||||||
|
Payload *map[string]interface{} `db:"payload" json:"payload,omitempty"`
|
||||||
|
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
-- Migration: 001_initial_schema (down)
|
||||||
|
-- Description: Reverts the core Control Center database schema.
|
||||||
|
|
||||||
|
-- Drop in reverse dependency order to avoid FK conflicts
|
||||||
|
DROP TABLE IF EXISTS agent_events;
|
||||||
|
DROP TABLE IF EXISTS task_logs;
|
||||||
|
DROP TABLE IF EXISTS sessions;
|
||||||
|
DROP TABLE IF EXISTS projects;
|
||||||
|
DROP TABLE IF EXISTS agents;
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
-- Migration: 001_initial_schema
|
||||||
|
-- Description: Creates the core Control Center database schema.
|
||||||
|
|
||||||
|
-- Enable UUID extension
|
||||||
|
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Table: agents
|
||||||
|
-- ============================================
|
||||||
|
CREATE TABLE agents (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'idle'
|
||||||
|
CHECK (status IN ('active', 'idle', 'thinking', 'error', 'offline')),
|
||||||
|
task TEXT,
|
||||||
|
progress INT NOT NULL DEFAULT 0
|
||||||
|
CHECK (progress >= 0 AND progress <= 100),
|
||||||
|
session_key TEXT,
|
||||||
|
channel TEXT,
|
||||||
|
last_activity TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE agents IS 'Registered agents and their current state';
|
||||||
|
COMMENT ON COLUMN agents.status IS 'Agent lifecycle status: active, idle, thinking, error, offline';
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Table: sessions
|
||||||
|
-- ============================================
|
||||||
|
CREATE TABLE sessions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
agent_id UUID NOT NULL,
|
||||||
|
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
ended_at TIMESTAMPTZ,
|
||||||
|
status TEXT NOT NULL DEFAULT 'running'
|
||||||
|
CHECK (status IN ('running', 'completed', 'crashed', 'terminated')),
|
||||||
|
CONSTRAINT fk_sessions_agent
|
||||||
|
FOREIGN KEY (agent_id) REFERENCES agents(id)
|
||||||
|
ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE sessions IS 'Agent session history';
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Table: task_logs
|
||||||
|
-- ============================================
|
||||||
|
CREATE TABLE task_logs (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
agent_id UUID NOT NULL,
|
||||||
|
task TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending'
|
||||||
|
CHECK (status IN ('pending', 'running', 'completed', 'failed', 'cancelled')),
|
||||||
|
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
completed_at TIMESTAMPTZ,
|
||||||
|
error_message TEXT,
|
||||||
|
CONSTRAINT fk_task_logs_agent
|
||||||
|
FOREIGN KEY (agent_id) REFERENCES agents(id)
|
||||||
|
ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE task_logs IS 'Historical record of tasks assigned to agents';
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Table: projects
|
||||||
|
-- ============================================
|
||||||
|
CREATE TABLE projects (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'planned'
|
||||||
|
CHECK (status IN ('planned', 'in_progress', 'completed', 'paused', 'cancelled')),
|
||||||
|
agent_id UUID,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
CONSTRAINT fk_projects_agent
|
||||||
|
FOREIGN KEY (agent_id) REFERENCES agents(id)
|
||||||
|
ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE projects IS 'Projects managed by the Control Center';
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Table: agent_events
|
||||||
|
-- ============================================
|
||||||
|
CREATE TABLE agent_events (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
agent_id UUID NOT NULL,
|
||||||
|
event_type TEXT NOT NULL,
|
||||||
|
payload JSONB,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
CONSTRAINT fk_agent_events_agent
|
||||||
|
FOREIGN KEY (agent_id) REFERENCES agents(id)
|
||||||
|
ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE agent_events IS 'Event stream for agent lifecycle and telemetry';
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
-- Migration: 002_add_indexes (down)
|
||||||
|
-- Description: Remove all indexes added in 002_add_indexes.
|
||||||
|
|
||||||
|
DROP INDEX IF EXISTS idx_agents_status;
|
||||||
|
DROP INDEX IF EXISTS idx_agents_last_activity;
|
||||||
|
DROP INDEX IF EXISTS idx_agents_created_at;
|
||||||
|
|
||||||
|
DROP INDEX IF EXISTS idx_sessions_agent_id;
|
||||||
|
DROP INDEX IF EXISTS idx_sessions_status;
|
||||||
|
DROP INDEX IF EXISTS idx_sessions_started_at;
|
||||||
|
|
||||||
|
DROP INDEX IF EXISTS idx_task_logs_agent_started;
|
||||||
|
DROP INDEX IF EXISTS idx_task_logs_status;
|
||||||
|
|
||||||
|
DROP INDEX IF EXISTS idx_agent_events_agent_created;
|
||||||
|
DROP INDEX IF EXISTS idx_agent_events_event_type;
|
||||||
|
|
||||||
|
DROP INDEX IF EXISTS idx_projects_status;
|
||||||
|
DROP INDEX IF EXISTS idx_projects_agent_id;
|
||||||
|
DROP INDEX IF EXISTS idx_projects_created;
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
-- Migration: 002_add_indexes
|
||||||
|
-- Description: Add performance indexes for common query patterns.
|
||||||
|
|
||||||
|
-- agents: status filtering, activity ordering
|
||||||
|
CREATE INDEX idx_agents_status ON agents(status);
|
||||||
|
CREATE INDEX idx_agents_last_activity ON agents(last_activity DESC);
|
||||||
|
CREATE INDEX idx_agents_created_at ON agents(created_at DESC);
|
||||||
|
|
||||||
|
-- sessions: agent session lookups, active session checks
|
||||||
|
CREATE INDEX idx_sessions_agent_id ON sessions(agent_id);
|
||||||
|
CREATE INDEX idx_sessions_status ON sessions(status);
|
||||||
|
CREATE INDEX idx_sessions_started_at ON sessions(started_at DESC);
|
||||||
|
|
||||||
|
-- task_logs: agent task history, chronological ordering
|
||||||
|
CREATE INDEX idx_task_logs_agent_started ON task_logs(agent_id, started_at DESC);
|
||||||
|
CREATE INDEX idx_task_logs_status ON task_logs(status);
|
||||||
|
|
||||||
|
-- agent_events: event stream queries
|
||||||
|
CREATE INDEX idx_agent_events_agent_created ON agent_events(agent_id, created_at DESC);
|
||||||
|
CREATE INDEX idx_agent_events_event_type ON agent_events(event_type);
|
||||||
|
|
||||||
|
-- projects: status filtering, agent assignment
|
||||||
|
CREATE INDEX idx_projects_status ON projects(status);
|
||||||
|
CREATE INDEX idx_projects_agent_id ON projects(agent_id);
|
||||||
|
CREATE INDEX idx_projects_created ON projects(created_at DESC);
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
FROM catthehacker/ubuntu:act-latest
|
||||||
|
|
||||||
|
# Install Go 1.23
|
||||||
|
RUN curl -sL https://go.dev/dl/go1.23.6.linux-amd64.tar.gz | tar -C /usr/local -xz
|
||||||
|
|
||||||
|
# Install Node 22
|
||||||
|
RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
|
||||||
|
&& apt-get install -y nodejs \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
ENV PATH="/usr/local/go/bin:${PATH}"
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
# Control Center - Go + React + PostgreSQL Deployment
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
services:
|
||||||
|
# ── Backend Service (Go) ───────────────────────────────────────────────
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: ./go-backend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
environment:
|
||||||
|
- DATABASE_URL=postgresql://controlcenter:controlcenter@db:5432/controlcenter?sslmode=disable
|
||||||
|
- CORS_ORIGIN=http://localhost:3000
|
||||||
|
- LOG_LEVEL=info
|
||||||
|
- ENVIRONMENT=production
|
||||||
|
- PORT=8080
|
||||||
|
- GATEWAY_URL=http://host.docker.internal:18789/api/agents
|
||||||
|
- WS_GATEWAY_URL=ws://host.docker.internal:18789/
|
||||||
|
- GATEWAY_TOKEN=${GATEWAY_TOKEN:-}
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "-qO-", "http://localhost:8080/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 10s
|
||||||
|
networks:
|
||||||
|
- control-center-network
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# ── Frontend Service (React) ───────────────────────────────────────────
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: ./frontend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
ports:
|
||||||
|
- "3000:80"
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
environment:
|
||||||
|
- VITE_API_URL=http://localhost:8080
|
||||||
|
networks:
|
||||||
|
- control-center-network
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# ── Database Service (PostgreSQL 16) ───────────────────────────────────
|
||||||
|
db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: control-center-db
|
||||||
|
environment:
|
||||||
|
- POSTGRES_USER=controlcenter
|
||||||
|
- POSTGRES_PASSWORD=controlcenter
|
||||||
|
- POSTGRES_DB=controlcenter
|
||||||
|
volumes:
|
||||||
|
- postgres-data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U controlcenter -d controlcenter"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 10s
|
||||||
|
networks:
|
||||||
|
- control-center-network
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
networks:
|
||||||
|
control-center-network:
|
||||||
|
driver: bridge
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres-data:
|
||||||
+304
@@ -0,0 +1,304 @@
|
|||||||
|
# Control Center — Project Context
|
||||||
|
|
||||||
|
> **Last updated:** 2026-05-21
|
||||||
|
> **Repo:** `CubeCraft-Creations/Control-Center` | **Host:** `code.cubecraftcreations.com`
|
||||||
|
> **Local clone:** `/mnt/ai-storage/projects/Control-Center` | **Default branch:** `dev`
|
||||||
|
> **Discord:** `DISCORD_DEV_CONTROL_CENTER_CHANNEL_ID`
|
||||||
|
> **Linear Epic:** [CUB-119](https://linear.app/cubecraft-creations/issue/CUB-119)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Real-time dashboard for monitoring and controlling the OpenClaw agent fleet. Displays agent statuses, active tasks, sessions, and projects. Uses SSE for live updates from the Go backend, which connects to the OpenClaw gateway via WebSocket for live agent data.
|
||||||
|
|
||||||
|
**Completed refactor:** ASP.NET Core + Angular → Go + React is done (CUB-119 epic). All legacy code is removed from git.
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
| Layer | Technology | Notes |
|
||||||
|
|-------|-----------|-------|
|
||||||
|
| Backend | Go 1.24+ | Chi router, pgx (PostgreSQL), SSE broker, gorilla/websocket |
|
||||||
|
| Frontend | React 18 + TypeScript | Vite, Tailwind CSS, React Query, TanStack Router |
|
||||||
|
| Database | PostgreSQL 16+ | snake_case naming, 2 migrations |
|
||||||
|
| Real-time | SSE + WebSocket | SSE for browser, WebSocket for OpenClaw gateway |
|
||||||
|
| Gateway Integration | WebSocket client | OpenClaw gateway `/ws` — live agent + session RPC |
|
||||||
|
| API Client | TypeScript SDK | `api-client/` — shared models, WS client, HTTP client |
|
||||||
|
| CI/CD | Gitea Actions | `.gitea/workflows/dev.yml`, `deploy-dev.yaml` |
|
||||||
|
| Deployment | Docker Compose | PostgreSQL + Go backend + React/nginx |
|
||||||
|
| Testing | Vitest, Go test | Unit + integration tests for WS client, gateway, handlers |
|
||||||
|
| Design | Kiosk layout | Bottom nav (mobile), nav rail (desktop), quick-jump drawer |
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
OpenClaw Gateway (WebSocket)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Go Backend (Chi + pgx)
|
||||||
|
├── Gateway WS Client (connect, reconnect, agents.list, sessions.list RPC)
|
||||||
|
├── SSE Broker (fan-out: agent.status, agent.task, fleet.update)
|
||||||
|
├── REST API (/api/agents, /api/sessions, /api/tasks, /api/projects)
|
||||||
|
└── Repository/Store layers → PostgreSQL
|
||||||
|
│
|
||||||
|
├── SSE /api/events
|
||||||
|
▼
|
||||||
|
React Frontend
|
||||||
|
├── SSEProvider → useRealtimeSync → React Query cache
|
||||||
|
├── HubPage (dashboard), LogsPage, ProjectsPage, SessionsPage, SettingsPage
|
||||||
|
└── Layout (header bar + nav rail + bottom nav + quick-jump drawer)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Architecture Decisions
|
||||||
|
1. **Go replaced ASP.NET Core** — lighter runtime, faster cold-start, better concurrency for gateway polling
|
||||||
|
2. **React replaced Angular** — lighter than Angular for dashboard/kiosk use
|
||||||
|
3. **SSE over SignalR** — simpler server-side, unidirectional events sufficient for browser updates
|
||||||
|
4. **WebSocket for gateway integration** — bidirectional needed for RPC (agents.list, sessions.list)
|
||||||
|
5. **PostgreSQL** — shared with Extrudex pattern; migrations in `go-backend/migrations/`
|
||||||
|
6. **Agent state seeded on first boot** via `gateway.SeedDemoAgents` for offline dev
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
Control-Center/
|
||||||
|
├── go-backend/ # Go backend
|
||||||
|
│ ├── cmd/server/main.go # Entrypoint, wire deps, start gateway poller
|
||||||
|
│ ├── Dockerfile / go.mod / go.sum
|
||||||
|
│ ├── migrations/ # 001_initial_schema, 002_add_indexes
|
||||||
|
│ └── internal/
|
||||||
|
│ ├── config/config.go # Env vars (DATABASE_URL, GATEWAY_URL, etc.)
|
||||||
|
│ ├── db/db.go # PostgreSQL pool (pgx)
|
||||||
|
│ ├── gateway/
|
||||||
|
│ │ ├── client.go # GW poller → sync DB + SSE fan-out
|
||||||
|
│ │ ├── events.go # SSE event broker
|
||||||
|
│ │ ├── events_test.go
|
||||||
|
│ │ ├── sync.go # Initial sync from gateway
|
||||||
|
│ │ ├── sync_test.go
|
||||||
|
│ │ ├── wsclient.go # WebSocket client (handshake, connect, reconnect, RPC)
|
||||||
|
│ │ └── wsclient_test.go
|
||||||
|
│ ├── handler/
|
||||||
|
│ │ ├── agent.go # CRUD + history
|
||||||
|
│ │ ├── project.go # List projects
|
||||||
|
│ │ ├── session.go # List sessions
|
||||||
|
│ │ ├── sse.go # SSE broker: subscribe + broadcast
|
||||||
|
│ │ ├── task.go # List tasks
|
||||||
|
│ │ ├── helpers.go
|
||||||
|
│ │ ├── handler_test.go
|
||||||
|
│ │ └── mock_repos_test.go
|
||||||
|
│ ├── models/models.go # Domain types
|
||||||
|
│ ├── repository/ # DB access layer + interfaces
|
||||||
|
│ ├── router/router.go # Chi router: REST + SSE mount
|
||||||
|
│ └── store/ # Agent, Project, Session, Task stores
|
||||||
|
├── api-client/ # Shared TypeScript SDK
|
||||||
|
│ └── src/
|
||||||
|
│ ├── models/types.ts # Agent, Session, Task, Project, SSE event types
|
||||||
|
│ ├── services/http-client.ts # Axios REST client
|
||||||
|
│ ├── utils/
|
||||||
|
│ │ ├── config.ts # Client config
|
||||||
|
│ │ └── status-mapper.ts # Agent status → display mapping
|
||||||
|
│ └── websocket/
|
||||||
|
│ └── ws-client.ts # WebSocket client (handshake, Send, RPC, reconnector)
|
||||||
|
├── frontend/ # React frontend
|
||||||
|
│ ├── Dockerfile + nginx.conf
|
||||||
|
│ ├── package.json + vite.config.ts
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── App.tsx / main.tsx
|
||||||
|
│ │ ├── components/
|
||||||
|
│ │ │ ├── ErrorBoundary.tsx
|
||||||
|
│ │ │ └── Layout.tsx # Header bar + nav rail + bottom nav + quick-jump
|
||||||
|
│ │ ├── contexts/
|
||||||
|
│ │ │ └── SSEContext.tsx # SSEProvider — wraps entire app
|
||||||
|
│ │ ├── hooks/
|
||||||
|
│ │ │ ├── useLocalStorage.ts
|
||||||
|
│ │ │ ├── useRealtimeSync.ts # SSE messages → React Query cache
|
||||||
|
│ │ │ ├── useRealtimeSync.test.tsx
|
||||||
|
│ │ │ ├── useSSE.ts # SSE: connect, reconnect, typed events
|
||||||
|
│ │ │ ├── useSSE.test.ts
|
||||||
|
│ │ │ └── useTheme.tsx
|
||||||
|
│ │ ├── pages/
|
||||||
|
│ │ │ ├── HubPage.tsx # Fleet dashboard (agent grid + stats)
|
||||||
|
│ │ │ ├── LogsPage.tsx # Agent log viewer
|
||||||
|
│ │ │ ├── ProjectsPage.tsx # Project list
|
||||||
|
│ │ │ ├── SessionsPage.tsx # Session list
|
||||||
|
│ │ │ └── SettingsPage.tsx # Settings + theme toggle
|
||||||
|
│ │ ├── services/
|
||||||
|
│ │ │ ├── api.ts # Axios REST client
|
||||||
|
│ │ │ └── sse.ts # SSE utilities
|
||||||
|
│ │ └── types/index.ts
|
||||||
|
│ └── vitest.config.ts
|
||||||
|
├── frontend-legacy/ # Original Angular frontend (kept for reference, not in git)
|
||||||
|
├── backend/ # Original ASP.NET backend (kept for reference, not in git)
|
||||||
|
│ ├── ControlCenter/ # ASP.NET Core project
|
||||||
|
│ └── Api/ # API layer
|
||||||
|
├── design/
|
||||||
|
│ ├── command-hub-spec.md # Detailed design spec
|
||||||
|
│ └── mockups/ # Desktop kiosk, mobile, quick-jump drawer
|
||||||
|
├── kiosk/
|
||||||
|
│ ├── control-center-kiosk.service # Systemd service
|
||||||
|
│ └── start-kiosk.sh # Kiosk startup script
|
||||||
|
├── reference/
|
||||||
|
│ └── CONTROL_CENTER_CONTEXT.md # Older context file (superseded by this one)
|
||||||
|
├── ci-image/Dockerfile # CI build image
|
||||||
|
├── docker-compose.yml
|
||||||
|
└── .env.example
|
||||||
|
```
|
||||||
|
|
||||||
|
## Database Schema (PostgreSQL)
|
||||||
|
|
||||||
|
### agents
|
||||||
|
| Column | Type | Notes |
|
||||||
|
|--------|------|-------|
|
||||||
|
| id | UUID PK | |
|
||||||
|
| display_name | VARCHAR(256) NOT NULL | |
|
||||||
|
| role | VARCHAR(256) | |
|
||||||
|
| status | VARCHAR(32) | active, idle, thinking, error |
|
||||||
|
| current_task | VARCHAR(512) | |
|
||||||
|
| task_progress | INTEGER DEFAULT 0 | |
|
||||||
|
| session_key | VARCHAR(256) | |
|
||||||
|
| channel | VARCHAR(256) | |
|
||||||
|
| last_activity | TIMESTAMP | |
|
||||||
|
| error_message | TEXT | |
|
||||||
|
| created_at | TIMESTAMP | |
|
||||||
|
| updated_at | TIMESTAMP | |
|
||||||
|
|
||||||
|
### sessions
|
||||||
|
| Column | Type |
|
||||||
|
|--------|------|
|
||||||
|
| id | UUID PK |
|
||||||
|
| session_key | VARCHAR(256) UNIQUE |
|
||||||
|
| agent_id | UUID FK → agents |
|
||||||
|
| channel | VARCHAR(256) |
|
||||||
|
| status | VARCHAR(32) |
|
||||||
|
| context_tokens | INTEGER |
|
||||||
|
| total_tokens | INTEGER |
|
||||||
|
| estimated_cost | NUMERIC |
|
||||||
|
| model | VARCHAR(256) |
|
||||||
|
| started_at | TIMESTAMP |
|
||||||
|
| last_activity_at | TIMESTAMP |
|
||||||
|
|
||||||
|
### tasks
|
||||||
|
| Column | Type |
|
||||||
|
|--------|------|
|
||||||
|
| id | UUID PK |
|
||||||
|
| agent_id | UUID FK → agents |
|
||||||
|
| title | VARCHAR(512) |
|
||||||
|
| description | TEXT |
|
||||||
|
| status | VARCHAR(32) |
|
||||||
|
| progress | INTEGER DEFAULT 0 |
|
||||||
|
| session_key | VARCHAR(256) |
|
||||||
|
| created_at, updated_at | TIMESTAMP |
|
||||||
|
|
||||||
|
### projects
|
||||||
|
| Column | Type |
|
||||||
|
|--------|------|
|
||||||
|
| id | UUID PK |
|
||||||
|
| name | VARCHAR(256) UNIQUE |
|
||||||
|
| description | TEXT |
|
||||||
|
| status | VARCHAR(32) |
|
||||||
|
| agent_ids | TEXT[] |
|
||||||
|
| created_at, updated_at | TIMESTAMP |
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
|--------|----------|-------------|
|
||||||
|
| GET | /health | Health check (probes DB) |
|
||||||
|
| GET | /api/agents | List all agents |
|
||||||
|
| POST | /api/agents | Create agent |
|
||||||
|
| GET | /api/agents/:id | Get agent detail |
|
||||||
|
| PUT | /api/agents/:id | Update agent |
|
||||||
|
| DELETE | /api/agents/:id | Delete agent |
|
||||||
|
| GET | /api/agents/:id/history | Agent status history |
|
||||||
|
| GET | /api/sessions | List sessions |
|
||||||
|
| GET | /api/tasks | List tasks |
|
||||||
|
| GET | /api/projects | List projects |
|
||||||
|
| GET | /api/events | SSE event stream |
|
||||||
|
|
||||||
|
## SSE Events
|
||||||
|
|
||||||
|
| Event Type | Payload |
|
||||||
|
|------------|---------|
|
||||||
|
| `agent.status` | Agent status change |
|
||||||
|
| `agent.task` | Agent current task updated |
|
||||||
|
| `agent.progress` | Task progress percentage |
|
||||||
|
| `fleet.update` | Full fleet snapshot |
|
||||||
|
| `connected` | Connection established |
|
||||||
|
|
||||||
|
## CI/CD Pipeline
|
||||||
|
|
||||||
|
### dev.yml
|
||||||
|
- Lint + typecheck: Go vet + golangci-lint + tsc
|
||||||
|
- Test: Go test + vitest
|
||||||
|
- Build: Go build + npm build
|
||||||
|
- Docker: Build and push images
|
||||||
|
- Triggers: push to dev/main
|
||||||
|
|
||||||
|
### deploy-dev.yaml
|
||||||
|
- Workflow dispatch
|
||||||
|
- SCP deploy script to dev host
|
||||||
|
- systemctl restart with rollback
|
||||||
|
|
||||||
|
## Docker Compose
|
||||||
|
|
||||||
|
| Service | Image/Build | Ports | Depends On |
|
||||||
|
|---------|-------------|-------|------------|
|
||||||
|
| postgres | postgres:16-alpine | 5432 | — |
|
||||||
|
| backend | ./go-backend/Dockerfile | 8080 | postgres (healthy) |
|
||||||
|
| frontend | ./frontend/Dockerfile | 3000 | backend (healthy) |
|
||||||
|
|
||||||
|
## Linear Issue Map
|
||||||
|
|
||||||
|
| CUB | Title | Status |
|
||||||
|
|-----|-------|--------|
|
||||||
|
| 119 | **Epic: Control Center Refactor — .NET → Go + React** | Todo |
|
||||||
|
| 120 | PostgreSQL schema + migrations | Done |
|
||||||
|
| 121 | React pages wired to real API | Done |
|
||||||
|
| 122 | React frontend scaffold | Done |
|
||||||
|
| 123 | Gateway integration + SSE streaming | Done |
|
||||||
|
| 124 | Go backend scaffold | Done |
|
||||||
|
| 125 | Real-time SSE frontend | Done |
|
||||||
|
| 126 | Docker Compose deployment | Done |
|
||||||
|
| 127 | CRUD API endpoints | Done |
|
||||||
|
| 200 | Live WebSocket gateway client (CUB-200-207 sub-epic) | In Review |
|
||||||
|
| 201 | agents.list + sessions.list RPC and data mapping | In Review |
|
||||||
|
| 202 | Real-time event subscription + SSE fan-out | In Review |
|
||||||
|
| 203 | WS client scaffold — handshake, connect, reconnect loop | In Review |
|
||||||
|
| 204 | Config, wiring, and graceful fallback | In Review |
|
||||||
|
| 205 | Unit tests — gateway utility functions | In Review |
|
||||||
|
| 206 | Unit tests — WSClient handshake, Send/RPC, frame router, reconnect | In Review |
|
||||||
|
| 207 | Unit tests — event handlers and initial sync | In Review |
|
||||||
|
|
||||||
|
### Legacy Issues (Angular/ASP.NET — all Done)
|
||||||
|
CUB-19 through CUB-63: All 27 Control Center issues completed, including minion mapping, breakroom UI, dark mode theme, agent cards, quick-jump drawer, adaptive nav, SignalR hub, and status animations.
|
||||||
|
|
||||||
|
## Known Limitations / Next Steps
|
||||||
|
|
||||||
|
1. Agent detail/history views are scaffolded but not fully implemented
|
||||||
|
2. 16-bit minion breakroom concept (CUB-59-63) was on Angular — needs React port if desired
|
||||||
|
3. `.env` must be created from `.env.example` with a valid `GATEWAY_URL` for live agent data
|
||||||
|
4. Docker containers not currently running — start with `docker compose up --build -d`
|
||||||
|
|
||||||
|
## Default Agent Assignments
|
||||||
|
|
||||||
|
| Area | Agent | Notes |
|
||||||
|
|------|-------|-------|
|
||||||
|
| Backend (Go API, Gateway WS, SSE) | Dex | gitea-dex MCP |
|
||||||
|
| Database (PostgreSQL schema) | Hex | gitea-hex MCP |
|
||||||
|
| Frontend (React, Tailwind) | Rex | gitea-rex MCP |
|
||||||
|
| Design (wireframes, UX) | Sketch | |
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /mnt/ai-storage/projects/Control-Center
|
||||||
|
git checkout dev
|
||||||
|
git pull origin dev
|
||||||
|
|
||||||
|
# Docker Compose (recommended)
|
||||||
|
cp .env.example .env # edit GATEWAY_URL first
|
||||||
|
docker compose up --build -d
|
||||||
|
|
||||||
|
# Manual
|
||||||
|
cd go-backend && go run cmd/server/main.go # → :8080
|
||||||
|
cd frontend && npm install && npm run dev # → :5173 (Vite proxy to :8080)
|
||||||
|
```
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-error.log
|
||||||
|
|
||||||
|
# Build output (rebuilt in container)
|
||||||
|
dist/
|
||||||
|
out-tsc/
|
||||||
|
|
||||||
|
# Angular cache
|
||||||
|
.angular/cache/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.sublime-workspace
|
||||||
|
|
||||||
|
# OS files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Git
|
||||||
|
.git/
|
||||||
|
.gitignore
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
Dockerfile
|
||||||
|
.dockerignore
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
coverage/
|
||||||
|
tmp/
|
||||||
|
*.log
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
|
||||||
|
|
||||||
|
# Compiled output
|
||||||
|
/dist
|
||||||
|
/tmp
|
||||||
|
/out-tsc
|
||||||
|
/bazel-out
|
||||||
|
|
||||||
|
# Node
|
||||||
|
/node_modules
|
||||||
|
npm-debug.log
|
||||||
|
yarn-error.log
|
||||||
|
|
||||||
|
# IDEs and editors
|
||||||
|
.idea/
|
||||||
|
.project
|
||||||
|
.classpath
|
||||||
|
.c9/
|
||||||
|
*.launch
|
||||||
|
.settings/
|
||||||
|
*.sublime-workspace
|
||||||
|
|
||||||
|
# Visual Studio Code
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/settings.json
|
||||||
|
!.vscode/tasks.json
|
||||||
|
!.vscode/launch.json
|
||||||
|
!.vscode/extensions.json
|
||||||
|
!.vscode/mcp.json
|
||||||
|
.history/*
|
||||||
|
|
||||||
|
# Miscellaneous
|
||||||
|
/.angular/cache
|
||||||
|
.sass-cache/
|
||||||
|
/connect.lock
|
||||||
|
/coverage
|
||||||
|
/libpeerconnection.log
|
||||||
|
testem.log
|
||||||
|
/typings
|
||||||
|
__screenshots__/
|
||||||
|
|
||||||
|
# System files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# ============================================================
|
||||||
|
# Control Center Frontend — Multi-stage Docker Build
|
||||||
|
# Angular 21 + nginx for static serving + API proxy
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
# --- Build Stage ---
|
||||||
|
FROM node:22-slim AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install dependencies first (layer caching)
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
# Copy source and build production bundle
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# --- Runtime Stage ---
|
||||||
|
FROM nginx:1.27-alpine AS runtime
|
||||||
|
|
||||||
|
# Remove default nginx config
|
||||||
|
RUN rm /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
# Copy custom nginx config
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
# Copy built Angular app from builder stage
|
||||||
|
COPY --from=builder /app/dist/frontend/browser /usr/share/nginx/html
|
||||||
|
|
||||||
|
# Expose HTTP port
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
# Health check — confirm nginx is serving
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||||
|
CMD wget -qO- http://localhost/ || exit 1
|
||||||
|
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# Frontend
|
||||||
|
|
||||||
|
This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 21.2.8.
|
||||||
|
|
||||||
|
## Development server
|
||||||
|
|
||||||
|
To start a local development server, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ng serve
|
||||||
|
```
|
||||||
|
|
||||||
|
Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files.
|
||||||
|
|
||||||
|
## Code scaffolding
|
||||||
|
|
||||||
|
Angular CLI includes powerful code scaffolding tools. To generate a new component, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ng generate component component-name
|
||||||
|
```
|
||||||
|
|
||||||
|
For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ng generate --help
|
||||||
|
```
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
To build the project run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ng build
|
||||||
|
```
|
||||||
|
|
||||||
|
This will compile your project and store the build artifacts in the `dist/` directory. By default, the production build optimizes your application for performance and speed.
|
||||||
|
|
||||||
|
## Running unit tests
|
||||||
|
|
||||||
|
To execute unit tests with the [Vitest](https://vitest.dev/) test runner, use the following command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ng test
|
||||||
|
```
|
||||||
|
|
||||||
|
## Running end-to-end tests
|
||||||
|
|
||||||
|
For end-to-end (e2e) testing, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ng e2e
|
||||||
|
```
|
||||||
|
|
||||||
|
Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs.
|
||||||
|
|
||||||
|
## Additional Resources
|
||||||
|
|
||||||
|
For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.
|
||||||
@@ -53,7 +53,13 @@
|
|||||||
],
|
],
|
||||||
"styles": [
|
"styles": [
|
||||||
"src/styles.scss"
|
"src/styles.scss"
|
||||||
]
|
],
|
||||||
|
"stylePreprocessorOptions": {
|
||||||
|
"includePaths": [
|
||||||
|
"src",
|
||||||
|
"src/styles"
|
||||||
|
]
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"configurations": {
|
"configurations": {
|
||||||
"production": {
|
"production": {
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# Gzip compression
|
||||||
|
gzip on;
|
||||||
|
gzip_vary on;
|
||||||
|
gzip_proxied any;
|
||||||
|
gzip_comp_level 6;
|
||||||
|
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript image/svg+xml;
|
||||||
|
|
||||||
|
# Cache static assets (Angular uses content hashes)
|
||||||
|
location /assets/ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
|
||||||
|
# Cache hashed JS/CSS bundles
|
||||||
|
location ~* \.(js|css)$ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
|
||||||
|
# Proxy API requests to backend
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://backend:8080/api/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Proxy SignalR WebSocket connections to backend
|
||||||
|
location /hubs/ {
|
||||||
|
proxy_pass http://backend:8080/hubs/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_read_timeout 86400;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Angular SPA — all other routes fall back to index.html
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+8034
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"scripts": {
|
||||||
|
"ng": "ng",
|
||||||
|
"start": "ng serve",
|
||||||
|
"build": "ng build",
|
||||||
|
"watch": "ng build --watch --configuration development",
|
||||||
|
"test": "ng test"
|
||||||
|
},
|
||||||
|
"private": true,
|
||||||
|
"packageManager": "npm@11.11.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@angular/animations": "^21.2.10",
|
||||||
|
"@angular/cdk": "^21.2.8",
|
||||||
|
"@angular/common": "^21.2.0",
|
||||||
|
"@angular/compiler": "^21.2.0",
|
||||||
|
"@angular/core": "^21.2.0",
|
||||||
|
"@angular/forms": "^21.2.0",
|
||||||
|
"@angular/material": "^21.2.8",
|
||||||
|
"@angular/platform-browser": "^21.2.0",
|
||||||
|
"@angular/router": "^21.2.0",
|
||||||
|
"@microsoft/signalr": "^10.0.0",
|
||||||
|
"rxjs": "~7.8.0",
|
||||||
|
"tslib": "^2.3.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@angular/build": "^21.2.8",
|
||||||
|
"@angular/cli": "^21.2.8",
|
||||||
|
"@angular/compiler-cli": "^21.2.0",
|
||||||
|
"prettier": "^3.8.1",
|
||||||
|
"typescript": "~5.9.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<router-outlet />
|
||||||
@@ -5,7 +5,7 @@ import { RouterOutlet } from '@angular/router';
|
|||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [RouterOutlet],
|
imports: [RouterOutlet],
|
||||||
template: `<router-outlet />`,
|
template: '<router-outlet />',
|
||||||
styles: [`
|
styles: [`
|
||||||
:host {
|
:host {
|
||||||
display: block;
|
display: block;
|
||||||
+23
-5
@@ -2,12 +2,19 @@
|
|||||||
<!-- AgentCard — per spec Section 7.3 -->
|
<!-- AgentCard — per spec Section 7.3 -->
|
||||||
<!-- Integrates: Status Badge · Task Progress Bar · Quick‑Jump Button -->
|
<!-- Integrates: Status Badge · Task Progress Bar · Quick‑Jump Button -->
|
||||||
<!-- Left‑border accent matches status color. role="article" + aria‑labels. -->
|
<!-- Left‑border accent matches status color. role="article" + aria‑labels. -->
|
||||||
|
<!-- Enhanced: data-status attribute, elapsed time, design tokens. -->
|
||||||
<!-- ========================================================================== -->
|
<!-- ========================================================================== -->
|
||||||
<article
|
<article
|
||||||
class="agent-card"
|
class="agent-card"
|
||||||
role="article"
|
role="article"
|
||||||
[attr.aria-label]="displayName + ' — ' + statusLabel()"
|
[attr.data-status]="status"
|
||||||
|
[attr.aria-label]="(displayName || agentId) + ' — ' + statusLabel()"
|
||||||
[style.border-left-color]="statusBorderColor()"
|
[style.border-left-color]="statusBorderColor()"
|
||||||
|
(click)="cardClick.emit(sessionKey)"
|
||||||
|
appLongPress
|
||||||
|
[appLongPressDuration]="500"
|
||||||
|
(appLongPress)="cardLongPress.emit(sessionKey)"
|
||||||
|
tabindex="0"
|
||||||
>
|
>
|
||||||
|
|
||||||
<!-- ── Header: status badge + agent info ── -->
|
<!-- ── Header: status badge + agent info ── -->
|
||||||
@@ -27,19 +34,20 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── Body: current task ── -->
|
<!-- ── Body: current task ── -->
|
||||||
<div class="agent-card__body" *ngIf="task || status === 'error'">
|
<div class="agent-card__body" *ngIf="task || isError()">
|
||||||
<p
|
<p
|
||||||
class="agent-card__task"
|
class="agent-card__task"
|
||||||
[attr.aria-label]="'Current task: ' + (status === 'error' ? errorMessage : task)"
|
[class.agent-card__task--error]="isError()"
|
||||||
|
[attr.aria-label]="'Current task: ' + (isError() ? errorMessage || task : task)"
|
||||||
>
|
>
|
||||||
{{ status === 'error' ? errorMessage || task : task }}
|
{{ isError() ? errorMessage || task : task }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── Task Progress Bar ── -->
|
<!-- ── Task Progress Bar ── -->
|
||||||
<div
|
<div
|
||||||
class="agent-card__progress"
|
class="agent-card__progress"
|
||||||
*ngIf="progress > 0 && status !== 'error'"
|
*ngIf="showProgress()"
|
||||||
[attr.aria-label]="'Task progress: ' + progress + '%'"
|
[attr.aria-label]="'Task progress: ' + progress + '%'"
|
||||||
>
|
>
|
||||||
<mat-progress-bar
|
<mat-progress-bar
|
||||||
@@ -50,6 +58,16 @@
|
|||||||
<span class="agent-card__progress-label text-mono">{{ progress }}%</span>
|
<span class="agent-card__progress-label text-mono">{{ progress }}%</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Elapsed Time ── -->
|
||||||
|
<div
|
||||||
|
class="agent-card__elapsed"
|
||||||
|
*ngIf="taskElapsed && isActiveLike()"
|
||||||
|
[attr.aria-label]="'Elapsed: ' + taskElapsed"
|
||||||
|
>
|
||||||
|
<mat-icon aria-hidden="true" class="agent-card__elapsed-icon">schedule</mat-icon>
|
||||||
|
<span class="text-mono">{{ taskElapsed }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- ── Footer: channel + last activity + quick‑jump ── -->
|
<!-- ── Footer: channel + last activity + quick‑jump ── -->
|
||||||
<div class="agent-card__footer">
|
<div class="agent-card__footer">
|
||||||
<div class="agent-card__meta">
|
<div class="agent-card__meta">
|
||||||
+50
-17
@@ -2,15 +2,18 @@
|
|||||||
// AgentCard — M3 tactical dark styling
|
// AgentCard — M3 tactical dark styling
|
||||||
// Per spec Section 7.3: left‑border accent, status‑aware coloring,
|
// Per spec Section 7.3: left‑border accent, status‑aware coloring,
|
||||||
// responsive card layout with 320px min‑width.
|
// responsive card layout with 320px min‑width.
|
||||||
|
// Enhanced: data-status selectors, elapsed time, design token imports.
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
|
@use 'tokens' as tokens;
|
||||||
|
|
||||||
.agent-card {
|
.agent-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-width: var(--cc-card-min-width);
|
min-width: tokens.$cc-card-min-width;
|
||||||
padding: var(--cc-card-padding);
|
padding: tokens.$cc-card-padding;
|
||||||
background-color: var(--cc-surface-container);
|
background-color: var(--cc-surface-container);
|
||||||
border-radius: var(--cc-card-border-radius);
|
border-radius: tokens.$cc-card-border-radius;
|
||||||
border-left: 4px solid var(--status-offline); // default; overridden by [style]
|
border-left: 4px solid var(--status-offline); // default; overridden by [style]
|
||||||
border-top: 1px solid var(--cc-outline);
|
border-top: 1px solid var(--cc-outline);
|
||||||
border-right: 1px solid var(--cc-outline);
|
border-right: 1px solid var(--cc-outline);
|
||||||
@@ -23,6 +26,14 @@
|
|||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CUB-26: Card is now clickable for session drawer
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid var(--color-primary);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
&:focus-within {
|
&:focus-within {
|
||||||
outline: 2px solid var(--status-active);
|
outline: 2px solid var(--status-active);
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
@@ -48,11 +59,6 @@
|
|||||||
letter-spacing: 0.02em;
|
letter-spacing: 0.02em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: var(--cc-on-surface);
|
color: var(--cc-on-surface);
|
||||||
|
|
||||||
// Per‑status background tints
|
|
||||||
.status-dot--active + & {
|
|
||||||
background-color: var(--status-active-bg);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.agent-card__status-label {
|
.agent-card__status-label {
|
||||||
@@ -94,8 +100,7 @@
|
|||||||
color: var(--cc-on-surface);
|
color: var(--cc-on-surface);
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
|
|
||||||
// Error messages get distinct styling
|
&--error {
|
||||||
.agent-card--error & {
|
|
||||||
color: var(--status-error);
|
color: var(--status-error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -109,6 +114,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.agent-card__progress-label {
|
.agent-card__progress-label {
|
||||||
|
font-family: var(--cc-font-mono);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--cc-on-surface-variant);
|
color: var(--cc-on-surface-variant);
|
||||||
@@ -130,6 +136,23 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Elapsed Time ──
|
||||||
|
.agent-card__elapsed {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--cc-on-surface-variant);
|
||||||
|
padding: 2px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-card__elapsed-icon {
|
||||||
|
font-size: 14px;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
color: var(--status-thinking);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Footer ──
|
// ── Footer ──
|
||||||
.agent-card__footer {
|
.agent-card__footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -153,7 +176,6 @@
|
|||||||
color: var(--cc-on-surface-variant);
|
color: var(--cc-on-surface-variant);
|
||||||
}
|
}
|
||||||
|
|
||||||
.agent-card__channel-icon,
|
|
||||||
.agent-card__channel .mat-icon {
|
.agent-card__channel .mat-icon {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
width: 14px;
|
width: 14px;
|
||||||
@@ -169,7 +191,6 @@
|
|||||||
.agent-card__jump {
|
.agent-card__jump {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
|
||||||
// Match M3 text button sizing
|
|
||||||
.mat-mdc-button {
|
.mat-mdc-button {
|
||||||
min-width: 36px;
|
min-width: 36px;
|
||||||
padding: 0 8px;
|
padding: 0 8px;
|
||||||
@@ -184,11 +205,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Status‑specific background tints for badge ──
|
// ── Status‑specific background tints for badge ──
|
||||||
// We use the global status-dot classes from styles.scss and pair them
|
// Using data-status attribute selectors for clean styling.
|
||||||
// with contextual background-color overrides here.
|
|
||||||
|
|
||||||
.agent-card[data-status="active"] .agent-card__badge,
|
.agent-card[data-status="active"] .agent-card__badge {
|
||||||
.agent-card .status-dot--active ~ .agent-card__badge {
|
|
||||||
background-color: var(--status-active-bg);
|
background-color: var(--status-active-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,8 +223,22 @@
|
|||||||
background-color: var(--status-error-bg);
|
background-color: var(--status-error-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.agent-card[data-status="offline"] .agent-card__badge {
|
||||||
|
background-color: var(--cc-surface-container-high);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Active‑like pulse on card border ──
|
||||||
|
.agent-card[data-status="active"],
|
||||||
|
.agent-card[data-status="thinking"] {
|
||||||
|
border-left-width: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-card[data-status="error"] {
|
||||||
|
border-left-color: var(--status-error);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Responsive ──
|
// ── Responsive ──
|
||||||
@media (max-width: 599px) {
|
@media (max-width: tokens.$cc-breakpoint-mobile) {
|
||||||
.agent-card {
|
.agent-card {
|
||||||
min-width: unset;
|
min-width: unset;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
+87
-31
@@ -1,8 +1,14 @@
|
|||||||
import {
|
import {
|
||||||
ChangeDetectionStrategy,
|
ChangeDetectionStrategy,
|
||||||
Component,
|
Component,
|
||||||
|
EventEmitter,
|
||||||
Input,
|
Input,
|
||||||
|
OnDestroy,
|
||||||
|
Output,
|
||||||
computed,
|
computed,
|
||||||
|
effect,
|
||||||
|
inject,
|
||||||
|
signal,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { RouterModule } from '@angular/router';
|
import { RouterModule } from '@angular/router';
|
||||||
@@ -11,11 +17,19 @@ import { MatButtonModule } from '@angular/material/button';
|
|||||||
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||||
import { AgentStatus } from '../../../models/agent.model';
|
import { AgentStatus } from '../../../models/agent.model';
|
||||||
|
import { LongPressDirective } from '../../../directives/long-press.directive';
|
||||||
|
import {
|
||||||
|
STATUS_COLORS,
|
||||||
|
STATUS_LABELS,
|
||||||
|
CHANNEL_ICONS,
|
||||||
|
} from '../../../design/tokens';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// AgentCard Component
|
// AgentCard Component
|
||||||
// Per spec Section 7.3: Composes Agent Status Badge, Task Progress Bar,
|
// Per spec Section 7.3: Composes Agent Status Badge, Task Progress Bar,
|
||||||
// and Quick‑Jump Button into a card with left‑border status accent.
|
// and Quick‑Jump Button into a card with left‑border status accent.
|
||||||
|
// CUB-26: Emits cardClick and cardLongPress for drawer/modal integration.
|
||||||
|
// Enhanced with data-status attribute, elapsed time, and design tokens.
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -28,12 +42,13 @@ import { AgentStatus } from '../../../models/agent.model';
|
|||||||
MatButtonModule,
|
MatButtonModule,
|
||||||
MatProgressBarModule,
|
MatProgressBarModule,
|
||||||
MatTooltipModule,
|
MatTooltipModule,
|
||||||
|
LongPressDirective,
|
||||||
],
|
],
|
||||||
templateUrl: './agent-card.component.html',
|
templateUrl: './agent-card.component.html',
|
||||||
styleUrl: './agent-card.component.scss',
|
styleUrl: './agent-card.component.scss',
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
})
|
})
|
||||||
export class AgentCardComponent {
|
export class AgentCardComponent implements OnDestroy {
|
||||||
// --- Six required inputs per spec ---
|
// --- Six required inputs per spec ---
|
||||||
|
|
||||||
/** Agent status — drives badge color and left‑border accent */
|
/** Agent status — drives badge color and left‑border accent */
|
||||||
@@ -68,7 +83,26 @@ export class AgentCardComponent {
|
|||||||
/** Error message (shown only when status is 'error') */
|
/** Error message (shown only when status is 'error') */
|
||||||
@Input() errorMessage = '';
|
@Input() errorMessage = '';
|
||||||
|
|
||||||
// --- Computed values ---
|
/** Elapsed time string, e.g. "04m 12s" */
|
||||||
|
@Input() taskElapsed = '';
|
||||||
|
|
||||||
|
// --- CUB-26: Outputs for drawer/modal integration ---
|
||||||
|
|
||||||
|
/** Emitted when the card is clicked — opens the session drawer. */
|
||||||
|
@Output() readonly cardClick = new EventEmitter<string>();
|
||||||
|
|
||||||
|
/** Emitted when the card is long-pressed — bypasses drawer, opens session log directly. */
|
||||||
|
@Output() readonly cardLongPress = new EventEmitter<string>();
|
||||||
|
|
||||||
|
// --- Internal state ---
|
||||||
|
|
||||||
|
/** Timer for refreshing relative-time label */
|
||||||
|
private _timer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
/** Internal signal to trigger relative-time recomputation */
|
||||||
|
private readonly _tick = signal(0);
|
||||||
|
|
||||||
|
// --- Computed values using design tokens ---
|
||||||
|
|
||||||
/** Map status → CSS custom property for the left‑border accent */
|
/** Map status → CSS custom property for the left‑border accent */
|
||||||
readonly statusBorderColor = computed(() => {
|
readonly statusBorderColor = computed(() => {
|
||||||
@@ -82,46 +116,68 @@ export class AgentCardComponent {
|
|||||||
return map[this.status] ?? 'var(--status-offline)';
|
return map[this.status] ?? 'var(--status-offline)';
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Human‑readable status label */
|
/** Human‑readable status label (from design tokens) */
|
||||||
readonly statusLabel = computed(() => {
|
readonly statusLabel = computed(() => STATUS_LABELS[this.status] ?? this.status);
|
||||||
const labels: Record<AgentStatus, string> = {
|
|
||||||
active: 'Active',
|
|
||||||
idle: 'Idle',
|
|
||||||
thinking: 'Thinking…',
|
|
||||||
error: 'Error',
|
|
||||||
offline: 'Offline',
|
|
||||||
};
|
|
||||||
return labels[this.status] ?? this.status;
|
|
||||||
});
|
|
||||||
|
|
||||||
/** CSS class suffix for the status badge dot */
|
/** CSS class suffix for the status badge dot */
|
||||||
readonly statusDotClass = computed(() => `status-dot--${this.status}`);
|
readonly statusDotClass = computed(() => `status-dot--${this.status}`);
|
||||||
|
|
||||||
/** Material icon name for the channel */
|
/** Material icon name for the channel (from design tokens) */
|
||||||
readonly channelIcon = computed(() => {
|
readonly channelIcon = computed(() => CHANNEL_ICONS[this.channel] ?? 'chat');
|
||||||
const icons: Record<string, string> = {
|
|
||||||
telegram: 'telegram', // falls back to font icon if no SVG registered
|
/** Relative time string for lastActivity, refreshed every 30s */
|
||||||
slack: 'chat',
|
readonly lastActivityLabel = computed(() => {
|
||||||
discord: 'forum',
|
// Read tick to create dependency that forces recomputation
|
||||||
whatsapp: 'chat',
|
this._tick();
|
||||||
webchat: 'language',
|
return this._relativeTime(this.lastActivity);
|
||||||
email: 'email',
|
|
||||||
};
|
|
||||||
return icons[this.channel] ?? 'chat';
|
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Relative time string for lastActivity */
|
/** Quick‑jump route derived from sessionKey */
|
||||||
readonly lastActivityLabel = computed(() => {
|
readonly jumpRoute = computed(() => `/sessions/${this.sessionKey}`);
|
||||||
if (!this.lastActivity) return '';
|
|
||||||
|
/** Whether progress bar should show */
|
||||||
|
readonly showProgress = computed(() => this.progress > 0 && this.status !== 'error');
|
||||||
|
|
||||||
|
/** Whether error state is active */
|
||||||
|
readonly isError = computed(() => this.status === 'error');
|
||||||
|
|
||||||
|
/** Whether card is in an active-like state (active or thinking) */
|
||||||
|
readonly isActiveLike = computed(() => this.status === 'active' || this.status === 'thinking');
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
// Start the relative-time refresh timer
|
||||||
|
this._startTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this._stopTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Private helpers ---
|
||||||
|
|
||||||
|
private _relativeTime(date: Date | null | undefined): string {
|
||||||
|
if (!date) return '';
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const then = this.lastActivity.getTime();
|
const then = date.getTime();
|
||||||
const diffSec = Math.max(0, Math.floor((now - then) / 1000));
|
const diffSec = Math.max(0, Math.floor((now - then) / 1000));
|
||||||
if (diffSec < 60) return 'just now';
|
if (diffSec < 60) return 'just now';
|
||||||
if (diffSec < 3600) return `${Math.floor(diffSec / 60)}m ago`;
|
if (diffSec < 3600) return `${Math.floor(diffSec / 60)}m ago`;
|
||||||
if (diffSec < 86400) return `${Math.floor(diffSec / 3600)}h ago`;
|
if (diffSec < 86400) return `${Math.floor(diffSec / 3600)}h ago`;
|
||||||
return `${Math.floor(diffSec / 86400)}d ago`;
|
return `${Math.floor(diffSec / 86400)}d ago`;
|
||||||
});
|
}
|
||||||
|
|
||||||
/** Quick‑jump route derived from sessionKey */
|
private _startTimer(): void {
|
||||||
readonly jumpRoute = computed(() => `/sessions/${this.sessionKey}`);
|
this._stopTimer();
|
||||||
|
this._timer = setInterval(() => {
|
||||||
|
// Increment tick to force lastActivityLabel recomputation
|
||||||
|
this._tick.update(v => v + 1);
|
||||||
|
}, 30_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _stopTimer(): void {
|
||||||
|
if (this._timer) {
|
||||||
|
clearInterval(this._timer);
|
||||||
|
this._timer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+65
-16
@@ -1,14 +1,16 @@
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Adaptive Navigation — Desktop sidebar / Mobile header
|
// Adaptive Navigation — Desktop sidebar / Mobile header
|
||||||
// Desktop (≥768px): 72px sidebar with full navigation items
|
// Per CUB-27 spec breakpoints:
|
||||||
// Mobile (<768px): 56px compact header with hamburger menu
|
// Compact (0–599px): Mobile header + hamburger + drawer
|
||||||
|
// Medium (600–1023px): Collapsed sidebar (icon-only, 72px)
|
||||||
|
// Expanded (≥1024px): Full sidebar with labels (72px collapsed, 256px expanded)
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Desktop Sidebar (visible ≥768px)
|
// Desktop Sidebar (visible ≥600px)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
.adaptive-nav__sidebar {
|
.adaptive-nav__sidebar {
|
||||||
display: flex;
|
display: none; // Hidden by default (mobile-first)
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
width: var(--cc-nav-rail-collapsed-width, 72px);
|
width: var(--cc-nav-rail-collapsed-width, 72px);
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
@@ -152,12 +154,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Mobile Header (visible <768px)
|
// Mobile Header (visible <600px only)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
.adaptive-nav__mobile-header {
|
.adaptive-nav__mobile-header {
|
||||||
display: none; // Hidden on desktop, shown on mobile via media query
|
display: none; // Hidden on desktop, shown on mobile via media query
|
||||||
align-items: center;
|
align-items: center;
|
||||||
height: 56px;
|
height: var(--cc-header-height-compact, 56px);
|
||||||
padding: 0 12px;
|
padding: 0 12px;
|
||||||
background-color: var(--cc-surface-container-high);
|
background-color: var(--cc-surface-container-high);
|
||||||
border-bottom: 1px solid var(--cc-outline);
|
border-bottom: 1px solid var(--cc-outline);
|
||||||
@@ -167,6 +169,8 @@
|
|||||||
|
|
||||||
.adaptive-nav__hamburger {
|
.adaptive-nav__hamburger {
|
||||||
color: var(--cc-on-surface-variant) !important;
|
color: var(--cc-on-surface-variant) !important;
|
||||||
|
min-width: 48px;
|
||||||
|
min-height: 48px;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: var(--cc-on-surface) !important;
|
color: var(--cc-on-surface) !important;
|
||||||
@@ -196,6 +200,8 @@
|
|||||||
|
|
||||||
.adaptive-nav__mobile-action {
|
.adaptive-nav__mobile-action {
|
||||||
color: var(--cc-on-surface-variant) !important;
|
color: var(--cc-on-surface-variant) !important;
|
||||||
|
min-width: 48px;
|
||||||
|
min-height: 48px;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: var(--cc-on-surface) !important;
|
color: var(--cc-on-surface) !important;
|
||||||
@@ -214,7 +220,7 @@
|
|||||||
|
|
||||||
.adaptive-nav__mobile-drawer {
|
.adaptive-nav__mobile-drawer {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 56px; // Below header
|
top: var(--cc-header-height-compact, 56px); // Below header
|
||||||
left: 0;
|
left: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
width: 280px;
|
width: 280px;
|
||||||
@@ -271,34 +277,73 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Media Queries — Layout Switch
|
// Media Queries — Layout Switch (CUB-27 breakpoints)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Desktop (≥768px): Show sidebar, hide mobile header
|
|
||||||
// Mobile (<768px): Hide sidebar, show compact header
|
// Compact (0–599px): Show mobile header, hide sidebar
|
||||||
// ---------------------------------------------------------------------------
|
@media (max-width: 599px) {
|
||||||
@media (min-width: 768px) {
|
.adaptive-nav__sidebar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adaptive-nav__mobile-header {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide mobile drawer and overlay on desktop
|
||||||
|
.adaptive-nav__overlay,
|
||||||
|
.adaptive-nav__mobile-drawer {
|
||||||
|
// These are conditional via @if in template, no display:none needed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Medium (600–1023px): Show collapsed sidebar (icon-only), hide mobile
|
||||||
|
@media (min-width: 600px) and (max-width: 1023px) {
|
||||||
.adaptive-nav__sidebar {
|
.adaptive-nav__sidebar {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
width: var(--cc-nav-rail-collapsed-width, 72px);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide labels on medium (collapsed)
|
||||||
|
.adaptive-nav__sidebar-label,
|
||||||
|
.adaptive-nav__brand {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adaptive-nav__sidebar-header {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adaptive-nav__sidebar-item {
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.adaptive-nav__mobile-header {
|
.adaptive-nav__mobile-header {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hide mobile drawer and overlay on desktop
|
|
||||||
.adaptive-nav__overlay,
|
.adaptive-nav__overlay,
|
||||||
.adaptive-nav__mobile-drawer {
|
.adaptive-nav__mobile-drawer {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 767px) {
|
// Expanded (≥1024px): Show sidebar with labels
|
||||||
|
@media (min-width: 1024px) {
|
||||||
.adaptive-nav__sidebar {
|
.adaptive-nav__sidebar {
|
||||||
display: none;
|
display: flex;
|
||||||
|
width: var(--cc-nav-rail-collapsed-width, 72px);
|
||||||
|
transition: width 200ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.adaptive-nav__mobile-header {
|
.adaptive-nav__mobile-header {
|
||||||
display: flex;
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adaptive-nav__overlay,
|
||||||
|
.adaptive-nav__mobile-drawer {
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -313,4 +358,8 @@
|
|||||||
.adaptive-nav__mobile-drawer {
|
.adaptive-nav__mobile-drawer {
|
||||||
animation: none;
|
animation: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.adaptive-nav__sidebar {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+38
-7
@@ -1,4 +1,4 @@
|
|||||||
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
|
import { ChangeDetectionStrategy, Component, signal, HostListener, OnDestroy, OnInit } from '@angular/core';
|
||||||
import { RouterLink, RouterLinkActive } from '@angular/router';
|
import { RouterLink, RouterLinkActive } from '@angular/router';
|
||||||
import { MatIconModule } from '@angular/material/icon';
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
import { MatButtonModule } from '@angular/material/button';
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
@@ -8,13 +8,14 @@ import { NAV_DESTINATIONS } from '../../models/nav.model';
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Adaptive Navigation Component — switches between desktop sidebar
|
* Adaptive Navigation Component — switches between desktop sidebar
|
||||||
* and mobile header layouts using CSS media queries.
|
* and mobile header layouts using CSS media queries + JS breakpoint sync.
|
||||||
*
|
*
|
||||||
* Desktop (≥768px): 72px sidebar with full navigation items.
|
* Per CUB-27 spec breakpoints:
|
||||||
* Mobile (<768px): 56px compact header with hamburger menu.
|
* Compact (0–599px): Mobile header + hamburger + bottom nav
|
||||||
|
* Medium (600–1023px): Collapsed sidebar (icon-only)
|
||||||
|
* Expanded (≥1024px): Expandable sidebar (hover/click)
|
||||||
*
|
*
|
||||||
* The LIVE status indicator is visible in both layouts.
|
* The LIVE status indicator is visible in all layouts.
|
||||||
* Per spec Section 3.1 (kiosk) and 3.2 (mobile).
|
|
||||||
*/
|
*/
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-adaptive-navigation',
|
selector: 'app-adaptive-navigation',
|
||||||
@@ -31,7 +32,7 @@ import { NAV_DESTINATIONS } from '../../models/nav.model';
|
|||||||
styleUrl: './adaptive-navigation.component.scss',
|
styleUrl: './adaptive-navigation.component.scss',
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
})
|
})
|
||||||
export class AdaptiveNavigationComponent {
|
export class AdaptiveNavigationComponent implements OnInit, OnDestroy {
|
||||||
/** Navigation destinations shared with other nav components */
|
/** Navigation destinations shared with other nav components */
|
||||||
protected readonly destinations = NAV_DESTINATIONS;
|
protected readonly destinations = NAV_DESTINATIONS;
|
||||||
|
|
||||||
@@ -41,6 +42,22 @@ export class AdaptiveNavigationComponent {
|
|||||||
/** Live connection status */
|
/** Live connection status */
|
||||||
protected readonly isConnected = signal(true);
|
protected readonly isConnected = signal(true);
|
||||||
|
|
||||||
|
/** Responsive breakpoint state */
|
||||||
|
protected readonly isMedium = signal(false);
|
||||||
|
protected readonly isExpanded = signal(false);
|
||||||
|
|
||||||
|
private readonly COMPACT_MAX = 599;
|
||||||
|
private readonly MEDIUM_MAX = 1023;
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.updateBreakpoint();
|
||||||
|
}
|
||||||
|
|
||||||
|
@HostListener('window:resize')
|
||||||
|
onResize(): void {
|
||||||
|
this.updateBreakpoint();
|
||||||
|
}
|
||||||
|
|
||||||
/** Toggle mobile menu */
|
/** Toggle mobile menu */
|
||||||
toggleMobileMenu(): void {
|
toggleMobileMenu(): void {
|
||||||
this.mobileMenuOpen.update((v) => !v);
|
this.mobileMenuOpen.update((v) => !v);
|
||||||
@@ -50,4 +67,18 @@ export class AdaptiveNavigationComponent {
|
|||||||
closeMobileMenu(): void {
|
closeMobileMenu(): void {
|
||||||
this.mobileMenuOpen.set(false);
|
this.mobileMenuOpen.set(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private updateBreakpoint(): void {
|
||||||
|
const w = window.innerWidth;
|
||||||
|
this.isMedium.set(w >= this.COMPACT_MAX + 1 && w <= this.MEDIUM_MAX);
|
||||||
|
this.isExpanded.set(w > this.MEDIUM_MAX);
|
||||||
|
// Close mobile menu when switching to desktop
|
||||||
|
if (w > this.COMPACT_MAX) {
|
||||||
|
this.mobileMenuOpen.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
// HostListener auto-unsubscribes
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+140
@@ -0,0 +1,140 @@
|
|||||||
|
<!-- ============================================================================
|
||||||
|
Agent Session Drawer — CUB-26
|
||||||
|
Desktop: 480px side drawer slides from right with modal overlay.
|
||||||
|
Mobile: Bottom sheet slides up from bottom.
|
||||||
|
Shows: Agent name, status badge, session key, live log tail,
|
||||||
|
recent messages, and action buttons.
|
||||||
|
============================================================================-->
|
||||||
|
|
||||||
|
<!-- Backdrop overlay -->
|
||||||
|
@if (isOpen()) {
|
||||||
|
<div
|
||||||
|
class="session-drawer-backdrop"
|
||||||
|
(click)="onBackdropClick()"
|
||||||
|
[class.session-drawer-backdrop--visible]="isOpen()"
|
||||||
|
></div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Drawer panel -->
|
||||||
|
<div
|
||||||
|
#drawerPanel
|
||||||
|
class="session-drawer"
|
||||||
|
[class.session-drawer--open]="isOpen()"
|
||||||
|
[class.session-drawer--mobile]="isMobile"
|
||||||
|
(keydown)="onDrawerKeydown($event)"
|
||||||
|
role="dialog"
|
||||||
|
aria-label="Agent session details"
|
||||||
|
[attr.aria-hidden]="!isOpen()"
|
||||||
|
>
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="session-drawer__header">
|
||||||
|
@if (agent) {
|
||||||
|
<div class="session-drawer__header-identity">
|
||||||
|
<span class="status-dot {{ getStatusClass(agent.status) }}" [attr.aria-label]="getStatusLabel(agent.status)"></span>
|
||||||
|
<div class="session-drawer__header-text">
|
||||||
|
<h2 class="session-drawer__title">{{ agent.displayName }}</h2>
|
||||||
|
<span class="session-drawer__role">{{ agent.role }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<button
|
||||||
|
class="session-drawer__close-btn"
|
||||||
|
type="button"
|
||||||
|
aria-label="Close drawer"
|
||||||
|
(click)="close()"
|
||||||
|
matIconButton
|
||||||
|
>
|
||||||
|
<mat-icon>close</mat-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content area -->
|
||||||
|
@if (agent) {
|
||||||
|
<div class="session-drawer__content">
|
||||||
|
|
||||||
|
<!-- Status & Session Key Section -->
|
||||||
|
<section class="session-drawer__section">
|
||||||
|
<div class="session-drawer__meta-row">
|
||||||
|
<span class="session-drawer__status-chip {{ getStatusChipColor(agent.status) }}">
|
||||||
|
{{ getStatusLabel(agent.status) }}
|
||||||
|
</span>
|
||||||
|
@if (agent.channel) {
|
||||||
|
<span class="session-drawer__channel-badge">
|
||||||
|
<mat-icon class="session-drawer__channel-icon">chat</mat-icon>
|
||||||
|
{{ agent.channel }}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="session-drawer__session-key">
|
||||||
|
<span class="session-drawer__label">Session Key</span>
|
||||||
|
<code class="session-drawer__key-value">{{ agent.sessionKey }}</code>
|
||||||
|
</div>
|
||||||
|
@if (agent.currentTask) {
|
||||||
|
<div class="session-drawer__task-info">
|
||||||
|
<span class="session-drawer__label">Current Task</span>
|
||||||
|
<span class="session-drawer__task-text">{{ agent.currentTask }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div class="session-drawer__last-activity">
|
||||||
|
<span class="session-drawer__label">Last Activity</span>
|
||||||
|
<span class="session-drawer__activity-time">{{ formatRelativeTime(agent.lastActivity) }}</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Recent Messages Section -->
|
||||||
|
<section class="session-drawer__section">
|
||||||
|
<h3 class="session-drawer__section-title">Recent Messages</h3>
|
||||||
|
<div class="session-drawer__messages">
|
||||||
|
@for (msg of recentMessages(); track msg.id) {
|
||||||
|
<div class="session-drawer__message session-drawer__message--{{ msg.sender }}">
|
||||||
|
<span class="session-drawer__message-sender">
|
||||||
|
{{ msg.sender === 'agent' ? agent.displayName : 'You' }}
|
||||||
|
</span>
|
||||||
|
<p class="session-drawer__message-text">{{ msg.content }}</p>
|
||||||
|
<span class="session-drawer__message-time">{{ formatTime(msg.timestamp) }}</span>
|
||||||
|
</div>
|
||||||
|
} @empty {
|
||||||
|
<p class="session-drawer__empty-state">No recent messages</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Live Log Tail Section -->
|
||||||
|
<section class="session-drawer__section">
|
||||||
|
<h3 class="session-drawer__section-title">Live Log</h3>
|
||||||
|
<div class="session-drawer__log-container">
|
||||||
|
@for (line of logLines(); track $index) {
|
||||||
|
<div class="session-drawer__log-line {{ getLogLevelClass(line.level) }}">
|
||||||
|
<span class="session-drawer__log-time">{{ formatTime(line.timestamp) }}</span>
|
||||||
|
<span class="session-drawer__log-level">{{ line.level.toUpperCase() }}</span>
|
||||||
|
<span class="session-drawer__log-message">{{ line.message }}</span>
|
||||||
|
</div>
|
||||||
|
} @empty {
|
||||||
|
<p class="session-drawer__empty-state">No log output</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Action buttons (sticky footer) -->
|
||||||
|
<div class="session-drawer__actions">
|
||||||
|
<button
|
||||||
|
class="session-drawer__action-btn session-drawer__action-btn--primary"
|
||||||
|
mat-raised-button
|
||||||
|
color="primary"
|
||||||
|
(click)="onOpenSession()"
|
||||||
|
>
|
||||||
|
<mat-icon>open_in_new</mat-icon>
|
||||||
|
Open Full Session
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="session-drawer__action-btn session-drawer__action-btn--secondary"
|
||||||
|
mat-stroked-button
|
||||||
|
(click)="onPinToDashboard()"
|
||||||
|
>
|
||||||
|
<mat-icon>push_pin</mat-icon>
|
||||||
|
Pin to Dashboard
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
+500
@@ -0,0 +1,500 @@
|
|||||||
|
// ============================================================================
|
||||||
|
// Agent Session Drawer — CUB-26
|
||||||
|
// Desktop: 480px side drawer slides from right with modal overlay.
|
||||||
|
// Mobile: Bottom sheet slides up from bottom.
|
||||||
|
// Uses Control Center design tokens from CUB-21.
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Backdrop
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
.session-drawer-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background-color: rgba(0, 0, 0, 0.6);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
z-index: 998;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 200ms ease-out;
|
||||||
|
|
||||||
|
&--visible {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Drawer Panel — Desktop: Side drawer from right
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
.session-drawer {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 480px;
|
||||||
|
max-width: 100vw;
|
||||||
|
background-color: var(--cc-surface-container);
|
||||||
|
border-left: 1px solid var(--cc-outline);
|
||||||
|
z-index: 999;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
transform: translateX(100%);
|
||||||
|
transition: transform 280ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
box-shadow: -4px 0 32px rgba(0, 0, 0, 0.4);
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
&--open {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mobile: Bottom Sheet — slides up from bottom
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
&--mobile {
|
||||||
|
top: auto;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100vw;
|
||||||
|
max-height: 85vh;
|
||||||
|
border-left: none;
|
||||||
|
border-top: 1px solid var(--cc-outline);
|
||||||
|
border-radius: 20px 20px 0 0;
|
||||||
|
transform: translateY(100%);
|
||||||
|
box-shadow: 0 -4px 32px rgba(0, 0, 0, 0.4);
|
||||||
|
|
||||||
|
&.session-drawer--open {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drag handle for mobile bottom sheet
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
display: block;
|
||||||
|
width: 32px;
|
||||||
|
height: 4px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: var(--cc-on-surface-variant);
|
||||||
|
opacity: 0.4;
|
||||||
|
margin: 8px auto 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Header
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
.session-drawer__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 20px 24px 16px;
|
||||||
|
border-bottom: 1px solid var(--cc-outline);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-drawer__header-identity {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-drawer__header-text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-drawer__title {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--cc-on-surface);
|
||||||
|
margin: 0;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-drawer__role {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--cc-on-surface-variant);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-drawer__close-btn {
|
||||||
|
--mat-icon-button-state-layer-color: transparent;
|
||||||
|
color: var(--cc-on-surface-variant);
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--cc-on-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid var(--color-primary);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Content — scrollable area
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
.session-drawer__content {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0 24px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Sections
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
.session-drawer__section {
|
||||||
|
padding: 16px 0;
|
||||||
|
border-bottom: 1px solid var(--cc-outline);
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-drawer__section-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--cc-on-surface-variant);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Meta Row — Status + Channel
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
.session-drawer__meta-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-drawer__status-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
|
||||||
|
&.status-chip--active {
|
||||||
|
background-color: var(--status-active-bg);
|
||||||
|
color: var(--status-active);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.status-chip--idle {
|
||||||
|
background-color: var(--status-idle-bg);
|
||||||
|
color: var(--status-idle);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.status-chip--thinking {
|
||||||
|
background-color: var(--status-thinking-bg);
|
||||||
|
color: var(--status-thinking);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.status-chip--error {
|
||||||
|
background-color: var(--status-error-bg);
|
||||||
|
color: var(--status-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.status-chip--offline {
|
||||||
|
background-color: rgba(100, 116, 139, 0.12);
|
||||||
|
color: var(--status-offline);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-drawer__channel-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--cc-surface-container-high);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--cc-on-surface-variant);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-drawer__channel-icon {
|
||||||
|
font-size: 14px;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Session Key
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
.session-drawer__session-key {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-drawer__label {
|
||||||
|
display: block;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--cc-on-surface-variant);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-drawer__key-value {
|
||||||
|
display: block;
|
||||||
|
font-family: var(--cc-font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-secondary);
|
||||||
|
background: var(--cc-surface);
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--cc-outline);
|
||||||
|
word-break: break-all;
|
||||||
|
line-height: 1.5;
|
||||||
|
user-select: all;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Task Info
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
.session-drawer__task-info {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-drawer__task-text {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--cc-on-surface);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Last Activity
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
.session-drawer__last-activity {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-drawer__activity-time {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--cc-on-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Recent Messages
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
.session-drawer__messages {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-drawer__message {
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 12px;
|
||||||
|
max-width: 100%;
|
||||||
|
|
||||||
|
&--agent {
|
||||||
|
background: var(--cc-surface-container-high);
|
||||||
|
border: 1px solid var(--cc-outline);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--user {
|
||||||
|
background: rgba(56, 189, 248, 0.08);
|
||||||
|
border: 1px solid rgba(56, 189, 248, 0.15);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-drawer__message-sender {
|
||||||
|
display: block;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--cc-on-surface-variant);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-drawer__message-text {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--cc-on-surface);
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.5;
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-drawer__message-time {
|
||||||
|
display: block;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--cc-on-surface-variant);
|
||||||
|
margin-top: 4px;
|
||||||
|
font-family: var(--cc-font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Live Log Container
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
.session-drawer__log-container {
|
||||||
|
background: var(--cc-surface);
|
||||||
|
border: 1px solid var(--cc-outline);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
max-height: 260px;
|
||||||
|
overflow-y: auto;
|
||||||
|
font-family: var(--cc-font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-drawer__log-line {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 1px 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
&--info {
|
||||||
|
color: var(--cc-on-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--warn {
|
||||||
|
color: #FBBF24;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--error {
|
||||||
|
color: var(--status-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--debug {
|
||||||
|
color: var(--cc-on-surface-variant);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-drawer__log-time {
|
||||||
|
color: var(--cc-on-surface-variant);
|
||||||
|
flex-shrink: 0;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-drawer__log-level {
|
||||||
|
width: 48px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-drawer__log-message {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Empty State
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
.session-drawer__empty-state {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--cc-on-surface-variant);
|
||||||
|
text-align: center;
|
||||||
|
padding: 16px 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Action Buttons — Sticky Footer
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
.session-drawer__actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px 24px 20px;
|
||||||
|
border-top: 1px solid var(--cc-outline);
|
||||||
|
background-color: var(--cc-surface-container);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-drawer__action-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-weight: 500;
|
||||||
|
|
||||||
|
.mat-icon {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--primary {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--secondary {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid var(--color-primary);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mobile Adjustments
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
.session-drawer--mobile {
|
||||||
|
.session-drawer__header {
|
||||||
|
padding: 12px 20px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-drawer__content {
|
||||||
|
padding: 0 20px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-drawer__actions {
|
||||||
|
padding: 12px 20px 16px;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.session-drawer__action-btn {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-drawer__log-container {
|
||||||
|
max-height: 200px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Responsive — wider viewports keep 480px, narrow go full-width
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
@media (max-width: 599px) {
|
||||||
|
.session-drawer:not(.session-drawer--mobile) {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100vw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Accessibility: Reduced Motion
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.session-drawer {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-drawer-backdrop {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
+268
@@ -0,0 +1,268 @@
|
|||||||
|
import {
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
Component,
|
||||||
|
ElementRef,
|
||||||
|
EventEmitter,
|
||||||
|
HostListener,
|
||||||
|
Input,
|
||||||
|
OnDestroy,
|
||||||
|
Output,
|
||||||
|
signal,
|
||||||
|
ViewChild,
|
||||||
|
} from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
|
import { MatChipsModule } from '@angular/material/chips';
|
||||||
|
import { AgentCardData, AgentStatus } from '../../models/agent.model';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Agent Session Drawer — Per CUB-26
|
||||||
|
// Desktop: 480px side drawer slides from right with modal overlay.
|
||||||
|
// Mobile: Bottom sheet slides up from bottom.
|
||||||
|
// Shows: Agent name, status badge, session key, live log tail,
|
||||||
|
// recent messages, and action buttons.
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface SessionLogLine {
|
||||||
|
timestamp: Date;
|
||||||
|
level: 'info' | 'warn' | 'error' | 'debug';
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionMessage {
|
||||||
|
id: string;
|
||||||
|
sender: 'agent' | 'user';
|
||||||
|
content: string;
|
||||||
|
timestamp: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-agent-session-drawer',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, MatButtonModule, MatIconModule, MatChipsModule],
|
||||||
|
templateUrl: './agent-session-drawer.component.html',
|
||||||
|
styleUrl: './agent-session-drawer.component.scss',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
})
|
||||||
|
export class AgentSessionDrawerComponent implements OnDestroy {
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Inputs
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** The agent whose session details are displayed. */
|
||||||
|
@Input() set agent(value: AgentCardData | null) {
|
||||||
|
this._agent = value;
|
||||||
|
if (value) {
|
||||||
|
this.isOpen.set(true);
|
||||||
|
this.loadSessionData(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
get agent(): AgentCardData | null {
|
||||||
|
return this._agent;
|
||||||
|
}
|
||||||
|
private _agent: AgentCardData | null = null;
|
||||||
|
|
||||||
|
/** Whether this is mobile viewport (bottom sheet mode). */
|
||||||
|
@Input() isMobile = false;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Outputs
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Emitted when the user clicks "Open Full Session". Payload is the session key. */
|
||||||
|
@Output() readonly openSession = new EventEmitter<string>();
|
||||||
|
|
||||||
|
/** Emitted when the user clicks "Pin to Dashboard". Payload is the session key. */
|
||||||
|
@Output() readonly pinToDashboard = new EventEmitter<string>();
|
||||||
|
|
||||||
|
/** Emitted when the drawer closes. */
|
||||||
|
@Output() readonly drawerClose = new EventEmitter<void>();
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Signals
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
readonly isOpen = signal(false);
|
||||||
|
readonly logLines = signal<SessionLogLine[]>([]);
|
||||||
|
readonly recentMessages = signal<SessionMessage[]>([]);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// View Children
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@ViewChild('drawerPanel') drawerPanel!: ElementRef<HTMLElement>;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Status Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
getStatusClass(status: string): string {
|
||||||
|
return `status-dot--${status}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
getStatusLabel(status: AgentStatus): string {
|
||||||
|
const labels: Record<AgentStatus, string> = {
|
||||||
|
active: 'Active',
|
||||||
|
idle: 'Idle',
|
||||||
|
thinking: 'Thinking…',
|
||||||
|
error: 'Error',
|
||||||
|
offline: 'Offline',
|
||||||
|
};
|
||||||
|
return labels[status] ?? status;
|
||||||
|
}
|
||||||
|
|
||||||
|
getStatusChipColor(status: AgentStatus): string {
|
||||||
|
const map: Record<AgentStatus, string> = {
|
||||||
|
active: 'status-chip--active',
|
||||||
|
idle: 'status-chip--idle',
|
||||||
|
thinking: 'status-chip--thinking',
|
||||||
|
error: 'status-chip--error',
|
||||||
|
offline: 'status-chip--offline',
|
||||||
|
};
|
||||||
|
return map[status] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
getLogLevelClass(level: SessionLogLine['level']): string {
|
||||||
|
return `log-line--${level}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Format a date to a short time string. */
|
||||||
|
formatTime(date: Date): string {
|
||||||
|
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Format a date to a relative time string. */
|
||||||
|
formatRelativeTime(date: Date): string {
|
||||||
|
const now = Date.now();
|
||||||
|
const then = date.getTime();
|
||||||
|
const diffSec = Math.max(0, Math.floor((now - then) / 1000));
|
||||||
|
if (diffSec < 60) return 'just now';
|
||||||
|
if (diffSec < 3600) return `${Math.floor(diffSec / 60)}m ago`;
|
||||||
|
if (diffSec < 86400) return `${Math.floor(diffSec / 3600)}h ago`;
|
||||||
|
return `${Math.floor(diffSec / 86400)}d ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Public API
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Open the drawer for a specific agent. */
|
||||||
|
open(agentData: AgentCardData): void {
|
||||||
|
this._agent = agentData;
|
||||||
|
this.isOpen.set(true);
|
||||||
|
this.loadSessionData(agentData);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Close the drawer. */
|
||||||
|
close(): void {
|
||||||
|
this.isOpen.set(false);
|
||||||
|
this.drawerClose.emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Keyboard Handling
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@HostListener('document:keydown.escape')
|
||||||
|
onEscapeKey(): void {
|
||||||
|
if (this.isOpen()) {
|
||||||
|
this.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Handle keyboard navigation within the drawer. */
|
||||||
|
onDrawerKeydown(event: KeyboardEvent): void {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
this.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Tab through actions — browser default Tab behavior is fine,
|
||||||
|
// we just trap focus within the drawer
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Outside Click
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
onBackdropClick(): void {
|
||||||
|
this.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Actions
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
onOpenSession(): void {
|
||||||
|
if (this._agent) {
|
||||||
|
this.openSession.emit(this._agent.sessionKey);
|
||||||
|
}
|
||||||
|
this.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
onPinToDashboard(): void {
|
||||||
|
if (this._agent) {
|
||||||
|
this.pinToDashboard.emit(this._agent.sessionKey);
|
||||||
|
}
|
||||||
|
// Don't close — user may want to keep viewing
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Lifecycle
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
// Clean up any subscriptions when needed
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Private
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Load mock session data for the agent (TODO: wire to real data service). */
|
||||||
|
private loadSessionData(agentData: AgentCardData): void {
|
||||||
|
// TODO: Replace with real session data service when available.
|
||||||
|
// For now, generate placeholder log lines and messages.
|
||||||
|
const now = new Date();
|
||||||
|
const logLines: SessionLogLine[] = [];
|
||||||
|
for (let i = 19; i >= 0; i--) {
|
||||||
|
const ts = new Date(now.getTime() - i * 5000);
|
||||||
|
const levels: SessionLogLine['level'][] = ['info', 'info', 'info', 'debug', 'warn'];
|
||||||
|
const messages = [
|
||||||
|
`Processing task queue for ${agentData.displayName}`,
|
||||||
|
`SignalR heartbeat OK`,
|
||||||
|
`Session state: active`,
|
||||||
|
`Checking for pending commands…`,
|
||||||
|
`Updating task progress: ${Math.floor(Math.random() * 100)}%`,
|
||||||
|
];
|
||||||
|
logLines.push({
|
||||||
|
timestamp: ts,
|
||||||
|
level: levels[i % levels.length],
|
||||||
|
message: messages[i % messages.length],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.logLines.set(logLines);
|
||||||
|
|
||||||
|
const recentMessages: SessionMessage[] = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
sender: 'user',
|
||||||
|
content: `Hey ${agentData.displayName}, how's the current task going?`,
|
||||||
|
timestamp: new Date(now.getTime() - 120000),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
sender: 'agent',
|
||||||
|
content: agentData.currentTask ?? 'Working on it — progress is steady.',
|
||||||
|
timestamp: new Date(now.getTime() - 115000),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
sender: 'user',
|
||||||
|
content: 'Great, let me know if you hit any blockers.',
|
||||||
|
timestamp: new Date(now.getTime() - 110000),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
this.recentMessages.set(recentMessages);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { AgentSessionDrawerComponent } from './agent-session-drawer.component';
|
||||||
|
export type { SessionLogLine, SessionMessage } from './agent-session-drawer.component';
|
||||||
+31
@@ -0,0 +1,31 @@
|
|||||||
|
<!-- Backdrop overlay — click to dismiss -->
|
||||||
|
<div class="global-action-modal__backdrop" #backdrop (click)="onBackdropClick()"></div>
|
||||||
|
|
||||||
|
<!-- Modal panel -->
|
||||||
|
<div class="global-action-modal__panel" (click)="onModalClick($event)" role="dialog" aria-modal="true" aria-label="Global Actions">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="global-action-modal__header">
|
||||||
|
<h2 class="global-action-modal__title">Global Actions</h2>
|
||||||
|
<button matIconButton
|
||||||
|
class="global-action-modal__close"
|
||||||
|
aria-label="Close modal"
|
||||||
|
(click)="onClose()">
|
||||||
|
<mat-icon>close</mat-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Action grid -->
|
||||||
|
<div class="global-action-modal__actions">
|
||||||
|
@for (action of actions; track action.key) {
|
||||||
|
<button class="global-action-modal__action-btn global-action-modal__action-btn--{{ action.color }}"
|
||||||
|
(click)="onAction(action)">
|
||||||
|
<div class="global-action-modal__action-icon">
|
||||||
|
<mat-icon>{{ action.icon }}</mat-icon>
|
||||||
|
</div>
|
||||||
|
<span class="global-action-modal__action-label">{{ action.label }}</span>
|
||||||
|
<span class="global-action-modal__action-desc">{{ action.description }}</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
+198
@@ -0,0 +1,198 @@
|
|||||||
|
// ============================================================================
|
||||||
|
// Global Action Modal — Tactical Dark Mode Styling
|
||||||
|
// Uses Control Center design tokens from styles.scss
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Backdrop
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-action-modal__backdrop {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Modal Panel
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
.global-action-modal__panel {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: min(560px, calc(100vw - 48px));
|
||||||
|
background: var(--cc-surface-container);
|
||||||
|
border: 1px solid var(--cc-outline);
|
||||||
|
border-radius: var(--cc-card-border-radius);
|
||||||
|
box-shadow: 0 24px 48px rgba(0, 0, 0, 0.5);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Header
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
.global-action-modal__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 20px 24px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-action-modal__title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--cc-on-surface);
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-action-modal__close {
|
||||||
|
--mat-icon-button-state-layer-color: transparent;
|
||||||
|
color: var(--cc-on-surface-variant);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--cc-on-surface);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Action Grid
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
.global-action-modal__actions {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 24px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Action Button
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
.global-action-modal__action-btn {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 20px 16px;
|
||||||
|
border: 1px solid var(--cc-outline);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--cc-surface);
|
||||||
|
color: var(--cc-on-surface);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 150ms ease, border-color 150ms ease, transform 100ms ease;
|
||||||
|
font-family: inherit;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--cc-surface-container-high);
|
||||||
|
border-color: var(--cc-on-surface-variant);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid var(--mat-sys-primary, #38BDF8);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Action icon wrapper
|
||||||
|
.global-action-modal__action-icon {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 12px;
|
||||||
|
|
||||||
|
.mat-icon {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
font-size: 28px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Action label
|
||||||
|
.global-action-modal__action-label {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Action description
|
||||||
|
.global-action-modal__action-desc {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--cc-on-surface-variant);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Color Variants — per-action accent colors
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
.global-action-modal__action-btn--deploy {
|
||||||
|
.global-action-modal__action-icon {
|
||||||
|
background: var(--status-active-bg);
|
||||||
|
color: var(--status-active);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--status-active);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-action-modal__action-btn--pause {
|
||||||
|
.global-action-modal__action-icon {
|
||||||
|
background: var(--status-idle-bg);
|
||||||
|
color: var(--status-idle);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--status-idle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-action-modal__action-btn--emergency {
|
||||||
|
.global-action-modal__action-icon {
|
||||||
|
background: var(--status-error-bg);
|
||||||
|
color: var(--status-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--status-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-action-modal__action-label {
|
||||||
|
color: var(--status-error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-action-modal__action-btn--add {
|
||||||
|
.global-action-modal__action-icon {
|
||||||
|
background: var(--status-thinking-bg);
|
||||||
|
color: var(--status-thinking);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--status-thinking);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Responsive — stack single column on narrow viewports
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
@media (max-width: 400px) {
|
||||||
|
.global-action-modal__actions {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
+87
@@ -0,0 +1,87 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component, ElementRef, EventEmitter, Output, ViewChild } from '@angular/core';
|
||||||
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global Action Modal — overlay for fleet-wide commands.
|
||||||
|
*
|
||||||
|
* Four main actions: Deploy All, Pause All, Emergency Stop, Add Agent.
|
||||||
|
* Tactical Dark Mode styling using Control Center design tokens.
|
||||||
|
* Dismisses on backdrop click or close button.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'app-global-action-modal',
|
||||||
|
standalone: true,
|
||||||
|
imports: [MatIconModule, MatButtonModule],
|
||||||
|
templateUrl: './global-action-modal.component.html',
|
||||||
|
styleUrl: './global-action-modal.component.scss',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
})
|
||||||
|
export class GlobalActionModalComponent {
|
||||||
|
/** Emitted when any action button is clicked. Payload is the action key. */
|
||||||
|
@Output() readonly actionSelected = new EventEmitter<GlobalAction>();
|
||||||
|
|
||||||
|
/** Emitted when the modal is dismissed (backdrop click or close button). */
|
||||||
|
@Output() readonly dismissed = new EventEmitter<void>();
|
||||||
|
|
||||||
|
@ViewChild('backdrop') backdropEl!: ElementRef<HTMLElement>;
|
||||||
|
|
||||||
|
/** All available global actions. */
|
||||||
|
readonly actions: GlobalActionDef[] = [
|
||||||
|
{
|
||||||
|
key: 'deploy-all',
|
||||||
|
label: 'Deploy All',
|
||||||
|
description: 'Deploy all agents in the fleet',
|
||||||
|
icon: 'rocket_launch',
|
||||||
|
color: 'deploy',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'pause-all',
|
||||||
|
label: 'Pause All',
|
||||||
|
description: 'Pause all running agents',
|
||||||
|
icon: 'pause_circle',
|
||||||
|
color: 'pause',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'emergency-stop',
|
||||||
|
label: 'Emergency Stop',
|
||||||
|
description: 'Immediately halt all agents',
|
||||||
|
icon: 'emergency',
|
||||||
|
color: 'emergency',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'add-agent',
|
||||||
|
label: 'Add Agent',
|
||||||
|
description: 'Register a new agent to the fleet',
|
||||||
|
icon: 'person_add',
|
||||||
|
color: 'add',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
onBackdropClick(): void {
|
||||||
|
this.dismissed.emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
onModalClick(event: Event): void {
|
||||||
|
// Prevent clicks inside the modal panel from closing it
|
||||||
|
event.stopPropagation();
|
||||||
|
}
|
||||||
|
|
||||||
|
onClose(): void {
|
||||||
|
this.dismissed.emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
onAction(action: GlobalActionDef): void {
|
||||||
|
this.actionSelected.emit(action.key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GlobalAction = 'deploy-all' | 'pause-all' | 'emergency-stop' | 'add-agent';
|
||||||
|
|
||||||
|
export interface GlobalActionDef {
|
||||||
|
key: GlobalAction;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
icon: string;
|
||||||
|
color: 'deploy' | 'pause' | 'emergency' | 'add';
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export * from './quick-jump-button/quick-jump-button.component';
|
||||||
|
export { AgentStatusBadgeComponent } from './agent-status-badge/agent-status-badge.component';
|
||||||
|
export { QuickJumpDrawerComponent } from './quick-jump-drawer/index';
|
||||||
|
export { AgentSessionDrawerComponent } from './agent-session-drawer/index';
|
||||||
|
export type { SessionLogLine, SessionMessage } from './agent-session-drawer/index';
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<button
|
||||||
|
mat-icon-button
|
||||||
|
class="quick-jump-button"
|
||||||
|
[attr.aria-label]="'Jump to agent session'"
|
||||||
|
(click)="onJumpClick()"
|
||||||
|
>
|
||||||
|
<mat-icon>arrow_forward</mat-icon>
|
||||||
|
</button>
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
// ============================================================================
|
||||||
|
// Quick-Jump Button — M3 FilledTonalIconButton
|
||||||
|
// Per spec Section 7.3: Agent Card Quick-Jump action
|
||||||
|
// M3 spec: FilledTonalIconButton uses secondary container color
|
||||||
|
// with 8% state layer overlay for hover/focus.
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
.quick-jump-button {
|
||||||
|
// M3 FilledTonalIconButton: secondary-container background
|
||||||
|
// Angular Material mat-icon-button sets up the base shape (40x40, round).
|
||||||
|
// We override the color tokens to match FilledTonal style.
|
||||||
|
--mdc-icon-button-icon-color: var(--mat-sys-on-secondary-container);
|
||||||
|
background-color: var(--mat-sys-secondary-container);
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: background-color 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
|
||||||
|
// M3 State Layer: 8% overlay on hover
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--mat-sys-secondary-container);
|
||||||
|
// State layer overlay using a pseudo-element for precise 8% opacity
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: var(--mat-sys-on-secondary-container);
|
||||||
|
opacity: 0.08;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// M3 State Layer: 12% overlay on focus-visible (slightly stronger for accessibility)
|
||||||
|
&:focus-visible {
|
||||||
|
background-color: var(--mat-sys-secondary-container);
|
||||||
|
outline: 3px solid var(--status-active);
|
||||||
|
outline-offset: 2px;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: var(--mat-sys-on-secondary-container);
|
||||||
|
opacity: 0.12;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// M3 State Layer: 12% overlay on active/pressed
|
||||||
|
&:active {
|
||||||
|
background-color: var(--mat-sys-secondary-container);
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: var(--mat-sys-on-secondary-container);
|
||||||
|
opacity: 0.12;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Icon color stays on-secondary-container across all states
|
||||||
|
.mat-icon {
|
||||||
|
color: var(--mat-sys-on-secondary-container);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component, EventEmitter, Output } from '@angular/core';
|
||||||
|
import { MatIconButton } from '@angular/material/button';
|
||||||
|
import { MatIcon } from '@angular/material/icon';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Quick-Jump Button — M3 FilledTonalIconButton
|
||||||
|
*
|
||||||
|
* An icon button that emits a navigation event for jumping to an agent session.
|
||||||
|
* Uses the Material Design 3 FilledTonalIconButton style with 8% state layer
|
||||||
|
* overlay on hover and focus.
|
||||||
|
*
|
||||||
|
* Per spec Section 7.3: Agent Card Component Interface
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'app-quick-jump-button',
|
||||||
|
standalone: true,
|
||||||
|
imports: [MatIconButton, MatIcon],
|
||||||
|
templateUrl: './quick-jump-button.component.html',
|
||||||
|
styleUrl: './quick-jump-button.component.scss',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
})
|
||||||
|
export class QuickJumpButtonComponent {
|
||||||
|
/** Emitted when the button is clicked, carrying the session key for navigation. */
|
||||||
|
@Output() jumpClick = new EventEmitter<string>();
|
||||||
|
|
||||||
|
/** The session key to navigate to. Set by the parent agent card. */
|
||||||
|
sessionKey = '';
|
||||||
|
|
||||||
|
onJumpClick(): void {
|
||||||
|
this.jumpClick.emit(this.sessionKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { QuickJumpDrawerComponent } from './quick-jump-drawer.component';
|
||||||
+109
@@ -0,0 +1,109 @@
|
|||||||
|
<!-- ============================================================================
|
||||||
|
Quick-Jump Drawer — Slide-out panel for fast agent switching
|
||||||
|
Per CUB-51: Slides from right, agent list with status badges,
|
||||||
|
search/filter input, closes via ESC or outside click.
|
||||||
|
============================================================================-->
|
||||||
|
|
||||||
|
<!-- Backdrop overlay -->
|
||||||
|
@if (isOpen()) {
|
||||||
|
<div
|
||||||
|
class="quick-jump-backdrop"
|
||||||
|
(click)="onBackdropClick($event)"
|
||||||
|
[@backdropEnter]
|
||||||
|
></div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Drawer panel -->
|
||||||
|
<div
|
||||||
|
class="quick-jump-drawer"
|
||||||
|
[class.quick-jump-drawer--open]="isOpen()"
|
||||||
|
(keydown)="onDrawerKeydown($event)"
|
||||||
|
role="dialog"
|
||||||
|
aria-label="Quick jump to agent"
|
||||||
|
[attr.aria-hidden]="!isOpen()"
|
||||||
|
>
|
||||||
|
<!-- Drawer header -->
|
||||||
|
<div class="quick-jump-drawer__header">
|
||||||
|
<h2 class="quick-jump-drawer__title">Jump to Agent</h2>
|
||||||
|
<button
|
||||||
|
class="quick-jump-drawer__close-btn"
|
||||||
|
type="button"
|
||||||
|
aria-label="Close drawer"
|
||||||
|
(click)="close()"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Search input -->
|
||||||
|
<div class="quick-jump-drawer__search">
|
||||||
|
<span class="quick-jump-drawer__search-icon">search</span>
|
||||||
|
<input
|
||||||
|
#searchInput
|
||||||
|
type="text"
|
||||||
|
class="quick-jump-drawer__search-input"
|
||||||
|
placeholder="Search agents..."
|
||||||
|
[formControl]="searchControl"
|
||||||
|
autocomplete="off"
|
||||||
|
aria-label="Search agents"
|
||||||
|
/>
|
||||||
|
@if (searchControl.value) {
|
||||||
|
<button
|
||||||
|
class="quick-jump-drawer__search-clear"
|
||||||
|
type="button"
|
||||||
|
aria-label="Clear search"
|
||||||
|
(click)="searchControl.setValue('')"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Agent list -->
|
||||||
|
<ul class="quick-jump-drawer__agent-list" role="listbox" aria-label="Agent list">
|
||||||
|
@for (agent of filteredAgents(); track agent.id; let i = $index) {
|
||||||
|
<li
|
||||||
|
[id]="'quick-jump-agent-' + i"
|
||||||
|
class="quick-jump-drawer__agent-item"
|
||||||
|
[class.quick-jump-drawer__agent-item--highlighted]="highlightedIndex() === i"
|
||||||
|
role="option"
|
||||||
|
[attr.aria-selected]="highlightedIndex() === i"
|
||||||
|
(click)="selectAgent(agent)"
|
||||||
|
(mouseenter)="highlightedIndex.set(i)"
|
||||||
|
(mouseleave)="highlightedIndex.set(-1)"
|
||||||
|
>
|
||||||
|
<!-- Status badge -->
|
||||||
|
<span
|
||||||
|
class="status-dot {{ getStatusClass(agent.status) }}"
|
||||||
|
[attr.aria-label]="getStatusLabel(agent.status)"
|
||||||
|
></span>
|
||||||
|
|
||||||
|
<!-- Agent info -->
|
||||||
|
<div class="quick-jump-drawer__agent-info">
|
||||||
|
<span class="quick-jump-drawer__agent-name">{{ agent.displayName }}</span>
|
||||||
|
<span class="quick-jump-drawer__agent-role">{{ agent.role }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Status label -->
|
||||||
|
<span class="quick-jump-drawer__agent-status-label" [class]="'status-label--' + agent.status">
|
||||||
|
{{ getStatusLabel(agent.status) }}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
} @empty {
|
||||||
|
<li class="quick-jump-drawer__empty">
|
||||||
|
@if (searchControl.value) {
|
||||||
|
<span>No agents matching "{{ searchControl.value }}"</span>
|
||||||
|
} @else {
|
||||||
|
<span>No agents online</span>
|
||||||
|
}
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<!-- Footer hint -->
|
||||||
|
<div class="quick-jump-drawer__footer">
|
||||||
|
<span class="quick-jump-drawer__footer-hint">
|
||||||
|
<kbd>↑↓</kbd> Navigate <kbd>↵</kbd> Select <kbd>Esc</kbd> Close
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
+333
@@ -0,0 +1,333 @@
|
|||||||
|
// ============================================================================
|
||||||
|
// Quick-Jump Drawer — Slide-out panel for fast agent switching
|
||||||
|
// Per CUB-51: slides from right, agent list with status badges,
|
||||||
|
// search/filter input, closes via ESC or outside click.
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Backdrop
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
.quick-jump-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
|
z-index: 998;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 200ms ease-out;
|
||||||
|
|
||||||
|
&.backdrop-visible {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Drawer Panel
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
.quick-jump-drawer {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 380px;
|
||||||
|
max-width: 90vw;
|
||||||
|
background-color: var(--cc-surface-container);
|
||||||
|
border-left: 1px solid var(--cc-outline);
|
||||||
|
z-index: 999;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
transform: translateX(100%);
|
||||||
|
transition: transform 250ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
box-shadow: -4px 0 24px rgba(0, 0, 0, 0.3);
|
||||||
|
|
||||||
|
&--open {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Header
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
.quick-jump-drawer__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 20px 24px 12px;
|
||||||
|
border-bottom: 1px solid var(--cc-outline);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-jump-drawer__title {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--cc-on-surface);
|
||||||
|
margin: 0;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-jump-drawer__close-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--cc-on-surface-variant);
|
||||||
|
font-size: 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 150ms ease, color 150ms ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--cc-surface-container-high);
|
||||||
|
color: var(--cc-on-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid var(--status-active);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Search
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
.quick-jump-drawer__search {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin: 16px 24px 8px;
|
||||||
|
border: 1px solid var(--cc-outline);
|
||||||
|
border-radius: 12px;
|
||||||
|
background-color: var(--cc-surface-container-high);
|
||||||
|
transition: border-color 150ms ease;
|
||||||
|
|
||||||
|
&:focus-within {
|
||||||
|
border-color: var(--status-active);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-jump-drawer__search-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding-left: 12px;
|
||||||
|
font-family: 'Material Icons';
|
||||||
|
font-size: 20px;
|
||||||
|
color: var(--cc-on-surface-variant);
|
||||||
|
pointer-events: none;
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
|
// Use a simple "search" text since icon font may not be loaded inside
|
||||||
|
// the drawer — rely on Material icon font from the parent app
|
||||||
|
&::before {
|
||||||
|
content: 'search';
|
||||||
|
font-family: 'Material Icons';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-jump-drawer__search-input {
|
||||||
|
flex: 1;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
background: transparent;
|
||||||
|
padding: 12px 8px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-family: 'Inter', 'Roboto', sans-serif;
|
||||||
|
color: var(--cc-on-surface);
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: var(--cc-on-surface-variant);
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-jump-drawer__search-clear {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
margin-right: 4px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--cc-on-surface-variant);
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 150ms ease, color 150ms ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--cc-surface-container);
|
||||||
|
color: var(--cc-on-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid var(--status-active);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Agent List
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
.quick-jump-drawer__agent-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 8px 12px;
|
||||||
|
overflow-y: auto;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-jump-drawer__agent-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 150ms ease;
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&--highlighted {
|
||||||
|
background-color: var(--cc-surface-container-high);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--highlighted {
|
||||||
|
outline: 2px solid var(--status-active);
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid var(--status-active);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-jump-drawer__agent-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0; // Allow text truncation
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-jump-drawer__agent-name {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--cc-on-surface);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-jump-drawer__agent-role {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--cc-on-surface-variant);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-jump-drawer__agent-status-label {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
&.status-label--active {
|
||||||
|
color: var(--status-active);
|
||||||
|
background-color: var(--status-active-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.status-label--idle {
|
||||||
|
color: var(--status-idle);
|
||||||
|
background-color: var(--status-idle-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.status-label--thinking {
|
||||||
|
color: var(--status-thinking);
|
||||||
|
background-color: var(--status-thinking-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.status-label--error {
|
||||||
|
color: var(--status-error);
|
||||||
|
background-color: var(--status-error-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.status-label--offline {
|
||||||
|
color: var(--status-offline);
|
||||||
|
background-color: rgba(100, 116, 139, 0.12);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Empty State
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
.quick-jump-drawer__empty {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 48px 24px;
|
||||||
|
color: var(--cc-on-surface-variant);
|
||||||
|
font-size: 14px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Footer
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
.quick-jump-drawer__footer {
|
||||||
|
padding: 12px 24px 16px;
|
||||||
|
border-top: 1px solid var(--cc-outline);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-jump-drawer__footer-hint {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--cc-on-surface-variant);
|
||||||
|
opacity: 0.7;
|
||||||
|
|
||||||
|
kbd {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: var(--cc-font-mono);
|
||||||
|
background-color: var(--cc-surface-container-high);
|
||||||
|
border: 1px solid var(--cc-outline);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--cc-on-surface-variant);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mobile Adjustments
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
@media (max-width: 599px) {
|
||||||
|
.quick-jump-drawer {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100vw;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-jump-drawer__header {
|
||||||
|
padding: 16px 16px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-jump-drawer__search {
|
||||||
|
margin: 12px 16px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-jump-drawer__agent-list {
|
||||||
|
padding: 4px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-jump-drawer__footer {
|
||||||
|
padding: 10px 16px 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
import {
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
Component,
|
||||||
|
ElementRef,
|
||||||
|
EventEmitter,
|
||||||
|
HostListener,
|
||||||
|
OnDestroy,
|
||||||
|
Output,
|
||||||
|
signal,
|
||||||
|
ViewChild,
|
||||||
|
} from '@angular/core';
|
||||||
|
import { FormControl, ReactiveFormsModule } from '@angular/forms';
|
||||||
|
import { Subject, takeUntil } from 'rxjs';
|
||||||
|
import { AgentCardData } from '../../models/agent.model';
|
||||||
|
import { AgentStatusService } from '../../services/agent-status.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-quick-jump-drawer',
|
||||||
|
standalone: true,
|
||||||
|
imports: [ReactiveFormsModule],
|
||||||
|
templateUrl: './quick-jump-drawer.component.html',
|
||||||
|
styleUrl: './quick-jump-drawer.component.scss',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
})
|
||||||
|
export class QuickJumpDrawerComponent implements OnDestroy {
|
||||||
|
/** Emits when the drawer should close (ESC, outside click, or item select). */
|
||||||
|
@Output() readonly drawerClose = new EventEmitter<void>();
|
||||||
|
|
||||||
|
/** Whether the drawer is visible. */
|
||||||
|
readonly isOpen = signal(false);
|
||||||
|
|
||||||
|
/** Search/filter input control. */
|
||||||
|
readonly searchControl = new FormControl('', { nonNullable: true });
|
||||||
|
|
||||||
|
/** Filtered agent list based on search. */
|
||||||
|
readonly filteredAgents = signal<AgentCardData[]>([]);
|
||||||
|
|
||||||
|
/** Track which agent row is highlighted via keyboard navigation. */
|
||||||
|
readonly highlightedIndex = signal(-1);
|
||||||
|
|
||||||
|
@ViewChild('searchInput') searchInput!: ElementRef<HTMLInputElement>;
|
||||||
|
@ViewChild('drawerPanel') drawerPanel!: ElementRef<HTMLElement>;
|
||||||
|
|
||||||
|
private readonly destroy$ = new Subject<void>();
|
||||||
|
|
||||||
|
constructor(private readonly agentStatusService: AgentStatusService) {
|
||||||
|
// Reactively filter agents as the search input changes
|
||||||
|
this.searchControl.valueChanges
|
||||||
|
.pipe(takeUntil(this.destroy$))
|
||||||
|
.subscribe((query) => this.filterAgents(query));
|
||||||
|
|
||||||
|
// Initial load
|
||||||
|
this.filterAgents('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Public API
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Open the drawer and focus the search input. */
|
||||||
|
open(): void {
|
||||||
|
this.isOpen.set(true);
|
||||||
|
this.searchControl.setValue('', { emitEvent: false });
|
||||||
|
this.highlightedIndex.set(-1);
|
||||||
|
// Focus search input after animation frame (drawer needs to render first)
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
this.searchInput?.nativeElement?.focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Close the drawer. */
|
||||||
|
close(): void {
|
||||||
|
this.isOpen.set(false);
|
||||||
|
this.searchControl.setValue('', { emitEvent: false });
|
||||||
|
this.highlightedIndex.set(-1);
|
||||||
|
this.drawerClose.emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Toggle the drawer open/close. */
|
||||||
|
toggle(): void {
|
||||||
|
if (this.isOpen()) {
|
||||||
|
this.close();
|
||||||
|
} else {
|
||||||
|
this.open();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Keyboard Handling
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@HostListener('document:keydown.escape')
|
||||||
|
onEscapeKey(): void {
|
||||||
|
if (this.isOpen()) {
|
||||||
|
this.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Handle keyboard navigation within the drawer panel. */
|
||||||
|
onDrawerKeydown(event: KeyboardEvent): void {
|
||||||
|
const agents = this.filteredAgents();
|
||||||
|
if (!agents.length) return;
|
||||||
|
|
||||||
|
switch (event.key) {
|
||||||
|
case 'ArrowDown': {
|
||||||
|
event.preventDefault();
|
||||||
|
this.highlightedIndex.update((i) =>
|
||||||
|
i < agents.length - 1 ? i + 1 : 0
|
||||||
|
);
|
||||||
|
this.scrollIntoView();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'ArrowUp': {
|
||||||
|
event.preventDefault();
|
||||||
|
this.highlightedIndex.update((i) =>
|
||||||
|
i > 0 ? i - 1 : agents.length - 1
|
||||||
|
);
|
||||||
|
this.scrollIntoView();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'Enter': {
|
||||||
|
const idx = this.highlightedIndex();
|
||||||
|
if (idx >= 0 && idx < agents.length) {
|
||||||
|
this.selectAgent(agents[idx]);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Outside Click
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Close when clicking on the backdrop (outside the panel). */
|
||||||
|
onBackdropClick(event: MouseEvent): void {
|
||||||
|
if (
|
||||||
|
this.drawerPanel?.nativeElement &&
|
||||||
|
!this.drawerPanel.nativeElement.contains(event.target as Node)
|
||||||
|
) {
|
||||||
|
this.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Agent Selection
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Select an agent — navigates or focuses the agent card. */
|
||||||
|
selectAgent(agent: AgentCardData): void {
|
||||||
|
// TODO: Wire up navigation to the selected agent's detail view
|
||||||
|
// For now, emit close after selection
|
||||||
|
console.log('[QuickJump] Selected agent:', agent.id);
|
||||||
|
this.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Status Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Get the CSS class for a given agent status. */
|
||||||
|
getStatusClass(status: string): string {
|
||||||
|
return `status-dot--${status}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get a human-readable label for an agent status. */
|
||||||
|
getStatusLabel(status: string): string {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
active: 'Active',
|
||||||
|
idle: 'Idle',
|
||||||
|
thinking: 'Thinking',
|
||||||
|
error: 'Error',
|
||||||
|
offline: 'Offline',
|
||||||
|
};
|
||||||
|
return labels[status] ?? status;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Lifecycle
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.destroy$.next();
|
||||||
|
this.destroy$.complete();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Private
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private filterAgents(query: string): void {
|
||||||
|
const allAgents = this.agentStatusService.agents();
|
||||||
|
const lowerQuery = query.toLowerCase().trim();
|
||||||
|
|
||||||
|
if (!lowerQuery) {
|
||||||
|
this.filteredAgents.set(allAgents);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filtered = allAgents.filter(
|
||||||
|
(agent) =>
|
||||||
|
agent.displayName.toLowerCase().includes(lowerQuery) ||
|
||||||
|
agent.id.toLowerCase().includes(lowerQuery) ||
|
||||||
|
agent.role.toLowerCase().includes(lowerQuery)
|
||||||
|
);
|
||||||
|
this.filteredAgents.set(filtered);
|
||||||
|
this.highlightedIndex.set(-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private scrollIntoView(): void {
|
||||||
|
const idx = this.highlightedIndex();
|
||||||
|
const el = document.getElementById(`quick-jump-agent-${idx}`);
|
||||||
|
el?.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
// ============================================================================
|
||||||
|
// OpenClaw Control Center — Design System Barrel Export
|
||||||
|
// ============================================================================
|
||||||
|
// Import everything from '@app/design' for convenient access.
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
// import { CcTokens, CcThemeService, CcCssProps } from '@app/design';
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export * from './tokens';
|
||||||
|
export * from './theme.service';
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
// ============================================================================
|
||||||
|
// OpenClaw Control Center — Theme Service
|
||||||
|
// ============================================================================
|
||||||
|
// Angular service providing programmatic access to design tokens, theme
|
||||||
|
// mode switching (dark/light), and runtime CSS custom property manipulation.
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
// constructor(private theme: CcThemeService) {}
|
||||||
|
//
|
||||||
|
// // Read a token
|
||||||
|
// const primary = this.theme.getToken('--cc-color-primary');
|
||||||
|
//
|
||||||
|
// // Set a token at runtime
|
||||||
|
// this.theme.setToken('--cc-color-primary', '#00ff00');
|
||||||
|
//
|
||||||
|
// // Toggle theme
|
||||||
|
// this.theme.setMode('light');
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
import { Injectable, signal, computed, effect } from '@angular/core';
|
||||||
|
import { CcCssProps, getStatusColor, setCssToken, getCssToken } from './tokens';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Theme Mode Types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
export type ThemeMode = 'dark' | 'light';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Light theme overrides (future use)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const LIGHT_THEME_OVERRIDES: Record<string, string> = {
|
||||||
|
// Surface tokens
|
||||||
|
'--cc-surface-darkest': '#F8FAFC',
|
||||||
|
'--cc-surface-dark': '#FFFFFF',
|
||||||
|
'--cc-surface-medium': '#F1F5F9',
|
||||||
|
'--cc-surface-light': '#E2E8F0',
|
||||||
|
'--cc-surface-lighter': '#CBD5E1',
|
||||||
|
|
||||||
|
// On-surface tokens
|
||||||
|
'--cc-on-surface': '#0F172A',
|
||||||
|
'--cc-on-surface-variant': '#475569',
|
||||||
|
'--cc-on-surface-muted': '#94A3B8',
|
||||||
|
|
||||||
|
// Border
|
||||||
|
'--cc-surface-lighter-alt': '#E2E8F0',
|
||||||
|
|
||||||
|
// M3 system overrides for light
|
||||||
|
'--mat-sys-surface': '#FFFFFF',
|
||||||
|
'--mat-sys-surface-container': '#F1F5F9',
|
||||||
|
'--mat-sys-surface-container-high': '#E2E8F0',
|
||||||
|
'--mat-sys-on-surface': '#0F172A',
|
||||||
|
'--mat-sys-on-surface-variant': '#475569',
|
||||||
|
'--mat-sys-outline': '#CBD5E1',
|
||||||
|
'--mat-sys-background': '#F8FAFC',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Dark theme (matches the SCSS defaults)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const DARK_THEME_OVERRIDES: Record<string, string> = {
|
||||||
|
'--cc-surface-darkest': '#0D0F12',
|
||||||
|
'--cc-surface-dark': '#13161A',
|
||||||
|
'--cc-surface-medium': '#1C2027',
|
||||||
|
'--cc-surface-light': '#252B33',
|
||||||
|
'--cc-surface-lighter': '#2D3748',
|
||||||
|
|
||||||
|
'--cc-on-surface': '#E2E8F0',
|
||||||
|
'--cc-on-surface-variant': '#8A9BB0',
|
||||||
|
'--cc-on-surface-muted': '#64748B',
|
||||||
|
|
||||||
|
'--mat-sys-surface': '#13161A',
|
||||||
|
'--mat-sys-surface-container': '#1C2027',
|
||||||
|
'--mat-sys-surface-container-high': '#252B33',
|
||||||
|
'--mat-sys-on-surface': '#E2E8F0',
|
||||||
|
'--mat-sys-on-surface-variant': '#8A9BB0',
|
||||||
|
'--mat-sys-outline': '#2D3748',
|
||||||
|
'--mat-sys-background': '#0D0F12',
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class CcThemeService {
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Signals for reactive theme state
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
private readonly _mode = signal<ThemeMode>(
|
||||||
|
(localStorage.getItem('cc-theme') as ThemeMode) ?? 'dark'
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Current theme mode */
|
||||||
|
readonly mode = this._mode.asReadonly();
|
||||||
|
|
||||||
|
/** Computed: is the current mode dark? */
|
||||||
|
readonly isDark = computed(() => this._mode() === 'dark');
|
||||||
|
|
||||||
|
/** Computed: is the current mode light? */
|
||||||
|
readonly isLight = computed(() => this._mode() === 'light');
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
// Apply theme on init and whenever mode changes
|
||||||
|
effect(() => {
|
||||||
|
this.applyTheme(this._mode());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Public API
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Set the theme mode and persist to localStorage */
|
||||||
|
setMode(mode: ThemeMode): void {
|
||||||
|
this._mode.set(mode);
|
||||||
|
localStorage.setItem('cc-theme', mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Toggle between dark and light mode */
|
||||||
|
toggle(): void {
|
||||||
|
this.setMode(this._mode() === 'dark' ? 'light' : 'dark');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read a CSS custom property from the document root */
|
||||||
|
getToken(property: string): string {
|
||||||
|
return getCssToken(property);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Set a CSS custom property on the document root */
|
||||||
|
setToken(property: string, value: string): void {
|
||||||
|
setCssToken(property, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get status color set by agent status */
|
||||||
|
getStatusColors(status: string): { fg: string; bg: string; border: string } {
|
||||||
|
return getStatusColor(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Internal
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Apply a theme mode by setting all CSS custom properties */
|
||||||
|
private applyTheme(mode: ThemeMode): void {
|
||||||
|
const overrides = mode === 'dark' ? DARK_THEME_OVERRIDES : LIGHT_THEME_OVERRIDES;
|
||||||
|
|
||||||
|
// Set color-scheme for native form controls
|
||||||
|
document.documentElement.style.setProperty('color-scheme', mode);
|
||||||
|
|
||||||
|
// Apply all overrides
|
||||||
|
for (const [prop, value] of Object.entries(overrides)) {
|
||||||
|
document.documentElement.style.setProperty(prop, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,462 @@
|
|||||||
|
// ============================================================================
|
||||||
|
// OpenClaw Control Center — Design Tokens (TypeScript)
|
||||||
|
// ============================================================================
|
||||||
|
// Typed representation of the design system tokens for programmatic access.
|
||||||
|
// These mirror the SCSS tokens in styles/_tokens.scss and the CSS custom
|
||||||
|
// properties emitted by styles/_css-properties.scss.
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
// import { CcTokens } from '@app/design/tokens';
|
||||||
|
// const primary = CcTokens.color.primary;
|
||||||
|
// const surface = CcTokens.surface.dark;
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Color Palette
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
export const CcColors = {
|
||||||
|
primary: {
|
||||||
|
50: '#ecfeff',
|
||||||
|
100: '#cffafe',
|
||||||
|
200: '#a5f3fc',
|
||||||
|
300: '#67e8f9',
|
||||||
|
400: '#22d3ee',
|
||||||
|
500: '#38bdf8',
|
||||||
|
600: '#0ea5e9',
|
||||||
|
700: '#0284c7',
|
||||||
|
800: '#0369a1',
|
||||||
|
900: '#075985',
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
50: '#f0fdfa',
|
||||||
|
100: '#ccfbf1',
|
||||||
|
200: '#99f6e4',
|
||||||
|
300: '#5eead4',
|
||||||
|
400: '#2dd4bf',
|
||||||
|
500: '#14b8a6',
|
||||||
|
600: '#0d9488',
|
||||||
|
700: '#0f766e',
|
||||||
|
800: '#115e59',
|
||||||
|
900: '#134e4a',
|
||||||
|
},
|
||||||
|
accent: {
|
||||||
|
50: '#f5f3ff',
|
||||||
|
100: '#ede9fe',
|
||||||
|
200: '#ddd6fe',
|
||||||
|
300: '#c4b5fd',
|
||||||
|
400: '#a78bfa',
|
||||||
|
500: '#8b5cf6',
|
||||||
|
600: '#7c3aed',
|
||||||
|
700: '#6d28d9',
|
||||||
|
800: '#5b21b6',
|
||||||
|
900: '#4c1d95',
|
||||||
|
},
|
||||||
|
danger: {
|
||||||
|
50: '#fef2f2',
|
||||||
|
100: '#fee2e2',
|
||||||
|
200: '#fecaca',
|
||||||
|
300: '#fca5a5',
|
||||||
|
400: '#f87171',
|
||||||
|
500: '#ef4444',
|
||||||
|
600: '#dc2626',
|
||||||
|
700: '#b91c1c',
|
||||||
|
800: '#991b1b',
|
||||||
|
900: '#7f1d1d',
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Semantic Colors (Tactical Dark)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
export const CcSemanticColors = {
|
||||||
|
surface: {
|
||||||
|
darkest: '#0D0F12',
|
||||||
|
dark: '#13161A',
|
||||||
|
medium: '#1C2027',
|
||||||
|
light: '#252B33',
|
||||||
|
lighter: '#2D3748',
|
||||||
|
},
|
||||||
|
onSurface: {
|
||||||
|
primary: '#E2E8F0',
|
||||||
|
variant: '#8A9BB0',
|
||||||
|
muted: '#64748B',
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Status Colors
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
export const CcStatusColors = {
|
||||||
|
active: { fg: '#38bdf8', bg: 'rgba(56, 189, 248, 0.12)', border: 'rgba(56, 189, 248, 0.40)' },
|
||||||
|
idle: { fg: '#2dd4bf', bg: 'rgba(45, 212, 191, 0.12)', border: 'rgba(45, 212, 191, 0.40)' },
|
||||||
|
thinking: { fg: '#a78bfa', bg: 'rgba(167, 139, 250, 0.12)', border: 'rgba(167, 139, 250, 0.40)' },
|
||||||
|
error: { fg: '#f87171', bg: 'rgba(248, 113, 113, 0.12)', border: 'rgba(248, 113, 113, 0.40)' },
|
||||||
|
offline: { fg: '#64748b', bg: 'rgba(100, 116, 139, 0.12)', border: 'rgba(100, 116, 139, 0.40)' },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Convenience exports for component usage (CUB-20)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
/** Status colors — maps AgentStatus to hex values */
|
||||||
|
export const STATUS_COLORS: Record<string, string> = {
|
||||||
|
active: '#38BDF8',
|
||||||
|
idle: '#2DD4BF',
|
||||||
|
thinking: '#A78BFA',
|
||||||
|
error: '#F87171',
|
||||||
|
offline: '#64748B',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Status background tints (12% opacity) */
|
||||||
|
export const STATUS_BG_COLORS: Record<string, string> = {
|
||||||
|
active: 'rgba(56, 189, 248, 0.12)',
|
||||||
|
idle: 'rgba(45, 212, 191, 0.12)',
|
||||||
|
thinking: 'rgba(167, 139, 250, 0.12)',
|
||||||
|
error: 'rgba(248, 113, 113, 0.12)',
|
||||||
|
offline: 'rgba(100, 116, 139, 0.12)',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Surface overrides (CUB-20 convenience) */
|
||||||
|
export const SURFACE = {
|
||||||
|
background: '#0D0F12',
|
||||||
|
surface: '#13161A',
|
||||||
|
container: '#1C2027',
|
||||||
|
containerHigh: '#252B33',
|
||||||
|
onSurface: '#E2E8F0',
|
||||||
|
onSurfaceVariant: '#8A9BB0',
|
||||||
|
outline: '#2D3748',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** Tactical Dark Mode color palette (CUB-20 convenience) */
|
||||||
|
export const COLORS = {
|
||||||
|
surface: '#0F172A',
|
||||||
|
surfaceLight: '#1E293B',
|
||||||
|
primary: '#38BDF8',
|
||||||
|
secondary: '#2DD4BF',
|
||||||
|
accent: '#A78BFA',
|
||||||
|
danger: '#F87171',
|
||||||
|
textPrimary: '#FFFFFF',
|
||||||
|
textSecondary: '#94A3B8',
|
||||||
|
border: '#334155',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** Layout constants (CUB-20 convenience) */
|
||||||
|
export const LAYOUT = {
|
||||||
|
navRailCollapsedWidth: 72,
|
||||||
|
navRailExpandedWidth: 256,
|
||||||
|
headerHeight: 64,
|
||||||
|
bottomNavHeight: 80,
|
||||||
|
cardBorderRadius: 16,
|
||||||
|
cardMinWidth: 320,
|
||||||
|
cardGap: 16,
|
||||||
|
cardPadding: 20,
|
||||||
|
sectionPadding: 24,
|
||||||
|
spacingUnit: 8,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** Breakpoints (px) (CUB-20 convenience) */
|
||||||
|
export const BREAKPOINTS = {
|
||||||
|
mobile: 599,
|
||||||
|
tablet: 1023,
|
||||||
|
desktop: 1024,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** Channel icon mapping (CUB-20) */
|
||||||
|
export const CHANNEL_ICONS: Record<string, string> = {
|
||||||
|
telegram: 'telegram',
|
||||||
|
slack: 'chat',
|
||||||
|
discord: 'forum',
|
||||||
|
whatsapp: 'chat',
|
||||||
|
webchat: 'language',
|
||||||
|
email: 'email',
|
||||||
|
mqtt: 'sensors',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Human-readable status labels (CUB-20) */
|
||||||
|
export const STATUS_LABELS: Record<string, string> = {
|
||||||
|
active: 'Active',
|
||||||
|
idle: 'Idle',
|
||||||
|
thinking: 'Thinking…',
|
||||||
|
error: 'Error',
|
||||||
|
offline: 'Offline',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Typography
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
export const CcTypography = {
|
||||||
|
fontFamily: {
|
||||||
|
brand: "'Inter, Roboto, sans-serif'",
|
||||||
|
body: "'Inter, Roboto, sans-serif'",
|
||||||
|
mono: "'Roboto Mono, Cascadia Code, Fira Code, monospace'",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
displayLarge: '57px',
|
||||||
|
displayMedium: '45px',
|
||||||
|
displaySmall: '36px',
|
||||||
|
headlineLarge: '32px',
|
||||||
|
headlineMedium: '28px',
|
||||||
|
headlineSmall: '24px',
|
||||||
|
titleLarge: '22px',
|
||||||
|
titleMedium: '16px',
|
||||||
|
titleSmall: '14px',
|
||||||
|
bodyLarge: '16px',
|
||||||
|
bodyMedium: '14px',
|
||||||
|
bodySmall: '12px',
|
||||||
|
labelLarge: '14px',
|
||||||
|
labelMedium: '12px',
|
||||||
|
labelSmall: '11px',
|
||||||
|
},
|
||||||
|
weight: {
|
||||||
|
regular: 400,
|
||||||
|
medium: 500,
|
||||||
|
bold: 600,
|
||||||
|
heavy: 700,
|
||||||
|
},
|
||||||
|
lineHeight: {
|
||||||
|
tight: '1.2',
|
||||||
|
normal: '1.5',
|
||||||
|
relaxed: '1.6',
|
||||||
|
},
|
||||||
|
letterSpacing: {
|
||||||
|
tight: '-0.01em',
|
||||||
|
normal: '0em',
|
||||||
|
wide: '0.02em',
|
||||||
|
mono: '0.05em',
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Spacing (4px grid)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
export const CcSpacing = {
|
||||||
|
0: '0px',
|
||||||
|
1: '4px',
|
||||||
|
2: '8px',
|
||||||
|
3: '12px',
|
||||||
|
4: '16px',
|
||||||
|
5: '20px',
|
||||||
|
6: '24px',
|
||||||
|
7: '28px',
|
||||||
|
8: '32px',
|
||||||
|
9: '36px',
|
||||||
|
10: '40px',
|
||||||
|
12: '48px',
|
||||||
|
14: '56px',
|
||||||
|
16: '64px',
|
||||||
|
20: '80px',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Layout
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
export const CcLayout = {
|
||||||
|
navRailCollapsedWidth: '72px',
|
||||||
|
navRailExpandedWidth: '256px',
|
||||||
|
headerHeight: '64px',
|
||||||
|
bottomNavHeight: '80px',
|
||||||
|
cardBorderRadius: '16px',
|
||||||
|
cardMinWidth: '320px',
|
||||||
|
badgeHeight: '24px',
|
||||||
|
badgeBorderRadius: '12px',
|
||||||
|
statusDotSize: '10px',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Breakpoints (M3 canonical)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
export const CcBreakpoints = {
|
||||||
|
compact: 599,
|
||||||
|
medium: 767,
|
||||||
|
expanded: 1023,
|
||||||
|
large: 1439,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Border Radius
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
export const CcRadius = {
|
||||||
|
none: '0px',
|
||||||
|
xs: '4px',
|
||||||
|
sm: '8px',
|
||||||
|
md: '12px',
|
||||||
|
lg: '16px',
|
||||||
|
xl: '24px',
|
||||||
|
full: '9999px',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Shadows (M3 elevation)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
export const CcShadows = {
|
||||||
|
level0: 'none',
|
||||||
|
level1: '0 1px 3px 0 rgba(0, 0, 0, 0.3), 0 1px 2px -1px rgba(0, 0, 0, 0.3)',
|
||||||
|
level2: '0 2px 6px 0 rgba(0, 0, 0, 0.3), 0 2px 4px -2px rgba(0, 0, 0, 0.3)',
|
||||||
|
level3: '0 4px 12px 0 rgba(0, 0, 0, 0.3), 0 4px 8px -4px rgba(0, 0, 0, 0.3)',
|
||||||
|
level4: '0 8px 24px 0 rgba(0, 0, 0, 0.3), 0 8px 16px -8px rgba(0, 0, 0, 0.3)',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Motion
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
export const CcMotion = {
|
||||||
|
duration: {
|
||||||
|
instant: 0,
|
||||||
|
fast: 100,
|
||||||
|
short: 150,
|
||||||
|
medium: 200,
|
||||||
|
standard: 300,
|
||||||
|
long: 500,
|
||||||
|
},
|
||||||
|
easing: {
|
||||||
|
standard: 'cubic-bezier(0.4, 0, 0.2, 1)',
|
||||||
|
decelerate: 'cubic-bezier(0, 0, 0.2, 1)',
|
||||||
|
accelerate: 'cubic-bezier(0.4, 0, 1, 1)',
|
||||||
|
sharp: 'cubic-bezier(0.4, 0, 0.6, 1)',
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Accessibility
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
export const CcA11y = {
|
||||||
|
focusRing: {
|
||||||
|
width: '2px',
|
||||||
|
offset: '2px',
|
||||||
|
color: '#38bdf8',
|
||||||
|
style: 'solid',
|
||||||
|
},
|
||||||
|
minTouchTarget: 48,
|
||||||
|
minBodyFont: 16,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Aggregate token object for convenient access
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
export const CcTokens = {
|
||||||
|
color: CcColors,
|
||||||
|
semantic: CcSemanticColors,
|
||||||
|
status: CcStatusColors,
|
||||||
|
typography: CcTypography,
|
||||||
|
spacing: CcSpacing,
|
||||||
|
layout: CcLayout,
|
||||||
|
breakpoints: CcBreakpoints,
|
||||||
|
radius: CcRadius,
|
||||||
|
shadows: CcShadows,
|
||||||
|
motion: CcMotion,
|
||||||
|
a11y: CcA11y,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// CSS Custom Property Names
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
export const CcCssProps = {
|
||||||
|
// Color
|
||||||
|
colorPrimary: '--cc-color-primary',
|
||||||
|
colorSecondary: '--cc-color-secondary',
|
||||||
|
colorAccent: '--cc-color-accent',
|
||||||
|
colorDanger: '--cc-color-danger',
|
||||||
|
|
||||||
|
// Surface
|
||||||
|
surfaceDarkest: '--cc-surface-darkest',
|
||||||
|
surfaceDark: '--cc-surface-dark',
|
||||||
|
surfaceMedium: '--cc-surface-medium',
|
||||||
|
surfaceLight: '--cc-surface-light',
|
||||||
|
surfaceLighter: '--cc-surface-lighter',
|
||||||
|
|
||||||
|
// On-surface
|
||||||
|
onSurface: '--cc-on-surface',
|
||||||
|
onSurfaceVariant: '--cc-on-surface-variant',
|
||||||
|
onSurfaceMuted: '--cc-on-surface-muted',
|
||||||
|
|
||||||
|
// Status
|
||||||
|
statusActive: '--cc-status-active',
|
||||||
|
statusIdle: '--cc-status-idle',
|
||||||
|
statusThinking: '--cc-status-thinking',
|
||||||
|
statusError: '--cc-status-error',
|
||||||
|
statusOffline: '--cc-status-offline',
|
||||||
|
statusActiveBg: '--cc-status-active-bg',
|
||||||
|
statusIdleBg: '--cc-status-idle-bg',
|
||||||
|
statusThinkingBg: '--cc-status-thinking-bg',
|
||||||
|
statusErrorBg: '--cc-status-error-bg',
|
||||||
|
statusOfflineBg: '--cc-status-offline-bg',
|
||||||
|
statusActiveBorder: '--cc-status-active-border',
|
||||||
|
statusIdleBorder: '--cc-status-idle-border',
|
||||||
|
statusThinkingBorder: '--cc-status-thinking-border',
|
||||||
|
statusErrorBorder: '--cc-status-error-border',
|
||||||
|
statusOfflineBorder: '--cc-status-offline-border',
|
||||||
|
|
||||||
|
// Typography
|
||||||
|
fontBrand: '--cc-font-brand',
|
||||||
|
fontBody: '--cc-font-body',
|
||||||
|
fontMono: '--cc-font-mono',
|
||||||
|
|
||||||
|
// Spacing
|
||||||
|
spacing2: '--cc-spacing-2',
|
||||||
|
spacing4: '--cc-spacing-4',
|
||||||
|
spacing6: '--cc-spacing-6',
|
||||||
|
spacing8: '--cc-spacing-8',
|
||||||
|
spacing12: '--cc-spacing-12',
|
||||||
|
spacing16: '--cc-spacing-16',
|
||||||
|
|
||||||
|
// Layout
|
||||||
|
navRailCollapsed: '--cc-nav-rail-collapsed',
|
||||||
|
navRailExpanded: '--cc-nav-rail-expanded',
|
||||||
|
headerHeight: '--cc-header-height',
|
||||||
|
bottomNavHeight: '--cc-bottom-nav-height',
|
||||||
|
cardRadius: '--cc-card-radius',
|
||||||
|
cardMinWidth: '--cc-card-min-width',
|
||||||
|
|
||||||
|
// Radius
|
||||||
|
radiusNone: '--cc-radius-none',
|
||||||
|
radiusXs: '--cc-radius-xs',
|
||||||
|
radiusSm: '--cc-radius-sm',
|
||||||
|
radiusMd: '--cc-radius-md',
|
||||||
|
radiusLg: '--cc-radius-lg',
|
||||||
|
radiusXl: '--cc-radius-xl',
|
||||||
|
radiusFull: '--cc-radius-full',
|
||||||
|
|
||||||
|
// Shadows
|
||||||
|
shadow0: '--cc-shadow-0',
|
||||||
|
shadow1: '--cc-shadow-1',
|
||||||
|
shadow2: '--cc-shadow-2',
|
||||||
|
shadow3: '--cc-shadow-3',
|
||||||
|
shadow4: '--cc-shadow-4',
|
||||||
|
|
||||||
|
// Motion
|
||||||
|
durationFast: '--cc-duration-fast',
|
||||||
|
durationShort: '--cc-duration-short',
|
||||||
|
durationMedium: '--cc-duration-medium',
|
||||||
|
durationStandard: '--cc-duration-standard',
|
||||||
|
durationLong: '--cc-duration-long',
|
||||||
|
easingStandard: '--cc-easing-standard',
|
||||||
|
easingDecelerate: '--cc-easing-decelerate',
|
||||||
|
easingAccelerate: '--cc-easing-accelerate',
|
||||||
|
|
||||||
|
// Accessibility
|
||||||
|
focusWidth: '--cc-focus-width',
|
||||||
|
focusOffset: '--cc-focus-offset',
|
||||||
|
focusColor: '--cc-focus-color',
|
||||||
|
touchMin: '--cc-touch-min',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Utility: Read a CSS custom property from the document
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
export function getCssToken(propertyName: string): string {
|
||||||
|
return getComputedStyle(document.documentElement).getPropertyValue(propertyName).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Utility: Set a CSS custom property on the document root
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
export function setCssToken(propertyName: string, value: string): void {
|
||||||
|
document.documentElement.style.setProperty(propertyName, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Utility: Get status color by agent status type
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
export function getStatusColor(status: string): { fg: string; bg: string; border: string } {
|
||||||
|
const statusMap: Record<string, { fg: string; bg: string; border: string }> = CcStatusColors;
|
||||||
|
return statusMap[status] ?? CcStatusColors.offline;
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { LongPressDirective } from './long-press.directive';
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import {
|
||||||
|
Directive,
|
||||||
|
ElementRef,
|
||||||
|
EventEmitter,
|
||||||
|
OnDestroy,
|
||||||
|
Output,
|
||||||
|
Input,
|
||||||
|
} from '@angular/core';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Long-Press Directive — CUB-26
|
||||||
|
// Emits after a sustained press (500ms default).
|
||||||
|
// Used on agent cards to bypass the drawer and open Session Log directly.
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
@Directive({
|
||||||
|
selector: '[appLongPress]',
|
||||||
|
standalone: true,
|
||||||
|
host: {
|
||||||
|
'(mousedown)': 'onMouseDown($event)',
|
||||||
|
'(mouseup)': 'onMouseUp()',
|
||||||
|
'(mouseleave)': 'onMouseLeave()',
|
||||||
|
'(touchstart)': 'onTouchStart($event)',
|
||||||
|
'(touchend)': 'onTouchEnd()',
|
||||||
|
'(touchmove)': 'onTouchMove()',
|
||||||
|
'(contextmenu)': 'onContextMenu($event)',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
export class LongPressDirective implements OnDestroy {
|
||||||
|
/** Duration in ms before a press counts as a long press. */
|
||||||
|
@Input() appLongPressDuration = 500;
|
||||||
|
|
||||||
|
/** Emits when a long press is detected. Payload is the original event. */
|
||||||
|
@Output() readonly appLongPress = new EventEmitter<MouseEvent | TouchEvent>();
|
||||||
|
|
||||||
|
private timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
private isLongPress = false;
|
||||||
|
|
||||||
|
onMouseDown(event: MouseEvent): void {
|
||||||
|
this.isLongPress = false;
|
||||||
|
this.timer = setTimeout(() => {
|
||||||
|
this.isLongPress = true;
|
||||||
|
this.appLongPress.emit(event);
|
||||||
|
}, this.appLongPressDuration);
|
||||||
|
}
|
||||||
|
|
||||||
|
onMouseUp(): void {
|
||||||
|
this.clearTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
onMouseLeave(): void {
|
||||||
|
this.clearTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
onTouchStart(event: TouchEvent): void {
|
||||||
|
this.isLongPress = false;
|
||||||
|
this.timer = setTimeout(() => {
|
||||||
|
this.isLongPress = true;
|
||||||
|
this.appLongPress.emit(event);
|
||||||
|
}, this.appLongPressDuration);
|
||||||
|
}
|
||||||
|
|
||||||
|
onTouchEnd(): void {
|
||||||
|
this.clearTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
onTouchMove(): void {
|
||||||
|
// Cancel on touch move (finger moved)
|
||||||
|
this.clearTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
onContextMenu(event: MouseEvent): void {
|
||||||
|
// Prevent native context menu on long press
|
||||||
|
if (this.isLongPress) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.clearTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearTimer(): void {
|
||||||
|
if (this.timer !== null) {
|
||||||
|
clearTimeout(this.timer);
|
||||||
|
this.timer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+21
-3
@@ -1,7 +1,8 @@
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Bottom Navigation Bar — Mobile Navigation
|
// Bottom Navigation Bar — Mobile Navigation
|
||||||
// Per spec Section 3.2: M3 NavigationBar pattern
|
// Per CUB-27 spec breakpoints:
|
||||||
// Visible only on compact breakpoint (< 600px)
|
// Compact (0–599px): Visible — M3 NavigationBar pattern
|
||||||
|
// Medium+ (≥600px): Hidden — nav rail takes over
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
.bottom-nav {
|
.bottom-nav {
|
||||||
@@ -17,6 +18,8 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-around;
|
justify-content: space-around;
|
||||||
padding: 0 8px;
|
padding: 0 8px;
|
||||||
|
// Safe area inset for notched devices
|
||||||
|
padding-bottom: env(safe-area-inset-bottom, 0px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.bottom-nav__item {
|
.bottom-nav__item {
|
||||||
@@ -68,9 +71,24 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show bottom nav only on compact breakpoint
|
// ---------------------------------------------------------------------------
|
||||||
|
// Compact (0–599px): Show bottom nav
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
@media (max-width: 599px) {
|
@media (max-width: 599px) {
|
||||||
.bottom-nav {
|
.bottom-nav {
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Medium+ (≥600px): Hidden — nav rail takes over
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Accessibility: Reduced Motion
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.bottom-nav__item {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
+10
@@ -2,6 +2,16 @@
|
|||||||
<h1 class="header-bar__title">Command Hub</h1>
|
<h1 class="header-bar__title">Command Hub</h1>
|
||||||
|
|
||||||
<div class="header-bar__actions">
|
<div class="header-bar__actions">
|
||||||
|
<!-- Quick-Jump trigger -->
|
||||||
|
<button
|
||||||
|
class="header-bar__action-btn"
|
||||||
|
mat-icon-button
|
||||||
|
aria-label="Jump to agent"
|
||||||
|
(click)="openQuickJump.emit()"
|
||||||
|
>
|
||||||
|
<mat-icon>keyboard_command_key</mat-icon>
|
||||||
|
</button>
|
||||||
|
|
||||||
<!-- Live indicator -->
|
<!-- Live indicator -->
|
||||||
<button
|
<button
|
||||||
class="header-bar__action-btn header-bar__live-btn"
|
class="header-bar__action-btn header-bar__live-btn"
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
// ============================================================================
|
||||||
|
// Header Bar — Top App Bar
|
||||||
|
// Per CUB-27 spec breakpoints:
|
||||||
|
// Compact (0–599px): SmallTopAppBar — 56px height, compact title, hidden labels
|
||||||
|
// Medium (600–1023px): Medium top bar — 64px height
|
||||||
|
// Expanded (≥1024px): MediumTopAppBar — 64px height, full actions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
.header-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
height: var(--cc-header-height-compact); // Compact by default (mobile-first)
|
||||||
|
padding: 0 var(--cc-section-padding-compact);
|
||||||
|
background-color: var(--cc-surface-container-high);
|
||||||
|
border-bottom: 1px solid var(--cc-outline);
|
||||||
|
z-index: 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-bar__title {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--cc-on-surface);
|
||||||
|
margin: 0;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-bar__actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-bar__action-btn {
|
||||||
|
color: var(--cc-on-surface-variant) !important;
|
||||||
|
min-width: 48px;
|
||||||
|
min-height: 48px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--cc-on-surface) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-bar__live-dot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin-right: 6px;
|
||||||
|
background-color: var(--status-error);
|
||||||
|
vertical-align: middle;
|
||||||
|
|
||||||
|
&--connected {
|
||||||
|
background-color: var(--status-active);
|
||||||
|
animation: pulse-active 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-bar__live-label {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--cc-on-surface-variant);
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Compact (0–599px): SmallTopAppBar — hide live label, tighter spacing
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
@media (max-width: 599px) {
|
||||||
|
.header-bar__live-label {
|
||||||
|
display: none; // Space saving on compact — dot alone is enough
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-bar__actions {
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Medium (600–1023px): Medium top bar
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
@media (min-width: 600px) and (max-width: 1023px) {
|
||||||
|
.header-bar {
|
||||||
|
height: var(--cc-header-height);
|
||||||
|
padding: 0 var(--cc-section-padding);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-bar__title {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-bar__actions {
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Expanded (≥1024px): Full top bar
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.header-bar {
|
||||||
|
height: var(--cc-header-height);
|
||||||
|
padding: 0 var(--cc-section-padding);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-bar__title {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-bar__actions {
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Accessibility: Reduced Motion
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.header-bar__live-dot--connected {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
-1
@@ -1,4 +1,4 @@
|
|||||||
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
|
import { ChangeDetectionStrategy, Component, EventEmitter, Output, signal } from '@angular/core';
|
||||||
import { MatIconModule } from '@angular/material/icon';
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
import { MatButtonModule } from '@angular/material/button';
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
import { MatBadgeModule } from '@angular/material/badge';
|
import { MatBadgeModule } from '@angular/material/badge';
|
||||||
@@ -17,6 +17,9 @@ import { MatBadgeModule } from '@angular/material/badge';
|
|||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
})
|
})
|
||||||
export class HeaderBarComponent {
|
export class HeaderBarComponent {
|
||||||
|
/** Emits when the user requests the Quick-Jump drawer. */
|
||||||
|
@Output() readonly openQuickJump = new EventEmitter<void>();
|
||||||
|
|
||||||
protected readonly notificationCount = signal(3);
|
protected readonly notificationCount = signal(3);
|
||||||
protected readonly isConnected = signal(true);
|
protected readonly isConnected = signal(true);
|
||||||
|
|
||||||
+11
-1
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
<div class="layout-shell__main">
|
<div class="layout-shell__main">
|
||||||
<!-- Header bar at top of content area -->
|
<!-- Header bar at top of content area -->
|
||||||
<app-header-bar class="layout-shell__header" />
|
<app-header-bar class="layout-shell__header" (openQuickJump)="openQuickJump()" />
|
||||||
|
|
||||||
<!-- Scrollable content area -->
|
<!-- Scrollable content area -->
|
||||||
<main class="layout-shell__content">
|
<main class="layout-shell__content">
|
||||||
@@ -15,3 +15,13 @@
|
|||||||
<!-- Mobile: Bottom Navigation Bar -->
|
<!-- Mobile: Bottom Navigation Bar -->
|
||||||
<app-bottom-nav class="layout-shell__bottom-nav" />
|
<app-bottom-nav class="layout-shell__bottom-nav" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Quick-Jump Drawer (global overlay) -->
|
||||||
|
<app-quick-jump-drawer />
|
||||||
|
|
||||||
|
<!-- Agent Session Drawer (CUB-26) — desktop: side drawer, mobile: bottom sheet -->
|
||||||
|
<app-agent-session-drawer
|
||||||
|
[isMobile]="isMobile()"
|
||||||
|
(openSession)="onOpenSession($event)"
|
||||||
|
(pinToDashboard)="onPinToDashboard($event)"
|
||||||
|
/>
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
// ============================================================================
|
||||||
|
// Layout Shell — Adaptive layout container
|
||||||
|
// Per CUB-27 spec breakpoints:
|
||||||
|
// Compact (0–599px): Header + Content + Bottom Nav (stacked)
|
||||||
|
// Medium (600–1023px): Collapsed Nav Rail + Header + Content
|
||||||
|
// Expanded (≥1024px): Expandable Nav Rail + Header + Content
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
.layout-shell {
|
||||||
|
display: flex;
|
||||||
|
min-height: 100vh;
|
||||||
|
background-color: var(--cc-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-shell__nav-rail {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-shell__main {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0; // Prevent flex overflow
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-shell__header {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-shell__content {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
padding: var(--cc-section-padding);
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-shell__bottom-nav {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Compact (0–599px): Stack layout vertically, bottom nav visible
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
@media (max-width: 599px) {
|
||||||
|
.layout-shell {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-shell__content {
|
||||||
|
padding: var(--cc-section-padding-compact);
|
||||||
|
// Account for bottom nav bar height
|
||||||
|
padding-bottom: calc(var(--cc-bottom-nav-height) + 16px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Medium (600–1023px): Sidebar + content, collapsed nav rail
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
@media (min-width: 600px) and (max-width: 1023px) {
|
||||||
|
.layout-shell__content {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Expanded (≥1024px): Full nav rail with expandable behavior
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.layout-shell__content {
|
||||||
|
padding: var(--cc-section-padding);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component, HostListener, OnDestroy, signal, ViewChild } from '@angular/core';
|
||||||
|
import { Router, RouterOutlet } from '@angular/router';
|
||||||
|
import { BreakpointObserver, Breakpoints } from '@angular/cdk/layout';
|
||||||
|
import { Subscription } from 'rxjs';
|
||||||
|
import { NavRailComponent } from '../nav-rail/nav-rail.component';
|
||||||
|
import { BottomNavComponent } from '../bottom-nav/bottom-nav.component';
|
||||||
|
import { HeaderBarComponent } from '../header-bar/header-bar.component';
|
||||||
|
import { QuickJumpDrawerComponent } from '../../components/quick-jump-drawer/index';
|
||||||
|
import { AgentSessionDrawerComponent } from '../../components/agent-session-drawer/index';
|
||||||
|
import { AgentCardData } from '../../models/agent.model';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Layout Shell — wraps the main content area with adaptive navigation.
|
||||||
|
* Desktop/Kiosk: Nav Rail (left) + Header + Content
|
||||||
|
* Mobile: Header + Content + Bottom Nav
|
||||||
|
* Per spec Section 3.1 (kiosk) and 3.2 (mobile).
|
||||||
|
* CUB-26: Hosts the Agent Session Drawer for quick-jump navigation.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'app-layout-shell',
|
||||||
|
standalone: true,
|
||||||
|
imports: [RouterOutlet, NavRailComponent, BottomNavComponent, HeaderBarComponent, QuickJumpDrawerComponent, AgentSessionDrawerComponent],
|
||||||
|
templateUrl: './layout-shell.component.html',
|
||||||
|
styleUrl: './layout-shell.component.scss',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
})
|
||||||
|
export class LayoutShellComponent implements OnDestroy {
|
||||||
|
@ViewChild(QuickJumpDrawerComponent) quickJumpDrawer!: QuickJumpDrawerComponent;
|
||||||
|
@ViewChild(AgentSessionDrawerComponent) sessionDrawer!: AgentSessionDrawerComponent;
|
||||||
|
|
||||||
|
/** Whether the viewport is mobile-sized. */
|
||||||
|
readonly isMobile = signal(false);
|
||||||
|
|
||||||
|
private readonly breakpointSub: Subscription;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly breakpointObserver: BreakpointObserver,
|
||||||
|
private readonly router: Router,
|
||||||
|
) {
|
||||||
|
this.breakpointSub = this.breakpointObserver
|
||||||
|
.observe([Breakpoints.Handset, Breakpoints.Small])
|
||||||
|
.subscribe((result) => {
|
||||||
|
this.isMobile.set(result.matches);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Open the quick-jump drawer from anywhere in the layout. */
|
||||||
|
openQuickJump(): void {
|
||||||
|
this.quickJumpDrawer?.open();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Open the session drawer for a specific agent. */
|
||||||
|
openSessionDrawer(agent: AgentCardData): void {
|
||||||
|
this.sessionDrawer?.open(agent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Open the session log page directly (long-press bypass). */
|
||||||
|
openSessionLog(sessionKey: string): void {
|
||||||
|
this.router.navigate(['/sessions'], { queryParams: { key: sessionKey } });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Handle "Open Full Session" action from session drawer. */
|
||||||
|
onOpenSession(sessionKey: string): void {
|
||||||
|
this.router.navigate(['/sessions'], { queryParams: { key: sessionKey } });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Handle "Pin to Dashboard" action from session drawer. */
|
||||||
|
onPinToDashboard(sessionKey: string): void {
|
||||||
|
// TODO: Implement pin-to-dashboard logic
|
||||||
|
console.log('[LayoutShell] Pin to dashboard:', sessionKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Global keyboard shortcut: Ctrl+K or Cmd+K opens the quick-jump drawer. */
|
||||||
|
@HostListener('document:keydown', ['$event'])
|
||||||
|
onGlobalKeydown(event: KeyboardEvent): void {
|
||||||
|
if ((event.ctrlKey || event.metaKey) && event.key === 'k') {
|
||||||
|
event.preventDefault();
|
||||||
|
this.quickJumpDrawer?.toggle();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.breakpointSub.unsubscribe();
|
||||||
|
}
|
||||||
|
}
|
||||||
+50
-4
@@ -1,11 +1,14 @@
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Nav Rail — Desktop/Kiosk Navigation
|
// Nav Rail — Desktop/Kiosk Navigation
|
||||||
// Per spec Section 3.1: 72px collapsed / 256px expanded
|
// Per CUB-27 spec breakpoints:
|
||||||
|
// Compact (0–599px): Hidden — bottom nav takes over
|
||||||
|
// Medium (600–1023px): Collapsed (72px), icon-only
|
||||||
|
// Expanded (≥1024px): Expandable (72px collapsed / 256px expanded on hover)
|
||||||
// Section 5.4: Spacing & Grid
|
// Section 5.4: Spacing & Grid
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
.nav-rail {
|
.nav-rail {
|
||||||
display: flex;
|
display: none; // Hidden by default (mobile-first)
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
width: var(--cc-nav-rail-collapsed-width);
|
width: var(--cc-nav-rail-collapsed-width);
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
@@ -104,9 +107,52 @@
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Responsive: Hide nav rail on mobile (bottom nav takes over)
|
// ---------------------------------------------------------------------------
|
||||||
@media (max-width: 599px) {
|
// Medium (600–1023px): Show collapsed nav rail (icon-only)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
@media (min-width: 600px) and (max-width: 1023px) {
|
||||||
.nav-rail {
|
.nav-rail {
|
||||||
|
display: flex;
|
||||||
|
width: var(--cc-nav-rail-collapsed-width);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always collapsed on medium — hide labels
|
||||||
|
.nav-rail__brand,
|
||||||
|
.nav-rail__label {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nav-rail__header {
|
||||||
|
justify-content: center;
|
||||||
|
padding: 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-rail__item {
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
margin: 2px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disable expand on medium
|
||||||
|
.nav-rail--expanded {
|
||||||
|
width: var(--cc-nav-rail-collapsed-width);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Expanded (≥1024px): Full expandable nav rail
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.nav-rail {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Accessibility: Reduced Motion
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.nav-rail {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component, HostListener, signal, OnDestroy, OnInit } from '@angular/core';
|
||||||
|
import { RouterLink, RouterLinkActive } from '@angular/router';
|
||||||
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
|
import { MatBadgeModule } from '@angular/material/badge';
|
||||||
|
import { NAV_DESTINATIONS } from '../../models/nav.model';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-nav-rail',
|
||||||
|
standalone: true,
|
||||||
|
imports: [RouterLink, RouterLinkActive, MatIconModule, MatBadgeModule],
|
||||||
|
templateUrl: './nav-rail.component.html',
|
||||||
|
styleUrl: './nav-rail.component.scss',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
})
|
||||||
|
export class NavRailComponent implements OnInit, OnDestroy {
|
||||||
|
protected readonly destinations = NAV_DESTINATIONS;
|
||||||
|
protected readonly expanded = signal(false);
|
||||||
|
protected readonly isExpandedBreakpoint = signal(false);
|
||||||
|
|
||||||
|
private readonly EXPANDED_BP = 1024;
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.updateBreakpoint();
|
||||||
|
}
|
||||||
|
|
||||||
|
@HostListener('window:resize')
|
||||||
|
onResize(): void {
|
||||||
|
this.updateBreakpoint();
|
||||||
|
}
|
||||||
|
|
||||||
|
@HostListener('mouseenter')
|
||||||
|
onHoverIn(): void {
|
||||||
|
if (this.isExpandedBreakpoint()) {
|
||||||
|
this.expanded.set(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@HostListener('mouseleave')
|
||||||
|
onHoverOut(): void {
|
||||||
|
if (this.isExpandedBreakpoint()) {
|
||||||
|
this.expanded.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleExpand(): void {
|
||||||
|
if (this.isExpandedBreakpoint()) {
|
||||||
|
this.expanded.update(v => !v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateBreakpoint(): void {
|
||||||
|
const isExpanded = window.innerWidth >= this.EXPANDED_BP;
|
||||||
|
this.isExpandedBreakpoint.set(isExpanded);
|
||||||
|
// Collapse when leaving expanded breakpoint
|
||||||
|
if (!isExpanded) {
|
||||||
|
this.expanded.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
// Cleanup is handled by HostListener auto-unsubscribe
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<!-- ========================================================================== -->
|
||||||
|
<!-- Hub Page — Responsive Agent Card Grid with Filter Chips -->
|
||||||
|
<!-- Per CUB-27 spec breakpoints: -->
|
||||||
|
<!-- Compact (0–599px): Single-column cards, horizontal-scroll filter chips -->
|
||||||
|
<!-- Medium (600–1023px): 2-column grid -->
|
||||||
|
<!-- Expanded (≥1024px): 3+ column auto-fill grid -->
|
||||||
|
<!-- CUB-26: Integrates AgentCard click/long-press with session drawer. -->
|
||||||
|
<!-- ========================================================================== -->
|
||||||
|
|
||||||
|
<div class="hub-page">
|
||||||
|
<h1 class="hub-page__title">Command Hub</h1>
|
||||||
|
|
||||||
|
<!-- Filter Chip Group — horizontal scroll on mobile -->
|
||||||
|
<div class="hub-page__filters" role="tablist" aria-label="Filter agents by status">
|
||||||
|
@for (filter of filters; track filter.value) {
|
||||||
|
<button
|
||||||
|
class="hub-page__filter-chip"
|
||||||
|
[class.hub-page__filter-chip--active]="activeFilter() === filter.value"
|
||||||
|
role="tab"
|
||||||
|
[attr.aria-selected]="activeFilter() === filter.value"
|
||||||
|
(click)="selectFilter(filter.value)"
|
||||||
|
>
|
||||||
|
{{ filter.label }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Agent Card Grid -->
|
||||||
|
<div class="hub-page__grid">
|
||||||
|
@for (agent of filteredAgents(); track agent.id) {
|
||||||
|
<app-agent-card
|
||||||
|
[status]="agent.status"
|
||||||
|
[task]="agent.currentTask ?? ''"
|
||||||
|
[progress]="agent.taskProgress ?? 0"
|
||||||
|
[sessionKey]="agent.sessionKey"
|
||||||
|
[channel]="agent.channel"
|
||||||
|
[lastActivity]="agent.lastActivity"
|
||||||
|
[agentId]="agent.id"
|
||||||
|
[displayName]="agent.displayName"
|
||||||
|
[role]="agent.role"
|
||||||
|
[errorMessage]="agent.errorMessage ?? ''"
|
||||||
|
(cardClick)="onCardClick($event)"
|
||||||
|
(cardLongPress)="onCardLongPress($event)"
|
||||||
|
/>
|
||||||
|
} @empty {
|
||||||
|
<p class="hub-page__empty">No agents online</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Agent Session Drawer (CUB-26) -->
|
||||||
|
<app-agent-session-drawer
|
||||||
|
[isMobile]="isMobile()"
|
||||||
|
(openSession)="onOpenSession($event)"
|
||||||
|
(pinToDashboard)="onPinToDashboard($event)"
|
||||||
|
(drawerClose)="onDrawerClose()"
|
||||||
|
/>
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
// ============================================================================
|
||||||
|
// Hub Page — Responsive AgentCard Grid with Filter Chips
|
||||||
|
// Per CUB-27 spec breakpoints:
|
||||||
|
// Compact (0–599px): Single-column cards, horizontal-scroll filter chips
|
||||||
|
// Medium (600–1023px): 2-column grid
|
||||||
|
// Expanded (≥1024px): 3+ column auto-fill grid
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
.hub-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
min-height: 400px;
|
||||||
|
overflow-x: hidden;
|
||||||
|
padding: var(--cc-section-padding);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hub-page__title {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--cc-on-surface);
|
||||||
|
margin: 0 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Filter Chip Group
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
.hub-page__filters {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 4px 0;
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
scrollbar-width: none; // Firefox
|
||||||
|
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
display: none; // Chrome/Safari
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.hub-page__filter-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 36px;
|
||||||
|
min-width: 48px; // Touch target
|
||||||
|
padding: 6px 16px;
|
||||||
|
border: 1px solid var(--cc-outline);
|
||||||
|
border-radius: 20px;
|
||||||
|
background-color: transparent;
|
||||||
|
color: var(--cc-on-surface-variant);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: background-color 150ms ease, color 150ms ease, border-color 150ms ease;
|
||||||
|
flex-shrink: 0; // Prevent shrinking in scroll container
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.06);
|
||||||
|
color: var(--cc-on-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid var(--status-active);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--active {
|
||||||
|
background-color: var(--status-active-bg);
|
||||||
|
color: var(--status-active);
|
||||||
|
border-color: var(--status-active);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Agent Card Grid
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
.hub-page__grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: var(--cc-card-gap);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Empty State
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
.hub-page__placeholder,
|
||||||
|
.hub-page__empty {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 48px 24px;
|
||||||
|
color: var(--cc-on-surface-variant);
|
||||||
|
font-size: 16px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Compact (0–599px): Single-column cards
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
@media (max-width: 599px) {
|
||||||
|
.hub-page {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hub-page__grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hub-page__filters {
|
||||||
|
padding: 4px 0 8px;
|
||||||
|
// Ensure horizontal scroll on mobile
|
||||||
|
margin: 0 -8px;
|
||||||
|
padding-left: 8px;
|
||||||
|
padding-right: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Medium (600–1023px): 2-column grid
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
@media (min-width: 600px) and (max-width: 1023px) {
|
||||||
|
.hub-page__grid {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: var(--cc-card-gap);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Expanded (≥1024px): 3+ column auto-fill grid
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.hub-page__grid {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(var(--cc-card-min-width), 1fr));
|
||||||
|
gap: var(--cc-card-gap);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component, signal, computed, ViewChild } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { MatChipsModule } from '@angular/material/chips';
|
||||||
|
import { AgentCardComponent } from '../../command-hub/components/agent-card/agent-card.component';
|
||||||
|
import { AgentSessionDrawerComponent } from '../../components/agent-session-drawer/index';
|
||||||
|
import { AgentCardData } from '../../models/agent.model';
|
||||||
|
import { AgentStatus } from '../../models/agent.model';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter options for the hub page agent card grid.
|
||||||
|
* Per CUB-27: "Filter chip group (All, Active, Error, etc.) with horizontal scroll on mobile"
|
||||||
|
*/
|
||||||
|
export type AgentFilter = 'all' | AgentStatus;
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-hub-page',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, MatChipsModule, AgentCardComponent, AgentSessionDrawerComponent],
|
||||||
|
templateUrl: './hub-page.component.html',
|
||||||
|
styleUrl: './hub-page.component.scss',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
})
|
||||||
|
export class HubPageComponent {
|
||||||
|
@ViewChild(AgentSessionDrawerComponent) sessionDrawer!: AgentSessionDrawerComponent;
|
||||||
|
|
||||||
|
readonly isMobile = signal(false);
|
||||||
|
|
||||||
|
protected readonly filters: { label: string; value: AgentFilter }[] = [
|
||||||
|
{ label: 'All', value: 'all' },
|
||||||
|
{ label: 'Active', value: 'active' },
|
||||||
|
{ label: 'Idle', value: 'idle' },
|
||||||
|
{ label: 'Thinking', value: 'thinking' },
|
||||||
|
{ label: 'Error', value: 'error' },
|
||||||
|
{ label: 'Offline', value: 'offline' },
|
||||||
|
];
|
||||||
|
|
||||||
|
protected readonly activeFilter = signal<AgentFilter>('all');
|
||||||
|
|
||||||
|
/** Stub agent data (TODO: wire to AgentStatusService / SignalR). */
|
||||||
|
readonly agents = signal<AgentCardData[]>([
|
||||||
|
{
|
||||||
|
id: 'otto',
|
||||||
|
displayName: 'Otto',
|
||||||
|
role: 'Orchestrator Agent',
|
||||||
|
status: 'active',
|
||||||
|
currentTask: 'Reviewing PR #42',
|
||||||
|
taskProgress: 67,
|
||||||
|
taskElapsed: '04m 12s',
|
||||||
|
sessionKey: 'agent:otto:slack:CUB-42:abc123',
|
||||||
|
channel: 'slack',
|
||||||
|
lastActivity: new Date(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'rex',
|
||||||
|
displayName: 'Rex',
|
||||||
|
role: 'Frontend Agent',
|
||||||
|
status: 'thinking',
|
||||||
|
currentTask: 'Building responsive layout',
|
||||||
|
taskProgress: 40,
|
||||||
|
taskElapsed: '02m 30s',
|
||||||
|
sessionKey: 'agent:rex:telegram:CUB-27:def456',
|
||||||
|
channel: 'telegram',
|
||||||
|
lastActivity: new Date(Date.now() - 30000),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'dex',
|
||||||
|
displayName: 'Dex',
|
||||||
|
role: 'Backend Agent',
|
||||||
|
status: 'idle',
|
||||||
|
currentTask: undefined,
|
||||||
|
taskProgress: undefined,
|
||||||
|
taskElapsed: undefined,
|
||||||
|
sessionKey: 'agent:dex:slack:CUB-53:ghi789',
|
||||||
|
channel: 'slack',
|
||||||
|
lastActivity: new Date(Date.now() - 300000),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'hex',
|
||||||
|
displayName: 'Hex',
|
||||||
|
role: 'Database Agent',
|
||||||
|
status: 'error',
|
||||||
|
currentTask: 'Migration failed — rollback initiated',
|
||||||
|
taskProgress: 0,
|
||||||
|
taskElapsed: '00m 45s',
|
||||||
|
sessionKey: 'agent:hex:slack:CUB-56:jkl012',
|
||||||
|
channel: 'slack',
|
||||||
|
lastActivity: new Date(Date.now() - 60000),
|
||||||
|
errorMessage: 'Connection timeout to database server',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'nano',
|
||||||
|
displayName: 'Nano',
|
||||||
|
role: 'ESP32 Agent',
|
||||||
|
status: 'offline',
|
||||||
|
currentTask: undefined,
|
||||||
|
taskProgress: undefined,
|
||||||
|
taskElapsed: undefined,
|
||||||
|
sessionKey: 'agent:nano:mqtt:CUB-48:mno345',
|
||||||
|
channel: 'mqtt',
|
||||||
|
lastActivity: new Date(Date.now() - 86400000),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
protected readonly filteredAgents = computed(() => {
|
||||||
|
const filter = this.activeFilter();
|
||||||
|
if (filter === 'all') return this.agents();
|
||||||
|
return this.agents().filter(a => a.status === filter);
|
||||||
|
});
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
// Detect mobile viewport
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const mql = window.matchMedia('(max-width: 599px)');
|
||||||
|
this.isMobile.set(mql.matches);
|
||||||
|
mql.addEventListener('change', (e) => this.isMobile.set(e.matches));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected selectFilter(filter: AgentFilter): void {
|
||||||
|
this.activeFilter.set(filter);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Card click → open session drawer with agent details. */
|
||||||
|
onCardClick(sessionKey: string): void {
|
||||||
|
const agent = this.agents().find((a) => a.sessionKey === sessionKey);
|
||||||
|
if (agent) {
|
||||||
|
this.sessionDrawer?.open(agent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Long-press on card → bypass drawer, go directly to session log. */
|
||||||
|
onCardLongPress(sessionKey: string): void {
|
||||||
|
console.log('[Hub] Long press — navigate to session log:', sessionKey);
|
||||||
|
// TODO: Navigate directly to session log page when sessions route is implemented
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Open full session from drawer action button. */
|
||||||
|
onOpenSession(sessionKey: string): void {
|
||||||
|
console.log('[Hub] Open full session:', sessionKey);
|
||||||
|
// TODO: Navigate to full session view
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pin agent to dashboard from drawer action button. */
|
||||||
|
onPinToDashboard(sessionKey: string): void {
|
||||||
|
console.log('[Hub] Pin to dashboard:', sessionKey);
|
||||||
|
// TODO: Implement pin-to-dashboard
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drawer closed. */
|
||||||
|
onDrawerClose(): void {
|
||||||
|
// No-op for now — drawer is self-managing
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,18 @@
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
// OpenClaw Control Center — M3 Tactical Dark Theme
|
// OpenClaw Control Center — M3 Tactical Dark Theme
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Material Design 3 theming with custom dark palette per design spec.
|
// Main global stylesheet. Imports the design system token modules and
|
||||||
// Section 5.1: Color Palette, Section 5.2: Typography
|
// applies the M3 dark theme. All tokens are defined once in
|
||||||
|
// styles/_tokens.scss — SCSS variables and mixins
|
||||||
|
// styles/_css-properties.scss — CSS custom property output
|
||||||
|
// styles/_utilities.scss — utility mixins for components
|
||||||
|
//
|
||||||
|
// Components should @use these modules rather than hardcoding values.
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
|
@use 'styles/tokens' as tokens;
|
||||||
|
@use 'styles/css-properties' as css-props;
|
||||||
|
@use 'styles/utilities' as utils;
|
||||||
@use '@angular/material' as mat;
|
@use '@angular/material' as mat;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -21,11 +29,11 @@ $dark-theme: mat.define-theme((
|
|||||||
tertiary: mat.$violet-palette,
|
tertiary: mat.$violet-palette,
|
||||||
),
|
),
|
||||||
typography: (
|
typography: (
|
||||||
brand-family: 'Inter, Roboto, sans-serif',
|
brand-family: tokens.$font-family-brand,
|
||||||
plain-family: 'Inter, Roboto, sans-serif',
|
plain-family: tokens.$font-family-body,
|
||||||
bold-weight: 600,
|
bold-weight: tokens.$font-weight-bold,
|
||||||
medium-weight: 500,
|
medium-weight: tokens.$font-weight-medium,
|
||||||
regular-weight: 400,
|
regular-weight: tokens.$font-weight-regular,
|
||||||
),
|
),
|
||||||
density: (
|
density: (
|
||||||
scale: 0,
|
scale: 0,
|
||||||
@@ -42,7 +50,10 @@ html {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Custom CSS Custom Properties — Status Colors
|
// Emit Design System CSS Custom Properties
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
@include css-props.emit-custom-properties;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Per spec Section 5.1 "Status Colors (Semantic — outside M3 tonal system)"
|
// Per spec Section 5.1 "Status Colors (Semantic — outside M3 tonal system)"
|
||||||
// These are NOT part of the M3 tonal palette; they are semantic overrides.
|
// These are NOT part of the M3 tonal palette; they are semantic overrides.
|
||||||
@@ -88,22 +99,29 @@ html {
|
|||||||
--cc-nav-rail-collapsed-width: 72px;
|
--cc-nav-rail-collapsed-width: 72px;
|
||||||
--cc-nav-rail-expanded-width: 256px;
|
--cc-nav-rail-expanded-width: 256px;
|
||||||
--cc-header-height: 64px;
|
--cc-header-height: 64px;
|
||||||
|
--cc-header-height-compact: 56px;
|
||||||
--cc-bottom-nav-height: 80px;
|
--cc-bottom-nav-height: 80px;
|
||||||
--cc-card-border-radius: 16px;
|
--cc-card-border-radius: 16px;
|
||||||
--cc-card-min-width: 320px;
|
--cc-card-min-width: 280px;
|
||||||
--cc-card-gap: 16px;
|
--cc-card-gap: 16px;
|
||||||
--cc-card-padding: 20px;
|
--cc-card-padding: 20px;
|
||||||
--cc-section-padding: 24px;
|
--cc-section-padding: 24px;
|
||||||
|
--cc-section-padding-compact: 16px;
|
||||||
--cc-spacing-unit: 8px;
|
--cc-spacing-unit: 8px;
|
||||||
|
|
||||||
|
// --- Responsive breakpoints (CUB-27) ---
|
||||||
|
--cc-breakpoint-compact: 599px; // 0-599px: phone / compact
|
||||||
|
--cc-breakpoint-medium: 600px; // 600-1023px: tablet / medium
|
||||||
|
--cc-breakpoint-expanded: 1024px; // ≥1024px: desktop/kiosk / expanded
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Global Body Styles
|
// Global Body Styles
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
body {
|
body {
|
||||||
background-color: var(--color-surface);
|
background-color: var(--cc-surface-darkest);
|
||||||
color: var(--cc-on-surface);
|
color: var(--cc-on-surface);
|
||||||
font-family: 'Inter', 'Roboto', sans-serif;
|
font-family: var(--cc-font-body);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
@@ -111,37 +129,60 @@ body {
|
|||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// M3 Surface Overrides
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Override M3 surface tokens to match our tactical dark palette
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
:root {
|
|
||||||
// Override M3 system color tokens to match custom palette
|
|
||||||
--mat-sys-surface: var(--cc-surface);
|
|
||||||
--mat-sys-surface-container: var(--cc-surface-container);
|
|
||||||
--mat-sys-surface-container-high: var(--cc-surface-container-high);
|
|
||||||
--mat-sys-on-surface: var(--cc-on-surface);
|
|
||||||
--mat-sys-on-surface-variant: var(--cc-on-surface-variant);
|
|
||||||
--mat-sys-outline: var(--cc-outline);
|
|
||||||
--mat-sys-background: var(--cc-background);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Typography Helpers
|
// Typography Helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
.text-mono {
|
.text-mono {
|
||||||
font-family: var(--cc-font-mono);
|
font-family: var(--cc-font-mono);
|
||||||
font-size: 13px;
|
font-size: tokens.$font-size-body-medium;
|
||||||
font-weight: 400;
|
font-weight: tokens.$font-weight-regular;
|
||||||
letter-spacing: 0.02em;
|
letter-spacing: tokens.$letter-spacing-mono;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-display-large {
|
||||||
|
font-size: tokens.$font-size-display-large;
|
||||||
|
font-weight: tokens.$font-weight-heavy;
|
||||||
|
line-height: tokens.$line-height-tight;
|
||||||
|
letter-spacing: tokens.$letter-spacing-tight;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-headline-medium {
|
||||||
|
font-size: tokens.$font-size-headline-medium;
|
||||||
|
font-weight: tokens.$font-weight-bold;
|
||||||
|
line-height: tokens.$line-height-tight;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-title-large {
|
||||||
|
font-size: tokens.$font-size-title-large;
|
||||||
|
font-weight: tokens.$font-weight-bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-title-medium {
|
||||||
|
font-size: tokens.$font-size-title-medium;
|
||||||
|
font-weight: tokens.$font-weight-medium;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-body-large {
|
||||||
|
font-size: tokens.$font-size-body-large;
|
||||||
|
font-weight: tokens.$font-weight-regular;
|
||||||
|
line-height: tokens.$line-height-normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-body-medium {
|
||||||
|
font-size: tokens.$font-size-body-medium;
|
||||||
|
font-weight: tokens.$font-weight-regular;
|
||||||
|
line-height: tokens.$line-height-normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-label-medium {
|
||||||
|
font-size: tokens.$font-size-label-medium;
|
||||||
|
font-weight: tokens.$font-weight-medium;
|
||||||
|
letter-spacing: tokens.$letter-spacing-wide;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Status Dot Pulse Animations
|
// Status Dot Pulse Animations
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Per spec Section 7.5: Animation Specs
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
@keyframes pulse-active {
|
@keyframes pulse-active {
|
||||||
0%, 100% { opacity: 1; transform: scale(1); }
|
0%, 100% { opacity: 1; transform: scale(1); }
|
||||||
50% { opacity: 0.7; transform: scale(1.15); }
|
50% { opacity: 0.7; transform: scale(1.15); }
|
||||||
@@ -158,35 +199,35 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Utility Classes
|
// Status Dot Utility Classes
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
.status-dot {
|
.status-dot {
|
||||||
width: 10px;
|
width: tokens.$status-dot-size;
|
||||||
height: 10px;
|
height: tokens.$status-dot-size;
|
||||||
border-radius: 50%;
|
border-radius: tokens.$radius-full;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
|
||||||
&--active {
|
&--active {
|
||||||
background-color: var(--status-active);
|
background-color: var(--cc-status-active);
|
||||||
animation: pulse-active 2s ease-in-out infinite;
|
animation: pulse-active tokens.$duration-standard tokens.$easing-standard infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
&--idle {
|
&--idle {
|
||||||
background-color: var(--status-idle);
|
background-color: var(--cc-status-idle);
|
||||||
}
|
}
|
||||||
|
|
||||||
&--thinking {
|
&--thinking {
|
||||||
background-color: var(--status-thinking);
|
background-color: var(--cc-status-thinking);
|
||||||
animation: pulse-thinking 3s ease-in-out infinite;
|
animation: pulse-thinking 3s tokens.$easing-standard infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
&--error {
|
&--error {
|
||||||
background-color: var(--status-error);
|
background-color: var(--cc-status-error);
|
||||||
animation: pulse-error 0.8s ease-in-out infinite;
|
animation: pulse-error tokens.$duration-fast tokens.$easing-standard infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
&--offline {
|
&--offline {
|
||||||
background-color: var(--status-offline);
|
background-color: var(--cc-status-offline);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,6 +242,27 @@ body {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Screen-reader-only utility
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
.sr-only {
|
||||||
|
@include tokens.sr-only;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Truncate utility
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
.truncate {
|
||||||
|
@include tokens.truncate;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Focus ring utility
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
.focus-ring {
|
||||||
|
@include tokens.focus-ring;
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Scrollbar Styling (Tactical Dark)
|
// Scrollbar Styling (Tactical Dark)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -210,11 +272,11 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-track {
|
::-webkit-scrollbar-track {
|
||||||
background: var(--cc-surface);
|
background: var(--cc-surface-dark);
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb {
|
||||||
background: var(--cc-outline);
|
background: var(--cc-surface-lighter);
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
// ============================================================================
|
||||||
|
// OpenClaw Control Center — CSS Custom Property Output
|
||||||
|
// ============================================================================
|
||||||
|
// This module emits ALL design tokens as CSS custom properties on :root.
|
||||||
|
// Import this in styles.scss to make tokens available to all components.
|
||||||
|
//
|
||||||
|
// Tokens are namespaced with --cc- (Control Center) to avoid collisions
|
||||||
|
// with Angular Material's --mat- variables.
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
@use 'tokens' as *;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Emit all CSS custom properties
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
@mixin emit-custom-properties {
|
||||||
|
:root {
|
||||||
|
// --- Color tokens ---
|
||||||
|
--cc-color-primary: #{$color-primary-500};
|
||||||
|
--cc-color-secondary: #{$color-secondary-400};
|
||||||
|
--cc-color-accent: #{$color-accent-400};
|
||||||
|
--cc-color-danger: #{$color-danger-400};
|
||||||
|
|
||||||
|
// --- Surface tokens ---
|
||||||
|
--cc-surface-darkest: #{$color-surface-darkest};
|
||||||
|
--cc-surface-dark: #{$color-surface-dark};
|
||||||
|
--cc-surface-medium: #{$color-surface-medium};
|
||||||
|
--cc-surface-light: #{$color-surface-light};
|
||||||
|
--cc-surface-lighter: #{$color-surface-lighter};
|
||||||
|
|
||||||
|
// --- On-surface tokens ---
|
||||||
|
--cc-on-surface: #{$color-on-surface};
|
||||||
|
--cc-on-surface-variant: #{$color-on-surface-variant};
|
||||||
|
--cc-on-surface-muted: #{$color-on-surface-muted};
|
||||||
|
|
||||||
|
// --- Status tokens ---
|
||||||
|
--cc-status-active: #{$status-active};
|
||||||
|
--cc-status-idle: #{$status-idle};
|
||||||
|
--cc-status-thinking: #{$status-thinking};
|
||||||
|
--cc-status-error: #{$status-error};
|
||||||
|
--cc-status-offline: #{$status-offline};
|
||||||
|
|
||||||
|
--cc-status-active-bg: #{$status-active-bg};
|
||||||
|
--cc-status-idle-bg: #{$status-idle-bg};
|
||||||
|
--cc-status-thinking-bg: #{$status-thinking-bg};
|
||||||
|
--cc-status-error-bg: #{$status-error-bg};
|
||||||
|
--cc-status-offline-bg: #{$status-offline-bg};
|
||||||
|
|
||||||
|
--cc-status-active-border: #{$status-active-border};
|
||||||
|
--cc-status-idle-border: #{$status-idle-border};
|
||||||
|
--cc-status-thinking-border: #{$status-thinking-border};
|
||||||
|
--cc-status-error-border: #{$status-error-border};
|
||||||
|
--cc-status-offline-border: #{$status-offline-border};
|
||||||
|
|
||||||
|
// --- Typography tokens ---
|
||||||
|
--cc-font-brand: #{$font-family-brand};
|
||||||
|
--cc-font-body: #{$font-family-body};
|
||||||
|
--cc-font-mono: #{$font-family-mono};
|
||||||
|
|
||||||
|
// --- Spacing tokens ---
|
||||||
|
--cc-spacing-0: #{$spacing-0};
|
||||||
|
--cc-spacing-1: #{$spacing-1};
|
||||||
|
--cc-spacing-2: #{$spacing-2};
|
||||||
|
--cc-spacing-3: #{$spacing-3};
|
||||||
|
--cc-spacing-4: #{$spacing-4};
|
||||||
|
--cc-spacing-5: #{$spacing-5};
|
||||||
|
--cc-spacing-6: #{$spacing-6};
|
||||||
|
--cc-spacing-7: #{$spacing-7};
|
||||||
|
--cc-spacing-8: #{$spacing-8};
|
||||||
|
--cc-spacing-10: #{$spacing-10};
|
||||||
|
--cc-spacing-12: #{$spacing-12};
|
||||||
|
--cc-spacing-16: #{$spacing-16};
|
||||||
|
|
||||||
|
// --- Layout tokens ---
|
||||||
|
--cc-nav-rail-collapsed: #{$nav-rail-collapsed-width};
|
||||||
|
--cc-nav-rail-expanded: #{$nav-rail-expanded-width};
|
||||||
|
--cc-header-height: #{$header-height};
|
||||||
|
--cc-bottom-nav-height: #{$bottom-nav-height};
|
||||||
|
--cc-card-radius: #{$card-border-radius};
|
||||||
|
--cc-card-min-width: #{$card-min-width};
|
||||||
|
|
||||||
|
// --- Radius tokens ---
|
||||||
|
--cc-radius-none: #{$radius-none};
|
||||||
|
--cc-radius-xs: #{$radius-xs};
|
||||||
|
--cc-radius-sm: #{$radius-sm};
|
||||||
|
--cc-radius-md: #{$radius-md};
|
||||||
|
--cc-radius-lg: #{$radius-lg};
|
||||||
|
--cc-radius-xl: #{$radius-xl};
|
||||||
|
--cc-radius-full: #{$radius-full};
|
||||||
|
|
||||||
|
// --- Shadow tokens ---
|
||||||
|
--cc-shadow-0: #{$shadow-level-0};
|
||||||
|
--cc-shadow-1: #{$shadow-level-1};
|
||||||
|
--cc-shadow-2: #{$shadow-level-2};
|
||||||
|
--cc-shadow-3: #{$shadow-level-3};
|
||||||
|
--cc-shadow-4: #{$shadow-level-4};
|
||||||
|
|
||||||
|
// --- Motion tokens ---
|
||||||
|
--cc-duration-fast: #{$duration-fast};
|
||||||
|
--cc-duration-short: #{$duration-short};
|
||||||
|
--cc-duration-medium: #{$duration-medium};
|
||||||
|
--cc-duration-standard: #{$duration-standard};
|
||||||
|
--cc-duration-long: #{$duration-long};
|
||||||
|
|
||||||
|
--cc-easing-standard: #{$easing-standard};
|
||||||
|
--cc-easing-decelerate: #{$easing-decelerate};
|
||||||
|
--cc-easing-accelerate: #{$easing-accelerate};
|
||||||
|
|
||||||
|
// --- Accessibility tokens ---
|
||||||
|
--cc-focus-width: #{$focus-ring-width};
|
||||||
|
--cc-focus-offset: #{$focus-ring-offset};
|
||||||
|
--cc-focus-color: #{$focus-ring-color};
|
||||||
|
--cc-touch-min: #{$min-touch-target};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,474 @@
|
|||||||
|
// ============================================================================
|
||||||
|
// OpenClaw Control Center — M3 Design Tokens
|
||||||
|
// ============================================================================
|
||||||
|
// Single source of truth for all design tokens.
|
||||||
|
// Components should @use this module and reference tokens via variables or
|
||||||
|
// the theme() mixin rather than hardcoding values.
|
||||||
|
//
|
||||||
|
// Token structure:
|
||||||
|
// 1. Color tokens — palette, semantic, status, surface
|
||||||
|
// 2. Typography — families, sizes, weights, line-heights
|
||||||
|
// 3. Spacing — 4px base grid, named steps
|
||||||
|
// 4. Layout — dimensions, breakpoints, radii, shadows
|
||||||
|
// 5. Motion — durations, easing curves
|
||||||
|
// 6. Accessibility — focus, reduced-motion
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
@use 'sass:map';
|
||||||
|
@use 'sass:meta';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 1. COLOR TOKENS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 1a. Primary Palette (M3 cyan-based)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
$color-primary-50: #ecfeff;
|
||||||
|
$color-primary-100: #cffafe;
|
||||||
|
$color-primary-200: #a5f3fc;
|
||||||
|
$color-primary-300: #67e8f9;
|
||||||
|
$color-primary-400: #22d3ee;
|
||||||
|
$color-primary-500: #38bdf8; // Brand primary
|
||||||
|
$color-primary-600: #0ea5e9;
|
||||||
|
$color-primary-700: #0284c7;
|
||||||
|
$color-primary-800: #0369a1;
|
||||||
|
$color-primary-900: #075985;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 1b. Secondary Palette (M3 teal-based)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
$color-secondary-50: #f0fdfa;
|
||||||
|
$color-secondary-100: #ccfbf1;
|
||||||
|
$color-secondary-200: #99f6e4;
|
||||||
|
$color-secondary-300: #5eead4;
|
||||||
|
$color-secondary-400: #2dd4bf; // Brand secondary
|
||||||
|
$color-secondary-500: #14b8a6;
|
||||||
|
$color-secondary-600: #0d9488;
|
||||||
|
$color-secondary-700: #0f766e;
|
||||||
|
$color-secondary-800: #115e59;
|
||||||
|
$color-secondary-900: #134e4a;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 1c. Accent / Tertiary Palette (M3 violet-based)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
$color-accent-50: #f5f3ff;
|
||||||
|
$color-accent-100: #ede9fe;
|
||||||
|
$color-accent-200: #ddd6fe;
|
||||||
|
$color-accent-300: #c4b5fd;
|
||||||
|
$color-accent-400: #a78bfa; // Brand accent
|
||||||
|
$color-accent-500: #8b5cf6;
|
||||||
|
$color-accent-600: #7c3aed;
|
||||||
|
$color-accent-700: #6d28d9;
|
||||||
|
$color-accent-800: #5b21b6;
|
||||||
|
$color-accent-900: #4c1d95;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 1d. Danger / Error Palette
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
$color-danger-50: #fef2f2;
|
||||||
|
$color-danger-100: #fee2e2;
|
||||||
|
$color-danger-200: #fecaca;
|
||||||
|
$color-danger-300: #fca5a5;
|
||||||
|
$color-danger-400: #f87171; // Brand danger
|
||||||
|
$color-danger-500: #ef4444;
|
||||||
|
$color-danger-600: #dc2626;
|
||||||
|
$color-danger-700: #b91c1c;
|
||||||
|
$color-danger-800: #991b1b;
|
||||||
|
$color-danger-900: #7f1d1d;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 1e. Semantic Surface Tokens (Tactical Dark)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
$color-surface-darkest: #0D0F12; // Page background
|
||||||
|
$color-surface-dark: #13161A; // Card / container surface
|
||||||
|
$color-surface-medium: #1C2027; // Container-elevated
|
||||||
|
$color-surface-light: #252B33; // Container-high / hover
|
||||||
|
$color-surface-lighter: #2D3748; // Border / divider zone
|
||||||
|
|
||||||
|
$color-on-surface: #E2E8F0; // Primary text on dark surfaces
|
||||||
|
$color-on-surface-variant: #8A9BB0; // Secondary / muted text
|
||||||
|
$color-on-surface-muted: #64748B; // Disabled / hint text
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 1f. Status Colors (Semantic — outside M3 tonal system)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
$status-active: #38bdf8;
|
||||||
|
$status-idle: #2dd4bf;
|
||||||
|
$status-thinking: #a78bfa;
|
||||||
|
$status-error: #f87171;
|
||||||
|
$status-offline: #64748b;
|
||||||
|
|
||||||
|
// Status background tints (12% opacity for badges, pills, backgrounds)
|
||||||
|
$status-active-bg: rgba(56, 189, 248, 0.12);
|
||||||
|
$status-idle-bg: rgba(45, 212, 191, 0.12);
|
||||||
|
$status-thinking-bg: rgba(167, 139, 250, 0.12);
|
||||||
|
$status-error-bg: rgba(248, 113, 113, 0.12);
|
||||||
|
$status-offline-bg: rgba(100, 116, 139, 0.12);
|
||||||
|
|
||||||
|
// Status border colors (40% opacity)
|
||||||
|
$status-active-border: rgba(56, 189, 248, 0.40);
|
||||||
|
$status-idle-border: rgba(45, 212, 191, 0.40);
|
||||||
|
$status-thinking-border: rgba(167, 139, 250, 0.40);
|
||||||
|
$status-error-border: rgba(248, 113, 113, 0.40);
|
||||||
|
$status-offline-border: rgba(100, 116, 139, 0.40);
|
||||||
|
|
||||||
|
// Map for iteration
|
||||||
|
$status-colors: (
|
||||||
|
'active': ('fg': $status-active, 'bg': $status-active-bg, 'border': $status-active-border),
|
||||||
|
'idle': ('fg': $status-idle, 'bg': $status-idle-bg, 'border': $status-idle-border),
|
||||||
|
'thinking': ('fg': $status-thinking, 'bg': $status-thinking-bg, 'border': $status-thinking-border),
|
||||||
|
'error': ('fg': $status-error, 'bg': $status-error-bg, 'border': $status-error-border),
|
||||||
|
'offline': ('fg': $status-offline, 'bg': $status-offline-bg, 'border': $status-offline-border),
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 1g. Full color map for programmatic access
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
$colors: (
|
||||||
|
'primary': $color-primary-500,
|
||||||
|
'secondary': $color-secondary-400,
|
||||||
|
'accent': $color-accent-400,
|
||||||
|
'danger': $color-danger-400,
|
||||||
|
'surface': $color-surface-dark,
|
||||||
|
'surface-light': $color-surface-light,
|
||||||
|
'on-surface': $color-on-surface,
|
||||||
|
'on-surface-variant': $color-on-surface-variant,
|
||||||
|
'border': $color-surface-lighter,
|
||||||
|
);
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 2. TYPOGRAPHY TOKENS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 2a. Font Families
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
$font-family-brand: 'Inter, Roboto, sans-serif';
|
||||||
|
$font-family-body: 'Inter, Roboto, sans-serif';
|
||||||
|
$font-family-mono: 'Roboto Mono, Cascadia Code, Fira Code, monospace';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 2b. Font Sizes (M3 type scale)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
$font-size-display-large: 57px;
|
||||||
|
$font-size-display-medium: 45px;
|
||||||
|
$font-size-display-small: 36px;
|
||||||
|
$font-size-headline-large: 32px;
|
||||||
|
$font-size-headline-medium: 28px;
|
||||||
|
$font-size-headline-small: 24px;
|
||||||
|
$font-size-title-large: 22px;
|
||||||
|
$font-size-title-medium: 16px;
|
||||||
|
$font-size-title-small: 14px;
|
||||||
|
$font-size-body-large: 16px;
|
||||||
|
$font-size-body-medium: 14px;
|
||||||
|
$font-size-body-small: 12px;
|
||||||
|
$font-size-label-large: 14px;
|
||||||
|
$font-size-label-medium: 12px;
|
||||||
|
$font-size-label-small: 11px;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 2c. Font Weights
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
$font-weight-regular: 400;
|
||||||
|
$font-weight-medium: 500;
|
||||||
|
$font-weight-bold: 600;
|
||||||
|
$font-weight-heavy: 700;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 2d. Line Heights
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
$line-height-tight: 1.2;
|
||||||
|
$line-height-normal: 1.5;
|
||||||
|
$line-height-relaxed: 1.6;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 2e. Letter Spacing
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
$letter-spacing-tight: -0.01em;
|
||||||
|
$letter-spacing-normal: 0em;
|
||||||
|
$letter-spacing-wide: 0.02em;
|
||||||
|
$letter-spacing-mono: 0.05em;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 2f. Typography map
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
$typography: (
|
||||||
|
'font-family-brand': $font-family-brand,
|
||||||
|
'font-family-body': $font-family-body,
|
||||||
|
'font-family-mono': $font-family-mono,
|
||||||
|
'size-display-large': $font-size-display-large,
|
||||||
|
'size-headline-medium': $font-size-headline-medium,
|
||||||
|
'size-title-large': $font-size-title-large,
|
||||||
|
'size-title-medium': $font-size-title-medium,
|
||||||
|
'size-body-large': $font-size-body-large,
|
||||||
|
'size-body-medium': $font-size-body-medium,
|
||||||
|
'size-body-small': $font-size-body-small,
|
||||||
|
'size-label-large': $font-size-label-large,
|
||||||
|
'size-label-medium': $font-size-label-medium,
|
||||||
|
'weight-regular': $font-weight-regular,
|
||||||
|
'weight-medium': $font-weight-medium,
|
||||||
|
'weight-bold': $font-weight-bold,
|
||||||
|
);
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 3. SPACING TOKENS (4px grid)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
$spacing-0: 0px;
|
||||||
|
$spacing-1: 4px;
|
||||||
|
$spacing-2: 8px;
|
||||||
|
$spacing-3: 12px;
|
||||||
|
$spacing-4: 16px;
|
||||||
|
$spacing-5: 20px;
|
||||||
|
$spacing-6: 24px;
|
||||||
|
$spacing-7: 28px;
|
||||||
|
$spacing-8: 32px;
|
||||||
|
$spacing-9: 36px;
|
||||||
|
$spacing-10: 40px;
|
||||||
|
$spacing-12: 48px;
|
||||||
|
$spacing-14: 56px;
|
||||||
|
$spacing-16: 64px;
|
||||||
|
$spacing-20: 80px;
|
||||||
|
|
||||||
|
// Named semantic spacing
|
||||||
|
$spacing-unit: $spacing-2; // 8px — base grid unit
|
||||||
|
$spacing-card-gap: $spacing-4; // 16px
|
||||||
|
$spacing-card-pad: $spacing-5; // 20px
|
||||||
|
$spacing-section: $spacing-6; // 24px
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 4. LAYOUT TOKENS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 4a. Dimensions
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
$nav-rail-collapsed-width: 72px;
|
||||||
|
$nav-rail-expanded-width: 256px;
|
||||||
|
$header-height: 64px;
|
||||||
|
$bottom-nav-height: 80px;
|
||||||
|
$card-border-radius: 16px;
|
||||||
|
$card-min-width: 320px;
|
||||||
|
$badge-height: 24px;
|
||||||
|
$badge-border-radius: 12px;
|
||||||
|
$status-dot-size: 10px;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 4b. Breakpoints (M3 canonical)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
$breakpoint-compact: 599px; // Mobile phone
|
||||||
|
$breakpoint-medium: 767px; // Tablet portrait
|
||||||
|
$breakpoint-expanded: 1023px; // Tablet landscape
|
||||||
|
$breakpoint-large: 1439px; // Desktop
|
||||||
|
|
||||||
|
// Named breakpoint map for @media mixins
|
||||||
|
$breakpoints: (
|
||||||
|
'compact': $breakpoint-compact,
|
||||||
|
'medium': $breakpoint-medium,
|
||||||
|
'expanded': $breakpoint-expanded,
|
||||||
|
'large': $breakpoint-large,
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 4c. Border Radius
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
$radius-none: 0px;
|
||||||
|
$radius-xs: 4px;
|
||||||
|
$radius-sm: 8px;
|
||||||
|
$radius-md: 12px;
|
||||||
|
$radius-lg: 16px;
|
||||||
|
$radius-xl: 24px;
|
||||||
|
$radius-full: 9999px;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 4d. Shadows (M3 elevation)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
$shadow-level-0: none;
|
||||||
|
$shadow-level-1: 0 1px 3px 0 rgba(0, 0, 0, 0.3), 0 1px 2px -1px rgba(0, 0, 0, 0.3);
|
||||||
|
$shadow-level-2: 0 2px 6px 0 rgba(0, 0, 0, 0.3), 0 2px 4px -2px rgba(0, 0, 0, 0.3);
|
||||||
|
$shadow-level-3: 0 4px 12px 0 rgba(0, 0, 0, 0.3), 0 4px 8px -4px rgba(0, 0, 0, 0.3);
|
||||||
|
$shadow-level-4: 0 8px 24px 0 rgba(0, 0, 0, 0.3), 0 8px 16px -8px rgba(0, 0, 0, 0.3);
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 5. MOTION TOKENS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
$duration-instant: 0ms; // No animation (reduced motion fallback)
|
||||||
|
$duration-fast: 100ms;
|
||||||
|
$duration-short: 150ms;
|
||||||
|
$duration-medium: 200ms;
|
||||||
|
$duration-standard: 300ms;
|
||||||
|
$duration-long: 500ms;
|
||||||
|
|
||||||
|
$easing-standard: cubic-bezier(0.4, 0, 0.2, 1); // M3 standard
|
||||||
|
$easing-decelerate: cubic-bezier(0, 0, 0.2, 1); // M3 decelerate (entering)
|
||||||
|
$easing-accelerate: cubic-bezier(0.4, 0, 1, 1); // M3 accelerate (exiting)
|
||||||
|
$easing-sharp: cubic-bezier(0.4, 0, 0.6, 1); // M3 sharp
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 6. ACCESSIBILITY TOKENS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
$focus-ring-width: 2px;
|
||||||
|
$focus-ring-offset: 2px;
|
||||||
|
$focus-ring-color: $status-active;
|
||||||
|
$focus-ring-style: solid;
|
||||||
|
|
||||||
|
$min-touch-target: 48px;
|
||||||
|
$min-body-font: 16px;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// MIXINS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Responsive breakpoint mixin
|
||||||
|
// Usage: @include tokens.respond-to('expanded') { ... }
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
@mixin respond-to($breakpoint) {
|
||||||
|
$value: map.get($breakpoints, $breakpoint);
|
||||||
|
@if $value {
|
||||||
|
@media (min-width: $value + 1) {
|
||||||
|
@content;
|
||||||
|
}
|
||||||
|
} @else {
|
||||||
|
@error "Unknown breakpoint: `#{$breakpoint}`. Valid: compact, medium, expanded, large";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Below-breakpoint mixin (max-width)
|
||||||
|
// Usage: @include tokens.below('expanded') { ... }
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
@mixin below($breakpoint) {
|
||||||
|
$value: map.get($breakpoints, $breakpoint);
|
||||||
|
@if $value {
|
||||||
|
@media (max-width: $value) {
|
||||||
|
@content;
|
||||||
|
}
|
||||||
|
} @else {
|
||||||
|
@error "Unknown breakpoint: `#{$breakpoint}`. Valid: compact, medium, expanded, large";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Focus ring mixin
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
@mixin focus-ring($color: $focus-ring-color) {
|
||||||
|
&:focus-visible {
|
||||||
|
outline: $focus-ring-width $focus-ring-style $color;
|
||||||
|
outline-offset: $focus-ring-offset;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Card surface mixin
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
@mixin card-surface {
|
||||||
|
background-color: $color-surface-medium;
|
||||||
|
border-radius: $card-border-radius;
|
||||||
|
border: 1px solid $color-surface-lighter;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mono text mixin
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
@mixin mono-text($size: $font-size-body-medium) {
|
||||||
|
font-family: $font-family-mono;
|
||||||
|
font-size: $size;
|
||||||
|
letter-spacing: $letter-spacing-mono;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Status dot mixin
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
@mixin status-dot($status) {
|
||||||
|
$colors: map.get($status-colors, $status);
|
||||||
|
@if not $colors {
|
||||||
|
@error "Unknown status: `#{$status}`. Valid: active, idle, thinking, error, offline";
|
||||||
|
}
|
||||||
|
$fg: map.get($colors, 'fg');
|
||||||
|
|
||||||
|
width: $status-dot-size;
|
||||||
|
height: $status-dot-size;
|
||||||
|
border-radius: $radius-full;
|
||||||
|
background-color: $fg;
|
||||||
|
|
||||||
|
@if $status == 'active' {
|
||||||
|
animation: pulse-active $duration-standard $easing-standard infinite;
|
||||||
|
} @else if $status == 'thinking' {
|
||||||
|
animation: pulse-thinking 3s $easing-standard infinite;
|
||||||
|
} @else if $status == 'error' {
|
||||||
|
animation: pulse-error $duration-fast $easing-standard infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Truncate text mixin (single line)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
@mixin truncate {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Screen-reader-only mixin
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
@mixin sr-only {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Touch target mixin — ensures minimum 48px touch area
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
@mixin touch-target($min-size: $min-touch-target) {
|
||||||
|
min-width: $min-size;
|
||||||
|
min-height: $min-size;
|
||||||
|
}
|
||||||
|
// ============================================================================
|
||||||
|
// CUB-20 / CUB-27 CONVENIENCE ALIASES
|
||||||
|
// ============================================================================
|
||||||
|
// Short aliases used by agent-card, hub-page, and other components.
|
||||||
|
// These map to the canonical M3 tokens above.
|
||||||
|
|
||||||
|
$cc-background: $color-surface-darkest;
|
||||||
|
$cc-surface: $color-surface-dark;
|
||||||
|
$cc-surface-container: $color-surface-medium;
|
||||||
|
$cc-surface-container-high: $color-surface-light;
|
||||||
|
$cc-on-surface: $color-on-surface;
|
||||||
|
$cc-on-surface-variant: $color-on-surface-variant;
|
||||||
|
$cc-outline: $color-surface-lighter;
|
||||||
|
|
||||||
|
$cc-font-mono: $font-family-mono;
|
||||||
|
$cc-font-family: $font-family-brand;
|
||||||
|
|
||||||
|
$cc-nav-rail-collapsed-width: $nav-rail-collapsed-width;
|
||||||
|
$cc-nav-rail-expanded-width: $nav-rail-expanded-width;
|
||||||
|
$cc-header-height: $header-height;
|
||||||
|
$cc-bottom-nav-height: $bottom-nav-height;
|
||||||
|
$cc-card-border-radius: $card-border-radius;
|
||||||
|
$cc-card-min-width: $card-min-width;
|
||||||
|
$cc-card-gap: $spacing-card-gap;
|
||||||
|
$cc-card-padding: $spacing-card-pad;
|
||||||
|
$cc-section-padding: $spacing-section;
|
||||||
|
$cc-spacing-unit: $spacing-unit;
|
||||||
|
|
||||||
|
$cc-breakpoint-mobile: $breakpoint-compact;
|
||||||
|
$cc-breakpoint-tablet: $breakpoint-medium;
|
||||||
|
$cc-breakpoint-desktop: $breakpoint-expanded;
|
||||||
|
|
||||||
|
$cc-surface-darkest: $color-surface-darkest;
|
||||||
|
$cc-surface-dark: $color-surface-dark;
|
||||||
|
$cc-surface-medium: $color-surface-medium;
|
||||||
|
$cc-surface-light: $color-surface-light;
|
||||||
|
$cc-surface-lighter: $color-surface-lighter;
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
// ============================================================================
|
||||||
|
// OpenClaw Control Center — Utility Mixins
|
||||||
|
// ============================================================================
|
||||||
|
// Reusable patterns that enforce design-system consistency.
|
||||||
|
// Components should @use this module and include mixins rather than
|
||||||
|
// writing repetitive CSS blocks.
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
@use 'tokens' as *;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Elevation / Surface Card
|
||||||
|
// Applies consistent card styling using design tokens.
|
||||||
|
// Usage: @include utils.card-surface();
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
@mixin card-surface($elevation: 1) {
|
||||||
|
background-color: var(--cc-surface-medium);
|
||||||
|
border-radius: var(--cc-card-radius);
|
||||||
|
border: 1px solid var(--cc-surface-lighter);
|
||||||
|
box-shadow: var(--cc-shadow-#{$elevation});
|
||||||
|
transition: box-shadow var(--cc-duration-short) var(--cc-easing-standard);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Elevated card on hover
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
@mixin card-hover($elevation: 2) {
|
||||||
|
&:hover {
|
||||||
|
box-shadow: var(--cc-shadow-#{$elevation});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Status-aware left border
|
||||||
|
// Applies colored left border based on agent status.
|
||||||
|
// Usage: @include utils.status-border('active');
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
@mixin status-border($status) {
|
||||||
|
$status-map: (
|
||||||
|
'active': var(--cc-status-active),
|
||||||
|
'idle': var(--cc-status-idle),
|
||||||
|
'thinking': var(--cc-status-thinking),
|
||||||
|
'error': var(--cc-status-error),
|
||||||
|
'offline': var(--cc-status-offline),
|
||||||
|
);
|
||||||
|
$color: map-get($status-map, $status);
|
||||||
|
@if not $color {
|
||||||
|
$color: var(--cc-status-offline);
|
||||||
|
}
|
||||||
|
border-left: 4px solid $color;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Status badge / pill
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
@mixin status-badge($status) {
|
||||||
|
$fg-map: (
|
||||||
|
'active': var(--cc-status-active),
|
||||||
|
'idle': var(--cc-status-idle),
|
||||||
|
'thinking': var(--cc-status-thinking),
|
||||||
|
'error': var(--cc-status-error),
|
||||||
|
'offline': var(--cc-status-offline),
|
||||||
|
);
|
||||||
|
$bg-map: (
|
||||||
|
'active': var(--cc-status-active-bg),
|
||||||
|
'idle': var(--cc-status-idle-bg),
|
||||||
|
'thinking': var(--cc-status-thinking-bg),
|
||||||
|
'error': var(--cc-status-error-bg),
|
||||||
|
'offline': var(--cc-status-offline-bg),
|
||||||
|
);
|
||||||
|
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
height: $badge-height;
|
||||||
|
padding: 0 8px;
|
||||||
|
border-radius: $badge-border-radius;
|
||||||
|
background-color: map-get($bg-map, $status);
|
||||||
|
color: map-get($fg-map, $status);
|
||||||
|
font-size: $font-size-label-medium;
|
||||||
|
font-weight: $font-weight-medium;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Glass surface (frosted glass effect)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
@mixin glass-surface {
|
||||||
|
background-color: rgba(19, 22, 26, 0.8);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
-webkit-backdrop-filter: blur(8px);
|
||||||
|
border: 1px solid var(--cc-surface-lighter);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Responsive grid
|
||||||
|
// Creates a responsive grid that adapts from 1-col to 2-col.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
@mixin responsive-grid($min-col-width: $card-min-width, $gap: $spacing-card-gap) {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax($min-col-width, 1fr));
|
||||||
|
gap: $gap;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Scroll container
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
@mixin scroll-container($direction: 'y') {
|
||||||
|
@if $direction == 'y' {
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
} @else {
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
|
}
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
|
||||||
|
// Custom scrollbar (tactical dark)
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
&::-webkit-scrollbar-track {
|
||||||
|
background: var(--cc-surface-dark);
|
||||||
|
}
|
||||||
|
&::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--cc-surface-lighter);
|
||||||
|
border-radius: 3px;
|
||||||
|
&:hover {
|
||||||
|
background: var(--cc-on-surface-variant);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Page container
|
||||||
|
// Standard page padding and layout
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
@mixin page-container {
|
||||||
|
padding: $spacing-section;
|
||||||
|
min-height: 400px;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Transition helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
@mixin transition-colors($duration: $duration-short) {
|
||||||
|
transition: color #{$duration} $easing-standard,
|
||||||
|
background-color #{$duration} $easing-standard,
|
||||||
|
border-color #{$duration} $easing-standard;
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin transition-transform($duration: $duration-medium) {
|
||||||
|
transition: transform #{$duration} $easing-standard;
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin transition-opacity($duration: $duration-short) {
|
||||||
|
transition: opacity #{$duration} $easing-standard;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Reduced motion
|
||||||
|
// Wraps content in a reduced-motion media query.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
@mixin reduced-motion {
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
@content;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
|
||||||
|
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
|
||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./out-tsc/app",
|
||||||
|
"types": []
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/**/*.ts"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"src/**/*.spec.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user