Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ff19a1b4d3 | |||
| d9802df2af | |||
| c9b9e68c18 | |||
| 3af62a9cc5 | |||
| 221ae94f4f | |||
| 786bc92768 | |||
| ca3f845c34 | |||
| 617d81e7d4 | |||
| 680ec3b897 |
@@ -1,6 +1,6 @@
|
|||||||
# .gitea/workflows/ci.yaml
|
# .gitea/workflows/ci.yaml
|
||||||
# ============================================
|
# ============================================
|
||||||
# Switchboard Core - CI/CD Pipeline (v0.17.3)
|
# Armature - CI/CD Pipeline (v0.17.3)
|
||||||
# ============================================
|
# ============================================
|
||||||
# Single unified image (Go backend + nginx frontend).
|
# Single unified image (Go backend + nginx frontend).
|
||||||
# v0.1.0: Dropped FE/BE image split per ROADMAP design decision.
|
# v0.1.0: Dropped FE/BE image split per ROADMAP design decision.
|
||||||
@@ -29,22 +29,22 @@
|
|||||||
# Tags (v*) → always full pipeline
|
# Tags (v*) → always full pipeline
|
||||||
#
|
#
|
||||||
# Deployment mapping (single domain, path-based):
|
# Deployment mapping (single domain, path-based):
|
||||||
# PR → FE + BE :dev → switchboard.DOMAIN/dev/ (DB wipe + fresh schema)
|
# PR → FE + BE :dev → armature.DOMAIN/dev/ (DB wipe + fresh schema)
|
||||||
# Push to main → FE + BE :test → switchboard.DOMAIN/test/ (migrate only)
|
# Push to main → FE + BE :test → armature.DOMAIN/test/ (migrate only)
|
||||||
# Tag v* → FE + BE :latest → switchboard.DOMAIN/ (migrate only)
|
# Tag v* → FE + BE :latest → armature.DOMAIN/ (migrate only)
|
||||||
# → Unified :latest → Docker Hub (not deployed)
|
# → Unified :latest → Docker Hub (not deployed)
|
||||||
#
|
#
|
||||||
# Database lifecycle:
|
# Database lifecycle:
|
||||||
# CI: bootstrap (admin creds) → create DB + role + extensions
|
# CI: bootstrap (admin creds) → create DB + role + extensions
|
||||||
# CI: dev wipe (app creds) → drop tables for fresh install test
|
# CI: dev wipe (app creds) → drop tables for fresh install test
|
||||||
# BE: auto-migrate on startup → schema_migrations tracking
|
# BE: auto-migrate on startup → schema_migrations tracking
|
||||||
# BE: bootstrap admin from SWITCHBOARD_ADMIN_* env vars (upsert on every restart)
|
# BE: bootstrap admin from ARMATURE_ADMIN_* env vars (upsert on every restart)
|
||||||
#
|
#
|
||||||
# Shared PG safety:
|
# Shared PG safety:
|
||||||
# - Bootstrap uses IF NOT EXISTS (idempotent)
|
# - Bootstrap uses IF NOT EXISTS (idempotent)
|
||||||
# - Dev wipe REFUSES to run on databases not ending in _dev
|
# - Dev wipe REFUSES to run on databases not ending in _dev
|
||||||
# - Admin creds never reach the backend pods
|
# - Admin creds never reach the backend pods
|
||||||
# - Each env has its own DB name (switchboard_core_{dev,test,})
|
# - Each env has its own DB name (armature_{dev,test,})
|
||||||
#
|
#
|
||||||
# Required Gitea Variables:
|
# Required Gitea Variables:
|
||||||
# REGISTRY, NAMESPACE, DOMAIN, POSTGRES_HOST
|
# REGISTRY, NAMESPACE, DOMAIN, POSTGRES_HOST
|
||||||
@@ -58,7 +58,7 @@
|
|||||||
# Required Gitea Secrets:
|
# Required Gitea Secrets:
|
||||||
# POSTGRES_USER, POSTGRES_PASSWORD
|
# POSTGRES_USER, POSTGRES_PASSWORD
|
||||||
# POSTGRES_ADMIN_USER, POSTGRES_ADMIN_PASSWORD
|
# POSTGRES_ADMIN_USER, POSTGRES_ADMIN_PASSWORD
|
||||||
# SWITCHBOARD_ADMIN_USERNAME, SWITCHBOARD_ADMIN_PASSWORD, SWITCHBOARD_ADMIN_EMAIL
|
# ARMATURE_ADMIN_USERNAME, ARMATURE_ADMIN_PASSWORD, ARMATURE_ADMIN_EMAIL
|
||||||
# ENCRYPTION_KEY — AES-256 key for API key encryption (openssl rand -base64 32)
|
# ENCRYPTION_KEY — AES-256 key for API key encryption (openssl rand -base64 32)
|
||||||
# PROVIDER_KEY — API key for live provider integration tests (optional, tests skip if missing)
|
# PROVIDER_KEY — API key for live provider integration tests (optional, tests skip if missing)
|
||||||
# DOCKERHUB_USERNAME, DOCKERHUB_TOKEN (optional)
|
# DOCKERHUB_USERNAME, DOCKERHUB_TOKEN (optional)
|
||||||
@@ -88,8 +88,8 @@ env:
|
|||||||
CERT_ISSUER: ${{ vars.CERT_ISSUER_PROD || 'letsencrypt-prod' }}
|
CERT_ISSUER: ${{ vars.CERT_ISSUER_PROD || 'letsencrypt-prod' }}
|
||||||
POSTGRES_HOST: ${{ vars.POSTGRES_HOST || 'postgres-primary.postgres.svc.cluster.local' }}
|
POSTGRES_HOST: ${{ vars.POSTGRES_HOST || 'postgres-primary.postgres.svc.cluster.local' }}
|
||||||
POSTGRES_PORT: ${{ vars.POSTGRES_PORT || '5432' }}
|
POSTGRES_PORT: ${{ vars.POSTGRES_PORT || '5432' }}
|
||||||
IMAGE: ${{ vars.REGISTRY }}/switchboard/core
|
IMAGE: ${{ vars.REGISTRY }}/armature
|
||||||
DOCKERHUB_IMAGE: ${{ vars.DOCKERHUB_IMAGE || 'gobha/switchboard-core' }}
|
DOCKERHUB_IMAGE: ${{ vars.DOCKERHUB_IMAGE || 'gobha/armature' }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# ── Stage 0: Detect Changed Paths ──────────────
|
# ── Stage 0: Detect Changed Paths ──────────────
|
||||||
@@ -319,7 +319,7 @@ jobs:
|
|||||||
PGPASSWORD: ${{ secrets.POSTGRES_ADMIN_PASSWORD }}
|
PGPASSWORD: ${{ secrets.POSTGRES_ADMIN_PASSWORD }}
|
||||||
APP_USER: ${{ secrets.POSTGRES_USER }}
|
APP_USER: ${{ secrets.POSTGRES_USER }}
|
||||||
APP_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
|
APP_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
|
||||||
DB_NAME: switchboard_core_ci
|
DB_NAME: armature_ci
|
||||||
run: |
|
run: |
|
||||||
echo "━━━ CI Test Database Setup ━━━"
|
echo "━━━ CI Test Database Setup ━━━"
|
||||||
# Create DB for Go integration tests (admin creds)
|
# Create DB for Go integration tests (admin creds)
|
||||||
@@ -363,7 +363,7 @@ jobs:
|
|||||||
PGUSER: ${{ secrets.POSTGRES_ADMIN_USER }}
|
PGUSER: ${{ secrets.POSTGRES_ADMIN_USER }}
|
||||||
PGPASSWORD: ${{ secrets.POSTGRES_ADMIN_PASSWORD }}
|
PGPASSWORD: ${{ secrets.POSTGRES_ADMIN_PASSWORD }}
|
||||||
run: |
|
run: |
|
||||||
psql -c "DROP DATABASE IF EXISTS switchboard_core_ci;" postgres
|
psql -c "DROP DATABASE IF EXISTS armature_ci;" postgres
|
||||||
echo "✓ Dropped CI test database"
|
echo "✓ Dropped CI test database"
|
||||||
|
|
||||||
# ── Stage 2: Build, Database, Deploy ─────────
|
# ── Stage 2: Build, Database, Deploy ─────────
|
||||||
@@ -395,9 +395,9 @@ jobs:
|
|||||||
- name: Determine environment
|
- name: Determine environment
|
||||||
id: setup
|
id: setup
|
||||||
run: |
|
run: |
|
||||||
# All environments share one host: switchboard.DOMAIN
|
# All environments share one host: armature.DOMAIN
|
||||||
# Environments are separated by path prefix (BASE_PATH)
|
# Environments are separated by path prefix (BASE_PATH)
|
||||||
DEPLOY_HOST="switchboard.${DOMAIN}"
|
DEPLOY_HOST="armature.${DOMAIN}"
|
||||||
echo "DEPLOY_HOST=${DEPLOY_HOST}" >> "$GITHUB_OUTPUT"
|
echo "DEPLOY_HOST=${DEPLOY_HOST}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
if [[ "${{ gitea.event_name }}" == "pull_request" ]]; then
|
if [[ "${{ gitea.event_name }}" == "pull_request" ]]; then
|
||||||
@@ -406,7 +406,7 @@ jobs:
|
|||||||
echo "IMAGE_TAG=dev" >> "$GITHUB_OUTPUT"
|
echo "IMAGE_TAG=dev" >> "$GITHUB_OUTPUT"
|
||||||
echo "BASE_PATH=/dev" >> "$GITHUB_OUTPUT"
|
echo "BASE_PATH=/dev" >> "$GITHUB_OUTPUT"
|
||||||
echo "DEPLOY_SUFFIX=-dev" >> "$GITHUB_OUTPUT"
|
echo "DEPLOY_SUFFIX=-dev" >> "$GITHUB_OUTPUT"
|
||||||
echo "DB_NAME=switchboard_core_dev" >> "$GITHUB_OUTPUT"
|
echo "DB_NAME=armature_dev" >> "$GITHUB_OUTPUT"
|
||||||
echo "DB_WIPE=true" >> "$GITHUB_OUTPUT"
|
echo "DB_WIPE=true" >> "$GITHUB_OUTPUT"
|
||||||
echo "REPLICAS=1" >> "$GITHUB_OUTPUT"
|
echo "REPLICAS=1" >> "$GITHUB_OUTPUT"
|
||||||
echo "MEMORY_REQUEST=256Mi" >> "$GITHUB_OUTPUT"
|
echo "MEMORY_REQUEST=256Mi" >> "$GITHUB_OUTPUT"
|
||||||
@@ -422,7 +422,7 @@ jobs:
|
|||||||
echo "EXTRA_TAG=${VERSION}" >> "$GITHUB_OUTPUT"
|
echo "EXTRA_TAG=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||||
echo "BASE_PATH=" >> "$GITHUB_OUTPUT"
|
echo "BASE_PATH=" >> "$GITHUB_OUTPUT"
|
||||||
echo "DEPLOY_SUFFIX=" >> "$GITHUB_OUTPUT"
|
echo "DEPLOY_SUFFIX=" >> "$GITHUB_OUTPUT"
|
||||||
echo "DB_NAME=switchboard_core" >> "$GITHUB_OUTPUT"
|
echo "DB_NAME=armature" >> "$GITHUB_OUTPUT"
|
||||||
echo "DB_WIPE=false" >> "$GITHUB_OUTPUT"
|
echo "DB_WIPE=false" >> "$GITHUB_OUTPUT"
|
||||||
echo "REPLICAS=2" >> "$GITHUB_OUTPUT"
|
echo "REPLICAS=2" >> "$GITHUB_OUTPUT"
|
||||||
echo "MEMORY_REQUEST=256Mi" >> "$GITHUB_OUTPUT"
|
echo "MEMORY_REQUEST=256Mi" >> "$GITHUB_OUTPUT"
|
||||||
@@ -437,7 +437,7 @@ jobs:
|
|||||||
echo "IMAGE_TAG=test" >> "$GITHUB_OUTPUT"
|
echo "IMAGE_TAG=test" >> "$GITHUB_OUTPUT"
|
||||||
echo "BASE_PATH=/test" >> "$GITHUB_OUTPUT"
|
echo "BASE_PATH=/test" >> "$GITHUB_OUTPUT"
|
||||||
echo "DEPLOY_SUFFIX=-test" >> "$GITHUB_OUTPUT"
|
echo "DEPLOY_SUFFIX=-test" >> "$GITHUB_OUTPUT"
|
||||||
echo "DB_NAME=switchboard_core_test" >> "$GITHUB_OUTPUT"
|
echo "DB_NAME=armature_test" >> "$GITHUB_OUTPUT"
|
||||||
echo "DB_WIPE=false" >> "$GITHUB_OUTPUT"
|
echo "DB_WIPE=false" >> "$GITHUB_OUTPUT"
|
||||||
echo "REPLICAS=1" >> "$GITHUB_OUTPUT"
|
echo "REPLICAS=1" >> "$GITHUB_OUTPUT"
|
||||||
echo "MEMORY_REQUEST=256Mi" >> "$GITHUB_OUTPUT"
|
echo "MEMORY_REQUEST=256Mi" >> "$GITHUB_OUTPUT"
|
||||||
@@ -579,14 +579,17 @@ jobs:
|
|||||||
# ── Push to Docker Hub (release only) ────────
|
# ── Push to Docker Hub (release only) ────────
|
||||||
- name: Push to Docker Hub
|
- name: Push to Docker Hub
|
||||||
if: steps.setup.outputs.is_release == 'true'
|
if: steps.setup.outputs.is_release == 'true'
|
||||||
|
env:
|
||||||
|
DH_USER: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
DH_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
docker tag ${IMAGE}:${{ steps.setup.outputs.IMAGE_TAG }} ${DOCKERHUB_IMAGE}:latest
|
docker tag ${IMAGE}:${{ steps.setup.outputs.IMAGE_TAG }} ${DOCKERHUB_IMAGE}:latest
|
||||||
docker tag ${IMAGE}:${{ steps.setup.outputs.IMAGE_TAG }} ${DOCKERHUB_IMAGE}:${{ steps.setup.outputs.EXTRA_TAG }}
|
docker tag ${IMAGE}:${{ steps.setup.outputs.IMAGE_TAG }} ${DOCKERHUB_IMAGE}:${{ steps.setup.outputs.EXTRA_TAG }}
|
||||||
if [[ -n "${{ secrets.DOCKERHUB_TOKEN }}" ]]; then
|
if [[ -n "${DH_TOKEN}" ]]; then
|
||||||
echo "${{ secrets.DOCKERHUB_TOKEN }}" | \
|
echo "${DH_TOKEN}" | docker login -u "${DH_USER}" --password-stdin
|
||||||
docker login -u "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin
|
|
||||||
docker push ${DOCKERHUB_IMAGE}:latest
|
docker push ${DOCKERHUB_IMAGE}:latest
|
||||||
docker push ${DOCKERHUB_IMAGE}:${{ steps.setup.outputs.EXTRA_TAG }}
|
docker push ${DOCKERHUB_IMAGE}:${{ steps.setup.outputs.EXTRA_TAG }}
|
||||||
|
echo "✓ Pushed to Docker Hub"
|
||||||
else
|
else
|
||||||
echo "⚠ Docker Hub credentials not configured, skipping push"
|
echo "⚠ Docker Hub credentials not configured, skipping push"
|
||||||
fi
|
fi
|
||||||
@@ -600,31 +603,31 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
PG_USER: ${{ secrets.POSTGRES_USER }}
|
PG_USER: ${{ secrets.POSTGRES_USER }}
|
||||||
PG_PASS: ${{ secrets.POSTGRES_PASSWORD }}
|
PG_PASS: ${{ secrets.POSTGRES_PASSWORD }}
|
||||||
SW_ADMIN_USER: ${{ secrets.SWITCHBOARD_ADMIN_USERNAME }}
|
SW_ADMIN_USER: ${{ secrets.ARMATURE_ADMIN_USERNAME }}
|
||||||
SW_ADMIN_PASS: ${{ secrets.SWITCHBOARD_ADMIN_PASSWORD }}
|
SW_ADMIN_PASS: ${{ secrets.ARMATURE_ADMIN_PASSWORD }}
|
||||||
SW_ADMIN_EMAIL: ${{ secrets.SWITCHBOARD_ADMIN_EMAIL }}
|
SW_ADMIN_EMAIL: ${{ secrets.ARMATURE_ADMIN_EMAIL }}
|
||||||
SEED_USERS: ${{ vars.SEED_USERS }}
|
SEED_USERS: ${{ vars.SEED_USERS }}
|
||||||
ENCRYPTION_KEY: ${{ secrets.ENCRYPTION_KEY }}
|
ENCRYPTION_KEY: ${{ secrets.ENCRYPTION_KEY }}
|
||||||
run: |
|
run: |
|
||||||
kubectl create secret generic switchboard-db-credentials \
|
kubectl create secret generic armature-db-credentials \
|
||||||
--namespace=${NAMESPACE} \
|
--namespace=${NAMESPACE} \
|
||||||
--from-literal=POSTGRES_USER="${PG_USER}" \
|
--from-literal=POSTGRES_USER="${PG_USER}" \
|
||||||
--from-literal=POSTGRES_PASSWORD="${PG_PASS}" \
|
--from-literal=POSTGRES_PASSWORD="${PG_PASS}" \
|
||||||
--dry-run=client -o yaml | kubectl apply -f -
|
--dry-run=client -o yaml | kubectl apply -f -
|
||||||
|
|
||||||
kubectl create secret generic switchboard-admin \
|
kubectl create secret generic armature-admin \
|
||||||
--namespace=${NAMESPACE} \
|
--namespace=${NAMESPACE} \
|
||||||
--from-literal=username="${SW_ADMIN_USER}" \
|
--from-literal=username="${SW_ADMIN_USER}" \
|
||||||
--from-literal=password="${SW_ADMIN_PASS}" \
|
--from-literal=password="${SW_ADMIN_PASS}" \
|
||||||
--from-literal=email="${SW_ADMIN_EMAIL}" \
|
--from-literal=email="${SW_ADMIN_EMAIL}" \
|
||||||
--dry-run=client -o yaml | kubectl apply -f -
|
--dry-run=client -o yaml | kubectl apply -f -
|
||||||
|
|
||||||
kubectl create secret generic switchboard-seed-users \
|
kubectl create secret generic armature-seed-users \
|
||||||
--namespace=${NAMESPACE} \
|
--namespace=${NAMESPACE} \
|
||||||
--from-literal=users="${SEED_USERS}" \
|
--from-literal=users="${SEED_USERS}" \
|
||||||
--dry-run=client -o yaml | kubectl apply -f -
|
--dry-run=client -o yaml | kubectl apply -f -
|
||||||
|
|
||||||
kubectl create secret generic switchboard-encryption \
|
kubectl create secret generic armature-encryption \
|
||||||
--namespace=${NAMESPACE} \
|
--namespace=${NAMESPACE} \
|
||||||
--from-literal=ENCRYPTION_KEY="${ENCRYPTION_KEY}" \
|
--from-literal=ENCRYPTION_KEY="${ENCRYPTION_KEY}" \
|
||||||
--dry-run=client -o yaml | kubectl apply -f -
|
--dry-run=client -o yaml | kubectl apply -f -
|
||||||
@@ -639,7 +642,7 @@ jobs:
|
|||||||
S3_REGION: ${{ vars.S3_REGION || 'us-east-1' }}
|
S3_REGION: ${{ vars.S3_REGION || 'us-east-1' }}
|
||||||
S3_PREFIX: ${{ vars.S3_PREFIX }}
|
S3_PREFIX: ${{ vars.S3_PREFIX }}
|
||||||
run: |
|
run: |
|
||||||
kubectl create secret generic switchboard-s3 \
|
kubectl create secret generic armature-s3 \
|
||||||
--namespace=${NAMESPACE} \
|
--namespace=${NAMESPACE} \
|
||||||
--from-literal=S3_ENDPOINT="${S3_ENDPOINT}" \
|
--from-literal=S3_ENDPOINT="${S3_ENDPOINT}" \
|
||||||
--from-literal=S3_BUCKET="${S3_BUCKET}" \
|
--from-literal=S3_BUCKET="${S3_BUCKET}" \
|
||||||
@@ -673,7 +676,7 @@ jobs:
|
|||||||
envsubst < k8s/storage-pvc.yaml > /tmp/storage-pvc.yaml
|
envsubst < k8s/storage-pvc.yaml > /tmp/storage-pvc.yaml
|
||||||
fi
|
fi
|
||||||
|
|
||||||
envsubst < k8s/switchboard.yaml > /tmp/switchboard.yaml
|
envsubst < k8s/armature.yaml > /tmp/armature.yaml
|
||||||
envsubst < k8s/middleware-retry.yaml > /tmp/middleware-retry.yaml
|
envsubst < k8s/middleware-retry.yaml > /tmp/middleware-retry.yaml
|
||||||
envsubst < k8s/ingress.yaml > /tmp/ingress.yaml
|
envsubst < k8s/ingress.yaml > /tmp/ingress.yaml
|
||||||
|
|
||||||
@@ -687,7 +690,7 @@ jobs:
|
|||||||
echo " ⚠ STORAGE_CLASS not set — file storage disabled"
|
echo " ⚠ STORAGE_CLASS not set — file storage disabled"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
kubectl apply -f /tmp/switchboard.yaml
|
kubectl apply -f /tmp/armature.yaml
|
||||||
|
|
||||||
# Traefik retry middleware (v0.28.8) — requires RBAC for traefik.io CRDs.
|
# Traefik retry middleware (v0.28.8) — requires RBAC for traefik.io CRDs.
|
||||||
# First-time setup: kubectl apply -f k8s/rbac-traefik.yaml (cluster admin)
|
# First-time setup: kubectl apply -f k8s/rbac-traefik.yaml (cluster admin)
|
||||||
@@ -706,8 +709,8 @@ jobs:
|
|||||||
# Traefik invalidates the entire router if it references a missing middleware,
|
# Traefik invalidates the entire router if it references a missing middleware,
|
||||||
# which kills all routes for this path prefix.
|
# which kills all routes for this path prefix.
|
||||||
if [[ "${MW_READY}" == "true" ]]; then
|
if [[ "${MW_READY}" == "true" ]]; then
|
||||||
MW_REF="${NAMESPACE}-switchboard-retry${DEPLOY_SUFFIX}@kubernetescrd"
|
MW_REF="${NAMESPACE}-armature-retry${DEPLOY_SUFFIX}@kubernetescrd"
|
||||||
kubectl annotate ingress "switchboard${DEPLOY_SUFFIX}" \
|
kubectl annotate ingress "armature${DEPLOY_SUFFIX}" \
|
||||||
"traefik.ingress.kubernetes.io/router.middlewares=${MW_REF}" \
|
"traefik.ingress.kubernetes.io/router.middlewares=${MW_REF}" \
|
||||||
--namespace="${NAMESPACE}" --overwrite
|
--namespace="${NAMESPACE}" --overwrite
|
||||||
echo " ✓ Ingress annotated with retry middleware"
|
echo " ✓ Ingress annotated with retry middleware"
|
||||||
@@ -718,20 +721,20 @@ jobs:
|
|||||||
SUFFIX: ${{ steps.setup.outputs.DEPLOY_SUFFIX }}
|
SUFFIX: ${{ steps.setup.outputs.DEPLOY_SUFFIX }}
|
||||||
ENV: ${{ steps.setup.outputs.ENVIRONMENT }}
|
ENV: ${{ steps.setup.outputs.ENVIRONMENT }}
|
||||||
run: |
|
run: |
|
||||||
kubectl rollout restart deployment/switchboard${SUFFIX} -n ${NAMESPACE}
|
kubectl rollout restart deployment/armature${SUFFIX} -n ${NAMESPACE}
|
||||||
kubectl rollout status deployment/switchboard${SUFFIX} -n ${NAMESPACE} --timeout=180s
|
kubectl rollout status deployment/armature${SUFFIX} -n ${NAMESPACE} --timeout=180s
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "━━━ Pod Status ━━━"
|
echo "━━━ Pod Status ━━━"
|
||||||
kubectl get pods -n ${NAMESPACE} -l app=switchboard,env=${ENV}
|
kubectl get pods -n ${NAMESPACE} -l app=armature,env=${ENV}
|
||||||
|
|
||||||
READY=$(kubectl get deployment switchboard${SUFFIX} -n ${NAMESPACE} -o jsonpath='{.status.readyReplicas}')
|
READY=$(kubectl get deployment armature${SUFFIX} -n ${NAMESPACE} -o jsonpath='{.status.readyReplicas}')
|
||||||
|
|
||||||
if [[ "${READY:-0}" -gt 0 ]]; then
|
if [[ "${READY:-0}" -gt 0 ]]; then
|
||||||
echo "✅ Healthy: ${READY} pods ready"
|
echo "✅ Healthy: ${READY} pods ready"
|
||||||
else
|
else
|
||||||
echo "❌ Unhealthy: ${READY:-0} pods ready"
|
echo "❌ Unhealthy: ${READY:-0} pods ready"
|
||||||
kubectl logs -n ${NAMESPACE} -l app=switchboard,env=${ENV} --tail=30
|
kubectl logs -n ${NAMESPACE} -l app=armature,env=${ENV} --tail=30
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
341
CHANGELOG.md
341
CHANGELOG.md
@@ -1,6 +1,329 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
All notable changes to Switchboard Core are documented here.
|
All notable changes to Armature are documented here.
|
||||||
|
|
||||||
|
## v0.6.16 — Usability Survey Gate
|
||||||
|
|
||||||
|
Machine-auditable UI quality gate. Four new audit scripts, a structured survey
|
||||||
|
prompt, contrast and touch-target fixes, and Docker Hub documentation correction.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **`scripts/generate-ui-inventory.sh`** — walks all kernel + package CSS,
|
||||||
|
extracts every class selector with surface, line number, responsive breakpoints,
|
||||||
|
spacing tokens, and font-size usage. Outputs `ui-inventory.json` (1567 entries).
|
||||||
|
- **`scripts/check-contrast.sh`** — parses `variables.css` dark/light token
|
||||||
|
pairs, computes WCAG AA contrast ratios for 24 semantic text-on-background
|
||||||
|
pairings per theme (48 total). Uses AA (4.5:1) for normal text, AA-lg (3.0:1)
|
||||||
|
for large/bold text contexts.
|
||||||
|
- **`scripts/generate-coverage-matrix.sh`** — 12 kernel primitives × all surfaces
|
||||||
|
markdown table. Flags any deprecated component usage (`.btn-primary`, etc.).
|
||||||
|
- **`scripts/audit-touch-targets.sh`** — static analysis for 44px minimum mobile
|
||||||
|
touch targets on close buttons and interactive elements.
|
||||||
|
- **`docs/USABILITY-SURVEY.md`** — structured 8-section prompt (viewport, banners,
|
||||||
|
responsive, styling, contrast, touch targets, focus indicators, component
|
||||||
|
uniformity) with pass/fail criteria and file paths for automated execution.
|
||||||
|
- **Focus indicators** — `:focus-visible` styles on `.sw-btn`, `.sw-input`,
|
||||||
|
`.sw-dropdown__trigger`, `.sw-menu__item`, `.sw-tabs__tab`.
|
||||||
|
- **Mobile touch targets** — `min-width/min-height: 44px` in `@media (max-width:
|
||||||
|
768px)` for `.sw-banner__close`, `.sw-toast__close`, `.sw-dialog__close`,
|
||||||
|
`.sw-drawer__close`, `.sw-tabs__arrow`, `.modal-close`, `.sw-tabs__tab`,
|
||||||
|
`.sw-dropdown__option`.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **WCAG contrast violations** — dark-mode accent darkened from `#6c9fff` to
|
||||||
|
`#6493ed` (3.03:1 with white text), dark-mode success from `#22c55e` to
|
||||||
|
`#1dab51` (3.00:1). Light-mode `--text-3` darkened from `#8b8da3` to `#787a92`.
|
||||||
|
Light-mode `--success-light` and `--warning-light` darkened for badge contrast.
|
||||||
|
All 48 pairings now pass.
|
||||||
|
- **Docker Hub references** — `docs/DEPLOYMENT.md` and `docs/DISTRIBUTION.md`
|
||||||
|
corrected from `ghcr.io/armature/armature` to `gobha/armature` (Docker Hub).
|
||||||
|
Builder image corrected to `gobha/armature-builder`. GitHub URL corrected to
|
||||||
|
`github.com/gobha/armature`.
|
||||||
|
|
||||||
|
## v0.6.15 — User Display Audit
|
||||||
|
|
||||||
|
Every user-facing identity surface now shows human-readable names instead of
|
||||||
|
UUIDs, with the canonical fallback chain: `display_name → username → "Unknown"`.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **`GET /api/v1/users/resolve?ids=...`** — batch endpoint returns identity
|
||||||
|
records (username, display_name, handle, avatar_url) for up to 100 user IDs.
|
||||||
|
Response keyed by ID for O(1) client lookups.
|
||||||
|
- **`sw.users` SDK module** — `resolve(id)`, `resolveMany(ids)`,
|
||||||
|
`displayName(user)` with 60-second local cache. Surfaces use this instead
|
||||||
|
of ad-hoc lookups or stale snapshots.
|
||||||
|
- **5 handler tests** for the resolve endpoint (single, multiple, missing,
|
||||||
|
empty, no-param).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Admin users list** — shows `display_name || username` as primary
|
||||||
|
identifier, with username shown as secondary when display_name is set.
|
||||||
|
- **Admin teams/groups member lists** — replaced `username || user_id`
|
||||||
|
with `display_name || username || 'Unknown'`.
|
||||||
|
- **Team-admin members** — dropdown and list now show display_name.
|
||||||
|
- **Chat participants** — resolved from users table via `sw.users.resolveMany()`
|
||||||
|
instead of relying on creation-time snapshot. Message sender names, typing
|
||||||
|
indicators, and participant sidebar all use resolved names.
|
||||||
|
- **Dashboard greeting** — added `|| 'Unknown'` terminal fallback.
|
||||||
|
- **Team activity log** — capitalized fallback to `'Unknown'`.
|
||||||
|
|
||||||
|
### Deprecated
|
||||||
|
|
||||||
|
- **`participants.display_name` column** in chat-core — column retained for
|
||||||
|
backward compatibility but UI no longer relies on snapshot values. Comments
|
||||||
|
added to `packages/chat-core/script.star` noting deprecation.
|
||||||
|
|
||||||
|
## v0.6.14 — Visual Polish
|
||||||
|
|
||||||
|
Systematic cleanup of stale values, self-hosted fonts, and rendering fixes.
|
||||||
|
Final visual pass before the v0.6.15 usability survey gate.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **v0.6.13 spacing regressions** — added half-step tokens (`--sp-1h` 6px,
|
||||||
|
`--sp-2h` 10px) and restored correct padding on `.sw-btn--sm`, `.sw-input`,
|
||||||
|
`.sw-menu__item`, `.sw-dropdown__option`, `.sw-tabs__tab`.
|
||||||
|
- **Theme settings toggle** — showed resolved theme ("Dark") instead of stored
|
||||||
|
mode ("System"). Changed `appearance.js` to read `sw.theme.mode`.
|
||||||
|
- **Notes surface scrollbar** — added `overflow: hidden` to `.surface-inner`
|
||||||
|
in `base.html`, preventing spurious scrollbar at any scale.
|
||||||
|
- **Chat input clipped at high scale** — same `overflow: hidden` fix prevents
|
||||||
|
zoomed content from overflowing the surface container.
|
||||||
|
- **User menu drift at scale > 100%** — `menu.js` now divides
|
||||||
|
`getBoundingClientRect()` coords by the CSS zoom factor, fixing position
|
||||||
|
for `position: fixed` menus inside a zoomed ancestor.
|
||||||
|
- **Undefined variables** — `--text-secondary` (login), `--text-muted`
|
||||||
|
(user picker), `--text-1` (settings toggle) replaced with correct tokens.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Stale fallback colors purged** — removed ~65 hex/rgba fallback values
|
||||||
|
from `var()` calls across 9 kernel CSS files and 3 extension packages.
|
||||||
|
Old gold theme color `#b38a4e` fully eliminated (9 instances).
|
||||||
|
- **Self-hosted fonts** — bundled DM Sans and JetBrains Mono woff2 files
|
||||||
|
in `src/fonts/`. Replaced Google Fonts `@import` and login.html `<link>`
|
||||||
|
with local `@font-face` declarations. Zero external font dependencies.
|
||||||
|
- **Consistent border-radius** — added `--radius-sm: 4px` token. Migrated
|
||||||
|
~60 hardcoded `border-radius` values across all kernel CSS and 12 extension
|
||||||
|
packages to three tokens: `--radius-sm` (4px), `--radius` (8px),
|
||||||
|
`--radius-lg` (12px).
|
||||||
|
|
||||||
|
### Updated
|
||||||
|
|
||||||
|
- `docs/EXTENSION-CSS.md` — added `--sp-1h`, `--sp-2h` half-step tokens
|
||||||
|
and `--radius-sm` to the public CSS contract.
|
||||||
|
|
||||||
|
## v0.6.13 — Responsive & Spacing
|
||||||
|
|
||||||
|
Spacing token scale and tablet breakpoint. All kernel CSS and extension
|
||||||
|
packages migrated from hardcoded values to design tokens.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Spacing tokens** (`--sp-1` through `--sp-12`) — 4px-grid scale in
|
||||||
|
`variables.css`. Nine stops: 4, 8, 12, 16, 20, 24, 32, 40, 48px.
|
||||||
|
Numeric naming (`--sp-N`), rem-based for zoom/font-size respect.
|
||||||
|
- **Tablet breakpoint** (`max-width: 1024px`) — new responsive tier
|
||||||
|
between mobile (768px) and desktop. Secondary workspace pane narrows
|
||||||
|
to 360px, admin sidebar to 120px, settings/admin/editor navs shrink.
|
||||||
|
- **Breakpoint documentation** in `EXTENSION-CSS.md` — Mobile (768px),
|
||||||
|
Tablet (1024px), Desktop (default).
|
||||||
|
- **Spacing guidelines** in `EXTENSION-CSS.md` — token table with
|
||||||
|
computed pixel values and usage examples.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **8 kernel CSS files** migrated to spacing tokens — `sw-primitives.css`,
|
||||||
|
`modals.css`, `surfaces.css`, `layout.css`, `sw-shell.css`,
|
||||||
|
`primitives.css`, `user-menu.css`, `sw-login.css`. Hardcoded padding,
|
||||||
|
margin, and gap values replaced with `var(--sp-N)`.
|
||||||
|
- **12 extension packages** migrated — chat, dashboard, editor,
|
||||||
|
git-board, hello-dashboard, icd-test-runner, notes, schedules,
|
||||||
|
sdk-test-runner, tasks, team-activity-log, workflow-demo.
|
||||||
|
- **Login hero breakpoint** normalized from 900px to 1024px (tablet).
|
||||||
|
- **Notes mobile breakpoint** normalized from 700px to 768px (standard).
|
||||||
|
|
||||||
|
## v0.6.12 — Extension CSS Isolation
|
||||||
|
|
||||||
|
Prefix enforcement prevents extension CSS from leaking into the kernel or
|
||||||
|
sibling extensions. All 12 in-tree packages migrated to `.ext-{slug}-*`
|
||||||
|
naming convention.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **`data-ext` attribute** on extension mount container — enables scoped
|
||||||
|
selectors like `[data-ext="chat"] .ext-chat-app`.
|
||||||
|
- **CSS linter** (`scripts/lint-package-css.sh`) — validates that the first
|
||||||
|
class selector in every extension CSS rule starts with `.ext-{slug}`.
|
||||||
|
Exempts `:root`, `@keyframes`, `@font-face`, `@media`, kernel `.sw-*`
|
||||||
|
classes, and CodeMirror `.cm-*` classes.
|
||||||
|
- **Kernel CSS contract** (`docs/EXTENSION-CSS.md`) — documents stable
|
||||||
|
public classes and CSS variables that extensions may reference. Everything
|
||||||
|
else is internal kernel CSS.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **12 packages migrated** — all class selectors renamed to `.ext-{slug}-*`:
|
||||||
|
chat, dashboard, editor, git-board, hello-dashboard, icd-test-runner,
|
||||||
|
notes, schedules, sdk-test-runner, tasks, team-activity-log, workflow-demo.
|
||||||
|
CSS and JS files updated in lockstep.
|
||||||
|
- **`icd-test-runner`** — ID selectors (`#extension-mount`) converted to
|
||||||
|
class-based selectors with proper prefix.
|
||||||
|
- **`editor` cross-references** — compound selectors referencing notes
|
||||||
|
classes updated to new `.ext-notes-*` names.
|
||||||
|
- **`chat` kernel overrides** — `.sw-dialog:has(...)` override scoped under
|
||||||
|
`[data-ext="chat"]` instead of global.
|
||||||
|
|
||||||
|
## v0.6.11 — CSS Deduplication
|
||||||
|
|
||||||
|
One class per concept. The old `primitives.css` button, toast, popup-menu,
|
||||||
|
dropdown, and tabs systems are retired. `sw-primitives.css` is the single
|
||||||
|
source of truth for all Preact component styles.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Buttons**: All 29 files migrated from `.btn-primary` / `.btn-small` /
|
||||||
|
`.btn-danger` / `.btn-ghost` / `.btn-md` / `.btn-sm` to the BEM-style
|
||||||
|
`.sw-btn .sw-btn--{variant} .sw-btn--{size}` system.
|
||||||
|
- **Toasts**: Old `.toast-container` / `.toast` CSS deleted. SDK's
|
||||||
|
`sw.toast()` API already used `.sw-toast-*` classes — no JS changes.
|
||||||
|
- **Popup menus**: Old `.popup-menu` / `.popup-menu-item` CSS deleted
|
||||||
|
(unused — `.sw-menu` is the active system).
|
||||||
|
- **Dropdown collision resolved**: Old `.sw-dropdown` (styled `<select>`)
|
||||||
|
deleted from `primitives.css`. The `sw-primitives.css` custom dropdown
|
||||||
|
component (`.sw-dropdown` with BEM sub-elements) is authoritative.
|
||||||
|
- **Tabs collision resolved**: Old `.sw-tabs` / `.sw-tab-btn` deleted from
|
||||||
|
`primitives.css`. The `sw-primitives.css` scrollable tabs component
|
||||||
|
(`.sw-tabs__tab`) is authoritative.
|
||||||
|
- **`.settings-section` collision resolved**: Removed duplicate definition
|
||||||
|
from `modals.css`. The `surfaces.css` card-style definition is
|
||||||
|
authoritative; `.settings-content .settings-section` override resets
|
||||||
|
card styling for flat settings layouts.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `.sw-btn--success` variant in `sw-primitives.css` (green action button).
|
||||||
|
- `--bg-active` CSS variable in both dark/light themes (`variables.css`).
|
||||||
|
- `scripts/audit-css-collisions.sh` — finds duplicate class selectors
|
||||||
|
across kernel CSS files and outputs a JSON collision report.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- `packages/sdk-test-runner/css/main.css`: Wrong variable names
|
||||||
|
(`--text3` → `--text-3`, `--text2` → `--text-2`, `--bg1`/`--bg2` →
|
||||||
|
`--bg-raised`).
|
||||||
|
- `packages/icd-test-runner/css/main.css`: Replaced inline button fallback
|
||||||
|
styles with kernel `.sw-btn` system.
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- Old button classes: `.btn-primary`, `.btn-small`, `.btn-danger`,
|
||||||
|
`.btn-full`, `.btn-ghost`, `.btn-subtle`, `.btn-sm`, `.btn-md`.
|
||||||
|
- Old toast classes: `.toast-container`, `.toast`, `.toast.error/warning/success`.
|
||||||
|
- Old popup menu classes: `.popup-menu`, `.popup-menu-item`, `.popup-menu-*`.
|
||||||
|
- Old dropdown and tabs definitions from `primitives.css` that collided
|
||||||
|
with `sw-primitives.css`.
|
||||||
|
|
||||||
|
## v0.6.10 — Viewport Foundation
|
||||||
|
|
||||||
|
Single layout model. Every surface renders inside one containment chain:
|
||||||
|
`body → shell → surface`. No dual systems. No transform hacks.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **CSS `zoom` replaces `transform: scale()`**: UI scale (80%–175%) now
|
||||||
|
uses CSS `zoom` on `#surfaceInner` instead of `transform: scale()`.
|
||||||
|
`zoom` reflows layout correctly — `getBoundingClientRect()` returns
|
||||||
|
accurate values, eliminating the scale-correction hack in `menu.js`.
|
||||||
|
Supported in all evergreen browsers (Firefox 126+, June 2024).
|
||||||
|
- **Single layout root**: `<body>` in `base.html` is the authoritative
|
||||||
|
flex column layout. `.sw-shell` CSS demoted from viewport-level
|
||||||
|
container (`height: 100vh`) to fill-parent (`height: 100%`).
|
||||||
|
Safe-area insets moved from `.sw-shell` to `<body>`.
|
||||||
|
- **Banner single source of truth**: Template banners measure their own
|
||||||
|
height via inline `<script>` and set `--banner-top-height` /
|
||||||
|
`--banner-bottom-height` CSS variables. Removed `--banner-h: 28px`
|
||||||
|
fixed variable. `ShellBanner` Preact component's `useEffect`
|
||||||
|
measurement removed (dead code — no surface imports `AppShell`).
|
||||||
|
- **`sw-shell__banner` position**: Changed from `position: fixed` to
|
||||||
|
`position: static` — template banners are in-flow elements.
|
||||||
|
- **`sw-shell__body` padding**: Removed `padding-top/bottom` for
|
||||||
|
banner offsets — unnecessary with in-flow banners.
|
||||||
|
- **Extension surfaces `100vh` → `100%`**: `chat-app`, `chat-loading`,
|
||||||
|
`surface-dashboard` now use `height: 100%` to inherit from the
|
||||||
|
extension mount container (like Notes). Fixes overflow behind banners.
|
||||||
|
- **`100vh` → `100dvh` fallbacks**: All viewport-height declarations
|
||||||
|
use `height: 100vh; height: 100dvh;` pattern for correct behavior on
|
||||||
|
mobile browsers. Affects: `base.html`, `sw-login.css`,
|
||||||
|
`workflow.html`, `workflow-landing.html`, `primitives.css`,
|
||||||
|
`git-board/css/main.css`.
|
||||||
|
- **`sw.shell.getScale()` deprecated**: Returns `1` always — CSS `zoom`
|
||||||
|
handles layout reflow without manual correction.
|
||||||
|
|
||||||
|
### Deprecated
|
||||||
|
|
||||||
|
- `src/js/sw/shell/app-shell.js`, `app.js`, `surface-viewport.js` —
|
||||||
|
no surface imports these. Layout root is `<body>` in `base.html`.
|
||||||
|
|
||||||
|
## v0.6.9 — Session Lifetime Config
|
||||||
|
|
||||||
|
Admin-configurable session durations, "keep me logged in" opt-in, and
|
||||||
|
optional idle timeout. Completes the auth hardening started in v0.6.8.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Admin session settings**: `session.access_token_ttl` (default 15m,
|
||||||
|
clamp 5m–60m) and `session.refresh_token_ttl` (default 7d, clamp
|
||||||
|
1h–90d) stored in `global_settings`. New `LoadSessionConfig()` helper
|
||||||
|
reads and clamps values with `parseDurationString()` supporting `m`,
|
||||||
|
`h`, `d` suffixes.
|
||||||
|
- **"Keep me logged in" checkbox**: Login form opt-in. Checked = full
|
||||||
|
`refresh_token_ttl`. Unchecked = capped at 24h. Cookie `max-age`
|
||||||
|
tracks whichever lifetime was chosen. `keep_login` flag stored on
|
||||||
|
refresh token row.
|
||||||
|
- **Config-driven token generation**: `generateTokens()` reads TTLs from
|
||||||
|
`global_settings` instead of hardcoded `15*time.Minute` /
|
||||||
|
`7*24*time.Hour`. Response includes `expires_in` and
|
||||||
|
`refresh_expires_in` (seconds) so the client schedules refresh
|
||||||
|
correctly.
|
||||||
|
- **Idle timeout (optional)**: Admin-toggleable (default off). Server
|
||||||
|
checks `last_activity_at` on refresh-token row; rejects if gap exceeds
|
||||||
|
`session.idle_timeout`. Client SDK pings `POST /api/v1/auth/activity`
|
||||||
|
on click/keydown (debounced, max 1/min).
|
||||||
|
- **Admin Settings > Session section**: Dropdowns for access TTL, refresh
|
||||||
|
TTL, and idle timeout with toggle.
|
||||||
|
- **7 new tests**: Duration parsing, clamping (low/high), defaults,
|
||||||
|
invalid values, empty idle timeout.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- `CreateRefreshToken` store method now accepts `keepLogin bool`
|
||||||
|
parameter; both Postgres and SQLite implementations updated.
|
||||||
|
- `GetRefreshTokenInfo` returns `RefreshTokenInfo` struct with
|
||||||
|
`UserID`, `KeepLogin`, `LastActivityAt` for idle-timeout decisions.
|
||||||
|
- SDK `auth.login()` accepts optional third `keepLogin` parameter;
|
||||||
|
cookie max-age derived from server `refresh_expires_in` response.
|
||||||
|
- SDK boots activity tracking after successful auth boot.
|
||||||
|
|
||||||
|
## v0.6.8 — Cookie Fix + UI Hardening Roadmap
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Session cookie max-age bug**: `arm_token` cookie was set to 15 min
|
||||||
|
(matching access token) while refresh token lasted 7 days. Cookie now
|
||||||
|
matches refresh token lifetime so Go SSR middleware can serve page
|
||||||
|
shells while JS refreshes the access token client-side.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **ROADMAP-UI.md**: Detailed UI hardening roadmap (v0.6.9–v0.6.15)
|
||||||
|
covering session config, viewport foundation, CSS deduplication,
|
||||||
|
extension CSS isolation, responsive layout, visual polish, and
|
||||||
|
automated usability survey gate.
|
||||||
|
|
||||||
## v0.6.7 — Native mTLS
|
## v0.6.7 — Native mTLS
|
||||||
|
|
||||||
@@ -28,7 +351,7 @@ deployments where the Go binary terminates TLS itself.
|
|||||||
- **Peer TLS config**: `BuildPeerTLSConfig()` constructs a `*tls.Config` for
|
- **Peer TLS config**: `BuildPeerTLSConfig()` constructs a `*tls.Config` for
|
||||||
outbound node-to-node connections (forward-looking — cluster registry is
|
outbound node-to-node connections (forward-looking — cluster registry is
|
||||||
currently DB-backed with no HTTP peer calls).
|
currently DB-backed with no HTTP peer calls).
|
||||||
- **`switchboard-ca.sh`**: Shell wrapper around openssl for cert provisioning.
|
- **`armature-ca.sh`**: Shell wrapper around openssl for cert provisioning.
|
||||||
Three commands: `init` (CA keypair), `issue-node` (365d, ServerAuth +
|
Three commands: `init` (CA keypair), `issue-node` (365d, ServerAuth +
|
||||||
ClientAuth EKU), `issue-user` (90d, ClientAuth only). All ECDSA P-256, PEM
|
ClientAuth EKU), `issue-user` (90d, ClientAuth only). All ECDSA P-256, PEM
|
||||||
output.
|
output.
|
||||||
@@ -875,7 +1198,7 @@ Indexes on `folder_id`, `creator_id`, `pinned`, `updated_at`.
|
|||||||
- **Migration 012**: Adds `bundled` to `packages.source` CHECK constraint
|
- **Migration 012**: Adds `bundled` to `packages.source` CHECK constraint
|
||||||
(both Postgres and SQLite).
|
(both Postgres and SQLite).
|
||||||
- **K8s manifest**: `SKIP_BUNDLED_PACKAGES` and `BUNDLED_PACKAGES` env vars
|
- **K8s manifest**: `SKIP_BUNDLED_PACKAGES` and `BUNDLED_PACKAGES` env vars
|
||||||
added to `k8s/switchboard.yaml`.
|
added to `k8s/armature.yaml`.
|
||||||
- **Tests**: 6 handler tests (fresh install, skip existing, missing dir, empty
|
- **Tests**: 6 handler tests (fresh install, skip existing, missing dir, empty
|
||||||
dir, dormant handling, allowlist filtering).
|
dir, dormant handling, allowlist filtering).
|
||||||
|
|
||||||
@@ -1339,7 +1662,7 @@ Indexes on `folder_id`, `creator_id`, `pinned`, `updated_at`.
|
|||||||
(`"triggers": [{"type": "event", "pattern": "workflow.completed", ...}]`).
|
(`"triggers": [{"type": "event", "pattern": "workflow.completed", ...}]`).
|
||||||
Wired via `bus.Subscribe()` on startup. Handlers fire asynchronously.
|
Wired via `bus.Subscribe()` on startup. Handlers fire asynchronously.
|
||||||
- **Webhook triggers**: Inbound HTTP at `/api/v1/hooks/:package_id/:slug`.
|
- **Webhook triggers**: Inbound HTTP at `/api/v1/hooks/:package_id/:slug`.
|
||||||
HMAC-SHA256 verification via `X-Switchboard-Signature` header. Synchronous
|
HMAC-SHA256 verification via `X-Armature-Signature` header. Synchronous
|
||||||
Starlark handler can return custom HTTP status and body.
|
Starlark handler can return custom HTTP status and body.
|
||||||
- **Scheduled tasks**: User-created cron-scheduled Starlark scripts with
|
- **Scheduled tasks**: User-created cron-scheduled Starlark scripts with
|
||||||
restricted sandbox (no raw HTTP, no DB table creation, connections-only
|
restricted sandbox (no raw HTTP, no DB table creation, connections-only
|
||||||
@@ -1492,21 +1815,21 @@ storage, and the Starlark sandbox. Everything else is a package.
|
|||||||
- CI deploy: k8s resource quantity vars (`BE_MEMORY_REQUEST` → `MEMORY_REQUEST`)
|
- CI deploy: k8s resource quantity vars (`BE_MEMORY_REQUEST` → `MEMORY_REQUEST`)
|
||||||
aligned with CI workflow outputs — `envsubst` was producing empty strings
|
aligned with CI workflow outputs — `envsubst` was producing empty strings
|
||||||
- CI deploy: image var (`BE_IMAGE` → `IMAGE`) — caused `InvalidImageName` in pods
|
- CI deploy: image var (`BE_IMAGE` → `IMAGE`) — caused `InvalidImageName` in pods
|
||||||
- CI rollout: deployment name (`switchboard` → `switchboard-be`) — rollout
|
- CI rollout: deployment name (`switchboard` → `armature-be`) — rollout
|
||||||
verification was looking for wrong deployment name
|
verification was looking for wrong deployment name
|
||||||
- Nginx BASE_PATH: regex cache-header locations intercepted static asset
|
- Nginx BASE_PATH: regex cache-header locations intercepted static asset
|
||||||
requests before alias could strip the sub-path prefix — moved inside alias block
|
requests before alias could strip the sub-path prefix — moved inside alias block
|
||||||
- Post-login blank page: dead Go template references (`surface-chat`,
|
- Post-login blank page: dead Go template references (`surface-chat`,
|
||||||
`surface-notes`, `surface-projects`) caused html/template to silently
|
`surface-notes`, `surface-projects`) caused html/template to silently
|
||||||
produce Content-Length: 0 responses
|
produce Content-Length: 0 responses
|
||||||
- Login branding: "Chat Switchboard" → "Switchboard Core", updated tagline
|
- Login branding: "Chat Armature" → "Armature", updated tagline
|
||||||
and feature pills to reflect platform pivot
|
and feature pills to reflect platform pivot
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- Go module: `switchboard-core`
|
- Go module: `armature`
|
||||||
- VERSION: `0.1.0`
|
- VERSION: `0.1.0`
|
||||||
- Default DB name: `switchboard_core`
|
- Default DB name: `armature`
|
||||||
- Fresh migrations: 9 files × 2 dialects (postgres + sqlite), 27 tables
|
- Fresh migrations: 9 files × 2 dialects (postgres + sqlite), 27 tables
|
||||||
- Store interfaces: 40 → 20 (13 in interfaces.go + 7 in separate iface files)
|
- Store interfaces: 40 → 20 (13 in interfaces.go + 7 in separate iface files)
|
||||||
- Stage modes: `chat_only` removed, `custom` added
|
- Stage modes: `chat_only` removed, `custom` added
|
||||||
@@ -1514,7 +1837,7 @@ storage, and the Starlark sandbox. Everything else is a package.
|
|||||||
- Kernel permissions: 16 → 6 (`extension.use`, `extension.install`,
|
- Kernel permissions: 16 → 6 (`extension.use`, `extension.install`,
|
||||||
`workflow.create`, `workflow.submit`, `admin.view`, `token.unlimited`)
|
`workflow.create`, `workflow.submit`, `admin.view`, `token.unlimited`)
|
||||||
- Everyone group seed: `["extension.use","workflow.submit"]`
|
- Everyone group seed: `["extension.use","workflow.submit"]`
|
||||||
- Global settings seed: site name "Switchboard Core"
|
- Global settings seed: site name "Armature"
|
||||||
- Config: removed 7 dropped fields (SessionExpiryDays, WorkflowStaleHours,
|
- Config: removed 7 dropped fields (SessionExpiryDays, WorkflowStaleHours,
|
||||||
ProviderAutoDisableThreshold, ExtractionConcurrency, etc.)
|
ProviderAutoDisableThreshold, ExtractionConcurrency, etc.)
|
||||||
- Health stores rewritten: kernel-only Prune for stale tickets, counters, presence
|
- Health stores rewritten: kernel-only Prune for stale tickets, counters, presence
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Contributing to Switchboard Core
|
# Contributing to Armature
|
||||||
|
|
||||||
## Development Setup
|
## Development Setup
|
||||||
|
|
||||||
@@ -8,8 +8,8 @@
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
cd server
|
cd server
|
||||||
go build -o switchboard-core .
|
go build -o armature .
|
||||||
DB_DRIVER=sqlite DATABASE_URL=/tmp/switchboard.db ./switchboard-core
|
DB_DRIVER=sqlite DATABASE_URL=/tmp/armature.db ./armature
|
||||||
```
|
```
|
||||||
|
|
||||||
**Docker (recommended):**
|
**Docker (recommended):**
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# ============================================
|
# ============================================
|
||||||
# Switchboard Core — Unified Dockerfile
|
# Armature — Unified Dockerfile
|
||||||
# ============================================
|
# ============================================
|
||||||
# Stage 1: Build Go backend
|
# Stage 1: Build Go backend
|
||||||
# Stage 2: Download JS vendor libs (marked, DOMPurify)
|
# Stage 2: Download JS vendor libs (marked, DOMPurify)
|
||||||
@@ -20,7 +20,7 @@ COPY server/go.mod server/go.sum* ./
|
|||||||
COPY server/ .
|
COPY server/ .
|
||||||
COPY VERSION ./
|
COPY VERSION ./
|
||||||
RUN go mod download && go mod tidy && go mod verify
|
RUN go mod download && go mod tidy && go mod verify
|
||||||
RUN CGO_ENABLED=0 go build -ldflags="-s -w -X main.Version=$(cat VERSION)" -o /bin/switchboard .
|
RUN CGO_ENABLED=0 go build -ldflags="-s -w -X main.Version=$(cat VERSION)" -o /bin/armature .
|
||||||
|
|
||||||
# ── Stage 2: Vendor JS libs ─────────────────
|
# ── Stage 2: Vendor JS libs ─────────────────
|
||||||
FROM node:20-alpine AS vendor
|
FROM node:20-alpine AS vendor
|
||||||
@@ -70,7 +70,7 @@ FROM nginx:1-alpine
|
|||||||
RUN apk add --no-cache bash git
|
RUN apk add --no-cache bash git
|
||||||
|
|
||||||
# Go backend binary
|
# Go backend binary
|
||||||
COPY --from=backend /bin/switchboard /usr/local/bin/switchboard
|
COPY --from=backend /bin/armature /usr/local/bin/armature
|
||||||
COPY --from=backend /app/database/migrations /app/database/migrations
|
COPY --from=backend /app/database/migrations /app/database/migrations
|
||||||
|
|
||||||
# Frontend static files
|
# Frontend static files
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# ============================================
|
# ============================================
|
||||||
# Switchboard Core — Builder Image
|
# Armature — Builder Image
|
||||||
# ============================================
|
# ============================================
|
||||||
# Pre-caches Go modules and Node dependencies
|
# Pre-caches Go modules and Node dependencies
|
||||||
# for faster custom builds. Use as a base in
|
# for faster custom builds. Use as a base in
|
||||||
@@ -7,12 +7,12 @@
|
|||||||
# download on every build.
|
# download on every build.
|
||||||
#
|
#
|
||||||
# Usage:
|
# Usage:
|
||||||
# FROM ghcr.io/switchboard-core/builder:latest AS go-builder
|
# FROM ghcr.io/armature/builder:latest AS go-builder
|
||||||
# COPY server/ /app/
|
# COPY server/ /app/
|
||||||
# RUN cd /app && go build -o /bin/switchboard .
|
# RUN cd /app && go build -o /bin/armature .
|
||||||
#
|
#
|
||||||
# Or build this image locally:
|
# Or build this image locally:
|
||||||
# docker build -f Dockerfile.builder -t switchboard-builder .
|
# docker build -f Dockerfile.builder -t armature-builder .
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|
||||||
# ── Go module cache ─────────────────────────
|
# ── Go module cache ─────────────────────────
|
||||||
@@ -61,5 +61,5 @@ RUN ln -sf /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm
|
|||||||
|
|
||||||
WORKDIR /build
|
WORKDIR /build
|
||||||
|
|
||||||
LABEL org.opencontainers.image.title="Switchboard Core Builder"
|
LABEL org.opencontainers.image.title="Armature Builder"
|
||||||
LABEL org.opencontainers.image.description="Pre-cached build dependencies for faster custom Switchboard Core builds"
|
LABEL org.opencontainers.image.description="Pre-cached build dependencies for faster custom Armature builds"
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
# Switchboard Core
|
# Armature
|
||||||
|
|
||||||
A self-hosted extension platform. Identity, teams, permissions, workflows,
|
A self-hosted extension platform. Identity, teams, permissions, workflows,
|
||||||
and a package system. Everything else ships as installable extensions.
|
and a package system. Everything else ships as installable extensions.
|
||||||
|
|
||||||
## What This Is
|
## What This Is
|
||||||
|
|
||||||
Switchboard Core is the kernel. It provides the primitives that extensions
|
Armature is the kernel. It provides the primitives that extensions
|
||||||
build on: users, teams, groups, scoped credentials, a Starlark sandbox,
|
build on: users, teams, groups, scoped credentials, a Starlark sandbox,
|
||||||
workflow orchestration, notifications, and a package installer. It does not
|
workflow orchestration, notifications, and a package installer. It does not
|
||||||
include AI chat, providers, personas, or any domain-specific features —
|
include AI chat, providers, personas, or any domain-specific features —
|
||||||
@@ -27,7 +27,7 @@ docker compose up --build
|
|||||||
# → http://localhost:3000 (admin/admin)
|
# → http://localhost:3000 (admin/admin)
|
||||||
|
|
||||||
# Or from source
|
# Or from source
|
||||||
git clone <repo-url> && cd switchboard-core
|
git clone <repo-url> && cd armature
|
||||||
cp server/.env.example server/.env # edit DB credentials
|
cp server/.env.example server/.env # edit DB credentials
|
||||||
cd server && go run .
|
cd server && go run .
|
||||||
# → http://localhost:8080
|
# → http://localhost:8080
|
||||||
|
|||||||
151
ROADMAP-UI.md
Normal file
151
ROADMAP-UI.md
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
# Armature — v0.6.x UI Hardening Roadmap
|
||||||
|
|
||||||
|
> **Goal**: Fix all viewport scaling, banner integration, and styling inconsistencies
|
||||||
|
> so Claude Code can run a meaningful automated usability survey against a
|
||||||
|
> stable, uniform UI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Problem Inventory
|
||||||
|
|
||||||
|
### P1 — Viewport & Scaling (blocks everything)
|
||||||
|
|
||||||
|
| # | Issue | Where | Impact |
|
||||||
|
|---|-------|-------|--------|
|
||||||
|
| 1 | **Dual layout systems** — `base.html` template has its own banner→surface→footer column; `app-shell.js` Preact shell has a separate `sw-shell` → `sw-shell__body` → `sw-shell__surface` column. They never coordinate. | `server/pages/templates/base.html` ↔ `src/js/sw/shell/app-shell.js` | Every surface inherits an ambiguous viewport ancestor. |
|
||||||
|
| 2 | **Transform-based scaling is broken** — `transform: scale()` on `#surfaceInner` doesn't reflow layout. Breaks `getBoundingClientRect` (menu.js already carries a scale-correction hack at line 20), scroll containment, pointer events, and the inverse-width/height hack (`100/s%`) doesn't account for banners consuming viewport space. | `base.html:56-67`, `appearance.js:41-53`, `menu.js:20-33` | Menus, tooltips, dropdowns all mis-positioned at any non-100% scale. Click targets wrong. |
|
||||||
|
| 3 | **Banner height desync** — `base.html` hardcodes `--banner-h: 28px` and renders banners in-flow. `app-shell.js` ShellBanner measures `offsetHeight` into `--banner-top-height` / `--banner-bottom-height` and uses `position: fixed` + body padding. Two independent banner systems. | `base.html:31,72-75,103-107` ↔ `app-shell.js:25-42`, `sw-shell.css:22-37,63-69` | With banners enabled, content overflows by the banner height or gets double-inset depending on which code path is active. |
|
||||||
|
| 4 | **`100vh` on mobile** — `.sw-shell`, `.chat-app`, `.chat-loading`, `.login-shell` use `100vh` which doesn't subtract mobile browser chrome (address bar, toolbar). | `sw-shell.css:9`, `chat/css/main.css:15,25`, `sw-login.css:7` | Content overflows on iOS Safari, Android Chrome. |
|
||||||
|
| 5 | **Chat uses `100vh` not `100%`** — `.chat-app { height: 100vh }` ignores parent container (which already excludes banners/footer). Notes correctly uses `height: 100%`. Every extension surface that copies chat's pattern will inherit the bug. | `packages/chat/css/main.css:15` | Chat surface overflows behind banners. |
|
||||||
|
|
||||||
|
### P2 — CSS Architecture
|
||||||
|
|
||||||
|
| # | Issue | Where | Impact |
|
||||||
|
|---|-------|-------|--------|
|
||||||
|
| 6 | **Class name collisions** — `.settings-section` defined in both `modals.css:41-43` and `surfaces.css:75-83` with different padding, margin, and border-bottom rules. `.sw-dropdown` in `primitives.css:443` (styled `<select>`) vs `sw-primitives.css:262` (custom dropdown component). `.sw-tabs` in `primitives.css:459` vs `sw-primitives.css:234`. | Kernel CSS | Styles randomly win depending on load order. Bug reports differ by surface. |
|
||||||
|
| 7 | **Old/new primitive duplication** — Two button systems (`.btn-primary`/`.btn-small` in `primitives.css` vs `.sw-btn--primary`/`.sw-btn--sm` in `sw-primitives.css`). Two toast systems (`.toast` vs `.sw-toast`). Two dropdown, two tabs implementations. | `primitives.css` ↔ `sw-primitives.css` | No single source of truth for any component. Extension authors can't know which to use. |
|
||||||
|
| 8 | **Extension CSS bleeds globally** — Extension `main.css` loaded via `<link>` at document level, no scoping. Any class name can override kernel or sibling extension styles. | `base.html:26-28`, `extension-surface.css` | Package CSS fights with kernel. Two packages with `.sidebar` both lose. |
|
||||||
|
| 9 | **No intermediate breakpoint** — Only `768px` mobile breakpoint. No tablet (768–1024px). Secondary workspace pane hardcoded `480px`. Admin nav hardcoded `200px`. | `layout.css:263`, `surfaces.css:305` | Cramped layout on iPad/small laptops. |
|
||||||
|
|
||||||
|
### P3 — Visual Consistency
|
||||||
|
|
||||||
|
| # | Issue | Where | Impact |
|
||||||
|
|---|-------|-------|--------|
|
||||||
|
| 10 | **Mixed unit systems** — `px`, `rem`, `em` used interchangeably with no rationale. Some font-sizes in `px` (primitives), others in `rem` (sw-primitives), others in `em` (user-picker). | All CSS files | Zoom/scale behaves differently per element. |
|
||||||
|
| 11 | **Google Fonts CDN dependency** — `@import url(...)` in `variables.css:5` blocks rendering if CDN is slow; breaks entirely on air-gapped deployments. | `variables.css:5` | FOUT or blank page on slow networks. No offline support. |
|
||||||
|
| 12 | **No spacing scale** — Padding/margins are ad-hoc values (10px, 12px, 14px, 16px, 20px, 24px, 28px…). No design-token spacing scale. | All CSS | Inconsistent visual rhythm across surfaces. |
|
||||||
|
| 13 | **Hardcoded fallback colors in component CSS** — `sw-shell.css`, `sw-primitives.css` have inline fallbacks like `var(--accent, #b38a4e)` — a gold color from a previous theme that doesn't match the current blue `#6c9fff`. | `sw-shell.css`, `sw-primitives.css` | Wrong colors flash briefly if variables load late. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Roadmap
|
||||||
|
|
||||||
|
### v0.6.9 — Session Lifetime Config ✅
|
||||||
|
|
||||||
|
Shipped. Admin-configurable TTLs, "keep me logged in" checkbox, idle timeout,
|
||||||
|
config-driven `generateTokens()`, 7 new tests. See CHANGELOG.md for details.
|
||||||
|
|
||||||
|
### v0.6.10 — Viewport Foundation ✅
|
||||||
|
|
||||||
|
Shipped. Single layout model: `body → shell → surface`. CSS `zoom` replaces
|
||||||
|
`transform: scale()`. `100dvh` fallbacks everywhere. Extension surfaces use
|
||||||
|
`100%` instead of `100vh`. Dead shell layout code deprecated. See CHANGELOG.md.
|
||||||
|
|
||||||
|
### v0.6.11 — CSS Deduplication ✅
|
||||||
|
|
||||||
|
Shipped. Old primitive system retired. One class per concept. See CHANGELOG.md.
|
||||||
|
|
||||||
|
| Step | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| Audit collision inventory | Script that finds all duplicate class selectors across kernel CSS files. Produce a machine-readable collision report (JSON). |
|
||||||
|
| Migrate `.settings-section` | Remove `modals.css:41-43` definition. The `surfaces.css` definition is authoritative. Modals that used the old styles get explicit overrides scoped to `.modal .settings-section`. |
|
||||||
|
| Retire `primitives.css` old components | `.btn-primary`, `.btn-small`, `.btn-danger`, `.btn-full` → migrate all usages to `.sw-btn--*`. `.toast-container` / `.toast` → migrate to `.sw-toast-container` / `.sw-toast`. Old `.popup-menu` → migrate to `.sw-menu`. Old `.sw-dropdown` (styled select in primitives.css) → rename to `.sw-native-select` or delete if unused. Old `.sw-tabs` / `.sw-tab-btn` in primitives.css → delete (sw-primitives.css version is authoritative). |
|
||||||
|
| Mark deprecated classes | Any remaining old classes get a `/* DEPRECATED v0.6.11 — use .sw-btn--* */` comment and a 1-version grace period for package authors. |
|
||||||
|
| Package CSS audit | Scan all `packages/*/css/main.css` for references to deprecated kernel classes. Fix in-tree packages. |
|
||||||
|
|
||||||
|
### v0.6.12 — Extension CSS Isolation ✅
|
||||||
|
|
||||||
|
Shipped. Prefix enforcement via linter. All 12 in-tree packages migrated.
|
||||||
|
See CHANGELOG.md.
|
||||||
|
|
||||||
|
| Step | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| Scoping strategy | Prefix enforcement (option B). `@scope` support still patchy; prefix works everywhere, trivially lintable. |
|
||||||
|
| Linter | `scripts/lint-package-css.sh` — validates first class selector in every rule starts with `.ext-{slug}`. Exempts `:root`, `@keyframes`, `@font-face`, `@media`, `.sw-*` kernel classes, `.cm-*` CodeMirror classes. |
|
||||||
|
| Kernel CSS contract | `docs/EXTENSION-CSS.md` — stable public classes (`.sw-btn--*`, `.sw-input`, `.sw-field`, `.sw-dialog`, `.sw-toast`, `.sw-menu`, `.sw-tabs`, etc.) and all CSS variables. Everything else internal. |
|
||||||
|
| Migrate in-tree packages | `data-ext="{{.Surface}}"` on extension mount. All 12 packages prefixed to `.ext-{slug}-*`. CSS + JS updated in lockstep. |
|
||||||
|
|
||||||
|
### v0.6.13 — Responsive & Spacing ✅
|
||||||
|
|
||||||
|
Shipped. Spacing token scale (`--sp-1` through `--sp-12`, 4px grid) in
|
||||||
|
`variables.css`. Tablet breakpoint (`max-width: 1024px`) in layout, surfaces,
|
||||||
|
login. All kernel CSS and 12 in-tree packages migrated to spacing tokens.
|
||||||
|
`EXTENSION-CSS.md` updated with spacing table and breakpoint guide.
|
||||||
|
Font-size scale deferred to v0.6.14. See CHANGELOG.md.
|
||||||
|
|
||||||
|
### v0.6.14 — Visual Polish ✅
|
||||||
|
|
||||||
|
Shipped. Stale fallback colors purged (~65 instances), fonts self-hosted
|
||||||
|
(DM Sans + JetBrains Mono woff2), border-radius standardized to three tokens
|
||||||
|
(`--radius-sm`, `--radius`, `--radius-lg`). Also fixed v0.6.13 spacing
|
||||||
|
regressions (half-step tokens), theme settings default, surface overflow,
|
||||||
|
and user menu zoom drift. See CHANGELOG.md.
|
||||||
|
|
||||||
|
### v0.6.15 — User Display Audit ✅
|
||||||
|
|
||||||
|
Shipped. `GET /api/v1/users/resolve?ids=...` batch endpoint, `sw.users`
|
||||||
|
SDK module with `resolve()`, `resolveMany()`, `displayName()` + 60s cache.
|
||||||
|
Chat participants resolved from users table instead of snapshot. All admin
|
||||||
|
surfaces show `display_name || username || 'Unknown'`. Chat-core snapshot
|
||||||
|
column deprecated. 5 new handler tests. See CHANGELOG.md.
|
||||||
|
|
||||||
|
### v0.6.16 — Usability Survey Gate ✅
|
||||||
|
|
||||||
|
Shipped. Four audit scripts, structured survey prompt, contrast/touch-target
|
||||||
|
fixes, Docker docs corrected. All 48 contrast pairings pass, all close buttons
|
||||||
|
have 44px mobile targets, focus-visible on all key primitives. See CHANGELOG.md.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sequencing Rationale
|
||||||
|
|
||||||
|
```
|
||||||
|
v0.6.9 Session Lifetime Config ✅ SHIPPED
|
||||||
|
↓
|
||||||
|
v0.6.10 Viewport Foundation ✅ SHIPPED
|
||||||
|
↓
|
||||||
|
v0.6.11 CSS Deduplication ✅ SHIPPED
|
||||||
|
↓
|
||||||
|
v0.6.12 Extension CSS Isolation ✅ SHIPPED
|
||||||
|
↓
|
||||||
|
v0.6.13 Responsive & Spacing ✅ SHIPPED
|
||||||
|
↓
|
||||||
|
v0.6.14 Visual Polish ✅ SHIPPED
|
||||||
|
↓
|
||||||
|
v0.6.15 User Display Audit ✅ SHIPPED
|
||||||
|
↓
|
||||||
|
v0.6.16 Usability Survey Gate ✅ SHIPPED
|
||||||
|
```
|
||||||
|
|
||||||
|
Each version is independently shippable and testable. No version depends on
|
||||||
|
anything after it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What "Usability Survey by Code" Means
|
||||||
|
|
||||||
|
The v0.6.16 deliverables give Claude Code (or any automated tool) three things:
|
||||||
|
|
||||||
|
1. **A structured inventory** (`ui-inventory.json`) of every component, on every
|
||||||
|
surface, with its CSS file and line number.
|
||||||
|
|
||||||
|
2. **Automated checks** (contrast, touch targets, token usage) that produce
|
||||||
|
machine-readable pass/fail reports.
|
||||||
|
|
||||||
|
3. **A survey prompt** (`USABILITY-SURVEY.md`) with explicit criteria and file
|
||||||
|
paths, so Claude Code can `cat` the relevant files, run the scripts, and
|
||||||
|
produce a scored report without human guidance.
|
||||||
|
|
||||||
|
The survey is not a substitute for real user testing — it's a structural
|
||||||
|
quality gate that catches the class of bugs (overflow, contrast, misalignment,
|
||||||
|
inconsistent styling) that have historically slipped through.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Switchboard Core — Roadmap
|
# Armature — Roadmap
|
||||||
|
|
||||||
## Current: v0.6.7 — Native mTLS
|
## Current: v0.6.14 — Visual Polish
|
||||||
|
|
||||||
Self-hosted extensible platform. Auth, identity, packages, Starlark sandbox,
|
Self-hosted extensible platform. Auth, identity, packages, Starlark sandbox,
|
||||||
storage, realtime, and ops are kernel primitives. Everything else is an extension.
|
storage, realtime, and ops are kernel primitives. Everything else is an extension.
|
||||||
@@ -135,7 +135,7 @@ End-to-end mutual TLS without a reverse proxy. Targets systemd+podman deployment
|
|||||||
| TLS server mode | ✅ | Go binary calls `ListenAndServeTLS` directly. `TLS_CERT`, `TLS_KEY`, `TLS_CA` path config. TLS 1.3 minimum, no fallback. `server/config/tls.go` loader + validation. |
|
| TLS server mode | ✅ | Go binary calls `ListenAndServeTLS` directly. `TLS_CERT`, `TLS_KEY`, `TLS_CA` path config. TLS 1.3 minimum, no fallback. `server/config/tls.go` loader + validation. |
|
||||||
| `MTLSNativeProvider` | ✅ | Reads `r.TLS.PeerCertificates[0]` — no header trust. `Subject.CommonName` → username. `sha256(Raw)` → `external_id`. Auto-provisions `auth_source=mtls`. Renamed existing `mtls.go` → `mtls_proxy.go`. Shared helpers in `mtls_helpers.go`. |
|
| `MTLSNativeProvider` | ✅ | Reads `r.TLS.PeerCertificates[0]` — no header trust. `Subject.CommonName` → username. `sha256(Raw)` → `external_id`. Auto-provisions `auth_source=mtls`. Renamed existing `mtls.go` → `mtls_proxy.go`. Shared helpers in `mtls_helpers.go`. |
|
||||||
| Node-to-node mTLS | ✅ | `BuildPeerTLSConfig()` constructs outbound TLS config with node cert + CA pool. Forward-looking — cluster registry is DB-backed (PG LISTEN/NOTIFY), no HTTP peer calls yet. |
|
| Node-to-node mTLS | ✅ | `BuildPeerTLSConfig()` constructs outbound TLS config with node cert + CA pool. Forward-looking — cluster registry is DB-backed (PG LISTEN/NOTIFY), no HTTP peer calls yet. |
|
||||||
| `switchboard-ca.sh` | ✅ | Shell wrapper around openssl. Three commands: `init` (CA keypair) · `issue-node --name <n> --san <addrs>` (365d) · `issue-user --cn <name>` (90d). All output PEM. |
|
| `armature-ca.sh` | ✅ | Shell wrapper around openssl. Three commands: `init` (CA keypair) · `issue-node --name <n> --san <addrs>` (365d) · `issue-user --cn <name>` (90d). All output PEM. |
|
||||||
| Unit tests | ✅ | Ephemeral CA via `crypto/x509`. Fabricated `PeerCertificates`. Valid cert → user provisioned · no TLS → `ErrNoCert` · empty certs → `ErrNoCert` · no CN → `ErrInvalidCreds`. |
|
| Unit tests | ✅ | Ephemeral CA via `crypto/x509`. Fabricated `PeerCertificates`. Valid cert → user provisioned · no TLS → `ErrNoCert` · empty certs → `ErrNoCert` · no CN → `ErrInvalidCreds`. |
|
||||||
| Integration tests | ✅ | Real TLS listener on localhost. No cert / wrong CA / expired → TLS handshake rejected. Valid cert → 200. Peer certificate CN + email visibility verified. |
|
| Integration tests | ✅ | Real TLS listener on localhost. No cert / wrong CA / expired → TLS handshake rejected. Valid cert → 200. Peer certificate CN + email visibility verified. |
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
apiVersion: v2
|
apiVersion: v2
|
||||||
name: switchboard
|
name: armature
|
||||||
description: Switchboard Core — self-hosted extension platform
|
description: Armature — self-hosted extension platform
|
||||||
type: application
|
type: application
|
||||||
version: 0.1.0
|
version: 0.1.0
|
||||||
appVersion: "0.0.0" # Patched at CI/release time from /VERSION
|
appVersion: "0.0.0" # Patched at CI/release time from /VERSION
|
||||||
home: https://gobha.ai
|
home: https://gobha.ai
|
||||||
sources:
|
sources:
|
||||||
- https://git.gobha.me/switchboard/core
|
- https://git.gobha.me/armature/core
|
||||||
maintainers:
|
maintainers:
|
||||||
- name: xcaliber
|
- name: xcaliber
|
||||||
|
|||||||
@@ -1,27 +1,27 @@
|
|||||||
# Switchboard Core — PrometheusRule alerts (v0.33.0)
|
# Armature Core — PrometheusRule alerts (v0.33.0)
|
||||||
# Source file for the Helm template. Deploy via:
|
# Source file for the Helm template. Deploy via:
|
||||||
# monitoring.prometheusRule.enabled: true
|
# monitoring.prometheusRule.enabled: true
|
||||||
apiVersion: monitoring.coreos.com/v1
|
apiVersion: monitoring.coreos.com/v1
|
||||||
kind: PrometheusRule
|
kind: PrometheusRule
|
||||||
metadata:
|
metadata:
|
||||||
name: switchboard-alerts
|
name: armature-alerts
|
||||||
spec:
|
spec:
|
||||||
groups:
|
groups:
|
||||||
- name: switchboard.rules
|
- name: armature.rules
|
||||||
rules:
|
rules:
|
||||||
# Pod restart (possible OOM)
|
# Pod restart (possible OOM)
|
||||||
- alert: SwitchboardPodRestart
|
- alert: ArmaturePodRestart
|
||||||
expr: increase(kube_pod_container_status_restarts_total{container="backend"}[1h]) > 0
|
expr: increase(kube_pod_container_status_restarts_total{container="backend"}[1h]) > 0
|
||||||
for: 0m
|
for: 0m
|
||||||
labels:
|
labels:
|
||||||
severity: warning
|
severity: warning
|
||||||
annotations:
|
annotations:
|
||||||
summary: "Switchboard backend pod restarted (possible OOM)"
|
summary: "Armature backend pod restarted (possible OOM)"
|
||||||
description: "Container {{ $labels.container }} in pod {{ $labels.pod }} restarted."
|
description: "Container {{ $labels.container }} in pod {{ $labels.pod }} restarted."
|
||||||
|
|
||||||
# Provider down for 5+ minutes
|
# Provider down for 5+ minutes
|
||||||
- alert: SwitchboardProviderDown
|
- alert: ArmatureProviderDown
|
||||||
expr: switchboard_provider_status > 2
|
expr: armature_provider_status > 2
|
||||||
for: 5m
|
for: 5m
|
||||||
labels:
|
labels:
|
||||||
severity: critical
|
severity: critical
|
||||||
@@ -29,8 +29,8 @@ spec:
|
|||||||
summary: "Provider {{ $labels.provider_config_id }} is down"
|
summary: "Provider {{ $labels.provider_config_id }} is down"
|
||||||
|
|
||||||
# DB pool >80% utilized
|
# DB pool >80% utilized
|
||||||
- alert: SwitchboardDBPoolExhaustion
|
- alert: ArmatureDBPoolExhaustion
|
||||||
expr: switchboard_db_in_use_connections / switchboard_db_open_connections > 0.8
|
expr: armature_db_in_use_connections / armature_db_open_connections > 0.8
|
||||||
for: 5m
|
for: 5m
|
||||||
labels:
|
labels:
|
||||||
severity: warning
|
severity: warning
|
||||||
@@ -38,10 +38,10 @@ spec:
|
|||||||
summary: "DB connection pool >80% utilized"
|
summary: "DB connection pool >80% utilized"
|
||||||
|
|
||||||
# HTTP 5xx error rate >5%
|
# HTTP 5xx error rate >5%
|
||||||
- alert: SwitchboardHighErrorRate
|
- alert: ArmatureHighErrorRate
|
||||||
expr: >
|
expr: >
|
||||||
sum(rate(switchboard_http_requests_total{status=~"5.."}[5m]))
|
sum(rate(armature_http_requests_total{status=~"5.."}[5m]))
|
||||||
/ sum(rate(switchboard_http_requests_total[5m])) > 0.05
|
/ sum(rate(armature_http_requests_total[5m])) > 0.05
|
||||||
for: 5m
|
for: 5m
|
||||||
labels:
|
labels:
|
||||||
severity: warning
|
severity: warning
|
||||||
@@ -49,10 +49,10 @@ spec:
|
|||||||
summary: "HTTP 5xx error rate exceeds 5%"
|
summary: "HTTP 5xx error rate exceeds 5%"
|
||||||
|
|
||||||
# Task failure rate >25%
|
# Task failure rate >25%
|
||||||
- alert: SwitchboardTaskFailureRate
|
- alert: ArmatureTaskFailureRate
|
||||||
expr: >
|
expr: >
|
||||||
rate(switchboard_task_executions_total{status="error"}[15m])
|
rate(armature_task_executions_total{status="error"}[15m])
|
||||||
/ rate(switchboard_task_executions_total[15m]) > 0.25
|
/ rate(armature_task_executions_total[15m]) > 0.25
|
||||||
for: 10m
|
for: 10m
|
||||||
labels:
|
labels:
|
||||||
severity: warning
|
severity: warning
|
||||||
@@ -60,8 +60,8 @@ spec:
|
|||||||
summary: "Task failure rate exceeds 25% over 15 minutes"
|
summary: "Task failure rate exceeds 25% over 15 minutes"
|
||||||
|
|
||||||
# No completions processed in 15 minutes (canary)
|
# No completions processed in 15 minutes (canary)
|
||||||
- alert: SwitchboardNoCompletions
|
- alert: ArmatureNoCompletions
|
||||||
expr: sum(rate(switchboard_completions_total[10m])) == 0
|
expr: sum(rate(armature_completions_total[10m])) == 0
|
||||||
for: 15m
|
for: 15m
|
||||||
labels:
|
labels:
|
||||||
severity: critical
|
severity: critical
|
||||||
@@ -17,10 +17,10 @@
|
|||||||
{ "type": "panel", "id": "gauge", "name": "Gauge", "version": "" }
|
{ "type": "panel", "id": "gauge", "name": "Gauge", "version": "" }
|
||||||
],
|
],
|
||||||
"id": null,
|
"id": null,
|
||||||
"uid": "switchboard-overview",
|
"uid": "armature-overview",
|
||||||
"title": "Switchboard Core — Overview",
|
"title": "Armature — Overview",
|
||||||
"description": "System overview: request rates, latency, provider health, token usage, DB pool.",
|
"description": "System overview: request rates, latency, provider health, token usage, DB pool.",
|
||||||
"tags": ["switchboard"],
|
"tags": ["armature"],
|
||||||
"timezone": "browser",
|
"timezone": "browser",
|
||||||
"refresh": "30s",
|
"refresh": "30s",
|
||||||
"schemaVersion": 38,
|
"schemaVersion": 38,
|
||||||
@@ -37,7 +37,7 @@
|
|||||||
"name": "namespace",
|
"name": "namespace",
|
||||||
"type": "query",
|
"type": "query",
|
||||||
"datasource": { "type": "prometheus", "uid": "${datasource}" },
|
"datasource": { "type": "prometheus", "uid": "${datasource}" },
|
||||||
"query": "label_values(switchboard_http_requests_total, namespace)",
|
"query": "label_values(armature_http_requests_total, namespace)",
|
||||||
"includeAll": true,
|
"includeAll": true,
|
||||||
"current": { "text": "All", "value": "$__all" }
|
"current": { "text": "All", "value": "$__all" }
|
||||||
},
|
},
|
||||||
@@ -45,7 +45,7 @@
|
|||||||
"name": "pod",
|
"name": "pod",
|
||||||
"type": "query",
|
"type": "query",
|
||||||
"datasource": { "type": "prometheus", "uid": "${datasource}" },
|
"datasource": { "type": "prometheus", "uid": "${datasource}" },
|
||||||
"query": "label_values(switchboard_http_requests_total{namespace=~\"$namespace\"}, pod)",
|
"query": "label_values(armature_http_requests_total{namespace=~\"$namespace\"}, pod)",
|
||||||
"includeAll": true,
|
"includeAll": true,
|
||||||
"current": { "text": "All", "value": "$__all" }
|
"current": { "text": "All", "value": "$__all" }
|
||||||
}
|
}
|
||||||
@@ -58,7 +58,7 @@
|
|||||||
"gridPos": { "h": 8, "w": 6, "x": 0, "y": 0 },
|
"gridPos": { "h": 8, "w": 6, "x": 0, "y": 0 },
|
||||||
"targets": [
|
"targets": [
|
||||||
{
|
{
|
||||||
"expr": "sum(rate(switchboard_http_requests_total{namespace=~\"$namespace\"}[5m]))",
|
"expr": "sum(rate(armature_http_requests_total{namespace=~\"$namespace\"}[5m]))",
|
||||||
"legendFormat": "Total req/s"
|
"legendFormat": "Total req/s"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -69,7 +69,7 @@
|
|||||||
"gridPos": { "h": 8, "w": 6, "x": 6, "y": 0 },
|
"gridPos": { "h": 8, "w": 6, "x": 6, "y": 0 },
|
||||||
"targets": [
|
"targets": [
|
||||||
{
|
{
|
||||||
"expr": "sum(rate(switchboard_http_requests_total{namespace=~\"$namespace\",status=~\"5..\"}[5m])) / sum(rate(switchboard_http_requests_total{namespace=~\"$namespace\"}[5m]))",
|
"expr": "sum(rate(armature_http_requests_total{namespace=~\"$namespace\",status=~\"5..\"}[5m])) / sum(rate(armature_http_requests_total{namespace=~\"$namespace\"}[5m]))",
|
||||||
"legendFormat": "5xx rate"
|
"legendFormat": "5xx rate"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -93,15 +93,15 @@
|
|||||||
"gridPos": { "h": 8, "w": 6, "x": 12, "y": 0 },
|
"gridPos": { "h": 8, "w": 6, "x": 12, "y": 0 },
|
||||||
"targets": [
|
"targets": [
|
||||||
{
|
{
|
||||||
"expr": "histogram_quantile(0.50, sum(rate(switchboard_http_request_duration_seconds_bucket{namespace=~\"$namespace\"}[5m])) by (le))",
|
"expr": "histogram_quantile(0.50, sum(rate(armature_http_request_duration_seconds_bucket{namespace=~\"$namespace\"}[5m])) by (le))",
|
||||||
"legendFormat": "p50"
|
"legendFormat": "p50"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"expr": "histogram_quantile(0.95, sum(rate(switchboard_http_request_duration_seconds_bucket{namespace=~\"$namespace\"}[5m])) by (le))",
|
"expr": "histogram_quantile(0.95, sum(rate(armature_http_request_duration_seconds_bucket{namespace=~\"$namespace\"}[5m])) by (le))",
|
||||||
"legendFormat": "p95"
|
"legendFormat": "p95"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"expr": "histogram_quantile(0.99, sum(rate(switchboard_http_request_duration_seconds_bucket{namespace=~\"$namespace\"}[5m])) by (le))",
|
"expr": "histogram_quantile(0.99, sum(rate(armature_http_request_duration_seconds_bucket{namespace=~\"$namespace\"}[5m])) by (le))",
|
||||||
"legendFormat": "p99"
|
"legendFormat": "p99"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -113,7 +113,7 @@
|
|||||||
"gridPos": { "h": 8, "w": 6, "x": 18, "y": 0 },
|
"gridPos": { "h": 8, "w": 6, "x": 18, "y": 0 },
|
||||||
"targets": [
|
"targets": [
|
||||||
{
|
{
|
||||||
"expr": "sum(switchboard_websocket_connections{namespace=~\"$namespace\"})",
|
"expr": "sum(armature_websocket_connections{namespace=~\"$namespace\"})",
|
||||||
"legendFormat": "Active"
|
"legendFormat": "Active"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -124,7 +124,7 @@
|
|||||||
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 8 },
|
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 8 },
|
||||||
"targets": [
|
"targets": [
|
||||||
{
|
{
|
||||||
"expr": "sum by (provider_config_id) (rate(switchboard_completions_total{namespace=~\"$namespace\"}[5m]))",
|
"expr": "sum by (provider_config_id) (rate(armature_completions_total{namespace=~\"$namespace\"}[5m]))",
|
||||||
"legendFormat": "{{provider_config_id}}"
|
"legendFormat": "{{provider_config_id}}"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -135,7 +135,7 @@
|
|||||||
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 8 },
|
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 8 },
|
||||||
"targets": [
|
"targets": [
|
||||||
{
|
{
|
||||||
"expr": "histogram_quantile(0.95, sum by (provider_config_id, le) (rate(switchboard_completion_duration_seconds_bucket{namespace=~\"$namespace\"}[5m])))",
|
"expr": "histogram_quantile(0.95, sum by (provider_config_id, le) (rate(armature_completion_duration_seconds_bucket{namespace=~\"$namespace\"}[5m])))",
|
||||||
"legendFormat": "{{provider_config_id}}"
|
"legendFormat": "{{provider_config_id}}"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -147,7 +147,7 @@
|
|||||||
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 16 },
|
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 16 },
|
||||||
"targets": [
|
"targets": [
|
||||||
{
|
{
|
||||||
"expr": "sum by (model_id) (rate(switchboard_completion_tokens_total{namespace=~\"$namespace\"}[5m])) * 60",
|
"expr": "sum by (model_id) (rate(armature_completion_tokens_total{namespace=~\"$namespace\"}[5m])) * 60",
|
||||||
"legendFormat": "{{model_id}}"
|
"legendFormat": "{{model_id}}"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -158,7 +158,7 @@
|
|||||||
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 16 },
|
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 16 },
|
||||||
"targets": [
|
"targets": [
|
||||||
{
|
{
|
||||||
"expr": "switchboard_provider_status{namespace=~\"$namespace\"}",
|
"expr": "armature_provider_status{namespace=~\"$namespace\"}",
|
||||||
"legendFormat": "{{provider_config_id}}"
|
"legendFormat": "{{provider_config_id}}"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -179,15 +179,15 @@
|
|||||||
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 24 },
|
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 24 },
|
||||||
"targets": [
|
"targets": [
|
||||||
{
|
{
|
||||||
"expr": "switchboard_db_open_connections{namespace=~\"$namespace\"}",
|
"expr": "armature_db_open_connections{namespace=~\"$namespace\"}",
|
||||||
"legendFormat": "Open"
|
"legendFormat": "Open"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"expr": "switchboard_db_in_use_connections{namespace=~\"$namespace\"}",
|
"expr": "armature_db_in_use_connections{namespace=~\"$namespace\"}",
|
||||||
"legendFormat": "In Use"
|
"legendFormat": "In Use"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"expr": "switchboard_db_idle_connections{namespace=~\"$namespace\"}",
|
"expr": "armature_db_idle_connections{namespace=~\"$namespace\"}",
|
||||||
"legendFormat": "Idle"
|
"legendFormat": "Idle"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -198,7 +198,7 @@
|
|||||||
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 24 },
|
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 24 },
|
||||||
"targets": [
|
"targets": [
|
||||||
{
|
{
|
||||||
"expr": "sum by (status) (rate(switchboard_task_executions_total{namespace=~\"$namespace\"}[5m]))",
|
"expr": "sum by (status) (rate(armature_task_executions_total{namespace=~\"$namespace\"}[5m]))",
|
||||||
"legendFormat": "{{status}}"
|
"legendFormat": "{{status}}"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
Chat Switchboard {{ .Chart.AppVersion }} deployed.
|
Armature {{ .Chart.AppVersion }} deployed.
|
||||||
|
|
||||||
{{- if .Values.ingress.enabled }}
|
{{- if .Values.ingress.enabled }}
|
||||||
Access the application at:
|
Access the application at:
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{{/*
|
{{/*
|
||||||
Common labels
|
Common labels
|
||||||
*/}}
|
*/}}
|
||||||
{{- define "switchboard.labels" -}}
|
{{- define "armature.labels" -}}
|
||||||
app.kubernetes.io/name: {{ .Chart.Name }}
|
app.kubernetes.io/name: {{ .Chart.Name }}
|
||||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||||
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
|
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
|
||||||
@@ -12,23 +12,23 @@ helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version }}
|
|||||||
{{/*
|
{{/*
|
||||||
Backend selector labels
|
Backend selector labels
|
||||||
*/}}
|
*/}}
|
||||||
{{- define "switchboard.backend.labels" -}}
|
{{- define "armature.backend.labels" -}}
|
||||||
app.kubernetes.io/component: backend
|
app.kubernetes.io/component: backend
|
||||||
{{ include "switchboard.labels" . }}
|
{{ include "armature.labels" . }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
|
||||||
{{/*
|
{{/*
|
||||||
Frontend selector labels
|
Frontend selector labels
|
||||||
*/}}
|
*/}}
|
||||||
{{- define "switchboard.frontend.labels" -}}
|
{{- define "armature.frontend.labels" -}}
|
||||||
app.kubernetes.io/component: frontend
|
app.kubernetes.io/component: frontend
|
||||||
{{ include "switchboard.labels" . }}
|
{{ include "armature.labels" . }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
|
||||||
{{/*
|
{{/*
|
||||||
Secret name
|
Secret name
|
||||||
*/}}
|
*/}}
|
||||||
{{- define "switchboard.secretName" -}}
|
{{- define "armature.secretName" -}}
|
||||||
{{- if .Values.existingSecret -}}
|
{{- if .Values.existingSecret -}}
|
||||||
{{ .Values.existingSecret }}
|
{{ .Values.existingSecret }}
|
||||||
{{- else -}}
|
{{- else -}}
|
||||||
@@ -39,21 +39,21 @@ Secret name
|
|||||||
{{/*
|
{{/*
|
||||||
Backend image
|
Backend image
|
||||||
*/}}
|
*/}}
|
||||||
{{- define "switchboard.backend.image" -}}
|
{{- define "armature.backend.image" -}}
|
||||||
{{ .Values.backend.image.repository }}:{{ .Values.backend.image.tag | default .Chart.AppVersion }}
|
{{ .Values.backend.image.repository }}:{{ .Values.backend.image.tag | default .Chart.AppVersion }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
|
||||||
{{/*
|
{{/*
|
||||||
Frontend image
|
Frontend image
|
||||||
*/}}
|
*/}}
|
||||||
{{- define "switchboard.frontend.image" -}}
|
{{- define "armature.frontend.image" -}}
|
||||||
{{ .Values.frontend.image.repository }}:{{ .Values.frontend.image.tag | default .Chart.AppVersion }}
|
{{ .Values.frontend.image.repository }}:{{ .Values.frontend.image.tag | default .Chart.AppVersion }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
|
||||||
{{/*
|
{{/*
|
||||||
Database URL — assembled from postgres.* if url is empty
|
Database URL — assembled from postgres.* if url is empty
|
||||||
*/}}
|
*/}}
|
||||||
{{- define "switchboard.databaseURL" -}}
|
{{- define "armature.databaseURL" -}}
|
||||||
{{- if .Values.database.url -}}
|
{{- if .Values.database.url -}}
|
||||||
{{ .Values.database.url }}
|
{{ .Values.database.url }}
|
||||||
{{- else if eq .Values.database.driver "sqlite" -}}
|
{{- else if eq .Values.database.driver "sqlite" -}}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ kind: ConfigMap
|
|||||||
metadata:
|
metadata:
|
||||||
name: {{ .Release.Name }}-config
|
name: {{ .Release.Name }}-config
|
||||||
labels:
|
labels:
|
||||||
{{- include "switchboard.labels" . | nindent 4 }}
|
{{- include "armature.labels" . | nindent 4 }}
|
||||||
data:
|
data:
|
||||||
PORT: {{ .Values.backend.port | quote }}
|
PORT: {{ .Values.backend.port | quote }}
|
||||||
BASE_PATH: {{ .Values.basePath | quote }}
|
BASE_PATH: {{ .Values.basePath | quote }}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ kind: CronJob
|
|||||||
metadata:
|
metadata:
|
||||||
name: {{ .Release.Name }}-backup
|
name: {{ .Release.Name }}-backup
|
||||||
labels:
|
labels:
|
||||||
{{- include "switchboard.labels" . | nindent 4 }}
|
{{- include "armature.labels" . | nindent 4 }}
|
||||||
app.kubernetes.io/component: backup
|
app.kubernetes.io/component: backup
|
||||||
spec:
|
spec:
|
||||||
schedule: {{ .Values.backup.schedule | quote }}
|
schedule: {{ .Values.backup.schedule | quote }}
|
||||||
@@ -35,7 +35,7 @@ spec:
|
|||||||
- |
|
- |
|
||||||
set -e
|
set -e
|
||||||
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
|
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
|
||||||
FILENAME="switchboard-${TIMESTAMP}.sql.gz"
|
FILENAME="armature-${TIMESTAMP}.sql.gz"
|
||||||
|
|
||||||
echo "Starting backup: ${FILENAME}"
|
echo "Starting backup: ${FILENAME}"
|
||||||
pg_dump "${DATABASE_URL}" | gzip > "/backup/${FILENAME}"
|
pg_dump "${DATABASE_URL}" | gzip > "/backup/${FILENAME}"
|
||||||
@@ -58,22 +58,22 @@ spec:
|
|||||||
|
|
||||||
# Prune old local backups beyond retention
|
# Prune old local backups beyond retention
|
||||||
cd /backup
|
cd /backup
|
||||||
ls -t switchboard-*.sql.gz 2>/dev/null | tail -n +{{ add1 .Values.backup.retention }} | xargs -r rm -v
|
ls -t armature-*.sql.gz 2>/dev/null | tail -n +{{ add1 .Values.backup.retention }} | xargs -r rm -v
|
||||||
echo "Backup complete"
|
echo "Backup complete"
|
||||||
env:
|
env:
|
||||||
- name: DATABASE_URL
|
- name: DATABASE_URL
|
||||||
value: {{ include "switchboard.databaseURL" . | quote }}
|
value: {{ include "armature.databaseURL" . | quote }}
|
||||||
{{- if .Values.backup.s3.enabled }}
|
{{- if .Values.backup.s3.enabled }}
|
||||||
- name: S3_ACCESS_KEY
|
- name: S3_ACCESS_KEY
|
||||||
valueFrom:
|
valueFrom:
|
||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
name: {{ include "switchboard.secretName" . }}
|
name: {{ include "armature.secretName" . }}
|
||||||
key: BACKUP_S3_ACCESS_KEY
|
key: BACKUP_S3_ACCESS_KEY
|
||||||
optional: true
|
optional: true
|
||||||
- name: S3_SECRET_KEY
|
- name: S3_SECRET_KEY
|
||||||
valueFrom:
|
valueFrom:
|
||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
name: {{ include "switchboard.secretName" . }}
|
name: {{ include "armature.secretName" . }}
|
||||||
key: BACKUP_S3_SECRET_KEY
|
key: BACKUP_S3_SECRET_KEY
|
||||||
optional: true
|
optional: true
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ kind: Deployment
|
|||||||
metadata:
|
metadata:
|
||||||
name: {{ .Release.Name }}-backend
|
name: {{ .Release.Name }}-backend
|
||||||
labels:
|
labels:
|
||||||
{{- include "switchboard.backend.labels" . | nindent 4 }}
|
{{- include "armature.backend.labels" . | nindent 4 }}
|
||||||
spec:
|
spec:
|
||||||
replicas: {{ .Values.backend.replicaCount }}
|
replicas: {{ .Values.backend.replicaCount }}
|
||||||
selector:
|
selector:
|
||||||
@@ -38,7 +38,7 @@ spec:
|
|||||||
topologyKey: kubernetes.io/hostname
|
topologyKey: kubernetes.io/hostname
|
||||||
containers:
|
containers:
|
||||||
- name: backend
|
- name: backend
|
||||||
image: {{ include "switchboard.backend.image" . }}
|
image: {{ include "armature.backend.image" . }}
|
||||||
imagePullPolicy: {{ .Values.backend.image.pullPolicy }}
|
imagePullPolicy: {{ .Values.backend.image.pullPolicy }}
|
||||||
ports:
|
ports:
|
||||||
- name: http
|
- name: http
|
||||||
@@ -48,10 +48,10 @@ spec:
|
|||||||
- configMapRef:
|
- configMapRef:
|
||||||
name: {{ .Release.Name }}-config
|
name: {{ .Release.Name }}-config
|
||||||
- secretRef:
|
- secretRef:
|
||||||
name: {{ include "switchboard.secretName" . }}
|
name: {{ include "armature.secretName" . }}
|
||||||
env:
|
env:
|
||||||
- name: DATABASE_URL
|
- name: DATABASE_URL
|
||||||
value: {{ include "switchboard.databaseURL" . | quote }}
|
value: {{ include "armature.databaseURL" . | quote }}
|
||||||
resources:
|
resources:
|
||||||
{{- toYaml .Values.backend.resources | nindent 12 }}
|
{{- toYaml .Values.backend.resources | nindent 12 }}
|
||||||
{{- if .Values.persistence.enabled }}
|
{{- if .Values.persistence.enabled }}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ kind: Deployment
|
|||||||
metadata:
|
metadata:
|
||||||
name: {{ .Release.Name }}-frontend
|
name: {{ .Release.Name }}-frontend
|
||||||
labels:
|
labels:
|
||||||
{{- include "switchboard.frontend.labels" . | nindent 4 }}
|
{{- include "armature.frontend.labels" . | nindent 4 }}
|
||||||
spec:
|
spec:
|
||||||
replicas: {{ .Values.frontend.replicaCount }}
|
replicas: {{ .Values.frontend.replicaCount }}
|
||||||
selector:
|
selector:
|
||||||
@@ -24,7 +24,7 @@ spec:
|
|||||||
{{- end }}
|
{{- end }}
|
||||||
containers:
|
containers:
|
||||||
- name: frontend
|
- name: frontend
|
||||||
image: {{ include "switchboard.frontend.image" . }}
|
image: {{ include "armature.frontend.image" . }}
|
||||||
imagePullPolicy: {{ .Values.frontend.image.pullPolicy }}
|
imagePullPolicy: {{ .Values.frontend.image.pullPolicy }}
|
||||||
ports:
|
ports:
|
||||||
- name: http
|
- name: http
|
||||||
|
|||||||
@@ -2,13 +2,13 @@
|
|||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
kind: ConfigMap
|
kind: ConfigMap
|
||||||
metadata:
|
metadata:
|
||||||
name: {{ include "switchboard.fullname" . }}-grafana-dashboard
|
name: {{ include "armature.fullname" . }}-grafana-dashboard
|
||||||
labels:
|
labels:
|
||||||
{{- include "switchboard.labels" . | nindent 4 }}
|
{{- include "armature.labels" . | nindent 4 }}
|
||||||
{{- with .Values.monitoring.grafanaDashboard.labels }}
|
{{- with .Values.monitoring.grafanaDashboard.labels }}
|
||||||
{{- toYaml . | nindent 4 }}
|
{{- toYaml . | nindent 4 }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
data:
|
data:
|
||||||
switchboard-overview.json: |-
|
armature-overview.json: |-
|
||||||
{{- .Files.Get "dashboards/switchboard-overview.json" | nindent 4 }}
|
{{- .Files.Get "dashboards/armature-overview.json" | nindent 4 }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ kind: Ingress
|
|||||||
metadata:
|
metadata:
|
||||||
name: {{ .Release.Name }}-ingress
|
name: {{ .Release.Name }}-ingress
|
||||||
labels:
|
labels:
|
||||||
{{- include "switchboard.labels" . | nindent 4 }}
|
{{- include "armature.labels" . | nindent 4 }}
|
||||||
{{- if or .Values.ingress.annotations (and (eq (default "traefik" .Values.ingress.className) "traefik") .Values.ingress.retry.annotateIngress) }}
|
{{- if or .Values.ingress.annotations (and (eq (default "traefik" .Values.ingress.className) "traefik") .Values.ingress.retry.annotateIngress) }}
|
||||||
annotations:
|
annotations:
|
||||||
{{- with .Values.ingress.annotations }}
|
{{- with .Values.ingress.annotations }}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ kind: Middleware
|
|||||||
metadata:
|
metadata:
|
||||||
name: {{ .Release.Name }}-retry
|
name: {{ .Release.Name }}-retry
|
||||||
labels:
|
labels:
|
||||||
{{- include "switchboard.labels" . | nindent 4 }}
|
{{- include "armature.labels" . | nindent 4 }}
|
||||||
spec:
|
spec:
|
||||||
retry:
|
retry:
|
||||||
attempts: {{ .Values.ingress.retry.attempts | default 2 }}
|
attempts: {{ .Values.ingress.retry.attempts | default 2 }}
|
||||||
|
|||||||
@@ -2,57 +2,57 @@
|
|||||||
apiVersion: monitoring.coreos.com/v1
|
apiVersion: monitoring.coreos.com/v1
|
||||||
kind: PrometheusRule
|
kind: PrometheusRule
|
||||||
metadata:
|
metadata:
|
||||||
name: {{ include "switchboard.fullname" . }}-alerts
|
name: {{ include "armature.fullname" . }}-alerts
|
||||||
labels:
|
labels:
|
||||||
{{- include "switchboard.labels" . | nindent 4 }}
|
{{- include "armature.labels" . | nindent 4 }}
|
||||||
{{- with .Values.monitoring.prometheusRule.labels }}
|
{{- with .Values.monitoring.prometheusRule.labels }}
|
||||||
{{- toYaml . | nindent 4 }}
|
{{- toYaml . | nindent 4 }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
spec:
|
spec:
|
||||||
groups:
|
groups:
|
||||||
- name: switchboard.rules
|
- name: armature.rules
|
||||||
rules:
|
rules:
|
||||||
- alert: SwitchboardPodRestart
|
- alert: ArmaturePodRestart
|
||||||
expr: increase(kube_pod_container_status_restarts_total{container="backend"}[1h]) > 0
|
expr: increase(kube_pod_container_status_restarts_total{container="backend"}[1h]) > 0
|
||||||
for: 0m
|
for: 0m
|
||||||
labels:
|
labels:
|
||||||
severity: warning
|
severity: warning
|
||||||
annotations:
|
annotations:
|
||||||
summary: "Switchboard backend pod restarted (possible OOM)"
|
summary: "Armature backend pod restarted (possible OOM)"
|
||||||
- alert: SwitchboardProviderDown
|
- alert: ArmatureProviderDown
|
||||||
expr: switchboard_provider_status > 2
|
expr: armature_provider_status > 2
|
||||||
for: 5m
|
for: 5m
|
||||||
labels:
|
labels:
|
||||||
severity: critical
|
severity: critical
|
||||||
annotations:
|
annotations:
|
||||||
summary: "Provider {{`{{ $labels.provider_config_id }}`}} is down"
|
summary: "Provider {{`{{ $labels.provider_config_id }}`}} is down"
|
||||||
- alert: SwitchboardDBPoolExhaustion
|
- alert: ArmatureDBPoolExhaustion
|
||||||
expr: switchboard_db_in_use_connections / switchboard_db_open_connections > 0.8
|
expr: armature_db_in_use_connections / armature_db_open_connections > 0.8
|
||||||
for: 5m
|
for: 5m
|
||||||
labels:
|
labels:
|
||||||
severity: warning
|
severity: warning
|
||||||
annotations:
|
annotations:
|
||||||
summary: "DB connection pool >80% utilized"
|
summary: "DB connection pool >80% utilized"
|
||||||
- alert: SwitchboardHighErrorRate
|
- alert: ArmatureHighErrorRate
|
||||||
expr: |
|
expr: |
|
||||||
sum(rate(switchboard_http_requests_total{status=~"5.."}[5m]))
|
sum(rate(armature_http_requests_total{status=~"5.."}[5m]))
|
||||||
/ sum(rate(switchboard_http_requests_total[5m])) > 0.05
|
/ sum(rate(armature_http_requests_total[5m])) > 0.05
|
||||||
for: 5m
|
for: 5m
|
||||||
labels:
|
labels:
|
||||||
severity: warning
|
severity: warning
|
||||||
annotations:
|
annotations:
|
||||||
summary: "HTTP 5xx error rate exceeds 5%"
|
summary: "HTTP 5xx error rate exceeds 5%"
|
||||||
- alert: SwitchboardTaskFailureRate
|
- alert: ArmatureTaskFailureRate
|
||||||
expr: |
|
expr: |
|
||||||
rate(switchboard_task_executions_total{status="error"}[15m])
|
rate(armature_task_executions_total{status="error"}[15m])
|
||||||
/ rate(switchboard_task_executions_total[15m]) > 0.25
|
/ rate(armature_task_executions_total[15m]) > 0.25
|
||||||
for: 10m
|
for: 10m
|
||||||
labels:
|
labels:
|
||||||
severity: warning
|
severity: warning
|
||||||
annotations:
|
annotations:
|
||||||
summary: "Task failure rate exceeds 25%"
|
summary: "Task failure rate exceeds 25%"
|
||||||
- alert: SwitchboardNoCompletions
|
- alert: ArmatureNoCompletions
|
||||||
expr: sum(rate(switchboard_completions_total[10m])) == 0
|
expr: sum(rate(armature_completions_total[10m])) == 0
|
||||||
for: 15m
|
for: 15m
|
||||||
labels:
|
labels:
|
||||||
severity: critical
|
severity: critical
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ kind: PersistentVolumeClaim
|
|||||||
metadata:
|
metadata:
|
||||||
name: {{ .Release.Name }}-backup
|
name: {{ .Release.Name }}-backup
|
||||||
labels:
|
labels:
|
||||||
{{- include "switchboard.labels" . | nindent 4 }}
|
{{- include "armature.labels" . | nindent 4 }}
|
||||||
app.kubernetes.io/component: backup
|
app.kubernetes.io/component: backup
|
||||||
spec:
|
spec:
|
||||||
accessModes:
|
accessModes:
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ kind: PersistentVolumeClaim
|
|||||||
metadata:
|
metadata:
|
||||||
name: {{ .Release.Name }}-data
|
name: {{ .Release.Name }}-data
|
||||||
labels:
|
labels:
|
||||||
{{- include "switchboard.labels" . | nindent 4 }}
|
{{- include "armature.labels" . | nindent 4 }}
|
||||||
spec:
|
spec:
|
||||||
accessModes:
|
accessModes:
|
||||||
- {{ .Values.persistence.accessMode }}
|
- {{ .Values.persistence.accessMode }}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ kind: Secret
|
|||||||
metadata:
|
metadata:
|
||||||
name: {{ .Release.Name }}-secrets
|
name: {{ .Release.Name }}-secrets
|
||||||
labels:
|
labels:
|
||||||
{{- include "switchboard.labels" . | nindent 4 }}
|
{{- include "armature.labels" . | nindent 4 }}
|
||||||
type: Opaque
|
type: Opaque
|
||||||
stringData:
|
stringData:
|
||||||
JWT_SECRET: {{ required "jwtSecret is required" .Values.jwtSecret | quote }}
|
JWT_SECRET: {{ required "jwtSecret is required" .Values.jwtSecret | quote }}
|
||||||
@@ -15,11 +15,11 @@ stringData:
|
|||||||
POSTGRES_PASSWORD: {{ .Values.database.postgres.password | quote }}
|
POSTGRES_PASSWORD: {{ .Values.database.postgres.password | quote }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
{{- if .Values.admin.password }}
|
{{- if .Values.admin.password }}
|
||||||
SWITCHBOARD_ADMIN_USERNAME: {{ .Values.admin.username | quote }}
|
ARMATURE_ADMIN_USERNAME: {{ .Values.admin.username | quote }}
|
||||||
SWITCHBOARD_ADMIN_PASSWORD: {{ .Values.admin.password | quote }}
|
ARMATURE_ADMIN_PASSWORD: {{ .Values.admin.password | quote }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
{{- if .Values.admin.email }}
|
{{- if .Values.admin.email }}
|
||||||
SWITCHBOARD_ADMIN_EMAIL: {{ .Values.admin.email | quote }}
|
ARMATURE_ADMIN_EMAIL: {{ .Values.admin.email | quote }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
{{- if .Values.storage.s3.accessKey }}
|
{{- if .Values.storage.s3.accessKey }}
|
||||||
S3_ACCESS_KEY: {{ .Values.storage.s3.accessKey | quote }}
|
S3_ACCESS_KEY: {{ .Values.storage.s3.accessKey | quote }}
|
||||||
|
|||||||
@@ -2,16 +2,16 @@
|
|||||||
apiVersion: monitoring.coreos.com/v1
|
apiVersion: monitoring.coreos.com/v1
|
||||||
kind: ServiceMonitor
|
kind: ServiceMonitor
|
||||||
metadata:
|
metadata:
|
||||||
name: {{ include "switchboard.fullname" . }}
|
name: {{ include "armature.fullname" . }}
|
||||||
labels:
|
labels:
|
||||||
{{- include "switchboard.labels" . | nindent 4 }}
|
{{- include "armature.labels" . | nindent 4 }}
|
||||||
{{- with .Values.monitoring.serviceMonitor.labels }}
|
{{- with .Values.monitoring.serviceMonitor.labels }}
|
||||||
{{- toYaml . | nindent 4 }}
|
{{- toYaml . | nindent 4 }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
spec:
|
spec:
|
||||||
selector:
|
selector:
|
||||||
matchLabels:
|
matchLabels:
|
||||||
{{- include "switchboard.selectorLabels" . | nindent 6 }}
|
{{- include "armature.selectorLabels" . | nindent 6 }}
|
||||||
app.kubernetes.io/component: backend
|
app.kubernetes.io/component: backend
|
||||||
endpoints:
|
endpoints:
|
||||||
- port: http
|
- port: http
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ kind: Service
|
|||||||
metadata:
|
metadata:
|
||||||
name: {{ .Release.Name }}-backend
|
name: {{ .Release.Name }}-backend
|
||||||
labels:
|
labels:
|
||||||
{{- include "switchboard.backend.labels" . | nindent 4 }}
|
{{- include "armature.backend.labels" . | nindent 4 }}
|
||||||
annotations:
|
annotations:
|
||||||
prometheus.io/scrape: "true"
|
prometheus.io/scrape: "true"
|
||||||
prometheus.io/port: {{ .Values.backend.port | quote }}
|
prometheus.io/port: {{ .Values.backend.port | quote }}
|
||||||
@@ -25,7 +25,7 @@ kind: Service
|
|||||||
metadata:
|
metadata:
|
||||||
name: {{ .Release.Name }}-frontend
|
name: {{ .Release.Name }}-frontend
|
||||||
labels:
|
labels:
|
||||||
{{- include "switchboard.frontend.labels" . | nindent 4 }}
|
{{- include "armature.frontend.labels" . | nindent 4 }}
|
||||||
spec:
|
spec:
|
||||||
type: ClusterIP
|
type: ClusterIP
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
# Switchboard Core — Helm values
|
# Armature — Helm values
|
||||||
# helm install switchboard ./chart
|
# helm install armature ./chart
|
||||||
|
|
||||||
# ── Images ─────────────────────────────────
|
# ── Images ─────────────────────────────────
|
||||||
backend:
|
backend:
|
||||||
image:
|
image:
|
||||||
repository: git.gobha.me/switchboard/core
|
repository: git.gobha.me/armature/core
|
||||||
tag: "" # defaults to Chart.appVersion
|
tag: "" # defaults to Chart.appVersion
|
||||||
pullPolicy: IfNotPresent
|
pullPolicy: IfNotPresent
|
||||||
replicaCount: 2 # v0.32.0: multi-replica HA
|
replicaCount: 2 # v0.32.0: multi-replica HA
|
||||||
@@ -19,7 +19,7 @@ backend:
|
|||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
image:
|
image:
|
||||||
repository: git.gobha.me/switchboard/core
|
repository: git.gobha.me/armature/core
|
||||||
tag: "" # defaults to Chart.appVersion
|
tag: "" # defaults to Chart.appVersion
|
||||||
pullPolicy: IfNotPresent
|
pullPolicy: IfNotPresent
|
||||||
replicaCount: 1
|
replicaCount: 1
|
||||||
@@ -41,12 +41,12 @@ database:
|
|||||||
postgres:
|
postgres:
|
||||||
host: postgresql
|
host: postgresql
|
||||||
port: 5432
|
port: 5432
|
||||||
user: switchboard
|
user: armature
|
||||||
password: "" # set via secret
|
password: "" # set via secret
|
||||||
database: switchboard
|
database: armature
|
||||||
sslmode: disable
|
sslmode: disable
|
||||||
# For sqlite: path inside the container (requires persistence)
|
# For sqlite: path inside the container (requires persistence)
|
||||||
sqlitePath: /data/switchboard.db
|
sqlitePath: /data/armature.db
|
||||||
|
|
||||||
# ── Core ───────────────────────────────────
|
# ── Core ───────────────────────────────────
|
||||||
basePath: "" # URL prefix, e.g. "/dev"
|
basePath: "" # URL prefix, e.g. "/dev"
|
||||||
@@ -85,7 +85,7 @@ ingress:
|
|||||||
enabled: true
|
enabled: true
|
||||||
className: traefik
|
className: traefik
|
||||||
annotations: {}
|
annotations: {}
|
||||||
host: switchboard.local
|
host: armature.local
|
||||||
tls:
|
tls:
|
||||||
enabled: false
|
enabled: false
|
||||||
secretName: ""
|
secretName: ""
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ done
|
|||||||
|
|
||||||
# ── Test 4: Stop one replica → stale sweep ────
|
# ── Test 4: Stop one replica → stale sweep ────
|
||||||
echo "=== Test 4: Stop node-3, wait for sweep ==="
|
echo "=== Test 4: Stop node-3, wait for sweep ==="
|
||||||
docker compose -f docker-compose-e2e.yml stop switchboard-3
|
docker compose -f docker-compose-e2e.yml stop armature-3
|
||||||
|
|
||||||
echo " waiting ${STALE_WAIT}s for stale sweep..."
|
echo " waiting ${STALE_WAIT}s for stale sweep..."
|
||||||
sleep "$STALE_WAIT"
|
sleep "$STALE_WAIT"
|
||||||
@@ -118,7 +118,7 @@ fi
|
|||||||
|
|
||||||
# ── Test 5: Restart replica → re-registers ────
|
# ── Test 5: Restart replica → re-registers ────
|
||||||
echo "=== Test 5: Restart node-3 ==="
|
echo "=== Test 5: Restart node-3 ==="
|
||||||
docker compose -f docker-compose-e2e.yml start switchboard-3
|
docker compose -f docker-compose-e2e.yml start armature-3
|
||||||
|
|
||||||
# Wait for startup + at least one heartbeat
|
# Wait for startup + at least one heartbeat
|
||||||
for i in $(seq 1 30); do
|
for i in $(seq 1 30); do
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ events {
|
|||||||
}
|
}
|
||||||
|
|
||||||
http {
|
http {
|
||||||
upstream switchboard {
|
upstream armature {
|
||||||
server switchboard-1:80;
|
server armature-1:80;
|
||||||
server switchboard-2:80;
|
server armature-2:80;
|
||||||
server switchboard-3:80;
|
server armature-3:80;
|
||||||
}
|
}
|
||||||
|
|
||||||
# WebSocket upgrade map
|
# WebSocket upgrade map
|
||||||
@@ -19,7 +19,7 @@ http {
|
|||||||
listen 80;
|
listen 80;
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
proxy_pass http://switchboard;
|
proxy_pass http://armature;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
@@ -28,7 +28,7 @@ http {
|
|||||||
|
|
||||||
# WebSocket endpoint
|
# WebSocket endpoint
|
||||||
location /ws {
|
location /ws {
|
||||||
proxy_pass http://switchboard;
|
proxy_pass http://armature;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
proxy_set_header Connection $connection_upgrade;
|
proxy_set_header Connection $connection_upgrade;
|
||||||
|
|||||||
@@ -83,11 +83,11 @@ authed() {
|
|||||||
echo -e "\n${YELLOW}═══ Phase 1: Build Images ═══${NC}"
|
echo -e "\n${YELLOW}═══ Phase 1: Build Images ═══${NC}"
|
||||||
|
|
||||||
echo "Building 'old' image from HEAD..."
|
echo "Building 'old' image from HEAD..."
|
||||||
docker build -t switchboard-core:v-old . -q
|
docker build -t armature:v-old . -q
|
||||||
ok "old image built"
|
ok "old image built"
|
||||||
|
|
||||||
echo "Building 'new' image from working tree..."
|
echo "Building 'new' image from working tree..."
|
||||||
docker build -t switchboard-core:v-new . -q
|
docker build -t armature:v-new . -q
|
||||||
ok "new image built"
|
ok "new image built"
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════
|
# ═══════════════════════════════════════════════
|
||||||
@@ -97,7 +97,7 @@ ok "new image built"
|
|||||||
echo -e "\n${YELLOW}═══ Phase 2: Start Cluster (Old Version) ═══${NC}"
|
echo -e "\n${YELLOW}═══ Phase 2: Start Cluster (Old Version) ═══${NC}"
|
||||||
|
|
||||||
# Override the build with old image
|
# Override the build with old image
|
||||||
SWITCHBOARD_IMAGE=switchboard-core:v-old \
|
ARMATURE_IMAGE=armature:v-old \
|
||||||
docker compose -f "$COMPOSE_FILE" up postgres -d
|
docker compose -f "$COMPOSE_FILE" up postgres -d
|
||||||
|
|
||||||
# Wait for postgres
|
# Wait for postgres
|
||||||
@@ -107,30 +107,30 @@ sleep 3
|
|||||||
docker run -d --name sb-old-1 --network "$(basename "$(pwd)")_default" \
|
docker run -d --name sb-old-1 --network "$(basename "$(pwd)")_default" \
|
||||||
-e PORT=8080 -e BASE_PATH="" \
|
-e PORT=8080 -e BASE_PATH="" \
|
||||||
-e DB_DRIVER=postgres \
|
-e DB_DRIVER=postgres \
|
||||||
-e "DATABASE_URL=postgres://switchboard:e2e-password@postgres:5432/switchboard_e2e?sslmode=disable" \
|
-e "DATABASE_URL=postgres://armature:e2e-password@postgres:5432/armature_e2e?sslmode=disable" \
|
||||||
-e JWT_SECRET=e2e-jwt-secret \
|
-e JWT_SECRET=e2e-jwt-secret \
|
||||||
-e "ENCRYPTION_KEY=e2e-encryption-key-32chars!!!!!" \
|
-e "ENCRYPTION_KEY=e2e-encryption-key-32chars!!!!!" \
|
||||||
-e SWITCHBOARD_ADMIN_USERNAME=admin \
|
-e ARMATURE_ADMIN_USERNAME=admin \
|
||||||
-e SWITCHBOARD_ADMIN_PASSWORD=admin \
|
-e ARMATURE_ADMIN_PASSWORD=admin \
|
||||||
-e STORAGE_BACKEND=pvc -e STORAGE_PATH=/data/storage \
|
-e STORAGE_BACKEND=pvc -e STORAGE_PATH=/data/storage \
|
||||||
-e "CORS_ALLOWED_ORIGINS=*" -e EXT_ALLOW_PRIVATE_IPS=true \
|
-e "CORS_ALLOWED_ORIGINS=*" -e EXT_ALLOW_PRIVATE_IPS=true \
|
||||||
-e LOG_FORMAT=text -e LOG_LEVEL=info \
|
-e LOG_FORMAT=text -e LOG_LEVEL=info \
|
||||||
-e "SEED_USERS=alice:password123:user,bob:password456:user" \
|
-e "SEED_USERS=alice:password123:user,bob:password456:user" \
|
||||||
-p 8081:80 switchboard-core:v-old 2>/dev/null || true
|
-p 8081:80 armature:v-old 2>/dev/null || true
|
||||||
|
|
||||||
docker run -d --name sb-old-2 --network "$(basename "$(pwd)")_default" \
|
docker run -d --name sb-old-2 --network "$(basename "$(pwd)")_default" \
|
||||||
-e PORT=8080 -e BASE_PATH="" \
|
-e PORT=8080 -e BASE_PATH="" \
|
||||||
-e DB_DRIVER=postgres \
|
-e DB_DRIVER=postgres \
|
||||||
-e "DATABASE_URL=postgres://switchboard:e2e-password@postgres:5432/switchboard_e2e?sslmode=disable" \
|
-e "DATABASE_URL=postgres://armature:e2e-password@postgres:5432/armature_e2e?sslmode=disable" \
|
||||||
-e JWT_SECRET=e2e-jwt-secret \
|
-e JWT_SECRET=e2e-jwt-secret \
|
||||||
-e "ENCRYPTION_KEY=e2e-encryption-key-32chars!!!!!" \
|
-e "ENCRYPTION_KEY=e2e-encryption-key-32chars!!!!!" \
|
||||||
-e SWITCHBOARD_ADMIN_USERNAME=admin \
|
-e ARMATURE_ADMIN_USERNAME=admin \
|
||||||
-e SWITCHBOARD_ADMIN_PASSWORD=admin \
|
-e ARMATURE_ADMIN_PASSWORD=admin \
|
||||||
-e STORAGE_BACKEND=pvc -e STORAGE_PATH=/data/storage \
|
-e STORAGE_BACKEND=pvc -e STORAGE_PATH=/data/storage \
|
||||||
-e "CORS_ALLOWED_ORIGINS=*" -e EXT_ALLOW_PRIVATE_IPS=true \
|
-e "CORS_ALLOWED_ORIGINS=*" -e EXT_ALLOW_PRIVATE_IPS=true \
|
||||||
-e LOG_FORMAT=text -e LOG_LEVEL=info \
|
-e LOG_FORMAT=text -e LOG_LEVEL=info \
|
||||||
-e "SEED_USERS=alice:password123:user,bob:password456:user" \
|
-e "SEED_USERS=alice:password123:user,bob:password456:user" \
|
||||||
-p 8082:80 switchboard-core:v-old 2>/dev/null || true
|
-p 8082:80 armature:v-old 2>/dev/null || true
|
||||||
|
|
||||||
# Start nginx LB
|
# Start nginx LB
|
||||||
docker compose -f "$COMPOSE_FILE" up lb -d
|
docker compose -f "$COMPOSE_FILE" up lb -d
|
||||||
@@ -189,16 +189,16 @@ echo "Starting new replica-1..."
|
|||||||
docker run -d --name sb-new-1 --network "$(basename "$(pwd)")_default" \
|
docker run -d --name sb-new-1 --network "$(basename "$(pwd)")_default" \
|
||||||
-e PORT=8080 -e BASE_PATH="" \
|
-e PORT=8080 -e BASE_PATH="" \
|
||||||
-e DB_DRIVER=postgres \
|
-e DB_DRIVER=postgres \
|
||||||
-e "DATABASE_URL=postgres://switchboard:e2e-password@postgres:5432/switchboard_e2e?sslmode=disable" \
|
-e "DATABASE_URL=postgres://armature:e2e-password@postgres:5432/armature_e2e?sslmode=disable" \
|
||||||
-e JWT_SECRET=e2e-jwt-secret \
|
-e JWT_SECRET=e2e-jwt-secret \
|
||||||
-e "ENCRYPTION_KEY=e2e-encryption-key-32chars!!!!!" \
|
-e "ENCRYPTION_KEY=e2e-encryption-key-32chars!!!!!" \
|
||||||
-e SWITCHBOARD_ADMIN_USERNAME=admin \
|
-e ARMATURE_ADMIN_USERNAME=admin \
|
||||||
-e SWITCHBOARD_ADMIN_PASSWORD=admin \
|
-e ARMATURE_ADMIN_PASSWORD=admin \
|
||||||
-e STORAGE_BACKEND=pvc -e STORAGE_PATH=/data/storage \
|
-e STORAGE_BACKEND=pvc -e STORAGE_PATH=/data/storage \
|
||||||
-e "CORS_ALLOWED_ORIGINS=*" -e EXT_ALLOW_PRIVATE_IPS=true \
|
-e "CORS_ALLOWED_ORIGINS=*" -e EXT_ALLOW_PRIVATE_IPS=true \
|
||||||
-e LOG_FORMAT=text -e LOG_LEVEL=info \
|
-e LOG_FORMAT=text -e LOG_LEVEL=info \
|
||||||
-e "SEED_USERS=alice:password123:user,bob:password456:user" \
|
-e "SEED_USERS=alice:password123:user,bob:password456:user" \
|
||||||
-p 8081:80 switchboard-core:v-new
|
-p 8081:80 armature:v-new
|
||||||
|
|
||||||
wait_for_health "$R1" "new replica-1"
|
wait_for_health "$R1" "new replica-1"
|
||||||
|
|
||||||
@@ -246,16 +246,16 @@ echo "Starting new replica-2..."
|
|||||||
docker run -d --name sb-new-2 --network "$(basename "$(pwd)")_default" \
|
docker run -d --name sb-new-2 --network "$(basename "$(pwd)")_default" \
|
||||||
-e PORT=8080 -e BASE_PATH="" \
|
-e PORT=8080 -e BASE_PATH="" \
|
||||||
-e DB_DRIVER=postgres \
|
-e DB_DRIVER=postgres \
|
||||||
-e "DATABASE_URL=postgres://switchboard:e2e-password@postgres:5432/switchboard_e2e?sslmode=disable" \
|
-e "DATABASE_URL=postgres://armature:e2e-password@postgres:5432/armature_e2e?sslmode=disable" \
|
||||||
-e JWT_SECRET=e2e-jwt-secret \
|
-e JWT_SECRET=e2e-jwt-secret \
|
||||||
-e "ENCRYPTION_KEY=e2e-encryption-key-32chars!!!!!" \
|
-e "ENCRYPTION_KEY=e2e-encryption-key-32chars!!!!!" \
|
||||||
-e SWITCHBOARD_ADMIN_USERNAME=admin \
|
-e ARMATURE_ADMIN_USERNAME=admin \
|
||||||
-e SWITCHBOARD_ADMIN_PASSWORD=admin \
|
-e ARMATURE_ADMIN_PASSWORD=admin \
|
||||||
-e STORAGE_BACKEND=pvc -e STORAGE_PATH=/data/storage \
|
-e STORAGE_BACKEND=pvc -e STORAGE_PATH=/data/storage \
|
||||||
-e "CORS_ALLOWED_ORIGINS=*" -e EXT_ALLOW_PRIVATE_IPS=true \
|
-e "CORS_ALLOWED_ORIGINS=*" -e EXT_ALLOW_PRIVATE_IPS=true \
|
||||||
-e LOG_FORMAT=text -e LOG_LEVEL=info \
|
-e LOG_FORMAT=text -e LOG_LEVEL=info \
|
||||||
-e "SEED_USERS=alice:password123:user,bob:password456:user" \
|
-e "SEED_USERS=alice:password123:user,bob:password456:user" \
|
||||||
-p 8082:80 switchboard-core:v-new
|
-p 8082:80 armature:v-new
|
||||||
|
|
||||||
wait_for_health "$R2" "new replica-2"
|
wait_for_health "$R2" "new replica-2"
|
||||||
|
|
||||||
|
|||||||
@@ -82,13 +82,13 @@ echo -e "\n${YELLOW}═══ Phase 1: Build Images ═══${NC}"
|
|||||||
# Build "old" image from last commit (before current changes)
|
# Build "old" image from last commit (before current changes)
|
||||||
echo "Building 'old' image from HEAD commit..."
|
echo "Building 'old' image from HEAD commit..."
|
||||||
git stash -q 2>/dev/null || true
|
git stash -q 2>/dev/null || true
|
||||||
docker build --no-cache -t switchboard-core:v-old . -q
|
docker build --no-cache -t armature:v-old . -q
|
||||||
git stash pop -q 2>/dev/null || true
|
git stash pop -q 2>/dev/null || true
|
||||||
ok "old image built"
|
ok "old image built"
|
||||||
|
|
||||||
# Build "new" image from working tree (with current changes)
|
# Build "new" image from working tree (with current changes)
|
||||||
echo "Building 'new' image from working tree..."
|
echo "Building 'new' image from working tree..."
|
||||||
docker build --no-cache -t core-switchboard-new . -q
|
docker build --no-cache -t armature:v-new . -q
|
||||||
ok "new image built"
|
ok "new image built"
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════
|
# ═══════════════════════════════════════════════
|
||||||
@@ -97,7 +97,7 @@ ok "new image built"
|
|||||||
|
|
||||||
echo -e "\n${YELLOW}═══ Phase 2: Seed Data on Old Version ═══${NC}"
|
echo -e "\n${YELLOW}═══ Phase 2: Seed Data on Old Version ═══${NC}"
|
||||||
|
|
||||||
docker compose -f "$COMPOSE_FILE" up postgres switchboard-old -d
|
docker compose -f "$COMPOSE_FILE" up postgres armature-old -d
|
||||||
wait_for_health "$HOST"
|
wait_for_health "$HOST"
|
||||||
|
|
||||||
# Auth
|
# Auth
|
||||||
@@ -179,17 +179,17 @@ ok "recorded $PRE_PKG_COUNT installed packages"
|
|||||||
echo -e "\n${YELLOW}═══ Phase 3: Upgrade ═══${NC}"
|
echo -e "\n${YELLOW}═══ Phase 3: Upgrade ═══${NC}"
|
||||||
|
|
||||||
echo "Stopping old version..."
|
echo "Stopping old version..."
|
||||||
docker compose -f "$COMPOSE_FILE" stop switchboard-old
|
docker compose -f "$COMPOSE_FILE" stop armature-old
|
||||||
ok "old version stopped"
|
ok "old version stopped"
|
||||||
|
|
||||||
echo "Starting new version..."
|
echo "Starting new version..."
|
||||||
docker compose -f "$COMPOSE_FILE" up switchboard-new -d
|
docker compose -f "$COMPOSE_FILE" up armature-new -d
|
||||||
wait_for_health "$HOST"
|
wait_for_health "$HOST"
|
||||||
ok "new version started"
|
ok "new version started"
|
||||||
|
|
||||||
# Check for migration errors in logs
|
# Check for migration errors in logs
|
||||||
echo -e "\n${YELLOW}Checking startup logs...${NC}"
|
echo -e "\n${YELLOW}Checking startup logs...${NC}"
|
||||||
LOGS=$(docker compose -f "$COMPOSE_FILE" logs switchboard-new 2>&1 || echo "")
|
LOGS=$(docker compose -f "$COMPOSE_FILE" logs armature-new 2>&1 || echo "")
|
||||||
if echo "$LOGS" | grep -qi "migration.*fail\|schema.*error\|panic\|fatal"; then
|
if echo "$LOGS" | grep -qi "migration.*fail\|schema.*error\|panic\|fatal"; then
|
||||||
fail "migration errors found in logs"
|
fail "migration errors found in logs"
|
||||||
echo "$LOGS" | grep -i "migration\|schema\|panic\|fatal" | head -5
|
echo "$LOGS" | grep -i "migration\|schema\|panic\|fatal" | head -5
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"realm": "switchboard",
|
"realm": "armature",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"registrationAllowed": false,
|
"registrationAllowed": false,
|
||||||
"loginWithEmailAllowed": true,
|
"loginWithEmailAllowed": true,
|
||||||
@@ -9,11 +9,11 @@
|
|||||||
"realm": [
|
"realm": [
|
||||||
{
|
{
|
||||||
"name": "sb-admin",
|
"name": "sb-admin",
|
||||||
"description": "Switchboard admin role"
|
"description": "Armature admin role"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "sb-user",
|
"name": "sb-user",
|
||||||
"description": "Switchboard regular user role"
|
"description": "Armature regular user role"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -29,11 +29,11 @@
|
|||||||
],
|
],
|
||||||
"clients": [
|
"clients": [
|
||||||
{
|
{
|
||||||
"clientId": "switchboard",
|
"clientId": "armature",
|
||||||
"name": "Switchboard Core",
|
"name": "Armature",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"publicClient": false,
|
"publicClient": false,
|
||||||
"secret": "switchboard-secret",
|
"secret": "armature-secret",
|
||||||
"redirectUris": [
|
"redirectUris": [
|
||||||
"http://localhost:3000/*",
|
"http://localhost:3000/*",
|
||||||
"http://localhost:8080/*"
|
"http://localhost:8080/*"
|
||||||
@@ -69,7 +69,7 @@
|
|||||||
"users": [
|
"users": [
|
||||||
{
|
{
|
||||||
"username": "alice",
|
"username": "alice",
|
||||||
"email": "alice@switchboard.test",
|
"email": "alice@armature.test",
|
||||||
"firstName": "Alice",
|
"firstName": "Alice",
|
||||||
"lastName": "Engineer",
|
"lastName": "Engineer",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
@@ -83,7 +83,7 @@
|
|||||||
],
|
],
|
||||||
"realmRoles": [
|
"realmRoles": [
|
||||||
"sb-user",
|
"sb-user",
|
||||||
"default-roles-switchboard"
|
"default-roles-armature"
|
||||||
],
|
],
|
||||||
"groups": [
|
"groups": [
|
||||||
"/engineering"
|
"/engineering"
|
||||||
@@ -91,7 +91,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"username": "bob",
|
"username": "bob",
|
||||||
"email": "bob@switchboard.test",
|
"email": "bob@armature.test",
|
||||||
"firstName": "Bob",
|
"firstName": "Bob",
|
||||||
"lastName": "Lead",
|
"lastName": "Lead",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
@@ -106,7 +106,7 @@
|
|||||||
"realmRoles": [
|
"realmRoles": [
|
||||||
"sb-admin",
|
"sb-admin",
|
||||||
"sb-user",
|
"sb-user",
|
||||||
"default-roles-switchboard"
|
"default-roles-armature"
|
||||||
],
|
],
|
||||||
"groups": [
|
"groups": [
|
||||||
"/engineering",
|
"/engineering",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# docker-compose-e2e.yml — Multi-replica E2E testing (Postgres)
|
# docker-compose-e2e.yml — Multi-replica E2E testing (Postgres)
|
||||||
#
|
#
|
||||||
# Three Switchboard replicas behind an nginx load balancer,
|
# Three Armature replicas behind an nginx load balancer,
|
||||||
# sharing a single Postgres instance. Tests cross-replica
|
# sharing a single Postgres instance. Tests cross-replica
|
||||||
# broadcast via pg_notify and cluster registry (v0.6.0).
|
# broadcast via pg_notify and cluster registry (v0.6.0).
|
||||||
#
|
#
|
||||||
@@ -14,18 +14,18 @@ services:
|
|||||||
postgres:
|
postgres:
|
||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: switchboard_e2e
|
POSTGRES_DB: armature_e2e
|
||||||
POSTGRES_USER: switchboard
|
POSTGRES_USER: armature
|
||||||
POSTGRES_PASSWORD: e2e-password
|
POSTGRES_PASSWORD: e2e-password
|
||||||
ports:
|
ports:
|
||||||
- "5432:5432"
|
- "5432:5432"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U switchboard -d switchboard_e2e"]
|
test: ["CMD-SHELL", "pg_isready -U armature -d armature_e2e"]
|
||||||
interval: 2s
|
interval: 2s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 10
|
retries: 10
|
||||||
|
|
||||||
switchboard-1:
|
armature-1:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
@@ -33,11 +33,11 @@ services:
|
|||||||
PORT: "8080"
|
PORT: "8080"
|
||||||
BASE_PATH: ""
|
BASE_PATH: ""
|
||||||
DB_DRIVER: postgres
|
DB_DRIVER: postgres
|
||||||
DATABASE_URL: postgres://switchboard:e2e-password@postgres:5432/switchboard_e2e?sslmode=disable
|
DATABASE_URL: postgres://armature:e2e-password@postgres:5432/armature_e2e?sslmode=disable
|
||||||
JWT_SECRET: e2e-jwt-secret
|
JWT_SECRET: e2e-jwt-secret
|
||||||
ENCRYPTION_KEY: e2e-encryption-key-32chars!!!!!
|
ENCRYPTION_KEY: e2e-encryption-key-32chars!!!!!
|
||||||
SWITCHBOARD_ADMIN_USERNAME: admin
|
ARMATURE_ADMIN_USERNAME: admin
|
||||||
SWITCHBOARD_ADMIN_PASSWORD: admin
|
ARMATURE_ADMIN_PASSWORD: admin
|
||||||
STORAGE_BACKEND: pvc
|
STORAGE_BACKEND: pvc
|
||||||
STORAGE_PATH: /data/storage
|
STORAGE_PATH: /data/storage
|
||||||
CORS_ALLOWED_ORIGINS: "*"
|
CORS_ALLOWED_ORIGINS: "*"
|
||||||
@@ -48,14 +48,14 @@ services:
|
|||||||
CLUSTER_NODE_ID: "node-1"
|
CLUSTER_NODE_ID: "node-1"
|
||||||
CLUSTER_HEARTBEAT_INTERVAL: "5s"
|
CLUSTER_HEARTBEAT_INTERVAL: "5s"
|
||||||
CLUSTER_STALE_THRESHOLD: "15s"
|
CLUSTER_STALE_THRESHOLD: "15s"
|
||||||
CLUSTER_ENDPOINT: "http://switchboard-1:8080"
|
CLUSTER_ENDPOINT: "http://armature-1:8080"
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
ports:
|
ports:
|
||||||
- "8081:80"
|
- "8081:80"
|
||||||
|
|
||||||
switchboard-2:
|
armature-2:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
@@ -63,11 +63,11 @@ services:
|
|||||||
PORT: "8080"
|
PORT: "8080"
|
||||||
BASE_PATH: ""
|
BASE_PATH: ""
|
||||||
DB_DRIVER: postgres
|
DB_DRIVER: postgres
|
||||||
DATABASE_URL: postgres://switchboard:e2e-password@postgres:5432/switchboard_e2e?sslmode=disable
|
DATABASE_URL: postgres://armature:e2e-password@postgres:5432/armature_e2e?sslmode=disable
|
||||||
JWT_SECRET: e2e-jwt-secret
|
JWT_SECRET: e2e-jwt-secret
|
||||||
ENCRYPTION_KEY: e2e-encryption-key-32chars!!!!!
|
ENCRYPTION_KEY: e2e-encryption-key-32chars!!!!!
|
||||||
SWITCHBOARD_ADMIN_USERNAME: admin
|
ARMATURE_ADMIN_USERNAME: admin
|
||||||
SWITCHBOARD_ADMIN_PASSWORD: admin
|
ARMATURE_ADMIN_PASSWORD: admin
|
||||||
STORAGE_BACKEND: pvc
|
STORAGE_BACKEND: pvc
|
||||||
STORAGE_PATH: /data/storage
|
STORAGE_PATH: /data/storage
|
||||||
CORS_ALLOWED_ORIGINS: "*"
|
CORS_ALLOWED_ORIGINS: "*"
|
||||||
@@ -78,14 +78,14 @@ services:
|
|||||||
CLUSTER_NODE_ID: "node-2"
|
CLUSTER_NODE_ID: "node-2"
|
||||||
CLUSTER_HEARTBEAT_INTERVAL: "5s"
|
CLUSTER_HEARTBEAT_INTERVAL: "5s"
|
||||||
CLUSTER_STALE_THRESHOLD: "15s"
|
CLUSTER_STALE_THRESHOLD: "15s"
|
||||||
CLUSTER_ENDPOINT: "http://switchboard-2:8080"
|
CLUSTER_ENDPOINT: "http://armature-2:8080"
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
ports:
|
ports:
|
||||||
- "8082:80"
|
- "8082:80"
|
||||||
|
|
||||||
switchboard-3:
|
armature-3:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
@@ -93,11 +93,11 @@ services:
|
|||||||
PORT: "8080"
|
PORT: "8080"
|
||||||
BASE_PATH: ""
|
BASE_PATH: ""
|
||||||
DB_DRIVER: postgres
|
DB_DRIVER: postgres
|
||||||
DATABASE_URL: postgres://switchboard:e2e-password@postgres:5432/switchboard_e2e?sslmode=disable
|
DATABASE_URL: postgres://armature:e2e-password@postgres:5432/armature_e2e?sslmode=disable
|
||||||
JWT_SECRET: e2e-jwt-secret
|
JWT_SECRET: e2e-jwt-secret
|
||||||
ENCRYPTION_KEY: e2e-encryption-key-32chars!!!!!
|
ENCRYPTION_KEY: e2e-encryption-key-32chars!!!!!
|
||||||
SWITCHBOARD_ADMIN_USERNAME: admin
|
ARMATURE_ADMIN_USERNAME: admin
|
||||||
SWITCHBOARD_ADMIN_PASSWORD: admin
|
ARMATURE_ADMIN_PASSWORD: admin
|
||||||
STORAGE_BACKEND: pvc
|
STORAGE_BACKEND: pvc
|
||||||
STORAGE_PATH: /data/storage
|
STORAGE_PATH: /data/storage
|
||||||
CORS_ALLOWED_ORIGINS: "*"
|
CORS_ALLOWED_ORIGINS: "*"
|
||||||
@@ -108,7 +108,7 @@ services:
|
|||||||
CLUSTER_NODE_ID: "node-3"
|
CLUSTER_NODE_ID: "node-3"
|
||||||
CLUSTER_HEARTBEAT_INTERVAL: "5s"
|
CLUSTER_HEARTBEAT_INTERVAL: "5s"
|
||||||
CLUSTER_STALE_THRESHOLD: "15s"
|
CLUSTER_STALE_THRESHOLD: "15s"
|
||||||
CLUSTER_ENDPOINT: "http://switchboard-3:8080"
|
CLUSTER_ENDPOINT: "http://armature-3:8080"
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -122,6 +122,6 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "3000:80"
|
- "3000:80"
|
||||||
depends_on:
|
depends_on:
|
||||||
- switchboard-1
|
- armature-1
|
||||||
- switchboard-2
|
- armature-2
|
||||||
- switchboard-3
|
- armature-3
|
||||||
|
|||||||
@@ -6,11 +6,11 @@
|
|||||||
# docker compose -f docker-compose.yml -f docker-compose-keycloak.yml up
|
# docker compose -f docker-compose.yml -f docker-compose-keycloak.yml up
|
||||||
#
|
#
|
||||||
# This extends the base docker-compose.yml to add:
|
# This extends the base docker-compose.yml to add:
|
||||||
# - Keycloak IdP with a pre-configured "switchboard" realm
|
# - Keycloak IdP with a pre-configured "armature" realm
|
||||||
# - Switchboard configured with AUTH_MODE=oidc pointing at Keycloak
|
# - Armature configured with AUTH_MODE=oidc pointing at Keycloak
|
||||||
#
|
#
|
||||||
# After startup:
|
# After startup:
|
||||||
# Switchboard: http://localhost:3000
|
# Armature: http://localhost:3000
|
||||||
# Keycloak: http://localhost:8180 (admin / admin)
|
# Keycloak: http://localhost:8180 (admin / admin)
|
||||||
#
|
#
|
||||||
# Pre-configured test users in Keycloak:
|
# Pre-configured test users in Keycloak:
|
||||||
@@ -21,14 +21,14 @@
|
|||||||
# ============================================
|
# ============================================
|
||||||
|
|
||||||
services:
|
services:
|
||||||
# Override switchboard to use OIDC
|
# Override armature to use OIDC
|
||||||
switchboard:
|
armature:
|
||||||
environment:
|
environment:
|
||||||
AUTH_MODE: oidc
|
AUTH_MODE: oidc
|
||||||
OIDC_ISSUER_URL: http://keycloak:8080/realms/switchboard
|
OIDC_ISSUER_URL: http://keycloak:8080/realms/armature
|
||||||
OIDC_EXTERNAL_ISSUER_URL: http://localhost:8180/realms/switchboard
|
OIDC_EXTERNAL_ISSUER_URL: http://localhost:8180/realms/armature
|
||||||
OIDC_CLIENT_ID: switchboard
|
OIDC_CLIENT_ID: armature
|
||||||
OIDC_CLIENT_SECRET: switchboard-secret
|
OIDC_CLIENT_SECRET: armature-secret
|
||||||
OIDC_REDIRECT_URL: http://localhost:3000/api/v1/auth/oidc/callback
|
OIDC_REDIRECT_URL: http://localhost:3000/api/v1/auth/oidc/callback
|
||||||
OIDC_AUTO_ACTIVATE: "true"
|
OIDC_AUTO_ACTIVATE: "true"
|
||||||
OIDC_ADMIN_ROLE: sb-admin
|
OIDC_ADMIN_ROLE: sb-admin
|
||||||
@@ -38,7 +38,7 @@ services:
|
|||||||
|
|
||||||
keycloak:
|
keycloak:
|
||||||
image: quay.io/keycloak/keycloak:26.0
|
image: quay.io/keycloak/keycloak:26.0
|
||||||
container_name: switchboard-keycloak
|
container_name: armature-keycloak
|
||||||
command:
|
command:
|
||||||
- start-dev
|
- start-dev
|
||||||
- --import-realm
|
- --import-realm
|
||||||
@@ -48,7 +48,7 @@ services:
|
|||||||
KC_HTTP_PORT: "8080"
|
KC_HTTP_PORT: "8080"
|
||||||
KC_HEALTH_ENABLED: "true"
|
KC_HEALTH_ENABLED: "true"
|
||||||
volumes:
|
volumes:
|
||||||
- ./ci/keycloak-realm.json:/opt/keycloak/data/import/switchboard-realm.json:ro
|
- ./ci/keycloak-realm.json:/opt/keycloak/data/import/armature-realm.json:ro
|
||||||
ports:
|
ports:
|
||||||
- "8180:8080"
|
- "8180:8080"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
|
|||||||
@@ -7,11 +7,11 @@
|
|||||||
# ci/e2e-upgrade-test.sh (orchestrates build → seed → upgrade → verify)
|
# ci/e2e-upgrade-test.sh (orchestrates build → seed → upgrade → verify)
|
||||||
#
|
#
|
||||||
# Manual usage:
|
# Manual usage:
|
||||||
# docker build -t switchboard-core:v-old .
|
# docker build -t armature:v-old .
|
||||||
# docker compose -f docker-compose-upgrade.yml up postgres switchboard-old -d
|
# docker compose -f docker-compose-upgrade.yml up postgres armature-old -d
|
||||||
# # ... seed data ...
|
# # ... seed data ...
|
||||||
# docker compose -f docker-compose-upgrade.yml stop switchboard-old
|
# docker compose -f docker-compose-upgrade.yml stop armature-old
|
||||||
# docker compose -f docker-compose-upgrade.yml up switchboard-new -d
|
# docker compose -f docker-compose-upgrade.yml up armature-new -d
|
||||||
# # ... verify data ...
|
# # ... verify data ...
|
||||||
# docker compose -f docker-compose-upgrade.yml down -v
|
# docker compose -f docker-compose-upgrade.yml down -v
|
||||||
|
|
||||||
@@ -19,28 +19,28 @@ services:
|
|||||||
postgres:
|
postgres:
|
||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: switchboard_upgrade
|
POSTGRES_DB: armature_upgrade
|
||||||
POSTGRES_USER: switchboard
|
POSTGRES_USER: armature
|
||||||
POSTGRES_PASSWORD: upgrade-password
|
POSTGRES_PASSWORD: upgrade-password
|
||||||
ports:
|
ports:
|
||||||
- "5433:5432"
|
- "5433:5432"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U switchboard -d switchboard_upgrade"]
|
test: ["CMD-SHELL", "pg_isready -U armature -d armature_upgrade"]
|
||||||
interval: 2s
|
interval: 2s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 10
|
retries: 10
|
||||||
|
|
||||||
switchboard-old:
|
armature-old:
|
||||||
image: switchboard-core:v-old
|
image: armature:v-old
|
||||||
environment:
|
environment:
|
||||||
PORT: "8080"
|
PORT: "8080"
|
||||||
BASE_PATH: ""
|
BASE_PATH: ""
|
||||||
DB_DRIVER: postgres
|
DB_DRIVER: postgres
|
||||||
DATABASE_URL: postgres://switchboard:upgrade-password@postgres:5432/switchboard_upgrade?sslmode=disable
|
DATABASE_URL: postgres://armature:upgrade-password@postgres:5432/armature_upgrade?sslmode=disable
|
||||||
JWT_SECRET: upgrade-jwt-secret
|
JWT_SECRET: upgrade-jwt-secret
|
||||||
ENCRYPTION_KEY: upgrade-encryption-key-32chars!!
|
ENCRYPTION_KEY: upgrade-encryption-key-32chars!!
|
||||||
SWITCHBOARD_ADMIN_USERNAME: admin
|
ARMATURE_ADMIN_USERNAME: admin
|
||||||
SWITCHBOARD_ADMIN_PASSWORD: admin
|
ARMATURE_ADMIN_PASSWORD: admin
|
||||||
STORAGE_BACKEND: pvc
|
STORAGE_BACKEND: pvc
|
||||||
STORAGE_PATH: /data/storage
|
STORAGE_PATH: /data/storage
|
||||||
CORS_ALLOWED_ORIGINS: "*"
|
CORS_ALLOWED_ORIGINS: "*"
|
||||||
@@ -56,17 +56,17 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- upgrade_storage:/data/storage
|
- upgrade_storage:/data/storage
|
||||||
|
|
||||||
switchboard-new:
|
armature-new:
|
||||||
image: core-switchboard-new
|
image: core-armature-new
|
||||||
environment:
|
environment:
|
||||||
PORT: "8080"
|
PORT: "8080"
|
||||||
BASE_PATH: ""
|
BASE_PATH: ""
|
||||||
DB_DRIVER: postgres
|
DB_DRIVER: postgres
|
||||||
DATABASE_URL: postgres://switchboard:upgrade-password@postgres:5432/switchboard_upgrade?sslmode=disable
|
DATABASE_URL: postgres://armature:upgrade-password@postgres:5432/armature_upgrade?sslmode=disable
|
||||||
JWT_SECRET: upgrade-jwt-secret
|
JWT_SECRET: upgrade-jwt-secret
|
||||||
ENCRYPTION_KEY: upgrade-encryption-key-32chars!!
|
ENCRYPTION_KEY: upgrade-encryption-key-32chars!!
|
||||||
SWITCHBOARD_ADMIN_USERNAME: admin
|
ARMATURE_ADMIN_USERNAME: admin
|
||||||
SWITCHBOARD_ADMIN_PASSWORD: admin
|
ARMATURE_ADMIN_PASSWORD: admin
|
||||||
STORAGE_BACKEND: pvc
|
STORAGE_BACKEND: pvc
|
||||||
STORAGE_PATH: /data/storage
|
STORAGE_PATH: /data/storage
|
||||||
CORS_ALLOWED_ORIGINS: "*"
|
CORS_ALLOWED_ORIGINS: "*"
|
||||||
|
|||||||
@@ -13,20 +13,20 @@
|
|||||||
# For Postgres / multi-replica / k8s deployment see k8s/ and Dockerfile.
|
# For Postgres / multi-replica / k8s deployment see k8s/ and Dockerfile.
|
||||||
|
|
||||||
services:
|
services:
|
||||||
switchboard:
|
armature:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: switchboard-core
|
container_name: armature
|
||||||
environment:
|
environment:
|
||||||
PORT: "8080"
|
PORT: "8080"
|
||||||
BASE_PATH: ""
|
BASE_PATH: ""
|
||||||
DB_DRIVER: sqlite
|
DB_DRIVER: sqlite
|
||||||
DATABASE_URL: /data/switchboard.db
|
DATABASE_URL: /data/armature.db
|
||||||
JWT_SECRET: ${JWT_SECRET:-change-me-for-production}
|
JWT_SECRET: ${JWT_SECRET:-change-me-for-production}
|
||||||
ENCRYPTION_KEY: ${ENCRYPTION_KEY:-change-me-for-production}
|
ENCRYPTION_KEY: ${ENCRYPTION_KEY:-change-me-for-production}
|
||||||
SWITCHBOARD_ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
|
ARMATURE_ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
|
||||||
SWITCHBOARD_ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin}
|
ARMATURE_ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin}
|
||||||
STORAGE_BACKEND: pvc
|
STORAGE_BACKEND: pvc
|
||||||
STORAGE_PATH: /data/storage
|
STORAGE_PATH: /data/storage
|
||||||
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-http://localhost:3000}
|
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-http://localhost:3000}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# ============================================
|
# ============================================
|
||||||
# Switchboard Core - Backend Launcher
|
# Armature - Backend Launcher
|
||||||
# ============================================
|
# ============================================
|
||||||
# Runs as an nginx entrypoint.d hook.
|
# Runs as an nginx entrypoint.d hook.
|
||||||
# Starts the Go backend in the background
|
# Starts the Go backend in the background
|
||||||
@@ -20,15 +20,15 @@ else
|
|||||||
-exec sed -i "s|%%BASE_PATH%%||g" {} +
|
-exec sed -i "s|%%BASE_PATH%%||g" {} +
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "🔀 Starting Switchboard Core backend..."
|
echo "🔀 Starting Armature backend..."
|
||||||
|
|
||||||
# Kill any stale backend from a previous entrypoint run
|
# Kill any stale backend from a previous entrypoint run
|
||||||
pkill -f /usr/local/bin/switchboard 2>/dev/null || true
|
pkill -f /usr/local/bin/armature 2>/dev/null || true
|
||||||
sleep 0.2
|
sleep 0.2
|
||||||
|
|
||||||
# Launch Go backend in background (from /app so migrations are found)
|
# Launch Go backend in background (from /app so migrations are found)
|
||||||
cd /app
|
cd /app
|
||||||
/usr/local/bin/switchboard &
|
/usr/local/bin/armature &
|
||||||
BACKEND_PID=$!
|
BACKEND_PID=$!
|
||||||
|
|
||||||
# Wait for backend to be ready (max 60s)
|
# Wait for backend to be ready (max 60s)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Switchboard Core — Architecture
|
# Armature — Architecture
|
||||||
|
|
||||||
Switchboard Core is a self-hosted extension platform. It provides identity,
|
Armature is a self-hosted extension platform. It provides identity,
|
||||||
teams, permissions, storage, workflows, notifications, and a package system.
|
teams, permissions, storage, workflows, notifications, and a package system.
|
||||||
Everything else — chat, AI providers, personas, knowledge bases, notes,
|
Everything else — chat, AI providers, personas, knowledge bases, notes,
|
||||||
tools — ships as installable extensions.
|
tools — ships as installable extensions.
|
||||||
@@ -284,7 +284,7 @@ Single Docker image: Go binary + migrations + frontend assets + vendor
|
|||||||
libs. Kubernetes deployment with 3-node PG cluster. CI via Gitea Actions
|
libs. Kubernetes deployment with 3-node PG cluster. CI via Gitea Actions
|
||||||
with DaemonSet DinD runners testing both PG and SQLite pipelines.
|
with DaemonSet DinD runners testing both PG and SQLite pipelines.
|
||||||
|
|
||||||
Registry: `registry.gobha.me:5000/xcaliber/switchboard-core`
|
Registry: `registry.gobha.me:5000/xcaliber/armature`
|
||||||
Namespace: `gobha-ai-chat`
|
Namespace: `gobha-ai-chat`
|
||||||
|
|
||||||
### Cluster Topology
|
### Cluster Topology
|
||||||
|
|||||||
@@ -280,7 +280,7 @@ def on_fire(ctx):
|
|||||||
|
|
||||||
resp = http.post(url, body=payload, headers={
|
resp = http.post(url, body=payload, headers={
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"X-Switchboard-Event": data.get("event_name", "workflow.notify")
|
"X-Armature-Event": data.get("event_name", "workflow.notify")
|
||||||
})
|
})
|
||||||
|
|
||||||
# Log the delivery
|
# Log the delivery
|
||||||
|
|||||||
@@ -3,14 +3,14 @@
|
|||||||
## Docker Single-Instance
|
## Docker Single-Instance
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker pull ghcr.io/switchboard-core/switchboard-core:latest
|
docker pull gobha/armature:latest
|
||||||
docker run -p 8080:80 \
|
docker run -p 8080:80 \
|
||||||
-e SWITCHBOARD_ADMIN_USERNAME=admin \
|
-e ARMATURE_ADMIN_USERNAME=admin \
|
||||||
-e SWITCHBOARD_ADMIN_PASSWORD=changeme \
|
-e ARMATURE_ADMIN_PASSWORD=changeme \
|
||||||
-e JWT_SECRET="$(openssl rand -hex 32)" \
|
-e JWT_SECRET="$(openssl rand -hex 32)" \
|
||||||
-e ENCRYPTION_KEY="$(openssl rand -hex 32)" \
|
-e ENCRYPTION_KEY="$(openssl rand -hex 32)" \
|
||||||
-v switchboard-data:/data \
|
-v armature-data:/data \
|
||||||
ghcr.io/switchboard-core/switchboard-core:latest
|
gobha/armature:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
This runs with SQLite and PVC storage. Suitable for evaluation and small teams.
|
This runs with SQLite and PVC storage. Suitable for evaluation and small teams.
|
||||||
@@ -22,22 +22,22 @@ services:
|
|||||||
postgres:
|
postgres:
|
||||||
image: postgres:16
|
image: postgres:16
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: switchboard
|
POSTGRES_DB: armature
|
||||||
POSTGRES_USER: switchboard
|
POSTGRES_USER: armature
|
||||||
POSTGRES_PASSWORD: secretpassword
|
POSTGRES_PASSWORD: secretpassword
|
||||||
volumes:
|
volumes:
|
||||||
- pg_data:/var/lib/postgresql/data
|
- pg_data:/var/lib/postgresql/data
|
||||||
|
|
||||||
switchboard:
|
armature:
|
||||||
image: ghcr.io/switchboard-core/switchboard-core:latest
|
image: gobha/armature:latest
|
||||||
ports:
|
ports:
|
||||||
- "8080:80"
|
- "8080:80"
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: "postgres://switchboard:secretpassword@postgres:5432/switchboard?sslmode=disable"
|
DATABASE_URL: "postgres://armature:secretpassword@postgres:5432/armature?sslmode=disable"
|
||||||
JWT_SECRET: "change-me-in-production"
|
JWT_SECRET: "change-me-in-production"
|
||||||
ENCRYPTION_KEY: "change-me-in-production"
|
ENCRYPTION_KEY: "change-me-in-production"
|
||||||
SWITCHBOARD_ADMIN_USERNAME: admin
|
ARMATURE_ADMIN_USERNAME: admin
|
||||||
SWITCHBOARD_ADMIN_PASSWORD: changeme
|
ARMATURE_ADMIN_PASSWORD: changeme
|
||||||
STORAGE_BACKEND: pvc
|
STORAGE_BACKEND: pvc
|
||||||
STORAGE_PATH: /data/storage
|
STORAGE_PATH: /data/storage
|
||||||
volumes:
|
volumes:
|
||||||
@@ -58,7 +58,7 @@ See the `k8s/` directory for example manifests. Key considerations:
|
|||||||
- Liveness probe: `/healthz/live`. Readiness probe: `/healthz/ready`.
|
- Liveness probe: `/healthz/live`. Readiness probe: `/healthz/ready`.
|
||||||
- Mount a PVC at `/data/storage` or configure S3.
|
- Mount a PVC at `/data/storage` or configure S3.
|
||||||
- Store `JWT_SECRET` and `ENCRYPTION_KEY` in Kubernetes Secrets.
|
- Store `JWT_SECRET` and `ENCRYPTION_KEY` in Kubernetes Secrets.
|
||||||
- Registry: `registry.gobha.me:5000/xcaliber/switchboard-core`.
|
- Registry: `registry.gobha.me:5000/xcaliber/armature`.
|
||||||
|
|
||||||
## Environment Variables
|
## Environment Variables
|
||||||
|
|
||||||
@@ -72,15 +72,15 @@ See the `k8s/` directory for example manifests. Key considerations:
|
|||||||
| `AUTH_MODE` | `builtin` | `builtin`, `mtls`, `oidc` |
|
| `AUTH_MODE` | `builtin` | `builtin`, `mtls`, `oidc` |
|
||||||
| `STORAGE_BACKEND` | auto | `pvc` or `s3` |
|
| `STORAGE_BACKEND` | auto | `pvc` or `s3` |
|
||||||
| `STORAGE_PATH` | `/data/storage` | PVC mount point |
|
| `STORAGE_PATH` | `/data/storage` | PVC mount point |
|
||||||
| `BASE_PATH` | | URL prefix (e.g., `/switchboard`) |
|
| `BASE_PATH` | | URL prefix (e.g., `/armature`) |
|
||||||
| `LOG_FORMAT` | `text` | `text` or `json` |
|
| `LOG_FORMAT` | `text` | `text` or `json` |
|
||||||
| `LOG_LEVEL` | `info` | `debug`, `info`, `warn`, `error` |
|
| `LOG_LEVEL` | `info` | `debug`, `info`, `warn`, `error` |
|
||||||
| `CORS_ALLOWED_ORIGINS` | | Comma-separated allowed origins |
|
| `CORS_ALLOWED_ORIGINS` | | Comma-separated allowed origins |
|
||||||
| `BUNDLED_PACKAGES` | (empty) | `""` defaults, `"*"` all, or comma-separated |
|
| `BUNDLED_PACKAGES` | (empty) | `""` defaults, `"*"` all, or comma-separated |
|
||||||
| `SKIP_BUNDLED_PACKAGES` | `false` | Disable bundled package install |
|
| `SKIP_BUNDLED_PACKAGES` | `false` | Disable bundled package install |
|
||||||
| `BUNDLED_PACKAGES_DIR` | `/app/bundled-packages` | Custom bundle directory |
|
| `BUNDLED_PACKAGES_DIR` | `/app/bundled-packages` | Custom bundle directory |
|
||||||
| `SWITCHBOARD_ADMIN_USERNAME` | | Bootstrap admin username |
|
| `ARMATURE_ADMIN_USERNAME` | | Bootstrap admin username |
|
||||||
| `SWITCHBOARD_ADMIN_PASSWORD` | | Bootstrap admin password |
|
| `ARMATURE_ADMIN_PASSWORD` | | Bootstrap admin password |
|
||||||
| `SEED_USERS` | | Dev seed users (ignored in production) |
|
| `SEED_USERS` | | Dev seed users (ignored in production) |
|
||||||
|
|
||||||
### S3 Storage Variables
|
### S3 Storage Variables
|
||||||
@@ -107,14 +107,14 @@ See the `k8s/` directory for example manifests. Key considerations:
|
|||||||
**PostgreSQL** (recommended for production):
|
**PostgreSQL** (recommended for production):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
DATABASE_URL="postgres://user:pass@host:5432/switchboard?sslmode=require"
|
DATABASE_URL="postgres://user:pass@host:5432/armature?sslmode=require"
|
||||||
```
|
```
|
||||||
|
|
||||||
**SQLite** (dev, test, edge deployments):
|
**SQLite** (dev, test, edge deployments):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
DB_DRIVER=sqlite
|
DB_DRIVER=sqlite
|
||||||
DATABASE_URL=/data/switchboard.db
|
DATABASE_URL=/data/armature.db
|
||||||
```
|
```
|
||||||
|
|
||||||
Both databases are first-class -- every query compiles and passes tests on both. The driver is auto-detected from `DATABASE_URL` if `DB_DRIVER` is not set.
|
Both databases are first-class -- every query compiles and passes tests on both. The driver is auto-detected from `DATABASE_URL` if `DB_DRIVER` is not set.
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
## Problem
|
## Problem
|
||||||
|
|
||||||
Switchboard-core is tightly coupled to PostgreSQL. Scaling horizontally requires instance coordination: peer discovery, health monitoring, ephemeral event routing, and (optionally) leader election. Traditional HA solutions (Raft, etcd, Consul) introduce a second consensus layer on top of PG — doubling operational complexity for a system that already has serializable transactions and LISTEN/NOTIFY.
|
Armature-core is tightly coupled to PostgreSQL. Scaling horizontally requires instance coordination: peer discovery, health monitoring, ephemeral event routing, and (optionally) leader election. Traditional HA solutions (Raft, etcd, Consul) introduce a second consensus layer on top of PG — doubling operational complexity for a system that already has serializable transactions and LISTEN/NOTIFY.
|
||||||
|
|
||||||
## Principle
|
## Principle
|
||||||
|
|
||||||
@@ -241,7 +241,7 @@ Pre-MVP: fold `CREATE UNLOGGED TABLE` into existing schema initialization. No ne
|
|||||||
|
|
||||||
### Single-Node Behavior
|
### Single-Node Behavior
|
||||||
|
|
||||||
When only one node is registered, the system behaves identically to pre-cluster switchboard-core. The registry has one row. LISTEN/NOTIFY delivers events back to the same instance. No special-casing required.
|
When only one node is registered, the system behaves identically to pre-cluster armature. The registry has one row. LISTEN/NOTIFY delivers events back to the same instance. No special-casing required.
|
||||||
|
|
||||||
### Health Endpoint Integration
|
### Health Endpoint Integration
|
||||||
|
|
||||||
|
|||||||
@@ -172,17 +172,17 @@ their own `node-N.key`.
|
|||||||
|
|
||||||
### Cert Provisioning Tooling
|
### Cert Provisioning Tooling
|
||||||
|
|
||||||
A shell script (`scripts/switchboard-ca.sh`) wrapping `openssl` is the
|
A shell script (`scripts/armature-ca.sh`) wrapping `openssl` is the
|
||||||
KISS path. No new binary, no new dependency. Three commands:
|
KISS path. No new binary, no new dependency. Three commands:
|
||||||
|
|
||||||
```
|
```
|
||||||
switchboard-ca init
|
armature-ca init
|
||||||
→ generates cluster-ca.crt + cluster-ca.key in ./ca/
|
→ generates cluster-ca.crt + cluster-ca.key in ./ca/
|
||||||
|
|
||||||
switchboard-ca issue-node --name node-1 --san "node-1.internal,10.0.0.1"
|
armature-ca issue-node --name node-1 --san "node-1.internal,10.0.0.1"
|
||||||
→ generates node-1.crt + node-1.key in ./nodes/
|
→ generates node-1.crt + node-1.key in ./nodes/
|
||||||
|
|
||||||
switchboard-ca issue-user --cn jeff [--email jeff@example.com]
|
armature-ca issue-user --cn jeff [--email jeff@example.com]
|
||||||
→ generates jeff.crt + jeff.key in ./users/
|
→ generates jeff.crt + jeff.key in ./users/
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -280,7 +280,7 @@ The new code is:
|
|||||||
| `server/auth/mtls_helpers.go` | Shared: `ParseDN`, `FingerprintCert`, `resolveOrProvision` |
|
| `server/auth/mtls_helpers.go` | Shared: `ParseDN`, `FingerprintCert`, `resolveOrProvision` |
|
||||||
| `server/auth/mtls_native_test.go` | Unit + integration tests |
|
| `server/auth/mtls_native_test.go` | Unit + integration tests |
|
||||||
| `server/config/tls.go` | `TLSConfig` struct, loader, validation |
|
| `server/config/tls.go` | `TLSConfig` struct, loader, validation |
|
||||||
| `scripts/switchboard-ca.sh` | Cert provisioning wrapper |
|
| `scripts/armature-ca.sh` | Cert provisioning wrapper |
|
||||||
|
|
||||||
`server/auth/mtls.go` (existing) is renamed to `mtls_proxy.go` for
|
`server/auth/mtls.go` (existing) is renamed to `mtls_proxy.go` for
|
||||||
clarity. No behavioral changes to the proxy provider.
|
clarity. No behavioral changes to the proxy provider.
|
||||||
|
|||||||
@@ -3,11 +3,11 @@
|
|||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker pull ghcr.io/switchboard-core/switchboard-core:latest
|
docker pull gobha/armature:latest
|
||||||
docker run -p 8080:80 \
|
docker run -p 8080:80 \
|
||||||
-e SWITCHBOARD_ADMIN_USERNAME=admin \
|
-e ARMATURE_ADMIN_USERNAME=admin \
|
||||||
-e SWITCHBOARD_ADMIN_PASSWORD=changeme \
|
-e ARMATURE_ADMIN_PASSWORD=changeme \
|
||||||
ghcr.io/switchboard-core/switchboard-core:latest
|
gobha/armature:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
On first run, bundled packages are automatically installed — workflows, surfaces, and extensions are ready to use immediately.
|
On first run, bundled packages are automatically installed — workflows, surfaces, and extensions are ready to use immediately.
|
||||||
@@ -66,12 +66,12 @@ Set `BUNDLED_PACKAGES` to control which packages are installed:
|
|||||||
# Install ALL packages (everything in the image)
|
# Install ALL packages (everything in the image)
|
||||||
docker run -p 8080:80 \
|
docker run -p 8080:80 \
|
||||||
-e BUNDLED_PACKAGES="*" \
|
-e BUNDLED_PACKAGES="*" \
|
||||||
ghcr.io/switchboard-core/switchboard-core:latest
|
gobha/armature:latest
|
||||||
|
|
||||||
# Install specific packages only
|
# Install specific packages only
|
||||||
docker run -p 8080:80 \
|
docker run -p 8080:80 \
|
||||||
-e BUNDLED_PACKAGES="notes,tasks,schedules" \
|
-e BUNDLED_PACKAGES="notes,tasks,schedules" \
|
||||||
ghcr.io/switchboard-core/switchboard-core:latest
|
gobha/armature:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
Empty (default) installs the curated default set. Use `*` to install all packages. This is useful for Helm charts where different environments need different packages.
|
Empty (default) installs the curated default set. Use `*` to install all packages. This is useful for Helm charts where different environments need different packages.
|
||||||
@@ -83,7 +83,7 @@ Set `SKIP_BUNDLED_PACKAGES=true` to prevent bundled packages from being installe
|
|||||||
```bash
|
```bash
|
||||||
docker run -p 8080:80 \
|
docker run -p 8080:80 \
|
||||||
-e SKIP_BUNDLED_PACKAGES=true \
|
-e SKIP_BUNDLED_PACKAGES=true \
|
||||||
ghcr.io/switchboard-core/switchboard-core:latest
|
gobha/armature:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
### Custom Bundle Directory
|
### Custom Bundle Directory
|
||||||
@@ -94,7 +94,7 @@ Override the default bundled packages location with `BUNDLED_PACKAGES_DIR`:
|
|||||||
docker run -p 8080:80 \
|
docker run -p 8080:80 \
|
||||||
-e BUNDLED_PACKAGES_DIR=/custom/packages \
|
-e BUNDLED_PACKAGES_DIR=/custom/packages \
|
||||||
-v /host/packages:/custom/packages \
|
-v /host/packages:/custom/packages \
|
||||||
ghcr.io/switchboard-core/switchboard-core:latest
|
gobha/armature:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
## Builder Image
|
## Builder Image
|
||||||
@@ -102,7 +102,7 @@ docker run -p 8080:80 \
|
|||||||
The builder image pre-caches Go modules and Node dependencies for faster custom builds.
|
The builder image pre-caches Go modules and Node dependencies for faster custom builds.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker pull ghcr.io/switchboard-core/builder:latest
|
docker pull gobha/armature-builder:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
### What It Caches
|
### What It Caches
|
||||||
@@ -117,16 +117,16 @@ docker pull ghcr.io/switchboard-core/builder:latest
|
|||||||
Reference the builder image as a base stage in your Dockerfile:
|
Reference the builder image as a base stage in your Dockerfile:
|
||||||
|
|
||||||
```dockerfile
|
```dockerfile
|
||||||
FROM ghcr.io/switchboard-core/builder:latest AS builder
|
FROM gobha/armature-builder:latest AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY server/ .
|
COPY server/ .
|
||||||
RUN go build -ldflags="-s -w" -o /bin/switchboard .
|
RUN go build -ldflags="-s -w" -o /bin/armature .
|
||||||
```
|
```
|
||||||
|
|
||||||
### Building Locally
|
### Building Locally
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker build -f Dockerfile.builder -t switchboard-builder .
|
docker build -f Dockerfile.builder -t armature-builder .
|
||||||
```
|
```
|
||||||
|
|
||||||
## Custom Build Guide
|
## Custom Build Guide
|
||||||
@@ -135,7 +135,7 @@ docker build -f Dockerfile.builder -t switchboard-builder .
|
|||||||
|
|
||||||
1. Create your package in `packages/your-package/` with a `manifest.json`
|
1. Create your package in `packages/your-package/` with a `manifest.json`
|
||||||
2. Build all packages: `cd packages && bash build.sh all`
|
2. Build all packages: `cd packages && bash build.sh all`
|
||||||
3. Build the Docker image: `docker build -t my-switchboard .`
|
3. Build the Docker image: `docker build -t my-armature .`
|
||||||
|
|
||||||
The Dockerfile automatically builds all packages in the `packages/` directory and bundles them into the production image.
|
The Dockerfile automatically builds all packages in the `packages/` directory and bundles them into the production image.
|
||||||
|
|
||||||
@@ -148,14 +148,14 @@ To exclude specific packages from the bundle, either:
|
|||||||
### Forking for Custom Builds
|
### Forking for Custom Builds
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/switchboard-core/switchboard-core.git
|
git clone https://github.com/gobha/armature.git
|
||||||
cd switchboard-core
|
cd armature
|
||||||
|
|
||||||
# Add/modify packages
|
# Add/modify packages
|
||||||
cp -r my-extension packages/my-extension/
|
cp -r my-extension packages/my-extension/
|
||||||
|
|
||||||
# Build with builder image for faster compilation
|
# Build with builder image for faster compilation
|
||||||
docker build -t my-switchboard .
|
docker build -t my-armature .
|
||||||
```
|
```
|
||||||
|
|
||||||
## Production Deployment
|
## Production Deployment
|
||||||
@@ -172,7 +172,7 @@ docker build -t my-switchboard .
|
|||||||
| `AUTH_MODE` | `builtin` | `builtin`, `mtls`, or `oidc` |
|
| `AUTH_MODE` | `builtin` | `builtin`, `mtls`, or `oidc` |
|
||||||
| `STORAGE_BACKEND` | (auto) | `pvc` or `s3` |
|
| `STORAGE_BACKEND` | (auto) | `pvc` or `s3` |
|
||||||
| `STORAGE_PATH` | `/data/storage` | PVC mount point |
|
| `STORAGE_PATH` | `/data/storage` | PVC mount point |
|
||||||
| `BASE_PATH` | | URL prefix (e.g. `/switchboard`) |
|
| `BASE_PATH` | | URL prefix (e.g. `/armature`) |
|
||||||
| `SKIP_BUNDLED_PACKAGES` | `false` | Disable auto-install of bundled packages |
|
| `SKIP_BUNDLED_PACKAGES` | `false` | Disable auto-install of bundled packages |
|
||||||
| `BUNDLED_PACKAGES` | (empty = defaults) | `""` curated defaults, `"*"` all, or comma-separated IDs |
|
| `BUNDLED_PACKAGES` | (empty = defaults) | `""` curated defaults, `"*"` all, or comma-separated IDs |
|
||||||
| `BUNDLED_PACKAGES_DIR` | `/app/bundled-packages` | Override bundled packages location |
|
| `BUNDLED_PACKAGES_DIR` | `/app/bundled-packages` | Override bundled packages location |
|
||||||
@@ -186,16 +186,16 @@ PostgreSQL is recommended for production. SQLite is suitable for single-instance
|
|||||||
```bash
|
```bash
|
||||||
# PostgreSQL (recommended)
|
# PostgreSQL (recommended)
|
||||||
docker run -p 8080:80 \
|
docker run -p 8080:80 \
|
||||||
-e DATABASE_URL="postgres://user:pass@host:5432/switchboard?sslmode=require" \
|
-e DATABASE_URL="postgres://user:pass@host:5432/armature?sslmode=require" \
|
||||||
-e JWT_SECRET="$(openssl rand -hex 32)" \
|
-e JWT_SECRET="$(openssl rand -hex 32)" \
|
||||||
-e ENCRYPTION_KEY="$(openssl rand -hex 32)" \
|
-e ENCRYPTION_KEY="$(openssl rand -hex 32)" \
|
||||||
ghcr.io/switchboard-core/switchboard-core:latest
|
gobha/armature:latest
|
||||||
|
|
||||||
# SQLite (evaluation only)
|
# SQLite (evaluation only)
|
||||||
docker run -p 8080:80 \
|
docker run -p 8080:80 \
|
||||||
-e DB_DRIVER=sqlite \
|
-e DB_DRIVER=sqlite \
|
||||||
-v switchboard-data:/data \
|
-v armature-data:/data \
|
||||||
ghcr.io/switchboard-core/switchboard-core:latest
|
gobha/armature:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
### Storage
|
### Storage
|
||||||
@@ -204,12 +204,12 @@ Object storage is required for file uploads and package asset extraction.
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# PVC (auto-detected if path is writable)
|
# PVC (auto-detected if path is writable)
|
||||||
docker run -v switchboard-storage:/data/storage ...
|
docker run -v armature-storage:/data/storage ...
|
||||||
|
|
||||||
# S3-compatible (MinIO, AWS S3, Ceph)
|
# S3-compatible (MinIO, AWS S3, Ceph)
|
||||||
docker run \
|
docker run \
|
||||||
-e STORAGE_BACKEND=s3 \
|
-e STORAGE_BACKEND=s3 \
|
||||||
-e S3_BUCKET=switchboard \
|
-e S3_BUCKET=armature \
|
||||||
-e S3_ENDPOINT=https://minio.corp:9000 \
|
-e S3_ENDPOINT=https://minio.corp:9000 \
|
||||||
-e S3_ACCESS_KEY=... \
|
-e S3_ACCESS_KEY=... \
|
||||||
-e S3_SECRET_KEY=... \
|
-e S3_SECRET_KEY=... \
|
||||||
|
|||||||
196
docs/EXTENSION-CSS.md
Normal file
196
docs/EXTENSION-CSS.md
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
# Extension CSS Contract
|
||||||
|
|
||||||
|
> **Version**: v0.6.13 — Responsive & Spacing
|
||||||
|
|
||||||
|
This document defines the CSS contract between the Armature kernel and extension
|
||||||
|
packages. Extensions **must** follow these rules; the kernel guarantees the listed
|
||||||
|
classes and variables are stable public API.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Naming Rule
|
||||||
|
|
||||||
|
All class selectors in extension CSS (`packages/{slug}/css/main.css`) must start
|
||||||
|
with `.ext-{slug}-`. The `{slug}` is the package directory name.
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Good */
|
||||||
|
.ext-my-app-sidebar { ... }
|
||||||
|
.ext-my-app-card { ... }
|
||||||
|
|
||||||
|
/* Bad — will be rejected by the linter */
|
||||||
|
.sidebar { ... }
|
||||||
|
.my-sidebar { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Compound selectors**: Descendant classes scoped under your `.ext-{slug}` root
|
||||||
|
are allowed to reference kernel classes or state modifiers:
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Allowed — kernel class scoped under extension namespace */
|
||||||
|
.ext-my-app .sw-btn { margin-top: 8px; }
|
||||||
|
|
||||||
|
/* Allowed — state modifier on an extension element */
|
||||||
|
.ext-my-app-item.active { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
Run `bash scripts/lint-package-css.sh` to validate. The linter checks that the
|
||||||
|
**first** class selector in every rule starts with `.ext-{slug}`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stable Kernel Classes
|
||||||
|
|
||||||
|
Extensions may reference these classes in compound selectors. They are part of
|
||||||
|
the public API and will not change without a major version bump.
|
||||||
|
|
||||||
|
### Components (from `sw-primitives.css`)
|
||||||
|
|
||||||
|
| Class pattern | Component |
|
||||||
|
|---------------|-----------|
|
||||||
|
| `.sw-btn`, `.sw-btn--{variant}`, `.sw-btn--{size}` | Buttons |
|
||||||
|
| `.sw-input` | Text inputs |
|
||||||
|
| `.sw-field`, `.sw-field__label`, `.sw-field__hint` | Form fields |
|
||||||
|
| `.sw-dialog`, `.sw-dialog__header`, `.sw-dialog__body`, `.sw-dialog__footer` | Dialogs |
|
||||||
|
| `.sw-toast`, `.sw-toast-container` | Toast notifications |
|
||||||
|
| `.sw-menu`, `.sw-menu-item` | Context menus |
|
||||||
|
| `.sw-tabs`, `.sw-tab-btn` | Tab strips |
|
||||||
|
| `.sw-dropdown` | Custom dropdowns |
|
||||||
|
| `.sw-spinner` | Loading spinners |
|
||||||
|
| `.sw-avatar` | User avatars |
|
||||||
|
| `.sw-drawer` | Slide-out drawers |
|
||||||
|
| `.sw-banner` | Banner bars |
|
||||||
|
| `.sw-tooltip` | Tooltips |
|
||||||
|
|
||||||
|
### Extension Mount
|
||||||
|
|
||||||
|
The extension surface container has a `data-ext` attribute set to the package
|
||||||
|
slug. Use this for scoping if needed:
|
||||||
|
|
||||||
|
```css
|
||||||
|
[data-ext="my-app"] .ext-my-app-sidebar { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stable CSS Variables
|
||||||
|
|
||||||
|
All variables from `variables.css` are public API. Extensions should use these
|
||||||
|
instead of hardcoded colors to respect the user's theme.
|
||||||
|
|
||||||
|
### Colors
|
||||||
|
|
||||||
|
| Variable | Purpose |
|
||||||
|
|----------|---------|
|
||||||
|
| `--bg` | Page background |
|
||||||
|
| `--bg-secondary` | Secondary/darker background |
|
||||||
|
| `--bg-elevated` | Elevated surface background |
|
||||||
|
| `--bg-raised` | Raised card background |
|
||||||
|
| `--bg-surface` | Surface-level background |
|
||||||
|
| `--bg-hover` | Hover state background |
|
||||||
|
| `--bg-active` | Active/pressed state background |
|
||||||
|
| `--bg-code` | Code block background |
|
||||||
|
| `--text` | Primary text color |
|
||||||
|
| `--text-2` | Secondary text color |
|
||||||
|
| `--text-3` | Tertiary/muted text color |
|
||||||
|
| `--text-on-color` | Text on colored backgrounds |
|
||||||
|
| `--accent` | Primary accent color |
|
||||||
|
| `--accent-dim` | Dimmed accent for backgrounds |
|
||||||
|
| `--accent-hover` | Accent hover state |
|
||||||
|
| `--accent-light` | Light accent variant |
|
||||||
|
| `--border` | Default border color |
|
||||||
|
| `--border-light` | Light border variant |
|
||||||
|
| `--border-elevated` | Border for elevated surfaces |
|
||||||
|
| `--danger` | Error/destructive color |
|
||||||
|
| `--danger-dim` | Dimmed danger background |
|
||||||
|
| `--danger-light` | Light danger variant |
|
||||||
|
| `--success` | Success/positive color |
|
||||||
|
| `--success-dim` | Dimmed success background |
|
||||||
|
| `--success-light` | Light success variant |
|
||||||
|
| `--warning` | Warning/caution color |
|
||||||
|
| `--warning-dim` | Dimmed warning background |
|
||||||
|
| `--warning-light` | Light warning variant |
|
||||||
|
| `--purple` | Purple accent |
|
||||||
|
| `--purple-dim` | Dimmed purple background |
|
||||||
|
|
||||||
|
### Spacing
|
||||||
|
|
||||||
|
Use spacing tokens instead of hardcoded values for padding, margin, and gap.
|
||||||
|
For sub-4px values (1px, 2px, 3px) used in borders and fine detail, hardcoded
|
||||||
|
values are acceptable.
|
||||||
|
|
||||||
|
| Variable | Value | Computed |
|
||||||
|
|----------|-------|---------|
|
||||||
|
| `--sp-1` | `0.25rem` | 4px |
|
||||||
|
| `--sp-1h` | `0.375rem` | 6px |
|
||||||
|
| `--sp-2` | `0.5rem` | 8px |
|
||||||
|
| `--sp-2h` | `0.625rem` | 10px |
|
||||||
|
| `--sp-3` | `0.75rem` | 12px |
|
||||||
|
| `--sp-4` | `1rem` | 16px |
|
||||||
|
| `--sp-5` | `1.25rem` | 20px |
|
||||||
|
| `--sp-6` | `1.5rem` | 24px |
|
||||||
|
| `--sp-8` | `2rem` | 32px |
|
||||||
|
| `--sp-10` | `2.5rem` | 40px |
|
||||||
|
| `--sp-12` | `3rem` | 48px |
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```css
|
||||||
|
.ext-my-app-card {
|
||||||
|
padding: var(--sp-3) var(--sp-4); /* 12px 16px */
|
||||||
|
gap: var(--sp-2); /* 8px */
|
||||||
|
margin-bottom: var(--sp-4); /* 16px */
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Layout & Typography
|
||||||
|
|
||||||
|
| Variable | Purpose |
|
||||||
|
|----------|---------|
|
||||||
|
| `--font` | Primary font family |
|
||||||
|
| `--mono` | Monospace font family |
|
||||||
|
| `--radius-sm` | Small border-radius (4px) — badges, inline controls |
|
||||||
|
| `--radius` | Default border-radius (8px) — buttons, inputs, cards |
|
||||||
|
| `--radius-lg` | Large border-radius (12px) — modals, dialogs, large cards |
|
||||||
|
| `--transition` | Default transition timing |
|
||||||
|
| `--shadow-lg` | Large elevation shadow |
|
||||||
|
| `--overlay` | Modal overlay color |
|
||||||
|
| `--glass` | Glassmorphism backdrop |
|
||||||
|
| `--input-bg` | Form input background |
|
||||||
|
| `--sidebar-w` | Sidebar width |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Responsive Breakpoints
|
||||||
|
|
||||||
|
The kernel uses these standard breakpoints. Extensions should use the same
|
||||||
|
values for consistency.
|
||||||
|
|
||||||
|
| Name | Media Query | Use Case |
|
||||||
|
|------|-------------|----------|
|
||||||
|
| Mobile | `@media (max-width: 768px)` | Phone-sized, single column |
|
||||||
|
| Tablet | `@media (max-width: 1024px)` | Tablet/small laptop, narrower sidebars |
|
||||||
|
| Desktop | Default (no query) | Full layout |
|
||||||
|
|
||||||
|
CSS custom properties cannot be used in `@media` queries — use the pixel
|
||||||
|
values directly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What Is Internal
|
||||||
|
|
||||||
|
Everything not listed above is **internal kernel CSS** and may change between
|
||||||
|
minor versions. Extensions must not depend on:
|
||||||
|
|
||||||
|
- Kernel layout classes (`.admin-*`, `.surface-*`, `.sidebar`, etc.)
|
||||||
|
- Kernel CSS file load order
|
||||||
|
- Specific HTML structure of the shell or topbar
|
||||||
|
- Undocumented CSS variables
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Enforcement
|
||||||
|
|
||||||
|
The linter script `scripts/lint-package-css.sh` runs against all
|
||||||
|
`packages/*/css/main.css` files. It exits non-zero if any rule's first class
|
||||||
|
selector does not start with `.ext-{slug}`.
|
||||||
@@ -20,10 +20,10 @@ docker compose down -v
|
|||||||
|
|
||||||
## First Boot
|
## First Boot
|
||||||
|
|
||||||
On first start, Switchboard Core will:
|
On first start, Armature will:
|
||||||
|
|
||||||
1. Run database migrations (SQLite by default in compose).
|
1. Run database migrations (SQLite by default in compose).
|
||||||
2. Create the admin user from `SWITCHBOARD_ADMIN_USERNAME` / `SWITCHBOARD_ADMIN_PASSWORD` env vars.
|
2. Create the admin user from `ARMATURE_ADMIN_USERNAME` / `ARMATURE_ADMIN_PASSWORD` env vars.
|
||||||
3. Auto-install the curated default package set (notes, chat-core, dashboard, workflow demos, etc.).
|
3. Auto-install the curated default package set (notes, chat-core, dashboard, workflow demos, etc.).
|
||||||
|
|
||||||
No manual setup steps are required.
|
No manual setup steps are required.
|
||||||
@@ -67,8 +67,8 @@ You can also upload `.pkg` archives through the Admin > Packages page.
|
|||||||
| `AUTH_MODE` | `builtin` | `builtin`, `mtls`, or `oidc` |
|
| `AUTH_MODE` | `builtin` | `builtin`, `mtls`, or `oidc` |
|
||||||
| `STORAGE_BACKEND` | auto | `pvc` or `s3` |
|
| `STORAGE_BACKEND` | auto | `pvc` or `s3` |
|
||||||
| `STORAGE_PATH` | `/data/storage` | PVC mount point |
|
| `STORAGE_PATH` | `/data/storage` | PVC mount point |
|
||||||
| `SWITCHBOARD_ADMIN_USERNAME` | | Bootstrap admin username |
|
| `ARMATURE_ADMIN_USERNAME` | | Bootstrap admin username |
|
||||||
| `SWITCHBOARD_ADMIN_PASSWORD` | | Bootstrap admin password |
|
| `ARMATURE_ADMIN_PASSWORD` | | Bootstrap admin password |
|
||||||
| `BUNDLED_PACKAGES` | (empty) | `""` = curated defaults, `"*"` = all, or comma-separated IDs |
|
| `BUNDLED_PACKAGES` | (empty) | `""` = curated defaults, `"*"` = all, or comma-separated IDs |
|
||||||
| `SKIP_BUNDLED_PACKAGES` | `false` | Disable auto-install entirely |
|
| `SKIP_BUNDLED_PACKAGES` | `false` | Disable auto-install entirely |
|
||||||
| `LOG_LEVEL` | `info` | `debug`, `info`, `warn`, `error` |
|
| `LOG_LEVEL` | `info` | `debug`, `info`, `warn`, `error` |
|
||||||
@@ -77,7 +77,7 @@ You can also upload `.pkg` archives through the Admin > Packages page.
|
|||||||
## From Source
|
## From Source
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone <repo-url> && cd switchboard-core
|
git clone <repo-url> && cd armature
|
||||||
cp server/.env.example server/.env # edit DB credentials
|
cp server/.env.example server/.env # edit DB credentials
|
||||||
cd server && go run .
|
cd server && go run .
|
||||||
# Backend on http://localhost:8080
|
# Backend on http://localhost:8080
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Package Format
|
# Package Format
|
||||||
|
|
||||||
Switchboard packages are distributed as `.pkg` files -- ZIP archives with a standard internal structure.
|
Armature packages are distributed as `.pkg` files -- ZIP archives with a standard internal structure.
|
||||||
|
|
||||||
## ZIP Structure
|
## ZIP Structure
|
||||||
|
|
||||||
@@ -125,6 +125,6 @@ To bundle custom packages into a Docker image:
|
|||||||
|
|
||||||
1. Place your package in `packages/your-package/` with a `manifest.json`.
|
1. Place your package in `packages/your-package/` with a `manifest.json`.
|
||||||
2. Run `cd packages && bash build.sh all`.
|
2. Run `cd packages && bash build.sh all`.
|
||||||
3. Build the image: `docker build -t my-switchboard .`
|
3. Build the image: `docker build -t my-armature .`
|
||||||
|
|
||||||
The Dockerfile builds all packages and copies them into the bundled packages directory.
|
The Dockerfile builds all packages and copies them into the bundled packages directory.
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ The registry is a static JSON file matching the `RegistryResponse` struct:
|
|||||||
"title": "Notes",
|
"title": "Notes",
|
||||||
"version": "0.8.0",
|
"version": "0.8.0",
|
||||||
"description": "Markdown notes with backlinks and graph view",
|
"description": "Markdown notes with backlinks and graph view",
|
||||||
"author": "switchboard",
|
"author": "armature",
|
||||||
"type": "extension",
|
"type": "extension",
|
||||||
"tier": "core",
|
"tier": "core",
|
||||||
"download_url": "https://cdn.example.com/pkg/notes.pkg",
|
"download_url": "https://cdn.example.com/pkg/notes.pkg",
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
# Build Your First Browser Extension
|
# Build Your First Browser Extension
|
||||||
|
|
||||||
This tutorial walks through building a browser extension that renders custom
|
This tutorial walks through building a browser extension that renders custom
|
||||||
code blocks, modeled on the CSV Table Viewer that ships with Switchboard.
|
code blocks, modeled on the CSV Table Viewer that ships with Armature.
|
||||||
|
|
||||||
**Prerequisites:** A running Switchboard instance, a text editor, and `zip`.
|
**Prerequisites:** A running Armature instance, a text editor, and `zip`.
|
||||||
|
|
||||||
## Step 1: Create the Directory
|
## Step 1: Create the Directory
|
||||||
|
|
||||||
|
|||||||
227
docs/USABILITY-SURVEY.md
Normal file
227
docs/USABILITY-SURVEY.md
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
# Armature Usability Survey — Automated Checklist
|
||||||
|
|
||||||
|
> **Purpose**: Machine-auditable quality gate for the Armature UI.
|
||||||
|
> Run all scripts first, then walk each section. A FAIL in any section blocks release.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
Run these scripts from the project root and save their output:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash scripts/generate-ui-inventory.sh > ui-inventory.json
|
||||||
|
bash scripts/check-contrast.sh > contrast-report.txt
|
||||||
|
bash scripts/generate-coverage-matrix.sh > coverage-matrix.md
|
||||||
|
bash scripts/audit-touch-targets.sh > touch-targets-report.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Section 1: Viewport Correctness
|
||||||
|
|
||||||
|
**Pass criteria:**
|
||||||
|
- No CSS file uses `100vh` (should be `100%` or `100dvh`)
|
||||||
|
- `.sw-shell` uses `height: 100%`, not `100vh`
|
||||||
|
- All extension surfaces use `height: 100%`
|
||||||
|
- No `transform: scale()` for zoom (should use CSS `zoom`)
|
||||||
|
|
||||||
|
**Files to inspect:**
|
||||||
|
- `src/css/sw-shell.css`
|
||||||
|
- `src/css/layout.css`
|
||||||
|
- `packages/*/css/main.css`
|
||||||
|
|
||||||
|
**How to check:**
|
||||||
|
```bash
|
||||||
|
grep -rn '100vh' src/css/ packages/*/css/ --include='*.css'
|
||||||
|
grep -rn 'transform.*scale' src/css/ packages/*/css/ --include='*.css'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Result:** PASS if zero matches. FAIL if any `100vh` or `transform: scale()` for layout sizing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Section 2: Banner Integration
|
||||||
|
|
||||||
|
**Pass criteria:**
|
||||||
|
- Banners are in-flow (no `position: fixed` on banner elements)
|
||||||
|
- `--banner-top-height` and `--banner-bottom-height` are defined in `:root`
|
||||||
|
- Shell layout accounts for banner height via CSS variables, not hardcoded px
|
||||||
|
- No surface hardcodes `28px` or other banner height values
|
||||||
|
|
||||||
|
**Files to inspect:**
|
||||||
|
- `src/css/sw-shell.css`
|
||||||
|
- `src/css/variables.css` (`:root` block)
|
||||||
|
- `src/js/sw/shell/app-shell.js`
|
||||||
|
|
||||||
|
**How to check:**
|
||||||
|
```bash
|
||||||
|
grep -n 'position.*fixed' src/css/sw-shell.css | grep -i banner
|
||||||
|
grep -n '28px' src/css/ -r --include='*.css'
|
||||||
|
grep -n 'banner-top-height\|banner-bottom-height' src/css/variables.css
|
||||||
|
```
|
||||||
|
|
||||||
|
**Result:** PASS if banners are in-flow and height is variable-driven. FAIL if fixed positioning or hardcoded heights.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Section 3: Responsive Behavior
|
||||||
|
|
||||||
|
**Pass criteria:**
|
||||||
|
- Kernel CSS uses `768px` (mobile) and `1024px` (tablet) breakpoints
|
||||||
|
- No hardcoded widths that break below 768px (except intentional min-widths on dialogs)
|
||||||
|
- Sidebar collapses on mobile
|
||||||
|
- Extension surfaces adapt to narrow viewports
|
||||||
|
|
||||||
|
**Files to inspect:**
|
||||||
|
- `src/css/layout.css`
|
||||||
|
- `src/css/surfaces.css`
|
||||||
|
- `packages/*/css/main.css`
|
||||||
|
|
||||||
|
**How to check:**
|
||||||
|
```bash
|
||||||
|
# Verify breakpoints used
|
||||||
|
grep -rn '@media.*max-width' src/css/ --include='*.css' | grep -v '768\|1024'
|
||||||
|
# Check for hardcoded widths
|
||||||
|
grep -rn 'width:.*[0-9]\+px' src/css/layout.css | grep -v 'max-width\|min-width\|--'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Result:** PASS if only 768px and 1024px breakpoints. WARN if other breakpoints exist but are justified. FAIL if layout breaks below 768px.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Section 4: Styling Consistency
|
||||||
|
|
||||||
|
**Pass criteria:**
|
||||||
|
- All spacing uses `--sp-*` tokens (no raw px for padding/margin/gap > 3px)
|
||||||
|
- All `border-radius` uses `--radius-sm`, `--radius`, or `--radius-lg`
|
||||||
|
- All `font-family` uses `var(--font)` or `var(--mono)`
|
||||||
|
- No external font CDN imports (`@import url(` or Google Fonts references)
|
||||||
|
- No stale fallback colors (`#b38a4e` or other non-token hex in property values)
|
||||||
|
|
||||||
|
**Files to inspect:**
|
||||||
|
- All `src/css/*.css`
|
||||||
|
- `packages/*/css/main.css`
|
||||||
|
|
||||||
|
**How to check:**
|
||||||
|
```bash
|
||||||
|
# Raw px spacing (padding/margin/gap > 3px, not inside var())
|
||||||
|
grep -rnE '(padding|margin|gap):\s*[0-9]+(px|rem)' src/css/ packages/*/css/ --include='*.css' | grep -v 'var(--' | grep -v '0px\|1px\|2px\|3px'
|
||||||
|
# Raw border-radius
|
||||||
|
grep -rn 'border-radius:' src/css/ packages/*/css/ --include='*.css' | grep -v 'var(--radius'
|
||||||
|
# External fonts
|
||||||
|
grep -rn '@import url\|fonts.googleapis' src/css/ --include='*.css'
|
||||||
|
# Stale fallback gold color
|
||||||
|
grep -rn '#b38a4e' src/css/ packages/*/css/ --include='*.css'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Result:** PASS if zero non-token values (excluding reset/keyframe contexts). WARN for 1-3 edge cases with justification. FAIL for systematic violations.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Section 5: Accessibility — Contrast
|
||||||
|
|
||||||
|
**Pass criteria:**
|
||||||
|
- `contrast-report.txt` shows all PASS for normal text (4.5:1 ratio)
|
||||||
|
- No FAIL results in either dark or light theme
|
||||||
|
|
||||||
|
**Files to inspect:**
|
||||||
|
- `contrast-report.txt` (generated above)
|
||||||
|
|
||||||
|
**How to check:**
|
||||||
|
```bash
|
||||||
|
grep 'FAIL' contrast-report.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
**Result:** PASS if zero FAIL lines. FAIL if any contrast violation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Section 6: Accessibility — Touch Targets
|
||||||
|
|
||||||
|
**Pass criteria:**
|
||||||
|
- `touch-targets-report.txt` shows zero violations
|
||||||
|
- All close buttons have `min-width: 44px; min-height: 44px` in `@media (max-width: 768px)`
|
||||||
|
- Menu items have `min-height: 44px` on mobile (already done in `sw-primitives.css`)
|
||||||
|
|
||||||
|
**Files to inspect:**
|
||||||
|
- `touch-targets-report.txt` (generated above)
|
||||||
|
- `src/css/sw-primitives.css` — close button rules
|
||||||
|
|
||||||
|
**How to check:**
|
||||||
|
```bash
|
||||||
|
grep 'FAIL\|MISSING' touch-targets-report.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
**Result:** PASS if zero violations. FAIL if any close button lacks mobile touch target.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Section 7: Accessibility — Focus Indicators
|
||||||
|
|
||||||
|
**Pass criteria:**
|
||||||
|
- All interactive primitives have `:focus-visible` styles
|
||||||
|
- No `outline: none` without a replacement focus indicator
|
||||||
|
- Focus ring is visible on both dark and light themes
|
||||||
|
|
||||||
|
**Files to inspect:**
|
||||||
|
- `src/css/sw-primitives.css`
|
||||||
|
- `src/css/primitives.css`
|
||||||
|
- `src/css/variables.css`
|
||||||
|
|
||||||
|
**How to check:**
|
||||||
|
```bash
|
||||||
|
# Check for focus-visible on key primitives
|
||||||
|
for cls in sw-btn sw-input sw-dropdown__trigger sw-menu__item sw-tabs__tab; do
|
||||||
|
echo -n "$cls: "
|
||||||
|
grep -c "\.${cls}.*:focus-visible\|\.${cls}:focus-visible" src/css/sw-primitives.css src/css/primitives.css 2>/dev/null || echo "0"
|
||||||
|
done
|
||||||
|
# Check for outline:none without replacement
|
||||||
|
grep -n 'outline.*none\|outline.*0' src/css/*.css | grep -v 'focus-visible\|focus-within'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Result:** PASS if all 5 key primitives have `:focus-visible`. WARN if outline:none exists with adequate replacement. FAIL if missing focus indicators.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Section 8: Component Uniformity
|
||||||
|
|
||||||
|
**Pass criteria:**
|
||||||
|
- `coverage-matrix.md` shows no deprecated component usage (no ⚠ in the deprecated row)
|
||||||
|
- All surfaces use `sw-*` primitives, not old `.btn-*`, `.toast`, `.popup-menu`
|
||||||
|
|
||||||
|
**Files to inspect:**
|
||||||
|
- `coverage-matrix.md` (generated above)
|
||||||
|
|
||||||
|
**How to check:**
|
||||||
|
```bash
|
||||||
|
grep '⚠' coverage-matrix.md
|
||||||
|
```
|
||||||
|
|
||||||
|
**Result:** PASS if zero ⚠ markers. FAIL if any deprecated component still in use.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scoring
|
||||||
|
|
||||||
|
| Section | Weight | Result |
|
||||||
|
|---------|--------|--------|
|
||||||
|
| 1. Viewport Correctness | Required | |
|
||||||
|
| 2. Banner Integration | Required | |
|
||||||
|
| 3. Responsive Behavior | Required | |
|
||||||
|
| 4. Styling Consistency | Required | |
|
||||||
|
| 5. Contrast | Required | |
|
||||||
|
| 6. Touch Targets | Required | |
|
||||||
|
| 7. Focus Indicators | Required | |
|
||||||
|
| 8. Component Uniformity | Required | |
|
||||||
|
|
||||||
|
**Overall:** PASS requires all sections PASS or WARN. Any FAIL blocks the release.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## After the Survey
|
||||||
|
|
||||||
|
1. Fix all FAIL items
|
||||||
|
2. Re-run affected scripts to confirm fixes
|
||||||
|
3. Re-run the full survey
|
||||||
|
4. Tag `v0.6.16` only after a clean survey pass
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
# k8s/switchboard.yaml
|
# k8s/armature.yaml
|
||||||
# ============================================
|
# ============================================
|
||||||
# Switchboard Core - Deployment
|
# Armature Core - Deployment
|
||||||
# ============================================
|
# ============================================
|
||||||
# Secrets:
|
# Secrets:
|
||||||
# switchboard-db-credentials - POSTGRES_USER, POSTGRES_PASSWORD
|
# armature-db-credentials - POSTGRES_USER, POSTGRES_PASSWORD
|
||||||
# switchboard-admin - username, password, email (optional)
|
# armature-admin - username, password, email (optional)
|
||||||
# switchboard-encryption - ENCRYPTION_KEY (required for v0.9.4+)
|
# armature-encryption - ENCRYPTION_KEY (required for v0.9.4+)
|
||||||
#
|
#
|
||||||
# PVC:
|
# PVC:
|
||||||
# switchboard-storage - Extraction scratch (always needed)
|
# armature-storage - Extraction scratch (always needed)
|
||||||
# Requires ReadWriteMany (RWX) — see k8s/storage-pvc.yaml
|
# Requires ReadWriteMany (RWX) — see k8s/storage-pvc.yaml
|
||||||
# With S3 backend, PVC is small (extraction status only, not blobs)
|
# With S3 backend, PVC is small (extraction status only, not blobs)
|
||||||
#
|
#
|
||||||
@@ -23,32 +23,32 @@
|
|||||||
# S3_ACCESS_KEY - S3 access key
|
# S3_ACCESS_KEY - S3 access key
|
||||||
# S3_SECRET_KEY - S3 secret key
|
# S3_SECRET_KEY - S3 secret key
|
||||||
#
|
#
|
||||||
# Admin bootstrap: on every pod start, if SWITCHBOARD_ADMIN_* env vars
|
# Admin bootstrap: on every pod start, if ARMATURE_ADMIN_* env vars
|
||||||
# are set, the backend upserts an admin user. Change the secret and
|
# are set, the backend upserts an admin user. Change the secret and
|
||||||
# restart the pod to reset the admin password.
|
# restart the pod to reset the admin password.
|
||||||
# ============================================
|
# ============================================
|
||||||
apiVersion: apps/v1
|
apiVersion: apps/v1
|
||||||
kind: Deployment
|
kind: Deployment
|
||||||
metadata:
|
metadata:
|
||||||
name: switchboard${DEPLOY_SUFFIX}
|
name: armature${DEPLOY_SUFFIX}
|
||||||
namespace: ${NAMESPACE}
|
namespace: ${NAMESPACE}
|
||||||
labels:
|
labels:
|
||||||
app: switchboard
|
app: armature
|
||||||
env: ${ENVIRONMENT}
|
env: ${ENVIRONMENT}
|
||||||
spec:
|
spec:
|
||||||
replicas: ${REPLICAS}
|
replicas: ${REPLICAS}
|
||||||
selector:
|
selector:
|
||||||
matchLabels:
|
matchLabels:
|
||||||
app: switchboard
|
app: armature
|
||||||
env: ${ENVIRONMENT}
|
env: ${ENVIRONMENT}
|
||||||
template:
|
template:
|
||||||
metadata:
|
metadata:
|
||||||
labels:
|
labels:
|
||||||
app: switchboard
|
app: armature
|
||||||
env: ${ENVIRONMENT}
|
env: ${ENVIRONMENT}
|
||||||
spec:
|
spec:
|
||||||
containers:
|
containers:
|
||||||
- name: switchboard
|
- name: armature
|
||||||
image: ${IMAGE}:${IMAGE_TAG}
|
image: ${IMAGE}:${IMAGE_TAG}
|
||||||
imagePullPolicy: Always
|
imagePullPolicy: Always
|
||||||
ports:
|
ports:
|
||||||
@@ -72,43 +72,43 @@ spec:
|
|||||||
- name: POSTGRES_USER
|
- name: POSTGRES_USER
|
||||||
valueFrom:
|
valueFrom:
|
||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
name: switchboard-db-credentials
|
name: armature-db-credentials
|
||||||
key: POSTGRES_USER
|
key: POSTGRES_USER
|
||||||
- name: POSTGRES_PASSWORD
|
- name: POSTGRES_PASSWORD
|
||||||
valueFrom:
|
valueFrom:
|
||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
name: switchboard-db-credentials
|
name: armature-db-credentials
|
||||||
key: POSTGRES_PASSWORD
|
key: POSTGRES_PASSWORD
|
||||||
- name: DATABASE_URL
|
- name: DATABASE_URL
|
||||||
value: "postgres://$(POSTGRES_USER):$(POSTGRES_PASSWORD)@$(POSTGRES_HOST):$(POSTGRES_PORT)/$(POSTGRES_DB)?sslmode=disable"
|
value: "postgres://$(POSTGRES_USER):$(POSTGRES_PASSWORD)@$(POSTGRES_HOST):$(POSTGRES_PORT)/$(POSTGRES_DB)?sslmode=disable"
|
||||||
- name: SWITCHBOARD_ADMIN_USERNAME
|
- name: ARMATURE_ADMIN_USERNAME
|
||||||
valueFrom:
|
valueFrom:
|
||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
name: switchboard-admin
|
name: armature-admin
|
||||||
key: username
|
key: username
|
||||||
optional: true
|
optional: true
|
||||||
- name: SWITCHBOARD_ADMIN_PASSWORD
|
- name: ARMATURE_ADMIN_PASSWORD
|
||||||
valueFrom:
|
valueFrom:
|
||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
name: switchboard-admin
|
name: armature-admin
|
||||||
key: password
|
key: password
|
||||||
optional: true
|
optional: true
|
||||||
- name: SWITCHBOARD_ADMIN_EMAIL
|
- name: ARMATURE_ADMIN_EMAIL
|
||||||
valueFrom:
|
valueFrom:
|
||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
name: switchboard-admin
|
name: armature-admin
|
||||||
key: email
|
key: email
|
||||||
optional: true
|
optional: true
|
||||||
- name: SEED_USERS
|
- name: SEED_USERS
|
||||||
valueFrom:
|
valueFrom:
|
||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
name: switchboard-seed-users
|
name: armature-seed-users
|
||||||
key: users
|
key: users
|
||||||
optional: true
|
optional: true
|
||||||
- name: ENCRYPTION_KEY
|
- name: ENCRYPTION_KEY
|
||||||
valueFrom:
|
valueFrom:
|
||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
name: switchboard-encryption
|
name: armature-encryption
|
||||||
key: ENCRYPTION_KEY
|
key: ENCRYPTION_KEY
|
||||||
optional: true
|
optional: true
|
||||||
# File storage (v0.12.0+)
|
# File storage (v0.12.0+)
|
||||||
@@ -120,37 +120,37 @@ spec:
|
|||||||
- name: S3_ENDPOINT
|
- name: S3_ENDPOINT
|
||||||
valueFrom:
|
valueFrom:
|
||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
name: switchboard-s3
|
name: armature-s3
|
||||||
key: S3_ENDPOINT
|
key: S3_ENDPOINT
|
||||||
optional: true
|
optional: true
|
||||||
- name: S3_BUCKET
|
- name: S3_BUCKET
|
||||||
valueFrom:
|
valueFrom:
|
||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
name: switchboard-s3
|
name: armature-s3
|
||||||
key: S3_BUCKET
|
key: S3_BUCKET
|
||||||
optional: true
|
optional: true
|
||||||
- name: S3_ACCESS_KEY
|
- name: S3_ACCESS_KEY
|
||||||
valueFrom:
|
valueFrom:
|
||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
name: switchboard-s3
|
name: armature-s3
|
||||||
key: S3_ACCESS_KEY
|
key: S3_ACCESS_KEY
|
||||||
optional: true
|
optional: true
|
||||||
- name: S3_SECRET_KEY
|
- name: S3_SECRET_KEY
|
||||||
valueFrom:
|
valueFrom:
|
||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
name: switchboard-s3
|
name: armature-s3
|
||||||
key: S3_SECRET_KEY
|
key: S3_SECRET_KEY
|
||||||
optional: true
|
optional: true
|
||||||
- name: S3_REGION
|
- name: S3_REGION
|
||||||
valueFrom:
|
valueFrom:
|
||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
name: switchboard-s3
|
name: armature-s3
|
||||||
key: S3_REGION
|
key: S3_REGION
|
||||||
optional: true
|
optional: true
|
||||||
- name: S3_PREFIX
|
- name: S3_PREFIX
|
||||||
valueFrom:
|
valueFrom:
|
||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
name: switchboard-s3
|
name: armature-s3
|
||||||
key: S3_PREFIX
|
key: S3_PREFIX
|
||||||
optional: true
|
optional: true
|
||||||
- name: S3_FORCE_PATH_STYLE
|
- name: S3_FORCE_PATH_STYLE
|
||||||
@@ -191,19 +191,19 @@ spec:
|
|||||||
volumes:
|
volumes:
|
||||||
- name: storage
|
- name: storage
|
||||||
persistentVolumeClaim:
|
persistentVolumeClaim:
|
||||||
claimName: switchboard-storage${DEPLOY_SUFFIX}
|
claimName: armature-storage${DEPLOY_SUFFIX}
|
||||||
---
|
---
|
||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
kind: Service
|
kind: Service
|
||||||
metadata:
|
metadata:
|
||||||
name: switchboard${DEPLOY_SUFFIX}
|
name: armature${DEPLOY_SUFFIX}
|
||||||
namespace: ${NAMESPACE}
|
namespace: ${NAMESPACE}
|
||||||
labels:
|
labels:
|
||||||
app: switchboard
|
app: armature
|
||||||
env: ${ENVIRONMENT}
|
env: ${ENVIRONMENT}
|
||||||
spec:
|
spec:
|
||||||
selector:
|
selector:
|
||||||
app: switchboard
|
app: armature
|
||||||
env: ${ENVIRONMENT}
|
env: ${ENVIRONMENT}
|
||||||
ports:
|
ports:
|
||||||
- protocol: TCP
|
- protocol: TCP
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
# k8s/ingress.yaml
|
# k8s/ingress.yaml
|
||||||
# ============================================
|
# ============================================
|
||||||
# Switchboard Core - Ingress
|
# Armature Core - Ingress
|
||||||
# ============================================
|
# ============================================
|
||||||
# Path-based routing on a single domain:
|
# Path-based routing on a single domain:
|
||||||
# switchboard.DOMAIN/ → production
|
# armature.DOMAIN/ → production
|
||||||
# switchboard.DOMAIN/test/ → test (main branch)
|
# armature.DOMAIN/test/ → test (main branch)
|
||||||
# switchboard.DOMAIN/dev/ → dev (PR branches)
|
# armature.DOMAIN/dev/ → dev (PR branches)
|
||||||
#
|
#
|
||||||
# Each environment deploys its own Ingress resource.
|
# Each environment deploys its own Ingress resource.
|
||||||
# Traefik merges rules for the same host automatically.
|
# Traefik merges rules for the same host automatically.
|
||||||
@@ -18,29 +18,29 @@
|
|||||||
apiVersion: networking.k8s.io/v1
|
apiVersion: networking.k8s.io/v1
|
||||||
kind: Ingress
|
kind: Ingress
|
||||||
metadata:
|
metadata:
|
||||||
name: switchboard${DEPLOY_SUFFIX}
|
name: armature${DEPLOY_SUFFIX}
|
||||||
namespace: ${NAMESPACE}
|
namespace: ${NAMESPACE}
|
||||||
annotations:
|
annotations:
|
||||||
cert-manager.io/cluster-issuer: "${CERT_ISSUER}"
|
cert-manager.io/cluster-issuer: "${CERT_ISSUER}"
|
||||||
traefik.ingress.kubernetes.io/router.entrypoints: websecure
|
traefik.ingress.kubernetes.io/router.entrypoints: websecure
|
||||||
labels:
|
labels:
|
||||||
app: switchboard
|
app: armature
|
||||||
env: ${ENVIRONMENT}
|
env: ${ENVIRONMENT}
|
||||||
spec:
|
spec:
|
||||||
ingressClassName: "traefik"
|
ingressClassName: "traefik"
|
||||||
tls:
|
tls:
|
||||||
- hosts:
|
- hosts:
|
||||||
- ${DEPLOY_HOST}
|
- ${DEPLOY_HOST}
|
||||||
secretName: switchboard-tls
|
secretName: armature-tls
|
||||||
rules:
|
rules:
|
||||||
- host: "${DEPLOY_HOST}"
|
- host: "${DEPLOY_HOST}"
|
||||||
http:
|
http:
|
||||||
paths:
|
paths:
|
||||||
# All traffic → switchboard (unified image)
|
# All traffic → armature (unified image)
|
||||||
- path: ${BASE_PATH}/
|
- path: ${BASE_PATH}/
|
||||||
pathType: Prefix
|
pathType: Prefix
|
||||||
backend:
|
backend:
|
||||||
service:
|
service:
|
||||||
name: switchboard${DEPLOY_SUFFIX}
|
name: armature${DEPLOY_SUFFIX}
|
||||||
port:
|
port:
|
||||||
number: 80
|
number: 80
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# k8s/middleware-retry.yaml
|
# k8s/middleware-retry.yaml
|
||||||
# ============================================
|
# ============================================
|
||||||
# Switchboard Core - Traefik Retry Middleware
|
# Armature Core - Traefik Retry Middleware
|
||||||
# ============================================
|
# ============================================
|
||||||
# Retries failed requests (502, connection reset) once before giving up.
|
# Retries failed requests (502, connection reset) once before giving up.
|
||||||
# Applies to API + WebSocket paths via the Ingress annotation.
|
# Applies to API + WebSocket paths via the Ingress annotation.
|
||||||
@@ -15,10 +15,10 @@
|
|||||||
apiVersion: traefik.io/v1alpha1
|
apiVersion: traefik.io/v1alpha1
|
||||||
kind: Middleware
|
kind: Middleware
|
||||||
metadata:
|
metadata:
|
||||||
name: switchboard-retry${DEPLOY_SUFFIX}
|
name: armature-retry${DEPLOY_SUFFIX}
|
||||||
namespace: ${NAMESPACE}
|
namespace: ${NAMESPACE}
|
||||||
labels:
|
labels:
|
||||||
app: switchboard
|
app: armature
|
||||||
env: ${ENVIRONMENT}
|
env: ${ENVIRONMENT}
|
||||||
spec:
|
spec:
|
||||||
retry:
|
retry:
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ metadata:
|
|||||||
name: traefik-middleware-manager
|
name: traefik-middleware-manager
|
||||||
namespace: ${NAMESPACE}
|
namespace: ${NAMESPACE}
|
||||||
labels:
|
labels:
|
||||||
app: switchboard
|
app: armature
|
||||||
rules:
|
rules:
|
||||||
- apiGroups: ["traefik.io"]
|
- apiGroups: ["traefik.io"]
|
||||||
resources: ["middlewares"]
|
resources: ["middlewares"]
|
||||||
@@ -28,7 +28,7 @@ metadata:
|
|||||||
name: gitea-runner-traefik-middleware
|
name: gitea-runner-traefik-middleware
|
||||||
namespace: ${NAMESPACE}
|
namespace: ${NAMESPACE}
|
||||||
labels:
|
labels:
|
||||||
app: switchboard
|
app: armature
|
||||||
subjects:
|
subjects:
|
||||||
- kind: ServiceAccount
|
- kind: ServiceAccount
|
||||||
name: gitea-runner-sa
|
name: gitea-runner-sa
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# k8s/storage-pvc.yaml
|
# k8s/storage-pvc.yaml
|
||||||
# ============================================
|
# ============================================
|
||||||
# Switchboard Core - Storage PVC
|
# Armature Core - Storage PVC
|
||||||
# ============================================
|
# ============================================
|
||||||
# Persistent storage for file attachments, extraction queue,
|
# Persistent storage for file attachments, extraction queue,
|
||||||
# and future knowledge base / compaction data.
|
# and future knowledge base / compaction data.
|
||||||
@@ -17,10 +17,10 @@
|
|||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
kind: PersistentVolumeClaim
|
kind: PersistentVolumeClaim
|
||||||
metadata:
|
metadata:
|
||||||
name: switchboard-storage${DEPLOY_SUFFIX}
|
name: armature-storage${DEPLOY_SUFFIX}
|
||||||
namespace: ${NAMESPACE}
|
namespace: ${NAMESPACE}
|
||||||
labels:
|
labels:
|
||||||
app: switchboard
|
app: armature
|
||||||
component: storage
|
component: storage
|
||||||
env: ${ENVIRONMENT}
|
env: ${ENVIRONMENT}
|
||||||
spec:
|
spec:
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Packages
|
# Packages
|
||||||
|
|
||||||
Installable `.pkg` archives for Chat Switchboard. Each subdirectory is a
|
Installable `.pkg` archives for Chat Armature. Each subdirectory is a
|
||||||
package — either a surface (routable UI), an extension (hooks/tools/pipes),
|
package — either a surface (routable UI), an extension (hooks/tools/pipes),
|
||||||
or both (`full` type).
|
or both (`full` type).
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"tier": "browser",
|
"tier": "browser",
|
||||||
"icon": "🐛",
|
"icon": "🐛",
|
||||||
"author": "Switchboard Core",
|
"author": "Armature",
|
||||||
"description": "Public bug report submission with severity-based routing, team assignment, and SLA timers.",
|
"description": "Public bug report submission with severity-based routing, team assignment, and SLA timers.",
|
||||||
"permissions": [],
|
"permissions": [],
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
"tier": "starlark",
|
"tier": "starlark",
|
||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"description": "Core chat library — conversations, messages, participants, read cursors. Usable by other packages via lib.require().",
|
"description": "Core chat library — conversations, messages, participants, read cursors. Usable by other packages via lib.require().",
|
||||||
"author": "switchboard",
|
"author": "armature",
|
||||||
|
|
||||||
"permissions": ["db.write", "realtime.publish"],
|
"permissions": ["db.write", "realtime.publish"],
|
||||||
|
|
||||||
|
|||||||
@@ -91,6 +91,9 @@ def create(title, type="group", participants=None, creator_id="", creator_displa
|
|||||||
cid = conv["id"]
|
cid = conv["id"]
|
||||||
|
|
||||||
# Add creator as admin participant
|
# Add creator as admin participant
|
||||||
|
# NOTE: display_name is a snapshot captured at creation time.
|
||||||
|
# DEPRECATED v0.6.15 — UI resolves display names from users table via sw.users.resolve().
|
||||||
|
# Column retained for backward compatibility; UI no longer relies on this value for display.
|
||||||
if creator_id:
|
if creator_id:
|
||||||
db.insert("participants", {
|
db.insert("participants", {
|
||||||
"conversation_id": cid,
|
"conversation_id": cid,
|
||||||
@@ -110,7 +113,7 @@ def create(title, type="group", participants=None, creator_id="", creator_displa
|
|||||||
"conversation_id": cid,
|
"conversation_id": cid,
|
||||||
"participant_id": pid,
|
"participant_id": pid,
|
||||||
"participant_type": _str(p.get("type", "user")),
|
"participant_type": _str(p.get("type", "user")),
|
||||||
"display_name": _str(p.get("display_name", "")),
|
"display_name": _str(p.get("display_name", "")), # DEPRECATED v0.6.15 — snapshot only
|
||||||
"role": _str(p.get("role", "member")),
|
"role": _str(p.get("role", "member")),
|
||||||
"joined_at": "",
|
"joined_at": "",
|
||||||
})
|
})
|
||||||
@@ -213,7 +216,7 @@ def add_participant(conversation_id, participant_id, participant_type="user", di
|
|||||||
"conversation_id": cid,
|
"conversation_id": cid,
|
||||||
"participant_id": pid,
|
"participant_id": pid,
|
||||||
"participant_type": _str(participant_type),
|
"participant_type": _str(participant_type),
|
||||||
"display_name": _str(display_name),
|
"display_name": _str(display_name), # DEPRECATED v0.6.15 — snapshot only
|
||||||
"role": _str(role),
|
"role": _str(role),
|
||||||
"joined_at": "",
|
"joined_at": "",
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -9,29 +9,29 @@
|
|||||||
|
|
||||||
/* ── Layout ─────────────────────────────── */
|
/* ── Layout ─────────────────────────────── */
|
||||||
|
|
||||||
.chat-app {
|
.ext-chat-app {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100vh;
|
height: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-loading {
|
.ext-chat-loading {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
height: 100vh;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-body {
|
.ext-chat-body {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-main {
|
.ext-chat-main {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -40,16 +40,16 @@
|
|||||||
|
|
||||||
/* ── Topbar extras ──────────────────────── */
|
/* ── Topbar extras ──────────────────────── */
|
||||||
|
|
||||||
.chat-topbar__thread-title {
|
.ext-chat-topbar__thread-title {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
margin-right: 8px;
|
margin-right: var(--sp-2);
|
||||||
color: var(--text-2);
|
color: var(--text-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Sidebar ────────────────────────────── */
|
/* ── Sidebar ────────────────────────────── */
|
||||||
|
|
||||||
.chat-sidebar {
|
.ext-chat-sidebar {
|
||||||
width: 280px;
|
width: 280px;
|
||||||
min-width: 280px;
|
min-width: 280px;
|
||||||
border-right: 1px solid var(--border);
|
border-right: 1px solid var(--border);
|
||||||
@@ -58,76 +58,76 @@
|
|||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-sidebar__header {
|
.ext-chat-sidebar__header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 12px 16px;
|
padding: var(--sp-3) var(--sp-4);
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-sidebar__title {
|
.ext-chat-sidebar__title {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-sidebar__list {
|
.ext-chat-sidebar__list {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-sidebar__empty {
|
.ext-chat-sidebar__empty {
|
||||||
padding: 24px 16px;
|
padding: var(--sp-6) var(--sp-4);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-sidebar__item {
|
.ext-chat-sidebar__item {
|
||||||
padding: 10px 16px;
|
padding: var(--sp-3) var(--sp-4);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-bottom: 1px solid var(--border-light);
|
border-bottom: 1px solid var(--border-light);
|
||||||
transition: background 0.1s;
|
transition: background 0.1s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-sidebar__item:hover {
|
.ext-chat-sidebar__item:hover {
|
||||||
background: var(--bg-hover);
|
background: var(--bg-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-sidebar__item--active {
|
.ext-chat-sidebar__item--active {
|
||||||
background: var(--accent-dim);
|
background: var(--accent-dim);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-sidebar__item-top {
|
.ext-chat-sidebar__item-top {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
margin-bottom: 2px;
|
margin-bottom: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-sidebar__item-title {
|
.ext-chat-sidebar__item-title {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
margin-right: 8px;
|
margin-right: var(--sp-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-sidebar__item-time {
|
.ext-chat-sidebar__item-time {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-sidebar__item-bottom {
|
.ext-chat-sidebar__item-bottom {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: var(--sp-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-sidebar__item-preview {
|
.ext-chat-sidebar__item-preview {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-2);
|
color: var(--text-2);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -136,7 +136,7 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-sidebar__badge {
|
.ext-chat-sidebar__badge {
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
color: var(--text-on-color);
|
color: var(--text-on-color);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
@@ -153,17 +153,17 @@
|
|||||||
|
|
||||||
/* ── Sidebar Search ────────────────────── */
|
/* ── Sidebar Search ────────────────────── */
|
||||||
|
|
||||||
.chat-sidebar__search {
|
.ext-chat-sidebar__search {
|
||||||
position: relative;
|
position: relative;
|
||||||
padding: 8px 16px;
|
padding: var(--sp-2) var(--sp-4);
|
||||||
border-bottom: 1px solid var(--border-light);
|
border-bottom: 1px solid var(--border-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-sidebar__search-input {
|
.ext-chat-sidebar__search-input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 6px;
|
border-radius: var(--radius);
|
||||||
padding: 6px 28px 6px 10px;
|
padding: var(--sp-2) var(--sp-6) var(--sp-2) var(--sp-3);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
background: var(--input-bg);
|
background: var(--input-bg);
|
||||||
@@ -171,12 +171,12 @@
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-sidebar__search-input:focus {
|
.ext-chat-sidebar__search-input:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-sidebar__search-clear {
|
.ext-chat-sidebar__search-clear {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 22px;
|
right: 22px;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
@@ -186,21 +186,21 @@
|
|||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
padding: 0 4px;
|
padding: 0 var(--sp-1);
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-sidebar__search-clear:hover {
|
.ext-chat-sidebar__search-clear:hover {
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-sidebar__search-results {
|
.ext-chat-sidebar__search-results {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-sidebar__search-section {
|
.ext-chat-sidebar__search-section {
|
||||||
padding: 8px 16px 4px;
|
padding: var(--sp-2) var(--sp-4) var(--sp-1);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -208,13 +208,13 @@
|
|||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-sidebar__search-loading {
|
.ext-chat-sidebar__search-loading {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 16px;
|
padding: var(--sp-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-sidebar__item--search-msg .chat-sidebar__item-preview {
|
.ext-chat-sidebar__item--search-msg .ext-chat-sidebar__item-preview {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
white-space: normal;
|
white-space: normal;
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
@@ -225,58 +225,58 @@
|
|||||||
|
|
||||||
/* ── Message Thread ─────────────────────── */
|
/* ── Message Thread ─────────────────────── */
|
||||||
|
|
||||||
.chat-thread {
|
.ext-chat-thread {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-thread--empty {
|
.ext-chat-thread--empty {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-thread__messages {
|
.ext-chat-thread__messages {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 16px;
|
padding: var(--sp-4);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 4px;
|
gap: var(--sp-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-thread__loading {
|
.ext-chat-thread__loading {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 24px;
|
padding: var(--sp-6);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-thread__loading-more {
|
.ext-chat-thread__loading-more {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 8px;
|
padding: var(--sp-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-thread__load-more {
|
.ext-chat-thread__load-more {
|
||||||
align-self: center;
|
align-self: center;
|
||||||
background: none;
|
background: none;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-sm);
|
||||||
padding: 4px 12px;
|
padding: var(--sp-1) var(--sp-3);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-2);
|
color: var(--text-2);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
margin-bottom: 8px;
|
margin-bottom: var(--sp-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-thread__load-more:hover {
|
.ext-chat-thread__load-more:hover {
|
||||||
background: var(--bg-hover);
|
background: var(--bg-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-thread__typing {
|
.ext-chat-thread__typing {
|
||||||
padding: 4px 16px 8px;
|
padding: var(--sp-1) var(--sp-4) var(--sp-2);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
@@ -284,52 +284,52 @@
|
|||||||
|
|
||||||
/* ── Message Bubble ─────────────────────── */
|
/* ── Message Bubble ─────────────────────── */
|
||||||
|
|
||||||
.chat-msg {
|
.ext-chat-msg {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 8px;
|
gap: var(--sp-2);
|
||||||
padding: 4px 0;
|
padding: var(--sp-1) 0;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-msg--own {
|
.ext-chat-msg--own {
|
||||||
flex-direction: row-reverse;
|
flex-direction: row-reverse;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-msg--system {
|
.ext-chat-msg--system {
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 2px 0;
|
padding: 2px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-msg--system span {
|
.ext-chat-msg--system span {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-msg--deleted {
|
.ext-chat-msg--deleted {
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 2px 0;
|
padding: 2px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-msg--deleted em {
|
.ext-chat-msg--deleted em {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-msg__body {
|
.ext-chat-msg__body {
|
||||||
max-width: 65%;
|
max-width: 65%;
|
||||||
background: var(--bg-raised);
|
background: var(--bg-raised);
|
||||||
border-radius: 12px;
|
border-radius: var(--radius-lg);
|
||||||
padding: 8px 12px;
|
padding: var(--sp-2) var(--sp-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-msg--own .chat-msg__body {
|
.ext-chat-msg--own .ext-chat-msg__body {
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
color: var(--text-on-color);
|
color: var(--text-on-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-msg__name {
|
.ext-chat-msg__name {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-2);
|
color: var(--text-2);
|
||||||
@@ -337,42 +337,42 @@
|
|||||||
margin-bottom: 2px;
|
margin-bottom: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-msg__content {
|
.ext-chat-msg__content {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-msg__meta {
|
.ext-chat-msg__meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 6px;
|
gap: var(--sp-2);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-msg__time {
|
.ext-chat-msg__time {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-msg--own .chat-msg__time {
|
.ext-chat-msg--own .ext-chat-msg__time {
|
||||||
color: rgba(255, 255, 255, 0.7);
|
color: rgba(255, 255, 255, 0.7);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-msg__edited {
|
.ext-chat-msg__edited {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-msg--own .chat-msg__edited {
|
.ext-chat-msg--own .ext-chat-msg__edited {
|
||||||
color: rgba(255, 255, 255, 0.7);
|
color: rgba(255, 255, 255, 0.7);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Message Actions ────────────────────── */
|
/* ── Message Actions ────────────────────── */
|
||||||
|
|
||||||
.chat-msg__actions {
|
.ext-chat-msg__actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -380,50 +380,50 @@
|
|||||||
right: 0;
|
right: 0;
|
||||||
background: var(--bg-surface);
|
background: var(--bg-surface);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 6px;
|
border-radius: var(--radius);
|
||||||
box-shadow: var(--shadow-lg);
|
box-shadow: var(--shadow-lg);
|
||||||
padding: 2px;
|
padding: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-msg--own .chat-msg__actions {
|
.ext-chat-msg--own .ext-chat-msg__actions {
|
||||||
right: auto;
|
right: auto;
|
||||||
left: 0;
|
left: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-msg__action {
|
.ext-chat-msg__action {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
padding: 4px 6px;
|
padding: var(--sp-1) var(--sp-2);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-sm);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
color: var(--text-2);
|
color: var(--text-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-msg__action:hover {
|
.ext-chat-msg__action:hover {
|
||||||
background: var(--bg-hover);
|
background: var(--bg-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-msg__action--danger:hover {
|
.ext-chat-msg__action--danger:hover {
|
||||||
background: var(--danger-bg);
|
background: var(--danger-bg);
|
||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Message Edit ───────────────────────── */
|
/* ── Message Edit ───────────────────────── */
|
||||||
|
|
||||||
.chat-msg__edit {
|
.ext-chat-msg__edit {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 6px;
|
gap: var(--sp-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-msg__edit-input {
|
.ext-chat-msg__edit-input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-width: 200px;
|
min-width: 200px;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 6px;
|
border-radius: var(--radius);
|
||||||
padding: 6px 8px;
|
padding: var(--sp-2) var(--sp-2);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
resize: vertical;
|
resize: vertical;
|
||||||
@@ -431,28 +431,28 @@
|
|||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-msg__edit-actions {
|
.ext-chat-msg__edit-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 6px;
|
gap: var(--sp-2);
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Compose Bar ────────────────────────── */
|
/* ── Compose Bar ────────────────────────── */
|
||||||
|
|
||||||
.chat-compose {
|
.ext-chat-compose {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
gap: 8px;
|
gap: var(--sp-2);
|
||||||
padding: 12px 16px;
|
padding: var(--sp-3) var(--sp-4);
|
||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid var(--border);
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-compose__input {
|
.ext-chat-compose__input {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 8px;
|
border-radius: var(--radius);
|
||||||
padding: 8px 12px;
|
padding: var(--sp-2) var(--sp-3);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
resize: none;
|
resize: none;
|
||||||
@@ -462,7 +462,7 @@
|
|||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-compose__input:focus {
|
.ext-chat-compose__input:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
box-shadow: 0 0 0 2px var(--accent-dim);
|
box-shadow: 0 0 0 2px var(--accent-dim);
|
||||||
@@ -470,7 +470,7 @@
|
|||||||
|
|
||||||
/* ── Participant Sidebar ────────────────── */
|
/* ── Participant Sidebar ────────────────── */
|
||||||
|
|
||||||
.chat-participants {
|
.ext-chat-participants {
|
||||||
width: 240px;
|
width: 240px;
|
||||||
min-width: 240px;
|
min-width: 240px;
|
||||||
border-left: 1px solid var(--border);
|
border-left: 1px solid var(--border);
|
||||||
@@ -479,30 +479,30 @@
|
|||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-participants__header {
|
.ext-chat-participants__header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 12px 16px;
|
padding: var(--sp-3) var(--sp-4);
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-participants__list {
|
.ext-chat-participants__list {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 8px 0;
|
padding: var(--sp-2) 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-participants__item {
|
.ext-chat-participants__item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: var(--sp-2);
|
||||||
padding: 6px 16px;
|
padding: var(--sp-2) var(--sp-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-participants__name {
|
.ext-chat-participants__name {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -510,14 +510,14 @@
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-participants__badge {
|
.ext-chat-participants__badge {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin-left: 4px;
|
margin-left: var(--sp-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-participants__status {
|
.ext-chat-participants__status {
|
||||||
width: 8px;
|
width: 8px;
|
||||||
height: 8px;
|
height: 8px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
@@ -525,75 +525,75 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-participants__status--online {
|
.ext-chat-participants__status--online {
|
||||||
background: var(--success);
|
background: var(--success);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-participants__remove {
|
.ext-chat-participants__remove {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
padding: 0 4px;
|
padding: 0 var(--sp-1);
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-participants__remove:hover {
|
.ext-chat-participants__remove:hover {
|
||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── New Conversation Dialog ────────────── */
|
/* ── New Conversation Dialog ────────────── */
|
||||||
|
|
||||||
.chat-new {
|
.ext-chat-new {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 12px;
|
gap: var(--sp-3);
|
||||||
min-width: 320px;
|
min-width: 320px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-new__type {
|
.ext-chat-new__type {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 16px;
|
gap: var(--sp-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-new__type label {
|
.ext-chat-new__type label {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: var(--sp-2);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-new__title {
|
.ext-chat-new__title {
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 6px;
|
border-radius: var(--radius);
|
||||||
padding: 8px 10px;
|
padding: var(--sp-2) var(--sp-3);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
background: var(--input-bg);
|
background: var(--input-bg);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-new__selected {
|
.ext-chat-new__selected {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 6px;
|
gap: var(--sp-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-new__chip {
|
.ext-chat-new__chip {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: var(--sp-1);
|
||||||
background: var(--accent-dim);
|
background: var(--accent-dim);
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
padding: 3px 8px;
|
padding: 3px var(--sp-2);
|
||||||
border-radius: 12px;
|
border-radius: var(--radius-lg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-new__chip button {
|
.ext-chat-new__chip button {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
@@ -607,10 +607,10 @@
|
|||||||
/* Allow the autocomplete dropdown to overflow the dialog body.
|
/* Allow the autocomplete dropdown to overflow the dialog body.
|
||||||
Applies to both New Conversation and Add Participant dialogs. */
|
Applies to both New Conversation and Add Participant dialogs. */
|
||||||
|
|
||||||
.sw-dialog__body:has(.sw-user-picker) {
|
[data-ext="chat"] .sw-dialog__body:has(.sw-user-picker) {
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sw-dialog:has(.sw-user-picker) {
|
[data-ext="chat"] .sw-dialog:has(.sw-user-picker) {
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,60 +123,60 @@
|
|||||||
var sMsgs = showSearch ? (searchResults.messages || []) : [];
|
var sMsgs = showSearch ? (searchResults.messages || []) : [];
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="chat-sidebar">
|
<div class="ext-chat-sidebar">
|
||||||
<div class="chat-sidebar__header">
|
<div class="ext-chat-sidebar__header">
|
||||||
<span class="chat-sidebar__title">Conversations</span>
|
<span class="ext-chat-sidebar__title">Conversations</span>
|
||||||
<${Button} size="sm" onClick=${onNew}>New<//>
|
<${Button} size="sm" onClick=${onNew}>New<//>
|
||||||
</div>
|
</div>
|
||||||
<div class="chat-sidebar__search">
|
<div class="ext-chat-sidebar__search">
|
||||||
<input class="chat-sidebar__search-input"
|
<input class="ext-chat-sidebar__search-input"
|
||||||
type="text"
|
type="text"
|
||||||
value=${searchQuery}
|
value=${searchQuery}
|
||||||
placeholder="Search\u2026"
|
placeholder="Search\u2026"
|
||||||
onInput=${handleSearchInput} />
|
onInput=${handleSearchInput} />
|
||||||
${searchQuery && html`
|
${searchQuery && html`
|
||||||
<button class="chat-sidebar__search-clear" onClick=${clearSearch}>\u00d7</button>`}
|
<button class="ext-chat-sidebar__search-clear" onClick=${clearSearch}>\u00d7</button>`}
|
||||||
</div>
|
</div>
|
||||||
${showSearch ? html`
|
${showSearch ? html`
|
||||||
<div class="chat-sidebar__search-results">
|
<div class="ext-chat-sidebar__search-results">
|
||||||
${searching && html`<div class="chat-sidebar__search-loading"><${Spinner} size="sm" /></div>`}
|
${searching && html`<div class="ext-chat-sidebar__search-loading"><${Spinner} size="sm" /></div>`}
|
||||||
${!searching && sConvs.length === 0 && sMsgs.length === 0 && html`
|
${!searching && sConvs.length === 0 && sMsgs.length === 0 && html`
|
||||||
<div class="chat-sidebar__empty">No results</div>`}
|
<div class="ext-chat-sidebar__empty">No results</div>`}
|
||||||
${sConvs.length > 0 && html`
|
${sConvs.length > 0 && html`
|
||||||
<div class="chat-sidebar__search-section">Conversations</div>
|
<div class="ext-chat-sidebar__search-section">Conversations</div>
|
||||||
${sConvs.map(c => html`
|
${sConvs.map(c => html`
|
||||||
<div key=${c.id} class="chat-sidebar__item" onClick=${() => selectFromSearch(c.id)}>
|
<div key=${c.id} class="ext-chat-sidebar__item" onClick=${() => selectFromSearch(c.id)}>
|
||||||
<div class="chat-sidebar__item-top">
|
<div class="ext-chat-sidebar__item-top">
|
||||||
<span class="chat-sidebar__item-title">${c.title || 'Untitled'}</span>
|
<span class="ext-chat-sidebar__item-title">${c.title || 'Untitled'}</span>
|
||||||
<span class="chat-sidebar__item-time">${timeAgo(c.updated_at || c.created_at)}</span>
|
<span class="ext-chat-sidebar__item-time">${timeAgo(c.updated_at || c.created_at)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>`)}`}
|
</div>`)}`}
|
||||||
${sMsgs.length > 0 && html`
|
${sMsgs.length > 0 && html`
|
||||||
<div class="chat-sidebar__search-section">Messages</div>
|
<div class="ext-chat-sidebar__search-section">Messages</div>
|
||||||
${sMsgs.map(m => html`
|
${sMsgs.map(m => html`
|
||||||
<div key=${m.id} class="chat-sidebar__item chat-sidebar__item--search-msg" onClick=${() => selectFromSearch(m.conversation_id)}>
|
<div key=${m.id} class="ext-chat-sidebar__item ext-chat-sidebar__item--search-msg" onClick=${() => selectFromSearch(m.conversation_id)}>
|
||||||
<div class="chat-sidebar__item-top">
|
<div class="ext-chat-sidebar__item-top">
|
||||||
<span class="chat-sidebar__item-preview">${truncate(m.content, 80)}</span>
|
<span class="ext-chat-sidebar__item-preview">${truncate(m.content, 80)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="chat-sidebar__item-bottom">
|
<div class="ext-chat-sidebar__item-bottom">
|
||||||
<span class="chat-sidebar__item-time">${timeAgo(m.created_at)}</span>
|
<span class="ext-chat-sidebar__item-time">${timeAgo(m.created_at)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>`)}`}
|
</div>`)}`}
|
||||||
</div>
|
</div>
|
||||||
` : html`
|
` : html`
|
||||||
<div class="chat-sidebar__list">
|
<div class="ext-chat-sidebar__list">
|
||||||
${conversations.length === 0 && html`
|
${conversations.length === 0 && html`
|
||||||
<div class="chat-sidebar__empty">No conversations yet</div>`}
|
<div class="ext-chat-sidebar__empty">No conversations yet</div>`}
|
||||||
${conversations.map(c => html`
|
${conversations.map(c => html`
|
||||||
<div key=${c.id}
|
<div key=${c.id}
|
||||||
class=${'chat-sidebar__item' + (selected === c.id ? ' chat-sidebar__item--active' : '')}
|
class=${'ext-chat-sidebar__item' + (selected === c.id ? ' ext-chat-sidebar__item--active' : '')}
|
||||||
onClick=${() => onSelect(c.id)}>
|
onClick=${() => onSelect(c.id)}>
|
||||||
<div class="chat-sidebar__item-top">
|
<div class="ext-chat-sidebar__item-top">
|
||||||
<span class="chat-sidebar__item-title">${c.title || 'Untitled'}</span>
|
<span class="ext-chat-sidebar__item-title">${c.title || 'Untitled'}</span>
|
||||||
<span class="chat-sidebar__item-time">${timeAgo(c.updated_at || c.created_at)}</span>
|
<span class="ext-chat-sidebar__item-time">${timeAgo(c.updated_at || c.created_at)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="chat-sidebar__item-bottom">
|
<div class="ext-chat-sidebar__item-bottom">
|
||||||
<span class="chat-sidebar__item-preview">
|
<span class="ext-chat-sidebar__item-preview">
|
||||||
${c.last_message
|
${c.last_message
|
||||||
? truncate(c.last_message.content_type === 'system'
|
? truncate(c.last_message.content_type === 'system'
|
||||||
? '\u2022 ' + c.last_message.content
|
? '\u2022 ' + c.last_message.content
|
||||||
@@ -184,7 +184,7 @@
|
|||||||
: 'No messages yet'}
|
: 'No messages yet'}
|
||||||
</span>
|
</span>
|
||||||
${(unread[c.id] || 0) > 0 && html`
|
${(unread[c.id] || 0) > 0 && html`
|
||||||
<span class="chat-sidebar__badge">${unread[c.id]}</span>`}
|
<span class="ext-chat-sidebar__badge">${unread[c.id]}</span>`}
|
||||||
</div>
|
</div>
|
||||||
</div>`)}
|
</div>`)}
|
||||||
</div>
|
</div>
|
||||||
@@ -204,14 +204,14 @@
|
|||||||
|
|
||||||
if (msg._deleted) {
|
if (msg._deleted) {
|
||||||
return html`
|
return html`
|
||||||
<div class="chat-msg chat-msg--deleted">
|
<div class="ext-chat-msg ext-chat-msg--deleted">
|
||||||
<em>This message was deleted</em>
|
<em>This message was deleted</em>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (msg.content_type === 'system') {
|
if (msg.content_type === 'system') {
|
||||||
return html`
|
return html`
|
||||||
<div class="chat-msg chat-msg--system">
|
<div class="ext-chat-msg ext-chat-msg--system">
|
||||||
<span>${msg.content}</span>
|
<span>${msg.content}</span>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
@@ -239,37 +239,37 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class=${'chat-msg' + (isOwn ? ' chat-msg--own' : '')}
|
<div class=${'ext-chat-msg' + (isOwn ? ' ext-chat-msg--own' : '')}
|
||||||
onMouseEnter=${() => setHover(true)}
|
onMouseEnter=${() => setHover(true)}
|
||||||
onMouseLeave=${() => setHover(false)}>
|
onMouseLeave=${() => setHover(false)}>
|
||||||
${!isOwn && html`
|
${!isOwn && html`
|
||||||
<${Avatar} name=${msg._display_name || msg.participant_id} size="sm" />`}
|
<${Avatar} name=${msg._display_name || 'Unknown'} size="sm" />`}
|
||||||
<div class="chat-msg__body">
|
<div class="ext-chat-msg__body">
|
||||||
${!isOwn && html`<span class="chat-msg__name">${msg._display_name || msg.participant_id}</span>`}
|
${!isOwn && html`<span class="ext-chat-msg__name">${msg._display_name || 'Unknown'}</span>`}
|
||||||
${editing ? html`
|
${editing ? html`
|
||||||
<div class="chat-msg__edit">
|
<div class="ext-chat-msg__edit">
|
||||||
<textarea class="chat-msg__edit-input"
|
<textarea class="ext-chat-msg__edit-input"
|
||||||
value=${editText}
|
value=${editText}
|
||||||
onInput=${e => setEditText(e.target.value)}
|
onInput=${e => setEditText(e.target.value)}
|
||||||
onKeyDown=${onEditKeyDown}
|
onKeyDown=${onEditKeyDown}
|
||||||
rows="2" />
|
rows="2" />
|
||||||
<div class="chat-msg__edit-actions">
|
<div class="ext-chat-msg__edit-actions">
|
||||||
<${Button} size="sm" variant="secondary" onClick=${cancelEdit}>Cancel<//>
|
<${Button} size="sm" variant="secondary" onClick=${cancelEdit}>Cancel<//>
|
||||||
<${Button} size="sm" onClick=${saveEdit}>Save<//>
|
<${Button} size="sm" onClick=${saveEdit}>Save<//>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
` : msg.content_type === 'markdown' && sw?.markdown?.ready ? html`
|
` : msg.content_type === 'markdown' && sw?.markdown?.ready ? html`
|
||||||
<div class="chat-msg__content" dangerouslySetInnerHTML=${{ __html: sw.markdown.renderSync(msg.content, { sanitize: true }) }} />` : html`
|
<div class="ext-chat-msg__content" dangerouslySetInnerHTML=${{ __html: sw.markdown.renderSync(msg.content, { sanitize: true }) }} />` : html`
|
||||||
<div class="chat-msg__content">${msg.content}</div>`}
|
<div class="ext-chat-msg__content">${msg.content}</div>`}
|
||||||
<div class="chat-msg__meta">
|
<div class="ext-chat-msg__meta">
|
||||||
<span class="chat-msg__time">${timeAgo(msg.created_at)}</span>
|
<span class="ext-chat-msg__time">${timeAgo(msg.created_at)}</span>
|
||||||
${msg.edited_at && html`<span class="chat-msg__edited">(edited)</span>`}
|
${msg.edited_at && html`<span class="ext-chat-msg__edited">(edited)</span>`}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
${hover && isOwn && !editing && html`
|
${hover && isOwn && !editing && html`
|
||||||
<div class="chat-msg__actions">
|
<div class="ext-chat-msg__actions">
|
||||||
<button class="chat-msg__action" onClick=${startEdit} title="Edit">✎</button>
|
<button class="ext-chat-msg__action" onClick=${startEdit} title="Edit">✎</button>
|
||||||
<button class="chat-msg__action chat-msg__action--danger" onClick=${() => onDelete(msg.id)} title="Delete">🗑</button>
|
<button class="ext-chat-msg__action ext-chat-msg__action--danger" onClick=${() => onDelete(msg.id)} title="Delete">🗑</button>
|
||||||
</div>`}
|
</div>`}
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
@@ -285,20 +285,32 @@
|
|||||||
var [hasMore, setHasMore] = useState(false);
|
var [hasMore, setHasMore] = useState(false);
|
||||||
var [nextCursor, setNextCursor] = useState('');
|
var [nextCursor, setNextCursor] = useState('');
|
||||||
var [typingUsers, setTypingUsers] = useState({});
|
var [typingUsers, setTypingUsers] = useState({});
|
||||||
|
var [resolvedNames, setResolvedNames] = useState({});
|
||||||
var bottomRef = useRef(null);
|
var bottomRef = useRef(null);
|
||||||
var listRef = useRef(null);
|
var listRef = useRef(null);
|
||||||
var userId = currentUserId();
|
var userId = currentUserId();
|
||||||
|
|
||||||
// Build participant lookup
|
// Resolve participant display names from users table (not snapshot)
|
||||||
|
useEffect(() => {
|
||||||
|
var ids = (participants || []).map(p => p.participant_id).filter(Boolean);
|
||||||
|
if (ids.length === 0) return;
|
||||||
|
sw.users.resolveMany(ids).then(map => {
|
||||||
|
var names = {};
|
||||||
|
map.forEach((user, id) => { names[id] = sw.users.displayName(user); });
|
||||||
|
setResolvedNames(names);
|
||||||
|
});
|
||||||
|
}, [participants]);
|
||||||
|
|
||||||
|
// Build participant lookup from resolved names
|
||||||
var partMap = useMemo(() => {
|
var partMap = useMemo(() => {
|
||||||
var m = {};
|
var m = {};
|
||||||
(participants || []).forEach(p => { m[p.participant_id] = p.display_name || p.participant_id; });
|
(participants || []).forEach(p => { m[p.participant_id] = resolvedNames[p.participant_id] || p.display_name || 'Unknown'; });
|
||||||
return m;
|
return m;
|
||||||
}, [participants]);
|
}, [participants, resolvedNames]);
|
||||||
|
|
||||||
// Enrich messages with display names
|
// Enrich messages with display names
|
||||||
function enrichMessages(msgs) {
|
function enrichMessages(msgs) {
|
||||||
return msgs.map(m => ({ ...m, _display_name: partMap[m.participant_id] || m.participant_id }));
|
return msgs.map(m => ({ ...m, _display_name: partMap[m.participant_id] || 'Unknown' }));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load initial messages
|
// Load initial messages
|
||||||
@@ -361,7 +373,7 @@
|
|||||||
|
|
||||||
var unsubs = [
|
var unsubs = [
|
||||||
sw.realtime.subscribe(channel, 'message', (payload) => {
|
sw.realtime.subscribe(channel, 'message', (payload) => {
|
||||||
var msg = { ...payload, _display_name: partMap[payload.participant_id] || payload.participant_id };
|
var msg = { ...payload, _display_name: partMap[payload.participant_id] || 'Unknown' };
|
||||||
setMessages(prev => [...prev, msg]);
|
setMessages(prev => [...prev, msg]);
|
||||||
setTimeout(() => scrollToBottom(), 50);
|
setTimeout(() => scrollToBottom(), 50);
|
||||||
// Auto mark read if from someone else
|
// Auto mark read if from someone else
|
||||||
@@ -386,7 +398,8 @@
|
|||||||
unsubs.push(sw.realtime.subscribe(channel, 'typing', (payload) => {
|
unsubs.push(sw.realtime.subscribe(channel, 'typing', (payload) => {
|
||||||
var pid = payload.participant_id;
|
var pid = payload.participant_id;
|
||||||
if (pid === userId) return;
|
if (pid === userId) return;
|
||||||
setTypingUsers(prev => ({ ...prev, [pid]: payload.display_name || pid }));
|
var typingName = resolvedNames[pid] || payload.display_name || 'Someone';
|
||||||
|
setTypingUsers(prev => ({ ...prev, [pid]: typingName }));
|
||||||
clearTimeout(typingTimers[pid]);
|
clearTimeout(typingTimers[pid]);
|
||||||
typingTimers[pid] = setTimeout(() => {
|
typingTimers[pid] = setTimeout(() => {
|
||||||
setTypingUsers(prev => {
|
setTypingUsers(prev => {
|
||||||
@@ -442,18 +455,18 @@
|
|||||||
|
|
||||||
if (!conversationId) {
|
if (!conversationId) {
|
||||||
return html`
|
return html`
|
||||||
<div class="chat-thread chat-thread--empty">
|
<div class="ext-chat-thread chat-thread--empty">
|
||||||
<p>Select a conversation or start a new one</p>
|
<p>Select a conversation or start a new one</p>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="chat-thread">
|
<div class="ext-chat-thread">
|
||||||
<div class="chat-thread__messages" ref=${listRef}>
|
<div class="ext-chat-thread__messages" ref=${listRef}>
|
||||||
${loading && messages.length === 0 && html`<div class="chat-thread__loading"><${Spinner} /></div>`}
|
${loading && messages.length === 0 && html`<div class="ext-chat-thread__loading"><${Spinner} /></div>`}
|
||||||
${loading && messages.length > 0 && html`<div class="chat-thread__loading-more"><${Spinner} size="sm" /></div>`}
|
${loading && messages.length > 0 && html`<div class="ext-chat-thread__loading-more"><${Spinner} size="sm" /></div>`}
|
||||||
${hasMore && !loading && html`
|
${hasMore && !loading && html`
|
||||||
<button class="chat-thread__load-more" onClick=${loadMore}>
|
<button class="ext-chat-thread__load-more" onClick=${loadMore}>
|
||||||
Load older messages
|
Load older messages
|
||||||
</button>`}
|
</button>`}
|
||||||
${messages.map(m => html`
|
${messages.map(m => html`
|
||||||
@@ -466,7 +479,7 @@
|
|||||||
/>`)}
|
/>`)}
|
||||||
<div ref=${bottomRef} />
|
<div ref=${bottomRef} />
|
||||||
</div>
|
</div>
|
||||||
${typingText && html`<div class="chat-thread__typing">${typingText}</div>`}
|
${typingText && html`<div class="ext-chat-thread__typing">${typingText}</div>`}
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -533,8 +546,8 @@
|
|||||||
if (!conversationId) return null;
|
if (!conversationId) return null;
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="chat-compose">
|
<div class="ext-chat-compose">
|
||||||
<textarea class="chat-compose__input"
|
<textarea class="ext-chat-compose__input"
|
||||||
ref=${textareaRef}
|
ref=${textareaRef}
|
||||||
value=${text}
|
value=${text}
|
||||||
placeholder="Type a message\u2026"
|
placeholder="Type a message\u2026"
|
||||||
@@ -553,6 +566,18 @@
|
|||||||
function ParticipantSidebar({ conversationId, participants, onRefresh, isAdmin }) {
|
function ParticipantSidebar({ conversationId, participants, onRefresh, isAdmin }) {
|
||||||
var [addOpen, setAddOpen] = useState(false);
|
var [addOpen, setAddOpen] = useState(false);
|
||||||
var [presence, setPresence] = useState({});
|
var [presence, setPresence] = useState({});
|
||||||
|
var [resolvedNames, setResolvedNames] = useState({});
|
||||||
|
|
||||||
|
// Resolve display names from users table
|
||||||
|
useEffect(() => {
|
||||||
|
var ids = (participants || []).map(p => p.participant_id).filter(Boolean);
|
||||||
|
if (ids.length === 0) return;
|
||||||
|
sw.users.resolveMany(ids).then(map => {
|
||||||
|
var names = {};
|
||||||
|
map.forEach((user, id) => { names[id] = sw.users.displayName(user); });
|
||||||
|
setResolvedNames(names);
|
||||||
|
});
|
||||||
|
}, [participants]);
|
||||||
|
|
||||||
// Query presence
|
// Query presence
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -580,22 +605,22 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="chat-participants">
|
<div class="ext-chat-participants">
|
||||||
<div class="chat-participants__header">
|
<div class="ext-chat-participants__header">
|
||||||
<span>Participants (${(participants || []).length})</span>
|
<span>Participants (${(participants || []).length})</span>
|
||||||
${isAdmin && html`<${Button} size="sm" onClick=${() => setAddOpen(true)}>Add<//>` }
|
${isAdmin && html`<${Button} size="sm" onClick=${() => setAddOpen(true)}>Add<//>` }
|
||||||
</div>
|
</div>
|
||||||
<div class="chat-participants__list">
|
<div class="ext-chat-participants__list">
|
||||||
${(participants || []).map(p => html`
|
${(participants || []).map(p => html`
|
||||||
<div key=${p.participant_id} class="chat-participants__item">
|
<div key=${p.participant_id} class="ext-chat-participants__item">
|
||||||
<${Avatar} name=${p.display_name || p.participant_id} size="sm" />
|
<${Avatar} name=${resolvedNames[p.participant_id] || p.display_name || 'Unknown'} size="sm" />
|
||||||
<span class="chat-participants__name">
|
<span class="ext-chat-participants__name">
|
||||||
${p.display_name || p.participant_id}
|
${resolvedNames[p.participant_id] || p.display_name || 'Unknown'}
|
||||||
${p.role === 'admin' && html`<span class="chat-participants__badge">admin</span>`}
|
${p.role === 'admin' && html`<span class="ext-chat-participants__badge">admin</span>`}
|
||||||
</span>
|
</span>
|
||||||
<span class=${'chat-participants__status' + (presence[p.participant_id] ? ' chat-participants__status--online' : '')} />
|
<span class=${'chat-participants__status' + (presence[p.participant_id] ? ' ext-chat-participants__status--online' : '')} />
|
||||||
${isAdmin && p.participant_id !== currentUserId() && html`
|
${isAdmin && p.participant_id !== currentUserId() && html`
|
||||||
<button class="chat-participants__remove" onClick=${() => removeUser(p.participant_id)} title="Remove">\u00d7</button>`}
|
<button class="ext-chat-participants__remove" onClick=${() => removeUser(p.participant_id)} title="Remove">\u00d7</button>`}
|
||||||
</div>`)}
|
</div>`)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -671,8 +696,8 @@
|
|||||||
|
|
||||||
return html`
|
return html`
|
||||||
<${Dialog} open=${open} title="New Conversation" onClose=${() => { reset(); onClose(); }} actions=${actions}>
|
<${Dialog} open=${open} title="New Conversation" onClose=${() => { reset(); onClose(); }} actions=${actions}>
|
||||||
<div class="chat-new">
|
<div class="ext-chat-new">
|
||||||
<div class="chat-new__type">
|
<div class="ext-chat-new__type">
|
||||||
<label>
|
<label>
|
||||||
<input type="radio" name="convType" value="group"
|
<input type="radio" name="convType" value="group"
|
||||||
checked=${type === 'group'} onChange=${() => { setType('group'); setSelected([]); }} />
|
checked=${type === 'group'} onChange=${() => { setType('group'); setSelected([]); }} />
|
||||||
@@ -685,14 +710,14 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
${type === 'group' && html`
|
${type === 'group' && html`
|
||||||
<input class="chat-new__title" type="text" value=${title}
|
<input class="ext-chat-new__title" type="text" value=${title}
|
||||||
placeholder="Conversation title (optional)"
|
placeholder="Conversation title (optional)"
|
||||||
onInput=${e => setTitle(e.target.value)} />`}
|
onInput=${e => setTitle(e.target.value)} />`}
|
||||||
<${UserPicker} onSelect=${addUser} placeholder=${type === 'direct' ? 'Search for a user\u2026' : 'Add participants\u2026'} />
|
<${UserPicker} onSelect=${addUser} placeholder=${type === 'direct' ? 'Search for a user\u2026' : 'Add participants\u2026'} />
|
||||||
${selected.length > 0 && html`
|
${selected.length > 0 && html`
|
||||||
<div class="chat-new__selected">
|
<div class="ext-chat-new__selected">
|
||||||
${selected.map(u => html`
|
${selected.map(u => html`
|
||||||
<span key=${u.id} class="chat-new__chip">
|
<span key=${u.id} class="ext-chat-new__chip">
|
||||||
${u.display_name || u.username}
|
${u.display_name || u.username}
|
||||||
<button onClick=${() => removeSelected(u.id)}>\u00d7</button>
|
<button onClick=${() => removeSelected(u.id)}>\u00d7</button>
|
||||||
</span>`)}
|
</span>`)}
|
||||||
@@ -807,27 +832,27 @@
|
|||||||
var threadTitle = selectedConv ? (selectedConv.title || 'Direct Message') : '';
|
var threadTitle = selectedConv ? (selectedConv.title || 'Direct Message') : '';
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return html`<div class="chat-loading"><${Spinner} /></div>`;
|
return html`<div class="ext-chat-loading"><${Spinner} /></div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="chat-app">
|
<div class="ext-chat-app">
|
||||||
<${Topbar} title="Chat">
|
<${Topbar} title="Chat">
|
||||||
${selectedId && html`
|
${selectedId && html`
|
||||||
<span class="chat-topbar__thread-title">${threadTitle}</span>
|
<span class="ext-chat-topbar__thread-title">${threadTitle}</span>
|
||||||
<${Button} size="sm" variant="secondary"
|
<${Button} size="sm" variant="secondary"
|
||||||
onClick=${() => setShowParticipants(!showParticipants)}>
|
onClick=${() => setShowParticipants(!showParticipants)}>
|
||||||
${showParticipants ? 'Hide' : 'People'}
|
${showParticipants ? 'Hide' : 'People'}
|
||||||
<//>`}
|
<//>`}
|
||||||
<//>
|
<//>
|
||||||
<div class="chat-body">
|
<div class="ext-chat-body">
|
||||||
<${ConversationList}
|
<${ConversationList}
|
||||||
selected=${selectedId}
|
selected=${selectedId}
|
||||||
onSelect=${selectConversation}
|
onSelect=${selectConversation}
|
||||||
onNew=${() => setShowNew(true)}
|
onNew=${() => setShowNew(true)}
|
||||||
conversations=${conversations}
|
conversations=${conversations}
|
||||||
unread=${unread} />
|
unread=${unread} />
|
||||||
<div class="chat-main">
|
<div class="ext-chat-main">
|
||||||
<${MessageThread}
|
<${MessageThread}
|
||||||
conversationId=${selectedId}
|
conversationId=${selectedId}
|
||||||
participants=${participants} />
|
participants=${participants} />
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"icon": "\ud83d\udcac",
|
"icon": "\ud83d\udcac",
|
||||||
"description": "Chat surface — conversations, messaging, typing indicators, read receipts.",
|
"description": "Chat surface — conversations, messaging, typing indicators, read receipts.",
|
||||||
"author": "switchboard",
|
"author": "armature",
|
||||||
|
|
||||||
"permissions": ["db.write", "realtime.publish"],
|
"permissions": ["db.write", "realtime.publish"],
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"tier": "browser",
|
"tier": "browser",
|
||||||
"icon": "📝",
|
"icon": "📝",
|
||||||
"author": "Switchboard Core",
|
"author": "Armature",
|
||||||
"description": "Multi-party content review with quorum signoff and revision cycle.",
|
"description": "Multi-party content review with quorum signoff and revision cycle.",
|
||||||
"permissions": [],
|
"permissions": [],
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"type": "extension",
|
"type": "extension",
|
||||||
"tier": "browser",
|
"tier": "browser",
|
||||||
"author": "switchboard",
|
"author": "armature",
|
||||||
"description": "Renders ```csv code blocks as sortable, interactive HTML tables",
|
"description": "Renders ```csv code blocks as sortable, interactive HTML tables",
|
||||||
"requires": [],
|
"requires": [],
|
||||||
"permissions": [],
|
"permissions": [],
|
||||||
|
|||||||
@@ -6,63 +6,63 @@
|
|||||||
All SDK components style themselves.
|
All SDK components style themselves.
|
||||||
========================================== */
|
========================================== */
|
||||||
|
|
||||||
.surface-dashboard {
|
.ext-dashboard {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100vh;
|
height: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Topbar ──────────────────────────────── */
|
/* ── Topbar ──────────────────────────────── */
|
||||||
|
|
||||||
.dashboard-topbar {
|
.ext-dashboard-topbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: var(--sp-3);
|
||||||
padding: 0 16px;
|
padding: 0 var(--sp-4);
|
||||||
height: 44px;
|
height: 44px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-topbar-back {
|
.ext-dashboard-topbar-back {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: var(--sp-1);
|
||||||
color: var(--text-2);
|
color: var(--text-2);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
padding: 4px 8px;
|
padding: var(--sp-1) var(--sp-2);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
transition: color 0.15s, background 0.15s;
|
transition: color 0.15s, background 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-topbar-back:hover {
|
.ext-dashboard-topbar-back:hover {
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
background: var(--bg-hover);
|
background: var(--bg-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-topbar-title {
|
.ext-dashboard-topbar-title {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-topbar-sep {
|
.ext-dashboard-topbar-sep {
|
||||||
width: 1px;
|
width: 1px;
|
||||||
height: 18px;
|
height: 18px;
|
||||||
background: var(--border);
|
background: var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-topbar-spacer {
|
.ext-dashboard-topbar-spacer {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Body ────────────────────────────────── */
|
/* ── Body ────────────────────────────────── */
|
||||||
|
|
||||||
.dashboard-body {
|
.ext-dashboard-body {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
@@ -70,7 +70,7 @@
|
|||||||
|
|
||||||
/* ── Sidebar ─────────────────────────────── */
|
/* ── Sidebar ─────────────────────────────── */
|
||||||
|
|
||||||
.dashboard-sidebar {
|
.ext-dashboard-sidebar {
|
||||||
width: 300px;
|
width: 300px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -81,34 +81,34 @@
|
|||||||
|
|
||||||
/* ── Main Content ────────────────────────── */
|
/* ── Main Content ────────────────────────── */
|
||||||
|
|
||||||
.dashboard-main {
|
.ext-dashboard-main {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 20px;
|
padding: var(--sp-5);
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-greeting {
|
.ext-dashboard-greeting {
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
margin-bottom: 4px;
|
margin-bottom: var(--sp-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-subtitle {
|
.ext-dashboard-subtitle {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
margin-bottom: 20px;
|
margin-bottom: var(--sp-5);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-filter-bar {
|
.ext-dashboard-filter-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: var(--sp-3);
|
||||||
margin-bottom: 16px;
|
margin-bottom: var(--sp-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-filter-label {
|
.ext-dashboard-filter-label {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-2);
|
color: var(--text-2);
|
||||||
@@ -118,34 +118,34 @@
|
|||||||
|
|
||||||
/* ── Cards Grid ──────────────────────────── */
|
/* ── Cards Grid ──────────────────────────── */
|
||||||
|
|
||||||
.dashboard-cards {
|
.ext-dashboard-cards {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||||
gap: 12px;
|
gap: var(--sp-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-card {
|
.ext-dashboard-card {
|
||||||
background: var(--bg-raised);
|
background: var(--bg-raised);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
padding: 16px;
|
padding: var(--sp-4);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: var(--sp-2);
|
||||||
transition: border-color 0.15s;
|
transition: border-color 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-card:hover {
|
.ext-dashboard-card:hover {
|
||||||
border-color: var(--border-elevated);
|
border-color: var(--border-elevated);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-card-header {
|
.ext-dashboard-card-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: var(--sp-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-card-title {
|
.ext-dashboard-card-title {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
@@ -155,43 +155,43 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-card-meta {
|
.ext-dashboard-card-meta {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-card-desc {
|
.ext-dashboard-card-desc {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-2);
|
color: var(--text-2);
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-card-actions {
|
.ext-dashboard-card-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
margin-top: 4px;
|
margin-top: var(--sp-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-empty {
|
.ext-dashboard-empty {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
padding: 40px 20px;
|
padding: var(--sp-10) var(--sp-5);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Admin Section ───────────────────────── */
|
/* ── Admin Section ───────────────────────── */
|
||||||
|
|
||||||
.dashboard-admin-section {
|
.ext-dashboard-admin-section {
|
||||||
margin-top: 24px;
|
margin-top: var(--sp-6);
|
||||||
padding-top: 16px;
|
padding-top: var(--sp-4);
|
||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-section-title {
|
.ext-dashboard-section-title {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-2);
|
color: var(--text-2);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
margin-bottom: 12px;
|
margin-bottom: var(--sp-3);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// ==========================================
|
// ==========================================
|
||||||
// Switchboard Core — Dashboard Package (v0.31.1)
|
// Armature — Dashboard Package (v0.31.1)
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// SDK Exercise Surface. Exercises every sw.* primitive with ZERO
|
// SDK Exercise Surface. Exercises every sw.* primitive with ZERO
|
||||||
// component CSS overrides. All styling comes from the SDK itself.
|
// component CSS overrides. All styling comes from the SDK itself.
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
if (!mount) return;
|
if (!mount) return;
|
||||||
|
|
||||||
const surface = document.createElement('div');
|
const surface = document.createElement('div');
|
||||||
surface.className = 'surface-dashboard';
|
surface.className = 'ext-dashboard';
|
||||||
surface.id = 'dashboardSurface';
|
surface.id = 'dashboardSurface';
|
||||||
mount.appendChild(surface);
|
mount.appendChild(surface);
|
||||||
|
|
||||||
@@ -41,12 +41,12 @@
|
|||||||
|
|
||||||
// ── Body ──
|
// ── Body ──
|
||||||
const body = document.createElement('div');
|
const body = document.createElement('div');
|
||||||
body.className = 'dashboard-body';
|
body.className = 'ext-dashboard-body';
|
||||||
surface.appendChild(body);
|
surface.appendChild(body);
|
||||||
|
|
||||||
// ── Sidebar (tabs: activity + notes) ──
|
// ── Sidebar (tabs: activity + notes) ──
|
||||||
const sidebar = document.createElement('div');
|
const sidebar = document.createElement('div');
|
||||||
sidebar.className = 'dashboard-sidebar';
|
sidebar.className = 'ext-dashboard-sidebar';
|
||||||
body.appendChild(sidebar);
|
body.appendChild(sidebar);
|
||||||
|
|
||||||
// sw.tabs — two tabs
|
// sw.tabs — two tabs
|
||||||
@@ -83,14 +83,14 @@
|
|||||||
|
|
||||||
// ── Main content area ──
|
// ── Main content area ──
|
||||||
const main = document.createElement('div');
|
const main = document.createElement('div');
|
||||||
main.className = 'dashboard-main';
|
main.className = 'ext-dashboard-main';
|
||||||
body.appendChild(main);
|
body.appendChild(main);
|
||||||
|
|
||||||
_buildMainContent(main);
|
_buildMainContent(main);
|
||||||
|
|
||||||
// ── Theme reactivity ──
|
// ── Theme reactivity ──
|
||||||
sw.theme.on('change', function (resolved) {
|
sw.theme.on('change', function (resolved) {
|
||||||
const cards = main.querySelectorAll('.dashboard-card');
|
const cards = main.querySelectorAll('.ext-dashboard-card');
|
||||||
cards.forEach(function (c) {
|
cards.forEach(function (c) {
|
||||||
c.style.borderColor = ''; // reset to CSS default for new theme
|
c.style.borderColor = ''; // reset to CSS default for new theme
|
||||||
});
|
});
|
||||||
@@ -98,7 +98,7 @@
|
|||||||
|
|
||||||
// ── Cross-component events ──
|
// ── Cross-component events ──
|
||||||
sw.on('dashboard.filter.changed', function (payload) {
|
sw.on('dashboard.filter.changed', function (payload) {
|
||||||
_loadChannels(main.querySelector('.dashboard-cards'), payload.value);
|
_loadChannels(main.querySelector('.ext-dashboard-cards'), payload.value);
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('[DashboardPkg] Mounted');
|
console.log('[DashboardPkg] Mounted');
|
||||||
@@ -108,15 +108,15 @@
|
|||||||
|
|
||||||
function _buildTopbar() {
|
function _buildTopbar() {
|
||||||
const el = document.createElement('div');
|
const el = document.createElement('div');
|
||||||
el.className = 'dashboard-topbar';
|
el.className = 'ext-dashboard-topbar';
|
||||||
el.innerHTML =
|
el.innerHTML =
|
||||||
'<a href="' + esc(base) + '/" class="dashboard-topbar-back" title="Back to chat">' +
|
'<a href="' + esc(base) + '/" class="ext-dashboard-topbar-back" title="Back to chat">' +
|
||||||
'<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>' +
|
'<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>' +
|
||||||
'Back' +
|
'Back' +
|
||||||
'</a>' +
|
'</a>' +
|
||||||
'<div class="dashboard-topbar-sep"></div>' +
|
'<div class="ext-dashboard-topbar-sep"></div>' +
|
||||||
'<span class="dashboard-topbar-title">Dashboard</span>' +
|
'<span class="ext-dashboard-topbar-title">Dashboard</span>' +
|
||||||
'<div class="dashboard-topbar-spacer"></div>';
|
'<div class="ext-dashboard-topbar-spacer"></div>';
|
||||||
|
|
||||||
// sw.toolbar — action buttons
|
// sw.toolbar — action buttons
|
||||||
const toolbarItems = [
|
const toolbarItems = [
|
||||||
@@ -125,7 +125,7 @@
|
|||||||
icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>',
|
icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>',
|
||||||
title: 'Refresh',
|
title: 'Refresh',
|
||||||
onClick: function () {
|
onClick: function () {
|
||||||
const cards = document.querySelector('.dashboard-cards');
|
const cards = document.querySelector('.ext-dashboard-cards');
|
||||||
if (cards) _loadChannels(cards, _currentFilter);
|
if (cards) _loadChannels(cards, _currentFilter);
|
||||||
sw.toast('Refreshed', 'success');
|
sw.toast('Refreshed', 'success');
|
||||||
},
|
},
|
||||||
@@ -155,22 +155,22 @@
|
|||||||
// sw.user — greeting
|
// sw.user — greeting
|
||||||
const user = sw.user;
|
const user = sw.user;
|
||||||
const greeting = document.createElement('div');
|
const greeting = document.createElement('div');
|
||||||
greeting.className = 'dashboard-greeting';
|
greeting.className = 'ext-dashboard-greeting';
|
||||||
greeting.textContent = 'Welcome back' + (user ? ', ' + (user.display_name || user.username) : '');
|
greeting.textContent = 'Welcome back' + (user ? ', ' + (user.display_name || user.username || 'Unknown') : '');
|
||||||
main.appendChild(greeting);
|
main.appendChild(greeting);
|
||||||
|
|
||||||
const subtitle = document.createElement('div');
|
const subtitle = document.createElement('div');
|
||||||
subtitle.className = 'dashboard-subtitle';
|
subtitle.className = 'ext-dashboard-subtitle';
|
||||||
subtitle.textContent = 'Your recent conversations' + (sw.isAdmin ? ' \u00b7 Admin' : '');
|
subtitle.textContent = 'Your recent conversations' + (sw.isAdmin ? ' \u00b7 Admin' : '');
|
||||||
main.appendChild(subtitle);
|
main.appendChild(subtitle);
|
||||||
|
|
||||||
// ── Filter bar ──
|
// ── Filter bar ──
|
||||||
const filterBar = document.createElement('div');
|
const filterBar = document.createElement('div');
|
||||||
filterBar.className = 'dashboard-filter-bar';
|
filterBar.className = 'ext-dashboard-filter-bar';
|
||||||
main.appendChild(filterBar);
|
main.appendChild(filterBar);
|
||||||
|
|
||||||
const filterLabel = document.createElement('span');
|
const filterLabel = document.createElement('span');
|
||||||
filterLabel.className = 'dashboard-filter-label';
|
filterLabel.className = 'ext-dashboard-filter-label';
|
||||||
filterLabel.textContent = 'Filter';
|
filterLabel.textContent = 'Filter';
|
||||||
filterBar.appendChild(filterLabel);
|
filterBar.appendChild(filterLabel);
|
||||||
|
|
||||||
@@ -192,7 +192,7 @@
|
|||||||
|
|
||||||
// ── Cards ──
|
// ── Cards ──
|
||||||
const cards = document.createElement('div');
|
const cards = document.createElement('div');
|
||||||
cards.className = 'dashboard-cards';
|
cards.className = 'ext-dashboard-cards';
|
||||||
main.appendChild(cards);
|
main.appendChild(cards);
|
||||||
|
|
||||||
_loadChannels(cards, '');
|
_loadChannels(cards, '');
|
||||||
@@ -200,16 +200,16 @@
|
|||||||
// ── Admin section (sw.isAdmin) ──
|
// ── Admin section (sw.isAdmin) ──
|
||||||
if (sw.isAdmin) {
|
if (sw.isAdmin) {
|
||||||
const adminSection = document.createElement('div');
|
const adminSection = document.createElement('div');
|
||||||
adminSection.className = 'dashboard-admin-section';
|
adminSection.className = 'ext-dashboard-admin-section';
|
||||||
main.appendChild(adminSection);
|
main.appendChild(adminSection);
|
||||||
|
|
||||||
const sectionTitle = document.createElement('div');
|
const sectionTitle = document.createElement('div');
|
||||||
sectionTitle.className = 'dashboard-section-title';
|
sectionTitle.className = 'ext-dashboard-section-title';
|
||||||
sectionTitle.textContent = 'Administration';
|
sectionTitle.textContent = 'Administration';
|
||||||
adminSection.appendChild(sectionTitle);
|
adminSection.appendChild(sectionTitle);
|
||||||
|
|
||||||
const adminCards = document.createElement('div');
|
const adminCards = document.createElement('div');
|
||||||
adminCards.className = 'dashboard-cards';
|
adminCards.className = 'ext-dashboard-cards';
|
||||||
adminSection.appendChild(adminCards);
|
adminSection.appendChild(adminCards);
|
||||||
|
|
||||||
_loadAdminCards(adminCards);
|
_loadAdminCards(adminCards);
|
||||||
@@ -220,7 +220,7 @@
|
|||||||
|
|
||||||
async function _loadChannels(container, typeFilter) {
|
async function _loadChannels(container, typeFilter) {
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
container.innerHTML = '<div class="dashboard-empty">Loading\u2026</div>';
|
container.innerHTML = '<div class="ext-dashboard-empty">Loading\u2026</div>';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// sw.api — real API call
|
// sw.api — real API call
|
||||||
@@ -231,7 +231,7 @@
|
|||||||
|
|
||||||
container.innerHTML = '';
|
container.innerHTML = '';
|
||||||
if (!channels.length) {
|
if (!channels.length) {
|
||||||
container.innerHTML = '<div class="dashboard-empty">No channels found</div>';
|
container.innerHTML = '<div class="ext-dashboard-empty">No channels found</div>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,31 +239,31 @@
|
|||||||
container.appendChild(_buildChannelCard(ch));
|
container.appendChild(_buildChannelCard(ch));
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
container.innerHTML = '<div class="dashboard-empty">Failed to load: ' + (typeof esc === 'function' ? esc(e.message) : e.message) + '</div>';
|
container.innerHTML = '<div class="ext-dashboard-empty">Failed to load: ' + (typeof esc === 'function' ? esc(e.message) : e.message) + '</div>';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function _buildChannelCard(ch) {
|
function _buildChannelCard(ch) {
|
||||||
const card = document.createElement('div');
|
const card = document.createElement('div');
|
||||||
card.className = 'dashboard-card';
|
card.className = 'ext-dashboard-card';
|
||||||
|
|
||||||
const header = document.createElement('div');
|
const header = document.createElement('div');
|
||||||
header.className = 'dashboard-card-header';
|
header.className = 'ext-dashboard-card-header';
|
||||||
card.appendChild(header);
|
card.appendChild(header);
|
||||||
|
|
||||||
const title = document.createElement('div');
|
const title = document.createElement('div');
|
||||||
title.className = 'dashboard-card-title';
|
title.className = 'ext-dashboard-card-title';
|
||||||
title.textContent = ch.title || ch.name || 'Untitled';
|
title.textContent = ch.title || ch.name || 'Untitled';
|
||||||
header.appendChild(title);
|
header.appendChild(title);
|
||||||
|
|
||||||
const meta = document.createElement('div');
|
const meta = document.createElement('div');
|
||||||
meta.className = 'dashboard-card-meta';
|
meta.className = 'ext-dashboard-card-meta';
|
||||||
meta.textContent = ch.type || '';
|
meta.textContent = ch.type || '';
|
||||||
card.appendChild(meta);
|
card.appendChild(meta);
|
||||||
|
|
||||||
if (ch.description) {
|
if (ch.description) {
|
||||||
const desc = document.createElement('div');
|
const desc = document.createElement('div');
|
||||||
desc.className = 'dashboard-card-desc';
|
desc.className = 'ext-dashboard-card-desc';
|
||||||
desc.textContent = ch.description.slice(0, 120);
|
desc.textContent = ch.description.slice(0, 120);
|
||||||
card.appendChild(desc);
|
card.appendChild(desc);
|
||||||
}
|
}
|
||||||
@@ -271,14 +271,14 @@
|
|||||||
const updated = ch.updated_at || ch.created_at;
|
const updated = ch.updated_at || ch.created_at;
|
||||||
if (updated) {
|
if (updated) {
|
||||||
const date = document.createElement('div');
|
const date = document.createElement('div');
|
||||||
date.className = 'dashboard-card-meta';
|
date.className = 'ext-dashboard-card-meta';
|
||||||
date.textContent = new Date(updated).toLocaleDateString();
|
date.textContent = new Date(updated).toLocaleDateString();
|
||||||
card.appendChild(date);
|
card.appendChild(date);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Card action menu ──
|
// ── Card action menu ──
|
||||||
const actions = document.createElement('div');
|
const actions = document.createElement('div');
|
||||||
actions.className = 'dashboard-card-actions';
|
actions.className = 'ext-dashboard-card-actions';
|
||||||
card.appendChild(actions);
|
card.appendChild(actions);
|
||||||
|
|
||||||
const menuBtn = document.createElement('button');
|
const menuBtn = document.createElement('button');
|
||||||
@@ -343,8 +343,8 @@
|
|||||||
(ch.description ? '<div style="font-size:12px;color:var(--text-2);margin-bottom:8px;"><strong>Description:</strong> ' + (typeof esc === 'function' ? esc(ch.description) : ch.description) + '</div>' : '') +
|
(ch.description ? '<div style="font-size:12px;color:var(--text-2);margin-bottom:8px;"><strong>Description:</strong> ' + (typeof esc === 'function' ? esc(ch.description) : ch.description) + '</div>' : '') +
|
||||||
(ch.created_at ? '<div style="font-size:12px;color:var(--text-3);margin-bottom:16px;">Created: ' + new Date(ch.created_at).toLocaleString() + '</div>' : '') +
|
(ch.created_at ? '<div style="font-size:12px;color:var(--text-3);margin-bottom:16px;">Created: ' + new Date(ch.created_at).toLocaleString() + '</div>' : '') +
|
||||||
'<div style="display:flex;justify-content:flex-end;gap:8px;">' +
|
'<div style="display:flex;justify-content:flex-end;gap:8px;">' +
|
||||||
'<button class="btn-small" id="dashDetailClose">Close</button>' +
|
'<button class="sw-btn sw-btn--secondary sw-btn--sm" id="dashDetailClose">Close</button>' +
|
||||||
'<button class="btn-small btn-primary" id="dashDetailOpen">Open Chat</button>' +
|
'<button class="sw-btn sw-btn--primary sw-btn--sm" id="dashDetailOpen">Open Chat</button>' +
|
||||||
'</div>';
|
'</div>';
|
||||||
|
|
||||||
modal.appendChild(box);
|
modal.appendChild(box);
|
||||||
@@ -372,7 +372,7 @@
|
|||||||
// sw.toast — success feedback
|
// sw.toast — success feedback
|
||||||
sw.toast('Channel deleted', 'success');
|
sw.toast('Channel deleted', 'success');
|
||||||
// Refresh cards
|
// Refresh cards
|
||||||
const cards = document.querySelector('.dashboard-cards');
|
const cards = document.querySelector('.ext-dashboard-cards');
|
||||||
if (cards) _loadChannels(cards, _currentFilter);
|
if (cards) _loadChannels(cards, _currentFilter);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// sw.toast — error feedback
|
// sw.toast — error feedback
|
||||||
@@ -390,21 +390,21 @@
|
|||||||
container.innerHTML = '';
|
container.innerHTML = '';
|
||||||
packages.forEach(function (pkg) {
|
packages.forEach(function (pkg) {
|
||||||
const card = document.createElement('div');
|
const card = document.createElement('div');
|
||||||
card.className = 'dashboard-card';
|
card.className = 'ext-dashboard-card';
|
||||||
card.innerHTML =
|
card.innerHTML =
|
||||||
'<div class="dashboard-card-header">' +
|
'<div class="ext-dashboard-card-header">' +
|
||||||
'<div class="dashboard-card-title">' + (typeof esc === 'function' ? esc(pkg.title || pkg.id) : (pkg.title || pkg.id)) + '</div>' +
|
'<div class="ext-dashboard-card-title">' + (typeof esc === 'function' ? esc(pkg.title || pkg.id) : (pkg.title || pkg.id)) + '</div>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="dashboard-card-meta">' + (typeof esc === 'function' ? esc(pkg.type || '') : (pkg.type || '')) + ' \u00b7 ' + (typeof esc === 'function' ? esc(pkg.tier || '') : (pkg.tier || '')) + '</div>' +
|
'<div class="ext-dashboard-card-meta">' + (typeof esc === 'function' ? esc(pkg.type || '') : (pkg.type || '')) + ' \u00b7 ' + (typeof esc === 'function' ? esc(pkg.tier || '') : (pkg.tier || '')) + '</div>' +
|
||||||
'<div class="dashboard-card-desc">' + (typeof esc === 'function' ? esc(pkg.description || '') : (pkg.description || '')) + '</div>';
|
'<div class="ext-dashboard-card-desc">' + (typeof esc === 'function' ? esc(pkg.description || '') : (pkg.description || '')) + '</div>';
|
||||||
container.appendChild(card);
|
container.appendChild(card);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!packages.length) {
|
if (!packages.length) {
|
||||||
container.innerHTML = '<div class="dashboard-empty">No packages installed</div>';
|
container.innerHTML = '<div class="ext-dashboard-empty">No packages installed</div>';
|
||||||
}
|
}
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
container.innerHTML = '<div class="dashboard-empty">Failed to load packages</div>';
|
container.innerHTML = '<div class="ext-dashboard-empty">Failed to load packages</div>';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"type": "full",
|
"type": "full",
|
||||||
"version": "0.31.1",
|
"version": "0.31.1",
|
||||||
"tier": "browser",
|
"tier": "browser",
|
||||||
"author": "Switchboard Core",
|
"author": "Armature",
|
||||||
"icon": "📊",
|
"icon": "📊",
|
||||||
"description": "Project dashboard exercising all SDK primitives (requires legacy sw.* SDK — dormant until rewritten)",
|
"description": "Project dashboard exercising all SDK primitives (requires legacy sw.* SDK — dormant until rewritten)",
|
||||||
"requires": ["legacy-sdk"],
|
"requires": ["legacy-sdk"],
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"type": "extension",
|
"type": "extension",
|
||||||
"tier": "browser",
|
"tier": "browser",
|
||||||
"author": "switchboard",
|
"author": "armature",
|
||||||
"description": "Renders ```diff code blocks with syntax-highlighted additions, deletions, and context lines",
|
"description": "Renders ```diff code blocks with syntax-highlighted additions, deletions, and context lines",
|
||||||
"requires": [],
|
"requires": [],
|
||||||
"permissions": [],
|
"permissions": [],
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/* ==========================================
|
/* ==========================================
|
||||||
Switchboard Core — Editor Surface (v0.25.0)
|
Armature — Editor Surface (v0.25.0)
|
||||||
==========================================
|
==========================================
|
||||||
Replaces editor-mode.css for the pane-based editor.
|
Replaces editor-mode.css for the pane-based editor.
|
||||||
Covers: topbar, bootstrap, and component-specific
|
Covers: topbar, bootstrap, and component-specific
|
||||||
@@ -8,166 +8,166 @@
|
|||||||
|
|
||||||
/* ── Surface Shell ─────────────────────────── */
|
/* ── Surface Shell ─────────────────────────── */
|
||||||
|
|
||||||
.surface-editor {
|
.ext-editor {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: var(--bg, #0e0e10);
|
background: var(--bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Topbar ────────────────────────────────── */
|
/* ── Topbar ────────────────────────────────── */
|
||||||
|
|
||||||
.editor-topbar {
|
.ext-editor-topbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: var(--sp-2);
|
||||||
padding: 0 12px;
|
padding: 0 var(--sp-3);
|
||||||
height: 40px;
|
height: 40px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
background: var(--bg-secondary, #151517);
|
background: var(--bg-secondary);
|
||||||
border-bottom: 1px solid var(--border, #2a2a2e);
|
border-bottom: 1px solid var(--border);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 20;
|
z-index: 20;
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-topbar-back {
|
.ext-editor-topbar-back {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: var(--sp-1);
|
||||||
color: var(--text-3, #777);
|
color: var(--text-3);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
padding: 4px 8px;
|
padding: var(--sp-1) var(--sp-2);
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-sm);
|
||||||
transition: color 0.15s, background 0.15s;
|
transition: color 0.15s, background 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-topbar-back:hover {
|
.ext-editor-topbar-back:hover {
|
||||||
color: var(--text, #eee);
|
color: var(--text);
|
||||||
background: var(--bg-hover);
|
background: var(--bg-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-topbar-sep {
|
.ext-editor-topbar-sep {
|
||||||
width: 1px;
|
width: 1px;
|
||||||
height: 18px;
|
height: 18px;
|
||||||
background: var(--border, #2a2a2e);
|
background: var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-topbar-name {
|
.ext-editor-topbar-name {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text, #eee);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Workspace Selector ────────────────────── */
|
/* ── Workspace Selector ────────────────────── */
|
||||||
|
|
||||||
.editor-ws-selector {
|
.ext-editor-ws-selector {
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-ws-selector-btn {
|
.ext-editor-ws-selector-btn {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: var(--sp-2);
|
||||||
background: none;
|
background: none;
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
color: var(--text, #eee);
|
color: var(--text);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
padding: 4px 8px;
|
padding: var(--sp-1) var(--sp-2);
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-sm);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: border-color 0.15s, background 0.15s;
|
transition: border-color 0.15s, background 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-ws-selector-btn:hover {
|
.ext-editor-ws-selector-btn:hover {
|
||||||
border-color: var(--border, #2a2a2e);
|
border-color: var(--border);
|
||||||
background: var(--bg-hover);
|
background: var(--bg-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-ws-dropdown {
|
.ext-editor-ws-dropdown {
|
||||||
display: none;
|
display: none;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 100%;
|
top: 100%;
|
||||||
left: 0;
|
left: 0;
|
||||||
margin-top: 4px;
|
margin-top: var(--sp-1);
|
||||||
background: var(--bg-secondary, #1a1a1e);
|
background: var(--bg-secondary);
|
||||||
border: 1px solid var(--border, #2a2a2e);
|
border: 1px solid var(--border);
|
||||||
border-radius: 8px;
|
border-radius: var(--radius);
|
||||||
min-width: 220px;
|
min-width: 220px;
|
||||||
max-height: 320px;
|
max-height: 320px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
|
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
|
||||||
padding: 4px 0;
|
padding: var(--sp-1) 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-ws-dropdown.open { display: block; }
|
.ext-editor-ws-dropdown.open { display: block; }
|
||||||
|
|
||||||
.editor-ws-list {
|
.ext-editor-ws-list {
|
||||||
max-height: 240px;
|
max-height: 240px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-ws-dropdown-item {
|
.ext-editor-ws-dropdown-item {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
color: var(--text, #eee);
|
color: var(--text);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
padding: 8px 12px;
|
padding: var(--sp-2) var(--sp-3);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.1s;
|
transition: background 0.1s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-ws-dropdown-item:hover {
|
.ext-editor-ws-dropdown-item:hover {
|
||||||
background: var(--bg-hover);
|
background: var(--bg-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-ws-dropdown-item.active {
|
.ext-editor-ws-dropdown-item.active {
|
||||||
color: var(--accent, #b38a4e);
|
color: var(--accent);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-ws-dropdown-divider {
|
.ext-editor-ws-dropdown-divider {
|
||||||
height: 1px;
|
height: 1px;
|
||||||
background: var(--border, #2a2a2e);
|
background: var(--border);
|
||||||
margin: 4px 0;
|
margin: var(--sp-1) 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-ws-new {
|
.ext-editor-ws-new {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: var(--sp-2);
|
||||||
color: var(--accent, #b38a4e);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-topbar-branch {
|
.ext-editor-topbar-branch {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: var(--sp-1);
|
||||||
background: var(--purple-dim, rgba(160, 120, 255, 0.1));
|
background: var(--purple-dim);
|
||||||
padding: 2px 8px;
|
padding: 2px var(--sp-2);
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-topbar-branch-text {
|
.ext-editor-topbar-branch-text {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--purple, #a078ff);
|
color: var(--purple);
|
||||||
font-family: var(--mono, 'SF Mono', monospace);
|
font-family: var(--mono, 'SF Mono', monospace);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Body ──────────────────────────────────── */
|
/* ── Body ──────────────────────────────────── */
|
||||||
|
|
||||||
.editor-body {
|
.ext-editor-body {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -175,47 +175,47 @@
|
|||||||
|
|
||||||
/* ── Bootstrap (no workspace) ──────────────── */
|
/* ── Bootstrap (no workspace) ──────────────── */
|
||||||
|
|
||||||
.editor-bootstrap {
|
.ext-editor-bootstrap {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-bootstrap-card {
|
.ext-editor-bootstrap-card {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 40px;
|
padding: var(--sp-10);
|
||||||
background: var(--bg-secondary, #151517);
|
background: var(--bg-secondary);
|
||||||
border: 1px solid var(--border, #2a2a2e);
|
border: 1px solid var(--border);
|
||||||
border-radius: 12px;
|
border-radius: var(--radius-lg);
|
||||||
max-width: 360px;
|
max-width: 360px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-bootstrap-input {
|
.ext-editor-bootstrap-input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 8px 12px;
|
padding: var(--sp-2) var(--sp-3);
|
||||||
background: var(--bg, #0e0e10);
|
background: var(--bg);
|
||||||
border: 1px solid var(--border, #2a2a2e);
|
border: 1px solid var(--border);
|
||||||
border-radius: 6px;
|
border-radius: var(--radius);
|
||||||
color: var(--text, #eee);
|
color: var(--text);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
outline: none;
|
outline: none;
|
||||||
margin-bottom: 12px;
|
margin-bottom: var(--sp-3);
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-bootstrap-input:focus {
|
.ext-editor-bootstrap-input:focus {
|
||||||
border-color: var(--accent, #b38a4e);
|
border-color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-bootstrap-btn {
|
.ext-editor-bootstrap-btn {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 10px 16px;
|
padding: var(--sp-3) var(--sp-4);
|
||||||
background: var(--accent, #b38a4e);
|
background: var(--accent);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 6px;
|
border-radius: var(--radius);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
@@ -223,108 +223,108 @@
|
|||||||
transition: opacity 0.15s;
|
transition: opacity 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-bootstrap-btn:hover { opacity: 0.9; }
|
.ext-editor-bootstrap-btn:hover { opacity: 0.9; }
|
||||||
.editor-bootstrap-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
.ext-editor-bootstrap-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
|
||||||
/* Workspace list in bootstrap */
|
/* Workspace list in bootstrap */
|
||||||
.editor-bootstrap-ws-item {
|
.ext-editor-bootstrap-ws-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 10px 12px;
|
padding: var(--sp-3) var(--sp-3);
|
||||||
background: var(--bg, #0e0e10);
|
background: var(--bg);
|
||||||
border: 1px solid var(--border, #2a2a2e);
|
border: 1px solid var(--border);
|
||||||
border-radius: 6px;
|
border-radius: var(--radius);
|
||||||
color: var(--text, #eee);
|
color: var(--text);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: border-color 0.15s, background 0.15s;
|
transition: border-color 0.15s, background 0.15s;
|
||||||
margin-bottom: 6px;
|
margin-bottom: var(--sp-2);
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-bootstrap-ws-item:hover {
|
.ext-editor-bootstrap-ws-item:hover {
|
||||||
border-color: var(--accent, #b38a4e);
|
border-color: var(--accent);
|
||||||
background: var(--bg-hover);
|
background: var(--bg-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-bootstrap-ws-name {
|
.ext-editor-bootstrap-ws-name {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-bootstrap-ws-date {
|
.ext-editor-bootstrap-ws-date {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-3, #777);
|
color: var(--text-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── FileTree overrides (in editor context) ── */
|
/* ── FileTree overrides (in editor context) ── */
|
||||||
|
|
||||||
.surface-editor .file-tree {
|
.ext-editor .file-tree {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
border-right: 1px solid var(--border, #2a2a2e);
|
border-right: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.surface-editor .file-tree-header {
|
.ext-editor .file-tree-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: var(--sp-2);
|
||||||
padding: 8px 12px;
|
padding: var(--sp-2) var(--sp-3);
|
||||||
border-bottom: 1px solid var(--border, #2a2a2e);
|
border-bottom: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.surface-editor .file-tree-title {
|
.ext-editor .file-tree-title {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-2, #999);
|
color: var(--text-2);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.4px;
|
letter-spacing: 0.4px;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.surface-editor .file-tree-items {
|
.ext-editor .file-tree-items {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 4px 0;
|
padding: var(--sp-1) 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.surface-editor .file-tree-row {
|
.ext-editor .file-tree-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: var(--sp-1);
|
||||||
padding: 3px 8px;
|
padding: 3px var(--sp-2);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-2, #999);
|
color: var(--text-2);
|
||||||
transition: background 0.1s;
|
transition: background 0.1s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.surface-editor .file-tree-row:hover {
|
.ext-editor .file-tree-row:hover {
|
||||||
background: var(--bg-hover);
|
background: var(--bg-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.surface-editor .file-tree-row.active {
|
.ext-editor .file-tree-row.active {
|
||||||
background: var(--accent-dim, rgba(179, 138, 78, 0.15));
|
background: var(--accent-dim);
|
||||||
color: var(--text, #eee);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.surface-editor .file-tree-arrow {
|
.ext-editor .file-tree-arrow {
|
||||||
width: 12px;
|
width: 12px;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--text-3, #555);
|
color: var(--text-3);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.surface-editor .file-tree-icon {
|
.ext-editor .file-tree-icon {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
width: 18px;
|
width: 18px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.surface-editor .file-tree-name {
|
.ext-editor .file-tree-name {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
@@ -332,134 +332,134 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Git status indicators */
|
/* Git status indicators */
|
||||||
.surface-editor .file-tree-row.git-modified .file-tree-name { color: var(--warning, #e5a842); }
|
.ext-editor .file-tree-row.git-modified .file-tree-name { color: var(--warning); }
|
||||||
.surface-editor .file-tree-row.git-added .file-tree-name { color: var(--success, #4caf50); }
|
.ext-editor .file-tree-row.git-added .file-tree-name { color: var(--success); }
|
||||||
.surface-editor .file-tree-row.git-untracked .file-tree-name { color: var(--text-3, #555); font-style: italic; }
|
.ext-editor .file-tree-row.git-untracked .file-tree-name { color: var(--text-3); font-style: italic; }
|
||||||
.surface-editor .file-tree-row.git-deleted .file-tree-name { color: var(--danger, #f44336); text-decoration: line-through; }
|
.ext-editor .file-tree-row.git-deleted .file-tree-name { color: var(--danger); text-decoration: line-through; }
|
||||||
|
|
||||||
/* Context menu */
|
/* Context menu */
|
||||||
.file-tree-ctx-menu {
|
.ext-editor-file-tree-ctx-menu {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
background: var(--bg-secondary, #1a1a1e);
|
background: var(--bg-secondary);
|
||||||
border: 1px solid var(--border, #2a2a2e);
|
border: 1px solid var(--border);
|
||||||
border-radius: 6px;
|
border-radius: var(--radius);
|
||||||
padding: 4px 0;
|
padding: var(--sp-1) 0;
|
||||||
min-width: 120px;
|
min-width: 120px;
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
|
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-tree-ctx-item {
|
.ext-editor-file-tree-ctx-item {
|
||||||
padding: 6px 12px;
|
padding: var(--sp-2) var(--sp-3);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text, #eee);
|
color: var(--text);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-tree-ctx-item:hover {
|
.ext-editor-file-tree-ctx-item:hover {
|
||||||
background: var(--bg-hover);
|
background: var(--bg-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── CodeEditor overrides ──────────────────── */
|
/* ── CodeEditor overrides ──────────────────── */
|
||||||
|
|
||||||
.surface-editor .code-editor {
|
.ext-editor .code-editor {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.surface-editor .code-editor-tabs {
|
.ext-editor .code-editor-tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0;
|
gap: 0;
|
||||||
background: var(--bg-secondary, #151517);
|
background: var(--bg-secondary);
|
||||||
border-bottom: 1px solid var(--border, #2a2a2e);
|
border-bottom: 1px solid var(--border);
|
||||||
height: 32px;
|
height: 32px;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.surface-editor .code-editor-tabs::-webkit-scrollbar { height: 0; }
|
.ext-editor .code-editor-tabs::-webkit-scrollbar { height: 0; }
|
||||||
|
|
||||||
.surface-editor .code-editor-tab {
|
.ext-editor .code-editor-tab {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: var(--sp-1);
|
||||||
padding: 0 12px;
|
padding: 0 var(--sp-3);
|
||||||
height: 100%;
|
height: 100%;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-3, #777);
|
color: var(--text-3);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-right: 1px solid var(--border, #2a2a2e);
|
border-right: 1px solid var(--border);
|
||||||
transition: background 0.1s;
|
transition: background 0.1s;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.surface-editor .code-editor-tab:hover { background: var(--bg-hover); }
|
.ext-editor .code-editor-tab:hover { background: var(--bg-hover); }
|
||||||
.surface-editor .code-editor-tab.active { color: var(--text, #eee); background: var(--bg, #0e0e10); }
|
.ext-editor .code-editor-tab.active { color: var(--text); background: var(--bg); }
|
||||||
.surface-editor .code-editor-tab.modified .code-editor-tab-modified { color: var(--warning, #e5a842); }
|
.ext-editor .code-editor-tab.modified .code-editor-tab-modified { color: var(--warning); }
|
||||||
|
|
||||||
.surface-editor .code-editor-tab-icon { font-size: 12px; }
|
.ext-editor .code-editor-tab-icon { font-size: 12px; }
|
||||||
.surface-editor .code-editor-tab-modified { font-size: 10px; color: var(--text-3); }
|
.ext-editor .code-editor-tab-modified { font-size: 10px; color: var(--text-3); }
|
||||||
|
|
||||||
.surface-editor .code-editor-tab-close {
|
.ext-editor .code-editor-tab-close {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
color: var(--text-3, #555);
|
color: var(--text-3);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 0 2px;
|
padding: 0 2px;
|
||||||
margin-left: 4px;
|
margin-left: var(--sp-1);
|
||||||
border-radius: 2px;
|
border-radius: var(--radius-sm);
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.surface-editor .code-editor-tab-close:hover {
|
.ext-editor .code-editor-tab-close:hover {
|
||||||
background: var(--danger-dim, rgba(244, 67, 54, 0.15));
|
background: var(--danger-dim);
|
||||||
color: var(--danger, #f44336);
|
color: var(--danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
.surface-editor .code-editor-content {
|
.ext-editor .code-editor-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.surface-editor .code-editor-welcome {
|
.ext-editor .code-editor-welcome {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.surface-editor .code-editor-cm-wrap {
|
.ext-editor .code-editor-cm-wrap {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.surface-editor .code-editor-cm-wrap .cm-editor {
|
.ext-editor .code-editor-cm-wrap .cm-editor {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.surface-editor .code-editor-statusbar {
|
.ext-editor .code-editor-statusbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 16px;
|
gap: var(--sp-4);
|
||||||
padding: 0 12px;
|
padding: 0 var(--sp-3);
|
||||||
height: 24px;
|
height: 24px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
background: var(--bg-secondary, #151517);
|
background: var(--bg-secondary);
|
||||||
border-top: 1px solid var(--border, #2a2a2e);
|
border-top: 1px solid var(--border);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-3, #777);
|
color: var(--text-3);
|
||||||
font-family: var(--mono, 'SF Mono', monospace);
|
font-family: var(--mono, 'SF Mono', monospace);
|
||||||
}
|
}
|
||||||
|
|
||||||
.surface-editor .code-editor-textarea-fallback {
|
.ext-editor .code-editor-textarea-fallback {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: var(--bg, #0e0e10);
|
background: var(--bg);
|
||||||
color: var(--text, #eee);
|
color: var(--text);
|
||||||
border: none;
|
border: none;
|
||||||
padding: 12px;
|
padding: var(--sp-3);
|
||||||
font-family: var(--mono, 'SF Mono', monospace);
|
font-family: var(--mono, 'SF Mono', monospace);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
resize: none;
|
resize: none;
|
||||||
@@ -469,18 +469,18 @@
|
|||||||
|
|
||||||
/* ── Tabbed assist pane overrides ──────────── */
|
/* ── Tabbed assist pane overrides ──────────── */
|
||||||
|
|
||||||
.surface-editor .pane-tabbed {
|
.ext-editor .pane-tabbed {
|
||||||
border-left: 1px solid var(--border, #2a2a2e);
|
border-left: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ChatPane in editor tabbed pane */
|
/* ChatPane in editor tabbed pane */
|
||||||
.surface-editor .chat-pane {
|
.ext-editor .chat-pane {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Notes in editor pane */
|
/* Notes in editor pane */
|
||||||
.surface-editor .note-editor {
|
.ext-editor .ext-notes-editor {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -488,43 +488,43 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.surface-editor .note-editor-list-view {
|
.ext-editor .ext-notes-editor-list-view {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.surface-editor .notes-list {
|
.ext-editor .ext-notes-list {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Compact notes toolbar for narrow pane */
|
/* Compact notes toolbar for narrow pane */
|
||||||
.surface-editor .notes-toolbar {
|
.ext-editor .ext-notes-toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 4px;
|
gap: var(--sp-1);
|
||||||
padding: 6px 8px;
|
padding: var(--sp-2) var(--sp-2);
|
||||||
border-bottom: 1px solid var(--border, #2a2a2e);
|
border-bottom: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.surface-editor .notes-toolbar .btn-small {
|
.ext-editor .ext-notes-toolbar .sw-btn--sm {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
padding: 3px 6px;
|
padding: 3px var(--sp-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.surface-editor .notes-search-row {
|
.ext-editor .ext-notes-search-row {
|
||||||
padding: 4px 8px;
|
padding: var(--sp-1) var(--sp-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.surface-editor .notes-filter-row {
|
.ext-editor .ext-notes-filter-row {
|
||||||
padding: 2px 8px 4px;
|
padding: 2px var(--sp-2) var(--sp-1);
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 4px;
|
gap: var(--sp-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.surface-editor .notes-filter-select {
|
.ext-editor .ext-notes-filter-select {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// ==========================================
|
// ==========================================
|
||||||
// Switchboard Core — Editor Package (v0.31.0)
|
// Armature — Editor Package (v0.31.0)
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// Installable .pkg that provides the code editor surface.
|
// Installable .pkg that provides the code editor surface.
|
||||||
// Mounts into #extension-mount (surface-extension template).
|
// Mounts into #extension-mount (surface-extension template).
|
||||||
@@ -56,7 +56,7 @@
|
|||||||
|
|
||||||
// Wrap in surface container for CSS scoping
|
// Wrap in surface container for CSS scoping
|
||||||
const surface = document.createElement('div');
|
const surface = document.createElement('div');
|
||||||
surface.className = 'surface-editor';
|
surface.className = 'ext-editor';
|
||||||
surface.id = 'editorSurface';
|
surface.id = 'editorSurface';
|
||||||
mount.appendChild(surface);
|
mount.appendChild(surface);
|
||||||
|
|
||||||
@@ -84,7 +84,7 @@
|
|||||||
|
|
||||||
// Build body + bootstrap
|
// Build body + bootstrap
|
||||||
const body = document.createElement('div');
|
const body = document.createElement('div');
|
||||||
body.className = 'editor-body';
|
body.className = 'ext-editor-body';
|
||||||
body.id = 'editorBody';
|
body.id = 'editorBody';
|
||||||
surface.appendChild(body);
|
surface.appendChild(body);
|
||||||
|
|
||||||
@@ -113,32 +113,32 @@
|
|||||||
|
|
||||||
function _buildTopbar(wsName) {
|
function _buildTopbar(wsName) {
|
||||||
const el = document.createElement('div');
|
const el = document.createElement('div');
|
||||||
el.className = 'editor-topbar';
|
el.className = 'ext-editor-topbar';
|
||||||
el.id = 'editorTopbar';
|
el.id = 'editorTopbar';
|
||||||
el.innerHTML =
|
el.innerHTML =
|
||||||
'<a href="' + esc(base) + '/" class="editor-topbar-back" title="Back to chat">' +
|
'<a href="' + esc(base) + '/" class="ext-editor-topbar-back" title="Back to chat">' +
|
||||||
'<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>' +
|
'<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>' +
|
||||||
'Back' +
|
'Back' +
|
||||||
'</a>' +
|
'</a>' +
|
||||||
'<div class="editor-topbar-sep"></div>' +
|
'<div class="ext-editor-topbar-sep"></div>' +
|
||||||
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color:var(--accent);"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>' +
|
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color:var(--accent);"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>' +
|
||||||
'<div class="editor-ws-selector" id="editorWsSelector">' +
|
'<div class="ext-editor-ws-selector" id="editorWsSelector">' +
|
||||||
'<button class="editor-ws-selector-btn" id="editorWsSelectorBtn">' +
|
'<button class="ext-editor-ws-selector-btn" id="editorWsSelectorBtn">' +
|
||||||
'<span id="editorWorkspaceName">' + esc(wsName || 'Editor') + '</span>' +
|
'<span id="editorWorkspaceName">' + esc(wsName || 'Editor') + '</span>' +
|
||||||
'<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>' +
|
'<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>' +
|
||||||
'</button>' +
|
'</button>' +
|
||||||
'<div class="editor-ws-dropdown" id="editorWsDropdown">' +
|
'<div class="ext-editor-ws-dropdown" id="editorWsDropdown">' +
|
||||||
'<div id="editorWsList" class="editor-ws-list"></div>' +
|
'<div id="editorWsList" class="ext-editor-ws-list"></div>' +
|
||||||
'<div class="editor-ws-dropdown-divider"></div>' +
|
'<div class="ext-editor-ws-dropdown-divider"></div>' +
|
||||||
'<button class="editor-ws-dropdown-item editor-ws-new" id="editorWsNewBtn">' +
|
'<button class="ext-editor-ws-dropdown-item ext-editor-ws-new" id="editorWsNewBtn">' +
|
||||||
'<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>' +
|
'<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>' +
|
||||||
'New Workspace' +
|
'New Workspace' +
|
||||||
'</button>' +
|
'</button>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="editor-topbar-branch" id="editorBranchBadge" style="display:none;">' +
|
'<div class="ext-editor-topbar-branch" id="editorBranchBadge" style="display:none;">' +
|
||||||
'<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="var(--purple)" stroke-width="2"><circle cx="12" cy="18" r="3"/><circle cx="12" cy="6" r="3"/><line x1="12" y1="9" x2="12" y2="15"/></svg>' +
|
'<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="var(--purple)" stroke-width="2"><circle cx="12" cy="18" r="3"/><circle cx="12" cy="6" r="3"/><line x1="12" y1="9" x2="12" y2="15"/></svg>' +
|
||||||
'<span id="editorBranchName" class="editor-topbar-branch-text">main</span>' +
|
'<span id="editorBranchName" class="ext-editor-topbar-branch-text">main</span>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div style="flex:1;"></div>' +
|
'<div style="flex:1;"></div>' +
|
||||||
'<button class="icon-btn" id="editorRefreshBtn" title="Refresh files">' +
|
'<button class="icon-btn" id="editorRefreshBtn" title="Refresh files">' +
|
||||||
@@ -151,11 +151,11 @@
|
|||||||
|
|
||||||
function _buildBootstrap() {
|
function _buildBootstrap() {
|
||||||
const el = document.createElement('div');
|
const el = document.createElement('div');
|
||||||
el.className = 'editor-bootstrap';
|
el.className = 'ext-editor-bootstrap';
|
||||||
el.id = 'editorBootstrap';
|
el.id = 'editorBootstrap';
|
||||||
el.style.display = 'none';
|
el.style.display = 'none';
|
||||||
el.innerHTML =
|
el.innerHTML =
|
||||||
'<div class="editor-bootstrap-card">' +
|
'<div class="ext-editor-bootstrap-card">' +
|
||||||
'<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" stroke-width="1.5" style="opacity:0.6;margin-bottom:12px;">' +
|
'<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" stroke-width="1.5" style="opacity:0.6;margin-bottom:12px;">' +
|
||||||
'<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>' +
|
'<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>' +
|
||||||
'</svg>' +
|
'</svg>' +
|
||||||
@@ -168,8 +168,8 @@
|
|||||||
'<span style="font-size:11px;color:var(--text-3);text-transform:uppercase;">or create new</span>' +
|
'<span style="font-size:11px;color:var(--text-3);text-transform:uppercase;">or create new</span>' +
|
||||||
'<div style="flex:1;height:1px;background:var(--border);"></div>' +
|
'<div style="flex:1;height:1px;background:var(--border);"></div>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<input type="text" id="editorBootstrapName" class="editor-bootstrap-input" placeholder="Workspace name" value="workspace">' +
|
'<input type="text" id="editorBootstrapName" class="ext-editor-bootstrap-input" placeholder="Workspace name" value="workspace">' +
|
||||||
'<button id="editorBootstrapBtn" class="editor-bootstrap-btn">Create Workspace</button>' +
|
'<button id="editorBootstrapBtn" class="ext-editor-bootstrap-btn">Create Workspace</button>' +
|
||||||
'</div>';
|
'</div>';
|
||||||
return el;
|
return el;
|
||||||
}
|
}
|
||||||
@@ -218,7 +218,7 @@
|
|||||||
}
|
}
|
||||||
workspaces.forEach(ws => {
|
workspaces.forEach(ws => {
|
||||||
const item = document.createElement('button');
|
const item = document.createElement('button');
|
||||||
item.className = 'editor-ws-dropdown-item' + (ws.id === currentWsId ? ' active' : '');
|
item.className = 'ext-editor-ws-dropdown-item' + (ws.id === currentWsId ? ' active' : '');
|
||||||
item.textContent = ws.name || ws.id?.slice(0, 8);
|
item.textContent = ws.name || ws.id?.slice(0, 8);
|
||||||
item.addEventListener('click', () => { window.location.href = base + '/s/editor?ws=' + ws.id; });
|
item.addEventListener('click', () => { window.location.href = base + '/s/editor?ws=' + ws.id; });
|
||||||
listEl.appendChild(item);
|
listEl.appendChild(item);
|
||||||
@@ -243,10 +243,10 @@
|
|||||||
listEl.innerHTML = '';
|
listEl.innerHTML = '';
|
||||||
workspaces.forEach(ws => {
|
workspaces.forEach(ws => {
|
||||||
const item = document.createElement('button');
|
const item = document.createElement('button');
|
||||||
item.className = 'editor-bootstrap-ws-item';
|
item.className = 'ext-editor-bootstrap-ws-item';
|
||||||
item.innerHTML =
|
item.innerHTML =
|
||||||
'<span class="editor-bootstrap-ws-name">' + esc(ws.name || ws.id?.slice(0, 8)) + '</span>' +
|
'<span class="ext-editor-bootstrap-ws-name">' + esc(ws.name || ws.id?.slice(0, 8)) + '</span>' +
|
||||||
'<span class="editor-bootstrap-ws-date">' + esc(ws.created_at ? new Date(ws.created_at).toLocaleDateString() : '') + '</span>';
|
'<span class="ext-editor-bootstrap-ws-date">' + esc(ws.created_at ? new Date(ws.created_at).toLocaleDateString() : '') + '</span>';
|
||||||
item.addEventListener('click', () => { window.location.href = base + '/s/editor?ws=' + ws.id; });
|
item.addEventListener('click', () => { window.location.href = base + '/s/editor?ws=' + ws.id; });
|
||||||
listEl.appendChild(item);
|
listEl.appendChild(item);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"type": "full",
|
"type": "full",
|
||||||
"version": "0.31.0",
|
"version": "0.31.0",
|
||||||
"tier": "browser",
|
"tier": "browser",
|
||||||
"author": "Switchboard Core",
|
"author": "Armature",
|
||||||
"icon": "✏️",
|
"icon": "✏️",
|
||||||
"description": "Code editor with workspace management, file tree, and AI assist (requires legacy sw.* SDK — dormant until rewritten)",
|
"description": "Code editor with workspace management, file tree, and AI assist (requires legacy sw.* SDK — dormant until rewritten)",
|
||||||
"requires": ["legacy-sdk"],
|
"requires": ["legacy-sdk"],
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"tier": "starlark",
|
"tier": "starlark",
|
||||||
"icon": "🏢",
|
"icon": "🏢",
|
||||||
"author": "Switchboard Core",
|
"author": "Armature",
|
||||||
"description": "Employee onboarding with automated provisioning, manager signoff, and welcome notification.",
|
"description": "Employee onboarding with automated provisioning, manager signoff, and welcome notification.",
|
||||||
"permissions": ["db.write", "notifications.send"],
|
"permissions": ["db.write", "notifications.send"],
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/* Git Board — Surface Styles */
|
/* Git Board — Surface Styles */
|
||||||
|
|
||||||
.gb-shell {
|
.ext-git-board-shell {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -9,80 +9,80 @@
|
|||||||
|
|
||||||
/* ── Header ──────────────────────────────── */
|
/* ── Header ──────────────────────────────── */
|
||||||
|
|
||||||
.gb-header {
|
.ext-git-board-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 12px 16px;
|
padding: var(--sp-3) var(--sp-4);
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gb-header__left,
|
.ext-git-board-header__left,
|
||||||
.gb-header__right {
|
.ext-git-board-header__right {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: var(--sp-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.gb-title {
|
.ext-git-board-title {
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gb-repo-picker {
|
.ext-git-board-repo-picker {
|
||||||
background: var(--bg-raised);
|
background: var(--bg-raised);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-family: var(--mono);
|
font-family: var(--mono);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
padding: 5px 8px;
|
padding: 5px var(--sp-2);
|
||||||
max-width: 260px;
|
max-width: 260px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Connection Setup ─────────────────────── */
|
/* ── Connection Setup ─────────────────────── */
|
||||||
|
|
||||||
.gb-setup {
|
.ext-git-board-setup {
|
||||||
max-width: 480px;
|
max-width: 480px;
|
||||||
margin: 60px auto;
|
margin: 60px auto;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 0 16px;
|
padding: 0 var(--sp-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.gb-setup h2 {
|
.ext-git-board-setup h2 {
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
margin: 0 0 8px;
|
margin: 0 0 var(--sp-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.gb-setup p {
|
.ext-git-board-setup p {
|
||||||
color: var(--text-2);
|
color: var(--text-2);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
margin: 0 0 20px;
|
margin: 0 0 var(--sp-5);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.gb-setup__hint {
|
.ext-git-board-setup__hint {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Kanban Board ────────────────────────── */
|
/* ── Kanban Board ────────────────────────── */
|
||||||
|
|
||||||
.gb-board {
|
.ext-git-board-board {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 12px;
|
gap: var(--sp-3);
|
||||||
padding: 12px 16px;
|
padding: var(--sp-3) var(--sp-4);
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
overflow-y: hidden;
|
overflow-y: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gb-column {
|
.ext-git-board-column {
|
||||||
min-width: 260px;
|
min-width: 260px;
|
||||||
max-width: 320px;
|
max-width: 320px;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -94,15 +94,15 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gb-column__header {
|
.ext-git-board-column__header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 10px 12px;
|
padding: var(--sp-3) var(--sp-3);
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.gb-column__title {
|
.ext-git-board-column__title {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
@@ -110,67 +110,67 @@
|
|||||||
letter-spacing: 0.03em;
|
letter-spacing: 0.03em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gb-column__count {
|
.ext-git-board-column__count {
|
||||||
background: var(--bg-raised);
|
background: var(--bg-raised);
|
||||||
color: var(--text-2);
|
color: var(--text-2);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
padding: 2px 7px;
|
padding: 2px 7px;
|
||||||
border-radius: 10px;
|
border-radius: var(--radius-lg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.gb-column__cards {
|
.ext-git-board-column__cards {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 8px;
|
padding: var(--sp-2);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 6px;
|
gap: var(--sp-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Cards ───────────────────────────────── */
|
/* ── Cards ───────────────────────────────── */
|
||||||
|
|
||||||
.gb-card {
|
.ext-git-board-card {
|
||||||
display: block;
|
display: block;
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
padding: 10px;
|
padding: var(--sp-3);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
transition: border-color var(--transition), background var(--transition);
|
transition: border-color var(--transition), background var(--transition);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gb-card:hover {
|
.ext-git-board-card:hover {
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
background: var(--bg-hover);
|
background: var(--bg-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.gb-card--pr {
|
.ext-git-board-card--pr {
|
||||||
border-left: 3px solid var(--accent);
|
border-left: 3px solid var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.gb-card__header {
|
.ext-git-board-card__header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: var(--sp-2);
|
||||||
margin-bottom: 4px;
|
margin-bottom: var(--sp-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.gb-card__number {
|
.ext-git-board-card__number {
|
||||||
font-family: var(--mono);
|
font-family: var(--mono);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.gb-card__assignee {
|
.ext-git-board-card__assignee {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gb-card__branch {
|
.ext-git-board-card__branch {
|
||||||
font-family: var(--mono);
|
font-family: var(--mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
@@ -181,54 +181,54 @@
|
|||||||
max-width: 150px;
|
max-width: 150px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gb-card__title {
|
.ext-git-board-card__title {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.gb-card__labels {
|
.ext-git-board-card__labels {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 4px;
|
gap: var(--sp-1);
|
||||||
margin-top: 6px;
|
margin-top: var(--sp-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.gb-card__labels .badge {
|
.ext-git-board-card__labels .ext-git-board-badge {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
padding: 1px 6px;
|
padding: 1px var(--sp-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.gb-card__meta {
|
.ext-git-board-card__meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
margin-top: 6px;
|
margin-top: var(--sp-2);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── DnD States ─────────────────────────── */
|
/* ── DnD States ─────────────────────────── */
|
||||||
|
|
||||||
.gb-card[draggable="true"] {
|
.ext-git-board-card[draggable="true"] {
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
.gb-card[draggable="true"]:active {
|
.ext-git-board-card[draggable="true"]:active {
|
||||||
cursor: grabbing;
|
cursor: grabbing;
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
.gb-column--dragover {
|
.ext-git-board-column--dragover {
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
background: color-mix(in srgb, var(--accent) 6%, var(--bg-surface));
|
background: color-mix(in srgb, var(--accent) 6%, var(--bg-surface));
|
||||||
}
|
}
|
||||||
.gb-column--dragover .gb-column__header {
|
.ext-git-board-column--dragover .ext-git-board-column__header {
|
||||||
border-bottom-color: var(--accent);
|
border-bottom-color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Empty state ─────────────────────────── */
|
/* ── Empty state ─────────────────────────── */
|
||||||
|
|
||||||
.gb-empty {
|
.ext-git-board-empty {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -239,7 +239,7 @@
|
|||||||
|
|
||||||
/* ── Issue Detail Modal ─────────────────── */
|
/* ── Issue Detail Modal ─────────────────── */
|
||||||
|
|
||||||
.gb-modal-overlay {
|
.ext-git-board-modal-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background: rgba(0,0,0,0.5);
|
background: rgba(0,0,0,0.5);
|
||||||
@@ -247,40 +247,41 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 40px 16px;
|
padding: var(--sp-10) var(--sp-4);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gb-modal {
|
.ext-git-board-modal {
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 680px;
|
max-width: 680px;
|
||||||
max-height: calc(100vh - 80px);
|
max-height: calc(100vh - 80px);
|
||||||
|
max-height: calc(100dvh - 80px);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
box-shadow: 0 8px 32px rgba(0,0,0,0.3);
|
box-shadow: 0 8px 32px rgba(0,0,0,0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.gb-modal__header {
|
.ext-git-board-modal__header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 16px 20px;
|
padding: var(--sp-4) var(--sp-5);
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gb-modal__title-row {
|
.ext-git-board-modal__title-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
gap: 8px;
|
gap: var(--sp-2);
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gb-modal__title {
|
.ext-git-board-modal__title {
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
@@ -288,59 +289,59 @@
|
|||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gb-modal__close {
|
.ext-git-board-modal__close {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 2px 6px;
|
padding: 2px var(--sp-2);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.gb-modal__close:hover {
|
.ext-git-board-modal__close:hover {
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
background: var(--bg-hover);
|
background: var(--bg-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.gb-modal__body {
|
.ext-git-board-modal__body {
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 16px 20px;
|
padding: var(--sp-4) var(--sp-5);
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gb-modal__meta {
|
.ext-git-board-modal__meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: var(--sp-3);
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
margin-bottom: 12px;
|
margin-bottom: var(--sp-3);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-2);
|
color: var(--text-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.gb-modal__date {
|
.ext-git-board-modal__date {
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.gb-modal__extlink {
|
.ext-git-board-modal__extlink {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
.gb-modal__extlink:hover {
|
.ext-git-board-modal__extlink:hover {
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gb-modal__description {
|
.ext-git-board-modal__description {
|
||||||
margin-bottom: 20px;
|
margin-bottom: var(--sp-5);
|
||||||
padding-bottom: 16px;
|
padding-bottom: var(--sp-4);
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.gb-modal__body-text {
|
.ext-git-board-modal__body-text {
|
||||||
font-family: var(--font);
|
font-family: var(--font);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
@@ -353,49 +354,49 @@
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gb-modal__empty {
|
.ext-git-board-modal__empty {
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gb-modal__section-title {
|
.ext-git-board-modal__section-title {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-2);
|
color: var(--text-2);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.03em;
|
letter-spacing: 0.03em;
|
||||||
margin: 0 0 12px;
|
margin: 0 0 var(--sp-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Comments ────────────────────────────── */
|
/* ── Comments ────────────────────────────── */
|
||||||
|
|
||||||
.gb-comment {
|
.ext-git-board-comment {
|
||||||
padding: 10px 0;
|
padding: var(--sp-3) 0;
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
.gb-comment:last-child {
|
.ext-git-board-comment:last-child {
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gb-comment__header {
|
.ext-git-board-comment__header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
gap: 8px;
|
gap: var(--sp-2);
|
||||||
margin-bottom: 4px;
|
margin-bottom: var(--sp-1);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
.gb-comment__header strong {
|
.ext-git-board-comment__header strong {
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.gb-comment__date {
|
.ext-git-board-comment__date {
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gb-comment__body {
|
.ext-git-board-comment__body {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
@@ -405,13 +406,13 @@
|
|||||||
|
|
||||||
/* ── Add Comment ─────────────────────────── */
|
/* ── Add Comment ─────────────────────────── */
|
||||||
|
|
||||||
.gb-modal__add-comment {
|
.ext-git-board-modal__add-comment {
|
||||||
margin-top: 16px;
|
margin-top: var(--sp-4);
|
||||||
padding-top: 16px;
|
padding-top: var(--sp-4);
|
||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.gb-modal__textarea {
|
.ext-git-board-modal__textarea {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background: var(--bg-raised);
|
background: var(--bg-raised);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
@@ -419,38 +420,31 @@
|
|||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-family: var(--font);
|
font-family: var(--font);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
padding: 8px 10px;
|
padding: var(--sp-2) var(--sp-3);
|
||||||
resize: vertical;
|
resize: vertical;
|
||||||
outline: none;
|
outline: none;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
.gb-modal__textarea:focus {
|
.ext-git-board-modal__textarea:focus {
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.gb-modal__actions {
|
.ext-git-board-modal__actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: var(--sp-2);
|
||||||
margin-top: 8px;
|
margin-top: var(--sp-2);
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Badge variants ──────────────────────── */
|
/* ── Badge variants ──────────────────────── */
|
||||||
|
|
||||||
.badge--green {
|
.ext-git-board-badge--green {
|
||||||
background: var(--green);
|
background: var(--green);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
.badge--muted {
|
.ext-git-board-badge--muted {
|
||||||
background: var(--bg-raised);
|
background: var(--bg-raised);
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-danger {
|
/* git-board danger button override — uses kernel .sw-btn--danger */
|
||||||
background: var(--danger, #e53e3e);
|
|
||||||
color: #fff;
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
.btn-danger:hover {
|
|
||||||
opacity: 0.9;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -160,21 +160,21 @@
|
|||||||
return html`
|
return html`
|
||||||
<div class="user-menu-container" ref=${menuRef}></div>
|
<div class="user-menu-container" ref=${menuRef}></div>
|
||||||
${needsConn ? html`<${ConnectionSetup} />` : html`
|
${needsConn ? html`<${ConnectionSetup} />` : html`
|
||||||
<div class="gb-shell">
|
<div class="ext-git-board-shell">
|
||||||
<header class="gb-header">
|
<header class="ext-git-board-header">
|
||||||
<div class="gb-header__left">
|
<div class="ext-git-board-header__left">
|
||||||
<h1 class="gb-title">Git Board</h1>
|
<h1 class="ext-git-board-title">Git Board</h1>
|
||||||
<${RepoPicker} repos=${repos} owner=${owner} repo=${repo}
|
<${RepoPicker} repos=${repos} owner=${owner} repo=${repo}
|
||||||
onSelect=${function (o, r) { setOwner(o); setRepo(r); }} />
|
onSelect=${function (o, r) { setOwner(o); setRepo(r); }} />
|
||||||
</div>
|
</div>
|
||||||
<div class="gb-header__right">
|
<div class="ext-git-board-header__right">
|
||||||
<button class="btn-small" onClick=${loadBoard} disabled=${loading}>
|
<button class="sw-btn sw-btn--secondary sw-btn--sm" onClick=${loadBoard} disabled=${loading}>
|
||||||
${loading ? '↻' : 'Refresh'}
|
${loading ? '↻' : 'Refresh'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
${board ? html`<${Board} data=${board} onDrop=${onDrop} onCardClick=${openModal} />` : html`
|
${board ? html`<${Board} data=${board} onDrop=${onDrop} onCardClick=${openModal} />` : html`
|
||||||
<div class="gb-empty">${loading ? 'Loading…' : 'Select a repository'}</div>
|
<div class="ext-git-board-empty">${loading ? 'Loading…' : 'Select a repository'}</div>
|
||||||
`}
|
`}
|
||||||
</div>
|
</div>
|
||||||
`}
|
`}
|
||||||
@@ -186,21 +186,21 @@
|
|||||||
|
|
||||||
function ConnectionSetup() {
|
function ConnectionSetup() {
|
||||||
return html`
|
return html`
|
||||||
<div class="gb-shell">
|
<div class="ext-git-board-shell">
|
||||||
<header class="gb-header">
|
<header class="ext-git-board-header">
|
||||||
<div class="gb-header__left"><h1 class="gb-title">Git Board</h1></div>
|
<div class="ext-git-board-header__left"><h1 class="ext-git-board-title">Git Board</h1></div>
|
||||||
</header>
|
</header>
|
||||||
<div class="gb-setup">
|
<div class="ext-git-board-setup">
|
||||||
<h2>Connect to Gitea</h2>
|
<h2>Connect to Gitea</h2>
|
||||||
<p>Git Board requires a Gitea connection to fetch repositories, issues, and pull requests.</p>
|
<p>Git Board requires a Gitea connection to fetch repositories, issues, and pull requests.</p>
|
||||||
<p>Ask your admin to add a <strong>Gitea</strong> connection in
|
<p>Ask your admin to add a <strong>Gitea</strong> connection in
|
||||||
<strong>Admin → Connections</strong>, or add a personal one in
|
<strong>Admin → Connections</strong>, or add a personal one in
|
||||||
<strong>Settings → Connections</strong>.</p>
|
<strong>Settings → Connections</strong>.</p>
|
||||||
<button class="btn-small btn-primary"
|
<button class="sw-btn sw-btn--primary sw-btn--sm"
|
||||||
onClick=${function () { window.location.href = base + '/settings'; }}>
|
onClick=${function () { window.location.href = base + '/settings'; }}>
|
||||||
Open Settings
|
Open Settings
|
||||||
</button>
|
</button>
|
||||||
<p class="gb-setup__hint">Connections are managed centrally — no API tokens in extension settings.</p>
|
<p class="ext-git-board-setup__hint">Connections are managed centrally — no API tokens in extension settings.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -211,7 +211,7 @@
|
|||||||
function RepoPicker({ repos, owner, repo, onSelect }) {
|
function RepoPicker({ repos, owner, repo, onSelect }) {
|
||||||
var current = owner + '/' + repo;
|
var current = owner + '/' + repo;
|
||||||
return html`
|
return html`
|
||||||
<select class="gb-repo-picker" value=${current}
|
<select class="ext-git-board-repo-picker" value=${current}
|
||||||
onChange=${function (e) {
|
onChange=${function (e) {
|
||||||
var parts = e.target.value.split('/');
|
var parts = e.target.value.split('/');
|
||||||
onSelect(parts[0], parts.slice(1).join('/'));
|
onSelect(parts[0], parts.slice(1).join('/'));
|
||||||
@@ -233,7 +233,7 @@
|
|||||||
var inProgress = issues.filter(function (i) { return i.state === 'open' && !!i.assignee; });
|
var inProgress = issues.filter(function (i) { return i.state === 'open' && !!i.assignee; });
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="gb-board">
|
<div class="ext-git-board-board">
|
||||||
<${Column} id="open" title="Open" count=${openUnassigned.length} onDrop=${onDrop}>
|
<${Column} id="open" title="Open" count=${openUnassigned.length} onDrop=${onDrop}>
|
||||||
${openUnassigned.map(function (i) {
|
${openUnassigned.map(function (i) {
|
||||||
return html`<${IssueCard} key=${i.number} issue=${i} onClick=${onCardClick} />`;
|
return html`<${IssueCard} key=${i.number} issue=${i} onClick=${onCardClick} />`;
|
||||||
@@ -268,19 +268,19 @@
|
|||||||
} : {};
|
} : {};
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="gb-column ${over ? 'gb-column--dragover' : ''}" ...${handlers}>
|
<div class="ext-git-board-column ${over ? 'ext-git-board-column--dragover' : ''}" ...${handlers}>
|
||||||
<div class="gb-column__header">
|
<div class="ext-git-board-column__header">
|
||||||
<span class="gb-column__title">${title}</span>
|
<span class="ext-git-board-column__title">${title}</span>
|
||||||
<span class="gb-column__count">${count || 0}</span>
|
<span class="ext-git-board-column__count">${count || 0}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="gb-column__cards">${children}</div>
|
<div class="ext-git-board-column__cards">${children}</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function IssueCard({ issue, onClick }) {
|
function IssueCard({ issue, onClick }) {
|
||||||
return html`
|
return html`
|
||||||
<div class="gb-card" draggable="true"
|
<div class="ext-git-board-card" draggable="true"
|
||||||
onDragStart=${function (e) {
|
onDragStart=${function (e) {
|
||||||
e.dataTransfer.setData('text/plain', String(issue.number));
|
e.dataTransfer.setData('text/plain', String(issue.number));
|
||||||
e.dataTransfer.effectAllowed = 'move';
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
@@ -289,14 +289,14 @@
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (onClick) onClick(issue.number);
|
if (onClick) onClick(issue.number);
|
||||||
}}>
|
}}>
|
||||||
<div class="gb-card__header">
|
<div class="ext-git-board-card__header">
|
||||||
<span class="gb-card__number">#${issue.number}</span>
|
<span class="ext-git-board-card__number">#${issue.number}</span>
|
||||||
${issue.assignee && html`<span class="gb-card__assignee">@${esc(issue.assignee)}</span>`}
|
${issue.assignee && html`<span class="ext-git-board-card__assignee">@${esc(issue.assignee)}</span>`}
|
||||||
</div>
|
</div>
|
||||||
<div class="gb-card__title">${esc(issue.title)}</div>
|
<div class="ext-git-board-card__title">${esc(issue.title)}</div>
|
||||||
<div class="gb-card__labels">
|
<div class="ext-git-board-card__labels">
|
||||||
${(issue.labels || []).map(function (l) {
|
${(issue.labels || []).map(function (l) {
|
||||||
return html`<span key=${l} class="badge">${esc(l)}</span>`;
|
return html`<span key=${l} class="ext-git-board-badge">${esc(l)}</span>`;
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -305,13 +305,13 @@
|
|||||||
|
|
||||||
function PRCard({ pr }) {
|
function PRCard({ pr }) {
|
||||||
return html`
|
return html`
|
||||||
<a class="gb-card gb-card--pr" href=${pr.html_url} target="_blank" rel="noopener">
|
<a class="ext-git-board-card ext-git-board-card--pr" href=${pr.html_url} target="_blank" rel="noopener">
|
||||||
<div class="gb-card__header">
|
<div class="ext-git-board-card__header">
|
||||||
<span class="gb-card__number">#${pr.number}</span>
|
<span class="ext-git-board-card__number">#${pr.number}</span>
|
||||||
<span class="gb-card__branch">${esc(pr.head)} → ${esc(pr.base)}</span>
|
<span class="ext-git-board-card__branch">${esc(pr.head)} → ${esc(pr.base)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="gb-card__title">${esc(pr.title)}</div>
|
<div class="ext-git-board-card__title">${esc(pr.title)}</div>
|
||||||
<div class="gb-card__meta">
|
<div class="ext-git-board-card__meta">
|
||||||
<span>@${esc(pr.user)}</span>
|
<span>@${esc(pr.user)}</span>
|
||||||
<span>${timeAgo(pr.created_at)}</span>
|
<span>${timeAgo(pr.created_at)}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -387,73 +387,73 @@
|
|||||||
}, [issue, owner, repo, number]);
|
}, [issue, owner, repo, number]);
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="gb-modal-overlay" onClick=${function (e) {
|
<div class="ext-git-board-modal-overlay" onClick=${function (e) {
|
||||||
if (e.target.classList.contains('gb-modal-overlay')) onClose(changed);
|
if (e.target.classList.contains('ext-git-board-modal-overlay')) onClose(changed);
|
||||||
}}>
|
}}>
|
||||||
<div class="gb-modal">
|
<div class="ext-git-board-modal">
|
||||||
<div class="gb-modal__header">
|
<div class="ext-git-board-modal__header">
|
||||||
<div class="gb-modal__title-row">
|
<div class="ext-git-board-modal__title-row">
|
||||||
${loading ? 'Loading…' : html`
|
${loading ? 'Loading…' : html`
|
||||||
<span class="gb-card__number">#${number}</span>
|
<span class="ext-git-board-card__number">#${number}</span>
|
||||||
<h2 class="gb-modal__title">${esc(issue && issue.title)}</h2>
|
<h2 class="ext-git-board-modal__title">${esc(issue && issue.title)}</h2>
|
||||||
`}
|
`}
|
||||||
</div>
|
</div>
|
||||||
<button class="gb-modal__close" onClick=${function () { onClose(changed); }}>✕</button>
|
<button class="ext-git-board-modal__close" onClick=${function () { onClose(changed); }}>✕</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
${!loading && issue && html`
|
${!loading && issue && html`
|
||||||
<div class="gb-modal__body" ref=${bodyRef}>
|
<div class="ext-git-board-modal__body" ref=${bodyRef}>
|
||||||
<div class="gb-modal__meta">
|
<div class="ext-git-board-modal__meta">
|
||||||
<span class="badge ${issue.state === 'open' ? 'badge--green' : 'badge--muted'}">${issue.state}</span>
|
<span class="ext-git-board-badge ${issue.state === 'open' ? 'ext-git-board-badge--green' : 'ext-git-board-badge--muted'}">${issue.state}</span>
|
||||||
${issue.assignee && html`<span class="gb-card__assignee">@${esc(issue.assignee)}</span>`}
|
${issue.assignee && html`<span class="ext-git-board-card__assignee">@${esc(issue.assignee)}</span>`}
|
||||||
${issue.created_at && html`<span class="gb-modal__date">${new Date(issue.created_at).toLocaleDateString()}</span>`}
|
${issue.created_at && html`<span class="ext-git-board-modal__date">${new Date(issue.created_at).toLocaleDateString()}</span>`}
|
||||||
<a href=${issue.html_url || '#'} target="_blank" rel="noopener"
|
<a href=${issue.html_url || '#'} target="_blank" rel="noopener"
|
||||||
class="gb-modal__extlink">Open in Gitea ↗</a>
|
class="ext-git-board-modal__extlink">Open in Gitea ↗</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
${issue.labels && issue.labels.length > 0 && html`
|
${issue.labels && issue.labels.length > 0 && html`
|
||||||
<div class="gb-card__labels" style="margin-bottom:12px;">
|
<div class="ext-git-board-card__labels" style="margin-bottom:12px;">
|
||||||
${issue.labels.map(function (l) { return html`<span key=${l} class="badge">${esc(l)}</span>`; })}
|
${issue.labels.map(function (l) { return html`<span key=${l} class="ext-git-board-badge">${esc(l)}</span>`; })}
|
||||||
</div>
|
</div>
|
||||||
`}
|
`}
|
||||||
|
|
||||||
<div class="gb-modal__description">
|
<div class="ext-git-board-modal__description">
|
||||||
${issue.body ? html`<pre class="gb-modal__body-text">${esc(issue.body)}</pre>`
|
${issue.body ? html`<pre class="ext-git-board-modal__body-text">${esc(issue.body)}</pre>`
|
||||||
: html`<p class="gb-modal__empty">No description.</p>`}
|
: html`<p class="ext-git-board-modal__empty">No description.</p>`}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="gb-modal__comments">
|
<div class="ext-git-board-modal__comments">
|
||||||
<h3 class="gb-modal__section-title">Comments (${(issue.comments || []).length})</h3>
|
<h3 class="ext-git-board-modal__section-title">Comments (${(issue.comments || []).length})</h3>
|
||||||
${(issue.comments || []).length === 0 && html`
|
${(issue.comments || []).length === 0 && html`
|
||||||
<p class="gb-modal__empty">No comments yet.</p>
|
<p class="ext-git-board-modal__empty">No comments yet.</p>
|
||||||
`}
|
`}
|
||||||
${(issue.comments || []).map(function (c, i) {
|
${(issue.comments || []).map(function (c, i) {
|
||||||
return html`
|
return html`
|
||||||
<div class="gb-comment" key=${i}>
|
<div class="ext-git-board-comment" key=${i}>
|
||||||
<div class="gb-comment__header">
|
<div class="ext-git-board-comment__header">
|
||||||
<strong>@${esc(c.user)}</strong>
|
<strong>@${esc(c.user)}</strong>
|
||||||
<span class="gb-comment__date">${timeAgo(c.created_at)}</span>
|
<span class="ext-git-board-comment__date">${timeAgo(c.created_at)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="gb-comment__body">${esc(c.body)}</div>
|
<div class="ext-git-board-comment__body">${esc(c.body)}</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="gb-modal__add-comment">
|
<div class="ext-git-board-modal__add-comment">
|
||||||
<textarea class="gb-modal__textarea" rows="3"
|
<textarea class="ext-git-board-modal__textarea" rows="3"
|
||||||
placeholder="Add a comment…"
|
placeholder="Add a comment…"
|
||||||
value=${comment}
|
value=${comment}
|
||||||
onInput=${function (e) { setComment(e.target.value); }}
|
onInput=${function (e) { setComment(e.target.value); }}
|
||||||
onKeyDown=${function (e) {
|
onKeyDown=${function (e) {
|
||||||
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) postComment();
|
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) postComment();
|
||||||
}} />
|
}} />
|
||||||
<div class="gb-modal__actions">
|
<div class="ext-git-board-modal__actions">
|
||||||
<button class="btn-small btn-primary" disabled=${posting || !comment.trim()}
|
<button class="sw-btn sw-btn--primary sw-btn--sm" disabled=${posting || !comment.trim()}
|
||||||
onClick=${postComment}>
|
onClick=${postComment}>
|
||||||
${posting ? 'Posting…' : 'Comment'}
|
${posting ? 'Posting…' : 'Comment'}
|
||||||
</button>
|
</button>
|
||||||
<button class="btn-small ${issue.state === 'open' ? 'btn-danger' : 'btn-secondary'}"
|
<button class="sw-btn sw-btn--secondary sw-btn--sm ${issue.state === 'open' ? 'sw-btn sw-btn--danger sw-btn--md' : 'sw-btn sw-btn--secondary sw-btn--md'}"
|
||||||
onClick=${toggleState}>
|
onClick=${toggleState}>
|
||||||
${issue.state === 'open' ? 'Close Issue' : 'Reopen Issue'}
|
${issue.state === 'open' ? 'Close Issue' : 'Reopen Issue'}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"icon": "🔀",
|
"icon": "🔀",
|
||||||
"description": "Gitea issue and PR board powered by the gitea-client library. Uses extension connections for authentication.",
|
"description": "Gitea issue and PR board powered by the gitea-client library. Uses extension connections for authentication.",
|
||||||
"author": "switchboard",
|
"author": "armature",
|
||||||
|
|
||||||
"permissions": ["connections.read"],
|
"permissions": ["connections.read"],
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
"tier": "starlark",
|
"tier": "starlark",
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"description": "Shared Gitea client — connections, API helpers, optional data caching.",
|
"description": "Shared Gitea client — connections, API helpers, optional data caching.",
|
||||||
"author": "switchboard",
|
"author": "armature",
|
||||||
|
|
||||||
"permissions": ["api.http", "connections.read", "db.read", "db.write"],
|
"permissions": ["api.http", "connections.read", "db.read", "db.write"],
|
||||||
|
|
||||||
|
|||||||
@@ -2,16 +2,16 @@
|
|||||||
Uses CSS custom properties from the platform theme system (variables.css).
|
Uses CSS custom properties from the platform theme system (variables.css).
|
||||||
See EXTENSION-SURFACES.md for the full property reference. */
|
See EXTENSION-SURFACES.md for the full property reference. */
|
||||||
|
|
||||||
.hello-dashboard { max-width: 720px; margin: 0 auto; padding: 40px 24px; }
|
.ext-hello-dashboard { max-width: 720px; margin: 0 auto; padding: var(--sp-10) var(--sp-6); }
|
||||||
.hello-header { margin-bottom: 32px; }
|
.ext-hello-dashboard-header { margin-bottom: var(--sp-8); }
|
||||||
.hello-header h1 { font-size: 28px; font-weight: 700; color: var(--text); margin: 0 0 8px 0; }
|
.ext-hello-dashboard-header h1 { font-size: 28px; font-weight: 700; color: var(--text); margin: 0 0 var(--sp-2) 0; }
|
||||||
.hello-subtitle { font-size: 14px; color: var(--text-2); margin: 0; }
|
.ext-hello-dashboard-subtitle { font-size: 14px; color: var(--text-2); margin: 0; }
|
||||||
.hello-subtitle code,
|
.ext-hello-dashboard-subtitle code,
|
||||||
.hello-card-value code { background: var(--bg-raised); padding: 2px 6px; border-radius: 4px; font-size: 13px; }
|
.ext-hello-dashboard-card-value code { background: var(--bg-raised); padding: 2px 6px; border-radius: var(--radius-sm); font-size: 13px; }
|
||||||
.hello-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; margin-bottom: 24px; }
|
.ext-hello-dashboard-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: var(--sp-4); margin-bottom: var(--sp-6); }
|
||||||
.hello-card { background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 16px; }
|
.ext-hello-dashboard-card { background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--radius-lg); padding: var(--sp-4); }
|
||||||
.hello-card-title { font-size: 12px; font-weight: 600; color: var(--text-2); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 8px; }
|
.ext-hello-dashboard-card-title { font-size: 12px; font-weight: 600; color: var(--text-2); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: var(--sp-2); }
|
||||||
.hello-card-value { font-size: 20px; font-weight: 600; color: var(--text); margin-bottom: 4px; }
|
.ext-hello-dashboard-card-value { font-size: 20px; font-weight: 600; color: var(--text); margin-bottom: var(--sp-1); }
|
||||||
.hello-card-detail { font-size: 12px; color: var(--text-3); }
|
.ext-hello-dashboard-card-detail { font-size: 12px; color: var(--text-3); }
|
||||||
.hello-actions { display: flex; gap: 12px; margin-bottom: 24px; }
|
.ext-hello-dashboard-actions { display: flex; gap: var(--sp-3); margin-bottom: var(--sp-6); }
|
||||||
.hello-manifest { background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px; font-size: 12px; color: var(--text-2); overflow-x: auto; white-space: pre-wrap; font-family: var(--mono); line-height: 1.5; }
|
.ext-hello-dashboard-manifest { background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--radius); padding: var(--sp-4); font-size: 12px; color: var(--text-2); overflow-x: auto; white-space: pre-wrap; font-family: var(--mono); line-height: 1.5; }
|
||||||
|
|||||||
@@ -20,12 +20,12 @@
|
|||||||
var name = user.display_name || user.username || 'World';
|
var name = user.display_name || user.username || 'World';
|
||||||
|
|
||||||
mount.innerHTML =
|
mount.innerHTML =
|
||||||
'<div class="hello-dashboard">' +
|
'<div class="ext-hello-dashboard">' +
|
||||||
'<div class="hello-header">' +
|
'<div class="ext-hello-dashboard-header">' +
|
||||||
'<h1>Hello, ' + esc(name) + '!</h1>' +
|
'<h1>Hello, ' + esc(name) + '!</h1>' +
|
||||||
'<p class="hello-subtitle">Extension surface <code>' + esc(manifest.id || 'unknown') + '</code> loaded successfully.</p>' +
|
'<p class="ext-hello-dashboard-subtitle">Extension surface <code>' + esc(manifest.id || 'unknown') + '</code> loaded successfully.</p>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="hello-cards">' +
|
'<div class="ext-hello-dashboard-cards">' +
|
||||||
card('Platform Access', (typeof UI !== 'undefined' ? '\u2713 Connected' : '\u2717 Unavailable'),
|
card('Platform Access', (typeof UI !== 'undefined' ? '\u2713 Connected' : '\u2717 Unavailable'),
|
||||||
'API, Theme, UI primitives available', 'var(--accent)') +
|
'API, Theme, UI primitives available', 'var(--accent)') +
|
||||||
card('Theme', isDark ? '\uD83C\uDF19 Dark' : '\u2600\uFE0F Light',
|
card('Theme', isDark ? '\uD83C\uDF19 Dark' : '\u2600\uFE0F Light',
|
||||||
@@ -33,11 +33,11 @@
|
|||||||
card('Route', '<code>' + esc(manifest.route || '/s/hello-dashboard') + '</code>',
|
card('Route', '<code>' + esc(manifest.route || '/s/hello-dashboard') + '</code>',
|
||||||
'Registered via surface manifest', '') +
|
'Registered via surface manifest', '') +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="hello-actions">' +
|
'<div class="ext-hello-dashboard-actions">' +
|
||||||
'<button class="btn-primary" id="helloToast">Show Toast</button>' +
|
'<button class="sw-btn sw-btn--primary sw-btn--md" id="helloToast">Show Toast</button>' +
|
||||||
'<button class="btn-secondary" id="helloApi">Test API</button>' +
|
'<button class="sw-btn sw-btn--secondary sw-btn--md" id="helloApi">Test API</button>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<pre class="hello-manifest">' + esc(JSON.stringify(manifest, null, 2)) + '</pre>' +
|
'<pre class="ext-hello-dashboard-manifest">' + esc(JSON.stringify(manifest, null, 2)) + '</pre>' +
|
||||||
'</div>';
|
'</div>';
|
||||||
|
|
||||||
// Wire toast button
|
// Wire toast button
|
||||||
@@ -69,10 +69,10 @@
|
|||||||
|
|
||||||
function card(title, value, detail, color) {
|
function card(title, value, detail, color) {
|
||||||
var style = color ? ' style="color:' + color + ';"' : '';
|
var style = color ? ' style="color:' + color + ';"' : '';
|
||||||
return '<div class="hello-card">' +
|
return '<div class="ext-hello-dashboard-card">' +
|
||||||
'<div class="hello-card-title">' + esc(title) + '</div>' +
|
'<div class="ext-hello-dashboard-card-title">' + esc(title) + '</div>' +
|
||||||
'<div class="hello-card-value"' + style + '>' + value + '</div>' +
|
'<div class="ext-hello-dashboard-card-value"' + style + '>' + value + '</div>' +
|
||||||
'<div class="hello-card-detail">' + esc(detail) + '</div>' +
|
'<div class="ext-hello-dashboard-card-detail">' + esc(detail) + '</div>' +
|
||||||
'</div>';
|
'</div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,74 +1,38 @@
|
|||||||
/* ICD Test Runner — Surface Styles */
|
/* ICD Test Runner — Surface Styles */
|
||||||
|
|
||||||
#extension-mount {
|
.ext-icd-test-runner-root {
|
||||||
font-family: var(--font, 'DM Sans', sans-serif);
|
font-family: var(--font, 'DM Sans', sans-serif);
|
||||||
}
|
}
|
||||||
|
|
||||||
#extension-mount h1 {
|
.ext-icd-test-runner-root h1 {
|
||||||
font-family: var(--font, 'DM Sans', sans-serif);
|
font-family: var(--font, 'DM Sans', sans-serif);
|
||||||
letter-spacing: -0.3px;
|
letter-spacing: -0.3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#extension-mount table {
|
.ext-icd-test-runner-root table {
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
|
||||||
#extension-mount table td,
|
.ext-icd-test-runner-root table td,
|
||||||
#extension-mount table th {
|
.ext-icd-test-runner-root table th {
|
||||||
border-color: var(--border);
|
border-color: var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
#extension-mount table tbody tr:last-child {
|
.ext-icd-test-runner-root table tbody tr:last-child {
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
#extension-mount table tbody tr:hover {
|
.ext-icd-test-runner-root table tbody tr:hover {
|
||||||
background: var(--bg-hover) !important;
|
background: var(--bg-hover) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Buttons — override small padding for our use */
|
/* Buttons — uses kernel sw-btn system, minor overrides for weight */
|
||||||
#extension-mount .btn-primary,
|
.ext-icd-test-runner-root .sw-btn {
|
||||||
#extension-mount .btn-secondary,
|
|
||||||
#extension-mount .btn-ghost {
|
|
||||||
font-size: 13px;
|
|
||||||
padding: 7px 16px;
|
|
||||||
border-radius: var(--radius, 8px);
|
|
||||||
cursor: pointer;
|
|
||||||
font-family: var(--font, 'DM Sans', sans-serif);
|
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
transition: background var(--transition, 180ms ease), opacity var(--transition, 180ms ease);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Fallback button styles if platform classes not loaded */
|
|
||||||
#extension-mount button.btn-primary {
|
|
||||||
background: var(--accent, #6c9fff);
|
|
||||||
color: #fff;
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
#extension-mount button.btn-primary:hover {
|
|
||||||
opacity: 0.88;
|
|
||||||
}
|
|
||||||
|
|
||||||
#extension-mount button.btn-secondary {
|
|
||||||
background: var(--accent-dim, rgba(108,159,255,0.12));
|
|
||||||
color: var(--accent, #6c9fff);
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
#extension-mount button.btn-secondary:hover {
|
|
||||||
background: var(--accent-dim, rgba(108,159,255,0.2));
|
|
||||||
}
|
|
||||||
|
|
||||||
#extension-mount button.btn-ghost {
|
|
||||||
background: transparent;
|
|
||||||
color: var(--text-2, #9898a8);
|
|
||||||
border: 1px solid var(--border, #2e2e35);
|
|
||||||
}
|
|
||||||
#extension-mount button.btn-ghost:hover {
|
|
||||||
background: var(--bg-hover, #2a2a30);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Status dot animation for running state */
|
/* Status dot animation for running state */
|
||||||
@keyframes pulse-dot {
|
@keyframes ext-icd-test-runner-pulse-dot {
|
||||||
0%, 100% { opacity: 1; }
|
0%, 100% { opacity: 1; }
|
||||||
50% { opacity: 0.4; }
|
50% { opacity: 0.4; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
type: 'full',
|
type: 'full',
|
||||||
version: '0.31.1',
|
version: '0.31.1',
|
||||||
tier: 'browser',
|
tier: 'browser',
|
||||||
author: 'Switchboard Core',
|
author: 'Armature',
|
||||||
description: 'Project dashboard exercising all SDK primitives',
|
description: 'Project dashboard exercising all SDK primitives',
|
||||||
route: '/s/dashboard',
|
route: '/s/dashboard',
|
||||||
permissions: [],
|
permissions: [],
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
type: 'full',
|
type: 'full',
|
||||||
version: '0.31.0',
|
version: '0.31.0',
|
||||||
tier: 'browser',
|
tier: 'browser',
|
||||||
author: 'Switchboard Core',
|
author: 'Armature',
|
||||||
description: 'Code editor with workspace management',
|
description: 'Code editor with workspace management',
|
||||||
route: '/s/editor',
|
route: '/s/editor',
|
||||||
layout: 'editor',
|
layout: 'editor',
|
||||||
|
|||||||
@@ -14,9 +14,9 @@
|
|||||||
var resp = await fetch(T.base + '/metrics');
|
var resp = await fetch(T.base + '/metrics');
|
||||||
T.assert(resp.ok, 'expected 200 from /metrics, got ' + resp.status);
|
T.assert(resp.ok, 'expected 200 from /metrics, got ' + resp.status);
|
||||||
var text = await resp.text();
|
var text = await resp.text();
|
||||||
T.assert(text.indexOf('switchboard_http_requests_total') !== -1, 'expected switchboard_http_requests_total in /metrics');
|
T.assert(text.indexOf('armature_http_requests_total') !== -1, 'expected armature_http_requests_total in /metrics');
|
||||||
T.assert(text.indexOf('switchboard_http_request_duration_seconds') !== -1, 'expected switchboard_http_request_duration_seconds in /metrics');
|
T.assert(text.indexOf('armature_http_request_duration_seconds') !== -1, 'expected armature_http_request_duration_seconds in /metrics');
|
||||||
T.assert(text.indexOf('switchboard_websocket_connections') !== -1, 'expected switchboard_websocket_connections in /metrics');
|
T.assert(text.indexOf('armature_websocket_connections') !== -1, 'expected armature_websocket_connections in /metrics');
|
||||||
});
|
});
|
||||||
|
|
||||||
// -- GET /api/docs (Swagger UI) --
|
// -- GET /api/docs (Swagger UI) --
|
||||||
|
|||||||
@@ -270,7 +270,7 @@
|
|||||||
|
|
||||||
function getAuthTokenFromStorage() {
|
function getAuthTokenFromStorage() {
|
||||||
var env = (T.base || '').replace(/^\//, '') || 'default';
|
var env = (T.base || '').replace(/^\//, '') || 'default';
|
||||||
var keys = ['sb_auth_' + env, 'sb_auth', 'sb_token'];
|
var keys = ['arm_auth_' + env, 'arm_auth', 'arm_token'];
|
||||||
for (var i = 0; i < keys.length; i++) {
|
for (var i = 0; i < keys.length; i++) {
|
||||||
try {
|
try {
|
||||||
var raw = localStorage.getItem(keys[i]);
|
var raw = localStorage.getItem(keys[i]);
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
* 8. tier-security.js — Adversarial red-team tests (auth, cross-tenant, input validation)
|
* 8. tier-security.js — Adversarial red-team tests (auth, cross-tenant, input validation)
|
||||||
* 9. tier-providers.js — Three-tier provider CRUD + live completions
|
* 9. tier-providers.js — Three-tier provider CRUD + live completions
|
||||||
* 10. tier-packaging.js — Extension permission lifecycle + secrets (v0.29.0)
|
* 10. tier-packaging.js — Extension permission lifecycle + secrets (v0.29.0)
|
||||||
* 11. tier-sdk.js — Switchboard SDK contract tests
|
* 11. tier-sdk.js — Armature SDK contract tests
|
||||||
* 12. ui.js — Render functions, export, provider setup panel
|
* 12. ui.js — Render functions, export, provider setup panel
|
||||||
* 11. (this file) — Boot
|
* 11. (this file) — Boot
|
||||||
*/
|
*/
|
||||||
@@ -24,6 +24,7 @@
|
|||||||
|
|
||||||
var mount = document.getElementById('extension-mount');
|
var mount = document.getElementById('extension-mount');
|
||||||
if (!mount) return;
|
if (!mount) return;
|
||||||
|
mount.classList.add('ext-icd-test-runner-root');
|
||||||
|
|
||||||
// ─── Boot Preact SDK (v0.37.14) ───────────────────────────
|
// ─── Boot Preact SDK (v0.37.14) ───────────────────────────
|
||||||
// Extension surfaces don't load Preact vendors or the SDK.
|
// Extension surfaces don't load Preact vendors or the SDK.
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
* Validates Preact SDK (sw/sdk/index.js) contract: boot, identity,
|
* Validates Preact SDK (sw/sdk/index.js) contract: boot, identity,
|
||||||
* REST client, events, theme, pipe registration, execution ordering,
|
* REST client, events, theme, pipe registration, execution ordering,
|
||||||
* scoping, halt semantics, error isolation, introspection.
|
* scoping, halt semantics, error isolation, introspection.
|
||||||
* v0.37.14: Updated from Switchboard.init() to boot() API.
|
* v0.37.14: Updated from Armature.init() to boot() API.
|
||||||
*/
|
*/
|
||||||
(function () {
|
(function () {
|
||||||
'use strict';
|
'use strict';
|
||||||
@@ -337,7 +337,7 @@
|
|||||||
await T.test('sdk', 'pipe-render', 'Render filter modifies DOM', async function () {
|
await T.test('sdk', 'pipe-render', 'Render filter modifies DOM', async function () {
|
||||||
sw.pipe.render(1, function (ctx) {
|
sw.pipe.render(1, function (ctx) {
|
||||||
if (ctx.element.dataset.sdkRenderTest) {
|
if (ctx.element.dataset.sdkRenderTest) {
|
||||||
ctx.element.classList.add('sdk-render-modified');
|
ctx.element.classList.add('ext-icd-test-runner-sdk-render-modified');
|
||||||
}
|
}
|
||||||
return ctx;
|
return ctx;
|
||||||
}, { source: '_test-render-dom' });
|
}, { source: '_test-render-dom' });
|
||||||
@@ -348,7 +348,7 @@
|
|||||||
element: el, message: null,
|
element: el, message: null,
|
||||||
channel: { id: 'fake', type: 'direct' }
|
channel: { id: 'fake', type: 'direct' }
|
||||||
});
|
});
|
||||||
T.assert(el.classList.contains('sdk-render-modified'), 'element should have class');
|
T.assert(el.classList.contains('ext-icd-test-runner-sdk-render-modified'), 'element should have class');
|
||||||
});
|
});
|
||||||
|
|
||||||
await T.test('sdk', 'pipe-render', 'Priority ordering in render chain', async function () {
|
await T.test('sdk', 'pipe-render', 'Priority ordering in render chain', async function () {
|
||||||
|
|||||||
@@ -93,7 +93,7 @@
|
|||||||
|
|
||||||
// Configure button
|
// Configure button
|
||||||
var cfgBtn = $('button', {
|
var cfgBtn = $('button', {
|
||||||
className: T.providerSetup.configured ? 'btn-ghost' : 'btn-primary',
|
className: T.providerSetup.configured ? 'sw-btn sw-btn--ghost sw-btn--sm' : 'sw-btn sw-btn--primary sw-btn--md',
|
||||||
style: { alignSelf: 'flex-end', whiteSpace: 'nowrap' },
|
style: { alignSelf: 'flex-end', whiteSpace: 'nowrap' },
|
||||||
onClick: function () {
|
onClick: function () {
|
||||||
if (!T.providerSetup.apiKey) {
|
if (!T.providerSetup.apiKey) {
|
||||||
@@ -110,7 +110,7 @@
|
|||||||
|
|
||||||
if (T.providerSetup.configured) {
|
if (T.providerSetup.configured) {
|
||||||
var clearBtn = $('button', {
|
var clearBtn = $('button', {
|
||||||
className: 'btn-ghost',
|
className: 'sw-btn sw-btn--ghost sw-btn--sm',
|
||||||
style: { alignSelf: 'flex-end', color: 'var(--danger)', whiteSpace: 'nowrap' },
|
style: { alignSelf: 'flex-end', color: 'var(--danger)', whiteSpace: 'nowrap' },
|
||||||
onClick: function () {
|
onClick: function () {
|
||||||
T.providerSetup.apiKey = '';
|
T.providerSetup.apiKey = '';
|
||||||
@@ -189,19 +189,19 @@
|
|||||||
T.renderControls = function () {
|
T.renderControls = function () {
|
||||||
T.el.controls.innerHTML = '';
|
T.el.controls.innerHTML = '';
|
||||||
var btnSmoke = $('button', {
|
var btnSmoke = $('button', {
|
||||||
className: 'btn-primary',
|
className: 'sw-btn sw-btn--primary sw-btn--md',
|
||||||
onClick: function () { T.runSuite('smoke'); }
|
onClick: function () { T.runSuite('smoke'); }
|
||||||
}, 'Smoke');
|
}, 'Smoke');
|
||||||
var btnCrud = $('button', {
|
var btnCrud = $('button', {
|
||||||
className: 'btn-secondary',
|
className: 'sw-btn sw-btn--secondary sw-btn--md',
|
||||||
onClick: function () { T.runSuite('crud'); }
|
onClick: function () { T.runSuite('crud'); }
|
||||||
}, 'CRUD');
|
}, 'CRUD');
|
||||||
var btnAll = $('button', {
|
var btnAll = $('button', {
|
||||||
className: 'btn-primary',
|
className: 'sw-btn sw-btn--primary sw-btn--md',
|
||||||
onClick: function () { T.runSuite('all'); }
|
onClick: function () { T.runSuite('all'); }
|
||||||
}, 'Run All');
|
}, 'Run All');
|
||||||
var btnClear = $('button', {
|
var btnClear = $('button', {
|
||||||
className: 'btn-ghost',
|
className: 'sw-btn sw-btn--ghost sw-btn--sm',
|
||||||
onClick: function () { T.results = []; T.renderProgress(); T.renderSummary(); T.renderDetail(); }
|
onClick: function () { T.results = []; T.renderProgress(); T.renderSummary(); T.renderDetail(); }
|
||||||
}, 'Clear');
|
}, 'Clear');
|
||||||
T.el.controls.appendChild(btnSmoke);
|
T.el.controls.appendChild(btnSmoke);
|
||||||
@@ -210,14 +210,14 @@
|
|||||||
// AuthZ button — only useful if admin (needs fixtures)
|
// AuthZ button — only useful if admin (needs fixtures)
|
||||||
if (T.user.role === 'admin') {
|
if (T.user.role === 'admin') {
|
||||||
var btnAuthz = $('button', {
|
var btnAuthz = $('button', {
|
||||||
className: T.fixtures.ready ? 'btn-secondary' : 'btn-ghost',
|
className: T.fixtures.ready ? 'sw-btn sw-btn--secondary sw-btn--md' : 'sw-btn sw-btn--ghost sw-btn--sm',
|
||||||
style: { opacity: T.fixtures.ready ? '1' : '0.5' },
|
style: { opacity: T.fixtures.ready ? '1' : '0.5' },
|
||||||
onClick: function () { T.runSuite('authz'); }
|
onClick: function () { T.runSuite('authz'); }
|
||||||
}, 'AuthZ');
|
}, 'AuthZ');
|
||||||
T.el.controls.appendChild(btnAuthz);
|
T.el.controls.appendChild(btnAuthz);
|
||||||
|
|
||||||
var btnSecurity = $('button', {
|
var btnSecurity = $('button', {
|
||||||
className: T.fixtures.ready ? 'btn-secondary' : 'btn-ghost',
|
className: T.fixtures.ready ? 'sw-btn sw-btn--secondary sw-btn--md' : 'sw-btn sw-btn--ghost sw-btn--sm',
|
||||||
style: {
|
style: {
|
||||||
opacity: T.fixtures.ready ? '1' : '0.5',
|
opacity: T.fixtures.ready ? '1' : '0.5',
|
||||||
borderColor: T.fixtures.ready ? 'var(--danger)' : undefined,
|
borderColor: T.fixtures.ready ? 'var(--danger)' : undefined,
|
||||||
@@ -230,15 +230,15 @@
|
|||||||
|
|
||||||
// Provider tier button — needs provider setup
|
// Provider tier button — needs provider setup
|
||||||
var btnProv = $('button', {
|
var btnProv = $('button', {
|
||||||
className: T.providerSetup.configured ? 'btn-secondary' : 'btn-ghost',
|
className: T.providerSetup.configured ? 'sw-btn sw-btn--secondary sw-btn--md' : 'sw-btn sw-btn--ghost sw-btn--sm',
|
||||||
style: { opacity: T.providerSetup.configured ? '1' : '0.5' },
|
style: { opacity: T.providerSetup.configured ? '1' : '0.5' },
|
||||||
onClick: function () { T.runSuite('provider'); }
|
onClick: function () { T.runSuite('provider'); }
|
||||||
}, 'Providers');
|
}, 'Providers');
|
||||||
T.el.controls.appendChild(btnProv);
|
T.el.controls.appendChild(btnProv);
|
||||||
|
|
||||||
// SDK tier button — tests switchboard-sdk.js contract
|
// SDK tier button — tests armature-sdk.js contract
|
||||||
var btnSdk = $('button', {
|
var btnSdk = $('button', {
|
||||||
className: (typeof window.sw !== 'undefined') ? 'btn-secondary' : 'btn-ghost',
|
className: (typeof window.sw !== 'undefined') ? 'sw-btn sw-btn--secondary sw-btn--md' : 'sw-btn sw-btn--ghost sw-btn--sm',
|
||||||
style: { opacity: (typeof window.sw !== 'undefined') ? '1' : '0.5' },
|
style: { opacity: (typeof window.sw !== 'undefined') ? '1' : '0.5' },
|
||||||
onClick: function () { T.runSuite('sdk'); }
|
onClick: function () { T.runSuite('sdk'); }
|
||||||
}, 'SDK');
|
}, 'SDK');
|
||||||
@@ -246,7 +246,7 @@
|
|||||||
|
|
||||||
// Packaging tier button — extension permission lifecycle (v0.29.0)
|
// Packaging tier button — extension permission lifecycle (v0.29.0)
|
||||||
var btnPkg = $('button', {
|
var btnPkg = $('button', {
|
||||||
className: 'btn-secondary',
|
className: 'sw-btn sw-btn--secondary sw-btn--md',
|
||||||
onClick: function () { T.runSuite('packaging'); }
|
onClick: function () { T.runSuite('packaging'); }
|
||||||
}, 'Packaging');
|
}, 'Packaging');
|
||||||
T.el.controls.appendChild(btnPkg);
|
T.el.controls.appendChild(btnPkg);
|
||||||
@@ -259,11 +259,11 @@
|
|||||||
|
|
||||||
// Export buttons
|
// Export buttons
|
||||||
var btnCopy = $('button', {
|
var btnCopy = $('button', {
|
||||||
className: 'btn-ghost',
|
className: 'sw-btn sw-btn--ghost sw-btn--sm',
|
||||||
onClick: function () { T.exportReport('clipboard'); }
|
onClick: function () { T.exportReport('clipboard'); }
|
||||||
}, 'Copy Report');
|
}, 'Copy Report');
|
||||||
var btnDownload = $('button', {
|
var btnDownload = $('button', {
|
||||||
className: 'btn-ghost',
|
className: 'sw-btn sw-btn--ghost sw-btn--sm',
|
||||||
onClick: function () { T.exportReport('download'); }
|
onClick: function () { T.exportReport('download'); }
|
||||||
}, 'Download');
|
}, 'Download');
|
||||||
T.el.controls.appendChild(btnCopy);
|
T.el.controls.appendChild(btnCopy);
|
||||||
@@ -274,12 +274,12 @@
|
|||||||
T.el.controls.appendChild($('div', { style: { width: '1px', height: '28px', background: 'var(--border)', flexShrink: '0' } }));
|
T.el.controls.appendChild($('div', { style: { width: '1px', height: '28px', background: 'var(--border)', flexShrink: '0' } }));
|
||||||
if (!T.fixtures.ready) {
|
if (!T.fixtures.ready) {
|
||||||
T.el.controls.appendChild($('button', {
|
T.el.controls.appendChild($('button', {
|
||||||
className: 'btn-secondary',
|
className: 'sw-btn sw-btn--secondary sw-btn--md',
|
||||||
onClick: async function () { await T.provisionFixtures(); T.renderControls(); }
|
onClick: async function () { await T.provisionFixtures(); T.renderControls(); }
|
||||||
}, 'Provision Test Users'));
|
}, 'Provision Test Users'));
|
||||||
} else {
|
} else {
|
||||||
T.el.controls.appendChild($('button', {
|
T.el.controls.appendChild($('button', {
|
||||||
className: 'btn-ghost',
|
className: 'sw-btn sw-btn--ghost sw-btn--sm',
|
||||||
style: { color: 'var(--danger)' },
|
style: { color: 'var(--danger)' },
|
||||||
onClick: async function () { await T.teardownFixtures(); T.renderControls(); }
|
onClick: async function () { await T.teardownFixtures(); T.renderControls(); }
|
||||||
}, 'Tear Down Fixtures'));
|
}, 'Tear Down Fixtures'));
|
||||||
@@ -368,7 +368,7 @@
|
|||||||
var totalMs = T.results.reduce(function (s, r) { return s + r.duration; }, 0);
|
var totalMs = T.results.reduce(function (s, r) { return s + r.duration; }, 0);
|
||||||
|
|
||||||
var lines = [];
|
var lines = [];
|
||||||
lines.push('=== Switchboard Core ICD Test Report ===');
|
lines.push('=== Armature ICD Test Report ===');
|
||||||
lines.push('Generated: ' + now);
|
lines.push('Generated: ' + now);
|
||||||
lines.push('Platform: v' + (T.manifest.version || '0.28.0'));
|
lines.push('Platform: v' + (T.manifest.version || '0.28.0'));
|
||||||
lines.push('User: ' + T.user.username + ' (' + T.user.role + ')');
|
lines.push('User: ' + T.user.username + ' (' + T.user.role + ')');
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"type": "extension",
|
"type": "extension",
|
||||||
"tier": "browser",
|
"tier": "browser",
|
||||||
"author": "switchboard",
|
"author": "armature",
|
||||||
"description": "Executes JavaScript in a sandboxed iframe. Allows the LLM to run code, verify calculations, and transform data — no server compute required.",
|
"description": "Executes JavaScript in a sandboxed iframe. Allows the LLM to run code, verify calculations, and transform data — no server compute required.",
|
||||||
"requires": ["chat"],
|
"requires": ["chat"],
|
||||||
"permissions": [],
|
"permissions": [],
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"type": "extension",
|
"type": "extension",
|
||||||
"tier": "browser",
|
"tier": "browser",
|
||||||
"author": "switchboard",
|
"author": "armature",
|
||||||
"description": "Renders LaTeX math expressions: ```latex blocks and inline $...$ / $$...$$ syntax",
|
"description": "Renders LaTeX math expressions: ```latex blocks and inline $...$ / $$...$$ syntax",
|
||||||
"requires": [],
|
"requires": [],
|
||||||
"permissions": [],
|
"permissions": [],
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"type": "extension",
|
"type": "extension",
|
||||||
"tier": "browser",
|
"tier": "browser",
|
||||||
"author": "switchboard",
|
"author": "armature",
|
||||||
"description": "Renders ```mermaid code blocks as interactive SVG diagrams with zoom/pan, SVG/PNG export, and source copy",
|
"description": "Renders ```mermaid code blocks as interactive SVG diagrams with zoom/pan, SVG/PNG export, and source copy",
|
||||||
"requires": [],
|
"requires": [],
|
||||||
"permissions": [],
|
"permissions": [],
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
/* ── Notes Surface Styles ─────────────────── */
|
/* ── Notes Surface Styles ─────────────────── */
|
||||||
|
|
||||||
.notes-app {
|
.ext-notes-app {
|
||||||
display: flex;
|
display: flex;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Sidebar ─────────────────────────────── */
|
/* ── Sidebar ─────────────────────────────── */
|
||||||
.notes-sidebar {
|
.ext-notes-sidebar {
|
||||||
width: 280px;
|
width: 280px;
|
||||||
min-width: 220px;
|
min-width: 220px;
|
||||||
border-right: 1px solid var(--border);
|
border-right: 1px solid var(--border);
|
||||||
@@ -16,106 +16,106 @@
|
|||||||
background: var(--bg-raised);
|
background: var(--bg-raised);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.notes-sidebar__header {
|
.ext-notes-sidebar__header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: var(--sp-2);
|
||||||
padding: 12px 14px;
|
padding: var(--sp-3) var(--sp-4);
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.notes-sidebar__title {
|
.ext-notes-sidebar__title {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
.notes-sidebar__count {
|
.ext-notes-sidebar__count {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
}
|
}
|
||||||
.notes-sidebar__actions {
|
.ext-notes-sidebar__actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 4px;
|
gap: var(--sp-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Folder Tree ────────────────────────── */
|
/* ── Folder Tree ────────────────────────── */
|
||||||
.folder-tree {
|
.ext-notes-folder-tree {
|
||||||
padding: 4px 0;
|
padding: var(--sp-1) 0;
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
max-height: 40%;
|
max-height: 40%;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
.folder-tree__item {
|
.ext-notes-folder-tree__item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: var(--sp-1);
|
||||||
padding: 5px 12px;
|
padding: 5px var(--sp-3);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--text-2);
|
color: var(--text-2);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
margin: 1px 6px;
|
margin: 1px var(--sp-2);
|
||||||
transition: var(--transition);
|
transition: var(--transition);
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
.folder-tree__item:hover {
|
.ext-notes-folder-tree__item:hover {
|
||||||
background: var(--bg-hover);
|
background: var(--bg-hover);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
.folder-tree__item--active {
|
.ext-notes-folder-tree__item--active {
|
||||||
background: var(--bg-active);
|
background: var(--bg-active);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
.folder-tree__toggle {
|
.ext-notes-folder-tree__toggle {
|
||||||
width: 14px;
|
width: 14px;
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
}
|
}
|
||||||
.folder-tree__icon {
|
.ext-notes-folder-tree__icon {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.folder-tree__name {
|
.ext-notes-folder-tree__name {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
.folder-tree__edit {
|
.ext-notes-folder-tree__edit {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
padding: 1px 4px;
|
padding: 1px var(--sp-1);
|
||||||
border: 1px solid var(--accent);
|
border: 1px solid var(--accent);
|
||||||
border-radius: 3px;
|
border-radius: var(--radius-sm);
|
||||||
background: var(--bg-surface);
|
background: var(--bg-surface);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-family: var(--font);
|
font-family: var(--font);
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
.folder-tree__add {
|
.ext-notes-folder-tree__add {
|
||||||
padding: 6px 12px;
|
padding: var(--sp-2) var(--sp-3);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: var(--transition);
|
transition: var(--transition);
|
||||||
}
|
}
|
||||||
.folder-tree__add:hover {
|
.ext-notes-folder-tree__add:hover {
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Search ──────────────────────────────── */
|
/* ── Search ──────────────────────────────── */
|
||||||
.notes-search {
|
.ext-notes-search {
|
||||||
padding: 8px 14px;
|
padding: var(--sp-2) var(--sp-4);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.notes-search input {
|
.ext-notes-search input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 6px 10px;
|
padding: var(--sp-2) var(--sp-3);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
background: var(--bg-surface);
|
background: var(--bg-surface);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
@@ -123,63 +123,63 @@
|
|||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
font-family: var(--font);
|
font-family: var(--font);
|
||||||
}
|
}
|
||||||
.notes-search input:focus {
|
.ext-notes-search input:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
}
|
}
|
||||||
.notes-search input::placeholder {
|
.ext-notes-search input::placeholder {
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Note List ───────────────────────────── */
|
/* ── Note List ───────────────────────────── */
|
||||||
.notes-list {
|
.ext-notes-list {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 4px 8px;
|
padding: var(--sp-1) var(--sp-2);
|
||||||
}
|
}
|
||||||
.notes-list__empty {
|
.ext-notes-list__empty {
|
||||||
padding: 40px 16px;
|
padding: var(--sp-10) var(--sp-4);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
.notes-list__loading {
|
.ext-notes-list__loading {
|
||||||
padding: 40px 0;
|
padding: var(--sp-10) 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Note Card (sidebar item) ────────────── */
|
/* ── Note Card (sidebar item) ────────────── */
|
||||||
.note-card {
|
.ext-notes-note-card {
|
||||||
padding: 10px 12px;
|
padding: var(--sp-3) var(--sp-3);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: var(--transition);
|
transition: var(--transition);
|
||||||
margin-bottom: 2px;
|
margin-bottom: 2px;
|
||||||
}
|
}
|
||||||
.note-card:hover {
|
.ext-notes-note-card:hover {
|
||||||
background: var(--bg-hover);
|
background: var(--bg-hover);
|
||||||
}
|
}
|
||||||
.note-card--active {
|
.ext-notes-note-card--active {
|
||||||
background: var(--bg-active);
|
background: var(--bg-active);
|
||||||
}
|
}
|
||||||
.note-card__title {
|
.ext-notes-note-card__title {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: var(--sp-2);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.note-card__pin {
|
.ext-notes-note-card__pin {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.note-card__snippet {
|
.ext-notes-note-card__snippet {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
margin-top: 3px;
|
margin-top: 3px;
|
||||||
@@ -188,21 +188,21 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
.note-card__date {
|
.ext-notes-note-card__date {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
margin-top: 3px;
|
margin-top: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Editor Pane ─────────────────────────── */
|
/* ── Editor Pane ─────────────────────────── */
|
||||||
.notes-editor {
|
.ext-notes-editor {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.notes-editor__empty {
|
.ext-notes-editor__empty {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -212,15 +212,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ── Editor Header ───────────────────────── */
|
/* ── Editor Header ───────────────────────── */
|
||||||
.notes-editor__header {
|
.ext-notes-editor__header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: var(--sp-2);
|
||||||
padding: 10px 20px;
|
padding: var(--sp-3) var(--sp-5);
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.notes-editor__title-input {
|
.ext-notes-editor__title-input {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -228,22 +228,22 @@
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
outline: none;
|
outline: none;
|
||||||
padding: 4px 0;
|
padding: var(--sp-1) 0;
|
||||||
font-family: var(--font);
|
font-family: var(--font);
|
||||||
}
|
}
|
||||||
.notes-editor__title-input::placeholder {
|
.ext-notes-editor__title-input::placeholder {
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
}
|
}
|
||||||
.notes-editor__actions {
|
.ext-notes-editor__actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 6px;
|
gap: var(--sp-2);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Folder select in editor ────────────── */
|
/* ── Folder select in editor ────────────── */
|
||||||
.notes-editor__folder-select {
|
.ext-notes-editor__folder-select {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
padding: 3px 6px;
|
padding: 3px var(--sp-2);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
background: var(--bg-raised);
|
background: var(--bg-raised);
|
||||||
@@ -252,13 +252,13 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
max-width: 140px;
|
max-width: 140px;
|
||||||
}
|
}
|
||||||
.notes-editor__folder-select:focus {
|
.ext-notes-editor__folder-select:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Editor Content (body + outline wrapper) */
|
/* ── Editor Content (body + outline wrapper) */
|
||||||
.notes-editor__content {
|
.ext-notes-editor__content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
@@ -266,23 +266,23 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ── Editor Body ─────────────────────────── */
|
/* ── Editor Body ─────────────────────────── */
|
||||||
.notes-editor__body {
|
.ext-notes-editor__body {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.notes-editor__body--split {
|
.ext-notes-editor__body--split {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
}
|
}
|
||||||
.notes-editor__body--split .notes-preview {
|
.ext-notes-editor__body--split .ext-notes-preview {
|
||||||
border-left: 1px solid var(--border);
|
border-left: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
.notes-editor__textarea {
|
.ext-notes-editor__textarea {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
resize: none;
|
resize: none;
|
||||||
padding: 20px;
|
padding: var(--sp-5);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
@@ -292,79 +292,79 @@
|
|||||||
font-family: var(--mono);
|
font-family: var(--mono);
|
||||||
tab-size: 2;
|
tab-size: 2;
|
||||||
}
|
}
|
||||||
.notes-editor__textarea::placeholder {
|
.ext-notes-editor__textarea::placeholder {
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── CodeMirror 6 container ─────────────── */
|
/* ── CodeMirror 6 container ─────────────── */
|
||||||
.notes-editor__cm {
|
.ext-notes-editor__cm {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.notes-editor__cm .cm-editor {
|
.ext-notes-editor__cm .cm-editor {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
max-height: none;
|
max-height: none;
|
||||||
}
|
}
|
||||||
.notes-editor__cm .cm-scroller {
|
.ext-notes-editor__cm .cm-scroller {
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
padding: 12px 20px;
|
padding: var(--sp-3) var(--sp-5);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Preview ─────────────────────────────── */
|
/* ── Preview ─────────────────────────────── */
|
||||||
.notes-preview {
|
.ext-notes-preview {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 20px;
|
padding: var(--sp-5);
|
||||||
border-left: 1px solid var(--border);
|
border-left: 1px solid var(--border);
|
||||||
background: var(--bg-surface);
|
background: var(--bg-surface);
|
||||||
}
|
}
|
||||||
.notes-preview h1 { font-size: 24px; font-weight: 700; color: var(--text); margin: 0 0 12px; }
|
.ext-notes-preview h1 { font-size: 24px; font-weight: 700; color: var(--text); margin: 0 0 var(--sp-3); }
|
||||||
.notes-preview h2 { font-size: 20px; font-weight: 600; color: var(--text); margin: 20px 0 8px; }
|
.ext-notes-preview h2 { font-size: 20px; font-weight: 600; color: var(--text); margin: var(--sp-5) 0 var(--sp-2); }
|
||||||
.notes-preview h3 { font-size: 16px; font-weight: 600; color: var(--text); margin: 16px 0 6px; }
|
.ext-notes-preview h3 { font-size: 16px; font-weight: 600; color: var(--text); margin: var(--sp-4) 0 var(--sp-2); }
|
||||||
.notes-preview p { font-size: 14px; color: var(--text); line-height: 1.7; margin: 0 0 10px; }
|
.ext-notes-preview p { font-size: 14px; color: var(--text); line-height: 1.7; margin: 0 0 var(--sp-3); }
|
||||||
.notes-preview ul, .notes-preview ol { padding-left: 24px; margin: 0 0 10px; color: var(--text); font-size: 14px; line-height: 1.7; }
|
.ext-notes-preview ul, .ext-notes-preview ol { padding-left: var(--sp-6); margin: 0 0 var(--sp-3); color: var(--text); font-size: 14px; line-height: 1.7; }
|
||||||
.notes-preview li { margin-bottom: 2px; }
|
.ext-notes-preview li { margin-bottom: 2px; }
|
||||||
.notes-preview code {
|
.ext-notes-preview code {
|
||||||
font-family: var(--mono);
|
font-family: var(--mono);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
background: var(--bg-raised);
|
background: var(--bg-raised);
|
||||||
padding: 2px 6px;
|
padding: 2px var(--sp-2);
|
||||||
border-radius: 3px;
|
border-radius: var(--radius-sm);
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
.notes-preview pre {
|
.ext-notes-preview pre {
|
||||||
background: var(--bg-raised);
|
background: var(--bg-raised);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
padding: 14px;
|
padding: var(--sp-4);
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
margin: 0 0 12px;
|
margin: 0 0 var(--sp-3);
|
||||||
}
|
}
|
||||||
.notes-preview pre code {
|
.ext-notes-preview pre code {
|
||||||
background: none;
|
background: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
.notes-preview blockquote {
|
.ext-notes-preview blockquote {
|
||||||
border-left: 3px solid var(--accent);
|
border-left: 3px solid var(--accent);
|
||||||
margin: 0 0 12px;
|
margin: 0 0 var(--sp-3);
|
||||||
padding: 4px 16px;
|
padding: var(--sp-1) var(--sp-4);
|
||||||
color: var(--text-2);
|
color: var(--text-2);
|
||||||
}
|
}
|
||||||
.notes-preview a { color: var(--accent); text-decoration: none; }
|
.ext-notes-preview a { color: var(--accent); text-decoration: none; }
|
||||||
.notes-preview a:hover { text-decoration: underline; }
|
.ext-notes-preview a:hover { text-decoration: underline; }
|
||||||
.notes-preview hr {
|
.ext-notes-preview hr {
|
||||||
border: none;
|
border: none;
|
||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid var(--border);
|
||||||
margin: 16px 0;
|
margin: var(--sp-4) 0;
|
||||||
}
|
}
|
||||||
.notes-preview strong { font-weight: 600; }
|
.ext-notes-preview strong { font-weight: 600; }
|
||||||
.notes-preview em { font-style: italic; }
|
.ext-notes-preview em { font-style: italic; }
|
||||||
|
|
||||||
/* ── Toggle button ───────────────────────── */
|
/* ── Toggle button ───────────────────────── */
|
||||||
.notes-toggle {
|
.ext-notes-toggle {
|
||||||
padding: 4px 10px;
|
padding: var(--sp-1) var(--sp-3);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
background: var(--bg-raised);
|
background: var(--bg-raised);
|
||||||
@@ -373,43 +373,43 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: var(--transition);
|
transition: var(--transition);
|
||||||
}
|
}
|
||||||
.notes-toggle:hover { background: var(--bg-hover); color: var(--text); }
|
.ext-notes-toggle:hover { background: var(--bg-hover); color: var(--text); }
|
||||||
.notes-toggle--active { background: var(--accent); color: #fff; border-color: var(--accent); }
|
.ext-notes-toggle--active { background: var(--accent); color: #fff; border-color: var(--accent); }
|
||||||
|
|
||||||
/* ── Inline buttons ──────────────────────── */
|
/* ── Inline buttons ──────────────────────── */
|
||||||
.notes-btn {
|
.ext-notes-btn {
|
||||||
border: none;
|
border: none;
|
||||||
background: var(--bg-raised);
|
background: var(--bg-raised);
|
||||||
color: var(--text-2);
|
color: var(--text-2);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
transition: var(--transition);
|
transition: var(--transition);
|
||||||
padding: 4px 10px;
|
padding: var(--sp-1) var(--sp-3);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
.notes-btn:hover { background: var(--bg-hover); color: var(--text); }
|
.ext-notes-btn:hover { background: var(--bg-hover); color: var(--text); }
|
||||||
.notes-btn--danger:hover { background: var(--danger-dim); color: var(--danger); }
|
.ext-notes-btn--danger:hover { background: var(--danger-dim); color: var(--danger); }
|
||||||
.notes-btn--accent { background: var(--accent); color: #fff; }
|
.ext-notes-btn--accent { background: var(--accent); color: #fff; }
|
||||||
.notes-btn--accent:hover { opacity: 0.9; color: #fff; }
|
.ext-notes-btn--accent:hover { opacity: 0.9; color: #fff; }
|
||||||
|
|
||||||
/* ── Saved indicator ─────────────────────── */
|
/* ── Saved indicator ─────────────────────── */
|
||||||
.notes-saved {
|
.ext-notes-saved {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
padding: 2px 8px;
|
padding: 2px var(--sp-2);
|
||||||
}
|
}
|
||||||
.notes-saved--dirty {
|
.ext-notes-saved--dirty {
|
||||||
color: var(--warning);
|
color: var(--warning);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Tag Pills (shared) ─────────────────── */
|
/* ── Tag Pills (shared) ─────────────────── */
|
||||||
.tag-pill {
|
.ext-notes-tag-pill {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding: 1px 8px;
|
padding: 1px var(--sp-2);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
background: var(--bg-raised);
|
background: var(--bg-raised);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 10px;
|
border-radius: var(--radius-lg);
|
||||||
color: var(--text-2);
|
color: var(--text-2);
|
||||||
margin: 2px 2px;
|
margin: 2px 2px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -417,23 +417,23 @@
|
|||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
transition: var(--transition);
|
transition: var(--transition);
|
||||||
}
|
}
|
||||||
.tag-pill:hover { background: var(--bg-hover); color: var(--text); }
|
.ext-notes-tag-pill:hover { background: var(--bg-hover); color: var(--text); }
|
||||||
.tag-pill--active { background: var(--accent); color: #fff; border-color: var(--accent); }
|
.ext-notes-tag-pill--active { background: var(--accent); color: #fff; border-color: var(--accent); }
|
||||||
.tag-pill__remove {
|
.ext-notes-tag-pill__remove {
|
||||||
margin-left: 4px;
|
margin-left: var(--sp-1);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.tag-pill__remove:hover { opacity: 1; }
|
.ext-notes-tag-pill__remove:hover { opacity: 1; }
|
||||||
|
|
||||||
/* ── Tag Filter (sidebar) ──────────────── */
|
/* ── Tag Filter (sidebar) ──────────────── */
|
||||||
.tag-filter {
|
.ext-notes-tag-filter {
|
||||||
padding: 6px 14px 4px;
|
padding: var(--sp-2) var(--sp-4) var(--sp-1);
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.tag-filter__label {
|
.ext-notes-tag-filter__label {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
margin-bottom: 3px;
|
margin-bottom: 3px;
|
||||||
@@ -441,7 +441,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
.tag-filter__clear {
|
.ext-notes-tag-filter__clear {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -450,27 +450,27 @@
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
font-family: var(--font);
|
font-family: var(--font);
|
||||||
}
|
}
|
||||||
.tag-filter__clear:hover { text-decoration: underline; }
|
.ext-notes-tag-filter__clear:hover { text-decoration: underline; }
|
||||||
.tag-filter__pills {
|
.ext-notes-tag-filter__pills {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 3px;
|
gap: 3px;
|
||||||
max-height: 60px;
|
max-height: 60px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
.tag-filter__pills .tag-pill { font-size: 10px; padding: 0 6px; }
|
.ext-notes-tag-filter__pills .ext-notes-tag-pill { font-size: 10px; padding: 0 var(--sp-2); }
|
||||||
|
|
||||||
/* ── Tag Input (editor) ────────────────── */
|
/* ── Tag Input (editor) ────────────────── */
|
||||||
.tag-input {
|
.ext-notes-tag-input {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 4px;
|
gap: var(--sp-1);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 4px 20px 6px;
|
padding: var(--sp-1) var(--sp-5) var(--sp-2);
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
.tag-input__field {
|
.ext-notes-tag-input__field {
|
||||||
border: none;
|
border: none;
|
||||||
outline: none;
|
outline: none;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
@@ -481,14 +481,14 @@
|
|||||||
padding: 2px 0;
|
padding: 2px 0;
|
||||||
font-family: var(--font);
|
font-family: var(--font);
|
||||||
}
|
}
|
||||||
.tag-input__field::placeholder { color: var(--text-3); }
|
.ext-notes-tag-input__field::placeholder { color: var(--text-3); }
|
||||||
.tag-input .tag-pill { font-size: 11px; }
|
.ext-notes-tag-input .ext-notes-tag-pill { font-size: 11px; }
|
||||||
|
|
||||||
/* ── Tag Autocomplete ──────────────────── */
|
/* ── Tag Autocomplete ──────────────────── */
|
||||||
.tag-autocomplete {
|
.ext-notes-tag-autocomplete {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 100%;
|
top: 100%;
|
||||||
left: 20px;
|
left: var(--sp-5);
|
||||||
background: var(--bg-surface);
|
background: var(--bg-surface);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
@@ -498,23 +498,23 @@
|
|||||||
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||||
min-width: 140px;
|
min-width: 140px;
|
||||||
}
|
}
|
||||||
.tag-autocomplete__item {
|
.ext-notes-tag-autocomplete__item {
|
||||||
padding: 4px 10px;
|
padding: var(--sp-1) var(--sp-3);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
.tag-autocomplete__item:hover { background: var(--bg-hover); }
|
.ext-notes-tag-autocomplete__item:hover { background: var(--bg-hover); }
|
||||||
|
|
||||||
/* ── Note Card Tags ────────────────────── */
|
/* ── Note Card Tags ────────────────────── */
|
||||||
.note-card__tags {
|
.ext-notes-note-card__tags {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 3px;
|
gap: 3px;
|
||||||
margin-top: 3px;
|
margin-top: 3px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.note-card__tags .tag-pill { font-size: 10px; padding: 0 6px; cursor: default; }
|
.ext-notes-note-card__tags .ext-notes-tag-pill { font-size: 10px; padding: 0 var(--sp-2); cursor: default; }
|
||||||
.note-card__tags .tag-pill--overflow {
|
.ext-notes-note-card__tags .ext-notes-tag-pill--overflow {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
@@ -523,7 +523,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ── Folder Context Menu ───────────────── */
|
/* ── Folder Context Menu ───────────────── */
|
||||||
.folder-context-menu {
|
.ext-notes-folder-context-menu {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
background: var(--bg-surface);
|
background: var(--bg-surface);
|
||||||
@@ -531,116 +531,116 @@
|
|||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
box-shadow: 0 4px 16px rgba(0,0,0,0.2);
|
box-shadow: 0 4px 16px rgba(0,0,0,0.2);
|
||||||
min-width: 150px;
|
min-width: 150px;
|
||||||
padding: 4px 0;
|
padding: var(--sp-1) 0;
|
||||||
}
|
}
|
||||||
.folder-context-menu__item {
|
.ext-notes-folder-context-menu__item {
|
||||||
padding: 6px 14px;
|
padding: var(--sp-2) var(--sp-4);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: var(--transition);
|
transition: var(--transition);
|
||||||
}
|
}
|
||||||
.folder-context-menu__item:hover {
|
.ext-notes-folder-context-menu__item:hover {
|
||||||
background: var(--bg-hover);
|
background: var(--bg-hover);
|
||||||
}
|
}
|
||||||
.folder-context-menu__item--danger {
|
.ext-notes-folder-context-menu__item--danger {
|
||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
}
|
}
|
||||||
.folder-context-menu__item--danger:hover {
|
.ext-notes-folder-context-menu__item--danger:hover {
|
||||||
background: var(--danger-dim);
|
background: var(--danger-dim);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Drag and Drop ─────────────────────── */
|
/* ── Drag and Drop ─────────────────────── */
|
||||||
.note-card--dragging { opacity: 0.4; }
|
.ext-notes-note-card--dragging { opacity: 0.4; }
|
||||||
.folder-tree__item--drop-target {
|
.ext-notes-folder-tree__item--drop-target {
|
||||||
background: color-mix(in srgb, var(--accent) 10%, transparent) !important;
|
background: color-mix(in srgb, var(--accent) 10%, transparent) !important;
|
||||||
outline: 2px dashed var(--accent);
|
outline: 2px dashed var(--accent);
|
||||||
outline-offset: -2px;
|
outline-offset: -2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Wikilinks ──────────────────────────── */
|
/* ── Wikilinks ──────────────────────────── */
|
||||||
.wikilink {
|
.ext-notes-wikilink {
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
border-bottom: 1px dashed var(--accent);
|
border-bottom: 1px dashed var(--accent);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.wikilink:hover {
|
.ext-notes-wikilink:hover {
|
||||||
border-bottom-style: solid;
|
border-bottom-style: solid;
|
||||||
}
|
}
|
||||||
.wikilink.wikilink--unresolved {
|
.ext-notes-wikilink.ext-notes-wikilink--unresolved {
|
||||||
color: var(--danger, #e53e3e);
|
color: var(--danger);
|
||||||
border-bottom-color: var(--danger, #e53e3e);
|
border-bottom-color: var(--danger);
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
.wikilink.wikilink--unresolved:hover {
|
.ext-notes-wikilink.ext-notes-wikilink--unresolved:hover {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Backlinks Panel ────────────────────── */
|
/* ── Backlinks Panel ────────────────────── */
|
||||||
.backlinks-panel {
|
.ext-notes-backlinks-panel {
|
||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid var(--border);
|
||||||
padding: 10px 16px;
|
padding: var(--sp-3) var(--sp-4);
|
||||||
background: var(--bg-raised);
|
background: var(--bg-raised);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
max-height: 200px;
|
max-height: 200px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
.backlinks-panel__header {
|
.ext-notes-backlinks-panel__header {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-2);
|
color: var(--text-2);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
margin-bottom: 6px;
|
margin-bottom: var(--sp-2);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: var(--sp-2);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
.backlinks-panel__toggle {
|
.ext-notes-backlinks-panel__toggle {
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
color: var(--text-2);
|
color: var(--text-2);
|
||||||
}
|
}
|
||||||
.backlinks-panel__count {
|
.ext-notes-backlinks-panel__count {
|
||||||
background: var(--bg-hover);
|
background: var(--bg-hover);
|
||||||
color: var(--text-2);
|
color: var(--text-2);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
padding: 0 6px;
|
padding: 0 var(--sp-2);
|
||||||
border-radius: 8px;
|
border-radius: var(--radius);
|
||||||
line-height: 16px;
|
line-height: 16px;
|
||||||
}
|
}
|
||||||
.backlinks-panel__item {
|
.ext-notes-backlinks-panel__item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: var(--sp-2);
|
||||||
padding: 4px 8px;
|
padding: var(--sp-1) var(--sp-2);
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-sm);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
.backlinks-panel__item:hover {
|
.ext-notes-backlinks-panel__item:hover {
|
||||||
background: var(--bg-hover);
|
background: var(--bg-hover);
|
||||||
}
|
}
|
||||||
.backlinks-panel__title {
|
.ext-notes-backlinks-panel__title {
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
.backlinks-panel__context {
|
.ext-notes-backlinks-panel__context {
|
||||||
color: var(--text-2);
|
color: var(--text-2);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Sidebar Tabs ────────────────────────── */
|
/* ── Sidebar Tabs ────────────────────────── */
|
||||||
.sidebar-tabs {
|
.ext-notes-sidebar-tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.sidebar-tabs__tab {
|
.ext-notes-sidebar-tabs__tab {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 10px 14px;
|
padding: var(--sp-3) var(--sp-4);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--text-2);
|
color: var(--text-2);
|
||||||
@@ -652,38 +652,38 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
font-family: var(--font);
|
font-family: var(--font);
|
||||||
}
|
}
|
||||||
.sidebar-tabs__tab:hover {
|
.ext-notes-sidebar-tabs__tab:hover {
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
background: var(--bg-hover);
|
background: var(--bg-hover);
|
||||||
}
|
}
|
||||||
.sidebar-tabs__tab--active {
|
.ext-notes-sidebar-tabs__tab--active {
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
border-bottom-color: var(--accent);
|
border-bottom-color: var(--accent);
|
||||||
}
|
}
|
||||||
.sidebar-tabs__tab--disabled {
|
.ext-notes-sidebar-tabs__tab--disabled {
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
cursor: default;
|
cursor: default;
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
.sidebar-tabs__tab--disabled:hover {
|
.ext-notes-sidebar-tabs__tab--disabled:hover {
|
||||||
background: none;
|
background: none;
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Sidebar Outline ─────────────────────── */
|
/* ── Sidebar Outline ─────────────────────── */
|
||||||
.sidebar-outline {
|
.ext-notes-sidebar-outline {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 8px 0;
|
padding: var(--sp-2) 0;
|
||||||
}
|
}
|
||||||
.sidebar-outline__empty {
|
.ext-notes-sidebar-outline__empty {
|
||||||
padding: 40px 16px;
|
padding: var(--sp-10) var(--sp-4);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
.sidebar-outline__item {
|
.ext-notes-sidebar-outline__item {
|
||||||
padding: 5px 12px;
|
padding: 5px var(--sp-3);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--text-2);
|
color: var(--text-2);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -692,104 +692,104 @@
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
transition: var(--transition);
|
transition: var(--transition);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
margin: 1px 6px;
|
margin: 1px var(--sp-2);
|
||||||
}
|
}
|
||||||
.sidebar-outline__item:hover {
|
.ext-notes-sidebar-outline__item:hover {
|
||||||
background: var(--bg-hover);
|
background: var(--bg-hover);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
.sidebar-outline__item--active {
|
.ext-notes-sidebar-outline__item--active {
|
||||||
background: var(--bg-active);
|
background: var(--bg-active);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
.sidebar-outline__item--h1 { font-weight: 600; color: var(--text); }
|
.ext-notes-sidebar-outline__item--h1 { font-weight: 600; color: var(--text); }
|
||||||
.sidebar-outline__item--h2 { font-weight: 500; }
|
.ext-notes-sidebar-outline__item--h2 { font-weight: 500; }
|
||||||
|
|
||||||
/* ── Graph Pane ─────────────────────────── */
|
/* ── Graph Pane ─────────────────────────── */
|
||||||
.graph-pane {
|
.ext-notes-graph-pane {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: var(--bg-surface, #fff);
|
background: var(--bg-surface);
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
.graph-pane canvas {
|
.ext-notes-graph-pane canvas {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
}
|
}
|
||||||
.graph-pane canvas:active { cursor: grabbing; }
|
.ext-notes-graph-pane canvas:active { cursor: grabbing; }
|
||||||
.graph-toolbar {
|
.ext-notes-graph-toolbar {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 12px;
|
top: var(--sp-3);
|
||||||
right: 12px;
|
right: var(--sp-3);
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: var(--sp-2);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
z-index: 5;
|
z-index: 5;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-2, #666);
|
color: var(--text-2);
|
||||||
}
|
}
|
||||||
.graph-toolbar label {
|
.ext-notes-graph-toolbar label {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: var(--sp-1);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
.graph-toolbar input[type="checkbox"] { margin: 0; }
|
.ext-notes-graph-toolbar input[type="checkbox"] { margin: 0; }
|
||||||
.graph-tooltip {
|
.ext-notes-graph-tooltip {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
background: var(--bg-raised, #fff);
|
background: var(--bg-raised);
|
||||||
border: 1px solid var(--border, #ddd);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius, 6px);
|
border-radius: var(--radius, 6px);
|
||||||
padding: 8px 12px;
|
padding: var(--sp-2) var(--sp-3);
|
||||||
box-shadow: 0 2px 8px rgba(0,0,0,0.12);
|
box-shadow: 0 2px 8px rgba(0,0,0,0.12);
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
max-width: 220px;
|
max-width: 220px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
.graph-tooltip__title {
|
.ext-notes-graph-tooltip__title {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
margin-bottom: 2px;
|
margin-bottom: 2px;
|
||||||
}
|
}
|
||||||
.graph-tooltip__tags {
|
.ext-notes-graph-tooltip__tags {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 4px;
|
gap: var(--sp-1);
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
margin-top: 4px;
|
margin-top: var(--sp-1);
|
||||||
}
|
}
|
||||||
.graph-tooltip__tags .tag-pill {
|
.ext-notes-graph-tooltip__tags .ext-notes-tag-pill {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
padding: 1px 6px;
|
padding: 1px var(--sp-2);
|
||||||
border-radius: 8px;
|
border-radius: var(--radius);
|
||||||
background: var(--bg-hover, #eee);
|
background: var(--bg-hover);
|
||||||
color: var(--text-2, #666);
|
color: var(--text-2);
|
||||||
}
|
}
|
||||||
.graph-tooltip__edges {
|
.ext-notes-graph-tooltip__edges {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-3, #999);
|
color: var(--text-3);
|
||||||
margin-top: 4px;
|
margin-top: var(--sp-1);
|
||||||
}
|
}
|
||||||
.graph-empty {
|
.ext-notes-graph-empty {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
color: var(--text-3, #999);
|
color: var(--text-3);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Responsive ──────────────────────────── */
|
/* ── Responsive ──────────────────────────── */
|
||||||
@media (max-width: 700px) {
|
@media (max-width: 768px) {
|
||||||
.notes-sidebar { width: 100%; border-right: none; border-bottom: 1px solid var(--border); max-height: 40vh; }
|
.ext-notes-sidebar { width: 100%; border-right: none; border-bottom: 1px solid var(--border); max-height: 40vh; }
|
||||||
.notes-app { flex-direction: column; }
|
.ext-notes-app { flex-direction: column; }
|
||||||
.folder-tree { max-height: 30vh; }
|
.ext-notes-folder-tree { max-height: 30vh; }
|
||||||
.notes-editor__body--split { grid-template-columns: 1fr; }
|
.ext-notes-editor__body--split { grid-template-columns: 1fr; }
|
||||||
.notes-editor__body--split .notes-preview { display: none; }
|
.ext-notes-editor__body--split .ext-notes-preview { display: none; }
|
||||||
.graph-pane { min-height: 50vh; }
|
.ext-notes-graph-pane { min-height: 50vh; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -132,16 +132,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="tag-input">
|
<div class="ext-notes-tag-input">
|
||||||
${(tags || []).map(function(tag) {
|
${(tags || []).map(function(tag) {
|
||||||
return html`
|
return html`
|
||||||
<span key=${tag} class="tag-pill">
|
<span key=${tag} class="ext-notes-tag-pill">
|
||||||
${tag}
|
${tag}
|
||||||
<span class="tag-pill__remove" onClick=${function() { removeTag(tag); }}>×</span>
|
<span class="ext-notes-tag-pill__remove" onClick=${function() { removeTag(tag); }}>×</span>
|
||||||
</span>
|
</span>
|
||||||
`;
|
`;
|
||||||
})}
|
})}
|
||||||
<input ref=${inputRef} class="tag-input__field" type="text"
|
<input ref=${inputRef} class="ext-notes-tag-input__field" type="text"
|
||||||
placeholder=${(tags && tags.length) ? 'Add tag…' : 'Add tags (comma to separate)…'}
|
placeholder=${(tags && tags.length) ? 'Add tag…' : 'Add tags (comma to separate)…'}
|
||||||
value=${input}
|
value=${input}
|
||||||
onInput=${handleInput}
|
onInput=${handleInput}
|
||||||
@@ -149,9 +149,9 @@
|
|||||||
onFocus=${function() { if (input.trim()) setShowAC(true); }}
|
onFocus=${function() { if (input.trim()) setShowAC(true); }}
|
||||||
onBlur=${function() { setTimeout(function() { setShowAC(false); }, 150); }} />
|
onBlur=${function() { setTimeout(function() { setShowAC(false); }, 150); }} />
|
||||||
${showAC && suggestions.length > 0 && html`
|
${showAC && suggestions.length > 0 && html`
|
||||||
<div class="tag-autocomplete">
|
<div class="ext-notes-tag-autocomplete">
|
||||||
${suggestions.map(function(s) {
|
${suggestions.map(function(s) {
|
||||||
return html`<div key=${s} class="tag-autocomplete__item"
|
return html`<div key=${s} class="ext-notes-tag-autocomplete__item"
|
||||||
onClick=${function() { addTag(s); }}>${s}</div>`;
|
onClick=${function() { addTag(s); }}>${s}</div>`;
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
@@ -169,17 +169,17 @@
|
|||||||
if (!tags || tags.length === 0) return null;
|
if (!tags || tags.length === 0) return null;
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="tag-filter">
|
<div class="ext-notes-tag-filter">
|
||||||
<div class="tag-filter__label">
|
<div class="ext-notes-tag-filter__label">
|
||||||
<span>Tags</span>
|
<span>Tags</span>
|
||||||
${activeTag && html`
|
${activeTag && html`
|
||||||
<button class="tag-filter__clear" onClick=${function() { onSelect(null); }}>Clear</button>
|
<button class="ext-notes-tag-filter__clear" onClick=${function() { onSelect(null); }}>Clear</button>
|
||||||
`}
|
`}
|
||||||
</div>
|
</div>
|
||||||
<div class="tag-filter__pills">
|
<div class="ext-notes-tag-filter__pills">
|
||||||
${tags.map(function(tag) {
|
${tags.map(function(tag) {
|
||||||
return html`
|
return html`
|
||||||
<span key=${tag} class="tag-pill ${activeTag === tag ? 'tag-pill--active' : ''}"
|
<span key=${tag} class="ext-notes-tag-pill ${activeTag === tag ? 'ext-notes-tag-pill--active' : ''}"
|
||||||
onClick=${function() { onSelect(activeTag === tag ? null : tag); }}>
|
onClick=${function() { onSelect(activeTag === tag ? null : tag); }}>
|
||||||
${tag}
|
${tag}
|
||||||
</span>
|
</span>
|
||||||
@@ -216,23 +216,23 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="note-card ${active ? 'note-card--active' : ''} ${dragging ? 'note-card--dragging' : ''}"
|
<div class="ext-notes-note-card ${active ? 'ext-notes-note-card--active' : ''} ${dragging ? 'ext-notes-note-card--dragging' : ''}"
|
||||||
onClick=${onClick}
|
onClick=${onClick}
|
||||||
draggable="true"
|
draggable="true"
|
||||||
onDragStart=${handleDragStart}
|
onDragStart=${handleDragStart}
|
||||||
onDragEnd=${handleDragEnd}>
|
onDragEnd=${handleDragEnd}>
|
||||||
<div class="note-card__title">
|
<div class="ext-notes-note-card__title">
|
||||||
${note.pinned ? html`<span class="note-card__pin">📌</span>` : null}
|
${note.pinned ? html`<span class="ext-notes-note-card__pin">📌</span>` : null}
|
||||||
${note.title || 'Untitled'}
|
${note.title || 'Untitled'}
|
||||||
</div>
|
</div>
|
||||||
${note.snippet && html`<div class="note-card__snippet">${note.snippet}</div>`}
|
${note.snippet && html`<div class="ext-notes-note-card__snippet">${note.snippet}</div>`}
|
||||||
${showTags.length > 0 && html`
|
${showTags.length > 0 && html`
|
||||||
<div class="note-card__tags">
|
<div class="ext-notes-note-card__tags">
|
||||||
${showTags.map(function(t) { return html`<span key=${t} class="tag-pill">${t}</span>`; })}
|
${showTags.map(function(t) { return html`<span key=${t} class="ext-notes-tag-pill">${t}</span>`; })}
|
||||||
${overflow > 0 && html`<span class="tag-pill tag-pill--overflow">+${overflow}</span>`}
|
${overflow > 0 && html`<span class="ext-notes-tag-pill ext-notes-tag-pill--overflow">+${overflow}</span>`}
|
||||||
</div>
|
</div>
|
||||||
`}
|
`}
|
||||||
${dateStr && html`<div class="note-card__date">${dateStr}</div>`}
|
${dateStr && html`<div class="ext-notes-note-card__date">${dateStr}</div>`}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@@ -254,10 +254,10 @@
|
|||||||
}, [onClose]);
|
}, [onClose]);
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div ref=${menuRef} class="folder-context-menu" style="top: ${y}px; left: ${x}px">
|
<div ref=${menuRef} class="ext-notes-folder-context-menu" style="top: ${y}px; left: ${x}px">
|
||||||
<div class="folder-context-menu__item" onClick=${onAddSub}>Add subfolder</div>
|
<div class="ext-notes-folder-context-menu__item" onClick=${onAddSub}>Add subfolder</div>
|
||||||
<div class="folder-context-menu__item" onClick=${onRename}>Rename</div>
|
<div class="ext-notes-folder-context-menu__item" onClick=${onRename}>Rename</div>
|
||||||
<div class="folder-context-menu__item folder-context-menu__item--danger" onClick=${onDelete}>Delete</div>
|
<div class="ext-notes-folder-context-menu__item ext-notes-folder-context-menu__item--danger" onClick=${onDelete}>Delete</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@@ -310,25 +310,25 @@
|
|||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div>
|
<div>
|
||||||
<div class="folder-tree__item ${isActive ? 'folder-tree__item--active' : ''} ${dropOver ? 'folder-tree__item--drop-target' : ''}"
|
<div class="ext-notes-folder-tree__item ${isActive ? 'ext-notes-folder-tree__item--active' : ''} ${dropOver ? 'ext-notes-folder-tree__item--drop-target' : ''}"
|
||||||
style="padding-left: ${indent}px"
|
style="padding-left: ${indent}px"
|
||||||
onClick=${function() { onSelect(node.folder.id); }}
|
onClick=${function() { onSelect(node.folder.id); }}
|
||||||
onContextMenu=${handleContext}
|
onContextMenu=${handleContext}
|
||||||
onDragOver=${handleDragOver}
|
onDragOver=${handleDragOver}
|
||||||
onDragLeave=${handleDragLeave}
|
onDragLeave=${handleDragLeave}
|
||||||
onDrop=${handleDrop}>
|
onDrop=${handleDrop}>
|
||||||
<span class="folder-tree__toggle" onClick=${handleToggle}>
|
<span class="ext-notes-folder-tree__toggle" onClick=${handleToggle}>
|
||||||
${hasChildren ? (expanded ? '▼' : '▶') : '·'}
|
${hasChildren ? (expanded ? '▼' : '▶') : '·'}
|
||||||
</span>
|
</span>
|
||||||
<span class="folder-tree__icon">${isActive ? '📂' : '📁'}</span>
|
<span class="ext-notes-folder-tree__icon">${isActive ? '📂' : '📁'}</span>
|
||||||
${editing
|
${editing
|
||||||
? html`<input class="folder-tree__edit" value=${editName}
|
? html`<input class="ext-notes-folder-tree__edit" value=${editName}
|
||||||
onInput=${function(e) { setEditName(e.target.value); }}
|
onInput=${function(e) { setEditName(e.target.value); }}
|
||||||
onBlur=${handleRename}
|
onBlur=${handleRename}
|
||||||
onKeyDown=${function(e) { if (e.key === 'Enter') handleRename(); if (e.key === 'Escape') setEditing(false); }}
|
onKeyDown=${function(e) { if (e.key === 'Enter') handleRename(); if (e.key === 'Escape') setEditing(false); }}
|
||||||
onClick=${function(e) { e.stopPropagation(); }}
|
onClick=${function(e) { e.stopPropagation(); }}
|
||||||
ref=${function(el) { if (el) el.focus(); }} />`
|
ref=${function(el) { if (el) el.focus(); }} />`
|
||||||
: html`<span class="folder-tree__name">${node.folder.name}</span>`
|
: html`<span class="ext-notes-folder-tree__name">${node.folder.name}</span>`
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
${menu && html`<${FolderContextMenu} x=${menu.x} y=${menu.y}
|
${menu && html`<${FolderContextMenu} x=${menu.x} y=${menu.y}
|
||||||
@@ -399,18 +399,18 @@
|
|||||||
var unfiledDrop = makeDropHandlers(setDropUnfiled);
|
var unfiledDrop = makeDropHandlers(setDropUnfiled);
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="folder-tree">
|
<div class="ext-notes-folder-tree">
|
||||||
<div class="folder-tree__item ${!activeFolderId && !showUnfiled ? 'folder-tree__item--active' : ''} ${dropAll ? 'folder-tree__item--drop-target' : ''}"
|
<div class="ext-notes-folder-tree__item ${!activeFolderId && !showUnfiled ? 'ext-notes-folder-tree__item--active' : ''} ${dropAll ? 'ext-notes-folder-tree__item--drop-target' : ''}"
|
||||||
onClick=${onSelectAll}
|
onClick=${onSelectAll}
|
||||||
onDragOver=${allDrop.onDragOver} onDragLeave=${allDrop.onDragLeave} onDrop=${allDrop.onDrop}>
|
onDragOver=${allDrop.onDragOver} onDragLeave=${allDrop.onDragLeave} onDrop=${allDrop.onDrop}>
|
||||||
<span class="folder-tree__icon">📋</span>
|
<span class="ext-notes-folder-tree__icon">📋</span>
|
||||||
<span class="folder-tree__name">All Notes</span>
|
<span class="ext-notes-folder-tree__name">All Notes</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="folder-tree__item ${showUnfiled ? 'folder-tree__item--active' : ''} ${dropUnfiled ? 'folder-tree__item--drop-target' : ''}"
|
<div class="ext-notes-folder-tree__item ${showUnfiled ? 'ext-notes-folder-tree__item--active' : ''} ${dropUnfiled ? 'ext-notes-folder-tree__item--drop-target' : ''}"
|
||||||
onClick=${onSelectUnfiled}
|
onClick=${onSelectUnfiled}
|
||||||
onDragOver=${unfiledDrop.onDragOver} onDragLeave=${unfiledDrop.onDragLeave} onDrop=${unfiledDrop.onDrop}>
|
onDragOver=${unfiledDrop.onDragOver} onDragLeave=${unfiledDrop.onDragLeave} onDrop=${unfiledDrop.onDrop}>
|
||||||
<span class="folder-tree__icon">📄</span>
|
<span class="ext-notes-folder-tree__icon">📄</span>
|
||||||
<span class="folder-tree__name">Unfiled</span>
|
<span class="ext-notes-folder-tree__name">Unfiled</span>
|
||||||
</div>
|
</div>
|
||||||
${tree.map(function(node) {
|
${tree.map(function(node) {
|
||||||
return html`<${FolderNode} key=${node.folder.id} node=${node} depth=${0}
|
return html`<${FolderNode} key=${node.folder.id} node=${node} depth=${0}
|
||||||
@@ -419,7 +419,7 @@
|
|||||||
onDelete=${onDeleteFolder} onCreateSub=${onCreateFolder}
|
onDelete=${onDeleteFolder} onCreateSub=${onCreateFolder}
|
||||||
onDropNote=${onDropNote} />`;
|
onDropNote=${onDropNote} />`;
|
||||||
})}
|
})}
|
||||||
<div class="folder-tree__add" onClick=${function() { onCreateFolder(''); }}>
|
<div class="ext-notes-folder-tree__add" onClick=${function() { onCreateFolder(''); }}>
|
||||||
+ New Folder
|
+ New Folder
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -446,18 +446,18 @@
|
|||||||
if (!backlinks.length) return null;
|
if (!backlinks.length) return null;
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="backlinks-panel">
|
<div class="ext-notes-backlinks-panel">
|
||||||
<div class="backlinks-panel__header" onClick=${function() { setCollapsed(!collapsed); }}>
|
<div class="ext-notes-backlinks-panel__header" onClick=${function() { setCollapsed(!collapsed); }}>
|
||||||
<span class="backlinks-panel__toggle">${collapsed ? '▶' : '▼'}</span>
|
<span class="ext-notes-backlinks-panel__toggle">${collapsed ? '▶' : '▼'}</span>
|
||||||
<span>Backlinks</span>
|
<span>Backlinks</span>
|
||||||
<span class="backlinks-panel__count">${backlinks.length}</span>
|
<span class="ext-notes-backlinks-panel__count">${backlinks.length}</span>
|
||||||
</div>
|
</div>
|
||||||
${!collapsed && backlinks.map(function(bl) {
|
${!collapsed && backlinks.map(function(bl) {
|
||||||
return html`
|
return html`
|
||||||
<div key=${bl.source_id} class="backlinks-panel__item"
|
<div key=${bl.source_id} class="ext-notes-backlinks-panel__item"
|
||||||
onClick=${function() { if (onNavigate) onNavigate(bl.source_id); }}>
|
onClick=${function() { if (onNavigate) onNavigate(bl.source_id); }}>
|
||||||
<span class="backlinks-panel__title">${bl.source_title}</span>
|
<span class="ext-notes-backlinks-panel__title">${bl.source_title}</span>
|
||||||
<span class="backlinks-panel__context">via [[${bl.link_text}]]</span>
|
<span class="ext-notes-backlinks-panel__context">via [[${bl.link_text}]]</span>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
})}
|
})}
|
||||||
@@ -547,10 +547,10 @@
|
|||||||
|
|
||||||
function SidebarTabs({ activeTab, onTabChange, noteSelected }) {
|
function SidebarTabs({ activeTab, onTabChange, noteSelected }) {
|
||||||
return html`
|
return html`
|
||||||
<div class="sidebar-tabs">
|
<div class="ext-ext-notes-sidebar-tabs">
|
||||||
<button class="sidebar-tabs__tab ${activeTab === 'notes' ? 'sidebar-tabs__tab--active' : ''}"
|
<button class="ext-ext-notes-sidebar-tabs__tab ${activeTab === 'notes' ? 'ext-ext-notes-sidebar-tabs__tab--active' : ''}"
|
||||||
onClick=${function() { onTabChange('notes'); }}>Notes</button>
|
onClick=${function() { onTabChange('notes'); }}>Notes</button>
|
||||||
<button class="sidebar-tabs__tab ${activeTab === 'outline' ? 'sidebar-tabs__tab--active' : ''} ${!noteSelected ? 'sidebar-tabs__tab--disabled' : ''}"
|
<button class="ext-ext-notes-sidebar-tabs__tab ${activeTab === 'outline' ? 'ext-ext-notes-sidebar-tabs__tab--active' : ''} ${!noteSelected ? 'ext-ext-notes-sidebar-tabs__tab--disabled' : ''}"
|
||||||
onClick=${function() { if (noteSelected) onTabChange('outline'); }}>Outline</button>
|
onClick=${function() { if (noteSelected) onTabChange('outline'); }}>Outline</button>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -563,14 +563,14 @@
|
|||||||
|
|
||||||
function SidebarOutline({ headings, activeHeadingIdx, onScrollToHeading }) {
|
function SidebarOutline({ headings, activeHeadingIdx, onScrollToHeading }) {
|
||||||
if (!headings || headings.length === 0) {
|
if (!headings || headings.length === 0) {
|
||||||
return html`<div class="sidebar-outline__empty">No headings found</div>`;
|
return html`<div class="ext-ext-notes-sidebar-outline__empty">No headings found</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="sidebar-outline">
|
<div class="ext-ext-notes-sidebar-outline">
|
||||||
${headings.map(function(h, i) {
|
${headings.map(function(h, i) {
|
||||||
return html`
|
return html`
|
||||||
<div key=${i} class="sidebar-outline__item sidebar-outline__item--h${h.level} ${i === activeHeadingIdx ? 'sidebar-outline__item--active' : ''}"
|
<div key=${i} class="ext-ext-notes-sidebar-outline__item ext-ext-notes-sidebar-outline__item--h${h.level} ${i === activeHeadingIdx ? 'ext-ext-notes-sidebar-outline__item--active' : ''}"
|
||||||
style="padding-left: ${10 + (h.level - 1) * 14}px"
|
style="padding-left: ${10 + (h.level - 1) * 14}px"
|
||||||
onClick=${function() { onScrollToHeading(h); }}>
|
onClick=${function() { onScrollToHeading(h); }}>
|
||||||
${h.text}
|
${h.text}
|
||||||
@@ -772,8 +772,8 @@
|
|||||||
for (var i = 0; i < outgoingLinks.length; i++) {
|
for (var i = 0; i < outgoingLinks.length; i++) {
|
||||||
var link = outgoingLinks[i];
|
var link = outgoingLinks[i];
|
||||||
if (!link.target_id) {
|
if (!link.target_id) {
|
||||||
var search = 'class="wikilink" data-wikilink="' + _escAttr(link.link_text) + '"';
|
var search = 'class="ext-notes-wikilink" data-wikilink="' + _escAttr(link.link_text) + '"';
|
||||||
var replace = 'class="wikilink wikilink--unresolved" data-wikilink="' + _escAttr(link.link_text) + '"';
|
var replace = 'class="ext-notes-wikilink ext-notes-wikilink--unresolved" data-wikilink="' + _escAttr(link.link_text) + '"';
|
||||||
h = h.split(search).join(replace);
|
h = h.split(search).join(replace);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -787,7 +787,7 @@
|
|||||||
if (!el || viewMode === 'edit') return;
|
if (!el || viewMode === 'edit') return;
|
||||||
function handleClick(e) {
|
function handleClick(e) {
|
||||||
var target = e.target;
|
var target = e.target;
|
||||||
if (target.classList && target.classList.contains('wikilink')) {
|
if (target.classList && target.classList.contains('ext-notes-wikilink')) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
var linkText = target.getAttribute('data-wikilink');
|
var linkText = target.getAttribute('data-wikilink');
|
||||||
if (linkText && onNavigateToTitle) onNavigateToTitle(linkText);
|
if (linkText && onNavigateToTitle) onNavigateToTitle(linkText);
|
||||||
@@ -939,44 +939,44 @@
|
|||||||
}, [folders]);
|
}, [folders]);
|
||||||
|
|
||||||
if (!note) {
|
if (!note) {
|
||||||
return html`<div class="notes-editor__empty">Select a note or create a new one</div>`;
|
return html`<div class="ext-notes-editor__empty">Select a note or create a new one</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Render editor body ─────────────────────
|
// ── Render editor body ─────────────────────
|
||||||
function renderEditorBody() {
|
function renderEditorBody() {
|
||||||
if (viewMode === 'rendered') {
|
if (viewMode === 'rendered') {
|
||||||
return html`<div class="notes-preview" ref=${previewRef} dangerouslySetInnerHTML=${{ __html: previewHtml }} />`;
|
return html`<div class="ext-notes-preview" ref=${previewRef} dangerouslySetInnerHTML=${{ __html: previewHtml }} />`;
|
||||||
}
|
}
|
||||||
if (viewMode === 'split') {
|
if (viewMode === 'split') {
|
||||||
var editor = hasCM
|
var editor = hasCM
|
||||||
? html`<div class="notes-editor__cm" ref=${cmContainerRef} />`
|
? html`<div class="ext-notes-editor__cm" ref=${cmContainerRef} />`
|
||||||
: html`<textarea class="notes-editor__textarea" ref=${textareaRef}
|
: html`<textarea class="ext-notes-editor__textarea" ref=${textareaRef}
|
||||||
value=${body} onInput=${handleBodyChange}
|
value=${body} onInput=${handleBodyChange}
|
||||||
onKeyDown=${handleKeyDown}
|
onKeyDown=${handleKeyDown}
|
||||||
placeholder="Start writing in Markdown…" />`;
|
placeholder="Start writing in Markdown…" />`;
|
||||||
return [
|
return [
|
||||||
editor,
|
editor,
|
||||||
html`<div class="notes-preview" ref=${previewRef} dangerouslySetInnerHTML=${{ __html: previewHtml }} />`
|
html`<div class="ext-notes-preview" ref=${previewRef} dangerouslySetInnerHTML=${{ __html: previewHtml }} />`
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
// edit mode
|
// edit mode
|
||||||
if (hasCM) {
|
if (hasCM) {
|
||||||
return html`<div class="notes-editor__cm" ref=${cmContainerRef} />`;
|
return html`<div class="ext-notes-editor__cm" ref=${cmContainerRef} />`;
|
||||||
}
|
}
|
||||||
return html`<textarea class="notes-editor__textarea" ref=${textareaRef}
|
return html`<textarea class="ext-notes-editor__textarea" ref=${textareaRef}
|
||||||
value=${body} onInput=${handleBodyChange}
|
value=${body} onInput=${handleBodyChange}
|
||||||
onKeyDown=${handleKeyDown}
|
onKeyDown=${handleKeyDown}
|
||||||
placeholder="Start writing in Markdown…" />`;
|
placeholder="Start writing in Markdown…" />`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="notes-editor">
|
<div class="ext-notes-editor">
|
||||||
<div class="notes-editor__header">
|
<div class="ext-notes-editor__header">
|
||||||
<input class="notes-editor__title-input" type="text"
|
<input class="ext-notes-editor__title-input" type="text"
|
||||||
value=${title} onInput=${handleTitleChange}
|
value=${title} onInput=${handleTitleChange}
|
||||||
placeholder="Note title" />
|
placeholder="Note title" />
|
||||||
<div class="notes-editor__actions">
|
<div class="ext-notes-editor__actions">
|
||||||
<select class="notes-editor__folder-select"
|
<select class="ext-notes-editor__folder-select"
|
||||||
value=${note.folder_id || ''}
|
value=${note.folder_id || ''}
|
||||||
onChange=${async function(e) {
|
onChange=${async function(e) {
|
||||||
await api.post('/notes/move', { note_id: note.id, folder_id: e.target.value });
|
await api.post('/notes/move', { note_id: note.id, folder_id: e.target.value });
|
||||||
@@ -987,28 +987,28 @@
|
|||||||
return html`<option key=${f.id} value=${f.id}>${f.label}</option>`;
|
return html`<option key=${f.id} value=${f.id}>${f.label}</option>`;
|
||||||
})}
|
})}
|
||||||
</select>
|
</select>
|
||||||
<span class=${dirty ? 'notes-saved notes-saved--dirty' : 'notes-saved'}>
|
<span class=${dirty ? 'ext-notes-saved ext-notes-saved--dirty' : 'ext-notes-saved'}>
|
||||||
${saving ? 'Saving…' : (dirty ? 'Unsaved' : 'Saved')}
|
${saving ? 'Saving…' : (dirty ? 'Unsaved' : 'Saved')}
|
||||||
</span>
|
</span>
|
||||||
<button class="notes-toggle" onClick=${function() {
|
<button class="ext-notes-toggle" onClick=${function() {
|
||||||
var next = viewMode === 'rendered' ? 'edit' : viewMode === 'edit' ? 'split' : 'rendered';
|
var next = viewMode === 'rendered' ? 'edit' : viewMode === 'edit' ? 'split' : 'rendered';
|
||||||
_modeStore.set('editor_mode', next);
|
_modeStore.set('editor_mode', next);
|
||||||
setViewMode(next);
|
setViewMode(next);
|
||||||
}}>
|
}}>
|
||||||
${viewMode === 'rendered' ? 'Edit' : viewMode === 'edit' ? 'Split' : 'Read'}
|
${viewMode === 'rendered' ? 'Edit' : viewMode === 'edit' ? 'Split' : 'Read'}
|
||||||
</button>
|
</button>
|
||||||
<button class="notes-btn" onClick=${handleExport} title="Export as .md">Export</button>
|
<button class="ext-notes-btn" onClick=${handleExport} title="Export as .md">Export</button>
|
||||||
<button class="notes-btn" onClick=${handlePin}
|
<button class="ext-notes-btn" onClick=${handlePin}
|
||||||
title=${note.pinned ? 'Unpin' : 'Pin'}>
|
title=${note.pinned ? 'Unpin' : 'Pin'}>
|
||||||
${note.pinned ? '📌' : '📍'}
|
${note.pinned ? '📌' : '📍'}
|
||||||
</button>
|
</button>
|
||||||
<button class="notes-btn notes-btn--danger" onClick=${handleArchive}
|
<button class="ext-notes-btn ext-notes-btn--danger" onClick=${handleArchive}
|
||||||
title="Archive">🗑</button>
|
title="Archive">🗑</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<${TagInput} tags=${noteTags} allTags=${allTags} onChange=${handleTagsChange} />
|
<${TagInput} tags=${noteTags} allTags=${allTags} onChange=${handleTagsChange} />
|
||||||
<div class="notes-editor__content">
|
<div class="ext-notes-editor__content">
|
||||||
<div class="notes-editor__body ${viewMode === 'split' ? 'notes-editor__body--split' : ''}">
|
<div class="ext-notes-editor__body ${viewMode === 'split' ? 'ext-notes-editor__body--split' : ''}">
|
||||||
${renderEditorBody()}
|
${renderEditorBody()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1405,17 +1405,17 @@
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return html`<div class="graph-pane"><div class="graph-empty"><${Spinner} size="md" /></div></div>`;
|
return html`<div class="ext-notes-graph-pane"><div class="ext-notes-graph-empty"><${Spinner} size="md" /></div></div>`;
|
||||||
}
|
}
|
||||||
if (!graphData || graphData.nodes.length === 0) {
|
if (!graphData || graphData.nodes.length === 0) {
|
||||||
return html`<div class="graph-pane"><div class="graph-empty">No notes to graph. Create notes with [[wikilinks]] to see connections.</div></div>`;
|
return html`<div class="ext-notes-graph-pane"><div class="ext-notes-graph-empty">No notes to graph. Create notes with [[wikilinks]] to see connections.</div></div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
var adj = graphData.adj;
|
var adj = graphData.adj;
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="graph-pane">
|
<div class="ext-notes-graph-pane">
|
||||||
<div class="graph-toolbar">
|
<div class="ext-notes-graph-toolbar">
|
||||||
<label>
|
<label>
|
||||||
<input type="checkbox" checked=${hideOrphans}
|
<input type="checkbox" checked=${hideOrphans}
|
||||||
onChange=${function(e) { setHideOrphans(e.target.checked); }} />
|
onChange=${function(e) { setHideOrphans(e.target.checked); }} />
|
||||||
@@ -1430,14 +1430,14 @@
|
|||||||
onMouseUp=${handleMouseUp}
|
onMouseUp=${handleMouseUp}
|
||||||
onMouseLeave=${handleMouseUp} />
|
onMouseLeave=${handleMouseUp} />
|
||||||
${hoveredNode && html`
|
${hoveredNode && html`
|
||||||
<div class="graph-tooltip" style=${{ left: (mousePos.x + 14) + 'px', top: (mousePos.y - 10) + 'px' }}>
|
<div class="ext-notes-graph-tooltip" style=${{ left: (mousePos.x + 14) + 'px', top: (mousePos.y - 10) + 'px' }}>
|
||||||
<div class="graph-tooltip__title">${hoveredNode.title}</div>
|
<div class="ext-notes-graph-tooltip__title">${hoveredNode.title}</div>
|
||||||
${hoveredNode.tags && hoveredNode.tags.length > 0 && html`
|
${hoveredNode.tags && hoveredNode.tags.length > 0 && html`
|
||||||
<div class="graph-tooltip__tags">
|
<div class="ext-notes-graph-tooltip__tags">
|
||||||
${hoveredNode.tags.map(function(t) { return html`<span class="tag-pill" key=${t}>${t}</span>`; })}
|
${hoveredNode.tags.map(function(t) { return html`<span class="ext-notes-tag-pill" key=${t}>${t}</span>`; })}
|
||||||
</div>
|
</div>
|
||||||
`}
|
`}
|
||||||
<div class="graph-tooltip__edges">${(adj[hoveredNode.id] ? adj[hoveredNode.id].size : 0)} connections</div>
|
<div class="ext-notes-graph-tooltip__edges">${(adj[hoveredNode.id] ? adj[hoveredNode.id].size : 0)} connections</div>
|
||||||
</div>
|
</div>
|
||||||
`}
|
`}
|
||||||
</div>
|
</div>
|
||||||
@@ -1736,8 +1736,8 @@
|
|||||||
<${Button} variant=${showGraph ? 'primary' : 'secondary'} size="sm"
|
<${Button} variant=${showGraph ? 'primary' : 'secondary'} size="sm"
|
||||||
onClick=${function() { setShowGraph(!showGraph); }}>Graph<//>
|
onClick=${function() { setShowGraph(!showGraph); }}>Graph<//>
|
||||||
<//>
|
<//>
|
||||||
<div class="notes-app">
|
<div class="ext-notes-app">
|
||||||
<div class="notes-sidebar">
|
<div class="ext-notes-sidebar">
|
||||||
<${SidebarTabs} activeTab=${sidebarTab} onTabChange=${setSidebarTab} noteSelected=${!!activeNote} />
|
<${SidebarTabs} activeTab=${sidebarTab} onTabChange=${setSidebarTab} noteSelected=${!!activeNote} />
|
||||||
${sidebarTab === 'notes' && html`
|
${sidebarTab === 'notes' && html`
|
||||||
<${FolderTree} folders=${folders}
|
<${FolderTree} folders=${folders}
|
||||||
@@ -1749,14 +1749,14 @@
|
|||||||
onDeleteFolder=${handleDeleteFolder}
|
onDeleteFolder=${handleDeleteFolder}
|
||||||
onDropNote=${handleDropNote} />
|
onDropNote=${handleDropNote} />
|
||||||
<${TagFilter} tags=${allTags} activeTag=${activeTag} onSelect=${handleTagFilter} />
|
<${TagFilter} tags=${allTags} activeTag=${activeTag} onSelect=${handleTagFilter} />
|
||||||
<div class="notes-search">
|
<div class="ext-notes-search">
|
||||||
<input type="text" placeholder="Search notes…"
|
<input type="text" placeholder="Search notes…"
|
||||||
value=${searchTerm} onInput=${handleSearch} />
|
value=${searchTerm} onInput=${handleSearch} />
|
||||||
</div>
|
</div>
|
||||||
<div class="notes-list">
|
<div class="ext-notes-list">
|
||||||
${loading && html`<div class="notes-list__loading"><${Spinner} size="md" /></div>`}
|
${loading && html`<div class="ext-notes-list__loading"><${Spinner} size="md" /></div>`}
|
||||||
${!loading && notes.length === 0 && html`
|
${!loading && notes.length === 0 && html`
|
||||||
<div class="notes-list__empty">
|
<div class="ext-notes-list__empty">
|
||||||
${searchTerm ? 'No matching notes' : (activeTag ? 'No notes with tag "' + activeTag + '"' : 'No notes yet. Click + New Note to start.')}
|
${searchTerm ? 'No matching notes' : (activeTag ? 'No notes with tag "' + activeTag + '"' : 'No notes yet. Click + New Note to start.')}
|
||||||
</div>
|
</div>
|
||||||
`}
|
`}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
"version": "0.8.0",
|
"version": "0.8.0",
|
||||||
"icon": "📝",
|
"icon": "📝",
|
||||||
"description": "Markdown notes surface with rich editor, folders, tags, backlinks, import/export, sidebar tabs, document outline, and note graph.",
|
"description": "Markdown notes surface with rich editor, folders, tags, backlinks, import/export, sidebar tabs, document outline, and note graph.",
|
||||||
"author": "switchboard",
|
"author": "armature",
|
||||||
|
|
||||||
"permissions": ["db.write"],
|
"permissions": ["db.write"],
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"type": "extension",
|
"type": "extension",
|
||||||
"tier": "browser",
|
"tier": "browser",
|
||||||
"author": "switchboard",
|
"author": "armature",
|
||||||
"description": "Tests regular expressions against input strings. Allows the LLM to verify regex patterns and see matches, groups, and indices.",
|
"description": "Tests regular expressions against input strings. Allows the LLM to verify regex patterns and see matches, groups, and indices.",
|
||||||
"requires": ["chat"],
|
"requires": ["chat"],
|
||||||
"permissions": [],
|
"permissions": [],
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/* ── Schedules Surface ───────────────────── */
|
/* ── Schedules Surface ───────────────────── */
|
||||||
|
|
||||||
.sched-app {
|
.ext-schedules-app {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -8,53 +8,53 @@
|
|||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sched-header {
|
.ext-schedules-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 12px 16px;
|
padding: var(--sp-3) var(--sp-4);
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sched-header h1 {
|
.ext-schedules-header h1 {
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sched-body {
|
.ext-schedules-body {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
padding: 16px;
|
padding: var(--sp-4);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 16px;
|
gap: var(--sp-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sched-count {
|
.ext-schedules-count {
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Table ────────────────────────────────── */
|
/* ── Table ────────────────────────────────── */
|
||||||
|
|
||||||
.sched-list__loading,
|
.ext-schedules-list__loading,
|
||||||
.sched-list__empty {
|
.ext-schedules-list__empty {
|
||||||
padding: 40px 16px;
|
padding: var(--sp-10) var(--sp-4);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sched-table {
|
.ext-schedules-table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sched-table th {
|
.ext-schedules-table th {
|
||||||
text-align: left;
|
text-align: left;
|
||||||
padding: 8px 12px;
|
padding: var(--sp-2) var(--sp-3);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -63,19 +63,19 @@
|
|||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sched-table td {
|
.ext-schedules-table td {
|
||||||
padding: 10px 12px;
|
padding: var(--sp-3) var(--sp-3);
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
vertical-align: top;
|
vertical-align: top;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sched-row:hover {
|
.ext-schedules-row:hover {
|
||||||
background: var(--bg-hover);
|
background: var(--bg-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sched-row__name { min-width: 160px; }
|
.ext-schedules-row__name { min-width: 160px; }
|
||||||
|
|
||||||
.sched-link {
|
.ext-schedules-link {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
@@ -85,86 +85,86 @@
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
.sched-link:hover { text-decoration: underline; }
|
.ext-schedules-link:hover { text-decoration: underline; }
|
||||||
|
|
||||||
.sched-row__desc {
|
.ext-schedules-row__desc {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sched-row__cron { white-space: nowrap; }
|
.ext-schedules-row__cron { white-space: nowrap; }
|
||||||
|
|
||||||
.sched-cron-badge {
|
.ext-schedules-cron-badge {
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
background: var(--bg-raised);
|
background: var(--bg-raised);
|
||||||
padding: 2px 6px;
|
padding: 2px var(--sp-2);
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-sm);
|
||||||
color: var(--text-2);
|
color: var(--text-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sched-cron-human {
|
.ext-schedules-cron-human {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sched-row__next {
|
.ext-schedules-row__next {
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: var(--text-2);
|
color: var(--text-2);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sched-row__actions {
|
.ext-schedules-row__actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 4px;
|
gap: var(--sp-1);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Toggle button ────────────────────────── */
|
/* ── Toggle button ────────────────────────── */
|
||||||
|
|
||||||
.sched-toggle-btn {
|
.ext-schedules-toggle-btn {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
padding: 2px 10px;
|
padding: 2px var(--sp-3);
|
||||||
border-radius: 10px;
|
border-radius: var(--radius-lg);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.15s, color 0.15s;
|
transition: background 0.15s, color 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sched-toggle-btn--on {
|
.ext-schedules-toggle-btn--on {
|
||||||
background: var(--success);
|
background: var(--success);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
border-color: var(--success);
|
border-color: var(--success);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sched-toggle-btn--off {
|
.ext-schedules-toggle-btn--off {
|
||||||
background: var(--bg-raised);
|
background: var(--bg-raised);
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sched-toggle-btn:hover { opacity: 0.85; }
|
.ext-schedules-toggle-btn:hover { opacity: 0.85; }
|
||||||
|
|
||||||
/* ── Action buttons ───────────────────────── */
|
/* ── Action buttons ───────────────────────── */
|
||||||
|
|
||||||
.sched-btn {
|
.ext-schedules-btn {
|
||||||
background: none;
|
background: none;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
color: var(--text-2);
|
color: var(--text-2);
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-sm);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.15s;
|
transition: background 0.15s;
|
||||||
}
|
}
|
||||||
.sched-btn:hover { background: var(--bg-hover); }
|
.ext-schedules-btn:hover { background: var(--bg-hover); }
|
||||||
|
|
||||||
.sched-btn--sm {
|
.ext-schedules-btn--sm {
|
||||||
padding: 3px 8px;
|
padding: 3px var(--sp-2);
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sched-btn--danger:hover {
|
.ext-schedules-btn--danger:hover {
|
||||||
background: var(--danger-dim);
|
background: var(--danger-dim);
|
||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
border-color: var(--danger);
|
border-color: var(--danger);
|
||||||
@@ -172,92 +172,92 @@
|
|||||||
|
|
||||||
/* ── Form ─────────────────────────────────── */
|
/* ── Form ─────────────────────────────────── */
|
||||||
|
|
||||||
.sched-form { display: flex; flex-direction: column; gap: 12px; }
|
.ext-schedules-form { display: flex; flex-direction: column; gap: var(--sp-3); }
|
||||||
.sched-form__row { display: flex; align-items: center; gap: 12px; }
|
.ext-schedules-form__row { display: flex; align-items: center; gap: var(--sp-3); }
|
||||||
.sched-form__actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 8px; }
|
.ext-schedules-form__actions { display: flex; justify-content: flex-end; gap: var(--sp-2); margin-top: var(--sp-2); }
|
||||||
|
|
||||||
.sched-cron-input {
|
.ext-schedules-cron-input {
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sched-cron-preview {
|
.ext-schedules-cron-preview {
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
margin-top: 4px;
|
margin-top: var(--sp-1);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sched-script {
|
.ext-schedules-script {
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
resize: vertical;
|
resize: vertical;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sched-toggle {
|
.ext-schedules-toggle {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: var(--sp-2);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Logs panel ───────────────────────────── */
|
/* ── Logs panel ───────────────────────────── */
|
||||||
|
|
||||||
.sched-logs {
|
.ext-schedules-logs {
|
||||||
background: var(--bg-surface);
|
background: var(--bg-surface);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 8px;
|
border-radius: var(--radius);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sched-logs__header {
|
.ext-schedules-logs__header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 8px 12px;
|
padding: var(--sp-2) var(--sp-3);
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sched-logs__loading,
|
.ext-schedules-logs__loading,
|
||||||
.sched-logs__empty {
|
.ext-schedules-logs__empty {
|
||||||
padding: 20px 12px;
|
padding: var(--sp-5) var(--sp-3);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sched-log-entry {
|
.ext-schedules-log-entry {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: var(--sp-3);
|
||||||
padding: 6px 12px;
|
padding: var(--sp-2) var(--sp-3);
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
}
|
}
|
||||||
.sched-log-entry:last-child { border-bottom: none; }
|
.ext-schedules-log-entry:last-child { border-bottom: none; }
|
||||||
|
|
||||||
.sched-log-entry__status {
|
.ext-schedules-log-entry__status {
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
width: 18px;
|
width: 18px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
.sched-log-entry__status--ok { color: var(--success); }
|
.ext-schedules-log-entry__status--ok { color: var(--success); }
|
||||||
.sched-log-entry__status--fail { color: var(--danger); }
|
.ext-schedules-log-entry__status--fail { color: var(--danger); }
|
||||||
|
|
||||||
.sched-log-entry__time {
|
.ext-schedules-log-entry__time {
|
||||||
color: var(--text-2);
|
color: var(--text-2);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sched-log-entry__duration {
|
.ext-schedules-log-entry__duration {
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sched-log-entry__error {
|
.ext-schedules-log-entry__error {
|
||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -269,9 +269,9 @@
|
|||||||
/* ── Responsive ───────────────────────────── */
|
/* ── Responsive ───────────────────────────── */
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.sched-table th:nth-child(3),
|
.ext-schedules-table th:nth-child(3),
|
||||||
.sched-table td:nth-child(3) {
|
.ext-schedules-table td:nth-child(3) {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
.sched-body { padding: 8px; }
|
.ext-schedules-body { padding: var(--sp-2); }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -148,7 +148,7 @@
|
|||||||
|
|
||||||
return html`
|
return html`
|
||||||
<${Dialog} open onClose=${onClose} title=${schedule ? 'Edit Schedule' : 'New Schedule'}>
|
<${Dialog} open onClose=${onClose} title=${schedule ? 'Edit Schedule' : 'New Schedule'}>
|
||||||
<form onSubmit=${handleSubmit} class="sched-form">
|
<form onSubmit=${handleSubmit} class="ext-schedules-form">
|
||||||
${error && html`<${Banner} variant="danger" text=${error} />`}
|
${error && html`<${Banner} variant="danger" text=${error} />`}
|
||||||
<${FormField} label="Name" required>
|
<${FormField} label="Name" required>
|
||||||
<input type="text" value=${name} onInput=${e => setName(e.target.value)}
|
<input type="text" value=${name} onInput=${e => setName(e.target.value)}
|
||||||
@@ -160,22 +160,22 @@
|
|||||||
<//>
|
<//>
|
||||||
<${FormField} label="Cron Expression" required>
|
<${FormField} label="Cron Expression" required>
|
||||||
<input type="text" value=${cron} onInput=${e => setCron(e.target.value)}
|
<input type="text" value=${cron} onInput=${e => setCron(e.target.value)}
|
||||||
placeholder="e.g. 0 9 * * 1-5" class="sched-cron-input" />
|
placeholder="e.g. 0 9 * * 1-5" class="ext-schedules-cron-input" />
|
||||||
${preview && preview !== cron && html`
|
${preview && preview !== cron && html`
|
||||||
<div class="sched-cron-preview">${preview}</div>
|
<div class="ext-schedules-cron-preview">${preview}</div>
|
||||||
`}
|
`}
|
||||||
<//>
|
<//>
|
||||||
<${FormField} label="Script">
|
<${FormField} label="Script">
|
||||||
<textarea rows="6" value=${script} onInput=${e => setScript(e.target.value)}
|
<textarea rows="6" value=${script} onInput=${e => setScript(e.target.value)}
|
||||||
placeholder="Starlark script (optional)" class="sched-script" />
|
placeholder="Starlark script (optional)" class="ext-schedules-script" />
|
||||||
<//>
|
<//>
|
||||||
<div class="sched-form__row">
|
<div class="ext-schedules-form__row">
|
||||||
<label class="sched-toggle">
|
<label class="ext-schedules-toggle">
|
||||||
<input type="checkbox" checked=${enabled} onChange=${e => setEnabled(e.target.checked)} />
|
<input type="checkbox" checked=${enabled} onChange=${e => setEnabled(e.target.checked)} />
|
||||||
<span>Enabled</span>
|
<span>Enabled</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="sched-form__actions">
|
<div class="ext-schedules-form__actions">
|
||||||
<${Button} variant="ghost" type="button" onClick=${onClose}>Cancel<//>
|
<${Button} variant="ghost" type="button" onClick=${onClose}>Cancel<//>
|
||||||
<${Button} variant="primary" type="submit" disabled=${saving}>
|
<${Button} variant="primary" type="submit" disabled=${saving}>
|
||||||
${saving ? 'Saving…' : (schedule ? 'Update' : 'Create')}
|
${saving ? 'Saving…' : (schedule ? 'Update' : 'Create')}
|
||||||
@@ -208,25 +208,25 @@
|
|||||||
}, [scheduleId]);
|
}, [scheduleId]);
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="sched-logs">
|
<div class="ext-schedules-logs">
|
||||||
<div class="sched-logs__header">
|
<div class="ext-schedules-logs__header">
|
||||||
<span>Execution History</span>
|
<span>Execution History</span>
|
||||||
<button class="sched-btn sched-btn--sm" onClick=${onClose}>Close</button>
|
<button class="ext-schedules-btn ext-schedules-btn--sm" onClick=${onClose}>Close</button>
|
||||||
</div>
|
</div>
|
||||||
${loading && html`<div class="sched-logs__loading"><${Spinner} size="sm" /></div>`}
|
${loading && html`<div class="ext-schedules-logs__loading"><${Spinner} size="sm" /></div>`}
|
||||||
${!loading && logs.length === 0 && html`
|
${!loading && logs.length === 0 && html`
|
||||||
<div class="sched-logs__empty">No executions yet</div>
|
<div class="ext-schedules-logs__empty">No executions yet</div>
|
||||||
`}
|
`}
|
||||||
${!loading && logs.map(function (log) {
|
${!loading && logs.map(function (log) {
|
||||||
var success = log.status === 'success' || log.status === 'ok';
|
var success = log.status === 'success' || log.status === 'ok';
|
||||||
return html`
|
return html`
|
||||||
<div class="sched-log-entry" key=${log.id || log.started_at}>
|
<div class="ext-schedules-log-entry" key=${log.id || log.started_at}>
|
||||||
<span class="sched-log-entry__status sched-log-entry__status--${success ? 'ok' : 'fail'}">
|
<span class="ext-schedules-log-entry__status ext-schedules-log-entry__status--${success ? 'ok' : 'fail'}">
|
||||||
${success ? '✓' : '✗'}
|
${success ? '✓' : '✗'}
|
||||||
</span>
|
</span>
|
||||||
<span class="sched-log-entry__time">${_formatTime(log.started_at)}</span>
|
<span class="ext-schedules-log-entry__time">${_formatTime(log.started_at)}</span>
|
||||||
<span class="sched-log-entry__duration">${_formatDuration(log.duration_ms)}</span>
|
<span class="ext-schedules-log-entry__duration">${_formatDuration(log.duration_ms)}</span>
|
||||||
${log.error && html`<span class="sched-log-entry__error" title=${log.error}>${log.error}</span>`}
|
${log.error && html`<span class="ext-schedules-log-entry__error" title=${log.error}>${log.error}</span>`}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
})}
|
})}
|
||||||
@@ -286,13 +286,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="sched-list">
|
<div class="ext-schedules-list">
|
||||||
${loading && html`<div class="sched-list__loading"><${Spinner} size="md" /></div>`}
|
${loading && html`<div class="ext-schedules-list__loading"><${Spinner} size="md" /></div>`}
|
||||||
${!loading && items.length === 0 && html`
|
${!loading && items.length === 0 && html`
|
||||||
<div class="sched-list__empty">No schedules yet. Create one to get started.</div>
|
<div class="ext-schedules-list__empty">No schedules yet. Create one to get started.</div>
|
||||||
`}
|
`}
|
||||||
${!loading && items.length > 0 && html`
|
${!loading && items.length > 0 && html`
|
||||||
<table class="sched-table">
|
<table class="ext-schedules-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Name</th>
|
<th>Name</th>
|
||||||
@@ -305,27 +305,27 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
${items.map(function (s) {
|
${items.map(function (s) {
|
||||||
return html`
|
return html`
|
||||||
<tr key=${s.id} class="sched-row">
|
<tr key=${s.id} class="ext-schedules-row">
|
||||||
<td class="sched-row__name">
|
<td class="ext-schedules-row__name">
|
||||||
<button class="sched-link" onClick=${() => onEdit(s)}>${s.name}</button>
|
<button class="ext-schedules-link" onClick=${() => onEdit(s)}>${s.name}</button>
|
||||||
${s.description && html`<div class="sched-row__desc">${s.description}</div>`}
|
${s.description && html`<div class="ext-schedules-row__desc">${s.description}</div>`}
|
||||||
</td>
|
</td>
|
||||||
<td class="sched-row__cron">
|
<td class="ext-schedules-row__cron">
|
||||||
<span class="sched-cron-badge">${s.cron_expr}</span>
|
<span class="ext-schedules-cron-badge">${s.cron_expr}</span>
|
||||||
<div class="sched-cron-human">${describeCron(s.cron_expr)}</div>
|
<div class="ext-schedules-cron-human">${describeCron(s.cron_expr)}</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="sched-row__next">${s.next_fire_at ? _formatTime(s.next_fire_at) : '—'}</td>
|
<td class="ext-schedules-row__next">${s.next_fire_at ? _formatTime(s.next_fire_at) : '—'}</td>
|
||||||
<td>
|
<td>
|
||||||
<button class="sched-toggle-btn sched-toggle-btn--${s.enabled ? 'on' : 'off'}"
|
<button class="ext-schedules-toggle-btn ext-schedules-toggle-btn--${s.enabled ? 'on' : 'off'}"
|
||||||
onClick=${() => handleToggle(s)}
|
onClick=${() => handleToggle(s)}
|
||||||
title=${s.enabled ? 'Disable' : 'Enable'}>
|
title=${s.enabled ? 'Disable' : 'Enable'}>
|
||||||
${s.enabled ? 'On' : 'Off'}
|
${s.enabled ? 'On' : 'Off'}
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
<td class="sched-row__actions">
|
<td class="ext-schedules-row__actions">
|
||||||
<button class="sched-btn sched-btn--sm" onClick=${() => handleRun(s)} title="Run now">▶</button>
|
<button class="ext-schedules-btn ext-schedules-btn--sm" onClick=${() => handleRun(s)} title="Run now">▶</button>
|
||||||
<button class="sched-btn sched-btn--sm" onClick=${() => onShowLogs(s.id)} title="View logs">📋</button>
|
<button class="ext-schedules-btn ext-schedules-btn--sm" onClick=${() => onShowLogs(s.id)} title="View logs">📋</button>
|
||||||
<button class="sched-btn sched-btn--sm sched-btn--danger" onClick=${() => handleDelete(s)}
|
<button class="ext-schedules-btn ext-schedules-btn--sm ext-schedules-btn--danger" onClick=${() => handleDelete(s)}
|
||||||
title="Delete">×</button>
|
title="Delete">×</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -371,14 +371,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="sched-app">
|
<div class="ext-schedules-app">
|
||||||
${Topbar ? html`
|
${Topbar ? html`
|
||||||
<${Topbar} title="Schedules">
|
<${Topbar} title="Schedules">
|
||||||
<span class="sched-count">${items.length} schedule${items.length !== 1 ? 's' : ''}</span>
|
<span class="ext-schedules-count">${items.length} schedule${items.length !== 1 ? 's' : ''}</span>
|
||||||
<${Button} variant="primary" size="sm" onClick=${() => setEditing({})}>+ New Schedule<//>
|
<${Button} variant="primary" size="sm" onClick=${() => setEditing({})}>+ New Schedule<//>
|
||||||
<//>
|
<//>
|
||||||
` : html`
|
` : html`
|
||||||
<div class="sched-header">
|
<div class="ext-schedules-header">
|
||||||
<h1>Schedules</h1>
|
<h1>Schedules</h1>
|
||||||
<${Button} variant="primary" size="sm" onClick=${() => setEditing({})}>+ New Schedule<//>
|
<${Button} variant="primary" size="sm" onClick=${() => setEditing({})}>+ New Schedule<//>
|
||||||
</div>
|
</div>
|
||||||
@@ -386,7 +386,7 @@
|
|||||||
|
|
||||||
${error && html`<${Banner} variant="danger" text=${error} />`}
|
${error && html`<${Banner} variant="danger" text=${error} />`}
|
||||||
|
|
||||||
<div class="sched-body">
|
<div class="ext-schedules-body">
|
||||||
<${ScheduleList}
|
<${ScheduleList}
|
||||||
items=${items}
|
items=${items}
|
||||||
loading=${loading}
|
loading=${loading}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"type": "full",
|
"type": "full",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"tier": "browser",
|
"tier": "browser",
|
||||||
"author": "Switchboard Core",
|
"author": "Armature",
|
||||||
"icon": "⏰",
|
"icon": "⏰",
|
||||||
"description": "Manage cron jobs and scheduled automations",
|
"description": "Manage cron jobs and scheduled automations",
|
||||||
"route": "/s/schedules",
|
"route": "/s/schedules",
|
||||||
|
|||||||
@@ -1,222 +1,222 @@
|
|||||||
/* SDK Test Runner — Styles */
|
/* SDK Test Runner — Styles */
|
||||||
|
|
||||||
.sdkr-header {
|
.ext-sdk-test-runner-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
gap: 12px;
|
gap: var(--sp-3);
|
||||||
margin-bottom: 4px;
|
margin-bottom: var(--sp-1);
|
||||||
}
|
}
|
||||||
.sdkr-header h1 {
|
.ext-sdk-test-runner-header h1 {
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--text, #e0e0e0);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
.sdkr-version {
|
.ext-sdk-test-runner-version {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text3, #888);
|
color: var(--text-3);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'JetBrains Mono', monospace;
|
||||||
}
|
}
|
||||||
.sdkr-desc {
|
.ext-sdk-test-runner-desc {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--text2, #aaa);
|
color: var(--text-2);
|
||||||
margin: 0 0 16px 0;
|
margin: 0 0 var(--sp-4) 0;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Fixtures panel */
|
/* Fixtures panel */
|
||||||
.sdkr-fixtures {
|
.ext-sdk-test-runner-fixtures {
|
||||||
margin-bottom: 16px;
|
margin-bottom: var(--sp-4);
|
||||||
padding: 10px 14px;
|
padding: var(--sp-3) var(--sp-4);
|
||||||
border: 1px solid var(--border, #333);
|
border: 1px solid var(--border);
|
||||||
border-radius: 6px;
|
border-radius: var(--radius);
|
||||||
background: var(--bg1, #1a1a1a);
|
background: var(--bg-raised);
|
||||||
}
|
}
|
||||||
.sdkr-fixture-row {
|
.ext-sdk-test-runner-fixture-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: var(--sp-3);
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
.sdkr-fixture-label {
|
.ext-sdk-test-runner-fixture-label {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text2, #aaa);
|
color: var(--text-2);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.sdkr-select {
|
.ext-sdk-test-runner-select {
|
||||||
padding: 4px 8px;
|
padding: var(--sp-1) var(--sp-2);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
background: var(--bg2, #222);
|
background: var(--bg-raised);
|
||||||
color: var(--text, #e0e0e0);
|
color: var(--text);
|
||||||
border: 1px solid var(--border, #444);
|
border: 1px solid var(--border);
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-sm);
|
||||||
min-width: 130px;
|
min-width: 130px;
|
||||||
}
|
}
|
||||||
.sdkr-input {
|
.ext-sdk-test-runner-input {
|
||||||
padding: 4px 8px;
|
padding: var(--sp-1) var(--sp-2);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
background: var(--bg2, #222);
|
background: var(--bg-raised);
|
||||||
color: var(--text, #e0e0e0);
|
color: var(--text);
|
||||||
border: 1px solid var(--border, #444);
|
border: 1px solid var(--border);
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-sm);
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 200px;
|
min-width: 200px;
|
||||||
max-width: 400px;
|
max-width: 400px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'JetBrains Mono', monospace;
|
||||||
}
|
}
|
||||||
.sdkr-input::placeholder {
|
.ext-sdk-test-runner-input::placeholder {
|
||||||
color: var(--text3, #666);
|
color: var(--text-3);
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
}
|
}
|
||||||
.sdkr-fixture-status {
|
.ext-sdk-test-runner-fixture-status {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text3, #888);
|
color: var(--text-3);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'JetBrains Mono', monospace;
|
||||||
}
|
}
|
||||||
.sdkr-fixture-note {
|
.ext-sdk-test-runner-fixture-note {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text3, #666);
|
color: var(--text-3);
|
||||||
margin-top: 6px;
|
margin-top: var(--sp-2);
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Controls */
|
/* Controls */
|
||||||
.sdkr-controls {
|
.ext-sdk-test-runner-controls {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 10px;
|
gap: var(--sp-3);
|
||||||
margin-bottom: 16px;
|
margin-bottom: var(--sp-4);
|
||||||
}
|
}
|
||||||
.sdkr-filters {
|
.ext-sdk-test-runner-filters {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: var(--sp-2);
|
||||||
}
|
}
|
||||||
.sdkr-filter-label {
|
.ext-sdk-test-runner-filter-label {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text2, #aaa);
|
color: var(--text-2);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
}
|
}
|
||||||
.sdkr-check {
|
.ext-sdk-test-runner-check {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--text, #e0e0e0);
|
color: var(--text);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 3px;
|
gap: 3px;
|
||||||
}
|
}
|
||||||
.sdkr-check input { cursor: pointer; }
|
.ext-sdk-test-runner-check input { cursor: pointer; }
|
||||||
.sdkr-btn-row {
|
.ext-sdk-test-runner-btn-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: var(--sp-2);
|
||||||
}
|
}
|
||||||
.sdkr-btn {
|
.ext-sdk-test-runner-btn {
|
||||||
padding: 6px 14px;
|
padding: var(--sp-2) var(--sp-4);
|
||||||
border-radius: 6px;
|
border-radius: var(--radius);
|
||||||
border: 1px solid var(--border, #444);
|
border: 1px solid var(--border);
|
||||||
background: var(--bg2, #2a2a2a);
|
background: var(--bg-raised);
|
||||||
color: var(--text, #e0e0e0);
|
color: var(--text);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.15s;
|
transition: background 0.15s;
|
||||||
}
|
}
|
||||||
.sdkr-btn:hover { background: var(--bg3, #383838); }
|
.ext-sdk-test-runner-btn:hover { background: var(--bg-hover); }
|
||||||
.sdkr-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
.ext-sdk-test-runner-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
.sdkr-btn-primary {
|
.ext-sdk-test-runner-btn-primary {
|
||||||
background: var(--accent, #3b82f6);
|
background: var(--accent);
|
||||||
border-color: var(--accent, #3b82f6);
|
border-color: var(--accent);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
.sdkr-btn-primary:hover { filter: brightness(1.15); }
|
.ext-sdk-test-runner-btn-primary:hover { filter: brightness(1.15); }
|
||||||
.sdkr-btn-danger {
|
.ext-sdk-test-runner-btn-danger {
|
||||||
background: var(--danger, #ef4444);
|
background: var(--danger);
|
||||||
border-color: var(--danger, #ef4444);
|
border-color: var(--danger);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Summary */
|
/* Summary */
|
||||||
.sdkr-summary {
|
.ext-sdk-test-runner-summary {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 8px;
|
gap: var(--sp-2);
|
||||||
margin-bottom: 12px;
|
margin-bottom: var(--sp-3);
|
||||||
padding: 10px 12px;
|
padding: var(--sp-3) var(--sp-3);
|
||||||
background: var(--bg2, #1e1e1e);
|
background: var(--bg-raised);
|
||||||
border-radius: 8px;
|
border-radius: var(--radius);
|
||||||
border: 1px solid var(--border, #333);
|
border: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
.sdkr-badge {
|
.ext-sdk-test-runner-badge {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding: 3px 10px;
|
padding: var(--sp-1) var(--sp-3);
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-sm);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'JetBrains Mono', monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Table */
|
/* Table */
|
||||||
.sdkr-table-wrap {
|
.ext-sdk-test-runner-table-wrap {
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
border: 1px solid var(--border, #333);
|
border: 1px solid var(--border);
|
||||||
border-radius: 8px;
|
border-radius: var(--radius);
|
||||||
}
|
}
|
||||||
.sdkr-table {
|
.ext-sdk-test-runner-table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
.sdkr-table th {
|
.ext-sdk-test-runner-table th {
|
||||||
text-align: left;
|
text-align: left;
|
||||||
padding: 8px 10px;
|
padding: var(--sp-2) var(--sp-3);
|
||||||
background: var(--bg2, #1e1e1e);
|
background: var(--bg-raised);
|
||||||
color: var(--text2, #aaa);
|
color: var(--text-2);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
border-bottom: 1px solid var(--border, #333);
|
border-bottom: 1px solid var(--border);
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
.sdkr-table td {
|
.ext-sdk-test-runner-table td {
|
||||||
padding: 6px 10px;
|
padding: var(--sp-2) var(--sp-3);
|
||||||
border-bottom: 1px solid var(--border-subtle, #2a2a2a);
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
color: var(--text, #e0e0e0);
|
color: var(--text);
|
||||||
vertical-align: top;
|
vertical-align: top;
|
||||||
}
|
}
|
||||||
.sdkr-table tbody tr:hover {
|
.ext-sdk-test-runner-table tbody tr:hover {
|
||||||
background: var(--bg-hover, rgba(255,255,255,0.03));
|
background: var(--bg-hover);
|
||||||
}
|
}
|
||||||
.sdkr-test-name {
|
.ext-sdk-test-runner-test-name {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'JetBrains Mono', monospace;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
.sdkr-verdict {
|
.ext-sdk-test-runner-verdict {
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'JetBrains Mono', monospace;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
.sdkr-ms {
|
.ext-sdk-test-runner-ms {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'JetBrains Mono', monospace;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text3, #888);
|
color: var(--text-3);
|
||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
.sdkr-detail {
|
.ext-sdk-test-runner-detail {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text3, #888);
|
color: var(--text-3);
|
||||||
max-width: 400px;
|
max-width: 400px;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Row verdict highlights */
|
/* Row verdict highlights */
|
||||||
.sdkr-row-sdk_bug td { background: rgba(245, 158, 11, 0.06); }
|
.ext-sdk-test-runner-row-sdk_bug td { background: rgba(245, 158, 11, 0.06); }
|
||||||
.sdkr-row-icd_bug td { background: rgba(239, 68, 68, 0.06); }
|
.ext-sdk-test-runner-row-icd_bug td { background: rgba(239, 68, 68, 0.06); }
|
||||||
.sdkr-row-shape_bug td { background: rgba(168, 85, 247, 0.06); }
|
.ext-sdk-test-runner-row-shape_bug td { background: rgba(168, 85, 247, 0.06); }
|
||||||
.sdkr-row-error td { background: rgba(239, 68, 68, 0.08); }
|
.ext-sdk-test-runner-row-error td { background: rgba(239, 68, 68, 0.08); }
|
||||||
|
|||||||
@@ -141,7 +141,7 @@
|
|||||||
|
|
||||||
function _readToken() {
|
function _readToken() {
|
||||||
var env = (T.base || '').replace(/^\//, '') || 'default';
|
var env = (T.base || '').replace(/^\//, '') || 'default';
|
||||||
var keys = ['sb_auth_' + env, 'sb_auth', 'sb_token'];
|
var keys = ['arm_auth_' + env, 'arm_auth', 'arm_token'];
|
||||||
for (var i = 0; i < keys.length; i++) {
|
for (var i = 0; i < keys.length; i++) {
|
||||||
try {
|
try {
|
||||||
var raw = localStorage.getItem(keys[i]);
|
var raw = localStorage.getItem(keys[i]);
|
||||||
|
|||||||
@@ -58,25 +58,25 @@
|
|||||||
mount.innerHTML = '';
|
mount.innerHTML = '';
|
||||||
|
|
||||||
// Header
|
// Header
|
||||||
var header = el('div', { className: 'sdkr-header' }, [
|
var header = el('div', { className: 'ext-sdk-test-runner-header' }, [
|
||||||
el('h1', {}, 'SDK Test Runner'),
|
el('h1', {}, 'SDK Test Runner'),
|
||||||
el('span', { className: 'sdkr-version' }, 'v' + (T.manifest.version || '?'))
|
el('span', { className: 'ext-sdk-test-runner-version' }, 'v' + (T.manifest.version || '?'))
|
||||||
]);
|
]);
|
||||||
mount.appendChild(header);
|
mount.appendChild(header);
|
||||||
|
|
||||||
// Description
|
// Description
|
||||||
mount.appendChild(el('p', { className: 'sdkr-desc' },
|
mount.appendChild(el('p', { className: 'ext-sdk-test-runner-desc' },
|
||||||
'Dual-path validation: SDK call → raw ICD fetch. ' +
|
'Dual-path validation: SDK call → raw ICD fetch. ' +
|
||||||
'SDK fail + ICD pass = SDK bug. Both fail = ICD bug.'));
|
'SDK fail + ICD pass = SDK bug. Both fail = ICD bug.'));
|
||||||
|
|
||||||
// ── Fixtures Panel ──
|
// ── Fixtures Panel ──
|
||||||
var fixturePanel = el('div', { className: 'sdkr-fixtures' });
|
var fixturePanel = el('div', { className: 'ext-sdk-test-runner-fixtures' });
|
||||||
|
|
||||||
var fixRow = el('div', { className: 'sdkr-fixture-row' });
|
var fixRow = el('div', { className: 'ext-sdk-test-runner-fixture-row' });
|
||||||
fixRow.appendChild(el('span', { className: 'sdkr-fixture-label' }, 'FIXTURES:'));
|
fixRow.appendChild(el('span', { className: 'ext-sdk-test-runner-fixture-label' }, 'FIXTURES:'));
|
||||||
|
|
||||||
// Provider dropdown
|
// Provider dropdown
|
||||||
var provSelect = el('select', { className: 'sdkr-select' });
|
var provSelect = el('select', { className: 'ext-sdk-test-runner-select' });
|
||||||
provSelect.appendChild(el('option', { value: '' }, '— provider —'));
|
provSelect.appendChild(el('option', { value: '' }, '— provider —'));
|
||||||
['openrouter', 'openai', 'anthropic', 'venice'].forEach(function (p) {
|
['openrouter', 'openai', 'anthropic', 'venice'].forEach(function (p) {
|
||||||
provSelect.appendChild(el('option', { value: p }, p));
|
provSelect.appendChild(el('option', { value: p }, p));
|
||||||
@@ -85,18 +85,18 @@
|
|||||||
|
|
||||||
// API key input
|
// API key input
|
||||||
var keyInput = el('input', {
|
var keyInput = el('input', {
|
||||||
type: 'password', className: 'sdkr-input',
|
type: 'password', className: 'ext-sdk-test-runner-input',
|
||||||
placeholder: 'API key (optional — enables model tests)'
|
placeholder: 'API key (optional — enables model tests)'
|
||||||
});
|
});
|
||||||
fixRow.appendChild(keyInput);
|
fixRow.appendChild(keyInput);
|
||||||
|
|
||||||
// Fixture status
|
// Fixture status
|
||||||
var fixStatus = el('span', { className: 'sdkr-fixture-status' }, '');
|
var fixStatus = el('span', { className: 'ext-sdk-test-runner-fixture-status' }, '');
|
||||||
|
|
||||||
fixRow.appendChild(fixStatus);
|
fixRow.appendChild(fixStatus);
|
||||||
fixturePanel.appendChild(fixRow);
|
fixturePanel.appendChild(fixRow);
|
||||||
|
|
||||||
var fixNote = el('div', { className: 'sdkr-fixture-note' },
|
var fixNote = el('div', { className: 'ext-sdk-test-runner-fixture-note' },
|
||||||
'Provide a provider + API key to unlock embedding/model tests. ' +
|
'Provide a provider + API key to unlock embedding/model tests. ' +
|
||||||
'Without a key, those tests are skipped. Key is never stored.');
|
'Without a key, those tests are skipped. Key is never stored.');
|
||||||
fixturePanel.appendChild(fixNote);
|
fixturePanel.appendChild(fixNote);
|
||||||
@@ -107,16 +107,16 @@
|
|||||||
T._fixtureInput = { provSelect: provSelect, keyInput: keyInput, statusEl: fixStatus };
|
T._fixtureInput = { provSelect: provSelect, keyInput: keyInput, statusEl: fixStatus };
|
||||||
|
|
||||||
// Controls row
|
// Controls row
|
||||||
var controlRow = el('div', { className: 'sdkr-controls' });
|
var controlRow = el('div', { className: 'ext-sdk-test-runner-controls' });
|
||||||
|
|
||||||
// Domain filter checkboxes
|
// Domain filter checkboxes
|
||||||
var filterBox = el('div', { className: 'sdkr-filters' });
|
var filterBox = el('div', { className: 'ext-sdk-test-runner-filters' });
|
||||||
filterBox.appendChild(el('span', { className: 'sdkr-filter-label' }, 'Domains: '));
|
filterBox.appendChild(el('span', { className: 'ext-sdk-test-runner-filter-label' }, 'Domains: '));
|
||||||
var domainNames = Object.keys(T.domains).sort();
|
var domainNames = Object.keys(T.domains).sort();
|
||||||
var domainChecks = {};
|
var domainChecks = {};
|
||||||
|
|
||||||
domainNames.forEach(function (name) {
|
domainNames.forEach(function (name) {
|
||||||
var label = el('label', { className: 'sdkr-check' });
|
var label = el('label', { className: 'ext-sdk-test-runner-check' });
|
||||||
var cb = el('input', { type: 'checkbox', checked: 'checked' });
|
var cb = el('input', { type: 'checkbox', checked: 'checked' });
|
||||||
domainChecks[name] = cb;
|
domainChecks[name] = cb;
|
||||||
label.appendChild(cb);
|
label.appendChild(cb);
|
||||||
@@ -126,11 +126,11 @@
|
|||||||
controlRow.appendChild(filterBox);
|
controlRow.appendChild(filterBox);
|
||||||
|
|
||||||
// Buttons
|
// Buttons
|
||||||
var btnRow = el('div', { className: 'sdkr-btn-row' });
|
var btnRow = el('div', { className: 'ext-sdk-test-runner-btn-row' });
|
||||||
var runBtn = el('button', { className: 'sdkr-btn sdkr-btn-primary', onClick: onRun }, 'Run All');
|
var runBtn = el('button', { className: 'ext-sdk-test-runner-btn ext-sdk-test-runner-btn-primary', onClick: onRun }, 'Run All');
|
||||||
var abortBtn = el('button', { className: 'sdkr-btn sdkr-btn-danger', onClick: function () { T.abort(); } }, 'Abort');
|
var abortBtn = el('button', { className: 'ext-sdk-test-runner-btn ext-sdk-test-runner-btn-danger', onClick: function () { T.abort(); } }, 'Abort');
|
||||||
abortBtn.style.display = 'none';
|
abortBtn.style.display = 'none';
|
||||||
var exportBtn = el('button', { className: 'sdkr-btn', onClick: onExport }, 'Export JSON');
|
var exportBtn = el('button', { className: 'ext-sdk-test-runner-btn', onClick: onExport }, 'Export JSON');
|
||||||
btnRow.appendChild(runBtn);
|
btnRow.appendChild(runBtn);
|
||||||
btnRow.appendChild(abortBtn);
|
btnRow.appendChild(abortBtn);
|
||||||
btnRow.appendChild(exportBtn);
|
btnRow.appendChild(exportBtn);
|
||||||
@@ -138,12 +138,12 @@
|
|||||||
mount.appendChild(controlRow);
|
mount.appendChild(controlRow);
|
||||||
|
|
||||||
// Summary bar
|
// Summary bar
|
||||||
var summaryBar = el('div', { className: 'sdkr-summary' });
|
var summaryBar = el('div', { className: 'ext-sdk-test-runner-summary' });
|
||||||
mount.appendChild(summaryBar);
|
mount.appendChild(summaryBar);
|
||||||
|
|
||||||
// Results table
|
// Results table
|
||||||
var tableWrap = el('div', { className: 'sdkr-table-wrap' });
|
var tableWrap = el('div', { className: 'ext-sdk-test-runner-table-wrap' });
|
||||||
var table = el('table', { className: 'sdkr-table' });
|
var table = el('table', { className: 'ext-sdk-test-runner-table' });
|
||||||
var thead = el('thead', {}, [
|
var thead = el('thead', {}, [
|
||||||
el('tr', {}, [
|
el('tr', {}, [
|
||||||
el('th', {}, '#'),
|
el('th', {}, '#'),
|
||||||
@@ -176,7 +176,7 @@
|
|||||||
];
|
];
|
||||||
items.forEach(function (item) {
|
items.forEach(function (item) {
|
||||||
var badge = el('span', {
|
var badge = el('span', {
|
||||||
className: 'sdkr-badge',
|
className: 'ext-sdk-test-runner-badge',
|
||||||
style: {
|
style: {
|
||||||
background: item[1] > 0 ? COLORS[item[0]] : 'var(--bg2, #333)',
|
background: item[1] > 0 ? COLORS[item[0]] : 'var(--bg2, #333)',
|
||||||
color: item[1] > 0 ? '#fff' : 'var(--text3, #888)'
|
color: item[1] > 0 ? '#fff' : 'var(--text3, #888)'
|
||||||
@@ -184,7 +184,7 @@
|
|||||||
}, LABELS[item[0]] + ': ' + item[1]);
|
}, LABELS[item[0]] + ': ' + item[1]);
|
||||||
summaryBar.appendChild(badge);
|
summaryBar.appendChild(badge);
|
||||||
});
|
});
|
||||||
summaryBar.appendChild(el('span', { className: 'sdkr-badge',
|
summaryBar.appendChild(el('span', { className: 'ext-sdk-test-runner-badge',
|
||||||
style: { background: 'var(--bg2, #333)' }
|
style: { background: 'var(--bg2, #333)' }
|
||||||
}, 'Total: ' + total));
|
}, 'Total: ' + total));
|
||||||
}
|
}
|
||||||
@@ -197,19 +197,19 @@
|
|||||||
if (entry.rawErr) detail += (detail ? ' | ' : '') + 'ICD: ' + esc(entry.rawErr);
|
if (entry.rawErr) detail += (detail ? ' | ' : '') + 'ICD: ' + esc(entry.rawErr);
|
||||||
if (entry.note) detail += (detail ? ' | ' : '') + esc(entry.note);
|
if (entry.note) detail += (detail ? ' | ' : '') + esc(entry.note);
|
||||||
|
|
||||||
var row = el('tr', { className: 'sdkr-row sdkr-row-' + v.toLowerCase() }, [
|
var row = el('tr', { className: 'ext-sdk-test-runner-row ext-sdk-test-runner-row-' + v.toLowerCase() }, [
|
||||||
el('td', {}, String(idx)),
|
el('td', {}, String(idx)),
|
||||||
el('td', {}, esc(entry.domain)),
|
el('td', {}, esc(entry.domain)),
|
||||||
el('td', {}, esc(entry.group)),
|
el('td', {}, esc(entry.group)),
|
||||||
el('td', { className: 'sdkr-test-name' }, esc(entry.name)),
|
el('td', { className: 'ext-sdk-test-runner-test-name' }, esc(entry.name)),
|
||||||
el('td', {}, [
|
el('td', {}, [
|
||||||
el('span', {
|
el('span', {
|
||||||
className: 'sdkr-verdict',
|
className: 'ext-sdk-test-runner-verdict',
|
||||||
style: { color: COLORS[v] || '#fff' }
|
style: { color: COLORS[v] || '#fff' }
|
||||||
}, LABELS[v] || v)
|
}, LABELS[v] || v)
|
||||||
]),
|
]),
|
||||||
el('td', { className: 'sdkr-ms' }, String(entry.durationMs)),
|
el('td', { className: 'ext-sdk-test-runner-ms' }, String(entry.durationMs)),
|
||||||
el('td', { className: 'sdkr-detail' }, detail)
|
el('td', { className: 'ext-sdk-test-runner-detail' }, detail)
|
||||||
]);
|
]);
|
||||||
tbody.appendChild(row);
|
tbody.appendChild(row);
|
||||||
updateSummary();
|
updateSummary();
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
/* ── Tasks Surface Styles ────────────────── */
|
/* ── Tasks Surface Styles ────────────────── */
|
||||||
|
|
||||||
.tasks-app {
|
.ext-tasks-app {
|
||||||
max-width: 1100px;
|
max-width: 1100px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 24px 20px;
|
padding: var(--sp-6) var(--sp-5);
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -11,37 +11,37 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ── Header ──────────────────────────────── */
|
/* ── Header ──────────────────────────────── */
|
||||||
.tasks-header {
|
.ext-tasks-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: var(--sp-3);
|
||||||
margin-bottom: 20px;
|
margin-bottom: var(--sp-5);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.tasks-title {
|
.ext-tasks-title {
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
.tasks-stats {
|
.ext-tasks-stats {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
}
|
}
|
||||||
.tasks-header__right {
|
.ext-tasks-header__right {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: var(--sp-3);
|
||||||
}
|
}
|
||||||
.tasks-view-tabs {
|
.ext-tasks-view-tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
background: var(--bg-raised);
|
background: var(--bg-raised);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
padding: 2px;
|
padding: 2px;
|
||||||
}
|
}
|
||||||
.tasks-view-tab {
|
.ext-tasks-view-tab {
|
||||||
padding: 5px 14px;
|
padding: 5px var(--sp-4);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
border: none;
|
border: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@@ -50,109 +50,109 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: var(--transition);
|
transition: var(--transition);
|
||||||
}
|
}
|
||||||
.tasks-view-tab:hover { color: var(--text); background: var(--bg-hover); }
|
.ext-tasks-view-tab:hover { color: var(--text); background: var(--bg-hover); }
|
||||||
.tasks-view-tab--active { color: var(--text); background: var(--bg-surface); }
|
.ext-tasks-view-tab--active { color: var(--text); background: var(--bg-surface); }
|
||||||
|
|
||||||
/* ── List View ───────────────────────────── */
|
/* ── List View ───────────────────────────── */
|
||||||
.task-list {
|
.ext-tasks-list {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 6px;
|
gap: var(--sp-2);
|
||||||
}
|
}
|
||||||
.task-list__filters {
|
.ext-tasks-list__filters {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: var(--sp-3);
|
||||||
margin-bottom: 8px;
|
margin-bottom: var(--sp-2);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.task-filter {
|
.ext-tasks-filter {
|
||||||
padding: 5px 10px;
|
padding: 5px var(--sp-3);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
background: var(--bg-raised);
|
background: var(--bg-raised);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
}
|
}
|
||||||
.task-list__count {
|
.ext-tasks-list__count {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
}
|
}
|
||||||
.task-list__loading, .task-list__empty {
|
.ext-tasks-list__loading, .ext-tasks-list__empty {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 40px 0;
|
padding: var(--sp-10) 0;
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Task Card ───────────────────────────── */
|
/* ── Task Card ───────────────────────────── */
|
||||||
.task-card {
|
.ext-tasks-card {
|
||||||
background: var(--bg-surface);
|
background: var(--bg-surface);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
padding: 12px 14px;
|
padding: var(--sp-3) var(--sp-4);
|
||||||
transition: var(--transition);
|
transition: var(--transition);
|
||||||
}
|
}
|
||||||
.task-card:hover {
|
.ext-tasks-card:hover {
|
||||||
border-color: var(--border-light);
|
border-color: var(--border-light);
|
||||||
background: var(--bg-raised);
|
background: var(--bg-raised);
|
||||||
}
|
}
|
||||||
.task-card__header {
|
.ext-tasks-card__header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: var(--sp-2);
|
||||||
}
|
}
|
||||||
.task-card__priority { font-size: 10px; }
|
.ext-tasks-card__priority { font-size: 10px; }
|
||||||
.task-card__title {
|
.ext-tasks-card__title {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.task-card__title:hover { color: var(--accent); }
|
.ext-tasks-card__title:hover { color: var(--accent); }
|
||||||
.task-card__status {
|
.ext-tasks-card__status {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
padding: 2px 8px;
|
padding: 2px var(--sp-2);
|
||||||
border-radius: 10px;
|
border-radius: var(--radius-lg);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
.task-card__status--todo { background: var(--accent-dim); color: var(--accent); }
|
.ext-tasks-card__status--todo { background: var(--accent-dim); color: var(--accent); }
|
||||||
.task-card__status--in_progress { background: var(--warning-dim); color: var(--warning); }
|
.ext-tasks-card__status--in_progress { background: var(--warning-dim); color: var(--warning); }
|
||||||
.task-card__status--done { background: var(--success-dim); color: var(--success); }
|
.ext-tasks-card__status--done { background: var(--success-dim); color: var(--success); }
|
||||||
.task-card__status--cancelled { background: var(--bg-raised); color: var(--text-3); }
|
.ext-tasks-card__status--cancelled { background: var(--bg-raised); color: var(--text-3); }
|
||||||
|
|
||||||
.task-card__desc {
|
.ext-tasks-card__desc {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--text-2);
|
color: var(--text-2);
|
||||||
margin-top: 6px;
|
margin-top: var(--sp-2);
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.task-card__footer {
|
.ext-tasks-card__footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: var(--sp-3);
|
||||||
margin-top: 8px;
|
margin-top: var(--sp-2);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
}
|
}
|
||||||
.task-card__actions {
|
.ext-tasks-card__actions {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 4px;
|
gap: var(--sp-1);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: var(--transition);
|
transition: var(--transition);
|
||||||
}
|
}
|
||||||
.task-card:hover .task-card__actions { opacity: 1; }
|
.ext-tasks-card:hover .ext-tasks-card__actions { opacity: 1; }
|
||||||
|
|
||||||
/* ── Inline buttons ──────────────────────── */
|
/* ── Inline buttons ──────────────────────── */
|
||||||
.task-btn {
|
.ext-tasks-btn {
|
||||||
border: none;
|
border: none;
|
||||||
background: var(--bg-raised);
|
background: var(--bg-raised);
|
||||||
color: var(--text-2);
|
color: var(--text-2);
|
||||||
@@ -160,86 +160,86 @@
|
|||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
transition: var(--transition);
|
transition: var(--transition);
|
||||||
}
|
}
|
||||||
.task-btn--sm { padding: 2px 8px; font-size: 13px; }
|
.ext-tasks-btn--sm { padding: 2px var(--sp-2); font-size: 13px; }
|
||||||
.task-btn:hover { background: var(--bg-hover); color: var(--text); }
|
.ext-tasks-btn:hover { background: var(--bg-hover); color: var(--text); }
|
||||||
.task-btn--danger:hover { background: var(--danger-dim); color: var(--danger); }
|
.ext-tasks-btn--danger:hover { background: var(--danger-dim); color: var(--danger); }
|
||||||
|
|
||||||
/* ── Board View ──────────────────────────── */
|
/* ── Board View ──────────────────────────── */
|
||||||
.task-board {
|
.ext-tasks-board {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, 1fr);
|
grid-template-columns: repeat(4, 1fr);
|
||||||
gap: 12px;
|
gap: var(--sp-3);
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
overflow-y: hidden;
|
overflow-y: hidden;
|
||||||
}
|
}
|
||||||
.task-board__col {
|
.ext-tasks-board__col {
|
||||||
background: var(--bg-raised);
|
background: var(--bg-raised);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
.task-board__col-header {
|
.ext-tasks-board__col-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 10px 12px;
|
padding: var(--sp-3) var(--sp-3);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-2);
|
color: var(--text-2);
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.task-board__col-count {
|
.ext-tasks-board__col-count {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
background: var(--bg-hover);
|
background: var(--bg-hover);
|
||||||
padding: 1px 7px;
|
padding: 1px 7px;
|
||||||
border-radius: 10px;
|
border-radius: var(--radius-lg);
|
||||||
}
|
}
|
||||||
.task-board__col-body {
|
.ext-tasks-board__col-body {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 8px;
|
padding: var(--sp-2);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 6px;
|
gap: var(--sp-2);
|
||||||
}
|
}
|
||||||
.task-board__card {
|
.ext-tasks-board__card {
|
||||||
background: var(--bg-surface);
|
background: var(--bg-surface);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
padding: 10px 12px;
|
padding: var(--sp-3) var(--sp-3);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: var(--transition);
|
transition: var(--transition);
|
||||||
}
|
}
|
||||||
.task-board__card:hover {
|
.ext-tasks-board__card:hover {
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
}
|
}
|
||||||
.task-board__card-title {
|
.ext-tasks-board__card-title {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: var(--sp-2);
|
||||||
}
|
}
|
||||||
.task-board__card-due {
|
.ext-tasks-board__card-due {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
margin-top: 4px;
|
margin-top: var(--sp-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Form ────────────────────────────────── */
|
/* ── Form ────────────────────────────────── */
|
||||||
.task-form { display: flex; flex-direction: column; gap: 12px; }
|
.ext-tasks-form { display: flex; flex-direction: column; gap: var(--sp-3); }
|
||||||
.task-form__row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
.ext-tasks-form__row { display: grid; grid-template-columns: 1fr 1fr; gap: var(--sp-3); }
|
||||||
.task-form__actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 4px; }
|
.ext-tasks-form__actions { display: flex; justify-content: flex-end; gap: var(--sp-2); margin-top: var(--sp-1); }
|
||||||
.task-form textarea,
|
.ext-tasks-form textarea,
|
||||||
.task-form input,
|
.ext-tasks-form input,
|
||||||
.task-form select {
|
.ext-tasks-form select {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 7px 10px;
|
padding: 7px var(--sp-3);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
background: var(--input-bg);
|
background: var(--input-bg);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
@@ -247,34 +247,34 @@
|
|||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
font-family: var(--font);
|
font-family: var(--font);
|
||||||
}
|
}
|
||||||
.task-form textarea:focus,
|
.ext-tasks-form textarea:focus,
|
||||||
.task-form input:focus,
|
.ext-tasks-form input:focus,
|
||||||
.task-form select:focus {
|
.ext-tasks-form select:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Statusbar widget ────────────────────── */
|
/* ── Statusbar widget ────────────────────── */
|
||||||
.task-status-widget {
|
.ext-tasks-status-widget {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
padding: 4px 10px;
|
padding: var(--sp-1) var(--sp-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Slot containers ─────────────────────── */
|
/* ── Slot containers ─────────────────────── */
|
||||||
.sw-slot {
|
.sw-slot {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: var(--sp-2);
|
||||||
}
|
}
|
||||||
.sw-slot--statusbar {
|
.sw-slot--statusbar {
|
||||||
padding: 4px 12px;
|
padding: var(--sp-1) var(--sp-3);
|
||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid var(--border);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.sw-slot--toolbar {
|
.sw-slot--toolbar {
|
||||||
padding: 4px 12px;
|
padding: var(--sp-1) var(--sp-3);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user