feat(web): Vue 3 SPA scaffold with avocet lessons applied

Sets up web/ Vue 3 SPA skeleton for issue #8, synthesizing all 15 gotchas
from avocet's Vue port testbed. Key fixes baked in before any component work:

- App.vue root uses .app-root class (not id="app") — gotcha #1
- overflow-x: clip on html (not hidden) — gotcha #3
- UnoCSS presetAttributify with prefixedOnly: true — gotcha #4
- peregrine.css alias map for theme variable names — gotcha #5
- useHaptics guards navigator.vibrate — gotcha #9
- Pinia setup store pattern documented — gotcha #10
- test-setup.ts stubs matchMedia, vibrate, ResizeObserver — gotcha #12
- min-height: 100dvh throughout — gotcha #13

Includes:
- All 7 Peregrine views as stubs (ready to port from Streamlit)
- AppNav with all routes
- useApi (fetch + SSE), useMotion, useHaptics, useEasterEgg composables
- Konami hacker mode easter egg + confetti + cursor trail
- docs/vue-spa-migration.md: full migration guide + implementation order
- Build verified clean (0 errors)
- .gitleaks.toml: allowlist web/package-lock.json (sha512 integrity hashes)
This commit is contained in:
pyr0ball 2026-03-17 21:24:00 -07:00
parent aa92bc1e5b
commit cc18927437
28 changed files with 6204 additions and 0 deletions

174
docs/vue-spa-migration.md Normal file
View file

