feat: add ResumeReviewModal with paged tab navigation and color-coded status
This commit is contained in:
parent
1253ef15a0
commit
a3aaed0e0c
6 changed files with 899 additions and 0 deletions
550
web/src/components/ResumeReviewModal.vue
Normal file
550
web/src/components/ResumeReviewModal.vue
Normal file
|
|
@ -0,0 +1,550 @@
|
|||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
class="rrm-backdrop"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
:aria-labelledby="`rrm-title-${jobId}`"
|
||||
@keydown.esc="handleEscape"
|
||||
@click.self="handleEscape"
|
||||
>
|
||||
<div class="rrm-card" ref="cardRef" tabindex="-1">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="rrm__header">
|
||||
<h2 :id="`rrm-title-${jobId}`" class="rrm__title">Resume Review</h2>
|
||||
<button class="rrm__close" aria-label="Close review" @click="handleEscape">✕</button>
|
||||
</div>
|
||||
|
||||
<!-- Tab bar -->
|
||||
<div class="rrm__tabs" role="tablist" aria-label="Resume sections">
|
||||
<button
|
||||
v-for="(page, idx) in pages"
|
||||
:key="page.id"
|
||||
role="tab"
|
||||
:aria-selected="idx === currentIdx"
|
||||
:aria-controls="`rrm-panel-${page.id}`"
|
||||
class="rrm__tab"
|
||||
:class="{ 'rrm__tab--active': idx === currentIdx }"
|
||||
@click="goTo(idx)"
|
||||
>
|
||||
<span
|
||||
v-if="page.type !== 'confirm'"
|
||||
class="tab__dot"
|
||||
:class="`tab__dot--${tabStatus(page)}`"
|
||||
:aria-label="`${page.label}: ${tabStatus(page)}`"
|
||||
/>
|
||||
<span class="tab__label">{{ page.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Page content -->
|
||||
<div
|
||||
:id="`rrm-panel-${currentPage.id}`"
|
||||
class="rrm__content"
|
||||
role="tabpanel"
|
||||
:aria-labelledby="`rrm-title-${jobId}`"
|
||||
>
|
||||
<!-- Skills page -->
|
||||
<template v-if="currentPage.type === 'skills'">
|
||||
<SkillsPage
|
||||
:section="skillsSection!"
|
||||
:approved-skills="approvedSkills"
|
||||
:skill-framings="skillFramings"
|
||||
@toggle-skill="toggleSkill"
|
||||
@set-framing-mode="setFramingMode"
|
||||
@set-framing-context="setFramingContext"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Summary page -->
|
||||
<template v-else-if="currentPage.type === 'summary'">
|
||||
<SummaryPage
|
||||
:section="summarySection!"
|
||||
:accepted="summaryAccepted"
|
||||
@update:accepted="summaryAccepted = $event"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Experience page -->
|
||||
<template v-else-if="currentPage.type === 'experience'">
|
||||
<ExperiencePage
|
||||
:entry="currentEntry!"
|
||||
:accepted="expAccepted[currentPage.entryKey!] ?? true"
|
||||
@update:accepted="expAccepted[currentPage.entryKey!] = $event"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Confirm page -->
|
||||
<template v-else-if="currentPage.type === 'confirm'">
|
||||
<ConfirmPage
|
||||
:pages="pages.filter(p => p.type !== 'confirm')"
|
||||
:tab-status="tabStatus"
|
||||
:submitting="submitting"
|
||||
:error="submitError"
|
||||
@preview="emitSubmit"
|
||||
@rewrite="emit('rewriteAgain')"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Footer navigation -->
|
||||
<div class="rrm__footer">
|
||||
<button
|
||||
class="rrm__back btn-secondary"
|
||||
:disabled="currentIdx === 0"
|
||||
@click="goTo(currentIdx - 1)"
|
||||
>
|
||||
← Back
|
||||
</button>
|
||||
<span class="rrm__page-counter">
|
||||
{{ currentIdx + 1 }} / {{ pages.length }}
|
||||
</span>
|
||||
<button
|
||||
v-if="currentPage.type !== 'confirm'"
|
||||
class="rrm__next btn-generate"
|
||||
@click="goTo(currentIdx + 1)"
|
||||
>
|
||||
Next →
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
class="rrm__preview btn-generate"
|
||||
:disabled="submitting"
|
||||
@click="emitSubmit"
|
||||
>
|
||||
<span aria-hidden="true">👁️</span>
|
||||
{{ submitting ? 'Generating…' : 'Preview Full Resume' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import SkillsPage from './resume-review/SkillsPage.vue'
|
||||
import SummaryPage from './resume-review/SummaryPage.vue'
|
||||
import ExperiencePage from './resume-review/ExperiencePage.vue'
|
||||
import ConfirmPage from './resume-review/ConfirmPage.vue'
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
type TabStatus = 'unvisited' | 'in-progress' | 'accepted' | 'partial' | 'skipped'
|
||||
type PageType = 'skills' | 'summary' | 'experience' | 'confirm'
|
||||
type GapFramingMode = 'skip' | 'adjacent' | 'learning'
|
||||
|
||||
interface GapFraming { mode: GapFramingMode; context: string }
|
||||
|
||||
export interface SkillsDiff {
|
||||
section: 'skills'
|
||||
type: 'skills_diff'
|
||||
added: string[]
|
||||
removed: string[]
|
||||
kept: string[]
|
||||
}
|
||||
|
||||
export interface TextDiff {
|
||||
section: 'summary'
|
||||
type: 'text_diff'
|
||||
original: string
|
||||
proposed: string
|
||||
}
|
||||
|
||||
export interface BulletsDiff {
|
||||
section: 'experience'
|
||||
type: 'bullets_diff'
|
||||
entries: Array<{
|
||||
title: string
|
||||
company: string
|
||||
original_bullets: string[]
|
||||
proposed_bullets: string[]
|
||||
}>
|
||||
}
|
||||
|
||||
export type SectionDiff = SkillsDiff | TextDiff | BulletsDiff
|
||||
|
||||
export interface ReviewDraft {
|
||||
sections: SectionDiff[]
|
||||
rewritten_struct: Record<string, unknown>
|
||||
gap_report?: unknown[]
|
||||
}
|
||||
|
||||
interface Page {
|
||||
id: string
|
||||
type: PageType
|
||||
label: string
|
||||
entryKey?: string
|
||||
}
|
||||
|
||||
// ── Props / emits ─────────────────────────────────────────────────────────────
|
||||
|
||||
const props = defineProps<{
|
||||
jobId: number
|
||||
draft: ReviewDraft
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
submit: [decisions: Record<string, unknown>]
|
||||
rewriteAgain: []
|
||||
}>()
|
||||
|
||||
// ── Section accessors ─────────────────────────────────────────────────────────
|
||||
|
||||
const skillsSection = computed(() =>
|
||||
props.draft.sections.find(s => s.section === 'skills') as SkillsDiff | undefined
|
||||
)
|
||||
const summarySection = computed(() =>
|
||||
props.draft.sections.find(s => s.section === 'summary') as TextDiff | undefined
|
||||
)
|
||||
const expSection = computed(() =>
|
||||
props.draft.sections.find(s => s.section === 'experience') as BulletsDiff | undefined
|
||||
)
|
||||
|
||||
// ── Page list ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const pages = computed<Page[]>(() => {
|
||||
const result: Page[] = []
|
||||
const skills = skillsSection.value
|
||||
const summary = summarySection.value
|
||||
const exp = expSection.value
|
||||
if (skills && skills.added.length > 0) {
|
||||
result.push({ id: 'skills', type: 'skills', label: 'Skills' })
|
||||
}
|
||||
if (summary) {
|
||||
result.push({ id: 'summary', type: 'summary', label: 'Summary' })
|
||||
}
|
||||
for (const e of (exp?.entries ?? [])) {
|
||||
const key = `${e.title}|${e.company}`
|
||||
result.push({ id: key, type: 'experience', label: e.title, entryKey: key })
|
||||
}
|
||||
result.push({ id: 'confirm', type: 'confirm', label: 'Confirm' })
|
||||
return result
|
||||
})
|
||||
|
||||
// ── Navigation state ──────────────────────────────────────────────────────────
|
||||
|
||||
const currentIdx = ref(0)
|
||||
const visitedPages = ref<Set<string>>(new Set())
|
||||
// Pages where the user has explicitly made a decision (toggled/changed something)
|
||||
const interactedPages = ref<Set<string>>(new Set())
|
||||
|
||||
const currentPage = computed(() => pages.value[currentIdx.value])
|
||||
|
||||
const currentEntry = computed(() => {
|
||||
const key = currentPage.value.entryKey
|
||||
if (!key) return undefined
|
||||
return expSection.value?.entries.find(e => `${e.title}|${e.company}` === key)
|
||||
})
|
||||
|
||||
function goTo(idx: number) {
|
||||
if (idx < 0 || idx >= pages.value.length) return
|
||||
// Mark current page as visited before leaving
|
||||
visitedPages.value = new Set([...visitedPages.value, currentPage.value.id])
|
||||
currentIdx.value = idx
|
||||
// Mark destination as visited on arrival
|
||||
visitedPages.value = new Set([...visitedPages.value, pages.value[idx].id])
|
||||
}
|
||||
|
||||
// ── Decision state ────────────────────────────────────────────────────────────
|
||||
|
||||
const approvedSkills = ref<Set<string>>(new Set(skillsSection.value?.added ?? []))
|
||||
const skillFramings = ref<Map<string, GapFraming>>(new Map())
|
||||
const summaryAccepted = ref(true)
|
||||
const expAccepted = ref<Record<string, boolean>>(
|
||||
Object.fromEntries(
|
||||
(expSection.value?.entries ?? []).map(e => [`${e.title}|${e.company}`, true])
|
||||
)
|
||||
)
|
||||
|
||||
function toggleSkill(skill: string) {
|
||||
interactedPages.value = new Set([...interactedPages.value, 'skills'])
|
||||
const next = new Set(approvedSkills.value)
|
||||
if (next.has(skill)) {
|
||||
next.delete(skill)
|
||||
if (!skillFramings.value.has(skill)) {
|
||||
skillFramings.value = new Map([...skillFramings.value, [skill, { mode: 'skip', context: '' }]])
|
||||
}
|
||||
} else {
|
||||
next.add(skill)
|
||||
const next2 = new Map(skillFramings.value)
|
||||
next2.delete(skill)
|
||||
skillFramings.value = next2
|
||||
}
|
||||
approvedSkills.value = next
|
||||
}
|
||||
|
||||
function setFramingMode(skill: string, mode: GapFramingMode) {
|
||||
const existing = skillFramings.value.get(skill) ?? { mode: 'skip' as GapFramingMode, context: '' }
|
||||
skillFramings.value = new Map([...skillFramings.value, [skill, { ...existing, mode }]])
|
||||
}
|
||||
|
||||
function setFramingContext(skill: string, context: string) {
|
||||
const existing = skillFramings.value.get(skill) ?? { mode: 'skip' as GapFramingMode, context: '' }
|
||||
skillFramings.value = new Map([...skillFramings.value, [skill, { ...existing, context }]])
|
||||
}
|
||||
|
||||
// ── Tab status ────────────────────────────────────────────────────────────────
|
||||
|
||||
function tabStatus(page: Page): TabStatus {
|
||||
if (!visitedPages.value.has(page.id)) return 'unvisited'
|
||||
// Only report a resolved status if the user has explicitly interacted
|
||||
if (!interactedPages.value.has(page.id)) return 'in-progress'
|
||||
if (page.type === 'skills') {
|
||||
const total = skillsSection.value?.added.length ?? 0
|
||||
const approved = approvedSkills.value.size
|
||||
if (approved === total) return 'accepted'
|
||||
if (approved === 0) return 'skipped'
|
||||
return 'partial'
|
||||
}
|
||||
if (page.type === 'summary') {
|
||||
return summaryAccepted.value ? 'accepted' : 'skipped'
|
||||
}
|
||||
if (page.type === 'experience' && page.entryKey) {
|
||||
return (expAccepted.value[page.entryKey] ?? true) ? 'accepted' : 'skipped'
|
||||
}
|
||||
return 'in-progress'
|
||||
}
|
||||
|
||||
// ── Submit ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const submitting = ref(false)
|
||||
const submitError = ref('')
|
||||
|
||||
function emitSubmit() {
|
||||
const decisions: Record<string, unknown> = {}
|
||||
|
||||
if (skillsSection.value) {
|
||||
decisions.skills = { approved_additions: [...approvedSkills.value] }
|
||||
}
|
||||
if (summarySection.value) {
|
||||
decisions.summary = { accepted: summaryAccepted.value }
|
||||
}
|
||||
if (expSection.value) {
|
||||
decisions.experience = {
|
||||
accepted_entries: expSection.value.entries.map(e => ({
|
||||
title: e.title,
|
||||
company: e.company,
|
||||
accepted: expAccepted.value[`${e.title}|${e.company}`] ?? true,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
const gap_framings = [...skillFramings.value.entries()]
|
||||
.filter(([, f]) => f.mode !== 'skip')
|
||||
.map(([skill, f]) => ({ skill, mode: f.mode, context: f.context }))
|
||||
if (gap_framings.length) decisions.gap_framings = gap_framings
|
||||
|
||||
emit('submit', decisions)
|
||||
}
|
||||
|
||||
// ── Escape / focus ────────────────────────────────────────────────────────────
|
||||
|
||||
function handleEscape() {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const cardRef = ref<HTMLElement | null>(null)
|
||||
|
||||
onMounted(() => {
|
||||
cardRef.value?.focus()
|
||||
})
|
||||
|
||||
function trapFocus(e: KeyboardEvent) {
|
||||
if (e.key !== 'Tab' || !cardRef.value) return
|
||||
const focusable = cardRef.value.querySelectorAll<HTMLElement>(
|
||||
'button:not([disabled]), input, textarea, select, [tabindex]:not([tabindex="-1"])'
|
||||
)
|
||||
if (focusable.length === 0) return
|
||||
const first = focusable[0]
|
||||
const last = focusable[focusable.length - 1]
|
||||
if (e.shiftKey && document.activeElement === first) {
|
||||
e.preventDefault()
|
||||
last.focus()
|
||||
} else if (!e.shiftKey && document.activeElement === last) {
|
||||
e.preventDefault()
|
||||
first.focus()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => document.addEventListener('keydown', trapFocus))
|
||||
onUnmounted(() => document.removeEventListener('keydown', trapFocus))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.rrm-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 200;
|
||||
padding: var(--space-4, 1rem);
|
||||
}
|
||||
|
||||
.rrm-card {
|
||||
background: var(--color-surface-raised, #f5f7fc);
|
||||
border-radius: var(--radius-lg, 1rem);
|
||||
box-shadow: var(--shadow-lg, 0 10px 30px rgba(26, 35, 56, 0.12));
|
||||
width: 100%;
|
||||
max-width: 860px;
|
||||
max-height: 90vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.rrm__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-4, 1rem) var(--space-6, 1.5rem);
|
||||
border-bottom: 1px solid var(--color-border, #a8b8d0);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.rrm__title {
|
||||
font-size: var(--font-lg, 1.125rem);
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: var(--color-text, #1a2338);
|
||||
font-family: var(--font-display, Georgia, serif);
|
||||
}
|
||||
|
||||
.rrm__close {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 1.1rem;
|
||||
color: var(--color-text-muted, #4a5c7a);
|
||||
padding: var(--space-1, 0.25rem) var(--space-2, 0.5rem);
|
||||
border-radius: var(--radius-sm, 0.25rem);
|
||||
line-height: 1;
|
||||
transition: color var(--transition, 200ms ease);
|
||||
}
|
||||
.rrm__close:hover { color: var(--color-text, #1a2338); }
|
||||
|
||||
/* Tab bar */
|
||||
.rrm__tabs {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
border-bottom: 1px solid var(--color-border, #a8b8d0);
|
||||
padding: 0 var(--space-4, 1rem);
|
||||
background: var(--color-surface, #eaeff8);
|
||||
}
|
||||
.rrm__tabs::-webkit-scrollbar { display: none; }
|
||||
|
||||
.rrm__tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2, 0.5rem);
|
||||
padding: var(--space-3, 0.75rem) var(--space-3, 0.75rem);
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
color: var(--color-text-muted, #4a5c7a);
|
||||
transition: color var(--transition, 200ms ease), border-color var(--transition, 200ms ease);
|
||||
}
|
||||
|
||||
.rrm__tab--active {
|
||||
color: var(--color-text, #1a2338);
|
||||
border-bottom-color: var(--color-accent, #c4732a);
|
||||
}
|
||||
|
||||
.rrm__tab:hover { color: var(--color-text, #1a2338); }
|
||||
|
||||
.tab__label { font-size: 0.875rem; }
|
||||
|
||||
.tab__dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
background: var(--tab-color, #94a3b8);
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.tab__dot--unvisited { --tab-color: var(--color-text-muted, #4a5c7a); opacity: 0.4; }
|
||||
.tab__dot--in-progress { --tab-color: var(--color-accent, #c4732a); }
|
||||
.tab__dot--accepted { --tab-color: var(--color-success, #3a7a32); }
|
||||
.tab__dot--partial { --tab-color: var(--color-warning, #d4891a); }
|
||||
.tab__dot--skipped { --tab-color: var(--color-border, #a8b8d0); }
|
||||
|
||||
/* Content */
|
||||
.rrm__content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--space-6, 1.5rem);
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.rrm__footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-4, 1rem) var(--space-6, 1.5rem);
|
||||
border-top: 1px solid var(--color-border, #a8b8d0);
|
||||
flex-shrink: 0;
|
||||
background: var(--color-surface, #eaeff8);
|
||||
border-radius: 0 0 var(--radius-lg, 1rem) var(--radius-lg, 1rem);
|
||||
}
|
||||
|
||||
.rrm__page-counter {
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-muted, #4a5c7a);
|
||||
}
|
||||
|
||||
/* Shared button styles (scoped — footer only) */
|
||||
.btn-generate {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2, 0.5rem);
|
||||
padding: var(--space-2, 0.5rem) var(--space-4, 1rem);
|
||||
background: var(--color-accent, #c4732a);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-md, 0.5rem);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background var(--transition, 200ms ease);
|
||||
}
|
||||
.btn-generate:hover:not(:disabled) { background: var(--color-accent-hover, #a85c1f); }
|
||||
.btn-generate:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
|
||||
.btn-secondary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2, 0.5rem);
|
||||
padding: var(--space-2, 0.5rem) var(--space-4, 1rem);
|
||||
background: var(--color-surface-alt, #dde4f0);
|
||||
color: var(--color-text, #1a2338);
|
||||
border: 1px solid var(--color-border, #a8b8d0);
|
||||
border-radius: var(--radius-md, 0.5rem);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background var(--transition, 200ms ease);
|
||||
}
|
||||
.btn-secondary:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.btn-secondary:hover:not(:disabled) { background: var(--color-surface, #eaeff8); }
|
||||
|
||||
/* Mobile */
|
||||
@media (max-width: 600px) {
|
||||
.rrm-card { max-height: 100vh; border-radius: 0; }
|
||||
.rrm-backdrop { padding: 0; align-items: flex-end; }
|
||||
.rrm__content { padding: var(--space-4, 1rem); }
|
||||
}
|
||||
</style>
|
||||
91
web/src/components/__tests__/ResumeReviewModal.test.ts
Normal file
91
web/src/components/__tests__/ResumeReviewModal.test.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import ResumeReviewModal from '../ResumeReviewModal.vue'
|
||||
|
||||
const minimalDraft = {
|
||||
sections: [
|
||||
{ section: 'skills', type: 'skills_diff', added: ['Python', 'FastAPI'], removed: [], kept: ['Git'] },
|
||||
{ section: 'summary', type: 'text_diff', original: 'Old summary.', proposed: 'New summary.' },
|
||||
{
|
||||
section: 'experience', type: 'bullets_diff',
|
||||
entries: [
|
||||
{ title: 'Staff Eng', company: 'Acme', original_bullets: ['Did A'], proposed_bullets: ['Led A'] },
|
||||
{ title: 'SWE', company: 'Beta', original_bullets: ['Did B'], proposed_bullets: ['Led B'] },
|
||||
],
|
||||
},
|
||||
],
|
||||
rewritten_struct: {},
|
||||
}
|
||||
|
||||
const factory = (draft = minimalDraft) =>
|
||||
mount(ResumeReviewModal, {
|
||||
props: { jobId: 1, draft },
|
||||
global: { stubs: { Teleport: true } },
|
||||
})
|
||||
|
||||
describe('page generation', () => {
|
||||
it('generates skills + summary + 2 experience + confirm = 5 pages', () => {
|
||||
const w = factory()
|
||||
expect(w.findAll('[role="tab"]').length).toBe(5)
|
||||
})
|
||||
|
||||
it('confirm tab has no status dot', () => {
|
||||
const w = factory()
|
||||
const tabs = w.findAll('[role="tab"]')
|
||||
const confirmTab = tabs[tabs.length - 1]
|
||||
expect(confirmTab.text()).toContain('Confirm')
|
||||
expect(confirmTab.find('.tab__dot').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('tab status', () => {
|
||||
it('all non-confirm tabs start as unvisited', () => {
|
||||
const w = factory()
|
||||
const dots = w.findAll('.tab__dot')
|
||||
dots.forEach(d => expect(d.classes()).toContain('tab__dot--unvisited'))
|
||||
})
|
||||
|
||||
it('visiting a page marks it in-progress', async () => {
|
||||
const w = factory()
|
||||
await w.find('[role="tab"]').trigger('click')
|
||||
const firstDot = w.findAll('.tab__dot')[0]
|
||||
expect(firstDot.classes()).toContain('tab__dot--in-progress')
|
||||
})
|
||||
})
|
||||
|
||||
describe('navigation', () => {
|
||||
it('next button advances to page 2', async () => {
|
||||
const w = factory()
|
||||
expect(w.find('.rrm__page-counter').text()).toContain('1')
|
||||
await w.find('.rrm__next').trigger('click')
|
||||
expect(w.find('.rrm__page-counter').text()).toContain('2')
|
||||
})
|
||||
|
||||
it('back button on page 1 is disabled', () => {
|
||||
const w = factory()
|
||||
expect(w.find('.rrm__back').attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
it('emits close when X button clicked', async () => {
|
||||
const w = factory()
|
||||
await w.find('.rrm__close').trigger('click')
|
||||
expect(w.emitted('close')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('decisions emit', () => {
|
||||
it('emits submit with correct shape when Preview clicked on confirm page', async () => {
|
||||
const w = factory()
|
||||
// Navigate to confirm page (page 5 = index 4)
|
||||
for (let i = 0; i < 4; i++) {
|
||||
await w.find('.rrm__next').trigger('click')
|
||||
}
|
||||
await w.find('.rrm__preview').trigger('click')
|
||||
const emitted = w.emitted('submit')
|
||||
expect(emitted).toBeTruthy()
|
||||
const decisions = emitted![0][0] as Record<string, unknown>
|
||||
expect(decisions).toHaveProperty('skills')
|
||||
expect(decisions).toHaveProperty('summary')
|
||||
expect(decisions).toHaveProperty('experience')
|
||||
})
|
||||
})
|
||||
77
web/src/components/resume-review/ConfirmPage.vue
Normal file
77
web/src/components/resume-review/ConfirmPage.vue
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
<template>
|
||||
<div class="rp-confirm">
|
||||
<h3 class="rp__heading">Ready to preview?</h3>
|
||||
<p class="rp__hint">Here's a summary of your decisions. Click Preview to assemble the resume.</p>
|
||||
<ul class="rp-confirm__list">
|
||||
<li v-for="page in pages" :key="page.id" class="rp-confirm__item">
|
||||
<span class="tab__dot" :class="`tab__dot--${tabStatus(page)}`" aria-hidden="true" />
|
||||
<span>{{ page.label }}</span>
|
||||
<span class="rp-confirm__status">{{ tabStatus(page) }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-if="error" class="rp__error" role="alert">{{ error }}</p>
|
||||
<div class="rp-confirm__actions">
|
||||
<button class="btn-generate" :disabled="submitting" @click="emit('preview')">
|
||||
<span aria-hidden="true">👁️</span>
|
||||
{{ submitting ? 'Generating…' : 'Preview Full Resume' }}
|
||||
</button>
|
||||
<button class="btn-secondary" @click="emit('rewrite')">
|
||||
<span aria-hidden="true">🔄</span> Rewrite again
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface PageRef {
|
||||
id: string
|
||||
type: string
|
||||
label: string
|
||||
entryKey?: string
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
pages: PageRef[]
|
||||
tabStatus: (page: PageRef) => string
|
||||
submitting: boolean
|
||||
error: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
preview: []
|
||||
rewrite: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.rp-confirm { display: flex; flex-direction: column; gap: var(--space-4, 1rem); }
|
||||
.rp__heading { font-size: var(--font-lg, 1.125rem); font-weight: 600; margin: 0; color: var(--color-text, #1a2338); }
|
||||
.rp__hint { font-size: var(--font-sm, 0.875rem); color: var(--color-text-muted, #4a5c7a); margin: 0; }
|
||||
.rp-confirm__list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: var(--space-2, 0.5rem); }
|
||||
.rp-confirm__item { display: flex; align-items: center; gap: var(--space-3, 0.75rem); padding: var(--space-2, 0.5rem) var(--space-3, 0.75rem); background: var(--color-surface-alt, #dde4f0); border-radius: var(--radius-sm, 0.25rem); font-size: var(--font-sm, 0.875rem); }
|
||||
.rp-confirm__status { margin-left: auto; font-size: var(--font-xs, 0.75rem); color: var(--color-text-muted, #4a5c7a); text-transform: capitalize; }
|
||||
.rp__error { color: var(--color-error, #c0392b); font-size: var(--font-sm, 0.875rem); margin: 0; }
|
||||
.rp-confirm__actions { display: flex; gap: var(--space-3, 0.75rem); flex-wrap: wrap; }
|
||||
.tab__dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; background: var(--tab-color, #94a3b8); }
|
||||
.tab__dot--unvisited { --tab-color: var(--color-text-muted, #94a3b8); }
|
||||
.tab__dot--in-progress { --tab-color: var(--color-accent, #c4732a); }
|
||||
.tab__dot--accepted { --tab-color: var(--color-success, #3a7a32); }
|
||||
.tab__dot--partial { --tab-color: var(--color-warning, #d4891a); }
|
||||
.tab__dot--skipped { --tab-color: var(--color-border, #a8b8d0); }
|
||||
|
||||
.btn-generate {
|
||||
display: inline-flex; align-items: center; gap: var(--space-2, 0.5rem);
|
||||
padding: var(--space-3, 0.75rem) var(--space-4, 1rem);
|
||||
background: var(--color-accent, #c4732a); color: #fff;
|
||||
border: none; border-radius: var(--radius-md, 0.5rem);
|
||||
font-size: var(--font-sm, 0.875rem); font-weight: 600; cursor: pointer;
|
||||
}
|
||||
.btn-generate:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
.btn-secondary {
|
||||
display: inline-flex; align-items: center; gap: var(--space-2, 0.5rem);
|
||||
padding: var(--space-3, 0.75rem) var(--space-4, 1rem);
|
||||
background: var(--color-surface-alt, #dde4f0); color: var(--color-text, #1a2338);
|
||||
border: 1px solid var(--color-border, #a8b8d0); border-radius: var(--radius-md, 0.5rem);
|
||||
font-size: var(--font-sm, 0.875rem); font-weight: 600; cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
56
web/src/components/resume-review/ExperiencePage.vue
Normal file
56
web/src/components/resume-review/ExperiencePage.vue
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
<template>
|
||||
<div class="rp-exp">
|
||||
<h3 class="rp__heading">{{ entry.title }}</h3>
|
||||
<p class="rp__company">{{ entry.company }}</p>
|
||||
<div class="rp__diff-pair">
|
||||
<div class="rp__diff-col">
|
||||
<span class="rp__diff-label">Original</span>
|
||||
<ul class="rp__bullet-list">
|
||||
<li v-for="b in entry.original_bullets" :key="b">{{ b }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="rp__diff-col">
|
||||
<span class="rp__diff-label">Proposed</span>
|
||||
<ul class="rp__bullet-list">
|
||||
<li v-for="b in entry.proposed_bullets" :key="b">{{ b }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<label class="rp__accept-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="accepted"
|
||||
@change="emit('update:accepted', ($event.target as HTMLInputElement).checked)"
|
||||
/>
|
||||
Accept proposed bullets
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
entry: {
|
||||
title: string
|
||||
company: string
|
||||
original_bullets: string[]
|
||||
proposed_bullets: string[]
|
||||
}
|
||||
accepted: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:accepted': [v: boolean]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.rp-exp { display: flex; flex-direction: column; gap: var(--space-4, 1rem); }
|
||||
.rp__heading { font-size: var(--font-lg, 1.125rem); font-weight: 600; margin: 0; color: var(--color-text, #1a2338); }
|
||||
.rp__company { font-size: var(--font-sm, 0.875rem); color: var(--color-text-muted, #4a5c7a); margin: 0; }
|
||||
.rp__diff-pair { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-4, 1rem); }
|
||||
@media (max-width: 600px) { .rp__diff-pair { grid-template-columns: 1fr; } }
|
||||
.rp__diff-col { display: flex; flex-direction: column; gap: var(--space-2, 0.5rem); }
|
||||
.rp__diff-label { font-size: var(--font-xs, 0.75rem); font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: var(--color-text-muted, #4a5c7a); }
|
||||
.rp__bullet-list { margin: 0; padding-left: var(--space-4, 1rem); font-size: var(--font-sm, 0.875rem); line-height: 1.6; background: var(--color-surface-alt, #dde4f0); border-radius: var(--radius-sm, 0.25rem); padding: var(--space-3, 0.75rem) var(--space-3, 0.75rem) var(--space-3, 0.75rem) var(--space-6, 1.5rem); }
|
||||
.rp__accept-toggle { display: inline-flex; align-items: center; gap: var(--space-2, 0.5rem); cursor: pointer; font-size: var(--font-sm, 0.875rem); }
|
||||
</style>
|
||||
78
web/src/components/resume-review/SkillsPage.vue
Normal file
78
web/src/components/resume-review/SkillsPage.vue
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
<template>
|
||||
<div class="rp-skills">
|
||||
<h3 class="rp__heading">Skills</h3>
|
||||
<p class="rp__hint">Check each skill you genuinely have. Uncheck skills you don't and choose how to frame the gap.</p>
|
||||
|
||||
<div class="rp__skill-list" role="group" aria-label="Proposed new skills">
|
||||
<div v-for="skill in section.added" :key="skill" class="rp__skill-group">
|
||||
<label class="rp__skill-chip" :class="{ 'rp__skill-chip--approved': approvedSkills.has(skill) }">
|
||||
<input type="checkbox" :checked="approvedSkills.has(skill)" @change="emit('toggleSkill', skill)" />
|
||||
{{ skill }}
|
||||
</label>
|
||||
<div v-if="!approvedSkills.has(skill)" class="rp__framing">
|
||||
<span class="rp__framing-label">Frame this gap:</span>
|
||||
<label><input type="radio" :name="`framing-${skill}`" value="skip"
|
||||
:checked="(skillFramings.get(skill)?.mode ?? 'skip') === 'skip'"
|
||||
@change="emit('setFramingMode', skill, 'skip')" /> Skip entirely</label>
|
||||
<label><input type="radio" :name="`framing-${skill}`" value="adjacent"
|
||||
:checked="skillFramings.get(skill)?.mode === 'adjacent'"
|
||||
@change="emit('setFramingMode', skill, 'adjacent')" /> Adjacent experience</label>
|
||||
<label><input type="radio" :name="`framing-${skill}`" value="learning"
|
||||
:checked="skillFramings.get(skill)?.mode === 'learning'"
|
||||
@change="emit('setFramingMode', skill, 'learning')" /> Actively learning</label>
|
||||
<textarea v-if="skillFramings.get(skill)?.mode === 'adjacent'"
|
||||
class="rp__framing-context" rows="2"
|
||||
placeholder="Describe related background"
|
||||
:value="skillFramings.get(skill)?.context ?? ''"
|
||||
@input="emit('setFramingContext', skill, ($event.target as HTMLTextAreaElement).value)" />
|
||||
<textarea v-else-if="skillFramings.get(skill)?.mode === 'learning'"
|
||||
class="rp__framing-context" rows="2"
|
||||
placeholder="Describe learning context"
|
||||
:value="skillFramings.get(skill)?.context ?? ''"
|
||||
@input="emit('setFramingContext', skill, ($event.target as HTMLTextAreaElement).value)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="section.removed.length" class="rp__removed">Skills being removed: {{ section.removed.join(', ') }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { SkillsDiff } from '../ResumeReviewModal.vue'
|
||||
|
||||
type GapFraming = { mode: 'skip' | 'adjacent' | 'learning'; context: string }
|
||||
|
||||
defineProps<{
|
||||
section: SkillsDiff
|
||||
approvedSkills: Set<string>
|
||||
skillFramings: Map<string, GapFraming>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
toggleSkill: [skill: string]
|
||||
setFramingMode: [skill: string, mode: 'skip' | 'adjacent' | 'learning']
|
||||
setFramingContext: [skill: string, context: string]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.rp-skills { display: flex; flex-direction: column; gap: var(--space-4, 1rem); }
|
||||
.rp__heading { font-size: var(--font-lg, 1.125rem); font-weight: 600; margin: 0; color: var(--color-text, #1a2338); }
|
||||
.rp__hint { font-size: var(--font-sm, 0.875rem); color: var(--color-text-muted, #4a5c7a); margin: 0; }
|
||||
.rp__skill-list { display: flex; flex-direction: column; gap: var(--space-3, 0.75rem); }
|
||||
.rp__skill-group { display: flex; flex-direction: column; gap: var(--space-2, 0.5rem); }
|
||||
.rp__skill-chip {
|
||||
display: inline-flex; align-items: center; gap: var(--space-2, 0.5rem);
|
||||
padding: var(--space-2, 0.5rem) var(--space-3, 0.75rem);
|
||||
border: 1px solid var(--color-border, #a8b8d0);
|
||||
border-radius: var(--radius-md, 0.5rem);
|
||||
cursor: pointer; font-size: var(--font-sm, 0.875rem);
|
||||
background: var(--color-surface-raised, #f5f7fc);
|
||||
transition: background var(--transition, 200ms ease);
|
||||
}
|
||||
.rp__skill-chip--approved { background: var(--color-primary-light, #e8f2e7); border-color: var(--color-primary, #2d5a27); }
|
||||
.rp__framing { padding: var(--space-2, 0.5rem) var(--space-3, 0.75rem); display: flex; flex-direction: column; gap: var(--space-2, 0.5rem); background: var(--color-surface-alt, #dde4f0); border-radius: var(--radius-sm, 0.25rem); }
|
||||
.rp__framing-label { font-size: var(--font-xs, 0.75rem); font-weight: 600; color: var(--color-text-muted, #4a5c7a); }
|
||||
.rp__framing-context { border: 1px solid var(--color-border, #a8b8d0); border-radius: var(--radius-sm, 0.25rem); padding: var(--space-2, 0.5rem); font-size: var(--font-sm, 0.875rem); resize: vertical; }
|
||||
.rp__removed { font-size: var(--font-sm, 0.875rem); color: var(--color-text-muted, #4a5c7a); font-style: italic; }
|
||||
</style>
|
||||
47
web/src/components/resume-review/SummaryPage.vue
Normal file
47
web/src/components/resume-review/SummaryPage.vue
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
<template>
|
||||
<div class="rp-summary">
|
||||
<h3 class="rp__heading">Summary</h3>
|
||||
<div class="rp__diff-pair">
|
||||
<div class="rp__diff-col">
|
||||
<span class="rp__diff-label" aria-label="Original">Original</span>
|
||||
<p class="rp__diff-text">{{ section.original || '(empty)' }}</p>
|
||||
</div>
|
||||
<div class="rp__diff-col">
|
||||
<span class="rp__diff-label" aria-label="Proposed">Proposed</span>
|
||||
<p class="rp__diff-text">{{ section.proposed }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<label class="rp__accept-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="accepted"
|
||||
@change="emit('update:accepted', ($event.target as HTMLInputElement).checked)"
|
||||
/>
|
||||
Accept proposed summary
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TextDiff } from '../ResumeReviewModal.vue'
|
||||
|
||||
defineProps<{
|
||||
section: TextDiff
|
||||
accepted: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:accepted': [v: boolean]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.rp-summary { display: flex; flex-direction: column; gap: var(--space-4, 1rem); }
|
||||
.rp__heading { font-size: var(--font-lg, 1.125rem); font-weight: 600; margin: 0; color: var(--color-text, #1a2338); }
|
||||
.rp__diff-pair { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-4, 1rem); }
|
||||
@media (max-width: 600px) { .rp__diff-pair { grid-template-columns: 1fr; } }
|
||||
.rp__diff-col { display: flex; flex-direction: column; gap: var(--space-2, 0.5rem); }
|
||||
.rp__diff-label { font-size: var(--font-xs, 0.75rem); font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: var(--color-text-muted, #4a5c7a); }
|
||||
.rp__diff-text { font-size: var(--font-sm, 0.875rem); line-height: 1.6; padding: var(--space-3, 0.75rem); background: var(--color-surface-alt, #dde4f0); border-radius: var(--radius-sm, 0.25rem); margin: 0; }
|
||||
.rp__accept-toggle { display: inline-flex; align-items: center; gap: var(--space-2, 0.5rem); cursor: pointer; font-size: var(--font-sm, 0.875rem); }
|
||||
</style>
|
||||
Loading…
Reference in a new issue