# UI Switcher Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add a Reddit-style UI switcher letting paid-tier users opt into the Vue 3 SPA, plus a demo tier toolbar for exploring feature tiers without a real license.
**Architecture:** A `prgn_ui` cookie acts as Caddy's routing signal — `vue` routes to a new nginx Docker service serving the Vue SPA, absent/`streamlit` routes to Streamlit. `user.yaml` persists the preference across browser clears. The Vue SPA switches back via a `?prgn_switch=streamlit` query param (Streamlit can't read HTTP cookies server-side; the param is the bridge). The demo toolbar uses the same cookie-injection pattern to simulate tiers via `st.session_state.simulated_tier`.
**Tech Stack:** Python 3.11, Streamlit, `st.components.v1.html()` for JS cookie injection, Vue 3 + Vite, nginx:alpine, Docker Compose, Caddy
**Spec:** `docs/superpowers/specs/2026-03-22-ui-switcher-design.md`
> **Implementation note — switch-back mechanism:** The spec's Vue→Streamlit flow assumed Streamlit could read the `prgn_ui` cookie server-side to detect the switch and update `user.yaml`. Streamlit cannot read HTTP cookies from Python. This plan uses `?prgn_switch=streamlit` as a query param bridge instead: `ClassicUIButton.vue` sets the cookie AND appends the param; `sync_ui_cookie()` reads `st.query_params` to detect it and update `user.yaml`. This supersedes the "cookie wins" description in spec §3/§4.
**Test command:** `/devl/miniconda3/envs/job-seeker/bin/pytest tests/ -v`
**Vue test command:** `cd web && npm run test`
---
## File Map
| File | Action | Responsibility |
|---|---|---|
| `app/wizard/tiers.py` | Modify | Add `vue_ui_beta` feature key; add `demo_tier` kwarg to `can_use()` |
| `tests/test_wizard_tiers.py` | Modify | Tests for new feature key and demo_tier behaviour |
| `scripts/user_profile.py` | Modify | Add `ui_preference` field (default: `"streamlit"`) |
| `tests/test_user_profile.py` | Modify | Tests for `ui_preference` round-trip |
| `app/components/ui_switcher.py` | Create | `sync_ui_cookie`, `switch_ui`, `render_banner`, `render_settings_toggle` |
| `tests/test_ui_switcher.py` | Create | Unit tests for switcher logic (mocked st + UserProfile) |
| `app/components/demo_toolbar.py` | Create | `render_demo_toolbar`, `set_simulated_tier` |
| `tests/test_demo_toolbar.py` | Create | Unit tests for toolbar logic |
| `app/app.py` | Modify | Wire in `sync_ui_cookie`, `render_demo_toolbar`, `render_banner` |
| `app/pages/2_Settings.py` | Modify | Add `render_settings_toggle` in Deployment expander |
| `web/src/components/ClassicUIButton.vue` | Create | Switch-back button (sets cookie + appends `?prgn_switch=streamlit`) |
| `web/src/composables/useFeatureFlag.ts` | Create | Demo-only: reads `prgn_demo_tier` cookie for display |
| `web/src/components/AppNav.vue` | Modify | Mount `ClassicUIButton` in nav |
| `docker/web/Dockerfile` | Create | Multi-stage: node build → nginx:alpine serve |
| `docker/web/nginx.conf` | Create | SPA-aware nginx config with `try_files` fallback |
| `compose.yml` | Modify | Add `web` service (port 8506) |
| `compose.demo.yml` | Modify | Add `web` service (port 8507) |
| `compose.cloud.yml` | Modify | Add `web` service (port 8508) |
| `manage.sh` | Modify | Include `web` in `build` target |
| `/devl/caddy-proxy/Caddyfile` | Modify | Add `prgn_ui` cookie matchers for both peregrine vhosts |
---
## Task 1: Extend `tiers.py` — add `vue_ui_beta` and `demo_tier`
**Files:**
- Modify: `app/wizard/tiers.py:50` (FEATURES dict), `app/wizard/tiers.py:104` (can_use signature)
- Modify: `tests/test_wizard_tiers.py`
- [ ] **Step 1.1: Write failing tests**
Add to `tests/test_wizard_tiers.py`:
```python
def test_vue_ui_beta_free_tier():
assert can_use("free", "vue_ui_beta") is False
def test_vue_ui_beta_paid_tier():
assert can_use("paid", "vue_ui_beta") is True
def test_vue_ui_beta_premium_tier():
assert can_use("premium", "vue_ui_beta") is True
def test_can_use_demo_tier_overrides_real_tier():
# demo_tier kwarg substitutes for the real tier when provided
assert can_use("free", "company_research", demo_tier="paid") is True
def test_can_use_demo_tier_free_restricts():
assert can_use("paid", "model_fine_tuning", demo_tier="free") is False
def test_can_use_demo_tier_none_falls_back_to_real():
# demo_tier=None means no override — real tier is used
assert can_use("paid", "company_research", demo_tier=None) is True
def test_can_use_demo_tier_does_not_affect_non_demo():
# demo_tier is only applied when DEMO_MODE_FLAG is set;
# in tests DEMO_MODE_FLAG is False by default, so demo_tier is ignored
# (this tests thread-safety: no st.session_state access inside can_use)
import os
os.environ.pop("DEMO_MODE", None)
assert can_use("free", "company_research", demo_tier="paid") is False
```
- [ ] **Step 1.2: Run to confirm failures**
```bash
/devl/miniconda3/envs/job-seeker/bin/pytest tests/test_wizard_tiers.py -v -k "vue_ui_beta or demo_tier"
```
Expected: 7 failures (`can_use` doesn't accept `demo_tier` yet, `vue_ui_beta` not in FEATURES)
- [ ] **Step 1.3: Implement changes in `tiers.py`**
Add to `FEATURES` dict (after the existing entries):
```python
# Beta UI access — stays gated (access management, not compute)
"vue_ui_beta": "paid",
```
Add module-level constant after the `BYOK_UNLOCKABLE` block:
```python
import os as _os
_DEMO_MODE = _os.environ.get("DEMO_MODE", "").lower() in ("1", "true", "yes")
```
Update `can_use()` signature (preserve existing positional order, add keyword-only arg):
```python
def can_use(
tier: str,
feature: str,
has_byok: bool = False,
*,
demo_tier: str | None = None,
) -> bool:
"""Return True if the given tier has access to the feature.
has_byok: pass has_configured_llm() to unlock BYOK_UNLOCKABLE features.
demo_tier: when set AND _DEMO_MODE is True, substitutes for `tier`.
Read from st.session_state by the *caller*, not here — keeps
this function thread-safe for background tasks and tests.
"""
effective_tier = demo_tier if (demo_tier is not None and _DEMO_MODE) else tier
required = FEATURES.get(feature)
if required is None:
return True
if has_byok and feature in BYOK_UNLOCKABLE:
return True
try:
return TIERS.index(effective_tier) >= TIERS.index(required)
except ValueError:
return False
```
- [ ] **Step 1.4: Run tests — expect all pass**
```bash
/devl/miniconda3/envs/job-seeker/bin/pytest tests/test_wizard_tiers.py -v
```
Expected: all existing tests still pass + 7 new tests pass (the `demo_tier` env test is context-sensitive — if DEMO_MODE is unset, `demo_tier` override is skipped)
- [ ] **Step 1.5: Commit**
```bash
git add app/wizard/tiers.py tests/test_wizard_tiers.py
git commit -m "feat(tiers): add vue_ui_beta feature key and demo_tier kwarg to can_use"
```
---
## Task 2: Extend `user_profile.py` — add `ui_preference`
**Files:**
- Modify: `scripts/user_profile.py` (lines ~12–80)
- Modify: `tests/test_user_profile.py`
- Modify: `config/user.yaml.example`
- [ ] **Step 2.1: Write failing tests**
Add to `tests/test_user_profile.py`:
```python
def test_ui_preference_default(tmp_path):
"""Fresh profile defaults to streamlit."""
p = tmp_path / "user.yaml"
p.write_text("name: Test User\n")
profile = UserProfile(p)
assert profile.ui_preference == "streamlit"
def test_ui_preference_vue(tmp_path):
"""Saved vue preference loads correctly."""
p = tmp_path / "user.yaml"
p.write_text("name: Test\nui_preference: vue\n")
profile = UserProfile(p)
assert profile.ui_preference == "vue"
def test_ui_preference_roundtrip(tmp_path):
"""Saving ui_preference: vue persists and reloads."""
p = tmp_path / "user.yaml"
p.write_text("name: Test\n")
profile = UserProfile(p)
profile.ui_preference = "vue"
profile.save()
reloaded = UserProfile(p)
assert reloaded.ui_preference == "vue"
def test_ui_preference_invalid_falls_back(tmp_path):
"""Unknown value falls back to streamlit."""
p = tmp_path / "user.yaml"
p.write_text("name: Test\nui_preference: newui\n")
profile = UserProfile(p)
assert profile.ui_preference == "streamlit"
```
- [ ] **Step 2.2: Run to confirm failures**
```bash
/devl/miniconda3/envs/job-seeker/bin/pytest tests/test_user_profile.py -v -k "ui_preference"
```
Expected: 4 failures (`UserProfile` has no `ui_preference` attribute)
- [ ] **Step 2.3: Implement in `user_profile.py`**
In `_DEFAULTS` dict, add:
```python
"ui_preference": "streamlit",
```
In `UserProfile.__init__()`, after the `dismissed_banners` line:
```python
raw_pref = data.get("ui_preference", "streamlit")
self.ui_preference: str = raw_pref if raw_pref in ("streamlit", "vue") else "streamlit"
```
In `UserProfile.save()` (or wherever other fields are serialised to yaml), add `ui_preference` to the output dict:
```python
"ui_preference": self.ui_preference,
```
- [ ] **Step 2.4: Run tests**
```bash
/devl/miniconda3/envs/job-seeker/bin/pytest tests/test_user_profile.py -v
```
Expected: all pass
- [ ] **Step 2.5: Update `config/user.yaml.example`**
Add after existing fields:
```yaml
# UI preference — "streamlit" (default) or "vue" (Beta: Paid tier)
ui_preference: streamlit
```
- [ ] **Step 2.6: Commit**
```bash
git add scripts/user_profile.py tests/test_user_profile.py config/user.yaml.example
git commit -m "feat(profile): add ui_preference field (streamlit|vue, default: streamlit)"
```
---
## Task 3: Create `app/components/ui_switcher.py`
**Files:**
- Create: `app/components/ui_switcher.py`
- Create: `tests/test_ui_switcher.py`
**Key implementation note:** Streamlit cannot read HTTP cookies from Python — only JavaScript running in the browser can. The `sync_ui_cookie()` function injects JS that sets the cookie. For the Vue→Streamlit switch-back, the Vue SPA appends `?prgn_switch=streamlit` to the redirect URL; `sync_ui_cookie()` detects this param via `st.query_params` and treats it as an override signal.
- [ ] **Step 3.1: Write failing tests**
Create `tests/test_ui_switcher.py`:
```python
"""Tests for app/components/ui_switcher.py.
Streamlit is not running during tests — mock all st.* calls.
"""
import sys
from pathlib import Path
import pytest
import yaml
sys.path.insert(0, str(Path(__file__).parent.parent))
@pytest.fixture
def profile_yaml(tmp_path):
data = {"name": "Test", "ui_preference": "streamlit", "wizard_complete": True}
p = tmp_path / "user.yaml"
p.write_text(yaml.dump(data))
return p
def test_sync_cookie_injects_vue_js(profile_yaml, monkeypatch):
"""When ui_preference is vue, JS sets prgn_ui=vue."""
import yaml as _yaml
profile_yaml.write_text(_yaml.dump({"name": "T", "ui_preference": "vue"}))
injected = []
monkeypatch.setattr("streamlit.components.v1.html", lambda html, height=0: injected.append(html))
monkeypatch.setattr("streamlit.query_params", {}, raising=False)
from app.components.ui_switcher import sync_ui_cookie
sync_ui_cookie(profile_yaml, tier="paid")
assert any("prgn_ui=vue" in s for s in injected)
def test_sync_cookie_injects_streamlit_js(profile_yaml, monkeypatch):
"""When ui_preference is streamlit, JS sets prgn_ui=streamlit."""
injected = []
monkeypatch.setattr("streamlit.components.v1.html", lambda html, height=0: injected.append(html))
monkeypatch.setattr("streamlit.query_params", {}, raising=False)
from app.components.ui_switcher import sync_ui_cookie
sync_ui_cookie(profile_yaml, tier="paid")
assert any("prgn_ui=streamlit" in s for s in injected)
def test_sync_cookie_prgn_switch_param_overrides_yaml(profile_yaml, monkeypatch):
"""?prgn_switch=streamlit in query params resets ui_preference to streamlit."""
import yaml as _yaml
profile_yaml.write_text(_yaml.dump({"name": "T", "ui_preference": "vue"}))
injected = []
monkeypatch.setattr("streamlit.components.v1.html", lambda html, height=0: injected.append(html))
monkeypatch.setattr("streamlit.query_params", {"prgn_switch": "streamlit"}, raising=False)
from importlib import reload
import app.components.ui_switcher as m
reload(m)
m.sync_ui_cookie(profile_yaml, tier="paid")
# user.yaml should now say streamlit
saved = _yaml.safe_load(profile_yaml.read_text())
assert saved["ui_preference"] == "streamlit"
# JS should set cookie to streamlit
assert any("prgn_ui=streamlit" in s for s in injected)
def test_sync_cookie_downgrades_tier_resets_to_streamlit(profile_yaml, monkeypatch):
"""Free-tier user with vue preference gets reset to streamlit."""
import yaml as _yaml
profile_yaml.write_text(_yaml.dump({"name": "T", "ui_preference": "vue"}))
injected = []
monkeypatch.setattr("streamlit.components.v1.html", lambda html, height=0: injected.append(html))
monkeypatch.setattr("streamlit.query_params", {}, raising=False)
from importlib import reload
import app.components.ui_switcher as m
reload(m)
m.sync_ui_cookie(profile_yaml, tier="free")
saved = _yaml.safe_load(profile_yaml.read_text())
assert saved["ui_preference"] == "streamlit"
assert any("prgn_ui=streamlit" in s for s in injected)
def test_switch_ui_writes_yaml_and_calls_sync(profile_yaml, monkeypatch):
"""switch_ui(to='vue') writes user.yaml and calls sync."""
import yaml as _yaml
synced = []
monkeypatch.setattr("streamlit.components.v1.html", lambda html, height=0: synced.append(html))
monkeypatch.setattr("streamlit.query_params", {}, raising=False)
monkeypatch.setattr("streamlit.rerun", lambda: None)
from importlib import reload
import app.components.ui_switcher as m
reload(m)
m.switch_ui(profile_yaml, to="vue", tier="paid")
saved = _yaml.safe_load(profile_yaml.read_text())
assert saved["ui_preference"] == "vue"
assert any("prgn_ui=vue" in s for s in synced)
```
- [ ] **Step 3.2: Run to confirm failures**
```bash
/devl/miniconda3/envs/job-seeker/bin/pytest tests/test_ui_switcher.py -v
```
Expected: ImportError — module doesn't exist yet
- [ ] **Step 3.3: Create `app/components/ui_switcher.py`**
```python
"""UI switcher component for Peregrine.
Manages the prgn_ui cookie (Caddy routing signal) and user.yaml
ui_preference (durability across browser clears).
Cookie mechanics
----------------
Streamlit cannot read HTTP cookies server-side. Instead:
- sync_ui_cookie() injects a JS snippet that sets document.cookie.
- Vue SPA switch-back appends ?prgn_switch=streamlit to the redirect URL.
sync_ui_cookie() reads this param via st.query_params and uses it as
an override signal, then writes user.yaml to match.
Call sync_ui_cookie() in the app.py render pass (after pg.run()).
"""
from __future__ import annotations
import os
from pathlib import Path
import streamlit as st
import streamlit.components.v1 as components
from scripts.user_profile import UserProfile
from app.wizard.tiers import can_use
_DEMO_MODE = os.environ.get("DEMO_MODE", "").lower() in ("1", "true", "yes")
_COOKIE_JS = """
"""
def _set_cookie_js(value: str) -> None:
components.html(_COOKIE_JS.format(value=value), height=0)
def sync_ui_cookie(yaml_path: Path, tier: str) -> None:
"""Sync the prgn_ui cookie to match user.yaml ui_preference.
Also handles:
- ?prgn_switch= param (Vue SPA switch-back signal): overrides yaml,
writes yaml to match, clears the param.
- Tier downgrade: resets vue preference to streamlit for ineligible users.
- ?ui_fallback=1 param: shows a toast (Vue SPA was unreachable).
"""
# ── ?ui_fallback=1 — Vue SPA was down, Caddy bounced us back ──────────────
if st.query_params.get("ui_fallback"):
st.toast("⚠️ New UI temporarily unavailable — switched back to Classic", icon="⚠️")
st.query_params.pop("ui_fallback", None)
# ── ?prgn_switch param — Vue SPA sent us here to switch back ──────────────
switch_param = st.query_params.get("prgn_switch")
if switch_param in ("streamlit", "vue"):
try:
profile = UserProfile(yaml_path)
profile.ui_preference = switch_param
profile.save()
except Exception:
pass
st.query_params.pop("prgn_switch", None)
_set_cookie_js(switch_param)
return
# ── Normal path: read yaml, enforce tier, inject cookie ───────────────────
try:
profile = UserProfile(yaml_path)
pref = profile.ui_preference
except Exception:
pref = "streamlit"
# Tier downgrade protection (skip in demo — demo bypasses tier gate)
if pref == "vue" and not _DEMO_MODE and not can_use(tier, "vue_ui_beta"):
try:
profile = UserProfile(yaml_path)
profile.ui_preference = "streamlit"
profile.save()
except Exception:
pass
pref = "streamlit"
_set_cookie_js(pref)
def switch_ui(yaml_path: Path, to: str, tier: str) -> None:
"""Write user.yaml, sync cookie, rerun.
to: "vue" | "streamlit"
"""
if to not in ("vue", "streamlit"):
return
try:
profile = UserProfile(yaml_path)
profile.ui_preference = to
profile.save()
except Exception:
pass
sync_ui_cookie(yaml_path, tier=tier)
st.rerun()
def render_banner(yaml_path: Path, tier: str) -> None:
"""Show the 'Try the new UI' banner once per session.
Dismissed flag stored in user.yaml dismissed_banners list so it
persists across sessions (uses the existing dismissed_banners pattern).
Eligible: paid+ tier, OR demo mode. Not shown if already on vue.
"""
eligible = _DEMO_MODE or can_use(tier, "vue_ui_beta")
if not eligible:
return
try:
profile = UserProfile(yaml_path)
except Exception:
return
if profile.ui_preference == "vue":
return
if "ui_switcher_beta" in (profile.dismissed_banners or []):
return
col1, col2, col3 = st.columns([8, 1, 1])
with col1:
st.info("✨ **New Peregrine UI available** — try the modern Vue interface (Beta, Paid tier)")
with col2:
if st.button("Try it", key="_ui_banner_try"):
switch_ui(yaml_path, to="vue", tier=tier)
with col3:
if st.button("Dismiss", key="_ui_banner_dismiss"):
profile.dismissed_banners = list(profile.dismissed_banners or []) + ["ui_switcher_beta"]
profile.save()
st.rerun()
def render_settings_toggle(yaml_path: Path, tier: str) -> None:
"""Toggle in Settings → System → Deployment expander."""
eligible = _DEMO_MODE or can_use(tier, "vue_ui_beta")
if not eligible:
return
try:
profile = UserProfile(yaml_path)
current = profile.ui_preference
except Exception:
current = "streamlit"
options = ["streamlit", "vue"]
labels = ["Classic (Streamlit)", "✨ New UI (Vue, Beta)"]
current_idx = options.index(current) if current in options else 0
st.markdown("**UI Version**")
chosen = st.radio(
"UI Version",
options=labels,
index=current_idx,
key="_ui_toggle_radio",
label_visibility="collapsed",
)
chosen_val = options[labels.index(chosen)]
if chosen_val != current:
switch_ui(yaml_path, to=chosen_val, tier=tier)
```
- [ ] **Step 3.4: Run tests**
```bash
/devl/miniconda3/envs/job-seeker/bin/pytest tests/test_ui_switcher.py -v
```
Expected: all pass
- [ ] **Step 3.5: Commit**
```bash
git add app/components/ui_switcher.py tests/test_ui_switcher.py
git commit -m "feat(ui-switcher): add ui_switcher component (sync_ui_cookie, switch_ui, render_banner, render_settings_toggle)"
```
---
## Task 4: Create `app/components/demo_toolbar.py`
**Files:**
- Create: `app/components/demo_toolbar.py`
- Create: `tests/test_demo_toolbar.py`
- [ ] **Step 4.1: Write failing tests**
Create `tests/test_demo_toolbar.py`:
```python
"""Tests for app/components/demo_toolbar.py."""
import sys, os
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).parent.parent))
# Ensure DEMO_MODE is set so the module initialises correctly
os.environ["DEMO_MODE"] = "true"
def test_set_simulated_tier_updates_session_state(monkeypatch):
"""set_simulated_tier writes to st.session_state.simulated_tier."""
session = {}
injected = []
monkeypatch.setattr("streamlit.components.v1.html", lambda h, height=0: injected.append(h))
monkeypatch.setattr("streamlit.session_state", session, raising=False)
monkeypatch.setattr("streamlit.rerun", lambda: None)
from importlib import reload
import app.components.demo_toolbar as m
reload(m)
m.set_simulated_tier("premium")
assert session.get("simulated_tier") == "premium"
assert any("prgn_demo_tier=premium" in h for h in injected)
def test_set_simulated_tier_invalid_ignored(monkeypatch):
"""Invalid tier strings are rejected."""
session = {}
monkeypatch.setattr("streamlit.components.v1.html", lambda h, height=0: None)
monkeypatch.setattr("streamlit.session_state", session, raising=False)
monkeypatch.setattr("streamlit.rerun", lambda: None)
from importlib import reload
import app.components.demo_toolbar as m
reload(m)
m.set_simulated_tier("ultramax")
assert "simulated_tier" not in session
def test_get_simulated_tier_defaults_to_paid(monkeypatch):
"""Returns 'paid' when no tier is set yet."""
monkeypatch.setattr("streamlit.session_state", {}, raising=False)
monkeypatch.setattr("streamlit.query_params", {}, raising=False)
from importlib import reload
import app.components.demo_toolbar as m
reload(m)
assert m.get_simulated_tier() == "paid"
def test_get_simulated_tier_reads_session(monkeypatch):
"""Returns tier from st.session_state when set."""
monkeypatch.setattr("streamlit.session_state", {"simulated_tier": "free"}, raising=False)
monkeypatch.setattr("streamlit.query_params", {}, raising=False)
from importlib import reload
import app.components.demo_toolbar as m
reload(m)
assert m.get_simulated_tier() == "free"
```
- [ ] **Step 4.2: Run to confirm failures**
```bash
/devl/miniconda3/envs/job-seeker/bin/pytest tests/test_demo_toolbar.py -v
```
Expected: ImportError — module doesn't exist yet
- [ ] **Step 4.3: Create `app/components/demo_toolbar.py`**
```python
"""Demo toolbar — tier simulation for DEMO_MODE instances.
Renders a slim full-width bar above the Streamlit nav showing
Free / Paid / Premium pills. Clicking a pill sets a prgn_demo_tier
cookie (for persistence across reloads) and st.session_state.simulated_tier
(for immediate use within the current render pass).
Only ever rendered when DEMO_MODE=true.
"""
from __future__ import annotations
import os
import streamlit as st
import streamlit.components.v1 as components
_VALID_TIERS = ("free", "paid", "premium")
_DEFAULT_TIER = "paid" # most compelling first impression
_COOKIE_JS = """
"""
def get_simulated_tier() -> str:
"""Return the current simulated tier, defaulting to 'paid'."""
return st.session_state.get("simulated_tier", _DEFAULT_TIER)
def set_simulated_tier(tier: str) -> None:
"""Set simulated tier in session state + cookie. Reruns the page."""
if tier not in _VALID_TIERS:
return
st.session_state["simulated_tier"] = tier
components.html(_COOKIE_JS.format(tier=tier), height=0)
st.rerun()
def render_demo_toolbar() -> None:
"""Render the demo mode toolbar.
Shows a dismissible info bar with tier-selection pills.
Call this at the TOP of app.py's render pass, before pg.run().
"""
current = get_simulated_tier()
labels = {
"free": "Free",
"paid": "Paid ✓" if current == "paid" else "Paid",
"premium": "Premium ✓" if current == "premium" else "Premium",
}
with st.container():
cols = st.columns([3, 1, 1, 1, 2])
with cols[0]:
st.caption("🎭 **Demo mode** — exploring as:")
for i, tier in enumerate(_VALID_TIERS):
with cols[i + 1]:
is_active = tier == current
if st.button(
labels[tier],
key=f"_demo_tier_{tier}",
type="primary" if is_active else "secondary",
use_container_width=True,
):
if not is_active:
set_simulated_tier(tier)
with cols[4]:
st.caption("[Get your own →](https://circuitforge.tech/software/peregrine)")
st.divider()
```
- [ ] **Step 4.4: Run tests**
```bash
/devl/miniconda3/envs/job-seeker/bin/pytest tests/test_demo_toolbar.py -v
```
Expected: all pass
- [ ] **Step 4.5: Commit**
```bash
git add app/components/demo_toolbar.py tests/test_demo_toolbar.py
git commit -m "feat(demo): add demo_toolbar component (tier simulation for DEMO_MODE)"
```
---
## Task 5: Wire components into `app/app.py` and Settings
**Files:**
- Modify: `app/app.py`
- Modify: `app/pages/2_Settings.py:997–1042`
- [ ] **Step 5.1: Wire `sync_ui_cookie` and banners into `app.py`**
Find the block after `pg.run()` in `app/app.py` (currently ends around line 175). Add imports near the top of `app.py` after existing imports:
```python
from app.components.ui_switcher import sync_ui_cookie, render_banner
```
After `_startup()` and the wizard gate block, before `pg = st.navigation(pages)`, add:
```python
# ── Demo toolbar ───────────────────────────────────────────────────────────────
if IS_DEMO:
from app.components.demo_toolbar import render_demo_toolbar
render_demo_toolbar()
```
After `pg.run()`, add:
```python
# ── UI switcher cookie sync + banner ──────────────────────────────────────────
# Must run after pg.run() — st.components.v1.html requires an active render pass.
try:
_current_tier = _UserProfile(_USER_YAML).tier # UserProfile.tier reads user.yaml + dev_tier_override
except Exception:
_current_tier = "free"
if IS_DEMO:
from app.components.demo_toolbar import get_simulated_tier as _get_sim_tier
_current_tier = _get_sim_tier()
sync_ui_cookie(_USER_YAML, tier=_current_tier)
render_banner(_USER_YAML, tier=_current_tier)
```
- [ ] **Step 5.2: Wire `render_settings_toggle` into Settings**
In `app/pages/2_Settings.py`, find the `🖥️ Deployment / Server` expander (around line 997). At the end of that expander block (after the existing save button), add:
```python
# ── UI Version switcher (Paid tier / Demo) ────────────────────────────
st.markdown("---")
from app.components.ui_switcher import render_settings_toggle as _render_ui_toggle
_render_ui_toggle(_USER_YAML, tier=_tier)
```
Where `_tier` is however the Settings page resolves the current tier (check the existing pattern — typically `UserProfile(_USER_YAML).tier` or via the license module).
- [ ] **Step 5.3: Smoke test — start Peregrine and verify no crash**
```bash
conda run -n job-seeker python -c "
import sys; sys.path.insert(0, '.')
from app.components.ui_switcher import sync_ui_cookie, render_banner, render_settings_toggle
from app.components.demo_toolbar import render_demo_toolbar, get_simulated_tier, set_simulated_tier
print('imports OK')
"
```
Expected: `imports OK` (no ImportError or AttributeError)
- [ ] **Step 5.4: Run full test suite**
```bash
/devl/miniconda3/envs/job-seeker/bin/pytest tests/ -v --ignore=tests/e2e -x
```
Expected: all pass (no regressions)
- [ ] **Step 5.5: Commit**
```bash
git add app/app.py app/pages/2_Settings.py
git commit -m "feat(app): wire ui_switcher and demo_toolbar into render pass"
```
---
## Task 6: Merge Vue SPA + add `ClassicUIButton.vue` + `useFeatureFlag.ts`
**Files:**
- Merge `.worktrees/feature-vue-spa/web/` → `web/` in main branch
- Create: `web/src/components/ClassicUIButton.vue`
- Create: `web/src/composables/useFeatureFlag.ts`
- Modify: `web/src/components/AppNav.vue`
- [ ] **Step 6.1: Merge the Vue SPA worktree into main**
```bash
# From the peregrine repo root
git merge feature-vue-spa --no-ff -m "feat(web): merge Vue SPA from feature-vue-spa"
```
If the worktree was never committed as a branch and only exists as a local worktree checkout:
```bash
# Check if feature-vue-spa is a branch
git branch | grep feature-vue-spa
# If it exists, merge it
git merge feature-vue-spa --no-ff -m "feat(web): merge Vue SPA from feature-vue-spa"
```
After merge, confirm `web/` directory is present in the repo root:
```bash
ls web/src/components/ web/src/views/
```
Expected: `AppNav.vue`, `JobCard.vue`, views etc.
- [ ] **Step 6.2: Write failing Vitest test for `ClassicUIButton`**
Create `web/src/components/__tests__/ClassicUIButton.test.ts`:
```typescript
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import ClassicUIButton from '../ClassicUIButton.vue'
describe('ClassicUIButton', () => {
beforeEach(() => {
// Reset cookie and location mock
Object.defineProperty(document, 'cookie', {
writable: true,
value: 'prgn_ui=vue',
})
delete (window as any).location
;(window as any).location = { reload: vi.fn(), href: '' }
})
it('renders a button', () => {
const wrapper = mount(ClassicUIButton)
expect(wrapper.find('button').exists()).toBe(true)
})
it('sets prgn_ui=streamlit cookie and appends prgn_switch param on click', async () => {
const wrapper = mount(ClassicUIButton)
await wrapper.find('button').trigger('click')
expect(document.cookie).toContain('prgn_ui=streamlit')
expect((window.location as any).href).toContain('prgn_switch=streamlit')
})
})
```
- [ ] **Step 6.3: Run test to confirm failure**
```bash
cd /Library/Development/CircuitForge/peregrine/web && npm run test -- --reporter=verbose ClassicUIButton
```
Expected: component file not found
- [ ] **Step 6.4: Create `web/src/components/ClassicUIButton.vue`**
```vue
```
- [ ] **Step 6.5: Create `web/src/composables/useFeatureFlag.ts`**
```typescript
/**
* useFeatureFlag — demo-mode tier display only.
*
* Reads the prgn_demo_tier cookie set by Streamlit's demo toolbar.
* NOT an authoritative feature gate — for display/visual consistency only.
* Real feature gating in the Vue SPA will use /api/features (future, issue #8).
*/
const TIERS = ['free', 'paid', 'premium'] as const
type Tier = typeof TIERS[number]
const TIER_RANKS: Record = { free: 0, paid: 1, premium: 2 }
function getDemoTier(): Tier {
const match = document.cookie.match(/prgn_demo_tier=([^;]+)/)
const raw = match?.[1] ?? 'paid'
return (TIERS as readonly string[]).includes(raw) ? (raw as Tier) : 'paid'
}
export function useFeatureFlag() {
const demoTier = getDemoTier()
const demoTierRank = TIER_RANKS[demoTier]
function canUseInDemo(requiredTier: Tier): boolean {
return demoTierRank >= TIER_RANKS[requiredTier]
}
return { demoTier, canUseInDemo }
}
```
- [ ] **Step 6.6: Mount `ClassicUIButton` in `AppNav.vue`**
In `web/src/components/AppNav.vue`, import and mount the button in the nav bar. Find the nav template and add:
```vue
```
Exact placement: alongside the existing nav controls (check `AppNav.vue` for the current structure and place it in a consistent spot, e.g. right side of the nav bar).
- [ ] **Step 6.7: Run all Vue tests**
```bash
cd /Library/Development/CircuitForge/peregrine/web && npm run test
```
Expected: all pass including new ClassicUIButton tests
- [ ] **Step 6.8: Commit**
```bash
cd /Library/Development/CircuitForge/peregrine
git add web/src/components/ClassicUIButton.vue \
web/src/components/__tests__/ClassicUIButton.test.ts \
web/src/composables/useFeatureFlag.ts \
web/src/components/AppNav.vue
git commit -m "feat(web): add ClassicUIButton and useFeatureFlag composable"
```
---
## Task 7: Docker `web` service
**Files:**
- Create: `docker/web/Dockerfile`
- Create: `docker/web/nginx.conf`
- Modify: `compose.yml`, `compose.demo.yml`, `compose.cloud.yml`
- [ ] **Step 7.1: Create `docker/web/nginx.conf`**
```nginx
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
# SPA fallback — all unknown paths serve index.html for Vue Router
location / {
try_files $uri $uri/ /index.html;
}
# Cache-bust JS/CSS assets (Vite hashes filenames)
location ~* \.(js|css|woff2?|ttf|eot|svg|png|jpg|jpeg|gif|ico)$ {
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
}
# Health check endpoint for Docker/Caddy
location /healthz {
return 200 "ok";
add_header Content-Type text/plain;
}
}
```
- [ ] **Step 7.2: Create `docker/web/Dockerfile`**
```dockerfile
# Stage 1: Build Vue SPA
FROM node:20-alpine AS builder
WORKDIR /build
COPY web/package*.json ./
RUN npm ci --prefer-offline
COPY web/ ./
RUN npm run build
# Stage 2: Serve with nginx
FROM nginx:alpine
COPY docker/web/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=builder /build/dist /usr/share/nginx/html
EXPOSE 80
HEALTHCHECK --interval=30s --timeout=5s \
CMD wget -qO- http://localhost/healthz || exit 1
```
- [ ] **Step 7.3: Add `web` service to `compose.yml`**
Add after the last service in `compose.yml`:
```yaml
web:
build:
context: .
dockerfile: docker/web/Dockerfile
ports:
- "8506:80"
restart: unless-stopped
```
- [ ] **Step 7.4: Add `web` service to `compose.demo.yml`**
```yaml
web:
build:
context: .
dockerfile: docker/web/Dockerfile
ports:
- "8507:80"
restart: unless-stopped
```
- [ ] **Step 7.5: Add `web` service to `compose.cloud.yml`**
```yaml
web:
build:
context: .
dockerfile: docker/web/Dockerfile
ports:
- "8508:80"
restart: unless-stopped
```
- [ ] **Step 7.6: Update `manage.sh` to build web service**
Find the `update` case in `manage.sh` (around line 138):
```bash
$COMPOSE build app
```
Change to:
```bash
$COMPOSE build app web
```
Also find anywhere `docker compose build` is called without specifying services and ensure `web` is included. Add a note to the help text listing `web` as one of the built services.
- [ ] **Step 7.7: Build and verify**
```bash
cd /Library/Development/CircuitForge/peregrine
docker compose build web 2>&1 | tail -20
```
Expected: `Successfully built` with no errors. The build will run `npm ci` + `vite build` inside the container.
```bash
docker compose up -d web
curl -s http://localhost:8506/healthz
```
Expected: `ok`
```bash
curl -s http://localhost:8506/ | head -5
```
Expected: HTML starting with `` (the Vue SPA index)
- [ ] **Step 7.8: Commit**
```bash
git add docker/web/Dockerfile docker/web/nginx.conf \
compose.yml compose.demo.yml compose.cloud.yml manage.sh
git commit -m "feat(docker): add web service for Vue SPA (nginx:alpine, ports 8506/8507/8508)"
```
---
## Task 8: Caddy routing
**Files:**
- Modify: `/devl/caddy-proxy/Caddyfile`
⚠️ **Caddy GOTCHA:** The Edit tool replaces files with a new inode. After editing, run `docker restart caddy-proxy` (not `caddy reload`).
- [ ] **Step 8.1: Update `menagerie.circuitforge.tech` peregrine block**
Find the existing block in the Caddyfile:
```
handle /peregrine* {
@no_session not header Cookie *cf_session*
redir @no_session https://circuitforge.tech/login?next={uri} 302
reverse_proxy http://host.docker.internal:8505 {
```
Replace with:
```
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
}
}
```
Also add a `handle_errors` block within the `menagerie.circuitforge.tech` vhost (outside the `/peregrine*` handle, at vhost level):
```
handle_errors 502 {
@vue_err {
header Cookie *prgn_ui=vue*
path /peregrine*
}
handle @vue_err {
header Set-Cookie "prgn_ui=streamlit; Path=/; SameSite=Lax"
redir * /peregrine?ui_fallback=1 302
}
}
```
- [ ] **Step 8.2: Update `demo.circuitforge.tech` peregrine block**
Find:
```
handle /peregrine* {
reverse_proxy http://host.docker.internal:8504
}
```
Replace with:
```
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
}
}
```
Add error handling within the `demo.circuitforge.tech` vhost:
```
handle_errors 502 {
@vue_err {
header Cookie *prgn_ui=vue*
path /peregrine*
}
handle @vue_err {
header Set-Cookie "prgn_ui=streamlit; Path=/; SameSite=Lax"
redir * /peregrine?ui_fallback=1 302
}
}
```
- [ ] **Step 8.3: Restart Caddy**
```bash
docker restart caddy-proxy
```
Wait 5 seconds, then verify Caddy is healthy:
```bash
docker logs caddy-proxy --tail=20
```
Expected: no `ERROR` lines, Caddy reports it is serving.
- [ ] **Step 8.4: Smoke test routing**
Test the cookie routing locally by simulating the cookie header:
```bash
# Without cookie — should hit Streamlit (8505 / 8504)
curl -s -o /dev/null -w "%{http_code}" https://menagerie.circuitforge.tech/peregrine
# With vue cookie — should hit Vue SPA (8508)
curl -s -o /dev/null -w "%{http_code}" \
-H "Cookie: prgn_ui=vue; cf_session=test" \
https://menagerie.circuitforge.tech/peregrine
```
Both should return 200 (or redirect codes if session auth kicks in — that's expected).
- [ ] **Step 8.5: Commit Caddyfile**
```bash
git -C /devl/caddy-proxy add Caddyfile
git -C /devl/caddy-proxy commit -m "feat(caddy): add prgn_ui cookie routing for peregrine Vue SPA"
```
---
## Task 9: Integration smoke test
- [ ] **Step 9.1: Full Python test suite**
```bash
/devl/miniconda3/envs/job-seeker/bin/pytest tests/ -v --ignore=tests/e2e -x
```
Expected: all pass
- [ ] **Step 9.2: Docker stack smoke test**
```bash
cd /Library/Development/CircuitForge/peregrine
./manage.sh start
sleep 10
curl -s http://localhost:8502 | grep -i "streamlit\|peregrine" | head -3
curl -s http://localhost:8506/healthz
```
Expected: Streamlit on 8502 responds, Vue SPA health check returns `ok`
- [ ] **Step 9.3: Manual switcher test (personal instance)**
1. Open http://localhost:8501 (or 8502)
2. Confirm the "Try it" banner appears (if on paid tier) or is absent (free tier)
3. Click "Try it" — confirm the page reloads and now serves from port 8506 (Vue SPA)
4. In the Vue SPA, click "← Classic UI" — confirm redirects back to Streamlit
5. Open Settings → System → Deployment → confirm the UI radio is present
6. Confirm `config/user.yaml` shows `ui_preference: vue` / `streamlit` after each switch
- [ ] **Step 9.4: Demo stack smoke test**
```bash
docker compose -f compose.demo.yml --project-name peregrine-demo up -d
sleep 10
curl -s http://localhost:8504 | head -5 # Streamlit demo
curl -s http://localhost:8507/healthz # Vue SPA demo
```
1. Open http://localhost:8504 (demo)
2. Confirm the demo toolbar appears with Free / Paid / Premium pills
3. Click "Free" — confirm gated features disappear
4. Click "Paid ✓" — confirm gated features reappear
5. Click "Try it" banner (should appear for all demo visitors)
6. Confirm routes to http://localhost:8507
- [ ] **Step 9.5: Final commit + tag**
```bash
cd /Library/Development/CircuitForge/peregrine
git tag v0.7.0-ui-switcher
git push origin main --tags
```
---
## Appendix: Checking `_tier` in `Settings.py`
Before wiring `render_settings_toggle`, check how `2_Settings.py` currently resolves the user's tier. Search for:
```bash
grep -n "tier\|can_use\|license" /Library/Development/CircuitForge/peregrine/app/pages/2_Settings.py | head -20
```
If the page already has a `_tier` or `_profile.tier` variable, use it directly. If not, use the same pattern as `app.py` (import `get_tier` from `scripts/license`).