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

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-spamain 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/DockerfileFROM 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.

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:

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 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)
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)