Compare commits
6 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2051880d73 | |||
| 7d1b1319be | |||
| b44a7975bc | |||
| 0d6ddd35cf | |||
| 5c4992dbeb | |||
| fc3bd8859e |
|
|
@ -45,7 +45,8 @@ FORGEJO_API_URL=https://git.opensourcesolarpunk.com/api/v1
|
||||||
# Set CF_LICENSE_KEY to authenticate with the hosted coordinator.
|
# Set CF_LICENSE_KEY to authenticate with the hosted coordinator.
|
||||||
# Leave both blank for local self-hosted cf-orch or bare-metal inference.
|
# Leave both blank for local self-hosted cf-orch or bare-metal inference.
|
||||||
CF_LICENSE_KEY=
|
CF_LICENSE_KEY=
|
||||||
CF_ORCH_URL=https://orch.circuitforge.tech
|
GPU_SERVER_URL=https://orch.circuitforge.tech
|
||||||
|
# CF_ORCH_URL is also accepted as a backward-compat alias for GPU_SERVER_URL
|
||||||
|
|
||||||
# cf-orch agent — GPU profiles only (single-gpu, dual-gpu-*)
|
# cf-orch agent — GPU profiles only (single-gpu, dual-gpu-*)
|
||||||
# The agent registers this node with the cf-orch coordinator and reports VRAM stats.
|
# The agent registers this node with the cf-orch coordinator and reports VRAM stats.
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,9 @@ jobs:
|
||||||
python-version: '3.12'
|
python-version: '3.12'
|
||||||
cache: pip
|
cache: pip
|
||||||
|
|
||||||
|
- name: Install system dependencies
|
||||||
|
run: sudo apt-get update -q && sudo apt-get install -y libsqlcipher-dev
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pip install -r requirements.txt
|
run: pip install -r requirements.txt
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@
|
||||||
[](#license)
|
[](#license)
|
||||||
[](https://github.com/CircuitForgeLLC/peregrine/actions/workflows/ci.yml)
|
[](https://github.com/CircuitForgeLLC/peregrine/actions/workflows/ci.yml)
|
||||||
[](https://docs.circuitforge.tech/peregrine/)
|
[](https://docs.circuitforge.tech/peregrine/)
|
||||||
|
[](https://git.opensourcesolarpunk.com/Circuit-Forge/peregrine/releases)
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
<a href="https://demo.circuitforge.tech/peregrine"><strong>Live Demo</strong></a> —
|
<a href="https://demo.circuitforge.tech/peregrine"><strong>Live Demo</strong></a> —
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,8 @@ services:
|
||||||
- HEIMDALL_ADMIN_TOKEN=${HEIMDALL_ADMIN_TOKEN}
|
- HEIMDALL_ADMIN_TOKEN=${HEIMDALL_ADMIN_TOKEN}
|
||||||
- PYTHONUNBUFFERED=1
|
- PYTHONUNBUFFERED=1
|
||||||
- FORGEJO_API_TOKEN=${FORGEJO_API_TOKEN:-}
|
- FORGEJO_API_TOKEN=${FORGEJO_API_TOKEN:-}
|
||||||
- CF_ORCH_URL=http://host.docker.internal:7700
|
- GPU_SERVER_URL=${GPU_SERVER_URL:-http://host.docker.internal:7700}
|
||||||
|
- CF_ORCH_URL=${CF_ORCH_URL:-${GPU_SERVER_URL:-http://host.docker.internal:7700}}
|
||||||
- CF_APP_NAME=peregrine
|
- CF_APP_NAME=peregrine
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
- "host.docker.internal:host-gateway"
|
- "host.docker.internal:host-gateway"
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,8 @@ services:
|
||||||
- STAGING_DB=/devl/job-seeker/staging.db
|
- STAGING_DB=/devl/job-seeker/staging.db
|
||||||
- PYTHONUNBUFFERED=1
|
- PYTHONUNBUFFERED=1
|
||||||
- STREAMLIT_SERVER_BASE_URL_PATH=
|
- STREAMLIT_SERVER_BASE_URL_PATH=
|
||||||
- CF_ORCH_URL=http://host.docker.internal:7700
|
- GPU_SERVER_URL=${GPU_SERVER_URL:-http://host.docker.internal:7700}
|
||||||
|
- CF_ORCH_URL=${CF_ORCH_URL:-${GPU_SERVER_URL:-http://host.docker.internal:7700}}
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
- "host.docker.internal:host-gateway"
|
- "host.docker.internal:host-gateway"
|
||||||
restart: "no"
|
restart: "no"
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,8 @@ services:
|
||||||
- OPENAI_COMPAT_KEY=${OPENAI_COMPAT_KEY:-}
|
- OPENAI_COMPAT_KEY=${OPENAI_COMPAT_KEY:-}
|
||||||
- PEREGRINE_GPU_COUNT=${PEREGRINE_GPU_COUNT:-0}
|
- PEREGRINE_GPU_COUNT=${PEREGRINE_GPU_COUNT:-0}
|
||||||
- PEREGRINE_GPU_NAMES=${PEREGRINE_GPU_NAMES:-}
|
- PEREGRINE_GPU_NAMES=${PEREGRINE_GPU_NAMES:-}
|
||||||
- CF_ORCH_URL=${CF_ORCH_URL:-http://host.docker.internal:7700}
|
- GPU_SERVER_URL=${GPU_SERVER_URL:-${CF_ORCH_URL:-http://host.docker.internal:7700}}
|
||||||
|
- CF_ORCH_URL=${CF_ORCH_URL:-${GPU_SERVER_URL:-http://host.docker.internal:7700}}
|
||||||
- CF_APP_NAME=peregrine
|
- CF_APP_NAME=peregrine
|
||||||
- PYTHONUNBUFFERED=1
|
- PYTHONUNBUFFERED=1
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
|
|
|
||||||
|
|
@ -46,11 +46,61 @@ backends:
|
||||||
type: vision_service
|
type: vision_service
|
||||||
supports_images: true
|
supports_images: true
|
||||||
|
|
||||||
# ── cf-orch trunk services ─────────────────────────────────────────────────
|
# ── cf-orch task-routed backends (preferred for GPU inference) ────────────
|
||||||
# These backends allocate via cf-orch rather than connecting to a static URL.
|
# Use these when GPU_SERVER_URL is configured. The coordinator resolves
|
||||||
# cf-orch starts the service on-demand and returns its URL; the router then
|
# product+task → model_id → node via assignments.yaml; no model IDs needed here.
|
||||||
# calls it directly using the openai_compat path.
|
# Set enabled: true once GPU_SERVER_URL is configured.
|
||||||
# Set CF_ORCH_URL (env) or url below; leave enabled: false if cf-orch is
|
cf_cover_letter:
|
||||||
|
type: openai_compat
|
||||||
|
enabled: false
|
||||||
|
base_url: http://localhost:8008/v1 # fallback when cf-orch is unavailable
|
||||||
|
model: __auto__
|
||||||
|
api_key: any
|
||||||
|
supports_images: false
|
||||||
|
cf_orch:
|
||||||
|
product: peregrine
|
||||||
|
task: cover_letter
|
||||||
|
ttl_s: 3600
|
||||||
|
|
||||||
|
cf_ats_rewrite:
|
||||||
|
type: openai_compat
|
||||||
|
enabled: false
|
||||||
|
base_url: http://localhost:8008/v1
|
||||||
|
model: __auto__
|
||||||
|
api_key: any
|
||||||
|
supports_images: false
|
||||||
|
cf_orch:
|
||||||
|
product: peregrine
|
||||||
|
task: ats_rewrite
|
||||||
|
ttl_s: 3600
|
||||||
|
|
||||||
|
cf_job_research:
|
||||||
|
type: openai_compat
|
||||||
|
enabled: false
|
||||||
|
base_url: http://localhost:8008/v1
|
||||||
|
model: __auto__
|
||||||
|
api_key: any
|
||||||
|
supports_images: false
|
||||||
|
cf_orch:
|
||||||
|
product: peregrine
|
||||||
|
task: job_research
|
||||||
|
ttl_s: 3600
|
||||||
|
|
||||||
|
cf_interview_prep:
|
||||||
|
type: openai_compat
|
||||||
|
enabled: false
|
||||||
|
base_url: http://localhost:8008/v1
|
||||||
|
model: __auto__
|
||||||
|
api_key: any
|
||||||
|
supports_images: false
|
||||||
|
cf_orch:
|
||||||
|
product: peregrine
|
||||||
|
task: interview_prep
|
||||||
|
ttl_s: 3600
|
||||||
|
|
||||||
|
# ── cf-orch trunk services (service-based, legacy) ─────────────────────────
|
||||||
|
# Generic service allocation — use the task-routed backends above when possible.
|
||||||
|
# Set GPU_SERVER_URL (env) or url below; leave enabled: false if cf-orch is
|
||||||
# not deployed in your environment.
|
# not deployed in your environment.
|
||||||
cf_text:
|
cf_text:
|
||||||
type: openai_compat
|
type: openai_compat
|
||||||
|
|
|
||||||
77
dev-api.py
|
|
@ -48,6 +48,21 @@ _CLOUD_DATA_ROOT = Path(os.environ.get("CLOUD_DATA_ROOT", "/devl/menagerie-data
|
||||||
_DIRECTUS_SECRET = os.environ.get("DIRECTUS_JWT_SECRET", "")
|
_DIRECTUS_SECRET = os.environ.get("DIRECTUS_JWT_SECRET", "")
|
||||||
IS_DEMO: bool = os.environ.get("DEMO_MODE", "").lower() in ("1", "true", "yes")
|
IS_DEMO: bool = os.environ.get("DEMO_MODE", "").lower() in ("1", "true", "yes")
|
||||||
|
|
||||||
|
# Resolve GPU inference server URL.
|
||||||
|
# Priority: GPU_SERVER_URL → CF_ORCH_URL (backward compat) → cloud default when licensed.
|
||||||
|
# Result is written back to CF_ORCH_URL so all downstream callers need no changes.
|
||||||
|
_GPU_SERVER_URL: str | None = (
|
||||||
|
os.environ.get("GPU_SERVER_URL")
|
||||||
|
or os.environ.get("CF_ORCH_URL")
|
||||||
|
or (
|
||||||
|
"https://orch.circuitforge.tech"
|
||||||
|
if os.environ.get("CF_LICENSE_KEY")
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if _GPU_SERVER_URL:
|
||||||
|
os.environ["CF_ORCH_URL"] = _GPU_SERVER_URL
|
||||||
|
|
||||||
# Per-request DB path — set by cloud_session_middleware; falls back to DB_PATH
|
# Per-request DB path — set by cloud_session_middleware; falls back to DB_PATH
|
||||||
_request_db: ContextVar[str | None] = ContextVar("_request_db", default=None)
|
_request_db: ContextVar[str | None] = ContextVar("_request_db", default=None)
|
||||||
|
|
||||||
|
|
@ -636,6 +651,51 @@ def resume_optimizer_task_status(job_id: int):
|
||||||
return {"status": row["status"], "stage": row["stage"], "message": row["error"]}
|
return {"status": row["status"], "stage": row["stage"], "message": row["error"]}
|
||||||
|
|
||||||
|
|
||||||
|
def _capture_review_corrections(
|
||||||
|
db_path: Path,
|
||||||
|
job_id: int,
|
||||||
|
draft: dict,
|
||||||
|
decisions: dict,
|
||||||
|
) -> None:
|
||||||
|
"""Persist (proposed, accepted) pairs when the user edits LLM output in the review UI.
|
||||||
|
|
||||||
|
Only saves corrections where accepted=True AND the user actually modified the
|
||||||
|
proposed text (proposed != accepted). Rejections carry no training signal.
|
||||||
|
"""
|
||||||
|
from scripts.db import save_resume_correction as _save_correction
|
||||||
|
|
||||||
|
sections = {s["section"]: s for s in (draft.get("sections") or [])}
|
||||||
|
|
||||||
|
# ── Summary correction ────────────────────────────────────────────────────
|
||||||
|
summary_dec = decisions.get("summary", {})
|
||||||
|
if summary_dec.get("accepted", True):
|
||||||
|
edited_text = summary_dec.get("edited_text")
|
||||||
|
proposed_summary = sections.get("summary", {}).get("proposed", "")
|
||||||
|
if edited_text is not None and edited_text.strip() != proposed_summary.strip():
|
||||||
|
_save_correction(db_path, job_id, "summary", proposed_summary, edited_text.strip())
|
||||||
|
|
||||||
|
# ── Experience bullet corrections ─────────────────────────────────────────
|
||||||
|
exp_sec = sections.get("experience", {})
|
||||||
|
entry_diffs = {
|
||||||
|
f"{e['title']}|{e['company']}": e
|
||||||
|
for e in (exp_sec.get("entries") or [])
|
||||||
|
}
|
||||||
|
for entry_dec in (decisions.get("experience", {}).get("accepted_entries") or []):
|
||||||
|
if not entry_dec.get("accepted", True):
|
||||||
|
continue
|
||||||
|
edited_bullets = entry_dec.get("edited_bullets")
|
||||||
|
if edited_bullets is None:
|
||||||
|
continue
|
||||||
|
key = f"{entry_dec.get('title', '')}|{entry_dec.get('company', '')}"
|
||||||
|
diff = entry_diffs.get(key)
|
||||||
|
if diff is None:
|
||||||
|
continue
|
||||||
|
proposed_bullets = diff.get("proposed_bullets") or []
|
||||||
|
cleaned = [b for b in edited_bullets if b.strip()]
|
||||||
|
if cleaned != proposed_bullets:
|
||||||
|
_save_correction(db_path, job_id, f"experience:{key}", proposed_bullets, cleaned)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/jobs/{job_id}/resume_optimizer/review")
|
@app.get("/api/jobs/{job_id}/resume_optimizer/review")
|
||||||
def get_resume_review(job_id: int):
|
def get_resume_review(job_id: int):
|
||||||
"""Return the pending review draft for this job (populated when task is awaiting_review)."""
|
"""Return the pending review draft for this job (populated when task is awaiting_review)."""
|
||||||
|
|
@ -692,6 +752,10 @@ def preview_resume_review(job_id: int, body: ResumeReviewBody):
|
||||||
# Step 1: apply section-level decisions
|
# Step 1: apply section-level decisions
|
||||||
struct = apply_review_decisions(draft, body.decisions)
|
struct = apply_review_decisions(draft, body.decisions)
|
||||||
|
|
||||||
|
# Step 1b: capture (proposed, accepted) correction pairs for Avocet fine-tuning.
|
||||||
|
# Only fires when accepted=True and the user actually edited the LLM output.
|
||||||
|
_capture_review_corrections(db_path, job_id, draft, body.decisions)
|
||||||
|
|
||||||
# Step 2: inject gap framing for rejected skills (adjacent / learning)
|
# Step 2: inject gap framing for rejected skills (adjacent / learning)
|
||||||
framings = [f.model_dump() for f in body.gap_framings if f.mode in ("adjacent", "learning")]
|
framings = [f.model_dump() for f in body.gap_framings if f.mode in ("adjacent", "learning")]
|
||||||
if framings:
|
if framings:
|
||||||
|
|
@ -713,6 +777,19 @@ def preview_resume_review(job_id: int, body: ResumeReviewBody):
|
||||||
return {"preview_text": preview_text, "preview_struct": struct}
|
return {"preview_text": preview_text, "preview_struct": struct}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/resume_optimizer/corrections")
|
||||||
|
def list_resume_corrections(job_id: int | None = None, limit: int = 200):
|
||||||
|
"""Return resume review correction pairs for Avocet import.
|
||||||
|
|
||||||
|
Each record is a (proposed, accepted) pair from the review UI where the
|
||||||
|
user edited the LLM output before accepting. These are SFT (supervised
|
||||||
|
fine-tuning) candidates that flow through Avocet for human review.
|
||||||
|
"""
|
||||||
|
from scripts.db import get_resume_corrections as _get_corrections
|
||||||
|
db_path = Path(_request_db.get() or DB_PATH)
|
||||||
|
return {"corrections": _get_corrections(db_path, limit=limit, job_id=job_id)}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/jobs/{job_id}/resume_optimizer/approve")
|
@app.post("/api/jobs/{job_id}/resume_optimizer/approve")
|
||||||
def approve_resume(job_id: int, body: dict):
|
def approve_resume(job_id: int, body: dict):
|
||||||
"""Save the user-approved assembled resume struct and mark the task complete.
|
"""Save the user-approved assembled resume struct and mark the task complete.
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ Thank you for your interest in contributing to Peregrine. This guide covers the
|
||||||
## Fork and Clone
|
## Fork and Clone
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://git.circuitforge.io/circuitforge/peregrine
|
git clone https://git.opensourcesolarpunk.com/Circuit-Forge/peregrine
|
||||||
cd peregrine
|
cd peregrine
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ Peregrine automates the full job search lifecycle: discovery, matching, cover le
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Clone and install dependencies
|
# 1. Clone and install dependencies
|
||||||
git clone https://git.circuitforge.io/circuitforge/peregrine
|
git clone https://git.opensourcesolarpunk.com/Circuit-Forge/peregrine
|
||||||
cd peregrine
|
cd peregrine
|
||||||
bash install.sh
|
bash install.sh
|
||||||
|
|
||||||
|
|
@ -31,20 +31,23 @@ The first-run wizard guides you through hardware detection, tier selection, iden
|
||||||
|
|
||||||
## Feature Overview
|
## Feature Overview
|
||||||
|
|
||||||
| Feature | Free | Paid | Premium |
|
| Feature | Free | Paid† | Premium |
|
||||||
|---------|------|------|---------|
|
|---------|------|-------|---------|
|
||||||
| Job discovery (JobSpy + custom boards) | Yes | Yes | Yes |
|
| Job discovery (JobSpy + custom boards) | Yes | Yes | Yes |
|
||||||
| Resume keyword matching | Yes | Yes | Yes |
|
| Resume keyword matching | Yes | Yes | Yes |
|
||||||
| Cover letter generation | - | Yes | Yes |
|
| Cover letter generation | BYOK‡ | Yes | Yes |
|
||||||
| Company research briefs | - | Yes | Yes |
|
| Company research briefs | BYOK‡ | Yes | Yes |
|
||||||
| Interview prep & practice Q&A | - | Yes | Yes |
|
| Interview prep & practice Q&A | BYOK‡ | Yes | Yes |
|
||||||
| Email sync & auto-classification | - | Yes | Yes |
|
| Email sync & auto-classification | - | Yes | Yes |
|
||||||
| Survey assistant (culture-fit Q&A) | - | Yes | Yes |
|
| Survey assistant (culture-fit Q&A) | BYOK‡ | Yes | Yes |
|
||||||
| Integration connectors (Notion, Airtable, etc.) | Partial | Yes | Yes |
|
| Integration connectors (Notion, Airtable, etc.) | Partial | Yes | Yes |
|
||||||
| Calendar sync (Google, Apple) | - | Yes | Yes |
|
| Calendar sync (Google, Apple) | - | Yes | Yes |
|
||||||
| Cover letter model fine-tuning | - | - | Yes |
|
| Cover letter model fine-tuning | - | - | Yes |
|
||||||
| Multi-user support | - | - | Yes |
|
| Multi-user support | - | - | Yes |
|
||||||
|
|
||||||
|
† **Paid** gives access to CircuitForge's hosted inference — no API key required.
|
||||||
|
‡ **BYOK** — configure any LLM backend in `config/llm.yaml` (local Ollama/vLLM or an API key) and these features unlock at no charge, regardless of tier.
|
||||||
|
|
||||||
See [Tier System](reference/tier-system.md) for the full feature gate table.
|
See [Tier System](reference/tier-system.md) for the full feature gate table.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -60,8 +63,8 @@ See [Tier System](reference/tier-system.md) for the full feature gate table.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
Core discovery pipeline: [MIT](https://git.circuitforge.io/circuitforge/peregrine/src/branch/main/LICENSE-MIT)
|
Core discovery pipeline: [MIT](https://git.opensourcesolarpunk.com/Circuit-Forge/peregrine/src/branch/main/LICENSE-MIT)
|
||||||
|
|
||||||
AI features (cover letter generation, company research, interview prep, UI): [BSL 1.1](https://git.circuitforge.io/circuitforge/peregrine/src/branch/main/LICENSE-BSL)
|
AI features (cover letter generation, company research, interview prep, UI): [BSL 1.1](https://git.opensourcesolarpunk.com/Circuit-Forge/peregrine/src/branch/main/LICENSE-BSL)
|
||||||
|
|
||||||
© 2026 Circuit Forge LLC
|
© 2026 Circuit Forge LLC
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 110 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 73 KiB |
|
|
@ -8,7 +8,9 @@
|
||||||
<!-- Apply stored theme before first paint — prevents FOUT flash on dark/hacker themes.
|
<!-- Apply stored theme before first paint — prevents FOUT flash on dark/hacker themes.
|
||||||
Mirrors the logic in useTheme.initTheme(). Must run before the <style> below. -->
|
Mirrors the logic in useTheme.initTheme(). Must run before the <style> below. -->
|
||||||
<script>try{if(localStorage.getItem('cf-hacker-mode')==='true'){document.documentElement.dataset.theme='hacker';}else{var t=localStorage.getItem('cf-theme');if(t&&t!=='auto')document.documentElement.dataset.theme=t;}}catch(e){}</script>
|
<script>try{if(localStorage.getItem('cf-hacker-mode')==='true'){document.documentElement.dataset.theme='hacker';}else{var t=localStorage.getItem('cf-theme');if(t&&t!=='auto')document.documentElement.dataset.theme=t;}}catch(e){}</script>
|
||||||
<!-- FOUT prevention: background only on html (body is transparent).
|
<!-- FOUT prevention: background only on html (body is transparent). Gotcha #14.
|
||||||
|
body paints on top of html — a hardcoded body background covers html's CSS-
|
||||||
|
variable-driven color even when it resolves correctly. Keep background off body.
|
||||||
Covers auto mode (media query) and all explicit theme choices. -->
|
Covers auto mode (media query) and all explicit theme choices. -->
|
||||||
<style>
|
<style>
|
||||||
html, body { margin: 0; min-height: 100vh; }
|
html, body { margin: 0; min-height: 100vh; }
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,10 @@ html {
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
|
/* Gotcha #14: do NOT set background on body. body paints on top of html —
|
||||||
|
a hardcoded body background will cover html's CSS-variable-driven color
|
||||||
|
even when html { background: var(--color-surface) } resolves correctly.
|
||||||
|
FOUT prevention lives in index.html on html only, not body. */
|
||||||
min-height: 100dvh; /* dynamic viewport — mobile chrome-aware. Gotcha #13. */
|
min-height: 100dvh; /* dynamic viewport — mobile chrome-aware. Gotcha #13. */
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,9 @@ body {
|
||||||
--score-low: var(--color-error); /* < 30% */
|
--score-low: var(--color-error); /* < 30% */
|
||||||
--score-none: var(--color-text-muted);
|
--score-none: var(--color-text-muted);
|
||||||
|
|
||||||
|
/* ── Hover overlay ── */
|
||||||
|
--color-hover: rgba(0, 0, 0, 0.06); /* subtle darkening on light surfaces */
|
||||||
|
|
||||||
/* ── Motion tokens ── */
|
/* ── Motion tokens ── */
|
||||||
--swipe-exit: 300ms;
|
--swipe-exit: 300ms;
|
||||||
--swipe-spring: 400ms cubic-bezier(0.34, 1.56, 0.64, 1);
|
--swipe-spring: 400ms cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
|
|
@ -91,6 +94,8 @@ body {
|
||||||
|
|
||||||
--score-mid-high: #5ba3d9; /* lighter blue for dark bg */
|
--score-mid-high: #5ba3d9; /* lighter blue for dark bg */
|
||||||
|
|
||||||
|
--color-hover: rgba(255, 255, 255, 0.07); /* subtle lightening on dark surfaces */
|
||||||
|
|
||||||
--status-synced: #9b8fea;
|
--status-synced: #9b8fea;
|
||||||
--status-survey: #b08fea;
|
--status-survey: #b08fea;
|
||||||
--status-phone: #4ec9be;
|
--status-phone: #4ec9be;
|
||||||
|
|
@ -112,6 +117,8 @@ body {
|
||||||
|
|
||||||
--score-mid-high: #5ba3d9;
|
--score-mid-high: #5ba3d9;
|
||||||
|
|
||||||
|
--color-hover: rgba(255, 255, 255, 0.07);
|
||||||
|
|
||||||
--status-synced: #9b8fea;
|
--status-synced: #9b8fea;
|
||||||
--status-survey: #b08fea;
|
--status-survey: #b08fea;
|
||||||
--status-phone: #4ec9be;
|
--status-phone: #4ec9be;
|
||||||
|
|
|
||||||
|
|
@ -59,9 +59,6 @@
|
||||||
<Cog6ToothIcon class="sidebar__icon" aria-hidden="true" />
|
<Cog6ToothIcon class="sidebar__icon" aria-hidden="true" />
|
||||||
<span class="sidebar__label">Settings</span>
|
<span class="sidebar__label">Settings</span>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
<button class="sidebar__classic-btn" @click="switchToClassic" title="Switch to Classic (Streamlit) UI">
|
|
||||||
⚡ Classic
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
|
@ -134,23 +131,6 @@ function exitHackerMode() {
|
||||||
restoreTheme()
|
restoreTheme()
|
||||||
}
|
}
|
||||||
|
|
||||||
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(() => [
|
const navLinks = computed(() => [
|
||||||
{ to: '/', icon: HomeIcon, label: 'Home' },
|
{ to: '/', icon: HomeIcon, label: 'Home' },
|
||||||
{ to: '/review', icon: ClipboardDocumentListIcon, label: 'Job Review' },
|
{ to: '/review', icon: ClipboardDocumentListIcon, label: 'Job Review' },
|
||||||
|
|
@ -321,29 +301,6 @@ const mobileLinks = [
|
||||||
margin: 0;
|
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Theme picker ───────────────────────────────────── */
|
/* ── Theme picker ───────────────────────────────────── */
|
||||||
.sidebar__theme {
|
.sidebar__theme {
|
||||||
padding: var(--space-2) var(--space-3);
|
padding: var(--space-2) var(--space-3);
|
||||||
|
|
|
||||||
|
|
@ -151,7 +151,7 @@ async function reclassifySignal(sig: StageSignal, newLabel: StageSignal['stage_s
|
||||||
}
|
}
|
||||||
|
|
||||||
const scoreClass = computed(() => {
|
const scoreClass = computed(() => {
|
||||||
const s = (props.job.match_score ?? 0) * 100
|
const s = props.job.match_score ?? 0
|
||||||
if (s >= 85) return 'score--high'
|
if (s >= 85) return 'score--high'
|
||||||
if (s >= 65) return 'score--mid'
|
if (s >= 65) return 'score--mid'
|
||||||
return 'score--low'
|
return 'score--low'
|
||||||
|
|
@ -159,7 +159,7 @@ const scoreClass = computed(() => {
|
||||||
|
|
||||||
const scoreLabel = computed(() =>
|
const scoreLabel = computed(() =>
|
||||||
props.job.match_score != null
|
props.job.match_score != null
|
||||||
? `${Math.round(props.job.match_score * 100)}%`
|
? `${Math.round(props.job.match_score)}%`
|
||||||
: '—'
|
: '—'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -588,7 +588,7 @@ async function saveFeedback() {
|
||||||
background: var(--color-hover);
|
background: var(--color-hover);
|
||||||
}
|
}
|
||||||
.btn-chip-active {
|
.btn-chip-active {
|
||||||
background: var(--color-primary-muted, #e8f0ff);
|
background: var(--app-primary-light);
|
||||||
color: var(--color-primary); border-color: var(--color-primary);
|
color: var(--color-primary); border-color: var(--color-primary);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -106,7 +106,7 @@ onMounted(load)
|
||||||
}
|
}
|
||||||
|
|
||||||
.rlc__title {
|
.rlc__title {
|
||||||
font-size: var(--font-sm, 0.875rem);
|
font-size: var(--text-sm);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -128,7 +128,7 @@ onMounted(load)
|
||||||
|
|
||||||
.rlc__name {
|
.rlc__name {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
font-size: var(--font-sm, 0.875rem);
|
font-size: var(--text-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.rlc__meta {
|
.rlc__meta {
|
||||||
|
|
@ -143,7 +143,7 @@ onMounted(load)
|
||||||
}
|
}
|
||||||
|
|
||||||
.rlc__empty {
|
.rlc__empty {
|
||||||
font-size: var(--font-sm, 0.875rem);
|
font-size: var(--text-sm);
|
||||||
color: var(--color-text-muted, #64748b);
|
color: var(--color-text-muted, #64748b);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -153,7 +153,7 @@ onMounted(load)
|
||||||
}
|
}
|
||||||
|
|
||||||
.rlc__loading {
|
.rlc__loading {
|
||||||
font-size: var(--font-sm, 0.875rem);
|
font-size: var(--text-sm);
|
||||||
color: var(--color-text-muted, #64748b);
|
color: var(--color-text-muted, #64748b);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -183,7 +183,7 @@ onMounted(load)
|
||||||
padding: var(--space-2, 0.5rem) var(--space-3, 0.75rem);
|
padding: var(--space-2, 0.5rem) var(--space-3, 0.75rem);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-radius: var(--radius-sm, 0.25rem);
|
border-radius: var(--radius-sm, 0.25rem);
|
||||||
font-size: var(--font-sm, 0.875rem);
|
font-size: var(--text-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.rlc__picker-item:hover,
|
.rlc__picker-item:hover,
|
||||||
|
|
|
||||||
|
|
@ -578,7 +578,7 @@ onUnmounted(stopPolling)
|
||||||
}
|
}
|
||||||
|
|
||||||
.rop__tier-note {
|
.rop__tier-note {
|
||||||
font-size: var(--font-sm, 0.875rem);
|
font-size: var(--text-sm);
|
||||||
color: var(--app-text-muted, #64748b);
|
color: var(--app-text-muted, #64748b);
|
||||||
background: var(--app-surface-alt, #f8fafc);
|
background: var(--app-surface-alt, #f8fafc);
|
||||||
border: 1px solid var(--app-border, #e2e8f0);
|
border: 1px solid var(--app-border, #e2e8f0);
|
||||||
|
|
@ -603,13 +603,13 @@ onUnmounted(stopPolling)
|
||||||
|
|
||||||
.rop__hint,
|
.rop__hint,
|
||||||
.rop__empty {
|
.rop__empty {
|
||||||
font-size: var(--font-sm, 0.875rem);
|
font-size: var(--text-sm);
|
||||||
color: var(--app-text-muted, #64748b);
|
color: var(--app-text-muted, #64748b);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rop__error {
|
.rop__error {
|
||||||
font-size: var(--font-sm, 0.875rem);
|
font-size: var(--text-sm);
|
||||||
color: var(--app-danger, #dc2626);
|
color: var(--app-danger, #dc2626);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
@ -618,7 +618,7 @@ onUnmounted(stopPolling)
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-2, 0.5rem);
|
gap: var(--space-2, 0.5rem);
|
||||||
font-size: var(--font-sm, 0.875rem);
|
font-size: var(--text-sm);
|
||||||
color: var(--app-text-muted, #64748b);
|
color: var(--app-text-muted, #64748b);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -643,7 +643,7 @@ onUnmounted(stopPolling)
|
||||||
border-radius: var(--radius-sm, 0.25rem);
|
border-radius: var(--radius-sm, 0.25rem);
|
||||||
border-left: 3px solid transparent;
|
border-left: 3px solid transparent;
|
||||||
background: var(--app-surface-alt, #f8fafc);
|
background: var(--app-surface-alt, #f8fafc);
|
||||||
font-size: var(--font-sm, 0.875rem);
|
font-size: var(--text-sm);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
@ -706,7 +706,7 @@ onUnmounted(stopPolling)
|
||||||
}
|
}
|
||||||
|
|
||||||
.rop__wordcount {
|
.rop__wordcount {
|
||||||
font-size: var(--font-sm, 0.875rem);
|
font-size: var(--text-sm);
|
||||||
color: var(--app-text-muted, #64748b);
|
color: var(--app-text-muted, #64748b);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -727,7 +727,7 @@ onUnmounted(stopPolling)
|
||||||
background: color-mix(in srgb, var(--app-danger, #dc2626) 8%, transparent);
|
background: color-mix(in srgb, var(--app-danger, #dc2626) 8%, transparent);
|
||||||
border: 1px solid color-mix(in srgb, var(--app-danger, #dc2626) 30%, transparent);
|
border: 1px solid color-mix(in srgb, var(--app-danger, #dc2626) 30%, transparent);
|
||||||
border-radius: var(--radius-md, 0.5rem);
|
border-radius: var(--radius-md, 0.5rem);
|
||||||
font-size: var(--font-sm, 0.875rem);
|
font-size: var(--text-sm);
|
||||||
color: var(--app-danger, #dc2626);
|
color: var(--app-danger, #dc2626);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -736,7 +736,7 @@ onUnmounted(stopPolling)
|
||||||
min-height: 20rem;
|
min-height: 20rem;
|
||||||
padding: var(--space-3, 0.75rem);
|
padding: var(--space-3, 0.75rem);
|
||||||
font-family: var(--font-mono, monospace);
|
font-family: var(--font-mono, monospace);
|
||||||
font-size: var(--font-sm, 0.875rem);
|
font-size: var(--text-sm);
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
border: 1px solid var(--app-border, #e2e8f0);
|
border: 1px solid var(--app-border, #e2e8f0);
|
||||||
border-radius: var(--radius-md, 0.5rem);
|
border-radius: var(--radius-md, 0.5rem);
|
||||||
|
|
@ -762,7 +762,7 @@ onUnmounted(stopPolling)
|
||||||
color: #fff;
|
color: #fff;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: var(--radius-md, 0.5rem);
|
border-radius: var(--radius-md, 0.5rem);
|
||||||
font-size: var(--font-sm, 0.875rem);
|
font-size: var(--text-sm);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.15s;
|
transition: background 0.15s;
|
||||||
|
|
@ -781,7 +781,7 @@ onUnmounted(stopPolling)
|
||||||
color: var(--app-text, #1e293b);
|
color: var(--app-text, #1e293b);
|
||||||
border: 1px solid var(--app-border, #e2e8f0);
|
border: 1px solid var(--app-border, #e2e8f0);
|
||||||
border-radius: var(--radius-md, 0.5rem);
|
border-radius: var(--radius-md, 0.5rem);
|
||||||
font-size: var(--font-sm, 0.875rem);
|
font-size: var(--text-sm);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.15s;
|
transition: background 0.15s;
|
||||||
|
|
@ -799,7 +799,7 @@ onUnmounted(stopPolling)
|
||||||
}
|
}
|
||||||
|
|
||||||
.rop__review-intro {
|
.rop__review-intro {
|
||||||
font-size: var(--font-sm, 0.875rem);
|
font-size: var(--text-sm);
|
||||||
color: var(--app-text-muted, #64748b);
|
color: var(--app-text-muted, #64748b);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: var(--space-3, 0.75rem) var(--space-4, 1rem);
|
padding: var(--space-3, 0.75rem) var(--space-4, 1rem);
|
||||||
|
|
@ -819,7 +819,7 @@ onUnmounted(stopPolling)
|
||||||
}
|
}
|
||||||
|
|
||||||
.rop__review-section-title {
|
.rop__review-section-title {
|
||||||
font-size: var(--font-sm, 0.875rem);
|
font-size: var(--text-sm);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
|
|
@ -852,7 +852,7 @@ onUnmounted(stopPolling)
|
||||||
gap: var(--space-1, 0.25rem);
|
gap: var(--space-1, 0.25rem);
|
||||||
padding: 0.3em 0.75em;
|
padding: 0.3em 0.75em;
|
||||||
border-radius: var(--radius-full, 9999px);
|
border-radius: var(--radius-full, 9999px);
|
||||||
font-size: var(--font-sm, 0.875rem);
|
font-size: var(--text-sm);
|
||||||
border: 1.5px solid var(--app-border, #e2e8f0);
|
border: 1.5px solid var(--app-border, #e2e8f0);
|
||||||
background: var(--app-surface, #fff);
|
background: var(--app-surface, #fff);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
@ -887,7 +887,7 @@ onUnmounted(stopPolling)
|
||||||
gap: var(--space-1, 0.25rem);
|
gap: var(--space-1, 0.25rem);
|
||||||
padding: var(--space-2, 0.5rem) var(--space-3, 0.75rem);
|
padding: var(--space-2, 0.5rem) var(--space-3, 0.75rem);
|
||||||
border-radius: var(--radius-sm, 0.25rem);
|
border-radius: var(--radius-sm, 0.25rem);
|
||||||
font-size: var(--font-sm, 0.875rem);
|
font-size: var(--text-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.rop__diff-col--original {
|
.rop__diff-col--original {
|
||||||
|
|
@ -936,7 +936,7 @@ onUnmounted(stopPolling)
|
||||||
}
|
}
|
||||||
|
|
||||||
.rop__exp-company {
|
.rop__exp-company {
|
||||||
font-size: var(--font-sm, 0.875rem);
|
font-size: var(--text-sm);
|
||||||
color: var(--app-text-muted, #64748b);
|
color: var(--app-text-muted, #64748b);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -945,7 +945,7 @@ onUnmounted(stopPolling)
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-1, 0.25rem);
|
gap: var(--space-1, 0.25rem);
|
||||||
font-size: var(--font-sm, 0.875rem);
|
font-size: var(--text-sm);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: var(--app-text, #1e293b);
|
color: var(--app-text, #1e293b);
|
||||||
}
|
}
|
||||||
|
|
@ -975,7 +975,7 @@ onUnmounted(stopPolling)
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
color: var(--app-accent, #6366f1);
|
color: var(--app-accent, #6366f1);
|
||||||
font-size: var(--font-sm, 0.875rem);
|
font-size: var(--text-sm);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
@ -994,7 +994,7 @@ onUnmounted(stopPolling)
|
||||||
background: var(--app-surface-alt, #f8fafc);
|
background: var(--app-surface-alt, #f8fafc);
|
||||||
border: 1px solid var(--app-border, #e2e8f0);
|
border: 1px solid var(--app-border, #e2e8f0);
|
||||||
border-radius: var(--radius-sm, 0.25rem);
|
border-radius: var(--radius-sm, 0.25rem);
|
||||||
font-size: var(--font-sm, 0.875rem);
|
font-size: var(--text-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.rop__history-date {
|
.rop__history-date {
|
||||||
|
|
@ -1060,7 +1060,7 @@ onUnmounted(stopPolling)
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-1, 0.25rem);
|
gap: var(--space-1, 0.25rem);
|
||||||
font-size: var(--font-sm, 0.875rem);
|
font-size: var(--text-sm);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: var(--app-text, #1e293b);
|
color: var(--app-text, #1e293b);
|
||||||
}
|
}
|
||||||
|
|
@ -1068,7 +1068,7 @@ onUnmounted(stopPolling)
|
||||||
.rop__framing-context {
|
.rop__framing-context {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: var(--space-2, 0.5rem) var(--space-3, 0.75rem);
|
padding: var(--space-2, 0.5rem) var(--space-3, 0.75rem);
|
||||||
font-size: var(--font-sm, 0.875rem);
|
font-size: var(--text-sm);
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
border: 1px solid var(--app-border, #e2e8f0);
|
border: 1px solid var(--app-border, #e2e8f0);
|
||||||
|
|
@ -1103,7 +1103,7 @@ onUnmounted(stopPolling)
|
||||||
|
|
||||||
|
|
||||||
.rop__preview-hint {
|
.rop__preview-hint {
|
||||||
font-size: var(--font-sm, 0.875rem);
|
font-size: var(--text-sm);
|
||||||
color: var(--app-text-muted, #64748b);
|
color: var(--app-text-muted, #64748b);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
@ -1132,7 +1132,7 @@ onUnmounted(stopPolling)
|
||||||
color: var(--app-text-muted, #64748b);
|
color: var(--app-text-muted, #64748b);
|
||||||
border: 1px solid var(--app-border, #e2e8f0);
|
border: 1px solid var(--app-border, #e2e8f0);
|
||||||
border-radius: var(--radius-md, 0.5rem);
|
border-radius: var(--radius-md, 0.5rem);
|
||||||
font-size: var(--font-sm, 0.875rem);
|
font-size: var(--text-sm);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.15s;
|
transition: background 0.15s;
|
||||||
}
|
}
|
||||||
|
|
@ -1163,7 +1163,7 @@ onUnmounted(stopPolling)
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-2, 0.5rem);
|
gap: var(--space-2, 0.5rem);
|
||||||
font-size: var(--font-sm, 0.875rem);
|
font-size: var(--text-sm);
|
||||||
color: var(--app-text, #1e293b);
|
color: var(--app-text, #1e293b);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
|
@ -1171,7 +1171,7 @@ onUnmounted(stopPolling)
|
||||||
|
|
||||||
.rop__resume-name-input {
|
.rop__resume-name-input {
|
||||||
padding: var(--space-2, 0.5rem) var(--space-3, 0.75rem);
|
padding: var(--space-2, 0.5rem) var(--space-3, 0.75rem);
|
||||||
font-size: var(--font-sm, 0.875rem);
|
font-size: var(--text-sm);
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
border: 1px solid var(--app-border, #e2e8f0);
|
border: 1px solid var(--app-border, #e2e8f0);
|
||||||
border-radius: var(--radius-sm, 0.25rem);
|
border-radius: var(--radius-sm, 0.25rem);
|
||||||
|
|
|
||||||
|
|
@ -131,7 +131,7 @@ defineEmits<{
|
||||||
background: var(--color-error, #dc2626);
|
background: var(--color-error, #dc2626);
|
||||||
color: #fff; border: none;
|
color: #fff; border: none;
|
||||||
border-radius: var(--radius-md); cursor: pointer;
|
border-radius: var(--radius-md); cursor: pointer;
|
||||||
font-size: var(--font-sm); font-weight: 600;
|
font-size: var(--text-sm); font-weight: 600;
|
||||||
}
|
}
|
||||||
.btn-danger:hover { filter: brightness(1.1); }
|
.btn-danger:hover { filter: brightness(1.1); }
|
||||||
.btn-secondary {
|
.btn-secondary {
|
||||||
|
|
@ -140,7 +140,7 @@ defineEmits<{
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: var(--radius-md); cursor: pointer;
|
border-radius: var(--radius-md); cursor: pointer;
|
||||||
font-size: var(--font-sm);
|
font-size: var(--text-sm);
|
||||||
}
|
}
|
||||||
.btn-secondary:hover { background: var(--color-surface-alt); }
|
.btn-secondary:hover { background: var(--color-surface-alt); }
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -46,11 +46,11 @@ const emit = defineEmits<{
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.rp-confirm { display: flex; flex-direction: column; gap: var(--space-4, 1rem); }
|
.rp-confirm { display: flex; flex-direction: column; gap: var(--space-4, 1rem); }
|
||||||
.rp__heading { font-size: var(--font-lg, 1.125rem); font-weight: 600; margin: 0; color: var(--color-text, #1a2338); }
|
.rp__heading { font-size: var(--font-lg, 1.125rem); font-weight: 600; margin: 0; color: var(--color-text, #1a2338); }
|
||||||
.rp__hint { font-size: var(--font-sm, 0.875rem); color: var(--color-text-muted, #4a5c7a); margin: 0; }
|
.rp__hint { font-size: var(--text-sm); color: var(--color-text-muted, #4a5c7a); margin: 0; }
|
||||||
.rp-confirm__list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: var(--space-2, 0.5rem); }
|
.rp-confirm__list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: var(--space-2, 0.5rem); }
|
||||||
.rp-confirm__item { display: flex; align-items: center; gap: var(--space-3, 0.75rem); padding: var(--space-2, 0.5rem) var(--space-3, 0.75rem); background: var(--color-surface-alt, #dde4f0); border-radius: var(--radius-sm, 0.25rem); font-size: var(--font-sm, 0.875rem); }
|
.rp-confirm__item { display: flex; align-items: center; gap: var(--space-3, 0.75rem); padding: var(--space-2, 0.5rem) var(--space-3, 0.75rem); background: var(--color-surface-alt, #dde4f0); border-radius: var(--radius-sm, 0.25rem); font-size: var(--text-sm); }
|
||||||
.rp-confirm__status { margin-left: auto; font-size: var(--font-xs, 0.75rem); color: var(--color-text-muted, #4a5c7a); text-transform: capitalize; }
|
.rp-confirm__status { margin-left: auto; font-size: var(--font-xs, 0.75rem); color: var(--color-text-muted, #4a5c7a); text-transform: capitalize; }
|
||||||
.rp__error { color: var(--color-error, #c0392b); font-size: var(--font-sm, 0.875rem); margin: 0; }
|
.rp__error { color: var(--color-error, #c0392b); font-size: var(--text-sm); margin: 0; }
|
||||||
.rp-confirm__actions { display: flex; gap: var(--space-3, 0.75rem); flex-wrap: wrap; }
|
.rp-confirm__actions { display: flex; gap: var(--space-3, 0.75rem); flex-wrap: wrap; }
|
||||||
.tab__dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; background: var(--tab-color, #94a3b8); }
|
.tab__dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; background: var(--tab-color, #94a3b8); }
|
||||||
.tab__dot--unvisited { --tab-color: var(--color-text-muted, #94a3b8); }
|
.tab__dot--unvisited { --tab-color: var(--color-text-muted, #94a3b8); }
|
||||||
|
|
@ -64,7 +64,7 @@ const emit = defineEmits<{
|
||||||
padding: var(--space-3, 0.75rem) var(--space-4, 1rem);
|
padding: var(--space-3, 0.75rem) var(--space-4, 1rem);
|
||||||
background: var(--color-accent, #c4732a); color: #fff;
|
background: var(--color-accent, #c4732a); color: #fff;
|
||||||
border: none; border-radius: var(--radius-md, 0.5rem);
|
border: none; border-radius: var(--radius-md, 0.5rem);
|
||||||
font-size: var(--font-sm, 0.875rem); font-weight: 600; cursor: pointer;
|
font-size: var(--text-sm); font-weight: 600; cursor: pointer;
|
||||||
}
|
}
|
||||||
.btn-generate:disabled { opacity: 0.6; cursor: not-allowed; }
|
.btn-generate:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||||
.btn-secondary {
|
.btn-secondary {
|
||||||
|
|
@ -72,6 +72,6 @@ const emit = defineEmits<{
|
||||||
padding: var(--space-3, 0.75rem) var(--space-4, 1rem);
|
padding: var(--space-3, 0.75rem) var(--space-4, 1rem);
|
||||||
background: var(--color-surface-alt, #dde4f0); color: var(--color-text, #1a2338);
|
background: var(--color-surface-alt, #dde4f0); color: var(--color-text, #1a2338);
|
||||||
border: 1px solid var(--color-border, #a8b8d0); border-radius: var(--radius-md, 0.5rem);
|
border: 1px solid var(--color-border, #a8b8d0); border-radius: var(--radius-md, 0.5rem);
|
||||||
font-size: var(--font-sm, 0.875rem); font-weight: 600; cursor: pointer;
|
font-size: var(--text-sm); font-weight: 600; cursor: pointer;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -67,18 +67,18 @@ function updateBullet(idx: number, value: string) {
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.rp-exp { display: flex; flex-direction: column; gap: var(--space-4, 1rem); }
|
.rp-exp { display: flex; flex-direction: column; gap: var(--space-4, 1rem); }
|
||||||
.rp__heading { font-size: var(--font-lg, 1.125rem); font-weight: 600; margin: 0; color: var(--color-text, #1a2338); }
|
.rp__heading { font-size: var(--font-lg, 1.125rem); font-weight: 600; margin: 0; color: var(--color-text, #1a2338); }
|
||||||
.rp__company { font-size: var(--font-sm, 0.875rem); color: var(--color-text-muted, #4a5c7a); margin: 0; }
|
.rp__company { font-size: var(--text-sm); color: var(--color-text-muted, #4a5c7a); margin: 0; }
|
||||||
.rp__diff-pair { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-4, 1rem); }
|
.rp__diff-pair { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-4, 1rem); }
|
||||||
@media (max-width: 600px) { .rp__diff-pair { grid-template-columns: 1fr; } }
|
@media (max-width: 600px) { .rp__diff-pair { grid-template-columns: 1fr; } }
|
||||||
.rp__diff-col { display: flex; flex-direction: column; gap: var(--space-2, 0.5rem); }
|
.rp__diff-col { display: flex; flex-direction: column; gap: var(--space-2, 0.5rem); }
|
||||||
.rp__diff-col--editable { gap: var(--space-2, 0.5rem); }
|
.rp__diff-col--editable { gap: var(--space-2, 0.5rem); }
|
||||||
.rp__diff-label { font-size: var(--font-xs, 0.75rem); font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: var(--color-text-muted, #4a5c7a); }
|
.rp__diff-label { font-size: var(--font-xs, 0.75rem); font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: var(--color-text-muted, #4a5c7a); }
|
||||||
.rp__bullet-list { margin: 0; padding-left: var(--space-4, 1rem); font-size: var(--font-sm, 0.875rem); line-height: 1.6; background: var(--color-surface-alt, #dde4f0); border-radius: var(--radius-sm, 0.25rem); padding: var(--space-3, 0.75rem) var(--space-3, 0.75rem) var(--space-3, 0.75rem) var(--space-6, 1.5rem); }
|
.rp__bullet-list { margin: 0; padding-left: var(--space-4, 1rem); font-size: var(--text-sm); line-height: 1.6; background: var(--color-surface-alt, #dde4f0); border-radius: var(--radius-sm, 0.25rem); padding: var(--space-3, 0.75rem) var(--space-3, 0.75rem) var(--space-3, 0.75rem) var(--space-6, 1.5rem); }
|
||||||
.rp__bullet-edit-list { display: flex; flex-direction: column; gap: var(--space-2, 0.5rem); }
|
.rp__bullet-edit-list { display: flex; flex-direction: column; gap: var(--space-2, 0.5rem); }
|
||||||
.rp__bullet-edit-row { display: flex; align-items: flex-start; gap: var(--space-1, 0.25rem); }
|
.rp__bullet-edit-row { display: flex; align-items: flex-start; gap: var(--space-1, 0.25rem); }
|
||||||
.rp__bullet-textarea {
|
.rp__bullet-textarea {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
font-size: var(--font-sm, 0.875rem);
|
font-size: var(--text-sm);
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
padding: var(--space-2, 0.5rem) var(--space-3, 0.75rem);
|
padding: var(--space-2, 0.5rem) var(--space-3, 0.75rem);
|
||||||
background: var(--color-surface, #eaeff8);
|
background: var(--color-surface, #eaeff8);
|
||||||
|
|
@ -91,5 +91,5 @@ function updateBullet(idx: number, value: string) {
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
}
|
}
|
||||||
.rp__bullet-textarea:focus { outline: 2px solid var(--color-accent, #c4732a); outline-offset: 2px; }
|
.rp__bullet-textarea:focus { outline: 2px solid var(--color-accent, #c4732a); outline-offset: 2px; }
|
||||||
.rp__accept-toggle { display: inline-flex; align-items: center; gap: var(--space-2, 0.5rem); cursor: pointer; font-size: var(--font-sm, 0.875rem); }
|
.rp__accept-toggle { display: inline-flex; align-items: center; gap: var(--space-2, 0.5rem); cursor: pointer; font-size: var(--text-sm); }
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -58,7 +58,7 @@ const emit = defineEmits<{
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.rp-skills { display: flex; flex-direction: column; gap: var(--space-4, 1rem); }
|
.rp-skills { display: flex; flex-direction: column; gap: var(--space-4, 1rem); }
|
||||||
.rp__heading { font-size: var(--font-lg, 1.125rem); font-weight: 600; margin: 0; color: var(--color-text, #1a2338); }
|
.rp__heading { font-size: var(--font-lg, 1.125rem); font-weight: 600; margin: 0; color: var(--color-text, #1a2338); }
|
||||||
.rp__hint { font-size: var(--font-sm, 0.875rem); color: var(--color-text-muted, #4a5c7a); margin: 0; }
|
.rp__hint { font-size: var(--text-sm); color: var(--color-text-muted, #4a5c7a); margin: 0; }
|
||||||
.rp__skill-list { display: flex; flex-direction: column; gap: var(--space-3, 0.75rem); }
|
.rp__skill-list { display: flex; flex-direction: column; gap: var(--space-3, 0.75rem); }
|
||||||
.rp__skill-group { display: flex; flex-direction: column; gap: var(--space-2, 0.5rem); }
|
.rp__skill-group { display: flex; flex-direction: column; gap: var(--space-2, 0.5rem); }
|
||||||
.rp__skill-chip {
|
.rp__skill-chip {
|
||||||
|
|
@ -66,13 +66,13 @@ const emit = defineEmits<{
|
||||||
padding: var(--space-2, 0.5rem) var(--space-3, 0.75rem);
|
padding: var(--space-2, 0.5rem) var(--space-3, 0.75rem);
|
||||||
border: 1px solid var(--color-border, #a8b8d0);
|
border: 1px solid var(--color-border, #a8b8d0);
|
||||||
border-radius: var(--radius-md, 0.5rem);
|
border-radius: var(--radius-md, 0.5rem);
|
||||||
cursor: pointer; font-size: var(--font-sm, 0.875rem);
|
cursor: pointer; font-size: var(--text-sm);
|
||||||
background: var(--color-surface-raised, #f5f7fc);
|
background: var(--color-surface-raised, #f5f7fc);
|
||||||
transition: background var(--transition, 200ms ease);
|
transition: background var(--transition, 200ms ease);
|
||||||
}
|
}
|
||||||
.rp__skill-chip--approved { background: var(--color-primary-light, #e8f2e7); border-color: var(--color-primary, #2d5a27); }
|
.rp__skill-chip--approved { background: var(--color-primary-light, #e8f2e7); border-color: var(--color-primary, #2d5a27); }
|
||||||
.rp__framing { padding: var(--space-2, 0.5rem) var(--space-3, 0.75rem); display: flex; flex-direction: column; gap: var(--space-2, 0.5rem); background: var(--color-surface-alt, #dde4f0); border-radius: var(--radius-sm, 0.25rem); }
|
.rp__framing { padding: var(--space-2, 0.5rem) var(--space-3, 0.75rem); display: flex; flex-direction: column; gap: var(--space-2, 0.5rem); background: var(--color-surface-alt, #dde4f0); border-radius: var(--radius-sm, 0.25rem); }
|
||||||
.rp__framing-label { font-size: var(--font-xs, 0.75rem); font-weight: 600; color: var(--color-text-muted, #4a5c7a); }
|
.rp__framing-label { font-size: var(--font-xs, 0.75rem); font-weight: 600; color: var(--color-text-muted, #4a5c7a); }
|
||||||
.rp__framing-context { border: 1px solid var(--color-border, #a8b8d0); border-radius: var(--radius-sm, 0.25rem); padding: var(--space-2, 0.5rem); font-size: var(--font-sm, 0.875rem); resize: vertical; }
|
.rp__framing-context { border: 1px solid var(--color-border, #a8b8d0); border-radius: var(--radius-sm, 0.25rem); padding: var(--space-2, 0.5rem); font-size: var(--text-sm); resize: vertical; }
|
||||||
.rp__removed { font-size: var(--font-sm, 0.875rem); color: var(--color-text-muted, #4a5c7a); font-style: italic; }
|
.rp__removed { font-size: var(--text-sm); color: var(--color-text-muted, #4a5c7a); font-style: italic; }
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -51,9 +51,9 @@ const emit = defineEmits<{
|
||||||
.rp__diff-col { display: flex; flex-direction: column; gap: var(--space-2, 0.5rem); }
|
.rp__diff-col { display: flex; flex-direction: column; gap: var(--space-2, 0.5rem); }
|
||||||
.rp__diff-col--editable { gap: var(--space-2, 0.5rem); }
|
.rp__diff-col--editable { gap: var(--space-2, 0.5rem); }
|
||||||
.rp__diff-label { font-size: var(--font-xs, 0.75rem); font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: var(--color-text-muted, #4a5c7a); }
|
.rp__diff-label { font-size: var(--font-xs, 0.75rem); font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: var(--color-text-muted, #4a5c7a); }
|
||||||
.rp__diff-text { font-size: var(--font-sm, 0.875rem); line-height: 1.6; padding: var(--space-3, 0.75rem); background: var(--color-surface-alt, #dde4f0); border-radius: var(--radius-sm, 0.25rem); margin: 0; }
|
.rp__diff-text { font-size: var(--text-sm); line-height: 1.6; padding: var(--space-3, 0.75rem); background: var(--color-surface-alt, #dde4f0); border-radius: var(--radius-sm, 0.25rem); margin: 0; }
|
||||||
.rp__edit-textarea {
|
.rp__edit-textarea {
|
||||||
font-size: var(--font-sm, 0.875rem);
|
font-size: var(--text-sm);
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
padding: var(--space-3, 0.75rem);
|
padding: var(--space-3, 0.75rem);
|
||||||
background: var(--color-surface, #eaeff8);
|
background: var(--color-surface, #eaeff8);
|
||||||
|
|
@ -67,5 +67,5 @@ const emit = defineEmits<{
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
}
|
}
|
||||||
.rp__edit-textarea:focus { outline: 2px solid var(--color-accent, #c4732a); outline-offset: 2px; }
|
.rp__edit-textarea:focus { outline: 2px solid var(--color-accent, #c4732a); outline-offset: 2px; }
|
||||||
.rp__accept-toggle { display: inline-flex; align-items: center; gap: var(--space-2, 0.5rem); cursor: pointer; font-size: var(--font-sm, 0.875rem); }
|
.rp__accept-toggle { display: inline-flex; align-items: center; gap: var(--space-2, 0.5rem); cursor: pointer; font-size: var(--text-sm); }
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ describe('usePrepStore', () => {
|
||||||
tech_brief: null, funding_brief: null, red_flags: null, accessibility_brief: null,
|
tech_brief: null, funding_brief: null, red_flags: null, accessibility_brief: null,
|
||||||
generated_at: '2026-03-20T12:00:00' }, error: null }) // research
|
generated_at: '2026-03-20T12:00:00' }, error: null }) // research
|
||||||
.mockResolvedValueOnce({ data: [], error: null }) // contacts
|
.mockResolvedValueOnce({ data: [], error: null }) // contacts
|
||||||
|
.mockResolvedValueOnce({ data: [], error: null }) // qa
|
||||||
.mockResolvedValueOnce({ data: { status: 'none', stage: null, message: null }, error: null }) // task
|
.mockResolvedValueOnce({ data: { status: 'none', stage: null, message: null }, error: null }) // task
|
||||||
.mockResolvedValueOnce({ data: { id: 1, title: 'Engineer', company: 'Acme', url: null,
|
.mockResolvedValueOnce({ data: { id: 1, title: 'Engineer', company: 'Acme', url: null,
|
||||||
description: 'Build things.', cover_letter: null, match_score: 80,
|
description: 'Build things.', cover_letter: null, match_score: 80,
|
||||||
|
|
@ -50,6 +51,7 @@ describe('usePrepStore', () => {
|
||||||
tech_brief: null, funding_brief: null, red_flags: null, accessibility_brief: null,
|
tech_brief: null, funding_brief: null, red_flags: null, accessibility_brief: null,
|
||||||
generated_at: null }, error: null })
|
generated_at: null }, error: null })
|
||||||
.mockResolvedValueOnce({ data: [], error: null })
|
.mockResolvedValueOnce({ data: [], error: null })
|
||||||
|
.mockResolvedValueOnce({ data: [], error: null }) // qa
|
||||||
.mockResolvedValueOnce({ data: { status: 'none', stage: null, message: null }, error: null })
|
.mockResolvedValueOnce({ data: { status: 'none', stage: null, message: null }, error: null })
|
||||||
.mockResolvedValueOnce({ data: { id: 1, title: 'Old Job', company: 'OldCo', url: null,
|
.mockResolvedValueOnce({ data: { id: 1, title: 'Old Job', company: 'OldCo', url: null,
|
||||||
description: null, cover_letter: null, match_score: null, keyword_gaps: null }, error: null })
|
description: null, cover_letter: null, match_score: null, keyword_gaps: null }, error: null })
|
||||||
|
|
@ -62,6 +64,7 @@ describe('usePrepStore', () => {
|
||||||
mockApiFetch
|
mockApiFetch
|
||||||
.mockResolvedValueOnce({ data: null, error: { kind: 'http', status: 404, detail: '' } }) // 404 → null
|
.mockResolvedValueOnce({ data: null, error: { kind: 'http', status: 404, detail: '' } }) // 404 → null
|
||||||
.mockResolvedValueOnce({ data: [], error: null })
|
.mockResolvedValueOnce({ data: [], error: null })
|
||||||
|
.mockResolvedValueOnce({ data: [], error: null }) // qa
|
||||||
.mockResolvedValueOnce({ data: { status: 'none', stage: null, message: null }, error: null })
|
.mockResolvedValueOnce({ data: { status: 'none', stage: null, message: null }, error: null })
|
||||||
.mockResolvedValueOnce({ data: { id: 2, title: 'New Job', company: 'NewCo', url: null,
|
.mockResolvedValueOnce({ data: { id: 2, title: 'New Job', company: 'NewCo', url: null,
|
||||||
description: null, cover_letter: null, match_score: null, keyword_gaps: null }, error: null })
|
description: null, cover_letter: null, match_score: null, keyword_gaps: null }, error: null })
|
||||||
|
|
@ -102,6 +105,7 @@ describe('usePrepStore', () => {
|
||||||
tech_brief: null, funding_brief: null, red_flags: null, accessibility_brief: null,
|
tech_brief: null, funding_brief: null, red_flags: null, accessibility_brief: null,
|
||||||
generated_at: null }, error: null })
|
generated_at: null }, error: null })
|
||||||
.mockResolvedValueOnce({ data: [], error: null })
|
.mockResolvedValueOnce({ data: [], error: null })
|
||||||
|
.mockResolvedValueOnce({ data: [], error: null }) // qa
|
||||||
.mockResolvedValueOnce({ data: { status: 'none', stage: null, message: null }, error: null })
|
.mockResolvedValueOnce({ data: { status: 'none', stage: null, message: null }, error: null })
|
||||||
.mockResolvedValueOnce({ data: { id: 1, title: 'Eng', company: 'Acme', url: null,
|
.mockResolvedValueOnce({ data: { id: 1, title: 'Eng', company: 'Acme', url: null,
|
||||||
description: null, cover_letter: null, match_score: null, keyword_gaps: null }, error: null })
|
description: null, cover_letter: null, match_score: null, keyword_gaps: null }, error: null })
|
||||||
|
|
@ -112,11 +116,12 @@ describe('usePrepStore', () => {
|
||||||
// Mock first poll → completed
|
// Mock first poll → completed
|
||||||
mockApiFetch
|
mockApiFetch
|
||||||
.mockResolvedValueOnce({ data: { status: 'completed', stage: null, message: null }, error: null })
|
.mockResolvedValueOnce({ data: { status: 'completed', stage: null, message: null }, error: null })
|
||||||
// re-fetch on completed: research, contacts, task, fullJob
|
// re-fetch on completed: research, contacts, qa, task, fullJob
|
||||||
.mockResolvedValueOnce({ data: { company_brief: 'Updated!', ceo_brief: null, talking_points: null,
|
.mockResolvedValueOnce({ data: { company_brief: 'Updated!', ceo_brief: null, talking_points: null,
|
||||||
tech_brief: null, funding_brief: null, red_flags: null, accessibility_brief: null,
|
tech_brief: null, funding_brief: null, red_flags: null, accessibility_brief: null,
|
||||||
generated_at: '2026-03-20T13:00:00' }, error: null })
|
generated_at: '2026-03-20T13:00:00' }, error: null })
|
||||||
.mockResolvedValueOnce({ data: [], error: null })
|
.mockResolvedValueOnce({ data: [], error: null })
|
||||||
|
.mockResolvedValueOnce({ data: [], error: null }) // qa
|
||||||
.mockResolvedValueOnce({ data: { status: 'completed', stage: null, message: null }, error: null })
|
.mockResolvedValueOnce({ data: { status: 'completed', stage: null, message: null }, error: null })
|
||||||
.mockResolvedValueOnce({ data: { id: 1, title: 'Eng', company: 'Acme', url: null,
|
.mockResolvedValueOnce({ data: { id: 1, title: 'Eng', company: 'Acme', url: null,
|
||||||
description: 'Now with content', cover_letter: null, match_score: null, keyword_gaps: null }, error: null })
|
description: 'Now with content', cover_letter: null, match_score: null, keyword_gaps: null }, error: null })
|
||||||
|
|
@ -134,6 +139,7 @@ describe('usePrepStore', () => {
|
||||||
tech_brief: null, funding_brief: null, red_flags: null, accessibility_brief: null,
|
tech_brief: null, funding_brief: null, red_flags: null, accessibility_brief: null,
|
||||||
generated_at: null }, error: null })
|
generated_at: null }, error: null })
|
||||||
.mockResolvedValueOnce({ data: [], error: null })
|
.mockResolvedValueOnce({ data: [], error: null })
|
||||||
|
.mockResolvedValueOnce({ data: [], error: null }) // qa
|
||||||
.mockResolvedValueOnce({ data: { status: 'none', stage: null, message: null }, error: null })
|
.mockResolvedValueOnce({ data: { status: 'none', stage: null, message: null }, error: null })
|
||||||
.mockResolvedValueOnce({ data: { id: 1, title: 'Eng', company: 'Acme', url: null,
|
.mockResolvedValueOnce({ data: { id: 1, title: 'Eng', company: 'Acme', url: null,
|
||||||
description: null, cover_letter: null, match_score: null, keyword_gaps: null }, error: null })
|
description: null, cover_letter: null, match_score: null, keyword_gaps: null }, error: null })
|
||||||
|
|
@ -162,6 +168,7 @@ describe('usePrepStore', () => {
|
||||||
tech_brief: null, funding_brief: null, red_flags: null, accessibility_brief: null,
|
tech_brief: null, funding_brief: null, red_flags: null, accessibility_brief: null,
|
||||||
generated_at: '2026-03-20T12:00:00' }, error: null }) // research OK
|
generated_at: '2026-03-20T12:00:00' }, error: null }) // research OK
|
||||||
.mockResolvedValueOnce({ data: null, error: { kind: 'http', status: 500, detail: 'DB error' } }) // contacts fail
|
.mockResolvedValueOnce({ data: null, error: { kind: 'http', status: 500, detail: 'DB error' } }) // contacts fail
|
||||||
|
.mockResolvedValueOnce({ data: [], error: null }) // qa
|
||||||
.mockResolvedValueOnce({ data: { status: 'none', stage: null, message: null }, error: null }) // task OK
|
.mockResolvedValueOnce({ data: { status: 'none', stage: null, message: null }, error: null }) // task OK
|
||||||
.mockResolvedValueOnce({ data: { id: 1, title: 'Engineer', company: 'Acme', url: null,
|
.mockResolvedValueOnce({ data: { id: 1, title: 'Engineer', company: 'Acme', url: null,
|
||||||
description: 'Build things.', cover_letter: null, match_score: 80,
|
description: 'Build things.', cover_letter: null, match_score: 80,
|
||||||
|
|
|
||||||
|
|
@ -54,14 +54,20 @@ describe('useSurveyStore', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
it('analyze stores result including mode and rawInput', async () => {
|
it('analyze stores result including mode and rawInput', async () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
const mockApiFetch = vi.mocked(useApiFetch)
|
const mockApiFetch = vi.mocked(useApiFetch)
|
||||||
|
// POST → task accepted
|
||||||
|
mockApiFetch.mockResolvedValueOnce({ data: { task_id: 7, is_new: true }, error: null })
|
||||||
|
// Poll → completed with result
|
||||||
mockApiFetch.mockResolvedValueOnce({
|
mockApiFetch.mockResolvedValueOnce({
|
||||||
data: { output: '1. B — reason', source: 'text_paste' },
|
data: { status: 'completed', stage: null, message: null,
|
||||||
|
result: { output: '1. B — reason', source: 'text_paste' } },
|
||||||
error: null,
|
error: null,
|
||||||
})
|
})
|
||||||
|
|
||||||
const store = useSurveyStore()
|
const store = useSurveyStore()
|
||||||
await store.analyze(1, { text: 'Q1: test', mode: 'quick' })
|
await store.analyze(1, { text: 'Q1: test', mode: 'quick' })
|
||||||
|
await vi.advanceTimersByTimeAsync(3000)
|
||||||
|
|
||||||
expect(store.analysis).not.toBeNull()
|
expect(store.analysis).not.toBeNull()
|
||||||
expect(store.analysis!.output).toBe('1. B — reason')
|
expect(store.analysis!.output).toBe('1. B — reason')
|
||||||
|
|
@ -69,6 +75,7 @@ describe('useSurveyStore', () => {
|
||||||
expect(store.analysis!.mode).toBe('quick')
|
expect(store.analysis!.mode).toBe('quick')
|
||||||
expect(store.analysis!.rawInput).toBe('Q1: test')
|
expect(store.analysis!.rawInput).toBe('Q1: test')
|
||||||
expect(store.loading).toBe(false)
|
expect(store.loading).toBe(false)
|
||||||
|
vi.useRealTimers()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('analyze sets error on failure', async () => {
|
it('analyze sets error on failure', async () => {
|
||||||
|
|
|
||||||
|
|
@ -767,7 +767,7 @@ function formatRejectionDate(job: PipelineJob): string {
|
||||||
background: var(--color-hover);
|
background: var(--color-hover);
|
||||||
}
|
}
|
||||||
.btn-chip-active {
|
.btn-chip-active {
|
||||||
background: var(--color-primary-muted, #e8f0ff);
|
background: var(--app-primary-light);
|
||||||
color: var(--color-primary); border-color: var(--color-primary);
|
color: var(--color-primary); border-color: var(--color-primary);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -332,7 +332,7 @@ onBeforeRouteLeave(() => {
|
||||||
|
|
||||||
.rv__item-star { color: var(--color-warning, #f59e0b); font-size: 1rem; flex-shrink: 0; margin-top: 2px; }
|
.rv__item-star { color: var(--color-warning, #f59e0b); font-size: 1rem; flex-shrink: 0; margin-top: 2px; }
|
||||||
.rv__item-info { display: flex; flex-direction: column; gap: 2px; }
|
.rv__item-info { display: flex; flex-direction: column; gap: 2px; }
|
||||||
.rv__item-name { font-weight: 500; font-size: var(--font-sm, 0.875rem); }
|
.rv__item-name { font-weight: 500; font-size: var(--text-sm); }
|
||||||
.rv__item-meta { font-size: var(--font-xs, 0.75rem); color: var(--color-text-muted, #64748b); }
|
.rv__item-meta { font-size: var(--font-xs, 0.75rem); color: var(--color-text-muted, #64748b); }
|
||||||
.rv__item-source { font-size: var(--font-xs, 0.75rem); color: var(--color-accent, #6366f1); }
|
.rv__item-source { font-size: var(--font-xs, 0.75rem); color: var(--color-accent, #6366f1); }
|
||||||
|
|
||||||
|
|
@ -340,7 +340,7 @@ onBeforeRouteLeave(() => {
|
||||||
.rv__preview-header { display: flex; align-items: flex-start; justify-content: space-between; flex-wrap: wrap; gap: var(--space-3, 0.75rem); }
|
.rv__preview-header { display: flex; align-items: flex-start; justify-content: space-between; flex-wrap: wrap; gap: var(--space-3, 0.75rem); }
|
||||||
.rv__preview-meta { display: flex; align-items: center; gap: var(--space-2, 0.5rem); flex-wrap: wrap; }
|
.rv__preview-meta { display: flex; align-items: center; gap: var(--space-2, 0.5rem); flex-wrap: wrap; }
|
||||||
.rv__preview-name { font-size: var(--font-lg, 1.125rem); font-weight: 600; margin: 0; }
|
.rv__preview-name { font-size: var(--font-lg, 1.125rem); font-weight: 600; margin: 0; }
|
||||||
.rv__preview-words { font-size: var(--font-sm, 0.875rem); color: var(--color-text-muted, #64748b); }
|
.rv__preview-words { font-size: var(--text-sm); color: var(--color-text-muted, #64748b); }
|
||||||
.rv__default-badge {
|
.rv__default-badge {
|
||||||
font-size: var(--font-xs, 0.75rem); font-weight: 600;
|
font-size: var(--font-xs, 0.75rem); font-weight: 600;
|
||||||
background: var(--color-success, #16a34a); color: #fff;
|
background: var(--color-success, #16a34a); color: #fff;
|
||||||
|
|
@ -352,7 +352,7 @@ onBeforeRouteLeave(() => {
|
||||||
border: 1px solid var(--color-error, #dc2626);
|
border: 1px solid var(--color-error, #dc2626);
|
||||||
border-radius: var(--radius-md, 0.5rem);
|
border-radius: var(--radius-md, 0.5rem);
|
||||||
padding: var(--space-2, 0.5rem) var(--space-3, 0.75rem);
|
padding: var(--space-2, 0.5rem) var(--space-3, 0.75rem);
|
||||||
cursor: pointer; font-size: var(--font-sm, 0.875rem);
|
cursor: pointer; font-size: var(--text-sm);
|
||||||
}
|
}
|
||||||
.rv__delete-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
.rv__delete-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||||
|
|
||||||
|
|
@ -364,13 +364,13 @@ onBeforeRouteLeave(() => {
|
||||||
.rv__textarea {
|
.rv__textarea {
|
||||||
flex: 1; min-height: 400px; padding: var(--space-3, 0.75rem);
|
flex: 1; min-height: 400px; padding: var(--space-3, 0.75rem);
|
||||||
border: 1px solid var(--color-border, #e2e8f0); border-radius: var(--radius-md, 0.5rem);
|
border: 1px solid var(--color-border, #e2e8f0); border-radius: var(--radius-md, 0.5rem);
|
||||||
font-family: monospace; font-size: var(--font-sm, 0.875rem); resize: vertical;
|
font-family: monospace; font-size: var(--text-sm); resize: vertical;
|
||||||
background: var(--color-surface-alt, #f8fafc);
|
background: var(--color-surface-alt, #f8fafc);
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
}
|
}
|
||||||
.rv__textarea:not([readonly]) { background: var(--color-surface); }
|
.rv__textarea:not([readonly]) { background: var(--color-surface); }
|
||||||
.rv__edit-actions { display: flex; gap: var(--space-2, 0.5rem); }
|
.rv__edit-actions { display: flex; gap: var(--space-2, 0.5rem); }
|
||||||
.rv__error { color: var(--color-error, #dc2626); font-size: var(--font-sm, 0.875rem); }
|
.rv__error { color: var(--color-error, #dc2626); font-size: var(--text-sm); }
|
||||||
|
|
||||||
.rv__download-menu { position: relative; }
|
.rv__download-menu { position: relative; }
|
||||||
.rv__download-dropdown {
|
.rv__download-dropdown {
|
||||||
|
|
@ -382,11 +382,11 @@ onBeforeRouteLeave(() => {
|
||||||
.rv__download-dropdown button {
|
.rv__download-dropdown button {
|
||||||
width: 100%; text-align: left; background: none; border: none;
|
width: 100%; text-align: left; background: none; border: none;
|
||||||
padding: var(--space-2, 0.5rem) var(--space-3, 0.75rem);
|
padding: var(--space-2, 0.5rem) var(--space-3, 0.75rem);
|
||||||
cursor: pointer; font-size: var(--font-sm, 0.875rem); border-radius: var(--radius-sm, 0.25rem);
|
cursor: pointer; font-size: var(--text-sm); border-radius: var(--radius-sm, 0.25rem);
|
||||||
}
|
}
|
||||||
.rv__download-dropdown button:hover { background: var(--color-surface-alt, #f8fafc); }
|
.rv__download-dropdown button:hover { background: var(--color-surface-alt, #f8fafc); }
|
||||||
|
|
||||||
.rv__loading, .rv__empty { color: var(--color-text-muted, #64748b); font-size: var(--font-sm, 0.875rem); }
|
.rv__loading, .rv__empty { color: var(--color-text-muted, #64748b); font-size: var(--text-sm); }
|
||||||
|
|
||||||
/* Button styles — defined locally since no global button sheet exists yet */
|
/* Button styles — defined locally since no global button sheet exists yet */
|
||||||
.btn-secondary {
|
.btn-secondary {
|
||||||
|
|
@ -396,7 +396,7 @@ onBeforeRouteLeave(() => {
|
||||||
border-radius: var(--radius-md, 0.5rem);
|
border-radius: var(--radius-md, 0.5rem);
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: var(--font-sm, 0.875rem);
|
font-size: var(--text-sm);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.btn-secondary:hover:not(:disabled) {
|
.btn-secondary:hover:not(:disabled) {
|
||||||
|
|
@ -412,7 +412,7 @@ onBeforeRouteLeave(() => {
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: var(--radius-md, 0.5rem);
|
border-radius: var(--radius-md, 0.5rem);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: var(--font-sm, 0.875rem);
|
font-size: var(--text-sm);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
|
|
||||||
|
|
@ -65,7 +65,7 @@ function back() { router.push('/setup/resume') }
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.wizard-step { display: flex; flex-direction: column; gap: var(--space-5, 1.25rem); }
|
.wizard-step { display: flex; flex-direction: column; gap: var(--space-5, 1.25rem); }
|
||||||
.step-title { font-family: var(--font-display); font-size: 1.25rem; font-weight: 700; display: flex; align-items: center; gap: var(--space-2, 0.5rem); }
|
.step-title { font-family: var(--font-display); font-size: 1.25rem; font-weight: 700; display: flex; align-items: center; gap: var(--space-2, 0.5rem); }
|
||||||
.optional-badge { font-family: var(--font-sans); font-size: 0.75rem; font-weight: 500; background: var(--color-surface-alt); color: var(--color-text-muted); padding: 2px 8px; border-radius: var(--radius-full, 9999px); }
|
.optional-badge { font-family: var(--font-body); font-size: 0.75rem; font-weight: 500; background: var(--color-surface-alt); color: var(--color-text-muted); padding: 2px 8px; border-radius: var(--radius-full, 9999px); }
|
||||||
.step-body { font-size: 0.9rem; color: var(--color-text); line-height: 1.6; }
|
.step-body { font-size: 0.9rem; color: var(--color-text); line-height: 1.6; }
|
||||||
.step-body-note { font-size: 0.85rem; color: var(--color-text-muted); line-height: 1.5; margin-top: calc(-1 * var(--space-3, 0.75rem)); }
|
.step-body-note { font-size: 0.85rem; color: var(--color-text-muted); line-height: 1.5; margin-top: calc(-1 * var(--space-3, 0.75rem)); }
|
||||||
.opt-in-label { display: flex; align-items: flex-start; gap: var(--space-2, 0.5rem); font-size: 0.9rem; cursor: pointer; }
|
.opt-in-label { display: flex; align-items: flex-start; gap: var(--space-2, 0.5rem); font-size: 0.9rem; cursor: pointer; }
|
||||||
|
|
|
||||||