From 06e9a9d1be2ae462252f02bc72a83531d3116b04 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Sun, 22 Mar 2026 14:30:43 -0700 Subject: [PATCH] docs: add UI switcher implementation plan; fix switch-back mechanism in spec --- .../plans/2026-03-22-ui-switcher.md | 1351 +++++++++++++++++ .../specs/2026-03-22-ui-switcher-design.md | 14 +- 2 files changed, 1360 insertions(+), 5 deletions(-) create mode 100644 docs/superpowers/plans/2026-03-22-ui-switcher.md diff --git a/docs/superpowers/plans/2026-03-22-ui-switcher.md b/docs/superpowers/plans/2026-03-22-ui-switcher.md new file mode 100644 index 0000000..496c577 --- /dev/null +++ b/docs/superpowers/plans/2026-03-22-ui-switcher.md @@ -0,0 +1,1351 @@ +# 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`). diff --git a/docs/superpowers/specs/2026-03-22-ui-switcher-design.md b/docs/superpowers/specs/2026-03-22-ui-switcher-design.md index 3ac450d..1afcd29 100644 --- a/docs/superpowers/specs/2026-03-22-ui-switcher-design.md +++ b/docs/superpowers/specs/2026-03-22-ui-switcher-design.md @@ -103,12 +103,14 @@ New `web/src/components/ClassicUIButton.vue`: ```js function switchToClassic() { - document.cookie = 'prgn_ui=streamlit; path=/'; - window.location.reload(); + document.cookie = 'prgn_ui=streamlit; path=/; SameSite=Lax'; + const url = new URL(window.location.href); + url.searchParams.set('prgn_switch', 'streamlit'); + window.location.href = url.toString(); } ``` -No backend call needed. On next Streamlit load, `sync_ui_cookie()` sees `prgn_ui=streamlit`, writes user.yaml to match. +**Why the query param?** Streamlit cannot read HTTP cookies from Python — only client-side JS can. The `?prgn_switch=streamlit` param acts as a bridge: `sync_ui_cookie()` reads it via `st.query_params`, updates user.yaml to match, then clears the param. The cookie is set by the JS before the navigation so Caddy routes the request to Streamlit, and the param ensures user.yaml stays consistent with the cookie. ### 5. Tier gate @@ -196,10 +198,12 @@ User clicks "Try it" banner or Settings toggle ``` User clicks "Classic UI" in Vue nav → document.cookie = 'prgn_ui=streamlit; path=/' - → window.location.reload() + → navigate to current URL + ?prgn_switch=streamlit → Caddy sees prgn_ui=streamlit → :8505/:8504 (Streamlit) - → app.py render pass: sync_ui_cookie() sees cookie=streamlit + → app.py render pass: sync_ui_cookie() sees ?prgn_switch=streamlit in st.query_params → writes user.yaml: ui_preference: streamlit + → clears query param + → injects JS to re-confirm cookie ``` ### Demo tier switch