@ -0,0 +1,174 @@
# Peregrine Vue 3 SPA Migration
**Branch:** `feature/vue-spa`
**Issue:** #8 — Vue 3 SPA frontend (Paid Tier GA milestone)
**Worktree:** `.worktrees/feature-vue-spa/`
**Reference:** `avocet/docs/vue-port-gotchas.md` (15 battle-tested gotchas)
---
## What We're Replacing
The current Streamlit UI (`app/app.py` + `app/pages/`) is an internal tool built for speed of development. The Vue SPA replaces it with a proper frontend — faster, more accessible, and extensible for the Paid Tier. The FastAPI already exists (partially, from the cloud managed instance work); the Vue SPA will consume it.
### Pages to Port
| Streamlit file | Vue view | Route | Notes |
|---|---|---|---|
| `app/Home.py` | `HomeView.vue` | `/` | Dashboard, discovery trigger, sync status |
| `app/pages/1_Job_Review.py` | `JobReviewView.vue` | `/review` | Batch approve/reject; primary daily-driver view |
| `app/pages/4_Apply.py` | `ApplyView.vue` | `/apply` | Cover letter gen + PDF + mark applied |
| `app/pages/5_Interviews.py` | `InterviewsView.vue` | `/interviews` | Kanban: phone_screen → offer → hired |
| `app/pages/6_Interview_Prep.py` | `InterviewPrepView.vue` | `/prep` | Live reference sheet + practice Q&A |
| `app/pages/7_Survey.py` | `SurveyView.vue` | `/survey` | Culture-fit survey assist + screenshot |
| `app/pages/2_Settings.py` | `SettingsView.vue` | `/settings` | 6 tabs: Profile, Resume, Search, System, Fine-Tune, License |
---
## Avocet Lessons Applied — What We Fixed Before Starting
The avocet SPA was the testbed. These bugs were found and fixed there; Peregrine's scaffold already incorporates all fixes. See `avocet/docs/vue-port-gotchas.md` for the full writeup.
### Applied at scaffold level (baked in — you don't need to think about these)
| # | Gotcha | How it's fixed in this scaffold |
|---|--------|----------------------------------|
| 1 | `id="app"` on App.vue root → nested `#app` elements, broken CSS specificity | `App.vue` root uses `class="app-root"`. `#app` in `index.html` is mount target only. |
| 3 | `overflow-x: hidden` on html → creates scroll container → 15px scrollbar jitter on Linux | `peregrine.css`: `html { overflow-x: clip }` |
| 4 | UnoCSS `presetAttributify` generates CSS for bare attribute names like `h2` | `uno.config.ts`: `presetAttributify({ prefix: 'un-', prefixedOnly: true })` |
| 5 | Theme variable name mismatches cause dark mode to silently fall back to hardcoded colors | `peregrine.css` alias map: `--color-bg → var(--color-surface)`, `--color-text-secondary → var(--color-text-muted)` |
| 7 | SPA cache: browser caches `index.html` indefinitely → old asset hashes → 404 on rebuild | FastAPI must register explicit `GET /` with no-cache headers before `StaticFiles` mount (see FastAPI section below) |
| 9 | `navigator.vibrate()` not supported on desktop/Safari — throws on call | `useHaptics.ts` guards with `'vibrate' in navigator` |
| 10 | Pinia options store = Vue 2 migration path | All stores use setup store form: `defineStore('id', () => { ... })` |
| 12 | `matchMedia`, `vibrate`, `ResizeObserver` absent in jsdom → composable tests throw | `test-setup.ts` stubs all three |
| 13 | `100vh` ignores mobile browser chrome | `App.vue`: `min-height: 100dvh` |
### Must actively avoid when writing new components
| # | Gotcha | Rule |
|---|--------|------|
| 2 | `transition: all` + spring easing → every CSS property bounces → layout explosion | Always enumerate: `transition: background 200ms ease, transform 250ms cubic-bezier(...)` |
| 6 | Keyboard composables called with snapshot arrays → keys don't work after async data loads | Accept `getLabels: () => labels.value` (reactive getter), not `labels: []` (snapshot) |
| 8 | Font reflow at ~780ms shifts layout measurements taken in `onMounted` | Measure layout in `document.fonts.ready` promise or after 1s timeout |
| 11 | `useSwipe` from `@vueuse/core` fires on desktop trackpad pointer events, not just touch | Add `pointer-type === 'touch'` guard if you need touch-only behavior |
| 14 | Rebuild workflow confusion | `cd web && npm run build` → refresh browser. Only restart FastAPI if `app/api.py` changed. |
| 15 | `:global(ancestor) .descendant` in `<style scoped>` → Vue drops the descendant entirely | Never use `:global(X) .Y` in scoped CSS. Use JS gate or CSS custom property token. |
---
## FastAPI Integration
### SPA serving (gotcha #7)
When the Vue SPA is built, FastAPI needs to serve it. Register the explicit `/` route **before** the `StaticFiles` mount, otherwise `index.html` gets cached and old asset hashes cause 404s after rebuild:
```python
from pathlib import Path
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
_DIST = Path(__file__).parent.parent / "web" / "dist"
_NO_CACHE = {
"Cache-Control": "no-cache, no-store, must-revalidate",
"Pragma": "no-cache",
}
@app.get("/")
def spa_root():
return FileResponse(_DIST / "index.html", headers=_NO_CACHE)
# Must come after the explicit route above
app.mount("/", StaticFiles(directory=str(_DIST), html=True), name="spa")
```
Hashed assets (`/assets/index-abc123.js`) can be cached aggressively — their filenames change with content. Only `index.html` needs no-cache.
### API prefix
Vue Router uses HTML5 history mode. All `/api/*` routes must be registered on FastAPI before the `StaticFiles` mount. Vue routes (`/`, `/review`, `/apply`, etc.) are handled client-side; FastAPI's `html=True` on `StaticFiles` serves `index.html` for any unmatched path.
---
## Peregrine-Specific Considerations
### Auth & license gating
The Streamlit UI uses `app/wizard/tiers.py` for tier gating. In the Vue SPA, tier state should be fetched from a `GET /api/license/status` endpoint on mount and stored in a Pinia store. Components check `licenseStore.tier` to gate features.
### Discovery trigger
The "Start Discovery" button on Home triggers `python scripts/discover.py` as a background process. The Vue version should use SSE (same pattern as avocet's finetune SSE) to stream progress back in real-time. The `useApiSSE` composable is already wired for this.
### Job Review — card stack UX
This is the daily-driver view. Consider the avocet ASMR bucket pattern here — approve/reject could transform into buckets on drag pickup. The motion tokens (`--transition-spring`, `--transition-dismiss`) are pre-defined in `peregrine.css`. The `useHaptics` composable is ready.
### Kanban (Interviews view)
The drag-to-column kanban is a strong candidate for `@vueuse/core`'s `useDraggable`. Watch for the `useSwipe` gotcha #11 — use pointer-type guards if drag behavior differs between touch and mouse.
### Settings — 6 tabs
Use a tab component with reactive route query params (`/settings?tab=license`) so direct links work and the page is shareable/bookmarkable.
---
## Build & Dev Workflow
```bash
# From worktree root
cd web
npm install # first time only
npm run dev # Vite dev server at :5173 (proxies /api/* to FastAPI at :8502)
npm run build # output to web/dist/
npm run test # Vitest unit tests
```
FastAPI serves the built `dist/` on the main port. During dev, configure Vite to proxy `/api` to the running FastAPI:
```ts
// vite.config.ts addition for dev proxy
server: {
proxy: {
'/api': 'http://localhost:8502',
}
}
```
After `npm run build`, just refresh the browser — no FastAPI restart needed unless `app/api.py` changed (gotcha #14).
---
## Implementation Order
Suggested sequence — validate the full stack before porting complex pages:
1. **FastAPI SPA endpoint** — serve `web/dist/` with correct cache headers
2. **App shell** — nav, routing, hacker mode, motion toggle work end-to-end
3. **Home view** — dashboard widgets, discovery trigger with SSE progress
4. **Job Review** — most-used view; gets the most polish
5. **Settings** — license tab is the blocker for tier gating in other views
6. **Apply Workspace** — cover letter gen + PDF export
7. **Interviews kanban** — drag-to-column + calendar sync
8. **Interview Prep** — reference sheet, practice Q&A
9. **Survey Assistant** — screenshot + text paste
---
## Checklist
Copy of the avocet gotchas checklist (all pre-applied at scaffold level are checked):
- [x] App.vue root element: use `.app-root` class, NOT `id="app"`
- [ ] No `transition: all` with spring easings — enumerate properties explicitly
- [ ] No `:global(ancestor) .descendant` in scoped CSS — Vue drops the descendant
- [x] `overflow-x: clip` on html, `overflow-x: hidden` on body
- [x] UnoCSS `presetAttributify`: `prefixedOnly: true`
- [x] Product CSS aliases: `--color-bg`, `--color-text-secondary` mapped in `peregrine.css`
- [ ] Keyboard composables: accept reactive getters, not snapshot arrays
- [x] FastAPI SPA serving pattern documented — apply when wiring FastAPI
- [ ] Font reflow: measure layout after `document.fonts.ready` or 1s timeout
- [x] Haptics: guard `navigator.vibrate` with feature detection
- [x] Pinia: use setup store form (function syntax)
- [x] Tests: mock matchMedia, vibrate, ResizeObserver in test-setup.ts
- [x] `min-height: 100dvh` on full-height layout containers

2
web/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
node_modules/
dist/

20
web/index.html Normal file
View file

@ -0,0 +1,20 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Peregrine — Job Search Assistant</title>
<!-- Inline background prevents blank flash before CSS bundle loads -->
<!-- Matches --color-surface light / dark from theme.css -->
<style>
html, body { margin: 0; background: #eaeff8; min-height: 100vh; }
@media (prefers-color-scheme: dark) { html, body { background: #16202e; } }
</style>
</head>
<body>
<!-- Mount target only — App.vue root must NOT use id="app". Gotcha #1. -->
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

4956
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

38
web/package.json Normal file
View file

@ -0,0 +1,38 @@
{
"name": "peregrine-web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc && vite build",
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@fontsource/atkinson-hyperlegible": "^5.2.8",
"@fontsource/fraunces": "^5.2.9",
"@fontsource/jetbrains-mono": "^5.2.8",
"@vueuse/core": "^14.2.1",
"@vueuse/integrations": "^14.2.1",
"animejs": "^4.3.6",
"pinia": "^3.0.4",
"vue": "^3.5.25",
"vue-router": "^5.0.3"
},
"devDependencies": {
"@types/node": "^24.10.1",
"@unocss/preset-attributify": "^66.6.4",
"@unocss/preset-wind": "^66.6.4",
"@vitejs/plugin-vue": "^6.0.2",
"@vue/test-utils": "^2.4.6",
"@vue/tsconfig": "^0.8.1",
"jsdom": "^28.1.0",
"typescript": "~5.9.3",
"unocss": "^66.6.4",
"vite": "^7.3.1",
"vitest": "^4.0.18",
"vue-tsc": "^3.1.5"
}
}

79
web/src/App.vue Normal file
View file

@ -0,0 +1,79 @@
<template>
<!-- IMPORTANT: root element uses class="app-root", NOT id="app".
index.html owns #app as the mount target.
Mixing the two creates nested #app elements with ambiguous CSS specificity.
Gotcha #1 from docs/vue-port-gotchas.md. -->
<div
class="app-root"
:class="{ 'rich-motion': motion.rich.value }"
:data-theme="hackerTheme"
>
<AppNav />
<main class="app-main">
<RouterView />
</main>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { RouterView } from 'vue-router'
import { useMotion } from './composables/useMotion'
import { useHackerMode, useKonamiCode } from './composables/useEasterEgg'
import AppNav from './components/AppNav.vue'
const motion = useMotion()
const { toggle, restore } = useHackerMode()
// Computed so template reactively tracks localStorage-driven theme
const hackerTheme = computed(() =>
typeof document !== 'undefined' && document.documentElement.dataset.theme === 'hacker'
? 'hacker'
: undefined,
)
useKonamiCode(toggle)
onMounted(() => {
restore() // re-apply hacker mode from localStorage on hard reload
})
</script>
<style>
/* Global resets in <style> (no scoped) — applied once to the document */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
font-family: var(--font-body, sans-serif);
color: var(--color-text, #1a2338);
background: var(--color-surface, #eaeff8);
/* clip (not hidden) — avoids BFC scroll-container side effects. Gotcha #3. */
overflow-x: clip;
}
body {
min-height: 100dvh; /* dvh = dynamic viewport height — mobile chrome-aware. Gotcha #13. */
overflow-x: hidden; /* body hidden is survivable; html must be clip */
}
/* Mount shell — thin container, no layout */
#app {
min-height: 100dvh;
}
/* App layout root */
.app-root {
display: flex;
min-height: 100dvh;
}
.app-main {
flex: 1;
min-width: 0; /* prevents flex children from blowing out container width */
padding-top: var(--nav-height, 4rem);
}
</style>

View file

@ -0,0 +1,52 @@
/* web/src/assets/peregrine.css
Peregrine token overrides imported AFTER theme.css.
Only overrides what is genuinely different from the CircuitForge base theme.
App colors:
Primary Forest Green (#2d5a27) inherited from theme.css --color-primary
Accent Amber/Copper (#c4732a) inherited from theme.css --color-accent
*/
/* ── Page-level overrides ───────────────────────────── */
html {
/* Prevent Mac Chrome horizontal swipe-to-navigate on viewport edge */
overscroll-behavior-x: none;
/* clip (not hidden) — no BFC scroll-container side effect. Gotcha #3. */
overflow-x: clip;
}
body {
/* Suppress horizontal scroll from animated transitions */
overflow-x: hidden;
}
/* ── Light mode (default) ──────────────────────────── */
:root {
/* Alias map component-expected names theme.css canonical names. Gotcha #5.
Components should prefer the theme.css names; add aliases only when needed. */
--color-bg: var(--color-surface);
--color-text-secondary: var(--color-text-muted);
/* Nav height token — consumed by .app-main padding-top in App.vue */
--nav-height: 4rem;
/* Motion tokens for future animated components (inspired by avocet bucket pattern) */
--transition-spring: 400ms cubic-bezier(0.34, 1.56, 0.64, 1);
--transition-dismiss: 350ms ease-in;
--transition-enter: 250ms ease-out;
}
/* ── Dark mode ─────────────────────────────────────── */
@media (prefers-color-scheme: dark) {
:root:not([data-theme="hacker"]) {
/* Aliases inherit dark values from theme.css automatically */
}
}
/* ── Hacker mode (Konami easter egg) ──────────────── */
/* Applied via document.documentElement.dataset.theme = 'hacker' */
/* Full token overrides live in theme.css [data-theme="hacker"] block */
[data-theme="hacker"] {
/* Cursor trail uses this color — override for hacker palette */
--color-accent: #00ff41;
}

268
web/src/assets/theme.css Normal file
View file

@ -0,0 +1,268 @@
/* assets/styles/theme.css CENTRAL THEME FILE
Accessible Solarpunk: warm, earthy, humanist, trustworthy.
Hacker mode: terminal green circuit-trace dark (Konami code).
ALL color/font/spacing tokens live here nowhere else.
*/
/* ── Accessible Solarpunk — light (default) ──────── */
:root {
/* Brand */
--color-primary: #2d5a27;
--color-primary-hover: #234820;
--color-primary-light: #e8f2e7;
/* Surfaces — cool blue-slate, crisp and legible */
--color-surface: #eaeff8;
--color-surface-alt: #dde4f0;
--color-surface-raised: #f5f7fc;
/* Borders — cool blue-gray */
--color-border: #a8b8d0;
--color-border-light: #ccd5e6;
/* Text — dark navy, cool undertone */
--color-text: #1a2338;
--color-text-muted: #4a5c7a;
--color-text-inverse: #eaeff8;
/* Accent — amber/terracotta (action, links, CTAs) */
--color-accent: #c4732a;
--color-accent-hover: #a85c1f;
--color-accent-light: #fdf0e4;
/* Semantic */
--color-success: #3a7a32;
--color-error: #c0392b;
--color-warning: #d4891a;
--color-info: #1e6091;
/* Typography */
--font-display: 'Fraunces', Georgia, serif; /* Headings — optical humanist serif */
--font-body: 'Atkinson Hyperlegible', system-ui, sans-serif; /* Body — designed for accessibility */
--font-mono: 'JetBrains Mono', 'Fira Code', monospace; /* Code, hacker mode */
/* Spacing scale */
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 0.75rem;
--space-4: 1rem;
--space-6: 1.5rem;
--space-8: 2rem;
--space-12: 3rem;
--space-16: 4rem;
--space-24: 6rem;
/* Radii */
--radius-sm: 0.25rem;
--radius-md: 0.5rem;
--radius-lg: 1rem;
--radius-full: 9999px;
/* Shadows — cool blue-navy base */
--shadow-sm: 0 1px 3px rgba(26, 35, 56, 0.08), 0 1px 2px rgba(26, 35, 56, 0.04);
--shadow-md: 0 4px 12px rgba(26, 35, 56, 0.1), 0 2px 4px rgba(26, 35, 56, 0.06);
--shadow-lg: 0 10px 30px rgba(26, 35, 56, 0.12), 0 4px 8px rgba(26, 35, 56, 0.06);
/* Transitions */
--transition: 200ms ease;
--transition-slow: 400ms ease;
/* Header */
--header-height: 4rem;
--header-border: 2px solid var(--color-border);
}
/* Accessible Solarpunk dark (system dark mode)
Activates when OS/browser is in dark mode.
Uses :not([data-theme="hacker"]) so the Konami easter
egg always wins over the system preference. */
@media (prefers-color-scheme: dark) {
:root:not([data-theme="hacker"]) {
/* Brand — lighter greens readable on dark surfaces */
--color-primary: #6ab870;
--color-primary-hover: #7ecb84;
--color-primary-light: #162616;
/* Surfaces — deep blue-slate, not pure black */
--color-surface: #16202e;
--color-surface-alt: #1e2a3a;
--color-surface-raised: #263547;
/* Borders */
--color-border: #2d4060;
--color-border-light: #233352;
/* Text */
--color-text: #e4eaf5;
--color-text-muted: #8da0bc;
--color-text-inverse: #16202e;
/* Accent — lighter amber for dark bg contrast (WCAG AA) */
--color-accent: #e8a84a;
--color-accent-hover: #f5bc60;
--color-accent-light: #2d1e0a;
/* Semantic */
--color-success: #5eb85e;
--color-error: #e05252;
--color-warning: #e8a84a;
--color-info: #4da6e8;
/* Shadows — darker base for dark bg */
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.3), 0 1px 2px rgba(0, 0, 0, 0.2);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.35), 0 2px 4px rgba(0, 0, 0, 0.2);
--shadow-lg: 0 10px 30px rgba(0, 0, 0, 0.4), 0 4px 8px rgba(0, 0, 0, 0.2);
}
}
/* ── Hacker/maker easter egg theme ──────────────── */
/* Activated by Konami code: ↑↑↓↓←→←→BA */
/* Stored in localStorage: 'cf-hacker-mode' */
/* Applied: document.documentElement.dataset.theme */
[data-theme="hacker"] {
--color-primary: #00ff41;
--color-primary-hover: #00cc33;
--color-primary-light: #001a00;
--color-surface: #0a0c0a;
--color-surface-alt: #0d120d;
--color-surface-raised: #111811;
--color-border: #1a3d1a;
--color-border-light: #123012;
--color-text: #b8f5b8;
--color-text-muted: #5a9a5a;
--color-text-inverse: #0a0c0a;
--color-accent: #00ff41;
--color-accent-hover: #00cc33;
--color-accent-light: #001a0a;
--color-success: #00ff41;
--color-error: #ff3333;
--color-warning: #ffaa00;
--color-info: #00aaff;
/* Hacker mode: mono font everywhere */
--font-display: 'JetBrains Mono', monospace;
--font-body: 'JetBrains Mono', monospace;
--shadow-sm: 0 1px 3px rgba(0, 255, 65, 0.08);
--shadow-md: 0 4px 12px rgba(0, 255, 65, 0.12);
--shadow-lg: 0 10px 30px rgba(0, 255, 65, 0.15);
--header-border: 2px solid var(--color-border);
/* Hacker glow variants — for box-shadow, text-shadow, bg overlays */
--color-accent-glow-xs: rgba(0, 255, 65, 0.08);
--color-accent-glow-sm: rgba(0, 255, 65, 0.15);
--color-accent-glow-md: rgba(0, 255, 65, 0.4);
--color-accent-glow-lg: rgba(0, 255, 65, 0.6);
}
/* ── Base resets ─────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; }
html {
font-family: var(--font-body);
color: var(--color-text);
background: var(--color-surface);
scroll-behavior: smooth;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body { margin: 0; min-height: 100vh; }
h1, h2, h3, h4, h5, h6 {
font-family: var(--font-display);
color: var(--color-primary);
line-height: 1.2;
margin: 0;
}
/* Focus visible — keyboard nav — accessibility requirement */
:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 3px;
border-radius: var(--radius-sm);
}
/* Respect reduced motion */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
/* ── Prose — CMS rich text ───────────────────────── */
.prose {
font-family: var(--font-body);
line-height: 1.75;
color: var(--color-text);
max-width: 65ch;
}
.prose h2 {
font-family: var(--font-display);
font-size: 1.5rem;
font-weight: 700;
margin: 2rem 0 0.75rem;
color: var(--color-primary);
}
.prose h3 {
font-family: var(--font-display);
font-size: 1.2rem;
font-weight: 600;
margin: 1.5rem 0 0.5rem;
color: var(--color-primary);
}
.prose p { margin: 0 0 1rem; }
.prose ul, .prose ol { margin: 0 0 1rem; padding-left: 1.5rem; }
.prose li { margin-bottom: 0.4rem; }
.prose a { color: var(--color-accent); text-decoration: underline; text-underline-offset: 3px; }
.prose strong { font-weight: 700; }
.prose code {
font-family: var(--font-mono);
font-size: 0.875em;
background: var(--color-surface-alt);
border: 1px solid var(--color-border-light);
padding: 0.1em 0.35em;
border-radius: var(--radius-sm);
}
.prose blockquote {
border-left: 3px solid var(--color-accent);
margin: 1.5rem 0;
padding: 0.5rem 0 0.5rem 1.25rem;
color: var(--color-text-muted);
font-style: italic;
}
/* ── Utility: screen reader only ────────────────── */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.sr-only:focus-visible {
position: fixed;
top: 0.5rem;
left: 0.5rem;
width: auto;
height: auto;
padding: 0.5rem 1rem;
clip: auto;
white-space: normal;
background: var(--color-accent);
color: var(--color-text-inverse);
border-radius: var(--radius-md);
font-weight: 600;
z-index: 9999;
}

View file

@ -0,0 +1,89 @@
<template>
<nav class="app-nav" role="navigation" aria-label="Main navigation">
<div class="app-nav__brand">
<RouterLink to="/" class="app-nav__logo">Peregrine</RouterLink>
</div>
<ul class="app-nav__links" role="list">
<li v-for="link in navLinks" :key="link.to">
<RouterLink :to="link.to" class="app-nav__link" active-class="app-nav__link--active">
<span class="app-nav__icon" aria-hidden="true">{{ link.icon }}</span>
<span class="app-nav__label">{{ link.label }}</span>
</RouterLink>
</li>
</ul>
</nav>
</template>
<script setup lang="ts">
import { RouterLink } from 'vue-router'
const navLinks = [
{ to: '/', icon: '🏠', label: 'Home' },
{ to: '/review', icon: '📋', label: 'Job Review' },
{ to: '/apply', icon: '✍️', label: 'Apply' },
{ to: '/interviews', icon: '🗓️', label: 'Interviews' },
{ to: '/prep', icon: '🎯', label: 'Interview Prep' },
{ to: '/survey', icon: '🔍', label: 'Survey' },
{ to: '/settings', icon: '⚙️', label: 'Settings' },
]
</script>
<style scoped>
.app-nav {
position: fixed;
top: 0;
left: 0;
right: 0;
height: var(--nav-height, 4rem);
display: flex;
align-items: center;
gap: var(--space-4);
padding: 0 var(--space-6);
background: var(--color-surface-raised);
border-bottom: 2px solid var(--color-border);
z-index: 100;
}
.app-nav__brand { flex-shrink: 0; }
.app-nav__logo {
font-family: var(--font-display);
font-weight: 700;
font-size: 1.2rem;
color: var(--color-primary);
text-decoration: none;
}
.app-nav__links {
display: flex;
gap: var(--space-2);
list-style: none;
margin: 0;
padding: 0;
overflow-x: auto;
}
.app-nav__link {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-md);
color: var(--color-text-muted);
text-decoration: none;
font-size: 0.875rem;
white-space: nowrap;
/* Enumerate only the properties that animate — no transition:all with spring easing. Gotcha #2. */
transition:
background 150ms ease,
color 150ms ease;
}
.app-nav__link:hover,
.app-nav__link--active {
background: var(--color-primary-light);
color: var(--color-primary);
}
.app-nav__icon { font-size: 1rem; }
</style>

