Skip to content

게임 플레이 관련 페이지 레이아웃 / 기능 업데이트#273

Merged
kimnamheeee merged 10 commits into
devfrom
game-play-layout
Feb 19, 2026
Merged

게임 플레이 관련 페이지 레이아웃 / 기능 업데이트#273
kimnamheeee merged 10 commits into
devfrom
game-play-layout

Conversation

@kimnamheeee

@kimnamheeee kimnamheeee commented Feb 19, 2026

Copy link
Copy Markdown
Contributor

📝 설명

🛠️ 주요 변경 사항

리뷰 시 고려해야 할 사항

Summary by CodeRabbit

릴리스 노트

  • 새로운 기능

    • 키보드 단축키 지원: Enter(정답 공개), ArrowRight/Left(다음/이전 이동)
    • 떠 있는 추가 버튼(Floating Add) 도입 및 홈 버튼 클릭 방식 지원
    • 문제 보기 흐름 제어: 정답 노출 상태를 URL로 제어 가능
  • 스타일

    • 레이아웃·간격 전반 재조정 및 반응형 사이드바 개선
    • 헤더/내비게이션 구조 단순화
  • 테스트

    • 키보드 조작·정답 공개·문제 이동 관련 E2E 테스트 보강

@coderabbitai

coderabbitai Bot commented Feb 19, 2026

Copy link
Copy Markdown

Walkthrough

게임 플레이와 설정 UI를 리팩토링하여 FloatingAddButton을 도입하고, 정답 표시를 URL 검색매개변수(answer)로 제어하도록 변경했으며 키보드 네비게이션(Enter/Arrow)과 헤더/사이드바 레이아웃을 조정했습니다.

Changes

Cohort / File(s) Summary
FloatingAddButton 컴포넌트
service/app/src/shared/ui/floatingAddButton/index.tsx
새로운 FloatingAddButton 컴포넌트 추가 (PrimaryBoxButton 래핑, ariaLabel/containerClassName 지원).
FloatingAddButton 채택
service/app/src/app/create/components/questionList.tsx, service/app/src/app/game/[gameId]/setup/components/teamSidebar.tsx
기존 StickyActionBar/PrimaryBoxButton 제거 후 FloatingAddButton으로 교체; 리스트/컨테이너 패딩 및 pb 확대.
게임 플레이 컨텐츠 제어화 및 네비게이션
service/app/src/app/game/[gameId]/play/components/gamePlayContent.tsx, service/app/src/app/game/[gameId]/play/page.tsx, service/app/src/app/game/[gameId]/play/schemas/index.ts
showAnswer를 외부(검색매개변수)로 제어하도록 변경; onShowAnswer 콜백 추가; answer 쿼리 파싱(문자열→불리언) 스키마 추가; 키보드(Enter/Arrow) 네비게이션 도입.
게임 플레이 테스트 업데이트
service/app/src/app/game/[gameId]/play/__tests__/game-play.spec.ts, service/app/src/app/game/[gameId]/play/__tests__/gamePlayPOM.ts, service/app/src/app/game/[gameId]/play/__tests__/game-play-e2e-scenarios.md
테스트/문서 재구성: showAnswer 플로우 및 키보드 조작 시나리오 추가, POM 메서드 및 셀렉터 변경.
헤더·레이아웃·내비게이션 변경
service/app/src/app/game/[gameId]/layout.tsx, service/app/src/app/game/[gameId]/play/components/gamePlayNavigation.tsx, shared/design/src/components/navigation/index.tsx
상대 위치 지정 및 pt-90 추가, GamePlayHeaderProps에 totalRounds 추가, centerContent의 응답성 변경 및 Navigation 컴포넌트 도입/사용 조정.
HomeButton 업데이트 및 채택
service/app/src/widgets/components/homeButton.tsx, service/app/src/app/game/[gameId]/play/components/gamePlayNavigation.tsx, service/app/src/app/game/[gameId]/result/components/gameResultNavigation.tsx, service/app/src/app/game/[gameId]/setup/components/gameNavigation.tsx
HomeButton이 href 또는 onClick 경로를 모두 지원하도록 변경; 여러 네비게이션 컴포넌트에서 교체 사용.
사이드바·스코어보드 스타일링
service/app/src/app/game/[gameId]/play/components/scoreboardSidebar.tsx, service/app/src/app/game/[gameId]/setup/components/teamSidebar.tsx
aside로 변경, 절대 위치·반응형 너비 및 스크롤/패딩 재구성; StickyActionBar→FloatingAddButton 교체.
설정 및 결과 뷰 조정
service/app/src/app/game/[gameId]/setup/components/teamInputForm.tsx, service/app/src/app/game/[gameId]/setup/page.tsx, service/app/src/app/game/[gameId]/result/components/gameResultContent.tsx, service/app/src/app/game/[gameId]/result/components/gameResultNavigation.tsx
레이아웃/간격/버튼 레이블 변경, 결과 네비게이션을 Navigation 컴포넌트로 리팩토링.
PlayerStatus 및 스토리/토큰 변경
shared/design/src/components/playerStatus/index.tsx, shared/design/src/stories/playerStatus.stories.tsx, shared/design/src/tokens/variables.json
PlayerStatus 레이아웃/인터랙션 재구성(좌/우 영역으로 점수 증감), 스토리명·설명 갱신, 브레이크포인트 값(sm/md/lg) 각각 +1 변경.

Sequence Diagram(s)

mermaid
sequenceDiagram
participant User as 사용자
participant Browser as 브라우저
participant Page as GamePlay Page
participant Content as GamePlayContent
participant Router as 라우터/검색매개변수
Note over User,Browser: 키보드/버튼 상호작용 흐름
User->>Browser: Enter / ArrowRight / ArrowLeft 클릭
Browser->>Content: 키 이벤트 전달
Content->>Page: onShowAnswer / onNext / onPrev 호출
Page->>Router: 검색매개변수(q, answer) 업데이트
Router-->>Browser: URL 변경(새 q / answer)
Browser->>Content: props(showAnswer) 갱신 및 렌더

mermaid
sequenceDiagram
participant User as 사용자
participant Browser as 브라우저
participant Create as QuestionList
participant FloatingBtn as FloatingAddButton
User->>Browser: 클릭(FloatingAddButton)
Browser->>FloatingBtn: onClick
FloatingBtn->>Create: addQuestion 호출
Create-->>Browser: 리스트 갱신 및 레이아웃 변경

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Suggested labels

✨feature, 👩🏻‍💻frontend, 🪄refactor

Suggested reviewers

  • whdgur5717

Poem

🐰 발끝으로 레이아웃 다듬고, 버튼을 띄웠네,
URL에 숨긴 정답을 살짝 꺼내고,
엔터와 화살표가 길을 열면,
문제는 한 걸음씩 나아가고,
토끼는 기쁘게 깡충—새 UI 축하할게! 🎉

🚥 Pre-merge checks | ✅ 1 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Description check ⚠️ Warning PR 설명이 저장소의 템플릿 구조만 있고, 실제 작업 내용 요약과 주요 변경사항에 대한 구체적인 정보가 모두 누락되어 있습니다. 설명 섹션에 구체적인 작업 내용 요약을 추가하고, 주요 변경사항(플로팅 버튼 추가, 레이아웃 조정, 키보드 네비게이션 등)과 관련 커밋 SHA를 기재하며, 리뷰 시 중점 사항을 작성해주세요.
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (1 passed)
Check name Status Explanation
Title check ✅ Passed PR 제목이 변경사항의 주요 내용을 명확하게 반영하고 있으며, 게임 플레이 관련 페이지의 레이아웃과 기능 업데이트라는 실제 변경사항과 일치합니다.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch game-play-layout

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions

github-actions Bot commented Feb 19, 2026

Copy link
Copy Markdown

Deploy preview for re-creation ready!

✅ Preview
https://re-creation-a1ggrdkv4-whdgur5717s-projects.vercel.app

Built with commit 54c2591.
This pull request is being automatically deployed with vercel-action

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🧹 Nitpick comments (11)
service/app/src/app/game/[gameId]/play/components/scoreboardSidebar.tsx (1)

27-27: top-[10px] — 디자인 토큰 대신 임의값 사용

동일 파일 내 모든 다른 간격(p-24, left-20, bottom-32 등)은 프로젝트 디자인 토큰을 사용하는데, top-[10px]만 임의값입니다. 해당 값에 대응하는 토큰이 있다면 토큰으로 교체하는 것이 일관성 유지에 좋습니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@service/app/src/app/game/`[gameId]/play/components/scoreboardSidebar.tsx at
line 27, In the ScoreboardSidebar component replace the inline utility
"top-[10px]" in the className string with the project spacing design token used
elsewhere (matching the visual spacing of 10px) so it matches tokens like
"left-20", "p-24", "bottom-32"; locate the className in scoreboardSidebar.tsx
and swap the arbitrary top value for the correct token (e.g., the project's
top-?? token) to keep spacing consistent.
shared/design/src/components/playerStatus/index.tsx (2)

5-12: score prop — scoreView=false 시 미사용, 선택적(optional)으로 변경 고려

현재 score: string은 항상 필수 prop이지만, scoreView=false일 때는 렌더링에 사용되지 않습니다. 스토리에서도 score=""를 전달하고 있어 API 사용이 어색합니다. score?: string으로 변경하면 호출 측의 사용 의도가 더 명확해집니다.

♻️ 제안 수정
 interface PlayerStatusProps {
   name: string
-  score: string
+  score?: string
   scoreView?: boolean
   onScoreIncrease?: () => void
   onScoreDecrease?: () => void
   className?: string
 }
-        <span className="typography-heading-lg-semibold text-text-primary">
+        <span className="typography-heading-lg-semibold text-text-primary">
           {score}

참고: scoreView=true일 때 score가 undefined이면 빈 span이 렌더링되므로, 필요 시 {score ?? ""} 또는 별도 가드를 추가해 주세요.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@shared/design/src/components/playerStatus/index.tsx` around lines 5 - 12,
Change the PlayerStatusProps interface so the score prop is optional (make
score?: string) because when scoreView is false the value is not used; update
any call sites and stories that pass an empty string to instead omit score where
appropriate. In addition, update the PlayerStatus component rendering to guard
for undefined when scoreView is true (e.g., render {score ?? ""} or another
explicit guard) to avoid rendering "undefined" if score is missing. Finally run
typechecks to fix any downstream places relying on score being mandatory.

50-50: 하드코딩된 hex 색상 값 — 디자인 토큰으로 대체 권장

#FFC7C8, #BDDCFF는 토큰 시스템(variables.json)에 존재하지 않는 커스텀 값입니다 (가장 가까운 기존 토큰인 red/200 = #FFC9C9, blue/200 = #BEDBFF와도 불일치). 향후 디자인 토큰이 업데이트될 경우 이 컴포넌트의 색상은 자동으로 반영되지 않아 일관성이 깨질 수 있습니다.

이 색상들을 새로운 시맨틱 토큰(예: color/background/score/decrease-hover, color/background/score/increase-hover 등)으로 추가하거나, 기존 원시 토큰과 정확히 일치하도록 조정한 후 Tailwind 설정에 연결하는 방식을 권장합니다.

Also applies to: 62-62

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@shared/design/src/components/playerStatus/index.tsx` at line 50, The
component in shared/design/src/components/playerStatus/index.tsx uses hardcoded
hex values (`#FFC7C8` and `#BDDCFF`) inside the className (the hover/active classes
on the element with className="... hover:bg-[`#FFC7C8`] active:bg-[`#FF6467`]" and
similarly at line ~62) — replace these literal hex colors with semantic design
tokens instead: add new tokens (suggested names like
color/background/score/decrease-hover and color/background/score/increase-hover)
or map the exact existing primitive tokens (e.g., red/200 or blue/200) so they
match, then reference those tokens via Tailwind utility classes (configured in
tailwind/variables.json and tailwind.config) rather than inline hex values;
update the className in the PlayerStatus component to use the token-based
Tailwind classes.
service/app/src/app/game/[gameId]/result/components/gameResultNavigation.tsx (1)

22-26: 로고 버튼의 접근성 레이블 명확성 개선 권장

<button onClick={onExit}> 안의 이미지 alt="홈 로고"가 버튼의 접근 가능 이름이 됩니다. 그러나 클릭 시 "게임 종료"가 실행되므로 스크린 리더 사용자에게는 동작이 레이블과 불일치합니다. 우측의 Cross 버튼(aria-label="게임 종료")과 일관성을 위해 aria-label 추가를 고려해 보세요.

♿ 개선 제안
-        <button onClick={onExit}>
+        <button onClick={onExit} aria-label="게임 종료 및 홈으로 이동">
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@service/app/src/app/game/`[gameId]/result/components/gameResultNavigation.tsx
around lines 22 - 26, 왼쪽 로고 버튼의 시멘틱 레이블이 이미지의 alt("홈 로고")에만 의존해 실제 클릭
동작(onExit)이 스크린리더에 올바르게 전달되지 않습니다; gameResultNavigation 컴포넌트에서 해당 <button
onClick={onExit}> 요소에 aria-label="게임 종료" (혹은 우측 Cross 버튼과 동일한 레이블) 속성을 추가해 접근성
이름을 명확히 하세요 — 참조 심볼: onExit, leftContent, 해당 Image 요소.
service/app/src/shared/ui/floatingAddButton/index.tsx (1)

8-12: aria-label을 기본 타입에서도 Omit해야 함

Omit<..., "children"> 만으로는 aria-labelComponentPropsWithoutRef<typeof PrimaryBoxButton> 에 여전히 포함됩니다. 컴포넌트 내부에서는 aria-label={ariaLabel} 뒤에 {...props}를 스프레드하므로, 호출 측이 실수로 aria-label="..." 를 직접 전달하면 의도한 ariaLabel 값이 덮어써집니다.

🛡️ 개선 제안
 interface FloatingAddButtonProps
-  extends Omit<ComponentPropsWithoutRef<typeof PrimaryBoxButton>, "children"> {
+  extends Omit<ComponentPropsWithoutRef<typeof PrimaryBoxButton>, "children" | "aria-label"> {
   ariaLabel: string
   containerClassName?: string
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@service/app/src/shared/ui/floatingAddButton/index.tsx` around lines 8 - 12,
The prop type currently extends Omit<ComponentPropsWithoutRef<typeof
PrimaryBoxButton>, "children"> but still allows callers to pass an "aria-label"
that would be spread over the intended ariaLabel prop; update
FloatingAddButtonProps to also Omit the aria-label key (i.e. Omit<...,
"children" | "aria-label">) so the component’s ariaLabel prop cannot be
overwritten by {...props}, and keep using ariaLabel internally when rendering
the PrimaryBoxButton.
service/app/src/app/game/[gameId]/play/__tests__/gamePlayPOM.ts (1)

120-123: POM 액션 메서드 내 expect 어서션 분리 권장

goToNextQuestion 내부에서 expect(this.nextQuestionButton).toBeVisible()를 호출하면, 실패 시 테스트 오류 메시지가 POM 내부에서 발생하여 어느 테스트 의도가 깨졌는지 파악하기 어렵습니다. 어서션은 테스트 코드 내에서 명시적으로 작성하는 것이 POM 패턴에 부합합니다.

♻️ 리팩토링 제안
   async goToNextQuestion() {
-    await expect(this.nextQuestionButton).toBeVisible()
-    await this.pressEnter()
+    await this.pressEnter()
   }

테스트 코드에서 필요 시 별도로 await expect(pageObj.nextQuestionButton).toBeVisible() 어서션을 추가하세요.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@service/app/src/app/game/`[gameId]/play/__tests__/gamePlayPOM.ts around lines
120 - 123, 요약: POM 메서드 goToNextQuestion 내부에서
expect(this.nextQuestionButton).toBeVisible() 어서션을 제거하고 POM은 동작만 수행하도록 수정하세요;
구체적으로 goToNextQuestion에서는 오직 await this.pressEnter()만 남기고, 테스트 코드에서 필요 시 await
expect(pageObj.nextQuestionButton).toBeVisible()를 호출해 명시적인 어서션을 수행하도록 변경합니다 (대상
식별자: goToNextQuestion, nextQuestionButton, pressEnter).
service/app/src/app/game/[gameId]/play/__tests__/game-play.spec.ts (1)

57-196: 4개 describe 블록에서 동일한 beforeEach 중복

"버튼 클릭 조작", "키보드 조작", "문제 이동 및 진행률" 블록 모두 "기본 UI"와 동일한 셋업 코드를 반복합니다. 헬퍼 함수로 추출하면 유지보수성이 향상됩니다.

♻️ 리팩토링 제안
+async function setupGame(page: Page) {
+  const setupPage = new GameSetupPOM(page)
+  await setupPage.goto("1")
+  await setupPage.startGame()
+  return new GamePlayPOM(page)
+}

 test.describe("게임 진행 - 기본 UI", () => {
   let pageObj: GamePlayPOM
-  let setupPage: GameSetupPOM

   test.beforeEach(async ({ page }) => {
-    setupPage = new GameSetupPOM(page)
-    await setupPage.goto("1")
-    await setupPage.startGame()
-    pageObj = new GamePlayPOM(page)
+    pageObj = await setupGame(page)
   })
   // ...
 })

 test.describe("게임 진행 - 버튼 클릭 조작", () => {
   let pageObj: GamePlayPOM
-  let setupPage: GameSetupPOM

   test.beforeEach(async ({ page }) => {
-    setupPage = new GameSetupPOM(page)
-    await setupPage.goto("1")
-    await setupPage.startGame()
-    pageObj = new GamePlayPOM(page)
+    pageObj = await setupGame(page)
   })
   // ...
 })
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@service/app/src/app/game/`[gameId]/play/__tests__/game-play.spec.ts around
lines 57 - 196, Three describe blocks duplicate the same beforeEach setup
(creating GameSetupPOM, goto("1"), startGame(), and new GamePlayPOM), so extract
that repeated setup into a single reusable helper or shared beforeEach: create a
setup function (e.g., setupGame) that accepts the Playwright page, instantiates
GameSetupPOM, calls goto("1") and startGame(), and returns a new GamePlayPOM (or
move that beforeEach into an outer describe that encloses the three inner
describes); then replace the per-block beforeEach with a call to this helper or
rely on the outer beforeEach so tests use the returned GamePlayPOM (reference
symbols: GameSetupPOM, GamePlayPOM, setupPage, pageObj, goto, startGame).
service/app/src/app/game/[gameId]/play/schemas/index.ts (1)

5-8: Zod 4의 z.stringbool() 대안으로 고려 가능

현재 .optional().transform((v) => v === "true") 패턴은 URL 파라미터 파싱에 최적화되어 있습니다. 파라미터가 없으면 undefined → false, "true"true, 다른 값들은 모두 false로 조용히 처리됩니다.

