Skip to content

화면(뷰포트) 대응 — 아이폰 Pro Max 양옆 여백에서 뷰포트 계층 계약까지 (2026-08-23)

  • 기간: 2026-08-23 (세션 2개 — 1차 세션이 실측·설계서 v1.1·오너 결정 D1~D4까지, 2차 세션이 Phase 1~5 연속 집행)
  • 랜딩: PR #600(Phase 1, 2648e96c) · #601(BUG-006 리포트, c77b9cca) · #602(Phase 2, 7bd10e6b) · #603(Phase 3·4, aa008f37) · #608(Phase 5, 96c0497c). 마이그레이션·엣지 0건, 웹은 Vercel 자동 배포
  • 설계서: 화면 대응 설계서 v1.1(artifact 36b415b5, "예상 효과·개선사항" 절 포함) — 본 문서가 완성본
  • 정본: 뷰포트 계층 계약 docs/platform/viewport-tiers.md, 컬럼 규칙 src/react/vite/app-host.css .gym-app-device, 루트 선택 src/react/vite/main.tsx DESKTOP_VIEWPORT_QUERY, 데스크톱 하한 src/react/vite/desktopRoot.tsx DESKTOP_ZOOM_FLOOR
  • 게이트: tests/react/backgroundTokens.test.mjs·tests/react/viewportTiers.test.mjs(scope 레인), e2e/viewport-matrix.spec.mjs(npm run test:e2e-viewport, full-ci 레인)
  • 버그리포트: bug-report/bug-006-20260823.md
  • 계약: docs/process/claude-free-design-contract.md "Viewport width" 절 신설

1. 배경