View file

@ -0,0 +1,50 @@
export type ApiError =
| { kind: 'network'; message: string }
| { kind: 'http'; status: number; detail: string }
export async function useApiFetch<T>(
url: string,
opts?: RequestInit,
): Promise<{ data: T | null; error: ApiError | null }> {
try {
const res = await fetch(url, opts)
if (!res.ok) {
const detail = await res.text().catch(() => '')
return { data: null, error: { kind: 'http', status: res.status, detail } }
}
const data = await res.json() as T
return { data, error: null }
} catch (e) {
return { data: null, error: { kind: 'network', message: String(e) } }
}
}
/**
* Open an SSE connection. Returns a cleanup function.
* onEvent receives each parsed JSON payload.
* onComplete is called when the server sends a {"type":"complete"} event.
* onError is called on connection error.
*/
export function useApiSSE(
url: string,
onEvent: (data: Record<string, unknown>) => void,
onComplete?: () => void,
onError?: (e: Event) => void,
): () => void {
const es = new EventSource(url)
es.onmessage = (e) => {
try {
const data = JSON.parse(e.data) as Record<string, unknown>
onEvent(data)
if (data.type === 'complete') {
es.close()
onComplete?.()
}
} catch { /* ignore malformed events */ }
}
es.onerror = (e) => {
onError?.(e)
es.close()
}
return () => es.close()
}

