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

181 lines
8 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 Design
**Date:** 2026-03-19
**Status:** Approved — ready for implementation planning
---
## Goal
Refactor the Apply view for desktop: replace the centered 760px list → full-page-navigation pattern with a persistent master-detail split pane. The left column holds a compact job list; clicking a job expands the cover letter workspace inline to the right. Mobile layout is unchanged.
---
## Decisions Made
| Decision | Choice | Future option |
|---|---|---|
| Split ratio | 28% list / 72% workspace (fixed) | Resizable drag handle (Option C) |
| Panel open animation | Expand from list divider edge (~200ms) | — |
| URL routing | Local state only — URL stays at `/apply` | URL-synced selection for deep linking |
| List row density | Option A: title + company + score badge, truncated | C (company+score only), D (wrapped/taller) via future layout selector |
---
## Layout
### Desktop (≥ 768px)
```
┌──────────────────────────────────────────────────────────────────┐
│ [NAV 220px] │ List (28%) │ Workspace (72%) │
│ │ ─────────────────── │ ──────────────────────── │
│ │ 25 jobs │ Sr. Software Engineer │
│ │ ▶ Sr. SWE Acme 87% │ Acme Corp │
│ │ FS Dev Globex 72% │ │
│ │ Backend Init 58% │ [Cover letter editor] │
│ │ ... │ [Actions: Generate / PDF] │
└──────────────────────────────────────────────────────────────────┘
```
- App nav sidebar (220px fixed, existing) + split pane fills the remainder
- No job selected → right panel shows a warm empty state: `← Select a job to open the workspace`
- The `max-width: 760px` constraint on `.apply-list` is removed for desktop; it remains (full-width) on mobile
### Mobile (< 768px)
No changes. Existing styles, full-width list, `RouterLink` navigation to `/apply/:id`.
---
## Component Architecture
### Current
```
ApplyView.vue → list only, RouterLink to /apply/:id
ApplyWorkspaceView.vue → full page, reads :id from route params
```
### New
```
ApplyView.vue → split pane (desktop) OR list (mobile)
├─ [left] NarrowJobList (inline, not a separate component — kept in ApplyView)
└─ [right] ApplyWorkspace.vue (new component, accepts :jobId prop)
ApplyWorkspaceView.vue → unchanged route wrapper; renders <ApplyWorkspace :job-id="route.params.id" />
```
**Why extract `ApplyWorkspace.vue`?** The workspace is now rendered in two contexts: the split pane (inline) and the existing `/apply/:id` route (for mobile and any future deep-link support). Extracting it as a prop-driven component avoids duplication.
---
## Narrow Job List (left panel)
**Row layout — Option A:**
```
┌─────────────────────────────────────┐
│ Sr. Software Engineer [87%]│ ← title truncated, score right-aligned
│ Acme Corp │ ← company truncated
└─────────────────────────────────────┘
```
- Score badge color-coded: green ≥ 70%, blue 5069%, yellow 3049%, red < 30%
- `has_cover_letter` shown as a subtle `✓` prefix on the company line (no separate badge space is tight)
- Selected row: `border-left: 3px solid var(--app-primary)` accent + tinted background (`color-mix(in srgb, var(--app-primary) 8%, var(--color-surface-raised))`)
- Hover: same border-left treatment at lower opacity
- `salary`, `location`, `is_remote` badge moved to the workspace header not shown in the narrow list
- List scrolls independently; workspace panel is sticky
---
## Panel Open Animation
CSS Grid column transition most reliable cross-browser approach for "grow from divider" effect:
```css
.apply-split {
display: grid;
grid-template-columns: 28% 0fr; /* collapsed: panel has 0 width */
transition: grid-template-columns 200ms ease-out;
}
.apply-split.has-selection {
grid-template-columns: 28% 1fr; /* expanded */
}
```
The panel column itself has `overflow: hidden`, so content is clipped during expansion. A `opacity: 0 → 1` fade on the panel content (100ms delay, 150ms duration) layers on top so content doesn't flash half-rendered mid-expand.
`prefers-reduced-motion`: skip the grid transition and opacity fade; panel appears instantly.
---
## Empty State (no job selected)
Shown in the right panel when `selectedJobId === null`:
```
🦅
Select a job to open
the workspace
```
Centered vertically, subdued text color, small bird emoji. Disappears the moment a job is selected (no transition needed the panel content crossfades in).
---
## Easter Eggs
All four easter eggs are scoped to `ApplyView.vue` / `ApplyWorkspace.vue`:
### 1. Speed Demon 🦅
- **Trigger:** User clicks 5+ different jobs in under 3 seconds
- **Effect:** A `<canvas>`-based 🦅 streaks horizontally across the panel area (left right, 600ms), followed by a brief "you're on the hunt" toast (2s, bottom-right)
- **`prefers-reduced-motion`:** Toast only, no canvas animation
### 2. Perfect Match ✨
- **Trigger:** A job with `match_score ≥ 70` is opened in the workspace
- **Effect:** The score badge in the workspace header plays a golden shimmer (`box-shadow` + `background` keyframe, 800ms, runs once per job open)
- **Threshold:** Stored as `const PERFECT_MATCH_THRESHOLD = 70` easy to tune when scoring improves
- **Note:** Current scoring rarely exceeds 40%; this easter egg may be dormant until the scoring algorithm is tuned. That's fine it's a delight for when it does fire.
### 3. Cover Letter Marathon 📬
- **Trigger:** 5th cover letter generated in a single session (session-scoped counter in the Pinia store or component ref)
- **Effect:** A subtle streak badge appears in the list panel header: `📬 5 today` with a warm amber glow. Increments with each subsequent generation. Disappears on page refresh.
- **Tooltip:** "You're on a roll!" on hover
### 4. Konami Code 🎮
- **Trigger:** ↑↑↓↓←→←→BA (standard Konami sequence), detected anywhere on the Apply view
- **Effect:** Activates hacker mode (`document.documentElement.setAttribute('data-theme', 'hacker')`) consistent with the cross-product Konami standard
- **Implementation:** `useKonami()` composable (shared if it exists, else add to `composables/`)
---
## What Stays the Same
- `/apply/:id` route still exists, still works (used by mobile nav and future deep links)
- `ApplyWorkspaceView.vue` becomes a thin wrapper around `<ApplyWorkspace :job-id="id" />`
- All existing mobile breakpoint styles in `ApplyView.vue`
- The `useApiFetch` data fetching pattern
- The `scoreBadgeClass()` utility
---
## Future Options (do not implement now)
- **Resizable split:** drag handle between panels, persisted in `localStorage` as `apply.splitRatio`
- **URL-synced selection:** update route to `/apply/:id` on selection; back button closes panel
- **Layout selector:** density toggle (icon buttons in list header) offering Option C (company+score only) and Option D (wrapped/taller cards). Persisted in `localStorage` as `apply.listDensity`.
---
## Files
| File | Action |
|---|---|
| `web/src/views/ApplyView.vue` | Replace: split-pane layout, narrow list, easter eggs |
| `web/src/components/ApplyWorkspace.vue` | Create: extracted from `ApplyWorkspaceView.vue`, accepts `jobId` prop |
| `web/src/views/ApplyWorkspaceView.vue` | Modify: thin wrapper `<ApplyWorkspace :job-id="route.params.id" />` |
| `web/src/composables/useKonami.ts` | Create (if not exists): Konami sequence detector composable |