peregrine/web/src/components/AppNav.vue
pyr0ball f3ce46e252 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
2026-03-17 22:00:42 -07:00

312 lines
8.5 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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,
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 = [
{ 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: '/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>