View file

@ -0,0 +1,160 @@
import { onMounted, onUnmounted } from 'vue'
const KONAMI = ['ArrowUp','ArrowUp','ArrowDown','ArrowDown','ArrowLeft','ArrowRight','ArrowLeft','ArrowRight','b','a']
const KONAMI_AB = ['ArrowUp','ArrowUp','ArrowDown','ArrowDown','ArrowLeft','ArrowRight','ArrowLeft','ArrowRight','a','b']
export function useKeySequence(sequence: string[], onActivate: () => void) {
let pos = 0
function handler(e: KeyboardEvent) {
if (e.key === sequence[pos]) {
pos++
if (pos === sequence.length) {
pos = 0
onActivate()
}
} else {
pos = 0
}
}
onMounted(() => window.addEventListener('keydown', handler))
onUnmounted(() => window.removeEventListener('keydown', handler))
}
export function useKonamiCode(onActivate: () => void) {
useKeySequence(KONAMI, onActivate)
useKeySequence(KONAMI_AB, onActivate)
}
export function useHackerMode() {
function toggle() {
const root = document.documentElement
if (root.dataset.theme === 'hacker') {
delete root.dataset.theme
localStorage.removeItem('cf-hacker-mode')
} else {
root.dataset.theme = 'hacker'
localStorage.setItem('cf-hacker-mode', 'true')
}
}
function restore() {
if (localStorage.getItem('cf-hacker-mode') === 'true') {
document.documentElement.dataset.theme = 'hacker'
}
}
return { toggle, restore }
}
/** Fire a confetti burst from a given x,y position. Pure canvas, no dependencies. */
export function fireConfetti(originX = window.innerWidth / 2, originY = window.innerHeight / 2) {
if (typeof requestAnimationFrame === 'undefined') return
const canvas = document.createElement('canvas')
canvas.style.cssText = 'position:fixed;inset:0;pointer-events:none;z-index:9999;'
canvas.width = window.innerWidth
canvas.height = window.innerHeight
document.body.appendChild(canvas)
const ctx = canvas.getContext('2d')!
const COLORS = ['#2d5a27','#c4732a','#5A9DBF','#D4854A','#FFC107','#4CAF50']
const particles = Array.from({ length: 80 }, () => ({
x: originX,
y: originY,
vx: (Math.random() - 0.5) * 14,
vy: (Math.random() - 0.6) * 12,
color: COLORS[Math.floor(Math.random() * COLORS.length)],
size: 5 + Math.random() * 6,
angle: Math.random() * Math.PI * 2,
spin: (Math.random() - 0.5) * 0.3,
life: 1.0,
}))
let raf = 0
function draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height)
let alive = false
for (const p of particles) {
p.x += p.vx
p.y += p.vy
p.vy += 0.35
p.vx *= 0.98
p.angle += p.spin
p.life -= 0.016
if (p.life <= 0) continue
alive = true
ctx.save()
ctx.globalAlpha = p.life
ctx.fillStyle = p.color
ctx.translate(p.x, p.y)
ctx.rotate(p.angle)
ctx.fillRect(-p.size / 2, -p.size / 2, p.size, p.size * 0.6)
ctx.restore()
}
if (alive) {
raf = requestAnimationFrame(draw)
} else {
cancelAnimationFrame(raf)
canvas.remove()
}
}
raf = requestAnimationFrame(draw)
}
/** Enable cursor trail in hacker mode — returns a cleanup function. */
export function useCursorTrail() {
const DOT_COUNT = 10
const dots: HTMLElement[] = []
let positions: { x: number; y: number }[] = []
let mouseX = 0
let mouseY = 0
let raf = 0
for (let i = 0; i < DOT_COUNT; i++) {
const dot = document.createElement('div')
dot.style.cssText = [
'position:fixed',
'pointer-events:none',
'z-index:9998',
'border-radius:50%',
'background:var(--color-accent)',
'transition:opacity 0.1s',
].join(';')
document.body.appendChild(dot)
dots.push(dot)
}
function onMouseMove(e: MouseEvent) {
mouseX = e.clientX
mouseY = e.clientY
}
function animate() {
positions.unshift({ x: mouseX, y: mouseY })
if (positions.length > DOT_COUNT) positions = positions.slice(0, DOT_COUNT)
dots.forEach((dot, i) => {
const pos = positions[i]
if (!pos) { dot.style.opacity = '0'; return }
const scale = 1 - i / DOT_COUNT
const size = Math.round(8 * scale)
dot.style.left = `${pos.x - size / 2}px`
dot.style.top = `${pos.y - size / 2}px`
dot.style.width = `${size}px`
dot.style.height = `${size}px`
dot.style.opacity = `${(1 - i / DOT_COUNT) * 0.7}`
})
raf = requestAnimationFrame(animate)
}
window.addEventListener('mousemove', onMouseMove)
raf = requestAnimationFrame(animate)
return function cleanup() {
window.removeEventListener('mousemove', onMouseMove)
cancelAnimationFrame(raf)
dots.forEach(d => d.remove())
}
}

