Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a7e38bc72a | |||
| 32e4d8725c | |||
| d6c7b21713 | |||
| 829caa3b20 | |||
| e916ed41ea | |||
| 1236220302 | |||
| e7d1b53ebf | |||
| ff19a1b4d3 | |||
| d9802df2af | |||
| c9b9e68c18 | |||
| 3af62a9cc5 | |||
| 221ae94f4f | |||
| 786bc92768 | |||
| ca3f845c34 | |||
| 617d81e7d4 |
@@ -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
|
||||||
|
|||||||
547
CHANGELOG.md
@@ -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 5m–60m) and `session.refresh_token_ttl` (default 7d, clamp
|
||||||
|
1h–90d) stored in `global_settings`. New `LoadSessionConfig()` helper
|
||||||
|
reads and clamps values with `parseDurationString()` supporting `m`,
|
||||||
|
`h`, `d` suffixes.
|
||||||
|
- **"Keep me logged in" checkbox**: Login form opt-in. Checked = full
|
||||||
|
`refresh_token_ttl`. Unchecked = capped at 24h. Cookie `max-age`
|
||||||
|
tracks whichever lifetime was chosen. `keep_login` flag stored on
|
||||||
|
refresh token row.
|
||||||
|
- **Config-driven token generation**: `generateTokens()` reads TTLs from
|
||||||
|
`global_settings` instead of hardcoded `15*time.Minute` /
|
||||||
|
`7*24*time.Hour`. Response includes `expires_in` and
|
||||||
|
`refresh_expires_in` (seconds) so the client schedules refresh
|
||||||
|
correctly.
|
||||||
|
- **Idle timeout (optional)**: Admin-toggleable (default off). Server
|
||||||
|
checks `last_activity_at` on refresh-token row; rejects if gap exceeds
|
||||||
|
`session.idle_timeout`. Client SDK pings `POST /api/v1/auth/activity`
|
||||||
|
on click/keydown (debounced, max 1/min).
|
||||||
|
- **Admin Settings > Session section**: Dropdowns for access TTL, refresh
|
||||||
|
TTL, and idle timeout with toggle.
|
||||||
|
- **7 new tests**: Duration parsing, clamping (low/high), defaults,
|
||||||
|
invalid values, empty idle timeout.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- `CreateRefreshToken` store method now accepts `keepLogin bool`
|
||||||
|
parameter; both Postgres and SQLite implementations updated.
|
||||||
|
- `GetRefreshTokenInfo` returns `RefreshTokenInfo` struct with
|
||||||
|
`UserID`, `KeepLogin`, `LastActivityAt` for idle-timeout decisions.
|
||||||
|
- SDK `auth.login()` accepts optional third `keepLogin` parameter;
|
||||||
|
cookie max-age derived from server `refresh_expires_in` response.
|
||||||
|
- SDK boots activity tracking after successful auth boot.
|
||||||
|
|
||||||
|
## v0.6.8 — Cookie Fix + UI Hardening Roadmap
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Session cookie max-age bug**: `arm_token` cookie was set to 15 min
|
||||||
|
(matching access token) while refresh token lasted 7 days. Cookie now
|
||||||
|
matches refresh token lifetime so Go SSR middleware can serve page
|
||||||
|
shells while JS refreshes the access token client-side.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **ROADMAP-UI.md**: Detailed UI hardening roadmap (v0.6.9–v0.6.15)
|
||||||
|
covering session config, viewport foundation, CSS deduplication,
|
||||||
|
extension CSS isolation, responsive layout, visual polish, and
|
||||||
|
automated usability survey gate.
|
||||||
|
|
||||||
## v0.6.7 — Native mTLS
|
## v0.6.7 — Native mTLS
|
||||||
|
|
||||||
End-to-end mutual TLS without a reverse proxy. Targets systemd+podman
|
End-to-end mutual TLS without a reverse proxy. Targets systemd+podman
|
||||||
|
|||||||
305
ROADMAP.md
@@ -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.0 — MVP
|
## v0.6.x — Completed (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:178–183` — orphaned ChannelListFilter comments. `pages/pages.go:922–930` — `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. 5–6 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. |
|
||||||
|
|||||||
11
ci/Dockerfile.test-runner
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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"]
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
@@ -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`) |
|
||||||
|
|||||||
448
docs/DESIGN-shell-contract.md
Normal 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 3–7 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.
|
||||||
407
docs/DESIGN-surface-runners.md
Normal file
@@ -0,0 +1,407 @@
|
|||||||
|
# DESIGN: Surface Runners — v0.7.1–v0.7.3
|
||||||
|
|
||||||
|
## Status: v0.7.1 Shipped (framework + migrations), v0.7.2–v0.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'`.
|
||||||
@@ -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
@@ -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}`.
|
||||||
@@ -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
@@ -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.
|
||||||
@@ -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
|
||||||
|
|||||||
121
docs/PERMISSIONS-AND-GROUPS.md
Normal 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
@@ -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}}
|
||||||
|
```
|
||||||
@@ -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
@@ -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
@@ -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.
|
||||||
BIN
icons/apple-touch-icon-b-dark.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
icons/apple-touch-icon-b-light.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
icons/apple-touch-icon-e-dark.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
icons/apple-touch-icon-e-light.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
icons/favicon-16-b-dark.png
Normal file
|
After Width: | Height: | Size: 218 B |
BIN
icons/favicon-16-b-light.png
Normal file
|
After Width: | Height: | Size: 214 B |
BIN
icons/favicon-16-e-dark.png
Normal file
|
After Width: | Height: | Size: 304 B |
BIN
icons/favicon-16-e-light.png
Normal file
|
After Width: | Height: | Size: 312 B |
BIN
icons/favicon-32-b-dark.png
Normal file
|
After Width: | Height: | Size: 363 B |
BIN
icons/favicon-32-b-light.png
Normal file
|
After Width: | Height: | Size: 332 B |
BIN
icons/favicon-32-e-dark.png
Normal file
|
After Width: | Height: | Size: 457 B |
BIN
icons/favicon-32-e-light.png
Normal file
|
After Width: | Height: | Size: 459 B |
20
icons/favicon-animated.svg
Normal 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
@@ -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
|
After Width: | Height: | Size: 1.2 KiB |
BIN
icons/icon-128-b-light.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
icons/icon-128-e-dark.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
icons/icon-128-e-light.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
icons/icon-192-b-dark.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
icons/icon-192-b-light.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
icons/icon-192-e-dark.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
icons/icon-192-e-light.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
icons/icon-256-b-dark.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
icons/icon-256-b-light.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
icons/icon-256-e-dark.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
icons/icon-256-e-light.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
icons/icon-48-b-dark.png
Normal file
|
After Width: | Height: | Size: 496 B |
BIN
icons/icon-48-b-light.png
Normal file
|
After Width: | Height: | Size: 492 B |
BIN
icons/icon-48-e-dark.png
Normal file
|
After Width: | Height: | Size: 564 B |
BIN
icons/icon-48-e-light.png
Normal file
|
After Width: | Height: | Size: 559 B |
BIN
icons/icon-512-b-dark.png
Normal file
|
After Width: | Height: | Size: 5.4 KiB |
BIN
icons/icon-512-b-light.png
Normal file
|
After Width: | Height: | Size: 5.4 KiB |
BIN
icons/icon-512-e-dark.png
Normal file
|
After Width: | Height: | Size: 5.7 KiB |
BIN
icons/icon-512-e-light.png
Normal file
|
After Width: | Height: | Size: 5.7 KiB |
BIN
icons/icon-64-b-dark.png
Normal file
|
After Width: | Height: | Size: 626 B |
BIN
icons/icon-64-b-light.png
Normal file
|
After Width: | Height: | Size: 620 B |
BIN
icons/icon-64-e-dark.png
Normal file
|
After Width: | Height: | Size: 711 B |
BIN
icons/icon-64-e-light.png
Normal file
|
After Width: | Height: | Size: 707 B |
48
icons/icon-b-dark-animated.svg
Normal 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
@@ -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 |
43
icons/icon-b-light-animated.svg
Normal 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
@@ -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 |
67
icons/icon-e-dark-animated.svg
Normal 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
@@ -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 |
63
icons/icon-e-light-animated.svg
Normal 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
@@ -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 |
174
icons/icon-morph-dark-animated.svg
Normal 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 |
127
icons/icon-morph-light-animated.svg
Normal 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
|
After Width: | Height: | Size: 15 KiB |
12
icons/wordmark-dark.svg
Normal 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
@@ -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 |
@@ -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": "",
|
||||||
})
|
})
|
||||||
|
|||||||
59
packages/chat-runner/js/conversations.js
Normal 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;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
64
packages/chat-runner/js/main.js
Normal 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();
|
||||||
|
})();
|
||||||
60
packages/chat-runner/js/messaging.js
Normal 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
31
packages/chat-runner/js/shell-topbar.js
Normal 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');
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
|
})();
|
||||||
9
packages/chat-runner/manifest.json
Normal 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."
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">✎</button>
|
<button class="ext-chat-msg__action" onClick=${startEdit} title="Edit">✎</button>
|
||||||
<button class="chat-msg__action chat-msg__action--danger" onClick=${() => onDelete(msg.id)} title="Delete">🗑</button>
|
<button class="ext-chat-msg__action ext-chat-msg__action--danger" onClick=${() => onDelete(msg.id)} title="Delete">🗑</button>
|
||||||
</div>`}
|
</div>`}
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
@@ -285,20 +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} />
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/* Git Board — Surface Styles */
|
/* Git Board — Surface Styles */
|
||||||
|
|
||||||
.gb-shell {
|
.ext-git-board-shell {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -9,80 +9,80 @@
|
|||||||
|
|
||||||
/* ── Header ──────────────────────────────── */
|
/* ── Header ──────────────────────────────── */
|
||||||
|
|
||||||
.gb-header {
|
.ext-git-board-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 12px 16px;
|
padding: var(--sp-3) var(--sp-4);
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gb-header__left,
|
.ext-git-board-header__left,
|
||||||
.gb-header__right {
|
.ext-git-board-header__right {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: var(--sp-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.gb-title {
|
.ext-git-board-title {
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gb-repo-picker {
|
.ext-git-board-repo-picker {
|
||||||
background: var(--bg-raised);
|
background: var(--bg-raised);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-family: var(--mono);
|
font-family: var(--mono);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
padding: 5px 8px;
|
padding: 5px var(--sp-2);
|
||||||
max-width: 260px;
|
max-width: 260px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Connection Setup ─────────────────────── */
|
/* ── Connection Setup ─────────────────────── */
|
||||||
|
|
||||||
.gb-setup {
|
.ext-git-board-setup {
|
||||||
max-width: 480px;
|
max-width: 480px;
|
||||||
margin: 60px auto;
|
margin: 60px auto;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 0 16px;
|
padding: 0 var(--sp-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.gb-setup h2 {
|
.ext-git-board-setup h2 {
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
margin: 0 0 8px;
|
margin: 0 0 var(--sp-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.gb-setup p {
|
.ext-git-board-setup p {
|
||||||
color: var(--text-2);
|
color: var(--text-2);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
margin: 0 0 20px;
|
margin: 0 0 var(--sp-5);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.gb-setup__hint {
|
.ext-git-board-setup__hint {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Kanban Board ────────────────────────── */
|
/* ── Kanban Board ────────────────────────── */
|
||||||
|
|
||||||
.gb-board {
|
.ext-git-board-board {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 12px;
|
gap: var(--sp-3);
|
||||||
padding: 12px 16px;
|
padding: var(--sp-3) var(--sp-4);
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
overflow-y: hidden;
|
overflow-y: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gb-column {
|
.ext-git-board-column {
|
||||||
min-width: 260px;
|
min-width: 260px;
|
||||||
max-width: 320px;
|
max-width: 320px;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -94,15 +94,15 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gb-column__header {
|
.ext-git-board-column__header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 10px 12px;
|
padding: var(--sp-3) var(--sp-3);
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.gb-column__title {
|
.ext-git-board-column__title {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
@@ -110,67 +110,67 @@
|
|||||||
letter-spacing: 0.03em;
|
letter-spacing: 0.03em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gb-column__count {
|
.ext-git-board-column__count {
|
||||||
background: var(--bg-raised);
|
background: var(--bg-raised);
|
||||||
color: var(--text-2);
|
color: var(--text-2);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
padding: 2px 7px;
|
padding: 2px 7px;
|
||||||
border-radius: 10px;
|
border-radius: var(--radius-lg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.gb-column__cards {
|
.ext-git-board-column__cards {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 8px;
|
padding: var(--sp-2);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 6px;
|
gap: var(--sp-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Cards ───────────────────────────────── */
|
/* ── Cards ───────────────────────────────── */
|
||||||
|
|
||||||
.gb-card {
|
.ext-git-board-card {
|
||||||
display: block;
|
display: block;
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
padding: 10px;
|
padding: var(--sp-3);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
transition: border-color var(--transition), background var(--transition);
|
transition: border-color var(--transition), background var(--transition);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gb-card:hover {
|
.ext-git-board-card:hover {
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
background: var(--bg-hover);
|
background: var(--bg-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.gb-card--pr {
|
.ext-git-board-card--pr {
|
||||||
border-left: 3px solid var(--accent);
|
border-left: 3px solid var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.gb-card__header {
|
.ext-git-board-card__header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: var(--sp-2);
|
||||||
margin-bottom: 4px;
|
margin-bottom: var(--sp-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.gb-card__number {
|
.ext-git-board-card__number {
|
||||||
font-family: var(--mono);
|
font-family: var(--mono);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.gb-card__assignee {
|
.ext-git-board-card__assignee {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gb-card__branch {
|
.ext-git-board-card__branch {
|
||||||
font-family: var(--mono);
|
font-family: var(--mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
@@ -181,54 +181,54 @@
|
|||||||
max-width: 150px;
|
max-width: 150px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gb-card__title {
|
.ext-git-board-card__title {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.gb-card__labels {
|
.ext-git-board-card__labels {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 4px;
|
gap: var(--sp-1);
|
||||||
margin-top: 6px;
|
margin-top: var(--sp-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.gb-card__labels .badge {
|
.ext-git-board-card__labels .ext-git-board-badge {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
padding: 1px 6px;
|
padding: 1px var(--sp-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.gb-card__meta {
|
.ext-git-board-card__meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
margin-top: 6px;
|
margin-top: var(--sp-2);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── DnD States ─────────────────────────── */
|
/* ── DnD States ─────────────────────────── */
|
||||||
|
|
||||||
.gb-card[draggable="true"] {
|
.ext-git-board-card[draggable="true"] {
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
.gb-card[draggable="true"]:active {
|
.ext-git-board-card[draggable="true"]:active {
|
||||||
cursor: grabbing;
|
cursor: grabbing;
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
.gb-column--dragover {
|
.ext-git-board-column--dragover {
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
background: color-mix(in srgb, var(--accent) 6%, var(--bg-surface));
|
background: color-mix(in srgb, var(--accent) 6%, var(--bg-surface));
|
||||||
}
|
}
|
||||||
.gb-column--dragover .gb-column__header {
|
.ext-git-board-column--dragover .ext-git-board-column__header {
|
||||||
border-bottom-color: var(--accent);
|
border-bottom-color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Empty state ─────────────────────────── */
|
/* ── Empty state ─────────────────────────── */
|
||||||
|
|
||||||
.gb-empty {
|
.ext-git-board-empty {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -239,7 +239,7 @@
|
|||||||
|
|
||||||
/* ── Issue Detail Modal ─────────────────── */
|
/* ── Issue Detail Modal ─────────────────── */
|
||||||
|
|
||||||
.gb-modal-overlay {
|
.ext-git-board-modal-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background: rgba(0,0,0,0.5);
|
background: rgba(0,0,0,0.5);
|
||||||
@@ -247,40 +247,41 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 40px 16px;
|
padding: var(--sp-10) var(--sp-4);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gb-modal {
|
.ext-git-board-modal {
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 680px;
|
max-width: 680px;
|
||||||
max-height: calc(100vh - 80px);
|
max-height: calc(100vh - 80px);
|
||||||
|
max-height: calc(100dvh - 80px);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
box-shadow: 0 8px 32px rgba(0,0,0,0.3);
|
box-shadow: 0 8px 32px rgba(0,0,0,0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.gb-modal__header {
|
.ext-git-board-modal__header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 16px 20px;
|
padding: var(--sp-4) var(--sp-5);
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gb-modal__title-row {
|
.ext-git-board-modal__title-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
gap: 8px;
|
gap: var(--sp-2);
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gb-modal__title {
|
.ext-git-board-modal__title {
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
@@ -288,59 +289,59 @@
|
|||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gb-modal__close {
|
.ext-git-board-modal__close {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 2px 6px;
|
padding: 2px var(--sp-2);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.gb-modal__close:hover {
|
.ext-git-board-modal__close:hover {
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
background: var(--bg-hover);
|
background: var(--bg-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.gb-modal__body {
|
.ext-git-board-modal__body {
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 16px 20px;
|
padding: var(--sp-4) var(--sp-5);
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gb-modal__meta {
|
.ext-git-board-modal__meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: var(--sp-3);
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
margin-bottom: 12px;
|
margin-bottom: var(--sp-3);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-2);
|
color: var(--text-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.gb-modal__date {
|
.ext-git-board-modal__date {
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.gb-modal__extlink {
|
.ext-git-board-modal__extlink {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
.gb-modal__extlink:hover {
|
.ext-git-board-modal__extlink:hover {
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gb-modal__description {
|
.ext-git-board-modal__description {
|
||||||
margin-bottom: 20px;
|
margin-bottom: var(--sp-5);
|
||||||
padding-bottom: 16px;
|
padding-bottom: var(--sp-4);
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.gb-modal__body-text {
|
.ext-git-board-modal__body-text {
|
||||||
font-family: var(--font);
|
font-family: var(--font);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
@@ -353,49 +354,49 @@
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gb-modal__empty {
|
.ext-git-board-modal__empty {
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gb-modal__section-title {
|
.ext-git-board-modal__section-title {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-2);
|
color: var(--text-2);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.03em;
|
letter-spacing: 0.03em;
|
||||||
margin: 0 0 12px;
|
margin: 0 0 var(--sp-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Comments ────────────────────────────── */
|
/* ── Comments ────────────────────────────── */
|
||||||
|
|
||||||
.gb-comment {
|
.ext-git-board-comment {
|
||||||
padding: 10px 0;
|
padding: var(--sp-3) 0;
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
.gb-comment:last-child {
|
.ext-git-board-comment:last-child {
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gb-comment__header {
|
.ext-git-board-comment__header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
gap: 8px;
|
gap: var(--sp-2);
|
||||||
margin-bottom: 4px;
|
margin-bottom: var(--sp-1);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
.gb-comment__header strong {
|
.ext-git-board-comment__header strong {
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.gb-comment__date {
|
.ext-git-board-comment__date {
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gb-comment__body {
|
.ext-git-board-comment__body {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
@@ -405,13 +406,13 @@
|
|||||||
|
|
||||||
/* ── Add Comment ─────────────────────────── */
|
/* ── Add Comment ─────────────────────────── */
|
||||||
|
|
||||||
.gb-modal__add-comment {
|
.ext-git-board-modal__add-comment {
|
||||||
margin-top: 16px;
|
margin-top: var(--sp-4);
|
||||||
padding-top: 16px;
|
padding-top: var(--sp-4);
|
||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.gb-modal__textarea {
|
.ext-git-board-modal__textarea {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background: var(--bg-raised);
|
background: var(--bg-raised);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
@@ -419,38 +420,31 @@
|
|||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-family: var(--font);
|
font-family: var(--font);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
padding: 8px 10px;
|
padding: var(--sp-2) var(--sp-3);
|
||||||
resize: vertical;
|
resize: vertical;
|
||||||
outline: none;
|
outline: none;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
.gb-modal__textarea:focus {
|
.ext-git-board-modal__textarea:focus {
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.gb-modal__actions {
|
.ext-git-board-modal__actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: var(--sp-2);
|
||||||
margin-top: 8px;
|
margin-top: var(--sp-2);
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Badge variants ──────────────────────── */
|
/* ── Badge variants ──────────────────────── */
|
||||||
|
|
||||||
.badge--green {
|
.ext-git-board-badge--green {
|
||||||
background: var(--green);
|
background: var(--green);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
.badge--muted {
|
.ext-git-board-badge--muted {
|
||||||
background: var(--bg-raised);
|
background: var(--bg-raised);
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-danger {
|
/* git-board danger button override — uses kernel .sw-btn--danger */
|
||||||
background: var(--danger, #e53e3e);
|
|
||||||
color: #fff;
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
.btn-danger:hover {
|
|
||||||
opacity: 0.9;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -160,21 +160,21 @@
|
|||||||
return html`
|
return html`
|
||||||
<div class="user-menu-container" ref=${menuRef}></div>
|
<div class="user-menu-container" ref=${menuRef}></div>
|
||||||
${needsConn ? html`<${ConnectionSetup} />` : html`
|
${needsConn ? html`<${ConnectionSetup} />` : html`
|
||||||
<div class="gb-shell">
|
<div class="ext-git-board-shell">
|
||||||
<header class="gb-header">
|
<header class="ext-git-board-header">
|
||||||
<div class="gb-header__left">
|
<div class="ext-git-board-header__left">
|
||||||
<h1 class="gb-title">Git Board</h1>
|
<h1 class="ext-git-board-title">Git Board</h1>
|
||||||
<${RepoPicker} repos=${repos} owner=${owner} repo=${repo}
|
<${RepoPicker} repos=${repos} owner=${owner} repo=${repo}
|
||||||
onSelect=${function (o, r) { setOwner(o); setRepo(r); }} />
|
onSelect=${function (o, r) { setOwner(o); setRepo(r); }} />
|
||||||
</div>
|
</div>
|
||||||
<div class="gb-header__right">
|
<div class="ext-git-board-header__right">
|
||||||
<button class="btn-small" onClick=${loadBoard} disabled=${loading}>
|
<button class="sw-btn sw-btn--secondary sw-btn--sm" onClick=${loadBoard} disabled=${loading}>
|
||||||
${loading ? '↻' : 'Refresh'}
|
${loading ? '↻' : 'Refresh'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
${board ? html`<${Board} data=${board} onDrop=${onDrop} onCardClick=${openModal} />` : html`
|
${board ? html`<${Board} data=${board} onDrop=${onDrop} onCardClick=${openModal} />` : html`
|
||||||
<div class="gb-empty">${loading ? 'Loading…' : 'Select a repository'}</div>
|
<div class="ext-git-board-empty">${loading ? 'Loading…' : 'Select a repository'}</div>
|
||||||
`}
|
`}
|
||||||
</div>
|
</div>
|
||||||
`}
|
`}
|
||||||
@@ -186,21 +186,21 @@
|
|||||||
|
|
||||||
function ConnectionSetup() {
|
function ConnectionSetup() {
|
||||||
return html`
|
return html`
|
||||||
<div class="gb-shell">
|
<div class="ext-git-board-shell">
|
||||||
<header class="gb-header">
|
<header class="ext-git-board-header">
|
||||||
<div class="gb-header__left"><h1 class="gb-title">Git Board</h1></div>
|
<div class="ext-git-board-header__left"><h1 class="ext-git-board-title">Git Board</h1></div>
|
||||||
</header>
|
</header>
|
||||||
<div class="gb-setup">
|
<div class="ext-git-board-setup">
|
||||||
<h2>Connect to Gitea</h2>
|
<h2>Connect to Gitea</h2>
|
||||||
<p>Git Board requires a Gitea connection to fetch repositories, issues, and pull requests.</p>
|
<p>Git Board requires a Gitea connection to fetch repositories, issues, and pull requests.</p>
|
||||||
<p>Ask your admin to add a <strong>Gitea</strong> connection in
|
<p>Ask your admin to add a <strong>Gitea</strong> connection in
|
||||||
<strong>Admin → Connections</strong>, or add a personal one in
|
<strong>Admin → Connections</strong>, or add a personal one in
|
||||||
<strong>Settings → Connections</strong>.</p>
|
<strong>Settings → Connections</strong>.</p>
|
||||||
<button class="btn-small btn-primary"
|
<button class="sw-btn sw-btn--primary sw-btn--sm"
|
||||||
onClick=${function () { window.location.href = base + '/settings'; }}>
|
onClick=${function () { window.location.href = base + '/settings'; }}>
|
||||||
Open Settings
|
Open Settings
|
||||||
</button>
|
</button>
|
||||||
<p class="gb-setup__hint">Connections are managed centrally — no API tokens in extension settings.</p>
|
<p class="ext-git-board-setup__hint">Connections are managed centrally — no API tokens in extension settings.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -211,7 +211,7 @@
|
|||||||
function RepoPicker({ repos, owner, repo, onSelect }) {
|
function RepoPicker({ repos, owner, repo, onSelect }) {
|
||||||
var current = owner + '/' + repo;
|
var current = owner + '/' + repo;
|
||||||
return html`
|
return html`
|
||||||
<select class="gb-repo-picker" value=${current}
|
<select class="ext-git-board-repo-picker" value=${current}
|
||||||
onChange=${function (e) {
|
onChange=${function (e) {
|
||||||
var parts = e.target.value.split('/');
|
var parts = e.target.value.split('/');
|
||||||
onSelect(parts[0], parts.slice(1).join('/'));
|
onSelect(parts[0], parts.slice(1).join('/'));
|
||||||
@@ -233,7 +233,7 @@
|
|||||||
var inProgress = issues.filter(function (i) { return i.state === 'open' && !!i.assignee; });
|
var inProgress = issues.filter(function (i) { return i.state === 'open' && !!i.assignee; });
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="gb-board">
|
<div class="ext-git-board-board">
|
||||||
<${Column} id="open" title="Open" count=${openUnassigned.length} onDrop=${onDrop}>
|
<${Column} id="open" title="Open" count=${openUnassigned.length} onDrop=${onDrop}>
|
||||||
${openUnassigned.map(function (i) {
|
${openUnassigned.map(function (i) {
|
||||||
return html`<${IssueCard} key=${i.number} issue=${i} onClick=${onCardClick} />`;
|
return html`<${IssueCard} key=${i.number} issue=${i} onClick=${onCardClick} />`;
|
||||||
@@ -268,19 +268,19 @@
|
|||||||
} : {};
|
} : {};
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="gb-column ${over ? 'gb-column--dragover' : ''}" ...${handlers}>
|
<div class="ext-git-board-column ${over ? 'ext-git-board-column--dragover' : ''}" ...${handlers}>
|
||||||
<div class="gb-column__header">
|
<div class="ext-git-board-column__header">
|
||||||
<span class="gb-column__title">${title}</span>
|
<span class="ext-git-board-column__title">${title}</span>
|
||||||
<span class="gb-column__count">${count || 0}</span>
|
<span class="ext-git-board-column__count">${count || 0}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="gb-column__cards">${children}</div>
|
<div class="ext-git-board-column__cards">${children}</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function IssueCard({ issue, onClick }) {
|
function IssueCard({ issue, onClick }) {
|
||||||
return html`
|
return html`
|
||||||
<div class="gb-card" draggable="true"
|
<div class="ext-git-board-card" draggable="true"
|
||||||
onDragStart=${function (e) {
|
onDragStart=${function (e) {
|
||||||
e.dataTransfer.setData('text/plain', String(issue.number));
|
e.dataTransfer.setData('text/plain', String(issue.number));
|
||||||
e.dataTransfer.effectAllowed = 'move';
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
@@ -289,14 +289,14 @@
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (onClick) onClick(issue.number);
|
if (onClick) onClick(issue.number);
|
||||||
}}>
|
}}>
|
||||||
<div class="gb-card__header">
|
<div class="ext-git-board-card__header">
|
||||||
<span class="gb-card__number">#${issue.number}</span>
|
<span class="ext-git-board-card__number">#${issue.number}</span>
|
||||||
${issue.assignee && html`<span class="gb-card__assignee">@${esc(issue.assignee)}</span>`}
|
${issue.assignee && html`<span class="ext-git-board-card__assignee">@${esc(issue.assignee)}</span>`}
|
||||||
</div>
|
</div>
|
||||||
<div class="gb-card__title">${esc(issue.title)}</div>
|
<div class="ext-git-board-card__title">${esc(issue.title)}</div>
|
||||||
<div class="gb-card__labels">
|
<div class="ext-git-board-card__labels">
|
||||||
${(issue.labels || []).map(function (l) {
|
${(issue.labels || []).map(function (l) {
|
||||||
return html`<span key=${l} class="badge">${esc(l)}</span>`;
|
return html`<span key=${l} class="ext-git-board-badge">${esc(l)}</span>`;
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -305,13 +305,13 @@
|
|||||||
|
|
||||||
function PRCard({ pr }) {
|
function PRCard({ pr }) {
|
||||||
return html`
|
return html`
|
||||||
<a class="gb-card gb-card--pr" href=${pr.html_url} target="_blank" rel="noopener">
|
<a class="ext-git-board-card ext-git-board-card--pr" href=${pr.html_url} target="_blank" rel="noopener">
|
||||||
<div class="gb-card__header">
|
<div class="ext-git-board-card__header">
|
||||||
<span class="gb-card__number">#${pr.number}</span>
|
<span class="ext-git-board-card__number">#${pr.number}</span>
|
||||||
<span class="gb-card__branch">${esc(pr.head)} → ${esc(pr.base)}</span>
|
<span class="ext-git-board-card__branch">${esc(pr.head)} → ${esc(pr.base)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="gb-card__title">${esc(pr.title)}</div>
|
<div class="ext-git-board-card__title">${esc(pr.title)}</div>
|
||||||
<div class="gb-card__meta">
|
<div class="ext-git-board-card__meta">
|
||||||
<span>@${esc(pr.user)}</span>
|
<span>@${esc(pr.user)}</span>
|
||||||
<span>${timeAgo(pr.created_at)}</span>
|
<span>${timeAgo(pr.created_at)}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -387,73 +387,73 @@
|
|||||||
}, [issue, owner, repo, number]);
|
}, [issue, owner, repo, number]);
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="gb-modal-overlay" onClick=${function (e) {
|
<div class="ext-git-board-modal-overlay" onClick=${function (e) {
|
||||||
if (e.target.classList.contains('gb-modal-overlay')) onClose(changed);
|
if (e.target.classList.contains('ext-git-board-modal-overlay')) onClose(changed);
|
||||||
}}>
|
}}>
|
||||||
<div class="gb-modal">
|
<div class="ext-git-board-modal">
|
||||||
<div class="gb-modal__header">
|
<div class="ext-git-board-modal__header">
|
||||||
<div class="gb-modal__title-row">
|
<div class="ext-git-board-modal__title-row">
|
||||||
${loading ? 'Loading…' : html`
|
${loading ? 'Loading…' : html`
|
||||||
<span class="gb-card__number">#${number}</span>
|
<span class="ext-git-board-card__number">#${number}</span>
|
||||||
<h2 class="gb-modal__title">${esc(issue && issue.title)}</h2>
|
<h2 class="ext-git-board-modal__title">${esc(issue && issue.title)}</h2>
|
||||||
`}
|
`}
|
||||||
</div>
|
</div>
|
||||||
<button class="gb-modal__close" onClick=${function () { onClose(changed); }}>✕</button>
|
<button class="ext-git-board-modal__close" onClick=${function () { onClose(changed); }}>✕</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
${!loading && issue && html`
|
${!loading && issue && html`
|
||||||
<div class="gb-modal__body" ref=${bodyRef}>
|
<div class="ext-git-board-modal__body" ref=${bodyRef}>
|
||||||
<div class="gb-modal__meta">
|
<div class="ext-git-board-modal__meta">
|
||||||
<span class="badge ${issue.state === 'open' ? 'badge--green' : 'badge--muted'}">${issue.state}</span>
|
<span class="ext-git-board-badge ${issue.state === 'open' ? 'ext-git-board-badge--green' : 'ext-git-board-badge--muted'}">${issue.state}</span>
|
||||||
${issue.assignee && html`<span class="gb-card__assignee">@${esc(issue.assignee)}</span>`}
|
${issue.assignee && html`<span class="ext-git-board-card__assignee">@${esc(issue.assignee)}</span>`}
|
||||||
${issue.created_at && html`<span class="gb-modal__date">${new Date(issue.created_at).toLocaleDateString()}</span>`}
|
${issue.created_at && html`<span class="ext-git-board-modal__date">${new Date(issue.created_at).toLocaleDateString()}</span>`}
|
||||||
<a href=${issue.html_url || '#'} target="_blank" rel="noopener"
|
<a href=${issue.html_url || '#'} target="_blank" rel="noopener"
|
||||||
class="gb-modal__extlink">Open in Gitea ↗</a>
|
class="ext-git-board-modal__extlink">Open in Gitea ↗</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
${issue.labels && issue.labels.length > 0 && html`
|
${issue.labels && issue.labels.length > 0 && html`
|
||||||
<div class="gb-card__labels" style="margin-bottom:12px;">
|
<div class="ext-git-board-card__labels" style="margin-bottom:12px;">
|
||||||
${issue.labels.map(function (l) { return html`<span key=${l} class="badge">${esc(l)}</span>`; })}
|
${issue.labels.map(function (l) { return html`<span key=${l} class="ext-git-board-badge">${esc(l)}</span>`; })}
|
||||||
</div>
|
</div>
|
||||||
`}
|
`}
|
||||||
|
|
||||||
<div class="gb-modal__description">
|
<div class="ext-git-board-modal__description">
|
||||||
${issue.body ? html`<pre class="gb-modal__body-text">${esc(issue.body)}</pre>`
|
${issue.body ? html`<pre class="ext-git-board-modal__body-text">${esc(issue.body)}</pre>`
|
||||||
: html`<p class="gb-modal__empty">No description.</p>`}
|
: html`<p class="ext-git-board-modal__empty">No description.</p>`}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="gb-modal__comments">
|
<div class="ext-git-board-modal__comments">
|
||||||
<h3 class="gb-modal__section-title">Comments (${(issue.comments || []).length})</h3>
|
<h3 class="ext-git-board-modal__section-title">Comments (${(issue.comments || []).length})</h3>
|
||||||
${(issue.comments || []).length === 0 && html`
|
${(issue.comments || []).length === 0 && html`
|
||||||
<p class="gb-modal__empty">No comments yet.</p>
|
<p class="ext-git-board-modal__empty">No comments yet.</p>
|
||||||
`}
|
`}
|
||||||
${(issue.comments || []).map(function (c, i) {
|
${(issue.comments || []).map(function (c, i) {
|
||||||
return html`
|
return html`
|
||||||
<div class="gb-comment" key=${i}>
|
<div class="ext-git-board-comment" key=${i}>
|
||||||
<div class="gb-comment__header">
|
<div class="ext-git-board-comment__header">
|
||||||
<strong>@${esc(c.user)}</strong>
|
<strong>@${esc(c.user)}</strong>
|
||||||
<span class="gb-comment__date">${timeAgo(c.created_at)}</span>
|
<span class="ext-git-board-comment__date">${timeAgo(c.created_at)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="gb-comment__body">${esc(c.body)}</div>
|
<div class="ext-git-board-comment__body">${esc(c.body)}</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="gb-modal__add-comment">
|
<div class="ext-git-board-modal__add-comment">
|
||||||
<textarea class="gb-modal__textarea" rows="3"
|
<textarea class="ext-git-board-modal__textarea" rows="3"
|
||||||
placeholder="Add a comment…"
|
placeholder="Add a comment…"
|
||||||
value=${comment}
|
value=${comment}
|
||||||
onInput=${function (e) { setComment(e.target.value); }}
|
onInput=${function (e) { setComment(e.target.value); }}
|
||||||
onKeyDown=${function (e) {
|
onKeyDown=${function (e) {
|
||||||
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) postComment();
|
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) postComment();
|
||||||
}} />
|
}} />
|
||||||
<div class="gb-modal__actions">
|
<div class="ext-git-board-modal__actions">
|
||||||
<button class="btn-small btn-primary" disabled=${posting || !comment.trim()}
|
<button class="sw-btn sw-btn--primary sw-btn--sm" disabled=${posting || !comment.trim()}
|
||||||
onClick=${postComment}>
|
onClick=${postComment}>
|
||||||
${posting ? 'Posting…' : 'Comment'}
|
${posting ? 'Posting…' : 'Comment'}
|
||||||
</button>
|
</button>
|
||||||
<button class="btn-small ${issue.state === 'open' ? 'btn-danger' : 'btn-secondary'}"
|
<button class="sw-btn sw-btn--secondary sw-btn--sm ${issue.state === 'open' ? 'sw-btn sw-btn--danger sw-btn--md' : 'sw-btn sw-btn--secondary sw-btn--md'}"
|
||||||
onClick=${toggleState}>
|
onClick=${toggleState}>
|
||||||
${issue.state === 'open' ? 'Close Issue' : 'Reopen Issue'}
|
${issue.state === 'open' ? 'Close Issue' : 'Reopen Issue'}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -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; }
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
@@ -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."
|
|
||||||
}
|
|
||||||
@@ -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; }
|
|
||||||
}
|
|
||||||
@@ -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');
|
|
||||||
});
|
|
||||||
|
|
||||||
};
|
|
||||||
})();
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
|
|
||||||
};
|
|
||||||
})();
|
|
||||||
@@ -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');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
};
|
|
||||||
})();
|
|
||||||
@@ -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');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
};
|
|
||||||
})();
|
|
||||||
@@ -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;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
};
|
|
||||||
})();
|
|
||||||