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)