View file

@ -0,0 +1,20 @@
import { useMotion } from './useMotion'
// navigator.vibrate() — Chrome for Android only. Desktop, iOS Safari: no-op.
// Always guard with feature detection. Gotcha #9.
export function useHaptics() {
const { rich } = useMotion()
function vibrate(pattern: number | number[]) {
if (rich.value && typeof navigator !== 'undefined' && 'vibrate' in navigator) {
navigator.vibrate(pattern)
}
}
return {
label: () => vibrate(40),
discard: () => vibrate([40, 30, 40]),
skip: () => vibrate(15),
undo: () => vibrate([20, 20, 60]),
}
}

View file

@ -0,0 +1,30 @@
import { computed, ref } from 'vue'
// Peregrine-namespaced localStorage entry (avocet uses cf-avocet-rich-motion)
const LS_MOTION = 'cf-peregrine-rich-motion'
// OS-level prefers-reduced-motion — checked once at module load
const OS_REDUCED = typeof window !== 'undefined'
? window.matchMedia('(prefers-reduced-motion: reduce)').matches
: false
// Reactive ref so toggling localStorage triggers re-reads in the same session
const _richOverride = ref(
typeof window !== 'undefined'
? localStorage.getItem(LS_MOTION)
: null,
)
export function useMotion() {
// null/missing = default ON; 'false' = explicitly disabled by user
const rich = computed(() =>
!OS_REDUCED && _richOverride.value !== 'false',
)
function setRich(enabled: boolean) {
localStorage.setItem(LS_MOTION, enabled ? 'true' : 'false')
_richOverride.value = enabled ? 'true' : 'false'
}
return { rich, setRich }
}

