test #2
219
docs/20260806_댓글액션버튼라벨/plan-task.md
Normal file
219
docs/20260806_댓글액션버튼라벨/plan-task.md
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
# 댓글 액션 버튼 표시 라벨 간소화 구현 계획
|
||||||
|
|
||||||
|
| 문서 항목 | 내용 |
|
||||||
|
|---|---|
|
||||||
|
| 상태 | 구현 완료 |
|
||||||
|
| 작성일 | 2026-08-06 |
|
||||||
|
| 요구사항 기준 | [prd.md](./prd.md) |
|
||||||
|
| API 기준 | 변경 불필요 — 기존 Audio·Community 댓글 계약 유지 |
|
||||||
|
| 현재 Phase | Phase 1 완료 |
|
||||||
|
| 현재 활성 Goal | 없음 |
|
||||||
|
|
||||||
|
## 목표
|
||||||
|
|
||||||
|
Audio·Community 댓글 액션은 짧은 동작명만 화면에 표시하고, 스크린 리더에는 대상 댓글 문맥을 유지한다.
|
||||||
|
|
||||||
|
## 현재 상태
|
||||||
|
|
||||||
|
| Phase | 상태 | 완료 Task | 활성/다음 Goal | 차단 또는 남은 조건 |
|
||||||
|
|---:|---|---:|---|---|
|
||||||
|
| 1 | 완료 | `1/1` | 없음 | 없음 |
|
||||||
|
|
||||||
|
- `CommentItem`은 화면에 답글·수정·삭제 동작명만 표시하고 명시적 `aria-label`로 댓글 문맥을 유지한다.
|
||||||
|
- focused test는 `9/9`, Comments unit은 `16/16`, Comments mock E2E는 `3/3` 통과했다.
|
||||||
|
- 1280px, 320px와 200% zoom 상당 환경에서 수평 overflow가 없고 axe critical·serious 위반이 0건이다.
|
||||||
|
|
||||||
|
## 범위
|
||||||
|
|
||||||
|
### 포함
|
||||||
|
|
||||||
|
- `CommentItem`의 `답글 작성`, `답글 보기`, `수정`, `삭제` visible label 간소화
|
||||||
|
- 기존 `댓글 내용 + 동작` accessible name 유지
|
||||||
|
- Audio·Community 원댓글과 답글의 단위·mock E2E 회귀 검증
|
||||||
|
- 320px, 200% zoom, keyboard와 접근성 확인
|
||||||
|
|
||||||
|
### 제외
|
||||||
|
|
||||||
|
- API, model, pagination, 권한, mutation과 error handling 변경
|
||||||
|
- `수정 저장`, `취소`, `답글 등록`과 form·region label 변경
|
||||||
|
- FanTalk, Community 게시글 열기 등 `CommentItem` 밖의 버튼 변경
|
||||||
|
- 새 component, helper, dependency 또는 style 추가
|
||||||
|
|
||||||
|
## 기술적 제약
|
||||||
|
|
||||||
|
- 기술 스택: React 19.2.8, TypeScript 6.0.3, Vitest 4.1.10, Playwright 1.61.1.
|
||||||
|
- 아키텍처: 공유 `CommentItem`의 표시 책임 안에서만 변경한다.
|
||||||
|
- 접근성: visible label 전체가 accessible name에 포함되고 댓글 문맥으로 반복 버튼을 구분해야 한다.
|
||||||
|
- 데이터·보안: 댓글 값을 새로 저장·전송·log하지 않는다.
|
||||||
|
- 호환성: 기존 desktop·tablet·mobile과 최소 320px 지원 범위를 유지한다.
|
||||||
|
- 의존성: 추가하지 않는다.
|
||||||
|
- 구현: RED → GREEN → REFACTOR 순서와 실제 검증 결과를 Progress에 기록한다.
|
||||||
|
|
||||||
|
## Phase 1. 표시 라벨과 accessible name 분리
|
||||||
|
|
||||||
|
**Phase 결과:** 댓글 본문은 카드에서 한 번만 보이고, 액션 버튼에는 동작명만 보이면서 보조기술은 기존 문맥형 이름을 읽는다.
|
||||||
|
|
||||||
|
**선행조건:** `CLB-001~007`, `CLB-DEC-001~002` 확정.
|
||||||
|
|
||||||
|
**Phase 완료 조건:** `P1-T1`과 `P1-GATE` 완료, PRD 성공 기준과 Progress 갱신.
|
||||||
|
|
||||||
|
### 구현 항목
|
||||||
|
|
||||||
|
#### Task 1.1 공유 댓글 액션 라벨 분리
|
||||||
|
|
||||||
|
**Goal 실행 `P1-T1`:** 공유 `CommentItem`의 visible label을 동작명으로 줄이고 기존 contextual accessible name을 보존한다.
|
||||||
|
|
||||||
|
- **시작 조건:** `prd.md`가 구현 기준 확정 상태이고 활성 goal이 없음.
|
||||||
|
- **완료 증거:** RED·GREEN·REFACTOR 체크박스, focused `9/9`, Comments E2E `3/3`, Progress 기록.
|
||||||
|
- **범위 밖:** 다른 component의 버튼 라벨, action 배치·style과 댓글 동작 변경.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Create: 없음
|
||||||
|
- Modify: `src/features/comments/components/CommentItem.tsx`
|
||||||
|
- Test: `src/features/comments/tests/comment-thread.test.tsx`
|
||||||
|
- Test: `tests/e2e/comments.spec.ts`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
|
||||||
|
- Consumes: 기존 `CommentItem` props의 `comment.comment`, `replyActionLabel`, `canEdit`, `canDelete`, `onShowReplies`.
|
||||||
|
- Produces: 기존 props와 callback contract를 바꾸지 않는 짧은 visible label과 `댓글 내용 + 동작` accessible name.
|
||||||
|
|
||||||
|
**TDD 절차:**
|
||||||
|
|
||||||
|
- [x] **RED: 실패 테스트 작성/실패 확인** — `comment-thread.test.tsx`에 답글·수정·삭제 버튼의 `textContent`가 동작명과 정확히 일치하고 role·name은 기존 `댓글 내용 + 동작`으로 조회되는 test 1개를 추가한다. `npm run test:run -- src/features/comments/tests/comment-thread.test.tsx`가 visible label 불일치로 `1 failed, 8 passed`인지 확인한다.
|
||||||
|
- [x] **GREEN: 최소 구현/통과 확인** — `CommentItem.tsx`의 세 버튼에 기존 contextual `aria-label`을 명시하고 children에서는 댓글 내용만 제거한다. 같은 명령이 `exit 0`, `9/9`인지 확인한다.
|
||||||
|
- [x] **REFACTOR: 정리/회귀 확인** — 새 abstraction 없이 중복 변수만 최소화한 뒤 focused test와 `npm run e2e:mock -- tests/e2e/comments.spec.ts --project=chromium`이 각각 `9/9`, `3/3`으로 통과하는지 확인한다.
|
||||||
|
- [x] `comments.spec.ts`의 320px Community 흐름에서 visible label과 contextual accessible name을 함께 확인한다.
|
||||||
|
|
||||||
|
**검증 기준:**
|
||||||
|
|
||||||
|
- **실행 명령:** `npm run test:run -- src/features/comments/tests/comment-thread.test.tsx`; `npm run e2e:mock -- tests/e2e/comments.spec.ts --project=chromium`; `npm run typecheck`; `npm run lint`.
|
||||||
|
- **기대 결과:** 모든 명령 `exit 0`, focused `9/9`, Comments E2E `3/3`, type·lint 오류 0건.
|
||||||
|
- **수동 확인:** 1280px Audio와 320px Community에서 버튼에는 동작명만 보이고, 접근성 트리에는 `댓글 내용 + 동작`이 보이며 수평 overflow가 없다.
|
||||||
|
|
||||||
|
- [x] TDD 단계와 검증 기준의 실제 결과를 Progress에 기록한다.
|
||||||
|
|
||||||
|
### 완료 조건
|
||||||
|
|
||||||
|
- [x] `P1-T1`의 체크박스와 완료 증거가 모두 충족됐다.
|
||||||
|
- [x] `CLB-001~007`이 구현 또는 Gate 증거로 추적된다.
|
||||||
|
- [x] PRD 성공 기준과 현재 상태를 실제 결과로 갱신했다.
|
||||||
|
- [x] 알려진 문서와 구현의 차이가 없다.
|
||||||
|
|
||||||
|
### 검증 방법
|
||||||
|
|
||||||
|
#### Phase 1 Gate
|
||||||
|
|
||||||
|
**Goal 실행 `P1-GATE`:** 짧은 표시 라벨, contextual accessible name과 기존 댓글 동작의 회귀 여부를 최종 판정한다.
|
||||||
|
|
||||||
|
- **시작 조건:** `P1-T1` 완료.
|
||||||
|
- **완료 증거:** 아래 자동·수동 검증 통과와 Progress 기록.
|
||||||
|
- **범위 밖:** test 삭제·완화, 관련 없는 UI·API 수정.
|
||||||
|
|
||||||
|
**실행 명령:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test:run -- src/features/comments
|
||||||
|
npm run e2e:mock -- tests/e2e/comments.spec.ts --project=chromium
|
||||||
|
npm run typecheck
|
||||||
|
npm run lint
|
||||||
|
npm run build:prod
|
||||||
|
git diff --check
|
||||||
|
```
|
||||||
|
|
||||||
|
**기대 결과:** 모든 명령 `exit 0`, Comments unit test `16/16` 이상, Comments E2E `3/3`, type·lint·build 오류 0건, whitespace 오류 0건.
|
||||||
|
|
||||||
|
**수동 확인:**
|
||||||
|
|
||||||
|
- [x] 1280px Audio 상세와 320px Community Sheet에서 답글·수정·삭제 visible label을 확인한다.
|
||||||
|
- [x] 접근성 트리에서 각 버튼의 `댓글 내용 + 동작` 이름과 keyboard focus 순서를 확인한다.
|
||||||
|
- [x] 200% zoom에서 수평 overflow와 가려진 action이 없는지 확인한다.
|
||||||
|
- [x] axe critical·serious 위반이 0건인지 확인한다.
|
||||||
|
|
||||||
|
## 실행 순서와 의존성
|
||||||
|
|
||||||
|
1. `P1-T1`에서 실패 test를 먼저 만들고 최소 UI 변경과 focused·E2E 회귀 검증을 완료한다.
|
||||||
|
2. `P1-GATE`에서 전체 Comments test와 공통 품질·수동 접근성 검증을 완료한다.
|
||||||
|
|
||||||
|
`P1-GATE`는 `P1-T1` 완료 전 시작하지 않는다.
|
||||||
|
|
||||||
|
## 변경 금지 항목
|
||||||
|
|
||||||
|
- 기존 완료 기록과 관련 PRD의 Decision Log를 삭제하거나 덮어쓰지 않는다.
|
||||||
|
- 댓글 endpoint, payload, model과 권한 조건을 변경하지 않는다.
|
||||||
|
- `CommentItem` 밖의 action label을 함께 정리하지 않는다.
|
||||||
|
- 새 dependency, helper 또는 shared abstraction을 추가하지 않는다.
|
||||||
|
- test를 삭제·skip·완화하거나 타입 오류를 우회하지 않는다.
|
||||||
|
|
||||||
|
## 의사결정 및 중단 규칙
|
||||||
|
|
||||||
|
- visible label은 동작명만, accessible name은 `댓글 내용 + 동작`으로 유지한다.
|
||||||
|
- 구현 범위가 바뀌면 PRD Decision Log와 이 계획을 먼저 갱신한다.
|
||||||
|
- 기존 role·name selector가 깨지면 accessible name 유지 요구사항을 우선하고 visible text selector만 보정한다.
|
||||||
|
- 같은 차단 사유가 3회 연속 반복되고 독립 작업도 불가능할 때만 goal을 `blocked`로 갱신한다.
|
||||||
|
- 코드와 일부 test만 완료된 상태에서는 goal을 `complete`로 갱신하지 않는다.
|
||||||
|
|
||||||
|
## Progress
|
||||||
|
|
||||||
|
기존 기록을 삭제하거나 덮어쓰지 않고 실제 실행 결과를 차수별로 누적한다.
|
||||||
|
|
||||||
|
### 계획 작성 — 2026-08-06
|
||||||
|
|
||||||
|
- 상태: 완료
|
||||||
|
- 무엇을: 사용자 선택 A를 `CLB-001~007`, 단일 구현 Task와 Phase Gate로 정규화했다.
|
||||||
|
- 왜: 현재 UI는 댓글 본문을 각 action에 반복하고 화면 표시와 accessible name을 분리하지 않는다.
|
||||||
|
- 어떻게:
|
||||||
|
- `npm run test:run -- src/features/comments/tests/comment-thread.test.tsx` — 성공, exit 0, baseline `8/8`.
|
||||||
|
- 코드·E2E 변경과 수동 UI 검증 — 미실행, 구현 요청 범위가 아님.
|
||||||
|
- 남은 항목: `P1-T1`, `P1-GATE`.
|
||||||
|
- 다음 행동: `P1-T1` RED test 작성.
|
||||||
|
|
||||||
|
### 1차 구현 — 2026-08-06
|
||||||
|
|
||||||
|
- 상태: 완료
|
||||||
|
- 무엇을: `CommentItem`의 답글·수정·삭제 visible label을 동작명으로 줄이고 `댓글 내용 + 동작` accessible name을 명시적으로 보존했다. 320px Community E2E에 visible label 검증을 추가했다.
|
||||||
|
- 왜: 댓글 본문과 액션 영역의 시각적 중복을 제거하면서 보조기술의 대상 식별 문맥을 유지하기 위해서다.
|
||||||
|
- 어떻게:
|
||||||
|
- RED `npm run test:run -- src/features/comments/tests/comment-thread.test.tsx` — 예상 실패, `1 failed, 8 passed`; `답글 보기` 기대값에 기존 `팬 루트 댓글 답글 보기`가 표시됨을 확인했다.
|
||||||
|
- GREEN 같은 명령 — 성공, exit 0, `9/9`.
|
||||||
|
- `npm run test:run -- src/features/comments` — 성공, exit 0, `16/16`.
|
||||||
|
- `npm run e2e:mock -- tests/e2e/comments.spec.ts --project=chromium` — 성공, exit 0, `3/3`.
|
||||||
|
- `npm run typecheck`; `npm run lint`; `npm run build:prod`; `git diff --check` — 모두 성공, exit 0. production build에는 기존 500kB 초과 chunk 경고만 있었고 오류는 없었다.
|
||||||
|
- Playwright 실제 화면 — 1280px Audio와 320px Community에서 visible label과 contextual accessible name 일치, keyboard로 댓글 액션 4개 도달, 수평 overflow 없음.
|
||||||
|
- 200% zoom 상당 검증 — 1280px의 유효 CSS 폭 640px로 확인, 수평 overflow와 가려진 action 없음.
|
||||||
|
- axe — 1280px Audio와 320px Community에서 critical·serious 위반 0건.
|
||||||
|
- 시각 QA — 기능·디자인 시스템 무결성 PASS/HIGH, 시각·CJK 정밀도 PASS/HIGH, 차단 항목 없음.
|
||||||
|
- 명세·코드 품질 review — 각각 무조건 승인, 발견 사항 없음.
|
||||||
|
- 남은 항목: 없음.
|
||||||
|
- 다음 행동: 현재 브랜치 변경 검토 후 통합 방식 결정.
|
||||||
|
|
||||||
|
## Decision Log
|
||||||
|
|
||||||
|
| 날짜 | ID | 상태 | 결정 | 근거 | 영향 Goal/문서 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 2026-08-06 | `CLB-PLAN-DEC-001` | 확정 | 화면 표시만 간소화하고 contextual accessible name을 유지한다. | 사용자 선택 A, `CLB-DEC-001` | `P1-T1`, `P1-GATE`, `prd.md` |
|
||||||
|
| 2026-08-06 | `CLB-PLAN-DEC-002` | 확정 | 공유 `CommentItem` 한 파일에서 최소 변경한다. | 모든 대상 UI가 같은 component를 사용한다. | `P1-T1` |
|
||||||
|
|
||||||
|
## 발견된 문제
|
||||||
|
|
||||||
|
| ID | 심각도 | 상태 | 발견 내용 | 영향 Goal | 처리 계획 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| `CLB-ISSUE-001` | Medium | 완료 | 댓글 본문이 카드 본문과 답글·수정·삭제 버튼마다 반복된다. | `P1-T1` | visible label과 accessible name 분리 완료 |
|
||||||
|
|
||||||
|
## 최종 보고 형식
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
구현 결과: Audio·Community 댓글 버튼은 동작명만 표시하고 보조기술에는 댓글 문맥을 유지한다.
|
||||||
|
|
||||||
|
- 변경: `CommentItem.tsx`의 visible label과 accessible name 분리
|
||||||
|
- 결정: `CLB-DEC-001` — 화면 표시만 간소화
|
||||||
|
- 검증:
|
||||||
|
- `npm run test:run -- src/features/comments` — <실제 결과>
|
||||||
|
- `npm run e2e:mock -- tests/e2e/comments.spec.ts --project=chromium` — <실제 결과>
|
||||||
|
- 1280px·320px·200% zoom·keyboard·접근성 트리 — <실제 결과>
|
||||||
|
- 남은 항목: <없음 또는 구체적인 항목>
|
||||||
|
- 문서: `docs/20260806_댓글액션버튼라벨/{prd.md,plan-task.md}`
|
||||||
|
```
|
||||||
|
|
||||||
|
최종 보고는 실제 실행한 최신 검증 결과와 완료되지 않은 범위를 함께 기록한다.
|
||||||
215
docs/20260806_댓글액션버튼라벨/prd.md
Normal file
215
docs/20260806_댓글액션버튼라벨/prd.md
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
# 댓글 액션 버튼 표시 라벨 간소화 PRD
|
||||||
|
|
||||||
|
## 문서 정보
|
||||||
|
|
||||||
|
| 항목 | 내용 |
|
||||||
|
|---|---|
|
||||||
|
| 문서 상태 | 구현 완료 |
|
||||||
|
| 작성일 | 2026-08-06 |
|
||||||
|
| 최종 수정일 | 2026-08-06 |
|
||||||
|
| 대상 제품 | AI 캐릭터 관리자 웹의 Audio·Community 댓글 관리 |
|
||||||
|
| 작성자·결정권자 | Codex 작성, 사용자 결정 |
|
||||||
|
| 관련 API Contract | 불필요 — 기존 댓글 API와 payload를 변경하지 않음 |
|
||||||
|
| 관련 구현 계획 | [plan-task.md](./plan-task.md) |
|
||||||
|
| 관련 review | 없음 |
|
||||||
|
|
||||||
|
### 요구사항 상태
|
||||||
|
|
||||||
|
| 상태 | 의미 | 구현 처리 |
|
||||||
|
|---|---|---|
|
||||||
|
| 확정 | 제품·기술 결정이 완료된 구현 기준 | `plan-task.md`의 Task와 완료 증거로 추적 |
|
||||||
|
| 미결 | 추가 결정 필요 | 구현 전 결정 |
|
||||||
|
| 외부 의존 | 프론트엔드 밖의 제공 필요 | 제공 전 관련 구현 중단 |
|
||||||
|
| 권고 | 확정 전 추천안 | 수용 기준으로 사용하지 않음 |
|
||||||
|
| 제외 | 이번 범위에서 구현하지 않음 | 포함 조건을 Decision Log에 기록 |
|
||||||
|
|
||||||
|
### 문서 우선순위와 갱신 순서
|
||||||
|
|
||||||
|
1. 표시 라벨과 accessible name 결정은 이 PRD가 소유한다.
|
||||||
|
2. API 변경은 없으므로 별도 API Contract를 만들지 않는다.
|
||||||
|
3. 구현 범위·순서·완료 증거는 `plan-task.md`가 소유한다.
|
||||||
|
4. 결정이 바뀌면 Decision Log → 요구사항 → 계획 순서로 갱신한다.
|
||||||
|
|
||||||
|
## 1. Overview
|
||||||
|
|
||||||
|
댓글 본문과 각 액션 버튼에 반복되는 댓글 내용을 분리한다. 화면에는 `답글 작성`, `답글 보기`, `수정`, `삭제`만 표시하고, 스크린 리더용 accessible name에는 기존처럼 `댓글 내용 + 동작`을 유지한다.
|
||||||
|
|
||||||
|
## 2. Problem Statement
|
||||||
|
|
||||||
|
현재 `CommentItem`은 댓글 본문을 별도로 표시하면서 버튼에도 같은 내용을 반복한다.
|
||||||
|
|
||||||
|
- 긴 댓글일수록 액션 영역이 커지고 동작명을 빠르게 구분하기 어렵다.
|
||||||
|
- 한 댓글의 여러 버튼에 같은 문장이 반복되어 모바일에서 시각적 밀도가 높아진다.
|
||||||
|
- 화면 표시 문구와 accessible name이 결합돼 있어 시각적 간소화와 보조기술 문맥 제공을 독립적으로 조정할 수 없다.
|
||||||
|
|
||||||
|
문제를 해결했다는 판단은 버튼 화면 텍스트가 동작명만 포함하고, 같은 버튼의 accessible name은 대상 댓글과 동작을 함께 식별할 때로 한다.
|
||||||
|
|
||||||
|
## 3. Goals
|
||||||
|
|
||||||
|
### 3.1 제품 목표
|
||||||
|
|
||||||
|
- 사용자가 댓글 본문과 액션을 빠르게 구분한다.
|
||||||
|
- Audio·Community의 공유 댓글 UI에 같은 규칙을 적용한다.
|
||||||
|
- 기존 조회·작성·수정·삭제 동작과 권한을 유지한다.
|
||||||
|
|
||||||
|
### 3.2 UX 목표
|
||||||
|
|
||||||
|
- 버튼 화면 텍스트를 `답글 작성`, `답글 보기`, `수정`, `삭제`로 제한한다.
|
||||||
|
- 스크린 리더가 버튼만 탐색해도 대상 댓글과 동작을 구분하게 한다.
|
||||||
|
- 320px 화면에서 긴 댓글이 액션 버튼마다 반복되지 않게 한다.
|
||||||
|
|
||||||
|
## 4. Non-Goals
|
||||||
|
|
||||||
|
- 댓글 API, DTO, pagination, mutation 또는 권한 정책 변경
|
||||||
|
- 댓글 본문, 작성 form, 답글 region의 label 변경
|
||||||
|
- FanTalk 답변 버튼과 Community 게시글 열기 버튼 변경
|
||||||
|
- 액션 버튼의 배치, 색상, 크기, 확인 dialog 또는 삭제 복원 기능 변경
|
||||||
|
|
||||||
|
Non-Goal을 변경하려면 Decision Log와 `plan-task.md`를 먼저 갱신한다.
|
||||||
|
|
||||||
|
## 5. Target Users and Permissions
|
||||||
|
|
||||||
|
| 사용자 | 목표 | 주요 작업 | 사용 환경 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| ADMIN | 댓글별 액션을 빠르게 구분 | 답글 열기·작성, AI 댓글 수정, 댓글 삭제 | desktop, tablet, mobile |
|
||||||
|
| 읽기 전용 ADMIN | 댓글과 기존 답글 조회 | 답글 보기 | desktop, tablet, mobile |
|
||||||
|
|
||||||
|
- 기존 `canMutate`, 작성자 판정과 비활성 workspace 정책을 그대로 사용한다.
|
||||||
|
- 라벨 변경으로 숨겨진 액션이 새로 노출되거나 기존 액션이 제거되지 않는다.
|
||||||
|
|
||||||
|
## 6. 핵심 사용자 흐름
|
||||||
|
|
||||||
|
1. 사용자가 Audio 상세 또는 Community 게시글 Sheet의 댓글 목록을 연다.
|
||||||
|
2. 댓글 본문은 카드 본문에서 한 번 읽고, 액션 영역에서는 짧은 동작명을 확인한다.
|
||||||
|
3. 사용자가 `답글 작성`·`답글 보기`·`수정`·`삭제` 중 허용된 버튼을 실행한다.
|
||||||
|
4. 스크린 리더는 각 버튼을 `댓글 내용 + 동작`으로 안내한다.
|
||||||
|
5. 기존 form, network request와 성공·실패 처리가 그대로 동작한다.
|
||||||
|
|
||||||
|
## 7. 정보 구조와 라우팅
|
||||||
|
|
||||||
|
```text
|
||||||
|
/ai-characters/:characterId/audio-contents/:contentId
|
||||||
|
/ai-characters/:characterId/community-posts
|
||||||
|
└─ 게시글 Sheet의 댓글 관리
|
||||||
|
```
|
||||||
|
|
||||||
|
- 새 route와 URL 상태를 추가하지 않는다.
|
||||||
|
- 두 진입점은 공유 `CommentThread`와 `CommentItem`을 사용한다.
|
||||||
|
|
||||||
|
## 8. 기능 요구사항
|
||||||
|
|
||||||
|
### 8.1 표시 라벨과 accessible name
|
||||||
|
|
||||||
|
| ID | 상태 | 요구사항 | 수용 기준 | 계약/Goal 연결 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `CLB-001` | 확정 | 답글 액션의 화면 텍스트에는 `답글 작성` 또는 `답글 보기`만 표시한다. | 원댓글의 답글 버튼 `textContent`가 전달된 `replyActionLabel`과 정확히 일치한다. | contract 불필요, `P1-T1` |
|
||||||
|
| `CLB-002` | 확정 | 수정 액션의 화면 텍스트에는 `수정`만 표시한다. | 수정 가능한 원댓글·답글 버튼의 `textContent`가 `수정`과 정확히 일치한다. | contract 불필요, `P1-T1` |
|
||||||
|
| `CLB-003` | 확정 | 삭제 액션의 화면 텍스트에는 `삭제`만 표시한다. | 삭제 가능한 원댓글·답글 버튼의 `textContent`가 `삭제`와 정확히 일치한다. | contract 불필요, `P1-T1` |
|
||||||
|
| `CLB-004` | 확정 | 각 액션 버튼의 accessible name에는 댓글 내용과 화면 동작명을 함께 유지한다. | role·name 조회에서 `댓글 내용 + 답글 작성/답글 보기/수정/삭제`로 각 버튼을 찾을 수 있고, visible label도 accessible name에 포함된다. | contract 불필요, `P1-T1` |
|
||||||
|
| `CLB-005` | 확정 | 공유 `CommentItem`을 사용하는 Audio·Community 원댓글과 답글에 동일한 규칙을 적용한다. | 두 target의 기존 단위·E2E 흐름이 통과하며 reply row에는 기존처럼 답글 액션이 없다. | contract 불필요, `P1-GATE` |
|
||||||
|
| `CLB-006` | 확정 | 라벨 외 동작·권한·상태는 변경하지 않는다. | 기존 GET·POST·PUT·DELETE 경로와 payload, disabled 조건, form 초기화·오류 복구 test가 통과한다. | 기존 댓글 계약 재사용, `P1-GATE` |
|
||||||
|
|
||||||
|
### 8.2 공통 파일·데이터 정책
|
||||||
|
|
||||||
|
| ID | 상태 | 요구사항 | 수용 기준 | 계약/Goal 연결 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `CLB-007` | 확정 | 댓글 원문은 가공·축약하지 않고 현재 값으로 accessible name을 구성한다. | 별도 상태·helper·dependency 없이 `CommentItem`의 `comment.comment`와 동작명을 사용한다. | contract 불필요, `P1-T1` |
|
||||||
|
|
||||||
|
## 9. 반응형 기능 범위
|
||||||
|
|
||||||
|
| 기능 | Desktop | Tablet | Mobile | 비고 |
|
||||||
|
|---|---:|---:|---:|---|
|
||||||
|
| 짧은 화면 표시 라벨 | 지원 | 지원 | 지원 | 공유 component 적용 |
|
||||||
|
| 문맥을 포함한 accessible name | 지원 | 지원 | 지원 | viewport와 무관 |
|
||||||
|
| 기존 댓글 액션 | 유지 | 유지 | 유지 | 권한·상태 변경 없음 |
|
||||||
|
|
||||||
|
- 최소 320px에서 수평 overflow 없이 액션을 사용할 수 있어야 한다.
|
||||||
|
- 200% zoom에서도 댓글 본문과 액션을 구분할 수 있어야 한다.
|
||||||
|
|
||||||
|
## 10. UI/UX Expectations
|
||||||
|
|
||||||
|
### 10.1 디자인과 component 원칙
|
||||||
|
|
||||||
|
- 댓글 본문은 카드 본문이, 동작명은 버튼이 각각 한 번만 시각적으로 표시한다.
|
||||||
|
- 기존 버튼 style, semantic color와 최소 높이 규칙을 유지한다.
|
||||||
|
- 새 component나 공통 helper를 만들지 않고 공유 `CommentItem`에서 처리한다.
|
||||||
|
|
||||||
|
### 10.2 화면 상태
|
||||||
|
|
||||||
|
- pending 중 disabled 처리와 loading status를 유지한다.
|
||||||
|
- 수정 mode의 `수정 저장`, `취소` 문구는 대상이 아니므로 유지한다.
|
||||||
|
- 오류·성공·empty 상태를 변경하지 않는다.
|
||||||
|
|
||||||
|
### 10.3 접근성
|
||||||
|
|
||||||
|
- visible label과 accessible name을 분리하되 visible label 전체가 accessible name에 포함돼야 한다.
|
||||||
|
- 동일 동작 버튼을 보조기술로 단독 탐색해도 댓글 내용으로 대상을 구분할 수 있어야 한다.
|
||||||
|
- button semantic, keyboard focus 순서와 focus 표시를 유지한다.
|
||||||
|
- axe critical·serious 위반 0건을 유지한다.
|
||||||
|
|
||||||
|
## 11. API 계약
|
||||||
|
|
||||||
|
### 11.1 공통 규칙
|
||||||
|
|
||||||
|
- 이번 변경은 표시 계층에만 적용한다.
|
||||||
|
- 기존 Audio·Community 댓글 endpoint, request/response, 오류와 pagination 계약을 변경하지 않는다.
|
||||||
|
|
||||||
|
### 11.2 Endpoint 추적
|
||||||
|
|
||||||
|
| 요구사항 | Method | Path | 계약 상태 | API Contract | 소유 Goal |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| `CLB-001~007` | 해당 없음 | 해당 없음 | 변경 불필요 | 기존 댓글 계약 유지 | `P1-T1`, `P1-GATE` |
|
||||||
|
|
||||||
|
### 11.3 외부 제공 대기 계약
|
||||||
|
|
||||||
|
없음.
|
||||||
|
|
||||||
|
## 12. 보안과 데이터 취급
|
||||||
|
|
||||||
|
- 댓글 내용은 현재처럼 DOM과 접근성 트리에 표시되며 새 저장·전송·log를 추가하지 않는다.
|
||||||
|
- 인증, 리소스 ownership과 mutation 권한 정책을 변경하지 않는다.
|
||||||
|
- 라벨을 analytics 또는 외부 서비스로 전송하지 않는다.
|
||||||
|
|
||||||
|
## 13. 성능과 품질 요구사항
|
||||||
|
|
||||||
|
- 새 dependency, state, effect 또는 network request를 추가하지 않는다.
|
||||||
|
- React 19.2.8, TypeScript 6.0.3과 기존 지원 browser를 유지한다.
|
||||||
|
- focused unit test, Comments mock E2E, typecheck, lint와 production build를 Gate로 사용한다.
|
||||||
|
- backend와 mock 계약 변경이 없으므로 별도 preview mode를 추가하지 않는다.
|
||||||
|
|
||||||
|
## 14. 성공 기준
|
||||||
|
|
||||||
|
### 14.1 기능 수용 기준
|
||||||
|
|
||||||
|
- [x] Audio·Community 댓글의 화면 액션은 짧은 동작명만 표시한다. (`CLB-001~003`)
|
||||||
|
- [x] 기존 답글·수정·삭제 동작과 권한이 유지된다. (`CLB-005~006`)
|
||||||
|
|
||||||
|
### 14.2 UI/UX 수용 기준
|
||||||
|
|
||||||
|
- [x] 모든 대상 버튼의 visible label과 contextual accessible name이 분리된다. (`CLB-004`)
|
||||||
|
- [x] 320px와 200% zoom에서 액션 사용과 본문 구분에 문제가 없다.
|
||||||
|
- [x] keyboard 흐름과 axe critical·serious 0건을 유지한다.
|
||||||
|
|
||||||
|
### 14.3 추적성 완료 기준
|
||||||
|
|
||||||
|
- [x] `CLB-001~007`이 `P1-T1` 또는 `P1-GATE` 완료 증거로 연결된다.
|
||||||
|
- [x] API Contract가 불필요한 표시 계층 변경임을 기록했다.
|
||||||
|
- [x] 미결·외부 의존 항목이 없다.
|
||||||
|
|
||||||
|
## 15. Open Questions
|
||||||
|
|
||||||
|
없음. 사용자는 화면 표시에서만 댓글 내용을 제거하고 accessible name에는 댓글 문맥을 유지하는 A안을 선택했다.
|
||||||
|
|
||||||
|
## 16. 요구사항 추적표
|
||||||
|
|
||||||
|
| 요구사항 범위 | API Contract | 계획 Phase | Goal | 자동 검증 | 수동 검증 |
|
||||||
|
|---|---|---:|---|---|---|
|
||||||
|
| `CLB-001~004`, `CLB-007` | 불필요 | 1 | `P1-T1` | `comment-thread.test.tsx` | 화면 텍스트와 접근성 트리 비교 |
|
||||||
|
| `CLB-005~006` | 기존 계약 유지 | 1 | `P1-GATE` | Comments unit·E2E, typecheck, lint, build | 1280px·320px·200% zoom·keyboard |
|
||||||
|
|
||||||
|
## 17. Decision Log
|
||||||
|
|
||||||
|
| 날짜 | ID | 상태 | 결정 | 근거 | 영향 요구사항·계약·Goal |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 2026-08-06 | `CLB-DEC-001` | 확정 | 화면 버튼에서는 댓글 내용을 제거하고 accessible name에는 `댓글 내용 + 동작`을 유지한다. | 사용자 선택 A. 시각적 중복을 줄이면서 보조기술의 대상 식별을 보존한다. | `CLB-001~004`, `P1-T1` |
|
||||||
|
| 2026-08-06 | `CLB-DEC-002` | 확정 | 공유 `CommentItem` 한 곳에서 Audio·Community 원댓글과 답글의 표시를 변경한다. | 모든 대상 호출이 같은 component를 사용하며 API·상태 변경이 필요 없다. | `CLB-005~007`, `P1-T1`, `P1-GATE` |
|
||||||
@@ -25,9 +25,9 @@ export function CommentItem({ canDelete = true, canEdit, comment, isSaving, onDe
|
|||||||
<p className="text-xs text-muted-foreground">{formatSeoulDateTime(comment.date)}{comment.isSecret ? " · 비밀" : ""}</p>
|
<p className="text-xs text-muted-foreground">{formatSeoulDateTime(comment.date)}{comment.isSecret ? " · 비밀" : ""}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{replyActionLabel !== undefined && onShowReplies !== undefined ? <button className="rounded-md border border-input bg-card px-3 py-2 text-sm font-semibold hover:bg-accent disabled:opacity-60" disabled={isSaving} onClick={onShowReplies} type="button">{label} {replyActionLabel}</button> : null}
|
{replyActionLabel !== undefined && onShowReplies !== undefined ? <button aria-label={`${label} ${replyActionLabel}`} className="rounded-md border border-input bg-card px-3 py-2 text-sm font-semibold hover:bg-accent disabled:opacity-60" disabled={isSaving} onClick={onShowReplies} type="button">{replyActionLabel}</button> : null}
|
||||||
{canEdit ? <button className="rounded-md border border-input bg-card px-3 py-2 text-sm font-semibold hover:bg-accent disabled:opacity-60" disabled={isSaving} onClick={() => setIsEditing(true)} type="button">{label} 수정</button> : null}
|
{canEdit ? <button aria-label={`${label} 수정`} className="rounded-md border border-input bg-card px-3 py-2 text-sm font-semibold hover:bg-accent disabled:opacity-60" disabled={isSaving} onClick={() => setIsEditing(true)} type="button">수정</button> : null}
|
||||||
{canDelete ? <button className="rounded-md border border-destructive bg-card px-3 py-2 text-sm font-semibold text-destructive hover:bg-accent disabled:opacity-60" disabled={isSaving} onClick={onDelete} type="button">{label} 삭제</button> : null}
|
{canDelete ? <button aria-label={`${label} 삭제`} className="rounded-md border border-destructive bg-card px-3 py-2 text-sm font-semibold text-destructive hover:bg-accent disabled:opacity-60" disabled={isSaving} onClick={onDelete} type="button">삭제</button> : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{isEditing ? (
|
{isEditing ? (
|
||||||
|
|||||||
@@ -154,6 +154,32 @@ test("CommentThread opens the existing reply form for a first Audio reply", asyn
|
|||||||
expect(within(repliesRegion).queryByRole("button", { name: /답글 작성/ })).not.toBeInTheDocument();
|
expect(within(repliesRegion).queryByRole("button", { name: /답글 작성/ })).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("CommentThread keeps contextual action names while showing concise button labels", async () => {
|
||||||
|
// Given
|
||||||
|
const requests: CapturedRequest[] = [];
|
||||||
|
render(<CommentThread apiClient={createThreadClient(requests)} target={target} />);
|
||||||
|
const showRepliesButton = await screen.findByRole("button", { name: "팬 루트 댓글 답글 보기" });
|
||||||
|
const writeReplyButton = screen.getByRole("button", { name: "AI 루트 댓글 답글 작성" });
|
||||||
|
const rootEditButton = screen.getByRole("button", { name: "AI 루트 댓글 수정" });
|
||||||
|
const rootDeleteButton = screen.getByRole("button", { name: "팬 루트 댓글 삭제" });
|
||||||
|
|
||||||
|
// When
|
||||||
|
fireEvent.click(showRepliesButton);
|
||||||
|
const repliesRegion = await screen.findByRole("region", { name: "팬 루트 댓글 답글" });
|
||||||
|
const replyArticle = within(repliesRegion).getByRole("article", { name: "루나 댓글" });
|
||||||
|
const replyEditButton = within(replyArticle).getByRole("button", { name: "AI 답글 수정" });
|
||||||
|
const replyDeleteButton = within(replyArticle).getByRole("button", { name: "AI 답글 삭제" });
|
||||||
|
|
||||||
|
// Then
|
||||||
|
expect(within(replyArticle).queryByRole("button", { name: /답글 (작성|보기)/ })).not.toBeInTheDocument();
|
||||||
|
expect(showRepliesButton.textContent).toBe("답글 보기");
|
||||||
|
expect(writeReplyButton.textContent).toBe("답글 작성");
|
||||||
|
expect(rootEditButton.textContent).toBe("수정");
|
||||||
|
expect(rootDeleteButton.textContent).toBe("삭제");
|
||||||
|
expect(replyEditButton.textContent).toBe("수정");
|
||||||
|
expect(replyDeleteButton.textContent).toBe("삭제");
|
||||||
|
});
|
||||||
|
|
||||||
test("CommentThread creates a first Community reply and keeps read-only Community roots closed", async () => {
|
test("CommentThread creates a first Community reply and keeps read-only Community roots closed", async () => {
|
||||||
// Given
|
// Given
|
||||||
const communityRequests: CapturedRequest[] = [];
|
const communityRequests: CapturedRequest[] = [];
|
||||||
|
|||||||
@@ -106,14 +106,16 @@ test("Community sheet comments keep two-level controls usable at 320px", async (
|
|||||||
await expect(dialog.getByRole("heading", { name: "댓글 관리" })).toBeVisible();
|
await expect(dialog.getByRole("heading", { name: "댓글 관리" })).toBeVisible();
|
||||||
await expect(dialog.getByLabel("새 댓글")).toBeInViewport();
|
await expect(dialog.getByLabel("새 댓글")).toBeInViewport();
|
||||||
await expect(dialog.getByText("커뮤니티 팬 루트 댓글", { exact: true })).toBeVisible();
|
await expect(dialog.getByText("커뮤니티 팬 루트 댓글", { exact: true })).toBeVisible();
|
||||||
await expect(dialog.getByRole("button", { name: "커뮤니티 AI 루트 댓글 답글 작성" })).toBeVisible();
|
const writeReply = dialog.getByRole("button", { name: "커뮤니티 AI 루트 댓글 답글 작성" });
|
||||||
|
await expect(writeReply).toBeVisible();
|
||||||
|
await expect(writeReply).toHaveText("답글 작성");
|
||||||
await expectNoHorizontalOverflow(page);
|
await expectNoHorizontalOverflow(page);
|
||||||
|
|
||||||
// When
|
// When
|
||||||
await dialog.getByLabel("새 댓글").fill("커뮤니티 루트 생성");
|
await dialog.getByLabel("새 댓글").fill("커뮤니티 루트 생성");
|
||||||
await dialog.getByRole("button", { name: "댓글 등록" }).click();
|
await dialog.getByRole("button", { name: "댓글 등록" }).click();
|
||||||
await expect(dialog.getByText("커뮤니티 루트 생성", { exact: true })).toBeVisible();
|
await expect(dialog.getByText("커뮤니티 루트 생성", { exact: true })).toBeVisible();
|
||||||
await dialog.getByRole("button", { name: "커뮤니티 AI 루트 댓글 답글 작성" }).click();
|
await writeReply.click();
|
||||||
const emptyReplies = dialog.getByRole("region", { name: "커뮤니티 AI 루트 댓글 답글" });
|
const emptyReplies = dialog.getByRole("region", { name: "커뮤니티 AI 루트 댓글 답글" });
|
||||||
const emptyReplyInput = emptyReplies.getByLabel("커뮤니티 AI 루트 댓글에 답글");
|
const emptyReplyInput = emptyReplies.getByLabel("커뮤니티 AI 루트 댓글에 답글");
|
||||||
await expect(emptyReplyInput).toBeVisible();
|
await expect(emptyReplyInput).toBeVisible();
|
||||||
@@ -143,6 +145,7 @@ test("Community sheet comments keep two-level controls usable at 320px", async (
|
|||||||
await expect(emptyReplies.getByRole("button", { name: /^(커뮤니티 첫 답글 생성|커뮤니티 두 번째 답글 생성) 답글 (작성|보기)$/ })).toHaveCount(0);
|
await expect(emptyReplies.getByRole("button", { name: /^(커뮤니티 첫 답글 생성|커뮤니티 두 번째 답글 생성) 답글 (작성|보기)$/ })).toHaveCount(0);
|
||||||
const showReplies = dialog.getByRole("button", { name: "커뮤니티 팬 루트 댓글 답글 보기" });
|
const showReplies = dialog.getByRole("button", { name: "커뮤니티 팬 루트 댓글 답글 보기" });
|
||||||
await expect(showReplies).toBeEnabled();
|
await expect(showReplies).toBeEnabled();
|
||||||
|
await expect(showReplies).toHaveText("답글 보기");
|
||||||
await showReplies.click();
|
await showReplies.click();
|
||||||
const replies = dialog.getByRole("region", { name: "커뮤니티 팬 루트 댓글 답글" });
|
const replies = dialog.getByRole("region", { name: "커뮤니티 팬 루트 댓글 답글" });
|
||||||
await expect(replies).toBeVisible();
|
await expect(replies).toBeVisible();
|
||||||
@@ -150,11 +153,15 @@ test("Community sheet comments keep two-level controls usable at 320px", async (
|
|||||||
await expect(replies.getByRole("button", { name: /^커뮤니티 AI 답글 답글 (작성|보기)$/ })).toHaveCount(0);
|
await expect(replies.getByRole("button", { name: /^커뮤니티 AI 답글 답글 (작성|보기)$/ })).toHaveCount(0);
|
||||||
await replies.getByLabel("커뮤니티 팬 루트 댓글에 답글").fill("커뮤니티 답글 생성");
|
await replies.getByLabel("커뮤니티 팬 루트 댓글에 답글").fill("커뮤니티 답글 생성");
|
||||||
await replies.getByRole("button", { name: "답글 등록" }).click();
|
await replies.getByRole("button", { name: "답글 등록" }).click();
|
||||||
await replies.getByRole("button", { name: "커뮤니티 AI 답글 수정" }).click();
|
const editReply = replies.getByRole("button", { name: "커뮤니티 AI 답글 수정" });
|
||||||
|
await expect(editReply).toHaveText("수정");
|
||||||
|
await editReply.click();
|
||||||
await replies.getByLabel("댓글 수정 내용").fill("커뮤니티 AI 답글 수정");
|
await replies.getByLabel("댓글 수정 내용").fill("커뮤니티 AI 답글 수정");
|
||||||
await replies.getByRole("button", { name: "수정 저장" }).click();
|
await replies.getByRole("button", { name: "수정 저장" }).click();
|
||||||
await expect.poll(() => replies.getByRole("button", { name: "커뮤니티 팬 답글 삭제" }).isEnabled()).toBe(true);
|
const deleteReply = replies.getByRole("button", { name: "커뮤니티 팬 답글 삭제" });
|
||||||
await replies.getByRole("button", { name: "커뮤니티 팬 답글 삭제" }).click();
|
await expect(deleteReply).toHaveText("삭제");
|
||||||
|
await expect.poll(() => deleteReply.isEnabled()).toBe(true);
|
||||||
|
await deleteReply.click();
|
||||||
|
|
||||||
// Then
|
// Then
|
||||||
await expect(replies.getByText("커뮤니티 팬 답글", { exact: true })).toBeHidden();
|
await expect(replies.getByText("커뮤니티 팬 답글", { exact: true })).toBeHidden();
|
||||||
|
|||||||
Reference in New Issue
Block a user