# 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`).