24
web/src/main.ts Normal file
View file

@ -0,0 +1,24 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import { router } from './router'
// Self-hosted fonts — no Google Fonts CDN (privacy requirement)
import '@fontsource/fraunces/400.css'
import '@fontsource/fraunces/700.css'
import '@fontsource/atkinson-hyperlegible/400.css'
import '@fontsource/atkinson-hyperlegible/700.css'
import '@fontsource/jetbrains-mono/400.css'
import 'virtual:uno.css'
import './assets/theme.css'
import './assets/peregrine.css'
import App from './App.vue'
// Manual scroll restoration — prevents browser from jumping to last position on SPA nav
if ('scrollRestoration' in history) history.scrollRestoration = 'manual'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')

16
web/src/router/index.ts Normal file
View file

@ -0,0 +1,16 @@
import { createRouter, createWebHistory } from 'vue-router'
export const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/', component: () => import('../views/HomeView.vue') },
{ path: '/review', component: () => import('../views/JobReviewView.vue') },
{ path: '/apply', component: () => import('../views/ApplyView.vue') },
{ path: '/interviews', component: () => import('../views/InterviewsView.vue') },
{ path: '/prep', component: () => import('../views/InterviewPrepView.vue') },
{ path: '/survey', component: () => import('../views/SurveyView.vue') },
{ path: '/settings', component: () => import('../views/SettingsView.vue') },
// Catch-all — FastAPI serves index.html for all unknown routes (SPA mode)
{ path: '/:pathMatch(.*)*', redirect: '/' },
],
})

