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:
parent
ae6021ceeb
commit
f3ce46e252
6 changed files with 1129 additions and 107 deletions
|
|
@ -1,22 +1,18 @@
|
|||
<template>
|
||||
<!-- IMPORTANT: root element uses class="app-root", NOT id="app".
|
||||
index.html owns #app as the mount target.
|
||||
Mixing the two creates nested #app elements with ambiguous CSS specificity.
|
||||
Gotcha #1 from docs/vue-port-gotchas.md. -->
|
||||
<div
|
||||
class="app-root"
|
||||
:class="{ 'rich-motion': motion.rich.value }"
|
||||
:data-theme="hackerTheme"
|
||||
>
|
||||
<!-- 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">
|
||||
<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 { computed, onMounted } from 'vue'
|
||||
import { onMounted } from 'vue'
|
||||
import { RouterView } from 'vue-router'
|
||||
import { useMotion } from './composables/useMotion'
|
||||
import { useHackerMode, useKonamiCode } from './composables/useEasterEgg'
|
||||
|
|
@ -25,13 +21,6 @@ import AppNav from './components/AppNav.vue'
|
|||
const motion = useMotion()
|
||||
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)
|
||||
|
||||
onMounted(() => {
|
||||
|
|
@ -40,7 +29,7 @@ onMounted(() => {
|
|||
</script>
|
||||
|
||||
<style>
|
||||
/* Global resets in <style> (no scoped) — applied once to the document */
|
||||
/* Global resets — unscoped, applied once to document */
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
|
|
@ -51,29 +40,55 @@ html {
|
|||
font-family: var(--font-body, sans-serif);
|
||||
color: var(--color-text, #1a2338);
|
||||
background: var(--color-surface, #eaeff8);
|
||||
/* clip (not hidden) — avoids BFC scroll-container side effects. Gotcha #3. */
|
||||
overflow-x: clip;
|
||||
overflow-x: clip; /* no BFC side effects. Gotcha #3. */
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100dvh; /* dvh = dynamic viewport height — mobile chrome-aware. Gotcha #13. */
|
||||
overflow-x: hidden; /* body hidden is survivable; html must be clip */
|
||||
min-height: 100dvh; /* dynamic viewport — mobile chrome-aware. Gotcha #13. */
|
||||
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 {
|
||||
display: flex;
|
||||
min-height: 100dvh;
|
||||
}
|
||||
|
||||
/* Main content area */
|
||||
.app-main {
|
||||
flex: 1;
|
||||
min-width: 0; /* prevents flex children from blowing out container width */
|
||||
padding-top: var(--nav-height, 4rem);
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -1,52 +1,113 @@
|
|||
/* web/src/assets/peregrine.css
|
||||
Peregrine token overrides — imported AFTER theme.css.
|
||||
Only overrides what is genuinely different from the CircuitForge base theme.
|
||||
Peregrine-specific token overrides — imported AFTER theme.css.
|
||||
Source of truth: circuitforge-plans/peregrine/2026-03-03-nuxt-design-system.md
|
||||
|
||||
App colors:
|
||||
Primary — Forest Green (#2d5a27) — inherited from theme.css --color-primary
|
||||
Accent — Amber/Copper (#c4732a) — inherited from theme.css --color-accent
|
||||
Brand:
|
||||
Falcon Blue (#2B6CB0) — slate-blue back plumage of the peregrine falcon
|
||||
Talon Orange (#E06820) — vivid orange-yellow talons and cere
|
||||
*/
|
||||
|
||||
/* ── Page-level overrides ───────────────────────────── */
|
||||
/* ── Page-level ─────────────────────────────────────── */
|
||||
html {
|
||||
/* Prevent Mac Chrome horizontal swipe-to-navigate on viewport edge */
|
||||
overscroll-behavior-x: none;
|
||||
/* clip (not hidden) — no BFC scroll-container side effect. Gotcha #3. */
|
||||
overflow-x: clip;
|
||||
overflow-x: clip; /* clip (not hidden) — no BFC scroll-container side effect. Gotcha #3. */
|
||||
}
|
||||
|
||||
body {
|
||||
/* Suppress horizontal scroll from animated transitions */
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* ── Light mode (default) ──────────────────────────── */
|
||||
:root {
|
||||
/* Alias map — component-expected names → theme.css canonical names. Gotcha #5.
|
||||
Components should prefer the theme.css names; add aliases only when needed. */
|
||||
/* ── 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);
|
||||
|
||||
/* Nav height token — consumed by .app-main padding-top in App.vue */
|
||||
--nav-height: 4rem;
|
||||
/* ── Layout ── */
|
||||
--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); /* 40–69% */
|
||||
--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-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"]) {
|
||||
/* 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) ──────────────── */
|
||||
/* Applied via document.documentElement.dataset.theme = 'hacker' */
|
||||
/* Full token overrides live in theme.css [data-theme="hacker"] block */
|
||||
[data-theme="hacker"] {
|
||||
/* Cursor trail uses this color — override for hacker palette */
|
||||
--color-accent: #00ff41;
|
||||
--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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,60 @@
|
|||
<template>
|
||||
<nav class="app-nav" role="navigation" aria-label="Main navigation">
|
||||
<div class="app-nav__brand">
|
||||
<RouterLink to="/" class="app-nav__logo">Peregrine</RouterLink>
|
||||
<!-- 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>
|
||||
<ul class="app-nav__links" role="list">
|
||||
|
||||
<!-- Nav links -->
|
||||
<ul class="sidebar__links" role="list">
|
||||
<li v-for="link in navLinks" :key="link.to">
|
||||
<RouterLink :to="link.to" class="app-nav__link" active-class="app-nav__link--active">
|
||||
<span class="app-nav__icon" aria-hidden="true">{{ link.icon }}</span>
|
||||
<span class="app-nav__label">{{ link.label }}</span>
|
||||
<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>
|
||||
|
|
@ -15,75 +62,251 @@
|
|||
</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: '🏠', label: 'Home' },
|
||||
{ to: '/review', icon: '📋', label: 'Job Review' },
|
||||
{ to: '/apply', icon: '✍️', label: 'Apply' },
|
||||
{ to: '/interviews', icon: '🗓️', label: 'Interviews' },
|
||||
{ to: '/prep', icon: '🎯', label: 'Interview Prep' },
|
||||
{ to: '/survey', icon: '🔍', label: 'Survey' },
|
||||
{ to: '/settings', icon: '⚙️', label: 'Settings' },
|
||||
{ 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>
|
||||
.app-nav {
|
||||
/* ── Sidebar (desktop ≥1024px) ──────────────────────── */
|
||||
.app-sidebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: var(--nav-height, 4rem);
|
||||
bottom: 0;
|
||||
width: var(--sidebar-width);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
padding: 0 var(--space-6);
|
||||
flex-direction: column;
|
||||
background: var(--color-surface-raised);
|
||||
border-bottom: 2px solid var(--color-border);
|
||||
border-right: 1px solid var(--color-border);
|
||||
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 {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
font-size: 1.2rem;
|
||||
color: var(--color-primary);
|
||||
.sidebar__logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.app-nav__links {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow-x: auto;
|
||||
/* Click-the-bird ruffle animation — easter egg 9.6 */
|
||||
.sidebar__bird {
|
||||
font-size: 1.4rem;
|
||||
display: inline-block;
|
||||
transform-origin: center bottom;
|
||||
}
|
||||
|
||||
.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;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
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: 0.875rem;
|
||||
white-space: nowrap;
|
||||
/* Enumerate only the properties that animate — no transition:all with spring easing. Gotcha #2. */
|
||||
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;
|
||||
background 150ms ease,
|
||||
color 150ms ease;
|
||||
}
|
||||
|
||||
.app-nav__link:hover,
|
||||
.app-nav__link--active {
|
||||
background: var(--color-primary-light);
|
||||
color: var(--color-primary);
|
||||
.sidebar__link:hover {
|
||||
background: var(--app-primary-light);
|
||||
color: var(--app-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>
|
||||
|
|
|
|||
96
web/src/components/WorkflowButton.vue
Normal file
96
web/src/components/WorkflowButton.vue
Normal 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
50
web/src/stores/jobs.ts
Normal 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 }
|
||||
})
|
||||
|
|
@ -1,18 +1,595 @@
|
|||
<template>
|
||||
<div class="view-placeholder">
|
||||
<h1>HomeView</h1>
|
||||
<p class="placeholder-note">Vue port in progress — Streamlit equivalent at app/pages/</p>
|
||||
<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 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>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.view-placeholder {
|
||||
padding: var(--space-8);
|
||||
max-width: 60ch;
|
||||
<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`
|
||||
}
|
||||
.placeholder-note {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.875rem;
|
||||
|
||||
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 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);
|
||||
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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue