From 2ebb8dbc322b19f431f318b369b2df42dc6c5a1f Mon Sep 17 00:00:00 2001 From: jisangs Date: Tue, 26 May 2026 17:34:40 +0900 Subject: [PATCH] =?UTF-8?q?=EB=A6=AC=ED=8E=99=ED=86=A0=EB=A7=81:=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=B5=9C=EC=A0=81=ED=99=94,=20AGENTS.md,?= =?UTF-8?q?=20README.md=20=EB=82=B4=EC=9A=A9=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 14 +++++++--- README.md | 13 ++++++--- app/(game)/play/PlayClient.tsx | 45 ++++++++++++++++--------------- components/game/AnswerForm.tsx | 48 +++++++++++++--------------------- 4 files changed, 62 insertions(+), 58 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8de2bb0..dfbbaf7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -91,9 +91,13 @@ prisma/ ### 접근성 / 키보드 조작 - 문제 풀이 화면은 키보드만으로 진행할 수 있어야 한다. -- 새 문제가 표시되면 첫째 항 입력 칸에 focus를 둔다. -- 첫째 항에서 Enter를 누르면 둘째 항 입력 칸으로 focus를 이동한다. -- 둘째 항에서 Enter를 누르면 제출 동작을 실행한다. +- 문제 풀이 화면은 모든 항을 하나의 공유 input group으로 입력한다. +- input group 아래에는 각 항의 입력값 버튼을 버튼 그룹으로 표시한다. 현재 입력 중인 항은 `aria-pressed`로 표시하고, 비어 있는 항은 `?`로 표시한다. +- 첫째 항에서 Enter를 누르면 같은 input group이 둘째 항 입력으로 전환된다. +- 둘째 항에서 Enter를 누르면 제출 동작을 실행한다. 별도 제출 버튼이 없어도 form submit 흐름이 유지되어야 한다. +- 모바일 화면에서는 input group에 `-/+` 버튼과 `↵` 버튼을 표시한다. `sm` 이상 화면에서는 물리 키보드 사용을 전제로 이 보조 버튼들을 숨긴다. +- 문제 풀이 화면 최초 진입 시 모바일 키보드를 강제로 열지 않는다. 사용자가 input을 직접 focus하거나 피드백 후 다음 문제로 넘어갈 때 input에 focus를 둔다. +- 모바일 input focus 시 가상 키보드가 올라온 뒤 `span.katex` 상단이 브라우저 화면 상단에 오도록 조정한다. - 정답·오답 피드백이 표시되면 다음 진행 버튼에 focus를 둔다. - 최종 결과 화면이 표시되면 다시 시작 버튼에 focus를 둔다. - 아이콘이나 기호만으로 상태를 표시할 때는 `aria-label`, `sr-only` 텍스트 등으로 의미를 함께 제공한다. @@ -157,12 +161,16 @@ NEXTAUTH_SECRET=your-secret 시작 화면 → 문제 유형 선택 → 게임 모드 선택 → 문제 풀이 → 결과 및 기록 ``` +- 시작 화면은 문제 수 `[5] [10]` 선택 버튼 그룹을 제공하고 기본값은 `10`이다. +- 문제 풀이 화면은 URL의 `count` 쿼리와 Zustand의 선택 문제 수를 동기화한다. 허용 문제 수는 `5`, `10`이며 유효하지 않은 값은 `10`으로 처리한다. + --- ## CI/CD - `.gitea/workflows/deploy.yml`이 CI/CD 진입점이다. main 브랜치 push 시 자동으로 빌드·배포된다. - 패키지 설치는 반드시 `pnpm`을 사용한다. `npm` / `yarn` 명령을 사용하지 않는다. +- 빌드 확인은 `pnpm build`로 실행한다. standalone 배포 산출물에 필요한 정적 파일 복사까지 `postbuild` 스크립트가 처리한다. - 배포 환경: Synology NAS + Docker Compose + Nginx Proxy Manager. --- diff --git a/README.md b/README.md index 8659f87..0dd2159 100644 --- a/README.md +++ b/README.md @@ -48,16 +48,19 @@ ## 현재 구현 상태 -- 시작 화면에서 `/play` 풀이 화면으로 이동할 수 있습니다. -- `/play`에서 괄호 앞 음수 분배 문제 10개를 풉니다. +- 시작 화면에서 문제 수를 5개 또는 10개로 선택한 뒤 `/play` 풀이 화면으로 이동할 수 있습니다. +- 문제 수 기본값은 10개이며, `/play?count=5` 또는 `/play?count=10`으로 선택값을 전달합니다. - 문제와 정답 수식은 KaTeX로 렌더링합니다. - 진행 단계는 현재 문제, 완료된 정답, 완료된 오답, 남은 문제를 구분해서 표시합니다. - 키보드 흐름을 지원합니다. - - 새 문제 시작 시 첫째 항 입력 칸에 focus - - 첫째 항 Enter → 둘째 항 입력 칸으로 이동 + - 모든 항은 하나의 input group에서 순서대로 입력 + - 첫째 항 Enter → 같은 input group이 둘째 항 입력으로 전환 - 둘째 항 Enter → 제출 - 피드백 화면 → 다음 문제/결과 보기 버튼에 focus + - 다음 문제 이동 후 → input에 focus - 결과 화면 → 다시 시작 버튼에 focus +- input group 아래에는 각 항의 입력값 버튼이 표시됩니다. 비어 있는 항은 `?`로 표시하고, 버튼을 누르면 해당 항을 초기화한 뒤 다시 입력합니다. +- 모바일 화면에서는 `-/+`와 `↵` 보조 버튼을 표시하고, input focus 후 가상 키보드가 올라오면 문제 수식이 화면 상단에 오도록 스크롤 위치를 조정합니다. --- @@ -104,6 +107,8 @@ pnpm build pnpm start ``` +`pnpm build`는 Next.js standalone 산출물에 필요한 정적 파일 복사까지 함께 처리합니다. + --- ## 프로젝트 구조 diff --git a/app/(game)/play/PlayClient.tsx b/app/(game)/play/PlayClient.tsx index 3c21f3d..2a5c6b4 100644 --- a/app/(game)/play/PlayClient.tsx +++ b/app/(game)/play/PlayClient.tsx @@ -16,6 +16,9 @@ import KatexRenderer from "@/components/math/KatexRenderer"; import { useGameStore } from "@/store/gameStore"; import type { ProblemCount } from "@/store/gameStore"; +const MOBILE_BREAKPOINT_QUERY = "(min-width: 640px)"; +const MOBILE_KEYBOARD_SPACER_OFFSET = 480; + function parseAnswer(value: string) { const trimmed = value.trim(); @@ -47,7 +50,6 @@ export default function PlayClient({ problemCount }: PlayClientProps) { const [answerA, setAnswerA] = useState(""); const [answerB, setAnswerB] = useState(""); const [activeInput, setActiveInput] = useState(0); - const [focusInputOnFormMount, setFocusInputOnFormMount] = useState(false); const [mobileFocusSpacerHeight, setMobileFocusSpacerHeight] = useState(0); const [inputError, setInputError] = useState(""); @@ -86,15 +88,17 @@ export default function PlayClient({ problemCount }: PlayClientProps) { setInputError(""); }, [currentProblem?.key]); - useEffect(() => { - return () => { - scrollTimeoutsRef.current.forEach((timeoutId) => { - window.clearTimeout(timeoutId); - }); - viewportResizeCleanupRef.current?.(); - }; + const clearViewportFocusCleanup = useCallback(() => { + scrollTimeoutsRef.current.forEach((timeoutId) => { + window.clearTimeout(timeoutId); + }); + scrollTimeoutsRef.current = []; + viewportResizeCleanupRef.current?.(); + viewportResizeCleanupRef.current = null; }, []); + useEffect(() => clearViewportFocusCleanup, [clearViewportFocusCleanup]); + function handleSubmit(event: FormEvent) { event.preventDefault(); @@ -113,15 +117,18 @@ export default function PlayClient({ problemCount }: PlayClientProps) { } function handleNextProblem() { - setFocusInputOnFormMount(true); flushSync(() => { nextProblem(); }); answerFormRef.current?.focusInput(); } + const handleInputBlur = useCallback(() => { + setMobileFocusSpacerHeight(0); + }, []); + const scrollKatexToViewportTop = useCallback(() => { - if (window.matchMedia("(min-width: 640px)").matches) return; + if (window.matchMedia(MOBILE_BREAKPOINT_QUERY).matches) return; function alignKatex() { const katexElement = @@ -158,15 +165,13 @@ export default function PlayClient({ problemCount }: PlayClientProps) { ); setMobileFocusSpacerHeight( - Math.max(window.innerHeight, keyboardHeight + 480), + Math.max( + window.innerHeight, + keyboardHeight + MOBILE_KEYBOARD_SPACER_OFFSET, + ), ); - scrollTimeoutsRef.current.forEach((timeoutId) => { - window.clearTimeout(timeoutId); - }); - scrollTimeoutsRef.current = []; - viewportResizeCleanupRef.current?.(); - viewportResizeCleanupRef.current = null; + clearViewportFocusCleanup(); if (visualViewport) { let settleTimeoutId: number | null = null; @@ -197,7 +202,7 @@ export default function PlayClient({ problemCount }: PlayClientProps) { const timeoutId = window.setTimeout(alignKatex, delay); scrollTimeoutsRef.current.push(timeoutId); }); - }, []); + }, [clearViewportFocusCleanup]); if (!session || sessionNeedsInit) { return ( @@ -272,13 +277,11 @@ export default function PlayClient({ problemCount }: PlayClientProps) { answerA={answerA} answerB={answerB} activeInput={activeInput} - disabled={submitted} error={inputError} - focusOnMount={focusInputOnFormMount} onActiveInputChange={setActiveInput} onAnswerAChange={setAnswerA} onAnswerBChange={setAnswerB} - onInputBlur={() => setMobileFocusSpacerHeight(0)} + onInputBlur={handleInputBlur} onInputFocus={scrollKatexToViewportTop} onSubmit={handleSubmit} /> diff --git a/components/game/AnswerForm.tsx b/components/game/AnswerForm.tsx index 36aba2b..2700530 100644 --- a/components/game/AnswerForm.tsx +++ b/components/game/AnswerForm.tsx @@ -3,7 +3,6 @@ import { forwardRef, useCallback, - useEffect, useId, useImperativeHandle, useRef, @@ -20,9 +19,7 @@ interface AnswerFormProps { answerA: string; answerB: string; activeInput: ActiveAnswerInput; - disabled: boolean; error: string; - focusOnMount?: boolean; onAnswerAChange: (value: string) => void; onAnswerBChange: (value: string) => void; onActiveInputChange: (input: ActiveAnswerInput) => void; @@ -36,9 +33,7 @@ const AnswerForm = forwardRef(function Answer answerA, answerB, activeInput, - disabled, error, - focusOnMount = false, onAnswerAChange, onAnswerBChange, onActiveInputChange, @@ -59,20 +54,21 @@ const AnswerForm = forwardRef(function Answer activeInput === 0 ? onAnswerAChange : onAnswerBChange; const focusInput = useCallback(() => { - inputRef.current?.focus({ preventScroll: true }); - onInputFocus?.(); + const input = inputRef.current; + + if (!input) return; + + const wasFocused = document.activeElement === input; + + input.focus({ preventScroll: true }); + + if (wasFocused) { + onInputFocus?.(); + } }, [onInputFocus]); useImperativeHandle(ref, () => ({ focusInput }), [focusInput]); - useEffect(() => { - if (!disabled && focusOnMount) { - window.requestAnimationFrame(() => { - focusInput(); - }); - } - }, [disabled, focusInput, focusOnMount]); - function setAnswer(input: ActiveAnswerInput, value: string) { if (input === 0) { onAnswerAChange(value); @@ -135,11 +131,10 @@ const AnswerForm = forwardRef(function Answer
@@ -148,12 +143,8 @@ const AnswerForm = forwardRef(function Answer ref={inputRef} type="text" value={activeAnswer} - onFocus={() => { - onInputFocus?.(); - }} - onBlur={() => { - onInputBlur?.(); - }} + onFocus={onInputFocus} + onBlur={onInputBlur} onChange={(event) => activeAnswerChange(event.target.value)} onKeyDown={(event) => { if (event.key === "Enter") { @@ -161,16 +152,14 @@ const AnswerForm = forwardRef(function Answer handleEnter(); } }} - disabled={disabled} autoComplete="off" enterKeyHint={activeEnterKeyHint} inputMode="numeric" pattern="-?[0-9]*" - className="h-full min-w-0 flex-1 px-3 text-lg font-semibold text-slate-950 outline-none disabled:bg-slate-100 sm:rounded-md sm:border sm:border-slate-300 sm:px-4 sm:transition sm:focus:border-emerald-600 sm:focus:ring-2 sm:focus:ring-emerald-200" + className="h-full min-w-0 flex-1 px-3 text-lg font-semibold text-slate-950 outline-none sm:rounded-md sm:border sm:border-slate-300 sm:px-4 sm:transition sm:focus:border-emerald-600 sm:focus:ring-2 sm:focus:ring-emerald-200" /> @@ -197,15 +186,14 @@ const AnswerForm = forwardRef(function Answer