peregrine/docs/superpowers/specs/2026-03-22-ui-switcher-design.md

258 lines
13 KiB
Markdown

# UI Switcher — Design Spec
**Date:** 2026-03-22
**Status:** Approved
**Scope:** Peregrine v0.7.0
---
## Overview
Add a Reddit-style UI switcher that lets paid-tier users opt into the new Vue 3 SPA while the Streamlit UI remains the default. The Vue SPA ships merged into `main` (gated behind a paid-tier feature flag), served by a new nginx Docker service alongside Streamlit. The demo instance gets both the UI switcher (open to all visitors) and a simulated tier switcher so demo visitors can explore all feature tiers.
---
## Decisions
| Question | Decision |
|---|---|
| Switcher placement | Banner (once per session, dismissible) + Settings → System toggle |
| Vue SPA serving | New `web` Docker service (nginx) in all three compose files |
| Preference persistence | JS cookie (`prgn_ui`) as Caddy routing signal; `user.yaml` as durability layer |
| Switching mechanism | JS cookie injection via `st.components.v1.html()` (Streamlit→Vue); client-side JS (Vue→Streamlit) |
| Tier gate | `vue_ui_beta: "paid"` in `tiers.py`; bypassed in `DEMO_MODE` |
| Branch strategy | Merge `feature-vue-spa``main` now; future Vue work uses `feature/vue-*``main` PRs |
| Demo UI switcher | Open to all demo visitors (no tier gate) |
| Demo tier switcher | Slim full-width toolbar above nav; cookie-based persistence (`prgn_demo_tier`) |
| Banner dismissal | Uses existing `dismissed_banners` list in `user.yaml` (key: `ui_switcher_beta`) |
---
## Port Reference
| Compose file | Host port | Purpose |
|---|---|---|
| `compose.yml` | 8501 | Personal dev instance |
| `compose.demo.yml` | 8504 | Demo (`demo.circuitforge.tech`) |
| `compose.cloud.yml` | 8505 | Cloud managed (`menagerie.circuitforge.tech`) |
| `compose.yml` (web) | 8506 | Vue SPA — dev |
| `compose.demo.yml` (web) | 8507 | Vue SPA — demo |
| `compose.cloud.yml` (web) | 8508 | Vue SPA — cloud |
---
## Architecture
Six additive components — nothing removed from the existing stack.
### 1. `web` Docker service
A minimal nginx container serving the Vue SPA `dist/` build. Added to `compose.yml`, `compose.demo.yml`, and `compose.cloud.yml`.
- `docker/web/Dockerfile``FROM nginx:alpine`, copies `nginx.conf`, copies `web/dist/` into `/usr/share/nginx/html/`
- `docker/web/nginx.conf` — standard SPA config with `try_files $uri /index.html` fallback
- Build step is image-baked (not a bind-mount): `docker compose build web` runs `vite build` in `web/` via a multi-stage Dockerfile, then copies the resulting `dist/` into the nginx image. This ensures a fresh clone + `manage.sh start` works without a separate manual build step.
- `manage.sh` updated: `build` target runs `docker compose build web app` so both are built together.
### 2. Caddy cookie routing
Caddy inspects the `prgn_ui` cookie on all Peregrine requests. Two vhost blocks require changes:
**`menagerie.circuitforge.tech` (cloud, port 8505/8508):**
```
handle /peregrine* {
@no_session not header Cookie *cf_session*
redir @no_session https://circuitforge.tech/login?next={uri} 302
@vue_ui header Cookie *prgn_ui=vue*
handle @vue_ui {
reverse_proxy http://host.docker.internal:8508
}
handle {
reverse_proxy http://host.docker.internal:8505
}
}
```
**`demo.circuitforge.tech` (demo, port 8504/8507):**
```
handle /peregrine* {
@vue_ui header Cookie *prgn_ui=vue*
handle @vue_ui {
reverse_proxy http://host.docker.internal:8507
}
handle {
reverse_proxy http://host.docker.internal:8504
}
}
```
Error handling: a `handle_errors { ... }` block on each vhost catches 502 from the Vue SPA service, redirects to the Streamlit upstream with `?ui_fallback=1`, and includes a `Set-Cookie: prgn_ui=streamlit; Path=/` response header to clear the routing cookie.
### 3. Streamlit switch mechanism
New module `app/components/ui_switcher.py`:
- `sync_ui_cookie()` — called **in the render pass** (after `pg.run()` in `app.py`), not inside the cached startup hook. Reads `user.yaml.ui_preference`; injects JS to set/clear `prgn_ui` cookie. Cookie/user.yaml conflict: **cookie wins** — if `prgn_ui` cookie is already present, writes user.yaml to match before re-injecting. If `DEMO_MODE`, skips tier check. If not `DEMO_MODE` and not `can_use("vue_ui_beta")`, resets preference to `streamlit` and clears cookie.
- `switch_ui(to: str)` — writes `user.yaml.ui_preference`, calls `sync_ui_cookie()`, then `st.rerun()`.
- `render_banner()` — dismissible banner shown to eligible users when `ui_switcher_beta` is not in `user_profile.dismissed_banners`. On dismiss: appends `ui_switcher_beta` to `dismissed_banners`, saves `user.yaml`. On "Try it": calls `switch_ui("vue")`. Also detects `?ui_fallback=1` in `st.query_params` and shows a toast ("New UI temporarily unavailable — switched back to Classic") then clears the param.
- `render_settings_toggle()` — toggle in Settings → System → Deployment expander. Calls `switch_ui()` on change.
### 4. Vue SPA switch-back
New `web/src/components/ClassicUIButton.vue`:
```js
function switchToClassic() {
document.cookie = 'prgn_ui=streamlit; path=/; SameSite=Lax';
const url = new URL(window.location.href);
url.searchParams.set('prgn_switch', 'streamlit');
window.location.href = url.toString();
}
```
**Why the query param?** Streamlit cannot read HTTP cookies from Python — only client-side JS can. The `?prgn_switch=streamlit` param acts as a bridge: `sync_ui_cookie()` reads it via `st.query_params`, updates user.yaml to match, then clears the param. The cookie is set by the JS before the navigation so Caddy routes the request to Streamlit, and the param ensures user.yaml stays consistent with the cookie.
### 5. Tier gate
`app/wizard/tiers.py`:
```python
FEATURES: dict[str, str] = {
...
"vue_ui_beta": "paid", # add this
}
```
Not in `BYOK_UNLOCKABLE` — the Vue UI has no LLM dependency; the gate is purely about beta access management.
`can_use()` signature change — keyword-only argument with a safe default:
```python
def can_use(
tier: str,
feature: str,
has_byok: bool = False,
*,
demo_tier: str | None = None,
) -> bool:
effective_tier = demo_tier if (demo_tier and DEMO_MODE_FLAG) else tier
...
```
Argument order preserved from the existing implementation (`tier` first, `feature` second) — no existing call sites need updating. `DEMO_MODE_FLAG` is read from the environment, not from `st.session_state`, so this function is safe to call from background task threads and tests. `st.session_state.simulated_tier` is only read by the **caller** (`render_banner()`, `render_settings_toggle()`, page feature gates) which then passes it as `demo_tier=`.
### 6. Demo toolbar
New module `app/components/demo_toolbar.py`:
- `render_demo_toolbar()` — slim full-width bar rendered at the top of `app.py`'s render pass when `DEMO_MODE=true`. Shows `🎭 Demo mode · Free · Paid · Premium` pills with the active tier highlighted.
- `set_simulated_tier(tier: str)` — injects JS to set `prgn_demo_tier` cookie, updates `st.session_state.simulated_tier`, calls `st.rerun()`.
- Initialization: on each page load in demo mode, `app.py` reads `prgn_demo_tier` from `st.query_params` or the cookie (via a JS→hidden Streamlit input bridge, same pattern used by existing components) and sets `st.session_state.simulated_tier`. **Default if not set: `paid`** — shows the full feature set immediately on first demo load.
`useFeatureFlag.ts` (Vue SPA, `web/src/composables/`) is **demo-toolbar only** — it reads `prgn_demo_tier` cookie for the visual indicator in the Vue SPA's ClassicUIButton area. It is **not** an authoritative feature gate. All real feature gating in the Vue SPA will use a future `/api/features` endpoint (tracked under issue #8). This composable exists solely so the demo toolbar's simulated tier is visually consistent when the user has switched to the Vue SPA.
---
## File Changes
### New files
| File | Purpose |
|---|---|
| `app/components/ui_switcher.py` | `sync_ui_cookie`, `switch_ui`, `render_banner`, `render_settings_toggle` |
| `app/components/demo_toolbar.py` | `render_demo_toolbar`, `set_simulated_tier` |
| `docker/web/Dockerfile` | Multi-stage: `node` build stage → `nginx:alpine` serve stage |
| `docker/web/nginx.conf` | SPA-aware nginx config |
| `web/` | Vue SPA source (merged from `feature-vue-spa` worktree) |
| `web/src/components/ClassicUIButton.vue` | Switch-back button for Vue SPA nav |
| `web/src/composables/useFeatureFlag.ts` | Demo toolbar tier display (not a production gate) |
### Modified files
| File | Change |
|---|---|
| `app/app.py` | Call `sync_ui_cookie()` + `render_demo_toolbar()` + `render_banner()` in render pass |
| `app/wizard/tiers.py` | Add `vue_ui_beta: "paid"` to `FEATURES`; add `demo_tier` keyword arg to `can_use()` |
| `app/pages/2_Settings.py` | Add `render_settings_toggle()` in System → Deployment expander |
| `config/user.yaml.example` | Add `ui_preference: streamlit` |
| `scripts/user_profile.py` | Add `ui_preference` field to schema (default: `streamlit`) |
| `compose.yml` | Add `web` service (port 8506) |
| `compose.demo.yml` | Add `web` service (port 8507) |
| `compose.cloud.yml` | Add `web` service (port 8508) |
| `manage.sh` | `build` target includes `web` service |
| `/devl/caddy-proxy/Caddyfile` | Cookie routing in `menagerie.circuitforge.tech` + `demo.circuitforge.tech` peregrine blocks |
---
## Data Flow
### Streamlit → Vue
```
User clicks "Try it" banner or Settings toggle
→ switch_ui(to="vue")
→ write user.yaml: ui_preference: vue
→ sync_ui_cookie(): inject JS → document.cookie = 'prgn_ui=vue; path=/'
→ st.rerun()
→ browser reloads → Caddy sees prgn_ui=vue → :8508/:8507 (Vue SPA)
```
### Vue → Streamlit
```
User clicks "Classic UI" in Vue nav
→ document.cookie = 'prgn_ui=streamlit; path=/'
→ navigate to current URL + ?prgn_switch=streamlit
→ Caddy sees prgn_ui=streamlit → :8505/:8504 (Streamlit)
→ app.py render pass: sync_ui_cookie() sees ?prgn_switch=streamlit in st.query_params
→ writes user.yaml: ui_preference: streamlit
→ clears query param
→ injects JS to re-confirm cookie
```
### Demo tier switch
```
User clicks tier pill in demo toolbar
→ set_simulated_tier("paid")
→ inject JS → document.cookie = 'prgn_demo_tier=paid; path=/'
→ st.session_state.simulated_tier = "paid"
→ st.rerun()
→ render_banner() / page feature gates call can_use(..., demo_tier=st.session_state.simulated_tier)
```
### Cookie cleared (durability)
```
Browser cookies cleared
→ next Streamlit load: sync_ui_cookie() reads user.yaml: ui_preference: vue
→ re-injects prgn_ui=vue cookie
→ next navigation: Caddy routes to Vue SPA
```
---
## Error Handling
| Scenario | Handling |
|---|---|
| Vue SPA service down (502) | Caddy `handle_errors` sets `Set-Cookie: prgn_ui=streamlit` + redirects to Streamlit with `?ui_fallback=1` |
| `?ui_fallback=1` detected | `render_banner()` shows toast "New UI temporarily unavailable — switched back to Classic"; calls `switch_ui("streamlit")` |
| user.yaml missing/malformed | `sync_ui_cookie()` try/except defaults to `streamlit`; no crash |
| Cookie/user.yaml conflict | Cookie wins — `sync_ui_cookie()` writes user.yaml to match cookie if present |
| Tier downgrade with vue cookie | `sync_ui_cookie()` detects `not can_use("vue_ui_beta")` → clears cookie + resets user.yaml |
| Demo toolbar in non-demo mode | `render_demo_toolbar()` only called when `DEMO_MODE=true`; `prgn_demo_tier` ignored by `can_use()` outside demo |
| `can_use()` called from background thread | `demo_tier` param defaults to `None`; `DEMO_MODE_FLAG` is env-only — no `st.session_state` access in the function body; thread-safe |
| First demo load (no cookie yet) | `st.session_state.simulated_tier` initialized to `"paid"` if `prgn_demo_tier` cookie absent |
---
## Testing
- **Unit**: `sync_ui_cookie()` with all three conflict cases; `can_use("vue_ui_beta")` for free/paid/premium/demo tiers; `set_simulated_tier()` state transitions; `can_use()` called with `demo_tier=` from a non-Streamlit context (no `RuntimeError`)
- **Integration**: Caddy routing with mocked cookie headers (both directions); 502 fallback redirect + cookie clear chain
- **E2E**: Streamlit→Vue switch → verify served from Vue SPA port; Vue→Streamlit → verify Streamlit port; demo tier pill → verify feature gate state changes; cookie persistence across Streamlit restart; fresh clone `./manage.sh start` builds and serves Vue SPA correctly
---
## Out of Scope
- Vue SPA feature parity with Streamlit (tracked under issue #8)
- Removing the Streamlit UI (v1 GA milestone)
- `old.peregrine.circuitforge.tech` subdomain alias (not needed — cookie approach is sufficient)
- Authoritative Vue-side feature gating via `/api/features` endpoint (post-parity, issue #8)
- Fine-tuned model or integrations gating in the Vue SPA (future work)