Zod 4는 z.stringbool()을 제공하며, "true", "false", "yes", "no" 등 다양한 불리언 문자열을 인식합니다. 다만 인식하지 못하는 값은 검증 오류를 발생시키므로, URL 파라미터 같이 자유로운 입력을 받는 경우라면 현재의 관대한 처리 방식이 더 적합합니다. 의도하는 동작이 명확하다면 현재 코드는 유지해도 무방합니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@service/app/src/app/game/`[gameId]/play/schemas/index.ts around lines 5 - 8,
The current answer schema uses .string().optional().transform((v) => v ===
"true") which silently maps undefined/any non-"true" to false; if you want
stricter parsing use Zod 4's z.stringbool() instead (replace the answer schema
with z.stringbool().optional() or z.stringbool() and handle validation errors),
otherwise keep the existing permissive transform but add a clarifying comment
near the answer definition to document the intended behavior; update the symbol
named answer in the schema accordingly.
service/app/src/widgets/components/homeButton.tsx (1)

25-25: className 조합에 cn() 유틸리티를 사용하는 것이 좋겠습니다.

코드베이스 전반에서 cn()을 사용하여 Tailwind 클래스를 조합하고 있는데, 이 컴포넌트만 템플릿 리터럴로 문자열을 직접 연결하고 있습니다. cn()을 사용하면 Prettier Tailwind 플러그인의 클래스 정렬이 적용되고, 조건부 클래스나 중복 클래스 처리도 올바르게 동작합니다.

♻️ cn() 유틸리티 사용 수정안
+import { cn } from "@ject-5-fe/design/lib"
 import Image from "next/image"
 import Link from "next/link"
-  const buttonClassName = `flex shrink-0 cursor-pointer flex-col items-center justify-center gap-2.5 p-3.5 focus:outline-none ${className}`
+  const buttonClassName = cn(
+    "flex shrink-0 cursor-pointer flex-col items-center justify-center gap-2.5 p-3.5 focus:outline-none",
+    className,
+  )

As per coding guidelines, "Tailwind CSS utilities should be ordered via the Prettier Tailwind plugin; avoid inline style objects unless necessary."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@service/app/src/widgets/components/homeButton.tsx` at line 25, Replace the
template-literal class concatenation used to build buttonClassName with the cn()
utility so Tailwind classes are ordered and conditional/duplicate classes are
handled; specifically, change the const buttonClassName = `flex ...
${className}` usage to const buttonClassName = cn("flex shrink-0 cursor-pointer
flex-col items-center justify-center gap-2.5 p-3.5 focus:outline-none",
className) and ensure cn is imported (from your project's utils/helpers) if not
already; remove the string interpolation and rely on cn() for combining base
classes and the className prop so Prettier Tailwind plugin can sort classes
correctly.
service/app/src/app/game/[gameId]/play/components/gamePlayNavigation.tsx (1)

20-46: 좌우 영역의 고정 너비 w-[420px]가 소형 화면에서 레이아웃 문제를 일으킬 수 있습니다.

헤더의 좌우 섹션이 각각 w-[420px]로 고정되어 있어, 합산 840px + Progress 바 영역이 됩니다. 사이드바가 sm:w-[334px], md:w-[400px]으로 반응형 처리되어 있는데, 헤더는 고정 너비라 작은 뷰포트에서 오버플로우가 발생할 수 있습니다. 반응형 너비 적용을 검토해 보세요.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@service/app/src/app/game/`[gameId]/play/components/gamePlayNavigation.tsx
around lines 20 - 46, The left and right header containers use a fixed class
w-[420px] which causes overflow on small viewports; update the two divs with
className "flex w-[420px] ..." to use responsive sizing and allow
shrinking—replace the fixed width with responsive classes (e.g., sm:w-[334px],
md:w-[400px]) or use max-width and flex-shrink (e.g., max-w-[420px] plus
flex-shrink-0/allow-shrink) so they collapse on small screens while keeping the
center Progress (the Progress component inside the div with className "flex
flex-1 items-center justify-center") flexible; ensure HomeButton, ThemeToggle
and SecondaryPlainIconButton remain aligned after the change.
service/app/src/app/game/[gameId]/play/__tests__/game-play-e2e-scenarios.md (1)

36-66: 섹션 3과 섹션 4의 내용이 상당 부분 중복됩니다.

섹션 3 (정답 공개 및 문제 이동)과 섹션 4 (문제 네비게이션)가 이전/다음 문제 이동, 정답 상태 초기화, 마지막 문제 처리 등 동일한 동작을 반복 설명하고 있습니다. 하나의 섹션으로 통합하면 유지보수가 쉬워집니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@service/app/src/app/game/`[gameId]/play/__tests__/game-play-e2e-scenarios.md
around lines 36 - 66, Merge sections titled "3. 정답 공개 및 문제 이동" and "4. 문제 네비게이션"
into a single consolidated section to remove duplication: create one section
(e.g., "정답 공개 및 문제 이동/네비게이션") that includes the button behavior ("정답은?" sets
answer=true in URL and shows "다음"), keyboard mappings (Enter and → to reveal
answer or advance, ← to go back), edge cases (no-op on ← at question 1, Enter/→
on last revealed answer navigates to /game/[gameId]/result), and the invariant
that moving to previous/next rounds resets answer-hidden state; keep the table
of key behaviors and the short bullet rules for previous/next navigation but
remove repeated sentences across the two original sections.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@service/app/src/app/game/`[gameId]/play/__tests__/gamePlayPOM.ts:
- Around line 66-70: The goto helper constructs query string incorrectly when
showAnswer is true but round is undefined; update the goto function (search
variable and URL assembly) to build query parameters safely (e.g., use a params
array or URLSearchParams) so the first param is prefixed with '?' and subsequent
params with '&'; locate goto in gamePlayPOM.ts and change the logic that appends
"&answer=true" to conditionally add "?" when search is empty or use
URLSearchParams to produce a correct query string for
page.goto(`game/${gameId}/play${search}`).

In `@service/app/src/app/game/`[gameId]/play/components/gamePlayContent.tsx:
- Around line 26-46: The keydown handler (handleKeyDown) currently runs
unconditionally and triggers navigation even when a confirmation dialog is open;
update handleKeyDown to early-return when a dialog/modal is open by adding a
prop or state guard (e.g., accept an isDialogOpen boolean prop passed from the
parent) and check that at the top of handleKeyDown before processing Enter/Arrow
keys; also include isDialogOpen in the useCallback dependency array and in the
useEffect dependency list so the listener logic updates correctly;
alternatively, if you prefer the other suggested approach, call
e.preventDefault() and return when a local dialog-open boolean is true.
- Around line 55-82: The current conditional wraps the entire question/answer
block with currentQuestion?.imageUrl, so when imageUrl is an empty string the
showAnswer branch never renders; update the JSX so the showAnswer answer display
(the div that shows currentQuestion?.questionAnswer) is rendered
outside/independent of the currentQuestion?.imageUrl check while keeping only
the <Image ... /> element inside the imageUrl conditional (use the existing
showAnswer, currentQuestion, currentQuestion?.questionAnswer,
currentQuestion.imageUrl, setImageWidths and currentRound identifiers to locate
the branches to refactor).

In `@service/app/src/app/game/`[gameId]/play/components/scoreboardSidebar.tsx:
- Around line 33-42: The toggle button (PrimarySolidIconButton) is missing
aria-expanded and aria-controls; update the onClick/button element to include
aria-expanded={isSidebarOpen} and aria-controls pointing to the scoreboard
panel's id, and add a matching id attribute to the panel div (the element
rendered when isSidebarOpen is true) so screen readers can associate the button
with the controlled region; use existing symbols isSidebarOpen,
setIsSidebarOpen, PrimarySolidIconButton and the panel div to implement this
linkage.

In `@shared/design/src/stories/playerStatus.stories.tsx`:
- Around line 228-268: AllStatesComparison story's inner decorator uses a fixed
400px width causing overflow inside the meta decorator's 350px container and the
decorator function also ignores Story/context so story args are unused; update
the decorator that currently returns <div className="flex w-[400px] ..."> to use
a responsive width like w-full (or match outer width w-[350px]) instead of
w-[400px], and either remove the unused args (e.g., args: { name, score,
scoreView }) from the story or change the decorator signature to accept (Story,
context) and forward context.args into the rendered PlayerStatus components so
Storybook controls actually affect PlayerStatus (refer to AllStatesComparison
decorator and PlayerStatus props like name/score/scoreView).

---

Nitpick comments:
In `@service/app/src/app/game/`[gameId]/play/__tests__/game-play-e2e-scenarios.md:
- Around line 36-66: Merge sections titled "3. 정답 공개 및 문제 이동" and "4. 문제 네비게이션"
into a single consolidated section to remove duplication: create one section
(e.g., "정답 공개 및 문제 이동/네비게이션") that includes the button behavior ("정답은?" sets
answer=true in URL and shows "다음"), keyboard mappings (Enter and → to reveal
answer or advance, ← to go back), edge cases (no-op on ← at question 1, Enter/→
on last revealed answer navigates to /game/[gameId]/result), and the invariant
that moving to previous/next rounds resets answer-hidden state; keep the table
of key behaviors and the short bullet rules for previous/next navigation but
remove repeated sentences across the two original sections.

In `@service/app/src/app/game/`[gameId]/play/__tests__/game-play.spec.ts:
- Around line 57-196: Three describe blocks duplicate the same beforeEach setup
(creating GameSetupPOM, goto("1"), startGame(), and new GamePlayPOM), so extract
that repeated setup into a single reusable helper or shared beforeEach: create a
setup function (e.g., setupGame) that accepts the Playwright page, instantiates
GameSetupPOM, calls goto("1") and startGame(), and returns a new GamePlayPOM (or
move that beforeEach into an outer describe that encloses the three inner
describes); then replace the per-block beforeEach with a call to this helper or
rely on the outer beforeEach so tests use the returned GamePlayPOM (reference
symbols: GameSetupPOM, GamePlayPOM, setupPage, pageObj, goto, startGame).

In `@service/app/src/app/game/`[gameId]/play/__tests__/gamePlayPOM.ts:
- Around line 120-123: 요약: POM 메서드 goToNextQuestion 내부에서
expect(this.nextQuestionButton).toBeVisible() 어서션을 제거하고 POM은 동작만 수행하도록 수정하세요;
구체적으로 goToNextQuestion에서는 오직 await this.pressEnter()만 남기고, 테스트 코드에서 필요 시 await
expect(pageObj.nextQuestionButton).toBeVisible()를 호출해 명시적인 어서션을 수행하도록 변경합니다 (대상
식별자: goToNextQuestion, nextQuestionButton, pressEnter).

In `@service/app/src/app/game/`[gameId]/play/components/gamePlayNavigation.tsx:
- Around line 20-46: The left and right header containers use a fixed class
w-[420px] which causes overflow on small viewports; update the two divs with
className "flex w-[420px] ..." to use responsive sizing and allow
shrinking—replace the fixed width with responsive classes (e.g., sm:w-[334px],
md:w-[400px]) or use max-width and flex-shrink (e.g., max-w-[420px] plus
flex-shrink-0/allow-shrink) so they collapse on small screens while keeping the
center Progress (the Progress component inside the div with className "flex
flex-1 items-center justify-center") flexible; ensure HomeButton, ThemeToggle
and SecondaryPlainIconButton remain aligned after the change.

In `@service/app/src/app/game/`[gameId]/play/components/scoreboardSidebar.tsx:
- Line 27: In the ScoreboardSidebar component replace the inline utility
"top-[10px]" in the className string with the project spacing design token used
elsewhere (matching the visual spacing of 10px) so it matches tokens like
"left-20", "p-24", "bottom-32"; locate the className in scoreboardSidebar.tsx
and swap the arbitrary top value for the correct token (e.g., the project's
top-?? token) to keep spacing consistent.

In `@service/app/src/app/game/`[gameId]/play/schemas/index.ts:
- Around line 5-8: The current answer schema uses
.string().optional().transform((v) => v === "true") which silently maps
undefined/any non-"true" to false; if you want stricter parsing use Zod 4's
z.stringbool() instead (replace the answer schema with z.stringbool().optional()
or z.stringbool() and handle validation errors), otherwise keep the existing
permissive transform but add a clarifying comment near the answer definition to
document the intended behavior; update the symbol named answer in the schema
accordingly.

In
`@service/app/src/app/game/`[gameId]/result/components/gameResultNavigation.tsx:
- Around line 22-26: 왼쪽 로고 버튼의 시멘틱 레이블이 이미지의 alt("홈 로고")에만 의존해 실제 클릭 동작(onExit)이
스크린리더에 올바르게 전달되지 않습니다; gameResultNavigation 컴포넌트에서 해당 <button onClick={onExit}>
요소에 aria-label="게임 종료" (혹은 우측 Cross 버튼과 동일한 레이블) 속성을 추가해 접근성 이름을 명확히 하세요 — 참조
심볼: onExit, leftContent, 해당 Image 요소.

In `@service/app/src/shared/ui/floatingAddButton/index.tsx`:
- Around line 8-12: The prop type currently extends
Omit<ComponentPropsWithoutRef<typeof PrimaryBoxButton>, "children"> but still
allows callers to pass an "aria-label" that would be spread over the intended
ariaLabel prop; update FloatingAddButtonProps to also Omit the aria-label key
(i.e. Omit<..., "children" | "aria-label">) so the component’s ariaLabel prop
cannot be overwritten by {...props}, and keep using ariaLabel internally when
rendering the PrimaryBoxButton.

In `@service/app/src/widgets/components/homeButton.tsx`:
- Line 25: Replace the template-literal class concatenation used to build
buttonClassName with the cn() utility so Tailwind classes are ordered and
conditional/duplicate classes are handled; specifically, change the const
buttonClassName = `flex ... ${className}` usage to const buttonClassName =
cn("flex shrink-0 cursor-pointer flex-col items-center justify-center gap-2.5
p-3.5 focus:outline-none", className) and ensure cn is imported (from your
project's utils/helpers) if not already; remove the string interpolation and
rely on cn() for combining base classes and the className prop so Prettier
Tailwind plugin can sort classes correctly.

In `@shared/design/src/components/playerStatus/index.tsx`:
- Around line 5-12: Change the PlayerStatusProps interface so the score prop is
optional (make score?: string) because when scoreView is false the value is not
used; update any call sites and stories that pass an empty string to instead
omit score where appropriate. In addition, update the PlayerStatus component
rendering to guard for undefined when scoreView is true (e.g., render {score ??
""} or another explicit guard) to avoid rendering "undefined" if score is
missing. Finally run typechecks to fix any downstream places relying on score
being mandatory.
- Line 50: The component in shared/design/src/components/playerStatus/index.tsx
uses hardcoded hex values (`#FFC7C8` and `#BDDCFF`) inside the className (the
hover/active classes on the element with className="... hover:bg-[`#FFC7C8`]
active:bg-[`#FF6467`]" and similarly at line ~62) — replace these literal hex
colors with semantic design tokens instead: add new tokens (suggested names like
color/background/score/decrease-hover and color/background/score/increase-hover)
or map the exact existing primitive tokens (e.g., red/200 or blue/200) so they
match, then reference those tokens via Tailwind utility classes (configured in
tailwind/variables.json and tailwind.config) rather than inline hex values;
update the className in the PlayerStatus component to use the token-based
Tailwind classes.

Comment on lines +66 to 70
async goto(gameId: string = "1", round?: number, showAnswer?: boolean) {
let search = round ? `?q=${round}` : ""
if (showAnswer) search += "&answer=true"
await this.page.goto(`game/${gameId}/play${search}`)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

goto에서 showAnswer=true이고 round가 없을 때 URL이 잘못 구성됨

roundundefined일 때 search는 빈 문자열("")이고, 여기에 "&answer=true"를 붙이면 ? 없이 game/1/play&answer=true가 됩니다. 현재 테스트에서는 gotoshowAnswer=true만 전달하는 케이스가 없어 문제가 드러나지 않지만, API 자체는 잘못된 동작을 합니다.

🐛 버그 수정 제안
-  async goto(gameId: string = "1", round?: number, showAnswer?: boolean) {
-    let search = round ? `?q=${round}` : ""
-    if (showAnswer) search += "&answer=true"
-    await this.page.goto(`game/${gameId}/play${search}`)
-  }
+  async goto(gameId: string = "1", round?: number, showAnswer?: boolean) {
+    const params: string[] = []
+    if (round) params.push(`q=${round}`)
+    if (showAnswer) params.push("answer=true")
+    const search = params.length ? `?${params.join("&")}` : ""
+    await this.page.goto(`game/${gameId}/play${search}`)
+  }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@service/app/src/app/game/`[gameId]/play/__tests__/gamePlayPOM.ts around lines
66 - 70, The goto helper constructs query string incorrectly when showAnswer is
true but round is undefined; update the goto function (search variable and URL
assembly) to build query parameters safely (e.g., use a params array or
URLSearchParams) so the first param is prefixed with '?' and subsequent params
with '&'; locate goto in gamePlayPOM.ts and change the logic that appends
"&answer=true" to conditionally add "?" when search is empty or use
URLSearchParams to produce a correct query string for
page.goto(`game/${gameId}/play${search}`).

Comment thread service/app/src/app/game/[gameId]/play/components/gamePlayContent.tsx Outdated
Comment on lines 33 to +42
<PrimarySolidIconButton
className="right-0 top-16"
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
aria-label="점수판 토글"
className="absolute right-24"
>
{isSidebarOpen ? <Show /> : <Hidden />}
</PrimarySolidIconButton>
</div>
{isSidebarOpen && (
<div className="flex max-h-[850px] flex-1 flex-col items-center gap-24 overflow-y-scroll pb-48">
<div className="flex min-h-0 flex-1 flex-col items-center gap-24 overflow-y-auto px-24 pb-24">

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

토글 버튼에 aria-expandedaria-controls 누락

현재 버튼은 aria-label만 있고 aria-expanded가 없어, 스크린 리더 사용자가 점수판 패널의 현재 펼침/접힘 상태를 알 수 없습니다. 토글 버튼은 제어하는 위젯의 idaria-controls에 설정하고, aria-expanded를 현재 상태로 설정해야 합니다.

♿ 제안된 수정 사항
+      {isSidebarOpen && (
+        <div
+          id="scoreboard-content"
+          className="flex min-h-0 flex-1 flex-col items-center gap-24 overflow-y-auto px-24 pb-24"
+        >
-      {isSidebarOpen && (
-        <div className="flex min-h-0 flex-1 flex-col items-center gap-24 overflow-y-auto px-24 pb-24">
        <PrimarySolidIconButton
          onClick={() => setIsSidebarOpen(!isSidebarOpen)}
          aria-label="점수판 토글"
+         aria-expanded={isSidebarOpen}
+         aria-controls="scoreboard-content"
          className="absolute right-24"
        >
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<PrimarySolidIconButton
className="right-0 top-16"
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
aria-label="점수판 토글"
className="absolute right-24"
>
{isSidebarOpen ? <Show /> : <Hidden />}
</PrimarySolidIconButton>
</div>
{isSidebarOpen && (
<div className="flex max-h-[850px] flex-1 flex-col items-center gap-24 overflow-y-scroll pb-48">
<div className="flex min-h-0 flex-1 flex-col items-center gap-24 overflow-y-auto px-24 pb-24">
<PrimarySolidIconButton
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
aria-label="점수판 토글"
aria-expanded={isSidebarOpen}
aria-controls="scoreboard-content"
className="absolute right-24"
>
{isSidebarOpen ? <Show /> : <Hidden />}
</PrimarySolidIconButton>
</div>
{isSidebarOpen && (
<div
id="scoreboard-content"
className="flex min-h-0 flex-1 flex-col items-center gap-24 overflow-y-auto px-24 pb-24"
>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@service/app/src/app/game/`[gameId]/play/components/scoreboardSidebar.tsx
around lines 33 - 42, The toggle button (PrimarySolidIconButton) is missing
aria-expanded and aria-controls; update the onClick/button element to include
aria-expanded={isSidebarOpen} and aria-controls pointing to the scoreboard
panel's id, and add a matching id attribute to the panel div (the element
rendered when isSidebarOpen is true) so screen readers can associate the button
with the controlled region; use existing symbols isSidebarOpen,
setIsSidebarOpen, PrimarySolidIconButton and the panel div to implement this
linkage.

Comment on lines +228 to +268
decorators: [
() => (
<div className="flex w-[400px] flex-col gap-16">
<div className="text-sm font-medium text-gray-600">
점수 표시 상태 (scoreView=true)
</div>
<div className="flex flex-col gap-8">
<PlayerStatus
name="A팀"
score="0점"
scoreView={true}
onScoreIncrease={() => console.log("점수 증가")}
onScoreDecrease={() => console.log("점수 감소")}
/>
<PlayerStatus
name="우승팀"
score="15점"
scoreView={true}
onScoreIncrease={() => console.log("점수 증가")}
onScoreDecrease={() => console.log("점수 감소")}
/>
<PlayerStatus
name="매우긴팀이름테스트"
score="20점"
scoreView={true}
onScoreIncrease={() => console.log("점수 증가")}
onScoreDecrease={() => console.log("점수 감소")}
/>
</div>

<div className="mt-16 text-sm font-medium text-gray-600">
팀명만 표시 (scoreView=false)
</div>
<div className="flex flex-col gap-8">
<PlayerStatus name="A팀" score="" scoreView={false} />
<PlayerStatus name="매우긴팀이름테스트" score="" scoreView={false} />
<PlayerStatus name="Champions" score="" scoreView={false} />
</div>
</div>
</div>
),
),
],

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

