feat(web): merge Vue SPA from feature/vue-spa; add ClassicUIButton + useFeatureFlag

- Import web/ directory (Vue 3 + Vite + UnoCSS SPA) from feature/vue-spa branch
- Add web/src/components/ClassicUIButton.vue: switch-back to Streamlit via
  cookie (prgn_ui=streamlit) + ?prgn_switch=streamlit query param bridge
- Add web/src/composables/useFeatureFlag.ts: reads prgn_demo_tier cookie for
  demo toolbar visual consistency (not an authoritative gate, see issue #8)
- Update .gitignore: add .superpowers/, pytest-output.txt, docs/superpowers/
This commit is contained in:
pyr0ball 2026-03-22 18:46:11 -07:00
parent 86de5d2f3f
commit 49e3265132
79 changed files with 18174 additions and 0 deletions

3
.gitignore vendored
View file

@ -35,6 +35,9 @@ config/user.yaml.working
# Claude context files — kept out of version control
CLAUDE.md
.superpowers/
pytest-output.txt
docs/superpowers/
data/email_score.jsonl
data/email_label_queue.jsonl

2
web/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
node_modules/
dist/

20
web/index.html Normal file
View file

@ -0,0 +1,20 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Peregrine — Job Search Assistant</title>
<!-- Inline background prevents blank flash before CSS bundle loads -->
<!-- Matches --color-surface light / dark from theme.css -->
<style>
html, body { margin: 0; background: #eaeff8; min-height: 100vh; }
@media (prefers-color-scheme: dark) { html, body { background: #16202e; } }
</style>
</head>
<body>
<!-- Mount target only — App.vue root must NOT use id="app". Gotcha #1. -->
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

4966
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

39
web/package.json Normal file
View file

@ -0,0 +1,39 @@
{
"name": "peregrine-web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc && vite build",
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@fontsource/atkinson-hyperlegible": "^5.2.8",
"@fontsource/fraunces": "^5.2.9",
"@fontsource/jetbrains-mono": "^5.2.8",
"@heroicons/vue": "^2.2.0",
"@vueuse/core": "^14.2.1",
"@vueuse/integrations": "^14.2.1",
"animejs": "^4.3.6",
"pinia": "^3.0.4",
"vue": "^3.5.25",
"vue-router": "^5.0.3"
},
"devDependencies": {
"@types/node": "^24.10.1",
"@unocss/preset-attributify": "^66.6.4",
"@unocss/preset-wind": "^66.6.4",
"@vitejs/plugin-vue": "^6.0.2",
"@vue/test-utils": "^2.4.6",
"@vue/tsconfig": "^0.8.1",
"jsdom": "^28.1.0",
"typescript": "~5.9.3",
"unocss": "^66.6.4",
"vite": "^7.3.1",
"vitest": "^4.0.18",
"vue-tsc": "^3.1.5"
}
}

97
web/src/App.vue Normal file
View file

@ -0,0 +1,97 @@
<template>
<!-- Root uses .app-root class, NOT id="app" index.html owns #app.
Nested #app elements cause ambiguous CSS specificity. Gotcha #1. -->
<div class="app-root" :class="{ 'rich-motion': motion.rich.value }">
<AppNav />
<main class="app-main" id="main-content" tabindex="-1">
<!-- Skip to main content link (screen reader / keyboard nav) -->
<a href="#main-content" class="skip-link">Skip to main content</a>
<RouterView />
</main>
</div>
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import { RouterView } from 'vue-router'
import { useMotion } from './composables/useMotion'
import { useHackerMode, useKonamiCode } from './composables/useEasterEgg'
import AppNav from './components/AppNav.vue'
import { useDigestStore } from './stores/digest'
const motion = useMotion()
const { toggle, restore } = useHackerMode()
const digestStore = useDigestStore()
useKonamiCode(toggle)
onMounted(() => {
restore() // re-apply hacker mode from localStorage on hard reload
digestStore.fetchAll() // populate badge immediately, before user visits Digest tab
})
</script>
<style>
/* Global resets — unscoped, applied once to document */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
font-family: var(--font-body, sans-serif);
color: var(--color-text, #1a2338);
background: var(--color-surface, #eaeff8);
overflow-x: clip; /* no BFC side effects. Gotcha #3. */
}
body {
min-height: 100dvh; /* dynamic viewport — mobile chrome-aware. Gotcha #13. */
overflow-x: hidden;
}
#app { min-height: 100dvh; }
/* Layout root — sidebar pushes content right on desktop */
.app-root {
display: flex;
min-height: 100dvh;
}
/* Main content area */
.app-main {
flex: 1;
min-width: 0; /* prevents flex blowout */
/* Desktop: offset by sidebar width */
margin-left: var(--sidebar-width, 220px);
/* Mobile: no sidebar, leave room for bottom tab bar */
}
/* Skip-to-content link — visible only on keyboard focus */
.skip-link {
position: absolute;
top: -999px;
left: var(--space-4);
background: var(--app-primary);
color: white;
padding: var(--space-2) var(--space-4);
border-radius: var(--radius-md);
font-weight: 600;
z-index: 9999;
text-decoration: none;
transition: top 0ms;
}
.skip-link:focus {
top: var(--space-4);
}
/* Mobile: no sidebar margin, add bottom tab bar clearance */
@media (max-width: 1023px) {
.app-main {
margin-left: 0;
padding-bottom: calc(56px + env(safe-area-inset-bottom));
}
}
</style>

View file

@ -0,0 +1,116 @@
/* web/src/assets/peregrine.css
Peregrine-specific token overrides imported AFTER theme.css.
Source of truth: circuitforge-plans/peregrine/2026-03-03-nuxt-design-system.md
Brand:
Falcon Blue (#2B6CB0) slate-blue back plumage of the peregrine falcon
Talon Orange (#E06820) vivid orange-yellow talons and cere
*/
/* ── Page-level ─────────────────────────────────────── */
html {
overscroll-behavior-x: none;
overflow-x: clip; /* clip (not hidden) — no BFC scroll-container side effect. Gotcha #3. */
}
body {
overflow-x: hidden;
}
/* ── Light mode (default) ──────────────────────────── */
:root {
/* ── Peregrine brand colors ── */
--app-primary: #2B6CB0; /* Falcon Blue — 4.70:1 on #eaeff8 ✅ AA */
--app-primary-hover: #245A9E;
--app-primary-light: #EBF4FF; /* subtle tint — background use only */
--app-accent: #E06820; /* Talon Orange — use dark text on top */
--app-accent-hover: #C05415;
--app-accent-light: #FFF3EB; /* subtle tint — background use only */
--app-accent-text: #1a2338; /* on-button text — dark navy, NEVER white (only 2.8:1) */
/* ── CSS variable aliases (component names → theme.css canonical names. Gotcha #5.) ── */
--color-bg: var(--color-surface);
--color-text-secondary: var(--color-text-muted);
/* ── Layout ── */
--nav-height: 4rem; /* top bar height (mobile) */
--sidebar-width: 220px; /* persistent sidebar (≥1024px) */
/* ── Pipeline status colors ── */
/* Always pair with text label or icon — never color alone (accessibility) */
--status-pending: var(--color-warning);
--status-approve: var(--color-success);
--status-reject: var(--color-error);
--status-applied: var(--color-info);
--status-synced: #5b4fa8;
--status-archived: var(--color-text-muted);
--status-survey: #6d3fa8;
--status-phone: #1a7a6e;
--status-interview: var(--color-info);
--status-offer: #b8620a;
--status-hired: var(--color-success);
/* ── Match score thresholds ── */
--score-high: var(--color-success); /* ≥ 70% */
--score-mid-high: #2b7cb8; /* 5069% — Falcon Blue variant */
--score-mid: var(--color-warning); /* 3049% */
--score-low: var(--color-error); /* < 30% */
--score-none: var(--color-text-muted);
/* ── Motion tokens ── */
--swipe-exit: 300ms;
--swipe-spring: 400ms cubic-bezier(0.34, 1.56, 0.64, 1);
--confetti-dur: 3500ms;
--transition-spring: 400ms cubic-bezier(0.34, 1.56, 0.64, 1);
--transition-dismiss: 350ms ease-in;
--transition-enter: 250ms ease-out;
/* ── Type scale ── */
--text-xs: 0.75rem; /* 12px — badge labels, timestamps, keyboard hints */
--text-sm: 0.875rem; /* 14px — card secondary, captions */
--text-base: 1rem; /* 16px — body, card descriptions */
--text-lg: 1.125rem; /* 18px — card title, section headers */
--text-xl: 1.25rem; /* 20px — page section headings */
--text-2xl: 1.5rem; /* 24px — page titles */
--text-3xl: 1.875rem; /* 30px — dashboard greeting */
}
/* ── Dark mode ─────────────────────────────────────── */
@media (prefers-color-scheme: dark) {
:root:not([data-theme="hacker"]) {
--app-primary: #68A8D8; /* Falcon Blue (dark) — 6.54:1 on #16202e ✅ AA */
--app-primary-hover: #7BBDE6;
--app-primary-light: #0D1F35;
--app-accent: #F6872A; /* Talon Orange (dark) — 5.22:1 on #16202e ✅ AA */
--app-accent-hover: #FF9840;
--app-accent-light: #2D1505;
--app-accent-text: #1a2338;
--score-mid-high: #5ba3d9; /* lighter blue for dark bg */
--status-synced: #9b8fea;
--status-survey: #b08fea;
--status-phone: #4ec9be;
--status-offer: #f5a43a;
}
}
/* ── Hacker mode (Konami easter egg) ──────────────── */
[data-theme="hacker"] {
--app-primary: #00ff41;
--app-primary-hover: #00cc33;
--app-primary-light: #001a00;
--app-accent: #00ff41;
--app-accent-hover: #00cc33;
--app-accent-light: #001a00;
--app-accent-text: #0a0c0a;
}
/* ── Focus style — keyboard nav (accessibility requirement) ── */
:focus-visible {
outline: 2px solid var(--app-primary);
outline-offset: 3px;
border-radius: var(--radius-sm);
}

268
web/src/assets/theme.css Normal file
View file

@ -0,0 +1,268 @@
/* assets/styles/theme.css CENTRAL THEME FILE
Accessible Solarpunk: warm, earthy, humanist, trustworthy.
Hacker mode: terminal green circuit-trace dark (Konami code).
ALL color/font/spacing tokens live here nowhere else.
*/
/* ── Accessible Solarpunk — light (default) ──────── */
:root {
/* Brand */
--color-primary: #2d5a27;
--color-primary-hover: #234820;
--color-primary-light: #e8f2e7;
/* Surfaces — cool blue-slate, crisp and legible */
--color-surface: #eaeff8;
--color-surface-alt: #dde4f0;
--color-surface-raised: #f5f7fc;
/* Borders — cool blue-gray */
--color-border: #a8b8d0;
--color-border-light: #ccd5e6;
/* Text — dark navy, cool undertone */
--color-text: #1a2338;
--color-text-muted: #4a5c7a;
--color-text-inverse: #eaeff8;
/* Accent — amber/terracotta (action, links, CTAs) */
--color-accent: #c4732a;
--color-accent-hover: #a85c1f;
--color-accent-light: #fdf0e4;
/* Semantic */
--color-success: #3a7a32;
--color-error: #c0392b;
--color-warning: #d4891a;
--color-info: #1e6091;
/* Typography */
--font-display: 'Fraunces', Georgia, serif; /* Headings — optical humanist serif */
--font-body: 'Atkinson Hyperlegible', system-ui, sans-serif; /* Body — designed for accessibility */
--font-mono: 'JetBrains Mono', 'Fira Code', monospace; /* Code, hacker mode */
/* Spacing scale */
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 0.75rem;
--space-4: 1rem;
--space-6: 1.5rem;
--space-8: 2rem;
--space-12: 3rem;
--space-16: 4rem;
--space-24: 6rem;
/* Radii */
--radius-sm: 0.25rem;
--radius-md: 0.5rem;
--radius-lg: 1rem;
--radius-full: 9999px;
/* Shadows — cool blue-navy base */
--shadow-sm: 0 1px 3px rgba(26, 35, 56, 0.08), 0 1px 2px rgba(26, 35, 56, 0.04);
--shadow-md: 0 4px 12px rgba(26, 35, 56, 0.1), 0 2px 4px rgba(26, 35, 56, 0.06);
--shadow-lg: 0 10px 30px rgba(26, 35, 56, 0.12), 0 4px 8px rgba(26, 35, 56, 0.06);
/* Transitions */
--transition: 200ms ease;
--transition-slow: 400ms ease;
/* Header */
--header-height: 4rem;
--header-border: 2px solid var(--color-border);
}
/* Accessible Solarpunk dark (system dark mode)
Activates when OS/browser is in dark mode.
Uses :not([data-theme="hacker"]) so the Konami easter
egg always wins over the system preference. */
@media (prefers-color-scheme: dark) {
:root:not([data-theme="hacker"]) {
/* Brand — lighter greens readable on dark surfaces */
--color-primary: #6ab870;
--color-primary-hover: #7ecb84;
--color-primary-light: #162616;
/* Surfaces — deep blue-slate, not pure black */
--color-surface: #16202e;
--color-surface-alt: #1e2a3a;
--color-surface-raised: #263547;
/* Borders */
--color-border: #2d4060;
--color-border-light: #233352;
/* Text */
--color-text: #e4eaf5;
--color-text-muted: #8da0bc;
--color-text-inverse: #16202e;
/* Accent — lighter amber for dark bg contrast (WCAG AA) */
--color-accent: #e8a84a;
--color-accent-hover: #f5bc60;
--color-accent-light: #2d1e0a;
/* Semantic */
--color-success: #5eb85e;
--color-error: #e05252;
--color-warning: #e8a84a;
--color-info: #4da6e8;
/* Shadows — darker base for dark bg */
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.3), 0 1px 2px rgba(0, 0, 0, 0.2);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.35), 0 2px 4px rgba(0, 0, 0, 0.2);
--shadow-lg: 0 10px 30px rgba(0, 0, 0, 0.4), 0 4px 8px rgba(0, 0, 0, 0.2);
}
}
/* ── Hacker/maker easter egg theme ──────────────── */
/* Activated by Konami code: ↑↑↓↓←→←→BA */
/* Stored in localStorage: 'cf-hacker-mode' */
/* Applied: document.documentElement.dataset.theme */
[data-theme="hacker"] {
--color-primary: #00ff41;
--color-primary-hover: #00cc33;
--color-primary-light: #001a00;
--color-surface: #0a0c0a;
--color-surface-alt: #0d120d;
--color-surface-raised: #111811;
--color-border: #1a3d1a;
--color-border-light: #123012;
--color-text: #b8f5b8;
--color-text-muted: #5a9a5a;
--color-text-inverse: #0a0c0a;
--color-accent: #00ff41;
--color-accent-hover: #00cc33;
--color-accent-light: #001a0a;
--color-success: #00ff41;
--color-error: #ff3333;
--color-warning: #ffaa00;
--color-info: #00aaff;
/* Hacker mode: mono font everywhere */
--font-display: 'JetBrains Mono', monospace;
--font-body: 'JetBrains Mono', monospace;
--shadow-sm: 0 1px 3px rgba(0, 255, 65, 0.08);
--shadow-md: 0 4px 12px rgba(0, 255, 65, 0.12);
--shadow-lg: 0 10px 30px rgba(0, 255, 65, 0.15);
--header-border: 2px solid var(--color-border);
/* Hacker glow variants — for box-shadow, text-shadow, bg overlays */
--color-accent-glow-xs: rgba(0, 255, 65, 0.08);
--color-accent-glow-sm: rgba(0, 255, 65, 0.15);
--color-accent-glow-md: rgba(0, 255, 65, 0.4);
--color-accent-glow-lg: rgba(0, 255, 65, 0.6);
}
/* ── Base resets ─────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; }
html {
font-family: var(--font-body);
color: var(--color-text);
background: var(--color-surface);
scroll-behavior: smooth;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body { margin: 0; min-height: 100vh; }
h1, h2, h3, h4, h5, h6 {
font-family: var(--font-display);
color: var(--color-primary);
line-height: 1.2;
margin: 0;
}
/* Focus visible — keyboard nav — accessibility requirement */
:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 3px;
border-radius: var(--radius-sm);
}
/* Respect reduced motion */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
/* ── Prose — CMS rich text ───────────────────────── */
.prose {
font-family: var(--font-body);
line-height: 1.75;
color: var(--color-text);
max-width: 65ch;
}
.prose h2 {
font-family: var(--font-display);
font-size: 1.5rem;
font-weight: 700;
margin: 2rem 0 0.75rem;
color: var(--color-primary);
}
.prose h3 {
font-family: var(--font-display);
font-size: 1.2rem;
font-weight: 600;
margin: 1.5rem 0 0.5rem;
color: var(--color-primary);
}
.prose p { margin: 0 0 1rem; }
.prose ul, .prose ol { margin: 0 0 1rem; padding-left: 1.5rem; }
.prose li { margin-bottom: 0.4rem; }
.prose a { color: var(--color-accent); text-decoration: underline; text-underline-offset: 3px; }
.prose strong { font-weight: 700; }
.prose code {
font-family: var(--font-mono);
font-size: 0.875em;
background: var(--color-surface-alt);
border: 1px solid var(--color-border-light);
padding: 0.1em 0.35em;
border-radius: var(--radius-sm);
}
.prose blockquote {
border-left: 3px solid var(--color-accent);
margin: 1.5rem 0;
padding: 0.5rem 0 0.5rem 1.25rem;
color: var(--color-text-muted);
font-style: italic;
}
/* ── Utility: screen reader only ────────────────── */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.sr-only:focus-visible {
position: fixed;
top: 0.5rem;
left: 0.5rem;
width: auto;
height: auto;
padding: 0.5rem 1rem;
clip: auto;
white-space: normal;
background: var(--color-accent);
color: var(--color-text-inverse);
border-radius: var(--radius-md);
font-weight: 600;
z-index: 9999;
}

View file

@ -0,0 +1,318 @@
<template>
<!-- Desktop: persistent sidebar (1024px) -->
<!-- Mobile: bottom tab bar (<1024px) -->
<!-- Design spec: circuitforge-plans/peregrine/2026-03-03-nuxt-frontend-design.md §3.1 -->
<nav class="app-sidebar" role="navigation" aria-label="Main navigation">
<!-- Brand -->
<div class="sidebar__brand">
<RouterLink to="/" class="sidebar__logo" @click.prevent="handleLogoClick">
<span class="sidebar__bird" :class="{ 'sidebar__bird--ruffle': ruffling }" aria-hidden="true">🦅</span>
<span class="sidebar__wordmark">Peregrine</span>
</RouterLink>
</div>
<!-- Nav links -->
<ul class="sidebar__links" role="list">
<li v-for="link in navLinks" :key="link.to">
<RouterLink
:to="link.to"
class="sidebar__link"
active-class="sidebar__link--active"
:aria-label="link.label"
>
<component :is="link.icon" class="sidebar__icon" aria-hidden="true" />
<span class="sidebar__label">{{ link.label }}</span>
<span v-if="link.badge" class="sidebar__badge" :aria-label="`${link.badge} items`">{{ link.badge }}</span>
</RouterLink>
</li>
</ul>
<!-- Hacker mode exit (shows when active) -->
<div v-if="isHackerMode" class="sidebar__hacker-exit">
<button class="sidebar__hacker-btn" @click="exitHackerMode">
Exit hacker mode
</button>
</div>
<!-- Settings at bottom -->
<div class="sidebar__footer">
<RouterLink to="/settings" class="sidebar__link sidebar__link--footer" active-class="sidebar__link--active">
<Cog6ToothIcon class="sidebar__icon" aria-hidden="true" />
<span class="sidebar__label">Settings</span>
</RouterLink>
</div>
</nav>
<!-- Mobile bottom tab bar -->
<nav class="app-tabbar" role="navigation" aria-label="Main navigation">
<ul class="tabbar__links" role="list">
<li v-for="link in mobileLinks" :key="link.to">
<RouterLink
:to="link.to"
class="tabbar__link"
active-class="tabbar__link--active"
:aria-label="link.label"
>
<component :is="link.icon" class="tabbar__icon" aria-hidden="true" />
<span class="tabbar__label">{{ link.label }}</span>
</RouterLink>
</li>
</ul>
</nav>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { RouterLink } from 'vue-router'
import {
HomeIcon,
ClipboardDocumentListIcon,
PencilSquareIcon,
CalendarDaysIcon,
LightBulbIcon,
MagnifyingGlassIcon,
NewspaperIcon,
Cog6ToothIcon,
} from '@heroicons/vue/24/outline'
import { useDigestStore } from '../stores/digest'
const digestStore = useDigestStore()
// Logo click easter egg 9.6: Click the Bird 5× rapidly
const logoClickCount = ref(0)
const ruffling = ref(false)
let clickTimer: ReturnType<typeof setTimeout> | null = null
function handleLogoClick() {
logoClickCount.value++
if (clickTimer) clearTimeout(clickTimer)
clickTimer = setTimeout(() => { logoClickCount.value = 0 }, 800)
if (logoClickCount.value >= 5) {
logoClickCount.value = 0
ruffling.value = true
setTimeout(() => { ruffling.value = false }, 600)
}
}
// Hacker mode state
const isHackerMode = computed(() =>
document.documentElement.dataset.theme === 'hacker',
)
function exitHackerMode() {
delete document.documentElement.dataset.theme
localStorage.removeItem('cf-hacker-mode')
}
const navLinks = computed(() => [
{ to: '/', icon: HomeIcon, label: 'Home' },
{ to: '/review', icon: ClipboardDocumentListIcon, label: 'Job Review' },
{ to: '/apply', icon: PencilSquareIcon, label: 'Apply' },
{ to: '/interviews', icon: CalendarDaysIcon, label: 'Interviews' },
{ to: '/digest', icon: NewspaperIcon, label: 'Digest',
badge: digestStore.entries.length || undefined },
{ to: '/prep', icon: LightBulbIcon, label: 'Interview Prep' },
{ to: '/survey', icon: MagnifyingGlassIcon, label: 'Survey' },
])
// Mobile: only the 5 most-used views
const mobileLinks = [
{ to: '/', icon: HomeIcon, label: 'Home' },
{ to: '/review', icon: ClipboardDocumentListIcon, label: 'Review' },
{ to: '/apply', icon: PencilSquareIcon, label: 'Apply' },
{ to: '/interviews', icon: CalendarDaysIcon, label: 'Interviews' },
{ to: '/settings', icon: Cog6ToothIcon, label: 'Settings' },
]
</script>
<style scoped>
/* ── Sidebar (desktop ≥1024px) ──────────────────────── */
.app-sidebar {
position: fixed;
top: 0;
left: 0;
bottom: 0;
width: var(--sidebar-width);
display: flex;
flex-direction: column;
background: var(--color-surface-raised);
border-right: 1px solid var(--color-border);
z-index: 100;
padding: var(--space-4) 0;
}
.sidebar__brand {
padding: 0 var(--space-4) var(--space-4);
border-bottom: 1px solid var(--color-border-light);
margin-bottom: var(--space-3);
}
.sidebar__logo {
display: flex;
align-items: center;
gap: var(--space-2);
text-decoration: none;
}
/* Click-the-bird ruffle animation — easter egg 9.6 */
.sidebar__bird {
font-size: 1.4rem;
display: inline-block;
transform-origin: center bottom;
}
.sidebar__bird--ruffle {
animation: bird-ruffle 0.5s ease;
}
@keyframes bird-ruffle {
0% { transform: rotate(0deg) scale(1); }
20% { transform: rotate(-8deg) scale(1.15); }
40% { transform: rotate(8deg) scale(1.2); }
60% { transform: rotate(-6deg) scale(1.1); }
80% { transform: rotate(4deg) scale(1.05); }
100% { transform: rotate(0deg) scale(1); }
}
.sidebar__wordmark {
font-family: var(--font-display);
font-weight: 700;
font-size: var(--text-lg);
color: var(--app-primary);
}
.sidebar__links {
flex: 1;
list-style: none;
margin: 0;
padding: 0 var(--space-3);
display: flex;
flex-direction: column;
gap: var(--space-1);
overflow-y: auto;
}
.sidebar__link {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-3) var(--space-4);
border-radius: var(--radius-md);
color: var(--color-text-muted);
text-decoration: none;
font-size: var(--text-sm);
font-weight: 500;
min-height: 44px; /* WCAG 2.5.5 touch target */
/* Enumerate properties explicitly — no transition:all. Gotcha #2. */
transition:
background 150ms ease,
color 150ms ease;
}
.sidebar__link:hover {
background: var(--app-primary-light);
color: var(--app-primary);
}
.sidebar__link--active {
background: var(--app-primary-light);
color: var(--app-primary);
font-weight: 600;
}
.sidebar__icon {
width: 1.25rem;
height: 1.25rem;
flex-shrink: 0;
}
.sidebar__badge {
margin-left: auto;
background: var(--app-accent);
color: var(--app-accent-text);
font-size: var(--text-xs);
font-weight: 700;
padding: 1px 6px;
border-radius: var(--radius-full);
min-width: 18px;
text-align: center;
}
/* Hacker mode exit button */
.sidebar__hacker-exit {
padding: var(--space-3);
border-top: 1px solid var(--color-border-light);
}
.sidebar__hacker-btn {
width: 100%;
padding: var(--space-2) var(--space-3);
background: transparent;
border: 1px solid var(--app-primary);
border-radius: var(--radius-md);
color: var(--app-primary);
font-family: var(--font-mono);
font-size: var(--text-xs);
cursor: pointer;
transition: background 150ms ease, color 150ms ease;
}
.sidebar__hacker-btn:hover {
background: var(--app-primary);
color: var(--color-surface);
}
.sidebar__footer {
padding: var(--space-3) var(--space-3) 0;
border-top: 1px solid var(--color-border-light);
}
.sidebar__link--footer {
margin: 0;
}
/* ── Mobile tab bar (<1024px) ───────────────────────── */
.app-tabbar {
display: none; /* hidden on desktop */
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: var(--color-surface-raised);
border-top: 1px solid var(--color-border);
z-index: 100;
padding-bottom: env(safe-area-inset-bottom); /* iPhone notch */
}
.tabbar__links {
display: flex;
list-style: none;
margin: 0;
padding: 0;
}
.tabbar__link {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
padding: var(--space-2) var(--space-1);
min-height: 56px; /* WCAG 2.5.5 touch target */
color: var(--color-text-muted);
text-decoration: none;
font-size: 10px;
transition: color 150ms ease;
}
.tabbar__link--active { color: var(--app-primary); }
.tabbar__icon { width: 1.5rem; height: 1.5rem; }
/* ── Responsive ─────────────────────────────────────── */
@media (max-width: 1023px) {
.app-sidebar { display: none; }
.app-tabbar { display: block; }
}
</style>

View file

@ -0,0 +1,862 @@
<template>
<div class="workspace">
<div v-if="loadingJob" class="workspace__loading" aria-live="polite">
<span class="spinner" aria-hidden="true" />
<span>Loading job</span>
</div>
<div v-else-if="!job" class="workspace__not-found" role="alert">
<p>Job not found.</p>
</div>
<template v-else>
<!-- Two-panel layout: job details | cover letter -->
<div class="workspace__panels">
<!-- Left: Job details -->
<aside class="workspace__job-panel">
<div class="job-details">
<!-- Badges -->
<div class="job-details__badges">
<span
v-if="job.match_score !== null"
class="score-badge"
:class="[scoreBadgeClass, { 'score-badge--shimmer': shimmeringBadge }]"
>
{{ job.match_score }}%
</span>
<span v-if="job.is_remote" class="remote-badge">Remote</span>
</div>
<h1 class="job-details__title">{{ job.title }}</h1>
<div class="job-details__company">
{{ job.company }}
<span v-if="job.location" aria-hidden="true"> · </span>
<span v-if="job.location" class="job-details__location">{{ job.location }}</span>
</div>
<div v-if="job.salary" class="job-details__salary">{{ job.salary }}</div>
<!-- Description -->
<div class="job-details__desc" :class="{ 'job-details__desc--clamped': !descExpanded }">
{{ job.description ?? 'No description available.' }}
</div>
<button
v-if="(job.description?.length ?? 0) > 300"
class="expand-btn"
:aria-expanded="descExpanded"
@click="descExpanded = !descExpanded"
>
{{ descExpanded ? 'Show less ▲' : 'Show more ▼' }}
</button>
<!-- Keyword gaps -->
<div v-if="gaps.length > 0" class="job-details__gaps">
<span class="gaps-label">Missing keywords:</span>
<span v-for="kw in gaps.slice(0, 6)" :key="kw" class="gap-pill">{{ kw }}</span>
<span v-if="gaps.length > 6" class="gaps-more">+{{ gaps.length - 6 }}</span>
</div>
<a v-if="job.url" :href="job.url" target="_blank" rel="noopener noreferrer" class="job-details__link">
View listing
</a>
</div>
</aside>
<!-- Right: Cover letter -->
<main class="workspace__cl-panel">
<h2 class="cl-heading">Cover Letter</h2>
<!-- State: none no draft yet -->
<template v-if="clState === 'none'">
<div class="cl-empty">
<p class="cl-empty__hint">No cover letter yet. Generate one with AI or paste your own.</p>
<div class="cl-empty__actions">
<button class="btn-generate" :disabled="generating" @click="generate()">
<span aria-hidden="true"></span> Generate with AI
</button>
<button class="btn-ghost" @click="clState = 'ready'; clText = ''">
Paste / write manually
</button>
</div>
</div>
</template>
<!-- State: queued / running generating -->
<template v-else-if="clState === 'queued' || clState === 'running'">
<div class="cl-generating" role="status" aria-live="polite">
<span class="spinner spinner--lg" aria-hidden="true" />
<p class="cl-generating__label">
{{ clState === 'queued' ? 'Queued…' : (taskStage ?? 'Generating cover letter…') }}
</p>
<p class="cl-generating__hint">This usually takes 2060 seconds</p>
</div>
</template>
<!-- State: failed -->
<template v-else-if="clState === 'failed'">
<div class="cl-error" role="alert">
<span aria-hidden="true"></span>
<span class="cl-error__msg">Cover letter generation failed</span>
<span v-if="taskError" class="cl-error__detail">{{ taskError }}</span>
<button class="btn-generate" @click="generate()">Retry</button>
</div>
</template>
<!-- State: ready editor -->
<template v-else-if="clState === 'ready'">
<div class="cl-editor">
<div class="cl-editor__toolbar">
<span class="cl-editor__wordcount" aria-live="polite">
{{ wordCount }} words
</span>
<button
class="btn-ghost btn-ghost--sm"
:disabled="isSaved || saving"
@click="saveCoverLetter"
>
{{ saving ? 'Saving…' : (isSaved ? '✓ Saved' : 'Save') }}
</button>
</div>
<textarea
ref="textareaEl"
v-model="clText"
class="cl-editor__textarea"
aria-label="Cover letter text"
placeholder="Your cover letter…"
@input="isSaved = false; autoResize()"
/>
</div>
<!-- Download PDF -->
<button class="btn-download" :disabled="!clText.trim() || downloadingPdf" @click="downloadPdf">
<span aria-hidden="true">📄</span>
{{ downloadingPdf ? 'Generating PDF…' : 'Download PDF' }}
</button>
</template>
<!-- Regenerate button (when ready, offer to redo) -->
<button
v-if="clState === 'ready'"
class="btn-ghost btn-ghost--sm cl-regen"
@click="generate()"
>
Regenerate
</button>
<!-- Bottom action bar -->
<div class="workspace__actions">
<button
class="action-btn action-btn--apply"
:disabled="actioning"
@click="markApplied"
>
<span aria-hidden="true">🚀</span>
{{ actioning === 'apply' ? 'Marking…' : 'Mark as Applied' }}
</button>
<button
class="action-btn action-btn--reject"
:disabled="!!actioning"
@click="rejectListing"
>
<span aria-hidden="true"></span>
{{ actioning === 'reject' ? 'Rejecting…' : 'Reject Listing' }}
</button>
</div>
</main>
</div>
</template>
<!-- Toast -->
<Transition name="toast">
<div v-if="toast" class="toast" role="status" aria-live="polite">{{ toast }}</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
import { useApiFetch } from '../composables/useApi'
import type { Job } from '../stores/review'
const props = defineProps<{ jobId: number }>()
const emit = defineEmits<{
'job-removed': []
'cover-letter-generated': []
}>()
// Perfect Match intentionally matches score-badge--high boundary (70%); update together
const PERFECT_MATCH_THRESHOLD = 70
const shimmeringBadge = ref(false)
// Job
const job = ref<Job | null>(null)
const loadingJob = ref(true)
const descExpanded = ref(false)
const gaps = computed<string[]>(() => {
if (!job.value?.keyword_gaps) return []
try { return JSON.parse(job.value.keyword_gaps) as string[] }
catch { return [] }
})
const scoreBadgeClass = computed(() => {
const s = job.value?.match_score ?? 0
if (s >= 70) return 'score-badge--high'
if (s >= 50) return 'score-badge--mid-high'
if (s >= 30) return 'score-badge--mid'
return 'score-badge--low'
})
// Cover letter state machine
// none queued running ready | failed
type ClState = 'none' | 'queued' | 'running' | 'ready' | 'failed'
const clState = ref<ClState>('none')
const clText = ref('')
const isSaved = ref(true)
const saving = ref(false)
const generating = ref(false)
const taskStage = ref<string | null>(null)
const taskError = ref<string | null>(null)
const wordCount = computed(() => {
const words = clText.value.trim().split(/\s+/).filter(Boolean)
return words.length
})
// Polling
let pollTimer = 0
function startPolling() {
stopPolling()
pollTimer = window.setInterval(pollTaskStatus, 2000)
}
function stopPolling() {
clearInterval(pollTimer)
}
async function pollTaskStatus() {
const { data } = await useApiFetch<{
status: string
stage: string | null
message: string | null
}>(`/api/jobs/${props.jobId}/cover_letter/task`)
if (!data) return
taskStage.value = data.stage
if (data.status === 'completed') {
stopPolling()
// Re-fetch the job to get the new cover letter text
await fetchJob()
clState.value = 'ready'
generating.value = false
emit('cover-letter-generated')
} else if (data.status === 'failed') {
stopPolling()
clState.value = 'failed'
taskError.value = data.message ?? 'Unknown error'
generating.value = false
} else {
clState.value = data.status === 'queued' ? 'queued' : 'running'
}
}
// Actions
async function generate() {
if (generating.value) return
generating.value = true
clState.value = 'queued'
taskError.value = null
const { error } = await useApiFetch(`/api/jobs/${props.jobId}/cover_letter/generate`, { method: 'POST' })
if (error) {
clState.value = 'failed'
taskError.value = error.kind === 'http' ? error.detail : 'Network error'
generating.value = false
return
}
startPolling()
}
async function saveCoverLetter() {
saving.value = true
const { error } = await useApiFetch(`/api/jobs/${props.jobId}/cover_letter`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: clText.value }),
})
saving.value = false
if (error) {
showToast('Save failed — please try again')
return
}
isSaved.value = true
}
// PDF download
const downloadingPdf = ref(false)
async function downloadPdf() {
if (!job.value) return
downloadingPdf.value = true
try {
const res = await fetch(`/api/jobs/${props.jobId}/cover_letter/pdf`)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
const company = job.value.company.replace(/[^a-zA-Z0-9]/g, '') || 'Company'
const dateStr = new Date().toISOString().slice(0, 10)
a.href = url
a.download = `CoverLetter_${company}_${dateStr}.pdf`
a.click()
URL.revokeObjectURL(url)
} catch {
showToast('PDF generation failed — save first and try again')
} finally {
downloadingPdf.value = false
}
}
// Mark applied / reject
const actioning = ref<'apply' | 'reject' | null>(null)
async function markApplied() {
if (actioning.value) return
actioning.value = 'apply'
if (!isSaved.value) await saveCoverLetter()
await useApiFetch(`/api/jobs/${props.jobId}/applied`, { method: 'POST' })
actioning.value = null
showToast('Marked as applied ✓')
setTimeout(() => emit('job-removed'), 1200)
}
async function rejectListing() {
if (actioning.value) return
actioning.value = 'reject'
await useApiFetch(`/api/jobs/${props.jobId}/reject`, { method: 'POST' })
actioning.value = null
showToast('Listing rejected')
setTimeout(() => emit('job-removed'), 1000)
}
// Toast
const toast = ref<string | null>(null)
let toastTimer = 0
function showToast(msg: string) {
clearTimeout(toastTimer)
toast.value = msg
toastTimer = window.setTimeout(() => { toast.value = null }, 3500)
}
// Auto-resize textarea
const textareaEl = ref<HTMLTextAreaElement | null>(null)
function autoResize() {
const el = textareaEl.value
if (!el) return
el.style.height = 'auto'
el.style.height = `${el.scrollHeight}px`
}
watch(clText, () => nextTick(autoResize))
// Data loading
async function fetchJob() {
const { data } = await useApiFetch<Job>(`/api/jobs/${props.jobId}`)
if (data) {
job.value = data
if (data.cover_letter) {
clText.value = data.cover_letter as string
clState.value = 'ready'
isSaved.value = true
}
if ((data.match_score ?? 0) >= PERFECT_MATCH_THRESHOLD) {
shimmeringBadge.value = false
nextTick(() => { shimmeringBadge.value = true })
setTimeout(() => { shimmeringBadge.value = false }, 850)
}
}
}
onMounted(async () => {
await fetchJob()
loadingJob.value = false
// Check if a generation task is already in flight
if (clState.value === 'none') {
const { data } = await useApiFetch<{ status: string; stage: string | null }>(`/api/jobs/${props.jobId}/cover_letter/task`)
if (data && (data.status === 'queued' || data.status === 'running')) {
clState.value = data.status as ClState
taskStage.value = data.stage
generating.value = true
startPolling()
}
}
await nextTick(autoResize)
})
onUnmounted(() => {
stopPolling()
clearTimeout(toastTimer)
})
// Extra type to allow cover_letter field on Job
declare module '../stores/review' {
interface Job { cover_letter?: string | null }
}
</script>
<style scoped>
.workspace {
max-width: 1200px;
margin: 0 auto;
padding: var(--space-6) var(--space-6) var(--space-12);
display: flex;
flex-direction: column;
gap: var(--space-5);
}
.workspace__loading,
.workspace__not-found {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-4);
padding: var(--space-16);
color: var(--color-text-muted);
font-size: var(--text-sm);
}
/* ── Two-panel layout ────────────────────────────────────────────────── */
.workspace__panels {
display: grid;
grid-template-columns: 1fr 1.3fr;
gap: var(--space-6);
align-items: start;
}
/* ── Job details panel ───────────────────────────────────────────────── */
.workspace__job-panel {
position: sticky;
top: var(--space-4);
}
.job-details {
background: var(--color-surface-raised);
border: 1px solid var(--color-border-light);
border-radius: var(--radius-lg);
padding: var(--space-5);
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.job-details__badges { display: flex; flex-wrap: wrap; gap: var(--space-2); }
.job-details__title {
font-family: var(--font-display);
font-size: var(--text-xl);
color: var(--color-text);
line-height: 1.25;
}
.job-details__company {
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-text-muted);
}
.job-details__location { font-weight: 400; }
.job-details__salary {
font-size: var(--text-sm);
font-weight: 700;
color: var(--color-success);
}
.job-details__desc {
font-size: var(--text-sm);
color: var(--color-text);
line-height: 1.6;
white-space: pre-wrap;
overflow-wrap: break-word;
}
.job-details__desc--clamped {
display: -webkit-box;
-webkit-line-clamp: 6;
-webkit-box-orient: vertical;
overflow: hidden;
}
.expand-btn {
align-self: flex-start;
background: transparent;
border: none;
color: var(--app-primary);
font-size: var(--text-xs);
cursor: pointer;
padding: 0;
font-weight: 600;
}
.job-details__gaps { display: flex; flex-wrap: wrap; gap: var(--space-1); align-items: center; }
.gaps-label { font-size: var(--text-xs); color: var(--color-text-muted); font-weight: 600; }
.gap-pill {
padding: 1px var(--space-2);
border-radius: 999px;
font-size: var(--text-xs);
background: var(--color-surface-alt);
border: 1px solid var(--color-border-light);
color: var(--color-text-muted);
}
.gaps-more { font-size: var(--text-xs); color: var(--color-text-muted); }
.job-details__link {
font-size: var(--text-xs);
color: var(--app-primary);
font-weight: 600;
text-decoration: none;
align-self: flex-start;
transition: opacity 150ms ease;
}
.job-details__link:hover { opacity: 0.7; }
/* ── Cover letter panel ──────────────────────────────────────────────── */
.workspace__cl-panel {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.cl-heading {
font-family: var(--font-display);
font-size: var(--text-xl);
color: var(--color-text);
}
/* Empty state */
.cl-empty {
background: var(--color-surface-raised);
border: 1px solid var(--color-border-light);
border-radius: var(--radius-lg);
padding: var(--space-8);
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-4);
text-align: center;
}
.cl-empty__hint { font-size: var(--text-sm); color: var(--color-text-muted); max-width: 36ch; }
.cl-empty__actions { display: flex; flex-direction: column; gap: var(--space-2); width: 100%; max-width: 260px; }
/* Generating state */
.cl-generating {
background: var(--color-surface-raised);
border: 1px solid var(--color-border-light);
border-radius: var(--radius-lg);
padding: var(--space-10);
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-4);
text-align: center;
}
.cl-generating__label {
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-text);
}
.cl-generating__hint { font-size: var(--text-xs); color: var(--color-text-muted); }
/* Error state */
.cl-error {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--space-2);
padding: var(--space-5);
background: rgba(192, 57, 43, 0.06);
border: 1px solid var(--color-error);
border-radius: var(--radius-lg);
color: var(--color-error);
font-size: var(--text-sm);
font-weight: 600;
}
.cl-error__msg { font-weight: 700; }
.cl-error__detail { font-size: var(--text-xs); color: var(--color-text-muted); font-weight: 400; }
/* Editor */
.cl-editor {
background: var(--color-surface-raised);
border: 1px solid var(--color-border-light);
border-radius: var(--radius-lg);
overflow: hidden;
display: flex;
flex-direction: column;
}
.cl-editor__toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-2) var(--space-4);
border-bottom: 1px solid var(--color-border-light);
background: var(--color-surface-alt);
}
.cl-editor__wordcount {
font-size: var(--text-xs);
color: var(--color-text-muted);
font-family: var(--font-mono);
}
.cl-editor__textarea {
width: 100%;
min-height: 360px;
padding: var(--space-5);
border: none;
background: transparent;
color: var(--color-text);
font-family: var(--font-body);
font-size: var(--text-sm);
line-height: 1.7;
resize: none;
overflow: hidden;
}
.cl-editor__textarea:focus { outline: none; }
.cl-regen {
align-self: flex-end;
color: var(--color-text-muted);
}
/* Download button */
.btn-download {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-3) var(--space-5);
background: var(--color-surface-raised);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
color: var(--color-text);
font-size: var(--text-sm);
font-weight: 600;
cursor: pointer;
transition: background 150ms ease, border-color 150ms ease;
min-height: 44px;
width: 100%;
justify-content: center;
}
.btn-download:hover:not(:disabled) { background: var(--app-primary-light); border-color: var(--app-primary); }
.btn-download:disabled { opacity: 0.5; cursor: not-allowed; }
/* Generate button */
.btn-generate {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
padding: var(--space-3) var(--space-5);
background: var(--app-accent);
color: var(--app-accent-text);
border: none;
border-radius: var(--radius-md);
font-size: var(--text-sm);
font-weight: 700;
cursor: pointer;
min-height: 44px;
transition: background 150ms ease;
width: 100%;
}
.btn-generate:hover:not(:disabled) { background: var(--app-accent-hover); }
.btn-generate:disabled { opacity: 0.6; cursor: not-allowed; }
/* ── Action bar ──────────────────────────────────────────────────────── */
.workspace__actions {
display: flex;
gap: var(--space-3);
padding-top: var(--space-2);
border-top: 1px solid var(--color-border-light);
margin-top: var(--space-2);
}
.action-btn {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
padding: var(--space-3) var(--space-4);
border-radius: var(--radius-lg);
font-size: var(--text-sm);
font-weight: 700;
cursor: pointer;
border: 2px solid transparent;
min-height: 48px;
transition: background 150ms ease, border-color 150ms ease;
}
.action-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.action-btn--apply {
background: rgba(39, 174, 96, 0.10);
border-color: var(--color-success);
color: var(--color-success);
}
.action-btn--apply:hover:not(:disabled) { background: rgba(39, 174, 96, 0.20); }
.action-btn--reject {
background: rgba(192, 57, 43, 0.08);
border-color: var(--color-error);
color: var(--color-error);
}
.action-btn--reject:hover:not(:disabled) { background: rgba(192, 57, 43, 0.16); }
/* ── Shared badges ───────────────────────────────────────────────────── */
.score-badge {
padding: 2px var(--space-2);
border-radius: 999px;
font-size: var(--text-xs);
font-weight: 700;
font-family: var(--font-mono);
}
.score-badge--high { background: rgba(39,174,96,0.12); color: var(--score-high); }
.score-badge--mid-high { background: rgba(43,124,184,0.12); color: var(--score-mid-high); }
.score-badge--mid { background: rgba(212,137,26,0.12); color: var(--score-mid); }
.score-badge--low { background: rgba(192,57,43,0.12); color: var(--score-low); }
/* Perfect Match shimmer — fires once when a ≥70% job opens */
@keyframes shimmer-badge {
0% { box-shadow: 0 0 0 0 rgba(212, 175, 55, 0); background: rgba(39,174,96,0.12); }
30% { box-shadow: 0 0 8px 3px rgba(212, 175, 55, 0.6); background: rgba(212, 175, 55, 0.2); }
100% { box-shadow: 0 0 0 0 rgba(212, 175, 55, 0); background: rgba(39,174,96,0.12); }
}
.score-badge--shimmer { animation: shimmer-badge 850ms ease-out forwards; }
.remote-badge {
padding: 2px var(--space-2);
border-radius: 999px;
font-size: var(--text-xs);
font-weight: 600;
background: var(--app-primary-light);
color: var(--app-primary);
}
/* ── Ghost button ────────────────────────────────────────────────────── */
.btn-ghost {
display: inline-flex;
align-items: center;
justify-content: center;
padding: var(--space-2) var(--space-4);
background: transparent;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
color: var(--color-text-muted);
font-size: var(--text-sm);
font-weight: 600;
cursor: pointer;
min-height: 36px;
transition: background 150ms ease, color 150ms ease;
text-decoration: none;
}
.btn-ghost:hover { background: var(--color-surface-alt); color: var(--color-text); }
.btn-ghost:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-ghost--sm { font-size: var(--text-xs); padding: var(--space-1) var(--space-3); min-height: 28px; }
/* ── Spinner ─────────────────────────────────────────────────────────── */
.spinner {
width: 1.2rem;
height: 1.2rem;
border: 2px solid var(--color-border);
border-top-color: var(--app-primary);
border-radius: 50%;
animation: spin 0.7s linear infinite;
flex-shrink: 0;
}
.spinner--lg { width: 2rem; height: 2rem; border-width: 3px; }
@keyframes spin { to { transform: rotate(360deg); } }
/* ── Toast ───────────────────────────────────────────────────────────── */
.toast {
position: fixed;
bottom: var(--space-6);
left: 50%;
transform: translateX(-50%);
background: var(--color-surface-raised);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--space-3) var(--space-5);
font-size: var(--text-sm);
color: var(--color-text);
box-shadow: var(--shadow-lg);
z-index: 300;
white-space: nowrap;
}
.toast-enter-active, .toast-leave-active { transition: opacity 250ms ease, transform 250ms ease; }
.toast-enter-from, .toast-leave-to { opacity: 0; transform: translateX(-50%) translateY(8px); }
/* ── Responsive ──────────────────────────────────────────────────────── */
@media (max-width: 900px) {
.workspace__panels {
grid-template-columns: 1fr;
}
.workspace__job-panel {
position: static;
}
.cl-editor__textarea { min-height: 260px; }
.toast {
left: var(--space-4);
right: var(--space-4);
transform: none;
bottom: calc(56px + env(safe-area-inset-bottom) + var(--space-3));
}
.toast-enter-from, .toast-leave-to { transform: translateY(8px); }
}
@media (max-width: 600px) {
.workspace { padding: var(--space-4); }
.workspace__actions { flex-direction: column; }
}
</style>

View file

@ -0,0 +1,48 @@
<template>
<button
class="classic-ui-btn"
:title="label"
@click="switchToClassic"
>
{{ label }}
</button>
</template>
<script setup lang="ts">
const props = withDefaults(defineProps<{
label?: string
}>(), {
label: 'Switch to Classic UI',
})
function switchToClassic(): void {
// Set cookie so Caddy routes next request to Streamlit
document.cookie = 'prgn_ui=streamlit; path=/; SameSite=Lax'
// Append ?prgn_switch=streamlit so Streamlit's sync_ui_cookie()
// updates user.yaml to match cookie alone can't be read server-side
const url = new URL(window.location.href)
url.searchParams.set('prgn_switch', 'streamlit')
window.location.href = url.toString()
}
</script>
<style scoped>
.classic-ui-btn {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.4rem 0.9rem;
border-radius: 0.5rem;
border: 1px solid var(--color-border, #444);
background: transparent;
color: var(--color-text-muted, #aaa);
font-size: 0.8rem;
cursor: pointer;
transition: color 0.15s, border-color 0.15s;
}
.classic-ui-btn:hover {
color: var(--color-text, #eee);
border-color: var(--color-text, #eee);
}
</style>

View file

@ -0,0 +1,426 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import type { PipelineJob } from '../stores/interviews'
import type { StageSignal, PipelineStage } from '../stores/interviews'
import { useApiFetch } from '../composables/useApi'
const props = defineProps<{
job: PipelineJob
focused?: boolean
}>()
const emit = defineEmits<{
move: [jobId: number, preSelectedStage?: PipelineStage]
prep: [jobId: number]
survey: [jobId: number]
}>()
// Signal state
const sigExpanded = ref(false)
interface SignalMeta {
label: string
stage: PipelineStage
color: 'amber' | 'green' | 'red'
}
const SIGNAL_META: Record<StageSignal['stage_signal'], SignalMeta> = {
interview_scheduled: { label: 'Move to Phone Screen', stage: 'phone_screen', color: 'amber' },
positive_response: { label: 'Move to Phone Screen', stage: 'phone_screen', color: 'amber' },
offer_received: { label: 'Move to Offer', stage: 'offer', color: 'green' },
survey_received: { label: 'Move to Survey', stage: 'survey', color: 'amber' },
rejected: { label: 'Mark Rejected', stage: 'interview_rejected', color: 'red' },
}
const COLOR_BG: Record<'amber' | 'green' | 'red', string> = {
amber: 'rgba(245,158,11,0.08)',
green: 'rgba(39,174,96,0.08)',
red: 'rgba(192,57,43,0.08)',
}
const COLOR_BORDER: Record<'amber' | 'green' | 'red', string> = {
amber: 'rgba(245,158,11,0.4)',
green: 'rgba(39,174,96,0.4)',
red: 'rgba(192,57,43,0.4)',
}
function visibleSignals(): StageSignal[] {
const sigs = props.job.stage_signals ?? []
return sigExpanded.value ? sigs : sigs.slice(0, 1)
}
async function dismissSignal(sig: StageSignal) {
// Optimistic removal
const arr = props.job.stage_signals
const idx = arr.findIndex(s => s.id === sig.id)
if (idx !== -1) arr.splice(idx, 1)
await useApiFetch(`/api/stage-signals/${sig.id}/dismiss`, { method: 'POST' })
}
const expandedSignalIds = ref(new Set<number>())
function toggleBodyExpand(sigId: number) {
const next = new Set(expandedSignalIds.value)
if (next.has(sigId)) next.delete(sigId)
else next.add(sigId)
expandedSignalIds.value = next
}
// Re-classify chips neutral/unrelated/digest trigger two-call dismiss path
const RECLASSIFY_CHIPS = [
{ label: '🟡 Interview', value: 'interview_scheduled' as const },
{ label: '✅ Positive', value: 'positive_response' as const },
{ label: '🟢 Offer', value: 'offer_received' as const },
{ label: '📋 Survey', value: 'survey_received' as const },
{ label: '✖ Rejected', value: 'rejected' as const },
{ label: '🚫 Unrelated', value: 'unrelated' },
{ label: '📰 Digest', value: 'digest' },
{ label: '— Neutral', value: 'neutral' },
] as const
const DISMISS_LABELS = new Set(['neutral', 'unrelated', 'digest'] as const)
async function reclassifySignal(sig: StageSignal, newLabel: StageSignal['stage_signal'] | 'neutral' | 'unrelated' | 'digest') {
if (DISMISS_LABELS.has(newLabel)) {
// Optimistic removal
const arr = props.job.stage_signals
const idx = arr.findIndex(s => s.id === sig.id)
if (idx !== -1) arr.splice(idx, 1)
// Two-call path: persist label (Avocet training hook) then dismiss
await useApiFetch(`/api/stage-signals/${sig.id}/reclassify`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ stage_signal: newLabel }),
})
await useApiFetch(`/api/stage-signals/${sig.id}/dismiss`, { method: 'POST' })
// Digest-only: add to browsable queue (fire-and-forget; sig.id === job_contacts.id)
if (newLabel === 'digest') {
void useApiFetch('/api/digest-queue', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ job_contact_id: sig.id }),
})
}
} else {
const prev = sig.stage_signal
sig.stage_signal = newLabel
const { error } = await useApiFetch(`/api/stage-signals/${sig.id}/reclassify`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ stage_signal: newLabel }),
})
if (error) sig.stage_signal = prev
}
}
const scoreClass = computed(() => {
const s = (props.job.match_score ?? 0) * 100
if (s >= 85) return 'score--high'
if (s >= 65) return 'score--mid'
return 'score--low'
})
const scoreLabel = computed(() =>
props.job.match_score != null
? `${Math.round(props.job.match_score * 100)}%`
: '—'
)
const interviewDateLabel = computed(() => {
if (!props.job.interview_date) return null
const d = new Date(props.job.interview_date)
const now = new Date()
const diffDays = Math.round((d.getTime() - now.getTime()) / 86400000)
const timeStr = d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
if (diffDays === 0) return `Today ${timeStr}`
if (diffDays === 1) return `Tomorrow ${timeStr}`
if (diffDays === -1) return `Yesterday ${timeStr}`
if (diffDays > 1 && diffDays < 7) return `${d.toLocaleDateString([], { weekday: 'short' })} ${timeStr}`
return d.toLocaleDateString([], { month: 'short', day: 'numeric' })
})
const dateChipIcon = computed(() => {
if (!props.job.interview_date) return ''
const map: Record<string, string> = { phone_screen: '📞', interviewing: '🎯', offer: '📜' }
return map[props.job.status] ?? '📅'
})
const columnColor = computed(() => {
const map: Record<string, string> = {
phone_screen: 'var(--status-phone)',
interviewing: 'var(--color-info)',
offer: 'var(--status-offer)',
hired: 'var(--color-success)',
}
return map[props.job.status] ?? 'var(--color-border)'
})
</script>
<template>
<article
class="interview-card"
:class="{ 'interview-card--focused': focused }"
:style="{ '--card-accent': columnColor }"
tabindex="0"
:aria-label="`${job.title} at ${job.company}`"
@keydown.enter="emit('prep', job.id)"
@keydown.m.exact="emit('move', job.id)"
>
<div class="card-body">
<div class="card-title">{{ job.title }}</div>
<div class="card-company">
{{ job.company }}
<span v-if="job.salary" class="card-salary">· {{ job.salary }}</span>
</div>
<div class="card-badges">
<span class="score-badge" :class="scoreClass">{{ scoreLabel }}</span>
<span v-if="job.is_remote" class="remote-badge">Remote</span>
</div>
<div v-if="interviewDateLabel" class="date-chip">
{{ dateChipIcon }} {{ interviewDateLabel }}
</div>
</div>
<footer class="card-footer">
<button class="card-action" @click.stop="emit('move', job.id)">Move to </button>
<button v-if="['phone_screen', 'interviewing', 'offer'].includes(job.status)" class="card-action" @click.stop="emit('prep', job.id)">Prep </button>
<button
v-if="['survey', 'phone_screen', 'interviewing', 'offer'].includes(job.status)"
class="card-action"
@click.stop="emit('survey', job.id)"
>Survey </button>
</footer>
<!-- Signal banners -->
<template v-if="job.stage_signals?.length">
<div
v-for="sig in visibleSignals()"
:key="sig.id"
class="signal-banner"
:style="{
background: COLOR_BG[SIGNAL_META[sig.stage_signal].color],
borderTopColor: COLOR_BORDER[SIGNAL_META[sig.stage_signal].color],
}"
>
<div class="signal-header">
<span class="signal-label">
📧 <strong>{{ SIGNAL_META[sig.stage_signal].label.replace('Move to ', '') }}</strong>
</span>
<span class="signal-subject">{{ sig.subject.slice(0, 60) }}{{ sig.subject.length > 60 ? '…' : '' }}</span>
<div class="signal-header-actions">
<button class="btn-signal-read" @click.stop="toggleBodyExpand(sig.id)">
{{ expandedSignalIds.has(sig.id) ? '▾ Hide' : '▸ Read' }}
</button>
<button
class="btn-signal-move"
@click.stop="emit('move', props.job.id, SIGNAL_META[sig.stage_signal].stage)"
:aria-label="`${SIGNAL_META[sig.stage_signal].label} for ${props.job.title}`"
> Move</button>
<button
class="btn-signal-dismiss"
@click.stop="dismissSignal(sig)"
aria-label="Dismiss signal"
></button>
</div>
</div>
<!-- Expanded body + reclassify chips -->
<div v-if="expandedSignalIds.has(sig.id)" class="signal-body-expanded">
<div v-if="sig.from_addr" class="signal-from">From: {{ sig.from_addr }}</div>
<div v-if="sig.body" class="signal-body-text">{{ sig.body }}</div>
<div v-else class="signal-body-empty">No email body available.</div>
<div class="signal-reclassify">
<span class="signal-reclassify-label">Re-classify:</span>
<button
v-for="chip in RECLASSIFY_CHIPS"
:key="chip.value"
class="btn-chip"
:class="{ 'btn-chip-active': sig.stage_signal === chip.value }"
@click.stop="reclassifySignal(sig, chip.value)"
>{{ chip.label }}</button>
</div>
</div>
</div>
<button
v-if="(job.stage_signals?.length ?? 0) > 1"
class="btn-sig-expand"
@click.stop="sigExpanded = !sigExpanded"
>{{ sigExpanded ? ' less' : `+${(job.stage_signals?.length ?? 1) - 1} more` }}</button>
</template>
</article>
</template>
<style scoped>
.interview-card {
background: var(--color-surface-raised);
border-radius: 10px;
border-left: 4px solid var(--card-accent, var(--color-border));
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.07);
cursor: pointer;
outline: none;
transition: box-shadow 150ms;
}
.interview-card--focused,
.interview-card:focus-visible {
box-shadow: 0 0 0 3px var(--card-accent, var(--color-primary));
}
.card-body {
padding: 10px 12px 8px;
display: flex;
flex-direction: column;
gap: 4px;
}
.card-title {
font-weight: 700;
font-size: 0.875rem;
color: var(--color-text);
line-height: 1.2;
}
.card-company {
font-size: 0.75rem;
color: var(--color-text-muted);
}
.card-salary {
color: var(--color-text-muted);
}
.card-badges {
display: flex;
flex-wrap: wrap;
gap: 5px;
margin-top: 2px;
}
.score-badge {
border-radius: 99px;
padding: 2px 8px;
font-size: 0.7rem;
font-weight: 700;
}
.score--high {
background: color-mix(in srgb, var(--color-success) 18%, var(--color-surface-raised));
color: var(--color-success);
}
.score--mid {
background: color-mix(in srgb, var(--color-warning) 18%, var(--color-surface-raised));
color: var(--color-warning);
}
.score--low {
background: color-mix(in srgb, var(--color-error) 18%, var(--color-surface-raised));
color: var(--color-error);
}
.remote-badge {
border-radius: 99px;
padding: 2px 8px;
font-size: 0.7rem;
font-weight: 700;
background: color-mix(in srgb, var(--color-info) 14%, var(--color-surface-raised));
color: var(--color-info);
}
.date-chip {
display: inline-flex;
align-items: center;
gap: 4px;
background: color-mix(in srgb, var(--color-info) 12%, var(--color-surface-raised));
color: var(--color-info);
border-radius: 6px;
padding: 3px 8px;
font-size: 0.7rem;
font-weight: 700;
margin-top: 2px;
align-self: flex-start;
}
.card-footer {
border-top: 1px solid var(--color-border-light);
padding: 6px 10px;
display: flex;
align-items: center;
justify-content: space-between;
background: color-mix(in srgb, var(--color-surface) 60%, transparent);
}
.card-action {
background: none;
border: none;
cursor: pointer;
font-size: 0.7rem;
font-weight: 700;
color: var(--color-info);
padding: 2px 4px;
border-radius: 4px;
}
.card-action:hover {
background: var(--color-surface);
}
.signal-banner {
border-top: 1px solid transparent; /* color set inline */
padding: 8px 12px;
display: flex; flex-direction: column; gap: 4px;
}
.signal-label { font-size: 0.82em; }
.signal-subject { font-size: 0.78em; color: var(--color-text-muted); }
.btn-signal-move {
background: var(--color-primary); color: #fff;
border: none; border-radius: 4px; padding: 2px 8px; font-size: 0.78em; cursor: pointer;
}
.btn-signal-dismiss {
background: none; border: none; color: var(--color-text-muted); font-size: 0.85em; cursor: pointer;
padding: 2px 4px;
}
.btn-signal-read {
background: none; border: none; color: var(--color-text-muted); font-size: 0.82em;
cursor: pointer; padding: 2px 6px; white-space: nowrap;
}
.signal-header {
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
}
.signal-header-actions {
margin-left: auto; display: flex; gap: 6px; align-items: center;
}
.signal-body-expanded {
margin-top: 8px; font-size: 0.8em; border-top: 1px dashed var(--color-border);
padding-top: 8px;
}
.signal-from {
color: var(--color-text-muted); margin-bottom: 4px;
}
.signal-body-text {
white-space: pre-wrap; color: var(--color-text); line-height: 1.5;
max-height: 200px; overflow-y: auto;
}
.signal-body-empty {
color: var(--color-text-muted); font-style: italic;
}
.signal-reclassify {
display: flex; gap: 6px; flex-wrap: wrap; align-items: center; margin-top: 8px;
}
.signal-reclassify-label {
font-size: 0.75em; color: var(--color-text-muted);
}
.btn-chip {
background: var(--color-surface); color: var(--color-text-muted);
border: 1px solid var(--color-border); border-radius: 4px;
padding: 2px 7px; font-size: 0.75em; cursor: pointer;
}
.btn-chip:hover {
background: var(--color-hover);
}
.btn-chip-active {
background: var(--color-primary-muted, #e8f0ff);
color: var(--color-primary); border-color: var(--color-primary);
font-weight: 600;
}
.btn-sig-expand {
background: none; border: none; font-size: 0.75em; color: var(--color-info); cursor: pointer;
padding: 4px 12px; text-align: left;
}
</style>

View file

@ -0,0 +1,282 @@
<template>
<article
class="job-card"
:class="{
'job-card--expanded': expanded,
'job-card--shimmer': isPerfectMatch,
}"
:aria-label="`${job.title} at ${job.company}`"
>
<!-- Score badge + remote badge -->
<div class="job-card__badges">
<span
v-if="job.match_score !== null"
class="score-badge"
:class="scoreBadgeClass"
:aria-label="`${job.match_score}% match`"
>
{{ job.match_score }}%
</span>
<span v-if="job.is_remote" class="remote-badge">Remote</span>
</div>
<!-- Title + company -->
<h2 class="job-card__title">{{ job.title }}</h2>
<div class="job-card__company">
<span>{{ job.company }}</span>
<span v-if="job.location" class="job-card__sep" aria-hidden="true"> · </span>
<span v-if="job.location" class="job-card__location">{{ job.location }}</span>
</div>
<!-- Salary -->
<div v-if="job.salary" class="job-card__salary">{{ job.salary }}</div>
<!-- Description -->
<div class="job-card__desc" :class="{ 'job-card__desc--clamped': !expanded }">
{{ descriptionText }}
</div>
<!-- Expand/collapse -->
<button
v-if="job.description && job.description.length > DESC_LIMIT"
class="job-card__expand-btn"
:aria-expanded="expanded"
@click.stop="$emit(expanded ? 'collapse' : 'expand')"
>
{{ expanded ? 'Show less ▲' : 'Show more ▼' }}
</button>
<!-- Keyword gaps -->
<div v-if="gaps.length > 0" class="job-card__gaps">
<span class="job-card__gaps-label">Missing keywords:</span>
<span v-for="kw in gaps.slice(0, 5)" :key="kw" class="gap-pill">{{ kw }}</span>
<span v-if="gaps.length > 5" class="job-card__gaps-more">+{{ gaps.length - 5 }} more</span>
</div>
<!-- Footer: source + date -->
<div class="job-card__footer">
<a
v-if="job.url"
:href="job.url"
class="job-card__url"
target="_blank"
rel="noopener noreferrer"
@click.stop
>View listing </a>
<span class="job-card__date">{{ formattedDate }}</span>
</div>
</article>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { Job } from '../stores/review'
const props = defineProps<{
job: Job
expanded: boolean
}>()
defineEmits<{ expand: []; collapse: [] }>()
const DESC_LIMIT = 300
const isPerfectMatch = computed(() => (props.job.match_score ?? 0) >= 95)
const scoreBadgeClass = computed(() => {
const s = props.job.match_score ?? 0
if (s >= 80) return 'score-badge--high'
if (s >= 60) return 'score-badge--mid'
return 'score-badge--low'
})
const gaps = computed<string[]>(() => {
if (!props.job.keyword_gaps) return []
try { return JSON.parse(props.job.keyword_gaps) as string[] }
catch { return [] }
})
const descriptionText = computed(() => {
const d = props.job.description ?? ''
return !props.expanded && d.length > DESC_LIMIT
? d.slice(0, DESC_LIMIT) + '…'
: d
})
const formattedDate = computed(() => {
if (!props.job.date_found) return ''
const d = new Date(props.job.date_found)
const days = Math.floor((Date.now() - d.getTime()) / 86400000)
if (days === 0) return 'Today'
if (days === 1) return 'Yesterday'
if (days < 7) return `${days}d ago`
if (days < 30) return `${Math.floor(days / 7)}w ago`
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
})
</script>
<style scoped>
.job-card {
padding: var(--space-6);
display: flex;
flex-direction: column;
gap: var(--space-3);
background: var(--color-surface-raised);
border-radius: var(--radius-card, 1rem);
user-select: none;
}
/* Perfect match shimmer — easter egg 9.4 */
.job-card--shimmer {
background: linear-gradient(
105deg,
var(--color-surface-raised) 30%,
rgba(251, 210, 60, 0.25) 50%,
var(--color-surface-raised) 70%
);
background-size: 300% auto;
animation: shimmer-sweep 1.8s ease 2;
}
@keyframes shimmer-sweep {
0% { background-position: 100% center; }
100% { background-position: -100% center; }
}
@media (prefers-reduced-motion: reduce) {
.job-card--shimmer { animation: none; }
}
.job-card__badges {
display: flex;
gap: var(--space-2);
flex-wrap: wrap;
}
.score-badge {
display: inline-flex;
align-items: center;
padding: 2px var(--space-2);
border-radius: 999px;
font-size: var(--text-xs);
font-weight: 700;
font-family: var(--font-mono);
}
.score-badge--high { background: rgba(39, 174, 96, 0.15); color: var(--score-high); }
.score-badge--mid { background: rgba(212, 137, 26, 0.15); color: var(--score-mid); }
.score-badge--low { background: rgba(192, 57, 43, 0.15); color: var(--score-low); }
.remote-badge {
display: inline-flex;
align-items: center;
padding: 2px var(--space-2);
border-radius: 999px;
font-size: var(--text-xs);
font-weight: 600;
background: var(--app-primary-light);
color: var(--app-primary);
}
.job-card__title {
font-family: var(--font-display);
font-size: var(--text-xl);
color: var(--color-text);
line-height: 1.25;
}
.job-card__company {
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-text-muted);
}
.job-card__sep { color: var(--color-border); }
.job-card__location { font-weight: 400; }
.job-card__salary {
font-size: var(--text-sm);
color: var(--color-success);
font-weight: 600;
}
.job-card__desc {
font-size: var(--text-sm);
color: var(--color-text);
line-height: 1.6;
white-space: pre-wrap;
overflow-wrap: break-word;
}
.job-card__desc--clamped {
display: -webkit-box;
-webkit-line-clamp: 5;
-webkit-box-orient: vertical;
overflow: hidden;
}
.job-card__expand-btn {
align-self: flex-start;
background: transparent;
border: none;
color: var(--app-primary);
font-size: var(--text-xs);
cursor: pointer;
padding: 0;
font-weight: 600;
transition: opacity 150ms ease;
}
.job-card__expand-btn:hover { opacity: 0.7; }
.job-card__gaps {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-2);
}
.job-card__gaps-label {
font-size: var(--text-xs);
color: var(--color-text-muted);
font-weight: 600;
}
.gap-pill {
padding: 2px var(--space-2);
border-radius: 999px;
font-size: var(--text-xs);
background: var(--color-surface-alt);
border: 1px solid var(--color-border-light);
color: var(--color-text-muted);
}
.job-card__gaps-more {
font-size: var(--text-xs);
color: var(--color-text-muted);
}
.job-card__footer {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: var(--space-1);
padding-top: var(--space-3);
border-top: 1px solid var(--color-border-light);
}
.job-card__url {
font-size: var(--text-xs);
color: var(--app-primary);
text-decoration: none;
font-weight: 600;
transition: opacity 150ms ease;
}
.job-card__url:hover { opacity: 0.7; }
.job-card__date {
font-size: var(--text-xs);
color: var(--color-text-muted);
}
</style>

View file

@ -0,0 +1,305 @@
<template>
<div class="card-stack" :aria-label="`${remaining} jobs remaining`">
<!-- Peek cards depth illusion behind active card -->
<div class="card-peek card-peek-2" aria-hidden="true" />
<div class="card-peek card-peek-1" aria-hidden="true" />
<!-- Active card wrapper receives pointer events -->
<div
ref="wrapperEl"
class="card-wrapper"
:class="{
'is-held': isHeld,
'is-exiting': isExiting,
}"
:style="cardStyle"
role="region"
:aria-label="job.title"
@pointerdown="onPointerDown"
@pointermove="onPointerMove"
@pointerup="onPointerUp"
@pointercancel="onPointerCancel"
>
<!-- Directional tint overlay -->
<div
class="card-tint"
:class="{
'card-tint--approve': dx > 0,
'card-tint--reject': dx < 0,
}"
:style="{ opacity: tintOpacity }"
aria-hidden="true"
>
<span class="card-tint__icon">{{ dx > 0 ? '✓' : '✗' }}</span>
</div>
<JobCard
:job="job"
:expanded="isExpanded"
@expand="isExpanded = true"
@collapse="isExpanded = false"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import JobCard from './JobCard.vue'
import type { Job } from '../stores/review'
const props = defineProps<{
job: Job
remaining: number
}>()
const emit = defineEmits<{
approve: []
reject: []
skip: []
}>()
// State
const wrapperEl = ref<HTMLElement | null>(null)
const isExpanded = ref(false)
const isHeld = ref(false)
const isExiting = ref(false)
const dx = ref(0)
const dy = ref(0)
// Derived style
// Max tilt at ±120px drag = ±6°
const TILT_MAX_DEG = 6
const TILT_AT_PX = 120
const cardStyle = computed(() => {
if (isExiting.value) return {} // exiting uses CSS class transition
if (!isHeld.value && dx.value === 0 && dy.value === 0) return {}
const tilt = Math.max(-TILT_MAX_DEG, Math.min(TILT_MAX_DEG, (dx.value / TILT_AT_PX) * TILT_MAX_DEG))
return { transform: `translate(${dx.value}px, ${dy.value}px) rotate(${tilt}deg)` }
})
// Tint opacity 00.6 at ±0120px
const tintOpacity = computed(() =>
isHeld.value ? Math.min(Math.abs(dx.value) / TILT_AT_PX, 1) * 0.6 : 0,
)
// Fling detection
const FLING_SPEED_PX_S = 600 // minimum px/s to qualify
const FLING_ALIGN = 0.707 // cos(45°) must be within 45° of horizontal
const FLING_WINDOW_MS = 50 // rolling sample window
let velocityBuf: { x: number; y: number; t: number }[] = []
// Zone detection
const ZONE_PCT = 0.2 // 20% of viewport width on each side
// Pointer events
let pickupX = 0
let pickupY = 0
function onPointerDown(e: PointerEvent) {
// Let interactive children (links, buttons) receive their events
if ((e.target as Element).closest('button, a, input, select, textarea')) return
if (isExiting.value) return
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
pickupX = e.clientX
pickupY = e.clientY
isHeld.value = true
velocityBuf = []
}
function onPointerMove(e: PointerEvent) {
if (!isHeld.value) return
dx.value = e.clientX - pickupX
dy.value = e.clientY - pickupY
// Rolling velocity buffer
const now = performance.now()
velocityBuf.push({ x: e.clientX, y: e.clientY, t: now })
while (velocityBuf.length > 1 && now - velocityBuf[0].t > FLING_WINDOW_MS) {
velocityBuf.shift()
}
}
function onPointerUp(e: PointerEvent) {
if (!isHeld.value) return
;(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId)
isHeld.value = false
// Fling detection fires first so a fast flick resolves without reaching the edge zone
if (velocityBuf.length >= 2) {
const oldest = velocityBuf[0]
const newest = velocityBuf[velocityBuf.length - 1]
const dt = (newest.t - oldest.t) / 1000
if (dt > 0) {
const vx = (newest.x - oldest.x) / dt
const vy = (newest.y - oldest.y) / dt
const speed = Math.sqrt(vx * vx + vy * vy)
if (speed >= FLING_SPEED_PX_S && Math.abs(vx) / speed >= FLING_ALIGN) {
velocityBuf = []
_dismiss(vx > 0 ? 'right' : 'left')
return
}
}
}
velocityBuf = []
// Zone check did the pointer release in an edge zone?
const vw = window.innerWidth
if (e.clientX < vw * ZONE_PCT) {
_dismiss('left')
} else if (e.clientX > vw * (1 - ZONE_PCT)) {
_dismiss('right')
} else {
_snapBack()
}
}
function onPointerCancel(e: PointerEvent) {
if (!isHeld.value) return
;(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId)
isHeld.value = false
velocityBuf = []
_snapBack()
}
// Animation helpers
function _snapBack() {
dx.value = 0
dy.value = 0
}
/** Fly card off-screen, then emit the action. */
async function _dismiss(direction: 'left' | 'right') {
if (!wrapperEl.value || isExiting.value) return
isExiting.value = true
const exitX = direction === 'right' ? 700 : -700
const exitTilt = direction === 'right' ? 14 : -14
wrapperEl.value.style.transform = `translate(${exitX}px, -60px) rotate(${exitTilt}deg)`
wrapperEl.value.style.opacity = '0'
await new Promise(r => setTimeout(r, 280))
emit(direction === 'right' ? 'approve' : 'reject')
}
// Keyboard-triggered dismiss (called from parent via template ref)
async function dismissApprove() { await _dismiss('right') }
async function dismissReject() { await _dismiss('left') }
function dismissSkip() { _snapBack(); emit('skip') }
// Reset when a new job is slotted in (Vue reuses the element)
watch(() => props.job.id, () => {
dx.value = 0
dy.value = 0
isExiting.value = false
isHeld.value = false
isExpanded.value = false
if (wrapperEl.value) {
// Suppress the spring transition for this frame without this the card
// spring-animates from its exit position back to center before the new
// job renders (the "snap-back on processed cards" glitch).
wrapperEl.value.style.transition = 'none'
wrapperEl.value.style.transform = ''
wrapperEl.value.style.opacity = ''
requestAnimationFrame(() => {
if (wrapperEl.value) wrapperEl.value.style.transition = ''
})
}
})
defineExpose({ dismissApprove, dismissReject, dismissSkip })
</script>
<style scoped>
.card-stack {
position: relative;
/* Reserve space for peek cards below active card */
padding-bottom: 18px;
}
/* Peek cards — static shadows giving a stack depth feel */
.card-peek {
position: absolute;
left: 0; right: 0; bottom: 0;
border-radius: var(--radius-card, 1rem);
background: var(--color-surface-raised);
border: 1px solid var(--color-border-light);
}
.card-peek-1 { transform: translateY(8px) scale(0.97); opacity: 0.55; height: 80px; }
.card-peek-2 { transform: translateY(16px) scale(0.94); opacity: 0.30; height: 80px; }
/* Active card wrapper */
.card-wrapper {
position: relative;
z-index: 1;
border-radius: var(--radius-card, 1rem);
background: var(--color-surface-raised);
border: 1px solid var(--color-border-light);
box-shadow: var(--shadow-md);
/* Spring snap-back when released with no action */
transition:
transform var(--swipe-spring),
opacity 200ms ease,
box-shadow 150ms ease;
touch-action: none;
cursor: grab;
overflow: hidden;
will-change: transform;
}
.card-wrapper.is-held {
cursor: grabbing;
transition: none; /* instant response while dragging */
box-shadow: var(--shadow-xl, 0 12px 40px rgba(0,0,0,0.18));
}
/* is-exiting: override to linear ease-in for off-screen fly */
.card-wrapper.is-exiting {
transition:
transform 280ms ease-in,
opacity 240ms ease-in !important;
pointer-events: none;
}
/* Directional tint overlay */
.card-tint {
position: absolute;
inset: 0;
border-radius: inherit;
pointer-events: none;
z-index: 2;
display: flex;
align-items: flex-start;
padding: var(--space-4);
transition: opacity 60ms linear;
}
.card-tint--approve { background: rgba(39, 174, 96, 0.35); }
.card-tint--reject { background: rgba(192, 57, 43, 0.35); }
.card-tint__icon {
font-size: 2rem;
font-weight: 900;
color: white;
text-shadow: 0 1px 3px rgba(0,0,0,0.3);
opacity: 0.85;
}
.card-tint--approve .card-tint__icon { margin-left: auto; }
.card-tint--reject .card-tint__icon { margin-right: auto; }
@media (prefers-reduced-motion: reduce) {
.card-wrapper { transition: none; }
.card-wrapper.is-exiting { transition: opacity 200ms ease !important; }
}
</style>

View file

@ -0,0 +1,176 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
import { STAGE_LABELS, PIPELINE_STAGES } from '../stores/interviews'
import type { PipelineStage } from '../stores/interviews'
const props = defineProps<{
currentStatus: string
jobTitle: string
preSelectedStage?: PipelineStage
}>()
const emit = defineEmits<{
move: [stage: PipelineStage, opts: { interview_date?: string; rejection_stage?: string }]
close: []
}>()
const selectedStage = ref<PipelineStage | null>(props.preSelectedStage ?? null)
const interviewDate = ref('')
const rejectionStage = ref('')
const focusIndex = ref(0)
const firstOptionEl = ref<HTMLButtonElement | null>(null)
const stages = computed(() =>
PIPELINE_STAGES.filter(s => s !== props.currentStatus)
)
function select(stage: PipelineStage) {
selectedStage.value = stage
}
function confirm() {
if (!selectedStage.value) return
if (!stages.value.includes(selectedStage.value)) return // guard: preSelectedStage was filtered out
const opts: { interview_date?: string; rejection_stage?: string } = {}
if (interviewDate.value) opts.interview_date = new Date(interviewDate.value).toISOString()
if (rejectionStage.value) opts.rejection_stage = rejectionStage.value
emit('move', selectedStage.value, opts)
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') { emit('close'); return }
if (e.key === 'ArrowDown' || e.key === 'ArrowRight') {
e.preventDefault()
focusIndex.value = Math.min(focusIndex.value + 1, stages.value.length - 1)
}
if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') {
e.preventDefault()
focusIndex.value = Math.max(focusIndex.value - 1, 0)
}
if (e.key === 'Enter' && stages.value[focusIndex.value]) {
select(stages.value[focusIndex.value])
}
}
onMounted(() => {
document.addEventListener('keydown', onKeydown)
nextTick(() => firstOptionEl.value?.focus())
})
onUnmounted(() => document.removeEventListener('keydown', onKeydown))
</script>
<template>
<Teleport to="body">
<div
class="sheet-backdrop"
role="dialog"
aria-modal="true"
:aria-label="`Move ${jobTitle}`"
@click.self="emit('close')"
>
<div class="sheet-panel">
<div class="sheet-header">
<span class="sheet-title">Move to</span>
<button class="sheet-close" @click="emit('close')" aria-label="Close"></button>
</div>
<div class="sheet-stages" role="listbox">
<button
v-for="(stage, i) in stages"
:key="stage"
:ref="i === 0 ? (el) => { firstOptionEl = el as HTMLButtonElement } : undefined"
class="stage-option"
:class="{
'stage-option--selected': selectedStage === stage,
'stage-option--focused': focusIndex === i,
}"
role="option"
:aria-selected="selectedStage === stage"
@click="select(stage)"
>
{{ STAGE_LABELS[stage] }}
</button>
</div>
<div
v-if="selectedStage === 'phone_screen' || selectedStage === 'interviewing'"
class="sheet-extras"
>
<label class="field-label">
Interview date/time (optional)
<input type="datetime-local" v-model="interviewDate" class="field-input" />
</label>
</div>
<div v-if="selectedStage === 'interview_rejected'" class="sheet-extras">
<label class="field-label">
Rejected after
<select v-model="rejectionStage" class="field-input">
<option value=""> select </option>
<option>Application</option>
<option>Phone screen</option>
<option>Interviewing</option>
<option>Offer stage</option>
</select>
</label>
</div>
<div class="sheet-actions">
<button class="btn-cancel" @click="emit('close')">Cancel</button>
<button class="btn-confirm" :disabled="!selectedStage" @click="confirm">Move </button>
</div>
</div>
</div>
</Teleport>
</template>
<style scoped>
.sheet-backdrop {
position: fixed; inset: 0; z-index: 200;
background: rgba(0,0,0,.45);
display: flex; align-items: flex-end; justify-content: center;
}
@media (min-width: 640px) {
.sheet-backdrop { align-items: center; }
}
.sheet-panel {
background: var(--color-surface-raised);
border-radius: 16px 16px 0 0;
padding: var(--space-4) var(--space-4) var(--space-6);
width: 100%; max-width: 480px;
display: flex; flex-direction: column; gap: var(--space-3);
}
@media (min-width: 640px) {
.sheet-panel { border-radius: 12px; }
}
.sheet-header { display: flex; align-items: center; justify-content: space-between; }
.sheet-title { font-weight: 700; font-size: 1rem; }
.sheet-close { background: none; border: none; cursor: pointer; font-size: 1rem; color: var(--color-text-muted); }
.sheet-stages { display: flex; flex-direction: column; gap: var(--space-2); }
.stage-option {
background: var(--color-surface);
border: 2px solid transparent;
border-radius: 8px; padding: var(--space-2) var(--space-3);
font-size: 0.9rem; font-weight: 600; text-align: left;
cursor: pointer; color: var(--color-text);
transition: border-color 120ms, background 120ms;
}
.stage-option:hover { background: var(--color-surface-alt); }
.stage-option--selected { border-color: var(--color-primary); background: var(--color-primary-light); }
.stage-option--focused { outline: 2px solid var(--color-primary); outline-offset: 1px; }
.sheet-extras { display: flex; flex-direction: column; gap: var(--space-2); }
.field-label { font-size: 0.8rem; font-weight: 600; color: var(--color-text-muted); display: flex; flex-direction: column; gap: 4px; }
.field-input { padding: var(--space-2); border: 1px solid var(--color-border); border-radius: 6px; background: var(--color-surface); font-size: 0.875rem; color: var(--color-text); }
.sheet-actions { display: flex; gap: var(--space-2); justify-content: flex-end; margin-top: var(--space-2); }
.btn-cancel {
background: var(--color-surface-alt); border: none; border-radius: 8px;
padding: var(--space-2) var(--space-4); font-weight: 600; cursor: pointer;
color: var(--color-text-muted);
}
.btn-confirm {
background: var(--color-primary); border: none; border-radius: 8px;
padding: var(--space-2) var(--space-4); font-weight: 700; cursor: pointer;
color: var(--color-text-inverse);
}
.btn-confirm:disabled { opacity: .4; cursor: not-allowed; }
</style>

View file

@ -0,0 +1,96 @@
<template>
<button
class="workflow-btn"
:class="{ 'workflow-btn--loading': loading }"
:disabled="loading"
:aria-busy="loading"
v-bind="$attrs"
>
<span class="workflow-btn__icon" aria-hidden="true">{{ emoji }}</span>
<span class="workflow-btn__body">
<span class="workflow-btn__label">{{ label }}</span>
<span class="workflow-btn__desc">{{ description }}</span>
</span>
<span v-if="loading" class="workflow-btn__spinner" aria-label="Running…" />
</button>
</template>
<script setup lang="ts">
defineProps<{
emoji: string
label: string
description: string
loading?: boolean
}>()
</script>
<style scoped>
.workflow-btn {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-4) var(--space-5);
background: var(--color-surface-raised);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
cursor: pointer;
text-align: left;
min-height: 72px; /* WCAG 2.5.5 */
width: 100%;
/* Enumerate transitions — no transition:all. Gotcha #2. */
transition:
background 150ms ease,
border-color 150ms ease,
box-shadow 150ms ease,
transform 150ms ease;
}
.workflow-btn:hover {
background: var(--app-primary-light);
border-color: var(--app-primary);
box-shadow: var(--shadow-sm);
transform: translateY(-1px);
}
.workflow-btn:disabled {
opacity: 0.7;
cursor: not-allowed;
transform: none;
}
.workflow-btn__icon {
font-size: 1.5rem;
flex-shrink: 0;
}
.workflow-btn__body {
display: flex;
flex-direction: column;
gap: 2px;
flex: 1;
min-width: 0;
}
.workflow-btn__label {
font-size: var(--text-sm);
font-weight: 700;
color: var(--color-text);
}
.workflow-btn__desc {
font-size: var(--text-xs);
color: var(--color-text-muted);
}
.workflow-btn__spinner {
width: 1.1rem;
height: 1.1rem;
border: 2px solid var(--color-border);
border-top-color: var(--app-primary);
border-radius: 50%;
animation: spin 0.7s linear infinite;
flex-shrink: 0;
}
@keyframes spin { to { transform: rotate(360deg); } }
</style>

View file

@ -0,0 +1,50 @@
export type ApiError =
| { kind: 'network'; message: string }
| { kind: 'http'; status: number; detail: string }
export async function useApiFetch<T>(
url: string,
opts?: RequestInit,
): Promise<{ data: T | null; error: ApiError | null }> {
try {
const res = await fetch(url, opts)
if (!res.ok) {
const detail = await res.text().catch(() => '')
return { data: null, error: { kind: 'http', status: res.status, detail } }
}
const data = await res.json() as T
return { data, error: null }
} catch (e) {
return { data: null, error: { kind: 'network', message: String(e) } }
}
}
/**
* Open an SSE connection. Returns a cleanup function.
* onEvent receives each parsed JSON payload.
* onComplete is called when the server sends a {"type":"complete"} event.
* onError is called on connection error.
*/
export function useApiSSE(
url: string,
onEvent: (data: Record<string, unknown>) => void,
onComplete?: () => void,
onError?: (e: Event) => void,
): () => void {
const es = new EventSource(url)
es.onmessage = (e) => {
try {
const data = JSON.parse(e.data) as Record<string, unknown>
onEvent(data)
if (data.type === 'complete') {
es.close()
onComplete?.()
}
} catch { /* ignore malformed events */ }
}
es.onerror = (e) => {
onError?.(e)
es.close()
}
return () => es.close()
}

View file

@ -0,0 +1,160 @@
import { onMounted, onUnmounted } from 'vue'
const KONAMI = ['ArrowUp','ArrowUp','ArrowDown','ArrowDown','ArrowLeft','ArrowRight','ArrowLeft','ArrowRight','b','a']
const KONAMI_AB = ['ArrowUp','ArrowUp','ArrowDown','ArrowDown','ArrowLeft','ArrowRight','ArrowLeft','ArrowRight','a','b']
export function useKeySequence(sequence: string[], onActivate: () => void) {
let pos = 0
function handler(e: KeyboardEvent) {
if (e.key === sequence[pos]) {
pos++
if (pos === sequence.length) {
pos = 0
onActivate()
}
} else {
pos = 0
}
}
onMounted(() => window.addEventListener('keydown', handler))
onUnmounted(() => window.removeEventListener('keydown', handler))
}
export function useKonamiCode(onActivate: () => void) {
useKeySequence(KONAMI, onActivate)
useKeySequence(KONAMI_AB, onActivate)
}
export function useHackerMode() {
function toggle() {
const root = document.documentElement
if (root.dataset.theme === 'hacker') {
delete root.dataset.theme
localStorage.removeItem('cf-hacker-mode')
} else {
root.dataset.theme = 'hacker'
localStorage.setItem('cf-hacker-mode', 'true')
}
}
function restore() {
if (localStorage.getItem('cf-hacker-mode') === 'true') {
document.documentElement.dataset.theme = 'hacker'
}
}
return { toggle, restore }
}
/** Fire a confetti burst from a given x,y position. Pure canvas, no dependencies. */
export function fireConfetti(originX = window.innerWidth / 2, originY = window.innerHeight / 2) {
if (typeof requestAnimationFrame === 'undefined') return
const canvas = document.createElement('canvas')
canvas.style.cssText = 'position:fixed;inset:0;pointer-events:none;z-index:9999;'
canvas.width = window.innerWidth
canvas.height = window.innerHeight
document.body.appendChild(canvas)
const ctx = canvas.getContext('2d')!
const COLORS = ['#2d5a27','#c4732a','#5A9DBF','#D4854A','#FFC107','#4CAF50']
const particles = Array.from({ length: 80 }, () => ({
x: originX,
y: originY,
vx: (Math.random() - 0.5) * 14,
vy: (Math.random() - 0.6) * 12,
color: COLORS[Math.floor(Math.random() * COLORS.length)],
size: 5 + Math.random() * 6,
angle: Math.random() * Math.PI * 2,
spin: (Math.random() - 0.5) * 0.3,
life: 1.0,
}))
let raf = 0
function draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height)
let alive = false
for (const p of particles) {
p.x += p.vx
p.y += p.vy
p.vy += 0.35
p.vx *= 0.98
p.angle += p.spin
p.life -= 0.016
if (p.life <= 0) continue
alive = true
ctx.save()
ctx.globalAlpha = p.life
ctx.fillStyle = p.color
ctx.translate(p.x, p.y)
ctx.rotate(p.angle)
ctx.fillRect(-p.size / 2, -p.size / 2, p.size, p.size * 0.6)
ctx.restore()
}
if (alive) {
raf = requestAnimationFrame(draw)
} else {
cancelAnimationFrame(raf)
canvas.remove()
}
}
raf = requestAnimationFrame(draw)
}
/** Enable cursor trail in hacker mode — returns a cleanup function. */
export function useCursorTrail() {
const DOT_COUNT = 10
const dots: HTMLElement[] = []
let positions: { x: number; y: number }[] = []
let mouseX = 0
let mouseY = 0
let raf = 0
for (let i = 0; i < DOT_COUNT; i++) {
const dot = document.createElement('div')
dot.style.cssText = [
'position:fixed',
'pointer-events:none',
'z-index:9998',
'border-radius:50%',
'background:var(--color-accent)',
'transition:opacity 0.1s',
].join(';')
document.body.appendChild(dot)
dots.push(dot)
}
function onMouseMove(e: MouseEvent) {
mouseX = e.clientX
mouseY = e.clientY
}
function animate() {
positions.unshift({ x: mouseX, y: mouseY })
if (positions.length > DOT_COUNT) positions = positions.slice(0, DOT_COUNT)
dots.forEach((dot, i) => {
const pos = positions[i]
if (!pos) { dot.style.opacity = '0'; return }
const scale = 1 - i / DOT_COUNT
const size = Math.round(8 * scale)
dot.style.left = `${pos.x - size / 2}px`
dot.style.top = `${pos.y - size / 2}px`
dot.style.width = `${size}px`
dot.style.height = `${size}px`
dot.style.opacity = `${(1 - i / DOT_COUNT) * 0.7}`
})
raf = requestAnimationFrame(animate)
}
window.addEventListener('mousemove', onMouseMove)
raf = requestAnimationFrame(animate)
return function cleanup() {
window.removeEventListener('mousemove', onMouseMove)
cancelAnimationFrame(raf)
dots.forEach(d => d.remove())
}
}

View file

@ -0,0 +1,48 @@
/**
* useFeatureFlag demo toolbar tier display helper.
*
* Reads the `prgn_demo_tier` cookie set by the Streamlit demo toolbar so the
* Vue SPA can visually reflect the simulated tier (e.g. in ClassicUIButton
* or feature-locked UI hints).
*
* NOT an authoritative feature gate. This is demo-only visual consistency.
* Production feature gating will use a future /api/features endpoint (issue #8).
* All real access control lives in the Python tier system (app/wizard/tiers.py).
*/
import { computed } from 'vue'
const VALID_TIERS = ['free', 'paid', 'premium'] as const
type Tier = (typeof VALID_TIERS)[number]
function _readDemoTierCookie(): Tier | null {
const match = document.cookie
.split('; ')
.find((row) => row.startsWith('prgn_demo_tier='))
if (!match) return null
const value = match.split('=')[1] as Tier
return VALID_TIERS.includes(value) ? value : null
}
/**
* Returns the simulated demo tier from the `prgn_demo_tier` cookie,
* or `null` when not in demo mode (cookie absent).
*
* Use for visual indicators only never for access control.
*/
export function useFeatureFlag() {
const demoTier = computed<Tier | null>(() => _readDemoTierCookie())
const isDemoMode = computed(() => demoTier.value !== null)
/**
* Returns true if the simulated demo tier meets `required`.
* Always returns false outside demo mode.
*/
function demoCanUse(required: Tier): boolean {
const order: Tier[] = ['free', 'paid', 'premium']
if (!demoTier.value) return false
return order.indexOf(demoTier.value) >= order.indexOf(required)
}
return { demoTier, isDemoMode, demoCanUse }
}

View file

@ -0,0 +1,20 @@
import { useMotion } from './useMotion'
// navigator.vibrate() — Chrome for Android only. Desktop, iOS Safari: no-op.
// Always guard with feature detection. Gotcha #9.
export function useHaptics() {
const { rich } = useMotion()
function vibrate(pattern: number | number[]) {
if (rich.value && typeof navigator !== 'undefined' && 'vibrate' in navigator) {
navigator.vibrate(pattern)
}
}
return {
label: () => vibrate(40),
discard: () => vibrate([40, 30, 40]),
skip: () => vibrate(15),
undo: () => vibrate([20, 20, 60]),
}
}

View file

@ -0,0 +1,30 @@
import { computed, ref } from 'vue'
// Peregrine-namespaced localStorage entry (avocet uses cf-avocet-rich-motion)
const LS_MOTION = 'cf-peregrine-rich-motion'
// OS-level prefers-reduced-motion — checked once at module load
const OS_REDUCED = typeof window !== 'undefined'
? window.matchMedia('(prefers-reduced-motion: reduce)').matches
: false
// Reactive ref so toggling localStorage triggers re-reads in the same session
const _richOverride = ref(
typeof window !== 'undefined'
? localStorage.getItem(LS_MOTION)
: null,
)
export function useMotion() {
// null/missing = default ON; 'false' = explicitly disabled by user
const rich = computed(() =>
!OS_REDUCED && _richOverride.value !== 'false',
)
function setRich(enabled: boolean) {
localStorage.setItem(LS_MOTION, enabled ? 'true' : 'false')
_richOverride.value = enabled ? 'true' : 'false'
}
return { rich, setRich }
}

24
web/src/main.ts Normal file
View file

@ -0,0 +1,24 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import { router } from './router'
// Self-hosted fonts — no Google Fonts CDN (privacy requirement)
import '@fontsource/fraunces/400.css'
import '@fontsource/fraunces/700.css'
import '@fontsource/atkinson-hyperlegible/400.css'
import '@fontsource/atkinson-hyperlegible/700.css'
import '@fontsource/jetbrains-mono/400.css'
import 'virtual:uno.css'
import './assets/theme.css'
import './assets/peregrine.css'
import App from './App.vue'
// Manual scroll restoration — prevents browser from jumping to last position on SPA nav
if ('scrollRestoration' in history) history.scrollRestoration = 'manual'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')

44
web/src/router/index.ts Normal file
View file

@ -0,0 +1,44 @@
import { createRouter, createWebHistory } from 'vue-router'
import { useAppConfigStore } from '../stores/appConfig'
import { settingsGuard } from './settingsGuard'
export const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/', component: () => import('../views/HomeView.vue') },
{ path: '/review', component: () => import('../views/JobReviewView.vue') },
{ path: '/apply', component: () => import('../views/ApplyView.vue') },
{ path: '/apply/:id', component: () => import('../views/ApplyWorkspaceView.vue') },
{ path: '/interviews', component: () => import('../views/InterviewsView.vue') },
{ path: '/digest', component: () => import('../views/DigestView.vue') },
{ path: '/prep', component: () => import('../views/InterviewPrepView.vue') },
{ path: '/prep/:id', component: () => import('../views/InterviewPrepView.vue') },
{ path: '/survey', component: () => import('../views/SurveyView.vue') },
{ path: '/survey/:id', component: () => import('../views/SurveyView.vue') },
{
path: '/settings',
component: () => import('../views/settings/SettingsView.vue'),
redirect: '/settings/my-profile',
children: [
{ path: 'my-profile', component: () => import('../views/settings/MyProfileView.vue') },
{ path: 'resume', component: () => import('../views/settings/ResumeProfileView.vue') },
{ path: 'search', component: () => import('../views/settings/SearchPrefsView.vue') },
{ path: 'system', component: () => import('../views/settings/SystemSettingsView.vue') },
{ path: 'fine-tune', component: () => import('../views/settings/FineTuneView.vue') },
{ path: 'license', component: () => import('../views/settings/LicenseView.vue') },
{ path: 'data', component: () => import('../views/settings/DataView.vue') },
{ path: 'privacy', component: () => import('../views/settings/PrivacyView.vue') },
{ path: 'developer', component: () => import('../views/settings/DeveloperView.vue') },
],
},
// Catch-all — FastAPI serves index.html for all unknown routes (SPA mode)
{ path: '/:pathMatch(.*)*', redirect: '/' },
],
})
router.beforeEach(async (to, _from, next) => {
if (!to.path.startsWith('/settings/')) return next()
const config = useAppConfigStore()
if (!config.loaded) await config.load()
settingsGuard(to, _from, next)
})

View file

@ -0,0 +1,135 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useAppConfigStore } from '../stores/appConfig'
import { settingsGuard } from './settingsGuard'
vi.mock('../composables/useApi', () => ({ useApiFetch: vi.fn() }))
describe('settingsGuard', () => {
beforeEach(() => {
setActivePinia(createPinia())
localStorage.clear()
})
afterEach(() => {
localStorage.clear()
})
it('passes through non-settings routes immediately', () => {
const next = vi.fn()
settingsGuard({ path: '/review' }, {}, next)
// Guard only handles /settings/* — for non-settings routes the router
// calls next() before reaching settingsGuard, but the guard itself
// will still call next() with no redirect since no tab matches
expect(next).toHaveBeenCalledWith()
})
it('redirects /settings/system in cloud mode', () => {
const store = useAppConfigStore()
store.isCloud = true
const next = vi.fn()
settingsGuard({ path: '/settings/system' }, {}, next)
expect(next).toHaveBeenCalledWith('/settings/my-profile')
})
it('allows /settings/system in self-hosted mode', () => {
const store = useAppConfigStore()
store.isCloud = false
const next = vi.fn()
settingsGuard({ path: '/settings/system' }, {}, next)
expect(next).toHaveBeenCalledWith()
})
it('redirects /settings/fine-tune for non-GPU self-hosted', () => {
const store = useAppConfigStore()
store.isCloud = false
store.inferenceProfile = 'cpu'
const next = vi.fn()
settingsGuard({ path: '/settings/fine-tune' }, {}, next)
expect(next).toHaveBeenCalledWith('/settings/my-profile')
})
it('allows /settings/fine-tune for single-gpu self-hosted', () => {
const store = useAppConfigStore()
store.isCloud = false
store.inferenceProfile = 'single-gpu'
const next = vi.fn()
settingsGuard({ path: '/settings/fine-tune' }, {}, next)
expect(next).toHaveBeenCalledWith()
})
it('allows /settings/fine-tune for dual-gpu self-hosted', () => {
const store = useAppConfigStore()
store.isCloud = false
store.inferenceProfile = 'dual-gpu'
const next = vi.fn()
settingsGuard({ path: '/settings/fine-tune' }, {}, next)
expect(next).toHaveBeenCalledWith()
})
it('redirects /settings/fine-tune on cloud when tier is not premium', () => {
const store = useAppConfigStore()
store.isCloud = true
store.tier = 'paid'
const next = vi.fn()
settingsGuard({ path: '/settings/fine-tune' }, {}, next)
expect(next).toHaveBeenCalledWith('/settings/my-profile')
})
it('allows /settings/fine-tune on cloud when tier is premium', () => {
const store = useAppConfigStore()
store.isCloud = true
store.tier = 'premium'
const next = vi.fn()
settingsGuard({ path: '/settings/fine-tune' }, {}, next)
expect(next).toHaveBeenCalledWith()
})
it('redirects /settings/developer when not dev mode and no override', () => {
const store = useAppConfigStore()
store.isDevMode = false
const next = vi.fn()
settingsGuard({ path: '/settings/developer' }, {}, next)
expect(next).toHaveBeenCalledWith('/settings/my-profile')
})
it('allows /settings/developer when isDevMode is true', () => {
const store = useAppConfigStore()
store.isDevMode = true
const next = vi.fn()
settingsGuard({ path: '/settings/developer' }, {}, next)
expect(next).toHaveBeenCalledWith()
})
it('allows /settings/developer when dev_tier_override set in localStorage', () => {
const store = useAppConfigStore()
store.isDevMode = false
localStorage.setItem('dev_tier_override', 'premium')
const next = vi.fn()
settingsGuard({ path: '/settings/developer' }, {}, next)
expect(next).toHaveBeenCalledWith()
})
it('allows /settings/privacy in cloud mode', () => {
const store = useAppConfigStore()
store.isCloud = true
const next = vi.fn()
settingsGuard({ path: '/settings/privacy' }, {}, next)
expect(next).toHaveBeenCalledWith()
})
it('allows /settings/privacy in self-hosted mode', () => {
const store = useAppConfigStore()
store.isCloud = false
const next = vi.fn()
settingsGuard({ path: '/settings/privacy' }, {}, next)
expect(next).toHaveBeenCalledWith()
})
it('allows /settings/license in both modes', () => {
const store = useAppConfigStore()
store.isCloud = true
const next = vi.fn()
settingsGuard({ path: '/settings/license' }, {}, next)
expect(next).toHaveBeenCalledWith()
})
})

View file

@ -0,0 +1,31 @@
import { useAppConfigStore } from '../stores/appConfig'
const GPU_PROFILES = ['single-gpu', 'dual-gpu']
/**
* Synchronous tab-gating logic for /settings/* routes.
* Called by the async router.beforeEach after config.load() has resolved.
* Reading devTierOverride from localStorage here (not only the store ref) ensures
* the guard reflects overrides set externally before the store hydrates.
*/
export function settingsGuard(
to: { path: string },
_from: unknown,
next: (to?: string) => void,
): void {
const config = useAppConfigStore()
const tab = to.path.replace('/settings/', '')
const devOverride = config.devTierOverride || localStorage.getItem('dev_tier_override')
if (tab === 'system' && config.isCloud) return next('/settings/my-profile')
if (tab === 'fine-tune') {
const cloudBlocked = config.isCloud && config.tier !== 'premium'
const selfHostedBlocked = !config.isCloud && !GPU_PROFILES.includes(config.inferenceProfile)
if (cloudBlocked || selfHostedBlocked) return next('/settings/my-profile')
}
if (tab === 'developer' && !config.isDevMode && !devOverride) return next('/settings/my-profile')
next()
}

View file

@ -0,0 +1,41 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useAppConfigStore } from './appConfig'
vi.mock('../composables/useApi', () => ({
useApiFetch: vi.fn(),
}))
import { useApiFetch } from '../composables/useApi'
const mockFetch = vi.mocked(useApiFetch)
describe('useAppConfigStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
})
it('defaults to safe values before load', () => {
const store = useAppConfigStore()
expect(store.isCloud).toBe(false)
expect(store.tier).toBe('free')
})
it('load() populates from API response', async () => {
mockFetch.mockResolvedValue({
data: { isCloud: true, isDevMode: false, tier: 'paid', contractedClient: false, inferenceProfile: 'cpu' },
error: null,
})
const store = useAppConfigStore()
await store.load()
expect(store.isCloud).toBe(true)
expect(store.tier).toBe('paid')
})
it('load() error leaves defaults intact', async () => {
mockFetch.mockResolvedValue({ data: null, error: { kind: 'network', message: 'fail' } })
const store = useAppConfigStore()
await store.load()
expect(store.isCloud).toBe(false)
})
})

View file

@ -0,0 +1,42 @@
import { ref } from 'vue'
import { defineStore } from 'pinia'
import { useApiFetch } from '../composables/useApi'
export type Tier = 'free' | 'paid' | 'premium' | 'ultra'
export type InferenceProfile = 'remote' | 'cpu' | 'single-gpu' | 'dual-gpu'
export const useAppConfigStore = defineStore('appConfig', () => {
const isCloud = ref(false)
const isDevMode = ref(false)
const tier = ref<Tier>('free')
const contractedClient = ref(false)
const inferenceProfile = ref<InferenceProfile>('cpu')
const loaded = ref(false)
const devTierOverride = ref(localStorage.getItem('dev_tier_override') ?? '')
async function load() {
const { data } = await useApiFetch<{
isCloud: boolean; isDevMode: boolean; tier: Tier
contractedClient: boolean; inferenceProfile: InferenceProfile
}>('/api/config/app')
if (!data) return
isCloud.value = data.isCloud
isDevMode.value = data.isDevMode
tier.value = data.tier
contractedClient.value = data.contractedClient
inferenceProfile.value = data.inferenceProfile
loaded.value = true
}
function setDevTierOverride(value: string | null) {
if (value) {
localStorage.setItem('dev_tier_override', value)
devTierOverride.value = value
} else {
localStorage.removeItem('dev_tier_override')
devTierOverride.value = ''
}
}
return { isCloud, isDevMode, tier, contractedClient, inferenceProfile, loaded, load, devTierOverride, setDevTierOverride }
})

50
web/src/stores/digest.ts Normal file
View file

@ -0,0 +1,50 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { useApiFetch } from '../composables/useApi'
export interface DigestEntry {
id: number
job_contact_id: number
created_at: string
subject: string
from_addr: string | null
received_at: string
body: string | null
}
/** Extracted link from a digest email body. Used by DigestView.vue. */
export interface DigestLink {
url: string
score: number // 2 = job-likely, 1 = other
hint: string
}
export const useDigestStore = defineStore('digest', () => {
const entries = ref<DigestEntry[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
async function fetchAll() {
error.value = null
loading.value = true
const { data, error: err } = await useApiFetch<DigestEntry[]>('/api/digest-queue')
loading.value = false
if (err) {
error.value = err.kind === 'network' ? 'Network error' : `Error ${err.status}`
return
}
entries.value = data ?? []
}
async function remove(id: number) {
const snapshot = entries.value.find(e => e.id === id)
entries.value = entries.value.filter(e => e.id !== id)
const { error: err } = await useApiFetch(`/api/digest-queue/${id}`, { method: 'DELETE' })
if (err) {
if (snapshot) entries.value = [...entries.value, snapshot]
error.value = err.kind === 'network' ? 'Network error' : `Error ${err.status}`
}
}
return { entries, loading, error, fetchAll, remove }
})

View file

@ -0,0 +1,50 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useInterviewsStore } from './interviews'
vi.mock('../composables/useApi', () => ({
useApiFetch: vi.fn(),
}))
import { useApiFetch } from '../composables/useApi'
const mockFetch = vi.mocked(useApiFetch)
const SAMPLE_JOBS = [
{ id: 1, title: 'CS Lead', company: 'Stripe', status: 'applied', match_score: 0.92, interview_date: null },
{ id: 2, title: 'CS Dir', company: 'Notion', status: 'phone_screen', match_score: 0.78, interview_date: '2026-03-20T15:00:00' },
{ id: 3, title: 'VP CS', company: 'Linear', status: 'hired', match_score: 0.95, interview_date: null },
]
describe('useInterviewsStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
mockFetch.mockResolvedValue({ data: SAMPLE_JOBS, error: null })
})
it('loads and groups jobs by status', async () => {
const store = useInterviewsStore()
await store.fetchAll()
expect(store.applied).toHaveLength(1)
expect(store.phoneScreen).toHaveLength(1)
expect(store.hired).toHaveLength(1)
})
it('move updates status optimistically', async () => {
mockFetch.mockResolvedValueOnce({ data: SAMPLE_JOBS, error: null })
mockFetch.mockResolvedValueOnce({ data: null, error: null }) // move API
const store = useInterviewsStore()
await store.fetchAll()
await store.move(1, 'phone_screen')
expect(store.applied).toHaveLength(0)
expect(store.phoneScreen).toHaveLength(2)
})
it('move rolls back on API error', async () => {
mockFetch.mockResolvedValueOnce({ data: SAMPLE_JOBS, error: null })
mockFetch.mockResolvedValueOnce({ data: null, error: { kind: 'http', status: 500, detail: 'err' } })
const store = useInterviewsStore()
await store.fetchAll()
await store.move(1, 'phone_screen')
expect(store.applied).toHaveLength(1)
})
})

View file

@ -0,0 +1,90 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { useApiFetch } from '../composables/useApi'
export interface StageSignal {
id: number // job_contacts.id — used for POST /api/stage-signals/{id}/dismiss
subject: string
received_at: string // ISO timestamp
stage_signal: 'interview_scheduled' | 'positive_response' | 'offer_received' | 'survey_received' | 'rejected'
body: string | null // email body text; null if not available
from_addr: string | null // sender address; null if not available
}
export interface PipelineJob {
id: number
title: string
company: string
url: string | null
location: string | null
is_remote: boolean
salary: string | null
match_score: number | null
keyword_gaps: string | null
status: string
interview_date: string | null
rejection_stage: string | null
applied_at: string | null
phone_screen_at: string | null
interviewing_at: string | null
offer_at: string | null
hired_at: string | null
survey_at: string | null
stage_signals: StageSignal[] // undismissed signals, newest first
}
export const PIPELINE_STAGES = ['applied', 'survey', 'phone_screen', 'interviewing', 'offer', 'hired', 'interview_rejected'] as const
export type PipelineStage = typeof PIPELINE_STAGES[number]
export const STAGE_LABELS: Record<PipelineStage, string> = {
applied: 'Applied',
survey: 'Survey',
phone_screen: 'Phone Screen',
interviewing: 'Interviewing',
offer: 'Offer',
hired: 'Hired',
interview_rejected: 'Rejected',
}
export const useInterviewsStore = defineStore('interviews', () => {
const jobs = ref<PipelineJob[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
const applied = computed(() => jobs.value.filter(j => j.status === 'applied'))
const survey = computed(() => jobs.value.filter(j => j.status === 'survey'))
const phoneScreen = computed(() => jobs.value.filter(j => j.status === 'phone_screen'))
const interviewing = computed(() => jobs.value.filter(j => j.status === 'interviewing'))
const offer = computed(() => jobs.value.filter(j => j.status === 'offer'))
const hired = computed(() => jobs.value.filter(j => j.status === 'hired'))
const offerHired = computed(() => jobs.value.filter(j => j.status === 'offer' || j.status === 'hired'))
const rejected = computed(() => jobs.value.filter(j => j.status === 'interview_rejected'))
async function fetchAll() {
loading.value = true
const { data, error: err } = await useApiFetch<PipelineJob[]>('/api/interviews')
loading.value = false
if (err) { error.value = 'Could not load interview pipeline'; return }
jobs.value = (data ?? []).map(j => ({ ...j }))
}
async function move(jobId: number, status: PipelineStage, opts: { interview_date?: string; rejection_stage?: string } = {}) {
const job = jobs.value.find(j => j.id === jobId)
if (!job) return
const prevStatus = job.status
job.status = status
const { error: err } = await useApiFetch(`/api/jobs/${jobId}/move`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status, ...opts }),
})
if (err) {
job.status = prevStatus
error.value = 'Move failed — please try again'
}
}
return { jobs, loading, error, applied, survey, phoneScreen, interviewing, offer, hired, offerHired, rejected, fetchAll, move }
})

50
web/src/stores/jobs.ts Normal file
View file

@ -0,0 +1,50 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { useApiFetch } from '../composables/useApi'
export interface JobCounts {
pending: number
approved: number
applied: number
synced: number
rejected: number
total: number
}
export interface SystemStatus {
enrichment_enabled: boolean
enrichment_last_run: string | null
enrichment_next_run: string | null
tasks_running: number
integration_name: string | null // e.g. "Notion", "Airtable"
integration_unsynced: number
}
// Pinia setup store — function form, not options form (gotcha #10)
export const useJobsStore = defineStore('jobs', () => {
const counts = ref<JobCounts | null>(null)
const status = ref<SystemStatus | null>(null)
const loading = ref(false)
const error = ref<string | null>(null)
const hasPending = computed(() => (counts.value?.pending ?? 0) > 0)
async function fetchCounts() {
loading.value = true
const { data, error: err } = await useApiFetch<JobCounts>('/api/jobs/counts')
loading.value = false
if (err) { error.value = err.kind === 'network' ? 'Network error' : `Error ${err.status}`; return }
counts.value = data
}
async function fetchStatus() {
const { data } = await useApiFetch<SystemStatus>('/api/system/status')
if (data) status.value = data
}
async function refresh() {
await Promise.all([fetchCounts(), fetchStatus()])
}
return { counts, status, loading, error, hasPending, fetchCounts, fetchStatus, refresh }
})

186
web/src/stores/prep.test.ts Normal file
View file

@ -0,0 +1,186 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { usePrepStore } from './prep'
// Mock useApiFetch
vi.mock('../composables/useApi', () => ({
useApiFetch: vi.fn(),
}))
import { useApiFetch } from '../composables/useApi'
describe('usePrepStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
vi.clearAllMocks()
})
it('fetchFor loads research, contacts, task, and full job in parallel', async () => {
const mockApiFetch = vi.mocked(useApiFetch)
mockApiFetch
.mockResolvedValueOnce({ data: { company_brief: 'Acme', ceo_brief: null, talking_points: null,
tech_brief: null, funding_brief: null, red_flags: null, accessibility_brief: null,
generated_at: '2026-03-20T12:00:00' }, error: null }) // research
.mockResolvedValueOnce({ data: [], error: null }) // contacts
.mockResolvedValueOnce({ data: { status: 'none', stage: null, message: null }, error: null }) // task
.mockResolvedValueOnce({ data: { id: 1, title: 'Engineer', company: 'Acme', url: null,
description: 'Build things.', cover_letter: null, match_score: 80,
keyword_gaps: null }, error: null }) // fullJob
const store = usePrepStore()
await store.fetchFor(1)
expect(store.research?.company_brief).toBe('Acme')
expect(store.contacts).toEqual([])
expect(store.taskStatus.status).toBe('none')
expect(store.fullJob?.description).toBe('Build things.')
expect(store.currentJobId).toBe(1)
})
it('fetchFor clears state when called for a different job', async () => {
const mockApiFetch = vi.mocked(useApiFetch)
// First call for job 1
mockApiFetch
.mockResolvedValueOnce({ data: { company_brief: 'OldCo', ceo_brief: null, talking_points: null,
tech_brief: null, funding_brief: null, red_flags: null, accessibility_brief: null,
generated_at: null }, error: null })
.mockResolvedValueOnce({ data: [], error: null })
.mockResolvedValueOnce({ data: { status: 'none', stage: null, message: null }, error: 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 })
const store = usePrepStore()
await store.fetchFor(1)
expect(store.research?.company_brief).toBe('OldCo')
// Second call for job 2 - clears first
mockApiFetch
.mockResolvedValueOnce({ data: null, error: { kind: 'http', status: 404, detail: '' } }) // 404 → null
.mockResolvedValueOnce({ data: [], error: null })
.mockResolvedValueOnce({ data: { status: 'none', stage: null, message: null }, error: 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 })
await store.fetchFor(2)
expect(store.research).toBeNull()
expect(store.currentJobId).toBe(2)
})
it('generateResearch calls POST then starts polling', async () => {
const mockApiFetch = vi.mocked(useApiFetch)
mockApiFetch.mockResolvedValueOnce({ data: { task_id: 7, is_new: true }, error: null })
const store = usePrepStore()
store.currentJobId = 1
// Spy on pollTask via the interval
const pollSpy = mockApiFetch
.mockResolvedValueOnce({ data: { status: 'running', stage: 'Analyzing', message: null }, error: null })
await store.generateResearch(1)
// Advance timer one tick — should poll
await vi.advanceTimersByTimeAsync(3000)
// Should have called POST generate + poll task
expect(mockApiFetch).toHaveBeenCalledWith(
expect.stringContaining('/research/generate'),
expect.objectContaining({ method: 'POST' })
)
})
it('pollTask stops when status is completed and re-fetches research', async () => {
const mockApiFetch = vi.mocked(useApiFetch)
// Set up store with a job loaded
mockApiFetch
.mockResolvedValueOnce({ data: { company_brief: 'Acme', ceo_brief: null, talking_points: null,
tech_brief: null, funding_brief: null, red_flags: null, accessibility_brief: null,
generated_at: null }, error: null })
.mockResolvedValueOnce({ data: [], error: null })
.mockResolvedValueOnce({ data: { status: 'none', stage: null, message: null }, error: null })
.mockResolvedValueOnce({ data: { id: 1, title: 'Eng', company: 'Acme', url: null,
description: null, cover_letter: null, match_score: null, keyword_gaps: null }, error: null })
const store = usePrepStore()
await store.fetchFor(1)
// Mock first poll → completed
mockApiFetch
.mockResolvedValueOnce({ data: { status: 'completed', stage: null, message: null }, error: null })
// re-fetch on completed: research, contacts, task, fullJob
.mockResolvedValueOnce({ data: { company_brief: 'Updated!', ceo_brief: null, talking_points: null,
tech_brief: null, funding_brief: null, red_flags: null, accessibility_brief: null,
generated_at: '2026-03-20T13:00:00' }, error: null })
.mockResolvedValueOnce({ data: [], error: null })
.mockResolvedValueOnce({ data: { status: 'completed', stage: null, message: null }, error: 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 })
store.pollTask(1)
await vi.advanceTimersByTimeAsync(3000)
expect(store.research?.company_brief).toBe('Updated!')
})
it('clear cancels polling interval and resets state', async () => {
const mockApiFetch = vi.mocked(useApiFetch)
mockApiFetch
.mockResolvedValueOnce({ data: { company_brief: 'Acme', ceo_brief: null, talking_points: null,
tech_brief: null, funding_brief: null, red_flags: null, accessibility_brief: null,
generated_at: null }, error: null })
.mockResolvedValueOnce({ data: [], error: null })
.mockResolvedValueOnce({ data: { status: 'none', stage: null, message: null }, error: null })
.mockResolvedValueOnce({ data: { id: 1, title: 'Eng', company: 'Acme', url: null,
description: null, cover_letter: null, match_score: null, keyword_gaps: null }, error: null })
const store = usePrepStore()
await store.fetchFor(1)
store.pollTask(1)
store.clear()
// Advance timers — if polling wasn't cancelled, fetchFor would be called again
const callCountBeforeClear = mockApiFetch.mock.calls.length
await vi.advanceTimersByTimeAsync(9000)
expect(mockApiFetch.mock.calls.length).toBe(callCountBeforeClear)
expect(store.research).toBeNull()
expect(store.contacts).toEqual([])
expect(store.contactsError).toBeNull()
expect(store.currentJobId).toBeNull()
})
it('fetchFor sets contactsError and leaves other data intact when contacts fetch fails', async () => {
const mockApiFetch = vi.mocked(useApiFetch)
mockApiFetch
.mockResolvedValueOnce({ data: { company_brief: 'Acme', ceo_brief: null, talking_points: null,
tech_brief: null, funding_brief: null, red_flags: null, accessibility_brief: null,
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: { status: 'none', stage: null, message: null }, error: null }) // task OK
.mockResolvedValueOnce({ data: { id: 1, title: 'Engineer', company: 'Acme', url: null,
description: 'Build things.', cover_letter: null, match_score: 80,
keyword_gaps: null }, error: null }) // fullJob OK
const store = usePrepStore()
await store.fetchFor(1)
// Contacts error shown in Email tab only
expect(store.contactsError).toBe('Could not load email history.')
expect(store.contacts).toEqual([])
// Everything else still renders
expect(store.research?.company_brief).toBe('Acme')
expect(store.fullJob?.description).toBe('Build things.')
expect(store.fullJob?.match_score).toBe(80)
expect(store.taskStatus.status).toBe('none')
// Top-level error stays null (no full-panel blank-out)
expect(store.error).toBeNull()
})
})

173
web/src/stores/prep.ts Normal file
View file

@ -0,0 +1,173 @@
import { ref } from 'vue'
import { defineStore } from 'pinia'
import { useApiFetch } from '../composables/useApi'
export interface ResearchBrief {
company_brief: string | null
ceo_brief: string | null
talking_points: string | null
tech_brief: string | null
funding_brief: string | null
red_flags: string | null
accessibility_brief: string | null
generated_at: string | null
}
export interface Contact {
id: number
direction: 'inbound' | 'outbound'
subject: string | null
from_addr: string | null
body: string | null
received_at: string | null
}
export interface TaskStatus {
status: 'queued' | 'running' | 'completed' | 'failed' | 'none' | null
stage: string | null
message: string | null
}
export interface FullJobDetail {
id: number
title: string
company: string
url: string | null
description: string | null
cover_letter: string | null
match_score: number | null
keyword_gaps: string | null
}
export const usePrepStore = defineStore('prep', () => {
const research = ref<ResearchBrief | null>(null)
const contacts = ref<Contact[]>([])
const contactsError = ref<string | null>(null)
const taskStatus = ref<TaskStatus>({ status: null, stage: null, message: null })
const fullJob = ref<FullJobDetail | null>(null)
const loading = ref(false)
const error = ref<string | null>(null)
const currentJobId = ref<number | null>(null)
let pollInterval: ReturnType<typeof setInterval> | null = null
function _clearInterval() {
if (pollInterval !== null) {
clearInterval(pollInterval)
pollInterval = null
}
}
async function fetchFor(jobId: number) {
if (jobId !== currentJobId.value) {
_clearInterval()
research.value = null
contacts.value = []
contactsError.value = null
taskStatus.value = { status: null, stage: null, message: null }
fullJob.value = null
error.value = null
currentJobId.value = jobId
}
loading.value = true
try {
const [researchResult, contactsResult, taskResult, jobResult] = await Promise.all([
useApiFetch<ResearchBrief>(`/api/jobs/${jobId}/research`),
useApiFetch<Contact[]>(`/api/jobs/${jobId}/contacts`),
useApiFetch<TaskStatus>(`/api/jobs/${jobId}/research/task`),
useApiFetch<FullJobDetail>(`/api/jobs/${jobId}`),
])
// Research 404 is expected (no research yet) — only surface non-404 errors
if (researchResult.error && !(researchResult.error.kind === 'http' && researchResult.error.status === 404)) {
error.value = 'Failed to load research data'
return
}
if (jobResult.error) {
error.value = 'Failed to load job details'
return
}
research.value = researchResult.data ?? null
// Contacts failure is non-fatal — degrade the Email tab only
if (contactsResult.error) {
contactsError.value = 'Could not load email history.'
contacts.value = []
} else {
contacts.value = contactsResult.data ?? []
contactsError.value = null
}
taskStatus.value = taskResult.data ?? { status: null, stage: null, message: null }
fullJob.value = jobResult.data ?? null
// If a task is already running/queued, start polling
const ts = taskStatus.value.status
if (ts === 'queued' || ts === 'running') {
pollTask(jobId)
}
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to load prep data'
} finally {
loading.value = false
}
}
async function generateResearch(jobId: number) {
const { data, error: fetchError } = await useApiFetch<{ task_id: number; is_new: boolean }>(
`/api/jobs/${jobId}/research/generate`,
{ method: 'POST' }
)
if (fetchError || !data) {
error.value = 'Failed to start research generation'
return
}
pollTask(jobId)
}
/** @internal — called by fetchFor and generateResearch; not for component use */
function pollTask(jobId: number) {
_clearInterval()
pollInterval = setInterval(async () => {
const { data } = await useApiFetch<TaskStatus>(`/api/jobs/${jobId}/research/task`)
if (data) {
taskStatus.value = data
if (data.status === 'completed' || data.status === 'failed') {
_clearInterval()
if (data.status === 'completed') {
await fetchFor(jobId)
}
}
}
}, 3000)
}
function clear() {
_clearInterval()
research.value = null
contacts.value = []
contactsError.value = null
taskStatus.value = { status: null, stage: null, message: null }
fullJob.value = null
loading.value = false
error.value = null
currentJobId.value = null
}
return {
research,
contacts,
contactsError,
taskStatus,
fullJob,
loading,
error,
currentJobId,
fetchFor,
generateResearch,
pollTask,
clear,
}
})

144
web/src/stores/review.ts Normal file
View file

@ -0,0 +1,144 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { useApiFetch } from '../composables/useApi'
export interface Job {
id: number
title: string
company: string
url: string
source: string | null
location: string | null
is_remote: boolean
salary: string | null
description: string | null
match_score: number | null
keyword_gaps: string | null // JSON-encoded string[]
date_found: string
status: string
}
interface UndoEntry {
job: Job
action: 'approve' | 'reject' | 'skip'
prevStatus: string
}
// Stoop speed: 10 cards in 60 seconds — easter egg 9.2
const STOOP_CARDS = 10
const STOOP_SECS = 60
export const useReviewStore = defineStore('review', () => {
const queue = ref<Job[]>([])
const listJobs = ref<Job[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
const undoStack = ref<UndoEntry[]>([])
const sessionStart = ref<number | null>(null)
const sessionCount = ref(0)
const stoopAchieved = ref(false)
const currentJob = computed(() => queue.value[0] ?? null)
const remaining = computed(() => queue.value.length)
const isStoopSpeed = computed(() => {
if (stoopAchieved.value || !sessionStart.value) return false
const elapsed = (Date.now() - sessionStart.value) / 1000
return sessionCount.value >= STOOP_CARDS && elapsed <= STOOP_SECS
})
async function fetchQueue() {
loading.value = true
error.value = null
const { data, error: err } = await useApiFetch<Job[]>('/api/jobs?status=pending&limit=50')
loading.value = false
if (err) { error.value = 'Failed to load queue'; return }
queue.value = data ?? []
// Start session clock on first load with items
if (!sessionStart.value && queue.value.length > 0) {
sessionStart.value = Date.now()
sessionCount.value = 0
}
}
async function fetchList(status: string) {
loading.value = true
error.value = null
const { data, error: err } = await useApiFetch<Job[]>(`/api/jobs?status=${encodeURIComponent(status)}`)
loading.value = false
if (err) { error.value = 'Failed to load jobs'; return }
listJobs.value = data ?? []
}
async function approve(job: Job) {
const { error: err } = await useApiFetch(`/api/jobs/${job.id}/approve`, { method: 'POST' })
if (err) return false
undoStack.value.push({ job, action: 'approve', prevStatus: job.status })
queue.value = queue.value.filter(j => j.id !== job.id)
_tickSession()
return true
}
async function reject(job: Job) {
const { error: err } = await useApiFetch(`/api/jobs/${job.id}/reject`, { method: 'POST' })
if (err) return false
undoStack.value.push({ job, action: 'reject', prevStatus: job.status })
queue.value = queue.value.filter(j => j.id !== job.id)
_tickSession()
return true
}
function skip(job: Job) {
// Skip: move current card to back of queue without API call
queue.value = queue.value.filter(j => j.id !== job.id)
queue.value.push(job)
undoStack.value.push({ job, action: 'skip', prevStatus: job.status })
_tickSession()
return true
}
async function undo() {
const entry = undoStack.value.pop()
if (!entry) return false
const { job, action } = entry
if (action === 'skip') {
// Was at back of queue — remove from wherever it landed, put at front
queue.value = queue.value.filter(j => j.id !== job.id)
queue.value.unshift(job)
} else {
await useApiFetch(`/api/jobs/${job.id}/revert`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: entry.prevStatus }),
})
queue.value.unshift(job)
}
sessionCount.value = Math.max(0, sessionCount.value - 1)
return true
}
function _tickSession() {
sessionCount.value++
}
function markStoopAchieved() {
stoopAchieved.value = true
}
function resetSession() {
sessionStart.value = Date.now()
sessionCount.value = 0
stoopAchieved.value = false
}
return {
queue, listJobs, loading, error,
undoStack,
currentJob, remaining,
sessionCount, isStoopSpeed, stoopAchieved,
fetchQueue, fetchList,
approve, reject, skip, undo,
markStoopAchieved, resetSession,
}
})

View file

@ -0,0 +1,22 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useDataStore } from './data'
vi.mock('../../composables/useApi', () => ({ useApiFetch: vi.fn() }))
import { useApiFetch } from '../../composables/useApi'
const mockFetch = vi.mocked(useApiFetch)
describe('useDataStore', () => {
beforeEach(() => { setActivePinia(createPinia()); vi.clearAllMocks() })
it('initial backupPath is null', () => {
expect(useDataStore().backupPath).toBeNull()
})
it('createBackup() sets backupPath after success', async () => {
mockFetch.mockResolvedValue({ data: { path: 'data/backup.zip', file_count: 12, size_bytes: 1024 }, error: null })
const store = useDataStore()
await store.createBackup(false)
expect(store.backupPath).toBe('data/backup.zip')
})
})

View file

@ -0,0 +1,30 @@
import { ref } from 'vue'
import { defineStore } from 'pinia'
import { useApiFetch } from '../../composables/useApi'
export const useDataStore = defineStore('settings/data', () => {
const backupPath = ref<string | null>(null)
const backupFileCount = ref(0)
const backupSizeBytes = ref(0)
const creatingBackup = ref(false)
const restoring = ref(false)
const restoreResult = ref<{restored: string[]; skipped: string[]} | null>(null)
const backupError = ref<string | null>(null)
const restoreError = ref<string | null>(null)
async function createBackup(includeDb: boolean) {
creatingBackup.value = true
backupError.value = null
const { data, error } = await useApiFetch<{path: string; file_count: number; size_bytes: number}>(
'/api/settings/data/backup/create',
{ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ include_db: includeDb }) }
)
creatingBackup.value = false
if (error || !data) { backupError.value = 'Backup failed'; return }
backupPath.value = data.path
backupFileCount.value = data.file_count
backupSizeBytes.value = data.size_bytes
}
return { backupPath, backupFileCount, backupSizeBytes, creatingBackup, restoring, restoreResult, backupError, restoreError, createBackup }
})

View file

@ -0,0 +1,39 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useFineTuneStore } from './fineTune'
vi.mock('../../composables/useApi', () => ({ useApiFetch: vi.fn() }))
import { useApiFetch } from '../../composables/useApi'
const mockFetch = vi.mocked(useApiFetch)
describe('useFineTuneStore', () => {
beforeEach(() => { setActivePinia(createPinia()); vi.clearAllMocks(); vi.useFakeTimers() })
afterEach(() => { vi.useRealTimers() })
it('initial step is 1', () => {
expect(useFineTuneStore().step).toBe(1)
})
it('resetStep() returns to step 1', () => {
const store = useFineTuneStore()
store.step = 3
store.resetStep()
expect(store.step).toBe(1)
})
it('loadStatus() sets inFlightJob when status is running', async () => {
mockFetch.mockResolvedValue({ data: { status: 'running', pairs_count: 10 }, error: null })
const store = useFineTuneStore()
await store.loadStatus()
expect(store.inFlightJob).toBe(true)
})
it('startPolling() calls loadStatus on interval', async () => {
mockFetch.mockResolvedValue({ data: { status: 'idle' }, error: null })
const store = useFineTuneStore()
store.startPolling()
await vi.advanceTimersByTimeAsync(4000)
expect(mockFetch).toHaveBeenCalledWith('/api/settings/fine-tune/status')
store.stopPolling()
})
})

View file

@ -0,0 +1,54 @@
import { ref } from 'vue'
import { defineStore } from 'pinia'
import { useApiFetch } from '../../composables/useApi'
export const useFineTuneStore = defineStore('settings/fineTune', () => {
const step = ref(1)
const inFlightJob = ref(false)
const jobStatus = ref<string>('idle')
const pairsCount = ref(0)
const quotaRemaining = ref<number | null>(null)
const uploading = ref(false)
const loading = ref(false)
let _pollTimer: ReturnType<typeof setInterval> | null = null
function resetStep() { step.value = 1 }
async function loadStatus() {
const { data } = await useApiFetch<{ status: string; pairs_count: number; quota_remaining?: number }>('/api/settings/fine-tune/status')
if (!data) return
jobStatus.value = data.status
pairsCount.value = data.pairs_count ?? 0
quotaRemaining.value = data.quota_remaining ?? null
inFlightJob.value = ['queued', 'running'].includes(data.status)
}
function startPolling() {
loadStatus()
_pollTimer = setInterval(loadStatus, 2000)
}
function stopPolling() {
if (_pollTimer !== null) { clearInterval(_pollTimer); _pollTimer = null }
}
async function submitJob() {
const { data, error } = await useApiFetch<{ job_id: string }>('/api/settings/fine-tune/submit', { method: 'POST' })
if (!error && data) { inFlightJob.value = true; jobStatus.value = 'queued' }
}
return {
step,
inFlightJob,
jobStatus,
pairsCount,
quotaRemaining,
uploading,
loading,
resetStep,
loadStatus,
startPolling,
stopPolling,
submitJob,
}
})

View file

@ -0,0 +1,30 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useLicenseStore } from './license'
vi.mock('../../composables/useApi', () => ({ useApiFetch: vi.fn() }))
import { useApiFetch } from '../../composables/useApi'
const mockFetch = vi.mocked(useApiFetch)
describe('useLicenseStore', () => {
beforeEach(() => { setActivePinia(createPinia()); vi.clearAllMocks() })
it('initial active is false', () => {
expect(useLicenseStore().active).toBe(false)
})
it('activate() on success sets tier and active=true', async () => {
mockFetch.mockResolvedValue({ data: { ok: true, tier: 'paid' }, error: null })
const store = useLicenseStore()
await store.activate('CFG-PRNG-TEST-1234-5678')
expect(store.tier).toBe('paid')
expect(store.active).toBe(true)
})
it('activate() on failure sets activateError', async () => {
mockFetch.mockResolvedValue({ data: { ok: false, error: 'Invalid key' }, error: null })
const store = useLicenseStore()
await store.activate('bad-key')
expect(store.activateError).toBe('Invalid key')
})
})

View file

@ -0,0 +1,51 @@
import { ref } from 'vue'
import { defineStore } from 'pinia'
import { useApiFetch } from '../../composables/useApi'
export const useLicenseStore = defineStore('settings/license', () => {
const tier = ref<string>('free')
const licenseKey = ref<string | null>(null)
const active = ref(false)
const gracePeriodEnds = ref<string | null>(null)
const loading = ref(false)
const activating = ref(false)
const activateError = ref<string | null>(null)
async function loadLicense() {
loading.value = true
const { data } = await useApiFetch<{tier: string; key: string | null; active: boolean; grace_period_ends?: string}>('/api/settings/license')
loading.value = false
if (!data) return
tier.value = data.tier
licenseKey.value = data.key
active.value = data.active
gracePeriodEnds.value = data.grace_period_ends ?? null
}
async function activate(key: string) {
activating.value = true
activateError.value = null
const { data } = await useApiFetch<{ok: boolean; tier?: string; error?: string}>(
'/api/settings/license/activate',
{ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key }) }
)
activating.value = false
if (!data) { activateError.value = 'Request failed'; return }
if (data.ok) {
active.value = true
tier.value = data.tier ?? tier.value
licenseKey.value = key
} else {
activateError.value = data.error ?? 'Activation failed'
}
}
async function deactivate() {
await useApiFetch('/api/settings/license/deactivate', { method: 'POST' })
active.value = false
licenseKey.value = null
tier.value = 'free'
}
return { tier, licenseKey, active, gracePeriodEnds, loading, activating, activateError, loadLicense, activate, deactivate }
})

View file

@ -0,0 +1,43 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { usePrivacyStore } from './privacy'
vi.mock('../../composables/useApi', () => ({ useApiFetch: vi.fn() }))
import { useApiFetch } from '../../composables/useApi'
const mockFetch = vi.mocked(useApiFetch)
describe('usePrivacyStore', () => {
beforeEach(() => { setActivePinia(createPinia()); vi.clearAllMocks() })
it('byokInfoDismissed is false by default', () => {
expect(usePrivacyStore().byokInfoDismissed).toBe(false)
})
it('dismissByokInfo() sets dismissed to true', () => {
const store = usePrivacyStore()
store.dismissByokInfo()
expect(store.byokInfoDismissed).toBe(true)
})
it('showByokPanel is true when cloud backends configured and not dismissed', () => {
const store = usePrivacyStore()
store.activeCloudBackends = ['anthropic']
store.byokInfoDismissed = false
expect(store.showByokPanel).toBe(true)
})
it('showByokPanel is false when dismissed', () => {
const store = usePrivacyStore()
store.activeCloudBackends = ['anthropic']
store.byokInfoDismissed = true
expect(store.showByokPanel).toBe(false)
})
it('showByokPanel re-appears when new backend added after dismissal', () => {
const store = usePrivacyStore()
store.activeCloudBackends = ['anthropic']
store.dismissByokInfo()
store.activeCloudBackends = ['anthropic', 'openai']
expect(store.showByokPanel).toBe(true)
})
})

View file

@ -0,0 +1,64 @@
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
import { useApiFetch } from '../../composables/useApi'
export const usePrivacyStore = defineStore('settings/privacy', () => {
// Session-scoped BYOK panel state
const activeCloudBackends = ref<string[]>([])
const byokInfoDismissed = ref(false)
const dismissedForBackends = ref<string[]>([])
// Self-hosted privacy prefs
const telemetryOptIn = ref(false)
// Cloud privacy prefs
const masterOff = ref(false)
const usageEvents = ref(true)
const contentSharing = ref(false)
const loading = ref(false)
const saving = ref(false)
// Panel shows if there are active cloud backends not yet covered by dismissal snapshot,
// or if byokInfoDismissed was set directly (e.g. loaded from server) and new backends haven't appeared
const showByokPanel = computed(() => {
if (activeCloudBackends.value.length === 0) return false
if (byokInfoDismissed.value && activeCloudBackends.value.every(b => dismissedForBackends.value.includes(b))) return false
if (byokInfoDismissed.value && dismissedForBackends.value.length === 0) return false
return !activeCloudBackends.value.every(b => dismissedForBackends.value.includes(b))
})
function dismissByokInfo() {
dismissedForBackends.value = [...activeCloudBackends.value]
byokInfoDismissed.value = true
}
async function loadPrivacy() {
loading.value = true
const { data } = await useApiFetch<Record<string, unknown>>('/api/settings/privacy')
loading.value = false
if (!data) return
telemetryOptIn.value = Boolean(data.telemetry_opt_in)
byokInfoDismissed.value = Boolean(data.byok_info_dismissed)
masterOff.value = Boolean(data.master_off)
usageEvents.value = data.usage_events !== false
contentSharing.value = Boolean(data.content_sharing)
}
async function savePrivacy(prefs: Record<string, unknown>) {
saving.value = true
await useApiFetch('/api/settings/privacy', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(prefs),
})
saving.value = false
}
return {
activeCloudBackends, byokInfoDismissed, dismissedForBackends,
telemetryOptIn, masterOff, usageEvents, contentSharing,
loading, saving, showByokPanel,
dismissByokInfo, loadPrivacy, savePrivacy,
}
})

View file

@ -0,0 +1,51 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useProfileStore } from './profile'
vi.mock('../../composables/useApi', () => ({ useApiFetch: vi.fn() }))
import { useApiFetch } from '../../composables/useApi'
const mockFetch = vi.mocked(useApiFetch)
describe('useProfileStore', () => {
beforeEach(() => { setActivePinia(createPinia()); vi.clearAllMocks() })
it('load() populates fields from API', async () => {
mockFetch.mockResolvedValue({
data: { name: 'Meg', email: 'meg@example.com', phone: '555-0100',
linkedin_url: '', career_summary: '', candidate_voice: '',
inference_profile: 'cpu', mission_preferences: [],
nda_companies: [], accessibility_focus: false, lgbtq_focus: false },
error: null,
})
const store = useProfileStore()
await store.load()
expect(store.name).toBe('Meg')
expect(store.email).toBe('meg@example.com')
})
it('save() calls PUT /api/settings/profile', async () => {
mockFetch.mockResolvedValue({ data: { ok: true }, error: null })
const store = useProfileStore()
store.name = 'Meg'
await store.save()
expect(mockFetch).toHaveBeenCalledWith('/api/settings/profile', expect.objectContaining({ method: 'PUT' }))
expect(mockFetch).toHaveBeenCalledWith(
'/api/settings/resume/sync-identity',
expect.objectContaining({ method: 'POST' })
)
})
it('save() error sets error state', async () => {
mockFetch.mockResolvedValue({ data: null, error: { kind: 'network', message: 'fail' } })
const store = useProfileStore()
await store.save()
expect(store.saveError).toBeTruthy()
})
it('sets loadError when load fails', async () => {
mockFetch.mockResolvedValueOnce({ data: null, error: { kind: 'network', message: 'Network error' } })
const store = useProfileStore()
await store.load()
expect(store.loadError).toBe('Network error')
})
})

View file

@ -0,0 +1,94 @@
import { ref } from 'vue'
import { defineStore } from 'pinia'
import { useApiFetch } from '../../composables/useApi'
export interface MissionPref { id: string; industry: string; note: string }
export const useProfileStore = defineStore('settings/profile', () => {
const name = ref('')
const email = ref('')
const phone = ref('')
const linkedin_url = ref('')
const career_summary = ref('')
const candidate_voice = ref('')
const inference_profile = ref('cpu')
const mission_preferences = ref<MissionPref[]>([])
const nda_companies = ref<string[]>([])
const accessibility_focus = ref(false)
const lgbtq_focus = ref(false)
const loading = ref(false)
const saving = ref(false)
const saveError = ref<string | null>(null)
const loadError = ref<string | null>(null)
async function load() {
loading.value = true
loadError.value = null
const { data, error } = await useApiFetch<Record<string, unknown>>('/api/settings/profile')
loading.value = false
if (error) {
loadError.value = error.kind === 'network' ? error.message : error.detail || 'Failed to load profile'
return
}
if (!data) return
name.value = String(data.name ?? '')
email.value = String(data.email ?? '')
phone.value = String(data.phone ?? '')
linkedin_url.value = String(data.linkedin_url ?? '')
career_summary.value = String(data.career_summary ?? '')
candidate_voice.value = String(data.candidate_voice ?? '')
inference_profile.value = String(data.inference_profile ?? 'cpu')
mission_preferences.value = ((data.mission_preferences as Array<{ industry: string; note: string }>) ?? [])
.map((m) => ({ id: crypto.randomUUID(), industry: m.industry ?? '', note: m.note ?? '' }))
nda_companies.value = (data.nda_companies as string[]) ?? []
accessibility_focus.value = Boolean(data.accessibility_focus)
lgbtq_focus.value = Boolean(data.lgbtq_focus)
}
async function save() {
saving.value = true
saveError.value = null
const body = {
name: name.value,
email: email.value,
phone: phone.value,
linkedin_url: linkedin_url.value,
career_summary: career_summary.value,
candidate_voice: candidate_voice.value,
inference_profile: inference_profile.value,
mission_preferences: mission_preferences.value.map(({ industry, note }) => ({ industry, note })),
nda_companies: nda_companies.value,
accessibility_focus: accessibility_focus.value,
lgbtq_focus: lgbtq_focus.value,
}
const { error } = await useApiFetch('/api/settings/profile', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
saving.value = false
if (error) {
saveError.value = 'Save failed — please try again.'
return
}
// fire-and-forget — identity sync failures don't block save
useApiFetch('/api/settings/resume/sync-identity', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: name.value,
email: email.value,
phone: phone.value,
linkedin_url: linkedin_url.value,
}),
})
}
return {
name, email, phone, linkedin_url, career_summary, candidate_voice, inference_profile,
mission_preferences, nda_companies, accessibility_focus, lgbtq_focus,
loading, saving, saveError, loadError,
load, save,
}
})

View file

@ -0,0 +1,50 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useResumeStore } from './resume'
vi.mock('../../composables/useApi', () => ({ useApiFetch: vi.fn() }))
import { useApiFetch } from '../../composables/useApi'
const mockFetch = vi.mocked(useApiFetch)
describe('useResumeStore', () => {
beforeEach(() => { setActivePinia(createPinia()); vi.clearAllMocks() })
it('hasResume is false before load', () => {
expect(useResumeStore().hasResume).toBe(false)
})
it('load() sets hasResume from API exists flag', async () => {
mockFetch.mockResolvedValue({ data: { exists: true, name: 'Meg', email: '', phone: '',
linkedin_url: '', surname: '', address: '', city: '', zip_code: '', date_of_birth: '',
experience: [], salary_min: 0, salary_max: 0, notice_period: '', remote: false,
relocation: false, assessment: false, background_check: false,
gender: '', pronouns: '', ethnicity: '', veteran_status: '', disability: '',
skills: [], domains: [], keywords: [],
}, error: null })
const store = useResumeStore()
await store.load()
expect(store.hasResume).toBe(true)
})
it('syncFromProfile() copies identity fields', () => {
const store = useResumeStore()
store.syncFromProfile({ name: 'Test', email: 'a@b.com', phone: '555', linkedin_url: 'li.com/test' })
expect(store.name).toBe('Test')
expect(store.email).toBe('a@b.com')
})
it('load() empty-state when exists=false', async () => {
mockFetch.mockResolvedValue({ data: { exists: false }, error: null })
const store = useResumeStore()
await store.load()
expect(store.hasResume).toBe(false)
})
it('load() sets loadError on API error', async () => {
mockFetch.mockResolvedValue({ data: null, error: { kind: 'network', message: 'Network error' } })
const store = useResumeStore()
await store.load()
expect(store.loadError).toBeTruthy()
expect(store.hasResume).toBe(false)
})
})

View file

@ -0,0 +1,125 @@
import { ref } from 'vue'
import { defineStore } from 'pinia'
import { useApiFetch } from '../../composables/useApi'
export interface WorkEntry {
id: string
title: string; company: string; period: string; location: string
industry: string; responsibilities: string; skills: string[]
}
export const useResumeStore = defineStore('settings/resume', () => {
const hasResume = ref(false)
const loading = ref(false)
const saving = ref(false)
const saveError = ref<string | null>(null)
const loadError = ref<string | null>(null)
// Identity (synced from profile store)
const name = ref(''); const email = ref(''); const phone = ref(''); const linkedin_url = ref('')
// Resume-only contact
const surname = ref(''); const address = ref(''); const city = ref('')
const zip_code = ref(''); const date_of_birth = ref('')
// Experience
const experience = ref<WorkEntry[]>([])
// Prefs
const salary_min = ref(0); const salary_max = ref(0); const notice_period = ref('')
const remote = ref(false); const relocation = ref(false)
const assessment = ref(false); const background_check = ref(false)
// Self-ID
const gender = ref(''); const pronouns = ref(''); const ethnicity = ref('')
const veteran_status = ref(''); const disability = ref('')
// Keywords
const skills = ref<string[]>([]); const domains = ref<string[]>([]); const keywords = ref<string[]>([])
function syncFromProfile(p: { name: string; email: string; phone: string; linkedin_url: string }) {
name.value = p.name; email.value = p.email
phone.value = p.phone; linkedin_url.value = p.linkedin_url
}
async function load() {
loading.value = true
loadError.value = null
const { data, error } = await useApiFetch<Record<string, unknown>>('/api/settings/resume')
loading.value = false
if (error) {
loadError.value = error.kind === 'network' ? error.message : (error.detail || 'Failed to load resume')
return
}
if (!data || !data.exists) { hasResume.value = false; return }
hasResume.value = true
name.value = String(data.name ?? ''); email.value = String(data.email ?? '')
phone.value = String(data.phone ?? ''); linkedin_url.value = String(data.linkedin_url ?? '')
surname.value = String(data.surname ?? ''); address.value = String(data.address ?? '')
city.value = String(data.city ?? ''); zip_code.value = String(data.zip_code ?? '')
date_of_birth.value = String(data.date_of_birth ?? '')
experience.value = (data.experience as Omit<WorkEntry, 'id'>[]).map(e => ({ ...e, id: crypto.randomUUID() })) ?? []
salary_min.value = Number(data.salary_min ?? 0); salary_max.value = Number(data.salary_max ?? 0)
notice_period.value = String(data.notice_period ?? '')
remote.value = Boolean(data.remote); relocation.value = Boolean(data.relocation)
assessment.value = Boolean(data.assessment); background_check.value = Boolean(data.background_check)
gender.value = String(data.gender ?? ''); pronouns.value = String(data.pronouns ?? '')
ethnicity.value = String(data.ethnicity ?? ''); veteran_status.value = String(data.veteran_status ?? '')
disability.value = String(data.disability ?? '')
skills.value = (data.skills as string[]) ?? []
domains.value = (data.domains as string[]) ?? []
keywords.value = (data.keywords as string[]) ?? []
}
async function save() {
saving.value = true; saveError.value = null
const body = {
name: name.value, email: email.value, phone: phone.value, linkedin_url: linkedin_url.value,
surname: surname.value, address: address.value, city: city.value, zip_code: zip_code.value,
date_of_birth: date_of_birth.value,
experience: experience.value.map(({ id: _id, ...e }) => e),
salary_min: salary_min.value, salary_max: salary_max.value, notice_period: notice_period.value,
remote: remote.value, relocation: relocation.value,
assessment: assessment.value, background_check: background_check.value,
gender: gender.value, pronouns: pronouns.value, ethnicity: ethnicity.value,
veteran_status: veteran_status.value, disability: disability.value,
skills: skills.value, domains: domains.value, keywords: keywords.value,
}
const { error } = await useApiFetch('/api/settings/resume', {
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
})
saving.value = false
if (error) saveError.value = 'Save failed — please try again.'
}
async function createBlank() {
const { error } = await useApiFetch('/api/settings/resume/blank', { method: 'POST' })
if (!error) { hasResume.value = true; await load() }
}
function addExperience() {
experience.value.push({ id: crypto.randomUUID(), title: '', company: '', period: '', location: '', industry: '', responsibilities: '', skills: [] })
}
function removeExperience(idx: number) {
experience.value.splice(idx, 1)
}
function addTag(field: 'skills' | 'domains' | 'keywords', value: string) {
const arr = field === 'skills' ? skills.value : field === 'domains' ? domains.value : keywords.value
const trimmed = value.trim()
if (!trimmed || arr.includes(trimmed)) return
arr.push(trimmed)
}
function removeTag(field: 'skills' | 'domains' | 'keywords', value: string) {
const arr = field === 'skills' ? skills.value : field === 'domains' ? domains.value : keywords.value
const idx = arr.indexOf(value)
if (idx !== -1) arr.splice(idx, 1)
}
return {
hasResume, loading, saving, saveError, loadError,
name, email, phone, linkedin_url, surname, address, city, zip_code, date_of_birth,
experience, salary_min, salary_max, notice_period, remote, relocation, assessment, background_check,
gender, pronouns, ethnicity, veteran_status, disability,
skills, domains, keywords,
syncFromProfile, load, save, createBlank,
addExperience, removeExperience, addTag, removeTag,
}
})

View file

@ -0,0 +1,42 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useSearchStore } from './search'
vi.mock('../../composables/useApi', () => ({ useApiFetch: vi.fn() }))
import { useApiFetch } from '../../composables/useApi'
const mockFetch = vi.mocked(useApiFetch)
describe('useSearchStore', () => {
beforeEach(() => { setActivePinia(createPinia()); vi.clearAllMocks() })
it('defaults remote_preference to both', () => {
expect(useSearchStore().remote_preference).toBe('both')
})
it('load() sets fields from API', async () => {
mockFetch.mockResolvedValue({ data: {
remote_preference: 'remote', job_titles: ['Engineer'], locations: ['NYC'],
exclude_keywords: [], job_boards: [], custom_board_urls: [],
blocklist_companies: [], blocklist_industries: [], blocklist_locations: [],
}, error: null })
const store = useSearchStore()
await store.load()
expect(store.remote_preference).toBe('remote')
expect(store.job_titles).toContain('Engineer')
})
it('suggest() adds to titleSuggestions without persisting', async () => {
mockFetch.mockResolvedValue({ data: { suggestions: ['Staff Engineer'] }, error: null })
const store = useSearchStore()
await store.suggestTitles()
expect(store.titleSuggestions).toContain('Staff Engineer')
expect(store.job_titles).not.toContain('Staff Engineer')
})
it('save() calls PUT endpoint', async () => {
mockFetch.mockResolvedValue({ data: { ok: true }, error: null })
const store = useSearchStore()
await store.save()
expect(mockFetch).toHaveBeenCalledWith('/api/settings/search', expect.objectContaining({ method: 'PUT' }))
})
})

View file

@ -0,0 +1,125 @@
import { ref } from 'vue'
import { defineStore } from 'pinia'
import { useApiFetch } from '../../composables/useApi'
export type RemotePreference = 'remote' | 'onsite' | 'both'
export interface JobBoard { name: string; enabled: boolean }
export const useSearchStore = defineStore('settings/search', () => {
const remote_preference = ref<RemotePreference>('both')
const job_titles = ref<string[]>([])
const locations = ref<string[]>([])
const exclude_keywords = ref<string[]>([])
const job_boards = ref<JobBoard[]>([])
const custom_board_urls = ref<string[]>([])
const blocklist_companies = ref<string[]>([])
const blocklist_industries = ref<string[]>([])
const blocklist_locations = ref<string[]>([])
const titleSuggestions = ref<string[]>([])
const locationSuggestions = ref<string[]>([])
const loading = ref(false)
const saving = ref(false)
const saveError = ref<string | null>(null)
const loadError = ref<string | null>(null)
async function load() {
loading.value = true
loadError.value = null
const { data, error } = await useApiFetch<Record<string, unknown>>('/api/settings/search')
loading.value = false
if (error) { loadError.value = 'Failed to load search preferences'; return }
if (!data) return
remote_preference.value = (data.remote_preference as RemotePreference) ?? 'both'
job_titles.value = (data.job_titles as string[]) ?? []
locations.value = (data.locations as string[]) ?? []
exclude_keywords.value = (data.exclude_keywords as string[]) ?? []
job_boards.value = (data.job_boards as JobBoard[]) ?? []
custom_board_urls.value = (data.custom_board_urls as string[]) ?? []
blocklist_companies.value = (data.blocklist_companies as string[]) ?? []
blocklist_industries.value = (data.blocklist_industries as string[]) ?? []
blocklist_locations.value = (data.blocklist_locations as string[]) ?? []
}
async function save() {
saving.value = true
saveError.value = null
const body = {
remote_preference: remote_preference.value,
job_titles: job_titles.value,
locations: locations.value,
exclude_keywords: exclude_keywords.value,
job_boards: job_boards.value,
custom_board_urls: custom_board_urls.value,
blocklist_companies: blocklist_companies.value,
blocklist_industries: blocklist_industries.value,
blocklist_locations: blocklist_locations.value,
}
const { error } = await useApiFetch('/api/settings/search', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
saving.value = false
if (error) saveError.value = 'Save failed — please try again.'
}
async function suggestTitles() {
const { data } = await useApiFetch<{ suggestions: string[] }>('/api/settings/search/suggest', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'titles', current: job_titles.value }),
})
if (data?.suggestions) {
titleSuggestions.value = data.suggestions.filter(s => !job_titles.value.includes(s))
}
}
async function suggestLocations() {
const { data } = await useApiFetch<{ suggestions: string[] }>('/api/settings/search/suggest', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'locations', current: locations.value }),
})
if (data?.suggestions) {
locationSuggestions.value = data.suggestions.filter(s => !locations.value.includes(s))
}
}
function addTag(field: 'job_titles' | 'locations' | 'exclude_keywords' | 'custom_board_urls' | 'blocklist_companies' | 'blocklist_industries' | 'blocklist_locations', value: string) {
const arr = { job_titles, locations, exclude_keywords, custom_board_urls, blocklist_companies, blocklist_industries, blocklist_locations }[field]
const trimmed = value.trim()
if (!trimmed || arr.value.includes(trimmed)) return
arr.value = [...arr.value, trimmed]
}
function removeTag(field: 'job_titles' | 'locations' | 'exclude_keywords' | 'custom_board_urls' | 'blocklist_companies' | 'blocklist_industries' | 'blocklist_locations', value: string) {
const arr = { job_titles, locations, exclude_keywords, custom_board_urls, blocklist_companies, blocklist_industries, blocklist_locations }[field]
arr.value = arr.value.filter(v => v !== value)
}
function acceptSuggestion(type: 'title' | 'location', value: string) {
if (type === 'title') {
if (!job_titles.value.includes(value)) job_titles.value = [...job_titles.value, value]
titleSuggestions.value = titleSuggestions.value.filter(s => s !== value)
} else {
if (!locations.value.includes(value)) locations.value = [...locations.value, value]
locationSuggestions.value = locationSuggestions.value.filter(s => s !== value)
}
}
function toggleBoard(name: string) {
job_boards.value = job_boards.value.map(b =>
b.name === name ? { ...b, enabled: !b.enabled } : b
)
}
return {
remote_preference, job_titles, locations, exclude_keywords, job_boards,
custom_board_urls, blocklist_companies, blocklist_industries, blocklist_locations,
titleSuggestions, locationSuggestions,
loading, saving, saveError, loadError,
load, save, suggestTitles, suggestLocations, addTag, removeTag, acceptSuggestion, toggleBoard,
}
})

View file

@ -0,0 +1,83 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useSystemStore } from './system'
vi.mock('../../composables/useApi', () => ({ useApiFetch: vi.fn() }))
import { useApiFetch } from '../../composables/useApi'
const mockFetch = vi.mocked(useApiFetch)
describe('useSystemStore — BYOK gate', () => {
beforeEach(() => { setActivePinia(createPinia()); vi.clearAllMocks() })
it('save() proceeds without modal when no cloud backends enabled', async () => {
mockFetch.mockResolvedValue({ data: { ok: true }, error: null })
const store = useSystemStore()
store.backends = [{ id: 'ollama', enabled: true, priority: 1 }]
store.byokAcknowledged = []
await store.trySave()
expect(store.byokPending).toHaveLength(0)
expect(mockFetch).toHaveBeenCalledWith('/api/settings/system/llm', expect.anything())
})
it('save() sets byokPending when new cloud backend enabled', async () => {
const store = useSystemStore()
store.backends = [{ id: 'anthropic', enabled: true, priority: 1 }]
store.byokAcknowledged = []
await store.trySave()
expect(store.byokPending).toContain('anthropic')
expect(mockFetch).not.toHaveBeenCalledWith('/api/settings/system/llm', expect.anything())
})
it('save() skips modal for already-acknowledged backends', async () => {
mockFetch.mockResolvedValue({ data: { ok: true }, error: null })
const store = useSystemStore()
store.backends = [{ id: 'anthropic', enabled: true, priority: 1 }]
store.byokAcknowledged = ['anthropic']
await store.trySave()
expect(store.byokPending).toHaveLength(0)
})
it('confirmByok() saves acknowledgment then commits LLM config', async () => {
mockFetch.mockResolvedValue({ data: { ok: true }, error: null })
const store = useSystemStore()
store.byokPending = ['anthropic']
store.backends = [{ id: 'anthropic', enabled: true, priority: 1 }]
await store.confirmByok()
expect(mockFetch).toHaveBeenCalledWith('/api/settings/system/llm/byok-ack', expect.anything())
expect(mockFetch).toHaveBeenCalledWith('/api/settings/system/llm', expect.anything())
})
it('confirmByok() sets saveError and leaves modal open when ack POST fails', async () => {
mockFetch.mockResolvedValue({ data: null, error: 'Network error' })
const store = useSystemStore()
store.byokPending = ['anthropic']
store.backends = [{ id: 'anthropic', enabled: true, priority: 1 }]
await store.confirmByok()
expect(store.saveError).toBeTruthy()
expect(store.byokPending).toContain('anthropic') // modal stays open
expect(mockFetch).not.toHaveBeenCalledWith('/api/settings/system/llm', expect.anything())
})
it('cancelByok() clears pending and restores backends to pre-save state', async () => {
mockFetch.mockResolvedValue({ data: { ok: true }, error: null })
const store = useSystemStore()
const original = [{ id: 'ollama', enabled: true, priority: 1 }]
store.backends = [...original]
await store.trySave() // captures snapshot, commits (no cloud backends)
store.backends = [{ id: 'anthropic', enabled: true, priority: 1 }]
store.byokPending = ['anthropic']
store.cancelByok()
expect(store.byokPending).toHaveLength(0)
expect(store.backends).toEqual(original)
})
})
describe('useSystemStore — services', () => {
it('loadServices() populates services list', async () => {
mockFetch.mockResolvedValue({ data: [{ name: 'ollama', port: 11434, running: true, note: '' }], error: null })
const store = useSystemStore()
await store.loadServices()
expect(store.services[0].name).toBe('ollama')
expect(store.services[0].running).toBe(true)
})
})

View file

@ -0,0 +1,246 @@
import { ref } from 'vue'
import { defineStore } from 'pinia'
import { useApiFetch } from '../../composables/useApi'
const CLOUD_BACKEND_IDS = ['anthropic', 'openai']
export interface Backend { id: string; enabled: boolean; priority: number }
export interface Service { name: string; port: number; running: boolean; note: string }
export interface IntegrationField { key: string; label: string; type: string }
export interface Integration { id: string; name: string; connected: boolean; tier_required: string; fields: IntegrationField[] }
export const useSystemStore = defineStore('settings/system', () => {
const backends = ref<Backend[]>([])
const byokAcknowledged = ref<string[]>([])
const byokPending = ref<string[]>([])
// Private snapshot — NOT in return(). Closure-level only.
let _preSaveSnapshot: Backend[] = []
const saving = ref(false)
const saveError = ref<string | null>(null)
const loadError = ref<string | null>(null)
const services = ref<Service[]>([])
const emailConfig = ref<Record<string, unknown>>({})
const integrations = ref<Integration[]>([])
const serviceErrors = ref<Record<string, string>>({})
const emailSaving = ref(false)
const emailError = ref<string | null>(null)
// File paths + deployment
const filePaths = ref<Record<string, string>>({})
const deployConfig = ref<Record<string, unknown>>({})
const filePathsSaving = ref(false)
const deploySaving = ref(false)
const filePathsError = ref<string | null>(null)
const deployError = ref<string | null>(null)
// Integration test/connect results — keyed by integration id
const integrationResults = ref<Record<string, {ok: boolean; error?: string}>>({})
async function loadLlm() {
loadError.value = null
const { data, error } = await useApiFetch<{ backends: Backend[]; byok_acknowledged: string[] }>('/api/settings/system/llm')
if (error) { loadError.value = 'Failed to load LLM config'; return }
if (!data) return
backends.value = data.backends ?? []
byokAcknowledged.value = data.byok_acknowledged ?? []
}
async function trySave() {
_preSaveSnapshot = JSON.parse(JSON.stringify(backends.value))
const newlyEnabled = backends.value
.filter(b => CLOUD_BACKEND_IDS.includes(b.id) && b.enabled)
.map(b => b.id)
.filter(id => !byokAcknowledged.value.includes(id))
if (newlyEnabled.length > 0) {
byokPending.value = newlyEnabled
return // modal takes over
}
await _commitSave()
}
async function confirmByok() {
saving.value = true
saveError.value = null
const { error } = await useApiFetch('/api/settings/system/llm/byok-ack', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ backends: byokPending.value }),
})
if (error) {
saving.value = false
saveError.value = 'Failed to save acknowledgment — please try again.'
return // leave modal open, byokPending intact
}
byokAcknowledged.value = [...byokAcknowledged.value, ...byokPending.value]
byokPending.value = []
await _commitSave()
}
function cancelByok() {
if (_preSaveSnapshot.length > 0) {
backends.value = JSON.parse(JSON.stringify(_preSaveSnapshot))
}
byokPending.value = []
_preSaveSnapshot = []
}
async function _commitSave() {
saving.value = true
saveError.value = null
const { error } = await useApiFetch('/api/settings/system/llm', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ backends: backends.value }),
})
saving.value = false
if (error) saveError.value = 'Save failed — please try again.'
}
async function loadServices() {
const { data } = await useApiFetch<Service[]>('/api/settings/system/services')
if (data) services.value = data
}
async function startService(name: string) {
const { data, error } = await useApiFetch<{ok: boolean; output: string}>(
`/api/settings/system/services/${name}/start`, { method: 'POST' }
)
if (error || !data?.ok) {
serviceErrors.value = { ...serviceErrors.value, [name]: data?.output ?? 'Start failed' }
} else {
serviceErrors.value = { ...serviceErrors.value, [name]: '' }
await loadServices()
}
}
async function stopService(name: string) {
const { data, error } = await useApiFetch<{ok: boolean; output: string}>(
`/api/settings/system/services/${name}/stop`, { method: 'POST' }
)
if (error || !data?.ok) {
serviceErrors.value = { ...serviceErrors.value, [name]: data?.output ?? 'Stop failed' }
} else {
serviceErrors.value = { ...serviceErrors.value, [name]: '' }
await loadServices()
}
}
async function loadEmail() {
const { data } = await useApiFetch<Record<string, unknown>>('/api/settings/system/email')
if (data) emailConfig.value = data
}
async function saveEmail() {
emailSaving.value = true
emailError.value = null
const { error } = await useApiFetch('/api/settings/system/email', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(emailConfig.value),
})
emailSaving.value = false
if (error) emailError.value = 'Save failed — please try again.'
}
async function testEmail() {
const { data } = await useApiFetch<{ok: boolean; error?: string}>(
'/api/settings/system/email/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(emailConfig.value),
}
)
return data
}
async function loadIntegrations() {
const { data } = await useApiFetch<Integration[]>('/api/settings/system/integrations')
if (data) integrations.value = data
}
async function connectIntegration(id: string, credentials: Record<string, string>) {
const { data, error } = await useApiFetch<{ok: boolean; error?: string}>(
`/api/settings/system/integrations/${id}/connect`,
{ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(credentials) }
)
const result = error || !data?.ok
? { ok: false, error: data?.error ?? 'Connection failed' }
: { ok: true }
integrationResults.value = { ...integrationResults.value, [id]: result }
if (result.ok) await loadIntegrations()
return result
}
async function testIntegration(id: string, credentials: Record<string, string>) {
const { data, error } = await useApiFetch<{ok: boolean; error?: string}>(
`/api/settings/system/integrations/${id}/test`,
{ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(credentials) }
)
const result = { ok: data?.ok ?? false, error: data?.error ?? (error ? 'Test failed' : undefined) }
integrationResults.value = { ...integrationResults.value, [id]: result }
return result
}
async function disconnectIntegration(id: string) {
const { error } = await useApiFetch(
`/api/settings/system/integrations/${id}/disconnect`, { method: 'POST' }
)
if (!error) await loadIntegrations()
}
async function saveEmailWithPassword(payload: Record<string, unknown>) {
emailSaving.value = true
emailError.value = null
const { error } = await useApiFetch('/api/settings/system/email', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
emailSaving.value = false
if (error) emailError.value = 'Save failed — please try again.'
else await loadEmail() // reload to get fresh password_set status
}
async function loadFilePaths() {
const { data } = await useApiFetch<Record<string, string>>('/api/settings/system/paths')
if (data) filePaths.value = data
}
async function saveFilePaths() {
filePathsSaving.value = true
const { error } = await useApiFetch('/api/settings/system/paths', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(filePaths.value),
})
filePathsSaving.value = false
filePathsError.value = error ? 'Failed to save file paths.' : null
}
async function loadDeployConfig() {
const { data } = await useApiFetch<Record<string, unknown>>('/api/settings/system/deploy')
if (data) deployConfig.value = data
}
async function saveDeployConfig() {
deploySaving.value = true
const { error } = await useApiFetch('/api/settings/system/deploy', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(deployConfig.value),
})
deploySaving.value = false
deployError.value = error ? 'Failed to save deployment config.' : null
}
return {
backends, byokAcknowledged, byokPending, saving, saveError, loadError,
loadLlm, trySave, confirmByok, cancelByok,
services, emailConfig, integrations, integrationResults, serviceErrors, emailSaving, emailError,
filePaths, deployConfig, filePathsSaving, deploySaving, filePathsError, deployError,
loadServices, startService, stopService,
loadEmail, saveEmail, testEmail, saveEmailWithPassword,
loadIntegrations, connectIntegration, testIntegration, disconnectIntegration,
loadFilePaths, saveFilePaths,
loadDeployConfig, saveDeployConfig,
}
})

View file

@ -0,0 +1,173 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useSurveyStore } from './survey'
vi.mock('../composables/useApi', () => ({
useApiFetch: vi.fn(),
}))
import { useApiFetch } from '../composables/useApi'
describe('useSurveyStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
afterEach(() => {
vi.clearAllMocks()
})
it('fetchFor loads history and vision availability in parallel', async () => {
const mockApiFetch = vi.mocked(useApiFetch)
mockApiFetch
.mockResolvedValueOnce({ data: [], error: null }) // history
.mockResolvedValueOnce({ data: { available: true }, error: null }) // vision
const store = useSurveyStore()
await store.fetchFor(1)
expect(store.history).toEqual([])
expect(store.visionAvailable).toBe(true)
expect(store.currentJobId).toBe(1)
expect(mockApiFetch).toHaveBeenCalledTimes(2)
})
it('fetchFor clears state when called for a different job', async () => {
const mockApiFetch = vi.mocked(useApiFetch)
// Job 1
mockApiFetch
.mockResolvedValueOnce({ data: [{ id: 1, llm_output: 'old' }], error: null })
.mockResolvedValueOnce({ data: { available: false }, error: null })
const store = useSurveyStore()
await store.fetchFor(1)
expect(store.history.length).toBe(1)
// Job 2 — state must be cleared before new data arrives
mockApiFetch
.mockResolvedValueOnce({ data: [], error: null })
.mockResolvedValueOnce({ data: { available: true }, error: null })
await store.fetchFor(2)
expect(store.history).toEqual([])
expect(store.currentJobId).toBe(2)
})
it('analyze stores result including mode and rawInput', async () => {
const mockApiFetch = vi.mocked(useApiFetch)
mockApiFetch.mockResolvedValueOnce({
data: { output: '1. B — reason', source: 'text_paste' },
error: null,
})
const store = useSurveyStore()
await store.analyze(1, { text: 'Q1: test', mode: 'quick' })
expect(store.analysis).not.toBeNull()
expect(store.analysis!.output).toBe('1. B — reason')
expect(store.analysis!.source).toBe('text_paste')
expect(store.analysis!.mode).toBe('quick')
expect(store.analysis!.rawInput).toBe('Q1: test')
expect(store.loading).toBe(false)
})
it('analyze sets error on failure', async () => {
const mockApiFetch = vi.mocked(useApiFetch)
mockApiFetch.mockResolvedValueOnce({
data: null,
error: { kind: 'http', status: 500, detail: 'LLM unavailable' },
})
const store = useSurveyStore()
await store.analyze(1, { text: 'Q1: test', mode: 'quick' })
expect(store.analysis).toBeNull()
expect(store.error).toBeTruthy()
expect(store.loading).toBe(false)
})
it('saveResponse prepends to history and clears analysis', async () => {
const mockApiFetch = vi.mocked(useApiFetch)
// Setup: fetchFor
mockApiFetch
.mockResolvedValueOnce({ data: [], error: null })
.mockResolvedValueOnce({ data: { available: true }, error: null })
const store = useSurveyStore()
await store.fetchFor(1)
// Set analysis state manually (as if analyze() was called)
store.analysis = {
output: '1. B — reason',
source: 'text_paste',
mode: 'quick',
rawInput: 'Q1: test',
}
// Save
mockApiFetch.mockResolvedValueOnce({
data: { id: 42 },
error: null,
})
await store.saveResponse(1, { surveyName: 'Round 1', reportedScore: '85%' })
expect(store.history.length).toBe(1)
expect(store.history[0].id).toBe(42)
expect(store.history[0].llm_output).toBe('1. B — reason')
expect(store.analysis).toBeNull()
expect(store.saving).toBe(false)
})
it('saveResponse sets error and preserves analysis on POST failure', async () => {
const mockApiFetch = vi.mocked(useApiFetch)
// Setup: fetchFor
mockApiFetch
.mockResolvedValueOnce({ data: [], error: null })
.mockResolvedValueOnce({ data: { available: true }, error: null })
const store = useSurveyStore()
await store.fetchFor(1)
// Set analysis state manually
store.analysis = {
output: '1. B — reason',
source: 'text_paste',
mode: 'quick',
rawInput: 'Q1: test',
}
// Save fails
mockApiFetch.mockResolvedValueOnce({
data: null,
error: { kind: 'http', status: 500, detail: 'Internal Server Error' },
})
await store.saveResponse(1, { surveyName: 'Round 1', reportedScore: '85%' })
expect(store.saving).toBe(false)
expect(store.error).toBeTruthy()
expect(store.analysis).not.toBeNull()
expect(store.analysis!.output).toBe('1. B — reason')
})
it('clear resets all state to initial values', async () => {
const mockApiFetch = vi.mocked(useApiFetch)
mockApiFetch
.mockResolvedValueOnce({ data: [{ id: 1, llm_output: 'test' }], error: null })
.mockResolvedValueOnce({ data: { available: true }, error: null })
const store = useSurveyStore()
await store.fetchFor(1)
store.clear()
expect(store.history).toEqual([])
expect(store.analysis).toBeNull()
expect(store.visionAvailable).toBe(false)
expect(store.loading).toBe(false)
expect(store.saving).toBe(false)
expect(store.error).toBeNull()
expect(store.currentJobId).toBeNull()
})
})

157
web/src/stores/survey.ts Normal file
View file

@ -0,0 +1,157 @@
import { ref } from 'vue'
import { defineStore } from 'pinia'
import { useApiFetch } from '../composables/useApi'
const validSources = ['text_paste', 'screenshot'] as const
type ValidSource = typeof validSources[number]
function isValidSource(s: string): s is ValidSource {
return validSources.includes(s as ValidSource)
}
export interface SurveyAnalysis {
output: string
source: 'text_paste' | 'screenshot'
mode: 'quick' | 'detailed'
rawInput: string | null
}
export interface SurveyResponse {
id: number
survey_name: string | null
mode: 'quick' | 'detailed'
source: string
raw_input: string | null
image_path: string | null
llm_output: string
reported_score: string | null
received_at: string | null
created_at: string | null
}
export const useSurveyStore = defineStore('survey', () => {
const analysis = ref<SurveyAnalysis | null>(null)
const history = ref<SurveyResponse[]>([])
const loading = ref(false)
const saving = ref(false)
const error = ref<string | null>(null)
const visionAvailable = ref(false)
const currentJobId = ref<number | null>(null)
async function fetchFor(jobId: number) {
if (jobId !== currentJobId.value) {
analysis.value = null
history.value = []
error.value = null
visionAvailable.value = false
currentJobId.value = jobId
}
loading.value = true
try {
const [historyResult, visionResult] = await Promise.all([
useApiFetch<SurveyResponse[]>(`/api/jobs/${jobId}/survey/responses`),
useApiFetch<{ available: boolean }>('/api/vision/health'),
])
if (historyResult.error) {
error.value = 'Could not load survey history.'
} else {
history.value = historyResult.data ?? []
}
visionAvailable.value = visionResult.data?.available ?? false
} finally {
loading.value = false
}
}
async function analyze(
jobId: number,
payload: { text?: string; image_b64?: string; mode: 'quick' | 'detailed' }
) {
loading.value = true
error.value = null
const { data, error: fetchError } = await useApiFetch<{ output: string; source: string }>(
`/api/jobs/${jobId}/survey/analyze`,
{ method: 'POST', body: JSON.stringify(payload) }
)
loading.value = false
if (fetchError || !data) {
error.value = 'Analysis failed. Please try again.'
return
}
analysis.value = {
output: data.output,
source: isValidSource(data.source) ? data.source : 'text_paste',
mode: payload.mode,
rawInput: payload.text ?? null,
}
}
async function saveResponse(
jobId: number,
args: { surveyName: string; reportedScore: string; image_b64?: string }
) {
if (!analysis.value) return
saving.value = true
error.value = null
const body = {
survey_name: args.surveyName || undefined,
mode: analysis.value.mode,
source: analysis.value.source,
raw_input: analysis.value.rawInput,
image_b64: args.image_b64,
llm_output: analysis.value.output,
reported_score: args.reportedScore || undefined,
}
const { data, error: fetchError } = await useApiFetch<{ id: number }>(
`/api/jobs/${jobId}/survey/responses`,
{ method: 'POST', body: JSON.stringify(body) }
)
saving.value = false
if (fetchError || !data) {
error.value = 'Save failed. Your analysis is preserved — try again.'
return
}
// Prepend the saved response to history
const now = new Date().toISOString()
const saved: SurveyResponse = {
id: data.id,
survey_name: args.surveyName || null,
mode: analysis.value.mode,
source: analysis.value.source,
raw_input: analysis.value.rawInput,
image_path: null,
llm_output: analysis.value.output,
reported_score: args.reportedScore || null,
received_at: now,
created_at: now,
}
history.value = [saved, ...history.value]
analysis.value = null
}
function clear() {
analysis.value = null
history.value = []
loading.value = false
saving.value = false
error.value = null
visionAvailable.value = false
currentJobId.value = null
}
return {
analysis,
history,
loading,
saving,
error,
visionAvailable,
currentJobId,
fetchFor,
analyze,
saveResponse,
clear,
}
})

35
web/src/test-setup.ts Normal file
View file

@ -0,0 +1,35 @@
// jsdom does not implement window.matchMedia — stub it so useMotion and other
// composables that check prefers-reduced-motion can import without throwing.
// Gotcha #12.
if (typeof window !== 'undefined' && !window.matchMedia) {
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: (query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: () => {},
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false,
}),
})
}
// navigator.vibrate not in jsdom — stub so useHaptics doesn't throw. Gotcha #9.
if (typeof window !== 'undefined' && !('vibrate' in window.navigator)) {
Object.defineProperty(window.navigator, 'vibrate', {
writable: true,
value: () => false,
})
}
// ResizeObserver not in jsdom — stub if any component uses it.
if (typeof window !== 'undefined' && !window.ResizeObserver) {
window.ResizeObserver = class {
observe() {}
unobserve() {}
disconnect() {}
}
}

561
web/src/views/ApplyView.vue Normal file
View file

@ -0,0 +1,561 @@
<template>
<!-- Mobile: full-width list -->
<div v-if="isMobile" class="apply-list">
<header class="apply-list__header">
<h1 class="apply-list__title">Apply</h1>
<p class="apply-list__subtitle">Approved jobs ready for applications</p>
</header>
<div v-if="loading" class="apply-list__loading" aria-live="polite">
<span class="spinner" aria-hidden="true" />
<span>Loading approved jobs</span>
</div>
<div v-else-if="jobs.length === 0" class="apply-list__empty" role="status">
<span aria-hidden="true" class="empty-icon">📋</span>
<h2 class="empty-title">No approved jobs yet</h2>
<p class="empty-desc">Approve listings in Job Review, then come back here to write applications.</p>
<RouterLink to="/review" class="empty-cta">Go to Job Review </RouterLink>
</div>
<ul v-else class="apply-list__jobs" role="list">
<li v-for="job in jobs" :key="job.id">
<RouterLink :to="`/apply/${job.id}`" class="job-row" :aria-label="`Open ${job.title} at ${job.company}`">
<div class="job-row__main">
<div class="job-row__badges">
<span v-if="job.match_score !== null" class="score-badge" :class="scoreBadgeClass(job.match_score)">
{{ job.match_score }}%
</span>
<span v-if="job.is_remote" class="remote-badge">Remote</span>
<span v-if="job.has_cover_letter" class="cl-badge cl-badge--done"> Draft</span>
<span v-else class="cl-badge cl-badge--pending"> No draft</span>
</div>
<span class="job-row__title">{{ job.title }}</span>
<span class="job-row__company">
{{ job.company }}
<span v-if="job.location" class="job-row__sep" aria-hidden="true"> · </span>
<span v-if="job.location">{{ job.location }}</span>
</span>
</div>
<div class="job-row__meta">
<span v-if="job.salary" class="job-row__salary">{{ job.salary }}</span>
<span class="job-row__arrow" aria-hidden="true"></span>
</div>
</RouterLink>
</li>
</ul>
</div>
<!-- Desktop: split pane -->
<div v-else class="apply-split" :class="{ 'has-selection': selectedJobId !== null }" ref="splitEl">
<!-- Left: narrow job list -->
<div class="apply-split__list">
<div class="split-list__header">
<h1 class="split-list__title">Apply</h1>
<span v-if="coverLetterCount >= 5" class="marathon-badge" title="You're on a roll!">
📬 {{ coverLetterCount }} today
</span>
</div>
<div v-if="loading" class="split-list__loading" aria-live="polite">
<span class="spinner" aria-hidden="true" />
</div>
<div v-else-if="jobs.length === 0" class="split-list__empty" role="status">
<span>No approved jobs yet.</span>
<RouterLink to="/review" class="split-list__cta">Go to Job Review </RouterLink>
</div>
<ul v-else class="split-list__jobs" role="list">
<li v-for="job in jobs" :key="job.id">
<button
class="narrow-row"
:class="{ 'narrow-row--selected': job.id === selectedJobId }"
:aria-label="`Open ${job.title} at ${job.company}`"
:aria-pressed="job.id === selectedJobId"
@click="selectJob(job.id)"
>
<div class="narrow-row__top">
<span class="narrow-row__title">{{ job.title }}</span>
<span
v-if="job.match_score !== null"
class="score-badge"
:class="scoreBadgeClass(job.match_score)"
>{{ job.match_score }}%</span>
</div>
<div class="narrow-row__company">
{{ job.company }}<span v-if="job.has_cover_letter" class="narrow-row__cl-tick"> </span>
</div>
</button>
</li>
</ul>
</div>
<!-- Right: workspace panel -->
<div class="apply-split__panel" aria-live="polite">
<!-- Empty state -->
<div v-if="selectedJobId === null" class="split-panel__empty">
<span aria-hidden="true" style="font-size: 2rem;">🦅</span>
<p>Select a job to open the workspace</p>
</div>
<!-- Workspace -->
<ApplyWorkspace
v-else
:key="selectedJobId"
:job-id="selectedJobId"
@job-removed="onJobRemoved"
@cover-letter-generated="onCoverLetterGenerated"
/>
</div>
<!-- Speed Demon canvas (hidden until triggered) -->
<canvas ref="birdCanvas" class="bird-canvas" aria-hidden="true" />
<!-- Toast -->
<Transition name="toast">
<div v-if="toast" class="split-toast" role="status" aria-live="polite">{{ toast }}</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { RouterLink } from 'vue-router'
import { useApiFetch } from '../composables/useApi'
import ApplyWorkspace from '../components/ApplyWorkspace.vue'
// Responsive
const isMobile = ref(window.innerWidth < 1024)
let _mq: MediaQueryList | null = null
let _mqHandler: ((e: MediaQueryListEvent) => void) | null = null
onMounted(() => {
_mq = window.matchMedia('(max-width: 1023px)')
_mqHandler = (e: MediaQueryListEvent) => { isMobile.value = e.matches }
_mq.addEventListener('change', _mqHandler)
})
onUnmounted(() => {
if (_mq && _mqHandler) _mq.removeEventListener('change', _mqHandler)
clearTimeout(toastTimer)
})
// Job list data
interface ApprovedJob {
id: number
title: string
company: string
location: string | null
is_remote: boolean
salary: string | null
match_score: number | null
has_cover_letter: boolean
}
const jobs = ref<ApprovedJob[]>([])
const loading = ref(true)
async function fetchJobs() {
loading.value = true
try {
const { data } = await useApiFetch<ApprovedJob[]>(
'/api/jobs?status=approved&limit=100&fields=id,title,company,location,is_remote,salary,match_score,has_cover_letter'
)
if (data) jobs.value = data
} finally {
loading.value = false
}
}
onMounted(fetchJobs)
// Score badge 4-tier
function scoreBadgeClass(score: number | null): string {
if (score === null) return ''
if (score >= 70) return 'score-badge--high'
if (score >= 50) return 'score-badge--mid-high'
if (score >= 30) return 'score-badge--mid'
return 'score-badge--low'
}
// Selection
const selectedJobId = ref<number | null>(null)
// Speed Demon: track up to 5 most-recent click timestamps
// Plain let (not ref) never bound to template, no reactivity needed
let recentClicks: number[] = []
function selectJob(id: number) {
selectedJobId.value = id
// Speed Demon tracking
const now = Date.now()
recentClicks = [...recentClicks, now].slice(-5)
if (
recentClicks.length === 5 &&
recentClicks[4] - recentClicks[0] < 3000
) {
fireSpeedDemon()
recentClicks = []
}
}
// Job removed
async function onJobRemoved() {
selectedJobId.value = null
await fetchJobs()
}
// Marathon counter
const coverLetterCount = ref(0)
function onCoverLetterGenerated() {
coverLetterCount.value++
}
// Toast
const toast = ref<string | null>(null)
let toastTimer = 0
function showToast(msg: string) {
clearTimeout(toastTimer)
toast.value = msg
toastTimer = window.setTimeout(() => { toast.value = null }, 2500)
}
// Easter egg: Speed Demon 🦅
const birdCanvas = ref<HTMLCanvasElement | null>(null)
const splitEl = ref<HTMLElement | null>(null)
function fireSpeedDemon() {
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
showToast('🦅 You\'re on the hunt!')
return
}
const canvas = birdCanvas.value
const parent = splitEl.value
if (!canvas || !parent) return
const rect = parent.getBoundingClientRect()
canvas.width = rect.width
canvas.height = rect.height
canvas.style.display = 'block'
const ctx = canvas.getContext('2d')!
const FRAMES = 36 // 600ms at 60fps
const startY = rect.height * 0.35
let frame = 0
function draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height)
const progress = frame / FRAMES
const x = progress * (canvas.width + 60) - 30
const y = startY + Math.sin(progress * Math.PI) * -30
ctx.font = '2rem serif'
ctx.globalAlpha = frame < 4 ? frame / 4 : frame > FRAMES - 4 ? (FRAMES - frame) / 4 : 1
ctx.fillText('🦅', x, y)
frame++
if (frame <= FRAMES) {
requestAnimationFrame(draw)
} else {
ctx.clearRect(0, 0, canvas.width, canvas.height)
canvas.style.display = 'none'
showToast('🦅 You\'re on the hunt!')
}
}
requestAnimationFrame(draw)
}
</script>
<style scoped>
/* ── Shared: spinner ─────────────────────────────────────────────── */
.spinner {
display: inline-block;
width: 1.2rem;
height: 1.2rem;
border: 2px solid var(--color-border);
border-top-color: var(--app-primary);
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* ── Shared: score badges ────────────────────────────────────────── */
.score-badge {
display: inline-flex;
align-items: center;
padding: 1px var(--space-2);
border-radius: 999px;
font-size: var(--text-xs);
font-weight: 700;
font-family: var(--font-mono);
flex-shrink: 0;
}
.score-badge--high { background: rgba(39,174,96,0.12); color: var(--score-high); }
.score-badge--mid-high { background: rgba(43,124,184,0.12); color: var(--score-mid-high); }
.score-badge--mid { background: rgba(212,137,26,0.12); color: var(--score-mid); }
.score-badge--low { background: rgba(192,57,43,0.12); color: var(--score-low); }
.remote-badge {
padding: 1px var(--space-2);
border-radius: 999px;
font-size: var(--text-xs);
font-weight: 600;
background: var(--app-primary-light);
color: var(--app-primary);
}
/* ── Mobile list (unchanged from original) ───────────────────────── */
.apply-list {
max-width: 760px;
margin: 0 auto;
padding: var(--space-8) var(--space-6);
display: flex;
flex-direction: column;
gap: var(--space-6);
}
.apply-list__header { display: flex; flex-direction: column; gap: var(--space-1); }
.apply-list__title { font-family: var(--font-display); font-size: var(--text-2xl); color: var(--app-primary); }
.apply-list__subtitle { font-size: var(--text-sm); color: var(--color-text-muted); }
.apply-list__loading { display: flex; align-items: center; gap: var(--space-3); padding: var(--space-12); color: var(--color-text-muted); font-size: var(--text-sm); justify-content: center; }
.apply-list__empty { display: flex; flex-direction: column; align-items: center; gap: var(--space-3); padding: var(--space-16) var(--space-8); text-align: center; }
.empty-icon { font-size: 3rem; }
.empty-title { font-family: var(--font-display); font-size: var(--text-xl); color: var(--color-text); }
.empty-desc { font-size: var(--text-sm); color: var(--color-text-muted); max-width: 32ch; }
.empty-cta { margin-top: var(--space-2); color: var(--app-primary); font-size: var(--text-sm); font-weight: 600; text-decoration: none; }
.empty-cta:hover { opacity: 0.7; }
.apply-list__jobs { list-style: none; display: flex; flex-direction: column; gap: var(--space-2); }
.job-row { display: flex; align-items: center; justify-content: space-between; gap: var(--space-4); padding: var(--space-4) var(--space-5); background: var(--color-surface-raised); border: 1px solid var(--color-border-light); border-radius: var(--radius-lg); text-decoration: none; min-height: 72px; transition: border-color 150ms ease, box-shadow 150ms ease, transform 120ms ease; }
.job-row:hover { border-color: var(--app-primary); box-shadow: var(--shadow-sm); transform: translateY(-1px); }
.job-row__main { display: flex; flex-direction: column; gap: var(--space-1); flex: 1; min-width: 0; }
.job-row__badges { display: flex; flex-wrap: wrap; gap: var(--space-1); margin-bottom: 2px; }
.job-row__title { font-size: var(--text-sm); font-weight: 700; color: var(--color-text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.job-row__company { font-size: var(--text-xs); color: var(--color-text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.job-row__meta { display: flex; align-items: center; gap: var(--space-3); flex-shrink: 0; }
.job-row__salary { font-size: var(--text-xs); color: var(--color-success); font-weight: 600; white-space: nowrap; }
.job-row__arrow { font-size: 1.25rem; color: var(--color-text-muted); line-height: 1; }
.job-row__sep { color: var(--color-border); }
.cl-badge { padding: 1px var(--space-2); border-radius: 999px; font-size: var(--text-xs); font-weight: 600; }
.cl-badge--done { background: rgba(39,174,96,0.10); color: var(--color-success); }
.cl-badge--pending { background: var(--color-surface-alt); color: var(--color-text-muted); }
/* ── Desktop split pane ──────────────────────────────────────────── */
.apply-split {
position: relative;
display: grid;
grid-template-columns: 28% 0fr;
height: calc(100vh - var(--nav-height, 4rem));
overflow: hidden;
transition: grid-template-columns 200ms ease-out;
}
@media (prefers-reduced-motion: reduce) {
.apply-split { transition: none; }
}
.apply-split.has-selection {
grid-template-columns: 28% 1fr;
}
/* ── Left: narrow list column ────────────────────────────────────── */
.apply-split__list {
display: flex;
flex-direction: column;
border-right: 1px solid var(--color-border-light);
overflow: hidden;
}
.split-list__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-5) var(--space-4) var(--space-3);
border-bottom: 1px solid var(--color-border-light);
flex-shrink: 0;
}
.split-list__title {
font-family: var(--font-display);
font-size: var(--text-xl);
color: var(--app-primary);
}
/* Marathon badge */
.marathon-badge {
font-size: var(--text-xs);
font-weight: 700;
padding: 2px var(--space-2);
border-radius: 999px;
background: rgba(224, 104, 32, 0.12);
color: var(--app-accent);
border: 1px solid rgba(224, 104, 32, 0.3);
cursor: default;
}
.split-list__loading {
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-8);
}
.split-list__empty {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-2);
padding: var(--space-8) var(--space-4);
text-align: center;
font-size: var(--text-sm);
color: var(--color-text-muted);
}
.split-list__cta {
color: var(--app-primary);
font-size: var(--text-xs);
font-weight: 600;
text-decoration: none;
}
.split-list__jobs {
list-style: none;
overflow-y: auto;
flex: 1;
}
/* ── Narrow row ──────────────────────────────────────────────────── */
.narrow-row {
width: 100%;
display: flex;
flex-direction: column;
gap: 2px;
padding: var(--space-3) var(--space-4);
background: none;
border: none;
border-left: 3px solid transparent;
border-bottom: 1px solid var(--color-border-light);
cursor: pointer;
text-align: left;
transition: background 100ms ease, border-left-color 100ms ease;
}
.narrow-row:hover {
background: var(--app-primary-light);
border-left-color: rgba(43, 108, 176, 0.3);
}
.narrow-row--selected {
background: var(--app-primary-light);
/* color-mix enhancement for supported browsers */
background: color-mix(in srgb, var(--app-primary) 8%, var(--color-surface-raised));
border-left-color: var(--app-primary);
}
.narrow-row__top {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
min-width: 0;
}
.narrow-row__title {
font-size: var(--text-sm);
font-weight: 700;
color: var(--color-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
min-width: 0;
}
.narrow-row__company {
font-size: var(--text-xs);
color: var(--color-text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.narrow-row__cl-tick {
color: var(--color-success);
font-weight: 700;
}
/* ── Right: workspace panel ──────────────────────────────────────── */
.apply-split__panel {
min-width: 0;
overflow: clip; /* clip prevents BFC side-effect of hidden; also lets position:sticky work inside */
overflow-y: auto;
height: 100%;
opacity: 0;
transition: opacity 150ms ease 100ms; /* 100ms delay so content fades in after column expands */
}
.apply-split.has-selection .apply-split__panel {
opacity: 1;
}
@media (prefers-reduced-motion: reduce) {
.apply-split__panel { transition: none; opacity: 1; }
}
.split-panel__empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-3);
height: 100%;
color: var(--color-text-muted);
font-size: var(--text-sm);
}
/* ── Easter egg: Speed Demon canvas ─────────────────────────────── */
.bird-canvas {
display: none;
position: absolute;
inset: 0;
pointer-events: none;
z-index: 50;
}
/* ── Toast ───────────────────────────────────────────────────────── */
.split-toast {
position: absolute;
bottom: var(--space-6);
right: var(--space-6);
background: var(--color-surface-raised);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--space-3) var(--space-5);
font-size: var(--text-sm);
color: var(--color-text);
box-shadow: var(--shadow-lg);
z-index: 100;
white-space: nowrap;
}
.toast-enter-active, .toast-leave-active { transition: opacity 200ms ease, transform 200ms ease; }
.toast-enter-from, .toast-leave-to { opacity: 0; transform: translateY(6px); }
/* ── Mobile overrides ────────────────────────────────────────────── */
@media (max-width: 767px) {
.apply-list { padding: var(--space-4); gap: var(--space-4); }
.apply-list__title { font-size: var(--text-xl); }
.job-row { padding: var(--space-3) var(--space-4); }
}
</style>

View file

@ -0,0 +1,20 @@
<template>
<!--
@cover-letter-generated is intentionally not forwarded here.
The Marathon badge lives in ApplyView.vue (desktop split-pane only) the full-page route is mobile-only.
-->
<ApplyWorkspace
:job-id="jobId"
@job-removed="router.push('/apply')"
/>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import ApplyWorkspace from '../components/ApplyWorkspace.vue'
const route = useRoute()
const router = useRouter()
const jobId = computed(() => Number(route.params.id))
</script>

View file

@ -0,0 +1,404 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useDigestStore, type DigestEntry, type DigestLink } from '../stores/digest'
import { useApiFetch } from '../composables/useApi'
const store = useDigestStore()
// Per-entry state keyed by DigestEntry.id
const expandedIds = ref<Record<number, boolean>>({})
const linkResults = ref<Record<number, DigestLink[]>>({})
const selectedUrls = ref<Record<number, Set<string>>>({})
const queueResult = ref<Record<number, { queued: number; skipped: number } | null>>({})
const extracting = ref<Record<number, boolean>>({})
const queuing = ref<Record<number, boolean>>({})
const entryError = ref<Record<number, string | null>>({})
onMounted(() => store.fetchAll())
function toggleExpand(id: number) {
expandedIds.value = { ...expandedIds.value, [id]: !expandedIds.value[id] }
}
// Spread-copy pattern same as expandedSignalIds in InterviewCard, safe for Vue 3 reactivity
function toggleUrl(entryId: number, url: string) {
const prev = selectedUrls.value[entryId] ?? new Set<string>()
const next = new Set(prev)
next.has(url) ? next.delete(url) : next.add(url)
selectedUrls.value = { ...selectedUrls.value, [entryId]: next }
}
function selectedCount(id: number) {
return selectedUrls.value[id]?.size ?? 0
}
function jobLinks(id: number): DigestLink[] {
return (linkResults.value[id] ?? []).filter(l => l.score >= 2)
}
function otherLinks(id: number): DigestLink[] {
return (linkResults.value[id] ?? []).filter(l => l.score < 2)
}
async function extractLinks(entry: DigestEntry) {
extracting.value = { ...extracting.value, [entry.id]: true }
const { data, error: err } = await useApiFetch<{ links: DigestLink[] }>(
`/api/digest-queue/${entry.id}/extract-links`,
{ method: 'POST' },
)
extracting.value = { ...extracting.value, [entry.id]: false }
if (err) {
entryError.value = { ...entryError.value, [entry.id]: 'Could not extract links — try again' }
return
}
entryError.value = { ...entryError.value, [entry.id]: null }
if (!data) return
linkResults.value = { ...linkResults.value, [entry.id]: data.links }
expandedIds.value = { ...expandedIds.value, [entry.id]: true }
// Pre-check job-likely links (score >= 2)
const preChecked = new Set(data.links.filter(l => l.score >= 2).map(l => l.url))
selectedUrls.value = { ...selectedUrls.value, [entry.id]: preChecked }
queueResult.value = { ...queueResult.value, [entry.id]: null }
}
async function queueJobs(entry: DigestEntry) {
const urls = [...(selectedUrls.value[entry.id] ?? [])]
if (!urls.length) return
queuing.value = { ...queuing.value, [entry.id]: true }
const { data, error: err } = await useApiFetch<{ queued: number; skipped: number }>(
`/api/digest-queue/${entry.id}/queue-jobs`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ urls }),
},
)
queuing.value = { ...queuing.value, [entry.id]: false }
if (err) {
entryError.value = { ...entryError.value, [entry.id]: 'Could not queue jobs — try again' }
return
}
entryError.value = { ...entryError.value, [entry.id]: null }
if (!data) return
queueResult.value = { ...queueResult.value, [entry.id]: data }
linkResults.value = { ...linkResults.value, [entry.id]: [] }
expandedIds.value = { ...expandedIds.value, [entry.id]: false }
}
function formatDate(iso: string) {
return new Date(iso).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
}
</script>
<template>
<div class="digest-view">
<h1 class="digest-heading">📰 Digest Queue</h1>
<div v-if="store.entries.length === 0" class="digest-empty">
<span class="empty-bird">🦅</span>
<p>No digest emails queued.</p>
<p class="empty-hint">When you mark an email as 📰 Digest, it appears here.</p>
</div>
<div v-else class="digest-list">
<div v-for="entry in store.entries" :key="entry.id" class="digest-entry">
<!-- Entry header row -->
<div
class="entry-header"
role="button"
tabindex="0"
:aria-expanded="!!expandedIds[entry.id]"
:aria-label="`Toggle ${entry.subject}`"
@click="toggleExpand(entry.id)"
@keydown.enter.prevent="toggleExpand(entry.id)"
@keydown.space.prevent="toggleExpand(entry.id)"
>
<span class="entry-toggle" aria-hidden="true">{{ expandedIds[entry.id] ? '▾' : '▸' }}</span>
<div class="entry-meta">
<span class="entry-subject">{{ entry.subject }}</span>
<span class="entry-from">
<template v-if="entry.from_addr">From: {{ entry.from_addr }} · </template>
{{ formatDate(entry.received_at) }}
</span>
</div>
<div class="entry-actions" @click.stop>
<button
class="btn-extract"
:disabled="extracting[entry.id]"
:aria-label="linkResults[entry.id]?.length ? 'Re-extract links' : 'Extract job links'"
@click="extractLinks(entry)"
>
{{ linkResults[entry.id]?.length ? 'Re-extract' : 'Extract' }}
</button>
<button
class="btn-dismiss"
aria-label="Remove from digest queue"
@click="store.remove(entry.id)"
></button>
</div>
</div>
<!-- Per-entry error -->
<div v-if="entryError[entry.id]" class="entry-error">{{ entryError[entry.id] }}</div>
<!-- Post-queue confirmation -->
<div v-if="queueResult[entry.id]" class="queue-result">
{{ queueResult[entry.id]!.queued }}
job{{ queueResult[entry.id]!.queued !== 1 ? 's' : '' }} queued for review<template
v-if="queueResult[entry.id]!.skipped > 0"
>, {{ queueResult[entry.id]!.skipped }} skipped (already in pipeline)</template>
</div>
<!-- Expanded: link list -->
<template v-if="expandedIds[entry.id]">
<div v-if="extracting[entry.id]" class="entry-status">Extracting links</div>
<div v-else-if="linkResults[entry.id] !== undefined && !linkResults[entry.id]!.length" class="entry-status">
No job links found in this email.
</div>
<div v-else-if="linkResults[entry.id]?.length" class="entry-links">
<!-- Job-likely links (score 2), pre-checked -->
<div class="link-group">
<label
v-for="link in jobLinks(entry.id)"
:key="link.url"
class="link-row"
>
<input
type="checkbox"
class="link-check"
:checked="selectedUrls[entry.id]?.has(link.url)"
@change="toggleUrl(entry.id, link.url)"
/>
<div class="link-text">
<span v-if="link.hint" class="link-hint">{{ link.hint }}</span>
<span class="link-url">{{ link.url }}</span>
</div>
</label>
</div>
<!-- Other links (score = 1), unchecked -->
<template v-if="otherLinks(entry.id).length">
<div class="link-divider">Other links</div>
<div class="link-group">
<label
v-for="link in otherLinks(entry.id)"
:key="link.url"
class="link-row link-row--other"
>
<input
type="checkbox"
class="link-check"
:checked="selectedUrls[entry.id]?.has(link.url)"
@change="toggleUrl(entry.id, link.url)"
/>
<div class="link-text">
<span v-if="link.hint" class="link-hint">{{ link.hint }}</span>
<span class="link-url">{{ link.url }}</span>
</div>
</label>
</div>
</template>
<button
class="btn-queue"
:disabled="selectedCount(entry.id) === 0 || queuing[entry.id]"
@click="queueJobs(entry)"
>
Queue {{ selectedCount(entry.id) > 0 ? selectedCount(entry.id) + ' ' : '' }}selected
</button>
</div>
</template>
</div>
</div>
</div>
</template>
<style scoped>
.digest-view {
padding: var(--space-6);
max-width: 720px;
margin: 0 auto;
}
.digest-heading {
font-size: 1.25rem;
font-weight: 700;
color: var(--color-text);
margin-bottom: var(--space-6);
}
/* Empty state */
.digest-empty {
text-align: center;
padding: var(--space-16) var(--space-8);
color: var(--color-text-muted);
}
.empty-bird { font-size: 2.5rem; display: block; margin-bottom: var(--space-4); }
.empty-hint { font-size: 0.875rem; margin-top: var(--space-2); }
/* Entry list */
.digest-list { display: flex; flex-direction: column; gap: var(--space-3); }
.digest-entry {
background: var(--color-surface-raised);
border: 1px solid var(--color-border-light);
border-radius: 10px;
overflow: hidden;
}
/* Entry header */
.entry-header {
display: flex;
align-items: flex-start;
gap: var(--space-3);
padding: var(--space-4);
cursor: pointer;
user-select: none;
}
.entry-header:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: -2px;
}
.entry-toggle { color: var(--color-text-muted); font-size: 0.9rem; flex-shrink: 0; padding-top: 2px; }
.entry-meta { flex: 1; min-width: 0; }
.entry-subject {
display: block;
font-weight: 600;
font-size: 0.9rem;
color: var(--color-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.entry-from { display: block; font-size: 0.75rem; color: var(--color-text-muted); margin-top: 2px; }
.entry-actions { display: flex; gap: var(--space-2); flex-shrink: 0; }
.btn-extract {
font-size: 0.75rem;
padding: 3px 10px;
border-radius: 5px;
border: 1px solid var(--color-border);
background: var(--color-surface-alt);
color: var(--color-text);
cursor: pointer;
transition: border-color 0.1s, color 0.1s;
}
.btn-extract:hover:not(:disabled) { border-color: var(--color-primary); color: var(--color-primary); }
.btn-extract:disabled { opacity: 0.5; cursor: default; }
.btn-dismiss {
font-size: 0.75rem;
padding: 3px 8px;
border-radius: 5px;
border: 1px solid var(--color-border-light);
background: transparent;
color: var(--color-text-muted);
cursor: pointer;
transition: border-color 0.1s, color 0.1s;
}
.btn-dismiss:hover { border-color: var(--color-error); color: var(--color-error); }
/* Queue result */
.queue-result {
margin: 0 var(--space-4) var(--space-3);
font-size: 0.8rem;
color: var(--color-success);
background: color-mix(in srgb, var(--color-success) 10%, var(--color-surface-raised));
border-radius: 6px;
padding: var(--space-2) var(--space-3);
}
/* Error message */
.entry-error {
padding: var(--space-2) var(--space-4);
font-size: 0.8rem;
color: var(--color-error);
background: color-mix(in srgb, var(--color-error) 10%, var(--color-surface-raised));
border-radius: 6px;
margin: 0 var(--space-4) var(--space-2);
}
/* Status messages */
.entry-status {
padding: var(--space-3) var(--space-4) var(--space-4);
font-size: 0.8rem;
color: var(--color-text-muted);
font-style: italic;
}
/* Link list */
.entry-links { padding: 0 var(--space-4) var(--space-4); }
.link-group { display: flex; flex-direction: column; gap: 2px; }
.link-row {
display: flex;
align-items: flex-start;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
border-radius: 6px;
cursor: pointer;
background: var(--color-surface);
transition: background 0.1s;
}
.link-row:hover { background: var(--color-surface-alt); }
.link-row--other { opacity: 0.8; }
.link-check { flex-shrink: 0; margin-top: 3px; accent-color: var(--color-primary); cursor: pointer; }
.link-text { min-width: 0; flex: 1; }
.link-hint {
display: block;
font-size: 0.8rem;
font-weight: 500;
color: var(--color-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.link-url {
display: block;
font-size: 0.7rem;
color: var(--color-text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.link-divider {
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-muted);
padding: var(--space-3) 0 var(--space-2);
border-top: 1px solid var(--color-border-light);
margin-top: var(--space-2);
}
.btn-queue {
margin-top: var(--space-3);
width: 100%;
padding: var(--space-2) var(--space-4);
border-radius: 6px;
border: none;
background: var(--color-primary);
color: var(--color-text-inverse);
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
transition: background 0.1s;
}
.btn-queue:hover:not(:disabled) { background: var(--color-primary-hover); }
.btn-queue:disabled { opacity: 0.4; cursor: default; }
@media (max-width: 600px) {
.digest-view { padding: var(--space-4); }
.entry-subject { font-size: 0.85rem; }
}
</style>

597
web/src/views/HomeView.vue Normal file
View file

@ -0,0 +1,597 @@
<template>
<div class="home">
<!-- Header -->
<header class="home__header">
<div>
<h1 class="home__greeting">
{{ greeting }}
<span v-if="isMidnight" aria-label="Late night session">🌙</span>
</h1>
<p class="home__subtitle">Discover Review Apply</p>
</div>
</header>
<!-- Metric cards -->
<section class="home__metrics" aria-label="Pipeline overview">
<RouterLink
v-for="metric in metrics"
:key="metric.status"
:to="metric.link"
class="metric-card"
:class="`metric-card--${metric.status}`"
:aria-label="`${metric.count ?? 0} ${metric.label} jobs`"
>
<span class="metric-card__count" aria-hidden="true">
{{ store.loading ? '—' : (metric.count ?? 0) }}
</span>
<span class="metric-card__label">{{ metric.label }}</span>
</RouterLink>
</section>
<!-- Primary workflow -->
<section class="home__section" aria-labelledby="workflow-heading">
<h2 id="workflow-heading" class="home__section-title">Primary Workflow</h2>
<div class="home__actions">
<WorkflowButton
emoji="🚀"
label="Run Discovery"
description="Scan job boards for new listings"
:loading="taskRunning === 'discovery'"
@click="runDiscovery"
/>
<WorkflowButton
emoji="📧"
label="Sync Emails"
description="Fetch and classify inbox"
:loading="taskRunning === 'email'"
@click="syncEmails"
/>
<WorkflowButton
emoji="📊"
label="Score Unscored"
description="Run match scoring on new jobs"
:loading="taskRunning === 'score'"
@click="scoreUnscored"
/>
</div>
<button
v-if="unsyncedCount > 0"
class="sync-banner"
:disabled="taskRunning === 'sync'"
:aria-busy="taskRunning === 'sync'"
@click="syncIntegration"
>
<span aria-hidden="true">📤</span>
<span>
Sync {{ unsyncedCount }} approved {{ unsyncedCount === 1 ? 'job' : 'jobs' }}
{{ integrationName }}
</span>
<span v-if="taskRunning === 'sync'" class="spinner" aria-hidden="true" />
</button>
</section>
<!-- Auto-enrichment status -->
<section v-if="store.status?.enrichment_enabled" class="home__section">
<div class="enrichment-row" role="status" aria-live="polite">
<span class="enrichment-row__dot" :class="enrichmentDotClass" aria-hidden="true" />
<span class="enrichment-row__text">
{{ store.status?.enrichment_last_run
? `Last enriched ${formatRelative(store.status.enrichment_last_run)}`
: 'Auto-enrichment active' }}
</span>
<button class="btn-ghost btn-ghost--sm" @click="runEnrich">Run Now</button>
</div>
</section>
<!-- Backlog management -->
<section v-if="showBacklog" class="home__section" aria-labelledby="backlog-heading">
<h2 id="backlog-heading" class="home__section-title">Backlog Management</h2>
<p class="home__section-desc">
You have
<strong>{{ store.counts?.pending ?? 0 }} pending</strong>
and
<strong>{{ store.counts?.approved ?? 0 }} approved</strong>
listings.
</p>
<div class="home__actions home__actions--secondary">
<button
v-if="(store.counts?.pending ?? 0) > 0"
class="action-btn action-btn--secondary"
@click="archiveByStatus(['pending'])"
>
📦 Archive Pending
</button>
<button
v-if="(store.counts?.rejected ?? 0) > 0"
class="action-btn action-btn--secondary"
@click="archiveByStatus(['rejected'])"
>
📦 Archive Rejected
</button>
<button
v-if="(store.counts?.approved ?? 0) > 0"
class="action-btn action-btn--secondary"
@click="archiveByStatus(['approved'])"
>
📦 Archive Approved (unapplied)
</button>
</div>
</section>
<!-- Add jobs by URL -->
<section class="home__section" aria-labelledby="add-heading">
<h2 id="add-heading" class="home__section-title">Add Jobs by URL</h2>
<div class="add-jobs">
<div class="add-jobs__tabs" role="tablist">
<button
role="tab"
:aria-selected="addTab === 'url'"
class="add-jobs__tab"
:class="{ 'add-jobs__tab--active': addTab === 'url' }"
@click="addTab = 'url'"
>Paste URLs</button>
<button
role="tab"
:aria-selected="addTab === 'csv'"
class="add-jobs__tab"
:class="{ 'add-jobs__tab--active': addTab === 'csv' }"
@click="addTab = 'csv'"
>Upload CSV</button>
</div>
<div class="add-jobs__panel" role="tabpanel">
<template v-if="addTab === 'url'">
<textarea
v-model="urlInput"
class="add-jobs__textarea"
placeholder="Paste one job URL per line…"
rows="4"
aria-label="Job URLs to add"
/>
<button
class="action-btn action-btn--primary"
:disabled="!urlInput.trim()"
@click="addByUrl"
>Add Jobs</button>
</template>
<template v-else>
<p class="home__section-desc">Upload a CSV with a <code>url</code> column.</p>
<input type="file" accept=".csv" aria-label="CSV file" @change="handleCsvUpload" />
</template>
</div>
</div>
</section>
<!-- Advanced -->
<section class="home__section">
<details class="advanced">
<summary class="advanced__summary">Advanced</summary>
<div class="advanced__body">
<p class="advanced__warning"> These actions are destructive and cannot be undone.</p>
<div class="home__actions home__actions--danger">
<button class="action-btn action-btn--danger" @click="confirmPurge">
🗑 Purge Pending + Rejected
</button>
<button class="action-btn action-btn--danger" @click="killTasks">
🛑 Kill Stuck Tasks
</button>
</div>
</div>
</details>
</section>
<!-- Stoop speed toast easter egg 9.2 -->
<Transition name="toast">
<div v-if="stoopToast" class="stoop-toast" role="status" aria-live="polite">
🦅 Stoop speed.
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { RouterLink } from 'vue-router'
import { useJobsStore } from '../stores/jobs'
import { useApiFetch } from '../composables/useApi'
import WorkflowButton from '../components/WorkflowButton.vue'
const store = useJobsStore()
// Greeting easter egg 9.7: midnight mode
const userName = ref('')
const hour = new Date().getHours()
const isMidnight = computed(() => hour >= 0 && hour < 5)
const greeting = computed(() => {
const name = userName.value ? `${userName.value}'s` : 'Your'
return isMidnight.value ? `${name} Late-Night Job Search` : `${name} Job Search`
})
const metrics = computed(() => [
{ status: 'pending', label: 'Pending', count: store.counts?.pending, link: '/review?status=pending' },
{ status: 'approve', label: 'Approved', count: store.counts?.approved, link: '/review?status=approved' },
{ status: 'applied', label: 'Applied', count: store.counts?.applied, link: '/review?status=applied' },
{ status: 'synced', label: 'Synced', count: store.counts?.synced, link: '/review?status=synced' },
{ status: 'reject', label: 'Rejected', count: store.counts?.rejected, link: '/review?status=rejected' },
])
const integrationName = computed(() => store.status?.integration_name ?? 'Export')
const unsyncedCount = computed(() => store.status?.integration_unsynced ?? 0)
const showBacklog = computed(() => (store.counts?.pending ?? 0) > 0 || (store.counts?.approved ?? 0) > 0)
const enrichmentDotClass = computed(() =>
store.status?.enrichment_last_run ? 'enrichment-row__dot--ok' : 'enrichment-row__dot--idle',
)
function formatRelative(isoStr: string) {
const mins = Math.round((Date.now() - new Date(isoStr).getTime()) / 60000)
if (mins < 2) return 'just now'
if (mins < 60) return `${mins} min ago`
const hrs = Math.round(mins / 60)
return hrs === 1 ? '1 hour ago' : `${hrs} hours ago`
}
const taskRunning = ref<string | null>(null)
const stoopToast = ref(false)
async function runTask(key: string, endpoint: string) {
taskRunning.value = key
await useApiFetch(endpoint, { method: 'POST' })
taskRunning.value = null
store.refresh()
}
const runDiscovery = () => runTask('discovery', '/api/tasks/discovery')
const syncEmails = () => runTask('email', '/api/tasks/email-sync')
const scoreUnscored = () => runTask('score', '/api/tasks/score')
const syncIntegration = () => runTask('sync', '/api/tasks/sync')
const runEnrich = () => useApiFetch('/api/tasks/enrich', { method: 'POST' })
const addTab = ref<'url' | 'csv'>('url')
const urlInput = ref('')
async function addByUrl() {
const urls = urlInput.value.split('\n').map(u => u.trim()).filter(Boolean)
await useApiFetch('/api/jobs/add', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ urls }),
})
urlInput.value = ''
store.refresh()
}
function handleCsvUpload(e: Event) {
const file = (e.target as HTMLInputElement).files?.[0]
if (!file) return
const form = new FormData()
form.append('file', file)
useApiFetch('/api/jobs/upload-csv', { method: 'POST', body: form })
}
async function archiveByStatus(statuses: string[]) {
await useApiFetch('/api/jobs/archive', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ statuses }),
})
store.refresh()
}
function confirmPurge() {
// TODO: replace with ConfirmModal component
if (confirm('Permanently delete all pending and rejected jobs? This cannot be undone.')) {
useApiFetch('/api/jobs/purge', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ target: 'pending_rejected' }),
})
store.refresh()
}
}
async function killTasks() {
await useApiFetch('/api/tasks/kill', { method: 'POST' })
}
onMounted(async () => {
store.refresh()
const { data } = await useApiFetch<{ name: string }>('/api/config/user')
if (data?.name) userName.value = data.name
})
</script>
<style scoped>
.home {
max-width: 900px;
margin: 0 auto;
padding: var(--space-8) var(--space-6);
display: flex;
flex-direction: column;
gap: var(--space-8);
}
.home__header {
padding-bottom: var(--space-4);
border-bottom: 1px solid var(--color-border-light);
}
.home__greeting {
font-family: var(--font-display);
font-size: var(--text-3xl);
color: var(--app-primary);
line-height: 1.1;
}
.home__subtitle {
margin-top: var(--space-2);
color: var(--color-text-muted);
font-size: var(--text-sm);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.home__metrics {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: var(--space-3);
}
.metric-card {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-1);
padding: var(--space-4) var(--space-3);
background: var(--color-surface-raised);
border: 2px solid transparent;
border-radius: var(--radius-lg);
text-decoration: none;
min-height: 44px;
transition:
border-color 150ms ease,
box-shadow 150ms ease,
transform 150ms ease;
}
.metric-card:hover {
border-color: var(--app-primary-light);
box-shadow: var(--shadow-md);
transform: translateY(-1px);
}
.metric-card__count {
font-family: var(--font-display);
font-size: var(--text-2xl);
font-weight: 700;
line-height: 1;
}
.metric-card__label {
font-size: var(--text-xs);
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.06em;
}
.metric-card--pending .metric-card__count { color: var(--status-pending); }
.metric-card--approve .metric-card__count { color: var(--status-approve); }
.metric-card--applied .metric-card__count { color: var(--status-applied); }
.metric-card--synced .metric-card__count { color: var(--status-synced); }
.metric-card--reject .metric-card__count { color: var(--status-reject); }
.home__section { display: flex; flex-direction: column; gap: var(--space-4); }
.home__section-title {
font-family: var(--font-display);
font-size: var(--text-xl);
color: var(--color-text);
}
.home__section-desc { font-size: var(--text-sm); color: var(--color-text-muted); }
.home__actions {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: var(--space-3);
}
.home__actions--secondary { grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); }
.home__actions--danger { grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); }
.sync-banner {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-4) var(--space-5);
background: var(--app-primary-light);
border: 1px solid var(--app-primary);
border-radius: var(--radius-md);
color: var(--app-primary);
font-size: var(--text-sm);
font-weight: 600;
cursor: pointer;
width: 100%;
text-align: left;
transition: background 150ms ease, box-shadow 150ms ease;
}
.sync-banner:hover { background: var(--color-surface-alt); box-shadow: var(--shadow-sm); }
.sync-banner:disabled { opacity: 0.6; cursor: not-allowed; }
.spinner {
width: 1rem;
height: 1rem;
border: 2px solid currentColor;
border-top-color: transparent;
border-radius: 50%;
animation: spin 0.7s linear infinite;
margin-left: auto;
}
@keyframes spin { to { transform: rotate(360deg); } }
.action-btn {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-3) var(--space-5);
border-radius: var(--radius-md);
font-size: var(--text-sm);
font-weight: 600;
cursor: pointer;
border: none;
min-height: 44px;
transition: background 150ms ease, box-shadow 150ms ease;
}
.action-btn--primary { background: var(--app-accent); color: var(--app-accent-text); }
.action-btn--primary:hover { background: var(--app-accent-hover); }
.action-btn--primary:disabled { opacity: 0.4; cursor: not-allowed; }
.action-btn--secondary { background: var(--color-surface-alt); color: var(--color-text); border: 1px solid var(--color-border); }
.action-btn--secondary:hover { background: var(--color-border-light); }
.action-btn--danger { background: transparent; color: var(--color-error); border: 1px solid var(--color-error); }
.action-btn--danger:hover { background: rgba(192, 57, 43, 0.08); }
.enrichment-row {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-3) var(--space-4);
background: var(--color-surface-raised);
border-radius: var(--radius-md);
font-size: var(--text-sm);
color: var(--color-text-muted);
}
.enrichment-row__dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.enrichment-row__dot--ok { background: var(--color-success); }
.enrichment-row__dot--idle { background: var(--color-text-muted); }
.enrichment-row__text { flex: 1; }
.btn-ghost {
background: transparent;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
color: var(--color-text-muted);
cursor: pointer;
transition: background 150ms ease, color 150ms ease;
}
.btn-ghost--sm { padding: var(--space-1) var(--space-3); font-size: var(--text-xs); }
.btn-ghost:hover { background: var(--color-surface-alt); color: var(--color-text); }
.add-jobs {
background: var(--color-surface-raised);
border-radius: var(--radius-lg);
border: 1px solid var(--color-border-light);
overflow: hidden;
}
.add-jobs__tabs { display: flex; border-bottom: 1px solid var(--color-border-light); }
.add-jobs__tab {
flex: 1;
padding: var(--space-3) var(--space-4);
border: none;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
background: transparent;
font-size: var(--text-sm);
color: var(--color-text-muted);
cursor: pointer;
transition: color 150ms ease, border-color 150ms ease;
}
.add-jobs__tab--active { color: var(--app-primary); border-bottom-color: var(--app-primary); font-weight: 600; }
.add-jobs__panel {
padding: var(--space-4);
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.add-jobs__textarea {
width: 100%;
padding: var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-surface);
color: var(--color-text);
font-family: var(--font-mono);
font-size: var(--text-sm);
resize: vertical;
}
.add-jobs__textarea:focus { outline: 2px solid var(--app-primary); outline-offset: 1px; }
.advanced {
background: var(--color-surface-raised);
border: 1px solid var(--color-border-light);
border-radius: var(--radius-md);
}
.advanced__summary {
padding: var(--space-3) var(--space-4);
cursor: pointer;
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-text-muted);
list-style: none;
user-select: none;
}
.advanced__summary::-webkit-details-marker { display: none; }
.advanced__summary::before { content: '▶ '; font-size: 0.7em; }
details[open] > .advanced__summary::before { content: '▼ '; }
.advanced__body { padding: 0 var(--space-4) var(--space-4); display: flex; flex-direction: column; gap: var(--space-4); }
.advanced__warning {
font-size: var(--text-sm);
color: var(--color-warning);
background: rgba(212, 137, 26, 0.08);
padding: var(--space-3) var(--space-4);
border-radius: var(--radius-md);
border-left: 3px solid var(--color-warning);
}
.stoop-toast {
position: fixed;
bottom: var(--space-6);
right: var(--space-6);
background: var(--color-surface-raised);
border: 1px solid var(--color-border);
padding: var(--space-3) var(--space-5);
border-radius: var(--radius-md);
font-size: var(--text-sm);
color: var(--color-text-muted);
box-shadow: var(--shadow-lg);
z-index: 200;
}
.toast-enter-active,
.toast-leave-active {
transition: opacity 300ms ease, transform 300ms ease;
}
.toast-enter-from,
.toast-leave-to {
opacity: 0;
transform: translateY(8px);
}
@media (max-width: 768px) {
.home { padding: var(--space-4); gap: var(--space-6); }
.home__greeting { font-size: var(--text-2xl); }
.home__metrics { grid-template-columns: repeat(3, 1fr); }
}
@media (max-width: 480px) {
.home__metrics { grid-template-columns: repeat(2, 1fr); }
.home__metrics .metric-card:last-child { grid-column: 1 / -1; }
}
</style>

View file

@ -0,0 +1,974 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useStorage } from '@vueuse/core'
import { usePrepStore } from '../stores/prep'
import { useInterviewsStore } from '../stores/interviews'
import type { PipelineJob } from '../stores/interviews'
const route = useRoute()
const router = useRouter()
const prepStore = usePrepStore()
const interviewsStore = useInterviewsStore()
// Job ID
const jobId = computed<number | null>(() => {
const raw = route.params.id
if (!raw) return null
const n = Number(Array.isArray(raw) ? raw[0] : raw)
return isNaN(n) ? null : n
})
// Current job (from interviews store)
const PREP_VALID_STATUSES = ['phone_screen', 'interviewing', 'offer'] as const
const job = ref<PipelineJob | null>(null)
// Tabs
type TabId = 'jd' | 'email' | 'letter'
const activeTab = ref<TabId>('jd')
// Call notes (localStorage via @vueuse/core)
const notesKey = computed(() => `cf-prep-notes-${jobId.value ?? 'none'}`)
const callNotes = useStorage(notesKey, '')
// Page-level error (e.g. network failure during guard)
const pageError = ref<string | null>(null)
// Routing / guard
async function guardAndLoad() {
if (jobId.value === null) {
router.replace('/interviews')
return
}
// Ensure the interviews store is populated
if (interviewsStore.jobs.length === 0) {
await interviewsStore.fetchAll()
if (interviewsStore.error) {
// Store fetch failed don't redirect, show error
pageError.value = 'Failed to load job data. Please try again.'
return
}
}
const found = interviewsStore.jobs.find(j => j.id === jobId.value)
if (!found || !PREP_VALID_STATUSES.includes(found.status as typeof PREP_VALID_STATUSES[number])) {
router.replace('/interviews')
return
}
job.value = found
await prepStore.fetchFor(jobId.value)
}
onMounted(() => {
guardAndLoad()
})
onUnmounted(() => {
prepStore.clear()
})
// Stage badge label
function stageBadgeLabel(status: string): string {
if (status === 'phone_screen') return 'Phone Screen'
if (status === 'interviewing') return 'Interviewing'
if (status === 'offer') return 'Offer'
return status
}
// Interview date countdown
interface DateCountdown {
icon: string
label: string
cls: string
}
const interviewCountdown = computed<DateCountdown | null>(() => {
const dateStr = job.value?.interview_date
if (!dateStr) return null
const today = new Date()
today.setHours(0, 0, 0, 0)
const target = new Date(dateStr)
target.setHours(0, 0, 0, 0)
const diffDays = Math.round((target.getTime() - today.getTime()) / 86400000)
if (diffDays === 0) return { icon: '🔴', label: 'TODAY', cls: 'countdown--today' }
if (diffDays === 1) return { icon: '🟡', label: 'TOMORROW', cls: 'countdown--tomorrow' }
if (diffDays > 1) return { icon: '🟢', label: `in ${diffDays} days`, cls: 'countdown--future' }
// Past
const ago = Math.abs(diffDays)
return { icon: '', label: `was ${ago} day${ago !== 1 ? 's' : ''} ago`, cls: 'countdown--past' }
})
// Research state helpers
const taskStatus = computed(() => prepStore.taskStatus)
const isRunning = computed(() => taskStatus.value.status === 'queued' || taskStatus.value.status === 'running')
const hasFailed = computed(() => taskStatus.value.status === 'failed')
const hasResearch = computed(() => !!prepStore.research)
// Stage label during generation
const stageLabel = computed(() => {
const s = taskStatus.value.stage
if (s) return s
return taskStatus.value.status === 'queued' ? 'Queued…' : 'Analyzing…'
})
// Generated-at caption
const generatedAtLabel = computed(() => {
const ts = prepStore.research?.generated_at
if (!ts) return null
const d = new Date(ts)
return d.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })
})
// Research sections
interface ResearchSection {
icon: string
title: string
content: string
cls?: string
caption?: string
}
const researchSections = computed<ResearchSection[]>(() => {
const r = prepStore.research
if (!r) return []
const sections: ResearchSection[] = []
if (r.talking_points?.trim()) {
sections.push({ icon: '🎯', title: 'Talking Points', content: r.talking_points })
}
if (r.company_brief?.trim()) {
sections.push({ icon: '🏢', title: 'Company Overview', content: r.company_brief })
}
if (r.ceo_brief?.trim()) {
sections.push({ icon: '👤', title: 'Leadership & Culture', content: r.ceo_brief })
}
if (r.tech_brief?.trim()) {
sections.push({ icon: '⚙️', title: 'Tech Stack & Product', content: r.tech_brief })
}
if (r.funding_brief?.trim()) {
sections.push({ icon: '💰', title: 'Funding & Market Position', content: r.funding_brief })
}
if (r.red_flags?.trim() && !/no significant red flags/i.test(r.red_flags)) {
sections.push({ icon: '⚠️', title: 'Red Flags & Watch-outs', content: r.red_flags, cls: 'section--warning' })
}
if (r.accessibility_brief?.trim()) {
sections.push({
icon: '♿',
title: 'Inclusion & Accessibility',
content: r.accessibility_brief,
caption: 'For your personal evaluation — not disclosed in any application.',
})
}
return sections
})
// Match score badge
const matchScore = computed(() => prepStore.fullJob?.match_score ?? null)
function matchScoreBadge(score: number | null): { icon: string; cls: string } {
if (score === null) return { icon: '—', cls: 'score--none' }
if (score >= 70) return { icon: `🟢 ${score}%`, cls: 'score--high' }
if (score >= 40) return { icon: `🟡 ${score}%`, cls: 'score--mid' }
return { icon: `🔴 ${score}%`, cls: 'score--low' }
}
// Keyword gaps
const keywordGaps = computed<string[]>(() => {
const raw = prepStore.fullJob?.keyword_gaps
if (!raw) return []
try {
const parsed = JSON.parse(raw)
if (Array.isArray(parsed)) return parsed.map(String)
} catch {
// Fall through: return raw as single item
}
return [raw]
})
// Generate / refresh
async function onGenerate() {
if (jobId.value === null) return
await prepStore.generateResearch(jobId.value)
}
</script>
<template>
<div class="prep-view">
<!-- Loading skeleton while interviews store loads -->
<div v-if="interviewsStore.loading && !job" class="prep-loading" aria-live="polite">
Loading
</div>
<template v-else-if="job">
<div class="prep-layout">
<!-- LEFT COLUMN -->
<aside class="prep-left" aria-label="Job overview and research">
<!-- Back link -->
<RouterLink to="/interviews" class="back-link"> Back to Interviews</RouterLink>
<!-- Job header -->
<header class="job-header">
<h1 class="job-title">{{ job.title }}</h1>
<p class="job-company">{{ job.company }}</p>
<div class="job-meta">
<span class="stage-badge" :class="`stage-badge--${job.status}`">
{{ stageBadgeLabel(job.status) }}
</span>
<span
v-if="interviewCountdown"
class="countdown-chip"
:class="interviewCountdown.cls"
>
<span v-if="interviewCountdown.icon" aria-hidden="true">{{ interviewCountdown.icon }}</span>
{{ interviewCountdown.label }}
</span>
</div>
<a
v-if="job.url"
:href="job.url"
target="_blank"
rel="noopener noreferrer"
class="btn-link-out"
>
Open job listing
</a>
</header>
<!-- Research controls -->
<section class="research-controls" aria-label="Research controls">
<!-- No research and no active task show generate button -->
<template v-if="!hasResearch && !isRunning && !hasFailed">
<button class="btn-primary" @click="onGenerate" :disabled="prepStore.loading">
Generate research brief
</button>
</template>
<!-- Task running/queued spinner + stage -->
<template v-else-if="isRunning">
<div class="research-running" aria-live="polite" aria-atomic="true">
<span class="spinner" aria-hidden="true"></span>
<span>{{ stageLabel }}</span>
</div>
</template>
<!-- Task failed error + retry -->
<template v-else-if="hasFailed">
<div class="research-error" role="alert">
<span> {{ taskStatus.message ?? 'Research generation failed.' }}</span>
<button class="btn-secondary" @click="onGenerate">Retry</button>
</div>
</template>
<!-- Research exists (completed or no task but research present) show refresh -->
<template v-else-if="hasResearch">
<div class="research-generated">
<span v-if="generatedAtLabel" class="research-ts">Generated: {{ generatedAtLabel }}</span>
<button
class="btn-secondary"
@click="onGenerate"
:disabled="isRunning"
>
Refresh
</button>
</div>
</template>
</section>
<!-- Error banner (store-level) -->
<div v-if="prepStore.error" class="error-banner" role="alert">
{{ prepStore.error }}
</div>
<!-- Research sections -->
<div v-if="hasResearch" class="research-sections">
<section
v-for="sec in researchSections"
:key="sec.title"
class="research-section"
:class="sec.cls"
>
<h2 class="section-title">
<span aria-hidden="true">{{ sec.icon }}</span> {{ sec.title }}
</h2>
<p v-if="sec.caption" class="section-caption">{{ sec.caption }}</p>
<div class="section-body">{{ sec.content }}</div>
</section>
</div>
<!-- Empty state: no research yet and not loading -->
<div v-else-if="!isRunning && !prepStore.loading" class="research-empty">
<span class="empty-bird">🦅</span>
<p>Generate a research brief to see company info, talking points, and more.</p>
</div>
</aside>
<!-- RIGHT COLUMN -->
<main class="prep-right" aria-label="Job details">
<!-- Tab bar -->
<div class="tab-bar" role="tablist" aria-label="Job details tabs">
<button
id="tab-jd"
class="tab-btn"
:class="{ 'tab-btn--active': activeTab === 'jd' }"
role="tab"
:aria-selected="activeTab === 'jd'"
aria-controls="tabpanel-jd"
@click="activeTab = 'jd'"
>
Job Description
</button>
<button
id="tab-email"
class="tab-btn"
:class="{ 'tab-btn--active': activeTab === 'email' }"
role="tab"
:aria-selected="activeTab === 'email'"
aria-controls="tabpanel-email"
@click="activeTab = 'email'"
>
Email History
<span v-if="prepStore.contacts.length" class="tab-count">{{ prepStore.contacts.length }}</span>
</button>
<button
id="tab-letter"
class="tab-btn"
:class="{ 'tab-btn--active': activeTab === 'letter' }"
role="tab"
:aria-selected="activeTab === 'letter'"
aria-controls="tabpanel-letter"
@click="activeTab = 'letter'"
>
Cover Letter
</button>
</div>
<!-- JD tab -->
<div
v-show="activeTab === 'jd'"
id="tabpanel-jd"
class="tab-panel"
role="tabpanel"
aria-labelledby="tab-jd"
>
<div class="jd-meta">
<span
class="score-badge"
:class="matchScoreBadge(matchScore).cls"
:aria-label="`Match score: ${matchScore ?? 'unknown'}%`"
>
{{ matchScoreBadge(matchScore).icon }}
</span>
<div v-if="keywordGaps.length" class="keyword-gaps">
<span class="keyword-gaps-label">Keyword gaps:</span>
<span class="keyword-gaps-list">{{ keywordGaps.join(', ') }}</span>
</div>
</div>
<div v-if="prepStore.fullJob?.description" class="jd-body">
{{ prepStore.fullJob.description }}
</div>
<div v-else class="tab-empty">
<span class="empty-bird">🦅</span>
<p>No job description available.</p>
</div>
</div>
<!-- Email tab -->
<div
v-show="activeTab === 'email'"
id="tabpanel-email"
class="tab-panel"
role="tabpanel"
aria-labelledby="tab-email"
>
<div v-if="prepStore.contactsError" class="error-state" role="alert">
{{ prepStore.contactsError }}
</div>
<template v-else-if="prepStore.contacts.length">
<div
v-for="contact in prepStore.contacts"
:key="contact.id"
class="email-card"
>
<div class="email-header">
<span class="email-dir" :title="contact.direction === 'inbound' ? 'Inbound' : 'Outbound'">
{{ contact.direction === 'inbound' ? '📥' : '📤' }}
</span>
<span class="email-subject">{{ contact.subject ?? '(no subject)' }}</span>
<span class="email-date" v-if="contact.received_at">
{{ new Date(contact.received_at).toLocaleDateString() }}
</span>
</div>
<div class="email-from" v-if="contact.from_addr">{{ contact.from_addr }}</div>
<div class="email-body" v-if="contact.body">{{ contact.body.slice(0, 500) }}{{ contact.body.length > 500 ? '…' : '' }}</div>
</div>
</template>
<div v-else class="tab-empty">
<span class="empty-bird">🦅</span>
<p>No email history for this job.</p>
</div>
</div>
<!-- Cover letter tab -->
<div
v-show="activeTab === 'letter'"
id="tabpanel-letter"
class="tab-panel"
role="tabpanel"
aria-labelledby="tab-letter"
>
<div v-if="prepStore.fullJob?.cover_letter" class="letter-body">
{{ prepStore.fullJob.cover_letter }}
</div>
<div v-else class="tab-empty">
<span class="empty-bird">🦅</span>
<p>No cover letter generated yet.</p>
</div>
</div>
<!-- Call notes -->
<section class="call-notes" aria-label="Call notes">
<h2 class="call-notes-title">Call Notes</h2>
<textarea
v-model="callNotes"
class="call-notes-textarea"
placeholder="Jot down notes during your call…"
aria-label="Call notes — saved locally"
></textarea>
<p class="call-notes-caption">Notes are saved locally they won't sync between devices.</p>
</section>
</main>
</div>
</template>
<!-- Network/load error don't redirect, show message -->
<div v-else-if="pageError" class="error-banner" role="alert">
{{ pageError }}
</div>
<!-- Fallback while redirecting -->
<div v-else class="prep-loading" aria-live="polite">
Redirecting
</div>
</div>
</template>
<style scoped>
/* ── Layout ─────────────────────────────────────────────────────────────── */
.prep-view {
padding: var(--space-4) var(--space-4) var(--space-12);
max-width: 1200px;
margin: 0 auto;
}
.prep-layout {
display: grid;
grid-template-columns: 40% 1fr;
gap: var(--space-6);
align-items: start;
}
/* Mobile: single column */
@media (max-width: 1023px) {
.prep-layout {
grid-template-columns: 1fr;
}
.prep-right {
order: 2;
}
.prep-left {
order: 1;
}
}
.prep-left {
position: sticky;
top: calc(var(--nav-height, 4rem) + var(--space-4));
max-height: calc(100vh - var(--nav-height, 4rem) - var(--space-8));
overflow-y: auto;
display: flex;
flex-direction: column;
gap: var(--space-4);
/* On mobile, don't stick */
}
@media (max-width: 1023px) {
.prep-left {
position: static;
max-height: none;
overflow-y: visible;
}
}
.prep-right {
display: flex;
flex-direction: column;
gap: var(--space-4);
min-width: 0;
}
/* ── Loading ─────────────────────────────────────────────────────────────── */
.prep-loading {
text-align: center;
padding: var(--space-16);
color: var(--color-text-muted);
font-size: var(--text-sm);
}
/* ── Back link ──────────────────────────────────────────────────────────── */
.back-link {
font-size: var(--text-sm);
color: var(--app-primary);
text-decoration: none;
display: inline-flex;
align-items: center;
gap: var(--space-1);
}
.back-link:hover { text-decoration: underline; }
/* ── Job header ─────────────────────────────────────────────────────────── */
.job-header {
background: var(--color-surface-raised);
border-radius: var(--radius-lg);
padding: var(--space-4);
display: flex;
flex-direction: column;
gap: var(--space-2);
border: 1px solid var(--color-border-light);
}
.job-title {
font-family: var(--font-display);
font-size: var(--text-xl);
color: var(--color-text);
line-height: 1.3;
}
.job-company {
font-size: var(--text-base);
color: var(--color-text-muted);
margin: 0;
font-weight: 600;
}
.job-meta {
display: flex;
align-items: center;
gap: var(--space-2);
flex-wrap: wrap;
}
/* Stage badges */
.stage-badge {
display: inline-block;
padding: 2px 10px;
border-radius: var(--radius-full);
font-size: var(--text-xs);
font-weight: 700;
text-transform: uppercase;
letter-spacing: .04em;
}
.stage-badge--phone_screen {
background: color-mix(in srgb, var(--status-phone) 12%, var(--color-surface-raised));
color: var(--status-phone);
}
.stage-badge--interviewing {
background: color-mix(in srgb, var(--status-interview) 12%, var(--color-surface-raised));
color: var(--status-interview);
}
.stage-badge--offer {
background: color-mix(in srgb, var(--status-offer) 12%, var(--color-surface-raised));
color: var(--status-offer);
}
/* Countdown chip */
.countdown-chip {
font-size: var(--text-xs);
font-weight: 700;
padding: 2px 8px;
border-radius: var(--radius-full);
display: inline-flex;
align-items: center;
gap: 4px;
}
.countdown--today { background: color-mix(in srgb, var(--color-error) 12%, var(--color-surface-raised)); color: var(--color-error); }
.countdown--tomorrow { background: color-mix(in srgb, var(--color-warning) 12%, var(--color-surface-raised)); color: var(--color-warning); }
.countdown--future { background: color-mix(in srgb, var(--color-success) 12%, var(--color-surface-raised)); color: var(--color-success); }
.countdown--past { background: var(--color-surface-alt); color: var(--color-text-muted); }
.btn-link-out {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: var(--text-sm);
color: var(--app-primary);
text-decoration: none;
width: fit-content;
}
.btn-link-out:hover { text-decoration: underline; }
/* ── Research controls ──────────────────────────────────────────────────── */
.research-controls {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.btn-primary {
background: var(--app-primary);
color: var(--color-text-inverse);
border: none;
border-radius: var(--radius-md);
padding: var(--space-2) var(--space-4);
font-size: var(--text-sm);
font-weight: 600;
cursor: pointer;
transition: background var(--transition);
}
.btn-primary:hover:not(:disabled) { background: var(--app-primary-hover); }
.btn-primary:disabled { opacity: 0.6; cursor: default; }
.btn-secondary {
background: none;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: var(--space-1) var(--space-3);
font-size: var(--text-sm);
font-weight: 600;
color: var(--app-primary);
cursor: pointer;
transition: background var(--transition);
}
.btn-secondary:hover:not(:disabled) { background: var(--color-surface-alt); }
.btn-secondary:disabled { opacity: 0.6; cursor: default; }
.research-running {
display: flex;
align-items: center;
gap: var(--space-2);
font-size: var(--text-sm);
color: var(--color-info);
}
/* Spinner */
.spinner {
width: 16px;
height: 16px;
border: 2px solid color-mix(in srgb, var(--color-info) 25%, transparent);
border-top-color: var(--color-info);
border-radius: 50%;
animation: spin 700ms linear infinite;
flex-shrink: 0;
}
@keyframes spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) {
.spinner { animation: none; border-top-color: var(--color-info); }
}
.research-generated {
display: flex;
align-items: center;
gap: var(--space-3);
flex-wrap: wrap;
}
.research-ts {
font-size: var(--text-xs);
color: var(--color-text-muted);
}
.research-error {
display: flex;
align-items: center;
gap: var(--space-3);
flex-wrap: wrap;
background: color-mix(in srgb, var(--color-error) 8%, var(--color-surface));
border: 1px solid color-mix(in srgb, var(--color-error) 25%, transparent);
border-radius: var(--radius-md);
padding: var(--space-2) var(--space-3);
font-size: var(--text-sm);
color: var(--color-error);
}
/* ── Error banner ────────────────────────────────────────────────────────── */
.error-banner {
background: color-mix(in srgb, var(--color-error) 10%, var(--color-surface));
color: var(--color-error);
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-md);
font-size: var(--text-sm);
}
/* Inline error state for tab panels (e.g. contacts fetch failure) */
.error-state {
background: color-mix(in srgb, var(--color-error) 8%, var(--color-surface));
color: var(--color-error);
border: 1px solid color-mix(in srgb, var(--color-error) 25%, transparent);
border-radius: var(--radius-md);
padding: var(--space-3) var(--space-4);
font-size: var(--text-sm);
}
/* ── Research sections ───────────────────────────────────────────────────── */
.research-sections {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.research-section {
background: var(--color-surface-raised);
border: 1px solid var(--color-border-light);
border-radius: var(--radius-md);
padding: var(--space-3) var(--space-4);
}
.research-section.section--warning {
background: color-mix(in srgb, var(--color-warning) 8%, var(--color-surface));
border-color: color-mix(in srgb, var(--color-warning) 30%, transparent);
}
.section-title {
font-family: var(--font-display);
font-size: var(--text-sm);
font-weight: 700;
color: var(--color-text);
margin-bottom: var(--space-2);
display: flex;
align-items: center;
gap: var(--space-1);
}
.section-caption {
font-size: var(--text-xs);
color: var(--color-text-muted);
font-style: italic;
margin: 0 0 var(--space-2);
}
.section-body {
font-size: var(--text-sm);
color: var(--color-text);
line-height: 1.6;
white-space: pre-wrap;
}
/* ── Empty state ─────────────────────────────────────────────────────────── */
.research-empty,
.tab-empty {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-2);
padding: var(--space-8) var(--space-4);
color: var(--color-text-muted);
text-align: center;
}
.empty-bird {
font-size: 2rem;
}
.tab-empty p {
font-size: var(--text-sm);
margin: 0;
}
/* ── Tab bar ─────────────────────────────────────────────────────────────── */
.tab-bar {
display: flex;
gap: 2px;
border-bottom: 2px solid var(--color-border-light);
overflow-x: auto;
}
.tab-btn {
background: none;
border: none;
border-bottom: 3px solid transparent;
padding: var(--space-2) var(--space-3);
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-text-muted);
cursor: pointer;
white-space: nowrap;
transition: color var(--transition), border-color var(--transition);
display: inline-flex;
align-items: center;
gap: var(--space-1);
margin-bottom: -2px;
}
.tab-btn:hover { color: var(--app-primary); }
.tab-btn--active {
color: var(--app-primary);
border-bottom-color: var(--app-primary);
}
.tab-count {
background: var(--color-surface-alt);
border-radius: var(--radius-full);
padding: 1px 6px;
font-size: var(--text-xs);
font-weight: 700;
color: var(--color-text-muted);
}
/* ── Tab panels ──────────────────────────────────────────────────────────── */
.tab-panel {
background: var(--color-surface-raised);
border: 1px solid var(--color-border-light);
border-radius: var(--radius-md);
padding: var(--space-4);
min-height: 200px;
}
/* JD tab */
.jd-meta {
display: flex;
align-items: flex-start;
flex-wrap: wrap;
gap: var(--space-3);
margin-bottom: var(--space-3);
}
.score-badge {
font-size: var(--text-sm);
font-weight: 700;
padding: 2px 10px;
border-radius: var(--radius-full);
}
.score--high { background: color-mix(in srgb, var(--color-success) 12%, var(--color-surface-raised)); color: var(--color-success); }
.score--mid { background: color-mix(in srgb, var(--color-warning) 12%, var(--color-surface-raised)); color: var(--color-warning); }
.score--low { background: color-mix(in srgb, var(--color-error) 12%, var(--color-surface-raised)); color: var(--color-error); }
.score--none { background: var(--color-surface-alt); color: var(--color-text-muted); }
.keyword-gaps {
font-size: var(--text-xs);
color: var(--color-text-muted);
display: flex;
gap: var(--space-1);
flex-wrap: wrap;
align-items: baseline;
}
.keyword-gaps-label { font-weight: 700; }
.jd-body {
font-size: var(--text-sm);
color: var(--color-text);
line-height: 1.7;
white-space: pre-wrap;
max-height: 60vh;
overflow-y: auto;
}
/* Email tab */
.email-card {
border: 1px solid var(--color-border-light);
border-radius: var(--radius-md);
padding: var(--space-3);
background: var(--color-surface);
display: flex;
flex-direction: column;
gap: var(--space-1);
margin-bottom: var(--space-3);
}
.email-card:last-child { margin-bottom: 0; }
.email-header {
display: flex;
align-items: baseline;
gap: var(--space-2);
flex-wrap: wrap;
}
.email-dir { font-size: 1rem; }
.email-subject {
font-weight: 600;
font-size: var(--text-sm);
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.email-date {
font-size: var(--text-xs);
color: var(--color-text-muted);
flex-shrink: 0;
}
.email-from {
font-size: var(--text-xs);
color: var(--color-text-muted);
}
.email-body {
font-size: var(--text-xs);
color: var(--color-text);
line-height: 1.5;
white-space: pre-wrap;
}
/* Cover letter tab */
.letter-body {
font-size: var(--text-sm);
color: var(--color-text);
line-height: 1.8;
white-space: pre-wrap;
}
/* ── Call notes ──────────────────────────────────────────────────────────── */
.call-notes {
background: var(--color-surface-raised);
border: 1px solid var(--color-border-light);
border-radius: var(--radius-md);
padding: var(--space-4);
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.call-notes-title {
font-family: var(--font-display);
font-size: var(--text-sm);
font-weight: 700;
color: var(--color-text);
}
.call-notes-textarea {
width: 100%;
min-height: 120px;
resize: vertical;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: var(--space-2) var(--space-3);
font-family: var(--font-body);
font-size: var(--text-sm);
color: var(--color-text);
line-height: 1.6;
box-sizing: border-box;
}
.call-notes-textarea::placeholder { color: var(--color-text-muted); }
.call-notes-textarea:focus-visible {
outline: 2px solid var(--app-primary);
outline-offset: 2px;
border-color: var(--app-primary);
}
.call-notes-caption {
font-size: var(--text-xs);
color: var(--color-text-muted);
margin: 0;
font-style: italic;
}
</style>

View file

@ -0,0 +1,736 @@
<script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { useInterviewsStore } from '../stores/interviews'
import type { PipelineJob, PipelineStage } from '../stores/interviews'
import type { StageSignal } from '../stores/interviews'
import { useApiFetch } from '../composables/useApi'
import InterviewCard from '../components/InterviewCard.vue'
import MoveToSheet from '../components/MoveToSheet.vue'
const router = useRouter()
const store = useInterviewsStore()
// Move sheet
const moveTarget = ref<PipelineJob | null>(null)
const movePreSelected = ref<PipelineStage | undefined>(undefined)
function openMove(jobId: number, preSelectedStage?: PipelineStage) {
moveTarget.value = store.jobs.find(j => j.id === jobId) ?? null
movePreSelected.value = preSelectedStage
}
async function onMove(stage: PipelineStage, opts: { interview_date?: string; rejection_stage?: string }) {
if (!moveTarget.value) return
const wasHired = stage === 'hired'
await store.move(moveTarget.value.id, stage, opts)
moveTarget.value = null
if (wasHired) triggerConfetti()
}
// Collapsible Applied section
const APPLIED_EXPANDED_KEY = 'peregrine.interviews.appliedExpanded'
const appliedExpanded = ref(localStorage.getItem(APPLIED_EXPANDED_KEY) === 'true')
watch(appliedExpanded, v => localStorage.setItem(APPLIED_EXPANDED_KEY, String(v)))
const APPLIED_PAGE_SIZE = 10
const appliedPage = ref(0)
const allApplied = computed(() => [...store.applied, ...store.survey])
const appliedPageCount = computed(() => Math.ceil(allApplied.value.length / APPLIED_PAGE_SIZE))
const pagedApplied = computed(() =>
allApplied.value.slice(
appliedPage.value * APPLIED_PAGE_SIZE,
(appliedPage.value + 1) * APPLIED_PAGE_SIZE,
)
)
// Clamp page when the list shrinks (e.g. after a move)
watch(allApplied, () => {
if (appliedPage.value >= appliedPageCount.value) appliedPage.value = 0
})
const appliedSignalCount = computed(() =>
[...store.applied, ...store.survey]
.reduce((n, job) => n + (job.stage_signals?.length ?? 0), 0)
)
// Signal metadata (pre-list rows)
const SIGNAL_META_PRE = {
interview_scheduled: { label: 'Move to Phone Screen', stage: 'phone_screen' as PipelineStage, color: 'amber' },
positive_response: { label: 'Move to Phone Screen', stage: 'phone_screen' as PipelineStage, color: 'amber' },
offer_received: { label: 'Move to Offer', stage: 'offer' as PipelineStage, color: 'green' },
survey_received: { label: 'Move to Survey', stage: 'survey' as PipelineStage, color: 'amber' },
rejected: { label: 'Mark Rejected', stage: 'interview_rejected' as PipelineStage, color: 'red' },
} as const
const sigExpandedIds = ref(new Set<number>())
// IMPORTANT: must reassign .value (not mutate in place) to trigger Vue reactivity
function togglePreSigExpand(jobId: number) {
const next = new Set(sigExpandedIds.value)
if (next.has(jobId)) next.delete(jobId)
else next.add(jobId)
sigExpandedIds.value = next
}
async function dismissPreSignal(job: PipelineJob, sig: StageSignal) {
const idx = job.stage_signals.findIndex(s => s.id === sig.id)
if (idx !== -1) job.stage_signals.splice(idx, 1)
await useApiFetch(`/api/stage-signals/${sig.id}/dismiss`, { method: 'POST' })
}
const bodyExpandedMap = ref<Record<number, boolean>>({})
function toggleBodyExpand(sigId: number) {
bodyExpandedMap.value = { ...bodyExpandedMap.value, [sigId]: !bodyExpandedMap.value[sigId] }
}
const PRE_RECLASSIFY_CHIPS = [
{ label: '🟡 Interview', value: 'interview_scheduled' as const },
{ label: '✅ Positive', value: 'positive_response' as const },
{ label: '🟢 Offer', value: 'offer_received' as const },
{ label: '📋 Survey', value: 'survey_received' as const },
{ label: '✖ Rejected', value: 'rejected' as const },
{ label: '🚫 Unrelated', value: 'unrelated' },
{ label: '📰 Digest', value: 'digest' },
{ label: '— Neutral', value: 'neutral' },
] as const
const DISMISS_LABELS = new Set(['neutral', 'unrelated', 'digest'] as const)
async function reclassifyPreSignal(job: PipelineJob, sig: StageSignal, newLabel: StageSignal['stage_signal'] | 'neutral' | 'unrelated' | 'digest') {
if (DISMISS_LABELS.has(newLabel)) {
const idx = job.stage_signals.findIndex(s => s.id === sig.id)
if (idx !== -1) job.stage_signals.splice(idx, 1)
await useApiFetch(`/api/stage-signals/${sig.id}/reclassify`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ stage_signal: newLabel }),
})
await useApiFetch(`/api/stage-signals/${sig.id}/dismiss`, { method: 'POST' })
// Digest-only: add to browsable queue (fire-and-forget; sig.id === job_contacts.id)
if (newLabel === 'digest') {
void useApiFetch('/api/digest-queue', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ job_contact_id: sig.id }),
})
}
} else {
const prev = sig.stage_signal
sig.stage_signal = newLabel
const { error } = await useApiFetch(`/api/stage-signals/${sig.id}/reclassify`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ stage_signal: newLabel }),
})
if (error) sig.stage_signal = prev
}
}
// Email sync status
interface SyncStatus {
state: 'idle' | 'queued' | 'running' | 'completed' | 'failed' | 'not_configured'
lastCompletedAt: string | null
error: string | null
}
const syncStatus = ref<SyncStatus>({ state: 'idle', lastCompletedAt: null, error: null })
const now = ref(Date.now())
let syncPollId: ReturnType<typeof setInterval> | null = null
let nowTickId: ReturnType<typeof setInterval> | null = null
function elapsedLabel(isoTs: string | null): string {
if (!isoTs) return ''
const diffMs = now.value - new Date(isoTs).getTime()
const mins = Math.floor(diffMs / 60000)
if (mins < 1) return 'just now'
if (mins < 60) return `${mins}m ago`
const hrs = Math.floor(mins / 60)
if (hrs < 24) return `${hrs}h ago`
return `${Math.floor(hrs / 24)}d ago`
}
async function fetchSyncStatus() {
const { data } = await useApiFetch<{
status: string; last_completed_at: string | null; error: string | null
}>('/api/email/sync/status')
if (!data) return
syncStatus.value = {
state: data.status as SyncStatus['state'],
lastCompletedAt: data.last_completed_at,
error: data.error,
}
}
function startSyncPoll() {
if (syncPollId) return
syncPollId = setInterval(async () => {
await fetchSyncStatus()
if (syncStatus.value.state === 'completed' || syncStatus.value.state === 'failed') {
clearInterval(syncPollId!); syncPollId = null
if (syncStatus.value.state === 'completed') store.fetchAll()
}
}, 3000)
}
async function triggerSync() {
if (syncStatus.value.state === 'queued' || syncStatus.value.state === 'running') return
const { data, error } = await useApiFetch<{ task_id: number }>('/api/email/sync', { method: 'POST' })
if (error) {
if (error.kind === 'http' && error.status === 503) {
// Email integration not configured set permanently for this session
syncStatus.value = { state: 'not_configured', lastCompletedAt: null, error: null }
} else {
// Transient error (network, server 5xx etc.) show failed but allow retry
syncStatus.value = { ...syncStatus.value, state: 'failed', error: error.kind === 'http' ? error.detail : error.message }
}
return
}
if (data) {
syncStatus.value = { ...syncStatus.value, state: 'queued' }
startSyncPoll()
}
}
// Confetti (easter egg 9.5)
const showHiredToast = ref(false)
const confettiCanvas = ref<HTMLCanvasElement | null>(null)
function triggerConfetti() {
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
if (reducedMotion) {
showHiredToast.value = true
setTimeout(() => { showHiredToast.value = false }, 6000)
return
}
const canvas = confettiCanvas.value
if (!canvas) return
canvas.width = window.innerWidth
canvas.height = window.innerHeight
canvas.style.display = 'block'
const ctx = canvas.getContext('2d')!
const COLORS = ['#c4732a','#1a7a6e','#3b82f6','#f5c518','#e84393','#6ab870']
const particles = Array.from({ length: 120 }, (_, i) => ({
x: Math.random() * canvas.width,
y: -10 - Math.random() * 200,
r: 4 + Math.random() * 6,
color: COLORS[i % COLORS.length],
vx: (Math.random() - 0.5) * 4,
vy: 3 + Math.random() * 4,
angle: Math.random() * 360,
spin: (Math.random() - 0.5) * 8,
}))
let frame = 0
function draw() {
ctx.clearRect(0, 0, canvas!.width, canvas!.height)
particles.forEach(p => {
p.x += p.vx; p.y += p.vy; p.vy += 0.08; p.angle += p.spin
ctx.save()
ctx.translate(p.x, p.y)
ctx.rotate((p.angle * Math.PI) / 180)
ctx.fillStyle = p.color
ctx.fillRect(-p.r / 2, -p.r / 2, p.r, p.r * 1.6)
ctx.restore()
})
frame++
if (frame < 240) requestAnimationFrame(draw)
else canvas!.style.display = 'none'
}
draw()
}
// Keyboard navigation
const focusedCol = ref(0)
const focusedCard = ref(0)
const columns = [
{ jobs: () => store.phoneScreen },
{ jobs: () => store.interviewing },
{ jobs: () => store.offerHired },
]
function onKeydown(e: KeyboardEvent) {
if (moveTarget.value) return
const colJobs = columns[focusedCol.value].jobs()
if (e.key === 'ArrowUp' || e.key === 'k' || e.key === 'K') {
e.preventDefault(); focusedCard.value = Math.max(0, focusedCard.value - 1)
} else if (e.key === 'ArrowDown' || e.key === 'j' || e.key === 'J') {
e.preventDefault(); focusedCard.value = Math.min(colJobs.length - 1, focusedCard.value + 1)
} else if (e.key === 'ArrowLeft' || e.key === '[' || e.key === '4') {
e.preventDefault(); focusedCol.value = Math.max(0, focusedCol.value - 1); focusedCard.value = 0
} else if (e.key === 'ArrowRight' || e.key === ']' || e.key === '6') {
e.preventDefault(); focusedCol.value = Math.min(columns.length - 1, focusedCol.value + 1); focusedCard.value = 0
} else if (e.key === 'm' || e.key === 'M') {
const job = colJobs[focusedCard.value]; if (job) openMove(job.id)
} else if (e.key === 'Enter' || e.key === ' ') {
const job = colJobs[focusedCard.value]; if (job) router.push(`/prep/${job.id}`)
}
}
onMounted(async () => {
await store.fetchAll()
document.addEventListener('keydown', onKeydown)
await fetchSyncStatus()
if (syncStatus.value.state === 'queued' || syncStatus.value.state === 'running') {
startSyncPoll()
}
nowTickId = setInterval(() => { now.value = Date.now() }, 60000)
})
onUnmounted(() => {
document.removeEventListener('keydown', onKeydown)
if (syncPollId) { clearInterval(syncPollId); syncPollId = null }
if (nowTickId) { clearInterval(nowTickId); nowTickId = null }
})
function daysSince(dateStr: string | null) {
if (!dateStr) return null
return Math.floor((Date.now() - new Date(dateStr).getTime()) / 86400000)
}
</script>
<template>
<div class="interviews-view">
<canvas ref="confettiCanvas" class="confetti-canvas" aria-hidden="true" />
<Transition name="toast">
<div v-if="showHiredToast" class="hired-toast" role="alert">
🎉 Congratulations! You got the job!
</div>
</Transition>
<header class="view-header">
<h1 class="view-title">Interviews</h1>
<div class="header-actions">
<!-- Email sync pill -->
<button
v-if="syncStatus.state === 'not_configured'"
class="sync-pill sync-pill--muted"
disabled
aria-label="Email not configured"
>📧 Email not configured</button>
<button
v-else-if="syncStatus.state === 'queued' || syncStatus.state === 'running'"
class="sync-pill sync-pill--syncing"
disabled
aria-label="Syncing emails"
> Syncing</button>
<button
v-else-if="(syncStatus.state === 'completed' || syncStatus.state === 'idle') && syncStatus.lastCompletedAt"
class="sync-pill sync-pill--synced"
@click="triggerSync"
:aria-label="`Email synced ${elapsedLabel(syncStatus.lastCompletedAt)} — click to re-sync`"
>📧 Synced {{ elapsedLabel(syncStatus.lastCompletedAt) }}</button>
<button
v-else-if="syncStatus.state === 'failed'"
class="sync-pill sync-pill--failed"
@click="triggerSync"
aria-label="Sync failed — click to retry"
> Sync failed</button>
<button
v-else
class="sync-pill sync-pill--idle"
@click="triggerSync"
aria-label="Sync emails"
>📧 Sync Emails</button>
<button class="btn-refresh" @click="store.fetchAll()" :disabled="store.loading" aria-label="Refresh">
{{ store.loading ? '⟳' : '↺' }}
</button>
</div>
</header>
<div v-if="store.error" class="error-banner">{{ store.error }}</div>
<!-- Pre-list: Applied + Survey (collapsible) -->
<section class="pre-list" aria-label="Applied jobs">
<button
class="pre-list-toggle"
@click="appliedExpanded = !appliedExpanded"
:aria-expanded="appliedExpanded"
aria-controls="pre-list-body"
>
<span class="pre-list-chevron" :class="{ 'is-expanded': appliedExpanded }"></span>
<span class="pre-list-toggle-title">
Applied
<span class="pre-list-count">{{ store.applied.length + store.survey.length }}</span>
</span>
<span v-if="appliedSignalCount > 0" class="pre-list-signal-count"> {{ appliedSignalCount }} signal{{ appliedSignalCount !== 1 ? 's' : '' }}</span>
</button>
<div
id="pre-list-body"
class="pre-list-body"
:class="{ 'is-expanded': appliedExpanded }"
>
<div v-if="store.applied.length === 0 && store.survey.length === 0" class="pre-list-empty">
<span class="empty-bird">🦅</span>
<span>No applied jobs yet. <RouterLink to="/apply">Go to Apply</RouterLink> to submit applications.</span>
</div>
<template v-for="job in pagedApplied" :key="job.id">
<div class="pre-list-row">
<div class="pre-row-info">
<span class="pre-row-title">{{ job.title }}</span>
<span class="pre-row-company">{{ job.company }}</span>
<span v-if="job.status === 'survey'" class="survey-badge">Survey</span>
</div>
<div class="pre-row-meta">
<span v-if="daysSince(job.applied_at) !== null" class="pre-row-days">{{ daysSince(job.applied_at) }}d ago</span>
<button class="btn-move-pre" @click="openMove(job.id)" :aria-label="`Move ${job.title}`">Move to </button>
<button
v-if="job.status === 'survey'"
class="btn-move-pre"
@click="router.push('/survey/' + job.id)"
>Survey </button>
</div>
</div>
<!-- Signal banners for pre-list rows -->
<template v-if="job.stage_signals?.length">
<div
v-for="sig in (job.stage_signals ?? []).slice(0, sigExpandedIds.has(job.id) ? undefined : 1)"
:key="sig.id"
class="pre-signal-banner"
:data-color="SIGNAL_META_PRE[sig.stage_signal]?.color"
>
<div class="signal-header">
<span class="signal-label">📧 <strong>{{ SIGNAL_META_PRE[sig.stage_signal]?.label?.replace('Move to ', '') ?? sig.stage_signal }}</strong></span>
<span class="signal-subject">{{ sig.subject.slice(0, 60) }}{{ sig.subject.length > 60 ? '…' : '' }}</span>
<div class="signal-header-actions">
<button class="btn-signal-read" @click.stop="toggleBodyExpand(sig.id)"
:aria-expanded="bodyExpandedMap[sig.id] ?? false"
:aria-label="(bodyExpandedMap[sig.id] ? 'Hide' : 'Read') + ' email body'">
{{ bodyExpandedMap[sig.id] ? '▾ Hide' : '▸ Read' }}
</button>
<button
class="btn-signal-move"
@click.stop="openMove(job.id, SIGNAL_META_PRE[sig.stage_signal]?.stage)"
:aria-label="`Move ${job.title} — ${SIGNAL_META_PRE[sig.stage_signal]?.label ?? 'Move'}`"
> Move</button>
<button class="btn-signal-dismiss" @click.stop="dismissPreSignal(job, sig)" aria-label="Dismiss signal"></button>
</div>
</div>
<!-- Expanded body + reclassify chips -->
<div v-if="bodyExpandedMap[sig.id]" class="signal-body-expanded">
<div v-if="sig.from_addr" class="signal-from">From: {{ sig.from_addr }}</div>
<div v-if="sig.body" class="signal-body-text">{{ sig.body }}</div>
<div v-else class="signal-body-empty">No email body available.</div>
<div class="signal-reclassify">
<span class="signal-reclassify-label">Re-classify:</span>
<button
v-for="chip in PRE_RECLASSIFY_CHIPS"
:key="chip.value"
class="btn-chip"
:class="{ 'btn-chip-active': sig.stage_signal === chip.value }"
@click.stop="reclassifyPreSignal(job, sig, chip.value)"
>{{ chip.label }}</button>
</div>
</div>
</div>
<button
v-if="(job.stage_signals?.length ?? 0) > 1"
class="btn-sig-expand"
@click="togglePreSigExpand(job.id)"
>{{ sigExpandedIds.has(job.id) ? ' less' : `+${(job.stage_signals?.length ?? 1) - 1} more` }}</button>
</template>
</template>
<!-- Pagination -->
<div v-if="appliedPageCount > 1" class="pre-list-pagination">
<button
class="btn-page"
:disabled="appliedPage === 0"
@click="appliedPage--"
aria-label="Previous page"
></button>
<span class="page-indicator">{{ appliedPage + 1 }} / {{ appliedPageCount }}</span>
<button
class="btn-page"
:disabled="appliedPage >= appliedPageCount - 1"
@click="appliedPage++"
aria-label="Next page"
></button>
</div>
</div>
</section>
<!-- Kanban columns -->
<section class="kanban" aria-label="Interview pipeline">
<div class="kanban-col" :class="{ 'kanban-col--focused': focusedCol === 0 }" aria-label="Phone Screen">
<div class="col-header" style="color: var(--status-phone)">
📞 Phone Screen <span class="col-count">{{ store.phoneScreen.length }}</span>
</div>
<div v-if="store.phoneScreen.length === 0" class="col-empty">
<div class="empty-bird-wrap"><span class="empty-bird-float">🦅</span></div>
<p class="empty-msg">No phone screens yet.<br>Move an applied job here when a recruiter reaches out.</p>
</div>
<InterviewCard v-for="(job, i) in store.phoneScreen" :key="job.id" :job="job"
:focused="focusedCol === 0 && focusedCard === i"
@move="openMove" @prep="router.push(`/prep/${$event}`)" @survey="router.push('/survey/' + $event)" />
</div>
<div class="kanban-col" :class="{ 'kanban-col--focused': focusedCol === 1 }" aria-label="Interviewing">
<div class="col-header" style="color: var(--color-info)">
🎯 Interviewing <span class="col-count">{{ store.interviewing.length }}</span>
</div>
<div v-if="store.interviewing.length === 0" class="col-empty">
<div class="empty-bird-wrap"><span class="empty-bird-float">🦅</span></div>
<p class="empty-msg">Phone screen going well?<br>Move it here when you've got a real interview scheduled.</p>
</div>
<InterviewCard v-for="(job, i) in store.interviewing" :key="job.id" :job="job"
:focused="focusedCol === 1 && focusedCard === i"
@move="openMove" @prep="router.push(`/prep/${$event}`)" @survey="router.push('/survey/' + $event)" />
</div>
<div class="kanban-col" :class="{ 'kanban-col--focused': focusedCol === 2 }" aria-label="Offer and Hired">
<div class="col-header" style="color: var(--status-offer)">
📜 Offer / Hired <span class="col-count">{{ store.offerHired.length }}</span>
</div>
<div v-if="store.offerHired.length === 0" class="col-empty">
<div class="empty-bird-wrap"><span class="empty-bird-float">🦅</span></div>
<p class="empty-msg">This is where offers land.<br>You've got this. 🙌</p>
</div>
<InterviewCard v-for="(job, i) in store.offerHired" :key="job.id" :job="job"
:focused="focusedCol === 2 && focusedCard === i"
@move="openMove" @prep="router.push(`/prep/${$event}`)" @survey="router.push('/survey/' + $event)" />
</div>
</section>
<!-- Rejected accordion -->
<details class="rejected-accordion" v-if="store.rejected.length > 0">
<summary class="rejected-summary">
Rejected ({{ store.rejected.length }})
<span class="rejected-hint"> expand for details</span>
</summary>
<div class="rejected-body">
<div class="rejected-stats">
<div class="stat-chip">
<span class="stat-num">{{ store.rejected.length }}</span>
<span class="stat-lbl">Total</span>
</div>
</div>
<div v-for="job in store.rejected" :key="job.id" class="rejected-row">
<span class="rejected-title">{{ job.title }} {{ job.company }}</span>
<span class="rejected-stage">{{ job.rejection_stage ?? 'No response' }}</span>
<button class="btn-unrej" @click="openMove(job.id)">Move </button>
</div>
</div>
</details>
<MoveToSheet
v-if="moveTarget"
:currentStatus="moveTarget.status"
:jobTitle="`${moveTarget.title} at ${moveTarget.company}`"
:preSelectedStage="movePreSelected"
@move="onMove"
@close="moveTarget = null; movePreSelected = undefined"
/>
</div>
</template>
<style scoped>
.interviews-view {
padding: var(--space-4) var(--space-4) var(--space-12);
max-width: 1100px; margin: 0 auto; position: relative;
}
.confetti-canvas { position: fixed; inset: 0; z-index: 300; pointer-events: none; display: none; }
.hired-toast {
position: fixed; bottom: var(--space-8); left: 50%; transform: translateX(-50%);
background: var(--color-success); color: #fff;
padding: var(--space-3) var(--space-6); border-radius: 12px;
font-weight: 700; font-size: 1.1rem; z-index: 400;
box-shadow: 0 4px 20px rgba(0,0,0,.2);
}
.toast-enter-active, .toast-leave-active { transition: all 400ms ease; }
.toast-enter-from, .toast-leave-to { opacity: 0; transform: translateX(-50%) translateY(20px); }
.view-header { display: flex; align-items: center; gap: var(--space-3); margin-bottom: var(--space-6); }
.view-title { font-size: 1.5rem; font-weight: 700; margin: 0; }
.btn-refresh { background: none; border: 1px solid var(--color-border); border-radius: 6px; cursor: pointer; padding: 4px 10px; font-size: 1rem; color: var(--color-text-muted); }
.error-banner { background: color-mix(in srgb, var(--color-error) 10%, var(--color-surface)); color: var(--color-error); padding: var(--space-2) var(--space-3); border-radius: 8px; margin-bottom: var(--space-4); }
/* Header actions */
.header-actions { display: flex; align-items: center; gap: var(--space-2); margin-left: auto; }
/* Email sync pill */
.sync-pill {
border-radius: 999px; padding: 3px 10px; font-size: 0.78em; font-weight: 600; cursor: pointer;
border: 1px solid transparent; transition: opacity 150ms;
}
.sync-pill:disabled { cursor: default; opacity: 0.8; }
.sync-pill--idle { border-color: var(--color-border); background: none; color: var(--color-text-muted); }
.sync-pill--syncing { background: color-mix(in srgb, var(--color-info) 10%, var(--color-surface)); color: var(--color-info); border-color: color-mix(in srgb, var(--color-info) 30%, transparent); animation: pulse 1.5s ease-in-out infinite; }
.sync-pill--synced { background: color-mix(in srgb, var(--color-success) 12%, var(--color-surface)); color: var(--color-success); border-color: color-mix(in srgb, var(--color-success) 30%, transparent); }
.sync-pill--failed { background: color-mix(in srgb, var(--color-error) 10%, var(--color-surface)); color: var(--color-error); border-color: color-mix(in srgb, var(--color-error) 30%, transparent); }
.sync-pill--muted { background: var(--color-surface-alt); color: var(--color-text-muted); border-color: var(--color-border-light); }
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.55} }
/* Collapsible pre-list toggle header */
.pre-list-toggle {
display: flex; align-items: center; gap: var(--space-2); width: 100%;
background: none; border: none; cursor: pointer; padding: var(--space-1) 0;
font-size: 0.9rem; font-weight: 700; color: var(--color-text);
text-align: left;
}
.pre-list-chevron { font-size: 0.7em; color: var(--color-text-muted); transition: transform 200ms; display: inline-block; }
.pre-list-chevron.is-expanded { transform: rotate(90deg); }
.pre-list-count {
display: inline-block; background: var(--color-surface-raised); border-radius: 999px;
padding: 1px 8px; font-size: 0.75em; font-weight: 700; margin-left: var(--space-1);
color: var(--color-text-muted);
}
.pre-list-signal-count { margin-left: auto; font-size: 0.75em; font-weight: 700; color: #e67e22; }
/* Collapsible pre-list body */
.pre-list-body {
max-height: 0;
overflow: hidden;
transition: max-height 300ms ease;
}
.pre-list-body.is-expanded { max-height: 800px; }
@media (prefers-reduced-motion: reduce) {
.pre-list-body, .pre-list-chevron { transition: none; }
}
.pre-list { background: var(--color-surface); border-radius: 10px; padding: var(--space-3) var(--space-4); margin-bottom: var(--space-6); }
.pre-list-toggle-title { display: flex; align-items: center; }
.pre-list-empty { display: flex; align-items: center; gap: var(--space-2); font-size: 0.85rem; color: var(--color-text-muted); padding: var(--space-2) 0; }
.pre-list-row { display: flex; align-items: center; justify-content: space-between; padding: var(--space-2) 0; border-top: 1px solid var(--color-border-light); gap: var(--space-3); }
.pre-row-info { display: flex; align-items: center; gap: var(--space-2); flex: 1; min-width: 0; }
.pre-row-title { font-weight: 600; font-size: 0.875rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.pre-row-company { color: var(--color-text-muted); font-size: 0.8rem; white-space: nowrap; }
.survey-badge { background: color-mix(in srgb, var(--status-phone) 12%, var(--color-surface-raised)); color: var(--status-phone); border-radius: 99px; padding: 1px 7px; font-size: 0.7rem; font-weight: 700; }
.pre-row-meta { display: flex; align-items: center; gap: var(--space-2); flex-shrink: 0; }
.pre-row-days { font-size: 0.75rem; color: var(--color-text-muted); }
.btn-move-pre { background: none; border: 1px solid var(--color-border); border-radius: 6px; padding: 2px 8px; font-size: 0.75rem; font-weight: 700; color: var(--color-info); cursor: pointer; }
/* Pre-list signal banners */
.pre-signal-banner {
padding: 8px 12px; border-radius: 6px; margin: 4px 0;
border-top: 1px solid transparent;
display: flex; flex-direction: column; gap: 4px;
}
.pre-signal-banner[data-color="amber"] { background: rgba(245,158,11,0.08); border-top-color: rgba(245,158,11,0.4); }
.pre-signal-banner[data-color="green"] { background: rgba(39,174,96,0.08); border-top-color: rgba(39,174,96,0.4); }
.pre-signal-banner[data-color="red"] { background: rgba(192,57,43,0.08); border-top-color: rgba(192,57,43,0.4); }
.signal-label { font-size: 0.82em; }
.signal-subject { font-size: 0.78em; color: var(--color-text-muted); }
.signal-actions { display: flex; gap: 6px; align-items: center; }
.btn-signal-move {
background: var(--color-primary); color: #fff;
border: none; border-radius: 4px; padding: 2px 8px; font-size: 0.78em; cursor: pointer;
}
.btn-signal-dismiss {
background: none; border: none; color: var(--color-text-muted); font-size: 0.85em; cursor: pointer;
padding: 2px 4px;
}
.btn-signal-read {
background: none; border: none; color: var(--color-text-muted); font-size: 0.82em;
cursor: pointer; padding: 2px 6px; white-space: nowrap;
}
.signal-header {
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
}
.signal-header-actions {
margin-left: auto; display: flex; gap: 6px; align-items: center;
}
.signal-body-expanded {
margin-top: 8px; font-size: 0.8em; border-top: 1px dashed var(--color-border);
padding-top: 8px;
}
.signal-from {
color: var(--color-text-muted); margin-bottom: 4px;
}
.signal-body-text {
white-space: pre-wrap; color: var(--color-text); line-height: 1.5;
max-height: 200px; overflow-y: auto;
}
.signal-body-empty {
color: var(--color-text-muted); font-style: italic;
}
.signal-reclassify {
display: flex; gap: 6px; flex-wrap: wrap; align-items: center; margin-top: 8px;
}
.signal-reclassify-label {
font-size: 0.75em; color: var(--color-text-muted);
}
.btn-chip {
background: var(--color-surface); color: var(--color-text-muted);
border: 1px solid var(--color-border); border-radius: 4px;
padding: 2px 7px; font-size: 0.75em; cursor: pointer;
}
.btn-chip:hover {
background: var(--color-hover);
}
.btn-chip-active {
background: var(--color-primary-muted, #e8f0ff);
color: var(--color-primary); border-color: var(--color-primary);
font-weight: 600;
}
.btn-sig-expand {
background: none; border: none; font-size: 0.75em; color: var(--color-info); cursor: pointer;
padding: 4px 12px; text-align: left;
}
.kanban {
display: grid; grid-template-columns: repeat(3, 1fr);
gap: var(--space-4); margin-bottom: var(--space-6);
}
@media (max-width: 720px) { .kanban { grid-template-columns: 1fr; } }
.kanban-col {
background: var(--color-surface); border-radius: 10px;
padding: var(--space-3); display: flex; flex-direction: column; gap: var(--space-3);
transition: box-shadow 150ms;
}
.kanban-col--focused { box-shadow: 0 0 0 2px var(--color-primary); }
.col-header {
font-size: 0.8rem; font-weight: 700; text-transform: uppercase;
letter-spacing: .05em; display: flex; align-items: center; justify-content: space-between;
}
.col-count { background: rgba(0,0,0,.08); border-radius: 99px; padding: 1px 8px; font-size: 0.75rem; font-weight: 700; color: var(--color-text-muted); }
.col-empty {
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: var(--space-2); padding: var(--space-6) var(--space-3); text-align: center;
}
.empty-bird-wrap { background: var(--color-surface-alt); border-radius: 50%; width: 52px; height: 52px; display: flex; align-items: center; justify-content: center; }
.empty-bird-float { font-size: 1.75rem; animation: float 3s ease-in-out infinite; }
@keyframes float { 0%,100%{transform:translateY(0)} 50%{transform:translateY(-6px)} }
.empty-msg { font-size: 0.8rem; color: var(--color-text-muted); line-height: 1.5; }
.rejected-accordion { border: 1px solid var(--color-border-light); border-radius: 10px; overflow: hidden; }
.rejected-summary {
list-style: none; padding: var(--space-3) var(--space-4);
background: color-mix(in srgb, var(--color-error) 10%, var(--color-surface));
cursor: pointer; font-weight: 700; font-size: 0.85rem; color: var(--color-error);
display: flex; align-items: center; gap: var(--space-2);
}
.rejected-summary::-webkit-details-marker { display: none; }
.rejected-hint { font-weight: 400; color: var(--color-text-muted); font-size: 0.75rem; }
.rejected-body { padding: var(--space-3) var(--space-4); background: color-mix(in srgb, var(--color-error) 4%, var(--color-surface-raised)); display: flex; flex-direction: column; gap: var(--space-2); }
.rejected-stats { display: flex; gap: var(--space-3); margin-bottom: var(--space-2); }
.stat-chip { background: var(--color-surface-raised); border-radius: 6px; padding: var(--space-2) var(--space-3); border: 1px solid var(--color-border-light); text-align: center; }
.stat-num { display: block; font-size: 1.25rem; font-weight: 700; color: var(--color-error); }
.stat-lbl { font-size: 0.7rem; color: var(--color-text-muted); }
.rejected-row { display: flex; align-items: center; gap: var(--space-3); background: var(--color-surface-raised); border-radius: 6px; padding: var(--space-2) var(--space-3); border-left: 3px solid var(--color-error); }
.rejected-title { flex: 1; font-weight: 600; font-size: 0.875rem; }
.rejected-stage { font-size: 0.75rem; color: var(--color-text-muted); }
.btn-unrej { background: none; border: 1px solid var(--color-border); border-radius: 6px; padding: 2px 8px; font-size: 0.75rem; font-weight: 700; color: var(--color-info); cursor: pointer; }
.empty-bird { font-size: 1.25rem; }
.pre-list-pagination {
display: flex; align-items: center; justify-content: center; gap: var(--space-2);
padding: 6px 12px; border-top: 1px solid var(--color-border-light);
}
.btn-page {
background: none; border: 1px solid var(--color-border); border-radius: 4px;
color: var(--color-text); font-size: 0.9em; padding: 2px 10px; cursor: pointer;
line-height: 1.6;
}
.btn-page:disabled {
opacity: 0.35; cursor: default;
}
.btn-page:not(:disabled):hover {
background: var(--color-surface-raised);
}
.page-indicator {
font-size: 0.8em; color: var(--color-text-muted); min-width: 40px; text-align: center;
}
</style>

View file

@ -0,0 +1,834 @@
<template>
<div class="review">
<!-- Header -->
<header class="review__header">
<div class="review__title-row">
<h1 class="review__title">Review Jobs</h1>
<button class="help-btn" :aria-expanded="showHelp" @click="showHelp = !showHelp">
<span aria-hidden="true">?</span>
<span class="sr-only">Keyboard shortcuts</span>
</button>
</div>
<!-- Status filter tabs (segmented control) -->
<div class="review__tabs" role="tablist" aria-label="Filter by status">
<button
v-for="tab in TABS"
:key="tab.status"
role="tab"
class="review__tab"
:class="{ 'review__tab--active': activeTab === tab.status }"
:aria-selected="activeTab === tab.status"
@click="setTab(tab.status)"
>
{{ tab.label }}
<span v-if="tab.status === 'pending' && store.remaining > 0" class="tab-badge">
{{ store.remaining }}
</span>
</button>
</div>
</header>
<!-- PENDING: card stack -->
<div v-if="activeTab === 'pending'" class="review__body">
<!-- Loading -->
<div v-if="store.loading" class="review__loading" aria-live="polite" aria-label="Loading jobs…">
<span class="spinner" aria-hidden="true" />
<span>Loading queue</span>
</div>
<!-- Empty state falcon stoop animation (easter egg 9.3) -->
<div v-else-if="store.remaining === 0 && !store.loading" class="review__empty" role="status">
<span class="empty-falcon" aria-hidden="true">🦅</span>
<h2 class="empty-title">Queue cleared.</h2>
<p class="empty-desc">Nothing to review right now. Run discovery to find new listings.</p>
</div>
<!-- Card stack -->
<template v-else-if="store.currentJob">
<!-- Keyboard hint bar -->
<div class="hint-bar" aria-hidden="true">
<span class="hint"><kbd></kbd><kbd>J</kbd> Reject</span>
<span class="hint-counter">{{ store.remaining }} remaining</span>
<span class="hint"><kbd></kbd><kbd>L</kbd> Approve</span>
</div>
<JobCardStack
ref="stackRef"
:job="store.currentJob"
:remaining="store.remaining"
@approve="onApprove"
@reject="onReject"
@skip="onSkip"
/>
<!-- Action buttons (non-swipe path) -->
<div class="review__actions" aria-label="Review actions">
<button
class="action-btn action-btn--reject"
aria-label="Reject this job"
@click="stackRef?.dismissReject()"
>
<span aria-hidden="true"></span> Reject
</button>
<button
class="action-btn action-btn--skip"
aria-label="Skip — come back later"
@click="stackRef?.dismissSkip()"
>
<span aria-hidden="true"></span> Skip
</button>
<button
class="action-btn action-btn--approve"
aria-label="Approve this job"
@click="stackRef?.dismissApprove()"
>
<span aria-hidden="true"></span> Approve
</button>
</div>
<!-- Undo hint -->
<p class="review__undo-hint" aria-hidden="true">Press <kbd>Z</kbd> to undo</p>
</template>
</div>
<!-- OTHER STATUS: list view -->
<div v-else class="review__body">
<div v-if="store.loading" class="review__loading" aria-live="polite">
<span class="spinner" aria-hidden="true" />
<span>Loading</span>
</div>
<div v-else-if="store.listJobs.length === 0" class="review__empty" role="status">
<p class="empty-desc">No {{ activeTab }} jobs.</p>
</div>
<ul v-else class="job-list" role="list">
<li v-for="job in store.listJobs" :key="job.id" class="job-list__item">
<div class="job-list__info">
<span class="job-list__title">{{ job.title }}</span>
<span class="job-list__company">{{ job.company }}</span>
</div>
<div class="job-list__meta">
<span v-if="job.match_score !== null" class="score-pill" :class="scorePillClass(job.match_score)">
{{ job.match_score }}%
</span>
<a :href="job.url" target="_blank" rel="noopener noreferrer" class="job-list__link">
View
</a>
</div>
</li>
</ul>
</div>
<!-- Help overlay -->
<Transition name="overlay">
<div
v-if="showHelp"
class="help-overlay"
role="dialog"
aria-modal="true"
aria-labelledby="help-title"
@click.self="showHelp = false"
>
<div class="help-modal">
<h2 id="help-title" class="help-modal__title">Keyboard Shortcuts</h2>
<dl class="help-keys">
<div class="help-keys__row">
<dt><kbd></kbd> / <kbd>L</kbd></dt>
<dd>Approve</dd>
</div>
<div class="help-keys__row">
<dt><kbd></kbd> / <kbd>J</kbd></dt>
<dd>Reject</dd>
</div>
<div class="help-keys__row">
<dt><kbd>S</kbd></dt>
<dd>Skip (come back later)</dd>
</div>
<div class="help-keys__row">
<dt><kbd>Enter</kbd></dt>
<dd>Expand / collapse description</dd>
</div>
<div class="help-keys__row">
<dt><kbd>Z</kbd></dt>
<dd>Undo last action</dd>
</div>
<div class="help-keys__row">
<dt><kbd>?</kbd></dt>
<dd>Toggle this help</dd>
</div>
</dl>
<button class="help-modal__close" @click="showHelp = false" aria-label="Close help"></button>
</div>
</div>
</Transition>
<!-- Undo toast -->
<Transition name="toast">
<div
v-if="undoToast"
class="undo-toast"
role="status"
aria-live="polite"
>
<span>{{ undoToast.message }}</span>
<button class="undo-toast__btn" @click="doUndo">Undo</button>
</div>
</Transition>
<!-- Stoop speed toast easter egg 9.2 -->
<Transition name="toast">
<div v-if="stoopToastVisible" class="stoop-toast" role="status" aria-live="polite">
🦅 Stoop speed.
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import { useRoute } from 'vue-router'
import { useReviewStore } from '../stores/review'
import JobCardStack from '../components/JobCardStack.vue'
const store = useReviewStore()
const route = useRoute()
const stackRef = ref<InstanceType<typeof JobCardStack> | null>(null)
// Tabs
const TABS = [
{ status: 'pending', label: 'Pending' },
{ status: 'approved', label: 'Approved' },
{ status: 'rejected', label: 'Rejected' },
{ status: 'applied', label: 'Applied' },
{ status: 'synced', label: 'Synced' },
]
const activeTab = ref((route.query.status as string) ?? 'pending')
async function setTab(status: string) {
activeTab.value = status
if (status === 'pending') {
await store.fetchQueue()
} else {
await store.fetchList(status)
}
}
// Undo toast
const undoToast = ref<{ message: string } | null>(null)
let toastTimer = 0
function showUndoToast(action: 'approved' | 'rejected' | 'skipped') {
clearTimeout(toastTimer)
undoToast.value = { message: `${capitalize(action)}` }
toastTimer = window.setTimeout(() => { undoToast.value = null }, 5000)
}
async function doUndo() {
clearTimeout(toastTimer)
undoToast.value = null
await store.undo()
}
function capitalize(s: string) { return s.charAt(0).toUpperCase() + s.slice(1) }
// Action handlers
async function onApprove() {
const job = store.currentJob
if (!job) return
await store.approve(job)
showUndoToast('approved')
checkStoopSpeed()
}
async function onReject() {
const job = store.currentJob
if (!job) return
await store.reject(job)
showUndoToast('rejected')
checkStoopSpeed()
}
function onSkip() {
const job = store.currentJob
if (!job) return
store.skip(job)
showUndoToast('skipped')
}
// Stoop speed easter egg 9.2
const stoopToastVisible = ref(false)
function checkStoopSpeed() {
if (!store.stoopAchieved && store.isStoopSpeed) {
store.markStoopAchieved()
stoopToastVisible.value = true
setTimeout(() => { stoopToastVisible.value = false }, 3500)
}
}
// Keyboard shortcuts
const showHelp = ref(false)
function onKeyDown(e: KeyboardEvent) {
// Don't steal keys when typing in an input
if ((e.target as Element).closest('input, textarea, select, [contenteditable]')) return
if (activeTab.value !== 'pending') return
switch (e.key) {
case 'ArrowRight':
case 'l':
case 'L':
e.preventDefault()
stackRef.value?.dismissApprove()
break
case 'ArrowLeft':
case 'j':
case 'J':
e.preventDefault()
stackRef.value?.dismissReject()
break
case 's':
case 'S':
e.preventDefault()
stackRef.value?.dismissSkip()
break
case 'z':
case 'Z':
e.preventDefault()
doUndo()
break
case 'Enter':
// Expand/collapse bubble to the card's button naturally; no action needed here
break
case '?':
showHelp.value = !showHelp.value
break
case 'Escape':
showHelp.value = false
break
}
}
// List view score pill
function scorePillClass(score: number) {
if (score >= 80) return 'score-pill--high'
if (score >= 60) return 'score-pill--mid'
return 'score-pill--low'
}
// Lifecycle
onMounted(async () => {
document.addEventListener('keydown', onKeyDown)
await store.fetchQueue()
})
onUnmounted(() => {
document.removeEventListener('keydown', onKeyDown)
clearTimeout(toastTimer)
})
</script>
<style scoped>
.review {
max-width: 680px;
margin: 0 auto;
padding: var(--space-8) var(--space-6);
display: flex;
flex-direction: column;
gap: var(--space-6);
min-height: 100dvh;
}
/* ── Header ─────────────────────────────────────────────────────────── */
.review__header {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.review__title-row {
display: flex;
align-items: center;
gap: var(--space-3);
}
.review__title {
font-family: var(--font-display);
font-size: var(--text-2xl);
color: var(--app-primary);
flex: 1;
}
.help-btn {
width: 32px;
height: 32px;
border-radius: 50%;
border: 1px solid var(--color-border);
background: var(--color-surface-raised);
color: var(--color-text-muted);
font-size: var(--text-sm);
font-weight: 700;
cursor: pointer;
flex-shrink: 0;
transition: background 150ms ease, border-color 150ms ease;
display: flex;
align-items: center;
justify-content: center;
}
.help-btn:hover { background: var(--app-primary-light); border-color: var(--app-primary); }
/* ── Tabs ────────────────────────────────────────────────────────────── */
.review__tabs {
display: flex;
background: var(--color-surface-raised);
border-radius: var(--radius-lg);
border: 1px solid var(--color-border-light);
padding: 3px;
gap: 2px;
overflow-x: auto;
scrollbar-width: none;
}
.review__tabs::-webkit-scrollbar { display: none; }
.review__tab {
flex: 1;
min-width: 0;
padding: var(--space-2) var(--space-3);
border: none;
border-radius: calc(var(--radius-lg) - 3px);
background: transparent;
color: var(--color-text-muted);
font-size: var(--text-xs);
font-weight: 600;
cursor: pointer;
white-space: nowrap;
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-1);
transition: background 150ms ease, color 150ms ease;
min-height: 32px;
}
.review__tab--active {
background: var(--app-primary);
color: white;
box-shadow: var(--shadow-sm);
}
.review__tab:not(.review__tab--active):hover {
background: var(--color-surface-alt);
color: var(--color-text);
}
.tab-badge {
background: var(--color-warning);
color: white;
font-size: 0.65rem;
font-weight: 700;
border-radius: 999px;
padding: 1px 5px;
line-height: 1.4;
}
.review__tab--active .tab-badge { background: rgba(255,255,255,0.3); }
/* ── Body ────────────────────────────────────────────────────────────── */
.review__body {
display: flex;
flex-direction: column;
gap: var(--space-4);
flex: 1;
}
/* ── Loading ─────────────────────────────────────────────────────────── */
.review__loading {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-3);
padding: var(--space-12);
color: var(--color-text-muted);
font-size: var(--text-sm);
}
/* ── Empty state — falcon stoop (easter egg 9.3) ────────────────────── */
.review__empty {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-4);
padding: var(--space-16) var(--space-8);
text-align: center;
}
.empty-falcon {
font-size: 4rem;
animation: falcon-stoop 1.2s cubic-bezier(0.4, 0, 0.2, 1) forwards;
display: block;
}
@keyframes falcon-stoop {
0% { transform: translateY(-60px) rotate(-30deg); opacity: 0; }
60% { transform: translateY(6px) rotate(0deg); opacity: 1; }
80% { transform: translateY(-4px); }
100% { transform: translateY(0); opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
.empty-falcon { animation: none; }
}
.empty-title {
font-family: var(--font-display);
font-size: var(--text-xl);
color: var(--color-text);
}
.empty-desc {
font-size: var(--text-sm);
color: var(--color-text-muted);
max-width: 32ch;
}
/* ── Hint bar ────────────────────────────────────────────────────────── */
.hint-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-2) var(--space-1);
}
.hint {
font-size: var(--text-xs);
color: var(--color-text-muted);
display: flex;
align-items: center;
gap: 4px;
}
.hint-counter {
font-size: var(--text-xs);
font-weight: 600;
color: var(--color-text-muted);
font-family: var(--font-mono);
}
kbd {
display: inline-flex;
align-items: center;
padding: 1px 5px;
border-radius: 4px;
border: 1px solid var(--color-border);
background: var(--color-surface-alt);
font-size: 0.7rem;
font-family: var(--font-mono);
color: var(--color-text);
line-height: 1.5;
}
/* ── Action buttons ──────────────────────────────────────────────────── */
.review__actions {
display: flex;
gap: var(--space-3);
justify-content: center;
margin-top: var(--space-2);
}
.action-btn {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-3) var(--space-6);
border-radius: var(--radius-lg);
font-size: var(--text-sm);
font-weight: 700;
cursor: pointer;
border: 2px solid transparent;
min-height: 44px;
transition: background 150ms ease, border-color 150ms ease, transform 100ms ease;
}
.action-btn:active { transform: scale(0.96); }
.action-btn--reject {
background: rgba(192, 57, 43, 0.08);
border-color: var(--color-error);
color: var(--color-error);
}
.action-btn--reject:hover { background: rgba(192, 57, 43, 0.16); }
.action-btn--skip {
background: var(--color-surface-raised);
border-color: var(--color-border);
color: var(--color-text-muted);
}
.action-btn--skip:hover { background: var(--color-surface-alt); }
.action-btn--approve {
background: rgba(39, 174, 96, 0.08);
border-color: var(--color-success);
color: var(--color-success);
}
.action-btn--approve:hover { background: rgba(39, 174, 96, 0.16); }
.review__undo-hint {
text-align: center;
font-size: var(--text-xs);
color: var(--color-text-muted);
}
/* ── Job list (non-pending tabs) ─────────────────────────────────────── */
.job-list {
list-style: none;
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.job-list__item {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
padding: var(--space-4) var(--space-5);
background: var(--color-surface-raised);
border: 1px solid var(--color-border-light);
border-radius: var(--radius-md);
min-height: 44px;
transition: box-shadow 150ms ease;
}
.job-list__item:hover { box-shadow: var(--shadow-sm); }
.job-list__info { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 0; }
.job-list__title {
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.job-list__company {
font-size: var(--text-xs);
color: var(--color-text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.job-list__meta { display: flex; align-items: center; gap: var(--space-2); flex-shrink: 0; }
.score-pill {
padding: 2px var(--space-2);
border-radius: 999px;
font-size: var(--text-xs);
font-weight: 700;
font-family: var(--font-mono);
}
.score-pill--high { background: rgba(39, 174, 96, 0.15); color: var(--score-high); }
.score-pill--mid { background: rgba(212, 137, 26, 0.15); color: var(--score-mid); }
.score-pill--low { background: rgba(192, 57, 43, 0.15); color: var(--score-low); }
.job-list__link {
font-size: var(--text-xs);
color: var(--app-primary);
text-decoration: none;
font-weight: 600;
}
/* ── Help overlay ────────────────────────────────────────────────────── */
.help-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
backdrop-filter: blur(2px);
z-index: 400;
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-6);
}
.help-modal {
background: var(--color-surface-raised);
border-radius: var(--radius-lg);
border: 1px solid var(--color-border);
padding: var(--space-6);
width: 100%;
max-width: 360px;
position: relative;
box-shadow: var(--shadow-xl, 0 16px 48px rgba(0,0,0,0.2));
}
.help-modal__title {
font-family: var(--font-display);
font-size: var(--text-xl);
color: var(--color-text);
margin-bottom: var(--space-4);
}
.help-keys { display: flex; flex-direction: column; gap: var(--space-3); }
.help-keys__row {
display: flex;
align-items: center;
gap: var(--space-4);
font-size: var(--text-sm);
}
.help-keys__row dt { width: 6rem; flex-shrink: 0; display: flex; align-items: center; gap: 4px; }
.help-keys__row dd { color: var(--color-text-muted); }
.help-modal__close {
position: absolute;
top: var(--space-4);
right: var(--space-4);
width: 32px;
height: 32px;
border-radius: 50%;
border: 1px solid var(--color-border-light);
background: transparent;
color: var(--color-text-muted);
font-size: var(--text-base);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 150ms ease;
}
.help-modal__close:hover { background: var(--color-surface-alt); }
/* ── Toasts ──────────────────────────────────────────────────────────── */
.undo-toast {
position: fixed;
bottom: var(--space-6);
left: 50%;
transform: translateX(-50%);
background: var(--color-surface-raised);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--space-3) var(--space-4);
display: flex;
align-items: center;
gap: var(--space-4);
font-size: var(--text-sm);
color: var(--color-text);
box-shadow: var(--shadow-lg);
z-index: 300;
white-space: nowrap;
}
.undo-toast__btn {
background: var(--app-primary);
color: white;
border: none;
border-radius: var(--radius-md);
padding: var(--space-1) var(--space-3);
font-size: var(--text-xs);
font-weight: 700;
cursor: pointer;
transition: background 150ms ease;
}
.undo-toast__btn:hover { background: var(--app-primary-hover); }
.stoop-toast {
position: fixed;
bottom: calc(var(--space-6) + 56px);
right: var(--space-6);
background: var(--color-surface-raised);
border: 1px solid var(--color-border);
padding: var(--space-3) var(--space-5);
border-radius: var(--radius-md);
font-size: var(--text-sm);
color: var(--color-text-muted);
box-shadow: var(--shadow-lg);
z-index: 300;
}
/* ── Toast transitions ───────────────────────────────────────────────── */
.toast-enter-active, .toast-leave-active { transition: opacity 280ms ease, transform 280ms ease; }
.toast-enter-from, .toast-leave-to { opacity: 0; transform: translateY(8px) translateX(-50%); }
.stoop-toast.toast-enter-from,
.stoop-toast.toast-leave-to { transform: translateY(8px); }
.overlay-enter-active, .overlay-leave-active { transition: opacity 200ms ease; }
.overlay-enter-from, .overlay-leave-to { opacity: 0; }
/* ── Spinner ─────────────────────────────────────────────────────────── */
.spinner {
width: 1.2rem;
height: 1.2rem;
border: 2px solid var(--color-border);
border-top-color: var(--app-primary);
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* ── Screen reader only ──────────────────────────────────────────────── */
.sr-only {
position: absolute;
width: 1px; height: 1px;
padding: 0; margin: -1px;
overflow: hidden;
clip: rect(0,0,0,0);
white-space: nowrap;
border: 0;
}
/* ── Responsive ──────────────────────────────────────────────────────── */
@media (max-width: 767px) {
.review { padding: var(--space-4); gap: var(--space-4); }
.review__title { font-size: var(--text-xl); }
.review__tab {
font-size: 0.65rem;
padding: var(--space-2);
}
.hint-bar { display: none; } /* mobile: no room — swipe speaks for itself */
.review__actions { gap: var(--space-2); }
.action-btn { padding: var(--space-3) var(--space-4); font-size: var(--text-xs); }
.undo-toast {
left: var(--space-4);
right: var(--space-4);
transform: none;
bottom: calc(56px + env(safe-area-inset-bottom) + var(--space-4));
}
.toast-enter-from, .toast-leave-to { transform: translateY(8px); }
}
</style>

View file

@ -0,0 +1,834 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useInterviewsStore } from '../stores/interviews'
import { useSurveyStore } from '../stores/survey'
const route = useRoute()
const router = useRouter()
const interviewsStore = useInterviewsStore()
const surveyStore = useSurveyStore()
const VALID_STAGES = ['survey', 'phone_screen', 'interviewing', 'offer']
const rawId = route.params.id
const jobId = rawId ? parseInt(String(rawId), 10) : NaN
const pickerMode = !rawId || isNaN(jobId)
// UI state
let saveSuccessTimer: ReturnType<typeof setTimeout> | null = null
const activeTab = ref<'text' | 'screenshot'>('text')
const textInput = ref('')
const imageB64 = ref<string | null>(null)
const imagePreviewUrl = ref<string | null>(null)
const selectedMode = ref<'quick' | 'detailed'>('quick')
const surveyName = ref('')
const reportedScore = ref('')
const saveSuccess = ref(false)
// Computed job from store
const job = computed(() =>
interviewsStore.jobs.find(j => j.id === jobId) ?? null
)
// Jobs eligible for survey (used in picker mode)
const pickerJobs = computed(() =>
interviewsStore.jobs.filter(j => VALID_STAGES.includes(j.status))
)
const stageLabel: Record<string, string> = {
survey: 'Survey', phone_screen: 'Phone Screen',
interviewing: 'Interviewing', offer: 'Offer',
}
onMounted(async () => {
if (interviewsStore.jobs.length === 0) {
await interviewsStore.fetchAll()
}
if (pickerMode) return
if (!job.value || !VALID_STAGES.includes(job.value.status)) {
router.replace('/interviews')
return
}
await surveyStore.fetchFor(jobId)
})
onUnmounted(() => {
surveyStore.clear()
if (saveSuccessTimer) clearTimeout(saveSuccessTimer)
})
// Screenshot handling
function handlePaste(e: ClipboardEvent) {
if (!surveyStore.visionAvailable) return
const items = e.clipboardData?.items
if (!items) return
for (const item of items) {
if (item.type.startsWith('image/')) {
const file = item.getAsFile()
if (file) loadImageFile(file)
break
}
}
}
function handleDrop(e: DragEvent) {
e.preventDefault()
if (!surveyStore.visionAvailable) return
const file = e.dataTransfer?.files[0]
if (file && file.type.startsWith('image/')) loadImageFile(file)
}
function handleFileUpload(e: Event) {
const file = (e.target as HTMLInputElement).files?.[0]
if (file) loadImageFile(file)
}
function loadImageFile(file: File) {
const reader = new FileReader()
reader.onload = (ev) => {
const result = ev.target?.result as string
imagePreviewUrl.value = result
imageB64.value = result.split(',')[1] // strip "data:image/...;base64,"
}
reader.readAsDataURL(file)
}
function clearImage() {
imageB64.value = null
imagePreviewUrl.value = null
}
// Analysis
const canAnalyze = computed(() =>
activeTab.value === 'text' ? textInput.value.trim().length > 0 : imageB64.value !== null
)
async function runAnalyze() {
const payload: { text?: string; image_b64?: string; mode: 'quick' | 'detailed' } = {
mode: selectedMode.value,
}
if (activeTab.value === 'screenshot' && imageB64.value) {
payload.image_b64 = imageB64.value
} else {
payload.text = textInput.value
}
await surveyStore.analyze(jobId, payload)
}
// Save
async function saveToJob() {
await surveyStore.saveResponse(jobId, {
surveyName: surveyName.value,
reportedScore: reportedScore.value,
image_b64: activeTab.value === 'screenshot' ? imageB64.value ?? undefined : undefined,
})
if (!surveyStore.error) {
saveSuccess.value = true
surveyName.value = ''
reportedScore.value = ''
if (saveSuccessTimer) clearTimeout(saveSuccessTimer)
saveSuccessTimer = setTimeout(() => { saveSuccess.value = false }, 3000)
}
}
// History accordion
const historyOpen = ref(false)
function formatDate(iso: string | null): string {
if (!iso) return ''
return new Date(iso).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
}
const expandedHistory = ref<Set<number>>(new Set())
function toggleHistoryEntry(id: number) {
const next = new Set(expandedHistory.value)
if (next.has(id)) next.delete(id)
else next.add(id)
expandedHistory.value = next
}
</script>
<template>
<div class="survey-layout">
<!-- Job picker (no id in route) -->
<div v-if="pickerMode" class="survey-content picker-mode">
<h2 class="picker-heading">Survey Assistant</h2>
<p class="picker-sub">Select a job to open the survey assistant.</p>
<div v-if="pickerJobs.length === 0" class="picker-empty">
No jobs in an active interview stage. Move a job to Survey, Phone Screen, Interviewing, or Offer first.
</div>
<ul v-else class="picker-list" role="list">
<li
v-for="j in pickerJobs"
:key="j.id"
class="picker-item"
@click="router.push('/survey/' + j.id)"
>
<div class="picker-item__main">
<span class="picker-item__company">{{ j.company }}</span>
<span class="picker-item__title">{{ j.title }}</span>
</div>
<span class="stage-badge">{{ stageLabel[j.status] ?? j.status }}</span>
</li>
</ul>
</div>
<!-- Survey assistant (id present) -->
<template v-else>
<!-- Sticky context bar -->
<div class="context-bar" v-if="job">
<span class="context-company">{{ job.company }}</span>
<span class="context-sep">·</span>
<span class="context-title">{{ job.title }}</span>
<span class="stage-badge">{{ stageLabel[job.status] ?? job.status }}</span>
</div>
<!-- Load/history error banner -->
<div class="error-banner" v-if="surveyStore.error && !surveyStore.analysis">
{{ surveyStore.error }}
</div>
<div class="survey-content">
<!-- Input card -->
<div class="card">
<div class="tab-bar">
<button
class="tab-btn"
:class="{ active: activeTab === 'text' }"
@click="activeTab = 'text'"
>📝 Paste Text</button>
<button
class="tab-btn"
:class="{ active: activeTab === 'screenshot', disabled: !surveyStore.visionAvailable }"
:aria-disabled="!surveyStore.visionAvailable"
:title="!surveyStore.visionAvailable ? 'Vision service not running — start it with: bash scripts/manage-vision.sh start' : undefined"
@click="surveyStore.visionAvailable && (activeTab = 'screenshot')"
>📷 Screenshot</button>
</div>
<!-- Text tab -->
<div v-if="activeTab === 'text'" class="tab-panel">
<textarea
v-model="textInput"
class="survey-textarea"
placeholder="Paste your survey questions here, e.g.:&#10;Q1: Which best describes your work style?&#10;A. I prefer working alone&#10;B. I thrive in teams&#10;C. Depends on the project"
/>
</div>
<!-- Screenshot tab -->
<div
v-else
class="screenshot-zone"
role="region"
aria-label="Screenshot upload area — paste, drag, or choose file"
@paste="handlePaste"
@dragover.prevent
@drop="handleDrop"
tabindex="0"
>
<div v-if="imagePreviewUrl" class="image-preview">
<img :src="imagePreviewUrl" alt="Survey screenshot preview" />
<button class="remove-btn" @click="clearImage"> Remove</button>
</div>
<div v-else class="drop-hint">
<p>Paste (Ctrl+V), drag &amp; drop, or upload a screenshot</p>
<label class="upload-label">
Choose file
<input type="file" accept="image/*" class="file-input" @change="handleFileUpload" />
</label>
</div>
</div>
</div>
<!-- Mode selection -->
<div class="mode-cards">
<button
class="mode-card"
:class="{ selected: selectedMode === 'quick' }"
@click="selectedMode = 'quick'"
>
<span class="mode-icon"></span>
<span class="mode-name">Quick</span>
<span class="mode-desc">Best answer + one-liner per question</span>
</button>
<button
class="mode-card"
:class="{ selected: selectedMode === 'detailed' }"
@click="selectedMode = 'detailed'"
>
<span class="mode-icon">📋</span>
<span class="mode-name">Detailed</span>
<span class="mode-desc">Option-by-option breakdown with reasoning</span>
</button>
</div>
<!-- Analyze button -->
<button
class="analyze-btn"
:disabled="!canAnalyze || surveyStore.loading"
@click="runAnalyze"
>
<span v-if="surveyStore.loading" class="spinner" aria-hidden="true"></span>
{{ surveyStore.loading ? 'Analyzing…' : '🔍 Analyze' }}
</button>
<!-- Analyze error -->
<div class="error-inline" v-if="surveyStore.error && !surveyStore.analysis">
{{ surveyStore.error }}
</div>
<!-- Results card -->
<div class="card results-card" v-if="surveyStore.analysis">
<div class="results-output">{{ surveyStore.analysis.output }}</div>
<div class="save-form">
<input
v-model="surveyName"
class="save-input"
type="text"
placeholder="Survey name (e.g. Culture Fit Round 1)"
/>
<input
v-model="reportedScore"
class="save-input"
type="text"
placeholder="Reported score (e.g. 82% or 4.2/5)"
/>
<button
class="save-btn"
:disabled="surveyStore.saving"
@click="saveToJob"
>
<span v-if="surveyStore.saving" class="spinner" aria-hidden="true"></span>
💾 Save to job
</button>
<div v-if="saveSuccess" class="save-success">Saved!</div>
<div v-if="surveyStore.error" class="error-inline">{{ surveyStore.error }}</div>
</div>
</div>
<!-- History accordion -->
<details class="history-accordion" :open="historyOpen" @toggle="historyOpen = ($event.target as HTMLDetailsElement).open">
<summary class="history-summary">
Survey history ({{ surveyStore.history.length }} response{{ surveyStore.history.length === 1 ? '' : 's' }})
</summary>
<div v-if="surveyStore.history.length === 0" class="history-empty">No responses saved yet.</div>
<div v-else class="history-list">
<div v-for="resp in surveyStore.history" :key="resp.id" class="history-entry">
<button class="history-toggle" @click="toggleHistoryEntry(resp.id)">
<span class="history-name">{{ resp.survey_name ?? 'Survey response' }}</span>
<span class="history-meta">{{ formatDate(resp.received_at) }}{{ resp.reported_score ? ` · ${resp.reported_score}` : '' }}</span>
<span class="history-chevron">{{ expandedHistory.has(resp.id) ? '▲' : '▼' }}</span>
</button>
<div v-if="expandedHistory.has(resp.id)" class="history-detail">
<div class="history-tags">
<span class="tag">{{ resp.mode }}</span>
<span class="tag">{{ resp.source }}</span>
<span v-if="resp.received_at" class="tag">{{ resp.received_at }}</span>
</div>
<div class="history-output">{{ resp.llm_output }}</div>
</div>
</div>
</div>
</details>
</div>
</template><!-- end v-else (id present) -->
</div>
</template>
<style scoped>
.survey-layout {
display: flex;
flex-direction: column;
min-height: 100vh;
}
.context-bar {
position: sticky;
top: 0;
z-index: 10;
display: flex;
align-items: center;
gap: var(--space-2);
padding: 0 var(--space-6);
height: 40px;
background: var(--color-surface-raised, #f8f9fa);
border-bottom: 1px solid var(--color-border, #e2e8f0);
font-size: 0.875rem;
}
.context-company {
font-weight: 600;
color: var(--color-text, #1a202c);
}
.context-sep {
color: var(--color-text-muted, #718096);
}
.context-title {
color: var(--color-text-muted, #718096);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.stage-badge {
margin-left: auto;
padding: 2px 8px;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 600;
background: var(--color-accent-subtle, #ebf4ff);
color: var(--color-accent, #3182ce);
}
.survey-content {
max-width: 760px;
margin: 0 auto;
padding: var(--space-6);
width: 100%;
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.card {
background: var(--color-surface, #fff);
border: 1px solid var(--color-border, #e2e8f0);
border-radius: var(--radius-md, 8px);
overflow: hidden;
}
.tab-bar {
display: flex;
border-bottom: 1px solid var(--color-border, #e2e8f0);
}
.tab-btn {
flex: 1;
padding: var(--space-3) var(--space-4);
background: none;
border: none;
cursor: pointer;
font-size: 0.875rem;
color: var(--color-text-muted, #718096);
transition: color 0.15s, background 0.15s;
}
.tab-btn.active {
color: var(--color-accent, #3182ce);
background: var(--color-accent-subtle, #ebf4ff);
font-weight: 600;
}
.tab-btn.disabled {
opacity: 0.45;
cursor: not-allowed;
}
.tab-panel {
padding: var(--space-4);
}
.survey-textarea {
width: 100%;
min-height: 200px;
padding: var(--space-3);
font-family: inherit;
font-size: 0.875rem;
border: 1px solid var(--color-border, #e2e8f0);
border-radius: var(--radius-sm, 4px);
resize: vertical;
background: var(--color-bg, #fff);
color: var(--color-text, #1a202c);
box-sizing: border-box;
}
.screenshot-zone {
min-height: 160px;
padding: var(--space-6);
display: flex;
align-items: center;
justify-content: center;
border: 2px dashed var(--color-border, #e2e8f0);
margin: var(--space-4);
border-radius: var(--radius-md, 8px);
outline: none;
}
.screenshot-zone:focus {
border-color: var(--color-accent, #3182ce);
}
.drop-hint {
text-align: center;
color: var(--color-text-muted, #718096);
}
.upload-label {
display: inline-block;
margin-top: var(--space-2);
padding: var(--space-2) var(--space-4);
border: 1px solid var(--color-border, #e2e8f0);
border-radius: var(--radius-sm, 4px);
cursor: pointer;
font-size: 0.875rem;
background: var(--color-surface, #fff);
}
.file-input {
display: none;
}
.image-preview {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-2);
width: 100%;
}
.image-preview img {
max-width: 100%;
max-height: 300px;
border-radius: var(--radius-sm, 4px);
}
.remove-btn {
font-size: 0.8rem;
color: var(--color-text-muted, #718096);
background: none;
border: 1px solid var(--color-border, #e2e8f0);
border-radius: var(--radius-sm, 4px);
padding: 2px 8px;
cursor: pointer;
}
.mode-cards {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.mode-card {
display: grid;
grid-template-columns: 2rem 1fr;
grid-template-rows: auto auto;
align-items: center;
gap: 0 var(--space-2);
padding: var(--space-4);
background: var(--color-surface, #fff);
border: 2px solid var(--color-border, #e2e8f0);
border-radius: var(--radius-md, 8px);
cursor: pointer;
text-align: left;
transition: border-color 0.15s, background 0.15s;
}
.mode-card.selected {
border-color: var(--color-accent, #3182ce);
background: var(--color-accent-subtle, #ebf4ff);
}
.mode-icon {
grid-row: 1 / 3;
font-size: 1.25rem;
line-height: 1;
align-self: center;
}
.mode-name {
font-weight: 600;
color: var(--color-text, #1a202c);
line-height: 1.3;
}
.mode-desc {
font-size: 0.8rem;
color: var(--color-text-muted, #718096);
}
.analyze-btn {
width: 100%;
padding: var(--space-3) var(--space-4);
background: var(--color-accent, #3182ce);
color: #fff;
border: none;
border-radius: var(--radius-md, 8px);
font-size: 1rem;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
transition: opacity 0.15s;
}
.analyze-btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.results-card {
padding: var(--space-4);
}
.results-output {
white-space: pre-wrap;
font-size: 0.9rem;
line-height: 1.6;
color: var(--color-text, #1a202c);
margin-bottom: var(--space-4);
}
.save-form {
display: flex;
flex-direction: column;
gap: var(--space-2);
padding-top: var(--space-4);
border-top: 1px solid var(--color-border, #e2e8f0);
}
.save-input {
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-border, #e2e8f0);
border-radius: var(--radius-sm, 4px);
font-size: 0.875rem;
background: var(--color-bg, #fff);
color: var(--color-text, #1a202c);
box-sizing: border-box;
}
.save-btn {
align-self: flex-start;
padding: var(--space-2) var(--space-4);
background: var(--color-surface-raised, #f8f9fa);
border: 1px solid var(--color-border, #e2e8f0);
border-radius: var(--radius-md, 8px);
cursor: pointer;
font-weight: 600;
display: flex;
align-items: center;
gap: var(--space-2);
transition: background 0.15s;
}
.save-btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.save-success {
color: var(--color-success, #38a169);
font-size: 0.875rem;
font-weight: 600;
}
.history-accordion {
border: 1px solid var(--color-border, #e2e8f0);
border-radius: var(--radius-md, 8px);
background: var(--color-surface, #fff);
}
.history-summary {
padding: var(--space-3) var(--space-4);
cursor: pointer;
font-size: 0.875rem;
color: var(--color-text-muted, #718096);
font-weight: 500;
list-style: none;
}
.history-summary::-webkit-details-marker { display: none; }
.history-empty {
padding: var(--space-4);
color: var(--color-text-muted, #718096);
font-size: 0.875rem;
}
.history-list {
display: flex;
flex-direction: column;
gap: 1px;
background: var(--color-border, #e2e8f0);
}
.history-entry {
background: var(--color-surface, #fff);
}
.history-toggle {
width: 100%;
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-3) var(--space-4);
background: none;
border: none;
cursor: pointer;
text-align: left;
font-size: 0.875rem;
}
.history-name {
font-weight: 500;
color: var(--color-text, #1a202c);
}
.history-meta {
color: var(--color-text-muted, #718096);
font-size: 0.8rem;
margin-left: auto;
}
.history-chevron {
font-size: 0.7rem;
color: var(--color-text-muted, #718096);
}
.history-detail {
padding: var(--space-3) var(--space-4) var(--space-4);
border-top: 1px solid var(--color-border, #e2e8f0);
}
.history-tags {
display: flex;
flex-wrap: wrap;
gap: var(--space-1);
margin-bottom: var(--space-2);
}
.tag {
padding: 1px 6px;
background: var(--color-accent-subtle, #ebf4ff);
color: var(--color-accent, #3182ce);
border-radius: 4px;
font-size: 0.75rem;
}
.history-output {
white-space: pre-wrap;
font-size: 0.875rem;
line-height: 1.6;
color: var(--color-text, #1a202c);
}
.error-banner {
background: var(--color-error-subtle, #fff5f5);
border-bottom: 1px solid var(--color-error, #fc8181);
padding: var(--space-2) var(--space-6);
font-size: 0.875rem;
color: var(--color-error-text, #c53030);
}
.error-inline {
font-size: 0.875rem;
color: var(--color-error-text, #c53030);
padding: var(--space-1) 0;
}
.spinner {
display: inline-block;
width: 1em;
height: 1em;
border: 2px solid rgba(255,255,255,0.4);
border-top-color: #fff;
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
.analyze-btn .spinner {
border-color: rgba(255,255,255,0.4);
border-top-color: #fff;
}
.save-btn .spinner {
border-color: rgba(0,0,0,0.15);
border-top-color: var(--color-accent, #3182ce);
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* ── Picker mode ── */
.picker-mode {
padding-top: var(--space-8, 2rem);
}
.picker-heading {
font-size: 1.25rem;
font-weight: 700;
color: var(--color-text, #1a202c);
margin: 0 0 var(--space-1) 0;
}
.picker-sub {
font-size: 0.875rem;
color: var(--color-text-muted, #718096);
margin: 0 0 var(--space-4) 0;
}
.picker-empty {
font-size: 0.875rem;
color: var(--color-text-muted, #718096);
padding: var(--space-4);
border: 1px dashed var(--color-border, #e2e8f0);
border-radius: var(--radius-md, 8px);
text-align: center;
}
.picker-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.picker-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
padding: var(--space-3) var(--space-4);
background: var(--color-surface, #fff);
border: 1px solid var(--color-border, #e2e8f0);
border-radius: var(--radius-md, 8px);
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
}
.picker-item:hover {
border-color: var(--color-accent, #3182ce);
background: var(--color-accent-subtle, #ebf4ff);
}
.picker-item__main {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.picker-item__company {
font-weight: 600;
font-size: 0.9rem;
color: var(--color-text, #1a202c);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.picker-item__title {
font-size: 0.8rem;
color: var(--color-text-muted, #718096);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style>

View file

@ -0,0 +1,81 @@
<script setup lang="ts">
import { ref } from 'vue'
import { storeToRefs } from 'pinia'
import { useDataStore } from '../../stores/settings/data'
const store = useDataStore()
const { backupPath, backupFileCount, backupSizeBytes, creatingBackup, backupError } = storeToRefs(store)
const includeDb = ref(false)
const showRestoreConfirm = ref(false)
const restoreFile = ref<File | null>(null)
function formatBytes(b: number) {
if (b < 1024) return `${b} B`
if (b < 1024 * 1024) return `${(b / 1024).toFixed(1)} KB`
return `${(b / 1024 / 1024).toFixed(1)} MB`
}
</script>
<template>
<div class="data-view">
<h2>Data &amp; Backup</h2>
<!-- Backup -->
<section class="form-section">
<h3>Create Backup</h3>
<p class="section-note">Exports your config files (and optionally the job database) as a zip archive.</p>
<label class="checkbox-row">
<input type="checkbox" v-model="includeDb" /> Include job database (staging.db)
</label>
<div class="form-actions">
<button @click="store.createBackup(includeDb)" :disabled="creatingBackup" class="btn-primary">
{{ creatingBackup ? 'Creating…' : 'Create Backup' }}
</button>
</div>
<p v-if="backupError" class="error-msg">{{ backupError }}</p>
<div v-if="backupPath" class="backup-result">
<span>{{ backupFileCount }} files · {{ formatBytes(backupSizeBytes) }}</span>
<span class="backup-path">{{ backupPath }}</span>
</div>
</section>
<!-- Restore -->
<section class="form-section">
<h3>Restore from Backup</h3>
<p class="section-note">Upload a backup zip to restore your configuration. Existing files will be overwritten.</p>
<input
type="file"
accept=".zip"
@change="restoreFile = ($event.target as HTMLInputElement).files?.[0] ?? null"
class="file-input"
/>
<div class="form-actions">
<button
@click="showRestoreConfirm = true"
:disabled="!restoreFile || store.restoring"
class="btn-warning"
>
{{ store.restoring ? 'Restoring…' : 'Restore' }}
</button>
</div>
<div v-if="store.restoreResult" class="restore-result">
<p>Restored {{ store.restoreResult.restored.length }} files.</p>
<p v-if="store.restoreResult.skipped.length">Skipped: {{ store.restoreResult.skipped.join(', ') }}</p>
</div>
<p v-if="store.restoreError" class="error-msg">{{ store.restoreError }}</p>
<Teleport to="body">
<div v-if="showRestoreConfirm" class="modal-overlay" @click.self="showRestoreConfirm = false">
<div class="modal-card" role="dialog">
<h3>Restore Backup?</h3>
<p>This will overwrite your current configuration. This cannot be undone.</p>
<div class="modal-actions">
<button @click="showRestoreConfirm = false" class="btn-danger">Restore</button>
<button @click="showRestoreConfirm = false" class="btn-secondary">Cancel</button>
</div>
</div>
</div>
</Teleport>
</section>
</div>
</template>

View file

@ -0,0 +1,130 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useApiFetch } from '../../composables/useApi'
const devTierOverride = ref<string | null>(null)
const hfTokenInput = ref('')
const hfTokenSet = ref(false)
const hfTestResult = ref<{ok: boolean; error?: string; username?: string} | null>(null)
const saving = ref(false)
const showWizardResetConfirm = ref(false)
const exportResult = ref<{count: number} | null>(null)
const TIERS = ['free', 'paid', 'premium', 'ultra']
onMounted(async () => {
const { data } = await useApiFetch<{dev_tier_override: string | null; hf_token_set: boolean}>('/api/settings/developer')
if (data) {
devTierOverride.value = data.dev_tier_override ?? null
hfTokenSet.value = data.hf_token_set
}
})
async function saveTierOverride() {
saving.value = true
await useApiFetch('/api/settings/developer/tier', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tier: devTierOverride.value }),
})
saving.value = false
// Reload page so tier gate updates
window.location.reload()
}
async function saveHfToken() {
if (!hfTokenInput.value) return
await useApiFetch('/api/settings/developer/hf-token', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: hfTokenInput.value }),
})
hfTokenSet.value = true
hfTokenInput.value = ''
}
async function testHfToken() {
const { data } = await useApiFetch<{ok: boolean; error?: string; username?: string}>('/api/settings/developer/hf-token/test', { method: 'POST' })
hfTestResult.value = data
}
async function resetWizard() {
await useApiFetch('/api/settings/developer/wizard-reset', { method: 'POST' })
showWizardResetConfirm.value = false
}
async function exportClassifier() {
const { data } = await useApiFetch<{count: number}>('/api/settings/developer/export-classifier', { method: 'POST' })
if (data) exportResult.value = { count: data.count }
}
</script>
<template>
<div class="developer-view">
<h2>Developer</h2>
<!-- Tier override -->
<section class="form-section">
<h3>Tier Override</h3>
<p class="section-note">Override the effective tier for UI testing. Does not affect licensing.</p>
<div class="field-row">
<label>Override Tier</label>
<select v-model="devTierOverride">
<option :value="null"> none (use real tier) </option>
<option v-for="t in TIERS" :key="t" :value="t">{{ t }}</option>
</select>
</div>
<div class="form-actions">
<button @click="saveTierOverride" :disabled="saving" class="btn-primary">Apply Override</button>
</div>
</section>
<!-- HF Token -->
<section class="form-section">
<h3>HuggingFace Token</h3>
<p class="section-note">Required for model downloads and fine-tune uploads.</p>
<p v-if="hfTokenSet" class="token-set">&#x2713; Token stored securely</p>
<div class="field-row">
<label>Token</label>
<input v-model="hfTokenInput" type="password" placeholder="hf_…" autocomplete="new-password" />
</div>
<div class="form-actions">
<button @click="saveHfToken" :disabled="!hfTokenInput" class="btn-primary">Save Token</button>
<button @click="testHfToken" class="btn-secondary">Test</button>
</div>
<p v-if="hfTestResult" :class="hfTestResult.ok ? 'status-ok' : 'error-msg'">
{{ hfTestResult.ok ? `✓ Logged in as ${hfTestResult.username}` : '✗ ' + hfTestResult.error }}
</p>
</section>
<!-- Wizard reset -->
<section class="form-section">
<h3>Wizard</h3>
<div class="form-actions">
<button @click="showWizardResetConfirm = true" class="btn-warning">Reset Setup Wizard</button>
</div>
<Teleport to="body">
<div v-if="showWizardResetConfirm" class="modal-overlay" @click.self="showWizardResetConfirm = false">
<div class="modal-card" role="dialog">
<h3>Reset Setup Wizard?</h3>
<p>The first-run setup wizard will be shown again on next launch.</p>
<div class="modal-actions">
<button @click="resetWizard" class="btn-warning">Reset</button>
<button @click="showWizardResetConfirm = false" class="btn-secondary">Cancel</button>
</div>
</div>
</div>
</Teleport>
</section>
<!-- Export classifier data -->
<section class="form-section">
<h3>Export Training Data</h3>
<p class="section-note">Export labeled emails as JSONL for classifier training.</p>
<div class="form-actions">
<button @click="exportClassifier" class="btn-secondary">Export to data/email_score.jsonl</button>
</div>
<p v-if="exportResult" class="status-ok">Exported {{ exportResult.count }} labeled emails.</p>
</section>
</div>
</template>

View file

@ -0,0 +1,163 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { storeToRefs } from 'pinia'
import { useFineTuneStore } from '../../stores/settings/fineTune'
import { useAppConfigStore } from '../../stores/appConfig'
const store = useFineTuneStore()
const config = useAppConfigStore()
const { step, inFlightJob, jobStatus, pairsCount, quotaRemaining } = storeToRefs(store)
const fileInput = ref<HTMLInputElement | null>(null)
const selectedFiles = ref<File[]>([])
const uploadResult = ref<{ file_count: number } | null>(null)
const extractError = ref<string | null>(null)
const modelReady = ref<boolean | null>(null)
async function handleUpload() {
if (!selectedFiles.value.length) return
store.uploading = true
const form = new FormData()
for (const f of selectedFiles.value) form.append('files', f)
try {
const res = await fetch('/api/settings/fine-tune/upload', { method: 'POST', body: form })
uploadResult.value = await res.json()
store.step = 2
} catch {
extractError.value = 'Upload failed'
} finally {
store.uploading = false
}
}
async function handleExtract() {
extractError.value = null
const res = await fetch('/api/settings/fine-tune/extract', { method: 'POST' })
if (!res.ok) { extractError.value = 'Extraction failed'; return }
store.step = 3
}
async function checkLocalModel() {
const res = await fetch('/api/settings/fine-tune/local-status')
const data = await res.json()
modelReady.value = data.model_ready
}
onMounted(async () => {
store.startPolling()
if (store.step === 3 && !config.isCloud) await checkLocalModel()
})
onUnmounted(() => { store.stopPolling(); store.resetStep() })
</script>
<template>
<div class="fine-tune-view">
<h2>Fine-Tune Model</h2>
<!-- Wizard steps indicator -->
<div class="wizard-steps">
<span :class="['step', step >= 1 ? 'active' : '']">1. Upload</span>
<span class="step-divider"></span>
<span :class="['step', step >= 2 ? 'active' : '']">2. Extract</span>
<span class="step-divider"></span>
<span :class="['step', step >= 3 ? 'active' : '']">3. Train</span>
</div>
<!-- Step 1: Upload -->
<section v-if="step === 1" class="form-section">
<h3>Upload Cover Letters</h3>
<p class="section-note">Upload .md or .txt cover letter files to build your training dataset.</p>
<input
ref="fileInput"
type="file"
accept=".md,.txt"
multiple
@change="selectedFiles = Array.from(($event.target as HTMLInputElement).files ?? [])"
class="file-input"
/>
<div class="form-actions">
<button
@click="handleUpload"
:disabled="!selectedFiles.length || store.uploading"
class="btn-primary"
>
{{ store.uploading ? 'Uploading…' : `Upload ${selectedFiles.length} file(s)` }}
</button>
</div>
</section>
<!-- Step 2: Extract pairs -->
<section v-else-if="step === 2" class="form-section">
<h3>Extract Training Pairs</h3>
<p v-if="uploadResult">{{ uploadResult.file_count }} file(s) uploaded.</p>
<p class="section-note">Extract job description + cover letter pairs for training.</p>
<p v-if="pairsCount > 0" class="pairs-count">{{ pairsCount }} pairs extracted so far.</p>
<p v-if="extractError" class="error-msg">{{ extractError }}</p>
<div class="form-actions">
<button @click="handleExtract" :disabled="inFlightJob" class="btn-primary">
{{ inFlightJob ? 'Extracting…' : 'Extract Pairs' }}
</button>
<button @click="store.step = 3" class="btn-secondary">Skip Train</button>
</div>
</section>
<!-- Step 3: Train -->
<section v-else class="form-section">
<h3>Train Model</h3>
<p class="pairs-count">{{ pairsCount }} training pairs available.</p>
<!-- Job status banner (if in-flight) -->
<div v-if="inFlightJob" class="status-banner status-running">
Job {{ jobStatus }} polling every 2s
</div>
<div v-else-if="jobStatus === 'completed'" class="status-banner status-ok">
Training complete.
</div>
<div v-else-if="jobStatus === 'failed'" class="status-banner status-fail">
Training failed. Check logs.
</div>
<!-- Self-hosted path -->
<template v-if="!config.isCloud">
<p class="section-note">Run locally with Unsloth + Ollama:</p>
<pre class="code-block">make finetune</pre>
<div v-if="modelReady === null" class="form-actions">
<button @click="checkLocalModel" class="btn-secondary">Check Model Status</button>
</div>
<p v-else-if="modelReady" class="status-ok"> alex-cover-writer model is ready in Ollama.</p>
<p v-else class="status-fail">Model not yet registered. Run <code>make finetune</code> first.</p>
</template>
<!-- Cloud path -->
<template v-else>
<p v-if="quotaRemaining !== null" class="section-note">
Cloud quota remaining: {{ quotaRemaining }} jobs
</p>
<div class="form-actions">
<button
@click="store.submitJob()"
:disabled="inFlightJob || pairsCount === 0"
class="btn-primary"
>
{{ inFlightJob ? 'Job queued…' : 'Submit Training Job' }}
</button>
</div>
</template>
</section>
</div>
</template>
<style scoped>
.fine-tune-view { max-width: 640px; }
.wizard-steps { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 1.5rem; font-size: 0.9rem; }
.step { padding: 0.25rem 0.75rem; border-radius: 99px; background: var(--color-surface-2, #eee); color: var(--color-text-muted, #888); }
.step.active { background: var(--color-accent, #3b82f6); color: #fff; }
.step-divider { color: var(--color-text-muted, #888); }
.file-input { display: block; margin: 0.75rem 0; }
.pairs-count { font-weight: 600; margin-bottom: 0.5rem; }
.code-block { background: var(--color-surface-2, #f5f5f5); padding: 0.75rem 1rem; border-radius: 6px; font-family: monospace; margin: 0.75rem 0; }
.status-banner { padding: 0.6rem 1rem; border-radius: 6px; margin-bottom: 1rem; font-size: 0.9rem; }
.status-running { background: var(--color-warning-bg, #fef3c7); color: var(--color-warning-fg, #92400e); }
.status-ok { color: var(--color-success, #16a34a); }
.status-fail { color: var(--color-error, #dc2626); }
</style>

View file

@ -0,0 +1,18 @@
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { useAppConfigStore } from '../../stores/appConfig'
const { tier } = storeToRefs(useAppConfigStore())
</script>
<template>
<div class="form-section">
<h2>Plan</h2>
<div class="license-info">
<span class="tier-badge">{{ tier?.toUpperCase() ?? 'FREE' }}</span>
</div>
<p class="section-note">
Manage your subscription at <a href="https://circuitforge.tech/account" target="_blank">circuitforge.tech/account</a>
</p>
</div>
</template>

View file

@ -0,0 +1,57 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { storeToRefs } from 'pinia'
import { useLicenseStore } from '../../stores/settings/license'
const store = useLicenseStore()
const { tier, licenseKey, active, gracePeriodEnds, activating, activateError } = storeToRefs(store)
const keyInput = ref('')
const showDeactivateConfirm = ref(false)
onMounted(() => store.loadLicense())
</script>
<template>
<div class="form-section">
<h2>License</h2>
<!-- Active license -->
<template v-if="active">
<div class="license-info">
<span :class="`tier-badge tier-${tier}`">{{ tier.toUpperCase() }}</span>
<span v-if="licenseKey" class="license-key">{{ licenseKey }}</span>
<span v-if="gracePeriodEnds" class="grace-notice">Grace period ends: {{ gracePeriodEnds }}</span>
</div>
<div class="form-actions">
<button @click="showDeactivateConfirm = true" class="btn-danger">Deactivate</button>
</div>
<Teleport to="body">
<div v-if="showDeactivateConfirm" class="modal-overlay" @click.self="showDeactivateConfirm = false">
<div class="modal-card" role="dialog">
<h3>Deactivate License?</h3>
<p>You will lose access to paid features. You can reactivate later with the same key.</p>
<div class="modal-actions">
<button @click="store.deactivate(); showDeactivateConfirm = false" class="btn-danger">Deactivate</button>
<button @click="showDeactivateConfirm = false" class="btn-secondary">Cancel</button>
</div>
</div>
</div>
</Teleport>
</template>
<!-- No active license -->
<template v-else>
<p class="section-note">Enter your license key to unlock paid features.</p>
<div class="field-row">
<label>License Key</label>
<input v-model="keyInput" placeholder="CFG-PRNG-XXXX-XXXX-XXXX" class="monospace" />
</div>
<p v-if="activateError" class="error-msg">{{ activateError }}</p>
<div class="form-actions">
<button @click="store.activate(keyInput)" :disabled="!keyInput || activating" class="btn-primary">
{{ activating ? 'Activating…' : 'Activate' }}
</button>
</div>
</template>
</div>
</template>

View file

@ -0,0 +1,16 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useAppConfigStore } from '../../stores/appConfig'
import LicenseSelfHosted from './LicenseSelfHosted.vue'
import LicenseCloud from './LicenseCloud.vue'
const config = useAppConfigStore()
const isCloud = computed(() => config.isCloud)
</script>
<template>
<div class="license-view">
<LicenseCloud v-if="isCloud" />
<LicenseSelfHosted v-else />
</div>
</template>

View file

@ -0,0 +1,577 @@
<template>
<div class="my-profile">
<header class="page-header">
<h2>My Profile</h2>
<p class="subtitle">Your identity and preferences used for cover letters, research, and interview prep.</p>
</header>
<div v-if="store.loading" class="loading-state">Loading profile</div>
<template v-else>
<div v-if="loadError" class="load-error-banner" role="alert">
<strong>Error loading profile:</strong> {{ loadError }}
</div>
<!-- Identity -->
<section class="form-section">
<h3 class="section-title">Identity</h3>
<div class="field-row">
<label class="field-label" for="profile-name">Full name</label>
<input id="profile-name" v-model="store.name" type="text" class="text-input" placeholder="Your Name" />
</div>
<div class="field-row">
<label class="field-label" for="profile-email">Email</label>
<input id="profile-email" v-model="store.email" type="email" class="text-input" placeholder="you@example.com" />
</div>
<div class="field-row">
<label class="field-label" for="profile-phone">Phone</label>
<input id="profile-phone" v-model="store.phone" type="tel" class="text-input" placeholder="555-000-0000" />
</div>
<div class="field-row">
<label class="field-label" for="profile-linkedin">LinkedIn URL</label>
<input id="profile-linkedin" v-model="store.linkedin_url" type="url" class="text-input" placeholder="linkedin.com/in/yourprofile" />
</div>
<div class="field-row field-row--stacked">
<label class="field-label" for="profile-summary">Career summary</label>
<textarea
id="profile-summary"
v-model="store.career_summary"
class="text-area"
rows="5"
placeholder="23 sentences summarising your experience and focus."
/>
<button
v-if="config.tier !== 'free'"
class="btn-generate"
type="button"
@click="generateSummary"
:disabled="generatingSummary"
>{{ generatingSummary ? 'Generating…' : 'Generate ✦' }}</button>
</div>
<div class="field-row field-row--stacked">
<label class="field-label" for="profile-voice">Candidate voice</label>
<textarea
id="profile-voice"
v-model="store.candidate_voice"
class="text-area"
rows="3"
placeholder="How you write and communicate — used to shape cover letter voice."
/>
</div>
<div class="field-row">
<label class="field-label" for="profile-inference">Inference profile</label>
<select id="profile-inference" v-model="store.inference_profile" class="select-input">
<option value="remote">Remote</option>
<option value="cpu">CPU</option>
<option value="single-gpu">Single GPU</option>
<option value="dual-gpu">Dual GPU</option>
</select>
</div>
<div class="save-row">
<button class="btn-save" type="button" @click="store.save()" :disabled="store.saving">
{{ store.saving ? 'Saving…' : 'Save Identity' }}
</button>
<p v-if="store.saveError" class="error-msg">{{ store.saveError }}</p>
</div>
</section>
<!-- Mission & Values -->
<section class="form-section">
<h3 class="section-title">Mission &amp; Values</h3>
<p class="section-desc">
Industries you care about. When a job matches, the cover letter includes your personal alignment note.
</p>
<div
v-for="(pref, idx) in store.mission_preferences"
:key="pref.id"
class="mission-row"
>
<input
v-model="pref.industry"
type="text"
class="text-input mission-industry"
placeholder="Industry (e.g. music)"
/>
<input
v-model="pref.note"
type="text"
class="text-input mission-note"
placeholder="Your personal note (optional)"
/>
<button class="btn-remove" type="button" @click="removeMission(idx)" aria-label="Remove">×</button>
</div>
<div class="mission-actions">
<button class="btn-secondary" type="button" @click="addMission">+ Add mission</button>
<button
v-if="config.tier !== 'free'"
class="btn-generate"
type="button"
@click="generateMissions"
:disabled="generatingMissions"
>{{ generatingMissions ? 'Generating…' : 'Generate ✦' }}</button>
</div>
<div class="save-row">
<button class="btn-save" type="button" @click="store.save()" :disabled="store.saving">
{{ store.saving ? 'Saving…' : 'Save Mission' }}
</button>
<p v-if="store.saveError" class="error-msg">{{ store.saveError }}</p>
</div>
</section>
<!-- NDA Companies -->
<section class="form-section">
<h3 class="section-title">NDA Companies</h3>
<p class="section-desc">
Companies you can't name. They appear as "previous employer (NDA)" in research briefs when match score is low.
</p>
<div class="tag-list">
<span
v-for="(company, idx) in store.nda_companies"
:key="company"
class="tag"
>
{{ company }}
<button class="tag-remove" type="button" @click="removeNda(idx)" :aria-label="`Remove ${company}`">×</button>
</span>
</div>
<div class="nda-add-row">
<input
v-model="newNdaCompany"
type="text"
class="text-input nda-input"
placeholder="Company name"
@keydown.enter.prevent="addNda"
/>
<button class="btn-secondary" type="button" @click="addNda" :disabled="!newNdaCompany.trim()">Add</button>
</div>
</section>
<!-- Research Brief Preferences -->
<section class="form-section">
<h3 class="section-title">Research Brief Preferences</h3>
<p class="section-desc">
Optional sections added to company briefs for your personal decision-making only.
These details are never included in applications.
</p>
<div class="checkbox-row">
<input
id="pref-accessibility"
v-model="store.accessibility_focus"
type="checkbox"
class="checkbox"
@change="autosave"
/>
<label for="pref-accessibility" class="checkbox-label">
Include accessibility &amp; inclusion research in company briefs
</label>
</div>
<div class="checkbox-row">
<input
id="pref-lgbtq"
v-model="store.lgbtq_focus"
type="checkbox"
class="checkbox"
@change="autosave"
/>
<label for="pref-lgbtq" class="checkbox-label">
Include LGBTQ+ inclusion research in company briefs
</label>
</div>
</section>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { storeToRefs } from 'pinia'
import { useProfileStore } from '../../stores/settings/profile'
import { useAppConfigStore } from '../../stores/appConfig'
import { useApiFetch } from '../../composables/useApi'
const store = useProfileStore()
const { loadError } = storeToRefs(store)
const config = useAppConfigStore()
const newNdaCompany = ref('')
const generatingSummary = ref(false)
const generatingMissions = ref(false)
onMounted(() => { store.load() })
// Mission helpers
function addMission() {
store.mission_preferences = [...store.mission_preferences, { id: crypto.randomUUID(), industry: '', note: '' }]
}
function removeMission(idx: number) {
store.mission_preferences = store.mission_preferences.filter((_, i) => i !== idx)
}
// NDA helpers (autosave on add/remove)
function addNda() {
const trimmed = newNdaCompany.value.trim()
if (!trimmed || store.nda_companies.includes(trimmed)) return
store.nda_companies = [...store.nda_companies, trimmed]
newNdaCompany.value = ''
store.save()
}
function removeNda(idx: number) {
store.nda_companies = store.nda_companies.filter((_, i) => i !== idx)
store.save()
}
// Research prefs autosave (debounced 400ms)
let debounceTimer: ReturnType<typeof setTimeout> | null = null
function autosave() {
if (debounceTimer) clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => store.save(), 400)
}
// AI generation (paid tier)
async function generateSummary() {
generatingSummary.value = true
const { data, error } = await useApiFetch<{ summary?: string }>(
'/api/settings/profile/generate-summary', { method: 'POST' }
)
generatingSummary.value = false
if (!error && data?.summary) store.career_summary = data.summary
}
async function generateMissions() {
generatingMissions.value = true
const { data, error } = await useApiFetch<{ mission_preferences?: Array<{ industry: string; note: string }> }>(
'/api/settings/profile/generate-missions', { method: 'POST' }
)
generatingMissions.value = false
if (!error && data?.mission_preferences) {
store.mission_preferences = data.mission_preferences.map((m) => ({
id: crypto.randomUUID(), industry: m.industry ?? '', note: m.note ?? '',
}))
}
}
</script>
<style scoped>
.my-profile {
max-width: 680px;
}
.page-header {
margin-bottom: var(--space-6);
}
.page-header h2 {
margin: 0 0 var(--space-1);
font-size: 1.25rem;
font-weight: 600;
}
.subtitle {
margin: 0;
color: var(--color-text-muted);
font-size: 0.875rem;
}
.loading-state {
color: var(--color-text-muted);
font-size: 0.875rem;
padding: var(--space-4) 0;
}
.load-error-banner {
padding: var(--space-3) var(--space-4);
margin-bottom: var(--space-4);
background: color-mix(in srgb, var(--color-danger, #c0392b) 10%, transparent);
border: 1px solid color-mix(in srgb, var(--color-danger, #c0392b) 40%, transparent);
border-radius: 6px;
color: var(--color-danger, #c0392b);
font-size: 0.875rem;
}
/* ── Sections ──────────────────────────────────────────── */
.form-section {
border: 1px solid var(--color-border);
border-radius: 8px;
padding: var(--space-5);
margin-bottom: var(--space-5);
}
.section-title {
margin: 0 0 var(--space-3);
font-size: 0.9rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--color-text-muted);
}
.section-desc {
margin: calc(-1 * var(--space-2)) 0 var(--space-4);
font-size: 0.8rem;
color: var(--color-text-muted);
line-height: 1.5;
}
/* ── Fields ───────────────────────────────────────────── */
.field-row {
display: grid;
grid-template-columns: 160px 1fr;
align-items: center;
gap: var(--space-3);
margin-bottom: var(--space-3);
}
.field-row--stacked {
grid-template-columns: 1fr;
align-items: flex-start;
}
.field-row--stacked .field-label {
margin-bottom: var(--space-1);
}
.field-label {
font-size: 0.825rem;
color: var(--color-text-muted);
font-weight: 500;
}
.text-input,
.select-input {
width: 100%;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-surface-raised, var(--color-surface));
color: var(--color-text);
font-size: 0.875rem;
box-sizing: border-box;
}
.text-input:focus,
.select-input:focus,
.text-area:focus {
outline: 2px solid var(--color-primary);
outline-offset: -1px;
border-color: transparent;
}
.text-area {
width: 100%;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-surface-raised, var(--color-surface));
color: var(--color-text);
font-size: 0.875rem;
resize: vertical;
font-family: inherit;
box-sizing: border-box;
}
/* ── Save row ─────────────────────────────────────────── */
.save-row {
display: flex;
align-items: center;
gap: var(--space-3);
margin-top: var(--space-4);
padding-top: var(--space-4);
border-top: 1px solid var(--color-border);
}
.btn-save {
padding: var(--space-2) var(--space-5);
background: var(--color-primary);
color: var(--color-on-primary, #fff);
border: none;
border-radius: 6px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
}
.btn-save:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.error-msg {
margin: 0;
color: var(--color-danger, #c0392b);
font-size: 0.825rem;
}
.btn-generate {
padding: var(--space-2) var(--space-3);
background: transparent;
border: 1px solid var(--color-primary);
color: var(--color-primary);
border-radius: 6px;
font-size: 0.8rem;
cursor: pointer;
margin-top: var(--space-2);
align-self: flex-start;
}
.btn-generate:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-secondary {
padding: var(--space-2) var(--space-3);
background: transparent;
border: 1px solid var(--color-border);
color: var(--color-text-secondary);
border-radius: 6px;
font-size: 0.8rem;
cursor: pointer;
}
.btn-secondary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* ── Mission rows ─────────────────────────────────────── */
.mission-row {
display: grid;
grid-template-columns: 1fr 2fr auto;
gap: var(--space-2);
margin-bottom: var(--space-2);
align-items: center;
}
.mission-actions {
display: flex;
gap: var(--space-2);
margin-top: var(--space-2);
}
.btn-remove {
background: transparent;
border: 1px solid var(--color-border);
color: var(--color-text-muted);
border-radius: 4px;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-size: 1rem;
line-height: 1;
}
.btn-remove:hover {
border-color: var(--color-danger, #c0392b);
color: var(--color-danger, #c0392b);
}
/* ── NDA tags ─────────────────────────────────────────── */
.tag-list {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
margin-bottom: var(--space-3);
min-height: 32px;
}
.tag {
display: inline-flex;
align-items: center;
gap: var(--space-1);
padding: var(--space-1) var(--space-2);
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
border: 1px solid color-mix(in srgb, var(--color-primary) 30%, transparent);
border-radius: 999px;
font-size: 0.8rem;
color: var(--color-text);
}
.tag-remove {
background: transparent;
border: none;
color: var(--color-text-muted);
cursor: pointer;
font-size: 1rem;
line-height: 1;
padding: 0;
display: flex;
align-items: center;
}
.tag-remove:hover {
color: var(--color-danger, #c0392b);
}
.nda-add-row {
display: flex;
gap: var(--space-2);
}
.nda-input {
flex: 1;
}
/* ── Checkboxes ───────────────────────────────────────── */
.checkbox-row {
display: flex;
align-items: flex-start;
gap: var(--space-3);
margin-bottom: var(--space-3);
}
.checkbox {
flex-shrink: 0;
margin-top: 2px;
width: 16px;
height: 16px;
accent-color: var(--color-primary);
cursor: pointer;
}
.checkbox-label {
font-size: 0.875rem;
line-height: 1.5;
cursor: pointer;
}
/* ── Mobile ───────────────────────────────────────────── */
@media (max-width: 767px) {
.field-row {
grid-template-columns: 1fr;
}
.mission-row {
grid-template-columns: 1fr auto;
grid-template-rows: auto auto;
}
.mission-note {
grid-column: 1;
}
.btn-remove {
grid-row: 1;
grid-column: 2;
align-self: start;
}
}
</style>

View file

@ -0,0 +1,82 @@
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { storeToRefs } from 'pinia'
import { usePrivacyStore } from '../../stores/settings/privacy'
import { useAppConfigStore } from '../../stores/appConfig'
import { useSystemStore } from '../../stores/settings/system'
const privacy = usePrivacyStore()
const config = useAppConfigStore()
const system = useSystemStore()
const { telemetryOptIn, masterOff, usageEvents, contentSharing, showByokPanel, saving } = storeToRefs(privacy)
// Sync active cloud backends from system store into privacy store
const activeCloudBackends = computed(() =>
system.backends.filter(b => b.enabled && ['anthropic', 'openai'].includes(b.id)).map(b => b.id)
)
onMounted(async () => {
await privacy.loadPrivacy()
privacy.activeCloudBackends = activeCloudBackends.value
})
async function handleSave() {
if (config.isCloud) {
await privacy.savePrivacy({ master_off: masterOff.value, usage_events: usageEvents.value, content_sharing: contentSharing.value })
} else {
await privacy.savePrivacy({ telemetry_opt_in: telemetryOptIn.value })
}
}
</script>
<template>
<div class="privacy-view">
<h2>Privacy</h2>
<!-- Self-hosted -->
<template v-if="!config.isCloud">
<section class="form-section">
<h3>Telemetry</h3>
<p class="section-note">Peregrine is fully local by default no data leaves your machine unless you opt in.</p>
<label class="checkbox-row">
<input type="checkbox" v-model="telemetryOptIn" />
Share anonymous usage statistics to help improve Peregrine
</label>
</section>
<!-- BYOK Info Panel -->
<section v-if="showByokPanel" class="form-section byok-panel">
<h3>Cloud LLM Privacy Notice</h3>
<p>You have cloud LLM backends enabled. Your job descriptions and cover letter content will be sent to those providers' APIs. Peregrine never logs this content, but the providers' own data policies apply.</p>
<div class="form-actions">
<button @click="privacy.dismissByokInfo()" class="btn-secondary">Got it, don't show again</button>
</div>
</section>
</template>
<!-- Cloud -->
<template v-else>
<section class="form-section">
<h3>Data Controls</h3>
<label class="checkbox-row danger">
<input type="checkbox" v-model="masterOff" />
Disable all data collection (master off)
</label>
<label class="checkbox-row">
<input type="checkbox" v-model="usageEvents" :disabled="masterOff" />
Usage events (feature analytics)
</label>
<label class="checkbox-row">
<input type="checkbox" v-model="contentSharing" :disabled="masterOff" />
Share content for model improvement
</label>
</section>
</template>
<div class="form-actions">
<button @click="handleSave" :disabled="saving" class="btn-primary">
{{ saving ? 'Saving…' : 'Save' }}
</button>
</div>
</div>
</template>

View file

@ -0,0 +1,310 @@
<template>
<div class="resume-profile">
<h2>Resume Profile</h2>
<!-- Load error banner -->
<div v-if="loadError" class="error-banner">
Failed to load resume: {{ loadError }}
</div>
<!-- Empty state -->
<div v-if="!store.hasResume && !store.loading" class="empty-state">
<p>No resume found. Choose how to get started:</p>
<div class="empty-actions">
<!-- Upload -->
<div class="empty-card">
<h3>Upload & Parse</h3>
<p>Upload a PDF, DOCX, or ODT and we'll extract your info automatically.</p>
<input type="file" accept=".pdf,.docx,.odt" @change="handleUpload" ref="fileInput" />
<p v-if="uploadError" class="error">{{ uploadError }}</p>
</div>
<!-- Blank -->
<div class="empty-card">
<h3>Fill in Manually</h3>
<p>Start with a blank form and fill in your details.</p>
<button @click="store.createBlank()" :disabled="store.loading">Start from Scratch</button>
</div>
<!-- Wizard -->
<div class="empty-card">
<h3>Run Setup Wizard</h3>
<p>Walk through the onboarding wizard to set up your profile step by step.</p>
<RouterLink to="/setup">Open Setup Wizard </RouterLink>
</div>
</div>
</div>
<!-- Full form (when resume exists) -->
<template v-else-if="store.hasResume">
<!-- Personal Information -->
<section class="form-section">
<h3>Personal Information</h3>
<div class="field-row">
<label>First Name <span class="sync-label"> from My Profile</span></label>
<input v-model="store.name" />
</div>
<div class="field-row">
<label>Last Name</label>
<input v-model="store.surname" />
</div>
<div class="field-row">
<label>Email <span class="sync-label"> from My Profile</span></label>
<input v-model="store.email" type="email" />
</div>
<div class="field-row">
<label>Phone <span class="sync-label"> from My Profile</span></label>
<input v-model="store.phone" type="tel" />
</div>
<div class="field-row">
<label>LinkedIn URL <span class="sync-label"> from My Profile</span></label>
<input v-model="store.linkedin_url" type="url" />
</div>
<div class="field-row">
<label>Address</label>
<input v-model="store.address" />
</div>
<div class="field-row">
<label>City</label>
<input v-model="store.city" />
</div>
<div class="field-row">
<label>ZIP Code</label>
<input v-model="store.zip_code" />
</div>
<div class="field-row">
<label>Date of Birth</label>
<input v-model="store.date_of_birth" type="date" />
</div>
</section>
<!-- Work Experience -->
<section class="form-section">
<h3>Work Experience</h3>
<div v-for="(entry, idx) in store.experience" :key="entry.id" class="experience-card">
<div class="field-row">
<label>Job Title</label>
<input v-model="entry.title" />
</div>
<div class="field-row">
<label>Company</label>
<input v-model="entry.company" />
</div>
<div class="field-row">
<label>Period</label>
<input v-model="entry.period" placeholder="e.g. Jan 2022 Present" />
</div>
<div class="field-row">
<label>Location</label>
<input v-model="entry.location" />
</div>
<div class="field-row">
<label>Industry</label>
<input v-model="entry.industry" />
</div>
<div class="field-row">
<label>Responsibilities</label>
<textarea v-model="entry.responsibilities" rows="4" />
</div>
<button class="remove-btn" @click="store.removeExperience(idx)">Remove</button>
</div>
<button @click="store.addExperience()">+ Add Position</button>
</section>
<!-- Preferences -->
<section class="form-section">
<h3>Preferences & Availability</h3>
<div class="field-row">
<label>Salary Min</label>
<input v-model.number="store.salary_min" type="number" />
</div>
<div class="field-row">
<label>Salary Max</label>
<input v-model.number="store.salary_max" type="number" />
</div>
<div class="field-row">
<label>Notice Period</label>
<input v-model="store.notice_period" />
</div>
<label class="checkbox-row">
<input type="checkbox" v-model="store.remote" /> Open to remote
</label>
<label class="checkbox-row">
<input type="checkbox" v-model="store.relocation" /> Open to relocation
</label>
<label class="checkbox-row">
<input type="checkbox" v-model="store.assessment" /> Willing to complete assessments
</label>
<label class="checkbox-row">
<input type="checkbox" v-model="store.background_check" /> Willing to undergo background check
</label>
</section>
<!-- Self-ID (collapsible) -->
<section class="form-section">
<h3>
Self-Identification
<button class="toggle-btn" @click="showSelfId = !showSelfId">
{{ showSelfId ? '▲ Hide' : '▼ Show' }}
</button>
</h3>
<p class="section-note">Optional. Used only for your personal tracking.</p>
<template v-if="showSelfId">
<div class="field-row">
<label>Gender</label>
<input v-model="store.gender" />
</div>
<div class="field-row">
<label>Pronouns</label>
<input v-model="store.pronouns" />
</div>
<div class="field-row">
<label>Ethnicity</label>
<input v-model="store.ethnicity" />
</div>
<div class="field-row">
<label>Veteran Status</label>
<input v-model="store.veteran_status" />
</div>
<div class="field-row">
<label>Disability</label>
<input v-model="store.disability" />
</div>
</template>
</section>
<!-- Skills & Keywords -->
<section class="form-section">
<h3>Skills & Keywords</h3>
<div class="tag-section">
<label>Skills</label>
<div class="tags">
<span v-for="skill in store.skills" :key="skill" class="tag">
{{ skill }} <button @click="store.removeTag('skills', skill)">×</button>
</span>
</div>
<input v-model="skillInput" @keydown.enter.prevent="store.addTag('skills', skillInput); skillInput = ''" placeholder="Add skill, press Enter" />
</div>
<div class="tag-section">
<label>Domains</label>
<div class="tags">
<span v-for="domain in store.domains" :key="domain" class="tag">
{{ domain }} <button @click="store.removeTag('domains', domain)">×</button>
</span>
</div>
<input v-model="domainInput" @keydown.enter.prevent="store.addTag('domains', domainInput); domainInput = ''" placeholder="Add domain, press Enter" />
</div>
<div class="tag-section">
<label>Keywords</label>
<div class="tags">
<span v-for="kw in store.keywords" :key="kw" class="tag">
{{ kw }} <button @click="store.removeTag('keywords', kw)">×</button>
</span>
</div>
<input v-model="kwInput" @keydown.enter.prevent="store.addTag('keywords', kwInput); kwInput = ''" placeholder="Add keyword, press Enter" />
</div>
</section>
<!-- Save -->
<div class="form-actions">
<button @click="store.save()" :disabled="store.saving" class="btn-primary">
{{ store.saving ? 'Saving…' : 'Save Resume' }}
</button>
<p v-if="store.saveError" class="error">{{ store.saveError }}</p>
</div>
</template>
<div v-else class="loading">Loading</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { storeToRefs } from 'pinia'
import { useResumeStore } from '../../stores/settings/resume'
import { useProfileStore } from '../../stores/settings/profile'
import { useApiFetch } from '../../composables/useApi'
const store = useResumeStore()
const profileStore = useProfileStore()
const { loadError } = storeToRefs(store)
const showSelfId = ref(false)
const skillInput = ref('')
const domainInput = ref('')
const kwInput = ref('')
const uploadError = ref<string | null>(null)
const fileInput = ref<HTMLInputElement | null>(null)
onMounted(async () => {
await store.load()
// Only prime identity from profile on a fresh/empty resume
if (!store.hasResume) {
store.syncFromProfile({
name: profileStore.name,
email: profileStore.email,
phone: profileStore.phone,
linkedin_url: profileStore.linkedin_url,
})
}
})
async function handleUpload(event: Event) {
const file = (event.target as HTMLInputElement).files?.[0]
if (!file) return
uploadError.value = null
const formData = new FormData()
formData.append('file', file)
const { data, error } = await useApiFetch<{ ok: boolean; data?: Record<string, unknown>; error?: string }>(
'/api/settings/resume/upload',
{ method: 'POST', body: formData }
)
if (error || !data?.ok) {
uploadError.value = data?.error ?? (typeof error === 'string' ? error : (error?.kind === 'network' ? error.message : error?.detail ?? 'Upload failed'))
return
}
if (data.data) {
await store.load()
}
}
</script>
<style scoped>
.resume-profile { max-width: 720px; margin: 0 auto; padding: var(--space-4, 24px); }
h2 { font-size: 1.4rem; font-weight: 600; margin-bottom: var(--space-6, 32px); color: var(--color-text-primary, #e2e8f0); }
h3 { font-size: 1rem; font-weight: 600; margin-bottom: var(--space-3, 16px); color: var(--color-text-primary, #e2e8f0); }
.form-section { margin-bottom: var(--space-8, 48px); padding-bottom: var(--space-6, 32px); border-bottom: 1px solid var(--color-border, rgba(255,255,255,0.08)); }
.field-row { display: flex; flex-direction: column; gap: 4px; margin-bottom: var(--space-3, 16px); }
.field-row label { font-size: 0.82rem; color: var(--color-text-secondary, #94a3b8); }
.field-row input, .field-row textarea, .field-row select {
background: var(--color-surface-2, rgba(255,255,255,0.05));
border: 1px solid var(--color-border, rgba(255,255,255,0.12));
border-radius: 6px;
color: var(--color-text-primary, #e2e8f0);
padding: 7px 10px;
font-size: 0.88rem;
width: 100%;
box-sizing: border-box;
}
.sync-label { font-size: 0.72rem; color: var(--color-accent, #7c3aed); margin-left: 6px; }
.checkbox-row { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; font-size: 0.88rem; color: var(--color-text-primary, #e2e8f0); cursor: pointer; }
.experience-card { border: 1px solid var(--color-border, rgba(255,255,255,0.08)); border-radius: 8px; padding: var(--space-4, 24px); margin-bottom: var(--space-4, 24px); }
.remove-btn { margin-top: 8px; padding: 4px 12px; border-radius: 4px; background: rgba(239,68,68,0.15); color: #ef4444; border: 1px solid rgba(239,68,68,0.3); cursor: pointer; font-size: 0.82rem; }
.empty-state { text-align: center; padding: var(--space-8, 48px) 0; }
.empty-actions { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: var(--space-4, 24px); margin-top: var(--space-6, 32px); }
.empty-card { background: var(--color-surface-2, rgba(255,255,255,0.04)); border: 1px solid var(--color-border, rgba(255,255,255,0.08)); border-radius: 10px; padding: var(--space-4, 24px); text-align: left; }
.empty-card h3 { margin-bottom: 8px; }
.empty-card p { font-size: 0.85rem; color: var(--color-text-secondary, #94a3b8); margin-bottom: 16px; }
.empty-card button, .empty-card a { padding: 8px 16px; border-radius: 6px; font-size: 0.85rem; cursor: pointer; text-decoration: none; display: inline-block; background: var(--color-accent, #7c3aed); color: #fff; border: none; }
.tag-section { margin-bottom: var(--space-4, 24px); }
.tag-section label { font-size: 0.82rem; color: var(--color-text-secondary, #94a3b8); display: block; margin-bottom: 6px; }
.tags { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; }
.tag { padding: 3px 10px; background: rgba(124,58,237,0.15); border: 1px solid rgba(124,58,237,0.3); border-radius: 12px; font-size: 0.78rem; color: var(--color-accent, #a78bfa); display: flex; align-items: center; gap: 5px; }
.tag button { background: none; border: none; color: inherit; cursor: pointer; padding: 0; line-height: 1; }
.tag-section input { background: var(--color-surface-2, rgba(255,255,255,0.05)); border: 1px solid var(--color-border, rgba(255,255,255,0.12)); border-radius: 6px; color: var(--color-text-primary, #e2e8f0); padding: 6px 10px; font-size: 0.85rem; width: 100%; box-sizing: border-box; }
.form-actions { margin-top: var(--space-6, 32px); display: flex; align-items: center; gap: var(--space-4, 24px); }
.btn-primary { padding: 9px 24px; background: var(--color-accent, #7c3aed); color: #fff; border: none; border-radius: 7px; font-size: 0.9rem; cursor: pointer; font-weight: 600; }
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
.error { color: #ef4444; font-size: 0.82rem; }
.error-banner { background: rgba(239,68,68,0.1); border: 1px solid rgba(239,68,68,0.3); border-radius: 6px; color: #ef4444; font-size: 0.85rem; padding: 10px 14px; margin-bottom: var(--space-4, 24px); }
.section-note { font-size: 0.8rem; color: var(--color-text-secondary, #94a3b8); margin-bottom: 16px; }
.toggle-btn { margin-left: 10px; padding: 2px 10px; background: transparent; border: 1px solid var(--color-border, rgba(255,255,255,0.15)); border-radius: 4px; color: var(--color-text-secondary, #94a3b8); cursor: pointer; font-size: 0.78rem; }
.loading { text-align: center; padding: var(--space-8, 48px); color: var(--color-text-secondary, #94a3b8); }
</style>

View file

@ -0,0 +1,204 @@
<template>
<div class="search-prefs">
<h2>Search Preferences</h2>
<p v-if="store.loadError" class="error-banner">{{ store.loadError }}</p>
<!-- Remote Preference -->
<section class="form-section">
<h3>Remote Preference</h3>
<div class="remote-options">
<button
v-for="opt in remoteOptions"
:key="opt.value"
:class="['remote-btn', { active: store.remote_preference === opt.value }]"
@click="store.remote_preference = opt.value"
>{{ opt.label }}</button>
</div>
<p class="section-note">This filter runs at scrape time listings that don't match are excluded before they count against per-board quotas.</p>
</section>
<!-- Job Titles -->
<section class="form-section">
<h3>Job Titles</h3>
<div class="tags">
<span v-for="title in store.job_titles" :key="title" class="tag">
{{ title }} <button @click="store.removeTag('job_titles', title)">×</button>
</span>
</div>
<div class="tag-input-row">
<input v-model="titleInput" @keydown.enter.prevent="addTitle" placeholder="Add title, press Enter" />
<button @click="store.suggestTitles()" class="btn-suggest">Suggest</button>
</div>
<div v-if="store.titleSuggestions.length > 0" class="suggestions">
<span
v-for="s in store.titleSuggestions"
:key="s"
class="suggestion-chip"
@click="store.acceptSuggestion('title', s)"
>+ {{ s }}</span>
</div>
</section>
<!-- Locations -->
<section class="form-section">
<h3>Locations</h3>
<div class="tags">
<span v-for="loc in store.locations" :key="loc" class="tag">
{{ loc }} <button @click="store.removeTag('locations', loc)">×</button>
</span>
</div>
<div class="tag-input-row">
<input v-model="locationInput" @keydown.enter.prevent="addLocation" placeholder="Add location, press Enter" />
<button @click="store.suggestLocations()" class="btn-suggest">Suggest</button>
</div>
<div v-if="store.locationSuggestions.length > 0" class="suggestions">
<span
v-for="s in store.locationSuggestions"
:key="s"
class="suggestion-chip"
@click="store.acceptSuggestion('location', s)"
>+ {{ s }}</span>
</div>
</section>
<!-- Exclude Keywords -->
<section class="form-section">
<h3>Exclude Keywords</h3>
<div class="tags">
<span v-for="kw in store.exclude_keywords" :key="kw" class="tag">
{{ kw }} <button @click="store.removeTag('exclude_keywords', kw)">×</button>
</span>
</div>
<input v-model="excludeInput" @keydown.enter.prevent="store.addTag('exclude_keywords', excludeInput); excludeInput = ''" placeholder="Add keyword, press Enter" />
</section>
<!-- Job Boards -->
<section class="form-section">
<h3>Job Boards</h3>
<div v-for="board in store.job_boards" :key="board.name" class="board-row">
<label class="checkbox-row">
<input type="checkbox" :checked="board.enabled" @change="store.toggleBoard(board.name)" />
{{ board.name }}
</label>
</div>
<div class="field-row" style="margin-top: 12px">
<label>Custom Board URLs</label>
<div class="tags">
<span v-for="url in store.custom_board_urls" :key="url" class="tag">
{{ url }} <button @click="store.removeTag('custom_board_urls', url)">×</button>
</span>
</div>
<input v-model="customUrlInput" @keydown.enter.prevent="store.addTag('custom_board_urls', customUrlInput); customUrlInput = ''" placeholder="https://..." />
</div>
</section>
<!-- Blocklists -->
<section class="form-section">
<h3>Blocklists</h3>
<div class="blocklist-group">
<label>Companies</label>
<div class="tags">
<span v-for="c in store.blocklist_companies" :key="c" class="tag">
{{ c }} <button @click="store.removeTag('blocklist_companies', c)">×</button>
</span>
</div>
<input v-model="blockCompanyInput" @keydown.enter.prevent="store.addTag('blocklist_companies', blockCompanyInput); blockCompanyInput = ''" placeholder="Company name" />
</div>
<div class="blocklist-group">
<label>Industries</label>
<div class="tags">
<span v-for="i in store.blocklist_industries" :key="i" class="tag">
{{ i }} <button @click="store.removeTag('blocklist_industries', i)">×</button>
</span>
</div>
<input v-model="blockIndustryInput" @keydown.enter.prevent="store.addTag('blocklist_industries', blockIndustryInput); blockIndustryInput = ''" placeholder="Industry name" />
</div>
<div class="blocklist-group">
<label>Locations</label>
<div class="tags">
<span v-for="l in store.blocklist_locations" :key="l" class="tag">
{{ l }} <button @click="store.removeTag('blocklist_locations', l)">×</button>
</span>
</div>
<input v-model="blockLocationInput" @keydown.enter.prevent="store.addTag('blocklist_locations', blockLocationInput); blockLocationInput = ''" placeholder="Location name" />
</div>
</section>
<!-- Save -->
<div class="form-actions">
<button @click="store.save()" :disabled="store.saving" class="btn-primary">
{{ store.saving ? 'Saving…' : 'Save Search Preferences' }}
</button>
<p v-if="store.saveError" class="error">{{ store.saveError }}</p>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useSearchStore } from '../../stores/settings/search'
const store = useSearchStore()
const remoteOptions = [
{ value: 'remote' as const, label: 'Remote only' },
{ value: 'onsite' as const, label: 'On-site only' },
{ value: 'both' as const, label: 'Both' },
]
const titleInput = ref('')
const locationInput = ref('')
const excludeInput = ref('')
const customUrlInput = ref('')
const blockCompanyInput = ref('')
const blockIndustryInput = ref('')
const blockLocationInput = ref('')
function addTitle() {
store.addTag('job_titles', titleInput.value)
titleInput.value = ''
}
function addLocation() {
store.addTag('locations', locationInput.value)
locationInput.value = ''
}
onMounted(() => store.load())
</script>
<style scoped>
.search-prefs { max-width: 720px; margin: 0 auto; padding: var(--space-4, 24px); }
h2 { font-size: 1.4rem; font-weight: 600; margin-bottom: var(--space-6, 32px); color: var(--color-text-primary, #e2e8f0); }
h3 { font-size: 1rem; font-weight: 600; margin-bottom: var(--space-3, 16px); color: var(--color-text-primary, #e2e8f0); }
.form-section { margin-bottom: var(--space-8, 48px); padding-bottom: var(--space-6, 32px); border-bottom: 1px solid var(--color-border, rgba(255,255,255,0.08)); }
.remote-options { display: flex; gap: 8px; margin-bottom: 10px; }
.remote-btn { padding: 8px 18px; border-radius: 6px; border: 1px solid var(--color-border, rgba(255,255,255,0.15)); background: transparent; color: var(--color-text-secondary, #94a3b8); cursor: pointer; font-size: 0.88rem; transition: all 0.15s; }
.remote-btn.active { background: var(--color-accent, #7c3aed); border-color: var(--color-accent, #7c3aed); color: #fff; }
.section-note { font-size: 0.78rem; color: var(--color-text-secondary, #94a3b8); margin-top: 8px; }
.tags { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; }
.tag { padding: 3px 10px; background: rgba(124,58,237,0.15); border: 1px solid rgba(124,58,237,0.3); border-radius: 12px; font-size: 0.78rem; color: var(--color-accent, #a78bfa); display: flex; align-items: center; gap: 5px; }
.tag button { background: none; border: none; color: inherit; cursor: pointer; padding: 0; line-height: 1; }
.tag-input-row { display: flex; gap: 8px; }
.tag-input-row input, input[type="text"], input:not([type]) {
background: var(--color-surface-2, rgba(255,255,255,0.05));
border: 1px solid var(--color-border, rgba(255,255,255,0.12));
border-radius: 6px; color: var(--color-text-primary, #e2e8f0);
padding: 7px 10px; font-size: 0.85rem; flex: 1; box-sizing: border-box;
}
.btn-suggest { padding: 7px 14px; border-radius: 6px; background: rgba(124,58,237,0.2); border: 1px solid rgba(124,58,237,0.3); color: var(--color-accent, #a78bfa); cursor: pointer; font-size: 0.82rem; white-space: nowrap; }
.suggestions { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
.suggestion-chip { padding: 4px 12px; border-radius: 12px; font-size: 0.78rem; background: rgba(255,255,255,0.05); border: 1px dashed rgba(255,255,255,0.2); color: var(--color-text-secondary, #94a3b8); cursor: pointer; transition: all 0.15s; }
.suggestion-chip:hover { background: rgba(124,58,237,0.15); border-color: rgba(124,58,237,0.3); color: var(--color-accent, #a78bfa); }
.board-row { margin-bottom: 8px; }
.checkbox-row { display: flex; align-items: center; gap: 8px; font-size: 0.88rem; color: var(--color-text-primary, #e2e8f0); cursor: pointer; }
.field-row { display: flex; flex-direction: column; gap: 6px; }
.field-row label { font-size: 0.82rem; color: var(--color-text-secondary, #94a3b8); }
.blocklist-group { margin-bottom: var(--space-4, 24px); }
.blocklist-group label { font-size: 0.82rem; color: var(--color-text-secondary, #94a3b8); display: block; margin-bottom: 6px; }
.form-actions { margin-top: var(--space-6, 32px); display: flex; align-items: center; gap: var(--space-4, 24px); }
.btn-primary { padding: 9px 24px; background: var(--color-accent, #7c3aed); color: #fff; border: none; border-radius: 7px; font-size: 0.9rem; cursor: pointer; font-weight: 600; }
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
.error-banner { background: rgba(239,68,68,0.1); border: 1px solid rgba(239,68,68,0.3); border-radius: 6px; color: #ef4444; padding: 10px 14px; margin-bottom: 20px; font-size: 0.85rem; }
.error { color: #ef4444; font-size: 0.82rem; }
</style>

View file

@ -0,0 +1,45 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { createRouter, createWebHistory } from 'vue-router'
import SettingsView from './SettingsView.vue'
import { useAppConfigStore } from '../../stores/appConfig'
function makeRouter() {
return createRouter({ history: createWebHistory(), routes: [{ path: '/:p*', component: { template: '<div/>' } }] })
}
describe('SettingsView sidebar', () => {
beforeEach(() => setActivePinia(createPinia()))
it('hides System group items in cloud mode', async () => {
const store = useAppConfigStore()
store.isCloud = true
const wrapper = mount(SettingsView, { global: { plugins: [makeRouter()] } })
expect(wrapper.find('[data-testid="nav-system"]').exists()).toBe(false)
})
it('shows System when not cloud', async () => {
const store = useAppConfigStore()
store.isCloud = false
const wrapper = mount(SettingsView, { global: { plugins: [makeRouter()] } })
expect(wrapper.find('[data-testid="nav-system"]').exists()).toBe(true)
})
it('hides Developer when neither devMode nor devTierOverride', () => {
const store = useAppConfigStore()
store.isDevMode = false
localStorage.removeItem('dev_tier_override')
const wrapper = mount(SettingsView, { global: { plugins: [makeRouter()] } })
expect(wrapper.find('[data-testid="nav-developer"]').exists()).toBe(false)
})
it('shows Developer when devTierOverride is set in store', () => {
const store = useAppConfigStore()
store.isDevMode = false
store.setDevTierOverride('premium')
const wrapper = mount(SettingsView, { global: { plugins: [makeRouter()] } })
expect(wrapper.find('[data-testid="nav-developer"]').exists()).toBe(true)
store.setDevTierOverride(null) // cleanup
})
})

View file

@ -0,0 +1,156 @@
<template>
<div class="settings-layout">
<!-- Desktop sidebar -->
<nav class="settings-sidebar" aria-label="Settings navigation">
<template v-for="group in visibleGroups" :key="group.label">
<div class="nav-group-label">{{ group.label }}</div>
<RouterLink
v-for="item in group.items"
:key="item.path"
:to="item.path"
:data-testid="`nav-${item.key}`"
class="nav-item"
active-class="nav-item--active"
>{{ item.label }}</RouterLink>
</template>
</nav>
<!-- Mobile chip bar -->
<div class="settings-chip-bar" role="tablist">
<RouterLink
v-for="item in visibleTabs"
:key="item.path"
:to="item.path"
class="chip"
active-class="chip--active"
role="tab"
>{{ item.label }}</RouterLink>
</div>
<main class="settings-content">
<RouterView />
</main>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useAppConfigStore } from '../../stores/appConfig'
const config = useAppConfigStore()
const devOverride = computed(() => !!config.devTierOverride)
const gpuProfiles = ['single-gpu', 'dual-gpu']
const showSystem = computed(() => !config.isCloud)
const showFineTune = computed(() => {
if (config.isCloud) return config.tier === 'premium'
return gpuProfiles.includes(config.inferenceProfile)
})
const showDeveloper = computed(() => config.isDevMode || devOverride.value)
// IMPORTANT: `show` values must be ComputedRef<boolean> objects (e.g. showSystem),
// NOT raw booleans (e.g. showSystem.value). Using .value here would capture a static
// boolean at setup time and break reactivity.
const allGroups = [
{ label: 'Profile', items: [
{ key: 'my-profile', path: '/settings/my-profile', label: 'My Profile', show: true },
{ key: 'resume', path: '/settings/resume', label: 'Resume Profile', show: true },
]},
{ label: 'Search', items: [
{ key: 'search', path: '/settings/search', label: 'Search Prefs', show: true },
]},
{ label: 'App', items: [
{ key: 'system', path: '/settings/system', label: 'System', show: showSystem },
{ key: 'fine-tune', path: '/settings/fine-tune', label: 'Fine-Tune', show: showFineTune },
]},
{ label: 'Account', items: [
{ key: 'license', path: '/settings/license', label: 'License', show: true },
{ key: 'data', path: '/settings/data', label: 'Data', show: true },
{ key: 'privacy', path: '/settings/privacy', label: 'Privacy', show: true },
]},
{ label: 'Dev', items: [
{ key: 'developer', path: '/settings/developer', label: 'Developer', show: showDeveloper },
]},
]
const visibleGroups = computed(() =>
allGroups
.map(g => ({ ...g, items: g.items.filter(i => i.show === true || (typeof i.show !== 'boolean' && i.show.value)) }))
.filter(g => g.items.length > 0)
)
const visibleTabs = computed(() => visibleGroups.value.flatMap(g => g.items))
</script>
<style scoped>
.settings-layout {
display: grid;
grid-template-columns: 180px 1fr;
grid-template-rows: auto 1fr;
min-height: calc(100vh - var(--header-height, 56px));
}
.settings-sidebar {
grid-column: 1;
grid-row: 1 / -1;
border-right: 1px solid var(--color-border);
padding: var(--space-4) 0;
}
.nav-group-label {
padding: var(--space-3) var(--space-4) var(--space-1);
font-size: 0.65rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--color-text-muted);
}
.nav-item {
display: block;
padding: var(--space-2) var(--space-4);
font-size: 0.8rem;
color: var(--color-text-secondary);
text-decoration: none;
border-right: 2px solid transparent;
}
.nav-item--active {
background: color-mix(in srgb, var(--color-primary) 15%, transparent);
color: var(--color-primary);
border-right-color: var(--color-primary);
}
.settings-chip-bar {
display: none;
grid-column: 1 / -1;
overflow-x: auto;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
border-bottom: 1px solid var(--color-border);
white-space: nowrap;
-webkit-mask-image: linear-gradient(to right, black 85%, transparent);
mask-image: linear-gradient(to right, black 85%, transparent);
}
.chip {
display: inline-block;
padding: var(--space-1) var(--space-3);
border-radius: 999px;
border: 1px solid var(--color-border);
background: var(--color-surface-raised);
color: var(--color-text-secondary);
font-size: 0.78rem;
text-decoration: none;
flex-shrink: 0;
}
.chip--active {
background: color-mix(in srgb, var(--color-primary) 20%, transparent);
border-color: var(--color-primary);
color: var(--color-primary);
}
.settings-content {
grid-column: 2;
padding: var(--space-6) var(--space-8);
overflow-y: auto;
}
@media (max-width: 767px) {
.settings-layout { grid-template-columns: 1fr; }
.settings-sidebar { display: none; }
.settings-chip-bar { display: flex; }
.settings-content { grid-column: 1; padding: var(--space-4); }
}
</style>

View file

@ -0,0 +1,394 @@
<template>
<div class="system-settings">
<h2>System Settings</h2>
<p class="tab-note">This tab is only available in self-hosted mode.</p>
<p v-if="store.loadError" class="error-banner">{{ store.loadError }}</p>
<!-- LLM Backends -->
<section class="form-section">
<h3>LLM Backends</h3>
<p class="section-note">Drag to reorder. Higher position = higher priority in the fallback chain.</p>
<div class="backend-list">
<div
v-for="(backend, idx) in visibleBackends"
:key="backend.id"
class="backend-card"
draggable="true"
@dragstart="dragStart(idx)"
@dragover.prevent="dragOver(idx)"
@drop="drop"
>
<span class="drag-handle" aria-hidden="true"></span>
<span class="priority-badge">{{ idx + 1 }}</span>
<span class="backend-id">{{ backend.id }}</span>
<label class="toggle-label">
<input
type="checkbox"
:checked="backend.enabled"
@change="store.backends = store.backends.map(b =>
b.id === backend.id ? { ...b, enabled: !b.enabled } : b
)"
/>
<span class="toggle-text">{{ backend.enabled ? 'Enabled' : 'Disabled' }}</span>
</label>
</div>
</div>
<div class="form-actions">
<button @click="store.trySave()" :disabled="store.saving" class="btn-primary">
{{ store.saving ? 'Saving…' : 'Save Backends' }}
</button>
<p v-if="store.saveError" class="error">{{ store.saveError }}</p>
</div>
</section>
<!-- Services section -->
<section class="form-section">
<h3>Services</h3>
<p class="section-note">Port-based status. Start/Stop via Docker Compose.</p>
<div class="service-grid">
<div v-for="svc in store.services" :key="svc.name" class="service-card">
<div class="service-header">
<span class="service-dot" :class="svc.running ? 'dot-running' : 'dot-stopped'"></span>
<span class="service-name">{{ svc.name }}</span>
<span class="service-port">:{{ svc.port }}</span>
</div>
<p class="service-note">{{ svc.note }}</p>
<div class="service-actions">
<button v-if="!svc.running" @click="store.startService(svc.name)" class="btn-start">Start</button>
<button v-else @click="store.stopService(svc.name)" class="btn-stop">Stop</button>
</div>
<p v-if="store.serviceErrors[svc.name]" class="error">{{ store.serviceErrors[svc.name] }}</p>
</div>
</div>
</section>
<!-- Email section -->
<section class="form-section">
<h3>Email (IMAP)</h3>
<p class="section-note">Used for email sync in the Interviews pipeline.</p>
<div class="field-row">
<label>IMAP Host</label>
<input v-model="(store.emailConfig as any).host" placeholder="imap.gmail.com" />
</div>
<div class="field-row">
<label>Port</label>
<input v-model.number="(store.emailConfig as any).port" type="number" placeholder="993" />
</div>
<label class="checkbox-row">
<input type="checkbox" v-model="(store.emailConfig as any).ssl" /> Use SSL
</label>
<div class="field-row">
<label>Username</label>
<input v-model="(store.emailConfig as any).username" type="email" />
</div>
<div class="field-row">
<label>Password / App Password</label>
<input
v-model="emailPasswordInput"
type="password"
:placeholder="(store.emailConfig as any).password_set ? '••••••• (saved — enter new to change)' : 'Password'"
/>
<span class="field-hint">Gmail: use an App Password. Tip: type ${ENV_VAR_NAME} to use an environment variable.</span>
</div>
<div class="field-row">
<label>Sent Folder</label>
<input v-model="(store.emailConfig as any).sent_folder" placeholder="[Gmail]/Sent Mail" />
</div>
<div class="field-row">
<label>Lookback Days</label>
<input v-model.number="(store.emailConfig as any).lookback_days" type="number" placeholder="30" />
</div>
<div class="form-actions">
<button @click="handleSaveEmail()" :disabled="store.emailSaving" class="btn-primary">
{{ store.emailSaving ? 'Saving…' : 'Save Email Config' }}
</button>
<button @click="handleTestEmail" class="btn-secondary">Test Connection</button>
<span v-if="emailTestResult !== null" :class="emailTestResult ? 'test-ok' : 'test-fail'">
{{ emailTestResult ? '✓ Connected' : '✗ Failed' }}
</span>
<p v-if="store.emailError" class="error">{{ store.emailError }}</p>
</div>
</section>
<!-- Integrations -->
<section class="form-section">
<h3>Integrations</h3>
<div v-if="store.integrations.length === 0" class="empty-note">No integrations registered.</div>
<div v-for="integration in store.integrations" :key="integration.id" class="integration-card">
<div class="integration-header">
<span class="integration-name">{{ integration.name }}</span>
<div class="integration-badges">
<span v-if="!meetsRequiredTier(integration.tier_required)" class="tier-badge">
Requires {{ integration.tier_required }}
</span>
<span :class="['status-badge', integration.connected ? 'badge-connected' : 'badge-disconnected']">
{{ integration.connected ? 'Connected' : 'Disconnected' }}
</span>
</div>
</div>
<!-- Locked state for insufficient tier -->
<div v-if="!meetsRequiredTier(integration.tier_required)" class="tier-locked">
<p>Upgrade to {{ integration.tier_required }} to use this integration.</p>
</div>
<!-- Normal state for sufficient tier -->
<template v-else>
<div v-if="!integration.connected" class="integration-form">
<div v-for="field in integration.fields" :key="field.key" class="field-row">
<label>{{ field.label }}</label>
<input v-model="integrationInputs[integration.id + ':' + field.key]"
:type="field.type === 'password' ? 'password' : 'text'" />
</div>
<div class="form-actions">
<button @click="handleConnect(integration.id)" class="btn-primary">Connect</button>
<button @click="handleTest(integration.id)" class="btn-secondary">Test</button>
<span v-if="store.integrationResults[integration.id]" :class="store.integrationResults[integration.id].ok ? 'test-ok' : 'test-fail'">
{{ store.integrationResults[integration.id].ok ? '✓ OK' : '✗ ' + store.integrationResults[integration.id].error }}
</span>
</div>
</div>
<div v-else>
<button @click="store.disconnectIntegration(integration.id)" class="btn-danger">Disconnect</button>
</div>
</template>
</div>
</section>
<!-- File Paths -->
<section class="form-section">
<h3>File Paths</h3>
<div class="field-row">
<label>Documents Directory</label>
<input v-model="(store.filePaths as any).docs_dir" placeholder="/Library/Documents/JobSearch" />
</div>
<div class="field-row">
<label>Data Directory</label>
<input v-model="(store.filePaths as any).data_dir" placeholder="data/" />
</div>
<div class="field-row">
<label>Model Directory</label>
<input v-model="(store.filePaths as any).model_dir" placeholder="/Library/Assets/LLM" />
</div>
<div class="form-actions">
<button @click="store.saveFilePaths()" :disabled="store.filePathsSaving" class="btn-primary">
{{ store.filePathsSaving ? 'Saving…' : 'Save Paths' }}
</button>
</div>
<p v-if="store.filePathsError" class="error-msg">{{ store.filePathsError }}</p>
</section>
<!-- Deployment / Server -->
<section class="form-section">
<h3>Deployment / Server</h3>
<p class="section-note">Restart required for changes to take effect.</p>
<div class="field-row">
<label>Base URL Path</label>
<input v-model="(store.deployConfig as any).base_url_path" placeholder="/peregrine" />
</div>
<div class="field-row">
<label>Server Host</label>
<input v-model="(store.deployConfig as any).server_host" placeholder="0.0.0.0" />
</div>
<div class="field-row">
<label>Server Port</label>
<input v-model.number="(store.deployConfig as any).server_port" type="number" placeholder="8502" />
</div>
<div class="form-actions">
<button @click="store.saveDeployConfig()" :disabled="store.deploySaving" class="btn-primary">
{{ store.deploySaving ? 'Saving…' : 'Save (requires restart)' }}
</button>
</div>
<p v-if="store.deployError" class="error-msg">{{ store.deployError }}</p>
</section>
<!-- BYOK Modal -->
<Teleport to="body">
<div v-if="store.byokPending.length > 0" class="modal-overlay" @click.self="store.cancelByok()">
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="byok-title">
<h3 id="byok-title"> Cloud LLM Key Required</h3>
<p>You are enabling the following cloud backends:</p>
<ul>
<li v-for="b in store.byokPending" :key="b">{{ b }}</li>
</ul>
<p class="byok-warning">
These services require your own API key. Your requests and data will be
sent to these third-party providers. Costs will be charged to your account.
</p>
<label class="checkbox-row">
<input type="checkbox" v-model="byokConfirmed" />
I understand and have configured my API key in <code>config/llm.yaml</code>
</label>
<div class="modal-actions">
<button @click="store.cancelByok()" class="btn-cancel">Cancel</button>
<button
@click="handleConfirmByok"
:disabled="!byokConfirmed || store.saving"
class="btn-primary"
>{{ store.saving ? 'Saving…' : 'Save with Cloud LLM' }}</button>
</div>
</div>
</div>
</Teleport>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { storeToRefs } from 'pinia'
import { useSystemStore } from '../../stores/settings/system'
import { useAppConfigStore } from '../../stores/appConfig'
const store = useSystemStore()
const config = useAppConfigStore()
const { tier } = storeToRefs(config)
const byokConfirmed = ref(false)
const dragIdx = ref<number | null>(null)
const CONTRACTED_ONLY = ['claude-code', 'copilot']
const visibleBackends = computed(() =>
store.backends.filter(b =>
!CONTRACTED_ONLY.includes(b.id) || config.contractedClient
)
)
const tierOrder = ['free', 'paid', 'premium', 'ultra']
function meetsRequiredTier(required: string): boolean {
return tierOrder.indexOf(tier.value) >= tierOrder.indexOf(required || 'free')
}
function dragStart(idx: number) {
dragIdx.value = idx
}
function dragOver(toFilteredIdx: number) {
if (dragIdx.value === null || dragIdx.value === toFilteredIdx) return
const fromId = visibleBackends.value[dragIdx.value].id
const toId = visibleBackends.value[toFilteredIdx].id
const arr = [...store.backends]
const fromFull = arr.findIndex(b => b.id === fromId)
const toFull = arr.findIndex(b => b.id === toId)
if (fromFull === -1 || toFull === -1) return
const [moved] = arr.splice(fromFull, 1)
arr.splice(toFull, 0, moved)
store.backends = arr.map((b, i) => ({ ...b, priority: i + 1 }))
dragIdx.value = toFilteredIdx
}
function drop() {
dragIdx.value = null
}
async function handleConfirmByok() {
await store.confirmByok()
byokConfirmed.value = false
}
const emailTestResult = ref<boolean | null>(null)
const emailPasswordInput = ref('')
const integrationInputs = ref<Record<string, string>>({})
async function handleTestEmail() {
const result = await store.testEmail()
emailTestResult.value = result?.ok ?? false
}
async function handleSaveEmail() {
const payload = { ...store.emailConfig, password: emailPasswordInput.value || undefined }
await store.saveEmailWithPassword(payload)
}
async function handleConnect(id: string) {
const integration = store.integrations.find(i => i.id === id)
if (!integration) return
const credentials: Record<string, string> = {}
for (const field of integration.fields) {
credentials[field.key] = integrationInputs.value[`${id}:${field.key}`] ?? ''
}
await store.connectIntegration(id, credentials)
}
async function handleTest(id: string) {
const integration = store.integrations.find(i => i.id === id)
if (!integration) return
const credentials: Record<string, string> = {}
for (const field of integration.fields) {
credentials[field.key] = integrationInputs.value[`${id}:${field.key}`] ?? ''
}
await store.testIntegration(id, credentials)
}
onMounted(async () => {
await store.loadLlm()
await Promise.all([
store.loadServices(),
store.loadEmail(),
store.loadIntegrations(),
store.loadFilePaths(),
store.loadDeployConfig(),
])
})
</script>
<style scoped>
.system-settings { max-width: 720px; margin: 0 auto; padding: var(--space-4, 24px); }
h2 { font-size: 1.4rem; font-weight: 600; margin-bottom: 6px; color: var(--color-text-primary, #e2e8f0); }
h3 { font-size: 1rem; font-weight: 600; margin-bottom: var(--space-3, 16px); color: var(--color-text-primary, #e2e8f0); }
.tab-note { font-size: 0.82rem; color: var(--color-text-secondary, #94a3b8); margin-bottom: var(--space-6, 32px); }
.form-section { margin-bottom: var(--space-8, 48px); padding-bottom: var(--space-6, 32px); border-bottom: 1px solid var(--color-border, rgba(255,255,255,0.08)); }
.section-note { font-size: 0.78rem; color: var(--color-text-secondary, #94a3b8); margin-bottom: 14px; }
.backend-list { display: flex; flex-direction: column; gap: 8px; margin-bottom: 20px; }
.backend-card { display: flex; align-items: center; gap: 12px; padding: 10px 14px; background: var(--color-surface-2, rgba(255,255,255,0.04)); border: 1px solid var(--color-border, rgba(255,255,255,0.08)); border-radius: 8px; cursor: grab; user-select: none; }
.backend-card:active { cursor: grabbing; }
.drag-handle { font-size: 1.1rem; color: var(--color-text-secondary, #64748b); }
.priority-badge { width: 22px; height: 22px; border-radius: 50%; background: rgba(124,58,237,0.2); color: var(--color-accent, #a78bfa); font-size: 0.72rem; font-weight: 700; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.backend-id { flex: 1; font-size: 0.9rem; font-family: monospace; color: var(--color-text-primary, #e2e8f0); }
.toggle-label { display: flex; align-items: center; gap: 6px; cursor: pointer; font-size: 0.82rem; color: var(--color-text-secondary, #94a3b8); }
.form-actions { display: flex; align-items: center; gap: var(--space-4, 24px); }
.btn-primary { padding: 9px 24px; background: var(--color-accent, #7c3aed); color: #fff; border: none; border-radius: 7px; font-size: 0.9rem; cursor: pointer; font-weight: 600; }
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-cancel { padding: 9px 18px; background: transparent; border: 1px solid var(--color-border, rgba(255,255,255,0.2)); border-radius: 7px; color: var(--color-text-secondary, #94a3b8); cursor: pointer; font-size: 0.9rem; }
.error-banner { background: rgba(239,68,68,0.1); border: 1px solid rgba(239,68,68,0.3); border-radius: 6px; color: #ef4444; padding: 10px 14px; margin-bottom: 20px; font-size: 0.85rem; }
.error { color: #ef4444; font-size: 0.82rem; }
/* BYOK Modal */
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.6); display: flex; align-items: center; justify-content: center; z-index: 9999; }
.modal-card { background: var(--color-surface-1, #1e293b); border: 1px solid var(--color-border, rgba(255,255,255,0.12)); border-radius: 12px; padding: 28px; max-width: 480px; width: 90%; }
.modal-card h3 { font-size: 1.1rem; margin-bottom: 12px; color: var(--color-text-primary, #e2e8f0); }
.modal-card p { font-size: 0.88rem; color: var(--color-text-secondary, #94a3b8); margin-bottom: 12px; }
.modal-card ul { margin: 8px 0 16px 20px; font-size: 0.88rem; color: var(--color-text-primary, #e2e8f0); }
.byok-warning { background: rgba(245,158,11,0.1); border: 1px solid rgba(245,158,11,0.3); border-radius: 6px; padding: 10px 12px; color: #fbbf24 !important; }
.checkbox-row { display: flex; align-items: flex-start; gap: 8px; font-size: 0.85rem; color: var(--color-text-primary, #e2e8f0); cursor: pointer; margin: 16px 0; }
.modal-actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 20px; }
.service-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px; margin-bottom: 16px; }
.service-card { background: var(--color-surface-2, rgba(255,255,255,0.04)); border: 1px solid var(--color-border, rgba(255,255,255,0.08)); border-radius: 8px; padding: 14px; }
.service-header { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
.service-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.dot-running { background: #22c55e; box-shadow: 0 0 6px rgba(34,197,94,0.5); }
.dot-stopped { background: #64748b; }
.service-name { font-weight: 600; font-size: 0.88rem; color: var(--color-text-primary, #e2e8f0); }
.service-port { font-size: 0.75rem; color: var(--color-text-secondary, #64748b); font-family: monospace; }
.service-note { font-size: 0.75rem; color: var(--color-text-secondary, #94a3b8); margin-bottom: 10px; }
.service-actions { display: flex; gap: 6px; }
.btn-start { padding: 4px 12px; border-radius: 4px; background: rgba(34,197,94,0.15); color: #22c55e; border: 1px solid rgba(34,197,94,0.3); cursor: pointer; font-size: 0.78rem; }
.btn-stop { padding: 4px 12px; border-radius: 4px; background: rgba(239,68,68,0.1); color: #f87171; border: 1px solid rgba(239,68,68,0.2); cursor: pointer; font-size: 0.78rem; }
.field-row { display: flex; flex-direction: column; gap: 4px; margin-bottom: 14px; }
.field-row label { font-size: 0.82rem; color: var(--color-text-secondary, #94a3b8); }
.field-row input { background: var(--color-surface-2, rgba(255,255,255,0.05)); border: 1px solid var(--color-border, rgba(255,255,255,0.12)); border-radius: 6px; color: var(--color-text-primary, #e2e8f0); padding: 7px 10px; font-size: 0.88rem; }
.field-hint { font-size: 0.72rem; color: var(--color-text-secondary, #64748b); margin-top: 3px; }
.btn-secondary { padding: 9px 18px; background: transparent; border: 1px solid var(--color-border, rgba(255,255,255,0.2)); border-radius: 7px; color: var(--color-text-secondary, #94a3b8); cursor: pointer; font-size: 0.88rem; }
.btn-danger { padding: 6px 14px; border-radius: 6px; background: rgba(239,68,68,0.1); color: #ef4444; border: 1px solid rgba(239,68,68,0.25); cursor: pointer; font-size: 0.82rem; }
.test-ok { color: #22c55e; font-size: 0.85rem; }
.test-fail { color: #ef4444; font-size: 0.85rem; }
.integration-card { background: var(--color-surface-2, rgba(255,255,255,0.04)); border: 1px solid var(--color-border, rgba(255,255,255,0.08)); border-radius: 8px; padding: 16px; margin-bottom: 12px; }
.integration-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
.integration-name { font-weight: 600; font-size: 0.9rem; color: var(--color-text-primary, #e2e8f0); }
.status-badge { font-size: 0.72rem; padding: 2px 8px; border-radius: 10px; }
.badge-connected { background: rgba(34,197,94,0.15); color: #22c55e; border: 1px solid rgba(34,197,94,0.3); }
.badge-disconnected { background: rgba(100,116,139,0.15); color: #94a3b8; border: 1px solid rgba(100,116,139,0.2); }
.empty-note { font-size: 0.85rem; color: var(--color-text-secondary, #94a3b8); padding: 16px 0; }
.tier-badge { font-size: 0.68rem; padding: 2px 7px; border-radius: 8px; background: rgba(245,158,11,0.15); color: #f59e0b; border: 1px solid rgba(245,158,11,0.3); margin-right: 6px; }
.tier-locked { padding: 12px 0; font-size: 0.85rem; color: var(--color-text-secondary, #94a3b8); }
.integration-badges { display: flex; align-items: center; gap: 4px; }
</style>

14
web/tsconfig.app.json Normal file
View file

@ -0,0 +1,14 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"types": ["vite/client"],
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}

7
web/tsconfig.json Normal file
View file

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

22
web/tsconfig.node.json Normal file
View file

@ -0,0 +1,22 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts", "uno.config.ts"]
}

10
web/uno.config.ts Normal file
View file

@ -0,0 +1,10 @@
import { defineConfig, presetWind, presetAttributify } from 'unocss'
export default defineConfig({
presets: [
presetWind(),
// prefixedOnly: avoids false-positive CSS for bare attribute names like "h2", "grid",
// "shadow" in source files. Use <div un-flex> not <div flex>. Gotcha #4.
presetAttributify({ prefix: 'un-', prefixedOnly: true }),
],
})

22
web/vite.config.ts Normal file
View file

@ -0,0 +1,22 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import UnoCSS from 'unocss/vite'
export default defineConfig({
plugins: [vue(), UnoCSS()],
server: {
host: '0.0.0.0',
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:8601',
changeOrigin: true,
},
},
},
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./src/test-setup.ts'],
},
})