diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml
index f262fbd..a8ab990 100644
--- a/.gitea/workflows/ci.yaml
+++ b/.gitea/workflows/ci.yaml
@@ -261,7 +261,7 @@ jobs:
echo "${UNIT_PKGS}" | sed 's/^/ /'
echo ""
- go test -v -race -count=1 -p 1 ${UNIT_PKGS}
+ go test -v -race -count=1 -p 1 -timeout 8m ${UNIT_PKGS}
echo "✓ Unit tests complete"
- name: Run SQLite handler integration tests
@@ -270,7 +270,7 @@ jobs:
DB_DRIVER: sqlite
run: |
echo "━━━ SQLite Integration Tests (handlers + stores) ━━━"
- go test -v -race -count=1 -p 1 \
+ go test -v -race -count=1 -p 1 -timeout 8m \
./handlers/... \
./store/sqlite/...
echo "✓ SQLite integration tests complete"
@@ -351,7 +351,7 @@ jobs:
PGPASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
run: |
echo "━━━ Postgres Integration Tests ━━━"
- go test -v -race -count=1 -p 1 \
+ go test -v -race -count=1 -p 1 -timeout 8m \
./store/postgres/... \
./handlers/...
echo "✓ Postgres integration tests complete"
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8288bf6..03f4fbc 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,29 @@
All notable changes to Chat Switchboard.
+## [0.22.6] — 2026-03-03
+
+### Fixed
+- **Split FE/BE deployment: page route proxying.** Frontend container now supports `BACKEND_URL` env var. When set, the entrypoint generates nginx `proxy_pass` blocks for page routes (`/`, `/login`, `/chat/:id`, `/admin/*`, `/editor/*`, `/notes/*`, `/settings/*`) so Go template rendering works in k8s split-image deployments. When unset, behavior is unchanged (SPA fallback). Both `BASE_PATH=""` and `BASE_PATH="/prefix"` variants supported.
+- **CI test timeout.** All `go test` commands now include `-timeout 8m` to prevent indefinite hangs from runner resource contention or race-detector compilation stalls. Previously used Go's 10-minute default with no goroutine dump on timeout.
+
+### Removed
+- **`router.js` (322 lines).** Client-side hash router fully replaced by server-side page routes. All `Router.*` call sites in `chat.js` replaced with `history.replaceState()` for URL updates. All `typeof Router` guards in `app.js` removed.
+- **`surfaces.js` (368 lines).** Client-side surface registry replaced by Go template surface architecture. Extension API stubs (`surfaces.register`, `surfaces.activate`, etc.) now log deprecation warnings. `surfaces.getCurrent()` returns `window.__SURFACE__` from server-rendered page data.
+- **`index.html` SPA shell (1,296 lines → 16 lines).** Original monolithic SPA entry point replaced with a minimal redirect page. All real routes now served by Go templates; `index.html` only serves as nginx `try_files` fallback for unknown paths.
+- **`editor-mode.js` old Surfaces paths (~260 lines).** Removed `_register()`, `openDirect()`, `_unregister()`, `_activate()`, `_deactivate()`, `_embedChat()`, `_returnChat()`, and full `check()`/`checkStartup()` implementations. Only `mountServerRendered()` remains as the entry point. `check()` and `checkStartup()` kept as no-ops for backward compat with `projects-ui.js` callers.
+- **SPA-only entrypoint code path.** `docker-entrypoint-fe.sh` now requires `BACKEND_URL` (fails fast if unset). Removed dead `%%BASE_HREF%%`, `%%ENVIRONMENT%%`, `%%BRANDING_JSON%%` injection into index.html. Removed branding config file loading (branding now handled by Go templates).
+
+### Changed
+- `docker-entrypoint-fe.sh`: Simplified from 227 → 184 lines. Requires `BACKEND_URL`. Removed SPA-only fallback path.
+- `nginx.conf` (unified container): Consolidated repeated `proxy_set_header` blocks. Uses `$backend` variable. Page routes proxy to Go backend for template rendering.
+- `base.html`: Added `__ENV__` and `__BRANDING__` globals for JS compatibility (previously injected by index.html sed).
+- `server/pages/pages.go`: Added `Environment` field to `PageData`, populated from config.
+- `extensions.js`: `ui.replace()`, `ui.restore()`, and `surfaces.*` API methods now log deprecation warnings instead of calling removed Surfaces system.
+- `app.js`: Removed `Surfaces.init()`, `EditorMode.checkStartup()`, and `Router.init()` calls. Simplified to direct `sessionStorage` chat restore.
+- `chat.js`: Replaced `Router.update()` calls with `history.replaceState()` for URL-bar sync on chat selection/creation.
+- `repl.js`: Removed `surfaces.js` import reference.
+
## [0.22.5] — 2026-03-03
### Added
diff --git a/Dockerfile.frontend b/Dockerfile.frontend
index a34aaf7..7d507be 100644
--- a/Dockerfile.frontend
+++ b/Dockerfile.frontend
@@ -1,15 +1,20 @@
# ==========================================
# Chat Switchboard - Frontend Dockerfile
# ==========================================
-# Static SPA served by nginx. No API proxy —
-# in k8s, the Ingress routes /api/ and /ws to
-# the backend service directly.
+# Static SPA served by nginx. In k8s, the
+# Ingress routes /api/ and /ws to the backend
+# service directly.
#
# Supports path-based deployment via BASE_PATH
# env var (e.g. /dev, /test, or empty for root).
# The entrypoint generates the nginx config and
# injects BASE_PATH into index.html at startup.
#
+# v0.22.6+: Set BACKEND_URL to proxy page routes
+# (/, /login, /admin, /editor, /notes, /settings)
+# to the Go backend for server-rendered templates.
+# When unset, page routes fall through to SPA.
+#
# Build context: repo root
# ==========================================
diff --git a/VERSION b/VERSION
index 03035cd..18fb7fe 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-0.22.5
+0.22.6
diff --git a/docker-entrypoint-fe.sh b/docker-entrypoint-fe.sh
index 752fd7f..fd1bdeb 100644
--- a/docker-entrypoint-fe.sh
+++ b/docker-entrypoint-fe.sh
@@ -2,16 +2,23 @@
# ============================================
# Chat Switchboard - Frontend Entrypoint
# ============================================
-# Injects BASE_PATH into index.html and nginx
-# config at container startup. Supports path-
-# based multi-env deployment on a single domain.
+# nginx serves static assets and proxies page
+# routes to the Go backend for template rendering.
+# API/WS routes are handled by Ingress directly.
#
-# Environment:
-# BASE_PATH - URL prefix (e.g. "/dev", "/test", or "")
+# Environment (required):
+# BACKEND_URL - Backend service URL
+# (e.g. "http://switchboard-be:8080")
+#
+# Environment (optional):
+# BASE_PATH - URL prefix (e.g. "/dev", "" for root)
+# ENVIRONMENT - Environment name for logging
# ============================================
set -e
BASE_PATH="${BASE_PATH:-}"
+BACKEND_URL="${BACKEND_URL:?BACKEND_URL is required (e.g. http://switchboard-be:8080)}"
+ENVIRONMENT="${ENVIRONMENT:-production}"
# Read version from VERSION file (baked in at build time)
APP_VERSION="dev"
@@ -19,39 +26,16 @@ if [ -f /VERSION ]; then
APP_VERSION=$(cat /VERSION | tr -d '[:space:]')
fi
-# Compute base href (needs trailing slash for tag)
-if [ -z "${BASE_PATH}" ]; then
- BASE_HREF="/"
-else
- BASE_HREF="${BASE_PATH}/"
-fi
-
-# ── Read branding config ────────────────────
-BRANDING_JSON="{}"
-if [ -f /branding/branding.json ]; then
- # Compact to single line for safe sed injection
- BRANDING_JSON=$(tr -d '\n' < /branding/branding.json | sed 's/ */ /g')
- echo "✅ Branding config loaded from /branding/branding.json"
-else
- echo "ℹ️ No branding mount — using defaults"
-fi
-
-# ── Read environment name ─────────────────
-ENVIRONMENT="${ENVIRONMENT:-production}"
-
-# Compute build hash from frontend file contents (changes on every deploy)
+# Compute build hash from JS file contents (unique per deploy)
BUILD_HASH=$(find /usr/share/nginx/html/js -name '*.js' -exec md5sum {} + 2>/dev/null | sort | md5sum | cut -c1-8)
if [ -z "${BUILD_HASH}" ]; then
BUILD_HASH=$(date +%s | md5sum | cut -c1-8)
fi
-# ── Inject into index.html ──────────────────
+# ── Inject into index.html (redirect page) ──
sed -i \
-e "s|%%BASE_PATH%%|${BASE_PATH}|g" \
- -e "s|%%BASE_HREF%%|${BASE_HREF}|g" \
-e "s|%%APP_VERSION%%|${APP_VERSION}|g" \
- -e "s|%%ENVIRONMENT%%|${ENVIRONMENT}|g" \
- -e "s|%%BRANDING_JSON%%|${BRANDING_JSON}|g" \
/usr/share/nginx/html/index.html
# Inject version and build hash into service worker
@@ -60,13 +44,45 @@ sed -i \
-e "s|%%BUILD_HASH%%|${BUILD_HASH}|g" \
/usr/share/nginx/html/sw.js
-echo "✅ Frontend configured: BASE_PATH=${BASE_PATH:-/} VERSION=${APP_VERSION} BUILD=${BUILD_HASH} ENV=${ENVIRONMENT}"
+echo "✅ Frontend: BASE_PATH=${BASE_PATH:-/} VERSION=${APP_VERSION} BUILD=${BUILD_HASH} BACKEND=${BACKEND_URL}"
+
+# ── Page route proxy blocks ─────────────────
+page_proxy_block() {
+ local path="$1"
+ cat < /etc/nginx/conf.d/default.conf <<'NGINX'
+ cat > /etc/nginx/conf.d/default.conf <
window.__BASE__ = '{{.BasePath}}';
window.__VERSION__ = '{{.Version}}';
+ window.__ENV__ = '{{.Environment}}';
+ window.__BRANDING__ = {};
window.__SURFACE__ = '{{.Surface}}';
window.__PAGE_DATA__ = {{.Data | toJSON}};
window.__USER__ = {{.User | toJSON}};
diff --git a/server/pages/templates/surfaces/chat.html b/server/pages/templates/surfaces/chat.html
index 03e9519..cc40cd9 100644
--- a/server/pages/templates/surfaces/chat.html
+++ b/server/pages/templates/surfaces/chat.html
@@ -1,14 +1,631 @@
{{/*
Chat surface (Phase 1 bridge).
- Server renders the page shell. Existing SPA JS takes over the DOM.
- All scripts from index.html are loaded here — minus the three
- already in base.html (api.js, events.js, ui-primitives.js).
+ Server renders the page shell with full SPA scaffold.
+ Existing SPA JS (app.js, ui-core.js, chat.js, etc.) populates
+ these pre-built DOM elements with data.
+
+ v0.22.6 fix: The original index.html contained the full scaffold
+ (1,296 lines). When it was replaced with a redirect stub, this
+ template needed the scaffold — but it was shipped empty.
+ This template restores the required DOM structure.
*/}}
{{define "surface-chat"}}
-
- {{/* SPA builds its DOM here — same as current index.html */}}
+
+{{/* ── Splash / Auth Gate ───────────────────────
+ Server-rendered surfaces use /login for auth, so the splash
+ is hidden by default. Kept as a DOM node so showSplash()/
+ hideSplash() in app.js don't crash on null. */}}
+