프론트엔드
코드 비교 뷰어 구현
React와 TypeScript로 고정된 세 파일의 변경 전후 코드를 보여 주는 코드 비교 뷰어를 구현한다. 파일·버전 선택, 현재 코드 복사, 복사 결과 알림, 키보드 접근성, 375px 반응형과 테스트를 갖춰야 한다.
Task
성공 조건
- 세 파일 경로를 실제 button으로 표시하고, 첫 파일과 변경 전 코드를 초기 상태로 보여 주며 선택 상태를 aria-pressed로 전달한다.
- 선택한 파일의 경로와 선택한 버전의 전체 코드만 pre > code로 표시하고, pre의 aria-label에 파일 경로·버전을 연결한다.
- 코드 복사 버튼이 현재 코드 문자열을 navigator.clipboard.writeText로 복사하고, 성공 시 복사됨·실패 시 복사하지 못했습니다.를 aria-live=polite로 알린다.
- 파일 선택 영역은 aria-label=변경 파일인 button 그룹이며, Tab·Enter·Space로 모든 버튼을 조작할 수 있다.
- 375px에서 탭은 줄바꿈되고 코드 영역만 가로 스크롤되며, 768px 이상에서는 읽기 좋은 2열 또는 충분한 여백의 단일 열로 배치된다.
- 초기 상태, 파일·버전 전환, 현재 코드 복사와 성공 알림을 테스트한다. 구현과 테스트는 4~8개 파일 안에 둔다.
Common conditions
핵심 조건
- 고정된 Profile, formatDate, SubmitButton 세 파일의 변경 전후 문자열만 사용하는 React 코드 뷰어를 만든다.
- 파일과 변경 전후를 실제 버튼·aria-pressed로 전환하고, 현재 코드만 Clipboard API로 복사한다.
- 복사 실패 live region, 키보드 조작, 375px 가로 넘침 방지, Vitest 기반 핵심 동작 테스트를 포함한다.
Model results
모델별 결과
데이터·화면·CSS Modules·UI 테스트·Vitest 설정을 5개 파일에 나눠, 파일·버전 전환과 복사 성공·실패·키보드 조작까지 검증한 접근성 중심 구현.
코드 비교 뷰어 구현
고정 데이터와 타입을 changedFiles.ts로 분리하고, 화면과 상태는 App.tsx 하나에 뒀습니다. 상태는 선택한 파일, 버전, 복사 상태 세 개뿐이며 파일·버전이 바뀌면 복사 상태를 초기화합니다. 복사 결과는 성공·실패 모두 같은 role="status" aria-live="polite" 문단 하나로 알려서 스크린 리더가 두 결과를 같은 위치에서 듣게 했습니다. 스타일은 CSS Modules 하나만 사용하고 index.css는 건드리지 않았습니다. 총 5개 파일(구현 3 + 테스트 2)입니다.
src/changedFiles.ts
export type FileId = "profile" | "format-date" | "submit-button";
export type Version = "before" | "after";
export type ChangedFile = {
id: FileId;
path: string;
before: string;
after: string;
};
export const VERSION_LABELS: Record<Version, string> = {
before: "변경 전",
after: "변경 후",
};
export const changedFiles: ChangedFile[] = [
{
id: "profile",
path: "src/components/Profile.tsx",
before: `export function Profile({ name }: { name: string }) {
return <div>{name}</div>;
}`,
after: `type ProfileProps = {
name: string;
role: string;
};
export function Profile({ name, role }: ProfileProps) {
return <p>{name} · {role}</p>;
}`,
},
{
id: "format-date",
path: "src/lib/formatDate.ts",
before: `export function formatDate(value: string) {
return new Date(value).toLocaleDateString();
}`,
after: `export function formatDate(value: string) {
return new Intl.DateTimeFormat("ko-KR", {
year: "numeric",
month: "long",
day: "numeric",
}).format(new Date(value));
}`,
},
{
id: "submit-button",
path: "src/components/SubmitButton.tsx",
before: `export function SubmitButton() {
return <button>저장</button>;
}`,
after: `type SubmitButtonProps = {
disabled?: boolean;
};
export function SubmitButton({ disabled = false }: SubmitButtonProps) {
return <button disabled={disabled}>저장</button>;
}`,
},
];
src/App.tsx
import { useEffect, useState } from "react";
import styles from "./App.module.css";
import {
changedFiles,
VERSION_LABELS,
type FileId,
type Version,
} from "./changedFiles";
type CopyState = "idle" | "copied" | "failed";
const COPY_MESSAGES: Record<CopyState, string> = {
idle: "",
copied: "복사됨",
failed: "복사하지 못했습니다.",
};
const versions = Object.keys(VERSION_LABELS) as Version[];
export default function App() {
const [fileId, setFileId] = useState<FileId>(changedFiles[0].id);
const [version, setVersion] = useState<Version>("before");
const [copyState, setCopyState] = useState<CopyState>("idle");
const file = changedFiles.find((item) => item.id === fileId) ?? changedFiles[0];
const code = file[version];
useEffect(() => {
setCopyState("idle");
}, [fileId, version]);
async function handleCopy() {
try {
if (typeof navigator.clipboard?.writeText !== "function") {
throw new Error("Clipboard API를 사용할 수 없습니다.");
}
await navigator.clipboard.writeText(code);
setCopyState("copied");
} catch {
setCopyState("failed");
}
}
return (
<main className={styles.page}>
<h1 className={styles.title}>코드 비교 뷰어</h1>
<p className={styles.intro}>
변경된 파일 3개의 변경 전후 코드를 확인하고, 지금 보고 있는 코드를 그대로 복사합니다.
</p>
<div className={styles.layout}>
<div className={styles.files} role="group" aria-label="변경 파일">
{changedFiles.map((item) => (
<button
key={item.id}
type="button"
className={styles.tab}
aria-pressed={item.id === file.id}
onClick={() => setFileId(item.id)}
>
{item.path}
</button>
))}
</div>
<section className={styles.viewer} aria-label="코드 보기">
<h2 className={styles.path}>{file.path}</h2>
<div className={styles.controls}>
<div className={styles.versions} role="group" aria-label="코드 버전">
{versions.map((value) => (
<button
key={value}
type="button"
className={styles.tab}
aria-pressed={value === version}
onClick={() => setVersion(value)}
>
{VERSION_LABELS[value]}
</button>
))}
</div>
<div className={styles.copyArea}>
<button type="button" className={styles.copyButton} onClick={handleCopy}>
코드 복사
</button>
<p
className={styles.status}
data-state={copyState}
role="status"
aria-live="polite"
>
{COPY_MESSAGES[copyState]}
</p>
</div>
</div>
<pre
className={styles.code}
tabIndex={0}
aria-label={`${file.path} ${VERSION_LABELS[version]} 코드`}
>
<code>{code}</code>
</pre>
</section>
</div>
</main>
);
}
<pre>에 tabIndex={0}을 준 이유는 가로 스크롤이 생기는 영역을 키보드만으로도 스크롤할 수 있어야 하기 때문입니다. aria-label이 있어 포커스 시 파일 경로와 버전이 함께 읽힙니다.
src/App.module.css
.page {
max-width: 60rem;
margin: 0 auto;
padding: 1.5rem 1rem 3rem;
box-sizing: border-box;
}
.title {
margin: 0 0 0.5rem;
font-size: 1.375rem;
line-height: 1.3;
}
.intro {
margin: 0 0 1.5rem;
color: #4b5563;
line-height: 1.6;
overflow-wrap: anywhere;
}
.layout {
display: grid;
gap: 1rem;
min-width: 0;
}
.files,
.versions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
min-width: 0;
}
.tab {
flex: 0 1 auto;
max-width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid #d1d5db;
border-radius: 0.5rem;
background: #fff;
color: #111827;
font: inherit;
font-size: 0.875rem;
text-align: left;
cursor: pointer;
overflow-wrap: anywhere;
}
.tab[aria-pressed="true"] {
border-color: #111827;
background: #111827;
color: #fff;
}
.tab:focus-visible,
.copyButton:focus-visible,
.code:focus-visible {
outline: 2px solid #2563eb;
outline-offset: 2px;
}
.viewer {
min-width: 0;
display: grid;
gap: 0.75rem;
}
.path {
margin: 0;
font-size: 0.9375rem;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
overflow-wrap: anywhere;
}
.controls {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: center;
justify-content: space-between;
}
.copyArea {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
}
.copyButton {
padding: 0.5rem 0.75rem;
border: 1px solid #111827;
border-radius: 0.5rem;
background: #fff;
color: #111827;
font: inherit;
font-size: 0.875rem;
cursor: pointer;
}
.status {
margin: 0;
min-height: 1.25rem;
font-size: 0.8125rem;
color: #15803d;
}
.status[data-state="failed"] {
color: #b91c1c;
}
.code {
margin: 0;
padding: 0.875rem;
border: 1px solid #e5e7eb;
border-radius: 0.5rem;
background: #f9fafb;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.8125rem;
line-height: 1.6;
max-width: 100%;
overflow-x: auto;
white-space: pre;
tab-size: 2;
}
@media (min-width: 768px) {
.layout {
grid-template-columns: minmax(0, 16rem) minmax(0, 1fr);
gap: 1.5rem;
}
.files {
flex-direction: column;
align-items: stretch;
}
}
가로 넘침을 막는 지점은 세 곳입니다. 그리드 트랙에 minmax(0, …)과 min-width: 0을 줘서 코드 블록이 트랙을 밀어내지 못하게 하고, 탭은 flex-wrap: wrap으로 줄바꿈하며, white-space: pre인 <pre>만 overflow-x: auto로 스스로 스크롤합니다. .status의 min-height는 문구가 나타났다 사라질 때 레이아웃이 흔들리지 않게 자리를 미리 잡아 둡니다.
src/App.test.tsx
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import App from "./App";
import { changedFiles } from "./changedFiles";
const [profile, formatDate] = changedFiles;
function codeText() {
return screen.getByLabelText(/코드$/).textContent;
}
function setClipboard(value: unknown) {
Object.defineProperty(navigator, "clipboard", {
value,
configurable: true,
writable: true,
});
}
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
describe("코드 비교 뷰어", () => {
it("처음에는 첫 번째 파일의 변경 전 코드를 보여 준다", () => {
render(<App />);
expect(
screen.getByRole("button", { name: profile.path, pressed: true }),
).toBeTruthy();
expect(
screen.getByRole("button", { name: "변경 전", pressed: true }),
).toBeTruthy();
expect(
screen.getByRole("button", { name: "변경 후", pressed: false }),
).toBeTruthy();
expect(screen.getByLabelText(`${profile.path} 변경 전 코드`)).toBeTruthy();
expect(codeText()).toBe(profile.before);
expect(
screen.getByRole("heading", { level: 2, name: profile.path }),
).toBeTruthy();
expect(screen.getAllByLabelText(/코드$/)).toHaveLength(1);
});
it("다른 파일과 변경 후를 선택하면 경로·코드·aria-pressed가 갱신된다", async () => {
const user = userEvent.setup();
render(<App />);
await user.click(screen.getByRole("button", { name: formatDate.path }));
await user.click(screen.getByRole("button", { name: "변경 후" }));
expect(
screen.getByRole("button", { name: formatDate.path, pressed: true }),
).toBeTruthy();
expect(
screen.getByRole("button", { name: profile.path, pressed: false }),
).toBeTruthy();
expect(
screen.getByRole("button", { name: "변경 후", pressed: true }),
).toBeTruthy();
expect(
screen.getByRole("button", { name: "변경 전", pressed: false }),
).toBeTruthy();
expect(
screen.getByRole("heading", { level: 2, name: formatDate.path }),
).toBeTruthy();
expect(
screen.getByLabelText(`${formatDate.path} 변경 후 코드`),
).toBeTruthy();
expect(codeText()).toBe(formatDate.after);
});
it("복사 버튼이 현재 보이는 코드를 복사하고 복사됨을 알린다", async () => {
const user = userEvent.setup();
const writeText = vi.fn().mockResolvedValue(undefined);
setClipboard({ writeText });
render(<App />);
await user.click(screen.getByRole("button", { name: "변경 후" }));
await user.click(screen.getByRole("button", { name: "코드 복사" }));
expect(writeText).toHaveBeenCalledWith(profile.after);
const status = await screen.findByRole("status");
expect(status.textContent).toBe("복사됨");
expect(status.getAttribute("aria-live")).toBe("polite");
});
it("Clipboard API를 쓸 수 없으면 실패 문구를 같은 live region으로 알린다", async () => {
const user = userEvent.setup();
setClipboard(undefined);
render(<App />);
await user.click(screen.getByRole("button", { name: "코드 복사" }));
const status = await screen.findByRole("status");
expect(status.textContent).toBe("복사하지 못했습니다.");
expect(status.getAttribute("aria-live")).toBe("polite");
});
it("키보드 Tab·Enter·Space로 파일과 버전을 바꿀 수 있다", async () => {
const user = userEvent.setup();
render(<App />);
await user.tab();
expect(document.activeElement).toBe(
screen.getByRole("button", { name: profile.path }),
);
await user.tab();
await user.keyboard("{Enter}");
expect(
screen.getByRole("button", { name: formatDate.path, pressed: true }),
).toBeTruthy();
await user.tab();
await user.tab();
await user.tab();
await user.keyboard(" ");
expect(
screen.getByRole("button", { name: "변경 후", pressed: true }),
).toBeTruthy();
expect(codeText()).toBe(formatDate.after);
});
});
userEvent.setup()은 자체 클립보드 스텁을 navigator.clipboard에 붙이므로, 복사 테스트에서는 setup()을 먼저 부르고 그다음에 목을 덮어써야 합니다. 순서를 반대로 하면 목이 스텁에 덮여 호출 인자를 검증할 수 없습니다.
vitest.config.ts
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "jsdom",
include: ["src/**/*.test.tsx"],
},
});
JSX 변환은 Vite 시작 프로젝트의 tsconfig.json에 있는 "jsx": "react-jsx"를 그대로 따르므로 별도 설정이 필요 없습니다. CSS Modules는 Vitest가 기본적으로 클래스 이름 프록시로 처리하기 때문에 스타일 관련 설정도 추가하지 않았습니다.
실행
npm i -D vitest jsdom @testing-library/react @testing-library/user-event
npx vitest run
실제 실행 결과: 테스트 5건 모두 통과(초기 상태, 파일·버전 전환, 복사 성공, 복사 실패, 키보드 조작). 런타임 의존성은 추가하지 않았고 테스트 도구만 개발 의존성입니다.
확인 방법
- 키보드: Tab이 파일 버튼 3개 → 버전 버튼 2개 → 복사 버튼 → 코드 영역 순으로 이동하는 것을
user.tab()테스트로 고정했고, Enter로 파일을, Space로 버전을 바꾼 뒤aria-pressed와 코드 내용이 함께 갱신되는지 확인했습니다.:focus-visible아웃라인으로 현재 위치가 보입니다. - 복사:
navigator.clipboard.writeText를 목으로 바꿔 지금 화면에 보이는 문자열(변경 후전환 뒤profile.after)이 그대로 전달되는지 확인했고,navigator.clipboard를undefined로 만들어 실패 경로도 같은 live region에복사하지 못했습니다.로 나오는지 확인했습니다. - 375px: 빌드한 화면을 375·768·1280px 뷰포트에서 열어 실제 값을 측정했습니다. 375px에서
documentElement.scrollWidth가clientWidth와 같은 375로 페이지 가로 스크롤이 없고, 가장 긴formatDate변경 후 코드에서만<pre>가clientWidth 341 / scrollWidth 363으로 자체 스크롤했습니다. 파일 탭 3개는 각각 다른 줄로 줄바꿈됐고, 768px 이상에서는grid-template-columns가256px + 나머지2열로 바뀌는 것을 확인했습니다.
Recommendation
이 과업의 추천 모델: Claude
Claude는 명세의 기능과 접근성 조건을 구현하는 데 그치지 않고, 복사 실패와 키보드 입력, 실제 375px 가로 넘침까지 검증 근거를 갖췄다. CSS Modules 한 가지와 5개 파일 구성을 지키면서도 테스트 실행 절차를 분명히 해, 바로 적용할 수 있는 완성도가 가장 높다.
잘 맞는 경우
- 작은 React 화면에서 상태 전환·Clipboard API·키보드 접근성을 함께 검증해야 할 때
- 375px 모바일 가로 넘침을 CSS 구조와 실제 화면 측정으로 확인해야 할 때
주의할 점
- 적용 전 기존 프로젝트의 Vitest, jsdom, Testing Library 설정과 설치된 의존성을 확인해 중복 설정을 피해야 한다.
Prompt and environment
공통 프롬프트와 실행 환경
프롬프트 전문 보기
React와 TypeScript로 고정된 파일 3개의 변경 전후 코드를 보여 주는 코드 비교 뷰어를 구현해 주세요. 파일 선택, 변경 전후 보기, 현재 보이는 코드 복사 기능을 포함하고, 구현과 테스트를 합쳐 4~8개 파일 안에 머무르세요.
제공된 changedFiles의 profile, format-date, submit-button 데이터와 문자열을 그대로 사용하세요. 페이지 제목과 짧은 소개를 표시하고, 세 파일 경로를 실제 button으로 나열해 하나를 선택할 수 있어야 합니다. 선택한 파일의 경로와 코드만 표시하며 초기 선택은 첫 번째 파일입니다. 변경 전·변경 후도 실제 button으로 전환하고, 선택된 파일·버전의 전체 코드를 pre > code로 표시하세요.
코드 복사 버튼은 현재 문자열을 navigator.clipboard.writeText로 복사해야 합니다. 성공 뒤에는 복사됨을, Clipboard API 미지원 또는 실패 시에는 복사하지 못했습니다.를 aria-live="polite" 영역으로 알려야 합니다. 파일 선택 영역은 aria-label="변경 파일" button 그룹이고 선택 버튼은 aria-pressed="true"여야 하며, 버전 버튼도 aria-pressed를 사용하세요.
375px에서는 파일 탭·버전 탭·코드 영역이 페이지 폭을 넘지 않게 하고 탭은 줄바꿈할 수 있어야 합니다. 코드 영역만 가로 스크롤할 수 있어야 하며, 768px 이상에서는 읽기 좋은 2열 또는 여백 있는 단일 열로 배치하세요. 초기 상태, 파일·버전 전환, 복사 성공을 테스트하고, 외부 UI 라이브러리·실제 diff 알고리즘·문법 강조·서버 연동은 추가하지 마세요.
- React 19
- TypeScript
- 기존 Vite 또는 React 시작 프로젝트
- CSS Modules 또는 기존 전역 CSS 한 가지
- Vitest 및 React Testing Library
- 고정 코드 문자열 3건, 외부 UI·diff·문법 강조·서버 연동 없음