12 KiB
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, copiesnginx.conf, copiesweb/dist/into/usr/share/nginx/html/docker/web/nginx.conf— standard SPA config withtry_files $uri /index.htmlfallback- Build step is image-baked (not a bind-mount):
docker compose build webrunsvite buildinweb/via a multi-stage Dockerfile, then copies the resultingdist/into the nginx image. This ensures a fresh clone +manage.sh startworks without a separate manual build step. manage.shupdated:buildtarget runsdocker compose build web appso 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 (afterpg.run()inapp.py), not inside the cached startup hook. Readsuser.yaml.ui_preference; injects JS to set/clearprgn_uicookie. Cookie/user.yaml conflict: cookie wins — ifprgn_uicookie is already present, writes user.yaml to match before re-injecting. IfDEMO_MODE, skips tier check. If notDEMO_MODEand notcan_use("vue_ui_beta"), resets preference tostreamlitand clears cookie.switch_ui(to: str)— writesuser.yaml.ui_preference, callssync_ui_cookie(), thenst.rerun().render_banner()— dismissible banner shown to eligible users whenui_switcher_betais not inuser_profile.dismissed_banners. On dismiss: appendsui_switcher_betatodismissed_banners, savesuser.yaml. On "Try it": callsswitch_ui("vue"). Also detects?ui_fallback=1inst.query_paramsand shows a toast ("New UI temporarily unavailable — switched back to Classic") then clears the param.render_settings_toggle()— toggle in Settings → System → Deployment expander. Callsswitch_ui()on change.
4. Vue SPA switch-back
New web/src/components/ClassicUIButton.vue:
function switchToClassic() {
document.cookie = 'prgn_ui=streamlit; path=/';
window.location.reload();
}
No backend call needed. On next Streamlit load, sync_ui_cookie() sees prgn_ui=streamlit, writes user.yaml to match.
5. Tier gate
app/wizard/tiers.py:
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:
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 ofapp.py's render pass whenDEMO_MODE=true. Shows🎭 Demo mode · Free · Paid · Premiumpills with the active tier highlighted.set_simulated_tier(tier: str)— injects JS to setprgn_demo_tiercookie, updatesst.session_state.simulated_tier, callsst.rerun().- Initialization: on each page load in demo mode,
app.pyreadsprgn_demo_tierfromst.query_paramsor the cookie (via a JS→hidden Streamlit input bridge, same pattern used by existing components) and setsst.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=/'
→ window.location.reload()
→ Caddy sees prgn_ui=streamlit → :8505/:8504 (Streamlit)
→ app.py render pass: sync_ui_cookie() sees cookie=streamlit
→ writes user.yaml: ui_preference: streamlit
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 withdemo_tier=from a non-Streamlit context (noRuntimeError) - 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 startbuilds 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.techsubdomain alias (not needed — cookie approach is sufficient)- Authoritative Vue-side feature gating via
/api/featuresendpoint (post-parity, issue #8) - Fine-tuned model or integrations gating in the Vue SPA (future work)