Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/ui-patterns.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,8 @@ UI 표현:

## 9. 모바일 대응

- 면접 세션은 데스크탑 우선. 모바일 진입 시 "데스크탑 환경 권장" 배너 (dismissable)
- 면접 세션은 데스크탑 우선. 좁은 화면(<768px) 진입 시 1회성 안내 배너 — `SmallScreenNotice`
(dismiss 상태는 localStorage 영속, 마운트 시점 폭만 판정해 리사이즈마다 끼어들지 않음)
- 외 페이지: 768px 이하에서 SideNav → Drawer로 전환, TopNav 햄버거 메뉴

---
Expand Down
10 changes: 8 additions & 2 deletions frontend/src/features/interview/ui/live/InterviewStage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -124,9 +125,13 @@ export function InterviewStage({
)}
</p>
</div>
<div className="flex items-center gap-2">
{/* shrink-0 이 없으면 좁은 화면에서 버튼 글자가 눌려 읽히지 않는다.
연결 배지는 좁은 화면에서 숨긴다 — ConnectionBanner 가 같은 내용을 이미 알린다. */}
<div className="flex shrink-0 items-center gap-2">
<DeliveryModeToggle value={deliveryMode} onChange={onDeliveryModeChange} />
<StatusBadge tone={connTone[connection]}>{connLabel[connection]}</StatusBadge>
<span className="hidden sm:inline">
<StatusBadge tone={connTone[connection]}>{connLabel[connection]}</StatusBadge>
</span>
<Button variant="ghost" size="sm" onClick={() => setTranscriptOpen(true)}>
기록
</Button>
Expand All @@ -136,6 +141,7 @@ export function InterviewStage({
</div>
</header>

<SmallScreenNotice />
<ConnectionBanner connection={connection} />

<div className="relative z-10 flex flex-1 flex-col items-center justify-center gap-6 overflow-y-auto px-5 py-8">
Expand Down
44 changes: 44 additions & 0 deletions frontend/src/features/interview/ui/live/SmallScreenNotice.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<SmallScreenNotice />)

expect(screen.getByText(/데스크탑 환경을 권장/)).toBeInTheDocument()
})

it('넓은 화면에서는 끼어들지 않는다', () => {
setWidth(1280)

render(<SmallScreenNotice />)

expect(screen.queryByText(/데스크탑 환경을 권장/)).not.toBeInTheDocument()
})

// 매 진입마다 같은 안내가 뜨면 그게 더 방해가 된다.
it('한 번 닫으면 다시 뜨지 않는다', async () => {
setWidth(375)
const { unmount } = render(<SmallScreenNotice />)

await userEvent.click(screen.getByRole('button', { name: '안내 닫기' }))
expect(screen.queryByText(/데스크탑 환경을 권장/)).not.toBeInTheDocument()
unmount()

render(<SmallScreenNotice />)
expect(screen.queryByText(/데스크탑 환경을 권장/)).not.toBeInTheDocument()
})
})
60 changes: 60 additions & 0 deletions frontend/src/features/interview/ui/live/SmallScreenNotice.tsx
Original file line number Diff line number Diff line change
@@ -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`).
*
* <p>면접 화면은 데스크탑 우선으로 만들어졌다 — 웹캠 셀프뷰·질문 카드·컴포저가 한 화면에
* 있어야 하고 마이크 권한도 필요하다. 좁은 화면에서 뭔가 어긋나 보일 때 이유를 모르면
* 사용자는 제품이 고장난 줄 안다. 막지는 않고(진행은 가능하다) 기대치만 맞춘다.
*/
// 마운트 시점의 폭만 본다. 리사이즈에 반응하지 않는 건 의도다 — 한 번 안내하는 힌트이지
// 창을 줄일 때마다 다시 끼어들 이유가 없다. 이펙트가 아니라 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 (
<div
role="status"
className="relative z-20 flex items-center justify-between gap-3 bg-info-50 px-4 py-2 text-caption text-info-700"
>
<span style={{ wordBreak: 'keep-all' }}>
화면이 좁아 일부 요소가 겹칠 수 있어요. 데스크탑 환경을 권장합니다.
</span>
<button
type="button"
onClick={dismiss}
aria-label="안내 닫기"
className="shrink-0 rounded-md px-2 py-1 font-medium underline-offset-2 hover:underline"
>
닫기
</button>
</div>
)
}
Loading