diff --git a/README.md b/README.md
index 55051ff..228ac81 100644
--- a/README.md
+++ b/README.md
@@ -154,7 +154,7 @@ Re-enter the wizard any time via **Settings → Developer → Reset wizard**.
| Calendar sync (Google, Apple) | Paid |
| Slack notifications | Paid |
| CircuitForge shared cover-letter model | Paid |
-| Vue 3 SPA beta UI | Paid |
+| Vue 3 SPA — full UI with onboarding wizard, job board, apply workspace, sort/filter, research modal, draft cover letter | Free |
| **Voice guidelines** (custom writing style & tone) | Premium with LLM¹ ² |
| Cover letter model fine-tuning (your writing, your model) | Premium |
| Multi-user support | Premium |
diff --git a/compose.cloud.yml b/compose.cloud.yml
index c42c15f..ea3c23d 100644
--- a/compose.cloud.yml
+++ b/compose.cloud.yml
@@ -45,6 +45,30 @@ services:
- "host.docker.internal:host-gateway"
restart: unless-stopped
+ api:
+ build:
+ context: ..
+ dockerfile: peregrine/Dockerfile.cfcore
+ command: >
+ bash -c "uvicorn dev_api:app --host 0.0.0.0 --port 8601"
+ volumes:
+ - /devl/menagerie-data:/devl/menagerie-data
+ - ./config/llm.cloud.yaml:/app/config/llm.yaml:ro
+ environment:
+ - CLOUD_MODE=true
+ - CLOUD_DATA_ROOT=/devl/menagerie-data
+ - STAGING_DB=/devl/menagerie-data/cloud-default.db
+ - DIRECTUS_JWT_SECRET=${DIRECTUS_JWT_SECRET}
+ - CF_SERVER_SECRET=${CF_SERVER_SECRET}
+ - PLATFORM_DB_URL=${PLATFORM_DB_URL}
+ - HEIMDALL_URL=${HEIMDALL_URL:-http://cf-license:8000}
+ - HEIMDALL_ADMIN_TOKEN=${HEIMDALL_ADMIN_TOKEN}
+ - PYTHONUNBUFFERED=1
+ - FORGEJO_API_TOKEN=${FORGEJO_API_TOKEN:-}
+ extra_hosts:
+ - "host.docker.internal:host-gateway"
+ restart: unless-stopped
+
web:
build:
context: .
@@ -53,6 +77,8 @@ services:
VITE_BASE_PATH: /peregrine/
ports:
- "8508:80"
+ depends_on:
+ - api
restart: unless-stopped
searxng:
diff --git a/docker/web/nginx.conf b/docker/web/nginx.conf
index 35b52b8..2107e1a 100644
--- a/docker/web/nginx.conf
+++ b/docker/web/nginx.conf
@@ -2,6 +2,8 @@ server {
listen 80;
server_name _;
+ client_max_body_size 20m;
+
root /usr/share/nginx/html;
index index.html;
diff --git a/scripts/db.py b/scripts/db.py
index 234c179..0e6bd5f 100644
--- a/scripts/db.py
+++ b/scripts/db.py
@@ -383,6 +383,19 @@ def mark_applied(db_path: Path = DEFAULT_DB, ids: list[int] = None) -> None:
conn.close()
+def cancel_task(db_path: Path = DEFAULT_DB, task_id: int = 0) -> bool:
+ """Cancel a single queued/running task by id. Returns True if a row was updated."""
+ conn = sqlite3.connect(db_path)
+ count = conn.execute(
+ "UPDATE background_tasks SET status='failed', error='Cancelled by user',"
+ " finished_at=datetime('now') WHERE id=? AND status IN ('queued','running')",
+ (task_id,),
+ ).rowcount
+ conn.commit()
+ conn.close()
+ return count > 0
+
+
def kill_stuck_tasks(db_path: Path = DEFAULT_DB) -> int:
"""Mark all queued/running background tasks as failed. Returns count killed."""
conn = sqlite3.connect(db_path)
diff --git a/web/src/components/AppNav.vue b/web/src/components/AppNav.vue
index cd4af21..84d616d 100644
--- a/web/src/components/AppNav.vue
+++ b/web/src/components/AppNav.vue
@@ -40,6 +40,9 @@
+
@@ -105,6 +108,23 @@ function exitHackerMode() {
localStorage.removeItem('cf-hacker-mode')
}
+const _apiBase = import.meta.env.BASE_URL.replace(/\/$/, '')
+
+async function switchToClassic() {
+ // Persist preference via API so Streamlit reads streamlit from user.yaml
+ // and won't re-set the cookie back to vue (avoids the ?prgn_switch rerun cycle)
+ try {
+ await fetch(_apiBase + '/api/settings/ui-preference', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ preference: 'streamlit' }),
+ })
+ } catch { /* non-fatal — cookie below is enough for immediate redirect */ }
+ document.cookie = 'prgn_ui=streamlit; path=/; SameSite=Lax'
+ // Navigate to root (no query params) — Caddy routes to Streamlit based on cookie
+ window.location.href = window.location.origin + '/'
+}
+
const navLinks = computed(() => [
{ to: '/', icon: HomeIcon, label: 'Home' },
{ to: '/review', icon: ClipboardDocumentListIcon, label: 'Job Review' },
@@ -272,6 +292,29 @@ const mobileLinks = [
margin: 0;
}
+.sidebar__classic-btn {
+ display: flex;
+ align-items: center;
+ width: 100%;
+ padding: var(--space-2) var(--space-3);
+ margin-top: var(--space-1);
+ background: none;
+ border: none;
+ border-radius: var(--radius-md);
+ color: var(--color-text-muted);
+ font-size: var(--text-xs);
+ font-weight: 500;
+ cursor: pointer;
+ opacity: 0.6;
+ transition: opacity 150ms, background 150ms;
+ white-space: nowrap;
+}
+
+.sidebar__classic-btn:hover {
+ opacity: 1;
+ background: var(--color-surface-alt);
+}
+
/* ── Mobile tab bar (<1024px) ───────────────────────── */
.app-tabbar {
display: none; /* hidden on desktop */
diff --git a/web/src/components/ApplyWorkspace.vue b/web/src/components/ApplyWorkspace.vue
index 71a46f0..a8e3a8e 100644
--- a/web/src/components/ApplyWorkspace.vue
+++ b/web/src/components/ApplyWorkspace.vue
@@ -56,6 +56,49 @@
+{{ gaps.length - 6 }}
+
+
+
+
+
+
+
Domains
+
+ {{ d }}
+
+
+
+
Keywords
+
+ {{ k }}
+
+
+
+
+
View listing ↗
@@ -151,6 +194,61 @@
+
+
+
+
+
+
+ No questions yet — add one below to get LLM-suggested answers.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
-
-
+
+
Run Setup Wizard
Walk through the onboarding wizard to set up your profile step by step.
Open Setup Wizard →
@@ -35,6 +41,21 @@
+
+
+