From 70afac3997bc938fabd7677197b2076bf2107829 Mon Sep 17 00:00:00 2001 From: jmj Date: Tue, 30 Jun 2026 12:14:29 +0900 Subject: [PATCH] =?UTF-8?q?fix(ux):=20UX=20=EA=B0=90=EC=82=AC=201=EC=B0=A8?= =?UTF-8?q?=20=EB=B0=B0=EC=B9=98=20=E2=80=94=20=EC=A0=91=EA=B7=BC=EC=84=B1?= =?UTF-8?q?=C2=B7=EC=83=81=ED=83=9C=20=ED=94=BC=EB=93=9C=EB=B0=B1=C2=B7?= =?UTF-8?q?=ED=94=8C=EB=A1=9C=EC=9A=B0=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 회의적 max + 전문가 UX 감사(22건 검증 통과)에서 저effort·고영향 11건 반영. 접근성: - 캡션 대비: fg-subtle #6e7f9f→#586a8e, warning-700 #8a6529→#7e5a22 (WCAG AA). - 키보드 포커스 링 복원: TextArea·세션설정·자소서 입력의 focus:outline-none → focus-visible:ring-2 ring-primary. - 자소서 폼 문항 input/textarea aria-label. 상태/오류 피드백: - 음성 답변 업로드 실패 토스트(useLiveInterview onError) — 녹음 소실 무음 차단. - 공유/PDF 다운로드 실패 토스트(FeedbackReport + useShareFeedback onError). - 자소서 폼: 안정 key(uuid), 빈 답변 무음 drop → 인라인 경고+제출 차단. 플로우/일관성: - 세션 자료 선택기 영문 ENUM → 한국어 라벨(SOURCE_LABEL을 domain/rag로 추출 공유, '자소서' 포함), 빈 상태에 자료 준비 동선 링크. - 면접 모드 4종 설명 추가(ModeSelector). - 모바일 워크스페이스 사이드바: 가로 오버플로 차단 + 모바일 로그아웃 노출 + 활성 라우트 primary 강조. - 하드코딩 hex → sage 토큰, 자소서 제출버튼 shared Button 통일. - 점수 추이 차트 preserveAspectRatio 제거 + aspect-[24/11](점 타원화 방지). build 통과, lint 신규 에러 없음. 프론트 전용. (낙관적 롤백·검증 교차·피드백 섹션 재배치·터치타깃·스켈레톤은 후속 배치로 보류.) Co-Authored-By: Claude Opus 4.8 --- frontend/src/app/styles/tokens.css | 6 +- frontend/src/domain/rag/index.ts | 14 ++++ .../src/features/analysis/ui/DocumentList.tsx | 8 +-- .../cover-letter/ui/CoverLetterForm.tsx | 68 ++++++++++++------- .../features/feedback/model/useFeedback.ts | 6 +- .../features/feedback/ui/FeedbackReport.tsx | 12 +++- .../src/features/history/ui/ScoreTrend.tsx | 3 +- .../interview/model/useLiveInterview.ts | 3 + .../ui/setup/ContextDocumentPicker.tsx | 17 ++++- .../interview/ui/setup/InterviewSetupForm.tsx | 6 +- .../interview/ui/setup/ModeSelector.tsx | 12 ++-- .../InterviewSetup/ui/InterviewSetupPage.tsx | 3 +- frontend/src/shared/ui/TextArea/TextArea.tsx | 2 +- frontend/src/widgets/home-cta/ui/HomeCta.tsx | 2 +- .../widgets/home-services/ui/HomeServices.tsx | 2 +- .../src/widgets/site-footer/ui/SiteFooter.tsx | 2 +- frontend/src/widgets/site-nav/ui/SiteNav.tsx | 4 +- .../workspace-sidebar/ui/WorkspaceSidebar.tsx | 31 +++++++-- 18 files changed, 142 insertions(+), 59 deletions(-) diff --git a/frontend/src/app/styles/tokens.css b/frontend/src/app/styles/tokens.css index 447633ee..bb6ddb40 100644 --- a/frontend/src/app/styles/tokens.css +++ b/frontend/src/app/styles/tokens.css @@ -45,7 +45,9 @@ --color-fg-strong: var(--color-sage-800); /* 본문 위 보조 텍스트가 밝은 표면에서 충분히 읽히도록 한 단계씩 진하게 (WCAG AA). */ --color-fg-muted: var(--color-sage-500); - --color-fg-subtle: var(--color-sage-400); + /* sage-400(#6e7f9f)는 white 4.04:1 로 AA(4.5:1) 미달 — 흐린 텍스트 전용으로 한 톤 진하게. + * #586a8e: white 5.43:1 / surface 4.80:1, fg-muted(#4a5a7e)보다 밝아 위계 유지. */ + --color-fg-subtle: #586a8e; --color-fg-disabled: var(--color-sage-200); --color-fg-on-primary: var(--color-white); @@ -68,7 +70,7 @@ --color-warning-50: #f4e8d4; --color-warning-500: #b88840; - --color-warning-700: #8a6529; + --color-warning-700: #7e5a22; --color-warning: var(--color-warning-500); --color-danger-50: #f4e0d8; diff --git a/frontend/src/domain/rag/index.ts b/frontend/src/domain/rag/index.ts index e69de29b..fd7310da 100644 --- a/frontend/src/domain/rag/index.ts +++ b/frontend/src/domain/rag/index.ts @@ -0,0 +1,14 @@ +// 분석 문서(RAG 소스)의 출처 유형 — 이력서/레포/웹/자소서. features 간 공유(라벨 일관성). +export type DocumentSourceType = 'RESUME' | 'REPOSITORY' | 'WEB' | 'COVER_LETTER' + +export const DOCUMENT_SOURCE_LABEL: Record = { + RESUME: '이력서', + REPOSITORY: '레포지토리', + WEB: '웹', + COVER_LETTER: '자소서', +} + +// 임의 문자열(서버 ENUM)도 안전하게 한국어 라벨로. 미매핑이면 원문 반환. +export function documentSourceLabel(source: string): string { + return DOCUMENT_SOURCE_LABEL[source as DocumentSourceType] ?? source +} diff --git a/frontend/src/features/analysis/ui/DocumentList.tsx b/frontend/src/features/analysis/ui/DocumentList.tsx index 18f11317..5baf8030 100644 --- a/frontend/src/features/analysis/ui/DocumentList.tsx +++ b/frontend/src/features/analysis/ui/DocumentList.tsx @@ -1,6 +1,7 @@ import { useState } from 'react' import { isApiError } from '@/shared/api' import { EmptyState, Modal, StatusBadge, type StatusTone } from '@/shared/ui' +import { DOCUMENT_SOURCE_LABEL as SOURCE_LABEL } from '@/domain/rag' import type { DocumentFilter } from '../api/analysis' import { useDocuments } from '../model/useDocuments' import type { @@ -16,13 +17,6 @@ const STATUS_META: Record = FAILED: { tone: 'danger', label: '분석 실패' }, } -const SOURCE_LABEL: Record = { - RESUME: '이력서', - REPOSITORY: '레포지토리', - WEB: '웹', - COVER_LETTER: '자소서', -} - const TECH_PREVIEW_COUNT = 4 type Props = { diff --git a/frontend/src/features/cover-letter/ui/CoverLetterForm.tsx b/frontend/src/features/cover-letter/ui/CoverLetterForm.tsx index 82a16e24..e8da1147 100644 --- a/frontend/src/features/cover-letter/ui/CoverLetterForm.tsx +++ b/frontend/src/features/cover-letter/ui/CoverLetterForm.tsx @@ -1,13 +1,16 @@ import { useState } from 'react' -import { Spinner } from '@/shared/ui/Spinner' +import { Button } from '@/shared/ui/Button' import { useCreateCoverLetter } from '../model/useCoverLetters' const TITLE_MAX = 200 const ANSWER_MAX = 5000 -type DraftItem = { question: string; answer: string } +type DraftItem = { id: string; question: string; answer: string } -const emptyItem = (): DraftItem => ({ question: '', answer: '' }) +const emptyItem = (): DraftItem => ({ id: crypto.randomUUID(), question: '', answer: '' }) + +const FIELD_FOCUS = + 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--color-primary)] focus-visible:border-primary' // 공채 자소서 문항별 입력 — 질문(문항) + 답변을 여러 개 추가. 텍스트 전용. export function CoverLetterForm() { @@ -15,18 +18,23 @@ export function CoverLetterForm() { const [items, setItems] = useState([emptyItem()]) const create = useCreateCoverLetter() - const updateItem = (idx: number, patch: Partial) => - setItems((prev) => prev.map((it, i) => (i === idx ? { ...it, ...patch } : it))) + const updateItem = (id: string, patch: Partial) => + setItems((prev) => prev.map((it) => (it.id === id ? { ...it, ...patch } : it))) const addItem = () => setItems((prev) => [...prev, emptyItem()]) - const removeItem = (idx: number) => - setItems((prev) => (prev.length <= 1 ? prev : prev.filter((_, i) => i !== idx))) + const removeItem = (id: string) => + setItems((prev) => (prev.length <= 1 ? prev : prev.filter((it) => it.id !== id))) // 답변이 하나라도 채워져 있어야 제출 가능. const hasAnswer = items.some((it) => it.answer.trim().length > 0) + // 질문은 적었는데 답변이 빈 문항 — 무음 drop 대신 경고 + 제출 차단. + const isIncomplete = (it: DraftItem) => + it.question.trim().length > 0 && it.answer.trim().length === 0 + const hasIncomplete = items.some(isIncomplete) const submitting = create.isPending + const canSubmit = hasAnswer && !hasIncomplete && !submitting const handleSubmit = () => { - if (!hasAnswer || submitting) return + if (!canSubmit) return const payloadItems = items .filter((it) => it.answer.trim().length > 0) .map((it) => ({ question: it.question.trim(), answer: it.answer.trim() })) @@ -54,19 +62,23 @@ export function CoverLetterForm() { maxLength={TITLE_MAX} placeholder="예: OO기업 2026 상반기 공채 자소서" onChange={(e) => setTitle(e.target.value)} - className="rounded-lg border border-border bg-surface px-3 py-2 text-body text-fg outline-none focus:border-primary" + className={`rounded-lg border border-border bg-surface px-3 py-2 text-body text-fg ${FIELD_FOCUS}`} />
{items.map((item, idx) => ( -
+
문항 {idx + 1} {items.length > 1 ? (