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:
parent
bf1dc39f14
commit
ae6021ceeb
28 changed files with 6204 additions and 0 deletions
174
docs/vue-spa-migration.md
Normal file
174
docs/vue-spa-migration.md
Normal 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
2
web/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
node_modules/
|
||||
dist/
|
||||
20
web/index.html
Normal file
20
web/index.html
Normal 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
4956
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
38
web/package.json
Normal file
38
web/package.json
Normal 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
79
web/src/App.vue
Normal 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>
|
||||
52
web/src/assets/peregrine.css
Normal file
52
web/src/assets/peregrine.css
Normal 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
268
web/src/assets/theme.css
Normal 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;
|
||||
}
|
||||
89
web/src/components/AppNav.vue
Normal file
89
web/src/components/AppNav.vue
Normal 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>
|
||||
50
web/src/composables/useApi.ts
Normal file
50
web/src/composables/useApi.ts
Normal 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()
|
||||
}
|
||||
160
web/src/composables/useEasterEgg.ts
Normal file
160
web/src/composables/useEasterEgg.ts
Normal 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())
|
||||
}
|
||||
}
|
||||
20
web/src/composables/useHaptics.ts
Normal file
20
web/src/composables/useHaptics.ts
Normal 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]),
|
||||
}
|
||||
}
|
||||
30
web/src/composables/useMotion.ts
Normal file
30
web/src/composables/useMotion.ts
Normal 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
24
web/src/main.ts
Normal 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
16
web/src/router/index.ts
Normal 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
35
web/src/test-setup.ts
Normal 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() {}
|
||||
}
|
||||
}
|
||||
18
web/src/views/ApplyView.vue
Normal file
18
web/src/views/ApplyView.vue
Normal 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>
|
||||
18
web/src/views/HomeView.vue
Normal file
18
web/src/views/HomeView.vue
Normal 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>
|
||||
18
web/src/views/InterviewPrepView.vue
Normal file
18
web/src/views/InterviewPrepView.vue
Normal 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>
|
||||
18
web/src/views/InterviewsView.vue
Normal file
18
web/src/views/InterviewsView.vue
Normal 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>
|
||||
18
web/src/views/JobReviewView.vue
Normal file
18
web/src/views/JobReviewView.vue
Normal 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>
|
||||
18
web/src/views/SettingsView.vue
Normal file
18
web/src/views/SettingsView.vue
Normal 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>
|
||||
18
web/src/views/SurveyView.vue
Normal file
18
web/src/views/SurveyView.vue
Normal 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
14
web/tsconfig.app.json
Normal 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
7
web/tsconfig.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
22
web/tsconfig.node.json
Normal file
22
web/tsconfig.node.json
Normal 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
10
web/uno.config.ts
Normal 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
12
web/vite.config.ts
Normal 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'],
|
||||
},
|
||||
})
|
||||
Loading…
Reference in a new issue