화면(뷰포트) 대응 — 아이폰 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.tsxDESKTOP_VIEWPORT_QUERY, 데스크톱 하한src/react/vite/desktopRoot.tsxDESKTOP_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.css의 100vh는 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, 로그인 상태 홈, 변경 전/후 같은 스크립트):
| # | 뷰포트 | 입력 | 컬럼 전 → 후 | 거터 좌/우 전 → 후 | 호스트 배경 전 → 후 |
|---|---|---|---|---|---|
| V1 | 360×780 갤럭시 S24 | 터치 | 360 → 360 | 0/0 | 투명 → 지면 |
| V2 | 375×667 iPhone SE | 터치 | 375 → 375 | 0/0 | 투명 → 지면 |
| V3 | 390×844 iPhone 14 | 터치 | 390 → 390 | 0/0 | 픽셀 diff 0 |
| V4 | 402×874 iPhone 16 | 터치 | 402 → 402 | 0/0 | 투명 → 지면 |
| V5 | 440×956 iPhone 16/17 Pro Max | 터치 | 430 → 440 | 5/5 → 0/0 | 투명 → 지면 |
| V6 | 820×1180 iPad 10 세로 | 터치 | 600 → 600 | 110/110 | 좌백/우청 → 지면 |
| V7 | 984×1092 갤럭시 폴드 내부 | 터치 | 600 → 600 | 192/192 | 좌백/우청 → 지면 |
| V8 | 932×440 Pro Max 가로 | 터치 | 600 → 600 | 166/166 | 좌백/우청 → 지면 |
| V9 | 1440×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-dempty100vh → 100dvh.- D4:
ios/App/App/Info.plistiPhone Portrait만(~ipad전 방향 유지),public/manifest.webmanifest"orientation": "portrait". viewportTiers.test.mjs: 문서의 분기점·컬럼·캔버스 숫자(520/600/900·390×844·1913×1063·1180·320) = 코드 상수, 호스트 폭 미디어쿼리는 520 하나뿐, 안전영역 4변, 모바일 화면 CSS100vh부재, Info.plist/manifest 방향, 계약문 조항.
Phase 3 — 화면 감사 매트릭스 (#603, 수리 0건)
로그인 상태(일회용 유저 + 완료 세션 6건)로 홈·일지·하루 시트·세션 상세·리포트·피드·내 정보·운동 시작 8화면을 뷰포트 9종에서 순회하며 JS로 판정했다 — documentElement·.app .screen 가로 넘침, 컬럼 경계 밖 요소(오버레이·오프캔버스· 가로 스크롤 컨테이너 제외), 탭바 라벨 줄바꿈, 리프 .num 넘침, 호스트 배경.
| 뷰포트 | 계층 | 컬럼 | 결과(8화면) |
|---|---|---|---|
| 320×568 | compact | 320 | 넘침 0 · 이탈 0 · 탭바 58px×5 |
| 360 · 375 · 390 · 440 | phone | 전폭 | 넘침 0 · 이탈 0 · 탭바 66/69/72/82px×5 |
| 820×1180 · 984×1092 · 932×440 | wide-touch | 600 | 넘침 0 · 이탈 0 · 탭바 114px×5, 가로 440 높이에서 시트 정상 |
| 800×900 마우스 | pointer-mockup | 390 | 넘침 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.desktop이overflow·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.ymlmigration-smoke(= full-ci) 레인 마지막 스텝. scope 정책이e2e/·playwright.*.config·워크플로 변경을 자동 full로 올리므로 PR 자체가 첫 실행이었다 — migration-smoke 7m44s 초록, 아티팩트 5.7MB.- 게이트 증명:
.gym-app-device에max-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 로드맵 항목 — 이 트랙 밖.