35
web/src/test-setup.ts Normal file
View file

@ -0,0 +1,35 @@
// jsdom does not implement window.matchMedia — stub it so useMotion and other
// composables that check prefers-reduced-motion can import without throwing.
// Gotcha #12.
if (typeof window !== 'undefined' && !window.matchMedia) {
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: (query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: () => {},
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false,
}),
})
}
// navigator.vibrate not in jsdom — stub so useHaptics doesn't throw. Gotcha #9.
if (typeof window !== 'undefined' && !('vibrate' in window.navigator)) {
Object.defineProperty(window.navigator, 'vibrate', {
writable: true,
value: () => false,
})
}
// ResizeObserver not in jsdom — stub if any component uses it.
if (typeof window !== 'undefined' && !window.ResizeObserver) {
window.ResizeObserver = class {
observe() {}
unobserve() {}
disconnect() {}
}
}

View file

@ -0,0 +1,18 @@
<template>
<div class="view-placeholder">
<h1>ApplyView</h1>
<p class="placeholder-note">Vue port in progress Streamlit equivalent at app/pages/</p>
</div>
</template>
<style scoped>
.view-placeholder {
padding: var(--space-8);
max-width: 60ch;
}
.placeholder-note {
color: var(--color-text-muted);
font-size: 0.875rem;
margin-top: var(--space-2);
}
</style>

