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

36 KiB
Raw Blame History

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:

  --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:

  --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
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:

  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 onUnmountedstopPolling() + 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:

// 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:
.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:
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 /apply as 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, 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

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