From fdf3c9876d2b2e5c7106604e6a97f745222d6d7d Mon Sep 17 00:00:00 2001 From: jmj Date: Thu, 20 Aug 2026 14:33:28 +0900 Subject: [PATCH] =?UTF-8?q?fix(frontend):=20=EC=A2=81=EC=9D=80=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=EB=A9=B4=EC=A0=91=20=EC=A7=84=EC=9E=85=20=EC=95=88?= =?UTF-8?q?=EB=82=B4=20+=20=ED=97=A4=EB=8D=94=20=EC=B6=95=EC=86=8C=20?= =?UTF-8?q?=EB=B0=A9=EC=96=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UI/UX 점검. 면접 스테이지는 반응형 클래스가 `sm:bottom-28` **하나뿐**이고, `docs/ui-patterns.md §9` 에 규약만 있던 모바일 안내는 구현돼 있지 않았다. - `SmallScreenNotice` — 768px 미만 진입 시 1회성 안내(dismiss 는 localStorage 영속). 막지 않고 기대치만 맞춘다. 좁은 화면에서 요소가 겹칠 때 이유를 모르면 사용자는 제품이 고장난 줄 안다. 마운트 시점 폭만 보고 리사이즈에는 반응하지 않는다 — 한 번 안내하는 힌트이지 창을 줄일 때마다 끼어들 이유가 없다 - 헤더 우측 컨트롤 묶음에 `shrink-0` — 없으면 좁은 화면에서 버튼 글자가 눌려 읽히지 않는다 (모드 토글·연결 배지·기록·종료 4개가 축소 방어 없이 들어 있었다) - 연결 배지는 좁은 화면에서 숨긴다. `ConnectionBanner` 가 같은 내용을 이미 알리고 있어 좁은 폭에서 중복으로 자리만 차지했다 `hover:bg-info-100` 을 쓰려다 발견 — info 토큰은 50/500/700 만 있다. 없는 토큰은 조용히 무효가 되므로 있는 것으로 바꿨다. 테스트 `SmallScreenNotice.test.tsx` (3) — 좁은 화면 노출, 넓은 화면 미노출, 닫으면 재노출 안 함. --- docs/ui-patterns.md | 3 +- .../interview/ui/live/InterviewStage.tsx | 10 +++- .../ui/live/SmallScreenNotice.test.tsx | 44 ++++++++++++++ .../interview/ui/live/SmallScreenNotice.tsx | 60 +++++++++++++++++++ 4 files changed, 114 insertions(+), 3 deletions(-) create mode 100644 frontend/src/features/interview/ui/live/SmallScreenNotice.test.tsx create mode 100644 frontend/src/features/interview/ui/live/SmallScreenNotice.tsx diff --git a/docs/ui-patterns.md b/docs/ui-patterns.md index aeb62c6..873596b 100644 --- a/docs/ui-patterns.md +++ b/docs/ui-patterns.md @@ -146,7 +146,8 @@ UI 표현: ## 9. 모바일 대응 -- 면접 세션은 데스크탑 우선. 모바일 진입 시 "데스크탑 환경 권장" 배너 (dismissable) +- 면접 세션은 데스크탑 우선. 좁은 화면(<768px) 진입 시 1회성 안내 배너 — `SmallScreenNotice` + (dismiss 상태는 localStorage 영속, 마운트 시점 폭만 판정해 리사이즈마다 끼어들지 않음) - 외 페이지: 768px 이하에서 SideNav → Drawer로 전환, TopNav 햄버거 메뉴 --- diff --git a/frontend/src/features/interview/ui/live/InterviewStage.tsx b/frontend/src/features/interview/ui/live/InterviewStage.tsx index dba1717..043bfc3 100644 --- a/frontend/src/features/interview/ui/live/InterviewStage.tsx +++ b/frontend/src/features/interview/ui/live/InterviewStage.tsx @@ -8,6 +8,7 @@ import type { Session } from '@/domain/session' import type { ConnectionStatus, ThreadItem } from '../../model/useLiveInterview' import type { DeliveryMode } from '../../model/useDeliveryMode' import { ConnectionBanner } from './ConnectionBanner' +import { SmallScreenNotice } from './SmallScreenNotice' import { AnswerComposer } from './AnswerComposer' import { StageQuestion } from './StageQuestion' import { DeliveryModeToggle } from './DeliveryModeToggle' @@ -124,9 +125,13 @@ export function InterviewStage({ )}

-
+ {/* shrink-0 이 없으면 좁은 화면에서 버튼 글자가 눌려 읽히지 않는다. + 연결 배지는 좁은 화면에서 숨긴다 — ConnectionBanner 가 같은 내용을 이미 알린다. */} +
- {connLabel[connection]} + + {connLabel[connection]} + @@ -136,6 +141,7 @@ export function InterviewStage({
+
diff --git a/frontend/src/features/interview/ui/live/SmallScreenNotice.test.tsx b/frontend/src/features/interview/ui/live/SmallScreenNotice.test.tsx new file mode 100644 index 0000000..47d2382 --- /dev/null +++ b/frontend/src/features/interview/ui/live/SmallScreenNotice.test.tsx @@ -0,0 +1,44 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { SmallScreenNotice } from './SmallScreenNotice' + +const originalWidth = window.innerWidth + +function setWidth(px: number) { + Object.defineProperty(window, 'innerWidth', { value: px, configurable: true, writable: true }) +} + +beforeEach(() => window.localStorage.clear()) +afterEach(() => setWidth(originalWidth)) + +describe('SmallScreenNotice', () => { + it('좁은 화면에서 안내를 보여준다', () => { + setWidth(375) + + render() + + expect(screen.getByText(/데스크탑 환경을 권장/)).toBeInTheDocument() + }) + + it('넓은 화면에서는 끼어들지 않는다', () => { + setWidth(1280) + + render() + + expect(screen.queryByText(/데스크탑 환경을 권장/)).not.toBeInTheDocument() + }) + + // 매 진입마다 같은 안내가 뜨면 그게 더 방해가 된다. + it('한 번 닫으면 다시 뜨지 않는다', async () => { + setWidth(375) + const { unmount } = render() + + await userEvent.click(screen.getByRole('button', { name: '안내 닫기' })) + expect(screen.queryByText(/데스크탑 환경을 권장/)).not.toBeInTheDocument() + unmount() + + render() + expect(screen.queryByText(/데스크탑 환경을 권장/)).not.toBeInTheDocument() + }) +}) diff --git a/frontend/src/features/interview/ui/live/SmallScreenNotice.tsx b/frontend/src/features/interview/ui/live/SmallScreenNotice.tsx new file mode 100644 index 0000000..28fe7e0 --- /dev/null +++ b/frontend/src/features/interview/ui/live/SmallScreenNotice.tsx @@ -0,0 +1,60 @@ +import { useState } from 'react' + +const STORAGE_KEY = 'stackup:interview-small-screen-notice-dismissed' +// 아래로는 면접 스테이지(질문 카드 + 컴포저 + 셀프뷰)가 서로 겹치기 시작한다. +const MIN_COMFORTABLE_WIDTH = 768 + +/** + * 좁은 화면에서 한 번만 뜨는 안내 (`docs/ui-patterns.md §9`). + * + *

면접 화면은 데스크탑 우선으로 만들어졌다 — 웹캠 셀프뷰·질문 카드·컴포저가 한 화면에 + * 있어야 하고 마이크 권한도 필요하다. 좁은 화면에서 뭔가 어긋나 보일 때 이유를 모르면 + * 사용자는 제품이 고장난 줄 안다. 막지는 않고(진행은 가능하다) 기대치만 맞춘다. + */ +// 마운트 시점의 폭만 본다. 리사이즈에 반응하지 않는 건 의도다 — 한 번 안내하는 힌트이지 +// 창을 줄일 때마다 다시 끼어들 이유가 없다. 이펙트가 아니라 lazy 초기값으로 계산한다 +// (이펙트에서 setState 하면 렌더가 한 번 더 돈다 — react-hooks/set-state-in-effect). +function shouldShow(): boolean { + if (typeof window === 'undefined') return false + if (window.innerWidth >= MIN_COMFORTABLE_WIDTH) return false + try { + return window.localStorage.getItem(STORAGE_KEY) !== '1' + } catch { + // 프라이빗 모드 등 저장이 막힌 경우 — 안내는 보여준다. + return true + } +} + +export function SmallScreenNotice() { + const [visible, setVisible] = useState(shouldShow) + + if (!visible) return null + + const dismiss = () => { + setVisible(false) + try { + window.localStorage.setItem(STORAGE_KEY, '1') + } catch { + /* 무시 */ + } + } + + return ( +

+ + 화면이 좁아 일부 요소가 겹칠 수 있어요. 데스크탑 환경을 권장합니다. + + +
+ ) +}