quiz UX 심화 (radiogroup 격상 + 분야별 통계 시각화 + 전환 모션)
요약Sprint 221~223으로 시각·a11y가 정석화된 CS 퀴즈(/quiz)의 추가 UX를 3개 독립 Wave로 심화한 프론트엔드 전용 스프린트. (Wave A) QuizStart의 분야·난이도·문항 수 3개 단일 선택 그룹을 button+aria-pressed에서 ARIA radiogroup 패턴(role=radiogroup/radio + aria-checked + roving tabindex + 화살표/Home/End 키)으로 격상 — 공유 패턴을 quiz 로컬 헬퍼 PillRadioGroup으로 추출, 기존 active 스타일 유지로 시각 무변경(Sprint 222 명시 이월). (Wave B) 시작 화면 하단에 분야별 최고 정답률을 accent 색 막대로 요약하는 '내 기록' 영역(QuizStats) 추가 — page.tsx가 idle 진입 시 store.getAllBest()를 aggregateCategoryBests(순수 함수)로 집계해 stats prop 전달, 로그인=API/게스트=local 자동 분기, api-store에 in-flight 디듀프 추가. (Wave C) 문항/피드백 전환에 기존 fadeInUp keyframe 재사용 유틸(.animate-fade-in-up)만 추가해 등장 모션 적용(신규 keyframe 0, reduced-motion 전역 무력화 존중). 신규 디자인 토큰 0·신규 components/ui 0·신규 i18n 키 2건(stats.title/scoreAria, ko+en). Critic 3라운드(R1 P2×2·R2 P2×1 → R3 CLEAN): 통계 키 프로토타입 매칭 차단 + 통계 GET을 merge-up 완료 게이트(인증 한정)로 막아 stale 표시·캐시 폴루션 제거. merge≠라이브(별도 운영 이월).
목표
- Sprint 221~223으로 시각·접근성이 완성된 CS 퀴즈(
/quiz)의 추가 UX를 심화한다. - 사용자 확정 범위: 3개 영역 모두 — (A) 분야·난이도 pill의 radiogroup 격상, (B) 분야별 통계 시각화, (C) 전환 모션 심화.
- 프론트엔드 전용 — 데이터/스키마/백엔드 무변경. 신규 디자인 토큰·신규
components/ui0. 회귀 테스트 동반, 커버리지 게이트(83/71) 유지./quiz는 인증 게이트이므로 라이브 검증은 별도 운영 이월(merge ≠ 라이브).
배경
Sprint 222(D1)는 QuizStart의 분야·난이도 pill을 button + aria-pressed로 유지하고 radiogroup 격상은 회귀 위험 최소화 차원에서 후속으로 이월했다. Sprint 223은 공통 progress 래퍼의 a11y 갭을 정석화했다. 본 스프린트는 그 이월 항목(radiogroup)을 소화하고, 사용자 요청으로 통계 시각화·모션을 함께 심화한다.
결정
Wave A — radiogroup 격상
- D-A1: 3개 단일 선택 그룹(분야·난이도·문항 수)을 ARIA radiogroup 패턴으로 격상. 컨테이너
role="radiogroup"+aria-labelledby(useId라벨 연결), 각 pillrole="radio"+aria-checked(aria-pressed제거), roving tabindex(선택 항목만tabIndex=0, 나머지-1), 키보드←/→/↑/↓순환 이동 + 선택,Home/End. - D-A2: 3곳이 공유하는 패턴을 quiz 로컬 헬퍼 컴포넌트
PillRadioGroup(components/quiz/)로 추출 —components/ui/가 아니므로 Palette UI 가이드 트리거 회피. 시각 스타일(accent/semantic)은 소비처가className/style콜백으로 주입 → 시각 무변경, 신규 토큰/keyframe 0.
Wave B — 통계 시각화
- D-B1: 시작 화면(idle) 하단에 분야별 최고 정답률을 accent 색 막대로 요약하는 "내 기록" 영역(
QuizStats, quiz 로컬). 막대는role="progressbar"(aria-valuenow/min/max) + 분야별aria-label, accent 색은var()토큰 인라인. - D-B2: 집계는 순수 함수
aggregateCategoryBests(lib/quiz/stats.ts) —getAllBest()의${category}::${difficulty}복합 키를 분야 단위로 접어 난이도 across 최고 점수 산출, 점수 내림차순 정렬, 미등록/손상 키 무시.page.tsx가 idle 진입 시 조회해QuizStart에statsprop 전달(로그인=API/게스트=local 자동 분기). 기록 없으면 영역 미표시. - D-B3:
api-storefetchAllBest에 in-flight 디듀프 추가 — 시작 화면 통계 GET과getBestGET이 동시 발생해도 서버 요청을 1회로 합친다.
Wave C — 모션 심화
- D-C1: 문항(
QuizQuestion)·채점 피드백(QuizFeedback) 전환에 살짝 위로 슬라이드되는 등장 모션 적용. 기존fadeInUpkeyframe을 재사용하는.animate-fade-in-up유틸 클래스만 추가(신규 keyframe 0).reduced-motion전역 미디어쿼리가 모션을 무력화하므로 접근성 안전.
절제
- 신규 디자인 토큰 0 · 신규
components/ui0(전부 quiz 로컬) · 신규 keyframe 0 · 신규 i18n 키 2건(stats.title/stats.scoreAria, ko+en).
구현
산출물 (Wave 순서)
총 6 atomic commit (start bdd989f):
| Wave | 에이전트 | 커밋 | 내용 |
|---|---|---|---|
| A | Herald | 303b874 | PillRadioGroup(신규) + QuizStart 3그룹 radiogroup 격상 + QuizStart.test radio 시맨틱·roving tabindex·화살표 네비 회귀(13→16) |
| A 후속 | Herald | 933cf9f | PillRadioGroup moveTo focus 옵셔널 체이닝 null 분기 istanbul ignore(ref always attached 관례) — branch 100% |
| B | Scribe | 25a4ea8 | stats.ts(집계 순수 함수)·QuizStats(신규)·api-store in-flight 디듀프·page.tsx idle 통계 조회·QuizStart stats prop·i18n 2키·테스트(stats +6, QuizStats +6, api-store +1, page 인증 경로 통계 GET 반영) |
| C | Herald | 5275bdd | globals.css .animate-fade-in-up(fadeInUp 재사용)·QuizQuestion/QuizFeedback 전환 모션 |
| Critic R1 | Herald | 7b24be5 | [P2×2] stats.ts 분야 키 검사 in→own-key(프로토타입 키 차단)+회귀 테스트 / page.tsx 통계 GET을 merge-up 완료 게이트로 차단 |
| Critic R2 | Herald | 48a8a32 | [P2] 통계 게이트를 인증 사용자 한정(isAuthenticated && !mergeUpDone)으로 재설계 — 게스트→로그인 전환 시 조기 GET 차단(effect 스냅샷 안전) |
변경 상세
PillRadioGroup.tsx(신규, Wave A): 제네릭 단일 선택 pill 그룹.role=radiogroup/radio+aria-checked+ roving tabindex + 화살표/Home/End 키 핸들링. 스타일은 소비처 콜백 주입.QuizStart.tsx(Wave A·B): 3개<fieldset>수동 pill →PillRadioGroup3개로 교체(시각 무변경).statsprop 수신 →<QuizStats>렌더.stats.ts(신규, Wave B):aggregateCategoryBests— 복합 키 → 분야별 최고 점수. own-key 검사로 손상 키 차단(Critic R1).QuizStats.tsx(신규, Wave B): 분야 accent 막대 요약. 빈 배열이면null반환(부모 미표시).api-store.ts(Wave B):fetchAllBestin-flight 디듀프(동시 GET 1회로 합침).page.tsx(Wave B·Critic): idle 통계 조회 effect. 인증 사용자는 merge-up 완료(mergeUpDone) 게이트 후에만 조회(Critic R1/R2) — 게스트는 게이트 미적용으로 전환 안전.- 모션 (Wave C):
.animate-fade-in-up유틸(globals.css) +QuizQuestion/QuizFeedback적용.
검증
- tsc: 0 errors.
- ESLint(실제
next lint바이너리): 0 errors / 487 warnings(Sprint 222 483 → +4, 전부react/forbid-dom-props— accentvar()토큰 인라인 style, DifficultyBadge 등 459건 기존 선례와 동일한 의도된 예외). - jest: 1533 tests PASS(Sprint 223 1514 → +19). quiz 컴포넌트·lib 100/100/100/100(
PillRadioGroup·QuizStats·QuizStart·stats.ts·api-store전부). 글로벌 lines 88.01%/branches 79.31%(게이트 83/71). - next build: ✓.
/[locale]/quiz40.2 kB(Sprint 223 39.4 → +0.8 — QuizStats·PillRadioGroup·모션). - ADR 게이트: index count(sprint 162, --strict) / adr-en coverage(sprint-224 EN, --strict) / adr-links 0 broken / doc-refs no broken.
Critic 교차 리뷰
- 도구: Codex codex-cli 0.130.0,
codex review --base bdd989f -c model=gpt-5.5 - 라운드: 3
R1 — Critical/High 0 · [P2] 2건:
- [P2]
stats.ts:44—categoryStr in QUIZ_CATEGORY_META가 프로토타입 상속 키(toString::ALL·__proto__::ALL)까지 매칭 → 손상 키가 유효 분야로 통과해getQuizCategoryMetaundefined 역참조 위험. →7b24be5own-key 검사로 교체 + 회귀 테스트. - [P2]
page.tsx:113-114— 인증 사용자의 통계 GET이 merge-up POST와 병렬 실행돼 stale 표시·캐시 폴루션. →7b24be5merge-up 완료 게이트로 차단.
R2 — 원 P2 2건 해소 확인 · 새 [P2] 1건:
- [P2]
page.tsx:83-85— 게스트→로그인 전환 시 게이트가 열린 채 남아 조기 GET 가능. →48a8a32게이트를 인증 사용자 한정(isAuthenticated && !mergeUpDone)으로 재설계해 전환 스냅샷 안전 확보.
R3 — CLEAN (P-finding 0): "I did not identify any discrete, introduced issues that would break existing behavior or tests. The new stats aggregation/display and API in-flight deduplication appear consistent with the surrounding quiz storage flow."
종합 판정: ✅ 머지 가능 — 3라운드에 걸쳐 P2 3건(통계 키 프로토타입 차단·merge-up 게이트·게이트 인증 한정화) 전부 수정, Critical/High 0, 최종 CLEAN.