View file

@ -0,0 +1,18 @@
<template>
<div class="view-placeholder">
<h1>HomeView</h1>
<p class="placeholder-note">Vue port in progress Streamlit equivalent at app/pages/</p>
</div>
</template>
<style scoped>
.view-placeholder {
padding: var(--space-8);
max-width: 60ch;
}
.placeholder-note {
color: var(--color-text-muted);
font-size: 0.875rem;
margin-top: var(--space-2);
}
</style>

View file

@ -0,0 +1,18 @@
<template>
<div class="view-placeholder">
<h1>InterviewPrepView</h1>
<p class="placeholder-note">Vue port in progress Streamlit equivalent at app/pages/</p>
</div>
</template>
<style scoped>
.view-placeholder {
padding: var(--space-8);
max-width: 60ch;
}
.placeholder-note {
color: var(--color-text-muted);
font-size: 0.875rem;
margin-top: var(--space-2);
}
</style>

View file

@ -0,0 +1,18 @@
<template>
<div class="view-placeholder">
<h1>InterviewsView</h1>
<p class="placeholder-note">Vue port in progress Streamlit equivalent at app/pages/</p>
</div>
</template>
<style scoped>
.view-placeholder {
padding: var(--space-8);
max-width: 60ch;
}
.placeholder-note {
color: var(--color-text-muted);
font-size: 0.875rem;
margin-top: var(--space-2);
}
</style>

View file

@ -0,0 +1,18 @@
<template>
<div class="view-placeholder">
<h1>JobReviewView</h1>
<p class="placeholder-note">Vue port in progress Streamlit equivalent at app/pages/</p>
</div>
</template>
<style scoped>
.view-placeholder {
padding: var(--space-8);
max-width: 60ch;
}
.placeholder-note {
color: var(--color-text-muted);
font-size: 0.875rem;
margin-top: var(--space-2);
}
</style>

View file

@ -0,0 +1,18 @@
<template>
<div class="view-placeholder">
<h1>SettingsView</h1>
<p class="placeholder-note">Vue port in progress Streamlit equivalent at app/pages/</p>
</div>
</template>
<style scoped>
.view-placeholder {
padding: var(--space-8);
max-width: 60ch;
}
.placeholder-note {
color: var(--color-text-muted);
font-size: 0.875rem;
margin-top: var(--space-2);
}
</style>

View file

@ -0,0 +1,18 @@
<template>
<div class="view-placeholder">
<h1>SurveyView</h1>
<p class="placeholder-note">Vue port in progress Streamlit equivalent at app/pages/</p>
</div>
</template>
<style scoped>
.view-placeholder {
padding: var(--space-8);
max-width: 60ch;
}
.placeholder-note {
color: var(--color-text-muted);
font-size: 0.875rem;
margin-top: var(--space-2);
}
</style>

14
web/tsconfig.app.json Normal file
View file

@ -0,0 +1,14 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"types": ["vite/client"],
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}

7
web/tsconfig.json Normal file
View file

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

22
web/tsconfig.node.json Normal file
View file

@ -0,0 +1,22 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts", "uno.config.ts"]
}

10
web/uno.config.ts Normal file
View file

@ -0,0 +1,10 @@
import { defineConfig, presetWind, presetAttributify } from 'unocss'
export default defineConfig({
presets: [
presetWind(),
// prefixedOnly: avoids false-positive CSS for bare attribute names like "h2", "grid",
// "shadow" in source files. Use <div un-flex> not <div flex>. Gotcha #4.
presetAttributify({ prefix: 'un-', prefixedOnly: true }),
],
})

12
web/vite.config.ts Normal file
View file

@ -0,0 +1,12 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import UnoCSS from 'unocss/vite'
export default defineConfig({
plugins: [vue(), UnoCSS()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./src/test-setup.ts'],
},
})