오너가 2026-08-23 보고했다 — "아이폰 프로맥스에서 열면 양쪽 끝에 하얗게 여백이 생긴다." 그리고 같은 자리에서 "다양한 스크린 대응 전반의 대책"을 요청했다. 하루 전(08-22) 갤럭시 폴드에서 같은 계열의 사고(#583 — 펼친 화면이 데스크톱 루트로 분류되고 강제 다크로 반전)를 막 닫은 뒤라, 이번에는 기기 하나를 고치는 대신 "지원 화면"을 이름과 숫자로 고정하는 트랙으로 잡았다.

로컬 vite를 Chromium 터치 에뮬레이션 440×956으로 열자 바로 재현됐다. .gym-app-device는 430px, 왼쪽 5px·오른쪽 5px이 남고, 그 틈으로 body의 데스크톱용 거터 배경(왼쪽 흰색 / 오른쪽 새벽 그라디언트)이 비쳤다. 호스트와 컬럼의 computed background는 둘 다 rgba(0,0,0,0) — 투명이었다.

2. 문제 제기

폰 컬럼에 숫자 상한이 박혀 있었다

.gym-app-device { max-width: 430px }는 Vite 컨테이너 최초 커밋(5ee149a6)부터 있었다. 430은 당시 가장 넓은 폰 (iPhone 14/15 Plus·Pro Max)의 CSS 폭이다. iPhone 16/17 Plus·Pro Max는 440이라 처음으로 선을 넘었다. 상한을 440으로 올리면 다음 Plus·Pro Max에서 같은 사고가 재생되고, "새 폰마다 숫자 갱신"이 운영 절차가 된다. 폰 폭(320~519)은 어차피 컬럼 하나라 상한이 주는 이득이 없다.

거터를 가려야 할 배경이 투명이었다

호스트와 컬럼은 background: #eef5f8; background: var(--bg);처럼 "폴백 + var()" 두 줄을 쓰고 있었다. 그런데 --bg.app/.lg-shell-scope(모바일)와 .dk-app/.adm(데스크톱) 스코프에만 정의돼 있고 호스트는 그 바깥이다. CSS에서 정의 안 된 변수를 쓴 선언은 파싱 단계가 아니라 계산 단계에서 무효(invalid at computed-value time)가 되어, 앞 줄의 폴백으로 돌아가지 않고 initial(투명)이 된다. 그래서 컬럼이 뷰포트보다 좁은 모든 경우 — 440 폰, 폴드, iPad, 가로 — 에 body의 거터가 그대로 보였다. #583이 "거터 색이 의도 밖"으로 남긴 항목의 원인이 바로 이것이었다.

지원 화면이 암묵이었다

모바일 CSS는 폭 기준 미디어쿼리 0건·vw/clamp/rem 0건으로 "컬럼 안에서만 유동"이다. 즉 컬럼 폭 규칙 하나가 곧 전 기기 대응인데, 그 규칙은 430 숫자 하나와 900 분기점 하나로 CSS 두 파일에 흩어져 있었고 이름도 문서도 없었다. 안전영역 토큰은 상·하만 있었고, records.css100vh는 iOS Safari 주소창 변동에 취약했으며, 데스크톱은 마우스 900px 이상이면 1913×1063 캔버스를 zoom = min(w/1913, h/1063)로 통째 축소해 932×440 창에서는 글자가 41%가 됐다(#583이 "장기 과제"로 남긴 것). iOS Info.plist는 아이폰 가로를 허용하고 있었다.

3. 해결 방안

원칙 (오너 결정 D1~D4, 2026-08-23 — 전부 권고안 채택)

#결정채택
D1폰 구간(터치 < 520) 컬럼 상한상한 없음 — 전폭. 새 폰 폭에 코드 변경 0
D2터치 ≥520(폴드·태블릿·가로) 컬럼600px 유지(08-22 3변형 실측값), 거터는 모바일 지면색
D3데스크톱 축소 창(마우스 900~1279)zoom 하한 + 캔버스 스크롤(최소 변경). 반응형화(1180~1913 유동)는 Design 로드맵으로 분리
D4아이폰 가로 모드세로 잠금(Info.plist iPhone Portrait + manifest orientation: portrait, iPad 전 방향 유지). 웹은 잠글 수 없으니 거터 색·안전영역으로 받는다

오너 지시: "Phase 1~5 중단 없이 자율로 완주."

접근

  • Phase 1만 사용자가 체감하는 수리다. Phase 2~5는 같은 사고가 다른 폭·다른 기기에서 반복되지 않게 하는 구조 작업이라 PR을 나눴다(1 → BUG 리포트 → 2 → 3·4 → 5).
  • 수리는 "숫자를 올리는" 대신 "상한을 없애고 거터를 격리"한다. 호스트 바깥은 리터럴만 쓴다.
  • 계층을 이름 붙여 문서로 고정하고, 문서의 숫자와 코드 상수를 계약 테스트로 묶는다 — 한쪽만 바꾸면 빨갛게.
  • 감사는 로그인 상태에서 탭 × 뷰포트를 JS로 판정한다(가로 넘침·경계 이탈·탭바 줄바꿈). 픽셀 스냅샷은 플레이크라 두지 않는다. 같은 스크립트가 Phase 5에서 CI 게이트가 된다.

4. 적용한 내용

Phase 1 — 폰 전폭 + 거터 배경 격리 (#600)

  • app-host.css .gym-app-device 기본 규칙에서 max-width: 430px 제거(width: 100%만). 터치 ≥520 600px·마우스 ≥520 390×844 목업 규칙은 그대로.
  • body의 "좌백/우새벽" 분할 그라디언트를 .gym-app-host.desktop@media (min-width:520px) and (pointer:fine) .gym-app-host (마우스 목업 프레임)로 옮기고, body·.gym-app-host·.gym-app-device는 모바일 지면 리터럴 oklch(0.972 0.004 240) (.app 글로우 베이스와 동일). 스코프 밖 var(--bg) 두 줄 삭제. 데스크톱은 fixed 기준이 같아 렌더가 픽셀 단위로 동일.
  • backgroundTokens.test.mjs: body 분할 배경 단언 → "body 리터럴 + 분할 거터는 데스크톱 호스트/목업 호스트" 단언, 호스트· 컬럼 var(--bg) 금지, 폰 구간에 max-width 없음 불변식. 원래 CSS에 대면 1건 빨강.
  • 실측(로컬 Playwright, 로그인 상태 홈, 변경 전/후 같은 스크립트):
#뷰포트입력컬럼 전 → 후거터 좌/우 전 → 후호스트 배경 전 → 후
V1360×780 갤럭시 S24터치360 → 3600/0투명 → 지면
V2375×667 iPhone SE터치375 → 3750/0투명 → 지면
V3390×844 iPhone 14터치390 → 3900/0픽셀 diff 0
V4402×874 iPhone 16터치402 → 4020/0투명 → 지면
V5440×956 iPhone 16/17 Pro Max터치430 → 4405/5 → 0/0투명 → 지면
V6820×1180 iPad 10 세로터치600 → 600110/110좌백/우청 → 지면
V7984×1092 갤럭시 폴드 내부터치600 → 600192/192좌백/우청 → 지면
V8932×440 Pro Max 가로터치600 → 600166/166좌백/우청 → 지면
V91440×900 · 1280×720마우스데스크톱 zoom 0.753 · 0.669픽셀 diff 0

Phase 1-5 — 버그리포트 BUG-006 (#601)

오너 형식(3줄 요약 + 5절 + 절별 요약). 번호는 같은 날 #599(탑세트)가 BUG-005를 먼저 썼으므로 006.

Phase 2 — 뷰포트 계층 계약 (#602)

  • docs/platform/viewport-tiers.md 신설. 계층 6개를 이름·경계·루트·컬럼·소유 파일·검증 뷰포트로 고정: compact(320~359) · phone(360~519, 전폭·상한 없음) · wide-touch(터치 ≥520, 600) · narrow-pointer(마우스 <520, 전폭) · pointer-mockup(마우스 520~899, 390×844) · desktop(마우스 ≥900, 1913×1063 zoom·1180 최소). 거터 규칙, var(--bg) 함정, 안전영역, 방향, "새 기기가 나왔을 때" 절차.
  • Design 계약문 "Viewport width": 360~600 유동이 정상, 390×844 프레임은 기준이지 상한이 아님, 고정 px는 max-width로만, 360·440·600에서 깨지면 계약 위반(반입 감사 항목).
  • tokens.css --safe-left/--safe-right + .app 좌우 패딩(세로 0, 웹 가로 노치). records.css .rc-dempty 100vh → 100dvh.
  • D4: ios/App/App/Info.plist iPhone Portrait만(~ipad 전 방향 유지), public/manifest.webmanifest "orientation": "portrait".
  • viewportTiers.test.mjs: 문서의 분기점·컬럼·캔버스 숫자(520/600/900·390×844·1913×1063·1180·320) = 코드 상수, 호스트 폭 미디어쿼리는 520 하나뿐, 안전영역 4변, 모바일 화면 CSS 100vh 부재, Info.plist/manifest 방향, 계약문 조항.

Phase 3 — 화면 감사 매트릭스 (#603, 수리 0건)

로그인 상태(일회용 유저 + 완료 세션 6건)로 홈·일지·하루 시트·세션 상세·리포트·피드·내 정보·운동 시작 8화면을 뷰포트 9종에서 순회하며 JS로 판정했다 — documentElement·.app .screen 가로 넘침, 컬럼 경계 밖 요소(오버레이·오프캔버스· 가로 스크롤 컨테이너 제외), 탭바 라벨 줄바꿈, 리프 .num 넘침, 호스트 배경.

뷰포트계층컬럼결과(8화면)
320×568compact320넘침 0 · 이탈 0 · 탭바 58px×5
360 · 375 · 390 · 440phone전폭넘침 0 · 이탈 0 · 탭바 66/69/72/82px×5
820×1180 · 984×1092 · 932×440wide-touch600넘침 0 · 이탈 0 · 탭바 114px×5, 가로 440 높이에서 시트 정상
800×900 마우스pointer-mockup390넘침 0 · 이탈 0

유일한 측정 적중은 리포트 연간 잔디의 월 라벨 span.qm.num("10월", 8.5px)이 주 컬럼(≈8px)보다 2~7px 넓은 것 — 폭과 무관하게 전 뷰포트에서 같고, 이웃 빈 칸 위로 걸치는 설계상 넘침이라 수리 대상이 아니다. "좁은 폭 넘침 / 넓은 폭 과벌어짐 / 가로" 세 묶음 모두 0건 — 08-22의 600 상한이 넓은 폭을 이미 받고 있었다.

Phase 4 — 데스크톱 축소 창 zoom 하한 (#603, D3)

  • desktopRoot.tsx·admin/AdminStandaloneRoot.tsx: DESKTOP_ZOOM_FLOOR = 0.7, zoom = max(0.7, min(w/1913, h/1063)). 클램프 시 --lg-desktop-root-overflow: auto·--lg-desktop-root-align: start를 루트 스타일에 싣고, app-host.css.gym-app-host.desktopoverflow·place-items를 그 변수로 소비한다 — grid center + overflow는 좌상단이 잘리므로 정렬도 같이 바꾼다.
  • 실측: 1440×900 0.753·1366×768 0.714 무변화(hidden·center), 1280×720 0.669 → 0.700, 932×440 0.414 → 0.700(auto·start, 캔버스 1339×744 스크롤 확인).

Phase 5 — 뷰포트 매트릭스를 CI 게이트로 (#608)

  • e2e/viewport-matrix.spec.mjs: 계층별 대표 뷰포트 9종 × 탭 5개(홈·일지·리포트·피드·내 정보) + 데스크톱 2종(무클램프· 클램프). 단언은 구조 3개 — 가로 넘침 0 · 컬럼 폭 = 계층 규칙값 · 호스트 배경 ≠ 투명 — 와 데스크톱 zoom/overflow 규칙. 스크린샷은 test-results/viewport-matrix/ → CI 아티팩트 viewport-matrix-screenshots(눈 확인용).
  • playwright.viewport.config.mjs(기본 config와 같은 4173 preview, error-cases 발견 규칙과 분리), npm run test:e2e-viewport, policy-contract.yml migration-smoke(= full-ci) 레인 마지막 스텝. scope 정책이 e2e/·playwright.*.config·워크플로 변경을 자동 full로 올리므로 PR 자체가 첫 실행이었다 — migration-smoke 7m44s 초록, 아티팩트 5.7MB.
  • 게이트 증명: .gym-app-devicemax-width: 430px를 되돌리면 V5 home: column width — Expected 440, Received 430 빨강 (계약 테스트와 이중 방어). docs/testing/browser-full-stack-e2e.md "Viewport matrix" 절.

작업 중 드러난 것

  • 문서 링크가 매번 깨지던 이유. 작업은 scratchpad git worktree(사이드 브랜치)에서 하는데 보고의 파일 링크는 주 작업 디렉토리 기준 상대 경로였다 — 머지 전에는 파일이 거기 없고, 머지 후에도 주 체크아웃을 당기기 전엔 없다. 전역 지침에 "링크는 파일이 실제로 있는 곳을 가리킨다(미머지 = 절대 경로/GitHub, 머지 후 = 주 체크아웃 fast-forward 뒤 상대 경로)" 규칙을 추가했다.
  • git stash를 복합 bash 명령에 넣자 워크트리 인덱스가 전부 삭제 상태로 깨졌다(작업 트리는 무사, git reset으로 복구). 같은 턴에 두 번 밟았다 — 변경 전/후 A/B는 파일 복사로 한다.
  • 인라인 node -e 문자열의 백틱을 bash가 명령 치환해 잡파일("3줄")을 만들었다. 다중행 치환 스크립트는 파일로 쓴다.
  • 감사 측정기의 오탐 3종(닫힌 드로어의 오프캔버스 자식·컨테이너에 붙은 .num·일지 세션 열기 셀렉터)을 걸러낸 뒤에야 매트릭스가 깨끗해졌다 — 경계 밖 판정은 오버레이/transform 서브트리를 통째로 제외해야 한다.
  • manifest orientation: portrait는 Android 설치형(PWA) 전부에 적용된다(폴드·태블릿 포함, 브라우저 탭·iOS Safari는 무시). 설계서 권고안 그대로 집행했고 계층 문서에 명시했다.
  • 스크린샷 픽셀 diff는 playwright-core/lib/utilsBundle의 PNG로 충분했다(pngjs 미설치).

5. 적용 결과

항목결과
iPhone 16/17 Plus·Pro Max(440) 양옆 거터5px × 2 → 0 (컬럼 430 → 440). 오너 실기기 확인(08-23)
폴드·iPad·가로의 600 컬럼 바깥 거터좌백/우청 반반 → 화면과 같은 지면 oklch(0.972 0.004 240)
호스트·컬럼 배경투명(스코프 밖 var(--bg)) → 리터럴. 계약 테스트가 var(--bg) 재유입 차단
데스크톱 932×440 창zoom 0.414 → 0.7 + 캔버스 스크롤. 1366×768·1440×900 무변화
데스크톱 1440×900·1280×720, 폰 390변경 전/후 스크린샷 픽셀 diff 0(거터 이동은 렌더 불변)
지원 화면 정의암묵(430·900) → 6계층 문서 + viewportTiers.test.mjs(문서 숫자 = 코드 상수)
감사 매트릭스9뷰포트 × 8화면 = 72칸 전 칸 통과, 수리 0건
CI 게이트full 레인 test:e2e-viewport 11/11(9 모바일 + 2 데스크톱), 스크린샷 아티팩트
Production 번들 검증app-*.css 430 상한 없음·리터럴 지면, records-*.css 100dvh, manifest portrait, desktopRoot-*.js ft=.7
마이그레이션·엣지0건. iOS 앱은 같은 번들이라 웹과 동시에 수리, Info.plist(D4)만 오너 빌드 대기

6. 이번 개선으로 향상된 것

새 폰이 나와도 할 일이 없다

phone 계층에는 상한이 없다. 450·460 폭이 나와도 코드 변경 0이고, 절차는 문서에 "조치 없음"으로 적혀 있다. 600 상한은 터치 폭 분포가 바뀔 때만 app-host.css 한 줄로 재검토한다.

지원 화면이 말로 통한다

compact·phone·wide-touch·narrow-pointer·pointer-mockup·desktop — 기획·디자인·버그 리포트가 같은 이름을 쓴다. 분기점 520/600/900은 문서·CSS·상수·테스트 네 곳에서 같은 숫자이고, 한 곳만 바꾸면 scope 레인이 빨갛다.

디자인-개발 계약에 폭 조항이 생겼다

390×844 프리뷰 프레임은 "기준이지 상한이 아님"이 계약문에 있다. 전달본이 360·440·600에서 깨지면 반입 감사에서 잡힌다.

같은 계열의 투명 버그가 재발할 수 없다

호스트 바깥의 "리터럴 + var() 폴백" 함정은 문서(계층 계약 §3)와 계약 테스트가 기억한다. 분할 거터의 소유자는 데스크톱 호스트와 마우스 목업 호스트 둘뿐이고, body는 플랫폼 중립 지면이다.

화면 회귀를 손으로 확인하지 않는다

새 기기·새 디자인 전달본·CSS 리팩터마다 full 레인이 9 뷰포트 × 5 탭에서 넘침·컬럼·배경을 단언하고 스크린샷을 남긴다. scope 레인(PR 기본) 속도에는 영향이 없다.

남은 것

  • iOS 앱 빌드 1회(오너) — D4 세로 잠금(Info.plist)이 그 빌드에 실린다(#574 대기열과 같이). 웹 수리 자체는 앱에도 이미 반영.
  • 삼성 인터넷·iOS Safari 실기기 확인(#583 Phase 4 잔여와 같은 건).
  • 데스크톱 반응형화(D3 선택지 (c), 1180~1913 유동)는 Design 로드맵 항목 — 이 트랙 밖.