15 Commits

Author SHA1 Message Date
a7e38bc72a Feat v0.7.4 docs surface work (#58)
All checks were successful
CI/CD / detect-changes (push) Successful in 4s
CI/CD / test-runners (push) Has been skipped
CI/CD / test-frontend (push) Successful in 5s
CI/CD / test-go-pg (push) Successful in 2m51s
CI/CD / test-sqlite (push) Successful in 2m51s
CI/CD / build-and-deploy (push) Successful in 39s
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
2026-04-02 14:46:54 +00:00
32e4d8725c Feat v0.7.3 extension shell migration (#57)
All checks were successful
CI/CD / detect-changes (push) Successful in 4s
CI/CD / test-runners (push) Has been skipped
CI/CD / test-frontend (push) Successful in 5s
CI/CD / test-go-pg (push) Successful in 2m51s
CI/CD / test-sqlite (push) Successful in 3m2s
CI/CD / build-and-deploy (push) Successful in 27s
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
2026-04-02 13:07:59 +00:00
d6c7b21713 Feat v0.7.2 package runners ci gate (#56)
All checks were successful
CI/CD / detect-changes (push) Successful in 4s
CI/CD / test-runners (push) Has been skipped
CI/CD / test-frontend (push) Successful in 6s
CI/CD / test-go-pg (push) Successful in 2m39s
CI/CD / test-sqlite (push) Successful in 2m55s
CI/CD / build-and-deploy (push) Successful in 29s
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
2026-04-02 12:10:57 +00:00
829caa3b20 Feat v0.7.1 surface runner framework (#55)
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 2m51s
CI/CD / test-sqlite (push) Successful in 3m2s
CI/CD / build-and-deploy (push) Successful in 1m26s
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
2026-04-01 23:01:38 +00:00
e916ed41ea V0.7.0 shell contract (#54)
All checks were successful
CI/CD / detect-changes (push) Successful in 3s
CI/CD / test-frontend (push) Successful in 6s
CI/CD / test-sqlite (push) Successful in 2m54s
CI/CD / test-go-pg (push) Successful in 2m55s
CI/CD / build-and-deploy (push) Successful in 1m5s
2026-04-01 20:19:45 +00:00
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
344 changed files with 14009 additions and 9814 deletions

View File

@@ -7,9 +7,10 @@
# #
# Pipeline: # Pipeline:
# 0. Detect changes (path-based gating for all downstream jobs) # 0. Detect changes (path-based gating for all downstream jobs)
# 1a. Frontend tests — skipped if only BE/docs changed # 1a. Frontend tests — skipped if only BE/docs/packages changed
# 1b. Go unit tests — all non-DB packages + SQLite integration (race-enabled) # 1b. Go unit tests — all non-DB packages + SQLite integration (race-enabled)
# 1c. Go test (PG) — PG store + handlers against Postgres (race-enabled) # 1c. Go test (PG) — PG store + handlers against Postgres (race-enabled)
# 1d. Test runners — disabled until v0.7.5 (Playwright headless fix)
# 2. Build + Deploy — skipped if docs-only change # 2. Build + Deploy — skipped if docs-only change
# #
# Test coverage mapping (no package tested by zero jobs): # Test coverage mapping (no package tested by zero jobs):
@@ -23,7 +24,9 @@
# Path gating rules: # Path gating rules:
# src/, src/editor/ → frontend tests # src/, src/editor/ → frontend tests
# server/, scripts/db-* → backend tests (PG + SQLite) # server/, scripts/db-* → backend tests (PG + SQLite)
# packages/ → test-runners (surface/extension tests)
# Dockerfile*, k8s/, .gitea/ → all tests (infra change) # Dockerfile*, k8s/, .gitea/ → all tests (infra change)
# ci/ → infra (CI scripts)
# docs/, *.md → skip all tests + deploy # docs/, *.md → skip all tests + deploy
# VERSION, scripts/* → frontend + backend tests # VERSION, scripts/* → frontend + backend tests
# Tags (v*) → always full pipeline # Tags (v*) → always full pipeline
@@ -100,6 +103,7 @@ jobs:
outputs: outputs:
frontend: ${{ steps.filter.outputs.frontend }} frontend: ${{ steps.filter.outputs.frontend }}
backend: ${{ steps.filter.outputs.backend }} backend: ${{ steps.filter.outputs.backend }}
packages: ${{ steps.filter.outputs.packages }}
infra: ${{ steps.filter.outputs.infra }} infra: ${{ steps.filter.outputs.infra }}
docs_only: ${{ steps.filter.outputs.docs_only }} docs_only: ${{ steps.filter.outputs.docs_only }}
steps: steps:
@@ -137,7 +141,7 @@ jobs:
echo "${CHANGED}" | sed 's/^/ /' echo "${CHANGED}" | sed 's/^/ /'
# Classify # Classify
FE=false; BE=false; INFRA=false; DOCS=false; OTHER=false FE=false; BE=false; PKG=false; INFRA=false; DOCS=false; OTHER=false
while IFS= read -r file; do while IFS= read -r file; do
[[ -z "$file" ]] && continue [[ -z "$file" ]] && continue
case "$file" in case "$file" in
@@ -145,19 +149,23 @@ jobs:
FE=true ;; FE=true ;;
server/*|scripts/db-*) server/*|scripts/db-*)
BE=true ;; BE=true ;;
packages/*)
PKG=true ;;
.gitea/*|k8s/*|Dockerfile*|docker-compose*|docker-entrypoint*|nginx.conf) .gitea/*|k8s/*|Dockerfile*|docker-compose*|docker-entrypoint*|nginx.conf)
INFRA=true ;; INFRA=true ;;
docs/*|*.md|CHANGELOG.md|LICENSE) docs/*|*.md|CHANGELOG.md|LICENSE)
DOCS=true ;; DOCS=true ;;
VERSION|scripts/*) VERSION|scripts/*)
FE=true; BE=true ;; FE=true; BE=true ;;
ci/*)
INFRA=true ;;
*) *)
OTHER=true ;; OTHER=true ;;
esac esac
done <<< "${CHANGED}" done <<< "${CHANGED}"
# Docs-only: only docs changed, nothing else # Docs-only: only docs changed, nothing else
if [[ "$DOCS" == "true" && "$FE" == "false" && "$BE" == "false" && "$INFRA" == "false" && "$OTHER" == "false" ]]; then if [[ "$DOCS" == "true" && "$FE" == "false" && "$BE" == "false" && "$PKG" == "false" && "$INFRA" == "false" && "$OTHER" == "false" ]]; then
DOCS_ONLY=true DOCS_ONLY=true
else else
DOCS_ONLY=false DOCS_ONLY=false
@@ -165,6 +173,7 @@ jobs:
echo "frontend=${FE}" >> "$GITHUB_OUTPUT" echo "frontend=${FE}" >> "$GITHUB_OUTPUT"
echo "backend=${BE}" >> "$GITHUB_OUTPUT" echo "backend=${BE}" >> "$GITHUB_OUTPUT"
echo "packages=${PKG}" >> "$GITHUB_OUTPUT"
echo "infra=${INFRA}" >> "$GITHUB_OUTPUT" echo "infra=${INFRA}" >> "$GITHUB_OUTPUT"
echo "docs_only=${DOCS_ONLY}" >> "$GITHUB_OUTPUT" echo "docs_only=${DOCS_ONLY}" >> "$GITHUB_OUTPUT"
@@ -172,6 +181,7 @@ jobs:
echo "━━━ Change Detection ━━━" echo "━━━ Change Detection ━━━"
echo " frontend: ${FE}" echo " frontend: ${FE}"
echo " backend: ${BE}" echo " backend: ${BE}"
echo " packages: ${PKG}"
echo " infra: ${INFRA}" echo " infra: ${INFRA}"
echo " docs_only: ${DOCS_ONLY}" echo " docs_only: ${DOCS_ONLY}"
@@ -366,6 +376,41 @@ jobs:
psql -c "DROP DATABASE IF EXISTS armature_ci;" postgres psql -c "DROP DATABASE IF EXISTS armature_ci;" postgres
echo "✓ Dropped CI test database" echo "✓ Dropped CI test database"
# ── Stage 1d: Surface Test Runners ──────────
# Boots the server in Docker, runs all installed test-runner packages
# via Playwright, and asserts zero failures.
#
# DISABLED: Playwright driver can't find the Run All button in headless
# mode — likely a shell/SPA rendering issue. Run manually from the
# test-runners surface after deploy until this is resolved.
# See: docker-compose.ci.yml, ci/surface-test-driver.js
#
# Runs when: backend, frontend, or packages changed (runners test all tiers).
# Skipped when: only docs changed.
test-runners:
runs-on: ubuntu-latest
needs: [detect-changes]
if: false # disabled — see comment above. Condition for v0.7.5:
# needs.detect-changes.outputs.backend == 'true' || needs.detect-changes.outputs.frontend == 'true' || needs.detect-changes.outputs.packages == 'true' || needs.detect-changes.outputs.infra == 'true'
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Run surface tests (compose)
env:
BUNDLED_PACKAGES: '*'
ARMATURE_ADMIN_USERNAME: admin
ARMATURE_ADMIN_PASSWORD: admin
ARMATURE_ADMIN_EMAIL: admin@test.local
run: |
docker compose -f docker-compose.yml -f docker-compose.ci.yml up --build \
--abort-on-container-exit \
--exit-code-from test-runner
- name: Teardown
if: always()
run: docker compose -f docker-compose.yml -f docker-compose.ci.yml down -v
# ── Stage 2: Build, Database, Deploy ───────── # ── Stage 2: Build, Database, Deploy ─────────
# #
# Depends on all test jobs. Skipped jobs (due to path gating) # Depends on all test jobs. Skipped jobs (due to path gating)
@@ -374,7 +419,7 @@ jobs:
# Skipped entirely for docs-only changes (nothing to build). # Skipped entirely for docs-only changes (nothing to build).
build-and-deploy: build-and-deploy:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [detect-changes, test-go-pg, test-frontend, test-sqlite] needs: [detect-changes, test-go-pg, test-frontend, test-sqlite, test-runners]
# Run unless: a needed job failed, the workflow was cancelled, or it's docs-only. # Run unless: a needed job failed, the workflow was cancelled, or it's docs-only.
# Skipped test jobs (path-gated) are fine — they don't block. # Skipped test jobs (path-gated) are fine — they don't block.
if: | if: |
@@ -413,6 +458,7 @@ jobs:
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 }}"
@@ -429,6 +475,7 @@ jobs:
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
@@ -444,6 +491,7 @@ jobs:
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
@@ -670,6 +718,7 @@ 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

View File

@@ -2,6 +2,553 @@
All notable changes to Armature are documented here. All notable changes to Armature are documented here.
## v0.7.4 — Documentation + Deferred Surface Work
**Docs Category Grouping**
- Backend `Category` field on `docEntry` struct in `server/handlers/docs.go`
- Frontend sidebar groups docs by category with `.docs-category-heading` CSS
- Four categories: Getting Started, Platform, Extension Development, Operations
- 14 docs in ordered list (was 7 ordered + 3 auto-discovered)
**New Documentation (4 guides)**
- `PERMISSIONS-AND-GROUPS.md` — RBAC model, 7 permission slugs, system/custom groups, settings cascade, extension permissions
- `WORKFLOWS.md` — Entry modes, stage modes/types/audiences, signoff gates, SLA enforcement, branch rules, Starlark hooks
- `STARLARK-REFERENCE.md` — Sandbox constraints, 10 modules with function signatures and permission gates, example hook script
- `FRONTEND-JS-GUIDE.md` — Preact+htm runtime, 16 SDK modules with API reference, shell topbar patterns, CSS contract
**Extension Guide Updates**
- `config_section` manifest field documented: schema, backend discovery (`configSectionsForSurface()`), frontend `__CONFIG_SECTIONS__` contract, example component
- Starlark Sandbox API section replaced with pointer to new Starlark Reference
**Docs Content Refresh**
- GETTING-STARTED: `sb_data``armature_data` volume name
- ARCHITECTURE: `sb.register()`/`sb.ns()``sw` SDK references, shell topbar mention
- DEPLOYMENT: `sb_storage``armature_storage`, added `TLS_MODE` env var
- TUTORIAL-FIRST-EXTENSION: `--bg-2``--bg-secondary` CSS variable
- EXTENSION-CSS: self-hosted font notes on `--font` and `--mono`
- `docs.go`: added `AUDIT-` and `USABILITY-` prefix filters for auto-discovery
**Team Admin Workflows Split**
- `workflows.js` (722 lines) split into 3 ES modules:
- `workflows.js` (~160 lines) — `WorkflowsSection` + `WorkflowsTab` + imports
- `workflow-editor.js` (~240 lines) — `WorkflowEditor` + `StageForm`
- `workflow-monitor.js` (~210 lines) — `AssignmentsTab` + `MonitorTab` + `SignoffPanel`
- External import contract unchanged (default export stays in `workflows.js`)
**Bug Fixes**
- Docs outline `scrollToHeading` now scrolls `.docs-content` container instead of `scrollIntoView`, preventing topbar from being pushed off-screen
- `--bg-2` (undefined CSS variable) replaced with `--bg-secondary` in `sw-shell.css` and `sw-primitives.css`
## v0.7.3 — Extension Shell Migration
**Shell Topbar Migration**
- Migrated Chat, Notes, and Schedules from legacy `sw.shell.Topbar` component to the v0.7.0 shell topbar contract (`sw.shell.topbar.setTitle/setSlot`)
- Eliminated double topbar (shell-injected + surface-owned) on all three extension surfaces
- Chat: reactive slot updates for thread title + People button when conversation changes
- Notes: slot content for + New Note, Import .md, and Graph toggle buttons
- Schedules: reactive slot with schedule count and + New Schedule button; removed legacy fallback branch
**Runner Test Updates**
- Added `shell-topbar` test suite to chat-runner, notes-runner, and schedules-runner
- Tests fetch surface JS and assert: no legacy `sw.shell.Topbar` reference, uses `sw.shell.topbar.setTitle/setSlot` API
- 6 new tests across 3 runners
**Package Versions**
- Chat surface v0.3.0, Notes surface v0.9.0, Schedules surface v0.2.0
- Chat runner v0.2.0, Notes runner v0.2.0, Schedules runner v0.2.0
**Roadmap**
- Headless E2E automation moved to v0.7.5 (independent from shell migration)
## v0.7.2 — Package Runners + CI Gate
**Package Runners (5)**
- Notes runner: `requires: ["notes"]`. 3 suites (crud, folders, tags-search), 12 tests
- Chat runner: `requires: ["chat", "chat-core"]`. 2 suites (conversations, messaging), 9 tests
- Schedules runner: `requires: ["schedules"]`. 1 suite (crud), 5 tests
- Workflow runner: `requires: ["content-approval"]`. 1 suite (lifecycle), 5 tests
- Renderer runner: `requires: ["mermaid-renderer"]`. 1 suite (contract), 4 tests
**Runner Result API**
- `POST /api/v1/admin/test-runners/results` — store structured run results
- `GET /api/v1/admin/test-runners/results` — retrieve latest results per runner
- In-memory store with 4 Go handler tests
**CI Integration**
- `test-runners` stage in Gitea CI pipeline
- Playwright driver launches headless browser, navigates to `/s/test-runners`, triggers run-all
- `wait-for-healthy.sh` polls `/healthz/ready` before test execution
- DinD networking fix: resolve container IP via `docker inspect` (port mapping not exposed to runner localhost)
## v0.7.1 — Surface Runner Framework
**`sw.testing` SDK Module**
- New kernel SDK module at `src/js/sw/sdk/testing.js`
- `sw.testing.suite(name, fn)` — register test suites with lifecycle hooks
- `sw.testing.run(name?)` — execute one or all suites, returns structured JSON
- Suite context: `s.test()`, `s.beforeAll/afterAll()`, `s.beforeEach/afterEach()`, `s.track()`, `s.skip()`
- Test context: `t.assert.ok/eq/neq/gt/match/throws/status/shape/arrayOf`, `t.warn()`, `t.skip()`
- Auto-cleanup: `track(type, id)` registers resources for LIFO deletion in afterAll
- Three result statuses: passed / failed / warned — warnings are never silent
**`test-runner` Manifest Type**
- New `"type": "test-runner"` in `ValidateManifest` — surface-like packages discovered by type
- Test-runner packages excluded from sidebar nav (not type "surface" or "full")
- Runner manifests support `"requires": [...]` — missing packages → clean skip
**ICD Runner Migration**
- Migrated from hand-rolled `T.test()`/`T.assert()` framework to `sw.testing.suite()`
- Stripped extension-dependent tests (channels, notes, personas, etc.) — those belong in v0.7.2 package runners
- Kernel-only suites: smoke, crud (admin, profile, notifications, teams, workflows, extensions, surfaces, packages), authz, security, providers, packaging, sdk
- Type changed to `"test-runner"`, standalone route removed, UI rendering delegated to registry
**SDK Runner Migration**
- Migrated from `T.dualTest()`/`T.domains` framework to `sw.testing.suite()`
- Dual-path validation preserved: SDK call + raw ICD fetch + verdict dispatch
- Stripped extension domains — kernel-only suites: misc, workflows, admin, packages, connections, dependencies, composition
- SHAPE_BUG verdict maps to `t.warn()`, SDK_BUG/ICD_BUG to `t.assert.ok(false)`
**Runner Registry Surface**
- New `test-runners` surface at `/s/test-runners` (admin-only)
- Discovers installed test-runner packages via admin packages API
- Dynamically loads each runner's JS to register suites
- Run All button, per-runner Run button, real-time results dashboard
- Suite/test results with pass/fail/warn/skip color coding, timing, error details
- Export Failures / Export Full Results buttons for JSON download
- `requires` checking with prominent skip display for missing dependencies
- Dark mode styling using kernel CSS variables
**Database Migration**
- SQLite migration 013: adds `test-runner` to packages.type CHECK constraint
- Postgres migration 014: same constraint update
## v0.7.0 — Shell Contract + Surface Audit + Rebrand
**Shell Infrastructure**
- Kernel-injected two-slot topbar for all surfaces (home, left slot, center slot, bell, user menu)
- `sw.shell.topbar` SDK API: `setLeft()`, `setSlot()`, `setTitle()`, `hide()`, `show()`
- `.sw-topbar__tabs` / `.sw-topbar__tab` CSS classes for consistent tab styling
- Shell topbar auto-mounts on extension surfaces via `#shell-topbar` div
**Backend WS Events**
- `package.changed` event broadcast on install/uninstall/enable/disable/update
- `auth.changed` event targeted to affected user on team/group membership changes
- `notification.all_read` event split from `notification.read` for cleaner badge sync
- `Hub.Broadcast()` method for untargeted all-client events
**User Menu + Bell Reactivity**
- User menu re-fetches surface list on `package.changed` and `auth.changed` events
- Notification bell syncs on `notification.read` and `notification.all_read` across tabs
**Surface Migrations**
- Settings: Pattern B (flat tabs in topbar, no sidebar, full-width content)
- Admin: Pattern C (category tabs in topbar, surface-owned sidebar below)
- Team Admin: Pattern B (flat tabs, no sidebar, Groups tab removed)
- Docs: Pattern A (shell topbar auto-renders, removed explicit Topbar import)
- All 4 surfaces now have notification bell and user menu via shell topbar
**Error Handling + Empty States**
- `.sw-inline-error` CSS primitive for inline error + retry pattern
- `.sw-empty-state` CSS primitive for guided empty states
- Admin Workflows, Packages, Groups: inline error on list fetch failure
- Admin Workflows, Groups: descriptive empty state guidance
**Announcement Global Dismiss**
- Announcement dismiss state persisted to localStorage keyed by content hash
- Dismissed once on any surface, dismissed everywhere
**Rebrand Assets**
- `favicon-light.svg` renamed to `wordmark.svg` (was a 520x80 wordmark, not an icon)
- New `favicon-light.svg`: actual square light-mode icon
- New `wordmark-dark.svg`, `wordmark-light.svg` for dark/light backgrounds
- New `favicon-light-32.png`, `favicon-light-256.png` raster icons
- Full icon library deployed to `src/icons/` (both b/e variants, animated SVGs)
- `manifest.json` description updated to "Self-hosted extension platform"
- Light-mode icon entries added to PWA manifest
**Bug Fixes**
- Docs: "On this page" outline links now scroll to headings (IDs were missing from rendered HTML)
- ICD security tier: tightened path traversal assertion (400/422, not 409), added `finally` cleanup
- Workflow demo: replaced silent catch with inline error + retry
- Team Admin: signoff panel shows display names instead of raw UUIDs
- Deleted `packages/hello-dashboard/` (dead package)
- Deleted `team-admin/groups.js` (37-line dead-end, no CRUD)
## 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
End-to-end mutual TLS without a reverse proxy. Targets systemd+podman End-to-end mutual TLS without a reverse proxy. Targets systemd+podman

View File

@@ -1,6 +1,6 @@
# Armature — Roadmap # Armature — Roadmap
## Current: v0.6.7 — Native mTLS ## Current: v0.7.4 — Documentation + Deferred Surface Work
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.
@@ -19,152 +19,187 @@ upgrade test harness, cluster registry + HA.
--- ---
## v0.6.0MVP ## v0.6.xCompleted (MVP + Hardening)
Extension, communication, and operations tracks converge. First All v0.6.x work is shipped and documented in `CHANGELOG.md`. Summary:
externally usable release.
Design docs: `docs/DESIGN-cluster-registry.md` — PG-backed cluster registry and self-assembling mesh. | Version | Title | Key Deliverables |
|---------|-------|-----------------|
### v0.6.0 Cluster Registry + HA | v0.6.0 | Cluster Registry + HA | PG-backed node registry, heartbeat sweep, LISTEN/NOTIFY routing, self-eviction |
| v0.6.1 | Backup/Restore + Docs | `.swb` archive format, server-side backups, docs surface + 5 guides |
PG is the consensus layer. Zero new infrastructure. `UNLOGGED` table + `LISTEN/NOTIFY` replaces etcd/Consul/Redis for homelab-to-small-team scale. | v0.6.2 | Docs Polish + OpenAPI | Dark mode fix, topbar nav, `api_schema` manifest field, dynamic spec builder |
| v0.6.3 | Dead Code Sweep | Registry install fix, dead Go/JS/HTML deletion, narrowed default bundle |
| Step | Status | Description | | v0.6.4 | Admin Health/Metrics | Cluster dashboard merged into Admin tab, block renderer `requires` removed |
|------|--------|-------------| | v0.6.5 | Renderer Pipeline | `sw.renderers.register()` kernel primitive, unified markdown, docs rewrite |
| `node_registry` table | ✅ | `UNLOGGED TABLE` — node_id, endpoint, seq, registered_at, heartbeat, stats JSONB. Postgres migration 013. | | v0.6.6 | Final Hardening | Dependency auto-activation, `ValidateManifest()`, OIDC nonce, ICD/SDK update |
| Node registration | ✅ | Self-registration on startup: `INSERT ... ON CONFLICT DO UPDATE`. `node_id` = `hostname-PID` or `CLUSTER_NODE_ID` env override. | | v0.6.7 | Native mTLS | `TLS_MODE` config, `MTLSNativeProvider`, node-to-node mTLS, `armature-ca.sh` |
| Heartbeat tick | ✅ | Every 10s: update own heartbeat + collect runtime stats (goroutines, heap, GC, uptime, ws_clients). | | v0.6.8 | Cookie Fix + UI Roadmap | Cookie SameSite fix, UI hardening roadmap published |
| Stale sweep | ✅ | Every heartbeat tick: `DELETE WHERE heartbeat < now() - 30s`. All nodes run it — idempotent, no ring topology. | | v0.6.9 | Session Lifetime Config | Admin-configurable TTLs, idle timeout, "keep me logged in" |
| Self-eviction | ✅ | If heartbeat UPDATE returns 0 rows: node was swept by peer → log error + `os.Exit(1)`. K8s restarts → re-register. | | v0.6.10 | Viewport Foundation | Single layout model, CSS zoom, 100dvh, dead shell deprecated |
| LISTEN/NOTIFY routing | ✅ | Durable events (messages, state changes) fan-out via `pg_notify`. All replicas receive, push to local WS subscribers, drop if irrelevant. Phase 1: ephemeral events (typing, presence) also via NOTIFY (8KB limit, ~60 bytes each). | | v0.6.11 | CSS Deduplication | Old primitive system retired, one class per concept |
| Cluster API | ✅ | `GET /api/v1/admin/cluster` — returns `{data: [...]}` envelope with all registered nodes. | | v0.6.12 | Extension CSS Isolation | Prefix enforcement via linter, all 12 in-tree packages migrated |
| Admin cluster dashboard | ✅ | `cluster-dashboard` surface package renders one card per node: node_id, endpoint, uptime, ws_clients, heap, GC pause. JSONB stats — future keys render automatically, no schema migration. Auto-refresh every 10s. | | v0.6.13 | Responsive & Spacing | Spacing token scale (4px grid), tablet breakpoint |
| Health endpoint | ✅ | `GET /health` includes `node_id` and `cluster: {size, peers, heartbeat_age_ms}`. | | v0.6.14 | Visual Polish | Stale fallback colors purged, fonts self-hosted, radius tokens |
| Config | ✅ | `CLUSTER_NODE_ID`, `CLUSTER_HEARTBEAT_INTERVAL` (default 10s), `CLUSTER_STALE_THRESHOLD` (default 30s), `CLUSTER_ENDPOINT` (Phase 2 mesh, auto-detect). | | v0.6.15 | User Display Audit | Batch user resolve API, `sw.users` SDK module |
| Single-node regression | ✅ | One-node behavior identical to pre-cluster: one registry row, NOTIFY delivers back to same instance. No special-casing. SQLite returns nil store — all cluster code guarded. | | v0.6.16 | Usability Survey Gate | Four audit scripts, contrast/touch-target fixes |
| Multi-node integration test | ✅ | Docker Compose: 3 instances, shared PG. `ci/e2e-cluster-test.sh`: registration, stale sweep on stop, re-registration on restart. 3 unit tests + 2 handler tests. | | v0.6.17 | Bug Fixes & Welcome | Notes folder fix, team member add fix, welcome auto-disable, zero default bundle |
| v0.6.18 | CI Bundle Wiring | `BUNDLED_PACKAGES` env var wired into Gitea CI pipeline |
### v0.6.1 — Backup/Restore + Documentation
| Step | Status | Description |
|------|--------|-------------|
| Backup handler | ✅ | `POST /api/v1/admin/backup` streams `.swb` ZIP (JSONL core + ext_data tables + package assets). `POST /api/v1/admin/restore` wipes DB and restores from archive. Dialect-neutral (SQLite + Postgres). |
| Server-side backups | ✅ | `GET /api/v1/admin/backups` list, `GET /download`, `DELETE`. Store backups in `{STORAGE_PATH}/backups/`. |
| Admin backup section | ✅ | New "Backup" section under `/admin/backup`. Create (download or server-side), list, download, delete, restore with destructive confirmation. |
| Documentation API | ✅ | `GET /api/v1/docs` lists, `GET /api/v1/docs/:name` returns raw markdown. Authenticated (not admin-only). |
| Docs surface | ✅ | Builtin surface at `/docs/:section`. Sidebar navigation, client-side markdown renderer. 5 new docs: Getting Started, Extension Guide, API Reference, Deployment, Package Format. |
| Tests | ✅ | 6 handler tests (basic backup, ext_data backup, round-trip restore, schema mismatch, dump/restore table, list empty). E2E script `ci/e2e-backup-test.sh`. |
### v0.6.2 — Docs Polish + Dynamic OpenAPI
| Step | Status | Description |
|------|--------|-------------|
| Dark mode fix | ✅ | Added `--bg-code` to CSS variables (dark + light). Replaced all hardcoded light-mode fallbacks in docs CSS with theme-aware variables. Table styling, code blocks, nav items all readable in dark mode. |
| Loading & error handling | ✅ | Replaced borrowed `settings-placeholder` with docs-specific pulse animation. Added error state with retry button for failed doc list fetch. |
| Topbar navigation | ✅ | Imported `Topbar` + `UserMenu` into docs surface. Users can now navigate to other surfaces via the avatar menu. |
| Docs icon + menu entry | ✅ | Added `📖 Docs` entry to UserMenu standard items. Docs accessible from any surface's user menu. |
| `api_schema` manifest field | ✅ | Optional `api_schema` array in `manifest.json`. Parsed lazily by spec builder. Malformed entries logged and skipped — never blocks extension loading. |
| OpenAPI spec builder | ✅ | `BuildOpenAPISpec()` merges static kernel spec with extension routes. Tier 1: auto-generated stubs for all `api_routes`. Tier 2: `api_schema` replaces stubs with rich path items (params, body, response). |
| Dynamic spec endpoint | ✅ | `GET /api/docs/openapi.json` serves merged spec. Swagger UI updated to use JSON endpoint. Static YAML preserved for backward compat. |
| Tests | ✅ | 7 new handler tests: zero extensions, stubs, rich schema, multi-extension, malformed schema, disabled exclusion, required fields. |
--- ---
## v0.6.x — Hardening ## v0.7.x — Test Infrastructure + Quality Gate
Closes every audit finding before the public release. No new features — only correctness, dead code elimination, and architectural cleanup. Sequence is fixed: each version is a gate for the next. The v0.6.x series built the kernel. v0.7.x makes it provably correct.
### v0.6.3 — Dead Code Sweep + Registry Fix A full surface audit (docs/AUDIT-surfaces.md) found 7 cross-surface issues
and 18 surface-specific issues across Settings, Admin, Team Admin, and Docs.
Only Docs is properly built. The fix is three phases: (1) establish a shell
contract and bring all four primary surfaces to parity, (2) build a runner
framework for end-to-end browser tests, (3) automate those runners headlessly
in CI.
Pure cleanup. No behavior changes except fixing the broken registry install flow. Design docs:
- `docs/DESIGN-shell-contract.md` — two-slot topbar, three navigation patterns, surface migrations
- `docs/DESIGN-surface-runners.md` — runner framework, requires declarations, headless E2E
- `docs/AUDIT-surfaces.md` — full audit findings
### v0.7.0 — Shell Contract + Surface Audit + Rebrand Cleanup
Design doc: `docs/DESIGN-shell-contract.md`
**Shell Infrastructure**
| Step | Status | Description | | Step | Status | Description |
|------|--------|-------------| |------|--------|-------------|
| Fix registry install | ✅ | SDK sends `{ url }`, handler expects `{ download_url }`. Fix `api-domains.js` to send `{ download_url: url }`. Every Install click currently returns 400. | | Shell topbar (two-slot model) | done | Kernel injects topbar into `surface-extension` template. Two named slots: **left** (defaults to manifest title) and **center** (`flex: 1`, for tabs/pickers/search). Home link, notification bell, and user menu always present. |
| Registry settings UI | ✅ | Add "Package Registry" section to admin settings with URL input field. Only way to configure registry today is a raw `PUT /api/v1/admin/settings/package_registry` — no user will find it. | | Topbar customization API | done | `sw.shell.topbar.setLeft(vnode)` overrides left slot. `sw.shell.topbar.setSlot(vnode)` sets center slot. `sw.shell.topbar.setTitle(str)` shorthand for text-only left. `sw.shell.topbar.hide()` / `.show()` for full-bleed surfaces. |
| Registry tooling + docs | ✅ | `scripts/generate-registry.sh` scans a directory of `.pkg` files and emits registry JSON. `docs/PACKAGE-REGISTRY.md` documents the format. | | Kernel tab CSS | done | `.sw-topbar__tabs` and `.sw-topbar__tab` classes for consistent tab styling in the center slot. Surfaces use these for free or style their own slot content. |
| Delete dead kernel Go | ✅ | `store/interfaces.go:178183` — orphaned ChannelListFilter comments. `pages/pages.go:922930``roleFilterType()` + template registration (chat vestige, maps nonexistent roles). `main.go:67` — orphaned provider-type comment. | | Notification read broadcast | done | Backend emits `notification.read` and `notification.all_read` WS events. Bell listens for `.created`, `.read`, `.all_read`. Cross-surface sync without refetch. |
| Delete dead vendor JS | ✅ | `vendor/marked.min.js` and `vendor/purify.min.js` — 62KB, zero production imports. Only referenced in test helpers. | | User menu reactivity | done | Emit `package.changed` (install/uninstall/enable/disable) and `auth.changed` (role/membership) WS events. UserMenu listens and re-fetches surface list. Most impactful single fix. |
| Delete `dev.html` | ✅ | 676 lines, not imported by anything. Dead. | | Shell announcement global dismiss | done | Dismissed state persisted to localStorage keyed by content hash. Dismiss once, dismissed everywhere. |
| Remove `dashboard` from default bundle | ✅ | Requires `legacy-sdk` (doesn't exist), auto-installs as dormant on fresh installs. Confusing. Remove from `defaultBundledPackages`. |
| Remove `hello-dashboard` | ✅ | Proof-of-concept from early development. Move to `examples/` or delete. |
| Strip stale version comments | ✅ | 60+ files had legacy version annotations (`// v0.29.x:`, `// v0.33.x:`). Single sed pass. |
| Narrow default bundle | ✅ | Default: `notes`, `chat`, `chat-core`, `mermaid-renderer`, `schedules`. Everything else available via registry or `BUNDLED_PACKAGES=*`. |
### v0.6.4 — Admin Health/Metrics Tab + Cluster Merge **Surface Migrations**
Structural move: cluster dashboard becomes an Admin tab. Better home for health/metrics — shared context with other admin panels, no separate nav entry.
| Step | Status | Description | | Step | Status | Description |
|------|--------|-------------| |------|--------|-------------|
| "Health / Metrics" admin tab | ✅ | New tab in Admin surface. DB-agnostic metrics for all deployments. Cluster cards conditional on PG + multi-node detection. | | Settings → Pattern B (flat tabs) | done | Delete custom topbar + sidebar nav. 6 sections become flat tabs in topbar center slot. Content full-width. Fix Teams section: add team admin link, role display, leave action. Remove sessionStorage return URL logic. |
| Runtime metrics | ✅ | Per-node: goroutines, heap alloc/sys, stack in use, GC cycles, last GC pause, GC CPU %, uptime, WebSocket clients, extensions loaded, open FDs. | | Admin → Pattern C (category tabs + sidebar) | done | Delete custom `admin-topbar`. `setLeft()` for favicon + "Administration". `setSlot()` for category tabs (People / Workflows / System / Monitoring). Admin sidebar (sub-navigation) unchanged — surface-owned, below the topbar. Bell + user menu come free from shell. Delete bespoke CatIcon renderer if using standard SVGs. |
| DB pool metrics | ✅ | All deployments: DB latency (`SELECT 1` round-trip), pool active/idle/max, wait count, wait duration. PG-only: table bloat (`n_dead_tup`), active backends (`pg_stat_activity`). | | Team Admin → Pattern B (flat tabs) | done | Delete custom topbar + sidebar nav. 5 sections (Members / Connections / Workflows / Settings / Activity — Groups removed) become flat tabs. Content full-width. `setTitle()` for team-specific name. Remove sessionStorage return URL. Fix signoff user display (`user_id``sw.users.displayName()`). |
| Cluster metrics | ✅ | PG multi-node only: cluster size, peer list with endpoint + uptime, heartbeat age per node, event bus publish/deliver rates. | | Team Admin: remove Groups tab | done | 37-line dead-end. Read-only "No groups" with no create/docs/link. Remove until team-scoped group management is properly designed. |
| Extension runtime metrics | ✅ | Starlark exec/min, errors/min, avg duration, HTTP outbound requests/min, trigger fires/min, schedule overruns. | | Docs → Pattern A (default) | done | Delete explicit Topbar import. Shell topbar auto-renders with manifest title. Docs sidebar (document list) is in content area, unaffected. |
| Fatten heartbeat payload | ✅ | Heartbeat JSONB carries full metric set. `GET /api/v1/admin/metrics` for single-node SQLite fallback (same shape). |
| Retire `cluster-dashboard` | ✅ | Remove package once Admin Health tab ships. Update `defaultBundledPackages`. |
| Fix block renderer `requires` | ✅ | `mermaid-renderer`, `katex-renderer`, `csv-table`, `diff-viewer` all have `"requires": ["chat"]`. These are content renderers, not chat features. Remove constraint — they should activate without chat. |
| Health endpoint consolidation | ✅ | `/health` and `/api/v1/health` return near-identical JSON. Merge or clearly differentiate with docs. |
### v0.6.5 — Renderer Pipeline + Docs Rewrite **Error Handling + UX Pass**
Most complex sub-version. Lifts block rendering to a kernel SDK primitive so all surfaces share it, then rewrites the docs for an external audience.
| Step | Status | Description | | Step | Status | Description |
|------|--------|-------------| |------|--------|-------------|
| SDK renderer primitive | ✅ | `sw.renderers.register(pattern, handler)` — kernel-level registration. Extensions call once; all surfaces consume. Decouples renderer discovery from chat surface. | | Inline error states | done | Replace `catch { toast }` with inline error + retry on all list endpoints. New `.sw-inline-error` CSS primitive. Systematic pass across Settings, Admin, Team Admin. |
| Notes hooks SDK renderer pipeline | ✅ | Notes delegates to `sw.markdown.renderSync()` + `sw.renderers.runPostRenderers()`. Hand-rolled renderer deleted. | | Empty state guidance | done | Every "No X" message gets one-line explanation + primary action (create button or doc link). Admin Groups, Workflows, Teams; Team Admin Workflows; Settings Notifications. |
| Docs hooks SDK renderer pipeline | ✅ | Docs delegates to `sw.markdown.renderSync()` + `sw.renderers.runPostRenderers()`. Hand-rolled renderer deleted (~160 lines). |
| Unify markdown renderer | ✅ | `sw.markdown` module lazy-loads `marked` v16 (vendored). Notes, Docs, Chat all consume it. Two hand-rolled parsers deleted. |
| Sanitize HTML output | ✅ | DOMPurify wired as default post-render step in `sw.markdown`. SVG-safe config allows mermaid output. Notes sanitizes; Docs opts out (system-authored). |
| Mermaid/KaTeX/CSV/Diff work everywhere | ✅ | Browser extension loader injects renderer scripts into all pages. Extensions register via `sw:ready` event. All four render in Notes, Docs, and Chat. |
| Docs rewrite for external audience | ✅ | All fork references removed from docs, CHANGELOG, ROADMAP. DESIGN-WORKFLOW-REDESIGN-0.2.6.md replaced with DESIGN-WORKFLOWS.md. |
| Add Mermaid architecture diagrams | ✅ | Six diagrams in ARCHITECTURE.md: system overview, request flow, extension lifecycle, realtime events, settings cascade, cluster topology. |
| Surface alias decision | ✅ | Migrated SDK + ICD runner to `/admin/packages/`; aliases removed from main.go. |
| `CONTRIBUTING.md` + tutorial | ✅ | CONTRIBUTING.md at repo root + docs/TUTORIAL-FIRST-EXTENSION.md walkthrough. |
### v0.6.7 — Native mTLS **Bug Fixes**
End-to-end mutual TLS without a reverse proxy. Targets systemd+podman deployments where every connection (client→server, node→node) is mTLS. Design: `docs/DESIGN-native-mtls.md`.
| Step | Status | Description | | Step | Status | Description |
|------|--------|-------------| |------|--------|-------------|
| `TLS_MODE` config | ✅ | Three values: `none` (default, plain HTTP) · `server` (TLS, no client cert) · `mtls` (mutual TLS, client cert required). Independent of `AUTH_MODE`. | | evil-chat cleanup | done | ICD security tier: `finally` cleanup block + tighten `409` assertion. |
| 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. | | Workflow demo error surfacing | done | Replace silent `catch` with inline error + retry. |
| `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`. | | Hello dashboard removal | done | Delete `packages/hello-dashboard/`. |
| 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. |
| `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`. |
| Integration tests | ✅ | Real TLS listener on localhost. No cert / wrong CA / expired → TLS handshake rejected. Valid cert → 200. Peer certificate CN + email visibility verified. |
### v0.6.6 — Final Hardening **Rebrand**
Final pass before public release. Security, correctness, and developer experience.
| Step | Status | Description | | Step | Status | Description |
|------|--------|-------------| |------|--------|-------------|
| Extension dependency auto-activation | ✅ | Installing a package with unmet `depends`/`requires` auto-installs dependencies from the bundled set. If not bundled: clear error listing what's missing. | | Light-mode icon SVG | done | New `favicon-light.svg` — square icon, transparent bg, dark node fills. Rename current `favicon-light.svg` (wordmark) to `wordmark.svg`. |
| `ValidateManifest()` gate | ✅ | Single `ValidateManifest()` function in `package_validate.go`. Called at install time (both upload and bundled). 12 unit tests. | | Dark-mode wordmark SVG | done | New `wordmark-dark.svg` — light text for dark backgrounds. |
| Package signing hook | ✅ | Optional `signature` field in manifest (reserved). `PACKAGE_VERIFY_SIGNATURES` env var (default false, log-only). No cryptographic code yet — schema slot reserved. | | Light-mode raster assets | done | `favicon-light-32.png`, `favicon-light-256.png`. |
| OIDC state nonce validation | ✅ | `oidcClaims.Nonce` field added. `ValidateIDTokenNonce()` compares ID token nonce against stored state. Callback rejects mismatched nonces. | | PWA manifest description | done | "Self-hosted extension platform — build, compose, and run extensions." |
| Schema migration stub decision | ✅ | Stub replaced with log-only function documenting additive-only policy. Downgrade rejection preserved. | | REBRAND-SPEC.md | | Land into `docs/`. Find/replace patterns, validation checklist, asset inventory. |
| ICD/SDK runner update pass | ✅ | ICD smoke tier: added metrics, cluster, backups, docs, OpenAPI JSON endpoints. SDK admin domain: added metrics, cluster, backups tests. | | base.html favicon swap | done | Verify theme swap works with new square light icon. |
| Stale TODO resolution | ✅ | `main.go` session middleware: replaced with `OptionalAuth()` (auth if token present, anonymous pass-through). `auth.go:221` OIDC nonce: resolved. `starlark_helpers.go:96` migration stub: resolved. |
Then ship. **Tests**
| Step | Status | Description |
|------|--------|-------------|
| Shell topbar renders for extensions | done | Home, left slot, center slot, bell, user menu present. |
| Topbar API (setLeft, setSlot, hide) | done | Custom content renders. Hide removes topbar. |
| All 4 surfaces use shell topbar | done | No double topbars. Each pattern (A/B/C) renders correctly. |
| User menu reactive to package install | | Install → menu updates without reload. |
| Notification bell cross-surface sync | | Dismiss on Notes → clears on Chat. |
| Inline error on API failure | | Error + retry shown, not empty list. |
### v0.7.1 — Surface Runner Framework
Design doc: `docs/DESIGN-surface-runners.md`
| Step | Status | Description |
|------|--------|-------------|
| Runner framework (`sw.testing`) | done | SDK module: suite/test/assert, lifecycle hooks, structured JSON results. |
| `requires` declarations | done | Runner manifests declare dependencies. Missing packages → clean skip. |
| Cleanup enforcement | done | `s.track(type, id)` auto-deletes in `afterAll`. No leaked state. |
| Warning tier | done | pass / fail / warning. No silent catch swallowing. |
| ICD runner migration | done | Refactor to `sw.testing`. Test logic preserved. |
| SDK runner migration | done | Refactor to `sw.testing`. Domain suites preserved. |
| Runner registry surface | done | `/s/test-runners` — list runners, run-all, results dashboard. |
### v0.7.2 — Package Runners + CI Gate
| Step | Status | Description |
|------|--------|-------------|
| Notes runner | done | `requires: ["notes"]`. 3 suites (crud, folders, tags-search), 12 tests. |
| Chat runner | done | `requires: ["chat", "chat-core"]`. 2 suites (conversations, messaging), 9 tests. |
| Schedules runner | done | `requires: ["schedules"]`. 1 suite (crud), 5 tests. |
| Workflow runner | done | `requires: ["content-approval"]`. 1 suite (lifecycle), 5 tests. |
| Renderer runner | done | `requires: ["mermaid-renderer"]`. 1 suite (contract), 4 tests. |
| Runner result API | done | `POST/GET /api/v1/admin/test-runners/results`. In-memory store, 4 Go tests. |
| CI integration | done | `test-runners` stage in Gitea CI. Playwright driver, `wait-for-healthy.sh`. |
| CI DinD networking fix | done | Resolve container IP via `docker inspect` — DinD port mapping doesn't expose to runner localhost. |
### v0.7.3 — Extension Shell Migration
Chat, Notes, and Schedules still use the old `sw.shell.Topbar` component,
producing a double topbar (shell-injected + surface-owned). Migrate all
three to the v0.7.0 shell contract (`sw.shell.topbar.setTitle/setSlot`),
same pattern as the kernel surface migrations.
| Step | Status | Description |
|------|--------|-------------|
| Chat → shell topbar | done | Delete `<Topbar>`, use `setTitle('Chat')` + `setSlot()` for thread title/people button. |
| Notes → shell topbar | done | Delete `<Topbar>`, use `setTitle('Notes')` + `setSlot()` for action buttons. |
| Schedules → shell topbar | done | Delete `<Topbar>`, use `setTitle('Schedules')` + `setSlot()` for count/new button. |
| Update package runner tests | done | Shell-topbar test suite in each runner — assert no legacy Topbar, uses shell API. |
### v0.7.4 — Documentation + Deferred Surface Work
| Step | Status | Description |
|------|--------|-------------|
| Docs category grouping | done | Backend `Category` field on doc entries. Frontend groups sidebar by category with headings. Four categories: Getting Started, Platform, Extension Development, Operations. |
| Permissions & Groups guide | done | RBAC model, 7 permission slugs, system/custom groups, settings cascade, extension permissions. |
| Workflows user guide | done | Entry modes, stages, team roles, signoff gates, SLA, public forms, branch rules, Starlark hooks. |
| Starlark Reference | done | Sandbox constraints, 10 modules with function signatures, permission gates, example hook script. |
| Frontend JS Guide | done | Preact+htm runtime, 16 SDK modules with API reference, shell topbar patterns, CSS contract. |
| Extension config_section docs | done | Manifest schema, backend discovery, frontend contract, example component. Added to Extension Guide. |
| Docs content refresh | done | All 10 user-facing docs reviewed for v0.7.x accuracy: rebrand volume names, stale CSS vars, TLS_MODE env, self-hosted font notes, Architecture frontend section. |
| Team Admin Workflows split | done | 722-line `workflows.js` split into 3 modules: `workflows.js` (router+CRUD), `workflow-editor.js` (editor+stages), `workflow-monitor.js` (assignments+monitor+signoff). |
| Stale CSS variable fix | done | `--bg-2` references in `sw-shell.css` and `sw-primitives.css` replaced with `--bg-secondary`. |
### v0.7.5 — Headless E2E + CI Gate
| Step | Status | Description |
|------|--------|-------------|
| Playwright test harness | | `ci/e2e-surface-test.sh` — docker-compose, chromium, run-all, assert. |
| Screenshot-on-failure | | Full-page screenshot + console log. CI artifacts. |
| Navigation smoke test | | Playwright visits every surface. Asserts topbar, no JS errors, home link works. |
| Visual regression baseline | | Optional screenshot diff. Not a gate — report for review. |
| CI pipeline integration | | Enable `test-runners` stage, add `e2e-smoke` stage. Failure blocks merge. |
--- ---
## Post-MVP ## Post-v0.7.x
- LLM participation (`llm-bridge` extension: subscribes to `chat.message.created`, calls `provider.complete()`, streams response via `realtime.publish`, posts via `chat.send()`. Bot participants with persona config. Multi-model conversations.) - **LLM participation** (`llm-bridge` extension)
- Rich media extensions: image generation, code sandbox, STT/TTS - **Rich media extensions:** image generation, code sandbox, STT/TTS
- Desktop app (Tauri or Electron) - **Desktop app** (Tauri or Electron)
- Sidecar tier: container-based extensions - **Sidecar tier:** container-based extensions
- Federation: cross-instance package sharing - **Federation:** cross-instance package sharing
- Plugin marketplace with signing and review - **Plugin marketplace** with signing and review
--- ---
@@ -172,22 +207,24 @@ Then ship.
| Decision | Rationale | | Decision | Rationale |
|----------|-----------| |----------|-----------|
| Tasks → extension | Scheduler was the most entangled kernel component (~3,400 lines). Rebuilding as extension validates the trigger system and removes the worst compilation debt. Three trigger primitives (time, webhook, event) replace the monolithic scheduler. | | Tasks → extension | Three trigger primitives replace the monolithic scheduler. |
| Sessions removed | Kernel-managed sessions replaced by workflow instances with dedicated storage (ext_data tables or kernel table). | | Sessions removed | Workflow instances with dedicated storage replace kernel sessions. |
| `custom` stage mode | Stage mode `custom` delegates to a surface package, proving extension composability. Chat-in-workflow is handled by the chat extension, not the kernel. | | `custom` stage mode | Delegates to a surface package, proving extension composability. |
| Providers removed from kernel | Provider configs, model catalog, routing policies — all moved to extension track. Kernel provides credential storage (connections) and the Starlark `provider.complete` module as the interface. | | Providers removed from kernel | Connections + Starlark `provider.complete` as the interface. |
| Kernel permissions simplified | 6 platform permissions. Extensions define their own capability requirements in manifests. | | Kernel permissions simplified | 6 platform permissions. Extensions define their own. |
| Preact+htm retained | 3KB runtime, no build step, works for extension authors without bundler config. KISS. | | Preact+htm retained | 3KB runtime, no build step, KISS. |
| Single Docker image | Drop the frontend/backend split. Go binary + assets + migrations in one image. Simpler deployment, fewer moving parts. | | Single Docker image | Go binary + assets + migrations. |
| Admin → RBAC group | The `role` column is pre-RBAC. v0.2.0 replaces it with a seeded "Admins" group + `surface.admin.access` grant. All users auto-join "Everyone" group. Admin middleware becomes a grant check, not a role check. | | Admin → RBAC group | Grant check replaces role check. |
| Settings cascade | RBAC controls scope auth (who can set at what level). `user_overridable` flag controls whether lower scopes can override higher. Two orthogonal axes, composes cleanly with extension manifests. | | Settings cascade | Scope auth + `user_overridable`. Two orthogonal axes. |
| No new migrations pre-MVP | Edit existing migration SQL files in place. No migration chains until schema is in production. | | No new migrations pre-MVP | Proper versioned migrations post-MVP. |
| Notes over Editor | First surface is Obsidian-style notes (rich text, folders, backlinks) instead of a code editor. Notes is a stronger E2E proof — it exercises ext_data, storage, and the SDK more fully than a pure-browser CM6 editor. | | Chat as extension, not kernel | Zero kernel awareness. Proves extensibility thesis. |
| No built-in auto-install | Extensions ship in the repo but are not auto-installed. Distribution model TBD — explicit install only. Keeps the kernel clean and avoids opinionated defaults. | | PG as consensus layer | UNLOGGED node_registry + LISTEN/NOTIFY. No etcd/Consul/Redis. |
| Chat as extension, not kernel | Human-to-human chat built entirely as library + surface packages. Zero kernel awareness of conversations, messages, or participants. Kernel gains one generic `realtime` module. Proves near-infinite extensibility. LLM participation layers on top via a separate bridge extension — the chat system doesn't know or care whether a participant is human or AI. | | Two trigger tiers | Extension-declared (full sandbox) vs user ad-hoc (restricted). |
| PG as consensus layer | Horizontal scaling uses PG as the sole coordinator (UNLOGGED node_registry + LISTEN/NOTIFY). No etcd, Consul, Redis, or Raft. Rationale: system is already tightly coupled to PG; adding a second consensus layer doubles operational complexity for zero benefit at homelab-to-small-team scale. UNLOGGED table is visible to all connections, survives session disconnect, and cleans up automatically on PG crash — correct behavior since all nodes are dead anyway. Sweep-all for health (every node deletes stale rows) is simpler than ring topology and has no edge cases. | | Builtin package rationale | Must enhance kernel surfaces or demonstrate platform capabilities. |
| Two trigger tiers | Event + webhook triggers are extension-declared (manifest contract, full sandbox). Scheduled tasks are user-created ad-hoc (restricted sandbox — no raw HTTP, no DB table creation, connections-only outbound). Separation keeps extension contracts static and user automation safe. | | Two-slot topbar model | Left slot (title/branding) + center slot (`flex: 1`, tabs/pickers). Two named slots cover every navigation pattern: simple title (Pattern A), flat tabs full-width (Pattern B), category tabs + surface-owned sidebar (Pattern C). Surfaces without sub-items get full content width; surfaces with hierarchical navigation add their own sidebar below the topbar. Shell provides the stage; surface decides the theater. |
| Scheduled task identity | Tasks run as their creator (RBAC-scoped). Admin-created tasks can opt into system context. Creator deactivation pauses the schedule. Ensures audit trail and permission boundaries. | | All four primary surfaces migrate to shell topbar | Admin was "keep custom topbar" initially. The two-slot model makes it unnecessary — category tabs fit in the center slot, sidebar is surface-owned below. One topbar implementation replaces four. Bell + user menu + reactivity come free on every surface. |
| Builtin package rationale | A builtin must enhance kernel surfaces or be required to demonstrate the platform's own capabilities. **notes** — primary content surface, exercises ext_data/storage/SDK/settings/realtime fully. **chat + chat-core** — primary communication surface, proves the extensibility thesis (100% extension, zero kernel awareness). **mermaid-renderer** — docs surface uses Mermaid diagrams to explain the architecture; without it the platform's own documentation doesn't render (self-bootstrapping). **schedules** — UI for the kernel's scheduled task system; without it users can't manage cron jobs (kernel primitive UI). All others are domain features, examples, dormant, or LLM-only tools — available via registry or `BUNDLED_PACKAGES=*`. | | Settings / Team Admin → flat tabs (Pattern B) | Both had thin sidebars (~140px) that consumed width without justification. 56 sections fit cleanly in topbar tabs. Full-width content is a better use of space for these surfaces. |
| Cluster dashboard retired | `cluster-dashboard` shipped as a standalone surface package (v0.6.0) as an expedient. Health/metrics belong inside the Admin surface as a tab — shared context, no separate nav entry, no install required. Merged in v0.6.4. | | Team Admin Groups removed | 37-line read-only dead-end. Admin Groups has full CRUD. Restore when properly designed. |
| Block renderers decoupled from chat | `mermaid-renderer`, `katex-renderer`, `csv-table`, `diff-viewer` shipped with `"requires": ["chat"]` because renderer discovery lived inside the chat surface. These are content renderers, not chat features. v0.6.4 removes the constraint; v0.6.5 lifts renderer registration to the kernel SDK so all surfaces share it without reimplementing discovery. | | Docs is the reference surface | Only surface with shell Topbar, bell, user menu, inline errors. Others converge. |
| Surface runners over expanding ICD | Different test tier, different failure class. |
| Headless E2E via Playwright | Runners produce structured JSON; Playwright navigates and reads output. |

View File

@@ -1 +1 @@
0.6.7 0.7.4

11
ci/Dockerfile.test-runner Normal file
View File

@@ -0,0 +1,11 @@
# ci/Dockerfile.test-runner — Pre-built Playwright test runner
#
# Build & push (one-time, repeat when Playwright version bumps):
# docker build -t registry.gobha.me:5000/ci-test-runner:latest -f ci/Dockerfile.test-runner .
# docker push registry.gobha.me:5000/ci-test-runner:latest
#
# The CI compose override uses this image directly, avoiding npm install
# on every run. Only the ci/ scripts are COPY'd at compose build time.
FROM mcr.microsoft.com/playwright:v1.52.0-noble
WORKDIR /work
RUN npm init -y && npm install playwright@1.52.0

56
ci/run-surface-tests.sh Executable file
View File

@@ -0,0 +1,56 @@
#!/usr/bin/env bash
# ═══════════════════════════════════════════════
# Surface Test Runner — CI Entrypoint
# ═══════════════════════════════════════════════
#
# Authenticates as admin, navigates to the test-runners surface
# via Playwright, runs all test suites, and asserts zero failures.
#
# Prerequisites:
# - Server running at $SERVER_URL (default: http://localhost:3000)
# - npx playwright install chromium
# - ADMIN_USER / ADMIN_PASS env vars (default: admin/admin)
#
# Exit codes: 0 = all tests passed, 1 = failures detected
set -euo pipefail
SERVER_URL="${SERVER_URL:-http://localhost:3000}"
ADMIN_USER="${ADMIN_USER:-admin}"
ADMIN_PASS="${ADMIN_PASS:-admin}"
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
echo -e "${YELLOW}═══ Surface Test Runner ═══${NC}"
echo " Server: ${SERVER_URL}"
# ── Authenticate ─────────────────────────────
echo -e "${YELLOW}Authenticating...${NC}"
TOKEN=$(curl -sf -X POST "${SERVER_URL}/api/v1/auth/login" \
-H "Content-Type: application/json" \
-d "{\"login\":\"${ADMIN_USER}\",\"password\":\"${ADMIN_PASS}\"}" \
| node -e "process.stdin.resume(); let d=''; process.stdin.on('data',c=>d+=c); process.stdin.on('end',()=>{try{console.log(JSON.parse(d).token)}catch(e){process.exit(1)}})")
if [ -z "$TOKEN" ]; then
echo -e "${RED}Failed to authenticate${NC}"
exit 1
fi
echo -e "${GREEN}Authenticated${NC}"
# ── Run via Playwright ───────────────────────
echo -e "${YELLOW}Running surface tests via Playwright...${NC}"
node "$(dirname "$0")/surface-test-driver.js" \
--server="${SERVER_URL}" \
--token="${TOKEN}"
EXIT_CODE=$?
if [ $EXIT_CODE -eq 0 ]; then
echo -e "${GREEN}═══ All surface tests passed ═══${NC}"
else
echo -e "${RED}═══ Surface tests FAILED ═══${NC}"
fi
exit $EXIT_CODE

139
ci/surface-test-driver.js Normal file
View File

@@ -0,0 +1,139 @@
#!/usr/bin/env node
/**
* Surface Test Driver — Playwright-based CI runner
*
* Navigates to /s/test-runners as admin, clicks "Run All",
* waits for completion, then fetches results from the API.
*
* Usage:
* node ci/surface-test-driver.js --server=http://localhost:3000 --token=TOKEN
*
* Exit codes: 0 = all passed, 1 = failures
*/
'use strict';
const { chromium } = require('playwright');
// Parse args
const args = {};
process.argv.slice(2).forEach(a => {
const [k, v] = a.replace(/^--/, '').split('=');
args[k] = v;
});
const SERVER = args.server || 'http://localhost:3000';
const TOKEN = args.token || '';
if (!TOKEN) {
console.error('ERROR: --token is required');
process.exit(1);
}
(async () => {
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext();
// Set auth cookie — name must match server's SetCookie ("arm_token")
await context.addCookies([{
name: 'arm_token',
value: TOKEN,
domain: new URL(SERVER).hostname,
path: '/',
}]);
const page = await context.newPage();
// Collect console errors
const consoleErrors = [];
page.on('console', msg => {
if (msg.type() === 'error') consoleErrors.push(msg.text());
});
try {
// Navigate to test runners surface
console.log('Navigating to test-runners surface...');
await page.goto(SERVER + '/s/test-runners', { waitUntil: 'networkidle', timeout: 30000 });
// Wait for the "Run All" button — suites load asynchronously so the
// button only appears once runners have registered their suites.
console.log('Waiting for Run All button...');
const runAllBtn = await page.waitForSelector('button:has-text("Run All")', { timeout: 60000 })
.catch(() => null);
if (!runAllBtn) {
// Dump page content for debugging
const text = await page.textContent('body').catch(() => '(empty)');
console.error('ERROR: "Run All" button not found. Page text:', text.substring(0, 500));
await browser.close();
process.exit(1);
}
console.log('Clicking Run All...');
await runAllBtn.click();
// Wait for results — poll until running state clears
console.log('Waiting for test completion...');
// The running indicator disappears when tests finish
// Max wait: 5 minutes
const maxWait = 300000;
const start = Date.now();
let done = false;
while (!done && (Date.now() - start) < maxWait) {
await page.waitForTimeout(2000);
// Check if results are available via API
try {
const resp = await page.evaluate(async (serverUrl) => {
const r = await fetch(serverUrl + '/api/v1/admin/test-runners/results', {
credentials: 'include'
});
if (r.status === 200) return await r.json();
return null;
}, SERVER);
if (resp && resp.summary) {
done = true;
console.log('\n═══ Results ═══');
console.log(` Total: ${resp.summary.total}`);
console.log(` Passed: ${resp.summary.passed}`);
console.log(` Failed: ${resp.summary.failed}`);
console.log(` Warned: ${resp.summary.warned}`);
console.log(` Skipped: ${resp.summary.skipped}`);
console.log(` Duration: ${resp.duration_ms}ms`);
if (resp.summary.failed > 0) {
console.log('\n═══ Failures ═══');
for (const suite of (resp.suites || [])) {
for (const test of (suite.tests || [])) {
if (test.status === 'failed') {
console.log(`${suite.name} > ${test.name}: ${test.detail || 'failed'}`);
}
}
}
await browser.close();
process.exit(1);
}
await browser.close();
process.exit(0);
}
} catch (e) {
// Results not ready yet
}
}
if (!done) {
console.error('ERROR: Tests did not complete within timeout');
await browser.close();
process.exit(1);
}
} catch (e) {
console.error('Driver error:', e.message);
if (consoleErrors.length > 0) {
console.error('\nBrowser console errors:');
consoleErrors.forEach(e => console.error(' ' + e));
}
await browser.close();
process.exit(1);
}
})();

21
ci/wait-for-healthy.sh Executable file
View File

@@ -0,0 +1,21 @@
#!/usr/bin/env bash
# ═══════════════════════════════════════════════
# Wait for Armature server to be healthy
# ═══════════════════════════════════════════════
# Usage: ./ci/wait-for-healthy.sh [URL] [MAX_RETRIES]
set -euo pipefail
HOST="${1:-http://localhost:3000}"
MAX_RETRIES="${2:-60}"
echo "Waiting for ${HOST} to be healthy..."
for i in $(seq 1 "$MAX_RETRIES"); do
if curl -sf --connect-timeout 2 "${HOST}/api/v1/health" -o /dev/null 2>/dev/null; then
echo "Server healthy after ${i}s"
exit 0
fi
sleep 1
done
echo "Server not healthy after ${MAX_RETRIES}s"
exit 1

44
docker-compose.ci.yml Normal file
View File

@@ -0,0 +1,44 @@
# docker-compose.ci.yml — CI test override
#
# Extends base docker-compose.yml. Adds a healthcheck to armature and a
# Playwright test-runner service. All containers share the default compose
# bridge network, so the test-runner reaches armature via Docker DNS
# (http://armature:80).
#
# Usage:
# docker compose -f docker-compose.yml -f docker-compose.ci.yml up --build \
# --abort-on-container-exit --exit-code-from test-runner
#
# The workflow exits with the test-runner's exit code (0 = pass, 1 = fail).
#
# NOTE: Do NOT use network_mode: host — the workflow container and compose
# containers are in separate network namespaces inside DinD. Use the default
# bridge network and let services talk via Docker DNS instead.
services:
armature:
healthcheck:
test: ["CMD", "curl", "-sf", "http://localhost:80/api/v1/health"]
interval: 2s
timeout: 3s
retries: 30
start_period: 5s
test-runner:
build:
context: .
dockerfile_inline: |
FROM mcr.microsoft.com/playwright:v1.52.0-noble
WORKDIR /work
RUN npm init -y && npm install playwright@1.52.0
COPY ci/ /work/ci/
RUN chmod +x /work/ci/*.sh
depends_on:
armature:
condition: service_healthy
working_dir: /work
environment:
SERVER_URL: http://armature:80
ADMIN_USER: admin
ADMIN_PASS: admin
command: ["bash", "-c", "./ci/run-surface-tests.sh"]

View File

@@ -33,7 +33,7 @@ services:
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

@@ -271,12 +271,13 @@ graph TD
## Frontend ## Frontend
Preact (3KB) + htm (tagged template literals). No build step, no bundler Preact (3KB) + htm (tagged template literals). No build step, no bundler
(except CM6 via esbuild). IIFE/global-namespace pattern with (except CM6 via esbuild). ES modules loaded via `<script type="module">`.
`sb.register()`/`sb.ns()`. The SDK is exposed at `window.sw` — see the [Frontend JS Guide](FRONTEND-JS-GUIDE).
The shell loads surfaces into a viewport. Extensions use `window.html` The shell provides a two-slot topbar (left title + center slot) that every
and `window.preact` directly. Hooks via `window.hooks`. Vendor libs surface inherits. Extensions use `window.html` and `window.preact` directly.
(marked.js, DOMPurify, KaTeX, CodeMirror 6) baked into the image. Hooks via `window.hooks`. Vendor libs (marked.js, DOMPurify, KaTeX,
CodeMirror 6) baked into the image.
## Deployment ## Deployment

209
docs/AUDIT-surfaces.md Normal file
View File

@@ -0,0 +1,209 @@
# Surface Audit — Settings, Admin, Team Admin, Docs
## Audit Methodology
For each surface: read the index.js (tab/section structure), read every
section module, check the HTML template, trace the topbar/navigation
pattern, identify bugs, missing features, dead ends, and legacy baggage.
---
## 1. Settings Surface
**Files:** `src/js/sw/surfaces/settings/` (7 files, ~868 lines)
**Template:** `surfaces/settings.html` — mounts into `#settings-mount`
**Topbar:** Custom — back arrow + user icon + "Settings" title. No bell. No user menu.
### Sections
| Section | Lines | Status | Notes |
|---------|-------|--------|-------|
| General | 62 | ✅ OK | Default surface picker. Clean. |
| Appearance | 78 | ✅ OK | Theme toggle (light/dark/system) + UI scale slider. |
| Profile | 180 | ✅ OK | Display name, email, avatar upload, password change. |
| Teams | 47 | ⚠️ Thin | Read-only list of your teams. No actions. No link to team admin. |
| Connections | 222 | ✅ OK | Personal BYOK connection CRUD. Functional. |
| Notifications | 96 | ✅ OK | Toggle notification types on/off. |
### Issues Found
| # | Severity | Issue |
|---|----------|-------|
| S1 | **P1** | **No notification bell.** Custom topbar renders back arrow + icon + "Settings" — no bell, no user menu dropdown. User can't see notifications or navigate to other surfaces without using the back button. |
| S2 | **P2** | **Teams section is a dead-end.** Lists your teams with no actions — can't leave team, can't navigate to team admin, can't see team details. Just names. Should either link to team admin or show useful info. |
| S3 | **P2** | **Back button uses sessionStorage return URL.** `sb_settings_return` stash means: open settings in a new tab → back goes to `/` (correct). But open settings from a deep link → back goes to referrer, which might be unexpected. Shell topbar with consistent home link would fix this. |
| S4 | **P3** | **Extension config sections.** `__CONFIG_SECTIONS__` injection works but has no documentation. Extension authors don't know they can add settings sections. Needs a docs entry. |
### Shell Topbar Migration
Settings renders its own `settings-topbar`. With shell topbar injection:
- **Option A (simple):** `sw.shell.topbar.hide()` and keep custom topbar. Works immediately.
- **Option B (ideal):** Remove custom topbar. Shell topbar provides back + title + bell + user menu. Settings nav stays in the sidebar.
- **Recommendation:** Option B. Settings topbar adds nothing the shell topbar doesn't. The back arrow just navigates to `/`.
---
## 2. Admin Surface
**Files:** `src/js/sw/surfaces/admin/` (13 files, ~2,522 lines)
**Template:** `surfaces/admin.html` — mounts into `#admin-mount`
**Topbar:** Custom — favicon + "Administration" + category tabs (People/Workflows/System/Monitoring) + UserMenu component. No notification bell.
### Sections
| Section | Category | Lines | Status | Notes |
|---------|----------|-------|--------|-------|
| Users | People | 152 | ✅ OK | User list, create, edit status/role. Functional. |
| Teams | People | 178 | ✅ OK | Team list, create, member management. |
| Groups | People | 207 | ✅ OK | Full CRUD — create, delete, permission toggles, member add/remove. Functional but **undocumented** (see issues). |
| Workflows | Workflows | 163 | ⚠️ | CRUD + stage editor. `sw.api.workflows.list()` — needs same error-surfacing treatment as workflow-demo. |
| Settings | System | 242 | ✅ OK | Comprehensive: default surface, registration, banner, message bar, footer, session TTLs, vault, package registry, email test. Actually solid. |
| Storage | System | 76 | ✅ OK | Status cards, orphan cleanup. Clean. |
| Packages | System | 391 | ⚠️ | Core feature. Large. Package list, install, uninstall, registry browse. **User menu doesn't update after install/uninstall** (main bug Jeff reported). |
| Connections | System | 210 | ✅ OK | Global connection CRUD. |
| Broadcast | System | 44 | ✅ OK | Send broadcast. Minimal. |
| Backup | System | 162 | ✅ OK | Create/restore/download/delete. Works. |
| Health | Monitoring | 209 | ✅ OK | Runtime, DB pool, cluster, extension metrics. |
| Audit | Monitoring | 88 | ✅ OK | Audit log viewer with pagination. |
### Issues Found
| # | Severity | Issue |
|---|----------|-------|
| A1 | **P1** | **User menu not reactive to package changes.** `UserMenu` fetches surface list once on mount (`useEffect([authenticated])`). Installing/uninstalling a package doesn't trigger re-fetch. User must refresh the page to see new surfaces in the menu. Same for role changes (adding as team-admin). |
| A2 | **P1** | **No notification bell.** Admin topbar has category tabs + UserMenu but no NotificationBell component. |
| A3 | **P2** | **Groups: no documentation or inline help.** Admin Groups has full CRUD but zero explanation of what groups are, what permissions mean, or how the RBAC model works. "No groups" → user creates one → sees a list of permission slugs like `surface.admin.access` with no description. Every permission should have a human-readable description. |
| A4 | **P2** | **Workflows: silent error potential.** `sw.api.workflows.list()` — if this fails, `catch (e) { sw.toast(e.message, 'error'); }` fires a toast but leaves the list empty. Better than workflow-demo's silent swallow, but the toast disappears and the user is left with an empty list + no context. Should show inline error state. |
| A5 | **P2** | **Packages: no post-install feedback.** After installing a package, the package list refreshes (good) but the user menu doesn't update (bad — A1). User installs Notes, doesn't see it in the menu, thinks it's broken. |
| A6 | **P3** | **Admin topbar favicon is hardcoded.** Line 142: `<img src="${BASE}/favicon.svg">`. Should respect light/dark theme favicon swap. |
| A7 | **P3** | **Category icon rendering is fragile.** Custom compact SVG format (`C12 12 3\|M19.4 15...`) in `CatIcon`. Works but is unmaintainable — any icon change requires understanding the custom format. Should use standard SVG paths or lucide/feather icons. |
### Shell Topbar Migration
Admin has the most complex custom topbar — category tabs are genuinely useful navigation. Options:
- **Option A (recommended):** `sw.shell.topbar.hide()`. Admin keeps its custom topbar but adds NotificationBell component to its existing right-side area next to UserMenu.
- **Option B:** Shell topbar with `sw.shell.topbar.setSlot()` for category tabs. Works but requires rethinking the layout since shell topbar has fixed structure (home | title | slot | bell | user).
- **Recommendation:** Option A for v0.7.0. Admin's custom topbar is bespoke enough to warrant keeping. Just wire in the bell.
---
## 3. Team Admin Surface
**Files:** `src/js/sw/surfaces/team-admin/` (7 files, ~1,119 lines)
**Template:** `surfaces/team-admin.html` — mounts into `#team-admin-mount`
**Topbar:** Custom — back arrow + "Team Admin: {team name}" title. No bell. No user menu.
### Sections
| Section | Lines | Status | Notes |
|---------|-------|--------|-------|
| Members | ~90 | ✅ OK | Member list, add, remove. Functional. |
| Groups | 37 | ❌ Dead-end | Read-only "No groups" display. No create, no docs, no link to admin. |
| Connections | ~120 | ✅ OK | Team-scoped connections. Same pattern as user/admin connections. |
| Workflows | 723 | ⚠️ Massive | Three tabs: Workflows (CRUD + inline stage editor), Assignments (claim/release/complete), Monitor (active instances + signoff). This is 65% of the surface's code. |
| Settings | 72 | ✅ OK | Team name + description. Clean. |
| Activity | ~80 | ✅ OK | Audit log. Works. |
### Issues Found
| # | Severity | Issue |
|---|----------|-------|
| T1 | **P1** | **Groups is a dead-end.** 37 lines. Read-only list of team groups. No "Create Group" button. No explanation of what groups are. No link to Admin > Groups where creation actually happens. A team admin user who isn't a platform admin literally cannot create team groups. The Admin groups page supports `scope: team` but that creates a global group with team scope — it's unclear if team-admin should even see groups at all. |
| T2 | **P1** | **Workflows "Adopt Global" — same silent-error class.** `sw.api.teams.availableWorkflows(teamId)` — if this fails, the catch fires a toast but the adopt panel shows "No global workflows available" — indistinguishable from "there genuinely aren't any" vs "the API errored." |
| T3 | **P1** | **Workflows is disproportionately complex.** 723 lines — inline stage editor with mode/type selectors, SLA fields, stage reordering, team role assignment per stage. This is a full workflow designer embedded in a tab. It works but it's a maintenance burden and the UX is dense. Question: should this complexity live here or be a separate workflow-designer surface? |
| T4 | **P1** | **No notification bell.** Same as Settings — custom topbar with no bell. |
| T5 | **P2** | **No user menu.** Unlike Admin (which renders UserMenu), Team Admin has no user menu in its topbar. User can't navigate to other surfaces except via the back button. |
| T6 | **P2** | **Signoff panel shows raw user_id.** Line 714: `<span>${s.user_id}</span>` — shows UUID instead of display name. Should use `sw.users.displayName(s.user_id)`. |
| T7 | **P3** | **Back button uses sessionStorage.** Same pattern as Settings (`sb_team_admin_return`). Shell topbar would fix. |
### Shell Topbar Migration
Team Admin has a simple topbar (back + title). Direct replacement:
- Shell topbar provides: home link + "Team Admin: {name}" title + bell + user menu.
- Team name from `sw.api.teams.get(teamId)``sw.shell.topbar.setTitle('Team Admin: ' + team.name)`.
- Delete the custom topbar entirely.
---
## 4. Docs Surface
**Files:** `src/js/sw/surfaces/docs/` (1 file, 313 lines)
**Template:** `surfaces/docs.html` — mounts into `#docs-mount`
**Topbar:** Imports and renders `shell/topbar.js` (the SDK Topbar component). **Only surface that uses the shell Topbar.**
### Features
| Feature | Status | Notes |
|---------|--------|-------|
| Document list sidebar | ✅ OK | Fetches from `/api/v1/docs`, renders nav links. |
| Markdown rendering | ✅ OK | Uses `sw.markdown.renderSync()` + post-renderers (mermaid, katex). |
| Document outline | ✅ OK | Parses H1-H4 from markdown, renders table of contents. |
| Search | ✅ OK | Filters documents in sidebar. |
| URL updates | ✅ OK | `history.replaceState` on doc change. |
| Topbar | ✅ OK | Uses shell `Topbar` component — has title, bell, user menu. |
### Issues Found
| # | Severity | Issue |
|---|----------|-------|
| D1 | **P2** | **Stale content.** The docs themselves may be outdated — GETTING-STARTED, EXTENSION-GUIDE, API-REFERENCE, DEPLOYMENT, PACKAGE-FORMAT were written in v0.6.1. 18 versions later, some content is likely stale. Needs a content review pass. |
| D2 | **P3** | **No docs for RBAC/Groups.** Admin Groups exists with full CRUD but there's no documentation explaining the permission model, what each permission slug means, how groups interact with teams, or how the settings cascade works. This directly causes the "groups WTF" reaction. |
| D3 | **P3** | **No docs for Workflows.** The workflow engine is complex (multi-stage, team roles, signoff gates, SLA, public entry) but has no user-facing documentation. `DESIGN-WORKFLOWS.md` exists but is a design doc, not a user guide. |
| D4 | **P3** | **Shell topbar migration.** Docs already imports `shell/topbar.js` — when shell topbar injection lands, Docs will get a double topbar. Needs migration: delete the import, let shell topbar handle it. Docs currently passes no custom slot content, so it's a pure delete. |
---
## Cross-Surface Issues
These affect multiple or all surfaces:
| # | Severity | Issue | Surfaces |
|---|----------|-------|----------|
| X1 | **P0** | **User menu not reactive.** Package install/uninstall, role changes, team membership changes — none trigger a menu refresh. User must reload the page. | All (via UserMenu component) |
| X2 | **P1** | **No notification bell on 3/4 surfaces.** Only Docs has a bell (via Topbar import). Settings, Admin, and Team Admin all lack it. | Settings, Admin, Team Admin |
| X3 | **P1** | **No user menu on 2/4 surfaces.** Settings and Team Admin have no user menu at all. Admin and Docs have one. | Settings, Team Admin |
| X4 | **P2** | **Every surface has its own topbar.** Four different topbar implementations. None use the (not-yet-existing) shell topbar injection. Shell topbar (v0.7.0) eliminates this duplication. | All |
| X5 | **P2** | **Silent error swallowing.** Multiple sections use `catch (e) { toast }` which fires a toast and leaves an empty/stale UI. Toast disappears after seconds; user is left confused. Every list endpoint needs an inline error state with retry. | Admin Workflows, Team Admin Workflows, Packages |
| X6 | **P2** | **Empty states provide no guidance.** "No groups", "No workflows", "No notifications" — no explanation of what the feature is, why it's empty, or what action to take. Every empty state should have a one-line explanation and a primary action (create, link to docs, etc.). | Admin Groups, Team Admin Groups, Workflows |
| X7 | **P3** | **Raw IDs in UI.** Team Admin signoff panel shows `user_id` UUIDs. Any surface showing IDs should resolve via `sw.users.displayName()`. | Team Admin Workflows |
---
## Recommendations
### Immediate (fold into v0.7.0)
1. **User menu reactivity** — emit `package.changed` and `auth.changed` events over WS + local custom events. UserMenu listens and re-fetches surface list. This is the single most impactful fix.
2. **Shell topbar migration for Settings + Team Admin** — both have simple topbars that the shell topbar directly replaces. Docs deletes its Topbar import. Admin keeps its custom topbar but adds NotificationBell.
3. **Remove Team Admin Groups tab** — it's 37 lines of dead-end. Team-scoped group management should either (a) be added properly with create/edit/delete or (b) removed until it's properly designed. Showing "No groups" with no path forward is worse than not showing the tab.
4. **Error states** — replace `catch { toast }` with inline error + retry UI on every list endpoint. Systematic pass across all four surfaces.
5. **Empty state copy** — every "No X" message gets a one-line explanation + primary action button or doc link.
### Deferred (v0.7.1+ / runner coverage)
6. **Admin Groups documentation** — write a "Permissions & Groups" doc for the docs surface. Explain the RBAC model, list all permission slugs with descriptions, explain group scoping.
7. **Workflow user guide** — write a "Workflows" doc. Entry modes, stage types, team roles, signoff gates, SLA.
8. **Team Admin Workflows simplification** — the 723-line inline stage editor is the most complex piece of UI in the entire application. Consider extracting to a dedicated workflow-designer surface or at minimum breaking into separate files.
9. **Docs content refresh** — review all 5 docs for accuracy at v0.6.18+.
10. **Settings Teams section** — either add useful actions (link to team admin, show team role, leave team) or remove the tab.
---
## Asset Inventory
| Surface | Lines (total) | Sections | Custom Topbar | Bell | UserMenu | Error Handling |
|---------|--------------|----------|---------------|------|----------|---------------|
| Settings | 868 | 6 | Yes (back+icon) | ❌ | ❌ | Toast only |
| Admin | 2,522 | 12 | Yes (tabs+menu) | ❌ | ✅ | Toast only |
| Team Admin | 1,119 | 6 | Yes (back+title) | ❌ | ❌ | Toast only |
| Docs | 313 | 1 | Shell Topbar ✅ | ✅ | ✅ | Inline error ✅ |
Docs is the gold standard. The other three need to converge toward its pattern.

View File

@@ -3,14 +3,14 @@
## Docker Single-Instance ## Docker Single-Instance
```bash ```bash
docker pull ghcr.io/armature/armature:latest docker pull gobha/armature:latest
docker run -p 8080:80 \ docker run -p 8080:80 \
-e ARMATURE_ADMIN_USERNAME=admin \ -e ARMATURE_ADMIN_USERNAME=admin \
-e ARMATURE_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 armature-data:/data \ -v armature-data:/data \
ghcr.io/armature/armature: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.
@@ -29,7 +29,7 @@ services:
- pg_data:/var/lib/postgresql/data - pg_data:/var/lib/postgresql/data
armature: armature:
image: ghcr.io/armature/armature:latest image: gobha/armature:latest
ports: ports:
- "8080:80" - "8080:80"
environment: environment:
@@ -41,13 +41,13 @@ services:
STORAGE_BACKEND: pvc STORAGE_BACKEND: pvc
STORAGE_PATH: /data/storage STORAGE_PATH: /data/storage
volumes: volumes:
- sb_storage:/data/storage - armature_storage:/data/storage
depends_on: depends_on:
- postgres - postgres
volumes: volumes:
pg_data: pg_data:
sb_storage: armature_storage:
``` ```
## Kubernetes ## Kubernetes
@@ -70,6 +70,7 @@ See the `k8s/` directory for example manifests. Key considerations:
| `JWT_SECRET` | `dev-secret-change-me` | Token signing key -- **must change** | | `JWT_SECRET` | `dev-secret-change-me` | Token signing key -- **must change** |
| `ENCRYPTION_KEY` | | AES-256 key for credential vault | | `ENCRYPTION_KEY` | | AES-256 key for credential vault |
| `AUTH_MODE` | `builtin` | `builtin`, `mtls`, `oidc` | | `AUTH_MODE` | `builtin` | `builtin`, `mtls`, `oidc` |
| `TLS_MODE` | (empty) | `native` for node-to-node mTLS. Requires `MTLS_CERT_PATH` and `MTLS_KEY_PATH`. |
| `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., `/armature`) | | `BASE_PATH` | | URL prefix (e.g., `/armature`) |

View File

@@ -0,0 +1,448 @@
# DESIGN: Shell Contract — v0.7.0
## Status: Proposed
## Problem
Extension surfaces render into `#extension-mount` with no shell chrome.
A full audit (docs/AUDIT-surfaces.md) found: 3/4 primary surfaces lack a
notification bell, 2/4 lack a user menu, 4 different topbar implementations,
user menu never updates on package/role changes, toast-and-forget error
handling, and empty states that explain nothing.
## Solution
### 1. Shell Topbar — Two-Slot Model
The kernel injects a topbar for all extension surfaces. Two named slots
(left + center) let surfaces customize without replacing the entire bar.
**Layout:**
```
┌──────────────────────────────────────────────────────────────┐
│ ← │ [left] │ [center: flex-1] │ 🔔 │ 👤 │
└──────────────────────────────────────────────────────────────┘
```
- **← (home):** Always visible. Navigates to `__BASE__/`. Simple `<a>`.
- **Left slot:** Defaults to manifest title. Surfaces override with `setLeft()`.
- **Center slot:** Empty by default. Surfaces inject tabs, search, pickers.
`flex: 1` — expands to fill available space.
- **Bell + User Menu:** Always visible. Kernel-managed.
**Template change** (`surfaces/extension.html`):
```html
{{define "surface-extension"}}
<div id="extension-surface" class="extension-surface"
data-surface-id="{{.Surface}}">
<div id="shell-topbar" class="sw-topbar sw-topbar--shell"></div>
<div id="extension-mount" class="extension-mount" data-ext="{{.Surface}}"></div>
</div>
{{end}}
```
**SDK API:**
```js
sw.shell.topbar.setLeft(vnode) // Override left slot (default: title)
sw.shell.topbar.setSlot(vnode) // Set center slot content
sw.shell.topbar.setTitle(str) // Shorthand: setLeft with plain text
sw.shell.topbar.hide() // Remove topbar entirely
sw.shell.topbar.show() // Restore after hiding
```
### 2. Three Navigation Patterns
The shell topbar provides the top bar. What happens below it is the
surface's business. Three patterns emerge naturally:
#### Pattern A — Default (simple extensions, Docs)
```
┌──────────────────────────────────────────┐
│ ← │ Surface Title │ 🔔 │ 👤 │
├──────────────────────────────────────────┤
│ │
│ Content (full width) │
│ │
└──────────────────────────────────────────┘
```
Title only, no tabs, no sidebar. Content gets everything.
Zero code required — kernel defaults handle it.
**Used by:** Docs, Notes, Chat, simple extensions.
#### Pattern B — Flat Tabs (no sidebar, full width)
```
┌──────────────────────────────────────────────────────────┐
│ ← │ Title │ Tab1 │ Tab2 │ Tab3 │ Tab4 │ │ 🔔 │ 👤 │
├──────────────────────────────────────────────────────────┤
│ │
│ Content (full width, no sidebar) │
│ │
└──────────────────────────────────────────────────────────┘
```
Tabs in the topbar center slot. No sidebar — content fills the full
viewport width. For surfaces with 37 sections that don't have sub-items.
More real estate for content than a sidebar layout.
**Surface code:**
```js
sw.shell.topbar.setSlot(html`
<div class="sw-topbar__tabs">
${sections.map(s => html`
<a class="sw-topbar__tab ${active === s.key ? 'active' : ''}"
href=${s.href} onClick=${navigate}>${s.label}</a>
`)}
</div>
`);
```
**Used by:** Team Admin (Members / Connections / Workflows / Settings / Activity),
Settings, Schedules.
#### Pattern C — Category Tabs + Sidebar (two-level)
```
┌─────────────────────────────────────────────────────────────┐
│ ← │ Title │ People │ Workflows │ System │ Mon │ 🔔 │ 👤 │
├──────────┬──────────────────────────────────────────────────┤
│ Users │ │
│ Teams │ Content │
│ Groups │ │
│ │ │
└──────────┴──────────────────────────────────────────────────┘
```
Major categories in the topbar (via center slot). Surface renders its own
sidebar inside the content area for sub-navigation within the active
category. The shell topbar doesn't know about the sidebar — it's a
surface-level div below `#extension-mount`.
**Surface code:**
```js
// Topbar: major categories
sw.shell.topbar.setLeft(html`
<img src="${BASE}/favicon.svg" width="18" height="18" style="vertical-align:-3px" />
<span style="margin-left:6px">Administration</span>
`);
sw.shell.topbar.setSlot(html`
<div class="sw-topbar__tabs">
${categories.map(c => html`
<a class="sw-topbar__tab ${activeCat === c.key ? 'active' : ''}"
href=${c.href} onClick=${navigate}>
<${CatIcon} paths=${c.icon} /> ${c.label}
</a>
`)}
</div>
`);
// Content area: sidebar is surface-owned
return html`
<div class="admin-body">
<div class="admin-nav">
${sidebarSections.map(s => html`...`)}
</div>
<div class="admin-content">
<${SectionComponent} />
</div>
</div>
`;
```
**Used by:** Admin.
### 3. Kernel-Provided Tab CSS
The kernel provides `.sw-topbar__tabs` and `.sw-topbar__tab` CSS so
surfaces get consistent tab styling. Not required — surfaces can style
their own slot content however they want.
```css
.sw-topbar__tabs {
display: flex;
align-items: center;
gap: var(--sp-1);
height: 100%;
}
.sw-topbar__tab {
display: flex;
align-items: center;
gap: var(--sp-2);
padding: var(--sp-2) var(--sp-3);
font-size: 13px;
font-weight: 500;
color: var(--text-2);
text-decoration: none;
border-radius: var(--radius-sm);
transition: color var(--transition), background var(--transition);
white-space: nowrap;
}
.sw-topbar__tab:hover {
color: var(--text);
background: var(--bg-hover);
}
.sw-topbar__tab.active {
color: var(--text);
background: var(--bg-2);
}
```
### 4. Primary Surface Migrations
#### Settings → Pattern B (flat tabs)
Currently: custom topbar (back + icon + "Settings"), sidebar nav.
After: shell topbar with flat tabs, no sidebar. Content full width.
Settings has 6 sections (General / Appearance / Profile / Teams /
Connections / Notifications) — perfect for flat tabs. The sidebar was
thin (~140px) and ate width from the content area for no good reason.
**Migration:**
- Delete `settings-topbar` div and CSS.
- Delete sidebar nav. Move section links into `sw.shell.topbar.setSlot()`.
- Remove `sb_settings_return` sessionStorage — shell home link handles it.
- Content area becomes full-width.
- Fix Teams section: add "Open Team Admin →" link, show role, add leave action.
Extension config sections (`__CONFIG_SECTIONS__`) render as additional
tabs after the divider. Same as today, just in the topbar instead of sidebar.
#### Admin → Pattern C (category tabs + sidebar)
Currently: custom topbar (favicon + "Administration" + category tabs + UserMenu).
After: shell topbar with category tabs in center slot, surface-owned sidebar.
**Migration:**
- Delete custom `admin-topbar` div and CSS.
- `sw.shell.topbar.setLeft()` with favicon + "Administration".
- `sw.shell.topbar.setSlot()` with category tabs (People / Workflows / System / Monitoring).
- Bell and user menu come from the shell — delete the explicit `<${UserMenu}>`.
- Admin sidebar and content area unchanged — they're below the topbar.
- Delete custom `CatIcon` renderer if category tab icons use standard SVG.
- Fix hardcoded `favicon.svg` — left slot can use theme-aware image.
**Result:** Admin looks identical to today but its topbar is kernel-managed.
Bell added for free. User menu reactive for free.
#### Team Admin → Pattern B (flat tabs)
Currently: custom topbar (back + "Team Admin: {name}"), sidebar nav.
After: shell topbar with flat tabs, no sidebar.
With Groups removed, Team Admin has 5 sections: Members / Connections /
Workflows / Settings / Activity. Perfect for flat tabs.
**Migration:**
- Delete `team-admin-topbar` div and CSS.
- `sw.shell.topbar.setTitle('Team Admin: ' + team.name)` after team fetch.
- Section tabs into `sw.shell.topbar.setSlot()`.
- Delete sidebar nav. Content full-width.
- Remove `sb_team_admin_return` sessionStorage.
- Remove Groups tab entirely (37-line dead-end).
- Fix signoff display: `user_id``sw.users.displayName()`.
#### Docs → Pattern A (default)
Currently: imports `shell/topbar.js` and renders it explicitly.
After: shell topbar auto-renders. No surface code needed.
**Migration:**
- Delete `import { Topbar }` and `<${Topbar}>` render.
- Shell topbar provides title + bell + user menu automatically.
- Docs sidebar (document list) is in the content area, unaffected.
### 5. User Menu Reactivity
**The single most impactful fix.**
**Backend — new WS events:**
```go
// After package install/uninstall/enable/disable:
h.hub.BroadcastToUser(userID, "package.changed", map[string]string{
"action": "installed", "id": packageID,
})
// After team role/membership change:
h.hub.BroadcastToUser(userID, "auth.changed", map[string]string{
"reason": "team_role",
})
```
**Frontend** (`user-menu.js`):
```js
useEffect(() => {
if (!sw?.api?.surfaces?.list) return;
function fetchSurfaces() {
sw.api.surfaces.list().then(data => {
const raw = Array.isArray(data) ? data : data?.data || [];
setAllSurfaces(raw);
}).catch(() => {});
}
fetchSurfaces();
const off1 = sw.on?.('package.changed', fetchSurfaces);
const off2 = sw.on?.('auth.changed', fetchSurfaces);
return () => {
if (typeof off1 === 'function') off1();
if (typeof off2 === 'function') off2();
};
}, [authenticated]);
```
**Event inventory:**
| Event | Trigger | Payload |
|-------|---------|---------|
| `package.changed` | Install, uninstall, enable, disable | `{ action, id }` |
| `auth.changed` | Team role change, group membership change | `{ reason }` |
| `notification.read` | Mark notification read | `{ id }` |
| `notification.all_read` | Mark all read | `{}` |
### 6. Notification Read Broadcast
**Backend** — after `MarkRead()` / `MarkAllRead()`:
```go
h.hub.BroadcastToUser(userID, "notification.read", map[string]string{"id": id})
h.hub.BroadcastToUser(userID, "notification.all_read", nil)
```
**Frontend**`NotificationBell` listens for `.created`, `.read`, `.all_read`:
```js
const onRead = (e) => {
setNotifications(prev => prev.map(n =>
n.id === e.id ? { ...n, read_at: new Date().toISOString() } : n
));
};
const onAllRead = () => {
setNotifications(prev => prev.map(n => ({
...n, read_at: n.read_at || new Date().toISOString()
})));
};
```
### 7. Shell Announcement Global Dismiss
On dismiss, write: `localStorage.setItem('armature_dismissed_' + hash(text), '1')`.
On mount, check. New announcements (changed text) show again.
Implementation: inline `<script>` in `base.html` — checks localStorage on
DOMContentLoaded. Dismiss button writes the key. Works on every surface
including login.
### 8. Error Handling Pass
**Pattern** — inline error + retry replaces toast-and-forget:
```js
const [error, setError] = useState(null);
async function load() {
setError(null);
try {
const data = await sw.api.whatever.list();
setItems(data || []);
} catch (e) { setError(e.message); }
}
// In render:
${error && html`
<div class="sw-inline-error">
<span>${error}</span>
<button class="sw-btn sw-btn--secondary sw-btn--sm"
onClick=${load}>Retry</button>
</div>
`}
```
**New CSS class** (`sw-primitives.css`):
```css
.sw-inline-error {
display: flex; align-items: center; gap: var(--sp-3);
padding: var(--sp-3) var(--sp-4);
background: var(--bg-2); border: 1px solid var(--danger);
border-radius: var(--radius); font-size: 13px; color: var(--danger);
}
```
**Sections requiring this pass:**
| Surface | Section | Current | After |
|---------|---------|---------|-------|
| Admin | Workflows | Toast + empty | Inline error + retry |
| Admin | Packages | Toast + empty | Inline error + retry |
| Admin | Groups | Toast + empty | Inline error + retry |
| Team Admin | Workflows (adopt) | Toast + "No global workflows" | Inline error + retry |
| Team Admin | Members | Toast + empty | Inline error + retry |
| Settings | General | Console warn | Inline error + retry |
| Settings | Teams | Toast + empty | Inline error + retry |
| Workflow Demo (pkg) | Main | Silent swallow | Inline error + retry |
### 9. Empty State Guidance
Every "No X" empty state gets: one-line explanation, primary action or doc link.
| Surface | Section | Current | After |
|---------|---------|---------|-------|
| Admin | Groups | "No groups" | "Groups control access to surfaces and features via permissions." + Create button |
| Admin | Workflows | "No workflows" | "Workflows define multi-stage approval processes with team roles and SLA tracking." + Create button |
| Admin | Teams | "No teams" | "Teams group users for shared connections, workflows, and access control." + Create button |
| Team Admin | Workflows | "No workflows — create one or adopt" | Add: "Adopt copies a global workflow for this team to customize." |
| Settings | Notifications | "No notification preferences" | "Preferences appear when notification types are configured by an administrator." |
## Rebrand Asset Inventory
### Current → Target
| File | Current | Target |
|------|---------|--------|
| `favicon.svg` | Dark icon ✅ | Unchanged |
| `favicon-light.svg` | **MISNAMED** (520×80 wordmark) | Square icon, light mode **(NEW)** |
| `favicon-32.png` | Dark raster ✅ | Unchanged |
| `favicon-256.png` | Dark raster ✅ | Unchanged |
| `favicon-light-32.png` | Missing | Light raster **(NEW)** |
| `favicon-light-256.png` | Missing | Light raster **(NEW)** |
| `favicon.ico` | Dark ✅ | Unchanged |
| `wordmark.svg` | Missing | **RENAMED** from current `favicon-light.svg` |
| `wordmark-dark.svg` | Missing | Light text #E5E5E5 on transparent **(NEW)** |
| `manifest.json` | "Self-hosted AI chat..." | "Self-hosted extension platform..." |
## Bug Fixes (bundled)
- **evil-chat:** `finally` cleanup + tighten `409` assertion.
- **Workflow demo:** Silent `catch` → inline error + retry.
- **Hello dashboard:** Delete `packages/hello-dashboard/`.
## Changeset Plan
| CS | Scope | Description |
|----|-------|-------------|
| CS-1 | Backend (Go) | WS events: `notification.read`, `notification.all_read`, `package.changed`, `auth.changed`. Tests. |
| CS-2 | Frontend (JS + HTML) | Shell topbar component, SDK API (`setLeft`/`setSlot`/`setTitle`/`hide`), extension.html template, SDK boot auto-mount. |
| CS-3 | Frontend (JS) | User menu reactivity: listen for `package.changed` + `auth.changed`. Notification bell: listen for `.read` + `.all_read`. |
| CS-4 | Frontend (JS + HTML) | Surface migrations: Settings → Pattern B, Admin → Pattern C, Team Admin → Pattern B, Docs → Pattern A. Delete custom topbars. |
| CS-5 | Frontend (JS + CSS) | Error handling pass + empty state guidance. `sw-inline-error` CSS. All sections from inventory. |
| CS-6 | Frontend (JS) | Announcement global dismiss (localStorage). |
| CS-7 | Static + docs | Rebrand assets, manifest.json, REBRAND-SPEC.md. |
| CS-8 | Frontend (JS) | Bug fixes: evil-chat cleanup, workflow demo error, hello-dashboard deletion. |
Each changeset independently CI-green.

View File

@@ -0,0 +1,407 @@
# DESIGN: Surface Runners — v0.7.1v0.7.3
## Status: v0.7.1 Shipped (framework + migrations), v0.7.2v0.7.3 Proposed
## Problem
Armature has two test tiers today:
1. **Go unit tests** — test store methods, handlers, middleware, sandbox.
Run in CI on every push. Coverage is good for kernel internals.
2. **ICD/SDK test runners** — browser-based test suites that validate API
endpoint contracts and SDK domain methods. Run manually by navigating
to `/s/icd-test-runner` or `/s/sdk-test-runner`.
Neither tier catches the class of bugs discovered during manual testing:
- **Cross-surface state:** Notification bell state not syncing, announcement
dismiss not persisting across surface navigations.
- **Package integration:** Workflow demo shows "not installed" because its
API call fails silently. The API works (unit tests pass); the surface's
integration with the API is broken.
- **Test side-effects:** ICD security tests install `evil.surface` with no
cleanup — it leaks into the menu.
- **Surface lifecycle:** Surfaces fail to load, mount into wrong containers,
miss SDK boot, or render without shell chrome.
These are integration bugs — they live at the boundary between kernel and
package, between surface and surface, between API and UI. They require a
new test tier.
## Solution Overview
Three deliverables across three versions:
| Version | Deliverable | What it catches |
|---------|-------------|-----------------|
| v0.7.1 | Runner framework (`sw.testing`) | Framework bugs, standardizes existing runners |
| v0.7.2 | Package runners + CI gate | Package integration bugs, regressions |
| v0.7.3 | Headless E2E (Playwright) | DOM rendering bugs, navigation flows, visual regressions |
## v0.7.1 — Runner Framework
### `sw.testing` SDK Module
New kernel SDK module at `src/js/sw/sdk/testing.js`. Provides structured
test authoring, lifecycle hooks, cleanup tracking, and machine-readable
results.
```js
// Extension runner registers suites during load
sw.testing.suite('notes-crud', async (s) => {
let folderId, noteId;
s.beforeAll(async () => {
// Setup: create a test folder
const r = await sw.api.post('/api/v1/ext/notes/folders', {
name: 'test-' + Date.now()
});
folderId = r.id;
s.track('folder', folderId); // auto-cleanup
});
s.test('create note', async (t) => {
const r = await sw.api.post('/api/v1/ext/notes/notes', {
title: 'Test Note', folder_id: folderId, content: '# Hello'
});
t.assert.ok(r.id, 'note has ID');
t.assert.eq(r.title, 'Test Note');
noteId = r.id;
t.track('note', noteId); // auto-cleanup
});
s.test('renderers fire', async (t) => {
// Test that mermaid block in note content triggers renderer
await sw.api.patch('/api/v1/ext/notes/notes/' + noteId, {
content: '```mermaid\ngraph LR; A-->B\n```'
});
// Renderer integration tested via DOM assertion
// (only meaningful in headless E2E — marked as browser-only)
t.browserOnly(() => {
const el = document.querySelector('.mermaid svg');
t.assert.ok(el, 'mermaid rendered to SVG');
});
});
s.afterAll(async () => {
// s.track() resources auto-cleaned here
// Manual cleanup for anything not tracked
});
});
```
### Core API
```js
sw.testing.suite(name, fn) // Register a test suite
sw.testing.run(name?) // Run one suite or all
sw.testing.results() // Get structured results (JSON)
sw.testing.on('complete', fn) // Event when run finishes
// Inside suite:
s.test(name, fn) // Register a test
s.beforeAll(fn) // Runs once before all tests
s.afterAll(fn) // Runs once after all tests (always, even on failure)
s.beforeEach(fn) // Runs before each test
s.afterEach(fn) // Runs after each test
s.track(type, id) // Register resource for auto-cleanup
s.skip(reason) // Skip entire suite
// Inside test:
t.assert.ok(val, msg) // Truthy
t.assert.eq(a, b, msg) // Deep equality
t.assert.neq(a, b, msg) // Not equal
t.assert.gt(a, b, msg) // Greater than
t.assert.match(str, re, msg) // Regex match
t.assert.throws(fn, msg) // Expects throw
t.assert.status(resp, code, msg) // HTTP status check
t.track(type, id) // Register resource for auto-cleanup
t.warn(msg) // Emit warning (non-fatal)
t.browserOnly(fn) // Only runs in headless E2E, skipped in API-only mode
t.skip(reason) // Skip this test
```
### `requires` Declarations
Runner packages declare dependencies in their manifest:
```json
{
"id": "chat-runner",
"type": "test-runner",
"title": "Chat Runner",
"requires": ["chat", "chat-core"],
"version": "0.1.0"
}
```
On load, the framework calls `GET /api/v1/surfaces` (or equivalent) to
check which packages are installed. If any `requires` entry is missing:
- Suite is marked `skipped` with reason: `"Missing required package: chat-core"`
- No tests execute — clean skip, not a failure
- The runner registry surface shows the skip reason prominently
This directly solves the "workflow demo shows not-installed" pattern:
the runner *knows* what should be installed and reports clearly when it isn't.
### Auto-Cleanup
The `track(type, id)` method registers resources for deletion in `afterAll`.
Supported resource types and their cleanup endpoints:
| Type | Cleanup Action |
|------|---------------|
| `channel` | `DELETE /api/v1/channels/:id` |
| `note` | `DELETE /api/v1/ext/notes/notes/:id` |
| `folder` | `DELETE /api/v1/ext/notes/folders/:id` |
| `workflow` | `DELETE /api/v1/workflows/:id` |
| `schedule` | `DELETE /api/v1/schedules/:id` |
| `package` | `DELETE /api/v1/admin/packages/:id` |
| `user` | `DELETE /api/v1/admin/users/:id` |
| `team` | `DELETE /api/v1/admin/teams/:id` |
Cleanup runs in reverse order (LIFO) in `afterAll`, regardless of
test pass/fail. Cleanup failures are reported as warnings, not failures.
The framework never swallows cleanup errors silently.
### Result Structure
```json
{
"runner": "notes-runner",
"timestamp": "2026-04-01T12:00:00Z",
"duration_ms": 1234,
"summary": { "total": 5, "passed": 4, "failed": 0, "warned": 1, "skipped": 0 },
"suites": [
{
"name": "notes-crud",
"status": "passed",
"duration_ms": 890,
"tests": [
{
"name": "create note",
"status": "passed",
"duration_ms": 120,
"warnings": [],
"cleanup": { "tracked": 1, "cleaned": 1, "failed": 0 }
}
]
}
],
"requires": { "met": ["notes"], "missing": [] }
}
```
### Warning Tier
Three result statuses:
- **`passed`** — assertions all passed, cleanup succeeded
- **`failed`** — at least one assertion failed
- **`warned`** — assertions passed but something non-fatal happened:
- API returned unexpected shape (extra/missing fields) but test doesn't depend on the exact field
- Cleanup failed for a tracked resource
- Timing exceeded a soft threshold
- `t.warn(msg)` called explicitly
Warnings are **never silent.** They appear in the UI and structured results.
The difference from the current `catch (e) { /* ignore */ }` pattern is
that warnings are *visible* — a human or CI system can decide whether
to investigate.
### ICD/SDK Runner Migration
The existing runners use a hand-rolled framework (`T.test()`, `T.assert()`,
`T.authFetch()`). Migration preserves all test logic:
| Current | New |
|---------|-----|
| `T.test(tier, group, name, fn)` | `s.test(name, fn)` inside `sw.testing.suite(tier + '/' + group, fn)` |
| `T.assert(cond, msg)` | `t.assert.ok(cond, msg)` |
| `T.authFetch(token, method, path, body)` | Kept as utility — not an assertion primitive |
| `T.apiPost(...)` | Kept as utility |
| Result rendering (`ui.js`) | Delegated to runner registry surface |
| No cleanup hooks | `s.track()` + `s.afterAll()` |
The ICD and SDK runners become packages with `"type": "test-runner"` in
their manifests. Their existing surfaces (`/s/icd-test-runner`,
`/s/sdk-test-runner`) are replaced by the unified runner registry at
`/s/test-runners`.
## v0.7.2 — Package Runners
### Runner Inventory
| Runner | `requires` | Key Assertions |
|--------|-----------|---------------|
| `notes-runner` | `["notes"]` | CRUD, folders, tags, backlinks, search, markdown rendering, SDK integration |
| `chat-runner` | `["chat", "chat-core"]` | Channel CRUD, messaging, participant display, renderer blocks in messages |
| `schedules-runner` | `["schedules"]` | Schedule CRUD, cron expression, toggle, Starlark exec |
| `workflow-runner` | `["content-approval"]` | Install detection, stage progression, form submission, signoff |
| `renderer-runner` | `["mermaid-renderer"]` | `sw.renderers.register` contract, post-render hooks, block rendering |
### Runner Result API
New kernel endpoints (no package required — kernel-provided):
```
POST /api/v1/test-runners/run → Run all installed runners
POST /api/v1/test-runners/run/:id → Run specific runner
GET /api/v1/test-runners/results → Last run results (JSON)
GET /api/v1/test-runners/results/:id → Last run results for specific runner
```
These endpoints enable CI to trigger and consume runner results via
`curl` without browser automation. The v0.7.3 Playwright harness is
additive — not required for CI gating.
**Auth:** Admin-only. Runners create/delete resources — they must run
with elevated permissions.
### CI Integration
New stage in `.gitea/workflows/ci.yaml`:
```yaml
test-runners:
needs: [unit-tests]
steps:
- name: Boot server
run: |
docker compose up -d
./ci/wait-for-healthy.sh
- name: Run surface runners
run: |
RESULT=$(curl -s -X POST http://localhost:8080/api/v1/test-runners/run \
-H "Authorization: Bearer $ADMIN_TOKEN")
FAILED=$(echo "$RESULT" | jq '.summary.failed')
if [ "$FAILED" != "0" ]; then
echo "$RESULT" | jq '.suites[] | select(.status == "failed")'
exit 1
fi
```
Runs in both PG and SQLite pipelines. Server boots with `BUNDLED_PACKAGES=*`
so all packages and their runners are installed.
## v0.7.3 — Headless E2E
### Playwright Harness
`ci/e2e-surface-test.sh`:
1. `docker compose up -d` (server + DB)
2. `npx playwright install chromium` (CI caches this)
3. Run `ci/e2e-surfaces.spec.ts`
4. Collect screenshots on failure
5. `docker compose down`
### Surface Navigation Smoke Test
```ts
test('all surfaces reachable', async ({ page }) => {
// Login
await page.goto('/');
await page.fill('#username', 'admin');
await page.fill('#password', 'admin');
await page.click('button[type="submit"]');
// Navigate through every installed surface
const surfaces = ['notes', 'chat', 'admin', 'settings', 'docs'];
for (const s of surfaces) {
await page.goto(`/s/${s}`);
// Assert: page loaded, no uncaught JS errors
await expect(page.locator('.sw-topbar')).toBeVisible();
// Assert: home link works
await page.click('.sw-topbar__home');
await expect(page).toHaveURL('/');
}
});
```
This is the automated version of "hello dashboard has no way out" — if
any surface fails to render a topbar or its home link doesn't work, CI
catches it.
### Screenshot on Failure
```ts
test.afterEach(async ({ page }, testInfo) => {
if (testInfo.status !== 'passed') {
await page.screenshot({
path: `ci/artifacts/failure-${testInfo.title}.png`,
fullPage: true
});
const logs = await page.evaluate(() =>
(window.__consoleErrors || []).join('\n')
);
fs.writeFileSync(
`ci/artifacts/console-${testInfo.title}.log`, logs
);
}
});
```
Artifacts saved to CI workspace. On failure, the developer gets a
screenshot + console log dump without needing to reproduce locally.
### Visual Regression (Optional)
Not a CI gate in v0.7.3 — produces a diff report for human review:
```ts
test('visual baseline - notes', async ({ page }) => {
await page.goto('/s/notes');
await expect(page.locator('.sw-topbar')).toBeVisible();
await expect(page).toHaveScreenshot('notes.png', {
maxDiffPixelRatio: 0.01
});
});
```
Playwright stores baseline screenshots in `ci/visual-baselines/`.
`toHaveScreenshot` compares against baseline and produces a diff image
on mismatch. Foundation for future visual regression gating.
## Sequencing
```
v0.7.0 Shell Contract ← prerequisite: surfaces need topbar before
│ runners can assert on it
v0.7.1 Runner Framework ← standardize test authoring
v0.7.2 Package Runners + CI ← write the actual tests, wire into CI
v0.7.3 Headless E2E ← automate browser-based runner execution
```
Each version is independently shippable. v0.7.2's API-based CI gate
works without v0.7.3's Playwright. v0.7.3 adds coverage for DOM-level
bugs that API-only runners can't catch.
## Open Questions
1. **Runner package type.** Should `"type": "test-runner"` be a new
manifest type, or should runners be `"type": "surface"` with a
`"tags": ["test-runner"]` convention? New type is cleaner but requires
a `ValidateManifest()` update.
2. **Runner discovery.** The registry surface needs to find all installed
runners. Options: (a) scan installed packages for `type: "test-runner"`,
(b) runners register themselves via `sw.testing.register()` during SDK
boot. Option (a) is declarative and doesn't require runner JS to load
before discovery.
3. **Parallel vs sequential.** Should runners execute in parallel?
Probably not initially — shared DB state means test isolation is hard.
Sequential is safer. Parallel can be a future optimization.
4. **SQLite limitations.** Some runners (cluster, multi-node) are
PG-only. The `requires` mechanism should support
`"requires_db": "postgres"` for these cases, or runners should
self-skip when `sw.config.db_driver === 'sqlite'`.

View File

@@ -3,11 +3,11 @@
## Quick Start ## Quick Start
```bash ```bash
docker pull ghcr.io/armature/armature:latest docker pull gobha/armature:latest
docker run -p 8080:80 \ docker run -p 8080:80 \
-e ARMATURE_ADMIN_USERNAME=admin \ -e ARMATURE_ADMIN_USERNAME=admin \
-e ARMATURE_ADMIN_PASSWORD=changeme \ -e ARMATURE_ADMIN_PASSWORD=changeme \
ghcr.io/armature/armature: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/armature/armature: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/armature/armature: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/armature/armature: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/armature/armature: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/armature/builder:latest docker pull gobha/armature-builder:latest
``` ```
### What It Caches ### What It Caches
@@ -117,7 +117,7 @@ docker pull ghcr.io/armature/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/armature/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/armature . RUN go build -ldflags="-s -w" -o /bin/armature .
@@ -148,7 +148,7 @@ To exclude specific packages from the bundle, either:
### Forking for Custom Builds ### Forking for Custom Builds
```bash ```bash
git clone https://github.com/armature/armature.git git clone https://github.com/gobha/armature.git
cd armature cd armature
# Add/modify packages # Add/modify packages
@@ -189,13 +189,13 @@ docker run -p 8080:80 \
-e DATABASE_URL="postgres://user:pass@host:5432/armature?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/armature/armature: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 armature-data:/data \ -v armature-data:/data \
ghcr.io/armature/armature:latest gobha/armature:latest
``` ```
### Storage ### Storage

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 (self-hosted, no external requests) |
| `--mono` | Monospace font family (self-hosted) |
| `--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

@@ -79,6 +79,7 @@ Every package has a `manifest.json` at its root. Example for a surface:
| `settings` | no | User-configurable settings schema | | `settings` | no | User-configurable settings schema |
| `exports` | libraries | Functions exported for other packages | | `exports` | libraries | Functions exported for other packages |
| `hooks` | no | Event bus subscriptions | | `hooks` | no | Event bus subscriptions |
| `config_section` | no | Settings/Admin panel injection (see below) |
| `schema_version` | no | Integer for additive schema migrations | | `schema_version` | no | Integer for additive schema migrations |
## db_tables Schema ## db_tables Schema
@@ -138,24 +139,73 @@ Only `path` and `method` are required. All other fields are optional. Malformed
## Starlark Sandbox API ## Starlark Sandbox API
Starlark scripts run server-side with a CPU budget and memory ceiling. Available modules (granted per-permission by admin): Starlark scripts run server-side with a 1M operation budget and no
filesystem access. See the [Starlark Reference](STARLARK-REFERENCE) for
the complete module catalog, function signatures, and permission gates.
| Module | Permission | API | ## config_section — Settings Panel Injection
|--------|-----------|-----|
| `db` | `db.write` | `db.query(table, filters)`, `db.insert(table, row)`, `db.update(table, id, row)`, `db.delete(table, id)` |
| `http` | `http` | `http.get(url)`, `http.post(url, body)` -- SSRF-safe, no private IPs by default |
| `notifications` | `notifications` | `notifications.send(user_id, title, body)` |
| `secrets` | `secrets` | `secrets.get(connection_type)` -- reads from the credential vault |
| `api` | (implicit) | Registers HTTP routes at `/s/:slug/api/*path` |
| `realtime` | `realtime.publish` | `realtime.publish(channel, event, data)` -- push to WebSocket clients |
The sandbox cannot spawn goroutines, access the filesystem, or import arbitrary packages. Packages can inject configuration panels into the Settings, Admin, or
Team Admin surfaces. Declare `config_section` in `manifest.json`:
```json
{
"config_section": {
"label": "My Config",
"icon": "M12 2L2 7l10 5 10-5-10-5z",
"component": "js/config.js",
"surfaces": ["settings", "admin"],
"category": "system"
}
}
```
| Field | Required | Description |
|-------|----------|-------------|
| `label` | yes | Navigation label shown in the sidebar or tab list |
| `icon` | no | SVG path data for the nav icon |
| `component` | no | JS asset path (default: `js/config.js`). Must `export default` a Preact component. |
| `surfaces` | yes | Target surfaces: `"settings"`, `"admin"`, `"team-admin"` |
| `category` | no | Admin surface category tab (default: `"system"`). Ignored for settings/team-admin. |
**How it works:**
1. At page load, the backend scans all enabled packages for `config_section`
entries targeting the current surface.
2. Matching sections are injected into the page as `__CONFIG_SECTIONS__`.
3. The frontend dynamically imports the component module and renders it
as an additional tab/section.
4. The component receives `{ packageId, teamId }` as props.
**Example component** (`js/config.js`):
```javascript
const { html } = window;
const { useState, useEffect } = hooks;
export default function MyConfig({ packageId }) {
const [val, setVal] = useState('');
useEffect(() => {
sw.api.ext(packageId).get('/settings').then(r => setVal(r.value));
}, []);
return html`<div>
<label>API Key</label>
<input value=${val} onInput=${e => setVal(e.target.value)} />
<button onClick=${() => sw.api.ext(packageId).put('/settings', { value: val })}>Save</button>
</div>`;
}
```
## Permissions Model ## Permissions Model
Extensions declare required permissions in `manifest.json`. The admin must grant each permission before the extension can use the corresponding module. Permission status is visible in Admin > Packages > Permissions. Extensions declare required permissions in `manifest.json`. The admin
must grant each permission before the extension can use the corresponding
sandbox module. Permission status is visible in **Admin > Packages > Permissions**.
Kernel permissions for users/groups: `extension.use`, `extension.install`, `workflow.create`, `workflow.submit`, `admin.view`, `token.unlimited`. See [Permissions & Groups](PERMISSIONS-AND-GROUPS) for the full RBAC
model, user permission slugs, and settings cascade.
## File Structure ## File Structure

256
docs/FRONTEND-JS-GUIDE.md Normal file
View File

@@ -0,0 +1,256 @@
# Frontend JS Guide
Armature extensions run in the browser using **Preact + htm** — a 3 KB
runtime with no build step. The kernel provides a rich SDK at `window.sw`
that extensions use for API calls, auth, events, theming, and UI.
## Getting started
Extension surfaces are ES modules loaded via `<script type="module">`.
The SDK is available on `window.sw` after the `sw:ready` DOM event:
```javascript
document.addEventListener('sw:ready', () => {
const { html } = window;
const mount = document.getElementById('my-mount');
preact.render(html`<${App} />`, mount);
});
```
Or use the global `hooks` object for Preact hooks:
```javascript
const { useState, useEffect } = hooks;
```
## SDK modules
All modules live on the `window.sw` object. They are frozen after boot
and available to every extension.
### sw.api — REST client
Generic escape hatches for any endpoint:
```javascript
sw.api.get('/api/v1/docs')
sw.api.post('/api/v1/teams', { name: 'Eng' })
sw.api.put(path, body)
sw.api.patch(path, body)
sw.api.del(path)
sw.api.upload(path, file)
sw.api.stream(path, body, signal)
```
All methods auto-inject auth tokens and return unwrapped `data` from
`{ data: ... }` response envelopes.
**Domain namespaces** provide typed CRUD methods:
| Namespace | Key methods |
|-----------|-------------|
| `sw.api.auth` | `login`, `register`, `refresh`, `logout` |
| `sw.api.teams` | `list`, `get`, `create`, `members`, `workflows`, `assignments` |
| `sw.api.workflows` | `list`, `get`, `stages`, `instances`, `advance`, `cancel` |
| `sw.api.channels` | `list`, `get`, `create`, `update`, `del` |
| `sw.api.notifications` | `list`, `unreadCount`, `markRead`, `markAllRead` |
| `sw.api.admin` | Sub-objects for `users`, `teams`, `groups`, `packages`, `backup`, etc. |
| `sw.api.users` | `search`, `resolve` |
| `sw.api.connections` | `list`, `get`, `create`, `resolve` |
| `sw.api.ext(pkgId)` | Scoped client for extension API routes: `get`, `post`, `put`, `del` |
### sw.auth — Authentication state
```javascript
sw.auth.isAuthenticated // boolean
sw.auth.user // { id, username, display_name, email, role, avatar }
sw.auth.permissions // Set<string>
sw.auth.teams // Array<{ id, name, role }>
sw.auth.groups // Array<{ id, name, permissions }>
```
Lifecycle: `sw.auth.login(login, pw)`, `sw.auth.logout()`, `sw.auth.refresh()`.
### sw.can — RBAC gates
```javascript
sw.can('workflow.create') // true if user has permission
sw.isAdmin // true if surface.admin.access granted
sw.isTeamAdmin(teamId) // true if admin role in team
```
Use these to conditionally render UI elements.
### sw.on / sw.off / sw.emit — Event bus
```javascript
const unsub = sw.on('theme.changed', (payload) => { ... });
sw.once('auth.login', (user) => { ... });
sw.off('theme.changed'); // remove all listeners
sw.off('theme.changed', fn); // remove specific listener
sw.emit('my.event', { data });
```
The event bus bridges to the WebSocket — server-emitted events
(e.g. `notification.created`, `workflow.sla_breach`) arrive here.
### sw.theme — Theme control
```javascript
sw.theme.current // 'dark' or 'light' (resolved)
sw.theme.mode // 'dark', 'light', or 'system'
sw.theme.set('dark')
sw.theme.on('change', (theme) => { ... })
sw.theme.tokens // live CSS variables as camelCase JS object
```
### sw.storage — Namespaced localStorage
```javascript
const store = sw.storage.local('my-extension');
store.set('key', { complex: 'value' });
store.get('key') // parsed object
store.remove('key')
store.keys() // ['key', ...]
store.clear()
```
### sw.realtime — WebSocket pub/sub
```javascript
const unsub = sw.realtime.subscribe('my-channel', 'item.updated', (data) => { ... });
// Or subscribe to all events on a channel:
const unsub = sw.realtime.subscribe('my-channel', (event, data) => { ... });
```
### sw.slots — UI slot registry
Register components into named shell slots (e.g. toolbar areas):
```javascript
const unreg = sw.slots.register('topbar-actions', {
id: 'my-button',
component: MyButton,
priority: 10,
});
```
### sw.actions — Named action registry
```javascript
sw.actions.register('copy-link', {
handler: async (url) => navigator.clipboard.writeText(url),
label: 'Copy Link',
icon: 'M12 2...',
});
await sw.actions.run('copy-link', someUrl);
```
### sw.pipe — Filter pipeline
Three-stage pipeline for message processing:
```javascript
sw.pipe.pre(10, async (ctx) => { /* pre-process */ return ctx; });
sw.pipe.stream(10, async (ctx) => { /* streaming */ return ctx; });
sw.pipe.render(10, async (ctx) => { /* post-render */ return ctx; });
```
### sw.renderers — Block and post renderers
Register custom renderers for fenced code blocks or post-processing:
```javascript
sw.renderers.register('mermaid', {
type: 'block',
pattern: /^mermaid$/,
render: (code, container) => { /* render diagram */ },
});
sw.renderers.register('linkify', {
type: 'post',
render: (container) => { /* post-process rendered HTML */ },
});
```
### sw.markdown — Unified rendering
```javascript
const html = sw.markdown.renderSync(markdownString, { sanitize: false });
await sw.markdown.render(markdownString); // async variant
sw.markdown.ready // boolean — true after preload
```
Uses marked + DOMPurify + registered `sw.renderers`.
### sw.users — Identity resolution
```javascript
const user = await sw.users.resolve(userId);
const map = await sw.users.resolveMany([id1, id2]);
sw.users.displayName(userObj) // display_name || username || 'Unknown'
```
Results are cached for 60 seconds. Batch fetches use the server's
bulk resolve endpoint.
### sw.testing — Test framework
For writing package runner tests:
```javascript
sw.testing.suite('CRUD', async (s) => {
s.before(async () => { /* setup */ });
s.after(async () => { /* cleanup */ });
s.test('creates item', async (t) => {
const resp = await sw.api.post('/api/v1/items', { name: 'test' });
t.assert.status(resp, 200);
t.assert.ok(resp.id);
s.track('item', resp.id); // auto-cleanup in afterAll
});
});
await sw.testing.run();
```
### sw.shell — Topbar API
The kernel injects a two-slot topbar into every extension surface.
Extensions customize it via:
```javascript
sw.shell.topbar.setTitle('My Surface'); // text in left slot
sw.shell.topbar.setSlot(html`<${TabBar} />`); // center slot content
sw.shell.topbar.hide(); // full-bleed mode
sw.shell.topbar.show(); // restore topbar
```
### sw.toast / sw.confirm / sw.prompt — UI primitives
```javascript
sw.toast('Saved!', 'success'); // success | error | info
sw.toast('Something broke', 'error', 5000); // custom duration
const ok = await sw.confirm('Delete this item?');
const name = await sw.prompt('Enter name', 'default value');
```
## Shell topbar patterns
Every surface uses one of three patterns:
| Pattern | Description | Example |
|---------|-------------|---------|
| **A — Default** | Shell title only. No center slot content. | Docs |
| **B — Flat tabs** | `setTitle()` + tabs in center slot via `setSlot()`. Full-width content. | Settings, Team Admin |
| **C — Category tabs + sidebar** | `setTitle()` + category tabs in center slot. Surface-owned sidebar below. | Admin |
The shell provides the home link, notification bell, and user menu
on every surface for free.
## Extension CSS contract
Extensions must prefix all CSS classes with `.ext-{slug}-` to avoid
conflicts with kernel styles. See the [Extension CSS](EXTENSION-CSS)
doc for the full isolation rules, available primitives from
`sw-primitives.css`, and spacing tokens.

View File

@@ -12,7 +12,7 @@ docker compose up --build
Open [http://localhost:3000](http://localhost:3000). Default credentials: `admin` / `admin`. Open [http://localhost:3000](http://localhost:3000). Default credentials: `admin` / `admin`.
Data persists in the `sb_data` named volume. To reset everything: Data persists in the `armature_data` named volume. To reset everything:
```bash ```bash
docker compose down -v docker compose down -v

View File

@@ -0,0 +1,121 @@
# Permissions & Groups
Armature uses group-based RBAC. Permissions are granted to groups, and users
inherit the union of permissions from all groups they belong to. There are no
per-user permission grants — all access flows through group membership.
## Groups
### System groups
| Group | ID | Purpose |
|-------|----|---------|
| Everyone | `00000000-...0001` | Implicit membership for every authenticated user. Default permissions: `extension.use`, `workflow.submit`. |
| Admins | `00000000-...0002` | Full platform access. Members receive all seven permission slugs. Replaces the legacy `role = admin` check. |
Every new user is automatically added to **Everyone** on registration.
Admin status is granted by adding a user to the **Admins** group in
**Admin > People > Groups**.
### Custom groups
Administrators can create additional groups under **Admin > People > Groups**.
Each custom group has:
- **Name** — display label
- **Description** — purpose (shown in admin UI)
- **Scope** — always `global` (team-scoped groups reserved for future use)
- **Permissions** — zero or more permission slugs from the table below
## Permission slugs
Seven platform permissions control access to kernel features:
| Slug | Description |
|------|-------------|
| `surface.admin.access` | Full admin panel access (tabs, settings, package management) |
| `admin.view` | Read-only admin panel access (monitoring, health, audit log) |
| `extension.use` | Use installed extension surfaces and libraries |
| `extension.install` | Install, update, enable, and disable packages |
| `workflow.create` | Create and edit workflow definitions |
| `workflow.submit` | Submit instances to public-link workflows |
| `token.unlimited` | Bypass per-user token budgets (API rate limiting) |
Permissions follow a `domain.action` naming convention.
## Permission resolution
When a request arrives, the kernel resolves the effective permission set:
1. Fetch all groups the user belongs to (including Everyone).
2. Union all permission arrays across those groups.
3. Cache the result for the duration of the request.
A user has a permission if **any** of their groups grants it.
Frontend code checks permissions via `sw.can('slug')` — see the
[Frontend JS Guide](FRONTEND-JS-GUIDE) for details.
## Extension permissions
Separate from user permissions, each **package** can request sandbox
capabilities. These are granted per-package in **Admin > Packages**:
| Permission | Grants |
|------------|--------|
| `db.read` | Query `ext_data` tables (read-only) |
| `db.write` | Insert, update, and delete rows in `ext_data` tables |
| `api.http` | Make outbound HTTP requests from Starlark |
| `notifications.send` | Send in-app notifications to users |
| `secrets.read` | Read admin-configured extension secrets |
| `realtime.publish` | Publish WebSocket events to subscribed clients |
| `connections.read` | Read external connection configs (decrypted) |
| `workflow.access` | Read workflow definitions and instances |
See the [Starlark Reference](STARLARK-REFERENCE) for how these
map to sandbox modules.
## Settings cascade
Package settings use a three-tier resolution model:
```
user override → team override → global default
```
At each tier:
- **Global** — set by admins in **Admin > Packages > Settings**
- **Team** — set by team admins in **Team Admin > Settings**
- **User** — set by users in **Settings > Extensions**
### The `user_overridable` flag
Each setting key in a package manifest can declare `user_overridable`:
```json
{
"settings": [
{ "key": "theme", "user_overridable": true },
{ "key": "api_endpoint", "user_overridable": false }
]
}
```
- `true` (default) — team and user scopes can override the global value.
- `false` — only the global (admin) value is used. Team and user values
are silently ignored during resolution.
This gives administrators a lock mechanism: set `user_overridable: false`
on security-sensitive keys to prevent lower scopes from changing them,
while allowing cosmetic preferences to flow freely.
### Resolution algorithm
1. Start with the global value for each key.
2. For each key where `user_overridable` is true (or undeclared):
- If a team-scoped value exists, it overrides global.
- If a user-scoped value exists, it overrides team.
3. For keys where `user_overridable` is false:
- Team and user values are discarded.
4. Unknown keys (not in schema) default to overridable.

232
docs/STARLARK-REFERENCE.md Normal file
View File

@@ -0,0 +1,232 @@
# Starlark Reference
Armature extensions can include Starlark scripts for server-side logic.
Starlark is a Python-like language designed for configuration and
embedding — see the [official spec](https://github.com/google/starlark-go).
## Sandbox constraints
- **No `while` loops** — use `for` with bounded ranges.
- **No `load()`** — use `lib.require()` for library dependencies.
- **Max steps:** 1,000,000 bytecode operations per execution.
- **No filesystem or OS access** — all I/O goes through gated modules.
- **Deterministic** — same input produces same output (no `random`, no `time`).
## Always-available modules
These modules are injected into every script with no permission required.
### json
Standard Starlark JSON module.
```python
data = json.decode('{"key": "value"}')
text = json.encode({"key": "value"})
```
### settings
Read resolved package settings (global → team → user cascade).
```python
val = settings.get("theme", "light")
# Returns the resolved value, or the default if unset.
```
The cascade respects the `user_overridable` flag from the package manifest.
See [Permissions & Groups](PERMISSIONS-AND-GROUPS) for details.
### lib
Load exported functions from library packages.
```python
helpers = lib.require("my-utils")
result = helpers.format_date("2026-01-15")
```
Requirements:
- The library must be declared in your package manifest's `dependencies`.
- The library must be type `library`, status `active`, tier `starlark`.
- Circular dependencies are detected and rejected.
- Results are cached per execution (calling `require` twice returns the
same object).
## Permission-gated modules
These modules are only available if the package has the corresponding
permission granted in **Admin > Packages**.
### secrets
**Permission:** `secrets.read`
Read admin-configured secrets for this package.
```python
api_key = secrets.get("OPENAI_KEY") # str or None
all_keys = secrets.list() # list of key names
```
Secrets are set in **Admin > Packages > Secrets** and scoped per package.
### notifications
**Permission:** `notifications.send`
Send in-app notifications to users.
```python
notifications.send(
user_id, # str — target user UUID
title, # str — notification title
body="", # str — optional body text
type="extension.notify" # str — notification type
)
```
### db
**Permission:** `db.read` (queries) or `db.write` (mutations)
Read and write extension data tables. All tables are automatically
namespaced as `ext_{package_id}_{table_name}`.
#### Read operations
```python
# Query with filters, ordering, and pagination
rows = db.query(
"tasks", # table name (without prefix)
filters={"status": "open"}, # equality WHERE clauses
order="-created_at", # column name (prefix - for DESC)
limit=50, # max 1000
before={"created_at": ts}, # range: column < value
after={"created_at": ts}, # range: column > value
search_like={"title": "%bug%"} # LIKE/ILIKE search
)
# Read from system views (read-only)
users = db.view("users", filters={"display_name": "Alice"}, limit=10)
channels = db.view("channels", limit=100)
# List all tables owned by this package
tables = db.list_tables()
```
Available views: `users`, `channels`.
#### Write operations
```python
row = db.insert("tasks", {"title": "Fix bug", "status": "open"})
# Returns the inserted row dict (with generated id, created_at)
db.update("tasks", row_id, {"status": "closed"})
# Returns True on success
db.delete("tasks", row_id)
# Returns True on success
```
### http
**Permission:** `api.http`
Make outbound HTTP requests.
```python
resp = http.get("https://api.example.com/data", headers={"Authorization": "Bearer ..."})
resp = http.post(url, body='{"key": "val"}', headers={"Content-Type": "application/json"})
resp = http.put(url, body="...", headers={})
resp = http.delete(url, headers={})
resp = http.request("PATCH", url, body="...", headers={})
```
Response dict:
```python
{
"status": 200,
"headers": {"content-type": "application/json"},
"body": "..." # capped at 1 MB
}
```
**Security:**
- Private/loopback IPs are blocked (SSRF protection).
- Packages can declare `network_access.allow` (allowlist) or
`network_access.block` (blocklist) in their manifest.
- Max 10 redirects. 10-second timeout. 1 MB response body limit.
### realtime
**Permission:** `realtime.publish`
Publish WebSocket events to subscribed clients.
```python
realtime.publish(
"my-channel", # channel name
"item.updated", # event label
{"id": "abc"} # payload dict (max 7 KB)
)
```
The payload is automatically tagged with `_pkg: package_id`.
### connections
**Permission:** `connections.read`
Read external connection configurations (secrets are decrypted).
```python
conn = connections.get("postgres", "main-db")
# Returns dict with id, type, name, scope, plus flattened config fields
# Returns None if not found
all_pg = connections.list("postgres")
# Returns list of connection dicts
```
Connections are resolved via scope chain: personal → team → global.
### workflow
**Permission:** `workflow.access`
Read workflow definitions and instances (read-only from Starlark;
mutations go through the HTTP API).
```python
defn = workflow.get_definition(workflow_id)
# Returns dict: id, name, slug, entry_mode, is_active, version, stages[]
inst = workflow.get_instance(instance_id)
# Returns dict: id, workflow_id, current_stage, status, stage_data, ...
instances = workflow.list_instances(workflow_id, status="active")
# Returns list of instance dicts
```
## Example: automated stage hook
A simple hook that reads a setting, queries data, and advances:
```python
def on_run(ctx):
threshold = settings.get("approval_threshold", 1000)
amount = ctx["stage_data"].get("amount", 0)
if amount > threshold:
notifications.send(
ctx["started_by"],
"High-value submission",
body="Amount %d exceeds threshold." % amount,
)
return {"advance": True, "data": {"needs_review": True}}
return {"advance": True, "data": {"needs_review": False}}
```

View File

@@ -54,7 +54,7 @@ and register with the SDK through `sw.renderers`:
}, },
render(lang, code, container) { render(lang, code, container) {
container.innerHTML = container.innerHTML =
'<div style="padding:12px;background:var(--bg-2);' + '<div style="padding:12px;background:var(--bg-secondary);' +
'border:1px solid var(--border);border-radius:8px">' + 'border:1px solid var(--border);border-radius:8px">' +
'<strong>Demo:</strong> ' + code + '<strong>Demo:</strong> ' + code +
'</div>'; '</div>';
@@ -74,7 +74,7 @@ The IIFE wrapper keeps variables out of global scope. `sw.renderers.register`
takes a name and an options object: `type: 'block'` targets fenced code blocks, takes a name and an options object: `type: 'block'` targets fenced code blocks,
`match` checks the language tag, and `render` receives the language, raw code, `match` checks the language tag, and `render` receives the language, raw code,
and a container element. The `sw:ready` event fires once the SDK initializes; and a container element. The `sw:ready` event fires once the SDK initializes;
if already loaded, register immediately. Use CSS variables like `var(--bg-2)` if already loaded, register immediately. Use CSS variables like `var(--bg-secondary)`
and `var(--border)` to follow the active theme. and `var(--border)` to follow the active theme.
## Step 4: Package It ## Step 4: Package It

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

193
docs/WORKFLOWS.md Normal file
View File

@@ -0,0 +1,193 @@
# Workflows
Workflows are multi-stage processes with team assignment, validation gates,
SLA enforcement, and optional Starlark automation. They are managed in
**Team Admin > Workflows**.
## Core concepts
| Concept | Description |
|---------|-------------|
| **Definition** | A named template: stages, entry mode, staleness timeout. Created per-team or adopted from global definitions. |
| **Stage** | One step in the workflow. Has a mode, audience, optional team assignment, and optional SLA. |
| **Instance** | A running copy of a definition. Pins a published version snapshot and tracks accumulated stage data. |
| **Assignment** | A queue entry linking an instance stage to a team member. Claim → work → complete. |
| **Signoff** | An approval or rejection recorded against an instance stage (multi-party validation). |
## Entry modes
| Mode | Description |
|------|-------------|
| `team_only` | Only authenticated team members can start instances. |
| `public_link` | Anyone with the public URL can start an instance. The first stage must have `audience: public`. An `entry_token` is issued for the anonymous submitter to resume later. |
Public entry URL format:
```
{origin}/api/v1/public/workflows/{workflow_id}/start
```
## Stage modes
Each stage has a **mode** that determines how it progresses:
| Mode | Description |
|------|-------------|
| `form` | User submits structured data. Stage data is accumulated into the instance. |
| `review` | Multi-party sign-off gate. Requires configured approvals before advancing. |
| `delegated` | Assigned to a team member queue. The assignee claims, works, and completes. |
| `automated` | Starlark hook executes without user interaction. Can chain up to 10 consecutive automated stages. |
## Stage types
| Type | Description |
|------|-------------|
| `simple` | Linear — always advances to the next ordinal. |
| `dynamic` | Conditional — evaluates branch rules against stage data to pick the next stage. |
| `automated` | Combined with mode `automated` for fully scripted stages. |
## Audiences
| Audience | Description |
|----------|-------------|
| `team` | Only authenticated team members can interact. |
| `public` | Anonymous users can interact (used with `public_link` entry). |
| `system` | System-generated stages, no direct user interaction. |
## Team assignment
When a stage has `assignment_team_id` set, the engine creates an
**assignment** record:
1. Assignment enters the queue with status `unassigned`.
2. A team member **claims** the assignment (status → `claimed`).
3. The assignee works the stage and **completes** it (status → `completed`).
4. The engine auto-advances to the next stage.
A **required role** can restrict who may claim:
- Set `stage_config.required_role` to a team role name (e.g. `"reviewer"`).
- Only members with that role can claim the assignment.
Team roles are configured in **Team Admin > Settings > Roles**.
## Signoff gates (multi-party validation)
Review-mode stages can require multiple approvals before advancing.
Configure via `stage_config.validation`:
```json
{
"validation": {
"required_approvals": 2,
"required_role": "approver",
"reject_action": "cancel"
}
}
```
| Field | Description |
|-------|-------------|
| `required_approvals` | Minimum approve decisions needed to advance. |
| `required_role` | Only members with this team role can sign off. Empty = any member. |
| `reject_action` | What happens on rejection: `"cancel"` (default) cancels the instance, or a stage name to reroute. |
Each signoff records: user, decision (`approve` or `reject`), optional comment, timestamp.
## SLA enforcement
Two timeout mechanisms run in a background scanner (every 5 minutes):
### Per-stage SLA
Set `sla_seconds` on a stage. When an instance has been in that stage
longer than the threshold:
- `sla_breached` flag is set in instance metadata.
- A `workflow.sla_breach` WebSocket event is emitted.
- The instance is **not** auto-cancelled — breaches are informational.
### Per-workflow staleness
Set `staleness_timeout_hours` on the workflow definition. When an instance
has not been updated for longer than the threshold:
- Instance status is set to `stale`.
- All open assignments are cancelled.
- A `workflow.stale` WebSocket event is emitted.
## Branch rules
Dynamic stages evaluate conditions against accumulated `stage_data`
to determine the next stage. Rules are a JSON array on the stage:
```json
[
{ "field": "priority", "op": "eq", "value": "high", "target_stage": "escalation" },
{ "field": "amount", "op": "gt", "value": 10000, "target_stage": "manager-review" }
]
```
First matching rule wins. If no rules match, the next ordinal stage is used.
### Operators
| Op | Description |
|----|-------------|
| `eq` | Equal (string-normalized) |
| `neq` | Not equal |
| `gt`, `lt`, `gte`, `lte` | Numeric comparisons |
| `exists` | Field is present in stage data |
| `not_exists` | Field is absent |
| `in` | Value is in a list |
| `contains` | String contains substring |
`target_stage` can be a stage name (case-insensitive) or a numeric ordinal.
## Publishing
Workflows have a draft/publish lifecycle:
1. Edit stages and configuration in the workflow editor (draft state).
2. **Publish** creates a versioned snapshot of all stages.
3. New instances pin the latest published version.
4. Editing stages after publishing does not affect running instances.
Version numbers auto-increment. The snapshot preserves the complete
stage definition array at publish time.
## Starlark hooks
Automated stages execute a Starlark script via the `starlark_hook` field:
```
package_id:entry_point
```
For example: `my-automation:on_review` calls the `on_review` function
in the `my-automation` package. If no entry point is specified,
`on_run` is used.
The hook receives a context dict:
```python
{
"instance_id": "...",
"current_stage": "...",
"workflow_id": "...",
"started_by": "...",
"stage_data": { ... }
}
```
The hook returns a dict controlling what happens next:
| Key | Effect |
|-----|--------|
| `advance: True` | Auto-advance to the next stage |
| `data: { ... }` | Merge into stage data for the next stage |
| `error: "msg"` | Set instance status to `error` and halt |
Up to 10 consecutive automated stages can chain before the engine
stops with an error (cycle guard).
See the [Starlark Reference](STARLARK-REFERENCE) for available
sandbox modules.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

BIN
icons/favicon-16-b-dark.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 214 B

BIN
icons/favicon-16-e-dark.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 304 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 312 B

BIN
icons/favicon-32-b-dark.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 363 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 332 B

BIN
icons/favicon-32-e-dark.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 457 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 459 B

View File

@@ -0,0 +1,20 @@
<svg viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
<style>
@keyframes p { 0%,100%{r:2.5;opacity:1} 50%{r:3;opacity:0.8} }
.c { animation: p 2.4s ease-in-out infinite; }
</style>
<rect width="32" height="32" rx="6" fill="#14142a"/>
<line x1="16" y1="8" x2="26" y2="16" stroke="#c8c8d0" stroke-width="1" opacity="0.3"/>
<line x1="26" y1="16" x2="16" y2="24" stroke="#c8c8d0" stroke-width="1" opacity="0.3"/>
<line x1="16" y1="24" x2="6" y2="16" stroke="#c8c8d0" stroke-width="1" opacity="0.3"/>
<line x1="6" y1="16" x2="16" y2="8" stroke="#c8c8d0" stroke-width="1" opacity="0.3"/>
<line x1="6" y1="16" x2="16" y2="16" stroke="#c8c8d0" stroke-width="0.8" opacity="0.4"/>
<line x1="26" y1="16" x2="16" y2="16" stroke="#c8c8d0" stroke-width="0.8" opacity="0.4"/>
<line x1="16" y1="8" x2="16" y2="16" stroke="#c8c8d0" stroke-width="0.8" opacity="0.4"/>
<line x1="16" y1="16" x2="16" y2="24" stroke="#c8c8d0" stroke-width="0.8" opacity="0.4"/>
<circle cx="16" cy="8" r="2" fill="#3B82F6"/>
<circle cx="6" cy="16" r="2" fill="#3B82F6"/>
<circle cx="26" cy="16" r="2" fill="#EF4444"/>
<circle cx="16" cy="24" r="2" fill="#3B82F6"/>
<circle cx="16" cy="16" r="2.5" fill="#3B82F6" class="c"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

16
icons/favicon.svg Normal file
View File

@@ -0,0 +1,16 @@
<svg viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
<rect width="32" height="32" rx="6" fill="#14142a"/>
<line x1="16" y1="7" x2="26" y2="16" stroke="#c8c8d0" stroke-width="1" opacity="0.3"/>
<line x1="26" y1="16" x2="16" y2="25" stroke="#c8c8d0" stroke-width="1" opacity="0.3"/>
<line x1="16" y1="25" x2="6" y2="16" stroke="#c8c8d0" stroke-width="1" opacity="0.3"/>
<line x1="6" y1="16" x2="16" y2="7" stroke="#c8c8d0" stroke-width="1" opacity="0.3"/>
<line x1="6" y1="16" x2="16" y2="16" stroke="#c8c8d0" stroke-width="0.8" opacity="0.4"/>
<line x1="26" y1="16" x2="16" y2="16" stroke="#c8c8d0" stroke-width="0.8" opacity="0.4"/>
<line x1="16" y1="7" x2="16" y2="16" stroke="#c8c8d0" stroke-width="0.8" opacity="0.4"/>
<line x1="16" y1="16" x2="16" y2="25" stroke="#c8c8d0" stroke-width="0.8" opacity="0.4"/>
<circle cx="16" cy="7" r="2" fill="#3B82F6"/>
<circle cx="6" cy="16" r="2" fill="#3B82F6"/>
<circle cx="26" cy="16" r="2" fill="#EF4444"/>
<circle cx="16" cy="25" r="2" fill="#3B82F6"/>
<circle cx="16" cy="16" r="2.5" fill="#3B82F6"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
icons/icon-128-b-dark.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

BIN
icons/icon-128-b-light.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

BIN
icons/icon-128-e-dark.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

BIN
icons/icon-128-e-light.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

BIN
icons/icon-192-b-dark.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

BIN
icons/icon-192-b-light.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

BIN
icons/icon-192-e-dark.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

BIN
icons/icon-192-e-light.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

BIN
icons/icon-256-b-dark.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

BIN
icons/icon-256-b-light.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

BIN
icons/icon-256-e-dark.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

BIN
icons/icon-256-e-light.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

BIN
icons/icon-48-b-dark.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 496 B

BIN
icons/icon-48-b-light.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 492 B

BIN
icons/icon-48-e-dark.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 564 B

BIN
icons/icon-48-e-light.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 559 B

BIN
icons/icon-512-b-dark.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

BIN
icons/icon-512-b-light.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

BIN
icons/icon-512-e-dark.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

BIN
icons/icon-512-e-light.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

BIN
icons/icon-64-b-dark.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 626 B

BIN
icons/icon-64-b-light.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 620 B

BIN
icons/icon-64-e-dark.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 711 B

BIN
icons/icon-64-e-light.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 707 B

View File

@@ -0,0 +1,48 @@
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
<style>
@keyframes center-pulse {
0%, 100% { r: 10; opacity: 1; }
50% { r: 12; opacity: 0.85; }
}
@keyframes glow-pulse {
0%, 100% { r: 16; opacity: 0.12; }
50% { r: 22; opacity: 0.06; }
}
@keyframes node-breathe {
0%, 100% { opacity: 1; }
50% { opacity: 0.8; }
}
.center-node { animation: center-pulse 2.4s ease-in-out infinite; }
.center-glow { animation: glow-pulse 2.4s ease-in-out infinite; }
.node-tl { animation: node-breathe 2.4s ease-in-out infinite 0.3s; }
.node-tr { animation: node-breathe 2.4s ease-in-out infinite 0.6s; }
.node-bl { animation: node-breathe 2.4s ease-in-out infinite 0.9s; }
.node-br { animation: node-breathe 2.4s ease-in-out infinite 1.2s; }
</style>
<rect width="200" height="200" rx="32" fill="#14142a"/>
<!-- Diamond outline -->
<line x1="100" y1="51" x2="145" y2="100" stroke="#c8c8d0" stroke-width="2" stroke-linecap="round" opacity="0.25"/>
<line x1="145" y1="100" x2="100" y2="149" stroke="#c8c8d0" stroke-width="2" stroke-linecap="round" opacity="0.25"/>
<line x1="100" y1="149" x2="55" y2="100" stroke="#c8c8d0" stroke-width="2" stroke-linecap="round" opacity="0.25"/>
<line x1="55" y1="100" x2="100" y2="51" stroke="#c8c8d0" stroke-width="2" stroke-linecap="round" opacity="0.25"/>
<!-- Cross lines -->
<line x1="55" y1="100" x2="100" y2="100" stroke="#c8c8d0" stroke-width="2" stroke-linecap="round" opacity="0.4"/>
<line x1="145" y1="100" x2="100" y2="100" stroke="#c8c8d0" stroke-width="2" stroke-linecap="round" opacity="0.4"/>
<line x1="100" y1="51" x2="100" y2="100" stroke="#c8c8d0" stroke-width="2" stroke-linecap="round" opacity="0.4"/>
<line x1="100" y1="100" x2="100" y2="149" stroke="#c8c8d0" stroke-width="2" stroke-linecap="round" opacity="0.4"/>
<!-- Center glow -->
<circle cx="100" cy="100" r="16" fill="#3B82F6" opacity="0.12" class="center-glow"/>
<!-- Nodes -->
<circle cx="100" cy="51" r="8" fill="#3B82F6" class="node-tl"/>
<circle cx="55" cy="100" r="8" fill="#3B82F6" class="node-tr"/>
<circle cx="145" cy="100" r="8" fill="#EF4444" class="node-bl"/>
<circle cx="100" cy="149" r="8" fill="#3B82F6" class="node-br"/>
<!-- Center -->
<circle cx="100" cy="100" r="10" fill="#3B82F6" class="center-node"/>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

21
icons/icon-b-dark.svg Normal file
View File

@@ -0,0 +1,21 @@
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
<rect width="200" height="200" rx="32" fill="#14142a"/>
<!-- Diamond outline -->
<line x1="100" y1="55" x2="142" y2="100" stroke="#c8c8d0" stroke-width="2" stroke-linecap="round" opacity="0.25"/>
<line x1="142" y1="100" x2="100" y2="145" stroke="#c8c8d0" stroke-width="2" stroke-linecap="round" opacity="0.25"/>
<line x1="100" y1="145" x2="58" y2="100" stroke="#c8c8d0" stroke-width="2" stroke-linecap="round" opacity="0.25"/>
<line x1="58" y1="100" x2="100" y2="55" stroke="#c8c8d0" stroke-width="2" stroke-linecap="round" opacity="0.25"/>
<!-- Cross lines -->
<line x1="58" y1="100" x2="100" y2="100" stroke="#c8c8d0" stroke-width="2" stroke-linecap="round" opacity="0.4"/>
<line x1="142" y1="100" x2="100" y2="100" stroke="#c8c8d0" stroke-width="2" stroke-linecap="round" opacity="0.4"/>
<line x1="100" y1="55" x2="100" y2="100" stroke="#c8c8d0" stroke-width="2" stroke-linecap="round" opacity="0.4"/>
<line x1="100" y1="100" x2="100" y2="145" stroke="#c8c8d0" stroke-width="2" stroke-linecap="round" opacity="0.4"/>
<!-- Center glow -->
<circle cx="100" cy="100" r="16" fill="#3B82F6" opacity="0.12"/>
<!-- Nodes -->
<circle cx="100" cy="55" r="8" fill="#3B82F6"/>
<circle cx="58" cy="100" r="8" fill="#3B82F6"/>
<circle cx="142" cy="100" r="8" fill="#EF4444"/>
<circle cx="100" cy="145" r="8" fill="#3B82F6"/>
<circle cx="100" cy="100" r="10" fill="#3B82F6"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1,43 @@
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
<style>
@keyframes center-pulse {
0%, 100% { r: 10; opacity: 1; }
50% { r: 12; opacity: 0.85; }
}
@keyframes glow-pulse {
0%, 100% { r: 16; opacity: 0.1; }
50% { r: 22; opacity: 0.05; }
}
@keyframes node-breathe {
0%, 100% { opacity: 1; }
50% { opacity: 0.8; }
}
.center-node { animation: center-pulse 2.4s ease-in-out infinite; }
.center-glow { animation: glow-pulse 2.4s ease-in-out infinite; }
.node-tl { animation: node-breathe 2.4s ease-in-out infinite 0.3s; }
.node-tr { animation: node-breathe 2.4s ease-in-out infinite 0.6s; }
.node-bl { animation: node-breathe 2.4s ease-in-out infinite 0.9s; }
.node-br { animation: node-breathe 2.4s ease-in-out infinite 1.2s; }
</style>
<rect width="200" height="200" rx="32" fill="#e8e8ee"/>
<line x1="100" y1="51" x2="145" y2="100" stroke="#333" stroke-width="2" stroke-linecap="round" opacity="0.18"/>
<line x1="145" y1="100" x2="100" y2="149" stroke="#333" stroke-width="2" stroke-linecap="round" opacity="0.18"/>
<line x1="100" y1="149" x2="55" y2="100" stroke="#333" stroke-width="2" stroke-linecap="round" opacity="0.18"/>
<line x1="55" y1="100" x2="100" y2="51" stroke="#333" stroke-width="2" stroke-linecap="round" opacity="0.18"/>
<line x1="55" y1="100" x2="100" y2="100" stroke="#333" stroke-width="2" stroke-linecap="round" opacity="0.3"/>
<line x1="145" y1="100" x2="100" y2="100" stroke="#333" stroke-width="2" stroke-linecap="round" opacity="0.3"/>
<line x1="100" y1="51" x2="100" y2="100" stroke="#333" stroke-width="2" stroke-linecap="round" opacity="0.3"/>
<line x1="100" y1="100" x2="100" y2="149" stroke="#333" stroke-width="2" stroke-linecap="round" opacity="0.3"/>
<circle cx="100" cy="100" r="16" fill="#3B82F6" opacity="0.1" class="center-glow"/>
<circle cx="100" cy="51" r="8" fill="#3B82F6" class="node-tl"/>
<circle cx="55" cy="100" r="8" fill="#3B82F6" class="node-tr"/>
<circle cx="145" cy="100" r="8" fill="#EF4444" class="node-bl"/>
<circle cx="100" cy="149" r="8" fill="#3B82F6" class="node-br"/>
<circle cx="100" cy="100" r="10" fill="#3B82F6" class="center-node"/>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

17
icons/icon-b-light.svg Normal file
View File

@@ -0,0 +1,17 @@
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
<rect width="200" height="200" rx="32" fill="#e8e8ee"/>
<line x1="100" y1="55" x2="142" y2="100" stroke="#333" stroke-width="2" stroke-linecap="round" opacity="0.18"/>
<line x1="142" y1="100" x2="100" y2="145" stroke="#333" stroke-width="2" stroke-linecap="round" opacity="0.18"/>
<line x1="100" y1="145" x2="58" y2="100" stroke="#333" stroke-width="2" stroke-linecap="round" opacity="0.18"/>
<line x1="58" y1="100" x2="100" y2="55" stroke="#333" stroke-width="2" stroke-linecap="round" opacity="0.18"/>
<line x1="58" y1="100" x2="100" y2="100" stroke="#333" stroke-width="2" stroke-linecap="round" opacity="0.3"/>
<line x1="142" y1="100" x2="100" y2="100" stroke="#333" stroke-width="2" stroke-linecap="round" opacity="0.3"/>
<line x1="100" y1="55" x2="100" y2="100" stroke="#333" stroke-width="2" stroke-linecap="round" opacity="0.3"/>
<line x1="100" y1="100" x2="100" y2="145" stroke="#333" stroke-width="2" stroke-linecap="round" opacity="0.3"/>
<circle cx="100" cy="100" r="16" fill="#3B82F6" opacity="0.1"/>
<circle cx="100" cy="55" r="8" fill="#3B82F6"/>
<circle cx="58" cy="100" r="8" fill="#3B82F6"/>
<circle cx="142" cy="100" r="8" fill="#EF4444"/>
<circle cx="100" cy="145" r="8" fill="#3B82F6"/>
<circle cx="100" cy="100" r="10" fill="#3B82F6"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1,67 @@
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
<style>
@keyframes draw-edge {
from { stroke-dashoffset: 120; }
to { stroke-dashoffset: 0; }
}
@keyframes pop-node {
0% { r: 0; opacity: 0; }
70% { r: 8; }
100% { r: 6; opacity: 1; }
}
@keyframes pop-center {
0% { r: 0; opacity: 0; }
70% { r: 10; }
100% { r: 8; opacity: 0.7; }
}
@keyframes center-idle {
0%, 100% { r: 8; opacity: 0.7; }
50% { r: 9.5; opacity: 0.6; }
}
.edge { stroke-dasharray: 120; stroke-dashoffset: 120; }
.e-back-1 { animation: draw-edge 0.5s ease-out 0.0s forwards; }
.e-back-2 { animation: draw-edge 0.5s ease-out 0.1s forwards; }
.e-back-3 { animation: draw-edge 0.5s ease-out 0.2s forwards; }
.e-mid-1 { animation: draw-edge 0.5s ease-out 0.35s forwards; }
.e-mid-2 { animation: draw-edge 0.5s ease-out 0.45s forwards; }
.e-mid-3 { animation: draw-edge 0.5s ease-out 0.55s forwards; }
.e-frt-1 { animation: draw-edge 0.5s ease-out 0.7s forwards; }
.e-frt-2 { animation: draw-edge 0.5s ease-out 0.8s forwards; }
.e-frt-3 { animation: draw-edge 0.5s ease-out 0.9s forwards; }
.v-topback { animation: pop-node 0.3s ease-out 0.05s forwards; r: 0; opacity: 0; }
.v-backleft { animation: pop-node 0.3s ease-out 0.15s forwards; r: 0; opacity: 0; }
.v-backright { animation: pop-node 0.3s ease-out 0.25s forwards; r: 0; opacity: 0; }
.v-center { animation: pop-center 0.3s ease-out 0.5s forwards, center-idle 2.4s ease-in-out 1s infinite; r: 0; opacity: 0; }
.v-frontleft { animation: pop-node 0.3s ease-out 0.75s forwards; r: 0; opacity: 0; }
.v-frontright { animation: pop-node 0.3s ease-out 0.85s forwards; r: 0; opacity: 0; }
.v-bottom { animation: pop-node 0.3s ease-out 0.95s forwards; r: 0; opacity: 0; }
</style>
<rect width="200" height="200" rx="32" fill="#14142a"/>
<!-- Back edges -->
<line x1="100" y1="42" x2="46" y2="72" stroke="#c8c8d0" stroke-width="1.5" stroke-linecap="round" opacity="0.2" class="edge e-back-1"/>
<line x1="100" y1="42" x2="154" y2="72" stroke="#c8c8d0" stroke-width="1.5" stroke-linecap="round" opacity="0.2" class="edge e-back-2"/>
<line x1="154" y1="72" x2="154" y2="128" stroke="#c8c8d0" stroke-width="1.5" stroke-linecap="round" opacity="0.2" class="edge e-back-3"/>
<!-- Mid edges -->
<line x1="46" y1="72" x2="100" y2="100" stroke="#c8c8d0" stroke-width="2" stroke-linecap="round" opacity="0.4" class="edge e-mid-1"/>
<line x1="154" y1="72" x2="100" y2="100" stroke="#c8c8d0" stroke-width="2" stroke-linecap="round" opacity="0.4" class="edge e-mid-2"/>
<line x1="100" y1="100" x2="100" y2="158" stroke="#c8c8d0" stroke-width="2" stroke-linecap="round" opacity="0.4" class="edge e-mid-3"/>
<!-- Front edges -->
<line x1="46" y1="72" x2="46" y2="128" stroke="#c8c8d0" stroke-width="2.5" stroke-linecap="round" opacity="0.9" class="edge e-frt-1"/>
<line x1="46" y1="128" x2="100" y2="158" stroke="#c8c8d0" stroke-width="2.5" stroke-linecap="round" opacity="0.9" class="edge e-frt-2"/>
<line x1="154" y1="128" x2="100" y2="158" stroke="#c8c8d0" stroke-width="2.5" stroke-linecap="round" opacity="0.9" class="edge e-frt-3"/>
<!-- Vertices -->
<circle cx="100" cy="42" fill="#3B82F6" opacity="0.55" class="v-topback"/>
<circle cx="46" cy="72" fill="#3B82F6" opacity="0.55" class="v-backleft"/>
<circle cx="154" cy="72" fill="#EF4444" opacity="0.55" class="v-backright"/>
<circle cx="100" cy="100" fill="#3B82F6" class="v-center"/>
<circle cx="46" cy="128" fill="#EF4444" class="v-frontleft"/>
<circle cx="154" cy="128" fill="#3B82F6" class="v-frontright"/>
<circle cx="100" cy="158" fill="#3B82F6" class="v-bottom"/>
</svg>

After

Width:  |  Height:  |  Size: 3.7 KiB

23
icons/icon-e-dark.svg Normal file
View File

@@ -0,0 +1,23 @@
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
<rect width="200" height="200" rx="32" fill="#14142a"/>
<!-- Back edges -->
<line x1="100" y1="48" x2="150" y2="74" stroke="#c8c8d0" stroke-width="1.5" stroke-linecap="round" opacity="0.2"/>
<line x1="100" y1="48" x2="50" y2="74" stroke="#c8c8d0" stroke-width="1.5" stroke-linecap="round" opacity="0.2"/>
<line x1="150" y1="74" x2="150" y2="126" stroke="#c8c8d0" stroke-width="1.5" stroke-linecap="round" opacity="0.2"/>
<!-- Mid edges -->
<line x1="50" y1="74" x2="100" y2="100" stroke="#c8c8d0" stroke-width="2" stroke-linecap="round" opacity="0.4"/>
<line x1="150" y1="74" x2="100" y2="100" stroke="#c8c8d0" stroke-width="2" stroke-linecap="round" opacity="0.4"/>
<line x1="100" y1="100" x2="100" y2="152" stroke="#c8c8d0" stroke-width="2" stroke-linecap="round" opacity="0.4"/>
<!-- Front edges -->
<line x1="50" y1="74" x2="50" y2="126" stroke="#c8c8d0" stroke-width="2.5" stroke-linecap="round" opacity="0.9"/>
<line x1="50" y1="126" x2="100" y2="152" stroke="#c8c8d0" stroke-width="2.5" stroke-linecap="round" opacity="0.9"/>
<line x1="150" y1="126" x2="100" y2="152" stroke="#c8c8d0" stroke-width="2.5" stroke-linecap="round" opacity="0.9"/>
<!-- Vertices -->
<circle cx="100" cy="48" r="6" fill="#3B82F6" opacity="0.55"/>
<circle cx="50" cy="74" r="6" fill="#3B82F6" opacity="0.55"/>
<circle cx="150" cy="74" r="6" fill="#EF4444" opacity="0.55"/>
<circle cx="100" cy="100" r="8" fill="#3B82F6" opacity="0.7"/>
<circle cx="50" cy="126" r="6" fill="#EF4444"/>
<circle cx="150" cy="126" r="6" fill="#3B82F6"/>
<circle cx="100" cy="152" r="6" fill="#3B82F6"/>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -0,0 +1,63 @@
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
<style>
@keyframes draw-edge {
from { stroke-dashoffset: 120; }
to { stroke-dashoffset: 0; }
}
@keyframes pop-node {
0% { r: 0; opacity: 0; }
70% { r: 8; }
100% { r: 6; opacity: 1; }
}
@keyframes pop-center {
0% { r: 0; opacity: 0; }
70% { r: 10; }
100% { r: 8; opacity: 0.7; }
}
@keyframes center-idle {
0%, 100% { r: 8; opacity: 0.7; }
50% { r: 9.5; opacity: 0.6; }
}
.edge { stroke-dasharray: 120; stroke-dashoffset: 120; }
.e-back-1 { animation: draw-edge 0.5s ease-out 0.0s forwards; }
.e-back-2 { animation: draw-edge 0.5s ease-out 0.1s forwards; }
.e-back-3 { animation: draw-edge 0.5s ease-out 0.2s forwards; }
.e-mid-1 { animation: draw-edge 0.5s ease-out 0.35s forwards; }
.e-mid-2 { animation: draw-edge 0.5s ease-out 0.45s forwards; }
.e-mid-3 { animation: draw-edge 0.5s ease-out 0.55s forwards; }
.e-frt-1 { animation: draw-edge 0.5s ease-out 0.7s forwards; }
.e-frt-2 { animation: draw-edge 0.5s ease-out 0.8s forwards; }
.e-frt-3 { animation: draw-edge 0.5s ease-out 0.9s forwards; }
.v-topback { animation: pop-node 0.3s ease-out 0.05s forwards; r: 0; opacity: 0; }
.v-backleft { animation: pop-node 0.3s ease-out 0.15s forwards; r: 0; opacity: 0; }
.v-backright { animation: pop-node 0.3s ease-out 0.25s forwards; r: 0; opacity: 0; }
.v-center { animation: pop-center 0.3s ease-out 0.5s forwards, center-idle 2.4s ease-in-out 1s infinite; r: 0; opacity: 0; }
.v-frontleft { animation: pop-node 0.3s ease-out 0.75s forwards; r: 0; opacity: 0; }
.v-frontright { animation: pop-node 0.3s ease-out 0.85s forwards; r: 0; opacity: 0; }
.v-bottom { animation: pop-node 0.3s ease-out 0.95s forwards; r: 0; opacity: 0; }
</style>
<rect width="200" height="200" rx="32" fill="#e8e8ee"/>
<line x1="100" y1="42" x2="46" y2="72" stroke="#333" stroke-width="1.5" stroke-linecap="round" opacity="0.15" class="edge e-back-1"/>
<line x1="100" y1="42" x2="154" y2="72" stroke="#333" stroke-width="1.5" stroke-linecap="round" opacity="0.15" class="edge e-back-2"/>
<line x1="154" y1="72" x2="154" y2="128" stroke="#333" stroke-width="1.5" stroke-linecap="round" opacity="0.15" class="edge e-back-3"/>
<line x1="46" y1="72" x2="100" y2="100" stroke="#333" stroke-width="2" stroke-linecap="round" opacity="0.3" class="edge e-mid-1"/>
<line x1="154" y1="72" x2="100" y2="100" stroke="#333" stroke-width="2" stroke-linecap="round" opacity="0.3" class="edge e-mid-2"/>
<line x1="100" y1="100" x2="100" y2="158" stroke="#333" stroke-width="2" stroke-linecap="round" opacity="0.3" class="edge e-mid-3"/>
<line x1="46" y1="72" x2="46" y2="128" stroke="#333" stroke-width="2.5" stroke-linecap="round" opacity="0.7" class="edge e-frt-1"/>
<line x1="46" y1="128" x2="100" y2="158" stroke="#333" stroke-width="2.5" stroke-linecap="round" opacity="0.7" class="edge e-frt-2"/>
<line x1="154" y1="128" x2="100" y2="158" stroke="#333" stroke-width="2.5" stroke-linecap="round" opacity="0.7" class="edge e-frt-3"/>
<circle cx="100" cy="42" fill="#3B82F6" opacity="0.55" class="v-topback"/>
<circle cx="46" cy="72" fill="#3B82F6" opacity="0.55" class="v-backleft"/>
<circle cx="154" cy="72" fill="#EF4444" opacity="0.55" class="v-backright"/>
<circle cx="100" cy="100" fill="#3B82F6" class="v-center"/>
<circle cx="46" cy="128" fill="#EF4444" class="v-frontleft"/>
<circle cx="154" cy="128" fill="#3B82F6" class="v-frontright"/>
<circle cx="100" cy="158" fill="#3B82F6" class="v-bottom"/>
</svg>

After

Width:  |  Height:  |  Size: 3.6 KiB

19
icons/icon-e-light.svg Normal file
View File

@@ -0,0 +1,19 @@
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
<rect width="200" height="200" rx="32" fill="#e8e8ee"/>
<line x1="100" y1="48" x2="150" y2="74" stroke="#333" stroke-width="1.5" stroke-linecap="round" opacity="0.15"/>
<line x1="100" y1="48" x2="50" y2="74" stroke="#333" stroke-width="1.5" stroke-linecap="round" opacity="0.15"/>
<line x1="150" y1="74" x2="150" y2="126" stroke="#333" stroke-width="1.5" stroke-linecap="round" opacity="0.15"/>
<line x1="50" y1="74" x2="100" y2="100" stroke="#333" stroke-width="2" stroke-linecap="round" opacity="0.3"/>
<line x1="150" y1="74" x2="100" y2="100" stroke="#333" stroke-width="2" stroke-linecap="round" opacity="0.3"/>
<line x1="100" y1="100" x2="100" y2="152" stroke="#333" stroke-width="2" stroke-linecap="round" opacity="0.3"/>
<line x1="50" y1="74" x2="50" y2="126" stroke="#333" stroke-width="2.5" stroke-linecap="round" opacity="0.7"/>
<line x1="50" y1="126" x2="100" y2="152" stroke="#333" stroke-width="2.5" stroke-linecap="round" opacity="0.7"/>
<line x1="150" y1="126" x2="100" y2="152" stroke="#333" stroke-width="2.5" stroke-linecap="round" opacity="0.7"/>
<circle cx="100" cy="48" r="6" fill="#3B82F6" opacity="0.55"/>
<circle cx="50" cy="74" r="6" fill="#3B82F6" opacity="0.55"/>
<circle cx="150" cy="74" r="6" fill="#EF4444" opacity="0.55"/>
<circle cx="100" cy="100" r="8" fill="#3B82F6" opacity="0.7"/>
<circle cx="50" cy="126" r="6" fill="#EF4444"/>
<circle cx="150" cy="126" r="6" fill="#3B82F6"/>
<circle cx="100" cy="152" r="6" fill="#3B82F6"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,174 @@
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
<style>
@keyframes center-pulse {
0%, 100% { r: 8; }
50% { r: 9.5; }
}
.center-node { animation: center-pulse 2.4s ease-in-out infinite; }
</style>
<rect width="200" height="200" rx="32" fill="#14142a"/>
<!--
B-state (flat diamond) positions:
top: 100,51 left: 55,100 right: 145,100 bottom: 100,149 center: 100,100
E-state (wireframe cube) positions:
topBack: 100,42 backLeft: 46,72 backRight: 154,72
center: 100,100 frontLeft: 46,128 frontRight: 154,128 bottom: 100,158
Mapping B→E:
top → topBack (100,51 → 100,42)
left → backLeft (55,100 → 46,72) AND spawns frontLeft (55,100 → 46,128)
right → backRight (145,100 → 154,72) AND spawns frontRight (145,100 → 154,128)
bottom → bottom (100,149 → 100,158)
center → center (100,100 → 100,100) stays
Timing: 1.5s hold B, 1s morph to E, 2s hold E, 1s morph back, repeat
-->
<!-- ═══ EDGES ═══ -->
<!-- Diamond outline (B) that morphs to cube edges (E) -->
<!-- top→right becomes topBack→backRight -->
<line stroke="#c8c8d0" stroke-width="2" stroke-linecap="round" opacity="0.25"
x1="100" y1="51" x2="145" y2="100">
<animate attributeName="x1" values="100;100;100;100;100" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
<animate attributeName="y1" values="51;51;42;42;51" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
<animate attributeName="x2" values="145;145;154;154;145" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
<animate attributeName="y2" values="100;100;72;72;100" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
</line>
<!-- right→bottom becomes backRight→frontRight -->
<line stroke="#c8c8d0" stroke-width="2" stroke-linecap="round" opacity="0.25"
x1="145" y1="100" x2="100" y2="149">
<animate attributeName="x1" values="145;145;154;154;145" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
<animate attributeName="y1" values="100;100;72;72;100" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
<animate attributeName="x2" values="100;100;154;154;100" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
<animate attributeName="y2" values="149;149;128;128;149" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
</line>
<!-- bottom→left becomes frontRight→bottom (front bottom-right) -->
<line stroke="#c8c8d0" stroke-width="2.5" stroke-linecap="round" opacity="0.25"
x1="100" y1="149" x2="55" y2="100">
<animate attributeName="x1" values="100;100;154;154;100" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
<animate attributeName="y1" values="149;149;128;128;149" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
<animate attributeName="x2" values="55;55;100;100;55" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
<animate attributeName="y2" values="100;100;158;158;100" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
<animate attributeName="opacity" values="0.25;0.25;0.9;0.9;0.25" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
</line>
<!-- left→top becomes backLeft→topBack -->
<line stroke="#c8c8d0" stroke-width="2" stroke-linecap="round" opacity="0.25"
x1="55" y1="100" x2="100" y2="51">
<animate attributeName="x1" values="55;55;46;46;55" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
<animate attributeName="y1" values="100;100;72;72;100" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
<animate attributeName="x2" values="100;100;100;100;100" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
<animate attributeName="y2" values="51;51;42;42;51" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
</line>
<!-- Cross: left→center becomes backLeft→center -->
<line stroke="#c8c8d0" stroke-width="2" stroke-linecap="round" opacity="0.4"
x1="55" y1="100" x2="100" y2="100">
<animate attributeName="x1" values="55;55;46;46;55" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
<animate attributeName="y1" values="100;100;72;72;100" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
</line>
<!-- Cross: right→center becomes backRight→center -->
<line stroke="#c8c8d0" stroke-width="2" stroke-linecap="round" opacity="0.4"
x1="145" y1="100" x2="100" y2="100">
<animate attributeName="x1" values="145;145;154;154;145" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
<animate attributeName="y1" values="100;100;72;72;100" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
</line>
<!-- Cross: center→bottom stays vertical but bottom moves -->
<line stroke="#c8c8d0" stroke-width="2" stroke-linecap="round" opacity="0.4"
x1="100" y1="100" x2="100" y2="149">
<animate attributeName="y2" values="149;149;158;158;149" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
</line>
<!-- Cross: top→center vertical -->
<line stroke="#c8c8d0" stroke-width="2" stroke-linecap="round" opacity="0.4"
x1="100" y1="51" x2="100" y2="100">
<animate attributeName="y1" values="51;51;42;42;51" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
</line>
<!-- NEW edges that appear in E-state (start invisible) -->
<!-- frontLeft→bottom -->
<line stroke="#c8c8d0" stroke-width="2.5" stroke-linecap="round"
x1="55" y1="100" x2="100" y2="149" opacity="0">
<animate attributeName="x1" values="55;55;46;46;55" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
<animate attributeName="y1" values="100;100;128;128;100" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
<animate attributeName="y2" values="149;149;158;158;149" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
<animate attributeName="opacity" values="0;0;0.9;0.9;0" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
</line>
<!-- backLeft→frontLeft (left vertical) -->
<line stroke="#c8c8d0" stroke-width="2.5" stroke-linecap="round"
x1="55" y1="100" x2="55" y2="100" opacity="0">
<animate attributeName="x1" values="55;55;46;46;55" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
<animate attributeName="y1" values="100;100;72;72;100" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
<animate attributeName="x2" values="55;55;46;46;55" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
<animate attributeName="y2" values="100;100;128;128;100" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
<animate attributeName="opacity" values="0;0;0.9;0.9;0" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
</line>
<!-- ═══ NODES ═══ -->
<!-- Top → TopBack -->
<circle cx="100" cy="51" r="8" fill="#3B82F6">
<animate attributeName="cy" values="51;51;42;42;51" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
<animate attributeName="opacity" values="1;1;0.55;0.55;1" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
<animate attributeName="r" values="8;8;6;6;8" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
</circle>
<!-- Left → BackLeft -->
<circle cx="55" cy="100" r="8" fill="#3B82F6">
<animate attributeName="cx" values="55;55;46;46;55" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
<animate attributeName="cy" values="100;100;72;72;100" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
<animate attributeName="opacity" values="1;1;0.55;0.55;1" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
<animate attributeName="r" values="8;8;6;6;8" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
</circle>
<!-- Right → BackRight -->
<circle cx="145" cy="100" r="8" fill="#EF4444">
<animate attributeName="cx" values="145;145;154;154;145" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
<animate attributeName="cy" values="100;100;72;72;100" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
<animate attributeName="opacity" values="1;1;0.55;0.55;1" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
<animate attributeName="r" values="8;8;6;6;8" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
</circle>
<!-- Bottom → Bottom (slight shift) -->
<circle cx="100" cy="149" r="8" fill="#3B82F6">
<animate attributeName="cy" values="149;149;158;158;149" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
<animate attributeName="r" values="8;8;6;6;8" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
</circle>
<!-- FrontLeft: spawns from Left position, invisible in B -->
<circle cx="55" cy="100" r="0" fill="#EF4444" opacity="0">
<animate attributeName="cx" values="55;55;46;46;55" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
<animate attributeName="cy" values="100;100;128;128;100" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
<animate attributeName="r" values="0;0;6;6;0" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
<animate attributeName="opacity" values="0;0;1;1;0" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
</circle>
<!-- FrontRight: spawns from Right position, invisible in B -->
<circle cx="145" cy="100" r="0" fill="#3B82F6" opacity="0">
<animate attributeName="cx" values="145;145;154;154;145" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
<animate attributeName="cy" values="100;100;128;128;100" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
<animate attributeName="r" values="0;0;6;6;0" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
<animate attributeName="opacity" values="0;0;1;1;0" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
</circle>
<!-- Center (always visible, pulse) -->
<circle cx="100" cy="100" r="10" fill="#3B82F6" class="center-node">
<animate attributeName="r" values="10;10;8;8;10" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
<animate attributeName="opacity" values="1;1;0.7;0.7;1" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
</circle>
<!-- Center glow -->
<circle cx="100" cy="100" r="16" fill="#3B82F6" opacity="0.12">
<animate attributeName="r" values="16;16;12;12;16" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
<animate attributeName="opacity" values="0.12;0.12;0.06;0.06;0.12" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
</circle>
</svg>

After

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -0,0 +1,127 @@
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
<style>
@keyframes center-pulse {
0%, 100% { r: 8; }
50% { r: 9.5; }
}
.center-node { animation: center-pulse 2.4s ease-in-out infinite; }
</style>
<rect width="200" height="200" rx="32" fill="#e8e8ee"/>
<!-- Diamond/cube edges -->
<line stroke="#333" stroke-width="2" stroke-linecap="round" opacity="0.18"
x1="100" y1="51" x2="145" y2="100">
<animate attributeName="y1" values="51;51;42;42;51" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
<animate attributeName="x2" values="145;145;154;154;145" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
<animate attributeName="y2" values="100;100;72;72;100" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
</line>
<line stroke="#333" stroke-width="2" stroke-linecap="round" opacity="0.18"
x1="145" y1="100" x2="100" y2="149">
<animate attributeName="x1" values="145;145;154;154;145" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
<animate attributeName="y1" values="100;100;72;72;100" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
<animate attributeName="x2" values="100;100;154;154;100" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
<animate attributeName="y2" values="149;149;128;128;149" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
</line>
<line stroke="#333" stroke-width="2.5" stroke-linecap="round" opacity="0.18"
x1="100" y1="149" x2="55" y2="100">
<animate attributeName="x1" values="100;100;154;154;100" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
<animate attributeName="y1" values="149;149;128;128;149" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
<animate attributeName="x2" values="55;55;100;100;55" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
<animate attributeName="y2" values="100;100;158;158;100" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
<animate attributeName="opacity" values="0.18;0.18;0.7;0.7;0.18" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
</line>
<line stroke="#333" stroke-width="2" stroke-linecap="round" opacity="0.18"
x1="55" y1="100" x2="100" y2="51">
<animate attributeName="x1" values="55;55;46;46;55" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
<animate attributeName="y1" values="100;100;72;72;100" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
<animate attributeName="y2" values="51;51;42;42;51" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
</line>
<!-- Cross lines -->
<line stroke="#333" stroke-width="2" stroke-linecap="round" opacity="0.3"
x1="55" y1="100" x2="100" y2="100">
<animate attributeName="x1" values="55;55;46;46;55" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
<animate attributeName="y1" values="100;100;72;72;100" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
</line>
<line stroke="#333" stroke-width="2" stroke-linecap="round" opacity="0.3"
x1="145" y1="100" x2="100" y2="100">
<animate attributeName="x1" values="145;145;154;154;145" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
<animate attributeName="y1" values="100;100;72;72;100" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
</line>
<line stroke="#333" stroke-width="2" stroke-linecap="round" opacity="0.3"
x1="100" y1="100" x2="100" y2="149">
<animate attributeName="y2" values="149;149;158;158;149" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
</line>
<line stroke="#333" stroke-width="2" stroke-linecap="round" opacity="0.3"
x1="100" y1="51" x2="100" y2="100">
<animate attributeName="y1" values="51;51;42;42;51" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
</line>
<!-- E-only edges (spawn) -->
<line stroke="#333" stroke-width="2.5" stroke-linecap="round"
x1="55" y1="100" x2="100" y2="149" opacity="0">
<animate attributeName="x1" values="55;55;46;46;55" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
<animate attributeName="y1" values="100;100;128;128;100" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
<animate attributeName="y2" values="149;149;158;158;149" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
<animate attributeName="opacity" values="0;0;0.7;0.7;0" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
</line>
<line stroke="#333" stroke-width="2.5" stroke-linecap="round"
x1="55" y1="100" x2="55" y2="100" opacity="0">
<animate attributeName="x1" values="55;55;46;46;55" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
<animate attributeName="y1" values="100;100;72;72;100" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
<animate attributeName="x2" values="55;55;46;46;55" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
<animate attributeName="y2" values="100;100;128;128;100" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
<animate attributeName="opacity" values="0;0;0.7;0.7;0" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
</line>
<!-- Nodes -->
<circle cx="100" cy="51" r="8" fill="#3B82F6">
<animate attributeName="cy" values="51;51;42;42;51" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
<animate attributeName="opacity" values="1;1;0.55;0.55;1" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
<animate attributeName="r" values="8;8;6;6;8" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
</circle>
<circle cx="55" cy="100" r="8" fill="#3B82F6">
<animate attributeName="cx" values="55;55;46;46;55" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
<animate attributeName="cy" values="100;100;72;72;100" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
<animate attributeName="opacity" values="1;1;0.55;0.55;1" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
<animate attributeName="r" values="8;8;6;6;8" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
</circle>
<circle cx="145" cy="100" r="8" fill="#EF4444">
<animate attributeName="cx" values="145;145;154;154;145" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
<animate attributeName="cy" values="100;100;72;72;100" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
<animate attributeName="opacity" values="1;1;0.55;0.55;1" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
<animate attributeName="r" values="8;8;6;6;8" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
</circle>
<circle cx="100" cy="149" r="8" fill="#3B82F6">
<animate attributeName="cy" values="149;149;158;158;149" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
<animate attributeName="r" values="8;8;6;6;8" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
</circle>
<!-- Spawn nodes -->
<circle cx="55" cy="100" r="0" fill="#EF4444" opacity="0">
<animate attributeName="cx" values="55;55;46;46;55" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
<animate attributeName="cy" values="100;100;128;128;100" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
<animate attributeName="r" values="0;0;6;6;0" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
<animate attributeName="opacity" values="0;0;1;1;0" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
</circle>
<circle cx="145" cy="100" r="0" fill="#3B82F6" opacity="0">
<animate attributeName="cx" values="145;145;154;154;145" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
<animate attributeName="cy" values="100;100;128;128;100" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
<animate attributeName="r" values="0;0;6;6;0" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
<animate attributeName="opacity" values="0;0;1;1;0" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
</circle>
<!-- Center -->
<circle cx="100" cy="100" r="10" fill="#3B82F6" class="center-node">
<animate attributeName="r" values="10;10;8;8;10" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
<animate attributeName="opacity" values="1;1;0.7;0.7;1" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
</circle>
<circle cx="100" cy="100" r="16" fill="#3B82F6" opacity="0.1">
<animate attributeName="r" values="16;16;12;12;16" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
<animate attributeName="opacity" values="0.1;0.1;0.05;0.05;0.1" keyTimes="0;0.27;0.45;0.73;1" dur="5.5s" repeatCount="indefinite"/>
</circle>
</svg>

After

Width:  |  Height:  |  Size: 9.3 KiB

BIN
icons/preview-sheet.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

12
icons/wordmark-dark.svg Normal file
View File

@@ -0,0 +1,12 @@
<svg viewBox="0 0 520 80" xmlns="http://www.w3.org/2000/svg">
<!-- A-frame lettermark -->
<line x1="28" y1="8" x2="6" y2="68" stroke="#3B82F6" stroke-width="3.5" stroke-linecap="round"/>
<line x1="28" y1="8" x2="50" y2="68" stroke="#3B82F6" stroke-width="3.5" stroke-linecap="round"/>
<line x1="14" y1="46" x2="42" y2="46" stroke="#3B82F6" stroke-width="2.5" stroke-linecap="round"/>
<circle cx="28" cy="8" r="4" fill="#3B82F6"/>
<circle cx="14" cy="46" r="2.5" fill="#EF4444"/>
<circle cx="28" cy="46" r="2.5" fill="#3B82F6"/>
<circle cx="42" cy="46" r="2.5" fill="#EF4444"/>
<!-- "rmature" text -->
<text x="58" y="62" fill="#dddddd" font-family="-apple-system, system-ui, 'Segoe UI', sans-serif" font-size="52" font-weight="300" letter-spacing="1">rmature</text>
</svg>

After

Width:  |  Height:  |  Size: 793 B

10
icons/wordmark-light.svg Normal file
View File

@@ -0,0 +1,10 @@
<svg viewBox="0 0 520 80" xmlns="http://www.w3.org/2000/svg">
<line x1="28" y1="8" x2="6" y2="68" stroke="#3B82F6" stroke-width="3.5" stroke-linecap="round"/>
<line x1="28" y1="8" x2="50" y2="68" stroke="#3B82F6" stroke-width="3.5" stroke-linecap="round"/>
<line x1="14" y1="46" x2="42" y2="46" stroke="#3B82F6" stroke-width="2.5" stroke-linecap="round"/>
<circle cx="28" cy="8" r="4" fill="#3B82F6"/>
<circle cx="14" cy="46" r="2.5" fill="#EF4444"/>
<circle cx="28" cy="46" r="2.5" fill="#3B82F6"/>
<circle cx="42" cy="46" r="2.5" fill="#EF4444"/>
<text x="58" y="62" fill="#222222" font-family="-apple-system, system-ui, 'Segoe UI', sans-serif" font-size="52" font-weight="300" letter-spacing="1">rmature</text>
</svg>

After

Width:  |  Height:  |  Size: 737 B

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

@@ -0,0 +1,59 @@
/**
* Chat Runner — chat/conversations suite
*
* Tests conversation CRUD via the chat-core ext API.
*/
(function () {
'use strict';
var api = window.CR.api;
sw.testing.suite('chat/conversations', async function (s) {
var convId;
s.test('create conversation', async function (t) {
var r = await api.post('/conversations', {
title: 'Runner Test Convo ' + Date.now(),
type: 'direct'
});
t.assert.ok(r.id, 'conversation has id');
t.assert.ok(r.title, 'conversation has title');
convId = r.id;
s.track('conversation', convId);
});
s.test('get conversation', async function (t) {
t.assert.ok(convId, 'convId from previous test');
var r = await api.get('/conversations/' + convId);
t.assert.eq(r.id, convId, 'id matches');
});
s.test('update conversation', async function (t) {
t.assert.ok(convId, 'convId from previous test');
var r = await api.put('/conversations/' + convId, {
title: 'Updated Convo Title'
});
t.assert.eq(r.title, 'Updated Convo Title', 'title updated');
});
s.test('list conversations', async function (t) {
var r = await api.get('/conversations');
var list = Array.isArray(r) ? r : (r.data || []);
t.assert.ok(Array.isArray(list), 'response is array');
var found = list.some(function (c) { return c.id === convId; });
t.assert.ok(found, 'created conversation in list');
});
s.test('delete conversation', async function (t) {
t.assert.ok(convId, 'convId from previous test');
await api.del('/conversations/' + convId);
try {
await api.get('/conversations/' + convId);
t.assert.ok(false, 'expected error after delete');
} catch (e) {
t.assert.ok(true, 'conversation not found after delete');
}
convId = null;
});
});
})();

View File

@@ -0,0 +1,64 @@
/**
* Chat Runner — Entry Point
*
* Boot SDK, load test modules in dependency order.
* Each module is an IIFE that registers suites via sw.testing.suite().
*/
(async function () {
'use strict';
try {
var base = window.__BASE__ || '';
var ver = window.__VERSION__ || '0';
if (!window.preact) {
var { h, render } = await import(base + '/js/sw/vendor/preact.module.js');
var hooksModule = await import(base + '/js/sw/vendor/hooks.module.js');
var htmModule = await import(base + '/js/sw/vendor/htm.module.js');
window.preact = { h, render };
window.hooks = hooksModule;
window.html = htmModule.default.bind(h);
}
var sdk = await import(base + '/js/sw/sdk/index.js?v=' + ver);
await sdk.boot();
} catch (e) {
console.warn('[ChatRunner] SDK boot failed:', e.message);
}
window.CR = {
base: window.__BASE__ || '',
api: sw.api.ext('chat-core')
};
var surfaceId = 'chat-runner';
var assetBase = '/surfaces/' + surfaceId + '/js/';
if (window.CR.base) assetBase = window.CR.base + assetBase;
var modules = [
'conversations.js',
'messaging.js',
'shell-topbar.js'
];
var loaded = 0;
function loadNext() {
if (loaded >= modules.length) { onReady(); return; }
var script = document.createElement('script');
script.src = assetBase + modules[loaded] + '?v=' + (window.__VERSION__ || '0') + '.' + Date.now();
script.onload = function () { loaded++; loadNext(); };
script.onerror = function () {
console.error('[ChatRunner] Failed to load: ' + modules[loaded]);
loaded++; loadNext();
};
document.body.appendChild(script);
}
function onReady() {
console.log('[ChatRunner] All modules loaded — ' + sw.testing.suites().length + ' suites registered');
var manifest = window.__MANIFEST__ || {};
if (manifest.id === surfaceId) {
window.location.href = (window.__BASE__ || '') + '/s/test-runners';
}
}
loadNext();
})();

View File

@@ -0,0 +1,60 @@
/**
* Chat Runner — chat/messaging suite
*
* Tests message CRUD and search within conversations.
*/
(function () {
'use strict';
var api = window.CR.api;
sw.testing.suite('chat/messaging', async function (s) {
var convId, msgId;
s.beforeAll(async function () {
var r = await api.post('/conversations', {
title: 'Messaging Test ' + Date.now(),
type: 'direct'
});
convId = r.id;
s.track('conversation', convId);
});
s.test('send message', async function (t) {
var r = await api.post('/messages/' + convId, {
content: 'Hello from chat-runner test!',
content_type: 'text'
});
t.assert.ok(r.id, 'message has id');
t.assert.ok(r.content, 'message has content');
msgId = r.id;
});
s.test('list messages', async function (t) {
var r = await api.get('/messages/' + convId);
var list = Array.isArray(r) ? r : (r.data || r.messages || []);
t.assert.ok(Array.isArray(list), 'messages is array');
t.assert.ok(list.length > 0, 'at least one message');
var found = list.some(function (m) { return m.id === msgId; });
t.assert.ok(found, 'sent message appears in list');
});
s.test('search conversations', async function (t) {
var r = await api.get('/search?q=chat-runner');
var list = Array.isArray(r) ? r : (r.data || r.conversations || r.results || []);
t.assert.ok(Array.isArray(list), 'search returns array');
if (list.length === 0) {
t.warn('Search returned empty — may need time for indexing');
}
});
s.test('mark read', async function (t) {
try {
await api.post('/read/' + convId, {});
t.assert.ok(true, 'mark read succeeded');
} catch (e) {
t.warn('mark read failed: ' + e.message);
}
});
});
})();

View File

@@ -0,0 +1,31 @@
/**
* Chat Runner — chat/shell-topbar suite
*
* Validates the Chat surface uses the v0.7.0 shell topbar contract
* and does not render the legacy sw.shell.Topbar component.
*/
(function () {
'use strict';
sw.testing.suite('chat/shell-topbar', async function (s) {
s.test('surface JS does not reference legacy Topbar', async function (t) {
var base = window.__BASE__ || '';
var resp = await fetch(base + '/surfaces/chat/js/main.js');
t.assert.eq(resp.status, 200, 'fetched chat main.js');
var src = await resp.text();
var hasLegacy = src.indexOf('sw.shell.Topbar') !== -1;
t.assert.ok(!hasLegacy, 'no sw.shell.Topbar reference (uses shell topbar API)');
});
s.test('surface JS uses shell topbar API', async function (t) {
var base = window.__BASE__ || '';
var resp = await fetch(base + '/surfaces/chat/js/main.js');
var src = await resp.text();
var usesAPI = src.indexOf('sw.shell.topbar.setTitle') !== -1
|| src.indexOf('sw.shell.topbar.setSlot') !== -1;
t.assert.ok(usesAPI, 'uses sw.shell.topbar.setTitle or setSlot');
});
});
})();

View File

@@ -0,0 +1,9 @@
{
"id": "chat-runner",
"icon": "💬",
"type": "test-runner",
"title": "Chat Runner",
"auth": "admin",
"version": "0.2.0",
"description": "Integration tests for Chat package — conversations, messaging, search."
}

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

@@ -1,12 +1,12 @@
/** /**
* Chat — Surface Entry Point (v0.2.0) * Chat — Surface Entry Point (v0.3.0)
* *
* Messaging surface built on chat-core library: * Messaging surface built on chat-core library:
* sw.api.ext('chat-core') — conversation/message CRUD * sw.api.ext('chat-core') — conversation/message CRUD
* sw.api.ext('chat') — typing indicators * sw.api.ext('chat') — typing indicators
* sw.realtime — live events * sw.realtime — live events
* sw.ui.* — primitive components * sw.ui.* — primitive components
* sw.shell.Topbar — navigation bar * sw.shell.topbar shell topbar API
*/ */
(async function () { (async function () {
'use strict'; 'use strict';
@@ -42,7 +42,6 @@
var api = sw.api.ext('chat-core'); var api = sw.api.ext('chat-core');
var chatApi = sw.api.ext('chat'); var chatApi = sw.api.ext('chat');
var { Button, Spinner, Avatar, Dialog, Tabs } = sw.ui; var { Button, Spinner, Avatar, Dialog, Tabs } = sw.ui;
var Topbar = sw.shell.Topbar;
// Import UserPicker directly (not in sw.ui index) // Import UserPicker directly (not in sw.ui index)
var { UserPicker } = await import(base + '/js/sw/primitives/user-picker.js?v=' + ver); var { UserPicker } = await import(base + '/js/sw/primitives/user-picker.js?v=' + ver);
@@ -123,60 +122,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 +183,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 +203,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 +238,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 +284,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 +372,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 +397,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 +454,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 +478,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 +545,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 +565,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 +604,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 +695,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 +709,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>`)}
@@ -806,28 +830,41 @@
var selectedConv = conversations.find(c => c.id === selectedId); var selectedConv = conversations.find(c => c.id === selectedId);
var threadTitle = selectedConv ? (selectedConv.title || 'Direct Message') : ''; var threadTitle = selectedConv ? (selectedConv.title || 'Direct Message') : '';
// ── Shell topbar ───────────────────────────
useEffect(() => {
if (!sw.shell?.topbar) return;
sw.shell.topbar.setTitle('Chat');
}, []);
useEffect(() => {
if (!sw.shell?.topbar) return;
if (selectedId) {
sw.shell.topbar.setSlot(html`
<span class="ext-chat-topbar__thread-title">${threadTitle}</span>
<${Button} size="sm" variant="secondary"
onClick=${() => setShowParticipants(!showParticipants)}>
${showParticipants ? 'Hide' : 'People'}
<//>
`);
} else {
sw.shell.topbar.setSlot(null);
}
}, [selectedId, threadTitle, showParticipants]);
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"> <div class="ext-chat-body">
${selectedId && html`
<span class="chat-topbar__thread-title">${threadTitle}</span>
<${Button} size="sm" variant="secondary"
onClick=${() => setShowParticipants(!showParticipants)}>
${showParticipants ? 'Hide' : 'People'}
<//>`}
<//>
<div class="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

@@ -6,7 +6,7 @@
"route": "/s/chat", "route": "/s/chat",
"auth": "authenticated", "auth": "authenticated",
"layout": "single", "layout": "single",
"version": "0.2.0", "version": "0.3.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": "armature", "author": "armature",

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

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

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

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

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

@@ -1,17 +0,0 @@
/* Hello Dashboard — sample extension surface styles.
Uses CSS custom properties from the platform theme system (variables.css).
See EXTENSION-SURFACES.md for the full property reference. */
.hello-dashboard { max-width: 720px; margin: 0 auto; padding: 40px 24px; }
.hello-header { margin-bottom: 32px; }
.hello-header h1 { font-size: 28px; font-weight: 700; color: var(--text); margin: 0 0 8px 0; }
.hello-subtitle { font-size: 14px; color: var(--text-2); margin: 0; }
.hello-subtitle code,
.hello-card-value code { background: var(--bg-raised); padding: 2px 6px; border-radius: 4px; font-size: 13px; }
.hello-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; margin-bottom: 24px; }
.hello-card { background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 16px; }
.hello-card-title { font-size: 12px; font-weight: 600; color: var(--text-2); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 8px; }
.hello-card-value { font-size: 20px; font-weight: 600; color: var(--text); margin-bottom: 4px; }
.hello-card-detail { font-size: 12px; color: var(--text-3); }
.hello-actions { display: flex; gap: 12px; margin-bottom: 24px; }
.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; }

View File

@@ -1,84 +0,0 @@
/**
* Hello Dashboard — sample extension surface.
*
* Platform contract:
* - Mounts into #extension-mount
* - window.__MANIFEST__ — surface manifest (json, lowercase keys)
* - sw.auth.user — authenticated user (from SDK boot)
* - UI.toast(msg, type) — platform toast (success/error/info/warning)
* - API._get(path) — authenticated fetch (returns parsed JSON)
*/
(function() {
'use strict';
var mount = document.getElementById('extension-mount');
if (!mount) return;
var manifest = window.__MANIFEST__ || {};
var user = window.sw?.auth?.user || {};
var isDark = document.documentElement.getAttribute('data-theme') === 'dark';
var name = user.display_name || user.username || 'World';
mount.innerHTML =
'<div class="hello-dashboard">' +
'<div class="hello-header">' +
'<h1>Hello, ' + esc(name) + '!</h1>' +
'<p class="hello-subtitle">Extension surface <code>' + esc(manifest.id || 'unknown') + '</code> loaded successfully.</p>' +
'</div>' +
'<div class="hello-cards">' +
card('Platform Access', (typeof UI !== 'undefined' ? '\u2713 Connected' : '\u2717 Unavailable'),
'API, Theme, UI primitives available', 'var(--accent)') +
card('Theme', isDark ? '\uD83C\uDF19 Dark' : '\u2600\uFE0F Light',
'Reads from platform theme system', '') +
card('Route', '<code>' + esc(manifest.route || '/s/hello-dashboard') + '</code>',
'Registered via surface manifest', '') +
'</div>' +
'<div class="hello-actions">' +
'<button class="btn-primary" id="helloToast">Show Toast</button>' +
'<button class="btn-secondary" id="helloApi">Test API</button>' +
'</div>' +
'<pre class="hello-manifest">' + esc(JSON.stringify(manifest, null, 2)) + '</pre>' +
'</div>';
// Wire toast button
document.getElementById('helloToast').addEventListener('click', function() {
if (typeof UI !== 'undefined' && UI.toast) {
UI.toast('Extension surface is working!', 'success');
} else {
alert('Extension surface is working! (UI.toast not available)');
}
});
// Wire API test button
document.getElementById('helloApi').addEventListener('click', function() {
if (typeof API === 'undefined' || !API._get) {
toast('API module not available', 'error');
return;
}
API._get('/api/v1/surfaces').then(function(resp) {
toast('API returned ' + (resp.surfaces || []).length + ' registered surfaces', 'info');
}).catch(function(e) {
toast('API error: ' + e.message, 'error');
});
});
function toast(msg, type) {
if (typeof UI !== 'undefined' && UI.toast) UI.toast(msg, type);
else alert(msg);
}
function card(title, value, detail, color) {
var style = color ? ' style="color:' + color + ';"' : '';
return '<div class="hello-card">' +
'<div class="hello-card-title">' + esc(title) + '</div>' +
'<div class="hello-card-value"' + style + '>' + value + '</div>' +
'<div class="hello-card-detail">' + esc(detail) + '</div>' +
'</div>';
}
function esc(s) {
var el = document.createElement('span');
el.textContent = s;
return el.innerHTML;
}
})();

View File

@@ -1,13 +0,0 @@
{
"id": "hello-dashboard",
"icon": "👋",
"type": "surface",
"title": "Hello Dashboard",
"route": "/s/hello-dashboard",
"auth": "authenticated",
"layout": "single",
"components": [],
"hooks": ["surface"],
"version": "0.1.0",
"description": "Sample extension surface — verifies the /s/:slug pipeline works end-to-end."
}

View File

@@ -1,74 +0,0 @@
/* ICD Test Runner — Surface Styles */
#extension-mount {
font-family: var(--font, 'DM Sans', sans-serif);
}
#extension-mount h1 {
font-family: var(--font, 'DM Sans', sans-serif);
letter-spacing: -0.3px;
}
#extension-mount table {
font-variant-numeric: tabular-nums;
}
#extension-mount table td,
#extension-mount table th {
border-color: var(--border);
}
#extension-mount table tbody tr:last-child {
border-bottom: none;
}
#extension-mount table tbody tr:hover {
background: var(--bg-hover) !important;
}
/* Buttons — override small padding for our use */
#extension-mount .btn-primary,
#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;
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 */
@keyframes pulse-dot {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}

View File

@@ -1,517 +0,0 @@
/**
* ICD Test Runner — CRUD: Channels
* Channel lifecycle, DMs, folders, participants, model roster,
* KB linking, files, messages, typing, mark-read, user search.
*/
(function () {
'use strict';
var T = window.ICD;
if (!T) return;
if (!T.crud) T.crud = {};
T.crud.channels = async function (testTag) {
// ── Channels CRUD ──
var channelId = null;
await T.test('crud', 'channels', 'POST /channels (create)', async function () {
var d = await T.apiPost('/channels', {
title: testTag + '-channel',
type: 'direct',
description: 'ICD integration test channel',
tags: ['icd-test']
});
T.assertShape(d, T.S.channelFull, 'created channel');
T.assert(d.type === 'direct', 'type should be direct');
T.assert(d.is_archived === false, 'should not be archived');
channelId = d.id;
T.registerCleanup(function () { if (channelId) return T.safeDelete('/channels/' + channelId); });
});
if (channelId) {
await T.test('crud', 'channels', 'GET /channels/:id (read)', async function () {
var d = await T.apiGet('/channels/' + channelId);
T.assertShape(d, T.S.channelFull, 'channel');
T.assert(d.id === channelId, 'id mismatch');
T.assert(d.title.indexOf(testTag) !== -1, 'title mismatch');
});
await T.test('crud', 'channels', 'PUT /channels/:id (update title+topic+ai_mode)', async function () {
var d = await T.apiPut('/channels/' + channelId, {
title: testTag + '-updated',
topic: 'ICD test topic',
ai_mode: 'mention_only'
});
T.assert(d.title === testTag + '-updated', 'title not updated');
T.assert(d.topic === 'ICD test topic', 'topic not set');
T.assert(d.ai_mode === 'mention_only', 'ai_mode not set');
});
await T.test('crud', 'channels', 'PUT /channels/:id (restore ai_mode)', async function () {
var d = await T.apiPut('/channels/' + channelId, { ai_mode: 'auto' });
T.assert(d.ai_mode === 'auto', 'ai_mode not restored');
});
// ── Channel List Query Filters ──
await T.test('crud', 'channels', 'GET /channels?type=direct', async function () {
var d = await T.apiGet('/channels?type=direct&per_page=5');
var arr = d.data || [];
T.assert(Array.isArray(arr), 'expected data array');
arr.forEach(function (ch) { T.assert(ch.type === 'direct', 'type filter leaked: ' + ch.type); });
});
await T.test('crud', 'channels', 'GET /channels?search=...', async function () {
var d = await T.apiGet('/channels?search=' + encodeURIComponent(testTag) + '&per_page=5');
var arr = d.data || [];
T.assert(Array.isArray(arr), 'expected data array');
T.assert(arr.length >= 1, 'search should find our channel');
});
await T.test('crud', 'channels', 'GET /channels?archived=true (empty)', async function () {
var d = await T.apiGet('/channels?archived=true&per_page=5');
var arr = d.data || [];
T.assert(Array.isArray(arr), 'expected data array');
// Our channel is not archived, so it shouldn't appear here
});
// ── Message Tree ──
await T.test('crud', 'channels', 'GET /channels/:id/path (empty)', async function () {
var d = await T.apiGet('/channels/' + channelId + '/path');
T.assertHasKey(d, 'messages', '/path');
T.assert(Array.isArray(d.messages), 'messages should be array');
});
// ── Participants ──
await T.test('crud', 'channels', 'GET /channels/:id/participants (auto owner)', async function () {
var d = await T.apiGet('/channels/' + channelId + '/participants');
T.assertHasKey(d, 'participants', '/participants');
T.assert(Array.isArray(d.participants), 'participants should be array');
T.assert(d.participants.length >= 1, 'should have at least owner participant');
if (d.participants.length > 0) {
T.assertShape(d.participants[0], T.S.participant, 'participant[0]');
T.assert(d.participants[0].role === 'owner', 'first participant should be owner');
}
});
// ── Participant CRUD (add fixture user, update role, remove) ──
var addedParticipantId = null;
var fixtureUser = T.getFixtureUser ? T.getFixtureUser('-user') : null;
if (fixtureUser && fixtureUser.id) {
await T.test('crud', 'channels', 'POST /channels/:id/participants (add user)', async function () {
var d = await T.apiPost('/channels/' + channelId + '/participants', {
participant_type: 'user',
participant_id: fixtureUser.id,
role: 'member'
});
// Response varies — may be participant object or list
var p = d.participant || d;
T.assert(p.id || p.participants, 'expected participant id or list');
if (p.id) addedParticipantId = p.id;
// Verify by re-listing
var list = await T.apiGet('/channels/' + channelId + '/participants');
var found = list.participants.find(function (pp) { return pp.participant_id === fixtureUser.id; });
T.assert(found, 'added user not found in participant list');
if (!addedParticipantId && found) addedParticipantId = found.id;
});
if (addedParticipantId) {
await T.test('crud', 'channels', 'PATCH /channels/:id/participants/:id (update role)', async function () {
var d = await T.apiPatch('/channels/' + channelId + '/participants/' + addedParticipantId, {
role: 'observer'
});
T.assert(typeof d === 'object', 'expected response');
// Verify
var list = await T.apiGet('/channels/' + channelId + '/participants');
var found = list.participants.find(function (pp) { return pp.id === addedParticipantId; });
T.assert(found && found.role === 'observer', 'role not updated to observer');
});
// ── Multi-user access: participant can GET channel (CS1 fix) ──
await T.test('crud', 'channels', 'GET /channels/:id (as participant, CS1 fix)', async function () {
var d = await T.authFetch(fixtureUser.token, 'GET', '/channels/' + channelId);
T.assert(d._status === 200, 'participant should see channel, got ' + d._status);
T.assert(d.id === channelId, 'id mismatch');
});
await T.test('crud', 'channels', 'GET /channels/:id/path (as participant)', async function () {
var d = await T.authFetch(fixtureUser.token, 'GET', '/channels/' + channelId + '/path');
T.assert(d._status === 200, 'participant should see path, got ' + d._status);
T.assertHasKey(d, 'messages', 'participant path');
});
await T.test('crud', 'channels', 'DELETE /channels/:id/participants/:id (remove)', async function () {
await T.apiDelete('/channels/' + channelId + '/participants/' + addedParticipantId);
var list = await T.apiGet('/channels/' + channelId + '/participants');
var found = list.participants.find(function (pp) { return pp.id === addedParticipantId; });
T.assert(!found, 'participant should be removed');
addedParticipantId = null;
});
}
}
// ── Model Roster CRUD ──
await T.test('crud', 'channels', 'GET /channels/:id/models (roster)', async function () {
var d = await T.apiGet('/channels/' + channelId + '/models');
T.assertHasKey(d, 'models', '/channel-models');
T.assert(Array.isArray(d.models), 'models should be array');
});
var rosterModelId = null;
await T.test('crud', 'channels', 'POST /channels/:id/models (add)', async function () {
var d = await T.apiPost('/channels/' + channelId + '/models', {
model_id: 'icd-test-model',
display_name: 'ICD Test Model',
provider_config_id: null
});
T.assertHasKey(d, 'models', '/models add');
T.assert(Array.isArray(d.models), 'models should be array');
var added = d.models.find(function (m) { return m.model_id === 'icd-test-model'; });
T.assert(added, 'added model not found in roster');
rosterModelId = added ? added.id : null;
});
if (rosterModelId) {
await T.test('crud', 'channels', 'PATCH /channels/:id/models/:id (update)', async function () {
var d = await T.apiPatch('/channels/' + channelId + '/models/' + rosterModelId, {
display_name: 'ICD Updated Model'
});
T.assertHasKey(d, 'models', '/models update');
var updated = d.models.find(function (m) { return m.id === rosterModelId; });
T.assert(updated && updated.display_name === 'ICD Updated Model', 'display_name not updated');
});
await T.test('crud', 'channels', 'DELETE /channels/:id/models/:id (remove)', async function () {
var d = await T.apiDelete('/channels/' + channelId + '/models/' + rosterModelId);
T.assertHasKey(d, 'models', '/models delete');
var models = d.models || [];
var found = models.find(function (m) { return m.id === rosterModelId; });
T.assert(!found, 'deleted model still in roster');
rosterModelId = null;
});
}
// ── KB linking ──
await T.test('crud', 'channels', 'GET /channels/:id/knowledge-bases', async function () {
var d = await T.apiGet('/channels/' + channelId + '/knowledge-bases');
T.assertHasKey(d, 'data', '/channel-kbs');
});
// ── Files ──
await T.test('crud', 'channels', 'GET /channels/:id/files', async function () {
var d = await T.apiGet('/channels/' + channelId + '/files');
T.assertHasKey(d, 'files', '/channel-files');
T.assert(Array.isArray(d.files), 'files should be array');
});
// ── File Upload/Download Lifecycle (conditional on storage) ──
var storageOk = false;
if (T.user.role === 'admin') {
try {
var ss = await T.apiGet('/admin/storage/status');
storageOk = ss && ss.configured === true;
} catch (e) { /* not admin or storage disabled */ }
}
var uploadedFileId = null;
if (storageOk) {
await T.test('crud', 'channels', 'POST /channels/:id/files (upload)', async function () {
var blob = new Blob(['ICD test file content'], { type: 'text/plain' });
var d = await T.apiUpload('/channels/' + channelId + '/files', blob, 'icd-test.txt');
T.assert(d.id, 'expected file id');
T.assert(d.filename === 'icd-test.txt', 'filename mismatch');
T.assert(d.origin === 'user_upload', 'origin should be user_upload');
T.assert(d.content_type === 'text/plain', 'content_type mismatch');
uploadedFileId = d.id;
T.registerCleanup(function () { if (uploadedFileId) return T.safeDelete('/files/' + uploadedFileId); });
});
if (uploadedFileId) {
await T.test('crud', 'channels', 'GET /files/:id (metadata)', async function () {
var d = await T.apiGet('/files/' + uploadedFileId);
T.assertShape(d, T.S.file, 'file metadata');
T.assert(d.id === uploadedFileId, 'id mismatch');
T.assert(d.channel_id === channelId, 'channel_id mismatch');
});
await T.test('crud', 'channels', 'GET /files/:id/download', async function () {
var token = await T.getAuthToken();
var resp = await fetch(T.base + '/api/v1/files/' + uploadedFileId + '/download', {
headers: { 'Authorization': 'Bearer ' + token },
credentials: 'same-origin'
});
T.assert(resp.ok, 'download should succeed, got ' + resp.status);
var text = await resp.text();
T.assert(text === 'ICD test file content', 'download content mismatch');
});
await T.test('crud', 'channels', 'DELETE /files/:id', async function () {
var delId = uploadedFileId;
await T.safeDelete('/files/' + delId);
uploadedFileId = null;
// Verify gone
try {
await T.apiGet('/files/' + delId);
T.assert(false, 'file should be deleted');
} catch (e) {
T.assert(e.message.indexOf('404') !== -1 || e.message.indexOf('not found') !== -1, 'expected 404');
}
});
}
}
// ── Message Create + Edit + Cursor + Siblings ──
var msgId = null;
await T.test('crud', 'channels', 'POST /channels/:id/messages (create)', async function () {
var d = await T.apiPost('/channels/' + channelId + '/messages', {
role: 'user',
content: 'ICD integration test message'
});
T.assertShape(d, T.S.message, 'message');
msgId = d.id;
});
if (msgId) {
await T.test('crud', 'channels', 'GET /channels/:id/messages (all)', async function () {
var d = await T.apiGet('/channels/' + channelId + '/messages');
var arr = d.messages || d.data;
T.assert(Array.isArray(arr), 'expected array');
T.assert(arr.length >= 1, 'expected at least 1 message');
});
await T.test('crud', 'channels', 'GET /channels/:id/messages/:id/siblings', async function () {
var d = await T.apiGet('/channels/' + channelId + '/messages/' + msgId + '/siblings');
T.assertHasKey(d, 'siblings', '/siblings');
T.assert(Array.isArray(d.siblings), 'siblings should be array');
});
// Edit creates sibling
var editedMsgId = null;
await T.test('crud', 'channels', 'POST /channels/:id/messages/:id/edit', async function () {
var d = await T.apiPost('/channels/' + channelId + '/messages/' + msgId + '/edit', {
content: 'ICD edited message content'
});
T.assertShape(d, T.S.message, 'edited message');
T.assert(d.id !== msgId, 'edit should create new message id');
T.assert(d.content === 'ICD edited message content', 'content mismatch');
editedMsgId = d.id;
});
if (editedMsgId) {
await T.test('crud', 'channels', 'GET siblings (after edit, expect 2)', async function () {
var d = await T.apiGet('/channels/' + channelId + '/messages/' + msgId + '/siblings');
T.assert(d.siblings.length >= 2, 'edit should create sibling, got ' + d.siblings.length);
});
await T.test('crud', 'channels', 'PUT /channels/:id/cursor (switch branch)', async function () {
var d = await T.apiPut('/channels/' + channelId + '/cursor', {
active_leaf_id: msgId
});
T.assertHasKey(d, 'messages', '/cursor');
T.assert(Array.isArray(d.messages), 'cursor response should have messages array');
});
}
await T.test('crud', 'channels', 'GET /messages/:id/files', async function () {
var d = await T.apiGet('/messages/' + msgId + '/files');
T.assertHasKey(d, 'files', '/message-files');
T.assert(Array.isArray(d.files), 'files should be array');
});
await T.test('crud', 'channels', 'GET /channels/:id/path (after messages)', async function () {
var d = await T.apiGet('/channels/' + channelId + '/path');
T.assertHasKey(d, 'messages', '/path');
T.assert(d.messages.length >= 1, 'path should have messages');
});
await T.test('crud', 'channels', 'POST /channels/:id/generate-title', async function () {
try {
var d = await T.apiPost('/channels/' + channelId + '/generate-title', {});
T.assert(typeof d === 'object', 'expected object');
} catch (e) {
if (e.message && (e.message.indexOf('400') !== -1 || e.message.indexOf('502') !== -1 || e.message.indexOf('model') !== -1))
return;
throw e;
}
});
}
// ── Typing indicator ──
await T.test('crud', 'channels', 'POST /channels/:id/typing', async function () {
var d = await T.apiPost('/channels/' + channelId + '/typing', {});
T.assert(d.ok === true, 'expected { ok: true }');
});
// ── Mark Read ──
await T.test('crud', 'channels', 'POST /channels/:id/mark-read', async function () {
var d = await T.apiPost('/channels/' + channelId + '/mark-read', {});
T.assert(typeof d === 'object', 'expected object');
});
// ── Delete ──
await T.test('crud', 'channels', 'DELETE /channels/:id', async function () {
await T.safeDelete('/channels/' + channelId);
channelId = null;
});
}
// ── DM Creation + Dedup ──
var dmChannelId = null;
var dmUser = T.getFixtureUser ? T.getFixtureUser('-user') : null;
if (dmUser && dmUser.id) {
await T.test('crud', 'channels', 'POST /channels (type=dm, create)', async function () {
var d = await T.apiPost('/channels', {
title: testTag + '-dm',
type: 'dm',
participants: [dmUser.id]
});
T.assert(d.id, 'DM channel should have id');
T.assert(d.type === 'dm', 'type should be dm');
dmChannelId = d.id;
T.registerCleanup(function () { if (dmChannelId) return T.safeDelete('/channels/' + dmChannelId); });
});
if (dmChannelId) {
await T.test('crud', 'channels', 'POST /channels (dm dedup → 200)', async function () {
var d = await T.apiPost('/channels', {
title: testTag + '-dm-dup',
type: 'dm',
participants: [dmUser.id]
});
T.assert(d.id === dmChannelId, 'dedup should return same channel id');
});
await T.test('crud', 'channels', 'GET /channels?types=dm (type filter)', async function () {
var d = await T.apiGet('/channels?types=dm&per_page=10');
var arr = d.data || [];
T.assert(Array.isArray(arr), 'expected data array');
var found = arr.find(function (ch) { return ch.id === dmChannelId; });
T.assert(found, 'DM channel should appear in dm type filter');
});
await T.test('crud', 'channels', 'DELETE /channels/:id (dm cleanup)', async function () {
await T.safeDelete('/channels/' + dmChannelId);
dmChannelId = null;
});
}
}
// ── Channel Types (group, channel) ──
var groupChId = null;
await T.test('crud', 'channels', 'POST /channels (type=group)', async function () {
var d = await T.apiPost('/channels', {
title: testTag + '-group',
type: 'group',
description: 'ICD group test'
});
T.assertShape(d, T.S.channelFull, 'group channel');
T.assert(d.type === 'group', 'type should be group, got: ' + d.type);
groupChId = d.id;
T.registerCleanup(function () { if (groupChId) return T.safeDelete('/channels/' + groupChId); });
});
var teamChId = null;
await T.test('crud', 'channels', 'POST /channels (type=channel)', async function () {
var d = await T.apiPost('/channels', {
title: testTag + '-team-channel',
type: 'channel',
description: 'ICD channel test'
});
T.assertShape(d, T.S.channelFull, 'team channel');
T.assert(d.type === 'channel', 'type should be channel, got: ' + d.type);
teamChId = d.id;
T.registerCleanup(function () { if (teamChId) return T.safeDelete('/channels/' + teamChId); });
});
// ── Multi-type filter ──
await T.test('crud', 'channels', 'GET /channels?types=group,channel (multi)', async function () {
var d = await T.apiGet('/channels?types=group,channel&per_page=50');
var arr = d.data || [];
T.assert(Array.isArray(arr), 'expected data array');
arr.forEach(function (ch) {
T.assert(ch.type === 'group' || ch.type === 'channel',
'multi-type filter leaked: ' + ch.type);
});
});
await T.test('crud', 'channels', 'GET /channels?types=direct,dm,group,channel (all)', async function () {
var d = await T.apiGet('/channels?types=direct,dm,group,channel&per_page=50');
var arr = d.data || [];
T.assert(Array.isArray(arr), 'expected data array');
var types = new Set(arr.map(function (ch) { return ch.type; }));
T.assert(types.size <= 4, 'should only contain known types');
});
// Cleanup channel types
if (groupChId) {
await T.test('crud', 'channels', 'DELETE /channels/:id (group cleanup)', async function () {
await T.safeDelete('/channels/' + groupChId);
groupChId = null;
});
}
if (teamChId) {
await T.test('crud', 'channels', 'DELETE /channels/:id (channel cleanup)', async function () {
await T.safeDelete('/channels/' + teamChId);
teamChId = null;
});
}
// ── Folders CRUD + Channel Assignment ──
var folderId = null;
await T.test('crud', 'channels', 'POST /folders (create)', async function () {
var d = await T.apiPost('/folders', { name: testTag + '-folder', sort_order: 0 });
var folder = d.folder || d.data || d;
T.assert(folder.id || folder.ID, 'folder response missing id, got keys: ' + Object.keys(folder).join(', '));
folderId = folder.id || folder.ID;
T.registerCleanup(function () { if (folderId) return T.safeDelete('/folders/' + folderId); });
});
if (folderId) {
await T.test('crud', 'channels', 'PUT /folders/:id (update)', async function () {
var d = await T.apiPut('/folders/' + folderId, { name: testTag + '-folder-updated', sort_order: 1 });
T.assert(typeof d === 'object', 'expected object');
});
// Create a channel, assign to folder, verify filter works
var folderCh = null;
await T.test('crud', 'channels', 'PUT /channels/:id { folder_id } (assign)', async function () {
var ch = await T.apiPost('/channels', { title: testTag + '-folder-test', type: 'direct' });
folderCh = ch.id;
T.registerCleanup(function () { if (folderCh) return T.safeDelete('/channels/' + folderCh); });
var d = await T.apiPut('/channels/' + folderCh, { folder_id: folderId });
T.assert(d.folder_id === folderId, 'folder_id should be set, got: ' + d.folder_id);
});
if (folderCh) {
await T.test('crud', 'channels', 'GET /channels?folder_id=... (filter)', async function () {
var d = await T.apiGet('/channels?folder_id=' + folderId + '&per_page=10');
var arr = d.data || [];
T.assert(Array.isArray(arr), 'expected data array');
var found = arr.find(function (ch) { return ch.id === folderCh; });
T.assert(found, 'channel should appear in folder_id filter');
});
await T.test('crud', 'channels', 'PUT /channels/:id { folder_id: "" } (unbind)', async function () {
var d = await T.apiPut('/channels/' + folderCh, { folder_id: '' });
T.assert(!d.folder_id, 'folder_id should be cleared');
});
await T.test('crud', 'channels', 'DELETE /channels/:id (folder test cleanup)', async function () {
await T.safeDelete('/channels/' + folderCh);
folderCh = null;
});
}
await T.test('crud', 'channels', 'DELETE /folders/:id', async function () {
await T.safeDelete('/folders/' + folderId);
folderId = null;
});
}
// ── User Search ──
await T.test('crud', 'channels', 'GET /users/search', async function () {
var d = await T.apiGet('/users/search?q=' + encodeURIComponent(T.user.username.substring(0, 3)));
T.assertHasKey(d, 'users', '/users/search');
T.assert(Array.isArray(d.users), 'users should be array');
});
};
})();

View File

@@ -1,238 +0,0 @@
/**
* ICD Test Runner — CRUD: Knowledge Bases
* KB lifecycle — create, read, update, documents, search, discoverable,
* channel binding, scope auth, cross-user isolation, delete.
*/
(function () {
'use strict';
var T = window.ICD;
if (!T) return;
if (!T.crud) T.crud = {};
T.crud.knowledge = async function (testTag) {
// ── KB CRUD ──
var kbId = null;
await T.test('crud', 'knowledge', 'POST /knowledge-bases (create)', async function () {
var d = await T.apiPost('/knowledge-bases', {
name: testTag + '-kb',
description: 'ICD integration test KB',
scope: 'personal'
});
T.assertShape(d, T.S.kb, 'kb');
T.assert(d.scope === 'personal', 'scope should be personal');
T.assert(d.status === 'active', 'status should be active');
T.assert(d.document_count === 0, 'new KB should have 0 documents');
T.assert(d.chunk_count === 0, 'new KB should have 0 chunks');
kbId = d.id;
T.registerCleanup(function () { if (kbId) return T.safeDelete('/knowledge-bases/' + kbId); });
});
if (!kbId) return;
await T.test('crud', 'knowledge', 'GET /knowledge-bases/:id (read)', async function () {
var d = await T.apiGet('/knowledge-bases/' + kbId);
T.assertShape(d, T.S.kb, 'kb');
T.assert(d.id === kbId, 'id mismatch');
});
await T.test('crud', 'knowledge', 'PUT /knowledge-bases/:id (update)', async function () {
var newName = testTag + '-kb-updated';
var d = await T.apiPut('/knowledge-bases/' + kbId, { name: newName });
T.assertShape(d, T.S.kb, 'kb-update');
T.assert(d.name === newName, 'name should have updated, got: ' + d.name);
});
await T.test('crud', 'knowledge', 'PUT /knowledge-bases/:id (empty body → 400)', async function () {
var token = await T.getAuthToken();
var resp = await T.authFetch(token, 'PUT', '/knowledge-bases/' + kbId, {});
T.assert(resp._status === 400, 'expected 400 for empty update, got ' + resp._status);
});
// ── Documents ──
await T.test('crud', 'knowledge', 'GET /knowledge-bases/:id/documents (empty)', async function () {
var d = await T.apiGet('/knowledge-bases/' + kbId + '/documents');
T.assertHasKey(d, 'data', '/kb-documents');
T.assert(Array.isArray(d.data), 'data should be array');
T.assert(d.data.length === 0, 'new KB should have 0 documents');
});
// Document upload → status → delete (requires object store)
var docId = null;
var storageOk = false;
try {
var ss = await T.apiGet('/admin/storage/status');
storageOk = ss && ss.configured === true;
} catch (e) { /* non-admin or storage not configured */ }
if (storageOk) {
await T.test('crud', 'knowledge', 'POST /knowledge-bases/:id/documents (upload)', async function () {
var blob = new Blob(['# ICD Test Document\n\nThis is test content for KB ingestion.'], { type: 'text/markdown' });
try {
var d = await T.apiUpload('/knowledge-bases/' + kbId + '/documents', blob, 'icd-test.md');
T.assert(d.id, 'expected document id');
T.assert(d.filename === 'icd-test.md', 'filename mismatch: ' + d.filename);
T.assert(d.status === 'pending', 'initial status should be pending, got: ' + d.status);
T.assert(d.kb_id === kbId, 'kb_id mismatch');
docId = d.id;
} catch (e) {
// 412 = no embedding model role configured (infrastructure, not contract bug)
if (e.message && (e.message.indexOf('412') !== -1 || e.message.indexOf('embedding') !== -1)) return;
throw e;
}
});
if (docId) {
await T.test('crud', 'knowledge', 'GET /knowledge-bases/:id/documents/:docId/status', async function () {
var d = await T.apiGet('/knowledge-bases/' + kbId + '/documents/' + docId + '/status');
T.assert(d.id === docId, 'doc id mismatch');
T.assert(typeof d.status === 'string', 'status should be string');
T.assert(typeof d.filename === 'string', 'filename should be string');
});
await T.test('crud', 'knowledge', 'GET /knowledge-bases/:id/documents (has doc)', async function () {
var d = await T.apiGet('/knowledge-bases/' + kbId + '/documents');
T.assertHasKey(d, 'data', '/kb-documents');
T.assert(d.data.length >= 1, 'should have at least 1 document');
});
await T.test('crud', 'knowledge', 'DELETE /knowledge-bases/:id/documents/:docId', async function () {
var token = await T.getAuthToken();
var resp = await fetch(T.base + '/api/v1/knowledge-bases/' + kbId + '/documents/' + docId, {
method: 'DELETE',
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
credentials: 'same-origin'
});
T.assert(resp.ok, 'delete doc should succeed, got ' + resp.status);
var body = await resp.json();
T.assert(body.deleted === true, 'deleted should be true');
docId = null;
});
await T.test('crud', 'knowledge', 'GET /documents/:docId/status (deleted → 404)', async function () {
var token = await T.getAuthToken();
var resp = await T.authFetch(token, 'GET',
'/knowledge-bases/' + kbId + '/documents/00000000-0000-0000-0000-000000000099/status');
T.assert(resp._status === 404, 'expected 404 for deleted doc, got ' + resp._status);
});
}
}
// ── Search ──
await T.test('crud', 'knowledge', 'POST /knowledge-bases/:id/search', async function () {
try {
var d = await T.apiPost('/knowledge-bases/' + kbId + '/search', {
query: 'test', limit: 5
});
// Response envelope uses "data" key (not "results")
T.assertHasKey(d, 'data', '/kb-search');
T.assertHasKey(d, 'query', '/kb-search');
T.assertHasKey(d, 'total', '/kb-search');
T.assert(Array.isArray(d.data), 'data should be array');
} catch (e) {
// "failed to embed query" = no embedding model configured (infrastructure, not contract bug)
if (e.message && e.message.indexOf('embed') !== -1) return;
throw e;
}
});
// ── Rebuild (no documents = 400, or no ingester = 503) ──
await T.test('crud', 'knowledge', 'POST /knowledge-bases/:id/rebuild (empty → 400|503)', async function () {
var token = await T.getAuthToken();
var resp = await T.authFetch(token, 'POST', '/knowledge-bases/' + kbId + '/rebuild', {});
T.assert(resp._status === 400 || resp._status === 503,
'expected 400 (no docs) or 503 (no ingester), got ' + resp._status);
});
// ── Channel KB Binding ──
var bindChannelId = null;
await T.test('crud', 'knowledge', 'PUT /channels/:id/knowledge-bases (bind)', async function () {
// Create a temp channel
var ch = await T.apiPost('/channels', { title: testTag + '-kb-bind' });
bindChannelId = ch.id;
T.registerCleanup(function () { if (bindChannelId) return T.safeDelete('/channels/' + bindChannelId); });
// Bind KB
var d = await T.apiPut('/channels/' + bindChannelId + '/knowledge-bases', {
kb_ids: [kbId]
});
T.assertHasKey(d, 'data', '/channel-kb-set');
});
if (bindChannelId) {
await T.test('crud', 'knowledge', 'GET /channels/:id/knowledge-bases (verify)', async function () {
var d = await T.apiGet('/channels/' + bindChannelId + '/knowledge-bases');
T.assertHasKey(d, 'data', '/channel-kbs');
T.assert(d.data.length === 1, 'should have 1 linked KB, got ' + d.data.length);
var linked = d.data[0];
T.assert(linked.kb_id === kbId, 'linked kb_id mismatch');
T.assert(typeof linked.kb_name === 'string', 'kb_name should be string');
T.assert(typeof linked.enabled === 'boolean', 'enabled should be boolean');
});
await T.test('crud', 'knowledge', 'PUT /channels/:id/knowledge-bases (unbind)', async function () {
var d = await T.apiPut('/channels/' + bindChannelId + '/knowledge-bases', { kb_ids: [] });
T.assertHasKey(d, 'data', '/channel-kb-unbind');
T.assert(d.data.length === 0, 'should have 0 linked KBs after unbind');
});
}
// ── Discoverable ──
await T.test('crud', 'knowledge', 'PUT /knowledge-bases/:id/discoverable (toggle)', async function () {
var d = await T.apiPut('/knowledge-bases/' + kbId + '/discoverable', { discoverable: false });
T.assert(d.status === 'ok', 'expected status ok');
// Toggle back
d = await T.apiPut('/knowledge-bases/' + kbId + '/discoverable', { discoverable: true });
T.assert(d.status === 'ok', 'expected status ok on re-enable');
});
// ── Cross-user isolation (requires fixtures) ──
var fixtureUser = T.getFixtureUser ? T.getFixtureUser('-user') : null;
if (fixtureUser && fixtureUser.token) {
await T.test('crud', 'knowledge', 'isolation: other user cannot access personal KB', async function () {
var resp = await T.authFetch(fixtureUser.token, 'GET', '/knowledge-bases/' + kbId);
// Personal KB of admin should be hidden (404) from other user
T.assert(resp._status === 404, 'expected 404 for other user, got ' + resp._status);
});
await T.test('crud', 'knowledge', 'isolation: other user cannot delete personal KB', async function () {
var resp = await T.authFetch(fixtureUser.token, 'DELETE', '/knowledge-bases/' + kbId);
T.assert(resp._status === 404, 'expected 404 for delete by other user, got ' + resp._status);
});
await T.test('crud', 'knowledge', 'scope: non-admin global create → 403', async function () {
var resp = await T.authFetch(fixtureUser.token, 'POST', '/knowledge-bases', {
name: testTag + '-sneaky-global', scope: 'global'
});
T.assert(resp._status === 403, 'expected 403 for non-admin global KB, got ' + resp._status);
});
await T.test('crud', 'knowledge', 'discoverable: other user cannot toggle', async function () {
var resp = await T.authFetch(fixtureUser.token, 'PUT',
'/knowledge-bases/' + kbId + '/discoverable', { discoverable: false });
T.assert(resp._status === 404 || resp._status === 403,
'expected 403/404 for non-owner toggle, got ' + resp._status);
});
}
// ── Delete ──
await T.test('crud', 'knowledge', 'DELETE /knowledge-bases/:id', async function () {
await T.safeDelete('/knowledge-bases/' + kbId);
kbId = null;
});
await T.test('crud', 'knowledge', 'GET /knowledge-bases/:id (deleted → 404)', async function () {
var token = await T.getAuthToken();
var resp = await T.authFetch(token, 'GET', '/knowledge-bases/00000000-0000-0000-0000-000000000099');
T.assert(resp._status === 404, 'expected 404 for nonexistent KB, got ' + resp._status);
});
// Cleanup temp channel
if (bindChannelId) {
try { await T.safeDelete('/channels/' + bindChannelId); } catch (e) { /* ok */ }
bindChannelId = null;
}
};
})();

View File

@@ -1,92 +0,0 @@
/**
* ICD Test Runner — CRUD: Memory
* Memory listing, count shape, error paths, admin endpoints.
*/
(function () {
'use strict';
var T = window.ICD;
if (!T) return;
if (!T.crud) T.crud = {};
T.crud.memory = async function (testTag) {
// ── Memory CRUD ──
// No REST POST for memories (created by AI tools / background extraction).
// Test error paths, count shape, and admin endpoints.
var fakeMemId = '00000000-0000-0000-0000-000000000000';
await T.test('crud', 'memory', 'GET /memories (list, envelope shape)', async function () {
var d = await T.apiGet('/memories');
T.assertHasKey(d, 'data', '/memories');
T.assert(Array.isArray(d.data), 'data should be array');
// If memories exist, validate shape
if (d.data.length > 0) {
T.assertShape(d.data[0], T.S.memory, 'memory[0]');
}
});
await T.test('crud', 'memory', 'GET /memories?status=pending_review', async function () {
var d = await T.apiGet('/memories?status=pending_review');
T.assertHasKey(d, 'data', '/memories?status=pending_review');
T.assert(Array.isArray(d.data), 'data should be array');
});
await T.test('crud', 'memory', 'GET /memories/count (shape)', async function () {
var d = await T.apiGet('/memories/count');
T.assertHasKey(d, 'active', '/memories/count');
T.assertHasKey(d, 'pending', '/memories/count');
T.assert(typeof d.active === 'number', 'active should be number');
T.assert(typeof d.pending === 'number', 'pending should be number');
T.assert(d.active >= 0, 'active should be >= 0');
T.assert(d.pending >= 0, 'pending should be >= 0');
});
await T.test('crud', 'memory', 'PUT /memories/:id (not found → 404)', async function () {
var token = await T.getAuthToken();
var d = await T.authFetch(token, 'PUT', '/memories/' + fakeMemId, { value: 'x' });
T.assertStatus(d, 404, 'update non-existent memory');
});
await T.test('crud', 'memory', 'DELETE /memories/:id (not found → 404)', async function () {
var token = await T.getAuthToken();
var d = await T.authFetch(token, 'DELETE', '/memories/' + fakeMemId);
T.assertStatus(d, 404, 'delete non-existent memory');
});
await T.test('crud', 'memory', 'POST /memories/:id/approve (not found → 404)', async function () {
var token = await T.getAuthToken();
var d = await T.authFetch(token, 'POST', '/memories/' + fakeMemId + '/approve');
T.assertStatus(d, 404, 'approve non-existent memory');
});
await T.test('crud', 'memory', 'POST /memories/:id/reject (not found → 404)', async function () {
var token = await T.getAuthToken();
var d = await T.authFetch(token, 'POST', '/memories/' + fakeMemId + '/reject');
T.assertStatus(d, 404, 'reject non-existent memory');
});
if (T.user.role === 'admin') {
await T.test('crud', 'memory', 'GET /admin/memories/pending (admin)', async function () {
var d = await T.apiGet('/admin/memories/pending');
T.assertHasKey(d, 'data', '/admin/memories/pending');
T.assert(Array.isArray(d.data), 'data should be array');
if (d.data.length > 0) {
T.assertShape(d.data[0], T.S.memory, 'pending[0]');
}
});
await T.test('crud', 'memory', 'POST /admin/memories/bulk-approve (empty ids)', async function () {
var d = await T.apiPost('/admin/memories/bulk-approve', { ids: [] });
T.assertHasKey(d, 'approved', 'bulk-approve');
T.assert(d.approved === 0, 'empty ids should approve 0');
});
await T.test('crud', 'memory', 'POST /admin/memories/bulk-approve (bad body → 400)', async function () {
var token = await T.getAuthToken();
var d = await T.authFetch(token, 'POST', '/admin/memories/bulk-approve', 'not-json');
T.assertStatus(d, 400, 'bad body');
});
}
};
})();

View File

@@ -1,116 +0,0 @@
/**
* ICD Test Runner — CRUD: Models
* Model preference lifecycle — hide/unhide, bulk, validation.
*/
(function () {
'use strict';
var T = window.ICD;
if (!T) return;
if (!T.crud) T.crud = {};
T.crud.models = async function (testTag) {
// ── Model Preferences CRUD ──
// Depends on provider setup from smoke tier — needs at least one model in /models/enabled
await T.test('crud', 'models', 'GET /models/preferences (initially empty for test user)', async function () {
var d = await T.apiGet('/models/preferences');
T.assertHasKey(d, 'data', '/models/preferences');
T.assert(Array.isArray(d.data), 'data should be array');
});
// Find a model to use for preference tests
var prefModelId = null;
var prefConfigId = null;
await T.test('crud', 'models', 'GET /models/enabled (pick model for pref test)', async function () {
var d = await T.apiGet('/models/enabled');
var models = d.data || [];
var catalog = models.filter(function (m) { return !m.is_persona && m.provider_config_id; });
if (catalog.length === 0) return; // no catalog models yet — skip pref write tests
prefModelId = catalog[0].model_id;
prefConfigId = catalog[0].provider_config_id;
});
if (prefModelId && prefConfigId) {
await T.test('crud', 'models', 'PUT /models/preferences (hide model)', async function () {
var d = await T.apiPut('/models/preferences', {
model_id: prefModelId,
provider_config_id: prefConfigId,
hidden: true
});
T.assert(d.message === 'preference updated', 'expected success message');
});
await T.test('crud', 'models', 'GET /models/preferences (verify hidden)', async function () {
var d = await T.apiGet('/models/preferences');
var prefs = d.data || [];
var found = prefs.find(function (p) {
return p.model_id === prefModelId && p.provider_config_id === prefConfigId;
});
T.assert(found, 'preference entry should exist after PUT');
T.assert(found.hidden === true, 'hidden should be true');
T.assertShape(found, T.S.modelPreference, 'preference');
});
await T.test('crud', 'models', 'GET /models/enabled (hidden flag propagated)', async function () {
var d = await T.apiGet('/models/enabled');
var models = d.data || [];
var found = models.find(function (m) {
return m.model_id === prefModelId && m.provider_config_id === prefConfigId;
});
if (found) {
T.assert(found.hidden === true, 'model should have hidden=true in /models/enabled');
}
});
await T.test('crud', 'models', 'PUT /models/preferences (unhide — upsert)', async function () {
var d = await T.apiPut('/models/preferences', {
model_id: prefModelId,
provider_config_id: prefConfigId,
hidden: false
});
T.assert(d.message === 'preference updated', 'expected success message');
});
await T.test('crud', 'models', 'GET /models/preferences (verify unhidden, no dup)', async function () {
var d = await T.apiGet('/models/preferences');
var prefs = d.data || [];
var matches = prefs.filter(function (p) {
return p.model_id === prefModelId && p.provider_config_id === prefConfigId;
});
T.assert(matches.length === 1, 'upsert should not duplicate: got ' + matches.length);
T.assert(matches[0].hidden === false, 'hidden should be false after unhide');
});
await T.test('crud', 'models', 'PUT /models/preferences (missing provider_config_id → 400)', async function () {
var token = await T.getAuthToken();
var d = await T.authFetch(token, 'PUT', '/models/preferences', { model_id: prefModelId, hidden: true });
T.assertStatus(d, 400, 'missing provider_config_id');
});
await T.test('crud', 'models', 'PUT /models/preferences (missing model_id → 400)', async function () {
var token = await T.getAuthToken();
var d = await T.authFetch(token, 'PUT', '/models/preferences', { provider_config_id: prefConfigId, hidden: true });
T.assertStatus(d, 400, 'missing model_id');
});
await T.test('crud', 'models', 'POST /models/preferences/bulk (hide)', async function () {
var d = await T.apiPost('/models/preferences/bulk', {
entries: [{ model_id: prefModelId, provider_config_id: prefConfigId }],
hidden: true
});
T.assert(d.message === 'preferences updated', 'expected bulk success message');
T.assert(d.count === 1, 'count should be 1');
});
await T.test('crud', 'models', 'POST /models/preferences/bulk (unhide cleanup)', async function () {
var d = await T.apiPost('/models/preferences/bulk', {
entries: [{ model_id: prefModelId, provider_config_id: prefConfigId }],
hidden: false
});
T.assert(d.message === 'preferences updated', 'expected bulk success message');
});
}
};
})();

View File

@@ -1,64 +0,0 @@
/**
* ICD Test Runner — CRUD: Notes
* Note lifecycle — create, read, update, search, backlinks, delete.
*/
(function () {
'use strict';
var T = window.ICD;
if (!T) return;
if (!T.crud) T.crud = {};
T.crud.notes = async function (testTag) {
// ── Notes CRUD ──
var noteId = null;
await T.test('crud', 'notes', 'POST /notes (create)', async function () {
var d = await T.apiPost('/notes', {
title: testTag + '-note',
content: '# ICD Test Note\n\nTest content with [[wikilink]].',
folder: 'icd-test'
});
T.assertShape(d, T.S.note, 'note');
noteId = d.id;
T.registerCleanup(function () { if (noteId) return T.safeDelete('/notes/' + noteId); });
});
if (noteId) {
await T.test('crud', 'notes', 'GET /notes/:id (read)', async function () {
var d = await T.apiGet('/notes/' + noteId);
T.assertShape(d, T.S.note, 'note');
T.assert(d.id === noteId, 'id mismatch');
T.assert(d.title.indexOf(testTag) !== -1, 'title mismatch');
});
await T.test('crud', 'notes', 'PUT /notes/:id (update)', async function () {
var d = await T.apiPut('/notes/' + noteId, {
title: testTag + '-note-updated',
content: '# Updated\n\nNew content.'
});
T.assert(d.title === testTag + '-note-updated' || (d.id && d.id === noteId), 'update response');
});
await T.test('crud', 'notes', 'GET /notes/search', async function () {
var d = await T.apiGet('/notes/search?q=' + encodeURIComponent(testTag));
T.assertHasKey(d, 'data', '/notes/search');
});
await T.test('crud', 'notes', 'GET /notes/search-titles', async function () {
var d = await T.apiGet('/notes/search-titles?q=' + encodeURIComponent(testTag));
T.assertHasKey(d, 'data', '/notes/search-titles');
});
await T.test('crud', 'notes', 'GET /notes/:id/backlinks', async function () {
var d = await T.apiGet('/notes/' + noteId + '/backlinks');
T.assertHasKey(d, 'data', '/backlinks');
});
await T.test('crud', 'notes', 'DELETE /notes/:id', async function () {
await T.safeDelete('/notes/' + noteId);
noteId = null;
});
}
};
})();

Some files were not shown because too many files have changed in this diff Show More