peregrine/docs/superpowers/plans/2026-03-19-apply-split-pane.md

934 lines
36 KiB
Markdown
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.

# 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 5069%** (new), amber 3049%, 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-high` token and `.score-badge--mid-high` class**
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`:
```css
--score-high: var(--color-success); /* ≥ 70% */
--score-mid-high: #2b7cb8; /* 5069% — Falcon Blue variant */
--score-mid: var(--color-warning); /* 3049% */
--score-low: var(--color-error); /* < 30% */
--score-none: var(--color-text-muted);
```
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:
```css
--score-mid-high: #5ba3d9; /* lighter blue for dark bg */
```
Also update the existing `--score-mid` comment from `/* 4069% */` to `/* 3049% */` to keep the inline documentation accurate.
- [ ] **Step 2: Verify the token exists by checking the file**
```bash
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**
```bash
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`:**
1. `jobId` comes from prop, not `useRoute()` remove the `useRoute()`, `useRouter()`, and `RouterLink` imports
2. All API calls use `props.jobId` instead of the old module-level `const jobId`. The exact locations: `fetchJob()`, `pollTaskStatus()`, `generate()`, `saveCoverLetter()`, `downloadPdf()`, `markApplied()`, `rejectListing()`, and the in-flight task check inside `onMounted`
3. `markApplied` / `rejectListing`: emit `job-removed` instead of calling `router.push('/apply')`
4. `generate()` polling: emit `cover-letter-generated` when status transitions to `completed`
5. Remove the `← Back to Apply` `RouterLink` (only needed in the standalone route context)
6. **Preserve `onUnmounted`** `stopPolling()` + `clearTimeout(toastTimer)` cleanup is critical: the component can now unmount mid-session when the user selects a different job
7. `declare module '../stores/review'` augmentation moves here (path `'../stores/review'` is correct from `components/` resolves to `src/stores/review`)
8. Updated 4-tier `scoreBadgeClass` + `.score-badge--mid-high` class
9. `PERFECT_MATCH_THRESHOLD = 70` const + 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:**
```typescript
// 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--mid` and add `.score-badge--mid-high`:
```css
.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 module` augmentation from `ApplyWorkspaceView.vue` to here:
```typescript
declare module '../stores/review' {
interface Job { cover_letter?: string | null }
}
```
- [ ] **Step 2: Slim down `ApplyWorkspaceView.vue`**
Replace the entire file content with:
```vue
<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**
```bash
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):
```bash
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 `/apply` as before
- No console errors
- [ ] **Step 5: Run tests**
```bash
./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**
```bash
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, `selectedJobId` local state, `<ApplyWorkspace>` panel
- Mobile (<1024px): full-width list, `RouterLink` to `/apply/:id` (unchanged)
- Speed Demon: track last 5 click timestamps; if 5 clicks in < 3s, fire bird animation + toast
- Marathon: `coverLetterCount` ref incremented on `cover-letter-generated` emit 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**
```bash
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`**
```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**
```bash
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:
```bash
# 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**
```bash
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**
```bash
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**
```bash
./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:
```bash
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-high` CSS token added; `.score-badge--mid-high` class works
- [ ] `scoreBadgeClass()` uses 4-tier thresholds (≥70 / 50 / 30 / else) in all apply-flow files
- [ ] `ApplyWorkspace.vue` renders the full workspace from a `jobId: number` prop
- [ ] `ApplyWorkspace.vue` emits `job-removed` on mark-applied / reject-listing
- [ ] `ApplyWorkspace.vue` emits `cover-letter-generated` when polling completes
- [ ] Perfect Match shimmer fires once when a 70% job opens (`.score-badge--shimmer` keyframe)
- [ ] `ApplyWorkspaceView.vue` is a thin wrapper with `<ApplyWorkspace :job-id="..." @job-removed="...">`
- [ ] Desktop (≥1024px): 28/72 CSS Grid split with `grid-template-columns` transition
- [ ] Panel expand animation uses `overflow: clip` + `min-width: 0` (not `overflow: 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-mix` with `--app-primary-light` fallback)
- [ ] Empty panel state shows 🦅 + "Select a job to open the workspace"
- [ ] `@job-removed` clears `selectedJobId` + re-fetches job list
- [ ] Speed Demon: 5 clicks in <3s canvas bird + toast (reduced-motion: toast only)
- [ ] Marathon: 5+ cover letters in session `📬 N today` badge in list header
- [ ] Konami: already global in `App.vue` no additional code needed
- [ ] Mobile (<1024px): unchanged full-width list with `RouterLink` navigation
- [ ] Type-check: 0 errors; all tests pass