36 KiB
Apply View — Desktop Split-Pane Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Refactor the Apply view for desktop into a master-detail split pane (28% job list / 72% workspace) with an expand-from-divider animation, while leaving mobile completely unchanged.
Architecture: ApplyWorkspace.vue is extracted from ApplyWorkspaceView.vue as a prop-driven component, allowing it to render both inline (split pane) and as a standalone route (mobile). ApplyView.vue owns the split layout, selection state, and three easter eggs. The fourth easter egg (Perfect Match shimmer) lives inside ApplyWorkspace.vue since it needs access to the loaded job's score.
Tech Stack: Vue 3 + TypeScript + Pinia-free (local ref state) + CSS Grid column transitions + useEasterEgg.ts composables (existing)
File Map
| File | Action | Responsibility |
|---|---|---|
web/src/assets/peregrine.css |
Modify | Add --score-mid-high CSS variable; add .score-badge--mid-high class |
web/src/components/ApplyWorkspace.vue |
Create | Extracted workspace: jobId: number prop, emits job-removed + cover-letter-generated, Perfect Match shimmer |
web/src/views/ApplyWorkspaceView.vue |
Modify | Slim to thin wrapper: <ApplyWorkspace :job-id="..."> |
web/src/views/ApplyView.vue |
Replace | Split-pane layout, narrow list rows, Speed Demon + Marathon + (Konami already global) |
No router changes — /apply/:id stays as-is.
Task 1: Score Badge 4-Tier CSS
Files:
- Modify:
web/src/assets/peregrine.css
The current badge CSS has 3 tiers with outdated thresholds (≥80, ≥60). The new spec uses 4 tiers aligned with the existing CSS variable comments: green ≥70%, blue 50–69% (new), amber 30–49%, red <30%.
Why peregrine.css? Score tokens (--score-high, --score-mid, --score-low) are defined there. The new --score-mid-high token and the .score-badge--mid-high class belong alongside them.
Note: ApplyWorkspaceView.vue defines .score-badge--* classes in its <style scoped>. After Task 2 extracts the workspace into ApplyWorkspace.vue, those scoped styles move with it. The canonical badge classes already also exist in ApplyView.vue's scoped styles and will be updated there in Task 3.
- Step 1: Add
--score-mid-hightoken and.score-badge--mid-highclass
Open web/src/assets/peregrine.css. Find the score token block (around line 55). Add the --score-mid-high variable and its dark-mode equivalent. Then find where .score-badge--* classes are defined (if they exist globally) or note the pattern for Task 2.
In :root:
--score-high: var(--color-success); /* ≥ 70% */
--score-mid-high: #2b7cb8; /* 50–69% — Falcon Blue variant */
--score-mid: var(--color-warning); /* 30–49% */
--score-low: var(--color-error); /* < 30% */
--score-none: var(--color-text-muted);
Also add dark-mode override. The existing dark-mode block in peregrine.css uses:
@media (prefers-color-scheme: dark) { :root:not([data-theme="hacker"]) { ... } }
Add inside that exact block:
--score-mid-high: #5ba3d9; /* lighter blue for dark bg */
Also update the existing --score-mid comment from /* 40–69% */ to /* 30–49% */ to keep the inline documentation accurate.
- Step 2: Verify the token exists by checking the file
grep -n "score-mid-high\|score-high\|score-mid\|score-low" \
/Library/Development/CircuitForge/peregrine/.worktrees/feature-vue-spa/web/src/assets/peregrine.css
Expected: 4 lines with the new token appearing between score-high and score-mid.
- Step 3: Commit
cd /Library/Development/CircuitForge/peregrine/.worktrees/feature-vue-spa
git add web/src/assets/peregrine.css
git commit -m "style(apply): add score-badge--mid-high token for 4-tier scoring"
Task 2: Extract ApplyWorkspace.vue + Perfect Match Easter Egg
Files:
- Create:
web/src/components/ApplyWorkspace.vue - Modify:
web/src/views/ApplyWorkspaceView.vue
This is the largest refactor. The goal is to move all workspace logic out of the route view into a reusable component that accepts a jobId prop.
Dependencies: Task 2 must complete before Task 3 — ApplyView.vue imports ApplyWorkspace.vue.
Key changes vs. ApplyWorkspaceView.vue:
jobIdcomes from prop, notuseRoute()— remove theuseRoute(),useRouter(), andRouterLinkimports- All API calls use
props.jobIdinstead of the old module-levelconst jobId. The exact locations:fetchJob(),pollTaskStatus(),generate(),saveCoverLetter(),downloadPdf(),markApplied(),rejectListing(), and the in-flight task check insideonMounted markApplied/rejectListing: emitjob-removedinstead of callingrouter.push('/apply')generate()polling: emitcover-letter-generatedwhen status transitions tocompleted- Remove the
← Back to ApplyRouterLink(only needed in the standalone route context) - Preserve
onUnmounted—stopPolling()+clearTimeout(toastTimer)cleanup is critical: the component can now unmount mid-session when the user selects a different job declare module '../stores/review'augmentation moves here (path'../stores/review'is correct fromcomponents/— resolves tosrc/stores/review)- Updated 4-tier
scoreBadgeClass+.score-badge--mid-highclass PERFECT_MATCH_THRESHOLD = 70const + shimmer on open
- Step 1: Create
web/src/components/ApplyWorkspace.vue
Create the file with the following structure. Start from ApplyWorkspaceView.vue as the source — copy it wholesale, then apply the changes listed below.
<script setup lang="ts"> changes:
// Props (replaces route.params.id)
const props = defineProps<{ jobId: number }>()
// Emits
const emit = defineEmits<{
'job-removed': []
'cover-letter-generated': []
}>()
// Remove: const route = useRoute()
// Remove: const router = useRouter()
// Remove: RouterLink import (it is used in template only for the back-link — remove that element too)
// Remove: const jobId = Number(route.params.id)
// KEEP: onUnmounted(() => { stopPolling(); clearTimeout(toastTimer) }) ← do NOT remove this
// jobId is now: props.jobId — update all references from `jobId` to `props.jobId`
// Perfect Match
const PERFECT_MATCH_THRESHOLD = 70 // intentionally = score-badge--high boundary; update together
const shimmeringBadge = ref(false)
// Updated scoreBadgeClass — 4-tier, replaces old 3-tier
const scoreBadgeClass = computed(() => {
const s = job.value?.match_score ?? 0
if (s >= 70) return 'score-badge--high'
if (s >= 50) return 'score-badge--mid-high'
if (s >= 30) return 'score-badge--mid'
return 'score-badge--low'
})
// In markApplied() — replace router.push:
// showToast('Marked as applied ✓')
// setTimeout(() => emit('job-removed'), 1200)
// In rejectListing() — replace router.push:
// showToast('Listing rejected')
// setTimeout(() => emit('job-removed'), 1000)
// In pollTaskStatus(), when status === 'completed', after clState = 'ready':
// emit('cover-letter-generated')
// Perfect Match trigger — add inside fetchJob(), after clState and isSaved are set:
// if ((data.match_score ?? 0) >= PERFECT_MATCH_THRESHOLD) {
// shimmeringBadge.value = false
// nextTick(() => { shimmeringBadge.value = true })
// setTimeout(() => { shimmeringBadge.value = false }, 850)
// }
<template> changes:
- Remove the
<RouterLink to="/apply" class="workspace__back">← Back to Apply</RouterLink>element - Add
:class="{ 'score-badge--shimmer': shimmeringBadge }"to the score badge<span>in.job-details__badges
<style scoped> changes:
- Update
.score-badge--midand add.score-badge--mid-high:
.score-badge--high { background: rgba(39,174,96,0.12); color: var(--score-high); }
.score-badge--mid-high { background: rgba(43,124,184,0.12); color: var(--score-mid-high); }
.score-badge--mid { background: rgba(212,137,26,0.12); color: var(--score-mid); }
.score-badge--low { background: rgba(192,57,43,0.12); color: var(--score-low); }
/* Perfect Match shimmer — fires once when a ≥70% job opens */
@keyframes shimmer-badge {
0% { box-shadow: 0 0 0 0 rgba(212, 175, 55, 0); background: rgba(39,174,96,0.12); }
30% { box-shadow: 0 0 8px 3px rgba(212, 175, 55, 0.6); background: rgba(212, 175, 55, 0.2); }
100% { box-shadow: 0 0 0 0 rgba(212, 175, 55, 0); background: rgba(39,174,96,0.12); }
}
.score-badge--shimmer { animation: shimmer-badge 850ms ease-out forwards; }
- Move the
declare moduleaugmentation fromApplyWorkspaceView.vueto here:
declare module '../stores/review' {
interface Job { cover_letter?: string | null }
}
- Step 2: Slim down
ApplyWorkspaceView.vue
Replace the entire file content with:
<template>
<ApplyWorkspace
:job-id="jobId"
@job-removed="router.push('/apply')"
/>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import ApplyWorkspace from '../components/ApplyWorkspace.vue'
const route = useRoute()
const router = useRouter()
const jobId = computed(() => Number(route.params.id))
</script>
- Step 3: Run type-check
cd /Library/Development/CircuitForge/peregrine/.worktrees/feature-vue-spa/web
./node_modules/.bin/vue-tsc --noEmit
Expected: 0 errors. Fix any type errors before continuing. Common errors to expect: forgotten props.jobId rename (search for bare jobId in ApplyWorkspace.vue and confirm every instance is props.jobId); leftover useRoute/useRouter imports.
- Step 4: Smoke-test the standalone route
Start the dev stack (if not already running):
cd /Library/Development/CircuitForge/peregrine/.worktrees/feature-vue-spa
conda run -n job-seeker uvicorn dev-api:app --port 8601 --reload &
cd web && npm run dev
Navigate directly to http://localhost:5173/apply/1 (or any valid job ID from the staging DB). Verify:
-
The workspace loads the job correctly
-
"Mark as Applied" and "Reject Listing" navigate back to
/applyas before -
No console errors
-
Step 5: Run tests
./node_modules/.bin/vitest run
Expected: all existing tests still pass (3/3 in interviews.test.ts). The refactor should not touch any store logic.
- Step 6: Commit
cd /Library/Development/CircuitForge/peregrine/.worktrees/feature-vue-spa
git add web/src/components/ApplyWorkspace.vue web/src/views/ApplyWorkspaceView.vue
git commit -m "feat(apply): extract ApplyWorkspace component with job-removed emit and perfect match easter egg"
Task 3: Rebuild ApplyView.vue — Split Pane + Easter Eggs
Files:
- Replace:
web/src/views/ApplyView.vue
This replaces the entire file. The new ApplyView.vue is the split-pane orchestrator on desktop and the unchanged job list on mobile.
Key behaviors:
-
Desktop (≥1024px): CSS Grid split,
selectedJobIdlocal state,<ApplyWorkspace>panel -
Mobile (<1024px): full-width list,
RouterLinkto/apply/:id(unchanged) -
Speed Demon: track last 5 click timestamps; if 5 clicks in < 3s, fire bird animation + toast
-
Marathon:
coverLetterCountref incremented oncover-letter-generatedemit from child; badge appears after 5 -
Konami: verify it is already registered globally in
App.vue(see Step 1 below) — if so, no code needed here -
Step 1: Verify Konami is already global
grep -n "useKonamiCode" \
/Library/Development/CircuitForge/peregrine/.worktrees/feature-vue-spa/web/src/App.vue
Expected: a line like useKonamiCode(toggle) — confirming hacker mode is already wired globally. If that line is absent, add useKonamiCode + useHackerMode to ApplyView.vue per the useEasterEgg.ts composable API. (In practice it is there — this step just confirms it.)
- Step 2: Write the new
ApplyView.vue
<template>
<!-- ── Mobile: full-width list ──────────────────────────────────── -->
<div v-if="isMobile" class="apply-list">
<header class="apply-list__header">
<h1 class="apply-list__title">Apply</h1>
<p class="apply-list__subtitle">Approved jobs ready for applications</p>
</header>
<div v-if="loading" class="apply-list__loading" aria-live="polite">
<span class="spinner" aria-hidden="true" />
<span>Loading approved jobs…</span>
</div>
<div v-else-if="jobs.length === 0" class="apply-list__empty" role="status">
<span aria-hidden="true" class="empty-icon">📋</span>
<h2 class="empty-title">No approved jobs yet</h2>
<p class="empty-desc">Approve listings in Job Review, then come back here to write applications.</p>
<RouterLink to="/review" class="empty-cta">Go to Job Review →</RouterLink>
</div>
<ul v-else class="apply-list__jobs" role="list">
<li v-for="job in jobs" :key="job.id">
<RouterLink :to="`/apply/${job.id}`" class="job-row" :aria-label="`Open ${job.title} at ${job.company}`">
<div class="job-row__main">
<div class="job-row__badges">
<span v-if="job.match_score !== null" class="score-badge" :class="scoreBadgeClass(job.match_score)">
{{ job.match_score }}%
</span>
<span v-if="job.is_remote" class="remote-badge">Remote</span>
<span v-if="job.has_cover_letter" class="cl-badge cl-badge--done">✓ Draft</span>
<span v-else class="cl-badge cl-badge--pending">○ No draft</span>
</div>
<span class="job-row__title">{{ job.title }}</span>
<span class="job-row__company">
{{ job.company }}
<span v-if="job.location" class="job-row__sep" aria-hidden="true"> · </span>
<span v-if="job.location">{{ job.location }}</span>
</span>
</div>
<div class="job-row__meta">
<span v-if="job.salary" class="job-row__salary">{{ job.salary }}</span>
<span class="job-row__arrow" aria-hidden="true">›</span>
</div>
</RouterLink>
</li>
</ul>
</div>
<!-- ── Desktop: split pane ─────────────────────────────────────── -->
<div v-else class="apply-split" :class="{ 'has-selection': selectedJobId !== null }" ref="splitEl">
<!-- Left: narrow job list -->
<div class="apply-split__list">
<div class="split-list__header">
<h1 class="split-list__title">Apply</h1>
<span v-if="coverLetterCount >= 5" class="marathon-badge" title="You're on a roll!">
📬 {{ coverLetterCount }} today
</span>
</div>
<div v-if="loading" class="split-list__loading" aria-live="polite">
<span class="spinner" aria-hidden="true" />
</div>
<div v-else-if="jobs.length === 0" class="split-list__empty" role="status">
<span>No approved jobs yet.</span>
<RouterLink to="/review" class="split-list__cta">Go to Job Review →</RouterLink>
</div>
<ul v-else class="split-list__jobs" role="list">
<li v-for="job in jobs" :key="job.id">
<button
class="narrow-row"
:class="{ 'narrow-row--selected': job.id === selectedJobId }"
:aria-label="`Open ${job.title} at ${job.company}`"
:aria-pressed="job.id === selectedJobId"
@click="selectJob(job.id)"
>
<div class="narrow-row__top">
<span class="narrow-row__title">{{ job.title }}</span>
<span
v-if="job.match_score !== null"
class="score-badge"
:class="scoreBadgeClass(job.match_score)"
>{{ job.match_score }}%</span>
</div>
<div class="narrow-row__company">
{{ job.company }}<span v-if="job.has_cover_letter" class="narrow-row__cl-tick"> ✓</span>
</div>
</button>
</li>
</ul>
</div>
<!-- Right: workspace panel -->
<div class="apply-split__panel" aria-live="polite">
<!-- Empty state -->
<div v-if="selectedJobId === null" class="split-panel__empty">
<span aria-hidden="true" style="font-size: 2rem;">🦅</span>
<p>Select a job to open the workspace</p>
</div>
<!-- Workspace -->
<ApplyWorkspace
v-else
:key="selectedJobId"
:job-id="selectedJobId"
@job-removed="onJobRemoved"
@cover-letter-generated="onCoverLetterGenerated"
/>
</div>
<!-- Speed Demon canvas (hidden until triggered) -->
<canvas ref="birdCanvas" class="bird-canvas" aria-hidden="true" />
<!-- Toast -->
<Transition name="toast">
<div v-if="toast" class="split-toast" role="status" aria-live="polite">{{ toast }}</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { RouterLink } from 'vue-router'
import { useApiFetch } from '../composables/useApi'
import ApplyWorkspace from '../components/ApplyWorkspace.vue'
// ── Responsive ───────────────────────────────────────────────────────────────
const isMobile = ref(window.innerWidth < 1024)
let _mq: MediaQueryList | null = null
let _mqHandler: ((e: MediaQueryListEvent) => void) | null = null
onMounted(() => {
_mq = window.matchMedia('(max-width: 1023px)')
_mqHandler = (e: MediaQueryListEvent) => { isMobile.value = e.matches }
_mq.addEventListener('change', _mqHandler)
})
onUnmounted(() => {
if (_mq && _mqHandler) _mq.removeEventListener('change', _mqHandler)
})
// ── Job list data ─────────────────────────────────────────────────────────────
interface ApprovedJob {
id: number
title: string
company: string
location: string | null
is_remote: boolean
salary: string | null
match_score: number | null
has_cover_letter: boolean
}
const jobs = ref<ApprovedJob[]>([])
const loading = ref(true)
async function fetchJobs() {
loading.value = true
const { data } = await useApiFetch<ApprovedJob[]>(
'/api/jobs?status=approved&limit=100&fields=id,title,company,location,is_remote,salary,match_score,has_cover_letter'
)
loading.value = false
if (data) jobs.value = data
}
onMounted(fetchJobs)
// ── Score badge — 4-tier ──────────────────────────────────────────────────────
function scoreBadgeClass(score: number | null): string {
if (score === null) return ''
if (score >= 70) return 'score-badge--high'
if (score >= 50) return 'score-badge--mid-high'
if (score >= 30) return 'score-badge--mid'
return 'score-badge--low'
}
// ── Selection ─────────────────────────────────────────────────────────────────
const selectedJobId = ref<number | null>(null)
// Speed Demon: track up to 5 most-recent click timestamps
// Plain let (not ref) — never bound to template, no reactivity needed
let recentClicks: number[] = []
function selectJob(id: number) {
selectedJobId.value = id
// Speed Demon tracking
const now = Date.now()
recentClicks = [...recentClicks, now].slice(-5)
if (
recentClicks.length === 5 &&
recentClicks[4] - recentClicks[0] < 3000
) {
fireSpeedDemon()
recentClicks = []
}
}
// ── Job removed ───────────────────────────────────────────────────────────────
async function onJobRemoved() {
selectedJobId.value = null
await fetchJobs()
}
// ── Marathon counter ──────────────────────────────────────────────────────────
const coverLetterCount = ref(0)
function onCoverLetterGenerated() {
coverLetterCount.value++
}
// ── Toast ─────────────────────────────────────────────────────────────────────
const toast = ref<string | null>(null)
let toastTimer = 0
function showToast(msg: string) {
clearTimeout(toastTimer)
toast.value = msg
toastTimer = window.setTimeout(() => { toast.value = null }, 2500)
}
// ── Easter egg: Speed Demon 🦅 ────────────────────────────────────────────────
const birdCanvas = ref<HTMLCanvasElement | null>(null)
const splitEl = ref<HTMLElement | null>(null)
function fireSpeedDemon() {
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
showToast('🦅 You\'re on the hunt!')
return
}
const canvas = birdCanvas.value
const parent = splitEl.value
if (!canvas || !parent) return
const rect = parent.getBoundingClientRect()
canvas.width = rect.width
canvas.height = rect.height
canvas.style.display = 'block'
const ctx = canvas.getContext('2d')!
const FRAMES = 36 // 600ms at 60fps
const startY = rect.height * 0.35
let frame = 0
function draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height)
const progress = frame / FRAMES
const x = progress * (canvas.width + 60) - 30
const y = startY + Math.sin(progress * Math.PI) * -30 // slight arc up then down
ctx.font = '2rem serif'
ctx.globalAlpha = frame < 4 ? frame / 4 : frame > FRAMES - 4 ? (FRAMES - frame) / 4 : 1
ctx.fillText('🦅', x, y)
frame++
if (frame <= FRAMES) {
requestAnimationFrame(draw)
} else {
ctx.clearRect(0, 0, canvas.width, canvas.height)
canvas.style.display = 'none'
showToast('🦅 You\'re on the hunt!')
}
}
requestAnimationFrame(draw)
}
</script>
<style scoped>
/* ── Shared: spinner ─────────────────────────────────────────────── */
.spinner {
display: inline-block;
width: 1.2rem;
height: 1.2rem;
border: 2px solid var(--color-border);
border-top-color: var(--app-primary);
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* ── Shared: score badges ────────────────────────────────────────── */
.score-badge {
display: inline-flex;
align-items: center;
padding: 1px var(--space-2);
border-radius: 999px;
font-size: var(--text-xs);
font-weight: 700;
font-family: var(--font-mono);
flex-shrink: 0;
}
.score-badge--high { background: rgba(39,174,96,0.12); color: var(--score-high); }
.score-badge--mid-high { background: rgba(43,124,184,0.12); color: var(--score-mid-high); }
.score-badge--mid { background: rgba(212,137,26,0.12); color: var(--score-mid); }
.score-badge--low { background: rgba(192,57,43,0.12); color: var(--score-low); }
.remote-badge {
padding: 1px var(--space-2);
border-radius: 999px;
font-size: var(--text-xs);
font-weight: 600;
background: var(--app-primary-light);
color: var(--app-primary);
}
/* ── Mobile list (unchanged from original) ───────────────────────── */
.apply-list {
max-width: 760px;
margin: 0 auto;
padding: var(--space-8) var(--space-6);
display: flex;
flex-direction: column;
gap: var(--space-6);
}
.apply-list__header { display: flex; flex-direction: column; gap: var(--space-1); }
.apply-list__title { font-family: var(--font-display); font-size: var(--text-2xl); color: var(--app-primary); }
.apply-list__subtitle { font-size: var(--text-sm); color: var(--color-text-muted); }
.apply-list__loading { display: flex; align-items: center; gap: var(--space-3); padding: var(--space-12); color: var(--color-text-muted); font-size: var(--text-sm); justify-content: center; }
.apply-list__empty { display: flex; flex-direction: column; align-items: center; gap: var(--space-3); padding: var(--space-16) var(--space-8); text-align: center; }
.empty-icon { font-size: 3rem; }
.empty-title { font-family: var(--font-display); font-size: var(--text-xl); color: var(--color-text); }
.empty-desc { font-size: var(--text-sm); color: var(--color-text-muted); max-width: 32ch; }
.empty-cta { margin-top: var(--space-2); color: var(--app-primary); font-size: var(--text-sm); font-weight: 600; text-decoration: none; }
.empty-cta:hover { opacity: 0.7; }
.apply-list__jobs { list-style: none; display: flex; flex-direction: column; gap: var(--space-2); }
.job-row { display: flex; align-items: center; justify-content: space-between; gap: var(--space-4); padding: var(--space-4) var(--space-5); background: var(--color-surface-raised); border: 1px solid var(--color-border-light); border-radius: var(--radius-lg); text-decoration: none; min-height: 72px; transition: border-color 150ms ease, box-shadow 150ms ease, transform 120ms ease; }
.job-row:hover { border-color: var(--app-primary); box-shadow: var(--shadow-sm); transform: translateY(-1px); }
.job-row__main { display: flex; flex-direction: column; gap: var(--space-1); flex: 1; min-width: 0; }
.job-row__badges { display: flex; flex-wrap: wrap; gap: var(--space-1); margin-bottom: 2px; }
.job-row__title { font-size: var(--text-sm); font-weight: 700; color: var(--color-text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.job-row__company { font-size: var(--text-xs); color: var(--color-text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.job-row__meta { display: flex; align-items: center; gap: var(--space-3); flex-shrink: 0; }
.job-row__salary { font-size: var(--text-xs); color: var(--color-success); font-weight: 600; white-space: nowrap; }
.job-row__arrow { font-size: 1.25rem; color: var(--color-text-muted); line-height: 1; }
.job-row__sep { color: var(--color-border); }
.cl-badge { padding: 1px var(--space-2); border-radius: 999px; font-size: var(--text-xs); font-weight: 600; }
.cl-badge--done { background: rgba(39,174,96,0.10); color: var(--color-success); }
.cl-badge--pending { background: var(--color-surface-alt); color: var(--color-text-muted); }
/* ── Desktop split pane ──────────────────────────────────────────── */
.apply-split {
position: relative;
display: grid;
grid-template-columns: 28% 0fr;
height: calc(100vh - var(--nav-height, 4rem));
overflow: hidden;
transition: grid-template-columns 200ms ease-out;
}
@media (prefers-reduced-motion: reduce) {
.apply-split { transition: none; }
}
.apply-split.has-selection {
grid-template-columns: 28% 1fr;
}
/* ── Left: narrow list column ────────────────────────────────────── */
.apply-split__list {
display: flex;
flex-direction: column;
border-right: 1px solid var(--color-border-light);
overflow: hidden;
}
.split-list__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-5) var(--space-4) var(--space-3);
border-bottom: 1px solid var(--color-border-light);
flex-shrink: 0;
}
.split-list__title {
font-family: var(--font-display);
font-size: var(--text-xl);
color: var(--app-primary);
}
/* Marathon badge */
.marathon-badge {
font-size: var(--text-xs);
font-weight: 700;
padding: 2px var(--space-2);
border-radius: 999px;
background: rgba(224, 104, 32, 0.12);
color: var(--app-accent);
border: 1px solid rgba(224, 104, 32, 0.3);
cursor: default;
}
.split-list__loading {
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-8);
}
.split-list__empty {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-2);
padding: var(--space-8) var(--space-4);
text-align: center;
font-size: var(--text-sm);
color: var(--color-text-muted);
}
.split-list__cta {
color: var(--app-primary);
font-size: var(--text-xs);
font-weight: 600;
text-decoration: none;
}
.split-list__jobs {
list-style: none;
overflow-y: auto;
flex: 1;
}
/* ── Narrow row ──────────────────────────────────────────────────── */
.narrow-row {
width: 100%;
display: flex;
flex-direction: column;
gap: 2px;
padding: var(--space-3) var(--space-4);
background: none;
border: none;
border-left: 3px solid transparent;
border-bottom: 1px solid var(--color-border-light);
cursor: pointer;
text-align: left;
transition: background 100ms ease, border-left-color 100ms ease;
}
.narrow-row:hover {
background: var(--app-primary-light);
border-left-color: rgba(43, 108, 176, 0.3);
}
.narrow-row--selected {
background: var(--app-primary-light);
/* color-mix enhancement for supported browsers */
background: color-mix(in srgb, var(--app-primary) 8%, var(--color-surface-raised));
border-left-color: var(--app-primary);
}
.narrow-row__top {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
min-width: 0;
}
.narrow-row__title {
font-size: var(--text-sm);
font-weight: 700;
color: var(--color-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
min-width: 0;
}
.narrow-row__company {
font-size: var(--text-xs);
color: var(--color-text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.narrow-row__cl-tick {
color: var(--color-success);
font-weight: 700;
}
/* ── Right: workspace panel ──────────────────────────────────────── */
.apply-split__panel {
min-width: 0;
overflow: clip; /* clip prevents BFC side-effect of hidden; also lets position:sticky work inside */
overflow-y: auto;
height: 100%;
opacity: 0;
transition: opacity 150ms ease 100ms; /* 100ms delay so content fades in after column expands */
}
.apply-split.has-selection .apply-split__panel {
opacity: 1;
}
@media (prefers-reduced-motion: reduce) {
.apply-split__panel { transition: none; opacity: 1; }
}
.split-panel__empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-3);
height: 100%;
color: var(--color-text-muted);
font-size: var(--text-sm);
}
/* ── Easter egg: Speed Demon canvas ─────────────────────────────── */
.bird-canvas {
display: none;
position: absolute;
inset: 0;
pointer-events: none;
z-index: 50;
}
/* ── Toast ───────────────────────────────────────────────────────── */
.split-toast {
position: absolute;
bottom: var(--space-6);
right: var(--space-6);
background: var(--color-surface-raised);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--space-3) var(--space-5);
font-size: var(--text-sm);
color: var(--color-text);
box-shadow: var(--shadow-lg);
z-index: 100;
white-space: nowrap;
}
.toast-enter-active, .toast-leave-active { transition: opacity 200ms ease, transform 200ms ease; }
.toast-enter-from, .toast-leave-to { opacity: 0; transform: translateY(6px); }
/* ── Mobile overrides ────────────────────────────────────────────── */
@media (max-width: 767px) {
.apply-list { padding: var(--space-4); gap: var(--space-4); }
.apply-list__title { font-size: var(--text-xl); }
.job-row { padding: var(--space-3) var(--space-4); }
}
</style>
- Step 2: Run type-check
cd /Library/Development/CircuitForge/peregrine/.worktrees/feature-vue-spa/web
./node_modules/.bin/vue-tsc --noEmit
Expected: 0 errors. Fix any type errors before continuing.
- Step 3: Smoke-test in the browser
Start the dev stack:
# Terminal 1
cd /Library/Development/CircuitForge/peregrine/.worktrees/feature-vue-spa
conda run -n job-seeker uvicorn dev-api:app --port 8601 --reload &
# Terminal 2
cd web && npm run dev
Open http://localhost:5173/apply and verify:
-
Desktop (≥1024px): split pane renders, list is narrow on left, right shows empty state with 🦅
-
Click a job → panel expands from the divider with animation; workspace loads
-
Click another job → panel content switches, selected row highlight updates
-
Mark a job as Applied → panel closes, job disappears from list
-
Mobile emulation (DevTools → 375px) → single-column list with RouterLink navigation (no split)
-
Step 4: Test Speed Demon easter egg
Quickly click 5 different jobs within 3 seconds. Expected: 🦅 streaks across the panel, "You're on the hunt!" toast appears.
With DevTools → Rendering → prefers-reduced-motion: reduce: toast only, no canvas animation.
- Step 5: Test Marathon easter egg
Generate cover letters for 5 jobs (or temporarily lower the threshold to 2 for testing, then revert). Expected: 📬 5 today badge appears in list header. Tooltip on hover: "You're on a roll!".
- Step 6: Commit
cd /Library/Development/CircuitForge/peregrine/.worktrees/feature-vue-spa
git add web/src/views/ApplyView.vue
git commit -m "feat(apply): desktop split-pane layout with narrow list, expand animation, speed demon + marathon easter eggs"
Task 4: Type-Check and Test Suite
Files:
-
No changes — verification only
-
Step 1: Run full type-check
cd /Library/Development/CircuitForge/peregrine/.worktrees/feature-vue-spa/web
./node_modules/.bin/vue-tsc --noEmit
Expected: 0 errors.
- Step 2: Run full test suite
./node_modules/.bin/vitest run
Expected: all tests pass (minimum 3 from interviews.test.ts; any other tests that exist).
- Step 3: Commit fixes if needed
If any fixes were required:
cd /Library/Development/CircuitForge/peregrine/.worktrees/feature-vue-spa
git add -p
git commit -m "fix(apply): type-check and test fixes"
Done Criteria
--score-mid-highCSS token added;.score-badge--mid-highclass worksscoreBadgeClass()uses 4-tier thresholds (≥70 / ≥50 / ≥30 / else) in all apply-flow filesApplyWorkspace.vuerenders the full workspace from ajobId: numberpropApplyWorkspace.vueemitsjob-removedon mark-applied / reject-listingApplyWorkspace.vueemitscover-letter-generatedwhen polling completes- Perfect Match shimmer fires once when a ≥70% job opens (
.score-badge--shimmerkeyframe) ApplyWorkspaceView.vueis a thin wrapper with<ApplyWorkspace :job-id="..." @job-removed="...">- Desktop (≥1024px): 28/72 CSS Grid split with
grid-template-columnstransition - Panel expand animation uses
overflow: clip+min-width: 0(notoverflow: hidden) - Panel content fades in with 100ms delay after column expands
prefers-reduced-motion: no grid transition, no canvas animation (toast only for Speed Demon)- Narrow list rows: title + score badge (top row), company + ✓ tick (bottom row)
- Selected row: border-left accent + tinted background (
color-mixwith--app-primary-lightfallback) - Empty panel state shows 🦅 + "Select a job to open the workspace"
@job-removedclearsselectedJobId+ re-fetches job list- Speed Demon: 5 clicks in <3s → canvas bird + toast (reduced-motion: toast only)
- Marathon: 5+ cover letters in session →
📬 N todaybadge in list header - Konami: already global in
App.vue— no additional code needed - Mobile (<1024px): unchanged — full-width list with
RouterLinknavigation - Type-check: 0 errors; all tests pass