11 Commits

Author SHA1 Message Date
1236220302 Feat v0.6.18 ci bundled packages (#53)
All checks were successful
CI/CD / detect-changes (push) Successful in 4s
CI/CD / test-frontend (push) Successful in 6s
CI/CD / test-go-pg (push) Successful in 2m45s
CI/CD / test-sqlite (push) Successful in 2m58s
CI/CD / build-and-deploy (push) Successful in 1m37s
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
2026-04-01 16:49:01 +00:00
e7d1b53ebf Feat v0.6.17 bugfixes (#52)
Some checks failed
CI/CD / detect-changes (push) Successful in 16s
CI/CD / test-frontend (push) Successful in 6s
CI/CD / test-go-pg (push) Failing after 2m54s
CI/CD / test-sqlite (push) Failing after 3m7s
CI/CD / build-and-deploy (push) Has been skipped
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
2026-04-01 16:36:43 +00:00
ff19a1b4d3 Feat v0.6.16 usability survey gate (#51)
All checks were successful
CI/CD / detect-changes (push) Successful in 3s
CI/CD / test-frontend (push) Successful in 5s
CI/CD / test-sqlite (push) Successful in 2m49s
CI/CD / test-go-pg (push) Successful in 2m51s
CI/CD / build-and-deploy (push) Successful in 49s
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
2026-04-01 14:52:14 +00:00
d9802df2af Feat v0.6.15 user display audit (#50)
All checks were successful
CI/CD / detect-changes (push) Successful in 3s
CI/CD / test-frontend (push) Successful in 5s
CI/CD / test-go-pg (push) Successful in 2m51s
CI/CD / test-sqlite (push) Successful in 3m10s
CI/CD / build-and-deploy (push) Successful in 1m31s
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
2026-04-01 14:19:48 +00:00
c9b9e68c18 Feat v0.6.14 visual polish (#49)
All checks were successful
CI/CD / detect-changes (push) Successful in 4s
CI/CD / test-frontend (push) Successful in 5s
CI/CD / test-go-pg (push) Successful in 2m42s
CI/CD / test-sqlite (push) Successful in 3m1s
CI/CD / build-and-deploy (push) Successful in 1m8s
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
2026-04-01 13:25:04 +00:00
3af62a9cc5 Feat v0.6.13 responsive spacing (#48)
All checks were successful
CI/CD / detect-changes (push) Successful in 3s
CI/CD / test-frontend (push) Successful in 6s
CI/CD / test-go-pg (push) Successful in 2m45s
CI/CD / test-sqlite (push) Successful in 2m49s
CI/CD / build-and-deploy (push) Successful in 1m33s
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
2026-04-01 12:26:28 +00:00
221ae94f4f Feat v0.6.12 css isolation (#47)
All checks were successful
CI/CD / detect-changes (push) Successful in 4s
CI/CD / test-frontend (push) Successful in 6s
CI/CD / test-go-pg (push) Successful in 2m44s
CI/CD / test-sqlite (push) Successful in 2m47s
CI/CD / build-and-deploy (push) Successful in 1m46s
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
2026-04-01 11:58:39 +00:00
786bc92768 Feat v0.6.11 css dedup (#46)
All checks were successful
CI/CD / detect-changes (push) Successful in 3s
CI/CD / test-frontend (push) Successful in 5s
CI/CD / test-go-pg (push) Successful in 2m44s
CI/CD / test-sqlite (push) Successful in 2m54s
CI/CD / build-and-deploy (push) Successful in 1m17s
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
2026-04-01 11:18:28 +00:00
ca3f845c34 Feat v0.6.10 viewport foundation (#45)
All checks were successful
CI/CD / detect-changes (push) Successful in 4s
CI/CD / test-frontend (push) Successful in 5s
CI/CD / test-sqlite (push) Successful in 3m5s
CI/CD / test-go-pg (push) Successful in 3m14s
CI/CD / build-and-deploy (push) Successful in 1m44s
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
2026-04-01 10:42:44 +00:00
617d81e7d4 Feat v0.6.9 cookie fix roadmap (#44)
Some checks failed
CI/CD / detect-changes (push) Successful in 19s
CI/CD / test-frontend (push) Successful in 27s
CI/CD / test-go-pg (push) Failing after 2m46s
CI/CD / test-sqlite (push) Successful in 3m24s
CI/CD / build-and-deploy (push) Has been skipped
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
2026-04-01 09:41:59 +00:00
680ec3b897 Feat rebrand armature (#43)
All checks were successful
CI/CD / detect-changes (push) Successful in 3s
CI/CD / test-frontend (push) Successful in 5s
CI/CD / test-go-pg (push) Successful in 2m34s
CI/CD / test-sqlite (push) Successful in 2m46s
CI/CD / build-and-deploy (push) Successful in 1m55s
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
2026-03-31 23:25:37 +00:00
401 changed files with 6327 additions and 3349 deletions

View File

@@ -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,13 +406,14 @@ 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"
echo "MEMORY_LIMIT=512Mi" >> "$GITHUB_OUTPUT" echo "MEMORY_LIMIT=512Mi" >> "$GITHUB_OUTPUT"
echo "CPU_REQUEST=50m" >> "$GITHUB_OUTPUT" echo "CPU_REQUEST=50m" >> "$GITHUB_OUTPUT"
echo "CPU_LIMIT=250m" >> "$GITHUB_OUTPUT" echo "CPU_LIMIT=250m" >> "$GITHUB_OUTPUT"
echo "BUNDLED_PACKAGES=*" >> "$GITHUB_OUTPUT"
echo "env_label=dev (PR #${{ gitea.event.pull_request.number }})" >> "$GITHUB_OUTPUT" echo "env_label=dev (PR #${{ gitea.event.pull_request.number }})" >> "$GITHUB_OUTPUT"
elif [[ "${{ gitea.ref }}" == refs/tags/v* ]]; then elif [[ "${{ gitea.ref }}" == refs/tags/v* ]]; then
VERSION="${{ gitea.ref_name }}" VERSION="${{ gitea.ref_name }}"
@@ -422,13 +423,14 @@ 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"
echo "MEMORY_LIMIT=512Mi" >> "$GITHUB_OUTPUT" echo "MEMORY_LIMIT=512Mi" >> "$GITHUB_OUTPUT"
echo "CPU_REQUEST=100m" >> "$GITHUB_OUTPUT" echo "CPU_REQUEST=100m" >> "$GITHUB_OUTPUT"
echo "CPU_LIMIT=500m" >> "$GITHUB_OUTPUT" echo "CPU_LIMIT=500m" >> "$GITHUB_OUTPUT"
echo "BUNDLED_PACKAGES=notes,chat,chat-core,mermaid-renderer,schedules" >> "$GITHUB_OUTPUT"
echo "is_release=true" >> "$GITHUB_OUTPUT" echo "is_release=true" >> "$GITHUB_OUTPUT"
echo "env_label=production (${VERSION})" >> "$GITHUB_OUTPUT" echo "env_label=production (${VERSION})" >> "$GITHUB_OUTPUT"
else else
@@ -437,13 +439,14 @@ 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"
echo "MEMORY_LIMIT=512Mi" >> "$GITHUB_OUTPUT" echo "MEMORY_LIMIT=512Mi" >> "$GITHUB_OUTPUT"
echo "CPU_REQUEST=50m" >> "$GITHUB_OUTPUT" echo "CPU_REQUEST=50m" >> "$GITHUB_OUTPUT"
echo "CPU_LIMIT=250m" >> "$GITHUB_OUTPUT" echo "CPU_LIMIT=250m" >> "$GITHUB_OUTPUT"
echo "BUNDLED_PACKAGES=notes,chat,chat-core" >> "$GITHUB_OUTPUT"
echo "env_label=test (main)" >> "$GITHUB_OUTPUT" echo "env_label=test (main)" >> "$GITHUB_OUTPUT"
fi fi
@@ -579,14 +582,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 +606,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 +645,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}" \
@@ -667,13 +673,14 @@ jobs:
STORAGE_CLASS: ${{ vars.STORAGE_CLASS }} STORAGE_CLASS: ${{ vars.STORAGE_CLASS }}
STORAGE_SIZE: ${{ vars.STORAGE_SIZE || '10Gi' }} STORAGE_SIZE: ${{ vars.STORAGE_SIZE || '10Gi' }}
STORAGE_BACKEND: ${{ vars.STORAGE_BACKEND || 'pvc' }} STORAGE_BACKEND: ${{ vars.STORAGE_BACKEND || 'pvc' }}
BUNDLED_PACKAGES: ${{ steps.setup.outputs.BUNDLED_PACKAGES }}
run: | run: |
# Render PVC first (must exist before backend references it) # Render PVC first (must exist before backend references it)
if [[ -n "${STORAGE_CLASS}" ]]; then if [[ -n "${STORAGE_CLASS}" ]]; then
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 +694,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 +713,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 +725,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

View File

@@ -1,6 +1,381 @@
# Changelog # Changelog
All notable changes to Switchboard Core are documented here. All notable changes to Armature are documented here.
## v0.6.18 — CI Bundle Wiring
Wire `BUNDLED_PACKAGES` env var into the Gitea CI pipeline so each
environment gets the correct package set at boot.
### Changed
- **CI: dev deploys** — `BUNDLED_PACKAGES=*` (install all, matches docker-compose default).
- **CI: test deploys** — `BUNDLED_PACKAGES=notes,chat,chat-core` (core surfaces only).
- **CI: prod deploys** — `BUNDLED_PACKAGES=notes,chat,chat-core,mermaid-renderer,schedules`.
- **docker-compose.yml** — default `BUNDLED_PACKAGES` changed from empty to `*` (install all for local dev).
### Fixed
- **TestBundledInstall_DefaultAllowlist** — updated test assertions to match v0.6.17's empty default set (was still expecting `notes` in curated defaults).
## v0.6.17 — Bug Fixes & Welcome Logic
Fixes broken UI interactions (folder creation, team member add), dropdown
overflow, welcome surface auto-disable, and bare-install default behavior.
### Fixed
- **Notes "Add folder" button** — `prompt()` replaced with `sw.prompt()`
so the dialog renders correctly in the extension iframe sandbox.
- **Admin "Add team members"** — user list API returns `{data:[…]}`; handler
now unwraps the envelope (`Array.isArray(u) ? u : u.data`) so the user
picker populates.
- **Package filter dropdown overflow** — removed `right:0` constraint on
`.sw-dropdown__list`, added `min-width:max-content` and `overflow-x:hidden`
so option labels ("Extension", "Workflow") render fully without a scrollbar.
- **Admin actions cell wrapping** — switched `.admin-actions-cell` from
`white-space:nowrap` to flexbox with `flex-wrap:wrap; gap:4px` so buttons
don't overflow on narrow viewports.
### Changed
- **Welcome surface auto-disable** — welcome page now redirects to `/` when
any non-core extension surface is installed. Removed from the topbar
navigation surface list so it never appears alongside real surfaces.
- **Zero default bundled packages** — `defaultBundledPackages` map is now
empty. Fresh installs start bare; use `BUNDLED_PACKAGES` env var to control
what gets auto-installed per environment (`*` for all, comma-separated list
for selective).
- **Bundled filter logic** — `nil` (from `*`) means install all; empty map
(default) means install nothing. Previous code treated both as "install all".
- **User menu conditional items** — Docs, Settings, and Team Admin menu
entries only appear when those surfaces are actually enabled, not assumed.
- **SDK imperative host mount** — `ToastContainer` and `DialogStack` are now
auto-mounted by the SDK boot sequence for extension surfaces that lack an
AppShell, preventing missing toast/dialog hosts.
## 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 5m60m) and `session.refresh_token_ttl` (default 7d, clamp
1h90d) 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.9v0.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 +403,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 +1250,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 +1714,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 +1867,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 +1889,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

View File

@@ -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):**

View File

@@ -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

View File

@@ -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"

View File

@@ -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
View 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 (7681024px). 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.

View File

@@ -1,6 +1,6 @@
# Switchboard Core — Roadmap # Armature — Roadmap
## Current: v0.6.7Native mTLS ## Current: v0.6.14Visual 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. |

View File

@@ -1 +1 @@
0.6.7 0.6.18

View File

@@ -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

View File

@@ -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

View File

@@ -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}}"
} }
] ]

View File

@@ -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:

View File

@@ -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" -}}

View File

@@ -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 }}

View File

@@ -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 }}

View File

@@ -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 }}

View File

@@ -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

View File

@@ -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 }}

View File

@@ -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 }}

View File

@@ -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 }}

View File

@@ -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

View File

@@ -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:

View File

@@ -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 }}

View File

@@ -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 }}

View File

@@ -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

View File

@@ -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:

View File

@@ -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: ""

View File

@@ -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

View File

@@ -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;

View File

@@ -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"

View File

@@ -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

View File

@@ -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",

View File

@@ -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

View File

@@ -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:

View File

@@ -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: "*"

View File

@@ -13,27 +13,27 @@
# 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}
EXT_ALLOW_PRIVATE_IPS: ${EXT_ALLOW_PRIVATE_IPS:-true} EXT_ALLOW_PRIVATE_IPS: ${EXT_ALLOW_PRIVATE_IPS:-true}
LOG_FORMAT: ${LOG_FORMAT:-text} LOG_FORMAT: ${LOG_FORMAT:-text}
LOG_LEVEL: ${LOG_LEVEL:-info} LOG_LEVEL: ${LOG_LEVEL:-info}
BUNDLED_PACKAGES: ${BUNDLED_PACKAGES:-} BUNDLED_PACKAGES: ${BUNDLED_PACKAGES:-*}
# Dev seed users — ignored if ENVIRONMENT=production # Dev seed users — ignored if ENVIRONMENT=production
SEED_USERS: ${SEED_USERS:-alice:password123:user,bob:password456:user,charlie:password789:user} SEED_USERS: ${SEED_USERS:-alice:password123:user,bob:password456:user,charlie:password789:user}
volumes: volumes:

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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.