AllStatesComparison 스토리 — 데코레이터 너비 충돌로 인한 오버플로 및 사용되지 않는 args

이 스토리에는 두 가지 문제가 있습니다.

1. 레이아웃 오버플로 (minor)

Storybook 데코레이터는 안쪽에서 바깥쪽으로 합성됩니다.

  • 메타 데코레이터(바깥쪽): <div className="w-[350px]"><Story /></div>
  • 스토리 데코레이터(안쪽): <div className="flex w-[400px] flex-col gap-16">...</div>

결과적으로 400px 컨테이너가 350px 컨테이너 안에 렌더링되어 오버플로가 발생합니다. 스토리 데코레이터의 고정 너비를 w-full 또는 w-[350px]로 변경하거나 제거해 주세요.

2. 사용되지 않는 args (optional)

스토리 데코레이터 함수가 () => (...) 형태로 Storycontext 인자를 모두 무시하므로, args: { name: "비교용", score: "0점", scoreView: true }는 실제로 렌더링에 영향을 주지 않는 데드 코드입니다. Storybook Controls 패널에서 값을 변경해도 UI에 반영되지 않아 혼란을 줄 수 있습니다. args를 제거하거나, 의도적으로 무시한다는 주석을 추가해 주세요.

🛠️ 제안 수정
  decorators: [
    () => (
-     <div className="flex w-[400px] flex-col gap-16">
+     <div className="flex w-full flex-col gap-16">
 export const AllStatesComparison: Story = {
-  args: {
-    name: "비교용",
-    score: "0점",
-    scoreView: true,
-  },
   decorators: [
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@shared/design/src/stories/playerStatus.stories.tsx` around lines 228 - 268,
AllStatesComparison story's inner decorator uses a fixed 400px width causing
overflow inside the meta decorator's 350px container and the decorator function
also ignores Story/context so story args are unused; update the decorator that
currently returns <div className="flex w-[400px] ..."> to use a responsive width
like w-full (or match outer width w-[350px]) instead of w-[400px], and either
remove the unused args (e.g., args: { name, score, scoreView }) from the story
or change the decorator signature to accept (Story, context) and forward
context.args into the rendered PlayerStatus components so Storybook controls
actually affect PlayerStatus (refer to AllStatesComparison decorator and
PlayerStatus props like name/score/scoreView).

prevent keyboard navigation when dialog is open / set layout on cases where no imageUrl exists

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (1)
service/app/src/app/game/[gameId]/play/components/gamePlayContent.tsx (1)

51-51: 필수 prop currentQuestion에 불필요한 옵셔널 체이닝 사용.

currentQuestion은 non-optional prop(currentQuestion: GameQuestion, line 7)이지만 currentQuestion?.imageUrl(line 51), currentQuestion?.questionAnswer(lines 73, 95)에서 불필요하게 옵셔널 체이닝을 사용하고 있습니다. line 77과 96에서는 옵셔널 체이닝 없이 사용하는 것과 대비됩니다. 잘못된 타입 이해를 유발할 수 있으므로 일관성 있게 ?.을 제거하는 것이 좋습니다.

♻️ 수정 제안
-  const hasImage = !!currentQuestion?.imageUrl
+  const hasImage = !!currentQuestion.imageUrl
-              {currentQuestion?.questionAnswer}
+              {currentQuestion.questionAnswer}

(lines 73 and 95 모두 동일하게 적용)

Also applies to: 73-73, 95-95

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@service/app/src/app/game/`[gameId]/play/components/gamePlayContent.tsx at
line 51, The prop currentQuestion is non-optional so remove unnecessary optional
chaining: replace occurrences of currentQuestion?.imageUrl (used in hasImage)
and currentQuestion?.questionAnswer (used where questionAnswer is accessed
around the render logic) with plain currentQuestion.imageUrl and
currentQuestion.questionAnswer to be consistent with other uses; ensure hasImage
and any other references use currentQuestion without ?. so types remain accurate
and consistent (check usages near hasImage, and the two spots that access
questionAnswer).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@service/app/src/app/game/`[gameId]/play/components/gamePlayContent.tsx:
- Around line 39-41: The keyboard handler for navigation is missing
e.preventDefault(), causing page scrolling to occur alongside navigation; update
the key handling branches in the component that checks e.key (the ArrowLeft,
ArrowRight and Enter cases) to call e.preventDefault() before invoking
onPrevQuestion(), onNextQuestion(), or the submit handler so navigation doesn't
trigger page scroll or default browser behavior (ensure ArrowLeft retains its
currentRound > 1 guard and ArrowRight/Enter branches mirror the preventDefault
addition).
- Around line 56-97: The UI hides questionText and uses a plain span for answers
when hasImage is false; move the <h1> that renders currentQuestion.questionText
out of the hasImage conditional so the question is always shown, and change the
no-image branch inside the image/container area to render the same highlighted
answer box used in the image case when showAnswer is true (use the same classes
as the div that displays currentQuestion?.questionAnswer in the hasImage true
path); update the conditional that currently returns the plain <span> to return
the emphasized answer container when !hasImage && showAnswer, otherwise keep
showing the question text.
- Around line 31-37: The global keydown handler is causing duplicate calls
because pressing Enter on a focused PrimaryBoxButton fires both keydown and the
button's onClick (calling onNextQuestion/onShowAnswer); in the window keydown
listener in gamePlayContent.tsx, detect if the event originates from a button
(e.g., check event.target or document.activeElement is an HTMLButtonElement or
has role="button") and early-return when true so the handler only acts for
non-button key events, preserving existing logic that calls onNextQuestion and
onShowAnswer otherwise.

---

Duplicate comments:
In `@service/app/src/app/game/`[gameId]/play/components/gamePlayContent.tsx:
- Around line 28-29: The current guard using
document.querySelector("[role='dialog']") in gamePlayContent.tsx (variable
isDialogOpen) is acceptable; no change required—keep the check as implemented
(isDialogOpen = document.querySelector("[role='dialog']") !== null) because
Radix UI Dialog sets role="dialog" and this runtime DOM query avoids stale
closure issues.

---

Nitpick comments:
In `@service/app/src/app/game/`[gameId]/play/components/gamePlayContent.tsx:
- Line 51: The prop currentQuestion is non-optional so remove unnecessary
optional chaining: replace occurrences of currentQuestion?.imageUrl (used in
hasImage) and currentQuestion?.questionAnswer (used where questionAnswer is
accessed around the render logic) with plain currentQuestion.imageUrl and
currentQuestion.questionAnswer to be consistent with other uses; ensure hasImage
and any other references use currentQuestion without ?. so types remain accurate
and consistent (check usages near hasImage, and the two spots that access
questionAnswer).

Comment on lines +31 to +37
if (e.key === "Enter" || e.key === "ArrowRight") {
if (showAnswer) {
onNextQuestion()
} else {
onShowAnswer()
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

포커스된 버튼에서 Enter 입력 시 onNextQuestion이 두 번 호출될 수 있습니다.

window keydown 핸들러와 PrimaryBoxButtononClick이 동일한 함수를 호출합니다. 사용자가 Tab으로 버튼에 포커스한 후 Enter를 누르면, 브라우저가 keydownclick 이벤트를 순차적으로 발생시켜 onNextQuestion()(또는 onShowAnswer())이 중복 호출됩니다. showAnswer === true일 때 두 번 호출되면 문제를 한 라운드 건너뛸 수 있습니다.

🐛 수정 제안 — 버튼 요소가 이벤트 발생원일 경우 early return 추가
     (e: KeyboardEvent) => {
       const isDialogOpen = document.querySelector("[role='dialog']") !== null
       if (isDialogOpen) return

+      if (e.target instanceof HTMLButtonElement) return
+
       if (e.key === "Enter" || e.key === "ArrowRight") {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@service/app/src/app/game/`[gameId]/play/components/gamePlayContent.tsx around
lines 31 - 37, The global keydown handler is causing duplicate calls because
pressing Enter on a focused PrimaryBoxButton fires both keydown and the button's
onClick (calling onNextQuestion/onShowAnswer); in the window keydown listener in
gamePlayContent.tsx, detect if the event originates from a button (e.g., check
event.target or document.activeElement is an HTMLButtonElement or has
role="button") and early-return when true so the handler only acts for
non-button key events, preserving existing logic that calls onNextQuestion and
onShowAnswer otherwise.

Comment on lines +39 to +41
if (e.key === "ArrowLeft" && currentRound > 1) {
onPrevQuestion()
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Arrow 키에 e.preventDefault() 누락으로 스크롤과 네비게이션이 동시에 발생할 수 있습니다.

ArrowLeft/ArrowRight 처리 시 e.preventDefault()를 호출하지 않아, 페이지에 스크롤 가능한 영역이 있을 경우 네비게이션과 스크롤이 함께 발생합니다. Enter에도 같은 이슈가 있으나, ArrowRight 분기도 동일하게 적용하는 것이 일관성 있습니다.

🐛 수정 제안
       if (e.key === "Enter" || e.key === "ArrowRight") {
+        e.preventDefault()
         if (showAnswer) {
           onNextQuestion()
         } else {
           onShowAnswer()
         }
       }

       if (e.key === "ArrowLeft" && currentRound > 1) {
+        e.preventDefault()
         onPrevQuestion()
       }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@service/app/src/app/game/`[gameId]/play/components/gamePlayContent.tsx around
lines 39 - 41, The keyboard handler for navigation is missing
e.preventDefault(), causing page scrolling to occur alongside navigation; update
the key handling branches in the component that checks e.key (the ArrowLeft,
ArrowRight and Enter cases) to call e.preventDefault() before invoking
onPrevQuestion(), onNextQuestion(), or the submit handler so navigation doesn't
trigger page scroll or default browser behavior (ensure ArrowLeft retains its
currentRound > 1 guard and ArrowRight/Enter branches mirror the preventDefault
addition).

Comment on lines +56 to +97
{hasImage && (
<h1 className="text-center text-text-primary sm:typography-heading-3xl-extrabold md:typography-heading-4xl-extrabold">
{currentQuestion.questionText}
</h1>
)}

{currentRound === totalRounds && (
<Link
href="./result"
className="typography-heading-3xl-semibold inline-flex w-[572px] cursor-pointer flex-row items-center justify-center gap-8 rounded-12 border-[3px] border-transparent bg-background-secondary px-32 py-12 text-text-interactive-inverse hover:bg-background-interactive-primary-hovered active:border-transparent active:bg-background-interactive-primary-pressed"
>
결과 화면으로 이동하기
</Link>
<div
className="flex h-[246px] items-center justify-center overflow-hidden rounded-lg sm:h-[406px] md:h-[499px]"
style={
hasImage && currentImageWidth
? { width: currentImageWidth }
: undefined
}
>
{hasImage ? (
showAnswer ? (
<div className="flex size-full items-center justify-center rounded-12 border-2 border-border-interactive-secondary bg-background-interactive-inverse px-32 py-12 text-text-interactive-secondary sm:typography-heading-3xl-semibold md:typography-heading-4xl-semibold">
{currentQuestion?.questionAnswer}
</div>
) : (
<Image
src={currentQuestion.imageUrl}
alt="문제 이미지"
width={0}
height={0}
sizes="100vw"
className="h-full w-auto"
onLoad={(e) => {
const img = e.currentTarget
setImageWidths((prev) => ({
...prev,
[currentRound]: img.offsetWidth,
}))
}}
/>
)
) : (
<span className="text-center text-text-primary sm:typography-heading-3xl-extrabold md:typography-heading-4xl-extrabold">
{showAnswer
? currentQuestion?.questionAnswer
: currentQuestion.questionText}
</span>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

이미지 없는 문제에서 정답 공개 시 questionText가 사라지며, 정답 스타일도 불일치합니다.

두 가지 문제가 함께 존재합니다:

  1. questionText 미노출: hasImagefalse이면 <h1> 레더링이 생략되고, 정답 공개 시 <span>questionAnswer로 교체되면서 질문 텍스트가 완전히 사라집니다. 이미지 있는 문제는 정답 공개 후에도 <h1>을 통해 질문이 항상 보이는 것과 비교하면 비대칭적인 UX 회귀입니다.

  2. 정답 스타일 불일치: 이미지 있는 문제의 정답은 border, bg-background-interactive-inverse 등이 적용된 강조 박스로 표시되지만, 이미지 없는 문제의 정답은 질문 텍스트와 동일한 일반 <span>으로 표시되어 사용자가 현재 정답을 보고 있는지 구분하기 어렵습니다.

🐛 수정 제안
-        {hasImage && (
-          <h1 className="text-center text-text-primary sm:typography-heading-3xl-extrabold md:typography-heading-4xl-extrabold">
-            {currentQuestion.questionText}
-          </h1>
-        )}
+        {(hasImage || showAnswer) && (
+          <h1 className="text-center text-text-primary sm:typography-heading-3xl-extrabold md:typography-heading-4xl-extrabold">
+            {currentQuestion.questionText}
+          </h1>
+        )}

이미지 없는 문제에서 정답 공개 시 컨테이너 안의 <span> 내용도 동일한 강조 스타일을 사용하도록 변경:

         ) : (
-            <span className="text-center text-text-primary sm:typography-heading-3xl-extrabold md:typography-heading-4xl-extrabold">
-              {showAnswer
-                ? currentQuestion?.questionAnswer
-                : currentQuestion.questionText}
-            </span>
+            showAnswer ? (
+              <div className="flex size-full items-center justify-center rounded-12 border-2 border-border-interactive-secondary bg-background-interactive-inverse px-32 py-12 text-text-interactive-secondary sm:typography-heading-3xl-semibold md:typography-heading-4xl-semibold">
+                {currentQuestion?.questionAnswer}
+              </div>
+            ) : (
+              <span className="text-center text-text-primary sm:typography-heading-3xl-extrabold md:typography-heading-4xl-extrabold">
+                {currentQuestion.questionText}
+              </span>
+            )
         )}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@service/app/src/app/game/`[gameId]/play/components/gamePlayContent.tsx around
lines 56 - 97, The UI hides questionText and uses a plain span for answers when
hasImage is false; move the <h1> that renders currentQuestion.questionText out
of the hasImage conditional so the question is always shown, and change the
no-image branch inside the image/container area to render the same highlighted
answer box used in the image case when showAnswer is true (use the same classes
as the div that displays currentQuestion?.questionAnswer in the hasImage true
path); update the conditional that currently returns the plain <span> to return
the emphasized answer container when !hasImage && showAnswer, otherwise keep
showing the question text.

@kimnamheeee kimnamheeee merged commit e002228 into dev Feb 19, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant