docs(apply): fix spec review issues (overflow:clip, CSS var, threshold note)
This commit is contained in:
parent
5959860deb
commit
84e6ec1f43
1 changed files with 78 additions and 45 deletions
|
|
@ -15,7 +15,7 @@ Refactor the Apply view for desktop: replace the centered 760px list → full-pa
|
||||||
|
|
||||||
| Decision | Choice | Future option |
|
| Decision | Choice | Future option |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Split ratio | 28% list / 72% workspace (fixed) | Resizable drag handle (Option C) |
|
| Split ratio | 28% list / 72% workspace (fixed) | Resizable drag handle |
|
||||||
| Panel open animation | Expand from list divider edge (~200ms) | — |
|
| Panel open animation | Expand from list divider edge (~200ms) | — |
|
||||||
| URL routing | Local state only — URL stays at `/apply` | URL-synced selection for deep linking |
|
| 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 |
|
| List row density | Option A: title + company + score badge, truncated | C (company+score only), D (wrapped/taller) via future layout selector |
|
||||||
|
|
@ -24,7 +24,9 @@ Refactor the Apply view for desktop: replace the centered 760px list → full-pa
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
### Desktop (≥ 768px)
|
### Desktop (≥ 1024px)
|
||||||
|
|
||||||
|
The split pane activates at 1024px — the same breakpoint where the app nav sidebar collapses (`App.vue` `max-width: 1023px`). This ensures the two-column layout never renders without its sidebar, avoiding an uncomfortably narrow list column.
|
||||||
|
|
||||||
```
|
```
|
||||||
┌──────────────────────────────────────────────────────────────────┐
|
┌──────────────────────────────────────────────────────────────────┐
|
||||||
|
|
@ -38,13 +40,12 @@ Refactor the Apply view for desktop: replace the centered 760px list → full-pa
|
||||||
└──────────────────────────────────────────────────────────────────┘
|
└──────────────────────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
- 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` (desktop-only — the empty state element is conditionally rendered only inside the split layout, not the mobile list)
|
||||||
- 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
|
||||||
- The `max-width: 760px` constraint on `.apply-list` is removed for desktop; it remains (full-width) on mobile
|
|
||||||
|
|
||||||
### Mobile (< 768px)
|
### Mobile (< 1024px)
|
||||||
|
|
||||||
No changes. Existing styles, full-width list, `RouterLink` navigation to `/apply/:id`.
|
No changes. Existing full-width list + `RouterLink` navigation to `/apply/:id`. All existing mobile breakpoint styles are preserved.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -61,13 +62,18 @@ ApplyWorkspaceView.vue → full page, reads :id from route params
|
||||||
|
|
||||||
```
|
```
|
||||||
ApplyView.vue → split pane (desktop) OR list (mobile)
|
ApplyView.vue → split pane (desktop) OR list (mobile)
|
||||||
├─ [left] NarrowJobList (inline, not a separate component — kept in ApplyView)
|
├─ [left] Narrow job list (inline in ApplyView — not a separate component)
|
||||||
└─ [right] ApplyWorkspace.vue (new component, accepts :jobId prop)
|
└─ [right] ApplyWorkspace.vue (new component, :job-id prop)
|
||||||
|
|
||||||
ApplyWorkspaceView.vue → unchanged route wrapper; renders <ApplyWorkspace :job-id="route.params.id" />
|
ApplyWorkspaceView.vue → thin wrapper: <ApplyWorkspace :job-id="Number(route.params.id)" />
|
||||||
|
ApplyWorkspace.vue → extracted workspace content; accepts jobId: number prop
|
||||||
```
|
```
|
||||||
|
|
||||||
**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.
|
**Why extract `ApplyWorkspace.vue`?** The workspace now renders in two contexts: the split pane (inline, `jobId` from local state) and the existing `/apply/:id` route (for mobile + future deep links). Extracting it as a prop-driven component avoids duplication.
|
||||||
|
|
||||||
|
**`jobId` prop type:** `number`. The wrapper in `ApplyWorkspaceView.vue` does `Number(route.params.id)` before passing it. `ApplyWorkspace.vue` receives a `number` and never touches `route.params` directly.
|
||||||
|
|
||||||
|
**`declare module` augmentation:** The `declare module '@/stores/review'` block in the current `ApplyWorkspaceView.vue` (if present) moves into `ApplyWorkspace.vue`, not the thin wrapper.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -78,43 +84,73 @@ ApplyWorkspaceView.vue → unchanged route wrapper; renders <ApplyWorkspace :job
|
||||||
```
|
```
|
||||||
┌─────────────────────────────────────┐
|
┌─────────────────────────────────────┐
|
||||||
│ Sr. Software Engineer [87%]│ ← title truncated, score right-aligned
|
│ Sr. Software Engineer [87%]│ ← title truncated, score right-aligned
|
||||||
│ Acme Corp │ ← company truncated
|
│ Acme Corp ✓ │ ← company truncated; ✓ if has_cover_letter
|
||||||
└─────────────────────────────────────┘
|
└─────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
- Score badge color-coded: green ≥ 70%, blue 50–69%, yellow 30–49%, red < 30%
|
- The existing `cl-badge` (`✓ Draft` / `○ No draft`) badge row is **removed** from the narrow list. Cover letter status is indicated by a subtle `✓` suffix on the company line only when `has_cover_letter === true`. No badge for "no draft" — the absence of `✓` is sufficient signal at this density.
|
||||||
- `has_cover_letter` shown as a subtle `✓` prefix on the company line (no separate badge — space is tight)
|
- **Score badge color thresholds (unified — replaces old 3-tier system in the apply flow):**
|
||||||
- Selected row: `border-left: 3px solid var(--app-primary)` accent + tinted background (`color-mix(in srgb, var(--app-primary) 8%, var(--color-surface-raised))`)
|
- Green `score-badge--high`: ≥ 70%
|
||||||
- Hover: same border-left treatment at lower opacity
|
- Blue `score-badge--mid-high`: 50–69%
|
||||||
- `salary`, `location`, `is_remote` badge moved to the workspace header — not shown in the narrow list
|
- Amber `score-badge--mid`: 30–49%
|
||||||
- List scrolls independently; workspace panel is sticky
|
- Red `score-badge--low`: < 30%
|
||||||
|
- This 4-tier scheme applies in both the narrow list and the workspace header, replacing the previous `≥80 / ≥60 / else` thresholds. The `.score-badge--mid-high` class is new and needs adding to the shared badge CSS.
|
||||||
|
- Selected row: `border-left: 3px solid var(--app-primary)` accent + tinted background. Use `var(--app-primary-light)` as the primary fallback; `color-mix(in srgb, var(--app-primary) 8%, var(--color-surface-raised))` as the enhancement for browsers that support it (Chrome 111+, Firefox 113+, Safari 16.2+).
|
||||||
|
- Hover: same border-left treatment at 40% opacity
|
||||||
|
- `salary`, `location`, `is_remote` badge: shown in the workspace header only — not in the narrow list
|
||||||
|
- List scrolls independently within its column
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Panel Open Animation
|
## Panel Open Animation
|
||||||
|
|
||||||
CSS Grid column transition — most reliable cross-browser approach for "grow from divider" effect:
|
CSS Grid column transition on the `.apply-split` root element:
|
||||||
|
|
||||||
```css
|
```css
|
||||||
.apply-split {
|
.apply-split {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 28% 0fr; /* collapsed: panel has 0 width */
|
grid-template-columns: 28% 0fr;
|
||||||
transition: grid-template-columns 200ms ease-out;
|
transition: grid-template-columns 200ms ease-out;
|
||||||
}
|
}
|
||||||
.apply-split.has-selection {
|
.apply-split.has-selection {
|
||||||
grid-template-columns: 28% 1fr; /* expanded */
|
grid-template-columns: 28% 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Required: prevent intrinsic min-content from blocking collapse */
|
||||||
|
.apply-split__panel {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: clip; /* clip (not hidden) — hidden creates a new stacking context
|
||||||
|
and blocks position:sticky children inside the workspace */
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
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.
|
`min-width: 0` on `.apply-split__panel` is required — without it, the panel's intrinsic content width prevents the `0fr` column from collapsing to zero.
|
||||||
|
|
||||||
`prefers-reduced-motion`: skip the grid transition and opacity fade; panel appears instantly.
|
Panel content fades in on top of the expand: `opacity: 0 → 1` with a 100ms delay and 150ms duration, so content doesn't flash half-rendered mid-expand.
|
||||||
|
|
||||||
|
**Panel height:** The right panel uses `height: calc(100vh - var(--app-header-height, 4rem))` with `overflow-y: auto` so the workspace scrolls independently within the column. Use a CSS variable rather than a bare literal so height stays correct if the nav height changes.
|
||||||
|
|
||||||
|
**`prefers-reduced-motion`:** Skip the grid transition and opacity fade; panel appears and content shows instantly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Post-Action Behavior (Mark Applied / Reject)
|
||||||
|
|
||||||
|
In the current `ApplyWorkspaceView.vue`, both `markApplied()` and `rejectListing()` call `router.push('/apply')` after success — fine for a full-page route.
|
||||||
|
|
||||||
|
In the embedded split-pane context, `router.push('/apply')` is a no-op (already there), but `selectedJobId` must also be cleared and the job list refreshed. `ApplyWorkspace.vue` emits a `job-removed` event when either action completes. `ApplyView.vue` handles it:
|
||||||
|
|
||||||
|
```
|
||||||
|
@job-removed="onJobRemoved()" → selectedJobId = null + re-fetch job list
|
||||||
|
```
|
||||||
|
|
||||||
|
The thin `ApplyWorkspaceView.vue` wrapper can handle `@job-removed` by calling `router.push('/apply')` as before (same behavior, different mechanism).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Empty State (no job selected)
|
## Empty State (no job selected)
|
||||||
|
|
||||||
Shown in the right panel when `selectedJobId === null`:
|
Shown in the right panel when `selectedJobId === null` on desktop only:
|
||||||
|
|
||||||
```
|
```
|
||||||
🦅
|
🦅
|
||||||
|
|
@ -122,44 +158,41 @@ Shown in the right panel when `selectedJobId === null`:
|
||||||
the workspace
|
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).
|
Centered vertically, subdued text color. Disappears when a job is selected.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Easter Eggs
|
## Easter Eggs
|
||||||
|
|
||||||
All four easter eggs are scoped to `ApplyView.vue` / `ApplyWorkspace.vue`:
|
|
||||||
|
|
||||||
### 1. Speed Demon 🦅
|
### 1. Speed Demon 🦅
|
||||||
- **Trigger:** User clicks 5+ different jobs in under 3 seconds
|
- **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)
|
- **Effect:** A `<canvas>` element, absolutely positioned inside the split-pane container (`.apply-split` has `position: relative`), renders a 🦅 streaking left → right across the panel area (600ms). Followed by a "you're on the hunt" toast (2s, bottom-right).
|
||||||
- **`prefers-reduced-motion`:** Toast only, no canvas animation
|
- **`prefers-reduced-motion`:** Toast only, no canvas
|
||||||
|
|
||||||
### 2. Perfect Match ✨
|
### 2. Perfect Match ✨
|
||||||
- **Trigger:** A job with `match_score ≥ 70` is opened in the workspace
|
- **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)
|
- **Effect:** The score badge in the workspace header plays a golden shimmer (`box-shadow` + `background` keyframe, 800ms, once per open)
|
||||||
- **Threshold:** Stored as `const PERFECT_MATCH_THRESHOLD = 70` — easy to tune when scoring improves
|
- **Threshold constant:** `const PERFECT_MATCH_THRESHOLD = 70` at top of `ApplyWorkspace.vue` — intentionally matches the `score-badge--high` boundary (≥ 70%). If badge thresholds are tuned later, update this constant in sync.
|
||||||
- **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.
|
- **Note:** Current scoring rarely exceeds 40% — this easter egg may be dormant until the scoring algorithm is tuned. The constant makes it easy to adjust.
|
||||||
|
|
||||||
### 3. Cover Letter Marathon 📬
|
### 3. Cover Letter Marathon 📬
|
||||||
- **Trigger:** 5th cover letter generated in a single session (session-scoped counter in the Pinia store or component ref)
|
- **Trigger:** 5th cover letter generated in a single session
|
||||||
- **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.
|
- **Counter:** Component-level `ref<number>` in `ApplyView.vue` (not Pinia) — resets on page refresh, persists across job selections within the session
|
||||||
|
- **Effect:** A `📬 N today` streak badge appears in the list panel header with a warm amber glow. Increments with each subsequent generation.
|
||||||
- **Tooltip:** "You're on a roll!" on hover
|
- **Tooltip:** "You're on a roll!" on hover
|
||||||
|
|
||||||
### 4. Konami Code 🎮
|
### 4. Konami Code 🎮
|
||||||
- **Trigger:** ↑↑↓↓←→←→BA (standard Konami sequence), detected anywhere on the Apply view
|
- **Trigger:** ↑↑↓↓←→←→BA anywhere on the Apply view
|
||||||
- **Effect:** Activates hacker mode (`document.documentElement.setAttribute('data-theme', 'hacker')`) — consistent with the cross-product Konami standard
|
- **Implementation:** Use the **existing** `useKonamiCode(callback)` + `useHackerMode()` from `web/src/composables/useEasterEgg.ts`. Do **not** create a new `useKonami.ts` composable — one already exists. Do **not** add a new global `keydown` listener (one is already registered in `App.vue`); wire up via the composable's callback pattern instead.
|
||||||
- **Implementation:** `useKonami()` composable (shared if it exists, else add to `composables/`)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## What Stays the Same
|
## What Stays the Same
|
||||||
|
|
||||||
- `/apply/:id` route — still exists, still works (used by mobile nav and future deep links)
|
- `/apply/:id` route — still exists, still works (used by mobile nav)
|
||||||
- `ApplyWorkspaceView.vue` — becomes a thin wrapper around `<ApplyWorkspace :job-id="id" />`
|
|
||||||
- All existing mobile breakpoint styles in `ApplyView.vue`
|
- All existing mobile breakpoint styles in `ApplyView.vue`
|
||||||
- The `useApiFetch` data fetching pattern
|
- The `useApiFetch` data fetching pattern
|
||||||
- The `scoreBadgeClass()` utility
|
- The `remote-badge` and `salary` display — moved to workspace header, same markup
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -167,7 +200,7 @@ All four easter eggs are scoped to `ApplyView.vue` / `ApplyWorkspace.vue`:
|
||||||
|
|
||||||
- **Resizable split:** drag handle between panels, persisted in `localStorage` as `apply.splitRatio`
|
- **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
|
- **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`.
|
- **Layout selector:** density toggle in list header offering Option C (company+score only) and Option D (wrapped/taller cards), persisted in `localStorage` as `apply.listDensity`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -175,7 +208,7 @@ All four easter eggs are scoped to `ApplyView.vue` / `ApplyWorkspace.vue`:
|
||||||
|
|
||||||
| File | Action |
|
| File | Action |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `web/src/views/ApplyView.vue` | Replace: split-pane layout, narrow list, easter eggs |
|
| `web/src/views/ApplyView.vue` | Replace: split-pane layout (desktop), narrow list, easter eggs 1 + 3 + 4 |
|
||||||
| `web/src/components/ApplyWorkspace.vue` | Create: extracted from `ApplyWorkspaceView.vue`, accepts `jobId` prop |
|
| `web/src/components/ApplyWorkspace.vue` | Create: workspace content extracted from `ApplyWorkspaceView.vue`; `jobId: number` prop; emits `job-removed` |
|
||||||
| `web/src/views/ApplyWorkspaceView.vue` | Modify: thin wrapper — `<ApplyWorkspace :job-id="route.params.id" />` |
|
| `web/src/views/ApplyWorkspaceView.vue` | Modify: thin wrapper → `<ApplyWorkspace :job-id="Number(route.params.id)" @job-removed="router.push('/apply')" />` |
|
||||||
| `web/src/composables/useKonami.ts` | Create (if not exists): Konami sequence detector composable |
|
| `web/src/assets/theme.css` or `peregrine.css` | Add `.score-badge--mid-high` (blue, 50–69%) to badge CSS |
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue