feat(web): implement design spec — peregrine.css, sidebar nav, HomeView

Applies the full design spec from circuitforge-plans/peregrine/2026-03-03-nuxt-design-system.md:

CSS tokens:
- Falcon Blue (#2B6CB0 / #68A8D8 dark) — was incorrectly using forest green
- Talon Orange (#E06820 / #F6872A dark) with --app-accent-text dark navy (never white)
- Full pipeline status token set (--status-pending/approve/reject/applied/synced/...)
- Match score tokens, motion tokens, type scale tokens
- Dark mode + hacker mode overrides

AppNav: sidebar layout (replaces top bar)
- Desktop ≥1024px: persistent sidebar with brand, links, hacker-exit, settings footer
- Mobile <1024px: bottom tab bar with 5 primary destinations
- Click-the-bird easter egg (9.6): 5 rapid clicks → ruffle animation
- Heroicons via @heroicons/vue/24/outline

App.vue:
- Skip-to-content link (a11y)
- Sidebar margin-left layout (desktop) / tab bar clearance (mobile)

HomeView: full dashboard implementation
- Pipeline metric cards (Pending/Approved/Applied/Synced/Rejected) with status colors
- Primary workflow buttons (Run Discovery, Sync Emails, Score Unscored) + sync banner
- Auto-enrichment status row
- Backlog management (conditionally visible)
- Add Jobs by URL / CSV upload tabs
- Advanced/danger zone in collapsible <details>
- Stoop speed toast easter egg (9.2)
- Midnight mode greeting easter egg (9.7)

WorkflowButton component with loading spinner, proper touch targets (min-height 44px)
Pinia jobs store (setup form) with counts + system status

Build: clean 2.28s, 0 errors
This commit is contained in:
pyr0ball 2026-03-17 22:00:42 -07:00
parent ae6021ceeb
commit f3ce46e252
6 changed files with 1129 additions and 107 deletions

View file

@ -1,22 +1,18 @@
<template> <template>
<!-- IMPORTANT: root element uses class="app-root", NOT id="app". <!-- Root uses .app-root class, NOT id="app" index.html owns #app.
index.html owns #app as the mount target. Nested #app elements cause ambiguous CSS specificity. Gotcha #1. -->
Mixing the two creates nested #app elements with ambiguous CSS specificity. <div class="app-root" :class="{ 'rich-motion': motion.rich.value }">
Gotcha #1 from docs/vue-port-gotchas.md. -->
<div
class="app-root"
:class="{ 'rich-motion': motion.rich.value }"
:data-theme="hackerTheme"
>
<AppNav /> <AppNav />
<main class="app-main"> <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 /> <RouterView />
</main> </main>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted } from 'vue' import { onMounted } from 'vue'
import { RouterView } from 'vue-router' import { RouterView } from 'vue-router'
import { useMotion } from './composables/useMotion' import { useMotion } from './composables/useMotion'
import { useHackerMode, useKonamiCode } from './composables/useEasterEgg' import { useHackerMode, useKonamiCode } from './composables/useEasterEgg'
@ -25,13 +21,6 @@ import AppNav from './components/AppNav.vue'
const motion = useMotion() const motion = useMotion()
const { toggle, restore } = useHackerMode() const { toggle, restore } = useHackerMode()
// Computed so template reactively tracks localStorage-driven theme
const hackerTheme = computed(() =>
typeof document !== 'undefined' && document.documentElement.dataset.theme === 'hacker'
? 'hacker'
: undefined,
)
useKonamiCode(toggle) useKonamiCode(toggle)
onMounted(() => { onMounted(() => {
@ -40,7 +29,7 @@ onMounted(() => {
</script> </script>
<style> <style>
/* Global resets in <style> (no scoped) — applied once to the document */ /* Global resets — unscoped, applied once to document */
*, *::before, *::after { *, *::before, *::after {
box-sizing: border-box; box-sizing: border-box;
margin: 0; margin: 0;
@ -51,29 +40,55 @@ html {
font-family: var(--font-body, sans-serif); font-family: var(--font-body, sans-serif);
color: var(--color-text, #1a2338); color: var(--color-text, #1a2338);
background: var(--color-surface, #eaeff8); background: var(--color-surface, #eaeff8);
/* clip (not hidden) — avoids BFC scroll-container side effects. Gotcha #3. */ overflow-x: clip; /* no BFC side effects. Gotcha #3. */
overflow-x: clip;
} }
body { body {
min-height: 100dvh; /* dvh = dynamic viewport height — mobile chrome-aware. Gotcha #13. */ min-height: 100dvh; /* dynamic viewport — mobile chrome-aware. Gotcha #13. */
overflow-x: hidden; /* body hidden is survivable; html must be clip */ overflow-x: hidden;
} }
/* Mount shell — thin container, no layout */ #app { min-height: 100dvh; }
#app {
min-height: 100dvh;
}
/* App layout root */ /* Layout root — sidebar pushes content right on desktop */
.app-root { .app-root {
display: flex; display: flex;
min-height: 100dvh; min-height: 100dvh;
} }
/* Main content area */
.app-main { .app-main {
flex: 1; flex: 1;
min-width: 0; /* prevents flex children from blowing out container width */ min-width: 0; /* prevents flex blowout */
padding-top: var(--nav-height, 4rem); /* 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> </style>

View file

@ -1,52 +1,113 @@
/* web/src/assets/peregrine.css /* web/src/assets/peregrine.css
Peregrine token overrides imported AFTER theme.css. Peregrine-specific token overrides imported AFTER theme.css.
Only overrides what is genuinely different from the CircuitForge base theme. Source of truth: circuitforge-plans/peregrine/2026-03-03-nuxt-design-system.md
App colors: Brand:
Primary Forest Green (#2d5a27) inherited from theme.css --color-primary Falcon Blue (#2B6CB0) slate-blue back plumage of the peregrine falcon
Accent Amber/Copper (#c4732a) inherited from theme.css --color-accent Talon Orange (#E06820) vivid orange-yellow talons and cere
*/ */
/* ── Page-level overrides ───────────────────────────── */ /* ── Page-level ─────────────────────────────────────── */
html { html {
/* Prevent Mac Chrome horizontal swipe-to-navigate on viewport edge */
overscroll-behavior-x: none; overscroll-behavior-x: none;
/* clip (not hidden) — no BFC scroll-container side effect. Gotcha #3. */ overflow-x: clip; /* clip (not hidden) — no BFC scroll-container side effect. Gotcha #3. */
overflow-x: clip;
} }
body { body {
/* Suppress horizontal scroll from animated transitions */
overflow-x: hidden; overflow-x: hidden;
} }
/* ── Light mode (default) ──────────────────────────── */ /* ── Light mode (default) ──────────────────────────── */
:root { :root {
/* Alias map component-expected names theme.css canonical names. Gotcha #5. /* ── Peregrine brand colors ── */
Components should prefer the theme.css names; add aliases only when needed. */ --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-bg: var(--color-surface);
--color-text-secondary: var(--color-text-muted); --color-text-secondary: var(--color-text-muted);
/* Nav height token — consumed by .app-main padding-top in App.vue */ /* ── Layout ── */
--nav-height: 4rem; --nav-height: 4rem; /* top bar height (mobile) */
--sidebar-width: 220px; /* persistent sidebar (≥1024px) */
/* Motion tokens for future animated components (inspired by avocet bucket pattern) */ /* ── 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: var(--color-warning); /* 4069% */
--score-low: var(--color-error); /* < 40% */
--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-spring: 400ms cubic-bezier(0.34, 1.56, 0.64, 1);
--transition-dismiss: 350ms ease-in; --transition-dismiss: 350ms ease-in;
--transition-enter: 250ms ease-out; --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 ─────────────────────────────────────── */ /* ── Dark mode ─────────────────────────────────────── */
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
:root:not([data-theme="hacker"]) { :root:not([data-theme="hacker"]) {
/* Aliases inherit dark values from theme.css automatically */ --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;
--status-synced: #9b8fea;
--status-survey: #b08fea;
--status-phone: #4ec9be;
--status-offer: #f5a43a;
} }
} }
/* ── Hacker mode (Konami easter egg) ──────────────── */ /* ── Hacker mode (Konami easter egg) ──────────────── */
/* Applied via document.documentElement.dataset.theme = 'hacker' */
/* Full token overrides live in theme.css [data-theme="hacker"] block */
[data-theme="hacker"] { [data-theme="hacker"] {
/* Cursor trail uses this color — override for hacker palette */ --app-primary: #00ff41;
--color-accent: #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);
} }

View file

@ -1,13 +1,60 @@
<template> <template>
<nav class="app-nav" role="navigation" aria-label="Main navigation"> <!-- Desktop: persistent sidebar (1024px) -->
<div class="app-nav__brand"> <!-- Mobile: bottom tab bar (<1024px) -->
<RouterLink to="/" class="app-nav__logo">Peregrine</RouterLink> <!-- 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> </div>
<ul class="app-nav__links" role="list">
<!-- Nav links -->
<ul class="sidebar__links" role="list">
<li v-for="link in navLinks" :key="link.to"> <li v-for="link in navLinks" :key="link.to">
<RouterLink :to="link.to" class="app-nav__link" active-class="app-nav__link--active"> <RouterLink
<span class="app-nav__icon" aria-hidden="true">{{ link.icon }}</span> :to="link.to"
<span class="app-nav__label">{{ link.label }}</span> 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> </RouterLink>
</li> </li>
</ul> </ul>
@ -15,75 +62,251 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue'
import { RouterLink } from 'vue-router' import { RouterLink } from 'vue-router'
import {
HomeIcon,
ClipboardDocumentListIcon,
PencilSquareIcon,
CalendarDaysIcon,
LightBulbIcon,
MagnifyingGlassIcon,
Cog6ToothIcon,
} from '@heroicons/vue/24/outline'
// 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 = [ const navLinks = [
{ to: '/', icon: '🏠', label: 'Home' }, { to: '/', icon: HomeIcon, label: 'Home' },
{ to: '/review', icon: '📋', label: 'Job Review' }, { to: '/review', icon: ClipboardDocumentListIcon, label: 'Job Review' },
{ to: '/apply', icon: '✍️', label: 'Apply' }, { to: '/apply', icon: PencilSquareIcon, label: 'Apply' },
{ to: '/interviews', icon: '🗓️', label: 'Interviews' }, { to: '/interviews', icon: CalendarDaysIcon, label: 'Interviews' },
{ to: '/prep', icon: '🎯', label: 'Interview Prep' }, { to: '/prep', icon: LightBulbIcon, label: 'Interview Prep' },
{ to: '/survey', icon: '🔍', label: 'Survey' }, { to: '/survey', icon: MagnifyingGlassIcon, label: 'Survey' },
{ to: '/settings', icon: '⚙️', label: 'Settings' }, ]
// 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> </script>
<style scoped> <style scoped>
.app-nav { /* ── Sidebar (desktop ≥1024px) ──────────────────────── */
.app-sidebar {
position: fixed; position: fixed;
top: 0; top: 0;
left: 0; left: 0;
right: 0; bottom: 0;
height: var(--nav-height, 4rem); width: var(--sidebar-width);
display: flex; display: flex;
align-items: center; flex-direction: column;
gap: var(--space-4);
padding: 0 var(--space-6);
background: var(--color-surface-raised); background: var(--color-surface-raised);
border-bottom: 2px solid var(--color-border); border-right: 1px solid var(--color-border);
z-index: 100; z-index: 100;
padding: var(--space-4) 0;
} }
.app-nav__brand { flex-shrink: 0; } .sidebar__brand {
padding: 0 var(--space-4) var(--space-4);
border-bottom: 1px solid var(--color-border-light);
margin-bottom: var(--space-3);
}
.app-nav__logo { .sidebar__logo {
font-family: var(--font-display); display: flex;
font-weight: 700; align-items: center;
font-size: 1.2rem; gap: var(--space-2);
color: var(--color-primary);
text-decoration: none; text-decoration: none;
} }
.app-nav__links { /* Click-the-bird ruffle animation — easter egg 9.6 */
display: flex; .sidebar__bird {
gap: var(--space-2); font-size: 1.4rem;
list-style: none; display: inline-block;
margin: 0; transform-origin: center bottom;
padding: 0;
overflow-x: auto;
} }
.app-nav__link { .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; display: flex;
align-items: center; align-items: center;
gap: var(--space-2); gap: var(--space-3);
padding: var(--space-2) var(--space-3); padding: var(--space-3) var(--space-4);
border-radius: var(--radius-md); border-radius: var(--radius-md);
color: var(--color-text-muted); color: var(--color-text-muted);
text-decoration: none; text-decoration: none;
font-size: 0.875rem; font-size: var(--text-sm);
white-space: nowrap; font-weight: 500;
/* Enumerate only the properties that animate — no transition:all with spring easing. Gotcha #2. */ min-height: 44px; /* WCAG 2.5.5 touch target */
/* Enumerate properties explicitly — no transition:all. Gotcha #2. */
transition: transition:
background 150ms ease, background 150ms ease,
color 150ms ease; color 150ms ease;
} }
.app-nav__link:hover, .sidebar__link:hover {
.app-nav__link--active { background: var(--app-primary-light);
background: var(--color-primary-light); color: var(--app-primary);
color: var(--color-primary);
} }
.app-nav__icon { font-size: 1rem; } .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> </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>

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 }
})

View file

@ -1,18 +1,595 @@
<template> <template>
<div class="view-placeholder"> <div class="home">
<h1>HomeView</h1> <!-- Header -->
<p class="placeholder-note">Vue port in progress Streamlit equivalent at app/pages/</p> <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 class="action-btn action-btn--secondary" @click="archivePendingRejected">
📦 Archive Pending + Rejected
</button>
<button
v-if="(store.counts?.approved ?? 0) > 0"
class="action-btn action-btn--secondary"
@click="archiveApproved"
>
📦 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> </div>
</template> </template>
<style scoped> <script setup lang="ts">
.view-placeholder { import { ref, computed, onMounted } from 'vue'
padding: var(--space-8); import { RouterLink } from 'vue-router'
max-width: 60ch; 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`
} }
.placeholder-note {
color: var(--color-text-muted); const taskRunning = ref<string | null>(null)
font-size: 0.875rem; 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 archivePendingRejected() {
await useApiFetch('/api/jobs/archive', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ statuses: ['pending', 'rejected'] }),
})
store.refresh()
}
async function archiveApproved() {
await useApiFetch('/api/jobs/archive', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ statuses: ['approved'] }),
})
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); 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> </style>