-
Notifications
You must be signed in to change notification settings - Fork 0
feat(#221): 모바일 반응형 PR1 — 전역 레이어·앱 셸·위저드 (≤640px) #232
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
17 commits
Select commit
Hold shift + click to select a range
5e9b24f
docs(#221): 모바일 반응형 설계 스펙 — CSS-only·2-PR·640px 단일 브레이크포인트
pdw96 dbc5978
docs(#221): 스펙 r2 — refuter 4렌즈 사전검증 반영 (e2e 단언·safe-area·키보드·타이포)
pdw96 b7d07d6
docs(#221): 스펙 r3 — Codex 체크포인트 1R 반영 (위저드 PR1 이동·rooms 하드 계약·키보드 실측 필수)
pdw96 2804a08
docs(#221): 판사 패널 합성 구현 계획 — C(계약) 골격 + A/B 이식 + 판사 보강 3건
pdw96 5161c7f
docs(#221): 계획 r2 — Codex 계획 1R 반영 (G10b=UI경로+complete러너·블록추출 강건성·mut…
pdw96 bbaa70a
docs(#221): 계획 r3 — Codex 2R clean·비차단 구현 노트 3건 반영
pdw96 e57f786
test(#221): PR1-T1 vitest 계약 하네스 — §6 예외·셸 블록 핀(RED by design) + C2 핀…
pdw96 a8e908b
test(#221): PR1-T2 모바일 e2e 판정기 — 폰 RED 하네스+데스크톱 무회귀 가드(GREEN 기준선)
pdw96 894a3cb
feat(#221): PR1-T3 §6 열거 예외 — dvh 병기·viewport meta 확장·safe-area addit…
pdw96 d3e0356
feat(#221): PR1-T4 ≤640px 셸 블록 — 타이포 스케일·topbar 수용·터치 타깃 44·폼 16px·ro…
pdw96 4a2c2f7
feat(#221): PR1-T5 .wizard 래퍼(유일 tsx 변경)+위저드 폰 스타일 — 세션 탭 게이트 완결
pdw96 2a851a7
chore(#221): brain 재생성 — SessionsPanel .wizard 래퍼 줄수 반영
pdw96 4ffe30e
chore(#221): eslint ignores 에 deploy/workspace/** — 라이브 워크스페이스 잔재가 로컬…
pdw96 25173ba
fix(#221): PR1 자체 적대 리뷰 2렌즈 반영 — C2 타이포 누출 차단·safe-area 병기·핀/가드 강화
pdw96 e042219
fix(#221): PR1 ripple 렌즈 P3 4건 — .live 랩 정렬·위저드 라벨 44px·.field-label …
pdw96 65e77d2
test(#221): CodeRabbit 2건 반영 — env 출현별 괄호스코프 검사·규칙-정밀 핀·위저드 실경로 G5
pdw96 ef039a7
fix(#221): Codex PR 1R P2×2 — R1 footer 대기(flake)·.wizard code 긴 토큰 줄…
pdw96 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
184 changes: 184 additions & 0 deletions
184
docs/superpowers/plans/2026-07-13-issue221-mobile-responsive-plan.md
Large diffs are not rendered by default.
Oops, something went wrong.
180 changes: 180 additions & 0 deletions
180
docs/superpowers/specs/2026-07-13-issue221-mobile-responsive-design.md
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,260 @@ | ||
| import { expect, test, type Page } from '@playwright/test' | ||
| import { startFleetWebServer, type RunningWebServer } from './web-server' | ||
|
|
||
| /** | ||
| * #221 모바일 반응형 e2e 판정기(PR1: 셸+전역+위저드) — 실 chromium 이 ≤640px 미디어쿼리를 적용하므로 | ||
| * (jsdom 미레이아웃) 폰 뷰포트의 가로 오버플로·앵커·터치 타깃을 실측한다. | ||
| * | ||
| * 스크롤 아키텍처(스펙 §2): body{overflow:hidden} + .main{overflow:auto} — 패널 오버플로는 .main 이 | ||
| * 트랩하므로 documentElement.scrollWidth 단언은 topbar/footer(셸)만 커버한다. 패널 가로 스크롤 0 의 | ||
| * 권위 단언은 `.main.scrollWidth <= .main.clientWidth`. | ||
| * | ||
| * RED 정직성(계획 공통 규율): 구현 0줄에서 RED 가 실제로 발화해야 단언이 blind 가 아님이 증명된다. | ||
| * PR2 활성화 예정 케이스는 test.fail() 로 "지금은 실패가 정상"을 기계 선언(GREEN 전환 시 자동 경보). | ||
| */ | ||
|
|
||
| const TABS = ['세션', '프로젝트', '채팅'] as const | ||
|
|
||
| // 파일 수준 단일 서버 — 케이스는 전부 읽기 전용(레이아웃 측정)이라 공유해도 간섭 없음. | ||
| let server: RunningWebServer | ||
|
|
||
| test.beforeAll(async () => { | ||
| server = await startFleetWebServer() | ||
| }) | ||
| test.afterAll(async () => { | ||
| await server?.stop() | ||
| }) | ||
|
|
||
| /** 폰/데스크톱 공통 진입 — reduced-motion(애니 결정론)·뷰포트·폰트 로딩 완료 후 측정. */ | ||
| async function openApp(page: Page, url: string, width: number, height: number) { | ||
| await page.emulateMedia({ reducedMotion: 'reduce' }) | ||
| await page.setViewportSize({ width, height }) | ||
| await page.goto(url) | ||
| await page.getByText('FLEET').first().waitFor() | ||
| // 폰트 스왑(woff2·font-display:swap)으로 측정 폭이 변하는 flake 방지 — 로딩 완료 대기. | ||
| await page.evaluate(async () => { | ||
| await document.fonts.ready | ||
| }) | ||
| } | ||
|
|
||
| async function switchTab(page: Page, tab: (typeof TABS)[number]) { | ||
| await page.getByRole('button', { name: tab, exact: true }).click() | ||
| } | ||
|
|
||
| /** 가로 오버플로 측정 — doc(셸 표면)·main(패널 스크롤 컨테이너) 각각. */ | ||
| async function overflowOf(page: Page) { | ||
| return page.evaluate(() => { | ||
| const main = document.querySelector('.main') | ||
| if (!main) throw new Error('.main 부재') | ||
| return { | ||
| doc: document.documentElement.scrollWidth - window.innerWidth, | ||
| main: main.scrollWidth - main.clientWidth, | ||
| } | ||
| }) | ||
| } | ||
|
|
||
| /** boundingBox 가 뷰포트 사각형 안(C2 교훈 — y+h≥X 식 단언은 오프스크린을 통과시킨다). */ | ||
| function expectInViewport( | ||
| box: { x: number; y: number; width: number; height: number } | null, | ||
| vw: number, | ||
| vh: number, | ||
| label: string, | ||
| ) { | ||
| expect(box, `${label} boundingBox 없음`).not.toBeNull() | ||
| if (!box) return | ||
| expect(box.x, `${label} 좌측 이탈`).toBeGreaterThanOrEqual(-1) | ||
| expect(box.y, `${label} 상단 이탈`).toBeGreaterThanOrEqual(-1) | ||
| expect(box.x + box.width, `${label} 우측 이탈`).toBeLessThanOrEqual(vw + 1) | ||
| expect(box.y + box.height, `${label} 하단 이탈`).toBeLessThanOrEqual(vh + 1) | ||
| } | ||
|
|
||
| test.describe('#221 모바일 반응형(PR1 셸) — 390×844', () => { | ||
| test('G2 셸(3탭 공통): documentElement 가로 오버플로 0 + G1 세션 탭 .main 오버플로 0', async ({ | ||
| page, | ||
| }) => { | ||
| await openApp(page, server.url, 390, 844) | ||
| for (const tab of TABS) { | ||
| await switchTab(page, tab) | ||
| const o = await overflowOf(page) | ||
| expect(o.doc, `${tab} 탭 문서(셸) 가로 오버플로`).toBeLessThanOrEqual(0) | ||
| if (tab === '세션') { | ||
| expect(o.main, '세션 탭 .main 가로 오버플로').toBeLessThanOrEqual(0) | ||
| } | ||
| } | ||
| }) | ||
|
|
||
| // PR2(#221)에서 .chat/.project-layout 1열 전환 후 활성화 — 232px 사이드바 그리드가 미수정이라 | ||
| // 지금은 실패가 정상(test.fail). PR1 시점에 이 단언을 강제하면 즉시 fail, 빼면 false-green(스펙 §7). | ||
| // | ||
| // RED 정직성 메타규칙 발동 기록(PR1 T2 실측): 채팅 탭은 232px 그리드에서도 .main 가로 오버플로가 | ||
| // 발생하지 않는다(그리드가 짜부라져 조작 불능일 뿐 스크롤 없음) — 오버플로 단언 단독은 채팅 탭에 | ||
| // blind 로 판정, "1열 스택(고정 232px 트랙 부재) + 본문 폭 점유율" computed 단언으로 교체·강화. | ||
| for (const [tab, layoutSel, mainSel] of [ | ||
| ['채팅', '.chat', '.chat-main'], | ||
| ['프로젝트', '.project-layout', '.project-main'], | ||
| ] as const) { | ||
| test(`G1/G9 ${tab} 탭 폰 1열 스택 + .main 가로 오버플로 0 — PR2 활성화`, async ({ page }) => { | ||
| test.fail(true, `PR2(#221): ${layoutSel} 232px 그리드 미수정 — 1열 전환 후 활성화`) | ||
| await openApp(page, server.url, 390, 844) | ||
| await switchTab(page, tab) | ||
| const o = await overflowOf(page) | ||
| expect(o.main, `${tab} 탭 .main 가로 오버플로`).toBeLessThanOrEqual(0) | ||
| const layout = await page.evaluate( | ||
| ([lSel, mSel]) => { | ||
| const layout = document.querySelector(lSel) | ||
| const main = document.querySelector('.main') | ||
| const body = document.querySelector(mSel) | ||
| if (!layout || !main || !body) throw new Error(`${lSel}/${mSel} 부재`) | ||
| return { | ||
| columns: getComputedStyle(layout).gridTemplateColumns, | ||
| bodyWidth: body.getBoundingClientRect().width, | ||
| mainWidth: main.clientWidth, | ||
| } | ||
| }, | ||
| [layoutSel, mainSel] as const, | ||
| ) | ||
| // 1열 스택 = 고정 232px 사이드바 트랙 부재(computed 는 px 목록으로 나온다). | ||
| expect(layout.columns, `${tab} 탭 grid-template-columns`).not.toContain('232px') | ||
| // 본문이 가용 폭을 실질 점유(짜부라진 2열 그리드의 blind 통과 방지 — 폰 조작성의 대리 지표). | ||
| expect(layout.bodyWidth, `${tab} 탭 본문 폭 점유`).toBeGreaterThanOrEqual( | ||
| layout.mainWidth * 0.8, | ||
| ) | ||
| }) | ||
| } | ||
|
|
||
| // PR2 활성화 케이스(test.fail)가 셀렉터 rename 시 "부재 throw = 계속 expected-fail" 로 경보가 | ||
| // 영구 침묵하는 경로 차단(자체 적대 리뷰 P3-1) — 셀렉터 존재 자체는 항상-GREEN 계약으로 분리. | ||
| test('PR2 셀렉터 계약 — .chat/.chat-main·.project-layout/.project-main 존재', async ({ | ||
| page, | ||
| }) => { | ||
| await openApp(page, server.url, 390, 844) | ||
| await switchTab(page, '채팅') | ||
| await expect(page.locator('.chat')).toHaveCount(1) | ||
| await expect(page.locator('.chat-main')).toHaveCount(1) | ||
| await switchTab(page, '프로젝트') | ||
| await expect(page.locator('.project-layout')).toHaveCount(1) | ||
| await expect(page.locator('.project-main')).toHaveCount(1) | ||
| }) | ||
|
|
||
| test('G3 내비 3탭 뷰포트내 앵커 + G4 터치 타깃 ≥44px(내비·위저드 bare 버튼·.btn 표본)', async ({ | ||
| page, | ||
| }) => { | ||
| await openApp(page, server.url, 390, 844) | ||
| // G3: 세그먼트 내비 3버튼 전부 뷰포트 사각형 안. | ||
| for (const tab of TABS) { | ||
| const box = await page.getByRole('button', { name: tab, exact: true }).boundingBox() | ||
| expectInViewport(box, 390, 844, `내비 [${tab}]`) | ||
| } | ||
| // G4: 내비 터치 타깃. | ||
| for (const tab of TABS) { | ||
| const box = await page.getByRole('button', { name: tab, exact: true }).boundingBox() | ||
| expect(box, `내비 [${tab}]`).not.toBeNull() | ||
| if (box) expect(box.height, `내비 [${tab}] 높이`).toBeGreaterThanOrEqual(44) | ||
| } | ||
| // G4: 위저드 bare 버튼(.wizard 래퍼 스코프 — 다른 표면의 무클래스 버튼 혼입 차단·CodeRabbit). | ||
| const bareButtons = page.locator('.wizard button:not([class])') | ||
| const bareCount = await bareButtons.count() | ||
| expect(bareCount, '위저드 bare 버튼 표본 존재').toBeGreaterThan(0) | ||
| // 실측 실행 횟수 단언 — invisible/box 부재 무음 skip 으로 루프가 공허해지는 미래 변경을 적발 | ||
| // (자체 적대 리뷰 P2-3: 위저드 접힘 기본값 전환 등). | ||
| let measured = 0 | ||
| for (let i = 0; i < bareCount; i++) { | ||
| const b = bareButtons.nth(i) | ||
| if (!(await b.isVisible())) continue | ||
| const box = await b.boundingBox() | ||
| if (!box) continue | ||
| expect(box.height, `위저드 bare 버튼 #${i} 높이`).toBeGreaterThanOrEqual(44) | ||
| measured++ | ||
| } | ||
| expect(measured, '위저드 bare 버튼 실측 수(공허 루프 방지)').toBeGreaterThan(0) | ||
| // G4: .btn 표본(세션 카드 삭제 버튼 — FLEET_E2E 시드 세션 2개로 존재 보장). | ||
| const btn = page.locator('.main .btn').first() | ||
| const btnBox = await btn.boundingBox() | ||
| expect(btnBox, '.btn 표본').not.toBeNull() | ||
| if (btnBox) expect(btnBox.height, '.btn 높이').toBeGreaterThanOrEqual(44) | ||
| }) | ||
|
|
||
| test('G5 폼 필드 computed 16px(iOS 자동 줌 방지) — .field + 위저드 bare input 실경로', async ({ | ||
| page, | ||
| }) => { | ||
| await openApp(page, server.url, 390, 844) | ||
| // 표본 1: .field(#mcp-servers — 세션 탭 유일 .field 라 두 셀렉터는 같은 노드였다: 자체 적대 리뷰 P2-1). | ||
| const fieldSize = await page.evaluate(() => { | ||
| const t = document.querySelector('#mcp-servers') | ||
| return t ? getComputedStyle(t).fontSize : 'missing' | ||
| }) | ||
| expect(fieldSize, '.field(#mcp-servers) computed font-size').toBe('16px') | ||
| // 표본 2: 위저드 bare input — 요소 셀렉터(input) 규칙의 존재 이유. 초기 스텝엔 input 이 없어 | ||
| // 실경로(프로바이더→API 키)로 진입해 측정한다(요소 규칙 축소 회귀를 e2e 층에서도 적발). | ||
| await page.locator('.wizard button:not([class])').first().click() // 프로바이더 선택 | ||
| await page.getByRole('button', { name: 'API 키', exact: true }).click() | ||
| const bareInput = page.locator('.wizard input').first() | ||
| await bareInput.waitFor() | ||
| const bareSize = await bareInput.evaluate((el) => getComputedStyle(el).fontSize) | ||
| expect(bareSize, '위저드 bare input computed font-size').toBe('16px') | ||
| }) | ||
| }) | ||
|
|
||
| test.describe('#221 모바일 반응형(PR1 셸) — 360×740 최소 폭 계약', () => { | ||
| test('G3 재단언: 360px 에서도 내비 3탭 뷰포트내(스펙 §7 "360px 폭 수용" 문면)', async ({ | ||
| page, | ||
| }) => { | ||
| await openApp(page, server.url, 360, 740) | ||
| for (const tab of TABS) { | ||
| const box = await page.getByRole('button', { name: tab, exact: true }).boundingBox() | ||
| expectInViewport(box, 360, 740, `내비 [${tab}]`) | ||
| } | ||
| const o = await overflowOf(page) | ||
| expect(o.doc, '360px 문서(셸) 가로 오버플로').toBeLessThanOrEqual(0) | ||
| }) | ||
| }) | ||
|
|
||
| // R1 데스크톱 무회귀 가드 — §6 "640px 블록은 >640px 완전 비활성"의 기계 판별. 초안 B의 "기존 web e2e | ||
| // 가 데스크톱 겸임" 가정은 반증됨(approval-hold/handoff 가 390×844) — 명시 가드가 유일한 기계 게이트. | ||
| // 이 케이스들은 구현 전 GREEN(현행 값 핀)이어야 하고, 640px 블록이 새면 즉시 적발한다. | ||
| test.describe('#221 데스크톱 무회귀 가드 — 641×900(경계 최소 초과)·1280×800(대표)', () => { | ||
| for (const [w, h] of [ | ||
| [641, 900], | ||
| [1280, 800], | ||
| ] as const) { | ||
| test(`R1 ${w}×${h}: topbar/main/footer·타이포 유지(폰 분기 미적용)`, async ({ page }) => { | ||
| await openApp(page, server.url, w, h) | ||
| // footer 는 getAppInfo() 비동기 완료 후 조건부 렌더(App.tsx) — 대기 없이 computed 를 읽으면 | ||
| // 느린 기동에서 '.footer 부재' throw 로 flaky(Codex PR 1R P2). | ||
| await page.locator('.footer').waitFor() | ||
| const computed = await page.evaluate(() => { | ||
| const q = (sel: string) => { | ||
| const el = document.querySelector(sel) | ||
| if (!el) throw new Error(`${sel} 부재`) | ||
| return getComputedStyle(el) | ||
| } | ||
| const topbar = q('.topbar') | ||
| const footer = q('.footer') | ||
| return { | ||
| gap: topbar.gap, | ||
| // 4변 padding — shorthand→additive 재작성의 산술 실수를 기계 핀(자체 적대 리뷰 P3-3) | ||
| topbarPad: [ | ||
| topbar.paddingTop, | ||
| topbar.paddingRight, | ||
| topbar.paddingBottom, | ||
| topbar.paddingLeft, | ||
| ], | ||
| footerPad: [footer.paddingTop, footer.paddingBottom, footer.paddingLeft], | ||
| mainPad: q('.main').paddingLeft, | ||
| brandFont: q('.brand h1').fontSize, | ||
| fieldFont: q('#mcp-servers').fontSize, | ||
| bodyFont: getComputedStyle(document.body).fontSize, | ||
| tagVisible: q('.brand .tag').display !== 'none', | ||
| } | ||
| }) | ||
| expect(computed.gap, 'topbar gap').toBe('28px') | ||
| expect(computed.topbarPad, 'topbar padding 4변').toEqual(['16px', '26px', '16px', '26px']) | ||
| expect(computed.footerPad, 'footer padding').toEqual(['9px', '9px', '26px']) | ||
| expect(computed.mainPad, '.main padding').toBe('26px') | ||
| expect(computed.brandFont, 'brand h1').toBe('27px') | ||
| expect(computed.fieldFont, '.field(#mcp-servers) font-size').toBe('13px') | ||
| expect(computed.bodyFont, 'body font-size').toBe('13px') | ||
| expect(computed.tagVisible, '.brand .tag 가시(데스크톱)').toBe(true) | ||
| }) | ||
| } | ||
| }) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.