Compare commits

..

7 commits

Author SHA1 Message Date
797032bd97 ci: remove stale .github/workflows/ci.yml
Some checks failed
CI / Backend (Python) (push) Failing after 1m21s
CI / Frontend (Vue) (push) Failing after 19s
Mirror / mirror (push) Failing after 10s
The .forgejo/workflows/ci.yml is the canonical CI definition.
The old .github/workflows/ci.yml was being mirrored to GitHub via
--mirror push, triggering GitHub Actions runs that fail because
FORGEJO_TOKEN and other Forgejo-specific secrets are not set there.

GitHub Actions does not process .forgejo/workflows/ so removing
this file stops the spurious GitHub runs. ISSUE_TEMPLATE and
pull_request_template.md are preserved in .github/.
2026-04-15 20:11:07 -07:00
fb8b464dd0 fix: use resume_parser extractors in import endpoint to clean CID glyphs
The import endpoint was doing its own inline PDF/DOCX/ODT extraction
without calling _clean_cid(). Bullet CIDs (127, 149, 183) and other
ATS-reembedded font artifacts were stored raw, surfacing as (cid:127)
in the resume library. Switch to extract_text_from_pdf/docx/odt from
resume_parser.py which already handle two-column layouts and CID cleaning.
2026-04-15 12:23:12 -07:00
ec521e14c5 fix: sweep user DBs on cloud startup for pending migrations 2026-04-15 12:18:23 -07:00
a302049f72 fix: add date_posted migration + cloud startup sweep
date_posted column was added to db.py CREATE TABLE but had no migration
file, so existing user DBs were missing it. The list_jobs endpoint queries
this column, causing 500 errors and empty Apply/Review queues for all
existing cloud users while job_counts (which doesn't touch date_posted)
continued to work — making the home page show correct counts but tabs show
empty data.

Fixes:
- migrations/006_date_posted.sql: ALTER TABLE to add date_posted to existing DBs
- dev_api.py lifespan: on startup in cloud mode, sweep all user DBs in
  CLOUD_DATA_ROOT and apply pending migrations — ensures schema changes land
  for every user on each deploy, not only on their first post-deploy request
2026-04-15 12:17:55 -07:00
03b9e52301 feat: references tracker and recommendation letter system (#96)
- references_ + job_references tables with CREATE + migration
- Full CRUD: GET/POST /api/references, PATCH/DELETE /api/references/:id
- Link/unlink to jobs: POST/DELETE /api/references/:id/link-job/:job_id
- GET /api/references/for-job/:job_id — linked refs with prep/letter drafts
- POST /api/references/:id/prep-email — LLM drafts heads-up email to send
  reference before interview; persisted to job_references.prep_email
- POST /api/references/:id/rec-letter — LLM drafts recommendation letter
  reference can edit and send on their letterhead (Paid/BYOK tier)
- ReferencesView.vue: add/edit/delete form, tag system (technical/managerial/
  character/academic), inline confirm-before-delete
- Route /references + IdentificationIcon nav link
2026-04-15 08:42:06 -07:00
0e4fce44c4 feat: shadow listing detector, hired feedback widget, contacts manager
Shadow listing detector (#95):
- Capture date_posted from JobSpy in discover.py + insert_job()
- Add date_posted migration to _MIGRATIONS
- _shadow_score() heuristic: 'shadow' (≥30 days stale), 'stale' (≥14 days)
- list_jobs() computes shadow_score per listing
- JobCard.vue: 'Ghost post' and 'Stale' badges with tooltip

Post-hire feedback widget (#91):
- Add hired_feedback migration to _MIGRATIONS
- POST /api/jobs/:id/hired-feedback endpoint
- InterviewCard.vue: optional widget on hired cards with factor
  checkboxes + freetext; dismissible; shows saved state
- PipelineJob interface extended with hired_feedback field

Contacts manager (#73):
- GET /api/contacts endpoint with job join, direction/search filters
- New ContactsView.vue: searchable table, inbound/outbound filter,
  signal chip column, job link
- Route /contacts added; Contacts nav link (UsersIcon) in AppNav

Also: add git to Dockerfile apt-get for circuitforge-core editable install
2026-04-15 08:34:12 -07:00
6599bc6952 chore: ignore runtime data artifacts
Add gitignore entries for:
- data/.feedback_ratelimit.json (rate limit state)
- data/email_score.jsonl.bad-labels (debug artifact from label review)
- data/config/ (runtime config directory)
2026-04-15 08:16:14 -07:00
15 changed files with 1378 additions and 82 deletions

View file

@ -1,35 +0,0 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install system dependencies
run: sudo apt-get update -q && sudo apt-get install -y libsqlcipher-dev
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
cache: pip
- name: Configure git credentials for Forgejo
env:
FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }}
run: |
git config --global url."https://oauth2:${FORGEJO_TOKEN}@git.opensourcesolarpunk.com/".insteadOf "https://git.opensourcesolarpunk.com/"
- name: Install dependencies
run: pip install -r requirements.txt
- name: Run tests
run: pytest tests/ -v --tb=short

3
.gitignore vendored
View file

@ -40,8 +40,11 @@ pytest-output.txt
docs/superpowers/
data/email_score.jsonl
data/email_score.jsonl.bad-labels
data/email_label_queue.jsonl
data/email_compare_sample.jsonl
data/.feedback_ratelimit.json
data/config/
config/label_tool.yaml
config/server.yaml

View file

@ -6,7 +6,7 @@ WORKDIR /app
# System deps for companyScraper (beautifulsoup4, fake-useragent, lxml) and PDF gen
# libsqlcipher-dev: required to build pysqlcipher3 (SQLCipher AES-256 encryption for cloud mode)
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc libffi-dev curl libsqlcipher-dev \
gcc libffi-dev curl libsqlcipher-dev git \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .

View file

@ -16,7 +16,7 @@ import subprocess
import sys
import threading
from contextvars import ContextVar
from datetime import datetime
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional, List
from urllib.parse import urlparse
@ -58,6 +58,20 @@ async def lifespan(app: FastAPI):
_load_env(PEREGRINE_ROOT / ".env")
from scripts.db_migrate import migrate_db
migrate_db(Path(DB_PATH))
# Cloud mode: sweep all known user DBs at startup so schema changes land
# for every user on deploy, not only on their next request.
if _CLOUD_MODE and _CLOUD_DATA_ROOT.is_dir():
import logging as _log
_sweep_log = _log.getLogger("peregrine.startup")
for user_db in _CLOUD_DATA_ROOT.glob("*/peregrine/staging.db"):
try:
migrate_db(user_db)
_migrated_db_paths.add(str(user_db))
_sweep_log.info("Migrated user DB: %s", user_db)
except Exception as exc:
_sweep_log.warning("Migration failed for %s: %s", user_db, exc)
yield
@ -230,6 +244,28 @@ def _row_to_job(row) -> dict:
return d
def _shadow_score(date_posted: str | None, date_found: str | None) -> str | None:
"""Return 'shadow', 'stale', or None based on posting age when discovered.
A job posted >30 days before discovery is a shadow candidate (posted to
satisfy legal/HR requirements, likely already filled). 14-30 days is stale.
Returns None when dates are unavailable.
"""
if not date_posted or not date_found:
return None
try:
posted = datetime.fromisoformat(date_posted.replace("Z", "+00:00")).replace(tzinfo=timezone.utc)
found = datetime.fromisoformat(date_found.replace("Z", "+00:00")).replace(tzinfo=timezone.utc)
days = (found - posted).days
if days >= 30:
return "shadow"
if days >= 14:
return "stale"
except (ValueError, TypeError):
pass
return None
# ── GET /api/jobs ─────────────────────────────────────────────────────────────
@app.get("/api/jobs")
@ -237,7 +273,7 @@ def list_jobs(status: str = "pending", limit: int = 50, fields: str = ""):
db = _get_db()
rows = db.execute(
"SELECT id, title, company, url, source, location, is_remote, salary, "
"description, match_score, keyword_gaps, date_found, status, cover_letter "
"description, match_score, keyword_gaps, date_found, date_posted, status, cover_letter "
"FROM jobs WHERE status = ? ORDER BY match_score DESC NULLS LAST LIMIT ?",
(status, limit),
).fetchall()
@ -246,7 +282,7 @@ def list_jobs(status: str = "pending", limit: int = 50, fields: str = ""):
for r in rows:
d = _row_to_job(r)
d["has_cover_letter"] = bool(d.get("cover_letter"))
# Don't send full cover_letter text in the list view
d["shadow_score"] = _shadow_score(d.get("date_posted"), d.get("date_found"))
d.pop("cover_letter", None)
result.append(d)
return result
@ -737,32 +773,17 @@ async def import_resume_endpoint(file: UploadFile, name: str = ""):
text = content.decode("utf-8", errors="replace")
elif ext in (".pdf", ".docx", ".odt"):
with tempfile.NamedTemporaryFile(suffix=ext, delete=False) as tmp:
tmp.write(content)
tmp_path = tmp.name
try:
if ext == ".pdf":
import pdfplumber
with pdfplumber.open(tmp_path) as pdf:
text = "\n".join(p.extract_text() or "" for p in pdf.pages)
elif ext == ".docx":
from docx import Document
doc = Document(tmp_path)
text = "\n".join(p.text for p in doc.paragraphs)
else:
import zipfile
from xml.etree import ElementTree as ET
with zipfile.ZipFile(tmp_path) as z:
xml = z.read("content.xml")
ET_root = ET.fromstring(xml)
text = "\n".join(
el.text or ""
for el in ET_root.iter(
"{urn:oasis:names:tc:opendocument:xmlns:text:1.0}p"
)
)
finally:
os.unlink(tmp_path)
from scripts.resume_parser import (
extract_text_from_pdf as _extract_pdf,
extract_text_from_docx as _extract_docx,
extract_text_from_odt as _extract_odt,
)
if ext == ".pdf":
text = _extract_pdf(content)
elif ext == ".docx":
text = _extract_docx(content)
else:
text = _extract_odt(content)
elif ext in (".yaml", ".yml"):
import yaml as _yaml
@ -1490,6 +1511,284 @@ def suggest_qa_answer(job_id: int, payload: QASuggestPayload):
raise HTTPException(500, f"LLM generation failed: {e}")
# ── POST /api/jobs/:id/hired-feedback ─────────────────────────────────────────
class HiredFeedbackPayload(BaseModel):
what_helped: str = ""
factors: list[str] = []
@app.post("/api/jobs/{job_id}/hired-feedback")
def save_hired_feedback(job_id: int, payload: HiredFeedbackPayload):
db = _get_db()
row = db.execute("SELECT status FROM jobs WHERE id = ?", (job_id,)).fetchone()
if not row:
raise HTTPException(404, "Job not found")
if row["status"] != "hired":
raise HTTPException(400, "Feedback only accepted for hired jobs")
db.execute(
"UPDATE jobs SET hired_feedback = ? WHERE id = ?",
(json.dumps({"what_helped": payload.what_helped, "factors": payload.factors}), job_id),
)
db.commit()
db.close()
return {"ok": True}
# ── GET /api/contacts ──────────────────────────────────────────────────────────
@app.get("/api/contacts")
def list_contacts(job_id: Optional[int] = None, direction: Optional[str] = None,
search: Optional[str] = None, limit: int = 100, offset: int = 0):
db = _get_db()
query = """
SELECT jc.id, jc.job_id, jc.direction, jc.subject, jc.from_addr, jc.to_addr,
jc.received_at, jc.stage_signal,
j.title AS job_title, j.company AS job_company
FROM job_contacts jc
LEFT JOIN jobs j ON j.id = jc.job_id
WHERE 1=1
"""
params: list = []
if job_id is not None:
query += " AND jc.job_id = ?"
params.append(job_id)
if direction:
query += " AND jc.direction = ?"
params.append(direction)
if search:
query += " AND (jc.from_addr LIKE ? OR jc.to_addr LIKE ? OR jc.subject LIKE ?)"
like = f"%{search}%"
params += [like, like, like]
query += " ORDER BY jc.received_at DESC LIMIT ? OFFSET ?"
params += [limit, offset]
rows = db.execute(query, params).fetchall()
total = db.execute(
"SELECT COUNT(*) FROM job_contacts" + (" WHERE job_id = ?" if job_id else ""),
([job_id] if job_id else []),
).fetchone()[0]
db.close()
return {"total": total, "contacts": [dict(r) for r in rows]}
# ── References ─────────────────────────────────────────────────────────────────
class ReferencePayload(BaseModel):
name: str
relationship: str = ""
company: str = ""
email: str = ""
phone: str = ""
notes: str = ""
tags: list[str] = []
class PrepEmailPayload(BaseModel):
job_id: int
class RecLetterPayload(BaseModel):
job_id: int
talking_points: str = ""
@app.get("/api/references")
def list_references():
db = _get_db()
rows = db.execute(
"SELECT * FROM references_ ORDER BY name ASC"
).fetchall()
db.close()
return [dict(r) for r in rows]
@app.post("/api/references")
def create_reference(payload: ReferencePayload):
db = _get_db()
cur = db.execute(
"""INSERT INTO references_ (name, relationship, company, email, phone, notes, tags)
VALUES (?, ?, ?, ?, ?, ?, ?)""",
(payload.name, payload.relationship, payload.company,
payload.email, payload.phone, payload.notes,
json.dumps(payload.tags)),
)
db.commit()
row = db.execute("SELECT * FROM references_ WHERE id = ?", (cur.lastrowid,)).fetchone()
db.close()
return dict(row)
@app.patch("/api/references/{ref_id}")
def update_reference(ref_id: int, payload: ReferencePayload):
db = _get_db()
row = db.execute("SELECT id FROM references_ WHERE id = ?", (ref_id,)).fetchone()
if not row:
raise HTTPException(404, "Reference not found")
db.execute(
"""UPDATE references_ SET name=?, relationship=?, company=?, email=?, phone=?,
notes=?, tags=?, updated_at=datetime('now') WHERE id=?""",
(payload.name, payload.relationship, payload.company,
payload.email, payload.phone, payload.notes,
json.dumps(payload.tags), ref_id),
)
db.commit()
updated = db.execute("SELECT * FROM references_ WHERE id = ?", (ref_id,)).fetchone()
db.close()
return dict(updated)
@app.delete("/api/references/{ref_id}")
def delete_reference(ref_id: int):
db = _get_db()
db.execute("DELETE FROM references_ WHERE id = ?", (ref_id,))
db.commit()
db.close()
return {"ok": True}
@app.get("/api/references/for-job/{job_id}")
def references_for_job(job_id: int):
db = _get_db()
rows = db.execute(
"""SELECT r.*, jr.prep_email, jr.rec_letter, jr.id AS jr_id
FROM references_ r
JOIN job_references jr ON jr.reference_id = r.id
WHERE jr.job_id = ?
ORDER BY r.name ASC""",
(job_id,),
).fetchall()
db.close()
return [dict(r) for r in rows]
@app.post("/api/references/{ref_id}/link-job")
def link_reference_to_job(ref_id: int, body: PrepEmailPayload):
db = _get_db()
try:
db.execute(
"INSERT INTO job_references (job_id, reference_id) VALUES (?, ?)",
(body.job_id, ref_id),
)
db.commit()
except Exception:
pass # already linked
db.close()
return {"ok": True}
@app.delete("/api/references/{ref_id}/unlink-job/{job_id}")
def unlink_reference_from_job(ref_id: int, job_id: int):
db = _get_db()
db.execute(
"DELETE FROM job_references WHERE reference_id = ? AND job_id = ?",
(ref_id, job_id),
)
db.commit()
db.close()
return {"ok": True}
@app.post("/api/references/{ref_id}/prep-email")
def generate_prep_email(ref_id: int, payload: PrepEmailPayload):
"""Draft a short 'heads up' email to send a reference before they hear from the hiring team."""
db = _get_db()
ref_row = db.execute("SELECT * FROM references_ WHERE id = ?", (ref_id,)).fetchone()
if not ref_row:
db.close()
raise HTTPException(404, "Reference not found")
job_row = db.execute(
"SELECT title, company, description FROM jobs WHERE id = ?", (payload.job_id,)
).fetchone()
if not job_row:
db.close()
raise HTTPException(404, "Job not found")
ref = dict(ref_row)
job = dict(job_row)
db.close()
prompt = f"""Draft a short, warm email to send to a professional reference before a job interview.
Reference: {ref['name']} ({ref['relationship']} at {ref['company']})
Role applying for: {job['title']} at {job['company']}
Job description excerpt: {(job['description'] or '')[:500]}
The email should:
- Be 3-4 short paragraphs max
- Thank them for being a reference
- Briefly describe the role and why it's a good fit
- Mention 1-2 specific accomplishments they could speak to
- Give them a heads-up they may be contacted soon
- Be warm and professional, not overly formal
Return only the email body (no subject line)."""
try:
from scripts.llm_router import LLMRouter
router = LLMRouter()
email_text = router.complete(prompt)
except Exception as e:
raise HTTPException(500, f"LLM generation failed: {e}")
# Persist to job_references
db = _get_db()
db.execute(
"""INSERT INTO job_references (job_id, reference_id, prep_email)
VALUES (?, ?, ?)
ON CONFLICT(job_id, reference_id) DO UPDATE SET prep_email = excluded.prep_email""",
(payload.job_id, ref_id, email_text),
)
db.commit()
db.close()
return {"prep_email": email_text}
@app.post("/api/references/{ref_id}/rec-letter")
def generate_rec_letter(ref_id: int, payload: RecLetterPayload):
"""Draft a recommendation letter the reference can edit and send on their letterhead."""
db = _get_db()
ref_row = db.execute("SELECT * FROM references_ WHERE id = ?", (ref_id,)).fetchone()
if not ref_row:
db.close()
raise HTTPException(404, "Reference not found")
job_row = db.execute(
"SELECT title, company, description FROM jobs WHERE id = ?", (payload.job_id,)
).fetchone()
if not job_row:
db.close()
raise HTTPException(404, "Job not found")
ref = dict(ref_row)
job = dict(job_row)
db.close()
prompt = f"""Draft a professional recommendation letter that {ref['name']} ({ref['relationship']}) could send on their letterhead for a candidate applying to {job['title']} at {job['company']}.
Key talking points to highlight: {payload.talking_points or 'general professional excellence, collaboration, initiative'}
The letter should:
- Be addressed generically (Dear Hiring Manager)
- Be 3-4 paragraphs
- Sound natural written from the recommender's voice, not the candidate's
- Highlight specific, credible observations a {ref['relationship']} would have
- Close with strong endorsement and contact offer
Return only the letter body."""
try:
from scripts.llm_router import LLMRouter
router = LLMRouter()
letter_text = router.complete(prompt)
except Exception as e:
raise HTTPException(500, f"LLM generation failed: {e}")
db = _get_db()
db.execute(
"""INSERT INTO job_references (job_id, reference_id, rec_letter)
VALUES (?, ?, ?)
ON CONFLICT(job_id, reference_id) DO UPDATE SET rec_letter = excluded.rec_letter""",
(payload.job_id, ref_id, letter_text),
)
db.commit()
db.close()
return {"rec_letter": letter_text}
# ── GET /api/interviews ────────────────────────────────────────────────────────
PIPELINE_STATUSES = {
@ -1509,7 +1808,7 @@ def list_interviews():
f"SELECT id, title, company, url, location, is_remote, salary, "
f"match_score, keyword_gaps, status, "
f"interview_date, rejection_stage, "
f"applied_at, phone_screen_at, interviewing_at, offer_at, hired_at, survey_at "
f"applied_at, phone_screen_at, interviewing_at, offer_at, hired_at, survey_at, hired_feedback "
f"FROM jobs WHERE status IN ({placeholders}) "
f"ORDER BY match_score DESC NULLS LAST",
list(PIPELINE_STATUSES),

View file

@ -0,0 +1,6 @@
-- 006_date_posted.sql
-- Add date_posted column for shadow listing detection (stale/shadow score feature).
-- New DBs already have this column from the CREATE TABLE statement in db.py;
-- this migration adds it to existing user DBs.
ALTER TABLE jobs ADD COLUMN date_posted TEXT;

View file

@ -130,6 +130,32 @@ CREATE TABLE IF NOT EXISTS digest_queue (
)
"""
CREATE_REFERENCES = """
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 DEFAULT '[]',
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
"""
CREATE_JOB_REFERENCES = """
CREATE TABLE IF NOT EXISTS job_references (
id INTEGER PRIMARY KEY AUTOINCREMENT,
job_id INTEGER NOT NULL REFERENCES jobs(id) ON DELETE CASCADE,
reference_id INTEGER NOT NULL REFERENCES references_(id) ON DELETE CASCADE,
prep_email TEXT,
rec_letter TEXT,
UNIQUE(job_id, reference_id)
);
"""
_MIGRATIONS = [
("cover_letter", "TEXT"),
("applied_at", "TEXT"),
@ -143,6 +169,8 @@ _MIGRATIONS = [
("calendar_event_id", "TEXT"),
("optimized_resume", "TEXT"), # ATS-rewritten resume text (paid tier)
("ats_gap_report", "TEXT"), # JSON gap report (free tier)
("date_posted", "TEXT"), # Original posting date from job board (shadow listing detection)
("hired_feedback", "TEXT"), # JSON: optional post-hire "what helped" response
]
@ -176,6 +204,9 @@ def _migrate_db(db_path: Path) -> None:
conn.execute("ALTER TABLE background_tasks ADD COLUMN params TEXT")
except sqlite3.OperationalError:
pass # column already exists
# Ensure references tables exist (CREATE IF NOT EXISTS is idempotent)
conn.execute(CREATE_REFERENCES)
conn.execute(CREATE_JOB_REFERENCES)
conn.commit()
conn.close()
@ -189,6 +220,8 @@ def init_db(db_path: Path = DEFAULT_DB) -> None:
conn.execute(CREATE_BACKGROUND_TASKS)
conn.execute(CREATE_SURVEY_RESPONSES)
conn.execute(CREATE_DIGEST_QUEUE)
conn.execute(CREATE_REFERENCES)
conn.execute(CREATE_JOB_REFERENCES)
conn.commit()
conn.close()
_migrate_db(db_path)
@ -202,8 +235,8 @@ def insert_job(db_path: Path = DEFAULT_DB, job: dict = None) -> Optional[int]:
try:
cursor = conn.execute(
"""INSERT INTO jobs
(title, company, url, source, location, is_remote, salary, description, date_found)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(title, company, url, source, location, is_remote, salary, description, date_found, date_posted)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
job.get("title", ""),
job.get("company", ""),
@ -214,6 +247,7 @@ def insert_job(db_path: Path = DEFAULT_DB, job: dict = None) -> Optional[int]:
job.get("salary", ""),
job.get("description", ""),
job.get("date_found", ""),
job.get("date_posted", "") or "",
),
)
conn.commit()

View file

@ -307,6 +307,10 @@ def run_discovery(db_path: Path = DEFAULT_DB, notion_push: bool = False, config_
elif job_dict.get("salary_source") and str(job_dict["salary_source"]) not in ("nan", "None", ""):
salary_str = str(job_dict["salary_source"])
_dp = job_dict.get("date_posted")
date_posted_str = (
_dp.isoformat() if hasattr(_dp, "isoformat") else str(_dp)
) if _dp and str(_dp) not in ("nan", "None", "") else ""
row = {
"url": url,
"title": _s(job_dict.get("title")),
@ -316,6 +320,7 @@ def run_discovery(db_path: Path = DEFAULT_DB, notion_push: bool = False, config_
"is_remote": bool(job_dict.get("is_remote", False)),
"salary": salary_str,
"description": _s(job_dict.get("description")),
"date_posted": date_posted_str,
"_exclude_kw": exclude_kw,
}
if _insert_if_new(row, _s(job_dict.get("site"))):

View file

@ -96,6 +96,8 @@ import {
NewspaperIcon,
Cog6ToothIcon,
DocumentTextIcon,
UsersIcon,
IdentificationIcon,
} from '@heroicons/vue/24/outline'
import { useDigestStore } from '../stores/digest'
@ -155,6 +157,8 @@ const navLinks = computed(() => [
{ to: '/apply', icon: PencilSquareIcon, label: 'Apply' },
{ to: '/resumes', icon: DocumentTextIcon, label: 'Resumes' },
{ to: '/interviews', icon: CalendarDaysIcon, label: 'Interviews' },
{ to: '/contacts', icon: UsersIcon, label: 'Contacts' },
{ to: '/references', icon: IdentificationIcon, label: 'References' },
{ to: '/digest', icon: NewspaperIcon, label: 'Digest',
badge: digestStore.entries.length || undefined },
{ to: '/prep', icon: LightBulbIcon, label: 'Interview Prep' },

View file

@ -191,6 +191,37 @@ const columnColor = computed(() => {
}
return map[props.job.status] ?? 'var(--color-border)'
})
// Hired feedback
const FEEDBACK_FACTORS = [
'Resume match',
'Cover letter',
'Interview prep',
'Company research',
'Network / referral',
'Salary negotiation',
] as const
const feedbackDismissed = ref(false)
const feedbackSaved = ref(!!props.job.hired_feedback)
const feedbackText = ref('')
const feedbackFactors = ref<string[]>([])
const feedbackSaving = ref(false)
const showFeedbackWidget = computed(() =>
props.job.status === 'hired' && !feedbackDismissed.value && !feedbackSaved.value
)
async function saveFeedback() {
feedbackSaving.value = true
const { error } = await useApiFetch(`/api/jobs/${props.job.id}/hired-feedback`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ what_helped: feedbackText.value, factors: feedbackFactors.value }),
})
feedbackSaving.value = false
if (!error) feedbackSaved.value = true
}
</script>
<template>
@ -307,6 +338,38 @@ const columnColor = computed(() => {
@click.stop="sigExpanded = !sigExpanded"
>{{ sigExpanded ? ' less' : `+${(job.stage_signals?.length ?? 1) - 1} more` }}</button>
</template>
<!-- Hired feedback widget -->
<div v-if="showFeedbackWidget" class="hired-feedback" @click.stop>
<div class="hired-feedback__header">
<span class="hired-feedback__title">What helped you land this role?</span>
<button class="hired-feedback__dismiss" @click="feedbackDismissed = true" aria-label="Dismiss feedback"></button>
</div>
<div class="hired-feedback__factors">
<label
v-for="factor in FEEDBACK_FACTORS"
:key="factor"
class="hired-feedback__factor"
>
<input type="checkbox" :value="factor" v-model="feedbackFactors" />
{{ factor }}
</label>
</div>
<textarea
v-model="feedbackText"
class="hired-feedback__textarea"
placeholder="Anything else that made the difference…"
rows="2"
/>
<button
class="hired-feedback__save"
:disabled="feedbackSaving"
@click="saveFeedback"
>{{ feedbackSaving ? 'Saving…' : 'Save reflection' }}</button>
</div>
<div v-else-if="job.status === 'hired' && feedbackSaved" class="hired-feedback hired-feedback--saved">
Reflection saved.
</div>
</article>
</template>
@ -533,4 +596,80 @@ const columnColor = computed(() => {
background: none; border: none; font-size: 0.75em; color: var(--color-info); cursor: pointer;
padding: 4px 12px; text-align: left;
}
/* ── Hired feedback widget ── */
.hired-feedback {
padding: var(--space-3) var(--space-4);
border-top: 1px solid var(--color-border);
background: rgba(39, 174, 96, 0.04);
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.hired-feedback--saved {
font-size: var(--text-sm);
color: var(--color-text-muted);
text-align: center;
padding: var(--space-2) var(--space-4);
}
.hired-feedback__header {
display: flex;
justify-content: space-between;
align-items: center;
}
.hired-feedback__title {
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-success);
}
.hired-feedback__dismiss {
background: none;
border: none;
color: var(--color-text-muted);
cursor: pointer;
font-size: var(--text-sm);
padding: 2px 4px;
}
.hired-feedback__factors {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
.hired-feedback__factor {
display: flex;
align-items: center;
gap: 4px;
font-size: var(--text-xs);
color: var(--color-text-muted);
cursor: pointer;
}
.hired-feedback__textarea {
width: 100%;
font-size: var(--text-sm);
padding: var(--space-2);
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-surface);
color: var(--color-text);
resize: vertical;
box-sizing: border-box;
}
.hired-feedback__textarea:focus-visible {
outline: 2px solid var(--app-primary);
outline-offset: 2px;
}
.hired-feedback__save {
align-self: flex-end;
padding: var(--space-1) var(--space-3);
font-size: var(--text-sm);
background: var(--color-success);
color: #fff;
border: none;
border-radius: 6px;
cursor: pointer;
}
.hired-feedback__save:disabled {
opacity: 0.6;
cursor: not-allowed;
}
</style>

View file

@ -7,7 +7,7 @@
}"
:aria-label="`${job.title} at ${job.company}`"
>
<!-- Score badge + remote badge -->
<!-- Score badge + remote badge + shadow badge -->
<div class="job-card__badges">
<span
v-if="job.match_score !== null"
@ -18,6 +18,18 @@
{{ job.match_score }}%
</span>
<span v-if="job.is_remote" class="remote-badge">Remote</span>
<span
v-if="job.shadow_score === 'shadow'"
class="shadow-badge shadow-badge--shadow"
:title="`Posted 30+ days before discovery — may already be filled`"
aria-label="Possible shadow listing: posted long before discovery"
>Ghost post</span>
<span
v-else-if="job.shadow_score === 'stale'"
class="shadow-badge shadow-badge--stale"
:title="`Posted 14+ days before discovery — listing may be stale`"
aria-label="Stale listing: posted over 2 weeks before discovery"
>Stale</span>
</div>
<!-- Title + company -->
@ -178,6 +190,28 @@ const formattedDate = computed(() => {
color: var(--app-primary);
}
.shadow-badge {
display: inline-flex;
align-items: center;
padding: 2px var(--space-2);
border-radius: 999px;
font-size: var(--text-xs);
font-weight: 600;
cursor: help;
}
.shadow-badge--shadow {
background: rgba(99, 99, 99, 0.15);
color: var(--color-text-muted);
border: 1px solid rgba(99, 99, 99, 0.3);
}
.shadow-badge--stale {
background: rgba(212, 137, 26, 0.12);
color: var(--score-mid);
border: 1px solid rgba(212, 137, 26, 0.25);
}
.job-card__title {
font-family: var(--font-display);
font-size: var(--text-xl);

View file

@ -12,6 +12,8 @@ export const router = createRouter({
{ path: '/apply/:id', component: () => import('../views/ApplyWorkspaceView.vue') },
{ path: '/resumes', component: () => import('../views/ResumesView.vue') },
{ path: '/interviews', component: () => import('../views/InterviewsView.vue') },
{ path: '/contacts', component: () => import('../views/ContactsView.vue') },
{ path: '/references', component: () => import('../views/ReferencesView.vue') },
{ path: '/digest', component: () => import('../views/DigestView.vue') },
{ path: '/prep', component: () => import('../views/InterviewPrepView.vue') },
{ path: '/prep/:id', component: () => import('../views/InterviewPrepView.vue') },

View file

@ -30,6 +30,7 @@ export interface PipelineJob {
offer_at: string | null
hired_at: string | null
survey_at: string | null
hired_feedback: string | null // JSON: { what_helped, factors }
stage_signals: StageSignal[] // undismissed signals, newest first
}

View file

@ -3,19 +3,21 @@ import { ref, computed } from 'vue'
import { useApiFetch } from '../composables/useApi'
export interface Job {
id: number
title: string
company: string
url: string
source: string | null
location: string | null
is_remote: boolean
salary: string | null
description: string | null
match_score: number | null
keyword_gaps: string | null // JSON-encoded string[]
date_found: string
status: string
id: number
title: string
company: string
url: string
source: string | null
location: string | null
is_remote: boolean
salary: string | null
description: string | null
match_score: number | null
keyword_gaps: string | null // JSON-encoded string[]
date_found: string
date_posted: string | null
shadow_score: 'shadow' | 'stale' | null
status: string
}
interface UndoEntry {

View file

@ -0,0 +1,333 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useApiFetch } from '../composables/useApi'
interface Contact {
id: number
job_id: number
direction: 'inbound' | 'outbound'
subject: string | null
from_addr: string | null
to_addr: string | null
received_at: string | null
stage_signal: string | null
job_title: string | null
job_company: string | null
}
const contacts = ref<Contact[]>([])
const total = ref(0)
const loading = ref(false)
const error = ref<string | null>(null)
const search = ref('')
const direction = ref<'all' | 'inbound' | 'outbound'>('all')
const searchInput = ref('')
let debounceTimer: ReturnType<typeof setTimeout> | null = null
async function fetchContacts() {
loading.value = true
error.value = null
const params = new URLSearchParams({ limit: '100' })
if (direction.value !== 'all') params.set('direction', direction.value)
if (search.value) params.set('search', search.value)
const { data, error: fetchErr } = await useApiFetch<{ total: number; contacts: Contact[] }>(
`/api/contacts?${params}`
)
loading.value = false
if (fetchErr || !data) {
error.value = 'Failed to load contacts.'
return
}
contacts.value = data.contacts
total.value = data.total
}
function onSearchInput() {
if (debounceTimer) clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => {
search.value = searchInput.value
fetchContacts()
}, 300)
}
function onDirectionChange() {
fetchContacts()
}
function formatDate(iso: string | null): string {
if (!iso) return '—'
return new Date(iso).toLocaleDateString([], { month: 'short', day: 'numeric', year: 'numeric' })
}
function displayAddr(contact: Contact): string {
return contact.direction === 'inbound'
? contact.from_addr ?? '—'
: contact.to_addr ?? '—'
}
const signalLabel: Record<string, string> = {
interview_scheduled: '📅 Interview',
offer_received: '🟢 Offer',
rejected: '✖ Rejected',
positive_response: '✅ Positive',
survey_received: '📋 Survey',
}
onMounted(fetchContacts)
</script>
<template>
<div class="contacts-view">
<header class="contacts-header">
<h1 class="contacts-title">Contacts</h1>
<span class="contacts-count" v-if="total > 0">{{ total }} total</span>
</header>
<div class="contacts-toolbar">
<input
v-model="searchInput"
class="contacts-search"
type="search"
placeholder="Search name, email, or subject…"
aria-label="Search contacts"
@input="onSearchInput"
/>
<div class="contacts-filter" role="group" aria-label="Filter by direction">
<button
v-for="opt in (['all', 'inbound', 'outbound'] as const)"
:key="opt"
class="filter-btn"
:class="{ 'filter-btn--active': direction === opt }"
@click="direction = opt; onDirectionChange()"
>{{ opt === 'all' ? 'All' : opt === 'inbound' ? 'Inbound' : 'Outbound' }}</button>
</div>
</div>
<div v-if="loading" class="contacts-empty">Loading</div>
<div v-else-if="error" class="contacts-empty contacts-empty--error">{{ error }}</div>
<div v-else-if="contacts.length === 0" class="contacts-empty">
No contacts found{{ search ? ' for that search' : '' }}.
</div>
<div v-else class="contacts-table-wrap">
<table class="contacts-table" aria-label="Contacts">
<thead>
<tr>
<th>Contact</th>
<th>Subject</th>
<th>Job</th>
<th>Signal</th>
<th>Date</th>
</tr>
</thead>
<tbody>
<tr
v-for="c in contacts"
:key="c.id"
class="contacts-row"
:class="{ 'contacts-row--inbound': c.direction === 'inbound' }"
>
<td class="contacts-cell contacts-cell--addr">
<span class="dir-chip" :class="`dir-chip--${c.direction}`">
{{ c.direction === 'inbound' ? '↓' : '↑' }}
</span>
{{ displayAddr(c) }}
</td>
<td class="contacts-cell contacts-cell--subject">
{{ c.subject ? c.subject.slice(0, 60) + (c.subject.length > 60 ? '…' : '') : '—' }}
</td>
<td class="contacts-cell contacts-cell--job">
<span v-if="c.job_title">
{{ c.job_title }}<span v-if="c.job_company" class="job-company"> · {{ c.job_company }}</span>
</span>
<span v-else class="text-muted"></span>
</td>
<td class="contacts-cell contacts-cell--signal">
<span v-if="c.stage_signal && signalLabel[c.stage_signal]" class="signal-chip">
{{ signalLabel[c.stage_signal] }}
</span>
</td>
<td class="contacts-cell contacts-cell--date">{{ formatDate(c.received_at) }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<style scoped>
.contacts-view {
padding: var(--space-6);
max-width: 1000px;
}
.contacts-header {
display: flex;
align-items: baseline;
gap: var(--space-3);
margin-bottom: var(--space-5);
}
.contacts-title {
font-size: var(--text-2xl);
font-weight: 700;
color: var(--color-text);
margin: 0;
}
.contacts-count {
font-size: var(--text-sm);
color: var(--color-text-muted);
}
.contacts-toolbar {
display: flex;
gap: var(--space-3);
align-items: center;
margin-bottom: var(--space-4);
flex-wrap: wrap;
}
.contacts-search {
flex: 1;
min-width: 200px;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-border);
border-radius: 8px;
background: var(--color-surface);
color: var(--color-text);
font-size: var(--text-sm);
}
.contacts-search:focus-visible {
outline: 2px solid var(--app-primary);
outline-offset: 2px;
}
.contacts-filter {
display: flex;
gap: 4px;
}
.filter-btn {
padding: var(--space-1) var(--space-3);
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-surface);
color: var(--color-text-muted);
font-size: var(--text-sm);
cursor: pointer;
}
.filter-btn--active {
background: var(--app-primary-light);
color: var(--app-primary);
border-color: var(--app-primary);
font-weight: 600;
}
.contacts-empty {
color: var(--color-text-muted);
font-size: var(--text-sm);
padding: var(--space-8) 0;
text-align: center;
}
.contacts-empty--error {
color: var(--color-error, #c0392b);
}
.contacts-table-wrap {
overflow-x: auto;
}
.contacts-table {
width: 100%;
border-collapse: collapse;
font-size: var(--text-sm);
}
.contacts-table th {
text-align: left;
padding: var(--space-2) var(--space-3);
font-size: var(--text-xs);
font-weight: 600;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
border-bottom: 1px solid var(--color-border);
}
.contacts-row {
border-bottom: 1px solid var(--color-border);
}
.contacts-row:hover {
background: var(--color-hover);
}
.contacts-cell {
padding: var(--space-3);
vertical-align: top;
color: var(--color-text);
}
.contacts-cell--addr {
white-space: nowrap;
font-size: var(--text-xs);
font-family: var(--font-mono);
display: flex;
align-items: center;
gap: var(--space-2);
}
.contacts-cell--subject {
color: var(--color-text-muted);
}
.contacts-cell--job {
font-size: var(--text-xs);
}
.job-company {
color: var(--color-text-muted);
}
.contacts-cell--date {
white-space: nowrap;
color: var(--color-text-muted);
font-size: var(--text-xs);
}
.dir-chip {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border-radius: 4px;
font-size: 10px;
font-weight: 700;
flex-shrink: 0;
}
.dir-chip--inbound {
background: rgba(39, 174, 96, 0.15);
color: var(--color-success);
}
.dir-chip--outbound {
background: var(--app-primary-light);
color: var(--app-primary);
}
.signal-chip {
font-size: var(--text-xs);
white-space: nowrap;
}
.text-muted {
color: var(--color-text-muted);
}
</style>

View file

@ -0,0 +1,469 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useApiFetch } from '../composables/useApi'
export interface Reference {
id: number
name: string
relationship: string
company: string
email: string
phone: string
notes: string
tags: string // JSON-encoded string[]
created_at: string
updated_at: string
}
const TAG_OPTIONS = ['technical', 'managerial', 'character', 'academic'] as const
const references = ref<Reference[]>([])
const loading = ref(false)
const saving = ref(false)
const editingId = ref<number | 'new' | null>(null)
const deleteConfirmId = ref<number | null>(null)
const blankForm = () => ({
name: '', relationship: '', company: '', email: '', phone: '', notes: '', tags: [] as string[],
})
const form = ref(blankForm())
async function fetchRefs() {
loading.value = true
const { data } = await useApiFetch<Reference[]>('/api/references')
loading.value = false
if (data) references.value = data
}
function parseTags(raw: string): string[] {
try { return JSON.parse(raw) } catch { return [] }
}
function startNew() {
form.value = blankForm()
editingId.value = 'new'
}
function startEdit(ref: Reference) {
form.value = {
name: ref.name,
relationship: ref.relationship,
company: ref.company,
email: ref.email,
phone: ref.phone,
notes: ref.notes,
tags: parseTags(ref.tags),
}
editingId.value = ref.id
}
function cancelEdit() {
editingId.value = null
form.value = blankForm()
}
async function saveRef() {
if (!form.value.name.trim()) return
saving.value = true
const isNew = editingId.value === 'new'
const url = isNew ? '/api/references' : `/api/references/${editingId.value}`
const { data, error } = await useApiFetch<Reference>(url, {
method: isNew ? 'POST' : 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(form.value),
})
saving.value = false
if (error || !data) return
if (isNew) {
references.value = [...references.value, data]
} else {
references.value = references.value.map(r => r.id === data.id ? data : r)
}
cancelEdit()
}
async function deleteRef(id: number) {
await useApiFetch(`/api/references/${id}`, { method: 'DELETE' })
references.value = references.value.filter(r => r.id !== id)
deleteConfirmId.value = null
}
function tagLabel(tag: string): string {
const map: Record<string, string> = {
technical: 'Technical', managerial: 'Manager', character: 'Character', academic: 'Academic',
}
return map[tag] ?? tag
}
onMounted(fetchRefs)
</script>
<template>
<div class="refs-view">
<header class="refs-header">
<div class="refs-header__left">
<h1 class="refs-title">References</h1>
<span v-if="references.length" class="refs-count">{{ references.length }}</span>
</div>
<button class="btn-add" @click="startNew" :disabled="editingId !== null">
+ Add reference
</button>
</header>
<p class="refs-hint">
Store your references once, associate them with applications, and generate
a heads-up email or draft recommendation letter with one click.
</p>
<!-- New / edit form -->
<div v-if="editingId !== null" class="ref-form">
<h2 class="ref-form__title">{{ editingId === 'new' ? 'Add reference' : 'Edit reference' }}</h2>
<div class="ref-form__grid">
<label class="ref-form__field ref-form__field--full">
<span>Name <span class="required">*</span></span>
<input v-model="form.name" class="ref-input" placeholder="Full name" />
</label>
<label class="ref-form__field">
<span>Relationship</span>
<input v-model="form.relationship" class="ref-input" placeholder="e.g. Former manager" />
</label>
<label class="ref-form__field">
<span>Company</span>
<input v-model="form.company" class="ref-input" placeholder="Where you worked together" />
</label>
<label class="ref-form__field">
<span>Email</span>
<input v-model="form.email" class="ref-input" type="email" placeholder="email@example.com" />
</label>
<label class="ref-form__field">
<span>Phone</span>
<input v-model="form.phone" class="ref-input" type="tel" placeholder="+1 555 000 0000" />
</label>
<label class="ref-form__field ref-form__field--full">
<span>Notes</span>
<textarea v-model="form.notes" class="ref-input ref-textarea"
placeholder="Accomplishments they can speak to, context, anything to remind yourself…"
rows="2" />
</label>
<div class="ref-form__field ref-form__field--full">
<span>Type</span>
<div class="ref-tags">
<label v-for="t in TAG_OPTIONS" :key="t" class="ref-tag-option">
<input type="checkbox" :value="t" v-model="form.tags" />
{{ tagLabel(t) }}
</label>
</div>
</div>
</div>
<div class="ref-form__actions">
<button class="btn-save" @click="saveRef" :disabled="saving || !form.name.trim()">
{{ saving ? 'Saving…' : editingId === 'new' ? 'Add' : 'Save' }}
</button>
<button class="btn-cancel" @click="cancelEdit">Cancel</button>
</div>
</div>
<!-- Empty state -->
<div v-if="!loading && references.length === 0 && editingId === null" class="refs-empty">
No references yet. Add someone who can speak to your work.
</div>
<!-- Reference cards -->
<ul v-else class="refs-list" aria-label="References">
<li
v-for="ref in references"
:key="ref.id"
class="ref-card"
:class="{ 'ref-card--editing': editingId === ref.id }"
>
<div class="ref-card__body">
<div class="ref-card__name">{{ ref.name }}</div>
<div class="ref-card__meta">
<span v-if="ref.relationship">{{ ref.relationship }}</span>
<span v-if="ref.relationship && ref.company" class="sep"> · </span>
<span v-if="ref.company">{{ ref.company }}</span>
</div>
<div class="ref-card__contact">
<a v-if="ref.email" :href="`mailto:${ref.email}`" class="ref-link">{{ ref.email }}</a>
<span v-if="ref.email && ref.phone" class="sep"> · </span>
<span v-if="ref.phone">{{ ref.phone }}</span>
</div>
<div v-if="ref.notes" class="ref-card__notes">{{ ref.notes }}</div>
<div v-if="parseTags(ref.tags).length" class="ref-card__tags">
<span v-for="t in parseTags(ref.tags)" :key="t" class="tag-chip" :class="`tag-chip--${t}`">
{{ tagLabel(t) }}
</span>
</div>
</div>
<div class="ref-card__actions">
<button class="btn-ghost" @click="startEdit(ref)" :disabled="editingId !== null">Edit</button>
<button
v-if="deleteConfirmId !== ref.id"
class="btn-ghost btn-ghost--danger"
@click="deleteConfirmId = ref.id"
:disabled="editingId !== null"
>Delete</button>
<template v-else>
<button class="btn-ghost btn-ghost--danger" @click="deleteRef(ref.id)">Confirm</button>
<button class="btn-ghost" @click="deleteConfirmId = null">Cancel</button>
</template>
</div>
</li>
</ul>
</div>
</template>
<style scoped>
.refs-view {
padding: var(--space-6);
max-width: 760px;
}
.refs-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-2);
}
.refs-header__left {
display: flex;
align-items: baseline;
gap: var(--space-2);
}
.refs-title {
font-size: var(--text-2xl);
font-weight: 700;
color: var(--color-text);
margin: 0;
}
.refs-count {
font-size: var(--text-sm);
color: var(--color-text-muted);
}
.refs-hint {
font-size: var(--text-sm);
color: var(--color-text-muted);
margin: 0 0 var(--space-5);
line-height: 1.5;
}
.btn-add {
padding: var(--space-2) var(--space-4);
background: var(--app-primary);
color: #fff;
border: none;
border-radius: 8px;
font-size: var(--text-sm);
font-weight: 600;
cursor: pointer;
}
.btn-add:disabled { opacity: 0.5; cursor: not-allowed; }
/* Form */
.ref-form {
background: var(--color-surface-raised);
border: 1px solid var(--color-border);
border-radius: 10px;
padding: var(--space-5);
margin-bottom: var(--space-5);
}
.ref-form__title {
font-size: var(--text-lg);
font-weight: 600;
margin: 0 0 var(--space-4);
}
.ref-form__grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-3);
}
.ref-form__field {
display: flex;
flex-direction: column;
gap: 4px;
font-size: var(--text-sm);
color: var(--color-text-muted);
}
.ref-form__field--full { grid-column: 1 / -1; }
.required { color: var(--color-error, #c0392b); }
.ref-input {
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-surface);
color: var(--color-text);
font-size: var(--text-sm);
}
.ref-input:focus-visible {
outline: 2px solid var(--app-primary);
outline-offset: 2px;
}
.ref-textarea { resize: vertical; font-family: inherit; }
.ref-tags {
display: flex;
flex-wrap: wrap;
gap: var(--space-3);
margin-top: 4px;
}
.ref-tag-option {
display: flex;
align-items: center;
gap: 4px;
font-size: var(--text-sm);
cursor: pointer;
}
.ref-form__actions {
display: flex;
gap: var(--space-2);
margin-top: var(--space-4);
}
.btn-save {
padding: var(--space-2) var(--space-4);
background: var(--app-primary);
color: #fff;
border: none;
border-radius: 6px;
font-size: var(--text-sm);
font-weight: 600;
cursor: pointer;
}
.btn-save:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-cancel {
padding: var(--space-2) var(--space-4);
background: none;
border: 1px solid var(--color-border);
border-radius: 6px;
font-size: var(--text-sm);
cursor: pointer;
color: var(--color-text-muted);
}
/* Empty */
.refs-empty {
color: var(--color-text-muted);
font-size: var(--text-sm);
padding: var(--space-8) 0;
text-align: center;
}
/* List */
.refs-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.ref-card {
background: var(--color-surface-raised);
border: 1px solid var(--color-border);
border-radius: 10px;
padding: var(--space-4);
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: var(--space-4);
}
.ref-card--editing {
border-color: var(--app-primary);
box-shadow: 0 0 0 2px var(--app-primary-light);
}
.ref-card__body {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.ref-card__name {
font-weight: 600;
font-size: var(--text-base);
color: var(--color-text);
}
.ref-card__meta, .ref-card__contact {
font-size: var(--text-sm);
color: var(--color-text-muted);
}
.ref-card__notes {
font-size: var(--text-sm);
color: var(--color-text-muted);
margin-top: 4px;
font-style: italic;
}
.ref-link {
color: var(--app-primary);
text-decoration: none;
}
.ref-link:hover { text-decoration: underline; }
.sep { margin: 0 2px; }
.ref-card__tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: var(--space-2);
}
.tag-chip {
padding: 2px 8px;
border-radius: 999px;
font-size: var(--text-xs);
font-weight: 600;
}
.tag-chip--technical { background: var(--app-primary-light); color: var(--app-primary); }
.tag-chip--managerial { background: rgba(39, 174, 96, 0.12); color: var(--color-success); }
.tag-chip--character { background: rgba(212, 137, 26, 0.12); color: var(--score-mid); }
.tag-chip--academic { background: rgba(103, 58, 183, 0.12); color: #7c3aed; }
.ref-card__actions {
display: flex;
flex-direction: column;
gap: var(--space-1);
flex-shrink: 0;
}
.btn-ghost {
background: none;
border: 1px solid var(--color-border);
border-radius: 6px;
padding: 3px var(--space-3);
font-size: var(--text-xs);
cursor: pointer;
color: var(--color-text-muted);
white-space: nowrap;
}
.btn-ghost:hover { background: var(--color-hover); }
.btn-ghost:disabled { opacity: 0.4; cursor: not-allowed; }
.btn-ghost--danger { color: var(--color-error, #c0392b); border-color: rgba(192, 57, 43, 0.3); }
.btn-ghost--danger:hover { background: rgba(192, 57, 43, 0.07); }
</style>