13 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=/; 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:
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=/'
→ 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 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)