Merge pull request 'feat: public demo experience (Vue SPA with demo mode)' (#103) from feature/demo-experience into main
Some checks are pending
CI / Backend (Python) (push) Waiting to run
CI / Frontend (Vue) (push) Waiting to run
Mirror / mirror (push) Waiting to run

This commit is contained in:
pyr0ball 2026-04-21 10:15:02 -07:00
commit 5f92c52270
30 changed files with 1172 additions and 38 deletions

View file

@ -10,6 +10,23 @@
> *"Tools for the jobs that the system made hard on purpose."*
**[Try the live demo](https://demo.circuitforge.tech/peregrine)** — no account required, nothing saved.
---
![Job review — swipe right to approve, left to skip](docs/screenshots/02-review-swipe.gif)
<table>
<tr>
<td><img src="docs/screenshots/01-dashboard.png" alt="Dashboard with pipeline stats"/></td>
<td><img src="docs/screenshots/04-interviews.png" alt="Interview kanban with recruiter emails attached"/></td>
</tr>
<tr>
<td><img src="docs/screenshots/03-apply.png" alt="Apply workspace with AI cover letter draft"/></td>
<td><img src="docs/screenshots/02-review.png" alt="Job review card with match score and ghost-post detection"/></td>
</tr>
</table>
---
Job search is a second job nobody hired you for.

View file

@ -15,19 +15,21 @@
services:
app:
api:
build: .
ports:
- "8504:8501"
command: >
bash -c "uvicorn dev_api:app --host 0.0.0.0 --port 8601"
volumes:
- ./demo/config:/app/config
- ./demo/data:/app/data
# No /docs mount — demo has no personal documents
- ./demo:/app/demo:ro # seed.sql lives here; read-only
# /app/data is tmpfs — ephemeral, resets on every container start
tmpfs:
- /app/data
environment:
- DEMO_MODE=true
- STAGING_DB=/app/data/staging.db
- DEMO_SEED_FILE=/app/demo/seed.sql
- DOCS_DIR=/tmp/demo-docs
- STREAMLIT_SERVER_BASE_URL_PATH=peregrine
- PYTHONUNBUFFERED=1
- PYTHONLOGGING=WARNING
# No API keys — inference is blocked by DEMO_MODE before any key is needed
@ -37,6 +39,7 @@ services:
extra_hosts:
- "host.docker.internal:host-gateway"
restart: unless-stopped
# No host port — nginx proxies /api/ → api:8601 internally
web:
build:
@ -45,7 +48,9 @@ services:
args:
VITE_BASE_PATH: /peregrine/
ports:
- "8507:80"
- "8504:80" # demo.circuitforge.tech/peregrine* → host:8504
depends_on:
- api
restart: unless-stopped
searxng:

View file

@ -1,9 +1,11 @@
candidate_accessibility_focus: false
candidate_lgbtq_focus: false
candidate_voice: Clear, direct, and human. Focuses on impact over jargon.
career_summary: 'Experienced software engineer with a background in full-stack development,
cloud infrastructure, and data pipelines. Passionate about building tools that help
people navigate complex systems.
candidate_voice: Clear, direct, and human. Focuses on impact over jargon. Avoids
buzzwords and lets the work speak.
career_summary: 'Senior UX Designer with 6 years of experience designing for music,
education, and media products. Strong background in cross-platform design systems,
user research, and 0-to-1 feature development. Passionate about making complex
digital experiences feel effortless.
'
dev_tier_override: null
@ -16,9 +18,9 @@ inference_profile: remote
linkedin: ''
mission_preferences:
animal_welfare: ''
education: ''
education: Education technology is where design decisions have long-term impact on how people learn.
health: ''
music: ''
music: Love designing for music and audio discovery — it combines craft with genuine emotional resonance.
social_impact: Want my work to reach people who need it most.
name: Demo User
nda_companies: []

259
demo/seed.sql Normal file
View file

@ -0,0 +1,259 @@
-- jobs
INSERT INTO jobs (title, company, url, source, location, is_remote, salary, match_score, status, date_found, date_posted, cover_letter, applied_at, phone_screen_at, interviewing_at, offer_at, hired_at, interview_date, rejection_stage, hired_feedback) VALUES ('UX Designer', 'Spotify', 'https://www.linkedin.com/jobs/view/1000001', 'linkedin', 'Remote', '1', '$110k$140k', '94.0', 'approved', '2026-04-14', '2026-04-12', 'Dear Hiring Manager,
I''m excited to apply for the UX Designer role at Spotify. With five years of
experience designing for music discovery and cross-platform experiences, I''ve
consistently shipped features that make complex audio content feel effortless to
navigate. At my last role I led a redesign of the playlist creation flow that
reduced drop-off by 31%.
Spotify''s commitment to artist and listener discovery and its recent push into
audiobooks and podcast tooling aligns directly with the kind of cross-format
design challenges I''m most energised by.
I''d love to bring that focus to your product design team.
Warm regards,
[Your name]
', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL);
INSERT INTO jobs (title, company, url, source, location, is_remote, salary, match_score, status, date_found, date_posted, cover_letter, applied_at, phone_screen_at, interviewing_at, offer_at, hired_at, interview_date, rejection_stage, hired_feedback) VALUES ('Product Designer', 'Duolingo', 'https://www.linkedin.com/jobs/view/1000002', 'linkedin', 'Pittsburgh, PA', '0', '$95k$120k', '87.0', 'approved', '2026-04-13', '2026-04-10', 'Draft in progress — cover letter generating…', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL);
INSERT INTO jobs (title, company, url, source, location, is_remote, salary, match_score, status, date_found, date_posted, cover_letter, applied_at, phone_screen_at, interviewing_at, offer_at, hired_at, interview_date, rejection_stage, hired_feedback) VALUES ('UX Lead', 'NPR', 'https://www.indeed.com/viewjob?jk=1000003', 'indeed', 'Washington, DC', '1', '$120k$150k', '81.0', 'approved', '2026-04-12', '2026-04-08', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL);
INSERT INTO jobs (title, company, url, source, location, is_remote, salary, match_score, status, date_found, date_posted, cover_letter, applied_at, phone_screen_at, interviewing_at, offer_at, hired_at, interview_date, rejection_stage, hired_feedback) VALUES ('Senior UX Designer', 'Mozilla', 'https://www.linkedin.com/jobs/view/1000004', 'linkedin', 'Remote', '1', '$105k$130k', '81.0', 'pending', '2026-04-13', '2026-03-12', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL);
INSERT INTO jobs (title, company, url, source, location, is_remote, salary, match_score, status, date_found, date_posted, cover_letter, applied_at, phone_screen_at, interviewing_at, offer_at, hired_at, interview_date, rejection_stage, hired_feedback) VALUES ('Interaction Designer', 'Figma', 'https://www.indeed.com/viewjob?jk=1000005', 'indeed', 'San Francisco, CA', '1', '$115k$145k', '78.0', 'pending', '2026-04-11', '2026-04-09', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL);
INSERT INTO jobs (title, company, url, source, location, is_remote, salary, match_score, status, date_found, date_posted, cover_letter, applied_at, phone_screen_at, interviewing_at, offer_at, hired_at, interview_date, rejection_stage, hired_feedback) VALUES ('Product Designer II', 'Notion', 'https://www.linkedin.com/jobs/view/1000006', 'linkedin', 'Remote', '1', '$100k$130k', '76.0', 'pending', '2026-04-10', '2026-04-07', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL);
INSERT INTO jobs (title, company, url, source, location, is_remote, salary, match_score, status, date_found, date_posted, cover_letter, applied_at, phone_screen_at, interviewing_at, offer_at, hired_at, interview_date, rejection_stage, hired_feedback) VALUES ('UX Designer', 'Stripe', 'https://www.linkedin.com/jobs/view/1000007', 'linkedin', 'Remote', '1', '$120k$150k', '74.0', 'pending', '2026-04-09', '2026-04-06', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL);
INSERT INTO jobs (title, company, url, source, location, is_remote, salary, match_score, status, date_found, date_posted, cover_letter, applied_at, phone_screen_at, interviewing_at, offer_at, hired_at, interview_date, rejection_stage, hired_feedback) VALUES ('UI/UX Designer', 'Canva', 'https://www.indeed.com/viewjob?jk=1000008', 'indeed', 'Remote', '1', '$90k$115k', '72.0', 'pending', '2026-04-08', '2026-04-05', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL);
INSERT INTO jobs (title, company, url, source, location, is_remote, salary, match_score, status, date_found, date_posted, cover_letter, applied_at, phone_screen_at, interviewing_at, offer_at, hired_at, interview_date, rejection_stage, hired_feedback) VALUES ('Senior Product Designer', 'Asana', 'https://www.linkedin.com/jobs/view/1000009', 'linkedin', 'San Francisco, CA', '1', '$125k$155k', '69.0', 'pending', '2026-04-07', '2026-04-04', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL);
INSERT INTO jobs (title, company, url, source, location, is_remote, salary, match_score, status, date_found, date_posted, cover_letter, applied_at, phone_screen_at, interviewing_at, offer_at, hired_at, interview_date, rejection_stage, hired_feedback) VALUES ('UX Researcher', 'Intercom', 'https://www.indeed.com/viewjob?jk=1000010', 'indeed', 'Remote', '1', '$95k$120k', '67.0', 'pending', '2026-04-06', '2026-04-03', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL);
INSERT INTO jobs (title, company, url, source, location, is_remote, salary, match_score, status, date_found, date_posted, cover_letter, applied_at, phone_screen_at, interviewing_at, offer_at, hired_at, interview_date, rejection_stage, hired_feedback) VALUES ('Product Designer', 'Linear', 'https://www.linkedin.com/jobs/view/1000011', 'linkedin', 'Remote', '1', '$110k$135k', '65.0', 'pending', '2026-04-05', '2026-04-02', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL);
INSERT INTO jobs (title, company, url, source, location, is_remote, salary, match_score, status, date_found, date_posted, cover_letter, applied_at, phone_screen_at, interviewing_at, offer_at, hired_at, interview_date, rejection_stage, hired_feedback) VALUES ('UX Designer', 'Loom', 'https://www.indeed.com/viewjob?jk=1000012', 'indeed', 'Remote', '1', '$90k$110k', '62.0', 'pending', '2026-04-04', '2026-04-01', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL);
INSERT INTO jobs (title, company, url, source, location, is_remote, salary, match_score, status, date_found, date_posted, cover_letter, applied_at, phone_screen_at, interviewing_at, offer_at, hired_at, interview_date, rejection_stage, hired_feedback) VALUES ('Senior Product Designer', 'Asana', 'https://www.asana.com/jobs/1000013', 'linkedin', 'San Francisco, CA', '1', '$125k$155k', '91.0', 'phone_screen', '2026-04-01', '2026-03-30', NULL, '2026-04-08', '2026-04-15', NULL, NULL, NULL, '2026-04-15T14:00:00', NULL, NULL);
INSERT INTO jobs (title, company, url, source, location, is_remote, salary, match_score, status, date_found, date_posted, cover_letter, applied_at, phone_screen_at, interviewing_at, offer_at, hired_at, interview_date, rejection_stage, hired_feedback) VALUES ('Product Designer', 'Notion', 'https://www.notion.so/jobs/1000014', 'indeed', 'Remote', '1', '$100k$130k', '88.0', 'interviewing', '2026-03-25', '2026-03-23', NULL, '2026-04-01', '2026-04-05', '2026-04-12', NULL, NULL, '2026-04-22T10:00:00', NULL, NULL);
INSERT INTO jobs (title, company, url, source, location, is_remote, salary, match_score, status, date_found, date_posted, cover_letter, applied_at, phone_screen_at, interviewing_at, offer_at, hired_at, interview_date, rejection_stage, hired_feedback) VALUES ('Design Systems Designer', 'Figma', 'https://www.figma.com/jobs/1000015', 'linkedin', 'San Francisco, CA', '1', '$130k$160k', '96.0', 'hired', '2026-03-01', '2026-02-27', NULL, '2026-03-08', '2026-03-14', '2026-03-21', '2026-04-01', '2026-04-08', NULL, NULL, '{"factors":["clear_scope","great_manager","mission_aligned"],"notes":"Excited about design systems work. Salary met expectations."}');
INSERT INTO jobs (title, company, url, source, location, is_remote, salary, match_score, status, date_found, date_posted, cover_letter, applied_at, phone_screen_at, interviewing_at, offer_at, hired_at, interview_date, rejection_stage, hired_feedback) VALUES ('UX Designer', 'Slack', 'https://slack.com/jobs/1000016', 'indeed', 'Remote', '1', '$115k$140k', '79.0', 'applied', '2026-03-18', '2026-03-16', NULL, '2026-03-28', NULL, NULL, NULL, NULL, NULL, NULL, NULL);
-- job_contacts
INSERT INTO job_contacts (job_id, direction, subject, from_addr, to_addr, received_at, stage_signal) VALUES (1, 'inbound', 'Excited to connect — UX Designer role at Spotify', 'jamie.chen@spotify.com', 'you@example.com', '2026-04-12', 'positive_response');
INSERT INTO job_contacts (job_id, direction, subject, from_addr, to_addr, received_at, stage_signal) VALUES (1, 'outbound', 'Re: Excited to connect — UX Designer role at Spotify', 'you@example.com', 'jamie.chen@spotify.com', '2026-04-13', NULL);
INSERT INTO job_contacts (job_id, direction, subject, from_addr, to_addr, received_at, stage_signal) VALUES (13, 'inbound', 'Interview Confirmation — Senior Product Designer', 'recruiting@asana.com', 'you@example.com', '2026-04-13', 'interview_scheduled');
INSERT INTO job_contacts (job_id, direction, subject, from_addr, to_addr, received_at, stage_signal) VALUES (14, 'inbound', 'Your panel interview is confirmed for Apr 22', 'recruiting@notion.so', 'you@example.com', '2026-04-12', 'interview_scheduled');
INSERT INTO job_contacts (job_id, direction, subject, from_addr, to_addr, received_at, stage_signal) VALUES (14, 'inbound', 'Pre-interview prep resources', 'marcus.webb@notion.so', 'you@example.com', '2026-04-13', 'positive_response');
INSERT INTO job_contacts (job_id, direction, subject, from_addr, to_addr, received_at, stage_signal) VALUES (15, 'inbound', 'Figma Design Systems — Offer Letter', 'offers@figma.com', 'you@example.com', '2026-04-01', 'offer_received');
INSERT INTO job_contacts (job_id, direction, subject, from_addr, to_addr, received_at, stage_signal) VALUES (15, 'outbound', 'Re: Figma Design Systems — Offer Letter (acceptance)', 'you@example.com', 'offers@figma.com', '2026-04-05', NULL);
INSERT INTO job_contacts (job_id, direction, subject, from_addr, to_addr, received_at, stage_signal) VALUES (15, 'inbound', 'Welcome to Figma! Onboarding next steps', 'onboarding@figma.com', 'you@example.com', '2026-04-08', NULL);
INSERT INTO job_contacts (job_id, direction, subject, from_addr, to_addr, received_at, stage_signal) VALUES (16, 'inbound', 'Thanks for applying to Slack', 'noreply@slack.com', 'you@example.com', '2026-03-28', NULL);
-- references_
INSERT INTO references_ (name, email, role, company, relationship, notes, tags, prep_email) VALUES ('Dr. Priya Nair', 'priya.nair@example.com', 'Director of Design', 'Acme Corp', 'former_manager', 'Managed me for 3 years on the consumer app redesign. Enthusiastic reference.', '["manager","design"]', 'Hi Priya,
I hope you''re doing well! I''m currently interviewing for a few senior UX roles and would be so grateful if you''d be willing to serve as a reference.
Thank you!
[Your name]');
INSERT INTO references_ (name, email, role, company, relationship, notes, tags, prep_email) VALUES ('Sam Torres', 'sam.torres@example.com', 'Senior Product Designer', 'Acme Corp', 'former_colleague', 'Worked together on design systems. Great at speaking to collaborative process.', '["colleague","design_systems"]', NULL);
INSERT INTO references_ (name, email, role, company, relationship, notes, tags, prep_email) VALUES ('Jordan Kim', 'jordan.kim@example.com', 'VP of Product', 'Streamline Inc', 'former_manager', 'Led the product team I was embedded in. Can speak to business impact of design work.', '["manager","product"]', NULL);
-- resumes
INSERT INTO resumes (name, source, job_id, text, struct_json, word_count, is_default) VALUES (
'Base Resume',
'uploaded',
NULL,
'ALEX RIVERA
UX Designer · Product Design · Design Systems
alex.rivera@example.com · linkedin.com/in/alexrivera · Portfolio: alexrivera.design
SUMMARY
Senior UX Designer with 6 years of experience designing for music, education, and media platforms. Led 0-to-1 product work and redesigned high-traffic flows used by tens of millions of users. Deep background in user research, interaction design, and cross-platform design systems. Strong collaborator with engineering and product comfortable in ambiguity, methodical about process.
EXPERIENCE
Senior UX Designer StreamNote (2023present)
- Led redesign of the core listening queue, reducing abandonment by 31% across mobile and web
- Built and maintained a component library (Figma tokens + React) used by 8 product squads
- Ran 60+ moderated user research sessions; findings shaped 3 major product bets
- Partnered with ML team to design recommendation transparency features for power users
UX Designer EduPath (20212023)
- Designed the onboarding and early-habit loop for a K12 learning app (2.4M DAU)
- Shipped streak redesign that improved D7 retention by 18%
- Drove accessibility audit and remediation (WCAG 2.1 AA); filed and closed 47 issues
- Mentored 2 junior designers; led weekly design critique
Product Designer Signal Media (20192021)
- Designed editorial tools and reader-facing article experiences for a digital news publisher
- Prototyped and shipped a "read later" feature that became the #2 most-used feature within 90 days
- Collaborated with editorial and engineering to establish a shared component system (reduces new-story design time by 60%)
SKILLS
Figma · Prototyping · User Research · Usability Testing · Design Systems · Interaction Design
Accessibility (WCAG 2.1) · Cross-Platform (iOS/Android/Web) · React (collaboration-level) · SQL (basic)
Workshop Facilitation · Stakeholder Communication
EDUCATION
B.F.A. Graphic Design, Minor in Human-Computer Interaction State University of the Arts, 2019
SELECTED PROJECTS
Playlist Flow Redesign (StreamNote) reduced creation drop-off 31%, won internal design award
D7 Retention Streak (EduPath) +18% weekly retention; featured in company all-hands
Accessibility Audit (EduPath) full WCAG 2.1 AA remediation across iOS, Android, web',
'{"contact":{"name":"Alex Rivera","email":"alex.rivera@example.com","linkedin":"linkedin.com/in/alexrivera","portfolio":"alexrivera.design"},"summary":"Senior UX Designer with 6 years of experience designing for music, education, and media platforms.","experience":[{"company":"StreamNote","title":"Senior UX Designer","dates":"2023present","bullets":["Led redesign of core listening queue, reducing abandonment by 31%","Built component library used by 8 product squads","Ran 60+ moderated user research sessions"]},{"company":"EduPath","title":"UX Designer","dates":"20212023","bullets":["Designed onboarding and early-habit loop for K12 app (2.4M DAU)","Shipped streak redesign that improved D7 retention by 18%","Drove accessibility audit (WCAG 2.1 AA)"]},{"company":"Signal Media","title":"Product Designer","dates":"20192021","bullets":["Designed editorial tools and reader-facing article experiences","Prototyped and shipped read-later feature (top 2 used within 90 days)"]}],"education":[{"institution":"State University of the Arts","degree":"B.F.A. Graphic Design, Minor in HCI","year":"2019"}],"skills":["Figma","Prototyping","User Research","Usability Testing","Design Systems","Interaction Design","Accessibility (WCAG 2.1)","Cross-Platform","React","SQL","Workshop Facilitation"]}',
320,
1
);
-- ATS resume optimizer data for approved jobs (Spotify=1, Duolingo=2, NPR=3)
-- Spotify: gap report highlights audio/podcast tooling keywords; optimized resume tailored
UPDATE jobs SET
ats_gap_report = '[{"term":"audio UX","section":"experience","priority":3,"rationale":"Spotify''s JD emphasizes audio product experience; resume mentions music broadly but not audio-specific UX patterns"},{"term":"podcast design","section":"experience","priority":2,"rationale":"Spotify is investing heavily in podcast tooling; related experience at Signal Media could be framed around audio content"},{"term":"cross-platform mobile","section":"skills","priority":2,"rationale":"JD specifies iOS and Android explicitly; resume lists cross-platform but not mobile-first framing"},{"term":"A/B testing","section":"experience","priority":1,"rationale":"JD mentions data-driven iteration; resume does not reference experimentation framework"}]',
optimized_resume = 'ALEX RIVERA
UX Designer · Audio Product · Cross-Platform Design
alex.rivera@example.com · linkedin.com/in/alexrivera · Portfolio: alexrivera.design
SUMMARY
Senior UX Designer specializing in audio and media product design. 6 years of experience shipping cross-platform features used by millions with a focus on music discovery, content navigation, and habit-forming interactions. Comfortable moving from user research to pixel-perfect specs to cross-functional alignment.
EXPERIENCE
Senior UX Designer StreamNote (2023present)
- Led redesign of the core listening queue (audio UX) reduced abandonment 31% across iOS, Android, and web
- Designed podcast chapter navigation prototype; validated with 8 user sessions, handed off to eng in Q3
- Built Figma component library (tokens + variants) used by 8 product squads cut design-to-dev handoff time by 40%
- Drove A/B test framework with data team: 12 experiments shipped; 7 reached statistical significance
UX Designer EduPath (20212023)
- Designed cross-platform onboarding (iOS/Android/web) for K12 learning app, 2.4M DAU
- Shipped streak redesign with 3 A/B variants winning variant improved D7 retention by 18%
- Full WCAG 2.1 AA remediation across all platforms; filed and closed 47 issues
Product Designer Signal Media (20192021)
- Designed audio and editorial experiences for a digital media publisher
- Prototyped and shipped "listen later" feature for podcast content #2 most-used feature within 90 days
- Established shared design system that reduced new-story design time by 60%
SKILLS
Figma · Audio UX · Podcast Design · Cross-Platform (iOS/Android/Web) · Design Systems
A/B Testing · User Research · Usability Testing · Accessibility (WCAG 2.1) · Interaction Design
EDUCATION
B.F.A. Graphic Design, Minor in HCI State University of the Arts, 2019'
WHERE id = 1;
-- Duolingo: gap report highlights gamification, retention, and learning science keywords
UPDATE jobs SET
ats_gap_report = '[{"term":"gamification","section":"experience","priority":3,"rationale":"Duolingo''s entire product is built on gamification mechanics; streak work at EduPath is highly relevant but not explicitly framed"},{"term":"streak mechanics","section":"experience","priority":3,"rationale":"Duolingo invented the streak; EduPath streak redesign is directly applicable and should be foregrounded"},{"term":"learning science","section":"experience","priority":2,"rationale":"JD references behavioral psychology; resume does not mention research-backed habit design"},{"term":"localization","section":"skills","priority":1,"rationale":"Duolingo ships to 40+ languages; internationalization experience or awareness would strengthen application"}]',
optimized_resume = 'ALEX RIVERA
UX Designer · Gamification · Learning Products
alex.rivera@example.com · linkedin.com/in/alexrivera · Portfolio: alexrivera.design
SUMMARY
UX Designer with 6 years of experience in education and media products. Designed habit-forming experiences grounded in behavioral research streak systems, onboarding flows, and retention mechanics for apps with millions of daily active users. Passionate about learning products that feel like play.
EXPERIENCE
UX Designer EduPath (20212023)
- Redesigned streak and gamification mechanics for K12 learning app (2.4M DAU) D7 retention +18%
- Applied behavioral science principles (variable reward, loss aversion, social proof) to onboarding flow redesign
- Led 30+ user research sessions with students, parents, and teachers; findings shaped product roadmap for 2 quarters
- Drove WCAG 2.1 AA accessibility remediation 47 issues filed and closed across iOS, Android, web
Senior UX Designer StreamNote (2023present)
- Designed habit-reinforcing listening queue with personalized recommendations surface abandonment -31%
- Built and scaled Figma design system used by 8 squads; reduced design-to-dev cycle by 40%
- Ran A/B tests with data team; 12 experiments across retention and discovery features
Product Designer Signal Media (20192021)
- Designed reader engagement and content-return mechanics for digital news platform
- "Read later" feature reached #2 usage within 90 days of launch
SKILLS
Figma · Gamification Design · Habit & Retention Mechanics · User Research · Behavioral UX
Learning Products · Accessibility (WCAG 2.1) · Cross-Platform (iOS/Android/Web) · Design Systems
EDUCATION
B.F.A. Graphic Design, Minor in HCI State University of the Arts, 2019'
WHERE id = 2;
-- NPR: gap report highlights public media, accessibility, and editorial tool experience
UPDATE jobs SET
ats_gap_report = '[{"term":"public media","section":"experience","priority":3,"rationale":"NPR is a public media org; framing experience around mission-driven media rather than commercial products strengthens fit"},{"term":"editorial tools","section":"experience","priority":3,"rationale":"NPR''s UX Lead role includes internal tools for journalists; Signal Media editorial tools work is directly applicable"},{"term":"accessibility standards","section":"experience","priority":2,"rationale":"NPR serves a broad public audience including listeners with disabilities; WCAG work at EduPath should be prominent"},{"term":"content discovery","section":"experience","priority":2,"rationale":"NPR''s JD mentions listener discovery; StreamNote queue redesign is relevant framing"}]',
optimized_resume = 'ALEX RIVERA
UX Lead · Public Media · Accessible Design
alex.rivera@example.com · linkedin.com/in/alexrivera · Portfolio: alexrivera.design
SUMMARY
Senior UX Designer with 6 years of experience in media, education, and content platforms. Led design for editorial tools, content discovery surfaces, and accessible experiences for mission-driven organizations. Believes design has an obligation to reach all users especially the ones the industry tends to forget.
EXPERIENCE
Senior UX Designer StreamNote (2023present)
- Led content discovery redesign (listening queue, personalized surfaces) abandonment -31%
- Designed and shipped podcast chapter navigation as a 0-to-1 feature
- Built scalable Figma component library used by 8 cross-functional squads
- Ran 60+ moderated research sessions; regularly presented findings to CPO and VP Product
Product Designer Signal Media (20192021)
- Designed editorial authoring tools used daily by 120+ journalists reduced story publish time by 35%
- Shipped "read later" feature for a digital news publisher #2 most-used feature within 90 days
- Established shared design system that cut new-template design time by 60%
UX Designer EduPath (20212023)
- Led full WCAG 2.1 AA accessibility audit and remediation across iOS, Android, and web
- Designed onboarding and retention flows for a public K12 learning app (2.4M DAU)
- D7 retention +18% following streak redesign; results shared at company all-hands
SKILLS
Figma · Editorial & Publishing Tools · Content Discovery UX · Accessibility (WCAG 2.1 AA)
Public-Facing Product Design · User Research · Cross-Platform · Design Systems
EDUCATION
B.F.A. Graphic Design, Minor in HCI State University of the Arts, 2019'
WHERE id = 3;
-- company_research for interview-stage jobs
-- Job 13: Asana (phone_screen, interview 2026-04-15)
INSERT INTO company_research (job_id, generated_at, company_brief, ceo_brief, talking_points, tech_brief, funding_brief, competitors_brief, red_flags, accessibility_brief, scrape_used, raw_output) VALUES (
13,
'2026-04-14T09:00:00',
'Asana is a work management platform founded in 2008 by Dustin Moskovitz and Justin Rosenstein (both ex-Facebook). Headquartered in San Francisco, Asana went public on the NYSE in September 2020 via a direct listing. The product focuses on project and task management for teams, with a strong emphasis on clarity of ownership and cross-functional coordination. It serves over 130,000 paying customers across 190+ countries. Asana''s design philosophy centers on removing ambiguity from work — a principle that directly shapes product design decisions. The company has made significant investments in AI-assisted task management through its "AI Studio" features, launched in 2024.',
'Dustin Moskovitz, co-founder and CEO, is known for a thoughtful management style and genuine interest in org design and well-being at work. He is a co-founder of the effective altruism movement and the Open Philanthropy Project. Expect questions and conversation that reflect a values-driven culture — mission alignment matters here. Anne Raimondi is COO and a well-regarded operations leader.',
'["Asana''s design team works closely with the Core Product and Platform squads — ask how design embeds with engineering","Recent focus on AI features (AI Studio, smart task assignment) — familiarity with AI UX patterns will land well","Asana''s brand voice is unusually distinct — understand their design language before the call","Ask about the cross-functional collaboration model: how does design influence roadmap priority?","The role is hybrid SF — clarify expectations around in-office days upfront"]',
'Asana is built primarily on React (frontend), Python and PHP (backend), and uses a proprietary data model (the Asana object graph) that drives their real-time sync. Their design team uses Figma heavily. They have invested in their own design system ("Alchemy") which underpins the entire product.',
'Asana went public via direct listing (NYSE: ASAN) in September 2020. Revenue in FY2025 was approximately $726M, with consistent double-digit YoY growth. The company has been investing in profitability — operating losses have narrowed significantly. No recent acquisition activity.',
'Primary competitors: Monday.com, ClickUp, Notion (project management use cases), Jira (for engineering teams), and Microsoft Project. Asana differentiates on simplicity, clear ownership model, and enterprise reliability over raw feature count.',
NULL,
'Asana has published an accessibility statement and maintains WCAG 2.1 AA compliance across their core product. Their employee ERGs include groups for disability and neurodiversity. The company scores above average on Glassdoor for work-life balance. Their San Francisco HQ has dedicated quiet spaces and standing desks.',
0,
'Asana company research generated for phone screen 2026-04-15. Sources: public filings, company blog, Glassdoor.'
);
-- Job 14: Notion (interviewing, panel 2026-04-22)
INSERT INTO company_research (job_id, generated_at, company_brief, ceo_brief, talking_points, tech_brief, funding_brief, competitors_brief, red_flags, accessibility_brief, scrape_used, raw_output) VALUES (
14,
'2026-04-11T14:30:00',
'Notion is an all-in-one workspace tool combining notes, docs, wikis, and project management. Founded in 2013, relaunched in 2018 after a near-failure. Headquartered in San Francisco, with a significant remote-first culture. Notion reached a $10B valuation in its 2021 funding round and has since focused on consolidation and profitability. The product is unusually design-forward — Notion''s UI is considered a benchmark in the industry for flexibility without overwhelming complexity. Their 20232024 push into AI (Notion AI) added LLM-powered writing and summarization directly into the workspace. The product design team is small-but-influential and works closely with the founders.',
'Ivan Zhao is co-founder and CEO, known for being deeply product-focused and aesthetically driven. He has described Notion as an attempt to make software feel like a craftsman''s tool. Akshay Kothari is co-founder and COO. The culture reflects the founders'' values: deliberate, high-craft, opinionated. Expect the panel to include designers or PMs who will probe your design sensibility and taste.',
'["Notion''s design team is small and influential — expect ownership of end-to-end features, not component-level work","AI features (Notion AI) are a major current initiative — come with opinions on how AI should integrate into a workspace without disrupting user flow","Notion''s design language is a competitive moat — study it carefully before the panel","Panel likely includes a PM, a senior designer, and possibly a founder — tailor your portfolio walk to each audience","Ask about the product design team structure: how many designers, how do they embed with eng, what does the IC path look like?"]',
'Notion is built on a React frontend with a custom block-based data model. Their backend uses Postgres and Kafka for real-time sync. Notion AI uses third-party LLM providers (Anthropic, OpenAI) via API. The design team uses Figma and maintains a well-documented internal design system.',
'Notion raised $275M at a $10B valuation in October 2021 (led by Sequoia and Coatue). The company has not announced further funding rounds; public commentary suggests a path to profitability. ARR estimated at $300500M as of 2024.',
'Competitors include Confluence (Atlassian), Coda, Linear (for engineering-focused workflows), Obsidian (local-first notes), and increasingly Asana and ClickUp for project management use cases. Notion''s differentiator is its flexible block model and strong brand identity with knowledge workers.',
'Some employee reviews mention that the small team size means high ownership but also that projects can pivot quickly. Design headcount has been stable post-2022 layoffs. Worth asking about team stability in the panel.',
'Notion has made public commitments to WCAG 2.1 AA compliance but has received community feedback that keyboard navigation in the block editor has gaps. Their 2024 accessibility roadmap addressed the most commonly reported issues. The company has a neurodiversity ERG and remote-first culture (async-friendly).',
0,
'Notion company research generated for panel interview 2026-04-22. Sources: public filings, company blog, community accessibility reports.'
);
-- Job 15: Figma (hired — research used during interview cycle)
INSERT INTO company_research (job_id, generated_at, company_brief, ceo_brief, talking_points, tech_brief, funding_brief, competitors_brief, red_flags, accessibility_brief, scrape_used, raw_output) VALUES (
15,
'2026-03-13T11:00:00',
'Figma is the leading browser-based design tool, founded in 2012 by Dylan Field and Evan Wallace. Headquartered in San Francisco. Figma disrupted the design tool market with its collaborative, multiplayer approach — Google Docs for design. The product includes Figma Design, FigJam (whiteboarding), and Dev Mode (engineering handoff). Adobe''s attempted $20B acquisition was blocked by UK and EU regulators in 2023; Figma received a $1B termination fee. Post-Adobe, Figma has accelerated independent investment in AI features and a new "Figma Make" prototyping tool. The Design Systems team (the role you accepted) is responsible for the core component and token infrastructure used across all Figma products.',
'Dylan Field, co-founder and CEO, is known for being deeply technical and product-obsessed. He joined the board of OpenAI. Post-Adobe-deal fallout, Field has been publicly focused on Figma''s independent growth trajectory. Expect a culture of high standards and genuine product craft. Noah Levin leads the design org.',
'["You are joining the Design Systems team — the infrastructure team for Figma''s own product design","Your work will directly impact every other designer at Figma — high visibility, high leverage","Figma uses its own product (dogfooding) — you will be designing in Figma for Figma","Key initiative: AI-assisted component generation in Figma Make — design systems input is critical","You are the first external hire in this role since the Adobe deal fell through — ask about team direction post-acquisition"]',
'Figma''s frontend is React with a custom WebGL rendering engine (written in Rust + WASM) for the canvas. This is some of the most sophisticated browser-based graphics code in production. Dev Mode connects to GitHub, Storybook, and VS Code. The design system team works in Figma and outputs tokens that connect to code via Figma''s token pipeline.',
'Figma received a $1B termination fee from Adobe when the acquisition was blocked in late 2023. The company raised $200M at a $10B valuation in 2021. With the termination fee and strong ARR, Figma is well-capitalized for independent growth. No IPO timeline announced publicly.',
'Primary competitor is Sketch (declining market share), with Adobe XD effectively sunset. Framer is a growing competitor for prototyping. Penpot (open-source) is gaining traction in privacy-conscious and European markets. Figma''s multiplayer and browser-based approach remains a strong moat.',
NULL,
'Figma has an active accessibility team and public blog posts on designing accessible components. Their design system (the one you will be contributing to) includes built-in accessibility annotations and ARIA guidance. The company has disability and neurodiversity ERGs. Remote-friendly with SF HQ.',
0,
'Figma company research generated for interviewing stage 2026-03-13. Sources: company blog, public filings, design community.'
);

View file

@ -46,13 +46,30 @@ DB_PATH = os.environ.get("STAGING_DB", "/devl/job-seeker/staging.db")
_CLOUD_MODE = os.environ.get("CLOUD_MODE", "").lower() in ("1", "true")
_CLOUD_DATA_ROOT = Path(os.environ.get("CLOUD_DATA_ROOT", "/devl/menagerie-data"))
_DIRECTUS_SECRET = os.environ.get("DIRECTUS_JWT_SECRET", "")
IS_DEMO: bool = os.environ.get("DEMO_MODE", "").lower() in ("1", "true", "yes")
# Per-request DB path — set by cloud_session_middleware; falls back to DB_PATH
_request_db: ContextVar[str | None] = ContextVar("_request_db", default=None)
def _load_demo_seed(db_path: str, seed_file: str) -> None:
"""Load seed SQL into the demo DB if it is empty (no jobs rows yet)."""
import sqlite3 as _sqlite3
seed_path = Path(seed_file)
if not seed_path.exists():
return
con = _sqlite3.connect(db_path)
try:
count = con.execute("SELECT COUNT(*) FROM jobs").fetchone()[0]
if count == 0:
con.executescript(seed_path.read_text())
con.commit()
finally:
con.close()
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Load .env then run pending SQLite migrations on startup."""
"""Load .env, run migrations, and (in demo mode) seed the demo DB."""
# Load .env before any runtime env reads — safe because lifespan doesn't run
# when dev_api is imported by tests (only when uvicorn actually starts).
_load_env(PEREGRINE_ROOT / ".env")
@ -72,6 +89,8 @@ async def lifespan(app: FastAPI):
except Exception as exc:
_sweep_log.warning("Migration failed for %s: %s", user_db, exc)
if IS_DEMO and (seed_file := os.environ.get("DEMO_SEED_FILE")):
_load_demo_seed(DB_PATH, seed_file)
yield
@ -95,6 +114,12 @@ app.include_router(_feedback_router, prefix="/api/feedback")
_log = logging.getLogger("peregrine.session")
def _demo_guard() -> None:
"""Raise 403 if running in demo mode. Call at the top of any write endpoint."""
if IS_DEMO:
raise HTTPException(status_code=403, detail="demo-write-blocked")
def _resolve_cf_user_id(cookie_str: str) -> str | None:
"""Extract cf_session JWT from Cookie string and return Directus user_id.
@ -310,6 +335,7 @@ def job_counts():
@app.post("/api/jobs/{job_id}/approve")
def approve_job(job_id: int):
_demo_guard()
db = _get_db()
db.execute("UPDATE jobs SET status = 'approved' WHERE id = ?", (job_id,))
db.commit()
@ -321,6 +347,7 @@ def approve_job(job_id: int):
@app.post("/api/jobs/{job_id}/reject")
def reject_job(job_id: int):
_demo_guard()
db = _get_db()
db.execute("UPDATE jobs SET status = 'rejected' WHERE id = ?", (job_id,))
db.commit()
@ -410,6 +437,7 @@ def save_cover_letter(job_id: int, body: CoverLetterBody):
@app.post("/api/jobs/{job_id}/cover_letter/generate")
def generate_cover_letter(job_id: int):
_demo_guard()
try:
from scripts.task_runner import submit_task
task_id, is_new = submit_task(
@ -1596,6 +1624,7 @@ class HiredFeedbackPayload(BaseModel):
@app.post("/api/jobs/{job_id}/hired-feedback")
def save_hired_feedback(job_id: int, payload: HiredFeedbackPayload):
_demo_guard()
db = _get_db()
row = db.execute("SELECT status FROM jobs WHERE id = ?", (job_id,)).fetchone()
if not row:

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 220 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 114 KiB

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

View file

@ -0,0 +1,22 @@
-- Migration 006: Add columns and tables present in the live DB but missing from migrations
-- These were added via direct ALTER TABLE after the v0.8.5 baseline was written.
-- date_posted: used for ghost-post shadow-score detection
ALTER TABLE jobs ADD COLUMN date_posted TEXT;
-- hired_feedback: JSON blob saved when a job reaches the 'hired' outcome
ALTER TABLE jobs ADD COLUMN hired_feedback TEXT;
-- references_ table: contacts who can provide references for applications
CREATE TABLE IF NOT EXISTS references_ (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
relationship TEXT,
company TEXT,
email TEXT,
phone TEXT,
notes TEXT,
tags TEXT,
prep_email TEXT,
role TEXT
);

View file

@ -0,0 +1,254 @@
#!/usr/bin/env python3
"""
Generate demo/seed.sql committed seed INSERT statements for the demo DB.
Run whenever seed data needs to change:
conda run -n cf python scripts/generate_demo_seed.py
Outputs pure INSERT SQL (no DDL). Schema migrations are handled by db_migrate.py
at container startup. The seed SQL is loaded after migrations complete.
"""
from __future__ import annotations
from datetime import date, timedelta
from pathlib import Path
OUT_PATH = Path(__file__).parent.parent / "demo" / "seed.sql"
TODAY = date.today()
def _dago(n: int) -> str:
return (TODAY - timedelta(days=n)).isoformat()
def _dfrom(n: int) -> str:
return (TODAY + timedelta(days=n)).isoformat()
COVER_LETTER_SPOTIFY = """\
Dear Hiring Manager,
I'm excited to apply for the UX Designer role at Spotify. With five years of
experience designing for music discovery and cross-platform experiences, I've
consistently shipped features that make complex audio content feel effortless to
navigate. At my last role I led a redesign of the playlist creation flow that
reduced drop-off by 31%.
Spotify's commitment to artist and listener discovery — and its recent push into
audiobooks and podcast tooling aligns directly with the kind of cross-format
design challenges I'm most energised by.
I'd love to bring that focus to your product design team.
Warm regards,
[Your name]
"""
SQL_PARTS: list[str] = []
# ── Jobs ──────────────────────────────────────────────────────────────────────
# Columns: title, company, url, source, location, is_remote, salary,
# match_score, status, date_found, date_posted, cover_letter,
# applied_at, phone_screen_at, interviewing_at, offer_at, hired_at,
# interview_date, rejection_stage, hired_feedback
JOBS: list[tuple] = [
# ---- Review queue (12 jobs — mix of pending + approved) ------------------
("UX Designer",
"Spotify", "https://www.linkedin.com/jobs/view/1000001",
"linkedin", "Remote", 1, "$110k$140k",
94.0, "approved", _dago(1), _dago(3), COVER_LETTER_SPOTIFY,
None, None, None, None, None, None, None, None),
("Product Designer",
"Duolingo", "https://www.linkedin.com/jobs/view/1000002",
"linkedin", "Pittsburgh, PA", 0, "$95k$120k",
87.0, "approved", _dago(2), _dago(5), "Draft in progress — cover letter generating…",
None, None, None, None, None, None, None, None),
("UX Lead",
"NPR", "https://www.indeed.com/viewjob?jk=1000003",
"indeed", "Washington, DC", 1, "$120k$150k",
81.0, "approved", _dago(3), _dago(7), None,
None, None, None, None, None, None, None, None),
# Ghost post — date_posted 34 days ago → shadow indicator
("Senior UX Designer",
"Mozilla", "https://www.linkedin.com/jobs/view/1000004",
"linkedin", "Remote", 1, "$105k$130k",
81.0, "pending", _dago(2), _dago(34), None,
None, None, None, None, None, None, None, None),
("Interaction Designer",
"Figma", "https://www.indeed.com/viewjob?jk=1000005",
"indeed", "San Francisco, CA", 1, "$115k$145k",
78.0, "pending", _dago(4), _dago(6), None,
None, None, None, None, None, None, None, None),
("Product Designer II",
"Notion", "https://www.linkedin.com/jobs/view/1000006",
"linkedin", "Remote", 1, "$100k$130k",
76.0, "pending", _dago(5), _dago(8), None,
None, None, None, None, None, None, None, None),
("UX Designer",
"Stripe", "https://www.linkedin.com/jobs/view/1000007",
"linkedin", "Remote", 1, "$120k$150k",
74.0, "pending", _dago(6), _dago(9), None,
None, None, None, None, None, None, None, None),
("UI/UX Designer",
"Canva", "https://www.indeed.com/viewjob?jk=1000008",
"indeed", "Remote", 1, "$90k$115k",
72.0, "pending", _dago(7), _dago(10), None,
None, None, None, None, None, None, None, None),
("Senior Product Designer",
"Asana", "https://www.linkedin.com/jobs/view/1000009",
"linkedin", "San Francisco, CA", 1, "$125k$155k",
69.0, "pending", _dago(8), _dago(11), None,
None, None, None, None, None, None, None, None),
("UX Researcher",
"Intercom", "https://www.indeed.com/viewjob?jk=1000010",
"indeed", "Remote", 1, "$95k$120k",
67.0, "pending", _dago(9), _dago(12), None,
None, None, None, None, None, None, None, None),
("Product Designer",
"Linear", "https://www.linkedin.com/jobs/view/1000011",
"linkedin", "Remote", 1, "$110k$135k",
65.0, "pending", _dago(10), _dago(13), None,
None, None, None, None, None, None, None, None),
("UX Designer",
"Loom", "https://www.indeed.com/viewjob?jk=1000012",
"indeed", "Remote", 1, "$90k$110k",
62.0, "pending", _dago(11), _dago(14), None,
None, None, None, None, None, None, None, None),
# ---- Pipeline jobs (applied → hired) ------------------------------------
("Senior Product Designer",
"Asana", "https://www.asana.com/jobs/1000013",
"linkedin", "San Francisco, CA", 1, "$125k$155k",
91.0, "phone_screen", _dago(14), _dago(16), None,
_dago(7), _dfrom(0), None, None, None,
f"{_dfrom(0)}T14:00:00", None, None),
("Product Designer",
"Notion", "https://www.notion.so/jobs/1000014",
"indeed", "Remote", 1, "$100k$130k",
88.0, "interviewing", _dago(21), _dago(23), None,
_dago(14), _dago(10), _dago(3), None, None,
f"{_dfrom(7)}T10:00:00", None, None),
("Design Systems Designer",
"Figma", "https://www.figma.com/jobs/1000015",
"linkedin", "San Francisco, CA", 1, "$130k$160k",
96.0, "hired", _dago(45), _dago(47), None,
_dago(38), _dago(32), _dago(25), _dago(14), _dago(7),
None, None,
'{"factors":["clear_scope","great_manager","mission_aligned"],"notes":"Excited about design systems work. Salary met expectations."}'),
("UX Designer",
"Slack", "https://slack.com/jobs/1000016",
"indeed", "Remote", 1, "$115k$140k",
79.0, "applied", _dago(28), _dago(30), None,
_dago(18), None, None, None, None, None, None, None),
]
def _q(v: object) -> str:
"""SQL-quote a Python value."""
if v is None:
return "NULL"
return "'" + str(v).replace("'", "''") + "'"
_JOB_COLS = (
"title, company, url, source, location, is_remote, salary, "
"match_score, status, date_found, date_posted, cover_letter, "
"applied_at, phone_screen_at, interviewing_at, offer_at, hired_at, "
"interview_date, rejection_stage, hired_feedback"
)
SQL_PARTS.append("-- jobs")
for job in JOBS:
vals = ", ".join(_q(v) for v in job)
SQL_PARTS.append(f"INSERT INTO jobs ({_JOB_COLS}) VALUES ({vals});")
# ── Contacts ──────────────────────────────────────────────────────────────────
# (job_id, direction, subject, from_addr, to_addr, received_at, stage_signal)
CONTACTS: list[tuple] = [
(1, "inbound", "Excited to connect — UX Designer role at Spotify",
"jamie.chen@spotify.com", "you@example.com", _dago(3), "positive_response"),
(1, "outbound", "Re: Excited to connect — UX Designer role at Spotify",
"you@example.com", "jamie.chen@spotify.com", _dago(2), None),
(13, "inbound", "Interview Confirmation — Senior Product Designer",
"recruiting@asana.com", "you@example.com", _dago(2), "interview_scheduled"),
(14, "inbound", "Your panel interview is confirmed for Apr 22",
"recruiting@notion.so", "you@example.com", _dago(3), "interview_scheduled"),
(14, "inbound", "Pre-interview prep resources",
"marcus.webb@notion.so", "you@example.com", _dago(2), "positive_response"),
(15, "inbound", "Figma Design Systems — Offer Letter",
"offers@figma.com", "you@example.com", _dago(14), "offer_received"),
(15, "outbound", "Re: Figma Design Systems — Offer Letter (acceptance)",
"you@example.com", "offers@figma.com", _dago(10), None),
(15, "inbound", "Welcome to Figma! Onboarding next steps",
"onboarding@figma.com", "you@example.com", _dago(7), None),
(16, "inbound", "Thanks for applying to Slack",
"noreply@slack.com", "you@example.com", _dago(18), None),
]
SQL_PARTS.append("\n-- job_contacts")
for c in CONTACTS:
job_id, direction, subject, from_addr, to_addr, received_at, stage_signal = c
SQL_PARTS.append(
f"INSERT INTO job_contacts "
f"(job_id, direction, subject, from_addr, to_addr, received_at, stage_signal) "
f"VALUES ({job_id}, {_q(direction)}, {_q(subject)}, {_q(from_addr)}, "
f"{_q(to_addr)}, {_q(received_at)}, {_q(stage_signal)});"
)
# ── References ────────────────────────────────────────────────────────────────
# (name, email, role, company, relationship, notes, tags, prep_email)
REFERENCES: list[tuple] = [
("Dr. Priya Nair", "priya.nair@example.com", "Director of Design", "Acme Corp",
"former_manager",
"Managed me for 3 years on the consumer app redesign. Enthusiastic reference.",
'["manager","design"]',
"Hi Priya,\n\nI hope you're doing well! I'm currently interviewing for a few senior UX roles "
"and would be so grateful if you'd be willing to serve as a reference.\n\nThank you!\n[Your name]"),
("Sam Torres", "sam.torres@example.com", "Senior Product Designer", "Acme Corp",
"former_colleague",
"Worked together on design systems. Great at speaking to collaborative process.",
'["colleague","design_systems"]', None),
("Jordan Kim", "jordan.kim@example.com", "VP of Product", "Streamline Inc",
"former_manager",
"Led the product team I was embedded in. Can speak to business impact of design work.",
'["manager","product"]', None),
]
SQL_PARTS.append("\n-- references_")
for ref in REFERENCES:
name, email, role, company, relationship, notes, tags, prep_email = ref
SQL_PARTS.append(
f"INSERT INTO references_ "
f"(name, email, role, company, relationship, notes, tags, prep_email) "
f"VALUES ({_q(name)}, {_q(email)}, {_q(role)}, {_q(company)}, "
f"{_q(relationship)}, {_q(notes)}, {_q(tags)}, {_q(prep_email)});"
)
# ── Write output ──────────────────────────────────────────────────────────────
output = "\n".join(SQL_PARTS) + "\n"
OUT_PATH.write_text(output, encoding="utf-8")
print(
f"Wrote {OUT_PATH} "
f"({len(JOBS)} jobs, {len(CONTACTS)} contacts, {len(REFERENCES)} references)"
)

89
tests/test_demo_guard.py Normal file
View file

@ -0,0 +1,89 @@
"""IS_DEMO write-block guard tests."""
import importlib
import os
import sqlite3
import pytest
from fastapi.testclient import TestClient
_SCHEMA = """
CREATE TABLE jobs (
id INTEGER PRIMARY KEY, title TEXT, company TEXT, url TEXT,
location TEXT, is_remote INTEGER DEFAULT 0, salary TEXT,
match_score REAL, keyword_gaps TEXT, status TEXT DEFAULT 'pending',
date_found TEXT, cover_letter TEXT, interview_date TEXT,
rejection_stage TEXT, applied_at TEXT, phone_screen_at TEXT,
interviewing_at TEXT, offer_at TEXT, hired_at TEXT,
survey_at TEXT, date_posted TEXT, hired_feedback TEXT
);
CREATE TABLE background_tasks (
id INTEGER PRIMARY KEY, task_type TEXT, job_id INTEGER,
status TEXT DEFAULT 'queued', finished_at TEXT
);
"""
def _make_db(path: str) -> None:
con = sqlite3.connect(path)
con.executescript(_SCHEMA)
con.execute(
"INSERT INTO jobs (id, title, company, url, status) VALUES (1,'UX Designer','Spotify','https://ex.com/1','pending')"
)
con.execute(
"INSERT INTO jobs (id, title, company, url, status) VALUES (2,'Designer','Figma','https://ex.com/2','hired')"
)
con.commit()
con.close()
@pytest.fixture()
def demo_client(tmp_path, monkeypatch):
db_path = str(tmp_path / "staging.db")
_make_db(db_path)
monkeypatch.setenv("DEMO_MODE", "true")
monkeypatch.setenv("STAGING_DB", db_path)
import dev_api
importlib.reload(dev_api)
return TestClient(dev_api.app)
@pytest.fixture()
def normal_client(tmp_path, monkeypatch):
db_path = str(tmp_path / "staging.db")
_make_db(db_path)
monkeypatch.delenv("DEMO_MODE", raising=False)
monkeypatch.setenv("STAGING_DB", db_path)
import dev_api
importlib.reload(dev_api)
return TestClient(dev_api.app)
class TestDemoWriteBlock:
def test_approve_blocked_in_demo(self, demo_client):
r = demo_client.post("/api/jobs/1/approve")
assert r.status_code == 403
assert r.json()["detail"] == "demo-write-blocked"
def test_reject_blocked_in_demo(self, demo_client):
r = demo_client.post("/api/jobs/1/reject")
assert r.status_code == 403
assert r.json()["detail"] == "demo-write-blocked"
def test_cover_letter_generate_blocked_in_demo(self, demo_client):
r = demo_client.post("/api/jobs/1/cover_letter/generate")
assert r.status_code == 403
assert r.json()["detail"] == "demo-write-blocked"
def test_hired_feedback_blocked_in_demo(self, demo_client):
r = demo_client.post("/api/jobs/2/hired-feedback", json={"factors": [], "notes": ""})
assert r.status_code == 403
assert r.json()["detail"] == "demo-write-blocked"
def test_approve_allowed_in_normal_mode(self, normal_client):
r = normal_client.post("/api/jobs/1/approve")
assert r.status_code != 403
def test_config_reports_is_demo_true(self, demo_client):
r = demo_client.get("/api/config/app")
assert r.status_code == 200
assert r.json()["isDemo"] is True

View file

@ -7,10 +7,9 @@
<!-- Skip to main content link (screen reader / keyboard nav) -->
<a href="#main-content" class="skip-link">Skip to main content</a>
<!-- Demo mode banner sticky top bar, visible on all pages -->
<div v-if="config.isDemo" class="demo-banner" role="status" aria-live="polite">
👁 Demo mode changes are not saved and AI features are disabled.
</div>
<!-- Demo mode banner + welcome modal rendered when isDemo -->
<DemoBanner v-if="config.isDemo" />
<WelcomeModal v-if="config.isDemo" />
<RouterView />
@ -32,6 +31,8 @@ import { useHackerMode, useKonamiCode } from './composables/useEasterEgg'
import { useTheme } from './composables/useTheme'
import { useToast } from './composables/useToast'
import AppNav from './components/AppNav.vue'
import DemoBanner from './components/DemoBanner.vue'
import WelcomeModal from './components/WelcomeModal.vue'
import { useAppConfigStore } from './stores/appConfig'
import { useDigestStore } from './stores/digest'
@ -128,20 +129,6 @@ body {
padding-bottom: 0;
}
/* Demo mode banner — sticky top bar */
.demo-banner {
position: sticky;
top: 0;
z-index: 200;
background: var(--color-warning);
color: #1a1a1a; /* forced dark — warning bg is always light enough */
text-align: center;
font-size: 0.85rem;
font-weight: 600;
padding: 6px var(--space-4, 16px);
letter-spacing: 0.01em;
}
/* Global toast — bottom-center, above tab bar */
.global-toast {
position: fixed;

View file

@ -0,0 +1,79 @@
<template>
<div class="demo-banner" role="status" aria-live="polite">
<span class="demo-banner__label">👁 Demo mode changes are not saved</span>
<div class="demo-banner__ctas">
<a
href="https://circuitforge.tech/peregrine"
class="demo-banner__cta demo-banner__cta--primary"
target="_blank"
rel="noopener"
>Get free key</a>
<a
href="https://git.opensourcesolarpunk.com/Circuit-Forge/peregrine"
class="demo-banner__cta demo-banner__cta--secondary"
target="_blank"
rel="noopener"
>Self-host</a>
</div>
</div>
</template>
<script setup lang="ts">
// No props DemoBanner is only rendered when config.isDemo is true (App.vue)
</script>
<style scoped>
.demo-banner {
position: sticky;
top: 0;
z-index: 200;
background: color-mix(in srgb, var(--color-primary) 8%, var(--color-surface-raised));
border-bottom: 1px solid color-mix(in srgb, var(--color-primary) 20%, var(--color-border));
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px var(--space-4);
gap: var(--space-3);
}
.demo-banner__label {
font-size: 0.8rem;
color: var(--color-text-muted);
}
.demo-banner__ctas {
display: flex;
gap: var(--space-2);
flex-shrink: 0;
}
.demo-banner__cta {
font-size: 0.75rem;
font-weight: 600;
padding: 3px 10px;
border-radius: var(--radius-sm);
text-decoration: none;
transition: opacity var(--transition);
}
.demo-banner__cta:hover {
opacity: 0.85;
}
.demo-banner__cta--primary {
background: var(--color-primary);
color: var(--color-surface); /* surface is dark in dark mode, light in light mode — always contrasts primary */
}
.demo-banner__cta--secondary {
background: none;
border: 1px solid var(--color-border);
color: var(--color-text-muted);
}
@media (max-width: 480px) {
.demo-banner__label {
display: none;
}
}
</style>

View file

@ -0,0 +1,63 @@
<template>
<div v-if="!dismissed" class="hint-chip" role="status">
<span aria-hidden="true" class="hint-chip__icon">💡</span>
<span class="hint-chip__message">{{ message }}</span>
<button
class="hint-chip__dismiss"
@click="dismiss"
:aria-label="`Dismiss hint for ${viewKey}`"
></button>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const props = defineProps<{
viewKey: string // used for localStorage key e.g. 'home', 'review'
message: string
}>()
const LS_KEY = `peregrine_hint_${props.viewKey}`
const dismissed = ref(!!localStorage.getItem(LS_KEY))
function dismiss(): void {
localStorage.setItem(LS_KEY, '1')
dismissed.value = true
}
</script>
<style scoped>
.hint-chip {
display: flex;
align-items: flex-start;
gap: var(--space-2, 8px);
background: var(--color-surface, #0d1829);
border: 1px solid var(--app-primary, #2B6CB0);
border-radius: var(--radius-md, 8px);
padding: var(--space-2, 8px) var(--space-3, 12px);
margin-bottom: var(--space-3, 12px);
}
.hint-chip__icon { flex-shrink: 0; font-size: 0.9rem; }
.hint-chip__message {
flex: 1;
font-size: 0.85rem;
color: var(--color-text, #1a202c);
line-height: 1.4;
}
.hint-chip__dismiss {
flex-shrink: 0;
background: none;
border: none;
color: var(--color-text-muted, #8898aa);
cursor: pointer;
font-size: 0.75rem;
padding: 0 2px;
line-height: 1;
}
.hint-chip__dismiss:hover { color: var(--color-text, #eaeff8); }
</style>

View file

@ -216,7 +216,23 @@ watch(() => props.job.id, () => {
}
})
defineExpose({ dismissApprove, dismissReject, dismissSkip })
/** Restore card to its neutral state — used when an action is blocked (e.g. demo guard). */
function resetCard() {
dx.value = 0
dy.value = 0
isExiting.value = false
isHeld.value = false
if (wrapperEl.value) {
wrapperEl.value.style.transition = 'none'
wrapperEl.value.style.transform = ''
wrapperEl.value.style.opacity = ''
requestAnimationFrame(() => {
if (wrapperEl.value) wrapperEl.value.style.transition = ''
})
}
}
defineExpose({ dismissApprove, dismissReject, dismissSkip, resetCard })
</script>
<style scoped>

View file

@ -0,0 +1,160 @@
<template>
<Teleport to="body">
<div v-if="visible" class="welcome-modal-overlay" @click.self="dismiss">
<div
class="welcome-modal"
role="dialog"
aria-modal="true"
aria-labelledby="welcome-modal-title"
>
<span aria-hidden="true" class="welcome-modal__icon">🦅</span>
<h2 id="welcome-modal-title" class="welcome-modal__heading">
Welcome to Peregrine
</h2>
<p class="welcome-modal__desc">
A live demo with realistic job search data. Explore freely nothing you do here is saved.
</p>
<ul class="welcome-modal__features" aria-label="What to try">
<li>📋 Review &amp; rate matched jobs</li>
<li> Draft a cover letter with AI</li>
<li>📅 Track your interview pipeline</li>
<li>🎉 See a hired outcome</li>
</ul>
<button class="welcome-modal__explore" @click="dismiss">
Explore the demo
</button>
<div class="welcome-modal__links">
<a
href="https://circuitforge.tech/account"
class="welcome-modal__link welcome-modal__link--primary"
target="_blank"
rel="noopener"
>Get a free key</a>
<a
href="https://git.opensourcesolarpunk.com/Circuit-Forge/peregrine"
class="welcome-modal__link welcome-modal__link--secondary"
target="_blank"
rel="noopener"
>Self-host </a>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const LS_KEY = 'peregrine_demo_visited'
const emit = defineEmits<{ dismissed: [] }>()
const visible = ref(!localStorage.getItem(LS_KEY))
function dismiss(): void {
localStorage.setItem(LS_KEY, '1')
visible.value = false
emit('dismissed')
}
</script>
<style scoped>
.welcome-modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: var(--space-4, 16px);
}
.welcome-modal {
background: var(--color-surface-raised, #1e2d45);
border: 1px solid var(--color-border, #2a3a56);
border-radius: var(--radius-lg, 12px);
padding: var(--space-6, 24px);
width: 100%;
max-width: 360px;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.6);
display: flex;
flex-direction: column;
gap: var(--space-3, 12px);
}
.welcome-modal__icon { font-size: 2rem; }
.welcome-modal__heading {
font-size: 1.15rem;
font-weight: 700;
color: var(--color-text, #eaeff8);
margin: 0;
}
.welcome-modal__desc {
font-size: 0.85rem;
color: var(--color-text-muted, #8898aa);
line-height: 1.5;
margin: 0;
}
.welcome-modal__features {
list-style: none;
padding: 0;
margin: 0;
border-top: 1px solid var(--color-border, #2a3a56);
padding-top: var(--space-3, 12px);
display: flex;
flex-direction: column;
gap: var(--space-2, 8px);
}
.welcome-modal__features li {
font-size: 0.85rem;
color: var(--color-text-muted, #8898aa);
}
.welcome-modal__explore {
width: 100%;
background: var(--app-primary, #2B6CB0);
color: #fff;
border: none;
border-radius: var(--radius-md, 8px);
padding: 10px;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
transition: opacity 150ms;
}
.welcome-modal__explore:hover { opacity: 0.85; }
.welcome-modal__links {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-2, 8px);
}
.welcome-modal__link {
text-align: center;
font-size: 0.8rem;
font-weight: 600;
padding: 6px;
border-radius: var(--radius-sm, 4px);
text-decoration: none;
transition: opacity 150ms;
}
.welcome-modal__link:hover { opacity: 0.85; }
.welcome-modal__link--primary {
border: 1px solid var(--app-primary, #2B6CB0);
color: var(--app-primary-light, #68A8D8);
}
.welcome-modal__link--secondary {
border: 1px solid var(--color-border, #2a3a56);
color: var(--color-text-muted, #8898aa);
}
</style>

View file

@ -0,0 +1,22 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import DemoBanner from '../DemoBanner.vue'
describe('DemoBanner', () => {
it('renders the demo label', () => {
const w = mount(DemoBanner)
expect(w.text()).toContain('Demo mode')
})
it('renders a free key link', () => {
const w = mount(DemoBanner)
expect(w.find('a.demo-banner__cta--primary').exists()).toBe(true)
expect(w.find('a.demo-banner__cta--primary').text()).toContain('free key')
})
it('renders a self-host link', () => {
const w = mount(DemoBanner)
expect(w.find('a.demo-banner__cta--secondary').exists()).toBe(true)
expect(w.find('a.demo-banner__cta--secondary').text()).toContain('Self-host')
})
})

View file

@ -0,0 +1,28 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import HintChip from '../HintChip.vue'
beforeEach(() => { localStorage.clear() })
const factory = (viewKey = 'home', message = 'Test hint') =>
mount(HintChip, { props: { viewKey, message } })
describe('HintChip', () => {
it('renders the message', () => {
const w = factory()
expect(w.text()).toContain('Test hint')
})
it('is hidden when localStorage key is already set', () => {
localStorage.setItem('peregrine_hint_home', '1')
const w = factory()
expect(w.find('.hint-chip').exists()).toBe(false)
})
it('hides and sets localStorage when dismiss button is clicked', async () => {
const w = factory()
await w.find('.hint-chip__dismiss').trigger('click')
expect(w.find('.hint-chip').exists()).toBe(false)
expect(localStorage.getItem('peregrine_hint_home')).toBe('1')
})
})

View file

@ -0,0 +1,35 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import WelcomeModal from '../WelcomeModal.vue'
const LS_KEY = 'peregrine_demo_visited'
beforeEach(() => {
localStorage.clear()
})
describe('WelcomeModal', () => {
it('is visible when localStorage key is absent', () => {
const w = mount(WelcomeModal, { global: { stubs: { Teleport: true } } })
expect(w.find('.welcome-modal').exists()).toBe(true)
})
it('is hidden when localStorage key is set', () => {
localStorage.setItem(LS_KEY, '1')
const w = mount(WelcomeModal, { global: { stubs: { Teleport: true } } })
expect(w.find('.welcome-modal').exists()).toBe(false)
})
it('dismisses and sets localStorage on primary CTA click', async () => {
const w = mount(WelcomeModal, { global: { stubs: { Teleport: true } } })
await w.find('.welcome-modal__explore').trigger('click')
expect(w.find('.welcome-modal').exists()).toBe(false)
expect(localStorage.getItem(LS_KEY)).toBe('1')
})
it('emits dismissed event on close', async () => {
const w = mount(WelcomeModal, { global: { stubs: { Teleport: true } } })
await w.find('.welcome-modal__explore').trigger('click')
expect(w.emitted('dismissed')).toBeTruthy()
})
})

View file

@ -1,6 +1,9 @@
import { showToast } from './useToast'
export type ApiError =
| { kind: 'network'; message: string }
| { kind: 'http'; status: number; detail: string }
| { kind: 'demo-blocked' }
// Strip trailing slash so '/peregrine/' + '/api/...' → '/peregrine/api/...'
const _apiBase = import.meta.env.BASE_URL.replace(/\/$/, '')
@ -12,8 +15,20 @@ export async function useApiFetch<T>(
try {
const res = await fetch(_apiBase + url, opts)
if (!res.ok) {
const detail = await res.text().catch(() => '')
return { data: null, error: { kind: 'http', status: res.status, detail } }
const rawText = await res.text().catch(() => '')
// Demo mode: show toast and swallow the error so callers don't need to handle it
if (res.status === 403) {
try {
const body = JSON.parse(rawText) as { detail?: string }
if (body.detail === 'demo-write-blocked') {
showToast('Demo mode — sign in to save changes')
// Return a truthy error so callers bail early (no optimistic UI update),
// but the toast is already shown so no additional error handling needed.
return { data: null, error: { kind: 'demo-blocked' as const } }
}
} catch { /* not JSON — fall through to normal error */ }
}
return { data: null, error: { kind: 'http', status: res.status, detail: rawText } }
}
const data = await res.json() as T
return { data, error: null }

View file

@ -1,6 +1,11 @@
<template>
<!-- Mobile: full-width list -->
<div v-if="isMobile" class="apply-list">
<HintChip
v-if="config.isDemo"
view-key="apply"
message="The Spotify cover letter is ready — open it to see how AI drafts from your resume"
/>
<header class="apply-list__header">
<h1 class="apply-list__title">Apply</h1>
<p class="apply-list__subtitle">Approved jobs ready for applications</p>
@ -50,6 +55,11 @@
<div v-else class="apply-split" :class="{ 'has-selection': selectedJobId !== null }" ref="splitEl">
<!-- Left: narrow job list -->
<div class="apply-split__list">
<HintChip
v-if="config.isDemo"
view-key="apply"
message="The Spotify cover letter is ready — open it to see how AI drafts from your resume"
/>
<div class="split-list__header">
<h1 class="split-list__title">Apply</h1>
<span v-if="coverLetterCount >= 5" class="marathon-badge" title="You're on a roll!">
@ -124,6 +134,10 @@ import { ref, onMounted, onUnmounted } from 'vue'
import { RouterLink } from 'vue-router'
import { useApiFetch } from '../composables/useApi'
import ApplyWorkspace from '../components/ApplyWorkspace.vue'
import HintChip from '../components/HintChip.vue'
import { useAppConfigStore } from '../stores/appConfig'
const config = useAppConfigStore()
// Responsive

View file

@ -1,6 +1,10 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useApiFetch } from '../composables/useApi'
import HintChip from '../components/HintChip.vue'
import { useAppConfigStore } from '../stores/appConfig'
const config = useAppConfigStore()
interface Contact {
id: number
@ -79,6 +83,11 @@ onMounted(fetchContacts)
<template>
<div class="contacts-view">
<HintChip
v-if="config.isDemo"
view-key="contacts"
message="Peregrine logs every recruiter email automatically — no manual entry needed"
/>
<header class="contacts-header">
<h1 class="contacts-title">Contacts</h1>
<span class="contacts-count" v-if="total > 0">{{ total }} total</span>

View file

@ -1,5 +1,10 @@
<template>
<div class="home">
<HintChip
v-if="config.isDemo"
view-key="home"
message="Start in Job Review — 12 jobs are waiting for your verdict"
/>
<!-- Header -->
<header class="home__header">
<div>
@ -371,6 +376,10 @@ import { RouterLink } from 'vue-router'
import { useJobsStore } from '../stores/jobs'
import { useApiFetch } from '../composables/useApi'
import WorkflowButton from '../components/WorkflowButton.vue'
import HintChip from '../components/HintChip.vue'
import { useAppConfigStore } from '../stores/appConfig'
const config = useAppConfigStore()
const store = useJobsStore()

View file

@ -8,6 +8,10 @@ import { useApiFetch } from '../composables/useApi'
import InterviewCard from '../components/InterviewCard.vue'
import MoveToSheet from '../components/MoveToSheet.vue'
import CompanyResearchModal from '../components/CompanyResearchModal.vue'
import HintChip from '../components/HintChip.vue'
import { useAppConfigStore } from '../stores/appConfig'
const config = useAppConfigStore()
const router = useRouter()
const store = useInterviewsStore()
@ -347,6 +351,11 @@ function formatRejectionDate(job: PipelineJob): string {
<template>
<div class="interviews-view">
<HintChip
v-if="config.isDemo"
view-key="interviews"
message="Figma sent an offer — open it to see the hired outcome and post-hire feedback"
/>
<canvas ref="confettiCanvas" class="confetti-canvas" aria-hidden="true" />
<Transition name="toast">

View file

@ -1,5 +1,10 @@
<template>
<div class="review">
<HintChip
v-if="config.isDemo"
view-key="review"
message="Swipe right to approve, left to skip. One of these jobs is a ghost post — can you spot it?"
/>
<!-- Header -->
<header class="review__header">
<div class="review__title-row">
@ -214,6 +219,10 @@ import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useReviewStore } from '../stores/review'
import JobCardStack from '../components/JobCardStack.vue'
import HintChip from '../components/HintChip.vue'
import { useAppConfigStore } from '../stores/appConfig'
const config = useAppConfigStore()
const store = useReviewStore()
const route = useRoute()
@ -265,7 +274,8 @@ function capitalize(s: string) { return s.charAt(0).toUpperCase() + s.slice(1) }
async function onApprove() {
const job = store.currentJob
if (!job) return
await store.approve(job)
const ok = await store.approve(job)
if (!ok) { stackRef.value?.resetCard(); return }
showUndoToast('approved')
checkStoopSpeed()
}
@ -273,7 +283,8 @@ async function onApprove() {
async function onReject() {
const job = store.currentJob
if (!job) return
await store.reject(job)
const ok = await store.reject(job)
if (!ok) { stackRef.value?.resetCard(); return }
showUndoToast('rejected')
checkStoopSpeed()
}

View file

@ -10,7 +10,7 @@ export default defineConfig({
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:8601',
target: process.env.VITE_API_TARGET || 'http://localhost:8601',
changeOrigin: true,
},
},