View File

@@ -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

View File

@@ -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.

View File

@@ -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
View 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}`.

View File

@@ -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

View File

@@ -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.

View File

@@ -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",

View File

@@ -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
View 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

View File

@@ -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

View File

@@ -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

View File

@@ -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:

View File

@@ -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

View File

@@ -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:

View File

@@ -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).

View File

@@ -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": [],

View File

@@ -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"],

View File

@@ -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": "",
}) })

View File

@@ -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;
} }

View File

@@ -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">&#9998;</button> <button class="ext-chat-msg__action" onClick=${startEdit} title="Edit">&#9998;</button>
<button class="chat-msg__action chat-msg__action--danger" onClick=${() => onDelete(msg.id)} title="Delete">&#128465;</button> <button class="ext-chat-msg__action ext-chat-msg__action--danger" onClick=${() => onDelete(msg.id)} title="Delete">&#128465;</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} />

View File

@@ -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"],

View File

@@ -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": [],

View File

@@ -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": [],

View File

@@ -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);
} }

View File

@@ -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>';
} }
} }

View File

@@ -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"],

View File

@@ -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": [],

View File

@@ -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;

View File

@@ -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);
}); });

View File

@@ -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"],

View File

@@ -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"],

View File

@@ -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;
}

View File

@@ -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>

View File

@@ -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"],

View File

@@ -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"],

View File

@@ -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; }

View File

@@ -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>';
} }

View File

@@ -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; }
} }

View File

@@ -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: [],

View File

@@ -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',

View File

@@ -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) --

View File

@@ -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]);

View File

@@ -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.

View File

@@ -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 () {

View File

@@ -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 + ')');

View File

@@ -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": [],

View File

@@ -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": [],

View File

@@ -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": [],

View File

@@ -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; }
} }

View File

@@ -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>
@@ -1622,7 +1622,7 @@
setShowUnfiled(true); setShowUnfiled(true);
} }
async function handleCreateFolder(parentId) { async function handleCreateFolder(parentId) {
var name = prompt('Folder name:'); var name = await sw.prompt('Folder name:');
if (!name || !name.trim()) return; if (!name || !name.trim()) return;
try { try {
await api.post('/folders', { name: name.trim(), parent_id: parentId || '' }); await api.post('/folders', { name: name.trim(), parent_id: parentId || '' });
@@ -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>
`} `}

View File

@@ -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"],

View File

@@ -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": [],

View File

@@ -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); }
} }

View File

@@ -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}

View File

@@ -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",

View File

@@ -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); }

View File

@@ -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]);

View File

@@ -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();

View File

@@ -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