feat: add ResumeReviewModal with paged tab navigation and color-coded status

This commit is contained in:
pyr0ball 2026-04-12 11:18:01 -07:00
parent 1253ef15a0
commit a3aaed0e0c
6 changed files with 899 additions and 0 deletions

View 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>

View 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')
})
})

View 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>

View 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>

View 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>

View 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>