diff --git a/app/components/ui_switcher.py b/app/components/ui_switcher.py
new file mode 100644
index 0000000..4cfcef6
--- /dev/null
+++ b/app/components/ui_switcher.py
@@ -0,0 +1,167 @@
+"""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)
diff --git a/tests/test_ui_switcher.py b/tests/test_ui_switcher.py
new file mode 100644
index 0000000..2eb0ad1
--- /dev/null
+++ b/tests/test_ui_switcher.py
@@ -0,0 +1,106 @@
+"""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)